AW-14 — What's new · in-product changelog · Task List
Epic: AW-14-whats-new · Program: Agent Workspace
Spec: spec.md · Plan: plan.md
Status: Draft v1 · Date: 2026-09-06
Execute top to bottom. Every task names the exact files to create or modify and what "done"
means. Phases are independently shippable; each ends with develop green.
Repo commands used below (from CLAUDE.md): pnpm lint, pnpm type-check,
cd apps/api && pnpm test, cd packages/agent && pnpm test, cd apps/web && pnpm test,
cd apps/web && pnpm test:e2e.
Phase 1 — The count and the panel
Ships: a top-bar control with an honest unread count, a panel that lists updates, per-entry read tracking, and mark-all-read. No filters, no calls-to-action, no full page yet.
T-01 · Contracts: category and kind enums
Phase: P1
Create: packages/contracts/src/api/changelog/changelog.enum.ts
Export CHANGELOG_CATEGORIES (as const tuple of exactly agents, decisions, knowledge,
connections, costs, platform) with derived type ChangelogCategory, and
CHANGELOG_KINDS (new, improved, fixed, security) with derived type ChangelogKind.
Doc-comment each referencing spec FR-9 / FR-10 and stating that kind is a badge, never a filter.
Done when: both tuples are as const, both derived types are unions of literals, and
tsc resolves them from @ever-works/contracts/api after T-03.
T-02 · Contracts: wire DTOs
Phase: P1
Create: packages/contracts/src/api/changelog/changelog.dto.ts
Declare ChangelogEntryDto, ChangelogListResponseDto, ChangelogUnreadCountResponseDto,
ChangelogMarkReadResponseDto exactly as shaped in plan.md §3.6.
cta is { label, href } | null; unreadCount on the list response is documented as always
unfiltered (spec FR-38).
Done when: the four interfaces compile and carry doc comments citing the FRs they encode.
T-03 · Contracts: barrel exports
Phase: P1
Create: packages/contracts/src/api/changelog/index.ts
Modify: packages/contracts/src/api/index.ts
Re-export both files from the folder index; add one export * from './changelog/index.js';
line to the API barrel, following the existing comment style (a one-line note naming the epic).
Done when: import { CHANGELOG_CATEGORIES } from '@ever-works/contracts/api' type-checks
from apps/api and apps/web, and pnpm type-check passes.
T-04 · Entity: ProductChangelogRead
Phase: P1
Create: packages/agent/src/entities/product-changelog-read.entity.ts
Implement exactly as in plan.md §3.2: @Entity('product_changelog_reads'),
uuid PK id, userId (uuid), entrySlug (varchar 64), readAt (@CreateDateColumn,
timestamptz), unique uq_product_changelog_read_user_entry on (userId, entrySlug), index
idx_product_changelog_read_user on (userId). No tenantId / organizationId columns and
no entity-level @ManyToOne — both absences are requirements (spec FR-13) and must be stated
in the doc comment so a later scope sweep does not "fix" them.
Done when: the file compiles and its doc comment names spec FR-12, FR-13, FR-21 and FR-22.
T-05 · Entity registration (four files — a drift spec fails CI if any is missed)
Phase: P1 Modify:
packages/agent/src/entities/index.ts— addexport * from './product-changelog-read.entity';packages/agent/src/database/_entity-names.ts— add'ProductChangelogRead'toAGENT_ENTITY_NAMESin alphabetical positionpackages/agent/src/database/_entities-inventory.ts— add the import and the class to theENTITIESarray (consumed bydatabase.config.ts:52/:113)
Done when: cd packages/agent && pnpm test passes, specifically
database.module.spec.ts's drift checks and database.config.spec.ts.
T-06 · Repository
Phase: P1
Create: packages/agent/src/database/repositories/product-changelog-read.repository.ts
Modify: packages/agent/src/database/_repository-inventory.ts (import + entry in
REPOSITORY_PROVIDERS, alphabetical), packages/agent/src/database/index.ts (barrel line)
Model on packages/agent/src/database/repositories/notification-event-type.repository.ts.
Methods:
findReadSlugs(userId: string, slugs: string[]): Promise<Set<string>>markRead(userId: string, slugs: string[]): Promise<void>— a single insert withorIgnore()so concurrent tabs cannot conflict (spec FR-18, S-17)countUnread(userId: string, candidateSlugs: string[]): Promise<number>— theNOT EXISTScount from plan.md §4deleteBySlugsNotIn(slugs: string[], olderThan: Date): Promise<number>— used only by the P3 prune; ship it now so the prune is a wiring change later, not a schema change
Done when: REPOSITORY_PROVIDERS.length assertions in database.module.spec.ts pass and
the repository is importable from @ever-works/agent/database.
T-07 · Migration (same PR as T-04 — Constitution V)
Phase: P1
Create: apps/api/src/migrations/1791140000000-CreateProductChangelogReads.ts
Copy the body from plan.md §3.4.
Create-only with ifNotExists; unique constraint; index; FK on userId → users(id)
ON DELETE CASCADE; down() drops only product_changelog_reads.
Before merge: the timestamp is AW-14's reserved slot 00 (README §5 rule 10). Rebase on
develop; if a migration with a higher timestamp has landed, re-stamp the filename and class name.
Done when: cd apps/api && pnpm typeorm migration:run -d typeorm.config.ts applies cleanly
against a fresh database, a second run is a no-op, and migration:revert drops the table
without touching anything else.
T-08 · The catalogue
Phase: P1
Create: apps/api/src/changelog/changelog.catalog.ts
Declare ChangelogCatalogEntry and the frozen CHANGELOG_ENTRIES array per
plan.md §3.5. Seed it with the genuine entries for what has
already shipped in the last two releases — a minimum of 5 real entries spanning at least
3 categories and at least 2 kinds, so every surface has something honest to render.
Set cta on the entries that have an obvious destination; leave the field absent otherwise
(the CTA is not rendered until P2 regardless).
The file header states, in one paragraph: entries ship with the build, adding one is a code
change, and the constraints are enforced by changelog.catalog.spec.ts.
Done when: the array is as const, every entry satisfies the FR-5/6/8 limits by
inspection, and pnpm type-check passes.
T-09 · Changelog service
Phase: P1
Create: apps/api/src/changelog/changelog.service.ts
Implement against ProductChangelogReadRepository and CHANGELOG_ENTRIES:
visibleEntries(now: Date)— dropspublishedAt > now(spec FR-7); sorts pinned first, thenpublishedAtdesc, ties by slug asc (spec FR-8). Memoise per process; the array is frozen.list(userId, accountCreatedAt, { category?, limit = 20, cursor? })— clamplimitto 50; cursor is the slug of the last returned entry; returnsChangelogListResponseDtoincludingtotal, always-unfilteredunreadCount, andcategoriesWithEntries.getBySlug(userId, accountCreatedAt, slug)—nullfor absent and scheduled (spec S-15).unreadCount(userId, accountCreatedAt)— newest 50 visible entries, drop those withpublishedAt <= accountCreatedAt(spec FR-14), count the rest without a read row (spec FR-15).markRead(userId, slugs)— ignore slugs absent from the catalogue; return the fresh count.markAllRead(userId, accountCreatedAt)— every visible unread entry regardless of any filter (spec FR-19); returns{ unreadCount: 0 }.
Done when: every bullet above has a matching case in T-13 and cd apps/api && pnpm test
passes.
T-10 · Request DTO
Phase: P1
Create: apps/api/src/changelog/dto/mark-changelog-read.dto.ts
MarkChangelogReadDto with @IsArray, @ArrayNotEmpty, @ArrayMaxSize(25),
@IsString({ each: true }), @Matches(/^[a-z0-9-]{3,64}$/, { each: true }) (spec FR-6, FR-17).
Done when: the global ValidationPipe rejects an empty array, 26 slugs, and a slug with an
uppercase letter or a /.
T-11 · Controller
Phase: P1
Create: apps/api/src/changelog/changelog.controller.ts
@ApiTags('Changelog'), @ApiBearerAuth('JWT-auth'), @Controller('api/changelog'),
@UseGuards(AuthSessionGuard), @CurrentUser() for the account, and
@Header('Cache-Control', 'private, no-store') on every handler. Routes and throttles exactly
as plan.md §4:
| Handler | Route | Throttle |
|---|---|---|
list | GET / | { long: { limit: 120, ttl: 60_000 } } |
unreadCount | GET /unread-count | { long: { limit: 120, ttl: 60_000 } } |
getOne | GET /:slug | { long: { limit: 120, ttl: 60_000 } } |
markRead | POST /read | { long: { limit: 60, ttl: 60_000 } } |
markAllRead | POST /read-all | { long: { limit: 10, ttl: 60_000 } } |
GET /unread-count must be declared before GET /:slug. getOne returns 404 with an
identical body for absent and scheduled entries. Full @ApiOperation / @ApiQuery /
@ApiParam / @ApiResponse annotations so the MCP server and OpenAPI doc pick the routes up.
A doc comment states that this controller is deliberately not workspace-scoped (spec FR-13).
Done when: the routes respond as specified against a running API and T-14 passes.
T-12 · Module registration
Phase: P1
Create: apps/api/src/changelog/changelog.module.ts
Modify: apps/api/src/api.module.ts
The module imports whatever DatabaseModule wiring exposes ProductChangelogReadRepository,
provides ChangelogService, declares ChangelogController, and exports ChangelogService.
Register ChangelogModule in api.module.ts's imports array next to NotificationsModule
(line ~136), with a one-line comment naming this epic.
Done when: the API boots with pnpm dev:api and GET /api/changelog/unread-count returns
401 unauthenticated and { "count": … } authenticated.
T-13 · Service spec
Phase: P1
Create: apps/api/src/changelog/changelog.service.spec.ts
Cases, one it each: scheduled entries excluded; pinned sorts first; publishedAt desc with
slug-asc tie-break; cursor paging returns disjoint pages and nextCursor: null on the last
page; limit clamps at 50; category filter narrows entries but never unreadCount;
categoriesWithEntries lists only categories with visible entries; signup baseline — an entry
older than the account is read with zero rows written; unread capped at 50 candidates; unknown
slugs in markRead are ignored; markRead twice is a no-op; markAllRead twice is a no-op;
markAllRead ignores an active category filter.
Done when: all cases pass and each references its FR number in the it title.
T-14 · Controller spec
Phase: P1
Create: apps/api/src/changelog/changelog.controller.spec.ts
Model on apps/api/src/notifications/notifications.controller.spec.ts. Assert: AuthSessionGuard
is applied to the controller; every response sets Cache-Control: private, no-store;
GET /unread-count resolves to the count handler and not to getOne; getOne 404s identically
for an absent slug and a scheduled slug; MarkChangelogReadDto rejects [], 26 slugs and a
malformed slug; the five @Throttle configurations match spec FR-44.
Done when: cd apps/api && pnpm test is green.
T-15 · Web API client
Phase: P1
Create: apps/web/src/lib/api/changelog.ts
import 'server-only', built on serverFetch / serverMutation from
apps/web/src/lib/api/server-api.ts, mirroring apps/web/src/lib/api/notifications.ts.
Export changelogAPI with list, get, unreadCount, markRead, markAllRead.
unreadCount is wrapped in cache() with next: { revalidate: 300 } and returns null on
any failure — copy the shape and the doc comment style of apps/web/src/lib/api/version.ts
(spec FR-31, FR-47).
Done when: the module type-checks, re-uses the contracts types from T-02 rather than
redeclaring them, and never throws out of unreadCount.
T-16 · Server actions
Phase: P1
Create: apps/web/src/app/actions/changelog.ts
'use server'. Export getChangelog(params), getChangelogUnreadCount(),
markChangelogRead(slugs), markAllChangelogRead(). Each returns a
{ success, …, error } result object and never throws, mirroring
apps/web/src/app/actions/notifications.ts.
Done when: every action returns { success: false, error } for a failing API call and the
panel can be driven entirely through these four.
T-17 · Top-bar control
Phase: P1
Create: apps/web/src/components/dashboard/WhatsNewButton.tsx
Props: { unreadCount: number | null; onOpen: () => void; isOpen: boolean }. Sparkle icon from
lucide-react, wrapped in Tooltip from apps/web/src/components/ui/tooltip.tsx. Badge is
rendered only when unreadCount >= 1; renders 9+ above 9 (spec FR-24). Accessible name comes
from dashboard.whatsNew.controlLabel / controlLabelUnread; aria-expanded reflects
isOpen; aria-haspopup="dialog". Styling mirrors the badge treatment in
apps/web/src/components/dashboard/NotificationDropdown.tsx. No polling.
Done when: T-24 passes and the control renders identically at 375 px and 1440 px.
T-18 · Panel shell
Phase: P1
Create: apps/web/src/components/whats-new/WhatsNewPanel.tsx
Headless UI Dialog + Transition right slide-over, w-full sm:w-[420px], built by copying
the structure of apps/web/src/components/dashboard/HelpDrawer.tsx (same imports, same
open/onClose prop contract, same close-button treatment). Heading
dashboard.whatsNew.title; subheading switches between subtitleUnread, subtitleUnreadOne
and subtitleCaughtUp. Close button's accessible name is dashboard.whatsNew.close.
Initial focus lands on the heading; focus is trapped while open and restored to the opener on
close (spec FR-51). Fetches page 1 on open, not on mount.
Done when: Escape closes and returns focus to the control, and the dialog is announced
with its heading by a screen reader.
T-19 · Entry card
Phase: P1
Create: apps/web/src/components/whats-new/ChangelogEntryCard.tsx
Renders: unread dot plus an sr-only dashboard.whatsNew.unread (spec FR-53); kind badge from
dashboard.whatsNew.kinds.*; category label from dashboard.whatsNew.filters.*; date formatted
for the active locale; title; body as plain text with white-space: pre-line and no markup
interpretation (spec FR-5). Read state changes only the dot and the title weight — never the
card's position, size or presence (spec FR-20). CTA and permalink slots exist but render
nothing in P1.
Done when: a body containing <b>x</b> renders literally, and a read card occupies exactly
the same box as an unread one.
T-20 · Shared list body
Phase: P1
Create: apps/web/src/components/whats-new/ChangelogList.tsx
One implementation used by both the panel and (in P2) the page. Renders four states: skeletons
(3 cards, no spinner, no text), the list, the global empty state, and the error state with a
retry button. Empty states use apps/web/src/components/common/EmptyState.tsx — do not write
a new empty-state component.
Done when: each of the four states is reachable from props alone and matches the wireframes in spec.md §6.3–§6.6.
T-21 · Read tracker hook
Phase: P1
Create: apps/web/src/components/whats-new/use-changelog-read-tracker.ts
IntersectionObserver at threshold: 0.5; a per-entry 1000 ms dwell timer; a 2000 ms batching
window flushing at most 25 slugs per call (spec FR-16, FR-17). Also flushes on unmount and on
document.visibilitychange → hidden. Retries a failed flush at most twice, then drops silently
and surfaces nothing to the reader (spec FR-48). Calls back with the server's fresh
unreadCount so the badge updates without re-fetching the list.
Done when: T-27 passes, including the "< 1000 ms visibility does not mark" case.
T-22 · Shell wiring
Phase: P1 Modify:
apps/web/src/app/[locale]/(dashboard)/layout.tsx— addchangelogAPI.unreadCount().catch(() => null)as an 8th entry in the existingPromise.alland passchangelogUnreadCountdownapps/web/src/app/[locale]/(dashboard)/layout-client.tsx— addwhatsNewOpenstate (mirroring the existinghelpOpen), aunreadCountstate seeded from the prop, and mount<WhatsNewPanel />beside<HelpDrawer />apps/web/src/components/dashboard/DashboardHeader.tsx— new optional propwhatsNew?: { unreadCount: number | null; onOpen: () => void }; render<WhatsNewButton />immediately before<NotificationDropdown />
Done when: every existing DashboardHeader call site still compiles with the prop omitted,
the shell still renders when the count fetch fails (no badge, no error), and no new
setInterval exists anywhere in the diff.
T-23 · i18n — English
Phase: P1
Modify: apps/web/messages/en.json
Add the full dashboard.whatsNew namespace from plan.md §8, inserted
alphabetically within the dashboard object. Every leaf name is camelCase; no leaf name
contains a literal . — a dot in a leaf key is a runtime next-intl failure that reds five or
more e2e shards at once.
Done when: the JSON parses, no leaf key contains ., and the panel renders with no
MISSING_MESSAGE console errors.
T-24 · i18n — the other 20 locales
Phase: P1
Modify: apps/web/messages/{ar,bg,de,es,fr,he,hi,id,it,ja,ko,nl,pl,pt,ru,th,tr,uk,vi,zh}.json
Mirror the same key structure with translated values. Keys are identical to en.json in every
file.
Done when: a script or manual diff confirms all 21 files carry the identical
dashboard.whatsNew key set, and the hydration/console-error e2e sweep is green.
T-25 · Web unit spec — control
Phase: P1
Create: apps/web/src/components/dashboard/WhatsNewButton.unit.spec.tsx
No badge at 0; no badge at null; 3 renders 3; 27 renders 9+; the accessible name
switches between controlLabel and controlLabelUnread; aria-expanded follows isOpen.