Skip to Content

← All archived runs

Run: tenant-notification-centre

run.md

Run: tenant-notification-centre

  • branch: claude/tenant-notification-centre-pipeline-mjhobt
  • pr: #897

02_define/output/spec.md

Spec: The console notification centre

  • slug: tenant-notification-centre
  • personas: Admin (Sustentus team), Partner
  • touches: apps/console, packages/services/server
  • complexity: complex

Problem

The console records everything and tells nobody. tenant-activity-dashboard shipped an append-only trail of every administrative action and a dashboard feed that reads it, but both are places you go. A partner finds out a tenant they are responsible for was suspended by opening the console and looking; a Sustentus person watching a growing estate has to scan a feed to notice a tenant was created. The trail answers "what happened" for somebody already asking — it never reaches anybody who wasn't.

That gap is what stops the estate being genuinely operable by two audiences at once, which is the back half of "Establish product-market fit with vendor partners" (Scale the bridge, 2026-Q2, Objective 1). Partners can provision and administer other people's tenants; the accountability half is built, and the awareness half is not. The original scope deferred this at Q-12 — David has since named the events he wants people told about, and the 2026-08-27 amendment lifts that deferral for the events the app itself causes [Q-15].

Proposed change

A notification centre in the console: a bell in the shell header carrying an unread count, opening a preview of the newest unread, backed by a full /notifications page where every notification can be read and marked.

One record, two views

A notification is a projection of a trail entry, not a second record [Q-13]. There is no new event collection, no fan-out write, and no notification document per recipient. The centre is a scoped read of console-activity — the same collection, the same rows, the same snapshotted actor and tenant names — with three extra predicates and a per-person read flag laid over it.

That is what makes the stub's "no event appears in one and not the other" structurally true rather than a thing to keep true. Nothing writes a notification, so nothing can fail to write one; the trail entry is the notification, seen by somebody who is in scope for it. Marking one read writes to a separate per-person collection and touches the trail not at all.

It also settles the removal question by inheritance: permanently removing a tenant purges its trail entries, so its notifications go with them — the two views cannot disagree about a tenant that no longer exists. The one tenant-less tenant.removed entry the trail keeps back survives as a staff notification naming the removed workspace, exactly as it survives on the trail.

Which events are notifiable

Seven of the trail's fifteen actions:

Action Why
tenant.created a tenant was provisioned
tenant.suspended lifecycle
tenant.reactivated lifecycle
tenant.deleted lifecycle
tenant.recovered lifecycle
tenant.removed lifecycle
tenant.user_role_changed somebody's power over a tenant changed

The set is declared as a Record<ConsoleActivityAction, boolean> — a total map over the action union, in the shape CONSOLE_ACTIVITY_LABELS already uses — so adding a sixteenth action to the trail stops this file compiling until somebody decides whether it is notifiable. The eight that are not (tenant.partner_assigned, tenant.partner_cleared, tenant.user_invited, tenant.invitation_revoked, tenant.user_removed, and the three console.* access actions) are false there and named in Out of scope, so the omission reads as a decision rather than an oversight.

Plan changes are not among them, because there is no plan-change action to project. Moving a tenant between plans was dropped from tenant-plan-visibility on 2026-08-26 — Clerk's backend Billing API has no plan-change write — and tenant-activity-dashboard recorded the same gap. The stub names plan movement; the app does not do it, and the centre reflects what the app does. When plan movement arrives it is one more trail action and one more true in the map.

Who is told

Scope is the trail's scope, resolved live at request time — no recipient list, no denormalised partnerId on a row:

  • Sustentus staff see notifications for every tenant, the tenant-less tenant.removed entries included.
  • A partner sees only notifications whose tenant is currently assigned to them ({ tenantId: { $in: <their assigned ids> } }), which is the directory's own rule [Q-3]. A tenant re-assigned away takes its notifications with it, so a partner never sees an event raised before the tenant was theirs; a tenant-less entry matches no partner and is staff-only for free.
  • A partner's event set is the same seven as staff's. The stub's "never about an event they have no power over" is the console-level access events they have no standing in, which tenant scoping already excludes — not the lifecycle events they cannot themselves perform. Settled with Jamie on 2026-08-27: a partner is told when their tenant is suspended or deleted, which is precisely the awareness gap the Problem above describes.
  • Nobody is told of their own action: the read carries actorClerkUserId: { $ne: <viewer> }, applied in the query rather than filtered after, so the list, the count and the pages all agree.

Tenants' own admins are not an audience here — they never sign into this app, and email reaches them in tenant-notification-email [Q-14].

Read state

Read state is per person, so it cannot live on the shared trail row. Two small collections in packages/services, neither carrying a tenantId:

  1. console-notification-cursor — one document per person, { clerkUserId (unique), allReadBefore: Date }. "Mark all read" sets it to now.
  2. console-notification-read — one document per person per individually-read entry, { clerkUserId, activityId }, unique on the pair.

"Mark all read" is O(1): set the cursor, then delete that person's read rows, all of which name entries older than the new watermark and are therefore subsumed. So the read rows only ever accumulate between one mark-all-read and the next, which is what keeps the $nin below bounded without a cap that could silently mark something read.

  • Unread count = entries in scope, notifiable, not the viewer's own, with createdAt > allReadBefore and _id not among the viewer's read rows.
  • The list shows everything, read and unread — marking read never removes a notification, because the trail entry behind it is the audit and nothing in this app deletes one. A row is read when its entry predates the cursor or its id has a read row.

Neither collection carries tenantId, so neither takes tenantPlugin, neither needs a demo-reset policy classification (policy.test.ts classifies schemas that carry tenantId), and neither is reached by tenant-purge. A read row whose trail entry was purged with its tenant is inert — it can only ever exclude an entry that no longer exists — and disappears at that person's next mark-all-read.

Indexes come from a migration with autoIndex off, following the console-activity precedent, and are created with schemaPlugin's own { locale: "en", strength: 2 } collation named explicitly, so the collection default and the index agree however the collection came to exist. Keying on a Clerk user id under that collation is the same equality the tenant directory already does on partnerId; it is not a new property and needs no new decision here.

No new index on console-activity. The notification read is the trail read with extra predicates on the same leading fields: staff sort on createdAt (the createdAt index), a partner matches tenantId then sorts (the tenantId_createdAt index). action, actorClerkUserId and the _id exclusion are residual filters over a set already bounded by those two, at a volume of a handful of administrative actions a day.

The surface

  • The bell lives in the console shell header beside Tenants / Activity / Access, so the centre opens from anywhere in the app. It shows an unread count badge, and opens a popover previewing the newest few unread notifications with Mark all read and See all.
  • The layout is a server component and reads the count and the preview when the page renders; the bell itself is the client component that opens the popover over data it was handed. Opening the popover does not refetch — the count and preview are as of the page you are on, and both refresh when a server action revalidates the layout or you navigate.
  • /notifications is the full centre: every notification in scope, newest first, paginated on the same URL contract as /activity and the directory (page, 1-based, junk reads as page 1). Each row names what happened, the tenant it happened to, who did it and when — the trail's own words, from the shared renderer — is visibly read or unread, carries its own Mark read control when unread, and links to the tenant.
  • Per-notification "Mark read" is on the page, not in the popover. A server action inside the popover revalidates the layout, which remounts the bell and closes it — so the popover offers only the two actions that are meant to end with it closed.
  • Both mark actions are server actions calling requireViewer() and revalidatePath("/", "layout"), so the header count is right on the next render of any page. markRead refuses an entry the viewer cannot see: it resolves the entry through the same scoped read before writing, so a read row can only ever exist for a notification that was genuinely the viewer's.

Where a notification goes

Derived from the action, with no extra lookup per row:

  • tenant.removed — the entry carries no tenant and the tenant is gone. The row renders unlinked rather than pointing at a dead page.
  • tenant.deleted — the tenant is off the directory. Staff link to /tenants/deleted, the list they can still act on; a partner, who has no deleted-tenant surface, gets the row unlinked.
  • Everything else — /tenants/<tenantId>, which is already scoped, so a link a reader should not be able to follow behaves exactly as a typed URL does today.

Acceptance criteria

  • Each of the seven notifiable trail actions appears as a notification to everyone in scope for its tenant; the other eight appear nowhere in the centre, and the notifiable set is a total map over the trail's action union so a new action cannot be added without classifying it.
  • Every notification in the centre corresponds to exactly one console-activity entry and describes it in the same words; nothing writes a notification record, and the centre reads no collection but console-activity for its content.
  • A Sustentus person sees notifications for every tenant, the tenant-less tenant.removed ones included; a partner sees only those whose tenant is currently assigned to them.
  • A partner receives lifecycle notifications (suspended, deleted, recovered) on their assigned tenants, not just the events they could have caused themselves.
  • A partner sees no notification for a tenant that is not currently theirs — including one raised before the tenant was assigned to them, and including every tenant-less entry — proven by a request made directly to /notifications rather than by the absence of a link.
  • Re-assigning a tenant moves its existing notifications out of the old partner's centre and into the new partner's, because scope is resolved from current assignment.
  • Nobody sees a notification for an action they performed themselves, in the list, in the popover preview, or in the unread count.
  • The shell header shows a bell on every console page with the viewer's unread count, and no badge when the count is zero; the popover previews the newest unread and offers Mark all read and See all.
  • A person can mark one notification read from /notifications and mark all read from either surface; both are per person, so one person's marking changes nothing for anybody else.
  • Marking read never removes a notification from the centre — the row stays, shown as read — and changes nothing on /activity or on the dashboard feed.
  • Marking all read leaves that person's unread count at zero and leaves them no individual read rows; a notification raised afterwards is unread again.
  • markRead refuses an entry the caller is not in scope for, is not notifiable, or the caller authored, and writes no read row in any of those cases.
  • Opening a notification takes the reader to the tenant it concerns; a tenant.deleted notification takes a staff reader to the deleted-tenants list, and a notification naming a tenant that no longer exists renders without a link rather than to a dead page.
  • Permanently removing a tenant leaves no notification carrying that tenant in anybody's centre, and leaves the staff-visible tenant-less tenant.removed notification naming it.
  • /notifications paginates newest-first on the same URL contract as /activity, and a page past the end says so rather than erroring.
  • A migration creates both new collections' indexes with the schema collation named explicitly, and drops them symmetrically on down.
  • Neither new model carries tenantId; policy.test.ts and the trail's immutability.test.ts both still pass, and no update or delete path is added to the activity service.

Out of scope

  • Email. This feature sends none — tenant-notification-email (stub 9) delivers the same events to tenant admins, the assigned partner and staff [Q-14].
  • Notification on retirement-by-trial-lapse and on a payment failing. Both need the business to track trial expiry and payment state before anything can fire [Q-15]; when it does they are two more trail actions and two more trues in the notifiable map, not a rebuild.
  • Notification on a plan change. The console has no plan-change action to project — dropped from tenant-plan-visibility on 2026-08-26 and recorded again by tenant-activity-dashboard. Same shape when it arrives.
  • Notification on partner assignment or clearing, on a user being invited or removed, and on the three console-access actions. Settled with Jamie on 2026-08-27: the event set stays the one the stub names. All eight are false in the notifiable map, which is where to change it.
  • Recipient preferences, muting, opt-out, or digests — a fixed event set per audience [Q-16].
  • A before-and-after change record showing old and new values — the trail is the audit [Q-13].
  • Notifying tenants' own admins — they never sign into this app [Q-14].
  • Realtime push. The count and the preview refresh on navigation and on a server action's revalidation. No Ably channel, no polling, no websocket — the console is a low-volume operations surface and a stale-by-one-navigation count is not a defect.
  • Marking read by opening. Reading is an explicit act; following a notification's link does not mark it.
  • Deleting, archiving, snoozing or filtering notifications, and searching the centre. Newest-first pagination and read state only.
  • A per-tenant notification panel on the tenant detail view, and any notification surface outside this console.
  • Backfilling read state: every trail entry that predates this run starts unread for everybody in scope for it. There is no prior read state to migrate, and pre-marking them read would hide the estate's recent history from the people the centre exists to inform.

Open questions

  • none. The three that affected what gets built — whether a partner's event set excludes the lifecycle events they cannot perform, whether partner assignment joins the notifiable set, and whether the centre is a page or a header popover — were put to Jamie on 2026-08-27 and answered before this spec was written. Their answers are the Proposed change above. Whether a notification is a projection of a trail entry or a sibling record keyed to it was the stub's to leave to Define and is decided here, in favour of the projection.

Context budget: over. Define's Inputs allow "a few targeted greps"; establishing that notifications could be a pure projection needed the shipped trail read end to end — db/models/console-activity.ts, db/services/console-activity/index.ts, db/services/tenant/console-scope.ts, apps/console/lib/console-activity.ts, the shell layout, the /activity page and components/activity-feed.tsx — plus db/plugins/schema.ts and the console-activity index-collation migration to settle how the two new collections get their indexes, and demo-reset/policy.ts to confirm a tenantId-less model needs no classification. That is how the missing plan-change action, the tenant-less tenant.removed destination and the collation question were all found, none of which reading the stub would have surfaced.

03_build/output/notes.md

Build notes: tenant-notification-centre

  • commits: feat: tenant-notification-centre — the console notification centre
  • ci: GREEN on ea653a4 — every blocking check first time, Quality Project (format, lint, typecheck, tests) and the tenant-management preview included

What changed

packages/services — the read state, and only the read state

  • db/models/console-notification.ts (new) — two collections, both keyed on clerkUserId and neither carrying a tenantId: a per-person allReadBefore cursor, and one row per notification a person marked read individually. No notification collection exists, because a notification is a console-activity entry read by somebody in scope for it.
  • db/services/console-notification/index.ts (new) — the centre. The notifiable set is a total map over ConsoleActivityAction, so a sixteenth trail action cannot be added without classifying it; the reads are the trail's own reads with three predicates over them (notifiable, in scope, not the viewer's own); the writes are the two mark-read paths.
  • db/services/console-activity/index.tsextracted, not copied. The scope clause, the cross-tenant wrapper, the projection, the sort and the row mapper are now named exports the centre reuses. A partner's $in bound is the whole of partner authorization on this collection, and a second copy of it would be a second place for a partner's view to silently widen. listForConsole was rewritten onto the extracted pieces and is behaviourally unchanged.
  • db/migrations/1788430000000-console-notification-indexes.ts (new) — both unique indexes, collation named explicitly, symmetric down.

apps/console — the surface

  • lib/console-notification-links.ts (new) — where a notification takes its reader, derived from the action with no per-row lookup. Pure, so it unit tests without the server bundle behind it.
  • lib/console-notifications.ts (new) — viewer → scope → service, mirroring console-activity.ts.
  • components/notification-list.tsx (new) — the rows, shared by the popover and the page so the two cannot describe an event differently. Labels and the timestamp format are the trail's own, imported rather than restated.
  • components/notification-bell.tsx (new) — the only client component, and it holds no data: the shell renders the preview on the server and passes it in.
  • app/(console)/notifications/{page.tsx,actions.ts} (new) — the full centre and the two mark-read actions, both revalidating the layout so the header badge cannot go stale on other pages.
  • app/(console)/layout.tsx — the bell, reachable from every console page.
  • lib/console-activity.tsassignedTenantIdsFor is now cached per request. The shell added two more callers of it on every render; without this a partner paid for the same assignment lookup three times per page.

Acceptance criteria status

  • Seven notifiable actions, others nowhere, total map over the union — CONSOLE_NOTIFIABLE_ACTIONS, asserted by notifiable.test.ts.
  • Every notification is exactly one console-activity entry — there is no other collection holding content; nothing writes a notification record.
  • Staff see every tenant including the tenant-less tenant.removed; a partner only their currently-assigned tenants — consoleActivityScopeFilter.
  • A partner receives lifecycle notifications on their assigned tenants — the event set does not vary by audience.
  • A partner sees nothing for a tenant that is not currently theirs, direct request included — the bound is in the read, not in the link.
  • Re-assignment moves history — scope resolved from current assignment.
  • Nobody sees their own action — actorClerkUserId: { $ne: viewer } applied as a query predicate, so list, preview and count cannot disagree.
  • Bell on every page with the unread count, no badge at zero, popover previews the newest unread with Mark all read and See all.
  • Mark one from /notifications, mark all from either surface, per person.
  • Marking read never removes a row — the list filters on nothing read-related; only the count narrows. /activity and the dashboard feed are untouched.
  • Mark all read leaves the count at zero and no individual rows — the cursor advances and the rows are deleted as subsumed.
  • markRead refuses out-of-scope, non-notifiable and own entries, writing nothing — resolved through the same filter before any write.
  • Destination per action, unlinked where there is nowhere to go — asserted by console-notification-links.test.ts.
  • Permanent removal takes the notifications with the trail entries, and leaves the staff-visible tenant-less tenant.removed one. Inherited, not implemented: the projection has no separate copy to purge.
  • /notifications paginates on /activity's URL contract via the shared parseDirectoryParams / buildDirectoryQuery.
  • Migration creates both indexes with the collation named, drops both on down.
  • Neither model carries tenantId, so neither needs a demo-reset policy classification; no update or delete path was added to the activity service.

Notes for Verify

  • The trail refactor is the thing to read first. console-activity/index.ts gained five exports and listForConsole was rewritten onto them. It should be behaviourally identical — worth confirming /activity and the dashboard feed still scope correctly for both audiences on the preview.
  • Two reads were added to every console page render (count + preview, in the shell). Both are bounded by the trail's existing indexes; the cache on assignedTenantIdsFor is what keeps a partner's page from tripling its assignment lookups.
  • The $nin of individually-read ids is bounded by mark-all-read, which prunes it. A person who marks hundreds read individually and never marks all would grow it; at console volume that is not a realistic path, and it is stated in the model header rather than capped, because a cap would silently mark something read.
  • Unique indexes on clerkUserId carry the schema's en/2 collation, which makes that equality case-insensitive. This is the same property the directory already relies on for partnerId, and the migration header sets out why simple would be worse (a query inheriting the collection default could not use the index). Flagging it because "case-insensitive unique index on an identifier" is worth a second opinion.
  • No new environment variables, so no turbo.jsonglobalEnv change.

Context budget: within. The spec named apps/console and packages/services/server; the files read beyond the contract's Inputs were the shipped trail (model, service, lib, feed component, /activity page) that this run projects from, and proxy.ts to confirm /notifications needs no route entry.

04_verify/output/verify.md

Verify: tenant-notification-centre

  • ci: GREEN on db721a7 (the fix push) — settled via ci-status.sh; re-settled on the head carrying this file, which adds no code
  • previews smoked: tenant-management (console) built from db721a7 · web · marketing · demo, docs, help-centre, storybook, agentic-interface skipped for this diff (correctly — untouched)
  • production-readiness: run — 3 findings, 2 fixed on branch, 1 raised for an owner decision
  • code-review: high (spec complexity complex) — 5 findings, 4 fixed on branch, 1 raised
  • security-review: run — no HIGH or MEDIUM findings; one fail-open shape tightened proactively
  • playwright: TODO — manual DoD smoke performed instead

A note on the diff under review

git diff main...HEAD in this container reports 194 files — the local main ref is stale (64a64b8, predating the tenant-management-app merges). The PR's real base is origin/main (70d37ee), and the diff under review is 21 files, +1670/−31. Every pass in this stage was run against origin/main...HEAD. Anyone re-running the contract literally in a fresh clone will hit the same trap; git remote set-head origin main was also needed before /security-review could resolve its base at all.

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

Preview provenance, stated precisely. The console preview is built from db721a7 — the last commit on this branch carrying console code, and the one every fix below landed in. The head that hands over adds only this file, and the console project's ignore step correctly declines to rebuild for a doc-only commit, so it reports skipped there. The preview alias serves db721a7's build, which is the code under review. Recorded this way rather than quoting "green preview on HEAD", which would be false.

Worth knowing for the next run: the earlier Build hand-off had the same shape and I recorded it then as a caveat — a code push followed by a doc-only push always leaves the app previews built one commit behind the head. That is not a defect, but it is the sort of thing that reads as one.

  • Seven notifiable actions, the other eight nowhere — traced in the diff; CONSOLE_NOTIFIABLE_ACTIONS is a total Record over the action union and notifiable.test.ts asserts the set from the criteria. Unit suite green in Quality Project. (agent)
  • Every notification is exactly one console-activity entry — no collection holds notification content; the centre's only content read is console-activity. (agent)
  • Migration applies — Migrate preview database succeeded on this branch, so the two indexes were really created against the preview database, not just asserted. (agent, CI)
  • Neither model carries tenantId; policy.test.ts and immutability.test.ts both pass in Quality Project. (agent)
  • Destination per action, unlinked where there is nowhere to go — console-notification-links.test.ts, green in the suite. (agent)
  • Staff sees every tenant; a partner only their assigned ones — needs a signed-in console session as each audience. (operator)
  • A partner sees nothing for a tenant that is not theirs, by direct request to /notifications — the criterion explicitly demands a direct request, not an absent link. (operator)
  • Re-assigning a tenant moves its notifications between partners — and see the watermark finding below, which changes what the badge does here. (operator)
  • Nobody sees their own action — perform an action as one person, confirm no badge for them and a badge for the other. (operator)
  • Mark one / mark all, per person — including that one person's marking changes nothing for another. (operator)
  • auth: staff and partner sign-in + console reachable (operator)
  • payments: not touched — no payment path in the diff (agent)
  • notifications: none expected — this feature deliberately sends no email and publishes no Ably message; it is an in-app projection. The tenant-notification-email stub (9 of 9) owns delivery. Nothing to verify fired. (agent)

Findings & cleanup

Fixed on branch (this stage):

  • E11000 out of both mark actions — an upsert is not atomic against a unique index; two double-submitted markReads (or first-ever markAllReads) both attempt the insert and the loser raised a duplicate-key error out of the server action for an operation that had in fact succeeded. My original comment claimed the unique index made it "idempotent under a double submit" — the opposite. Both upserts now catch via the house isDuplicateKeyError.
  • Read rows were unbounded in practice — both "mark all read" controls hide at a zero count, so somebody who only ever marked rows individually could never reach the one operation that prunes them, and the $nin built from them grew for the life of the account. The model header claimed "bounded". markRead now compacts the moment nothing is left unread, which is observably identical to pressing the button and makes the bound structural. This closes the finding rather than deferring it to intake.
  • Six read-state loads per console page renderreadState ran once per public method, and the shell called two of them while the page called a third. Collapsed into two composite service methods (shellSummary, centrePage), each resolving read state once. The build notes said "two reads were added"; it was seven. Now two.
  • "Mark all read" gated on the wrong number — it was computed from the 25 rows on the page in front of you, so it vanished on any page whose rows happened to be read while unread sat on the next one. centrePage now returns the viewer's true unread total and the control gates on that.
  • Fail-open scope branch (proactive, from the security pass)consoleActivityScopeFilter and consoleActivityReadModel both matched !== "partner", so a third console audience added later would inherit the unbounded staff clause and the crossTenant escape hatch by default. Both now match === "sustentus" positively. Not exploitable today (the union has two members) — but my extraction made this the single chokepoint for partner authorization, which is the right place to fail closed.

Raised — needs an owner decision, not fixed here:

  • The mark-all-read watermark is global, but scope is resolved live. markAllRead stamps one allReadBefore per person with no notion of which tenants they could see at the time. So when a tenant is assigned to a partner after they last pressed the button, that tenant's existing history enters their centre already flagged read and never raises the badge — while a partner who never pressed it sees the same history as unread. The spec's criterion ("re-assigning moves the notifications into the new partner's centre") holds for the list but not for the count. Both audit passes flagged this independently. Fixing it properly means a per-scope watermark, or recording the assignment set the watermark was taken against — that is a spec change, so it goes to you rather than getting decided here. If the answer is "the current behaviour is fine", it should be written into the spec's Out of scope so the next reader does not re-find it as a bug.

  • The bell count does not refresh on client-side navigation. App Router does not re-render a shared layout when you navigate between routes under it, and the count is read in (console)/layout.tsx. So it is fresh on a full load and after either mark action (both call revalidatePath("/", "layout")), but a person clicking around the console for an hour keeps the count they arrived with. My original comment claimed it "refreshes when you navigate" — that claim is now corrected in both the layout and the bell rather than left asserting something untrue. The clean fix is to move the header into a (console)/template.tsx — templates are re-rendered per navigation, which is exactly their purpose. I did not make that change here: it restructures the shell that every console page renders inside, and I have no way to exercise it locally (block-local-checks stops the dev server and the unit tier cannot see routing). That is a change I would rather make deliberately with a preview to check it against than slip into a verify pass. Your call whether it blocks this run or becomes a tweak.

Accepted, recorded:

  • No new index on console-activity for the centre's extra predicates (action, actorClerkUserId, _id $nin). The existing createdAt and tenantId_createdAt serve the sort and the tenant bound; the rest filters over an already-bounded set, which is right at console trail volume. A decision, not an oversight.
  • The unique indexes on clerkUserId carry schemaPlugin's en/2 collation, making that equality case-insensitive. Not attacker-reachable (Clerk generates the ids; nobody picks one), and it is the same property the tenant directory already relies on for partnerId.
  • Review diff against CONVENTIONS.md reports skipped on this PR — the CI Claude review is not enabled on the repo, so it posted nothing. /code-review was run here at high in its place, which is exactly what the contract prescribes for that case.

Left to Ship (correctly out of scope here): the apps/docs page and the apps/help changelog entry, both of which the comparable predecessor (#895) shipped in its own feature PR.

Context budget: over. The stage's Inputs allow the spec, notes and diff; the three passes each read the touched source directly, and confirming the preview/base-ref facts needed ci-status.sh, the raw check-run conclusions, and the Vercel deployment states.

05_ship/output/changelog.md


title: Be told what happens in the tenant management console date: 2026-08-27T17:00:00Z personas: [admin] slug: tenant-notification-centre pr: https://github.com/sustentus/sustentus/pull/897

Be told what happens in the tenant management console

The console started writing everything down last week, but the record was somewhere you had to go and look. A partner found out that a workspace they are responsible for had been suspended by opening the console and reading the activity page. Now the console tells you instead.

A bell in the header, on every page. It carries the number of things that have happened since you last looked, and no badge at all when there is nothing new. Opening it previews the newest few, with a way to read them all or clear the lot.

A notifications page lists everything in full, newest first, in the same words the activity record uses — who did it, what they did, which workspace it was done to, and when. Each one takes you to the workspace it concerns.

Seven things are worth telling you about: a workspace being created, suspended, reactivated, deleted, recovered or permanently removed, and somebody's role in a workspace being changed. Not everything the console records is a notification — inviting a user, assigning a partner and granting console access are all still on the activity page without interrupting anybody.

You are told about the workspaces you are responsible for. The Sustentus team sees the whole estate. A partner sees the workspaces currently assigned to them and nothing else, including when the page is opened directly rather than followed from the bell. If a workspace moves between partners, its notifications move with it.

You are never notified of your own actions. Suspending a workspace yourself does not raise a badge for you — only for everybody else who can see it.

Read and unread are yours alone. Marking one read, or marking everything read, changes nothing for anybody else. Marking read does not remove anything either: the notification stays, shown as read, and the activity record behind it is untouched.

Nobody is emailed about any of this. This tells you when you are in the console; being told when you are not is coming separately.

05_ship/output/investor-update.md

The console now tells you what happened

Who it's for: Sustentus team, vendor partners What shipped: A notification centre in the console — a bell with an unread count, and a page of every lifecycle event in your estate. Why it matters: Partners can run other firms' workspaces only if they find out what happens to them. Establish Product-Market Fit with Vendor Partners, Scale the Bridge.

Dig deeper: https://github.com/sustentus/sustentus/pull/897 · https://help.sustentus.com/changelog/2026-08-27-tenant-notification-centre

05_ship/output/release.md

Ship: tenant-notification-centre

  • pr: #897 · merge: authorised — Ready to merge ticked; this commit rides the squash. Both gate anchors read [x] on the PR body: Spec approved (before Build) and Ready to merge (this stage). Neither was ticked by the agent, and the conversational Verify gate was confirmed by Jamie invoking this stage.

  • CI: GREEN, settled via .icm/scripts/ci-status.sh on the head this file rides in, after the last push — the only verdict that authorises the merge. Verify handed over GREEN on 7f99eca; everything added here (technical/applications/console, the changelog page, this file and the ship note) is documentation, so the code being merged is unchanged from that verdict — but the verdict was re-established rather than carried over. Nothing was read off a Vercel deployment event, the Vercel bot's comment, or Vercel Preview Comments.

    One thing worth stating precisely, because it otherwise looks like a gap: this PR carries the run's first apps/help change, so Vercel – help-centre builds here for the first time on this branch and is part of the verdict — the changelog page compiles or the merge does not happen. Vercel – tenant-management (the console) reports skipped on a documentation-only head, its ignore step correctly declining to rebuild; the console preview under it remains the one built from db721a7, which is the code being merged. verify.md records the same shape, and why quoting it as "green preview on HEAD" would be false.

  • technical docs: technical/applications/console — a /notifications row in the Routes table, a new Notification centre section (the projection-not-a-record decision and the three properties that follow from it, the seven notifiable actions and the total map that forces a sixteenth to be classified, the reused trail scoping and why both branches match sustentus positively, the two read-state collections and what makes the read rows bounded, the link derivation, and what the centre deliberately does not do), and a cross-reference from the trail's Who sees what to it — that scoping is now shared code rather than a description repeated on two pages.

    The two limitations Verify raised and did not fix are written into that section rather than left out: the count is stale between client-side navigations because App Router does not re-render a shared layout, and the mark-all-read watermark is global while scope is resolved live, so a tenant assigned to a partner after they last marked all read is in their list but never in their badge. Both are shipped behaviour, so the source of truth states them.

    No change to technical/packages/services: that page documents db/ at directory granularity ("Mongoose models, db services, plugins, connection cache") and this run adds two models and one service inside it — nothing the page states became untrue. The notifications/ line on the same page describes the tenant platform's per-resource Ably path, which this feature does not use; the console section says so explicitly so the two are not conflated.

  • business docs: no business docs impact. business/** describes the tenant-facing platform and its six personas; this console's audiences are Sustentus staff and vendor partners, which that section deliberately does not model. platform-overview mentions the console once, in the managed-lifecycle bullet about creating and ending tenants — nothing there became untrue. Same call as the four preceding console runs.

  • release notes: both. apps/help/app/changelog/2026-08-27-tenant-notification-centre/ is the user-facing entry, timestamped after tenant-activity-dashboard's so the index sorts the record above the notifications built on it. Its closing line answers that entry's: the predecessor said being told was coming separately, and this one says email still is.

  • sent: queued.github/workflows/ship-note.yaml fires on the merge and emails 05_ship/output/investor-update.md to the #product-update Slack channel. Not sent by hand. Its Dig deeper line carries both real URLs (PR #897 and the live changelog entry) and no placeholder, because the merge is what sends whatever the file says at that moment.

  • close-out: close-out.sh archives this run to apps/docs/archive/pipeline-runs/. The tenant-management-app epic is not finished by it — tenant-notification-email (stub 9 of 9) is still in intake, and it delivers these same events by email, so the epic stays in .icm/intake/.

Acceptance check (vs spec)

Seventeen criteria, all ticked on the PR. Where each was actually established — and the honest split is that the agent established the structural half and none of the signed-in half: the tenant-management preview sits behind Vercel deployment protection, every path 302s to vercel.com/sso-api, and there is no way in without operator credentials. verify.md records that in full rather than rounding it up, and Jamie ticked Ready to merge holding it.

  • Seven notifiable actions, the other eight nowhere, the set a total map over the action union — CONSOLE_NOTIFIABLE_ACTIONS is a Record<ConsoleActivityAction, boolean>, and notifiable.test.ts asserts the set from these criteria. Green in Quality Project.
  • Every notification is exactly one console-activity entry, in the same words; nothing writes a notification record — traced: no collection holds notification content, and the centre's only content read is console-activity, through the trail's own projection and row mapper.
  • Staff see every tenant including the tenant-less tenant.removed; a partner only their currently-assigned ones — traced to consoleActivityScopeFilter, which the centre reuses rather than copies. Operator smoke pending (verify.md).
  • A partner receives lifecycle notifications on their assigned tenants, not only the events they could have caused — traced: the notifiable map is one set, not two, and scope is the only audience-dependent clause in the read.
  • A partner sees nothing for a tenant that is not theirs, proven by a direct request to /notifications — traced: the bound is the $in inside the scoped read, and the route is deliberately not a staff route, so a direct request and a followed link take the same path. Operator smoke pending — the criterion demands the direct request, which only a signed-in session can make.
  • Re-assignment moves notifications between partners — traced: assignedTenantIdsFor resolves current assignment per request, and nothing freezes a partnerId onto a row. Operator smoke pending.
  • Nobody sees their own action, in the list, the preview or the count — traced: actorClerkUserId: { $ne: viewer } is a query predicate, so all three agree by construction. Operator smoke pending.
  • The bell is in the shell on every page, no badge at zero, and the popover previews the newest unread with Mark all read and See all — traced through (console)/layout.tsx and notification-bell.tsx. Operator smoke pending.
  • Mark one from /notifications, mark all from either surface, both per person — traced; read state is keyed on clerkUserId alone, so one person's marking cannot reach another's. Operator smoke pending.
  • Marking read never removes a notification, and changes nothing on /activity or the dashboard feed — traced: read state is a separate collection and no write touches console-activity, which immutability.test.ts continues to assert over the service's exported surface.
  • Mark all read leaves the count at zero and no individual read rows; a later notification is unread again — traced: the cursor is stamped and that person's read rows deleted in the same operation, all of them subsumed by the new watermark.
  • markRead refuses an out-of-scope, non-notifiable or self-authored entry and writes no row — traced: it re-resolves the entry through the same scoped read before writing, so that check is the boundary rather than the page that renders the button.
  • Destination per action, unlinked where there is nowhere to go — console-notification-links.test.ts, green in Quality Project. That file imports only a type, which is what lets it be tested without the server bundle.
  • Permanent removal leaves no notification carrying that tenant, and leaves the staff-visible tenant-less tenant.removed one naming it — inherited rather than implemented: the centre reads the trail, and tenant-purge already empties it. That inheritance is the point of the projection.
  • /notifications paginates newest-first on /activity's URL contract, and a page past the end says so rather than erroring — traced through the shared parseDirectoryParams / buildDirectoryQuery and the page's own past-the-end message.
  • A migration creates both collections' indexes with the collation named, and drops them symmetrically — Migrate preview database succeeded on this branch, so the indexes were really created against a real database rather than asserted.
  • Neither model carries tenantId; policy.test.ts and immutability.test.ts both pass, and no update or delete path was added to the activity service — green in Quality Project.

Carried out of this run, deliberately:

  • The five defects Verify found in Build's code were fixed on the branch and are in this merge (db721a7): the duplicate-key race out of both mark actions, the read rows that were unbounded in practice, six read-state loads collapsed to two, "mark all read" gated on the page instead of the viewer's true unread total, and the fail-open scope branch tightened to match sustentus positively.
  • The two findings Verify raised are not fixed here and are not silently dropped: they are in the shipped docs (above) as behaviour, and they belong in spec.md's Out of scope or a tweak lane — Jamie's call, taken with the Ready-to-merge tick.
  • The operator DoD lines above stay with the operator. Ship does not re-run Verify's reviews and did not mark them done.

Context budget: within budget. Inputs were the stage contract, the preamble, run.md, verify.md, spec.md, _shared/{github,ci,knowledge-map}.md, the docs-sync and changelog-entry skills, the one docs page changed plus two greps to establish the no-impact calls, the current OKR and initiative pages for the note's tie-in, and one archived run's ship outputs for house shape.