tenant-activity-dashboardrun.md02_define/output/spec.mdSix 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.
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.
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.
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.
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:
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.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.
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:
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./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].
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.access/actions.ts actions record no tenant./activity sees entries across all tenants, the tenant-less
console-access entries included, newest first and paginated./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./activity is reachable from the shell.tenant.removed entry naming it, visible to staff.tenant-purge getter, so
policy.test.ts passes and permanent removal reaches the collection.down.crossTenant: true; a partner read does not need it, and neither
throws the tenant plugin's unscoped-query error.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.tenant-notification-centre (8) and
tenant-notification-email (9) [Q-12, Q-13].{ tenantId: 1, createdAt: -1 } index
makes it a later half-hour; the stub asks for a trail and a dashboard.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.mdfeat: tenant-activity-dashboard — console activity trail and landing dashboard720cca1 — 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.packages/services — the recorddb/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.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.ts — buildConsoleTenantCountFilter. $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.ts — countForConsole (both dashboard numbers) and
listAssignedTenantIds (what bounds a partner's trail read, resolved live per request).apps/console — the record being written and readlib/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.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.redirect() that ends the request. grantAccess's two success branches record the same
action, only one of which runs.access/actions.ts actions record no tenant./activity see entries across all tenants, tenant-less ones included, newest first,
paginated.$in
clause in the scoped read, not the absence of a link, so a direct request answers the same.listAssignedTenantIds resolves current assignment on
every request rather than the entry carrying a frozen partnerId./activity is in the shell nav for both audiences.console-activity/immutability.test.ts over the exported surface.tenant.removed entry naming it. Recording it against the tenant would have written a row
into a collection purged moments earlier.tenant-purge getter.crossTenant: true; the partner read does not need it.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./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.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.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.md2390b84 — 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.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).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.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.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:
Vercel – tenant-management
deployed on 2390b84; the edge answers on /, /activity, /tenants (agent)Migrate preview database
green on 2390b84, including the new 1788340000000 fix-forward (agent)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)Operator-demonstrated — PENDING. The gate cannot pass until these are filled in.
/activity, tenant-less
console-access entries included, newest first and paginated (operator)/activity directly as a partner, not by the
absence of a nav link (operator)tenant.removed entry naming it, visible to staff (operator)tenant-notification-centre (8) is where they startautoIndex / collation collision — fixed 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.tenantId_1 — fixed 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.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.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.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 bug — not 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.consoleactivities — accepted 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 unbounded — accepted. A partner with very many
tenants makes a large $in on every dashboard render. Fine at today's scale;
intake.docs-sync, changelog-entry); the PR is at stage:verify..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.mdEverything 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.mdWho 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.mdpr: #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/.
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.
access/actions.ts actions carry none — traced.record call is awaited and uncaught by design)./activity, tenant-less ones included, newest first, paginated —
traced; crossTenant: true is declared on both halves of the paginated read.$in in listForConsole, not the nav link. Operator smoke pending (verify.md).listAssignedTenantIds resolves current assignment per
request rather than freezing a partnerId onto the entry./activity is in the shell nav for both audiences — traced.console-scope.test.ts, written from these criteria
and green in Quality Project.console-activity/immutability.test.ts over
the service's exported surface; green in Quality Project.tenant.removed naming it — traced through tenant-purge's MODEL_GETTERS and the
write-order at the call site.tenant-purge getter — enforced by
policy.test.ts, green in Quality Project.Migrate preview database
applied 1788330000000 and the 1788340000000 collation fix-forward against a real database.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.