Skip to Content

← All archived runs

Run: tenant-activity-dashboard

run.md

Run: tenant-activity-dashboard

  • branch: claude/tenant-activity-dashboard-pipeline-khqcji
  • pr: #895

02_define/output/spec.md

Spec: Activity trail and the console landing dashboard

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

Problem

Six console features have shipped and every one of them writes: tenants are created, assigned to partners, suspended, deleted, recovered, permanently removed, and their users invited, removed and re-roled. None of it is recorded. Nobody can answer who suspended a tenant, when a partner was re-assigned, or which member of staff removed a workspace — and the console's home is still the placeholder tenant-app-access shipped, telling the operator that the dashboard "arrives in later releases" while the directory does all the work.

That is the last gap in "Establish product-market fit with vendor partners" (Scale the bridge, 2026-Q2, Objective 1) for this batch's foundation half: the estate is administrable but not accountable, and partners cannot be trusted with provisioning powers over other people's tenants while nothing records what they do with them. The trail is also the record that stands in place of notifications [Q-10, Q-12], and stubs 8 and 9 — the notification centre and its email — read the events this run establishes [Q-13], so nothing downstream can start until it exists.

Proposed change

An append-only console activity trail, written by every administrative action in the app, read on a dedicated trail page and on a new landing dashboard that replaces the placeholder home.

What lands in the trail

Every exported server action in apps/console records one entry after its write succeeds — the fifteen that exist today, and no others, because there are no others:

Action file Recorded as
tenants/new/actions.ts tenant.created
tenants/[tenantId]/actions.ts tenant.partner_assigned, tenant.partner_cleared, tenant.user_invited, tenant.invitation_revoked, tenant.user_removed, tenant.user_role_changed, tenant.suspended, tenant.reactivated, tenant.deleted
tenants/deleted/actions.ts tenant.recovered, tenant.removed
access/actions.ts console.access_granted, console.invitation_revoked, console.access_revoked

An entry carries the actor (Clerk user id, and their name and email snapshotted at write time), the audience they acted as (sustentus or partner), the action, the subject tenant where there is one, the tenant's name snapshotted the same way, a short detail line (the invited address, the role moved to, the partner assigned), and the time. Names are snapshotted rather than joined so a trail page renders from one query instead of one Clerk call per row, and so an entry stays readable after the person or tenant it names has gone.

The three access/actions.ts entries carry no subject tenant — granting a person console access is an act against the console, not against a tenant. They are therefore visible to Sustentus staff only: a partner's trail read is bounded by their assigned tenant ids, which a tenant-less entry cannot match. That satisfies Q-10's "every action in the app is recorded" without showing a partner who Sustentus invited and revoked.

Recording is awaited, and a failure surfaces. The primary write has already committed by then, so a trail failure means "the action was done and not recorded" and the operator is told so — the entry is never swallowed to keep the screen tidy.

Immutability, and the one thing that removes entries

Nothing in the app edits or deletes an entry. The service module exports a writer and readers and no mutator; no console route, action or surface offers one; the model carries no softDeletePlugin (an audit is never edited or removed — the view-as-audit precedent). A unit test asserts the module's exported surface holds no update or delete.

The single exception is permanent removal, settled with Jamie on 2026-08-27: a tenant's trail is purged with the tenant. removeTenantPermanently already empties every tenant-scoped collection, and the trail is one, so after a permanent removal nothing tenant-scoped about that tenant remains — its creation, its suspensions, its user changes, all gone with it. One entry survives: the tenant.removed entry itself is written tenant-less, snapshotting the removed tenant's name, so the console still records that Sustentus removed a workspace and who did it. Being tenant-less, it is staff-only, which is the right audience — the tenant it names no longer exists for anyone.

How the record is held

A new console-activity model in packages/services, in the view-as-audit shape: a plain schema, schemaPlugin, no soft delete, indexes created by a migration rather than at registration.

Two details are deliberate and neither is optional:

  1. tenantId is declared by the schema itself, as optional, before tenantPlugin is applied. The plugin only adds the field if (!schema.path("tenantId")), so declaring it first keeps it optional — which the tenant-less access entries require — while the plugin's query guard still applies. Applying the plugin without declaring the field first would make tenantId required and those entries impossible; skipping the plugin altogether would make this the only tenantId model in the codebase without the guard.
  2. Staff reads declare crossTenant: true. The trail's whole purpose is a cross-tenant read, and the plugin's documented escape hatch is how an intentional one declares itself. A partner's read needs no hatch: its filter is { tenantId: { $in: <their assigned tenant ids> } }, which the plugin sees and passes through.

The model is classified KEPT_MODEL_FILES in the demo-reset policy — required by policy.test.ts, which fails on any unclassified tenantId-carrying model. Kept, not purged, because a demo reset is housekeeping on the demo tenant's seeded world, not a deliberate destruction of an audit; the console trail is never rendered in apps/web and is no part of the storyline the reset converges on, so sparing it costs nothing. This does not soften the decision above: permanent removal purges TENANT_SCOPED_MODEL_FILES — both lists — so the trail is purged with the tenant whichever list holds it. tenant-purge's MODEL_GETTERS gains the matching getter, which the Record key type makes a compile error to forget.

Indexes, created by a migration alongside the model (the view-as-audit-indexes precedent): { createdAt: -1 } for the staff feed, and { tenantId: 1, createdAt: -1 } for a partner's $in read and for any future per-tenant read.

Where it is read

The trail page — a new /activity route in the console shell, listing entries newest first with the directory's own URL contract (page, 1-based; junk reads as page 1) so the two list surfaces read the same way round. Staff see every entry; a partner sees only entries whose tenant is currently theirs.

A partner's scope is resolved from current assignment, exactly as the directory resolves it [Q-3] — their assigned tenant ids are read at request time and used as the $in bound. So a tenant re-assigned away takes its history out of the old partner's view and into the new partner's, which is the same rule the directory already applies to the tenant itself. Denormalising partnerId onto the entry would freeze it at write time and disagree with the directory the moment an assignment moved.

The dashboard/ stops being a placeholder. It shows, scoped to the viewer's audience:

  • Total tenants — every live tenant in scope: active + suspended, exactly what the directory lists.
  • Active tenants — those in the active lifecycle state, so the gap between the two numbers is the suspended ones. Deleted tenants are in neither count; they are off the directory and on the staff-only deleted list.
  • Recent activity — the newest entries from the same scoped read the trail page uses, linking to /activity for the rest.

Counts are countDocuments over the existing console-scope filter builders, extended with a lifecycle selector for the active count. The soft-delete clause stays carried explicitly in the filter rather than left to the plugin, for the reason console-scope.ts already records: softDeletePlugin hooks pre(/^find/), which countDocuments does not match, so a count that leans on the plugin counts deleted tenants.

No platform-health or uptime figure — Q-11 keeps it out until it has a real definition. Nobody is notified of anything: the trail is the record [Q-10, Q-12], and the telling is stubs 8 and 9 [Q-13].

Acceptance criteria

  • Each of the fifteen server actions in apps/console writes exactly one trail entry when it succeeds, carrying the actor's Clerk user id, their snapshotted name and email, the audience they acted as, the action, and the time.
  • The twelve tenant-scoped actions record the subject tenant and its snapshotted name; the three access/actions.ts actions record no tenant.
  • An action that fails writes no trail entry, and a trail write that fails is reported to the operator rather than swallowed.
  • A Sustentus person on /activity sees entries across all tenants, the tenant-less console-access entries included, newest first and paginated.
  • A partner on /activity sees only entries whose tenant is currently assigned to them: no entry for an unassigned tenant, and no tenant-less entry, proven by a request made directly to the route rather than by the absence of a link.
  • Re-assigning a tenant moves its existing entries out of the old partner's trail and into the new partner's, because the scope is resolved from current assignment.
  • The console home shows total and active tenant counts and a recent-activity feed, all scoped to the viewer's audience; the placeholder card is gone and /activity is reachable from the shell.
  • Total counts active + suspended tenants and Active counts active ones, both matching what the directory shows the same viewer; a deleted tenant is in neither, and recovering it returns it to both.
  • No surface, action or exported service method updates or deletes a trail entry — asserted by a test over the service module's exported surface.
  • Permanently removing a tenant leaves no trail entry carrying that tenant, and leaves exactly one tenant-less tenant.removed entry naming it, visible to staff.
  • The new model is classified in the demo-reset policy and has its tenant-purge getter, so policy.test.ts passes and permanent removal reaches the collection.
  • A migration creates both indexes and drops them symmetrically on down.
  • A staff trail read declares crossTenant: true; a partner read does not need it, and neither throws the tenant plugin's unscoped-query error.

Out of scope

  • Recording a plan movement. There is no plan-change action to record: moving a tenant between plans was dropped from tenant-plan-visibility with Jamie on 2026-08-26 (Clerk's backend Billing API has no plan-change write). The stub names plan movement among the actions to record; the action does not exist, and the trail records what the app does. When plan movement arrives it is one more action type, not a rebuild.
  • Notifying anyone of anything recorded here — tenant-notification-centre (8) and tenant-notification-email (9) [Q-12, Q-13].
  • A before-and-after change record showing old and new values — the trail is the audit [Q-13].
  • A platform-health or uptime figure on the dashboard [Q-11].
  • Recording actions taken outside the console — partner assignment can only change here (settled 2026-08-26), so nothing the trail owes Sustentus originates elsewhere.
  • Filtering, searching or exporting the trail — newest-first pagination only this run.
  • Retention, TTL or archival of trail entries. The collection grows unbounded by design; at the console's action volume that is years away, and an audit that expires on a timer is a different decision from an audit that is purged with its tenant.
  • A per-tenant activity panel on the tenant detail view. The { tenantId: 1, createdAt: -1 } index makes it a later half-hour; the stub asks for a trail and a dashboard.
  • Backfilling entries for administrative actions taken before this run — there is no record to backfill from.

Open questions

  • none. The three that affected what gets built — whether tenant-less console-access actions belong in the trail, whether a tenant's trail survives permanent removal, and what the two dashboard counts count — were put to Jamie on 2026-08-27 and answered before this spec was written. Their answers are the Proposed change above. On the second, Jamie chose purge-with-the-tenant over the recommended survives-removal; the consequence is stated plainly under "Immutability" and the tenant-less tenant.removed entry is the one thing kept back from it.

Context budget: over. Define's Inputs allow "a few targeted greps"; establishing that the trail cannot use tenantPlugin's default shape, that the demo-reset drift test would force a classification, and that permanent removal purges both policy lists needed fuller reads of packages/services/src/db/plugins/tenant.ts, db/services/demo-reset/policy.{ts,test.ts}, db/services/tenant-purge/index.ts, db/models/{view-as-audit,activity}.ts, db/services/tenant/console-scope.ts and the four apps/console action files. That is how the tenant-less-entry constraint and the missing plan-change action were both found, neither of which reading the stub would have surfaced.

03_build/output/notes.md

Build notes: tenant-activity-dashboard

  • commits: feat: tenant-activity-dashboard — console activity trail and landing dashboard
  • ci: on 720cca1 — every check concerning this diff is green: Quality Project (format, lint, typecheck, test), Migrate preview database, Audit database, Project run labels, all three advisories, and the Vercel – tenant-management preview (this feature's app) plus web and marketing. The overall verdict reads RED on one check alone — Vercel – agentic-interface — which is not this PR's; see Notes for Verify.

What changed

packages/services — the record

  • db/models/console-activity.ts (new) — the trail. Fifteen actions and two audiences as const tuples with derived types; actor name and email snapshotted at write time rather than joined (console people are org-less Clerk users with no user row to populate from, and an entry must stay readable after their access is revoked). Two deliberate departures from the mongoose-model idiom, both commented in the file:
    • tenantId is declared by the schema, optional, before tenantPlugin is applied. The plugin only adds the field if (!schema.path("tenantId")), so declaring it first keeps it optional — which the tenant-less console-access entries require — while the plugin's unscoped-query guard still applies. Applying the plugin without this would make tenantId required and those entries impossible; omitting the plugin would make this the only tenantId-carrying model in the codebase without the guard.
    • No softDeletePlugin, following view-as-audit: an audit is never edited or removed.
  • db/services/console-activity/ (new) — record plus the two scoped reads. Staff reads go through a wrapper that declares crossTenant: true on both halves of the paginated read; findPaginated builds its own queries internally, so wrapping the model is what let the house pagination helper be reused instead of hand-rolling skip/limit (CONVENTIONS.md → List surfaces). A partner's read carries tenantId itself and needs no declaration.
  • db/services/demo-reset/policy.ts — classified console-activity as KEPT, required by policy.test.ts. Kept rather than purged because a demo reset is housekeeping on the demo tenant's seeded world, not deliberate destruction of an audit. This does not soften the purge-with-the-tenant decision: permanent removal empties TENANT_SCOPED_MODEL_FILES, which is both lists.
  • db/services/tenant-purge/index.ts — the matching model getter, which the Record key type makes a compile error to forget.
  • db/migrations/1788330000000-console-activity-indexes.ts (new) — createdAt and tenantId_createdAt, symmetric down, cross-referenced from the schema. createdAt deliberately does not lead with tenantId: the staff read has no tenant clause to lead with, and a single-field createdAt is not a prefix of the compound one, so it is not redundant.
  • db/services/tenant/console-scope.tsbuildConsoleTenantCountFilter. $and, not two sibling $or keys, which would have silently dropped the deleted-tenant exclusion and counted deleted tenants as active.
  • db/services/tenant/index.tscountForConsole (both dashboard numbers) and listAssignedTenantIds (what bounds a partner's trail read, resolved live per request).

apps/console — the record being written and read

  • lib/console-activity.ts (new) — recordTenantActivity / recordConsoleActivity, the scoped reads, the action labels (a Record over the action union, so a new action cannot ship unlabelled), and the trail's to-the-minute timestamp format.
  • All fifteen server actions across the four action files now record exactly one entry after their own write succeeds — so a refused or invalid action leaves no trace, and a completed one always leaves exactly one. resolveTarget now also returns the tenant name, carried out of the read it already performed; the staff-only partner and lifecycle actions read it through tenantNameFor, before the write in the lifecycle case because delete makes it unreadable afterwards.
  • app/(console)/activity/page.tsx (new) — the full trail, paginated on the console's existing URL contract via buildDirectoryQuery. No proxy.ts entry: the proxy denies by default and covers every route, and this is deliberately a viewer route, not a staff one.
  • app/(console)/page.tsx — the placeholder is gone; the dashboard shows the two counts and the recent-activity feed.
  • components/{activity-feed,stat-card}.tsx (new) — one feed component for both surfaces, so the dashboard and the trail page can never describe the same entry differently.

Acceptance criteria status

  • Each of the fifteen server actions writes exactly one entry — recorded after the write, before the redirect() that ends the request. grantAccess's two success branches record the same action, only one of which runs.
  • The twelve tenant-scoped actions record the subject tenant and its snapshotted name; the three access/actions.ts actions record no tenant.
  • A failed action writes no entry (recording sits after every guard and refusal path), and a failed trail write is awaited and surfaces rather than being swallowed.
  • Staff on /activity see entries across all tenants, tenant-less ones included, newest first, paginated.
  • A partner sees only entries for tenants currently assigned to them — the bound is the $in clause in the scoped read, not the absence of a link, so a direct request answers the same.
  • Re-assignment moves history, because listAssignedTenantIds resolves current assignment on every request rather than the entry carrying a frozen partnerId.
  • The home shows both counts and the feed, scoped per audience; the placeholder card is gone and /activity is in the shell nav for both audiences.
  • Total counts active + suspended and Active counts active, both from the directory's own scope clause; deleted tenants are in neither, and recovery returns a tenant to both.
  • No surface, action or exported service method updates or deletes an entry — asserted by console-activity/immutability.test.ts over the exported surface.
  • Permanent removal leaves no entry carrying that tenant, and exactly one tenant-less tenant.removed entry naming it. Recording it against the tenant would have written a row into a collection purged moments earlier.
  • Classified in the demo-reset policy with its tenant-purge getter.
  • A migration creates both indexes and drops them symmetrically.
  • The staff read declares crossTenant: true; the partner read does not need it.

Notes for Verify

  • Vercel – agentic-interface is RED and is not this PR's. Its Vercel project builds apps/agent, which does not exist in this repository — agentic-app-scaffold is still a pending intake stub. It was already red on origin/main at this PR's base commit 818c73b, before any code here existed, and no in-repo fix exists (the project's Ignore Build Step lives in Vercel's settings, and there is no apps/agent directory to hold a vercel.json). Established and reported on the PR before Build started; the user confirmed it is being handled in a separate session. Every other check must be green on its own merits.
  • Worth a close read: the partner scoping of /activity. The bound is applied in ConsoleActivityService.listForConsole, so the test that matters is a direct request to /activity as a partner, not the presence of the nav link — server pages render for whoever reaches them.
  • The tenant-less tenant.removed entry is the one place this run kept something back from the purge-with-the-tenant decision (settled with Jamie, 2026-08-27). It is called out in spec.md under "Immutability" and commented at the call site; if it reads as re-litigating the decision rather than honouring it, it is a two-line deletion.
  • Not exercised locally: no test, lint, typecheck or build was run — the factory owns those. The DB is unreachable from the agent sandbox, so the migration is written and reviewed but not applied; CI's Migrate preview database step is the first real run of it.

Context budget: within budget. Inputs were the spec, CONVENTIONS.md, apps/console/AGENTS.md, packages/services/AGENTS.md, the mongoose-model and db-migration skills, and the files named in touches: — plus demo-reset/policy.test.ts and plugins/tenant.ts, which the spec's own constraints made unavoidable reading.

04_verify/output/verify.md

Verify: tenant-activity-dashboard

  • ci: RED on 2390b84 — settled via ci-status.sh after the last push. One failing check, Vercel – agentic-interface, which is not this PR's (below). Every other blocking check is green on 2390b84: Quality Project (format · lint · typecheck · test), Migrate preview database, Audit database, Migrate production database (skipped on PRs = pass), Project run labels, Review diff against CONVENTIONS.md (skipped = the CI review is off, which is why /code-review was run here), and the tenant-management, web and marketing previews. All three advisories pass.
  • previews smoked: none by the agent — the preview is behind Vercel deployment protection. tenant-management (https://tenant-management-git-claude-tenant-activity-d-528e49-sustentus.vercel.app) built for this head and every path 302s to vercel.com/sso-api, so the agent has no way in. demo, docs, help-centre, storybook skipped for this diff (no preview — not a pass).
  • production-readiness: run — one blocking finding, fixed. The model never set autoIndex: false despite the model and its migration both asserting it, so mongoose would have rebuilt the two declared indexes with schemaPlugin's collation and collided with the migration on every boot; plus a redundant tenantId_1 prefix index. Both fixed (2390b84). Three non-blockers taken to intake rather than fixed here.
  • code-review: high (spec complexity complex) — three findings, all actioned: the collation was left to the collection default rather than being "uncollated" as claimed; tenantId_1 needed dropping on databases that had already booted; two comment claims were factually wrong.
  • security-review: run — cleared, with one scope finding (below). Run inline rather than fanned out to sub-agents, per the operator's standing instruction against unrequested agents.
  • playwright: TODO — manual DoD smoke performed instead

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

Agent-run. The agent has no preview credentials, and this preview additionally sits behind Vercel SSO, so no acceptance criterion could be demonstrated by the agent. What the agent did establish:

  • The console preview builds and serves for this head — Vercel – tenant-management deployed on 2390b84; the edge answers on /, /activity, /tenants (agent)
  • The migration applies cleanly to a real database — Migrate preview database green on 2390b84, including the new 1788340000000 fix-forward (agent)
  • The unit suite pins what it can — Quality Project green, including the immutability test over the service's exported surface and the eight new count-filter tests written from the criteria (agent)
  • Every criterion traced through the diff to its code path (agent)

Operator-demonstrated — PENDING. The gate cannot pass until these are filled in.

  • Each of the fifteen actions writes exactly one entry with actor, action, tenant and time (operator)
  • Staff see the trail across all tenants on /activity, tenant-less console-access entries included, newest first and paginated (operator)
  • A partner sees only entries for tenants currently assigned to them — tested by requesting /activity directly as a partner, not by the absence of a nav link (operator)
  • Re-assigning a tenant moves its history between partners (operator)
  • The dashboard's Total and Active counts match what the same viewer's directory lists; a deleted tenant is in neither; recovery returns it to both (operator)
  • Permanent removal leaves no entry carrying that tenant, and exactly one tenant-less tenant.removed entry naming it, visible to staff (operator)
  • auth: a Sustentus staff member and a partner both sign in and reach the console (operator)
  • payments: not touched by this diff — nothing to test
  • notifications: none expected — this run builds the record, not the telling [Q-12]; tenant-notification-centre (8) is where they start

Findings & cleanup

  • autoIndex / collation collisionfixed on branch (2390b84). Blocking. The model asserted autoIndex was off and it was not, so every cold start would have fought the migration for the two index names. The repo has hit this exact failure twice before and documented both. The model now sets autoIndex: false.
  • Redundant tenantId_1fixed on branch (2390b84). index: true on the field built a strict prefix of tenantId_createdAt; removed from the schema, and dropped from already-booted databases by the follow-up migration.
  • Index collation left to a racefixed on branch (2390b84). An index built with no collation inherits the collection's default, and autoCreate (a separate switch that stays on) stamps the schema collation onto the collection — so whichever of the migration or the app's first compile ran first decided it, and preview and production could disagree. 1788340000000-console-activity-index-collation.ts names the collation explicitly. Fix-forward, not an edit: 1788330000000 had already been applied to preview, and packages/services/AGENTS.md is unconditional — ts-migrate-mongoose tracks by name, so an edit would never reach a database that had already run it. Same shape as 1787970000000-tenant-directory-indexes-collation.ts.
  • Audit timestamps had no timezonefixed on branch (2390b84). Minute precision on an audit rendered in the server's zone with no zone shown is a misleading record. Now pinned to UTC and labelled.
  • Partner-to-partner identity disclosureraised, and accepted by Jamie, 2026-08-27: ship as built. No code change; recorded here so the consequence is a decision on the record rather than an oversight found later. A partner's trail includes entries from before they were assigned, because scope follows current assignment [Q-3]. On a re-assigned tenant the incoming partner therefore sees tenant.partner_assigned — <previous-partner-email> in the detail line, plus Sustentus staff names and emails as actors. Entries moving is explicitly intended and approved; that the incoming partner sees the outgoing one's address is a consequence nobody decided, and partners are separate firms. The two alternatives — redacting detail/actor for entries predating the assignment, or bounding a partner's trail to their assignment window — both change what Q-3 settled and would need Define. Jamie chose neither: the trail stays scoped exactly as Q-3 describes.
  • view-as-audit carries the same latent autoIndex bugnot fixed, out of scope. Its migration claims autoIndex is off; the model never sets it. Same collision, pre-existing, untouched by this run. Intake material.
  • No retention bound on consoleactivitiesaccepted for this run. The spec puts retention and TTL under Out of scope explicitly. It is the one collection here with no upper bound; notification-delivery-log got a TTL for the same reason. Intake.
  • listAssignedTenantIds is unboundedaccepted. A partner with very many tenants makes a large $in on every dashboard render. Fine at today's scale; intake.
  • Docs and changelog not donecorrect, not a finding. Both are Ship-stage work (docs-sync, changelog-entry); the PR is at stage:verify.

Blocking Ship

.icm/stages/05_ship/CONTEXT.md gates the squash-merge on a settled GREEN, and ci-status.sh reports RED while Vercel – agentic-interface fails. That project builds apps/agent, which does not exist in this repository — agentic-app-scaffold is still a pending intake stub — and it was already red on this PR's base commit 818c73b, before any code here existed. It is being handled in a separate session. Ship will refuse until that is resolved, either by the other session landing the app or by the project being disabled / given an Ignore Build Step in Vercel. Nothing in this branch can change it.

Context budget: over. The three review passes (production-readiness, code-review, security-review) each read beyond this stage's Inputs table by design — that is what they are for, and it is how the autoIndex defect was found.

05_ship/output/changelog.md


title: See who did what in the tenant management console date: 2026-08-27T15:00:00Z personas: [admin] slug: tenant-activity-dashboard pr: https://github.com/sustentus/sustentus/pull/895

See who did what in the tenant management console

Everything the console can do — creating a tenant, assigning it to a partner, inviting and removing its people, changing their roles, suspending, deleting, recovering, removing for good, and granting or revoking console access itself — is now written down as it happens. Until now none of it was: the estate could be administered, but nobody could answer who suspended a workspace, when a partner was re-assigned, or who removed an account last month.

A new Activity page lists what has happened, newest first. Each line says who did it, what they did, which tenant it was done to, and when — in UTC, so a record read in one country means the same thing as one read in another. The name shown is the name the person had when they acted, which is what keeps the record readable long after someone's access has been taken away.

You see the tenants you are responsible for. The Sustentus team sees everything across the estate. A partner sees the tenants currently assigned to them and nothing else — including when the page is opened directly rather than followed from a link. If a tenant moves between partners, its history moves with it, the same way the tenant itself does.

The home page is now a dashboard rather than a placeholder. It shows how many tenants you have in total, how many of those are active — the difference between the two is what is suspended — and the most recent activity, with a link through to the rest. Deleted tenants count towards neither number, and recovering one puts it back in both.

Nothing here can be edited or deleted. There is no control to change an entry and no way to remove one, because a record you can quietly correct is not a record. The single exception is permanently removing a tenant, which erases everything held about it, the trail included — with one line kept behind, naming the workspace that was removed and who removed it.

Nobody is emailed or notified about any of this yet. This release is the record; being told about it is coming separately.

05_ship/output/investor-update.md

Every console action is now on the record

Who it's for: Sustentus team, vendor partners What shipped: An append-only trail of every console action, and a home dashboard of tenant counts and recent activity. Why it matters: Partners can be trusted with other firms' workspaces once their actions are recorded. Establish Product-Market Fit with Vendor Partners, Scale the Bridge.

Dig deeper: https://github.com/sustentus/sustentus/pull/895 · https://help.sustentus.com/changelog/2026-08-27-tenant-activity-dashboard

05_ship/output/release.md

Ship: tenant-activity-dashboard

  • pr: #895 · merge: authorised — Ready to merge ticked; this commit rides the squash. Both gates are [x] on the PR body: Spec approved (before Build) and Ready to merge (this stage). Neither was ticked by the agent.

  • CI: what failed, and how it was fixed here. Verify handed over RED on 2390b84 for one check — Vercel – agentic-interface — whose Vercel root directory is apps/agent, which did not exist on this branch's base (818c73b). It was not waived: apps/agent landed on main in #894 while this run sat at its gate, so main was merged into the branch here to bring it in, giving that project something real to build. Every other blocking check — Quality Project (format · lint · typecheck · test), Migrate preview database, Migrate production database, Audit database, Project run labels, Review diff against CONVENTIONS.md, and the repository's own Vercel previews — was green throughout Verify.

    Bringing main in then surfaced a second, real failure, and this one was ours: Migrate preview database went red with IndexKeySpecsConflict (code 86) on 1788330000000-console-activity-indexes. The log showed both console-activity migrations as State is down — recorded as applied in no database — so they replayed from scratch against a consoleactivities whose createdAt index already carried the schema's { locale: "en", strength: 2 } collation. createIndex is idempotent only against an identical spec, and collation is part of the spec, so the uncollated request conflicted instead of no-opping and failed the whole run. up now drops each index by name before creating it, which makes it re-runnable whatever collation an index of that name carries.

    That also corrects a premise recorded in verify.md, which said 1788330000000 "has already been applied to the preview database" and therefore fell under packages/services/AGENTS.md's never-edit-a-migration-that-has-run rule. The preview log proves otherwise: it is applied nowhere, and production has never seen it, because db-migrate.yaml only applies on merge to main. The rule protects databases that would never receive the edit; there are none, so the file was fixed in place rather than papered over with a third migration. Both migration headers now say so.

    The merge rests on a settled GREEN from .icm/scripts/ci-status.sh, established on the head this file rides in, after the last push; nothing else authorises it.

  • technical docs: technical/applications/console — the Routes table (/ is a dashboard now, not a placeholder; /activity added), a cross-reference on the permanent-removal bullet, and two new sections: Activity trail (what is recorded and when, who sees what, immutability and the one tenant-less survivor of a purge, and why the two indexes carry an explicit collation) and Home dashboard (the two counts, and why the soft-delete clause is carried explicitly rather than left to the plugin). No change to technical/packages/services: this run adds a model and a service inside db/, which is the granularity that page already documents, so nothing it states became untrue.

  • business docs: no business docs impact. business/** describes the tenant-facing platform and its six personas; the console's audiences are Sustentus staff and vendor partners, which that section deliberately does not model — the same call the three preceding console runs made.

  • release notes: both.

  • 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.

  • close-out: close-out.sh archives this run to apps/docs/archive/pipeline-runs/. The tenant-management-app epic is not finished by this run — stubs 8 and 9 (tenant-notification-centre, tenant-notification-email) are still in flight, and they read the events this run establishes, so the epic stays in .icm/intake/.

Acceptance check (vs spec)

All thirteen criteria are ticked on the PR, traced through the diff to their code paths in Build and re-checked in Verify. What follows says where each was actually established — and the honest line is that the agent demonstrated none of them on a running preview: tenant-management's preview sits behind Vercel deployment protection and every path 302s to vercel.com/sso-api, so there is no way in without operator credentials. verify.md records that in full rather than rounding it up.

  • Fifteen actions each write exactly one entry with actor, audience, action and time — traced through all four action files; recording sits after every guard and refusal path.
  • Twelve tenant-scoped actions carry the tenant and its snapshotted name; the three access/actions.ts actions carry none — traced.
  • A failed action writes no entry; a failed trail write surfaces rather than being swallowed — traced (the record call is awaited and uncaught by design).
  • Staff see every entry on /activity, tenant-less ones included, newest first, paginated — traced; crossTenant: true is declared on both halves of the paginated read.
  • A partner sees only currently-assigned tenants, on a direct request — traced: the bound is the $in in listForConsole, not the nav link. Operator smoke pending (verify.md).
  • Re-assignment moves history — traced: listAssignedTenantIds resolves current assignment per request rather than freezing a partnerId onto the entry.
  • The home shows both counts and the feed, scoped per audience; the placeholder is gone and /activity is in the shell nav for both audiences — traced.
  • Total counts active + suspended, Active counts active, deleted in neither, recovery returns to both — pinned by eight new unit tests in console-scope.test.ts, written from these criteria and green in Quality Project.
  • Nothing updates or deletes an entry — asserted by console-activity/immutability.test.ts over the service's exported surface; green in Quality Project.
  • Permanent removal leaves no entry carrying that tenant and exactly one tenant-less tenant.removed naming it — traced through tenant-purge's MODEL_GETTERS and the write-order at the call site.
  • Classified in the demo-reset policy with its tenant-purge getter — enforced by policy.test.ts, green in Quality Project.
  • A migration creates both indexes and drops them symmetrically — Migrate preview database applied 1788330000000 and the 1788340000000 collation fix-forward against a real database.
  • Staff reads declare crossTenant: true, partner reads need no hatch, neither throws the plugin's unscoped-query error — traced.

Left with the operator, and not closed by this stage: the signed-in DoD smoke listed under "Operator-demonstrated — PENDING" in verify.md. Jamie holds the Verify gate and the Ready-to-merge box, and ticked both; this ships on that authority, with the outstanding demonstrations recorded there rather than quietly marked done here.

Context budget: within budget. Inputs were the stage contract, run.md, verify.md, spec.md, _shared/{github,ci,knowledge-map}.md, the docs-sync and changelog-entry skills, the one docs page changed, and one archived run's ship outputs for the note's house shape.