tenant-notification-centrerun.md02_define/output/spec.mdThe 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].
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.
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.
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.
Scope is the trail's scope, resolved live at request time — no recipient list, no denormalised
partnerId on a row:
tenant.removed entries
included.{ 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.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 is per person, so it cannot live on the shared trail row. Two small collections in
packages/services, neither carrying a tenantId:
console-notification-cursor — one document per person,
{ clerkUserId (unique), allReadBefore: Date }. "Mark all read" sets it to now.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.
createdAt > allReadBefore and _id not among the viewer's read rows.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.
/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.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.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./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.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.tenant.removed ones
included; a partner sees only those whose tenant is currently assigned to them./notifications rather than by the absence of a link./notifications and mark all read from either
surface; both are per person, so one person's marking changes nothing for anybody else./activity or on the dashboard feed.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.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.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.down.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.tenant-notification-email (stub 9) delivers the same events
to tenant admins, the assigned partner and staff [Q-14].trues in the notifiable map, not a rebuild.tenant-plan-visibility on 2026-08-26 and recorded again by tenant-activity-dashboard. Same
shape when it arrives.false in the notifiable map, which is where to change it.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.mdfeat: tenant-notification-centre — the console notification centreea653a4 — every blocking check first time, Quality Project
(format, lint, typecheck, tests) and the tenant-management preview includedpackages/services — the read state, and only the read statedb/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.ts — extracted, 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 surfacelib/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.ts — assignedTenantIdsFor 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.CONSOLE_NOTIFIABLE_ACTIONS, asserted by notifiable.test.ts.console-activity entry — there is no
other collection holding content; nothing writes a notification record.tenant.removed; a
partner only their currently-assigned tenants — consoleActivityScopeFilter.actorClerkUserId: { $ne: viewer } applied
as a query predicate, so list, preview and count cannot disagree./notifications, mark all from either surface, per person./activity and the dashboard feed are untouched.markRead refuses out-of-scope, non-notifiable and own entries, writing
nothing — resolved through the same filter before any write.console-notification-links.test.ts.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.down.tenantId, so neither needs a demo-reset policy
classification; no update or delete path was added to the activity service.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.cache on
assignedTenantIdsFor is what keeps a partner's page from tripling its
assignment lookups.$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.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.turbo.json → globalEnv 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.mddb721a7 (the fix push) — settled via ci-status.sh; re-settled on the head
carrying this file, which adds no codedb721a7 · web · marketing · demo,
docs, help-centre, storybook, agentic-interface skipped for this diff (correctly — untouched)complex) — 5 findings, 4 fixed on branch, 1 raisedgit 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.
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.
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)console-activity entry — no collection holds notification
content; the centre's only content read is console-activity. (agent)Migrate preview database succeeded on this branch, so the two
indexes were really created against the preview database, not just asserted. (agent, CI)tenantId; policy.test.ts and immutability.test.ts both pass in
Quality Project. (agent)console-notification-links.test.ts,
green in the suite. (agent)/notifications
— the criterion explicitly demands a direct request, not an absent link. (operator)tenant-notification-email stub (9 of 9) owns
delivery. Nothing to verify fired. (agent)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.$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.readState 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.centrePage now returns the viewer's true unread total and the control gates on that.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:
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.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.mdThe 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.mdWho 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.mdpr: #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/.
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.
CONSOLE_NOTIFIABLE_ACTIONS is a Record<ConsoleActivityAction, boolean>, and
notifiable.test.ts asserts the set from these criteria. Green in Quality Project.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.tenant.removed; a partner only their
currently-assigned ones — traced to consoleActivityScopeFilter, which the centre reuses
rather than copies. Operator smoke pending (verify.md)./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.assignedTenantIdsFor resolves
current assignment per request, and nothing freezes a partnerId onto a row. Operator smoke
pending.actorClerkUserId: { $ne: viewer } is a query predicate, so all three agree by construction.
Operator smoke pending.(console)/layout.tsx and
notification-bell.tsx. Operator smoke pending./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./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.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.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.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.Migrate preview database succeeded on this branch, so the indexes were
really created against a real database rather than asserted.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:
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.spec.md's Out of scope or a tweak lane
— Jamie's call, taken with the Ready-to-merge tick.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.