Skip to Content
TechnicalPackagesServices Package

Services Package

@sustentus/services holds the platform’s server-side business logic — database access, AI, email, notifications, and shared utilities. It is consumed by apps/web (and other apps) through scoped entrypoints, never a single root import.

Overview

Package@sustentus/services
Pathpackages/services/
BuildTSUP (ESM + type declarations)
DatabaseMongoDB + Mongoose
AIVercel AI SDK v6 (Anthropic via the AI Gateway)

Entrypoints (import the right one)

import { … } from "@sustentus/services/server"; // DB connections, server-only logic import { … } from "@sustentus/services/shared"; // isomorphic utilities + types import { … } from "@sustentus/services/db"; // models + db services import { … } from "@sustentus/services/ai"; // the AI connector: defineAgent, model constants, tenant tools, prompt workspace import { … } from "@sustentus/services/sanitize"; // sanitizeAgentHtml / sanitizeRichTextHtml — HTML → the DOM, its own entry import { … } from "@sustentus/services/email"; // Resend + React Email sending import { … } from "@sustentus/services/blob"; // Vercel Blob upload/download

In apps/web and apps/agent, reach for /server and /shared first (ESLint bans the root @sustentus/services and /db outright); /ai, /sanitize, /email and /blob are the narrower entries for the jobs they name.

/sanitize is deliberately not folded into /shared. It pulls isomorphic-dompurify, which brings jsdom on the server, and /shared is imported by every app’s edge middleware (proxy.ts) — tsup bundles each entry as one file, so a jsdom import in /shared would land in the edge runtime.

The entry exports two sanitizers, and the split is by audience rather than by caller convenience. sanitizeAgentHtml allows the TipTap-safe set agent prompts are constrained to (p strong em ul ol li h2 h3 a br) and is for surfaces that render a model response unedited. sanitizeRichTextHtml allows what MinimalTiptapEditor can emit — every heading level, <u>, <code>, blockquote, code block, horizontal rule, the editor’s own class names, and inline data:image/* images — and is for the read view of a field a person authors in RichTextEditor. Remote image sources are dropped there rather than allowed: on a field an agent also writes, a <img src="https://attacker/…"> is an exfiltration beacon that fires on render. One of the two is the only sanctioned way HTML from an AI agent or the rich-text editor reaches dangerouslySetInnerHTML; widening the agent allowlist to suit an editor field widens every agent surface with it, which is the trade the split exists to avoid.

Source structure

packages/services/src/ ├── db/ # Mongoose models, db services, plugins, connection cache ├── ai/ # the connector: core/ (defineAgent, model constants, tenant tools), workspace/ │ # (runtime prompts), registry/ (persona registers + tool sets), canned/, │ # tools/, config — plus apps/web's legacy brd/ and onboarding/ agents ├── sanitize/ # sanitizeAgentHtml + sanitizeRichTextHtml — own entry so jsdom stays out of /shared ├── email/ # Resend client + React Email templates ├── notifications/ # Per-resource notifyX (Mongo + Ably) · console/ tenant-event email ├── permissions/ # Feature registry, role templates + overrides, effective-permissions resolver ├── auth/ # Request identity — who is calling and which tenant they may read ├── blob/ # Vercel Blob helpers ├── server/ · shared/ · types/ · utils/ # entrypoint barrels └── index.ts

Request identity

Two apps have to answer the same three questions on every request — who is calling, which tenant they are working in, and whether they may read it — so the answer lives here once rather than twice. apps/web and apps/agent both resolve it from this package; neither owns a second copy.

  • resolvePlatformContext(selectedTenantId?) from /server returns a discriminated union over the platform’s two populations, or null for anyone else. A kind: "tenant" context is a user with a Clerk organisation and a role in the platform’s enum. A kind: "console" context is an org-less Sustentus staff member or invited partner — recognised by the same publicMetadata marker the tenant-management console authorises on — and carries the ConsoleTenantScope that bounds them plus the tenant they have currently selected, which may be none. It never throws at the caller: no session, an unrecognised role, an unreachable database and a tenant that will not load all resolve to null or to “no selection”, so a surface renders an honest empty state rather than a stack trace.
  • A console caller’s tenant is a choice, not a claim. The id arrives in an httpOnly cookie the app never trusts on its own: it is re-authorised against the caller’s scope on every request through tenantService.findForConsole, so a forged id, a revoked partner assignment and a deleted tenant are indistinguishable — each is simply no selection. That single authorisation home is what keeps the cookie from becoming a second, weaker gate.
  • Reach, not power. Staff and partners get more tenants to work in, never different rules inside one: both context kinds bind to exactly one tenant through the unchanged createTenantContext, and both get the identical toolset. isTenantReachable gates both branches, so a suspended workspace is stood down in the agent as it is in the platform.
  • getTenant() / getTenantOrNull() from /server are the tenant read behind the org branch, including the upsert-from-Clerk path and the sanitised failure logging. apps/web/lib/tenant.ts re-exports them and defines neither.
  • platform-auth.ts in /shared holds the claim-reading half of the role model — mapClerkRoleToSystemRole, deriveGrantedRoles, resolveEffectiveRole and the claim/cookie names. It is pure and isomorphic (no Clerk SDK, no next/headers, no database), which is what lets either app’s edge middleware call it. The cookie-reading layer stays in the app that owns it.

List-surface URL contract

Every paged or searchable list in the platform reads the same three URL params — page (1-based), limit (whitelisted to 10/20/25/50/100, default 25) and search — and shared/list-query.ts is the one parser for them. parseListParams is what stops a non-numeric ?page=abc flowing a NaN into a skip() and an arbitrary ?limit=1000000 reaching a query; buildListQuery spells the links back out, omitting defaults so a canonical route stays clean. Entity helpers in apps/web/lib/<entity>-list-query.ts delegate their common core to it and add only their own filters.

A surface may take the contract’s filters without its pager. The notifications inbox is the one that does: apps/web/lib/notifications-list-query.ts parses its read-state, kind and date-range params and spells them back out through buildListQuery, but carries no page. The inbox accumulates pages client-side behind a “Load more”, so a ?page=3 that rendered page 3 alone would be a different surface from the one the reader was looking at. CONVENTIONS.md → List surfaces names it a sanctioned exception to the pager, not to the URL contract.

It lives in /shared because it is pure string and number work with no database import, and because more than one app pages a list: apps/web and apps/console both parse through it rather than growing a copy each. The matching footer component, ListPager, lives in @sustentus/ui/server for the same reason. The full rule, including what a custom table must render, is CONVENTIONS.md → List surfaces.

A search’s $or sits safely beside soft delete. softDeletePlugin adds its live clause to every find* and countDocuments as an $and entry, so a list search can put its own $or at the top level of the filter and it stays conjunctive with liveness. (Mongoose concatenates two top-level $or arrays rather than AND-ing them — which is why the plugin must never add its clause as one: the search term would then constrain nothing.) Two $ors of the caller’s own still go under $and, since sibling keys in one object overwrite each other.

A list’s optional scoping ids refuse, they never widen. A list read that takes an id to narrow by — findAll(tenantId, { customerId }), a kanban column’s expertUserId, a manager’s resolved teamIds — must not answer an unparseable id by dropping the clause: that turns “this customer’s requests” into every row in the tenant, which is a scoping filter failing in the one direction it must not. The rule has one home, db/services/shared/scope-id.ts (requireScopeId / requireScopeIds / applyScopeId): absent means “not supplied” and applies no filter, anything else that does not parse throws, and the error names the option rather than echoing the value. A resolved list such as teamIds has no “not supplied” entry, so a blank one throws too. Every caller passes a database _id, so an id that cannot be parsed is a programming error, and refusing surfaces it where an empty page would hide it. It is the same instinct as vendorClaimFilter under Metric dictionary → Tenant scoping, which throws rather than returning an unfiltered query. The leads service, the skill list’s serviceId, the CSAT list’s customerId / vendorId / leadId and the users directory’s teamIds all go through these helpers; the leads service’s own header records which of its ids were already failing closed and were left alone. A new optional scoping id imports from the shared module rather than growing a copy.

Which own-scoped roles have rows on a list has one home too, and it is isomorphic so a page’s subtitle can read it: shared/own-scope.ts (from @sustentus/services/shared). CSAT_OWN_ROLES (vendor, customer — the two parties a CSAT entry records) and PROPOSAL_OWN_ROLES (expert, customer) name the own-sets; csatOwnRole / proposalOwnRole return the viewer’s own-set role, or null when the role is outside the tuple or the id fails isObjectIdHex (24 lowercase hex, the form ObjectId#toString() produces — stricter than Types.ObjectId.isValid). isObjectIdHex is the one id rule every own-scope read decides by — the leads reads (scopedLeadFilter, isLeadViewerParty), the expert and vendor blocker inbox, CSAT and proposals — and apps/web’s parseListId reads it too, after lowercasing a URL-borne id. The viewer those reads take is one type, OwnScopeViewer. csatViewerFilter, isCsatVisibleToViewer and ProposalService.findAll’s viewer branch (which queries with a Types.ObjectId built from the viewer id) narrow by it, and apps/web/lib/page-subtitles.ts keys the /csat and /proposals sentences by its role types, so a role given rows fails typecheck until it has a sentence. null fails closed on both sides: the read matches nothing and the page renders no subtitle.

Taxonomy write contract

The four thin, tenant-scoped reference taxonomies — service, product, industry and skill — are written from two places now: the tenant’s own settings forms in apps/web, and staff editing a tenant’s catalogue from the console. So what one may be called is stated once, in shared/catalogue.ts, and both callers parse through it: taxonomyWriteSchema (name 2–50 after trimming, description ≤ 500, a blank box normalised away), taxonomyWriteSchemas(label) for the create/update pair with the entity named in the message, and taxonomyWriteFormSchema for the FormData variant a server action posts through. It is isomorphic — zod only — because the same bounds have to hold on a client-rendered form and in the action behind it. Until this existed the bounds lived only in the Mongoose schemas, so the two apps could disagree and only Mongo would notice.

The write refusals a caller answers differently are tagged rather than described: DuplicateTaxonomyNameError, TaxonomyReferencedError (the row something else still points at) and TaxonomyUnknownLinkError (a skill saved with no service, or a service the tenant does not have) carry a taxonomyErrorCode, read with the taxonomyErrorCode(error) predicate. The service, product, industry and skill services throw the ones that apply, with the messages they always had. It is a property check, not instanceof — the package ships both ESM and CJS, so a caller can hold a different class object for the same error.

Code → label contract

The database keeps keys lowercase — role, action-type, permission, language and settings-category codes are matched as keys and never re-cased. What a screen shows for one is a label, and every label comes from one isomorphic home in /shared, so two surfaces never word the same code differently:

  • Roles — ROLE_LABELS and roleLabel(role) in shared/roles.ts (csm → “CSM”). roleLabel takes any string and reads own keys only, so an untyped role (a Clerk organisation role) comes back as itself, never as an inherited Object.prototype member.
  • Everything else — shared/labels.ts, all sentence case with the acronyms in LABEL_ACRONYMS (BRD, CSAT, CSM, SDM, SLA) uppercase wherever they fall: humanizeActionName (brd_approved → “BRD approved”), humanisePermissionPart (assignCsm → “Assign CSM”), permissionLabel (lead.qualifyOut → “Lead: Qualify out”), notificationTypeLabel (service-lead.concern-raised → “Service lead concern raised”, derived so a new type needs no entry), languageName / isLanguageCode (en → “English”, via a lazily built Intl.DisplayNames), and tenantSettingCategoryLabel (goneCold → “Gone cold”; a unit test holds it to every category in SETTINGS_REGISTRY).
  • Console audiences — CONSOLE_AUDIENCE_LABELS in shared/console-access.ts, beside CONSOLE_AUDIENCES (sustentus → “Sustentus staff”).
  • Setting keys — the one label outside /shared: each SETTINGS_REGISTRY entry in db/services/tenant-setting/registry.ts carries a required label (goneCold.nudgeBusinessDays → “Days before the customer is sent a follow-up”; a unit test holds every entry to one). The admin settings tables receive it as a row field and name the row by it — text, aria labels, confirmations — never by its key; an unregistered key has no label and shows as itself.

A new code that needs a label gets its entry here, not a local map or capitaliser in an app. Stored names people typed (services, skills, companies) are not codes: they render exactly as stored, with no display-time casing.

Go-live readiness

The six checks that answer “is this tenant ready to launch?” — minimum catalogue, SLA, service model, integrations, roles, services priced — are derived, never stored. They began in the admin dashboard and now have two readers: the tenant’s own admin in apps/web, and Sustentus staff looking across the fleet from the console. So the scoring is stated once, in shared/tenant-readiness.ts.

The catalogue check comes first, because it is the one the others assume: below one product, two services and two skills (CATALOGUE_MINIMUM in shared/catalogue.ts) there is nothing a lead can be raised against, and nothing for the remaining five to be configured for. Its gap names both halves — what the tenant has and what it is short — so the fix is legible without opening a page.

computeTenantReadiness(facts) is pure and isomorphic. It takes plain values rather than the service row types — covered SLA stages, a live-integration boolean, the Price Book coverage (active services, and how many carry a list price), role coverage, and the catalogue’s three counts — for two reasons: it has to stay importable from a client bundle, and reducing each source to its one fact is what lets a fleet-wide read answer in the database. It returns the six verdicts, the per-stage service model, the commercial status, and one gap per unmet check in tile order. The gap carries the issue and the impact — and the catalogue gap alone also carries shortfalls, each entity below its minimum in entity order (products → services → skills), the order the work has to happen in. Where you go to fix it is a fact about the surface, so each app maps the gap’s check — and, for the catalogue, its first shortfall — to its own link, without re-scoring the counts. The required-role rule (REQUIRED_TENANT_ROLES, isDirectoryUserActive, deriveRolesReadiness) lives here too, because the rolesAssigned check reads it.

ACTIVE_DIRECTORY_USER_FILTER sits beside isDirectoryUserActive — the same “is this user active” rule as a MongoDB filter. The rule is expressed twice because the console scores a page of tenants at a time and cannot pull every tenant’s whole user directory into memory to run the predicate; the two readings are adjacent so a change to one is a visibly incomplete change without the other.

tenantReadinessService.getForTenants(ids) (from /server) is the fleet read: seven queries for any number of tenants — SLA definitions, a grouped existence check over integrations, a grouped distinct-roles aggregate over users, the catalogue’s three grouped reads over products, services and skills, and one read of every tenant’s default_currency (tenantSettingService.getDefaultCurrencies) — then the shared computation per tenant. The services read is one aggregate that yields both the catalogue’s service count (inactive services included) and the Price Book facts (active services, and how many of them carry a list price in each currency); scorePriceBookCoverage then scores them against the tenant’s own currency. The commercial check, servicesPriced, passes only when the tenant has at least one active (not archived, not switched off) service and every one of them has a list price in the tenant’s currency — default_currency, EUR when unset. A price in another currency counts as unpriced, and the gap names those services apart from the ones with no price (“3 of 12 active services unpriced: 1 has no price, 2 are priced in USD, not EUR”). getCatalogueFacts(tenantId) is the catalogue counts and the Price Book coverage for one tenant, which the admin dashboard calls; getPriceBookCoverage(tenantId) is the services read alone, which the Commercial settings tab calls. Five checks per tenant would have been 125 queries for one page of the console’s directory. It is deliberately scope-blind: it takes ids and asks no questions about audience, so every caller must feed it ids from an already-scoped read. Both of today’s do.

Two things it does that a reader should not have to rediscover: the aggregations state isDeleted: { $ne: true } themselves, because softDeletePlugin hooks pre(/^find/) and an aggregation pipeline does not match it; and ids are cast to ObjectId before entering $in, because a pipeline is not cast against the schema the way a find filter is.

AI

./ai contains the runtime AI built on the Vercel AI SDK:

  • Agent core (ai/core/) — the reusable foundation any AI feature builds on: defineAgent to declare a tool-loop agent (every one ends its turn on the step that produced an offer_options result, so a model never answers the question it just asked, within a 20-step cap), generateStructured for typed (Zod schema in, validated object out) generation, and a tenant-scoped tool registry (createTenantContext, defineTenantTool, buildTenantTools) so a tool wraps a domain service bound to one tenant and cannot reach across tenants. Exported from /ai (no database access).

  • Prompt workspace (ai/workspace/ + db/services/prompt-workspace/) — versioned system prompts assembled at runtime, structured as ICM layers, so prompt text is markdown someone edits rather than a constant a deploy ships. The pure half is exported from /ai: the code-owned rails preamble (tenant isolation, injection defences, tool-use law) composed into every assembled prompt and editable by nobody, the register catalogue deciding which files a register reads, the repo-shipped seed bodies, composition, and deterministic publish validation (required sections, rails overrides, the ICM token band). Its store is two platform-wide collections — promptfiles, one row per file holding the single mutable pointer at its live version, and promptversions, write-once bodies so history is never rewritten. Neither carries a tenantId: one workspace serves every tenant. promptWorkspaceService (from /server) resolves the published body per file and returns the exact version set behind the prompt, but the read and write paths answer an unreadable store differently. Assembly for a live conversation falls back to the seed where a file has never been published or the store cannot be read — the seeds are a complete prompt set, so the assistant keeps answering and the degradation goes to the logs. Publishing and the console’s editing reads refuse instead, raising PromptWorkspaceUnavailable with a message written for the staff member: validating a publish against seeds the store never confirmed can accept a prompt that breaches the token band, and filling an editor from them invites someone to supersede a live version they never saw. The error carries a reason — unreachable is waited out and retried, dangling (a file pointing at a version no longer in the store) is fixed by rolling that file back — because the two give the operator opposite next moves. A dangling file still opens for editing, flagged, so the rollback the message asks for is on the screen. Publishing writes a version (with the note its publisher gave it) and moves the pointer; rolling back moves the pointer and writes nothing. Assembly runs per request, so a publish takes effect on the next conversation turn with no deploy. A prompt edit can change what an agent says and never what it may reach — tools stay code-owned and tenant-bound. A file also holds one optional draft: the unpublished working copy, overwritten by the next save and cleared by a publish. No request path reads it, so a draft cannot reach a live agent: assembleForRegister, the call every live turn makes, keeps its signature and resolves no draft, and assembleForSource(registerKey, source) is the one method that reads a draft into an assembly — published is assembleForRegister itself; draft overlays every saved draft on a strict read of what is live, refusing with PromptWorkspaceUnavailable rather than composing seeds under the drafts, and refusing a dangling file unless its own draft stands in for the missing body. It has two callers, both staff-only and neither on a tenant’s request path: the eval suite and the console’s test conversation. reviewDraft is the single home for “would this publish be accepted, and what would it compose” — publish refuses on it, so a preview and the publish it precedes cannot drift apart. The agentic assistant is the first consumer; the tenant management console is the edit surface.

  • Agent registry (ai/registry/ + db/services/agent-register/) — which register answers which persona. A register is the unit of “the surface behaves differently per persona”: its own prompt-workspace contract, its own tool sets, resolved from the session’s effective platform role. Like the permission registry it is code, not data — publishing markdown changes what a register says, never which tools it holds. The pure half is exported from /ai: the register catalogue (roles, prompt contract, tool-set keys) and the context a tool set is built from (the bound tenant, the platform user, the caller’s effective permissions). The builder map is /server-only, typed Record<AgentToolsetKey, …>, so a register naming a tool set with no builder fails typecheck rather than answering with fewer tools; adding tools to a register means adding a key and a builder, and touching no request path. agentRegisterService (from /server) resolves the register and never throws — a role with no register, an org-less console caller, and an unreachable database all fall back to the generic assistant, which is a complete working surface. Today: the generic assistant register plus five persona registers — CSM (portfolio and blockers), vendor (funnel decomposition and the lead book), expert (bid-pool triage), SDM (demand against expert supply) and tenant admin (what the assistant is costing this workspace this month, and what is driving it). A tool declares the permission set it needs and mounts only for a caller holding every one of them, so a tool spanning two record families — a vendor figure over both leads and invoices, an expert read over both the bid pool and its leads — cannot reach a caller entitled to only half of it. The tenant admin’s set is the one gated on the platform role instead: its subject is the workspace’s own spend, which follows the role the spend page follows (holdsSpendEnvelope) rather than a permission two other personas’ templates also carry. Every agent run records the register that served it, and the prompt version set that composed its system prompt — one entry per prompt file naming the version live at the time (or the repo-shipped seed where nothing was published) plus one for the set as a whole, so “which prompt produced this answer” is a query rather than archaeology. An empty stamp means the record predates the feature, never that the turn ran on nothing.

  • Answer parts (shared/agent-answer.ts + ai/answers/) — what a register tool hands back when the answer is a figure, a decomposition or a position, and what the conversation renders as a component. A closed vocabulary of typed shapes — a claim, a generated table, a generated funnel, a share split, a meter, a journey, a levers rail, and a question with clickable options (options — its answer message is written and read back by formatOptionsAnswer / readOptionsAnswer) — so the renderer keys on the shape rather than on which tool produced it, and a later register ships claims without touching a request path. The shapes and their render-boundary guard live in /shared (no zod: that entry is reachable from every app’s edge middleware), and the guard is what a surface checks before rendering: a part whose records or reconciliation are missing is not renderable, so a figure nothing can reproduce cannot reach a screen. A claim’s definition grid carries its provenance structurally — the metric dictionary’s own words (claimDefinitionFromMetric) or the answer’s own (answerClaimDefinition, which pins the metric fields to null) — so an answer-defined figure cannot wear a dictionary metric’s definition. A converted money figure is unrepresentable without its rate and rate date, which is what keeps BR-12 structural rather than a matter of discipline. ai/answers/ holds the pure builders that turn tenant rows into those shapes and prove the arithmetic reconciles.

  • Journey spine (db/workflows/journey.ts) — deriveJourneySpine and resolveJourneyPosition: the chain of positive transitions out of a workflow’s first status, with everything off that chain as a drop-off, and a subject’s position resolved onto it through the persona’s own status labels. One home, because two surfaces render it — apps/web’s workspace strip and the CSM register’s journey answer — and a second copy would be a status list living outside workflows.json.

  • Lead levers (shared/lead-levers.ts + ai/answers/levers.ts) — the catalogue of what a persona can pull on a lead: each lever’s label, hint, intent, group, the capability it needs, the stages it belongs to and the workspace section that owns it. One home for the same reason the journey spine has one: apps/web’s workspace rail and the CSM register’s levers answer both read it, and a second copy would offer a move in words the platform has since changed, under a capability it no longer needs. apps/web joins the declarations to its own data preconditions and dispatch wiring, both of which need the workspace aggregate; the register derives its rail from the declarations alone — the stage the lead is at, intersected with the caller’s effective permission set — and links out rather than dispatching. A lever the caller lacks the capability for is absent, never disabled. The stage names are the workflow engine’s own and a test fails if one is not a status workflows.json declares, so a rename there cannot quietly empty a stage’s rail.

  • The platform → agent-app hand-off (shared/agent-handoff.ts) — the one definition both apps read: the flow catalogue (brd), the thread key <flow>:<requestId>, the same-origin check for a link-supplied path (resolveSameOriginPath, also the platform’s redirect_url rule) and the /start URL builder. The customer’s requirements conversation is the agent app’s customer register; the apps/web BRD agent that used to live at ai/brd/ was deleted with the platform’s BRD chat (agent-conversation-handoff).

  • Tenant tools (ai/tools/) — domain services exposed as tenant-scoped agent tools: the read-only catalogue tool over products and services, and the CSM reads over the portfolio of active-project accounts and the open go-live blockers across it — plus the CSM answer tools that return typed answer parts rather than data to narrate: the portfolio size and the revenue at risk as claims, a blocker breakdown as a generated table or funnel, one account’s position on the lead journey, the levers open on that account, and the SDM’s bid-price split as share bars. An answer part may also carry the register’s reading of the figure and a row of follow-up prompts — both optional, both validated at the render boundary, and the follow-up’s label is the message it sends. Each reports the scope it actually ran at, so a read a permission narrowed says so rather than looking like the wider one. Because they touch the database, they are exported from /server, not /ai. A tool’s input schema never carries a tenantId — execute closes over the bound tenant context — and a tool may additionally declare the permission its caller must hold, in which case a caller without it is never handed the tool at all. Where a permission is held at own scope, the read is narrowed to the caller’s own records whatever scope the model asks for.

  • Onboarding concierge (ai/onboarding/) — the tenant setup flow, in two parts. Source analysis turns a tenant’s own source (a website URL or a free-text business description) into confidence-scored candidate items per section against a shared Zod extraction contract — it persists nothing; website fetch is timeout- and size-bounded and degrades to a machine-readable reason rather than throwing. The concierge agent (buildOnboardingConciergeAgent) is a per-tenant tool-loop agent that interviews the first user through the six blueprint sections in order, offering each section’s candidates in conversation and drafting only what the operator confirms. The products section is a per-product gap interview: each product is captured as a typed, confidence-scored profile (overview, target customers, use cases, key features, pricing, integrations, differentiators, docs, support, FAQ — beyond the committed name + description), and the agent takes one product at a time and asks one specific question about the next empty or low-confidence field. The conversation is deliberately scoped to just two sections: the company profile and what the vendor can say about every product it sells. The concierge is not a register, but it writes in the same house style: the chat route resolves L3/how-to-answer.md through the prompt workspace (published version, else the seed) and appends it to the concierge’s prompt. Source analysis is told to write each field as a plain third-person statement in British English with no superlatives, and to keep the company’s own tagline word for word or leave it empty. Every drafted field has a character cap (PROFILE_FIELD_CAPS / PRODUCT_FIELD_CAPS in contract.ts): an over-long extracted field is cut at a word boundary, while draft_section and update_product_profile refuse an over-long value and name the field and its limit, so the model shortens it. Its tenant-scoped tools read and draft the profile, read and update per-product knowledge gaps, invoke source analysis, and advance the blueprint to in_review when setup is complete. Consumed by the apps/web /onboarding concierge wizard — its chat pane streams through the /api/onboarding/chat route while its read-only blueprint panel mirrors the conversation. Each draft item in the panel says where it came from (“You told the assistant” for human_edited, “Drafted by the assistant” for ai_drafted) and carries a “Check this” flag only when it is ai_drafted below GAP_CONFIDENCE_THRESHOLD; the confidence score itself never reaches the browser. The operator confirms and launches the blueprint as a whole via server actions over the onboarding-blueprint service. Neither door opens on an empty catalogue: both the confirm and the commit refuse a blueprint carrying no product, so a workspace can never go live with nothing a lead can be raised against. Products are the only thing the gate can count, because they are the only catalogue section the conversation drafts — the rest of the minimum is scored against the real catalogue by the catalogueMinimum readiness check above. At launch the commit writes each product’s rich profile into the product’s knowledge sub-document, derives a default service and skill per product, and seeds the SLA and tenant settings from platform defaults — so the vendor never has to discuss them. Because it writes to the database it is exported from /server. The captured knowledge is read back with getProductKnowledge(tenantId, productId), which reads the product’s knowledge directly and clamps each value to its cap — the seam a requirements conversation can consume (no agent reads it yet) — also from /server. Setup is not a one-way door. The tenant carries an onboarding history — firstCompletedAt / lastCompletedAt / skippedAt / lastEnteredAt / runCount, distinct from the coarse status — so “has this tenant ever finished setup?” is a fact separate from what it is doing now (shared/onboarding-progress.ts). The first-run redirect reads that history, not the status, so a tenant re-running setup is never trapped on /onboarding; the admin and vendor dashboards read it too, surfacing a prominent “finish setup” entry point for a tenant that skipped and a quiet “revisit setup” link for one that completed. A committed blueprint is re-enterable as a new round: it reopens as a draft keeping every item it launched (each stamped with the round that committed it), the concierge greets fresh, and a later round is add-only — it may add products, but items already live are refused and edits to committed catalogue go through the settings UI. Committing again is idempotent: the commit skips already-stamped items and re-seeds no SLA or settings, so a second launch creates no duplicate of what the workspace already holds.

  • Canned replay (ai/canned/) — deterministic replay of the onboarding conversation for live walkthroughs, layered at the provider boundary: replay-model.ts is a LanguageModelV3 that streams a recorded transcript instead of calling a provider, so the agents, routes, tools and approval affordances above it are untouched. Transcripts are versioned JSON fixtures (transcripts/onboarding.json) schema-checked on first use, and agent-model.ts is the one call a route makes — flow in, model plus replayed tool results out. Client-safe and DB-free, so it is exported from /ai. Reachable only on a tenant flagged isDemo — see the demo environment.

  • Turn telemetry (ai/core/telemetry.ts) — agentTurnTelemetry builds the AI SDK experimental_telemetry settings every agent turn is invoked with, so the SDK’s own spans for the model call and each tool carry the keys that join them to the run record: the tenant, the register, the thread and the prompt version set. Input and output recording are off and are not a setting — identifiers may go on a span, message content never. It adds no dependency, no key and no globalEnv entry, and with no tracer registered in the process the spans go to a no-op tracer; choosing a collector is a platform decision, and the durable staff-readable record is the run record itself.

  • Agent quality (db/services/agent-quality/) — read-only aggregations over agentruns, and nothing new is written for them. ratesByPromptRef answers “how did runs on this version of this file go” from the flat prompt-version stamp — one equality per entry, which is why the stamp is a flat array — bounded to one register so a shared file’s publish does not pool every register’s turns. readRegisterTrend gives a register’s outcome rates by month with the models that served them; readRuns returns the turns themselves with their tool calls; readPopulation states how many of a period’s runs carry no stamp at all. Rates come back as null, never 0%, when nothing has run — “no turn has run on this version yet” and “every turn succeeded” are opposite facts. Platform-wide and staff-only by construction: a prompt version has no tenant.

  • Scored evals (ai/evals/ + db/services/agent-eval/) — graded checks on a register’s assembled prompt and a set of canned answer fixtures, run on demand from the console against either the published version set or the set the saved drafts would produce — both assembled by promptWorkspaceService.assembleForSource, the same call the console’s test conversation talks to, so what is scored and what is talked to never differ. Four deterministic graders — claims present and well-formed, tool scope correct, register verb honoured, caveats present — with no model call, no network and no clock, so the same version set always scores the same. Results are written to agentevalruns, append-only and stamped with the version set they scored. Informative, never blocking: publish reads no result. Agents never author their own outcome records — a score is computed by the code-owned graders and written by the staff action that asked for it.

  • Expert-fit scoring (db/services/matching/) — generateObject scoring of expert↔lead fit.

Models

Every agent routes through Vercel’s AI Gateway (AI SDK v6) — models are addressed by gateway string, never a raw provider SDK. The current models are defined once, in packages/services/src/ai/core/agent.ts:

ConstantModelUsed for
DEFAULT_AGENT_MODELanthropic/claude-haiku-4.5Default for every platform agent
DEEP_EXTRACTION_MODELanthropic/claude-sonnet-5One-shot structured extraction over large schemas

This table is the docs’ single statement of the models — other pages link here instead of naming models, so a model bump is one code constant plus this table. Canned mode makes no provider call at all.

Notifications

A per-resource architecture. External callers use the notifyX functions exported from @sustentus/services/server (e.g. notifyBidSubmitted, notifyInvoicePaid, notifyServiceLeadCsatRequested). These persist a notification to MongoDB and publish it to Ably for real-time delivery. The notifications/core/** primitives are internal — never imported from outside the package. Full contract: packages/services/AGENTS.md.

The inbox read narrows; the kind filter is a family, not a type. getNotificationsForUser takes NotificationListOpts — a read state, a list of types, an inclusive createdAt range and the sort the shared PaginationOpts declares — and defaults to unread-first, which is the bell popover’s order; the full page asks for { createdAt: -1 } instead, because a list grouped by day cannot have its rows move as the reader marks them read. The 35 stored dot.case types belong to seven families (bid, quote, invoice, project, service-lead, user, messaging), and the family is the unit the inbox filters by: the vocabulary is isomorphic (shared/notification-families.ts, so a client control can offer the kinds), while expandNotificationFamily (notifications/families.ts, exported from /server) turns one into the concrete types it matches, read straight off the per-domain unions the senders write from. The query matches them with $in rather than a prefix regex, which is what keeps the collection’s existing {tenantId, userId, read, createdAt} and {tenantId, userId, createdAt} indexes usable — the filters needed no index of their own.

A notification names its record; the wording lives in one pure copy function per family. notifications/{bids,quotes,invoices,service-leads}/copy.ts build a notice’s in-app title, email subject and button label from the record id the caller holds — lead #N for staff and experts, request #N for the customer, the invoice reference (INV-<request>-<milestone>) for every recipient — through titleNamingRecord in notifications/shared/notice-copy.ts. With the id the subject repeats the title and the button opens the record; without it the generic title stands and the email keeps the type’s registered subject and “View in app”. A person’s name and a lead’s free-text title ride in the in-app title only, never the subject. The demo storyline’s authored bell rows are held to the same functions by storyline.test.ts.

A lead notification’s link is built, never interpolated. leadWorkspaceLink (notifications/shared/refs.ts, exported from @sustentus/services/server) returns /workspace/<leadId> — one destination for every persona, because that route requires lead.view, which every role template carries, and the page scopes what it renders to the viewer. It takes the lead reference rather than an id, so a Mongoose ref that arrived populated resolves instead of stringifying to [object Object], and it returns nothing at all when the lead cannot be resolved, so a notification carries no link rather than a broken one. A no-restricted-syntax rule over packages/services/** and apps/web/** fails lint on a hand-written link — ten call sites had drifted onto a /requests/<id> route apps/web has never had, and prose alone did not stop the tenth.

A notice about one invoice links the invoice, not the lead: invoiceDetailLink (beside leadWorkspaceLink in notifications/shared/refs.ts, exported the same way) returns /finances/invoices/<invoiceId> for every recipient of the raised, approved, rejected, disputed and paid notices — the page the notice’s “Open invoice INV-…” button names. It resolves the ref the same way and returns nothing when the invoice cannot be resolved. The survey request sent on the final payment is a notice about the lead and keeps leadWorkspaceLink.

Sends fail silently by design, and every failure is logged. A send never throws to its caller: createNotification returns null and the Ably publish and Resend send are fire-and-forget. So each failure path also writes a row to the notification-delivery-log collection — channel (in-app / realtime / email), reason, recipient and a truncated error — which admins read at /admin/notifications, and which the admin dashboard flags over a trailing 24 hours. Deliberate suppression (a preference switched off, a type in EMAIL_DISABLED_TYPES) is not a failure and is not recorded. A recipient who switched a type’s In-app channel off (the type not in LOCKED_IN_APP_TYPES) gets no in-app row and no Ably publish — only the email runs, under its own preference — and a preference read that fails counts as In-app on. The collection is bounded by a 30-day TTL index, so it cannot grow without limit.

Where the notification is the feature, the sender confirms it and retries once. Silence is right for almost every send, and wrong where nothing else moves the transaction on — a gone-cold nudge to a customer who is not visiting, an invoice.raised that starts the payout flow. Those senders pass confirm: true, which awaits the publish and the email too and resolves to a NotificationDelivery naming what each channel did (sent / failed / suppressed). A confirmed send cannot take the fan-out’s one-email-per-person dedupe (skipEmail, refused by its type), so its email outcome is always the email it actually sent. What counts as delivered depends on the recipient: someone who works in the product daily is reached once the in-app row lands, while a customer who is by definition absent needs the email as well. A channel the recipient switched off themselves counts as delivered — an In-app switched off reads suppressed for both in-app and realtime — so no retry runs against their own choice. With In-app off no row is written and the email is the one channel left, so it counts only while that email did not fail: In-app off with a failed email reached nobody, and is retried like any other failure. A failure marks the entity the notification was about — goneColdNudgeFailedAt on the lead, raisedNotifyFailedAt on the invoice — and the job that owns that entity re-attempts it exactly once: the gone-cold sweep is its own retry, and /api/cron/notification-retry-sweep is the one for senders that never run again. A second failure writes a notification-abandonments row naming the lead or invoice (90-day TTL), and the entity’s own surface takes over — the lead workspace tells a manager which draft invoices nobody was told about, and its Matching section which matched experts were never told. This is a registered list of senders, not a dead-letter queue over all of them; the rule and its duplicate trade-off are in the notification skill.

The bid pool’s matcher run is the one exception to “exactly once”. An expert told late can still bid, one told after the pool closes cannot, so the run’s bound is the pool, not one retry. leadService.approveBrd stamps matcherPendingAt in the write that opens the pool and hands runLeadExpertMatcher to the caller’s DeferWork (@sustentus/services/shared — the agent chat route builds it on Next’s after, so the customer’s confirmation never waits on the matcher); with no deferral, or one that cannot schedule, the run is awaited. A run that finishes clears the stamp, and with it matcherFailedRuns. Each night the retry sweep re-runs the matcher for a pool lead whose stamp is over ten minutes old, until the lead leaves the pool. A sweep re-run that does not finish adds one to the lead’s matcherFailedRuns, and the re-runs are taken fewest failures first, then oldest stamp, so a lead whose run fails every night is still tried but never goes ahead of one that has failed less. The match notifications themselves follow the “once” rule: a send that reaches nobody releases its claim and adds one to the match row’s notifyFailedAttempts, the next caller (a manual add, the matcher, or the sweep’s notifyPendingMatches over every pool lead with an untold row still owed a go) tries it again, and the second failure (MATCH_NOTIFY_MAX_ATTEMPTS) leaves the expert untold and writes one retry-exhausted abandonment against the lead, naming the expert and the channels that send failed on (none for a send that threw, or for a stuck claim the sweep released). An SDM adding the expert by hand clears the count and re-arms it. notifyExpertLeadMatch confirms its sends, because it runs after a response or in the cron, where a send still in flight can be frozen with the function, and resolves the whole delivery: the matcher reads told from it with reachedInbox and the failed channels with failedChannels. The lead workspace’s match card shows a given-up expert (isMatchNotifyGivenUp) as Not told, with the channels and the day from the lead’s service-lead.matched abandonments (notificationAbandonmentService.listByEntity with type), read only when a given-up row exists; the aggregate’s cache key carries leadExpertMatchService.notToldVersion, the newest given-up row’s updatedAt, because a give-up never writes the lead.

A claim is stamped until it settles. The claim writes notifyClaimedAt with wasNotified; a delivered send clears the stamp, a release clears it with the count, and the matcher’s exclusion clears it, unconditionally, with the row it marks untold. The send’s own delivered and release writes match the stamp it wrote, so a send that resumes after its claim was taken from it changes nothing. A stamp older than MATCH_CLAIM_GRACE_MS (ten minutes, past the agent route’s limit) is a claim that never resolved — its release write failed, or the function died mid-send — and the nightly sweep releases it (releaseStuckClaims, on every lead, in the pool or not, before the untold leads are read) as one failed send, recording the give-up at the cap. A send that was delivered but whose stamp clear failed reads the same, so its expert may be told twice — never more than the cap: told twice is preferred over never told.

The console’s email is a separate path under notifications/console/, and the two do not share a code path or a log. This one is per-resource, tenant-scoped, and delivers in-app + Ably + email to platform user rows; the console’s is a fan-out over tenant lifecycle events to three audiences — a tenant’s administrators, the assigned partner, and Sustentus staff — none of whom is necessarily a platform user, and one of whose events carries no tenant at all. It owns the matrix, the copy, the templates and its own failures-only console-email-delivery-log (also 30-day TTL); recipient resolution stays in apps/console, because it is entirely Clerk reads. See the console.

Permissions

./permissions holds the platform’s authorisation engine. The DB-backed resolver and models are exported from @sustentus/services/server; the pure, DB-free half is also exported from @sustentus/services/shared — the Permission type, the FEATURES / ALL_FEATURES registry, the PermissionScope type, the platform-default role templates, and the licence-class mapping. That split lets the apps/web route policy table derive a route’s allowed roles from a required permission, and lets read-only surfaces render the permission vocabulary, without either pulling the resolver — and Mongoose behind it — into the bundle. The policy table itself stays on /shared and DB-free; the proxy that consults it does reach the resolver, but only on the escalation path described above, and through its own module (apps/web/lib/route-effective-access.ts) rather than by widening what the table imports.

Licence classes are reporting, never a gate. LICENCE_CLASS_BY_ROLE maps each role to exactly one of Platform, Sales or Delivery — every role, the customer included, so every user appears in the licence report exactly once. Nothing in the platform reads it to allow or deny an action; it exists so capability can be grouped by class on the admin scope surface, and so the per-user licence report on that surface counts from the same mapping rather than a second one.

Three enforcement layers now read the model. Server actions enforce it via resolveActionContext, which resolves the caller’s effective set and rejects (Forbidden) when it lacks the action’s requiredPermission, threading the resolved own/full scope into the action context. The admission rule is admittedScope (exported from /shared as well as /server): it also refuses an own holding of a tenant-wide permission — TENANT_WIDE_PERMISSIONS in the registry: tenantConfig.view/manage, taxonomy.view/manage, onboarding.run, each one object that belongs to the whole tenant — exactly as it refuses no holding, so the actions behind a full route refuse what the route refuses. Route access enforces it too: each route policy declares a requiredPermission — or, in its any-of form, requiredPermissions, two or more of which any one admits (the settings hub root, the catalogue’s read pages) — and the proxy derives its allowed roles from the platform-default role templates — a static list, computed at module load, that is the gate’s fast path and costs no per-request DB read. When that list does not admit the acting role the proxy escalates once, resolving the principal’s effective permissions and admitting them if they hold the route’s permission (any one of them, for an any-of rule) by a per-user grant; the escalation only ever widens and fails closed on any read failure, so a route stays as shut as it was. A derived rule also declares the scope a holding must carry — requiredScope: "own" | "full", required by the rule’s type: own where the page narrows on the viewer’s resolved scope, so a holding at either scope opens it; full where the page renders the whole tenant, so an own holding opens nothing. Both steps read it — the static list counts a role only when its default template holds the permission at that scope, and the escalation admits a grant only at that scope. The review that decided each rule’s value sits above ROUTE_POLICY_RULES in apps/web/lib/route-policies.ts. It resolves for the signed-in user, acting as the role getRole() (apps/web/lib/auth.ts) returns. The AI route handlers gate through a shared requirePermission() helper backed by the resolver, which admits through the same admittedScope (an own tenant-wide holding answers 403).

The page behind a derived route asks the same question. A route rule opening for a per-user grant is only half an answer if the page it leads to then bounces the holder on a role list, so the pages and layouts behind derived rules gate through requireRoutePermission (apps/web/lib/require-route-permission.ts) — the server-component counterpart to the route handlers’ helper, redirecting rather than returning a response. It reaches its verdict from the same two steps and the same functions the proxy uses: the static derivation first (rolesForPermission, exported from the route-policy table), then, only for a principal that list refuses, the same widening escalation for the same signed-in principal — both at the scope the guard is given, which is the scope of the rule in front of the page ("full" for every current caller). Sharing the functions rather than restating the rule is what keeps the pair from drifting. A page behind an explicit rule keeps its role check, because an explicit allow-list is literal: a grant can widen a permission and cannot widen a rule, so a permission guard there would be wider than the route in front of it. The workspace UI capability matrix stays on legacy role checks until later phases; a handful of full-tenant directory/log routes keep an explicit staff-only allow-list until page-level scoping lands with the UI capability cutover.

Which user document a surface reads is part of the same model. A principal can hold one user document per role in a tenant, so an unscoped userService.findByClerkUserId(clerkUserId, tenantId) returns an arbitrary one of them and an own-scoped row filter built on its _id can name the wrong document. Both halves therefore resolve the same way, through apps/web/lib/resolve-viewer-for-acting-role.ts — the capability gate in apps/web/lib/api/require-permission.ts and the own-scope row filter alike: the role-scoped read first, then, when no document carries the acting role, the document at the first other role Clerk still grants the principal (the primary role first), so a single-role principal is unaffected. A document at a role Clerk no longer grants is never served — a principal demoted in Clerk whose new role has no document yet is refused, not handed the document at the role they lost. The sign-in path (apps/web/lib/resolve-app-user-for-tenant.ts) applies the same rule after its role repair, and logs [resolve-app-user] role sync did not land when that repair fails. Both pair the signed-in id from getIdentity() with the role getRole() returns — the active-role cookie when it names a role the session’s claims grant, else the primary role. getIdentity() (apps/web/lib/identity.ts) is the platform’s one server-side read of who is signed in — the user, their organisation, the role claims and the profile claims; the request proxy reads the same answer through identityFromAuth(). Lint rejects Clerk’s auth, currentUser and getAuth anywhere else in apps/web.

  • Feature-set registry (permissions/registry.ts) — a typed code constant and the single permission vocabulary, `${Feature}.${Action}` (e.g. invoice.approve). An unknown feature.action string is a compile error; a runtime isPermission guard checks DB-sourced values.
  • Models — RoleTemplate (per-tenant role → default permission set, with own/full scope qualifiers and an append-only, bounded audit trail) and UserPermissionOverride (per-user grants + revocations, bounded, with an audit trail). Both tenant-scoped via the tenantPlugin.
  • Resolver — resolveEffectivePermissions(tenantId, clerkUserId, role?) returns effective permissions = (template ∪ grants) − revocations, with admin short-circuited to allow-all. The role comes from the local user mirror (no Clerk round-trip); the result is memoised per request and cached with a short TTL. A principal may hold one user document per role in a tenant, so every caller that knows the role being acted as passes it as the third argument and is answered from that document — without it, the lookup is unscoped and the answer depends on which sibling document Mongo picks. A principal holding no document for the role passed falls back to the unscoped read, so a single-role principal and a role mirror still being repaired resolve as they always did.
  • Scopes fail closed. A stored or submitted scope counts as full only when it is exactly full; a missing or unrecognised one is own (toPermissionScope, exported from /server and /shared). Every reader of a resolved set — admittedScope, the workspace viewAccess, the bid average gate, the AI tools’ narrowing — treats a held permission with no scope entry as own too, so a malformed row or a hand-built set narrows a reader rather than widening them to the tenant.
  • The role mirror’s repair names one document. userService.syncRoleFromClerk re-mirrors the Clerk org-membership role onto the local user document, and takes the _id of the document to repair — a filter naming only the principal rewrites whichever sibling Mongo matches first, and collides with clerkUserId_tenantId_role_unique when the target role’s document already exists. It reports which document carries the role afterwards, so the caller acts as that one: the document it named, or the existing document when the principal already held the role (nothing is written then, and nothing throws). The sign-in path and the admin’s primary-role change both name the document they hold; the organizationMembership webhook cannot — the event says which role a principal now has, never which of their documents held the old one — so it repairs a single-document principal and leaves a multi-document one to the next sign-in, where the acting document is known.
  • The profile mirror reaches only the tenants a principal has joined. userService.refreshProfileFromClerk writes firstname, lastname, email and avatar (never role) on a principal’s rows in the tenants its caller names, and nowhere else — a row carrying a clerkUserId is not consent, because an invited customer’s row holds the id before they accept and a removed member’s keeps it after they leave. The user.updated webhook (apps/web/app/api/webhooks/clerk/route.ts) carries no org, so it reads the principal’s Clerk organisation memberships (every page), maps them to tenants with tenantService.findIdsByClerkOrgIds, and refreshes those; a pending invitation is not a membership, so the inviting tenant keeps what the vendor typed. organizationMembership.created — the invitation accepted, among other joins — fills the same fields in that one tenant from the Clerk user. A Clerk error answers 500 so Clerk retries; it never falls back to every row.
  • Cutover complete — every server action declares requiredPermission (resolver-enforced) or a documented authnOnly; the registry’s few unmodelled persona surfaces gate through an explicit allow-list (resolvePersonaActionContext in apps/web/lib/actions). The interim shadow-mode logger and its PERMISSIONS_SHADOW flag are gone — the resolver is the only decision path.

Per-tenant role templates are seeded from platform defaults by a ts-migrate-mongoose migration, and a tenant admin edits them in-app from Settings → Role templates (roleTemplateService) — each change diffs the grid, writes the template’s audit trail, and (because enforcement is live) takes effect on the affected users’ next request. The admin role’s template renders as a read-only superuser set and cannot be edited or reset, so an admin can never be locked out of the permission system.

Live UI convergence. Enforcement is next-request-fresh, but an open session would otherwise keep rendering the old permission-gated UI (levers, nav) until the user navigates. On any per-user override, role-template, or primary-role change, invalidatePermissionsForUser / invalidatePermissionsForRole publish a permissions_changed event on the affected user’s per-user Ably channel and mirror the freshly resolved effective-permissions version hash into their Clerk publicMetadata (the hash only — a few bytes, well inside the custom-claim budget). apps/web subscribes and refreshes the server-rendered UI, so an open session converges on the new set without a manual reload. Both are best-effort — a failed nudge never fails the admin’s save — and server enforcement is untouched, so it stays a client-freshness optimisation layered on the existing next-request checks.

Metric dictionary

metricdefinitions is a platform-wide collection — definitions are the business’s, set centrally and identical for every vendor — so unlike almost every other model it carries no tenantPlugin. Ten records (M1–M10) are seeded by a ts-migrate-mongoose migration, and every later change lands as a further migration rather than an edit to the one that shipped: corrected wording, a threshold a family depends on, or the removal of a definition whose figure has left the product. The collection is read-only: there is no write path in the product today, and a definition the dictionary no longer holds is one no surface can render.

A record holds what a figure means, never the figure itself — the KPI’s name, one-line summary, the question it answers and why it matters; its group and order on the vendor grid; its shape (cohort, activity or stock); and the claim vocabulary: population, period, basisDate, measure, exclusions, opensTo, and the settings it depends on. Each linksTo entry resolves to another record’s metricId or is explicitly marked not yet defined, so a dangling KPI name cannot render. Names are sentence case — only the CONVENTIONS product names (Active Service Revenue, Revenue at Risk, Forecast Service Revenue, Service Pipeline Value) keep their capitals — and a linksTo label is its target’s name. group stores the heading itself, so renaming one is a migration: currentMetricGroup in shared/metric-claim.ts reads a record still holding a retired name (Delivery & Quality, now Delivery and quality) as the current one, for the window between the web deploy and the production migration.

  • Reads — metricDictionaryService from /server: list(), grid() (the three groups in order) and registry() (the groups plus the definitions with no grid tile). The vendor dictionary page, the vendor dashboard’s popovers and the admin registry all read the same records, which is what stops two surfaces stating a different definition.

  • Claim vocabulary and period resolver — shared/metric-claim.ts and shared/period.ts, both exported from /shared so the model and the client surfaces share one definition of them. The surfaces render a Claim, which cannot exist without the RecordSet behind it; resolvePeriod({ preset, timeZone, now }) returns the window for last 90 days rolling, the current quarter or the last 12 months, with boundaries at local midnight in the vendor’s business timezone.

  • Tenant scoping — vendorClaimFilter({ tenantId }) is the single place a vendor’s figures get scoped, and it scopes to the whole tenant: a vendor is the tenant, so the figures count every record in it whichever seat (if any) it is attributed to, and nothing from another tenant, by every route including drill-downs (BR-23). It throws without a tenant rather than returning an unfiltered query, so a metric family that forgets it fails loudly instead of leaking. It exists because tenantPlugin does not hook aggregate, and the metric families are aggregation-shaped. The satisfaction read windowed on completedAt plans on the csats { tenantId: 1, completedAt: -1 } index; the vendor field and its { tenantId, vendor, … } indexes remain but no figure reads them.

  • Who reads the figures — vendorFigureAccess(effective) (shared/vendor-figure-access.ts, exported from /shared) answers tenant only when the resolved set holds lead.view, csat.view and invoice.view all at full; anything narrower is narrowed. apps/web asks it on the full resolver before any family read — /vendor and /vendor/metrics render VENDOR_FIGURES_NARROWED_NOTICE, the figure pages and /vendor/customers/[id] 404 — and the vendor register’s tool set keeps only vendor_lead_book and vendor_leads_in_status for a narrowed seat.

  • Metric families — the modules that turn the definitions into figures. The first is the vendor funnel and activation family: shared/vendor-funnel.ts holds the pure cohort logic — the lead workflow’s own forward path, the furthest stage each lead ever reached, the loss breakdown, and the reconciliation carried as data on the record set rather than as prose in a component — and vendorFunnelService from /server does the read, scoped through vendorClaimFilter, with stage labels resolved from workflows.json so the funnel carries no second vocabulary. The second is the delivery family: shared/vendor-delivery.ts classifies an engagement against the three conditions that make it Delivered — every milestone complete, every one acknowledged by the customer, and every bill settled — and derives revenue delivered and the on-time share from one list of records, so the value and the count cannot disagree. The report is stated in the reportingCurrency its caller passes — the tenant’s default_currency on every vendor surface — and an engagement priced in any other currency is set aside and counted, in the period and the trend alike. vendorDeliveryService does its read through the same scoping guard. Its report keeps the delivered engagements as rows and not only as monthly subtotals, which is what lets the drill-down read the twelve months of history, the engagements behind each of them, and the value delivered in the window before this one off one gather rather than a second report over a second window. A month’s coverage is read off the window’s dates and never off how many engagements fell in it — a quiet month a window covers is still inside it — and the customers behind a figure are counted by id, because every customer whose record carries no name resolves to the same words. The third is the satisfaction family: shared/vendor-satisfaction.ts holds the response cohort, the three-response floor below which no average is published, and the revenue weighting with its coverage share built into the claim rather than left to a surface to remember. vendorSatisfactionService reads through the same guard and windows responses on completedAt, a field csats now carries: the completion write sets it with $min, so the first answer stands and no later write can move a response into another period. It takes the tenant’s currency as reportingCurrency like the money family: every collected amount is stated in it, and a paid invoice in any other currency is left out of the collected spend, the coverage and the weighting and counted in a stated note — never converted. The fourth is the money family: shared/vendor-money.ts and the money types in shared/metric-claim.ts hold the rule that every money figure names which of Delivered, Committed, Billed or Collected it is — MoneyClaim has no unlabelled variant to construct, and totalMoney fixes the state and basis from its first amount via NoInfer, so a mixed set is a type error rather than a silently wrong number. Active Service Revenue is the order book: a stock as at a moment rather than a period total, carrying the composition its value decomposes into and the delivery-stage partition its rows sum to. An engagement’s contracted value is the accepted proposal’s total as amended by every approved variation to date — a variation is recorded as its own dated change-control entry rather than by rewriting the accepted quote, so contractedValueAsAt reads the value at a moment rather than off a stored number. That is also what lets the drill-down’s movement bridge foot: buildMovementBridge explains the change between two balances as accepted, settled, ended and re-priced, and a fixed per-engagement value would leave the last of those with nothing to move. vendorMoneyService reads through the same guard, takes one reporting currency and excludes engagements in any other, stating the count it left out beside the figure rather than converting them; its orderBook read shares one gather with report, so the dashboard tile and the drill-down page cannot state different numbers. report returns the movement bridge over that same gather, which is what lets the card state the change behind its figure without a second read; the bridge opens where the flows’ window starts and closes at the as-at moment, because the dashboard reads both over the range the header names. The fifth is the health family: shared/vendor-health.ts scores a customer out of 100 from four weighted components — satisfaction 40, engagement recency 25, delivery reliability 20, commercial trend 15 — where recency and trend are discrete buckets rather than interpolated curves, so a score can be reproduced by reading one value off the drill-down and finding its row. Each component is rounded once and the score is the sum of those rounded contributions, so the four figures on a row add up to the score beside them. A component with no record behind it is not a component worth zero: the customer carries no total and no band, lists every reason that applies, keeps the components it does have, and is excluded from the at-risk count rather than counted as safe. meanHealthScore is the cohort’s one mean — the scored customers’ scores, rounded once to a whole number, returned with that figure out of 10 and its band (null when nobody is scored) — and the Command Centre card and the figure page both read it, so they state the same mean. vendorHealthService reads through the same guard and reuses the delivery family’s own classifyEngagement rather than re-deriving Delivered, so the two surfaces cannot disagree about which work is done; for a period still in progress it narrows the comparison window to the same elapsed span, so an open quarter is read against a like-for-like slice of the previous one instead of against a complete period. Every fact behind a component carries a date, so scoreCustomerAsAt reads a customer as they stood at a past moment — the same four components over the records that existed by then — and buildAtRiskBridge turns that into the movement behind the count: at risk when the range opened, plus what entered, less what recovered and less what stopped being scored, equals at risk now. The commercial trend is the one component that cannot be filtered by date, so the caller supplies the pair of collected windows ending at that moment rather than stepping the current pair back one: on a surface with its own range control the score’s period and the bridge’s opening are different moments, and shifting would price a past score on a window unrelated to it. No score is persisted — the arithmetic module holds no database import, so there is no stored figure for any role to adjust. The sixth is the retention family: shared/vendor-retention.ts folds the previous period’s delivered engagements into one row per customer — their median delivery time, their region, and whether they came back — and cuts that single list two ways. Both breakdowns counting off one list is what makes them reconcile to the same population and the same returner count by construction, rather than by two aggregations agreeing on the day they were written. Delivery time is calendar days in the vendor’s business timezone, both ends reduced to local midnight, so the count matches the basis printed beside it rather than the number of elapsed 24-hour spans, and the median is taken unrounded, so 14.5 days falls in the 15-to-30 bucket. Every speed bucket is emitted whether or not anyone is in it, and the unknown-region bucket is always shown and always last: it holds both unclassifiable cases — no country stamped on the engagement, and a country stamped that no region groups — and is never merged into a named region. vendorRetentionService reads through the same guard and takes Delivered from the delivery family’s own engagementInputs() rather than re-deriving it, deduplicating to one row per lead so a second accepted proposal cannot double-weight a job in its customer’s median. The returner side reads the first work_in_progress transition per lead from status history, over the accepted-proposal set only, and the tenant’s region rows are read without an isActive filter, so deactivating a populated region cannot move its customers into the unknown bucket and restate a completed period. The seventh is the risk family: shared/vendor-risk.ts takes the money family’s own active service revenue as its population rather than running a second query over engagements, so what counts as in flight cannot differ between the two figures, and joins it to the health family’s scored customers by customerId — never by display name, which two customers can share. Each engagement is flagged against three deliberately overlapping conditions — the customer is at renewal risk on the health family’s own atRisk verdict, the engagement has been in its stage longer than STALLED_IN_STAGE_DAYS, or the customer’s mean response is at or below LOW_SATISFACTION_DISPLAY_SCORE once divided by CSAT_SCALE_FACTOR into the displayed scale rather than compared against the stored 1–10 value — and counted exactly once however many it carries, so the total is not a sum of four overlapping categories. The record set carries a column per flag so the drill-down can open on one of them without implying the three partition anything, and the claim is a labelled slice of the order book that names the stock it came out of, never a second pot to add to it. Both thresholds have one home, the constants in the module, and M10’s displayed settings are generated from them by a migration, so the number on the definition is the number in the computation rather than a second copy that can drift from it. partToWhole reconciles the order book down to the flagged slice — the whole, less the in-flight engagements carrying no flag, is the part — and that is what the drill-down draws where the money family draws a movement bridge. It cannot draw one: a flag describes an engagement now, the stalled condition reads how long it has sat in its current delivery stage, and nothing dates a milestone’s transition into that stage, so the flagged set at any earlier moment is unrecoverable. Two of the three flags could be re-derived for a past date and the third could not, which would leave a bridge missing a third of its population and not footing to the figure above it.

    Beside the seven metric families sits the drill-down register, the surface every card opens to. shared/vendor-register.ts holds it generically — a row is a bag of values, a classification per facet and the text a search matches — with two rules the whole family inherits: a facet’s counts always describe the entire population rather than the filtered view, so a held chip never restates itself and leaves the reader unable to see the shape of what they are narrowing; and two facets held together intersect, because they are two readings of one row along different axes. shared/vendor-pipeline.ts and shared/vendor-customers.ts build the rows for the funnel, loss, lead-to-scoping and customer registers on top of it. The registers deliberately read the legacy funnel gather rather than the claim surface’s own cohort: a page exists to explain the card that opened it, and the two count different things on different stage vocabularies, so a page built on the claim would not equal the card a reader had just clicked. Which of the three shapes a page’s last part takes follows from what the figure is — a bridge for a stock, the range against the equivalent previous range for a period flow, the cohort’s own progression for a cohort — and the page says which it is showing rather than dressing one as another.

    The admin overview reads through the same vocabulary rather than one of its own. shared/admin-figures.ts builds the five figures behind /admin/figures/* — configuration gaps, SLA coverage, connected integrations, the tenant directory and notification delivery failures — as populations with a numerator, so they render through the rate register and the rate bridge the vendor pages already use. Two rules are the module’s own, and both exist because configuration is not an accruing ledger. A bridge side names when a member entered that side, which is not always the member’s own date: a stage’s SLA definition dates the numerator it joined, and says nothing about the stage, so a fixed population — the six go-live checks, the service stages, the integration catalogue — passes an entry date of null and every member is an opening item. And leaving a numerator is dated nowhere in the platform: no settings write is audited, removing an SLA definition deletes the row that dated it, and deactivating a user sets a flag with no time. So each side carries the sentence saying what leaving would mean for that figure, in place of an empty table, and a member that has left drops out of the opening, the entries and the closing together — which is what keeps sideHolds exact. The one inversion is configuration gaps, reconciled by its exits: a gap cannot be recorded as opening, but the configuration that closes one is dated.

  • Counted stocks, and the CSM’s registers — the vendor’s figures are money; the internal roles’ are counts of accounts and blockers, and the same anatomy had to work for both. shared/count-bridge.ts holds the reconciliation generically: an opening, a set of signed movements each carrying the records behind it, and a closing. A movement’s value is derived from its rows rather than passed alongside them, so “nothing is collapsed into an other line” is structural rather than a rule to remember, and bridgeFoots is the assertion a page states rather than a claim it makes. An opening the platform does not hold is null and stays null — the page names the day the record begins instead of deriving an opening from today’s set, which would reconcile a figure to itself.

    shared/csm-figures.ts builds the CSM’s three registers on vendor-register.ts and supplies the two shapes of bridge behind them. buildMembershipBridge explains a population records join and leave with dated transitions — the portfolio, and the open blockers — reading the opening by predicate rather than deriving it, so the reconciliation proves something instead of being true by construction. A record that left carrying no date for it is excluded from the opening, from every movement and from the closing, and surfaced as an undated exit: including it anywhere would put the bridge out by exactly the rows nobody can date. buildSetBridge explains a population with no history at all — health is scored, not transitioned, and nothing records the moment an account turned amber — by differencing the stored daily snapshot against the set now, which is why csmPortfolioSnapshot carries the accounts behind each band and not only the counts.

Development

pnpm services:dev # TSUP watch mode pnpm services:build # build to dist/

Apps pick up changes automatically via the workspace link once the package is built.

Learn more

Last updated on