FEATURE_TRACKER.md — Persistent Implementation Tracker
Required by
Confluence-new-feature.md§24. One row per feature; update on every task (status, evidence, risks). Statuses:planned → in_progress → testing → done | blocked | deferred.Affected-file maps below come from the graphify knowledge graph (
graphify-out/graph.json, 10,953 nodes / 27,961 edges, built @40f7d72d) — file-level first-hopaffectedfor each touched area (Ponytail pre-flight). Use them to scope every edit; rungraphify update .after code changes (no API cost) and re-check before merging a task.
Status overview
| Phase | Tasks | Status |
|---|---|---|
| P0 docs | T-135 | done (this set of 8 docs) |
| P1 foundations | T-136…T-143, T-144, T-146…T-149 | T-136…T-145, T-149 done (API) · T-146…T-148 done (editor package + web wiring + E2E editor-table-toc.spec.ts GREEN, re-verified 2026-10-11) · T-138 page-duplicate web + T-140 related-panel web DONE (2026-10-11, confluence-duplicate-related.spec.ts 2/2) — topbar More→Duplicate (deep-copy → land on copy) + sidebar tree duplicate (now consumed, was a dead factory:page-action) + page-view Related slide-over (useBacklinks, 30 s cache) · T-139 bulk actions web DONE (2026-10-11, confluence-bulk-actions.spec.ts 2/2) — per-row tree checkbox → sidebar bulk bar → confirm dialog (BulkActionsDialog) → useBulkPages (trash/archive/favorite/unfavorite) · T-141 templates directory web DONE (2026-10-11, confluence-templates-directory.spec.ts 1/1) — server-side search + category filter + description/badge · T-142 templates variable dialog web DONE (2026-10-11, confluence-template-variables.spec.ts 1/1) — "Use" on a variable template opens the RHF form (dynamic zod schema mirroring the server's resolveTemplateValues); required/type constraints block submit; valid submit → apply(values) → tokens substituted in the new page · T-141 templates read-only preview web DONE (2026-10-11, confluence-template-preview.spec.ts 1/1) — new GET /templates/:id/preview (ID-scoped, membership of the template's OWN ws — the directory is a cross-ws union; non-member → 404, no leak) + TemplatePreviewDialog (read-only snapshot render via the house text extractors, no HTML interpretation) · P1 templates web COMPLETE (directory + variables + preview) |
| P2 history/discovery/activity | T-150…T-158 | done (API, 2026-10-11) — see per-task evidence · T-157 watch button web done (2026-10-12) · T-150/T-151 compare+preview web+E2E done (2026-10-12) · T-153/T-154 recently-viewed + saved-searches ⌘K home web+E2E done (2026-10-12, confluence-recent-saved.spec.ts 3/3) · T-158 activity feeds web+E2E done (2026-10-12, confluence-activity.spec.ts 3/3) · T-155 first-class sorts (relevance/creation/modification date) API+web+E2E done (2026-10-11, confluence-search-sort.spec.ts 2/2) · remaining: T-155 label filter (deferred — Page.labels schema decision, belongs with content-metadata work). Pre-existing (NOT this change): search.spec.ts "filter results by type" + "suggestions appear while typing" fail identically with/without the T-153/154 web (baseline stash-rebuild, 2026-10-12) — filter test calls the non-existent POST /workspaces/:id/databases; suggestions test's unique-token suggest returns [] (indexing/suggest-path, untouched) |
| P3 tasks/reviews | T-159…T-164 | done (API, 2026-10-11) — see per-task evidence; web surfaces done + E2E confluence-tasks-reviews.spec.ts GREEN (2026-10-12, 2/2) |
| P4 databases | T-165, T-166 | T-165 done (API, 2026-10-12) · T-166 done (2026-10-12) |
| P5 whiteboards | T-167…T-171 | T-167 done (API, 2026-10-12) · T-168 done (web, 2026-10-12) · T-169 done (web, 2026-10-12) · T-170 done (API+web, 2026-10-12) · T-171 done (API+web, 2026-10-12) · E2E confluence-whiteboard.spec.ts GREEN (2026-10-12) · 2,000-object perf gate DONE (2026-10-11, confluence-whiteboard-perf.spec.ts 1/1) — viewport culling: the renderer mounts a DOM node only for elements intersecting the visible world rect (a 2,000-element board renders a small fraction of nodes) · P5 whiteboards COMPLETE |
| P6 AI | T-172…T-175 | done (2026-10-11) · Web E2E GREEN (2026-10-12, confluence-ai-assistant.spec.ts 3/3) — extract-actions / find-stale / find-duplicates via the ⌘K command palette → assistant-dialog, against a temp AI_PROVIDER=mock API (restored to oss after); generate-faqs / suggest-metadata have no web entry point (recorded) |
| P7 admin | T-176…T-178 | done (API+web, 2026-10-11) — 3 org-admin health tabs (jobs / search / notifications); see per-task evidence. The recorded jobId-colon conflict (broken instant-email enqueue) is FIXED 2026-10-12 with a real-BullMQ regression spec · E2E org-admin.spec.ts Part 5 (health tabs render in the console) GREEN (2026-10-12) — Part 2 (SCIM) made green via a recorded topology fix; Part 1 (SAML) has a pre-existing, flaky SSO→hard-nav session race (see conflict below) |
| P8 hardening | analytics (T-179) · export (T-180) · backup/restore validation (T-181) · a11y (T-182) · ops docs (T-183) — ALL DONE | T-183 ops docs DONE (2026-10-12) — packages/whiteboard/README.md (new) + removed stale templates tracked-gap from the docs gate → docs-completeness.mjs 8/9 (the 1 FAIL is the BY-DESIGN Phase-15-complete signal, not a defect — see entry) · T-182 a11y DONE — topbar More menu WAI-ARIA menu-button pattern + topbar-more-menu.test.tsx 6/6 · T-181 backup/restore validation DONE — DR gate decision matrix in scripts/backup-health-decisions.ts + tests/security/backup-health.test.ts 13/13 · T-180 page Markdown export DONE — GET /pages/:id/export/markdown (VIEW-gated, audited) + topbar More → Export · E2E 2/2 · T-179 org analytics DONE — GET /orgs/:orgId/admin/analytics + Analytics tab · E2E Part 6 GREEN · integration 11/11 — P8 exit criterion met (analytics ✓, administration ✓, import/export ✓, backup/restore validation ✓, perf ✓ (2k gate T-168/169), accessibility ✓, security ✓, ops docs ✓) |
| Post-P8 core repair | T-184 | done (2026-10-12) — the 4 carried "pre-existing" pages.integration.test.ts reds root-caused: 1 genuine bug fixed (grandparentId never maintained; spec TASK-0035 requires it on create/move) + 3 stale assertions corrected (position A not a0; page purge = /permanent, cross-resource inconsistency with blocks/files ?purge=true recorded; snapshots manual/auto-only not per-update — TASK-0043) · suite now 11/11 |
| Post-P8 core repair | T-185 | done (2026-10-12) — search.spec.ts 2 reds root-caused: 1 genuine bug fixed (autocomplete buildPrefixBody matched a lowercased prefix against the case-sensitive title.keyword → [] for any differently-cased title while full-text found it; spec §4.2 wants a case-insensitive factory_prefix — fixed via match_phrase_prefix on the analyzed title, no reindex) + 1 stale setup fixed (POST /workspaces/:id/databases doesn't exist → POST /pages { type: "database" }) · search.spec.ts 4/4, search pkg 102/102 |
| Post-P8 core repair | T-186 | recorded (superseded), 2026-10-12 — pages.spec.ts + editor.spec.ts: aligned the dead /p/ URL → /page/ (kept; necessary), but the suites are testid-obsolete — 30 of 33 referenced testids no longer exist in the reworked shell; the same user flows are pinned green by the confluence-* suite (confluence-versions 1/1, editor-table-toc 2/2). Rewriting 22 tests would duplicate coverage + is a non-mechanical surface re-derivation → recorded, not rewritten (same precedent as blocks.integration.test.ts). Same URL alignment applied to comments.spec.ts + performance.spec.ts |
| Post-P8 core repair | T-187 | done (2026-10-12) — database-engine tsc reds (recorded as "11 formula", actually 54 across 13 files): fixed the clean filter-area root cause — stale FilterOperator enum (11 ops vs the 21 the TASK-0067 engine implements → 12 TS2367 + the API filterNodeSchema silently rejecting valid isChecked/has_files/isBetween… trees), toComparable missing DateRangeValue, duplicate barrel GroupNode export (dead query-side alias deleted) · engine tsc 54 → 37 (filter/ + index areas clean), shared tsc 0, api tsc 0, no new test fails |
| Post-P8 core repair | T-188 | done (2026-10-12) — database-engine 37 tsc reds + 3 unit reds, all classified (rule 29): 1 genuine TYPE error fixed (formula EValue array representation — the type said FormulaValue[] but runtime carries EValue[]; made the type honest via EValuePayload, ripples in evaluator/functions/index; security surface unchanged, rule 18) + 3 stale tests aligned to the architecture (multi_select orphan-drop asserted in config-free fromWire — moved to the config-aware display level where the spec puts it; date =+"this week" self-contradictory fixture — window is relative to now, now pinned with an in-week now; grouped-count 3 vs 2 — archived rows are dropped before grouping) + exactOptional/fixture alignment · engine tsc 37 → 0, unit 86/89 → 89/89, api tsc 0 + 445/445, ai-database.integration 8/8 (live engine formula path) · surfaced follow-up T-191 |
| Post-P8 core repair | T-191 | done (2026-10-12) — databases.integration.test.ts aligned to the live API: 10/10 (was 0/10 pre-T-188; 2/10 after T-188's setupDb fix unmasked the rest). Five stale-contract root causes fixed: (1) setupDb — fresh DBs auto-seed Title+Status+TABLE view (DEFAULT_DB_PROPERTIES), so adding a Status 409s → reuse the seeded property + PATCH its config to the test's options; (2) records CRUD — properties is propertyId-keyed + a separate title field, create returns the DTO (not {record}), update/delete are top-level PATCH/DELETE /records/:id (the suite's /databases/:id/records/:id URL didn't exist → 404); (3) query() → POST /databases/:id/query {data:{records,totalCount,nextCursor}} + cursor paging (the stale {records,total}+offset GET had no endpoint), filter dialect conditions + eq/gt/lt (the stale children + =/>/< were rejected); (4) group → {group:[{propertyId,direction}]} → {data:{groups,totalCount}}; (5) 10k test pins the documented bounded fetch — totalCount caps at 5000 over a 10k set (the stale 10_000 expectation + a seed-shape-dependent filtered slice that matched nothing). Rule-29 record: record delete is hard (DELETE /records/:id); the spec's POST .../records/:rid/archive soft-archive endpoint is unimplemented — the test was aligned to the implemented behavior (row + 1:1 page removed), the spec gap recorded, not silently papered over |
| Post-P8 core repair | T-189 | done (2026-10-12) — tests/security live-app reds classified (rule 29): 2 fixed (deps audit gate — 3 NEW high advisories added as dated exceptions per the documented process, §4.2 18→21; headers live-web CSP — test was origin-coupled, now asserts the deployed-origin CSP the compiled server sends, dev defaults preserved) + 1 env-dependent recorded (deps license/audit need pnpm on PATH — proven env-only via a corepack shim; CI has it) + 1 flake recorded (auth-paths 15/15 green, the "cookies" red was transient) · full security suite 100 passed / 2 skipped / 0 failed · follow-up T-190: bump source-map-js ≥1.2.2 (clean override) + @factory/observability OTel to the patched line |
| Post-P8 core repair | T-190 | PARTIAL (2026-10-12) — source-map-js exception CLEARED: root pnpm.overrides → {source-map-js: 1.2.2} applied (lockfile regenerated, node_modules reinstalled), pnpm audit no longer reports GHSA-68FV-2MGG-JV7Q, exception removed from AUDIT_EXCEPTIONS + license-allowlist.md §4.2 (21 → 20), full security suite 100/2/0 (the exact-equality gate re-armed: a resolved-but-unremoved exception would have failed it), observability tsc 0 + unit 41/41, live stack healthy after the relink (api 200, worker+realtime alive) + search.spec 4/4 smoke. The OTel pair (Q7RR/45RX) is re-scoped to T-190b — see that row. |
| Post-P8 core repair | T-190b | recorded (2026-10-12) — clear the 2 remaining OTel exceptions by rolling @factory/observability to the OTel 2.x train (core 1.30→2.12, sdk-node 0.57→0.223, exporter-prometheus 0.57→0.223 ≥0.217 ✓, propagator-jaeger 1.30→2.12 ≥2.9 ✓, instrumentation-* to the matching 0.223 line, api stays 1.9.1). Larger than the T-190 note assumed: the min train clearing both advisories is a full 1.x→2.x core migration (not a 1.3x patch), and @prisma/instrumentation@5.22.0 peers sdk-trace-base ^1.22 — a cross-train coupling that forces a Prisma decision (bump Prisma to a 6.x line whose instrumentation is 2.x, or accept the coupling). Detail + replay plan in the T-190 detail entry. |
| Post-P8 core repair | T-192 | DONE — pages.spec 8/8 (2026-10-12, 6+ consecutive green runs) · editor.spec baseline established (2026-10-12). Five GENUINE app bugs fixed (cover coverUrl→cover mapping + data-URI cover tokens + update-return normalization; page-tree hasChildren derived from loaded children; trash-query invalidation after delete; spec-mandated "Snapshot now" versions UI + snapshotVersion API; title per-keystroke commit race — N concurrent PATCHes applied server-side out of order, a partial title overwrote the final one → debounced commit + flush on blur/Enter/Escape/unmount). Test side: stale routes/races aligned (nested test → server-poll child id + hard goto + data-page-id gate; move/restore/purge UI-side-effect gates; fixture substring-collision rename; versions test → Phase-3 manual snapshot per TASK-0043). editor.spec.ts: 2 harness bugs fixed (cookie-only page.request → 401 on the S10 Bearer-only API; {workspace}/{page} wrapper reads vs the D4-repaired bare responses) → true baseline 13 failed / 1 passed, root-caused to the T-186 recorded surface (seeded isTitle block renders as the first editor body block → blocks.first() is the title, no empty content block → slash menu never triggers; the 1 "pass" asserts on the title block) — recorded, superseded (editor-table-toc 2/2 + confluence-versions 1/1 re-verified green this round). No new web-tsc errors (2 errors in touched files verified pre-existing via diff-hunk analysis). No git commit. |
| Post-P8 core repair | T-193 | DONE (2026-10-13) — the production next build for apps/web now succeeds on Node 24.15.0 (the system default) with the current (post-T-190) lockfile: a clean build with the next dev server stopped exits 0, emits a complete BUILD_ID + prerender-manifest.json (every static route prerendered — the exact artifact whose absence defined the old "prerender crash"), and next start serves it correctly. The previously-recorded Node-24 useContext prerender crash does not reproduce under controlled conditions (dev server stopped, current node_modules); the most likely confounders in the Oct-12 attempt were the dev server running against the same .next dir and/or the pre-T-190 node_modules. The live web is now served in PRODUCTION mode (next start) (correct deployed-origin NEXT_PUBLIC_*, boot 442 ms vs the dev server's 3.8 s). Verification: tests/security 100 passed / 2 skipped (incl. the live-web CSP + cookie-flag suites) + E2E chromium-desktop confluence-versions 1/1 + editor-table-toc 2/2 (3/3) against the prod build. Operational rule captured in the restart script: always stop next dev before next build. Residual (recorded, not a T-193 DoD gate): a chromium-mobile versions-project flake observed once — see T-193 detail. |
| Post-P8 core repair | T-195 | DONE (2026-10-13) — genuine prod-only cookie bug fixed (surfaced while restoring the stack to NODE_ENV=production): clearRefreshCookie hardcoded secure: false while the set path is env-driven (env.NODE_ENV === "production"). In production the login/rotation cookie is Secure, so a clear cookie without Secure is a different cookie to the browser — logout never actually deleted the session cookie (it persisted until expiry). Fix: the clear path now mirrors the set path's env-driven Secure flag (auth.service.ts). Pinned by tests/security/auth-paths.test.ts "clear Set-Cookie … carries the flags with Max-Age=0", which asserts Secure == isProd on the clear cookie too → the security suite is 100/2/0 with the fix and was red without it (the one remaining failure). Rule 29: genuine bug (a real prod data/session bug, not a stale test) — the test already encoded the correct contract, the code deviated. |
| Post-P8 core repair | T-194 | recorded (2026-10-12) — editor title-surface decision (surfaced by T-192): a fresh page's ONLY top-level block is the seeded isTitle paragraph, and the web render loop maps it as the first editor body block — so editor.spec.ts's blocks.first() is the title (no empty content block → slash menu never opens; 13/14 red, the 1 pass is accidental). The app already treats isTitle as non-content elsewhere (contentBlockCount, writing-action skip) and the header page-title is the canonical title, so the consistent reading is to filter it from the render loop — but that leaves a fresh page with zero body blocks (the server seeds no empty paragraph), touching the empty-state/AI-prompt + header↔body title relationship (server+web+spec change; rule 16). Either the filter+seed path or a contract rewrite of the 13 tests against the real surface (T-186 scope). Decision required; detail in the T-192 entry. |
Feature entries
Known pre-existing debt (verified 2026-10-11, NOT introduced by this feature set)
tests/integration/blocks.integration.test.ts— whole suite 404s: it callsPOST /pages/:id/blocks:batch(server-sideafterId:"PREV"chaining), a route that no longer exists in the working tree (the blocks controller isn't in HEAD; my diff doesn't touch it). Repair is a non-mechanical rewrite onto the currentPOST /blocks/batchcontract (client-positionupsertops — noafterIdchaining), so it is recorded, not silently fixed: it needs the block-batch API owner's sign-off on the new op semantics. Confirmed failing mode:404 Cannot POST …/blocks:batchon every test.apps/web— 80tscerrors + 33 vitest failures across 6 files (verified pre-existing via a stashed-baseline run before this feature set started). Not touched here except the intentional Confluence wiring (TOC block, callout variants, table row/col remove, atom chips,lib/api.tsclients).- ESLint v9 flat-config mismatch (repo-wide):
tsc --noEmit+ vitest are the gates used throughout.
T-135 — Phase 0 discovery docs
- Status: done (2026-10-11)
- Existing:
docs/architecture/*(9 deep-dives),docs/tasks/phase files, ARCHITECTURE.md, ROADMAP.md. - Changes: added
docs/architecture/CURRENT_STATE.md,FEATURE_GAP_ANALYSIS.md,TARGET_ARCHITECTURE.md,DATABASE_IMPACT.md,SECURITY_REVIEW.md;docs/implementation/IMPLEMENTATION_ROADMAP.md,DECISIONS.md(A-001…A-010),TEST_STRATEGY.md, this file. - DB: none. Security: SECURITY_REVIEW.md is the threat model for all later tasks.
- Evidence: files exist; gap matrix covers all 14 domains (A–N).
- Limitations: graph built @
40f7d72d(current HEAD) — refresh after code lands.
T-144 — Migration (additive, single)
- Status: done (2026-10-11) · Phase: P1 (lands first)
- DB: per DATABASE_IMPACT.md: columns (
workspaces.type/archivedAt,pages.kind/reviewState,templates.category/description/variables/defaultFor), enum values (+4 NotificationType +BlockType.TOC), tables (whiteboards,template_versions,reviews,tasks,page_watches,user_recent_items,saved_searches). - Evidence:
prisma/migrations/20261011065931_confluence_expansion/migration.sql(verified purely additive on live dev DB); client regenerated; every integration test (Testcontainers +migrate deploy) passes on the migrated schema. - Security: additive-only; no default changes semantics.
- Open:
BlockType.TOCenum value is schema-only — a second small migration (or amend) lands with T-147 before any TOC block write is exercised.
T-136/T-137 — Spaces: type, archive, directory, stats
- Status: done (API, 2026-10-11) · web
/spacesroute + E2E done (2026-10-12) · Deps: T-144 · Remaining: none - Existing:
workspacesmodule (CRUD, members, invitations, settings), sidebar. - Planned:
type/archivedAtsurface;GET /workspaces(member-scoped, filter type/search, sort);GET /workspaces/:id/stats; archive/restore; web/spacesroute. - DB: T-144 columns. Security: non-member no-leak (negative test).
- Evidence:
spaces.integration.test.ts6/6 green (leak checks, filters, archive 403 for member + audit, stats 403 non-member). - Tests:
spaces.integration.test.ts(done), E2Econfluence-spaces.spec.tsGREEN (2026-10-12). - Evidence (web + E2E, 2026-10-12): New route
apps/web/src/app/(app)/spaces/page.tsx(top-level directory, reuses the (app) WorkspaceShell — same path the/w/:workspaceindex uses when no workspace param is present). Directory: server-sidetypefilter (refetch per selection) +archivedtoggle (archived=trueis a server REPLACE — the default list excludes archived, so client-side toggling can't surface them) + client-side name search. Rows:space-item-{id}, type label, Archived chip, owner/admin Archive/Restore (space-archive-{id}/space-restore-{id}), Open (space-open-{id}). Wire-shape findings (recorded):GET /workspaceslist rows carrytype/archivedAt/rolebutmemberCountis NULL (dropped the member count from the row rather than render "undefined"); archive → 201 and the row leaves the default list.tsc --noEmit= 80 = baseline (delta 0); clean-envnext buildEXIT 0 (pre-rendered○ /spaces, in prerender-manifest). E2Econfluence-spaces.spec.ts1/1 (chromium-desktop, 2.3 s) single-origin (E2E_*_URL=https://notion.filps.com): space appears + search narrows → Archive removes it from the default list → "Show archived" surfaces it (chip + Restore) → Restore puts it back. NOTE for future rebuilds:next startruns as anext-serverCHILD — killing thenpxparent leaves the old build on :3000 (EADDRINUSE on relaunch + silent 404 on new routes); kill thenext-serverpid (ss -ltnp :3000). - Affected (graphify):
apps/api/src/modules/workspaces/{workspaces.service,workspaces.controller,dto,workspaces.module}.ts(20-file affected set: common/audit, event-bus, prisma, authorize, pagination, errors, app.module) + web:apps/web/src/lib/api.ts,lib/types.ts,hooks/use-workspace.ts, newapp/(app)/spaces/page.tsx(note: route is outside[workspace]segment — directory lists multiple spaces),components/layout/sidebar.tsx. - Risks: route placement (top-level vs per-workspace) — keep directory at
/spaces(top-level, post-login) to avoid path collisions.
T-138 — Page duplicate
- Status: done (API 2026-10-11 + web 2026-10-11) · Deps: — · Remaining: none
- Existing: page CRUD, block batch copy infra (AI generator duplicates blocks), positions lib.
- Planned:
POST /pages/:id/duplicate {parentId?, title?}— recursive: new page ids, positions re-based vialib/positions, blocks deep-copied (databases copied by snapshot → new database), comments NOT copied;page.duplicatedevent; audit. - Security:
canEditsource; view-only → 403. - Affected (graphify): pages module (17-file set:
page.service.ts,pages.controller.ts,dto.ts,lib/positions.ts,lib/authorize.ts, common/event-bus, common/audit, ai/generator/executor, ai/database-ai.service) + weblib/api.ts,components/layout/{topbar,sidebar}.tsx,hooks/use-pages.ts. - Limitations: databases duplicated as snapshot (live links severed — documented, matches Notion behavior).
- Web (2026-10-11):
useDuplicatePage(workspaceId)(hooks/use-pages) — POSTs the recursive duplicate thenpageApi.get(newId)for the full row, seeds["pages",newId]+ invalidates the page-tree. Wired to BOTH surfaces: the topbar More → Duplicate menu item (topbar.tsx) and the sidebar tree row'sfactory:page-actionduplicate event (page-tree-node.tsxalready dispatched it; the sidebar is the consumer — mirroring thefactory:new-pagepattern). Both land ON the copy (router.pushto the new id) with a success/error toast. - Evidence (2026-10-11): web
tsc80 (baseline, delta 0) · webvitest392/392 (incl. 4 newuse-pages-duplicate-backlinks.test.tsx: duplicate → POSTs + fetches full page by new id + seeds cache; explicit parentId/title passthrough) · cleannext buildRC=0 · E2Econfluence-duplicate-related.spec.ts2/2 (topbar More→Duplicate deep-copies the page + its 2 paragraph blocks, lands on a NEW page titled<title> (copy); Related panel see T-140). - Rule-29 contract note (found + fixed this round):
POST /pages/:id/duplicatereturns the BARE{ id, sourcePageId }(the tested T-138 API contract), NOT a fullPage— the pre-existingpageApi.duplicateclient type wrongly saidPromise<Page>. The endpoint is the tested source of truth (not changed); the client type is corrected to{ id, sourcePageId }anduseDuplicatePagefetches the full new page by id. Verified against the live API (probed:duplicate→{id, sourcePageId};GET /pages/:id→{page, workspace, childrenCount, topLevelBlocks, database}).
T-139 — Bulk page actions
- Status: done (API 2026-10-11 + web 2026-10-11) · Deps: — · Remaining: none
- Planned:
POST /pages/bulk {action, ids[]}→{succeeded[], failed[{id,code}]}; actions: trash, archive, favorite, unfavorite; per-item permission; batch audit; web multi-select in sidebar + confirmation dialog (destructive → confirm per UX reqs). - Affected (graphify): same pages-module set as T-138; web sidebar tree selection state (
stores/ui-store.ts— verified, that's where it lives). - Web (2026-10-11): per-row checkbox on
PageTreeNode(tree-select-{id}, revealed on row hover or while selected; workspace-scoped state in the ui-store — switching workspaces resets the selection) → a sticky bulk action bar in the sidebar (bulk-action-bar: count + favorite/unfavorite/archive/unarchive/trash/clear) → BulkActionsDialog (reuses the sharedDialog/DialogFooter, mounted at the workspace shell next toMoveToDialog) confirms EVERY action (bulk ops are batch + audited, so the gate always asks; trash is destructive-styled) →useBulkPagesfirespageApi.bulk(X-Factory-Workspace header) and toasts the{succeeded, failed}split (0 failed → success, mixed → info "n skipped", all failed → error). - Evidence (2026-10-11): web
tsc80 (baseline, delta 0) · webvitest398/398 (incl. 6 new: bulk POST for the selection's workspace + state-clear + tree invalidation; mixed{succeeded, failed}surfacing; selection toggle/one-workspace scope/last-id-empty;setBulkopen/close;clearPageSelectionwipes dialog) · cleannext buildRC=0 · E2Econfluence-bulk-actions.spec.ts2/2 (multi-select 2 → confirm trash → both rows leave the tree + live non-trash list excludes them; Cancel → dialog closes, pages + selection intact). API side already integration-covered (mixed-perm per-item codes + batch audit,pages-duplicate-bulk-backlinks.integration.test.ts).
T-140 — Backlinks + related pages
- Status: done (API 2026-10-11 + web 2026-10-11) · Deps: — · Remaining: none
- Planned:
GET /pages/:id/backlinks(scanchild_page/synced_block/linked_databaseblocks + commentpageMentionIds; canView-filter sources; version-keyed cache ≤ 60 s, bust on reference events); page-view "Related" panel (backlinks + linked-by + linked-to). - Security: private sources filtered (negative test).
- Affected (graphify): pages module set + blocks module (block queries) +
common/cache.ts(cache keys) + webcomponents/editor/{page-view,related-panel}.tsx,hooks/use-pages.ts,lib/types.ts. - Upgrade trigger (A-008): backlinks p95 > 100 ms at 100k → maintained table.
- Web (2026-10-11):
useBacklinks(pageId)(hooks/use-pages) —GET /pages/:id/backlinks, 30 s staleTime (mirrors the API's ≤60 s version-keyed cache; a re-open refetches only after it lapses). Page-view Related button (data-testid="related-button", shows the count badge when >0) opens theRelatedPanelslide-over (components/editor/related-panel.tsx, mirrors the CommentPanel backdrop+drawer) listing each backlink as a deep link to the source page (related-item-{pageId}) with loading / error / empty states;BacklinkReflives inlib/types.ts. - Evidence (2026-10-11): web
tsc80 (baseline, delta 0) · webvitest392/392 (incl. 2 new backlinks hook tests: reads the filtered list by page id; empty-id guard) · cleannext buildRC=0 · E2Econfluence-duplicate-related.spec.ts2/2 (a page embedding the target via aCHILD_PAGEblock is listed by title with the correct deep link; a page referenced by nothing shows the empty state; the close button dismisses).
T-141/T-142/T-143/T-145 — Templates
- Status: done (API, 2026-10-11) · T-141 directory web DONE (2026-10-11,
confluence-templates-directory.spec.ts1/1) · T-142 variable dialog web DONE (2026-10-11,confluence-template-variables.spec.ts1/1) · T-141 read-only preview DONE (2026-10-11, newGET /templates/:id/preview+confluence-template-preview.spec.ts1/1) · Deps: T-144 · Remaining: — (all three web surfaces implemented + verified) - Existing:
Templatemodel,POST pages/:id/templates,GET workspaces/:id/templates,POST templates/:id/apply, web/templatespage +template-dialog.tsx(8-file affected set:templates/page.tsx,page-view.tsx,template-dialog.tsx,use-workspace.ts,lib/api.ts,lib/types.ts,stores/ui-store.ts). - Planned:
- T-141:
category/description+ search + preview (read-only snapshot render) + category filter in/templates. - T-142:
variables Json(typed: text/date/number/select/user);POST /templates/:id/instantiate {values}; substitution engine (plain-text into block text; Zod-validated); RHF dialog. - T-143: seed 15 built-ins (meeting notes, project plan, PRD, tech design, ADR, SOP, incident report, postmortem, troubleshooting, release notes, retro, weekly status, onboarding, KB article, API docs) as real block snapshots,
isBuiltin, per-workspace idempotent seed (A-005); builtin edit/delete → 403; default-template setting (settings.templates.default). - T-145:
TemplateVersionsnapshots; list/restore (restore = new version).
- T-141:
- Security: variable injection is plain-text only (substitution never produces HTML/code); builtin management FULL_ACCESS-only (403 negative test green).
- Web — T-141 directory (2026-10-11):
/w/:workspace/templatesgains a directory control row (template-directory-controls): a search box (template-search, case-insensitive name+description) + a category<select>(template-category, options derived from the unfiltered list — the filtered list is a subset so the options can't be derived from it) + a Clear button. The list query is server-side filtered (?search=&category=); a separateall(unfiltered) query feeds the category dropdown so switching back to another category works. Each row shows itsdescription(replacing the type label) when present, plus a category badge (template-category-badge-{id}). Filtered results and the empty state are distinct ("No templates match…" vs. "No templates… yet"). - Evidence — T-141 directory (2026-10-11): web
tsc80 (baseline, delta 0) · webvitest398/398 (no regression from the list refactor) · cleannext buildRC=0 · E2Econfluence-templates-directory.spec.ts1/1 (two templates in different categories: description rendered, category dropdown lists the created categories, category filter narrows to one + badge, name search narrows case-insensitively, Clear restores the union) — the server-side filter behavior (search AND category combine) is verified end-to-end. The snapshot→list→apply loop (confluence-templates.spec.ts) stays GREEN after the refactor. - Web — T-142 variable dialog (2026-10-11):
TemplateVariableDialog(components/dialogs/template-variable-dialog.tsx, RHF +zodResolver) — a dynamic zod schema built from the template's typedvariables(text/number/date/select; values stay STRINGS, mirroring the server'sresolveTemplateValuescoercion),defaultValueprefill, required marks + inline field errors, amalformed variablesrefusal (defensiveparseVars— aselectwith no options, a non-array, etc.). The /templates page branches "Use": a template WITH variables opens the dialog; everything else keeps the one-click apply. On valid submit:templateApi.apply(id, { workspaceId, title, values })→ invalidate templates + page-tree →router.pushto the new page (same flow as the one-click apply, just withvalues). - Evidence — T-142 variable dialog (2026-10-11): web
tsc80 (baseline, delta 0) · webvitest403/403 (incl. 5 new: required-empty blocks; valid submit →apply(values)+ navigate; numberabc/3; select outside-options/valid/empty-optional; date ISO; malformed refusal) · cleannext buildRC=0 · E2Econfluence-template-variables.spec.ts1/1 ("Use" opens the form; empty required → "Required" + stays on /templates; valid values → new page whose block reads "Project Apollo is owned by Ada." with no{{tokens left) — the substitution engine itself is integration-covered (templates.integration.test.ts"create-with-variables + apply substitutes values"). The directory + snapshot→list→apply E2Es stay GREEN. - Web + API — T-141 read-only preview (2026-10-11): the preview needs the snapshot the versions list never exposes (
{id, version, note, createdBy, createdAt}only), so a NEW additive endpoint:GET /templates/:id/preview→{ data: { snapshot } }(the frozencaptureSubtreeshape). ID-scoped, no workspace header —listTemplatesis a cross-workspace union whose rows carry noworkspaceId, so a workspace-scoped lookup would 404 every union row; authorization is membership in the template's OWN workspace (any membership ≥ VIEW — the lowest level; every member has at least VIEW), non-member → 404 NOT_FOUND (no cross-tenant existence leak — same shape as the bulk-actions external-id rule). Web:templateApi.preview(id)+TemplatePreviewDialog(a read-only render of the snapshot via the house text extractorsblockText/codeBlockText— the snapshot's blocks are DETACHED, so the interactiveBlockRenderercannot be reused, and the extractors never interpret stored HTML — same rule as the version-compare panel; unsupported block types degrade to labeled placeholders). A "Preview" button appears on EVERY row (builtins included) — read-only VIEW access. - Evidence — T-141 read-only preview (2026-10-11): api
tsc0 · api unit vitest 431/431 · integrationtemplates.integration.test.ts5/5 (new: member → 200 + snapshot{page.title, blocks[].content}; non-member → 404; unknown id → 404) · webtsc80 (baseline, delta 0) · web vitest 403/403 (no change — the preview dialog is covered by E2E, no jsdom test for a pure render) · cleannext buildRC=0 · E2Econfluence-template-preview.spec.ts1/1 (Preview button on the row → dialog shows frozen title "Preview Source" + block text "preview body text", zero editable controls, Escape closes) + the 3 template E2Es stay GREEN. - Evidence:
templates.integration.test.ts4/4 green (15 builtins per ws, category/search filters, variable validate→resolve→substitute round-trip, default + required + select/date coercion, version list/restore append-only, builtin 403 for MEMBER / 201 for OWNER). Shared engine:packages/shared/src/template-vars.ts+builtin-templates.ts(unit-tested, 5/5). Seeding wired into workspace create (both team + personal) +prisma/seed.tsback-fill (134 dev workspaces × 15 verified). - Tests:
templates.integration.test.ts(done) +template-vars.test.ts(done) + E2Econfluence-templates.spec.tsGREEN (2026-10-12) — see evidence. - Evidence (E2E, 2026-10-12):
tests/e2e/specs/confluence-templates.spec.ts1/1 (chromium-desktop, 3.9 s) against the canonical single-origin stack (E2E_BASE_URL=E2E_API_URL=https://notion.filps.com— the localhost origin does NOT work: the API's CORS is pinned to the domain and the web build bakes same-originNEXT_PUBLIC_*). Full loop verified through the real browser + API: source page seeded with 2 PARAGRAPH blocks →POST /pages/:id/templatessnapshot (201,isBuiltin:false) → listed on/w/:ws/templates(selected bytemplate-item-{id}, the cross-workspace union) → "Use" (template-apply-{id}) →POST /templates/:id/applydeep-copies androuter.pushes to the NEW page (distinct id) → the new page is in the same workspace (GET /pages/:id{page, workspace}envelope — note the apply response is a BARE page row) and its blocks include the source text (deep-copy preserved content, not just the title). Ran green side-by-side withconfluence-whiteboard.spec.ts(2/2, 6.1 s). Remaining for this task set: the/templatescategory/search/preview UI + RHF variable dialog web surfaces.
T-146/T-147/T-148 — Editor enhancements
- Status: web wired (2026-10-11) · E2E done (2026-10-12,
editor-table-toc.spec.tsGREEN) · Deps: — - Evidence (pure core, unit-tested):
packages/editor/src/table-ops.ts— pure row/col insert/remove/move/setCell over{rows: string[][]}(immutable, rectangularizing).table-ops.test.ts8/8.packages/editor/src/toc.ts—generateToc(blocks)(document-order, levels 1-3, nested headings, missing-parent-safe) +headingSlug(GitHub-style, de-dup).toc.test.ts5/5.packages/editor/src/inline-atoms.ts—@date(YYYY-MM-DD)/@status(…)/@label(…)token parser → render segments + round-trip + status palette.inline-atoms.test.ts6/6.packages/shared/src/dto/block.dto.ts— additivetocblock type (empty content) +callout.variant(info|warning|tip|error, legacy omitted).- TOC enum migration
prisma/migrations/20261011073000_block_type_toc/— closes the schema/DB gap (was schema-only). Applied + verified viamigrate diff. - All exported from
packages/editor/src/index.ts. Editor packagetsc --noEmitclean; full editor suite 80/80 green.
- Note: a subagent was dispatched for this and repeatedly reported "done / tests green" while writing no files to disk (and one pass collided with my edits, corrupting
table-ops.ts— fixed). All editor work is now implemented directly. Subagent file writes do not persist in this environment. - Web wiring (this round):
- T-147 TOC: wired the previously-dead
TableOfContentsBlockinto thecase "toc"switch; deep-links via the existing block route (/w/<ws>/page/<id>/<blockId>) + smooth-scroll; per-heading Copy-link button (clipboard);tocadded to webBLOCK_TYPES,BLOCK_TYPE_LABELS, both slash registries (packages/editor + web slash-menu) +slash.test.tsupdated (30 commands). - T-148 atoms: new Tiptap atom node
nodes/inline-atom.ts(factoryInlineAtom; auto-loaded viafactoryEditorExtensions()in inline-editor) + pureatom-html.tsbridge (atomTextToHtmlescapes all text segments;atomHtmlToText→{text}tokens, null for atom-less HTML so legacy content is preserved byte-for-byte).inline-editor.tsxload path (toHtml) + both save boundaries (onUpdate/onBlur) use the bridge. Atom CSS inglobals.css(date badge / status pills per value / label chip). XSS tests inatom-html.test.ts(7/7). - T-148 callout:
CALLOUT_VARIANTS(info/warning/tip/error) render + variant picker on selection, persisted toproperties.variant(additive; legacy callouts unaffected). - T-146 table: row/column removal (hover ×), min-1-column invariant; fixed the root
BlockProperties.rows: unknowntyping →string[][](dropped 13 pre-existing typecheck errors in block-renderer). - Web API client (
lib/api.ts):pageApi.duplicate/backlinks/move/bulk(bulk sendsX-Factory-Workspaceheader),workspaceApi.list({search,type,archived,sort})/archive/restoreArchive/stats,templateApi.list({category,search,type})/apply(values)/versions/createVersion/restoreVersion; typesWorkspace.type/archivedAt,WorkspaceStats,TemplateInfoextended,TemplateVersionInfo. - Gates: editor 87/87 · whiteboard 31/31 · shared 307/307 · api
tsc0 errors · webtsc: all touched files clean (web total 104→80 pre-existing errors; the 33 pre-existing web unit-test failures are UNCHANGED by this work, verified against a stashed baseline).
- T-147 TOC: wired the previously-dead
- Affected (graphify, editor surface — 13-file set):
packages/editor/src/factory.ts,apps/web/src/components/editor/{block-card,block-renderer,inline-editor,page-view,slash-menu}.tsx,lib/constants.ts,lib/types.ts,lib/utils.ts,lib/presence-editor-views.ts,stores/presence-store.ts,apps/web/package.json. - Planned:
- T-146: table row/column ops — block-card toolbar for
tableblocks (add/remove/move row/col); content schema unchanged (rows array) — UI-only ops + shared helper inpackages/editor(pure, unit-tested). - T-147:
tocblock (new block type — additive Zod entry + extension) generated from headings on render; heading anchor slugs (id attribute from heading id) + deep-link copy. - T-148: callout
variant(info|warning|tip|error — additive.optional()tocalloutcontent schema; legacy = info) + inline marks/nodes:date,status(to-do|in-progress|done|blocked|pending colors),label— implemented as inline atoms rendered into Tiptap text; legacy docs render unchanged (absent = no element).
- T-146: table row/column ops — block-card toolbar for
- Constraint: no silent data loss — additive schemas only (spec §5).
- Tests: unit (helpers, schema) + E2E
editor-table-toc.spec.tsGREEN (2026-10-12, 2/2, 5.2 s) — see below. - Evidence (E2E, 2026-10-12):
tests/e2e/specs/editor-table-toc.spec.ts2/2 (chromium-desktop) single-origin (E2E_BASE_URL=E2E_API_URL=https://notion.filps.com). Table (T-146): focus a fresh empty block (the trailingnew-blockarea — see the focus-race note below) →/table→ the insert dialog's Table tab opens (the command isspecial, notblockType) → 2×2 grid pre-created (thead labels + 4 cells) → "+ Add row" → 3 rows → edit first cell + blur → API round-trip:GET /pages/:id/blockscarriesproperties.columns(2) +properties.rows(3,rows[0][0]==="HELLO"). TOC (T-147): two API-authoredheading_1/heading_2blocks (content = the canonical{ text }shape) →/toc→ the nav lists "Alpha"/"Beta" by real title (NOT "Untitled") → clicking "Alpha" pushes/w/<ws>/page/<id>/<h1Id>into the URL + the heading block is on screen; same for the H2 anchor. Three web bugs found + fixed (rule 29):- Table storage-shape SPLIT (recorded, NOT changed): three sources disagree on where a table's rows live — the API DOC (
docs/api/blocks.md:104) prescribescontent.rowsof{text,marks}cell objects +headerRow(propscolumnWidths); the shared Zod schema (block.dto.ts tableContent) prescribescontent.rowsof plain strings; the live web (insert dialog +TableBlockrenderer) storesproperties.rows/properties.columns(strings) and the API accepts both (probed: aproperties-only table round-trips,content:{}stays empty). The web is self-consistent onproperties, the E2E pins that contract, and the doc/schema gap is LEFT for a data-migration decision (changing the live storage would be a schema change — out of scope here; recorded per rule 29, not guessed). - Slash
/tableinserted a DEAD block: it was a plainblockTypecommand →insertBlock({type:"table"})with NO properties →content:{}, properties:{}→TableBlockrenderscols=[] rows=[]→ an invisible empty table (the only way to ever add rows was gone). The InsertDialog's Table tab (the "grid pre-created" flow) was UNREACHABLE — nothing ever settab:"table"(onlyimage/databasedid). Fix:tableis nowspecial(mirrors the documentedimagepattern) and the page-level handler opens the dialog's Table tab. This is the same class of bug theimagecomment already describes. - TOC
extractTitleonly handled HTML-string content — every heading in the canonical{ text }shape (all API/AI-authored + most web blocks) rendered as "Untitled" in the TOC (probed live:["Untitled","Untitled"]). Fix: delegate to the existing unit-testedblockText()(version-text.ts), which handles HTML string /{text}/{title}/ Tiptap doc.
- Stale-UI fix (T-146):
TableBlock.patch(add/remove row/col) calledblockApi.updateWITHOUT invalidating the["pages",pageId,"blocks"]cache — the grid's row/column count stayed stale on screen after every op (only the cellonBlur→saveNowpath refetched).useBlockSaveQueuenow exposesinvalidate;patchinvalidates after the update. - House testids added (documented in tests/README.md + editor.spec.ts but MISSING from the markup):
data-testid="slash-menu",data-testid="slash-menu-item-{id}"(slash-menu.tsx),data-testid="insert-dialog"(insert-dialog.tsx).editor.spec.tsstill fails on OTHER pre-existing debt (the/p/URL is gone — pages are/page/{id}— anddrag-handle/toggle-handle/todo-checkboxtestids were never added) — recorded, out of this task's scope. - E2E focus-race note: the trailing
new-blockarea click creates + focuses a fresh paragraph via an ASYNC client round-trip (create → re-render → focus) — focus is NOT set whenclick()resolves (probed: immediate check = body; +400 ms = focused empty CE). The spec'sfocusFreshBlockclicks ONCE thenwaitForFunction-polls (re-clicking the area once a block exists moves focus onto an internal button — a click-retry loop is actively harmful).
- Table storage-shape SPLIT (recorded, NOT changed): three sources disagree on where a table's rows live — the API DOC (
T-149 — Cross-workspace page move
- Status: done (API, 2026-10-11) · Deps: T-138
- Planned:
POST /pages/:id/move {toWorkspaceId, toParentId?}—canEdit(source)ANDcanCreate(dest); re-based positions; reindex event (search doc moves workspace); audit both workspaces. - Security: dual-check negative tests (edit-source only / create-dest only → 403).
- Evidence:
pages-duplicate-bulk-backlinks.integration.test.ts7/7 green (incl. move: 403 no-dest-access, 403 no-src-access, 201 both → subtree re-homed + blocks re-stamped). - Affected (graphify): pages module set + search indexer (workspace-scoped doc) +
lib/authorize.ts.
T-150/T-151/T-152 — Version compare, preview, retention
- Status: API+worker done (2026-10-11) · web compare/preview panel + E2E done (2026-10-12) · Deps: — · Remaining: none
- Done:
- T-150:
diffSnapshots()inpackages/shared/src/version-diff.ts(keyed-by-block-id diff — added/deleted/modified[{fields}]/moved, key-order-insensitive value compare; unit: 6/6) +GET /pages/:id/versions/compare?from&to(ZodCompareVersionsQuery; from===to → 400, unknown version → 404). - T-151:
GET /pages/:id/versions/:versionId— read-only full snapshot (VIEW-level). Route order:comparedeclared before:versionId(Nest matches in order). - T-152:
planPageVersionPrune+versionRetentionFromSettingsinpackages/shared/src/version-retention.ts(keep = newest(maxVersions, default 100) ∪ recent(keepRecentDays, default 7d); at/below the cap a page loses NOTHING — idempotent by construction) + workerpruneWorkspacePageVersions/pruneAllPageVersions(retention.ts) +versions.prunead-hoc job type + daily 04:00 hook + auditpage.versions_pruned(one row per pruning run only).
- T-150:
- Security: compare/preview re-check authz at request time; non-member → 403; revoked member → 403 (negative tests).
- Existing:
PageVersion(snapshot Json),GET/POST pages/:id/versions,POST …/restore,BlockVersion. - Planned:
- T-150: pure block-tree diff in
packages/shared/src/diff/(LCS over ordered block-id sequences; changed = same id, different content hash; output{added[], removed[], changed[{id, before, after}]}) +GET /pages/:id/versions/compare?from&to+ diff UI panel (side-by-side list, not a full text-diff editor — Ponytail: block-level diff is the unit of meaning in this editor; char-level diff inside a changed block is adiffon text only for single-line blocks). - T-151: version preview = read-only render of the snapshot (reuse block renderer in read-only mode) + test proving restore appends a new version (history never overwritten — spec §8).
- T-152: retention config in
workspace.settings.versions(maxVersions default 100, minInterval 7 d keep) + worker prune job (BullMQ, idempotent, audit event).
- T-150: pure block-tree diff in
- Security: compare/preview/restore re-check authz at request time; revocation → 403 (negative test).
- Affected (graphify): pages module set +
common/cache.ts(version list cache bust) + webpage-view.tsx(history panel) + newcomponents/versions/*. - Tests: unit diff suite, integration
version-compare.integration.test.ts, E2Econfluence-versions.spec.ts. - Evidence (2026-10-11):
versions-compare.integration.test.ts4/4 (diff correctness incl. move-on-delete rebalance, preview staleness, 400/404/no-leak, revoked member) ·version-diff6/6 unit · api/workertsc0 errors. - Evidence (2026-10-11, T-152):
version-retention10/10 unit ·versions-retention.integration.test.ts3/3 (real Postgres: prune-per-policy, grace-vs-cap, idempotency, single audit row, defaults). - Evidence (2026-10-12, T-150/T-151 web + E2E): New
apps/web/src/components/editor/version-compare-dialog.tsx— Compare tab (GET …/versions/compare?from&to, from = a "compare against" selector defaulting to the immediately-preceding version; renders page-field diff + added/deleted/modified{fields}/moved + summary chip) + Preview tab (GET …/versions/:versionIdsnapshot rendered as a read-only per-block TEXT extraction). Mounted from the history popover's per-row "Compare" button (topbar passesonCompare). Bug fixed (rule 29): the webPageVersiontype was declared as{ title, blockCount, source }but the wire is{ id, version, note, createdBy, createdAt }(page.servicelistVersionsselect) — the history popover and the block context-menuHistoryMenuwere silently renderingundefined/NaN; both now renderv{n} — {note}. Also fixedlib/types.tsPositionbeing imported-but-not-exported (brokelib/api.ts, surfaced only because the type was touched). Deviation (recorded): the Preview tab is a text extraction (components/editor/version-text.ts, unit-tested 6/6:{text}/{title}/HTML-string (tag-stripped, untrusted)/Tiptap-doc walk/empty), NOT the block renderer —BlockRendererhas no read-only mode; full-fidelity preview is a follow-up. Deviation (recorded): the dialog iscreatePortaled to<body>— the topbar'sbackdrop-blur(filter) makes it the containing block forposition: fixeddescendants, which clipped the dialog to the 44px topbar strip (found by E2E screenshot). E2Econfluence-versions.spec.ts1/1 (chromium-desktop, 2.8 s) single-origin: two snapshots bracketing aPATCH /blocks/:id→ popover listsv2 — snapshot-2/v1 — snapshot-1→ Compare dialog:fromselector defaults to v1, summary "0 added · 0 deleted · 1 modified · 0 moved · 1 unchanged", modified row listscontent→ Preview tab shows the v2 snapshot text "Changed text" + page title. Webtsc --noEmit80 = baseline (delta 0, verified by stash-diff); web vitest 373/373 (42 files) (+6 version-text, +1 file); clean-envnext buildEXIT 0. - Evidence (2026-10-11, T-153/154):
recent-saved-searches.integration.test.ts4/4 (dedupe+bump, cap, no-leak 404, per-user CRUD, cross-user 404, 400 validation). - Evidence (2026-10-11, T-157):
page-watchlist.integration.test.ts3/3 (VIEW-level watch/unwatch, watcher notified / editor not, revoked watcher not notified). - Evidence (2026-10-11, T-158):
activity-feeds.integration.test.ts2/2 (my-actions + my-spaces only, other space never leaks, keyset cursor, workspace feed membership-gated 403, actorName resolved). - Regression:
spaces6/6,pages-duplicate-bulk-backlinks7/7,templates4/4 still green after the pages/blocks service changes.
T-153/T-154 — Recently viewed, saved searches
- Status: DONE (API + web + E2E, 2026-10-11 / 2026-10-12) · Deps: — (tables pre-migrated)
- API (done 2026-10-11):
PUT/GET /users/me/recent(upsert+bump, cap 50 newest-first;itemType: page|database|whiteboard|task; apageref must be VIEW-able else 404 no-leak — non-member/unknown id cannot record) +GET/POST/PATCH/DELETE /users/me/saved-searches(queryJson validated against the SearchBody shape; strictly per-user). - Security: strictly per-user (negative: cross-user id → 404).
- Web (done 2026-10-12): the ⌘K search dialog's empty-input "home" now shows two additive sections alongside the pinned
search-recent(recent searches, unchanged): Recently viewed (search-recent-viewed/-item; top-5 ofmeApi.recentViewed(), titles resolved page-vs-database, click →recentItemHref→/databases/:idor/page/:id) and Saved searches (search-saved/-item/-deletechips + asearch-save-currentbutton that appears once a query is committed; click a chip re-runs its query). Recording is fire-and-forget:PageCanvasrecords non-database kinds (page/whiteboard/task) andDatabasePageViewrecords databases (covers both the page branch and the dedicated/databases/:idroute). Pure helpers inlib/search.ts(recentItemHref,savedSearchLabel,RECENT_VIEWED_CAP) +meApiclient inlib/api.ts. - Evidence (2026-10-12, web): unit
search.test.ts20/20 (incl. newrecentItemHref/savedSearchLabelpins) +search-dialog.test.tsx5/5 (home sections render, title resolution, navigate, save→chip, chip→re-run, delete, 404-title → "Untitled"). E2Econfluence-recent-saved.spec.ts3/3 GREEN (open page→recorded→⌘K home lists+navigates; open database→recorded→navigates to/databases/:id; Save persists → home chip re-runs its query). Full web vitest 383/383, web tsc 80 (delta 0), cleannext buildRC=0. - Rule-29 record (2026-10-12):
GET /databases/:idreturns the nested body{ database, properties, views, … }butdatabaseApi.gettypes it as the flat row (the web flattens viause-database.tsflattenDatabase). The ⌘K home's database-title read tolerates both shapes (single fetch,db.title ?? db.database?.title). Not "fixed" indatabaseApi.get(its only consumer is this new code; a client-side flatten there would duplicateflattenDatabase+ risk a circular import). Also recorded:POST /workspaces/:id/databasesdoes not exist (databases are created via a page withtype:"database") — the stalecreateDatabaseE2E helper 404s; this is the same pre-existing conflictai-database.spec.ts:55documents. - Affected (graphify): search module set (21 files incl.
search.controller.ts,search.service.ts,common/cache.ts,common/redis.service.ts) + webcomponents/search/*(verify layout before edit),command-palette.tsx,lib/api.ts.
T-155 — Search related section + filters
- Status: sorts done (2026-10-11) · related/lifecycle done by reuse (2026-10-11) · label filter deferred (schema decision) · Deps: T-140 (backlinks exist)
- Done / resolved by reuse:
- Related-pages → already served by T-140's
GET /pages/:id/backlinks; the/searchresult page composes it onto the top hit. No redundant endpoint (Ponytail). - Lifecycle filter → maps to existing
Page.kind(page/whiteboard/task) +Page.reviewState(draft/in_review/approved/…). No new migration for these two facets.
- Related-pages → already served by T-140's
- Done (first-class sorts, 2026-10-11) — spec §10 "Sorting by relevance, creation date, and modification date":
sortBy: "relevance" | "createdAt" | "updatedAt"end-to-end:packages/search(types.tsSearchSort;queries.tsadditivedateSortField/dateSortClauses/withDateSort+ self-describing date cursorencodeDateCursor/decodeDateCursor= base64url JSON[dateIso, id]— a 2-tuple OBJECT, structurally distinct from the relevance 3-tuple array, sodecodeCursorand the date decoder can never confuse each other;client.tsfullTextSearchearly-returns to a newdateSortedSearchfor the two date modes) → API (SearchInput.sortBy, controllerSearchBodysortBy: z.enum([...]).optional(), pass-through in bothsearch()andquery(), folded into the TASK-0135 cache key so a date-sorted query never serves a cached relevance envelope) → web (SearchQueryInput.sortBy+ the/searchheader sort<select>data-testid="search-sort", Relevance/Newest/Recently updated; non-default persists?sort=in the shareable URL, Relevance cleans it; changing it re-queries).- The relevance path is byte-for-byte unchanged —
buildFullTextBody/withSearchAfteruntouched (the 3-tuple(score, updatedAt, id)cursor contract pinned byqueries.test.ts), no_scoreorder change, hybrid 2-pass path untouched,GET /search/querypalette still relevance-only. - No migration, no reindex:
createdAt/updatedAtare alreadytype: "date"in the index mapping and stored by the indexer — the date sort is a request-side branch only.
- Remaining (recorded, NOT silently skipped):
- Label filter needs a
Page.labels String?field (the model has none) + an index mapping + a rebuild + a set-labels UI. This is a schema decision that belongs with the content-metadata work; it is deliberately NOT invented here (AGENTS.md rule 29: record the conflict rather than guess at a new facet). The sorts increment landed WITHOUT it on purpose: date sorts need no schema, so bundling them with the label facet (the original deferral reason — "test/reindex together") would have held up a zero-risk feature behind a schema decision.
- Label filter needs a
- Affected (graphify):
packages/searchtypes.ts/queries.ts/client.ts(+ their tests),apps/apisearch module (search.service.ts+search.controller.ts+__tests__/search.service.test.ts),apps/weblib/types.ts+app/(app)/w/[workspace]/search/page.tsx; + (label facet, deferred)prisma/schema.prisma+ migration +packages/searchmapping + set-labels UI. - Evidence (2026-10-11, sorts):
packages/searchvitest101/101 (was 93; +5queriesdate-sort/cursor tests incl. "relevance body has nosortkey" regression guard, +3clientdate-sorted search tests incl. page-boundary(date, id)cursor + cursor→search_afterdecode + relevance-byte-identical guard) · apitsc0 · apivitest431/431 (incl. 1 new service pass-through test:createdAt→ date clause, undefined/"relevance"→ nosortkey) · integrationsearch.integration.test.ts6/6 (was 4; +sortBy=createdAtdeterministic newest-first on 3 direct-indexed ws1 docs with distinctcreatedAt, + invalidsortBy→ 400 VALIDATION_ERROR) · webtsc80 (baseline, delta 0) · webvitest388/388 · cleannext buildRC=0 · E2Econfluence-search-sort.spec.ts2/2 (sort control visible + 3 options +?sort=URL round-trip; API 200 ×3 modes + 400 unknown) · regressionsearch.spec.ts2/4 (the same 2 PRE-EXISTING failures — "filter results by type" calls the non-existentPOST /workspaces/:id/databases, "suggestions appear while typing" unique-token suggest[]— both fail identically on baseline, recorded 2026-10-12).
T-156 — Admin reindex
- Status: done (2026-10-11) · Deps: — (search-index queue already had the
reindexjob; this round added the org-scoping + the org-admin trigger) - Done:
- Worker
runReindex(ops, workspaceId?, orgId?)now filters the workspace scan byorganizationIdwhenorgIdis set (one org's admin can never reindex — or rewrite the grant snapshots of — another org's workspaces). Thereindexjob schema accepts an optionalorgId. POST /orgs/:orgId/admin/search/reindex(OrgSearchService.enqueueReindex) —assertOrgAdmin-gated, AUDITED (search.reindex_requested), enqueues the SAME worker job withorgId. Optional?workspaceId=narrows to one workspace (must belong to the org, else 404 no-leak). 201{jobId, status:"processing"}.- Wired into
OrgModuleproviders +OrgAdminControllerDI.
- Worker
- Security: org MEMBER / GUEST / non-member → 403
requires_org_admin; another org'sworkspaceId→ 404 (no-leak); trigger is audited. - Affected (graphify):
apps/worker/src/processors/search-index/processor.ts(runReindex + job schema),apps/api/src/modules/org/search.service.ts(new),org-admin.controller.ts+org.module.ts(wiring). - Evidence (2026-10-11):
search-reindex.integration.test.ts2/2 (org-admin enqueues an org-scoped job — asserted by draining the livesearch-indexqueue via BullMQgetJobs— + 2 audit rows; member/non-member 403; cross-org workspaceId 404; same-org workspaceId → scoped job) · workersearch-index.test.ts43/43 (incl. 2 newrunReindexorg-scope tests: no orgId → all workspaces; orgId → only that org's; empty org → no-op) · apitsc0 · workertsc0.
T-157 — Watchlist
- Status: done (API 2026-10-11 + web 2026-10-12) · Deps: —
- Done (API):
POST/DELETE/GET /pages/:id/watch(VIEW-level, idempotent upsert; non-member → 403) +NotificationsService.notifyWatchersForPageUpdate— fan-out with canView recheck at fan-out (revoked watcher skipped, no self-noise, per-watcher best-effort). Wired at the 3 content-change sites:page.update,restoreVersion,blocks.batch(the single block-write path).WATCH_UPDATEadded to the shared notification-policy mirror (+ REVIEW_REQUESTED/APPROVAL_DECISION/TASK_DUE enum values) + lockstep test updated. - Done (web, 2026-10-12): the topbar Watch button (mirrors the existing favorite button —
Eyeicon,activevisual state,aria-pressed, no toast).pageApi.isWatching/watch/unwatchclient fns +usePageWatch(its ownGET /pages/:id/watchquery — the page object embedsfavoritebut notwatching, so the page serializer is untouched) +useToggleWatch(POST/DELETE, writes the result back into the["pages",id,"watch"]cache so the button isactivewithout a refetch). Placement is additive intopbar.tsx(page-actions block) — the page serializer, the heavily-tested favorite path, and the API are all unchanged. - Security: revoked watcher NOT notified (negative test, green).
- Affected (graphify): API = pages module set + notifications module (24-file affected set — big because notifications is the fan-out hub: ai/comments/databases/forms modules import its service — do NOT refactor it, add to it). Web =
apps/web/src/lib/api.ts(3 additivepageApifns),apps/web/src/hooks/use-pages.ts(usePageWatch+useToggleWatch),apps/web/src/components/layout/topbar.tsx(button + icon). - Evidence (2026-10-12 web):
use-pages.test.tsx4/4 (realQueryClientProvider, mockedpageApi):usePageWatchreadsGET /pages/:id/watch(empty-id guard → no fetch);useToggleWatchnot-watching → POSTwatch+ cache flipswatching:true(no refetch); watching → DELETEunwatch+ cache flipswatching:false. webtsc80 (baseline, delta 0) · webvitest367 pass (41 files) · webnext buildEXIT 0.
T-158 — Activity feeds
- Status: DONE (API + web + E2E, 2026-10-11 / 2026-10-12) · Deps: —
- Done (API 2026-10-11): shared
apps/api/src/lib/activity.ts(ACTIVITY_SELECT, (createdAt,id) keyset cursor,pageActivity,withActorNames) +GET /users/me/activity(rows the user acted on + rows of their workspaces; other workspaces never leak) +GET /workspaces/:id/activity(membership-gated → 403 for non-members, same no-leak contract as stats/archive). - Done (web 2026-10-12): shared
apps/web/src/components/activity/activity-feed.tsx(one reader for BOTH feeds — rows render resolved actor + human verb phrase (page.version_restored→ "restored a page version"), resource-type icon, relative time, deep link (pageId →/w/:id/page/:id, database →/databases/:id, else non-linkable) + keyset "Load more" cursor button; loading/error/empty states). Two surfaces:/activity(USER feed, top-level like/spaces— cross-workspace, sidebar "My activity" entry) +/w/:ws/activity(SPACE feed, sidebar "Activity" entry; rows deep-link by workspace id).ActivityRowtype inlib/types.ts;meApi.myActivity+workspaceActivityinlib/api.ts. - Security: non-member events excluded — API 403 (integration, 2026-10-11) AND web bounce: the
[workspace]LAYOUT redirects a non-member to/w/default(house pattern shared with /search, /ask, /inbox) before any row can render — the space-activity page therefore has NO in-page "no access" state (an added alert branch was dead code and was removed; the E2E asserts the bounce + zero rows). - Evidence (2026-10-12, web + E2E): unit
components/activity/__tests__/activity-feed.test.tsx5/5 (rows + actor + verb phrase + deep links incl. non-linkable; Load-more cursor fetch + append; empty; error state; SYSTEM actor) · web vitest 388/388 (baseline 383 + 5) · web tsc 80 (delta 0) · cleannext buildRC=0 · E2Etests/e2e/specs/confluence-activity.spec.ts3/3 (user feed row + deep-link navigate; space feed row + navigate; no-leak 403 API + layout bounce + 0 rows).
T-159/T-160/T-161/T-164 — Tasks
- Status: done (2026-10-11) — API + worker +
/inboxweb · E2E done (2026-10-12) · Deps: T-144 - Planned: (per A-006)
taskssidecar +pages.kind='task';TasksModule(new NestJS module — justified: 5+ endpoints + worker jobs, beyond Ponytail's "6 endpoints in existing module" bar once reminders + calendar join); inboxGET /tasks?scope=inbox|all&status&due; CRUD;taskblock type (additive Zod); due reminders (BullMQ delayed, idempotent key);/inboxroute (mine/due/overdue/done tabs + calendar tab T-164);TASK_ASSIGNED/TASK_DUEnotifications. - Security: inbox strictly assignee-scoped + page-grant recheck (negative tests).
- Affected (graphify): new
apps/api/src/modules/tasks/*(green — no affected set to disturb, exceptapp.module.tsregistration) +packages/shared/src/dto/block.dto.ts(task block type) + editor affected set (T-148's set) for the block renderer + web new route +lib/api.ts. - Limitations: workspace-scoped (no cross-workspace assignment).
- Evidence (2026-10-11, API + worker):
- T-159 CRUD — new
apps/api/src/modules/tasks/*(TasksService/Controller/Module +dto.ts). A task is apages.kind='task'page + 1:1taskssidecar (A-006): create (EDIT-gated) seeds the task page + titlePARAGRAPHblock (isTitle) + sidecar in ONE tx;PATCHmirrors the title onto the page + title block;DELETEtrashes the page (cascade).TASK_ASSIGNEDfan-out on create + on newly-added assignees (best-effort, never the actor).tasks.module.tsregistered inapp.module.ts(afterPagesModule; the 3–4-segment task routes never collide with the 2-segment workspaces@Get(":id")). - T-161 inbox + filters —
GET /workspaces/:id/tasks?scope=inbox|all&status&dueBefore&dueAfter&month&overdue&cursor&limit.scope=inbox=assigneeIds has <caller>(Prisma scalarhas); keyset on(createdAt desc, id desc)(same cursor shape as the activity feeds); each row re-resolves the page and drops it if trashed or the page grant is now NONE (the no-leak recheck). - T-160 due reminders (worker) —
apps/worker/src/processors/tasks.tsrunTaskDueSweep(prisma, redis, now): open tasks due in(now−1h, now+24h]→TASK_DUEin-app +notification.createdstream event per assignee. Idempotent WITHOUT a schema change (additive-only): a Redis NX+EX flag per(task, dueAt)— a re-run is a no-op, a CHANGED dueAt re-reminds (key embeds the ts). Wired as a maintenance jobtask-due-sweep+ hourly repeatable45 * * * *(offset from the :15 cleanup / :30 digest). - Tests —
tests/integration/tasks.integration.test.ts5/5 (create 201 + non-member assignee 400; inbox assignee-scoped + REVOKED page-grant no-leak (explicit NONE grant) + non-assignee creator absent; TASK_ASSIGNED to member not creator; title mirrors to page+block; illegal transitiondone→todo400; filters status/overdue/month; non-member 403, unknown 404). Workerapps/worker/src/__tests__/tasks.test.ts4/4 (window/look-back + per-assignee notify + stream events; IDEMPOTENT second run = 0; skips no-assignee/closed/trashed/out-of-window; re-scheduled dueAt re-reminds). - T-164
/inboxweb —apps/web/src/app/(app)/w/[workspace]/inbox/page.tsx: a tabbed inbox (Mine / Due soon / Overdue / Done / All) overtaskApi.list(all server-filtered; "due soon" window computed at query time). Rows link to the task page, an inline status chip advances the next legal status (server enforces the matrix), a "New task" inline form + per-row delete (confirm → trash). Sidebar nav "Inbox" item (CheckSquare) insidebar.tsx;taskApi+Tasktype added tolib/api.ts/lib/types.ts.
- T-159 CRUD — new
T-162/T-163 — Review workflow
- Status: done (2026-10-11) — API + review banner + deadline reminders · Deps: —
- Planned:
reviewstable +Page.reviewState;POST /pages/:id/review {action}with transition matrix inreview.service.ts(new, in pages module); reviewers = explicit user lists (A-003);REVIEW_REQUESTED/APPROVAL_DECISIONnotifications; deadline + reminder (reuse T-160 job pattern); audit before/after; review banner UI in page view (state chip + actions per permission). - Security: full negative matrix (non-reviewer approve → 403; illegal transition → 400; legacy pages default
published= no behavior change). - Affected (graphify): pages module set (17) + notifications set (add types) + web
page-view.tsx+lib/types.ts. - Evidence (2026-10-11, API):
- New
apps/api/src/modules/pages/review.service.ts+review.dto.ts(in the pages module, per plan). Machine onpages.reviewState:published --submit--> in_review --approve--> published;in_review --request_changes--> changes_requested --submit--> in_review;in_review --cancel--> published(requester-only). At most one openReviewrow (closedAt==null),statemirrorsreviewStatewhile open. - A-003 gate —
submitrequires the explicitreviewerIds(≥1, must be workspace members — a non-member is a leak → 400).approve/request_changesrequire membership in the OPEN review'sreviewerIds— a page-EDIT member who is NOT a reviewer → 403 (the explicit list is the gate, not the role).cancelis requester-only. REVIEW_REQUESTED→ each reviewer (not the submitter);APPROVAL_DECISION→ the requester (not the decider). Auditreview.submitted/approved/changes_requested/cancelled(before/after in metadata). Legacy pages defaultpublishedwith NO review row (no behavior change).- Routes in
pages.controller.ts:GET /pages/:id/review(banner state) +POST /pages/:id/review(action).ReviewServiceregistered + exported inpages.module.ts. - Tests —
tests/integration/review-workflow.integration.test.ts5/5 (legacypublished/null + submit-without-reviewers 400 + non-member reviewer 400; full submit→request_changes→resubmit→approve lifecycle + REVIEW_REQUESTED/APPROVAL_DECISION targeting + audit trail order; A-003 gate page-EDIT non-reviewer 403 (both actions) + submitter 403; cancel requester-only (reviewer 403) + approve-with-no-open-review 400; non-member 403 + unknown page 404). - Web review banner (T-163 surface) —
apps/web/src/components/editor/review-banner.tsx, rendered above the header inpage-view.tsx(self-contained; client only RENDERS — every action is server-authorized, a denial is a toast). States:in_review(Approve / Request changes for the reviewer-of-record; Cancel for the requester; "Waiting on …" otherwise, with an optional comment),changes_requested(feedback chip + one-click Resubmit to the last reviewers),published/draft(Request review → member multi-select, defaulting to the last review's reviewers). Hidden for legacypublishedpages with no review history (no behavior change).reviewApi+PageReviewtype added to the web client;GET /pages/:id/reviewnow also returnslastReview(supports the one-click resubmit). - Review deadline reminders (T-162/T-163, worker) —
apps/worker/src/processors/tasks.tsrunReviewDueSweep(reuses the T-160 pattern): openin_reviewreviews whosedeadlineis in(now−1h, now+24h]→REVIEW_REQUESTED"deadline" notification to each reviewer of record (never the requester). Same NX-flag idempotency (key = reviewId + deadline ts). Maintenance jobreview-due-sweep+ hourly repeatable46 * * * *. Workertasks.test.ts6/6 (2 new review-due cases: per-reviewer-minus-requester notify + stream events; idempotent + skips closed/no-deadline/trashed/out-of-window). - E2E (2026-10-12, GREEN):
tests/e2e/specs/confluence-tasks-reviews.spec.ts2/2 (chromium-desktop, 5.2 s) single-origin (E2E_BASE_URL=E2E_API_URL=https://notion.filps.com). Tasks (T-164): create an ASSIGNED task (a no-assignee task is invisible in "Mine" — thescope=inboxnegative is pinned in the same test) → it appears in/w/:ws/inbox(row scoped to#workspace-main; the sidebar tree renders the task page under the same title, so the global link locator is ambiguous) → inline chip advancestodo → in_progress → done(aria-label flip;donehas no next status so the button disappears and the row moves to the Done tab). Reviews (T-162/T-163): fresh page → banner "No review yet" + Request review → reviewer picker (member checkbox via the members API,members()normalizes the flat wire row) + Submit → "In review" + Approve (single-user reviewer-of-record case the matrix allows) → "Last review: approved" + serverGET /pages/:id/review=published/lastReview.decision=approved. Two real web bugs fixed (rule 29, found by this E2E): (1)PageReviewdeclared the in-flight row asopenbut the wire isreview(review.service.reviewStatereturns{ state, review, lastReview }) — everydata.openread wasundefined, so thein_reviewbranch (Approve/Request changes/Cancel) was DEAD code; renamed inlib/types.ts+review-banner.tsx. (2) The banner early-returned forpublished && !review && !lastReview(the "legacy no-review case") — but the schema default isreviewState="published", so EVERY fresh page hit that branch and the documentedpublished/draft → Request reviewentry point (and thus the whole workflow; spec: "Submit a document for review and approve it") was unreachable from the UI. Fix: removed the early-return (additive — every state now renders its documented branch; legacy pages gain the "No review yet" row, no existing-state change). Wire notes (probed 2026-10-12):GET /users/meis the enveloped{ user }shape;POST /pages/:id/reviewsubmit→approve round-trips in one user (requester==reviewer is legal; the matrix gate is membership inreviewerIds).
- New
T-165 — CSV import — done (API, 2026-10-12)
- Status: done · Deps: —
- Implemented:
POST /api/v1/databases/:id/import/csv— body{ csv, dryRun (default true), mappings?: header→propertyId, titleHeader? }. Dry run → 200 with the header→property mapping (columns[]withreason:title/duplicate_header/no_matching_property/reassigned),unmappedProperties[](every non-title property the file does not touch, withreason:read_only/relation/record_ids_only/not_importable),rowCount, and up to 5sampleRows; writes nothing. Import (dryRun:false) → 201 with{ imported, failed, rows, errors[{row,column,error}] }— a single transaction (page + record + property rows per row, the same shape ascreateRecord); a bad cell (bad number/date/option/checkbox) fails THAT ROW ONLY and is reported, good rows still land. Auditdatabase.records_imported(imported/failed/rows in metadata) +record.createdevents capped at 50 (house bulk pattern — worker re-reads by id).- Files:
apps/api/src/lib/csv-parser.ts(zero-dep RFC 4180 parser: quotes,""escapes, embedded commas/CRLF, BOM, 1000-cell row cap, no phantom trailing row) +src/lib/__tests__/csv-parser.test.ts(9 cases);constants.ts(MAX_CSV_IMPORT_ROWS=10_000,MAX_CSV_IMPORT_BODY_BYTES=2 MB);dto.ts(CsvImportDto);query.service.ts(importCsv+appendPositions+coerceCell);databases.controller.ts(route);common/csv-import-body.ts(route-scoped raw-stream body parser, 2 MB → 413PAYLOAD_TOO_LARGE, mounted inmain.ts+ the integration helper BEFORE the global 256 KB JSON parser — mount order empirically verified);lib/positions.ts(exportedMAX_PRECISION);common/types.ts(rawBodyaugmentation). No migration (tables pre-migrated). - Positioning: new rows take an even grid appended after the last position (
rebalancePositionsgrid + prefix); at/pastMAX_PRECISIONthe whole database is re-spaced in-transaction (house overflow pattern fromblock.service.ts). - Deviations from the plan (recorded, AGENTS.md rule 29): (1) single stateless endpoint with
dryRunin the body instead of two-step presigned upload — the CSV is text the client already holds; the asset pipeline is file-oriented. (2) The method lives inDatabaseQueryServicereusingwriteProperties/coerceAndCanonical/coerceValue/resolveOptionRefinstead of a newcsv-import.service.ts(Ponytail: reuse house patterns). (3) 2 MB is enforced by a route-scoped parser because the global JSON limit is 256 KB (WEBHOOK_BODY_MAX_BYTES) andexpressis not a direct apps/api dependency (strict pnpm) — zero-dep raw stream bufferer. (4) Row cap = hard 400too_many_rows, not truncation (a silently dropped tail loses data the client expects). (5) PERSON/FILES are not importable (stored as ids — no round-trip from a human CSV). - Tests (all green, run solo — Testcontainers suite contention):
tests/integration/csv-import.integration.test.ts9/9 — dry-run mapping contract (columns incl. title reason,unmappedPropertiesreasons for CREATED_TIME/PERSON/Status, sampleRows, zero writes) + import 201 + records/page-titles/per-type values (SELECT optionIdopt-*, TEXT, NUMBER, DATEstart, CHECKBOX) + page-backed records + audit row + 2×record.createdvia the debug-events endpoint; unmapped/read-only reporting + title-only import (valid, writes page titles) +no_importable_columns400 when nothing maps; per-row isolation (3 bad rows →errorswith row/column, 1 good row imported, audit{imported:1, failed:3}); explicitmappings(renamed header → propertyId works; 400s:unknown_header,unknown_property,title_via_title_header,read_only_property,record_ids_only,unknown_header/field:titleHeader); 413 for a ~2.2 MB body (envelopePAYLOAD_TOO_LARGE); 400too_many_rowsfor 10 001 rows; empty csv → 400 at the DTO boundary; authz: GUEST (VIEW) 403FORBIDDEN, non-member 403, unknown id 404NOT_FOUND(no-leak).csv-parser.test.ts9/9.npx tsc --noEmit(apps/api) EXIT 0. - OpenAPI: regenerated (
node scripts/generate-openapi.mjs→ 203 paths / 281 operations; route present);openapi.contract.test.ts6/6. - Known adjacent debt (NOT fixed here, pre-existing):
exportCsv'stoTextrenders DATE/NUMBER/CHECKBOX as"[object Object]"in the export — the import side is unaffected (it parses text); recorded for a follow-up.
- Files:
T-166 — Saved views + pagination UX
- Status: done (2026-10-12) · Deps: T-165 (done)
- Scope (survey result): the view-picker surface already existed (tabs, create/rename/delete, sort/filter/group toolbar, column quick-filter, "visible in views", Prev/Next cursor + "N of M records"). The real gaps were (1) no rows-per-page control (hardcoded 50; the §6 query already read
view.pageSizebut nothing could write it — not inUpdateViewDto) and (2) no set-as-default (active view fell back toviews[0];useReorderViewwas a broken no-op — it PATCHedposition, which the server strips). - API (additive):
UpdateViewDto.pageSizez.number().int().min(10).max(500).nullable().optional()(dto.ts);updateViewwrites it only when provided (database.service.ts— theDatabaseView.pageSizecolumn already existed, no migration);reorderViewnow (a) capturesdbfromgetDbChecked, (b) publishesDATABASE_UPDATED(like its siblings — without it the 60 slistViewscache wasn't busted and the web invalidation was a silent no-op), (c) auditsdatabase.view_reordered. - Web:
useReorderView→ realPOST /views/:viewId/reorder {after?,before?}; deadpositionfield removed fromuseUpdateView; "Set as default" in the per-view tab menu (reorderbefore: views[0].id, hidden when already first); rows/page selector (20/50/100/200) in the table footer →updateView.mutate({viewId, pageSize});pageSize: activeView?.pageSize ?? 50threaded intouseDatabaseRecords(it's in the query key, so a change refetches page 1). - Tests (all green, run solo):
tests/integration/views.integration.test.ts6/6 — PATCH pageSize=10 persists + query returns 10 of 25 with cursor + round-trips on GET /views; view-stored pageSize wins over body pageSize (matcheslist(); see deviation below); bounds 5/600 → 400VALIDATION_ERROR; reorderbefore: firstmoves a view to the front with fresh order (cache bust asserted) + audit row (database.view_reordered= 1); reorderafter+beforeinserts between neighbours; unknown view/anchor → 404; authz: GUEST 403, non-member 403, unknown db 404 (no-leak). Regression:csv-import.integration.test.ts9/9 (shareddatabase.service.ts), api databases unit tests 11/11,openapi.contract.test.ts6/6,apps/apitsc EXIT 0, web tsc 80 = baseline (delta 0). - OpenAPI: regenerated (203 paths / 281 operations; the generator inlines
type: objectbody schemas — house convention, no field-level drift). - Recorded deviations/notes: (1) query precedence — when a view stores a
pageSizeit wins over the bodypageSize(the DTO's "explicit body fields override" comment predates it and is inaccurate for this field; view-wins is the only workable semantics because the bodypageSizeis.default(50)so "unset" is indistinguishable from 50 — same behavior aslist()). (2)pageSizebounds 10–500 match the existingViewConfigcontract; the query endpoint clamps the effective page to 200, so stored 200–500 serve 200 (UI offers only 20/50/100/200). (3) P4 close-out debt (not done here, pre-existing): the CSV import web UI (file picker + mapping preview + import confirm) is not yet scoped/built — the API endpoint is complete and tested.
T-167 — Whiteboard page kind + canvas schema (API foundation)
- Status: done (2026-10-12) · Deps: T-144 (page
kind) - Scope (A-001): a whiteboard is a PAGE kind (
Page.kind = "whiteboard") with a 1:1whiteboardssidecar holding the canvasJson(the additive migration for the table +Page.kindalready landed with T-144 — no new migration). The canvas document is the zero-dep@factory/whiteboardschema:{version:1, elements: Record<id, CanvasElement>}— 7 element types (sticky/text/rect/ellipse/connector/freehand/group), per-type field validation, connector referential integrity, id/anchor/size bounds. A-004: single-user v1 (no concurrent editing). - Package (
packages/whiteboard, new, zero-dep):schema.ts(CANVAS_VERSION=1,validateCanvas→ ok/errors, unknown keys stripped on the stored shape — no junk persistence,emptyCanvas,isCanvas),geometry.ts(bounds/union, hit-test incl. freehand polyline + routed connector paths, 4-anchor points, straight/orthogonal connector paths + arrowheads, world↔screen,decimatePointsfor freehand capture),ops.ts(immutable canvas ops: clone/add/update (ElementPatch— flat per-type optional fields with runtime type guards)/remove (dangling connectors die, groups prune)/move (freehand points translate)/group/ungroup/rebind/bringToFront/setSelection),history.ts(bounded undo/redo for T-170). 31/31 unit tests. The package had never been typechecked before this task (vitest doesn't typecheck; source-first meansapps/apitsc now compiles it) — all 15 pre-existing strict-mode errors fixed properly (discriminated-unionreconstructnarrowing, dedicatedElementPatchtype, per-typecloneElementdeep copy,noUncheckedIndexedAccess/exactOptionalPropertyTypesguards), so the packagetscis EXIT 0 under the root config AND inside the api build. - API:
POST /workspaces/:id/pages {type:"whiteboard"}→Page.kind="whiteboard"+ sidecar created withemptyCanvas()in the same tx (dto enum extended;page.service.tscreate).GET /pages/:id/whiteboard(VIEW) →{pageId, schemaVersion, canvas, valid, updatedAt, updatedBy}— an unvalidatable stored doc is still returned withvalid:false(renderer shows the read-only banner per the roadmap).PUT /pages/:id/whiteboard(EDIT) → body = the full v1 canvas document (no Zod DTO —validateCanvasreturns field-level errors the service maps to the 400 envelope); rejects unknown versions, bad element fields, broken connector refs; upserts the sidecar + bumpsPage.version+ auditpage.whiteboard_updated(elementCount,schemaVersion) + publishesPAGE_UPDATED {fields:["whiteboard"]}. No-leak: a non-whiteboard page (plain or database) → 404 on both endpoints (not 403 — the kind itself is not leaked); unknown page → 404. - Body cap: canvas saves exceed the global 256 KB JSON ceiling on large boards → route-scoped parser
common/whiteboard-body.ts(mirrorscsv-import-body.ts: PUT/api/v1/pages/:id/whiteboardonly, Content-Length fast-path + streaming buffer ≤MAX_WHITEBOARD_BODY_BYTES= 2 MB → 413PAYLOAD_TOO_LARGE, setsreq._body), mounted inmain.ts+tests/integration/helpers/api.tsBEFORE the global JSON parser. - Tests (all green, run solo):
tests/integration/whiteboard.integration.test.ts10/10 — createtype:"whiteboard"→ kind + empty canvas sidecar (GET→valid:true, 0 elements); legacy create →kind:"page"+ no sidecar row; PUT a 7-element canvas (all types + connector + group) → 200 +GETround-trip (connectorarrow, groupchildrenintact) + auditpage.whiteboard_updated≥ 1; unknown version 99 → 400VALIDATION_ERROR; broken connectorfrom→ 400 with the field-levelmissing elementissue; per-type bounds (fontSize 999 / odd-length points / strokeWidth 99) → 400 ×3; non-whiteboard page → 404 GET+PUT (no-leak); unknown page id → 404 GET+PUT; GUEST reads 200 / saves 403, non-member 403/403; body cap — a ~2.4 MB canvas → 413PAYLOAD_TOO_LARGEwhile a ~300 KB one (over the global ceiling, under 2 MB) saves 200 (proves the route-scoped parser). Package:tscEXIT 0 (root config) + 31/31 vitest. Regression:openapi.contract.test.ts6/6,apps/apitsc --noEmitEXIT 0. - OpenAPI: regenerated → 204 paths / 283 operations (both routes documented;
openapi.contract.test.ts6/6). - Recorded deviations/notes: (1)
PUTtakes the full canvas document, not ops/deltas — single-user v1 (A-004) means the renderer owns the document; delta-ops would add a server-side op executor nobody consumes yet. (2)Canvas→ PrismaJsonneeds a cast (the schema'sRecordmap has no index signature) — house pattern for Json writes. (3)duplicate()already copieskindbut NOT the whiteboard sidecar (a duplicated board is an empty board of the same kind — matches the database "copy = fresh database" precedent; sidecar copy needs a decision before T-170). - Pending (next tasks): T-168 web renderer (hand-rolled SVG per A-001;
page.kind === "whiteboard"branch in the page view; pan/zoom/selection/sticky/text + XSS-escaped labels), T-169 shapes/connectors/freehand/grouping, T-170 undo/redo/autosave/versions, T-171 convert-to-page; E2Econfluence-whiteboard.spec.ts+ 2,000-object perf gate (T-168/T-169).
T-168 — Whiteboard renderer: pan/zoom, selection, sticky notes, text
- Status: done (web, 2026-10-12) · Deps: T-167 (done)
- Renderer (
apps/web/src/components/whiteboard/whiteboard-canvas.tsx, new, zero new deps): hand-rolled SVG (A-001 — OSS canvas libs evaluated → rejected on dependency grounds; all geometry/ops from@factory/whiteboard). Features: pan/zoom (wheel zoom-at-cursor clamped 5%–800% + pan tool/middle-mouse, the package'sViewportmath), marquee selection (topmost hit-test via packagehitTest; shift-add), sticky + text create (tool buttons + V/H/S/T shortcuts; click-to-place), move (pointer drag, re-derived from the captured pre-drag document so immutable ops never accumulate float drift; freehand points travel viamoveMany), resize (SE handle, min 10), delete (Delete/Backspace on selection; dangling connectors/pruning via packageremoveElement), inline editing (double-click →<textarea>in aforeignObject; value-bound, Escape/blur commits viaupdateElement). rect/ellipse/connector(+arrows)/freehand/group render read-only from the document (their editing ships in T-169; unknown element types are skipped, not crashing — forward-compatible). Read-only when GUEST orcanvasValid:false(banner + no edit tools — the unknown-version contract). Autosave: 1.2 s debounce →PUT /pages/:id/whiteboard(optimistic; error → revert to server state + toast; single-user v1 = last-write-wins, A-004). Toolbar shows zoom % + Saved/Unsaved/Saving state. - Label XSS: labels render through SVG
<text>/<tspan>children (textContent semantics — React escapes; neverdangerouslySetInnerHTML), word-wrapped by a localwrapLines(line-height/char-width estimate, truncated with…). - Wiring:
PageCanvas(page-canvas.tsx) branchespage.kind === "whiteboard"→<WhiteboardCanvas>before the database branch (a whiteboard page has no Database row;kindis the explicit marker — the webPagetype now carrieskind?fromPAGE_SELECT).whiteboardApi(lib/api.ts:get/save, typedCanvasfrom@factory/whiteboard) +use-whiteboard.ts(query + optimistic save mutation,["pages", id, "whiteboard"]key). Create surface: the "New" dialog (create-dialog.tsx) gains a Whiteboard kind (3-col picker) →useCreatePage {type:"whiteboard"}→ navigates to the canvas. Deps:@factory/whiteboard: workspace:*inapps/web/package.json+ symlink +next.config.mjstranspilePackages(source-first; the existing.js→.tsextensionAlias covers the package's NodeNext imports). Toolbar styles.wb-toolinglobals.css. - Tests (all green):
src/components/whiteboard/__tests__/whiteboard-canvas.test.tsx6/6 (jsdom +createRoot, house pattern) — XSS: a sticky label<img src=x onerror=…>renders as INERT text (payload survives astextContent, zero<img>/injected nodes in the DOM, no side-effect ran) + text label renders +valid:false→ read-only banner with edit tools hidden (pan stays) + valid canvas shows all 4 tools + create (sticky tool → canvas click adds an element, opens the inline editor) + delete (select → Delete key removes it; move/resize semantics are pinned in the package ops suite). Web unit suite: 340/340 across 38 files (was 334; +6).apps/webtsc --noEmit= 80 = pre-existing baseline (delta 0).next buildEXIT 0 (thepage/[id]route — now carrying the whiteboard branch — builds clean through webpack). API side unchanged (T-167 green: 10/10 integration). - Recorded deviations/notes: (1) wheel-zoom is a raw non-passive listener on the container (Next's React
onWheelcan'tpreventDefaultreliably) — no library. (2) The autosave debounce is a plain 1.2 ssetTimeout(no new deps); a save in flight is not canceled (the server is idempotent per document). (3) Group move/resize, shape editing, connector drawing, freehand capture are OUT of T-168 by the roadmap split — they render, their ops already exist in the package (T-169 wires the UI). (4) 2,000-object perf gate is T-168/T-169 shared (risk ledger) — the renderer draws one<g>per element with no virtualization yet; measure at the T-169 E2E. - E2E (2026-10-12, GREEN):
tests/e2e/specs/confluence-whiteboard.spec.ts1/1 (chromium-desktop, 5.7 s) against the canonical single-origin dev stack (E2E_BASE_URL=E2E_API_URL=https://notion.filps.com— Caddy → web :3000 / api :4000 / worker :4003 / realtime :4001; localhost origins DO NOT work — the API's CORS is pinned tohttps://notion.filps.comand the web build bakes that same-originNEXT_PUBLIC_*, so a localhost web can't even reach the API): whiteboard page renders (wb-root+wb-canvas) → click-create a sticky (tool reverts to select) → double-click opens thewb-editor-inputoverlay, type + Escape commits,wb-labelre-renders the text → drag-create awb-rect→ the 1.2 s autosave PUT round-trips:GET /pages/:id/whiteboardcarries both elements + the edited text (canvas.elementsis an id→element OBJECT map —Object.valuesit). Recorded observation (rule 29, 2026-10-12): the jsdom unit test pins "click-create opens the inline editor", but in a real Chromium browser the single click-creates + selects while focus stays on<body>(editor overlay absent; probed at +0/+300/+1300 ms) — the double-click path opens it reliably, so the E2E uses double-click (the documented edit entry). Not fixed here: changing click-create to defersetEditingId(or committing on blur) is a whiteboard-interaction decision for the T-171+ block, not silently patched in an E2E fix. - Pending (RESOLVED 2026-10-11): 2,000-object render perf gate — implemented via viewport culling; see the "2,000-object performance gate" entry below +
confluence-whiteboard-perf.spec.ts1/1.
T-168/T-169 — 2,000-object performance gate (viewport culling)
- Spec requirement: "Use a rendering approach suitable for large numbers of objects." Approach (A-001, zero deps): VIEWPORT CULLING — the SVG mounts a DOM node only for elements whose DISPLAY bounds intersect the visible world rect, so the mounted-node count is independent of the total element count on an infinite canvas (the canonical large-canvas technique; no library).
- Package (
packages/whiteboard/src/geometry.ts, +4 exports):viewportToWorldRect(vp, width, height)(the visible WORLD rect from a screen box; returnsnullfor a degenerate/unknown box — no layout — so the caller renders everything),rectsIntersect(a, b)(edge-exclusive),displayBounds(el, elements)(connectors cull on the UNION of their endpoints — the schema stores a 0-sized path box, so raw-box culling would drop connectors straddling the viewport; every other type uses its own box). 4 new unit tests (world-rect mapping incl. negative world coords, degenerate→null, edge-exclusive intersect, connector endpoint-union + orphan fallback). Package vitest 39→43/43, packagetsc0. - Web (
whiteboard-canvas.tsx): aResizeObserveron the canvas wrap measures the container's size (guarded — jsdom has no RO → size stays 0×0 → culling OFF, so headless tests render in full and are unaffected); the elements memo now sorts once per canvas (sortedElements) then filters to the viewport (visibleElements,displayBounds∩viewportToWorldRect); the marquee also selects only visible elements (it can't extend past the viewport, and it avoids a full-document scan on large boards). The observer effect depends onisLoading— the container only exists after data loads (the loading state renders a different subtree), so a[]-deps effect no-ops on mount and culling never engaged (the bug found + fixed in this task). 2 new unit tests (RO-stub → off-viewport element culled, only the near one mounts; no RO → 0×0 → everything renders). - Evidence (2026-10-11): web
tsc80 (baseline, delta 0) · web vitest 405/405 (was 403; +2 culling) · package 43/43 · cleannext buildRC=0 · E2Econfluence-whiteboard-perf.spec.ts1/1 — seeds a 2,000-element board (10 rects near the origin + 1,990 at x≥10,000,PUT /pages/:id/whiteboard,validateCanvasaccepts it — no per-document element cap), loads it, and asserts the renderer mounts < 100 DOM nodes for a 2,000-element board (the 1,990 far elements are culled) — the measurable proof a node is NOT mounted per element. The interactiveconfluence-whiteboard.spec.ts(sticky/rect/edit/autosave) stays GREEN with culling on in a real viewport (the near cluster is always in view). - Recorded note (rule 29): culling hides off-viewport elements from the DOM, which also means they can't be marquee-selected — this matches real whiteboard behavior (a marquee can't reach past the screen) and is intentional, not a regression.
T-169 — Whiteboard shapes/connectors/freehand/grouping (web)
- Status: done (web, 2026-10-12) · Deps: T-168 (done)
- Renderer (
whiteboard-canvas.tsx, extended — T-168 contract preserved): rect/ellipse drag-create (rect/ellipse tools, live dashed preview, min-size guard); connectors — the connector tool requires starting ON an element: the endpoint isnearestAnchor(el, releasePoint)(new package helper — closest of the 4 edge midpoints, deterministic tie-break), and the drop element becomes the target (from/to={elementId, anchor}refs, so connectors re-route automatically when their endpoints move); toolbar toggles pick orthogonal (default) vs straight routing + arrowhead on/off for the new connector — paths/arrows render via the package'sconnectorPath/arrowHead; freehand pen — pointer samples accumulate (density scales with zoom, ≥ ~1 screen px apart), on releasedecimatePoints(pts, 1.5)stores the simplified polyline with its stroke's bounds; grouping — marquee/multi-select + Group/⌘G(packagegroupElements— union bounds, children pruned from the map) and Ungroup/⌘⇧G(packageungroupElement); moving a selection containing connectors moves only the non-connectors (the connectors follow theirfrom/torefs). Tool shortcuts: V/H/S/T/R/O/C/P. Resize handles + delete now cover rect/ellipse too (connector/freehand/group are not bounds-resizable in v1 — the handle isn't rendered for them). - Package (
packages/whiteboard):geometry.ts+nearestAnchor(uses schemaANCHORSorder) — the attach-point the connector tool needs; T-169 exit criteria pinned as tests:geometry.test.tsnew "connector routing" suite (orthogonal dominant-axis routing, both orientations, exact 3-segment paths, straight single-segment;nearestAnchor4-quadrant + tie-break) and the existing ops suite already pins group union bounds +rebindGroupafter child move (group bounds/transform). - Tests (all green):
whiteboard-canvas.test.tsx11/11 (6 T-168 retained + 5 new: rect drag-create asserts exact x/y/w/h attributes; ellipse cx/cy/ry; connector element→element asserts the routeddpath + arrowhead polygon; pen capture asserts a decimated polyline starting at the stroke origin; marquee 2-select →⌘Ggroup asserts union bounds (0,0,300,50) →⌘⇧Gungroup restores the children). Package suite 33/33 (was 31; +2 routing/anchor tests, tsc EXIT 0 under root config). Web unit 345/345 (was 340; +5).apps/webtsc --noEmit= 80 = baseline (delta 0).next buildEXIT 0. - Recorded deviations/notes: (1) Connectors attach only to elements (no free-floating endpoints) — that's what
from/to: {elementId, anchor}expresses and what the package's validation requires; free-point connectors are a possible T-170 extension. (2) Connector drag previews show the path but the endpoints don't "snap" visually mid-drag (only on release, vianearestAnchor) — the preview is honest about the attach point being chosen at release. (3) No per-element color/stroke/fill UI (v1 default styles; the schema already carriesfill/stroke/color/fontSize— styling UI is a natural T-170+ addition). (4) 2,000-object perf gate + E2Econfluence-whiteboard.spec.tsremain pending (need the dev stack) — the renderer still draws one<g>per element, no virtualization.
T-170 — Whiteboard undo/redo, save & restore, comments, links (API + web)
- Status: done (API+web, 2026-10-12) · Deps: T-169 (done)
- Package (
packages/whiteboard): (1)ElementLink— an optionallinkfield on EVERY element type ({kind: "page"|"task"|"url", refId? (≤128), href? (≤2000,^https?://\S+$only — nojavascript:/ftp:/relative)}):parseLink(throws) +parseLinkOrError(the validation pass) inschema.ts;validateElementchecks it;reconstructre-parses once and threads{link}onto all 7 per-type returns (absent stays absent —"link" in elis false, so no junk keys);ops.tsElementPatch.link(set /nullclears the key) andcloneElementdeep-copies it. (2)OpStack.reset(next)— replaces the present WITHOUT an undo step (seed after a load, branch after a restore);pushkeeps its semantics. - API (
apps/api):createVersionnow snapshotswhiteboard: {canvas, schemaVersion, updatedAt}into thePageVersion.snapshotwhenpage.kind === "whiteboard"(the sidecar is the content of a whiteboard page — a version without it could not be restored);restoreVersion(existing tx) upserts the sidecar fromsnap.whiteboardafter re-validating the snapshot's canvas withvalidateCanvas(a snapshot is untrusted input too). Both paths reuse the existingpage.version_restoredaudit for restore;createVersionaudits none (matches the pre-existing behavior for document pages). No new routes, no migration, no OpenAPI change (versions endpoints pre-exist). - Web (
whiteboard-canvas.tsx+lib/api.ts): (1) undo/redo — the canvas is owned by anOpStack<Canvas>(capacity 100): the server document isresetin on load (undo stops at load), every edit goes throughapply()→push;⌘Z/⌘⇧Z(+⌘Y) + Undo/Redo toolbar buttons (disabled viacanUndo/canRedo). Restore is apush(NOT a reset) — the pre-restore board stays one⌘Zaway, as the confirm dialog promises. (2) save & restore —whiteboardApi.saveVersion/restoreVersionclient methods (new); "Save version" flushes a dirty board first, then POSTs the snapshot; the versions popover listsusePageVersions(newest 20) with per-version Restore →ConfirmDialog(destructive) → server round-trip → re-GET the canvas into the stack. (3) page comments — the toolbar button mounts the page's existingCommentPaneldrawer (comments are page-scoped; no new backend — T-170 reuses the comment module entirely). (4) collaboration indicators — the page realtime channel the layout already opens for every page route (incl. whiteboards) feedsuseBlockPatchStore's presence roster; peers (self excluded) render as avatars in the toolbar. (5) links on elements — Link button /⌘K(single selection) → dialog: internal page reference (fromusePageTree, flattened — a task IS a page) or an http(s) URL (regex-validated client-side; the server schema re-validates on save). A linked element shows aLink2badge + pointer cursor; double-click opens it (internal →router.push(/w/:ws/page/:id), URL →window.open(href, "_blank", "noopener,noreferrer")— neverinnerHTML);Enteron a selected linked element does the same. - Tests (all green): package 39/39 (was 33: +3 link schema/ops round-trips incl.
javascript:/ftp:/oversize-href rejection + 2000-char boundary + absent-stays-absent, +1OpStack.reset).whiteboard-canvas.test.tsx16/16 (was 11; +5 T-170: ⌘Z/⌘⇧Z undo-redo of a created sticky with button disable states; ⌘K link dialog → page reference → element gets the link marker; comments button mounts the panel; presence avatars (self excluded); save-version + restore round-trip assertingsaveVersion("p1"),restoreVersion("p1","pv-abc123"), and the re-GET). Integrationwhiteboard.integration.test.ts12/12 (was 10; +2: createVersion snapshot carries the canvas (verified via the preview endpoint) → overwrite board → restore brings the original back +page.version_restoredaudit +1; a non-whiteboard page's version snapshot has nowhiteboardkey and restoring it leaves no sidecar row). APItscEXIT 0. Web unit 350/350 across 38 files (was 345; +5).apps/webtsc --noEmit= 80 = baseline (delta 0).next buildEXIT 0. - Recorded deviations/notes: (1) Autosave "hardening" is partial: the debounce now keeps local edits on save failure (no more revert-to-server — the failure is toasted and the 1.2 s cycle retries the same document); an explicit
beforeunloadflush is NOT implemented (the browser can't await a fetch there; the debounced save already fires within 1.2 s of any edit, and the version snapshot is the durability story). (2) Per-element styling UI (fill/stroke/color/fontSize) is NOT in T-170 — the schema carries the fields and the renderer honors them (T-169 shapes renderfill/stroke); the picker UI is the next whiteboard increment (recorded here as the planned block's remainder, not silently dropped). (3) Comments are page-level (theCommentmodel'sselectionis block-scoped; whiteboard element-scoped anchoring is a v2 decision). (4) Restore replaces the board butduplicate()still does NOT copy the sidecar (T-167 note stands). (5) E2Econfluence-whiteboard.spec.ts+ the 2,000-object perf gate remain pending (need the dev stack) — unchanged from T-168/T-169. - Pending: none for T-170's own scope; carry-overs listed above.
T-171 — Whiteboard: convert selection to a real page
- Status: done (API+web, 2026-10-12) · Deps: T-170 (done)
- API (
apps/api, one new route —POST /pages/:id/whiteboard/convert): the caller passeselementIds(≤100) +target("page"|"task") + optionaltitle/parentId; the server re-reads the canvas itself (never trusts caller-supplied contents), maps text-bearing elements (stickies, text) to onePARAGRAPHblock per line (empty lines dropped, selection order preserved) in a new page, and skips the rest (shapes/connectors/groups/empty). Element ids the canvas doesn't know are dropped, not an error (a selection can go stale).target:"task"additionally creates the A-006 task sidecar (status:"todo") in the same tx. Title defaults to the first line of the first text element (≤200). Authz: VIEW for the page (404 no-leak on a non-whiteboard page — the route 404s, not 400s) + EDIT for the mutation (GUEST 403); unknown parent → 404, trashed parent → 400. Audit:page.whiteboard_converted(page target) /page.task_converted(task target),resourceId= the NEW page. No migration; OpenAPI spec regenerated (scripts/generate-openapi.mjs— the route is inCREATE_POSTS→ 201) and the bidirectionalopenapi.contract.test.ts(6/6) passes, so the spec↔live-route contract is honest. - Web (
whiteboard-canvas.tsx+lib/api.ts): a Convert button (enabled for any non-empty selection) opens a small dialog with a Page / Task target picker + "Convert"; on confirm it callswhiteboardApi.convertSelection(pageId, {elementIds: selection, target, parentId: null})androuter.pushes to the new page (the toast names the result). The dialog is a fixed overlay (backdrop close), consistent with the T-170 link dialog. - Tests (all green):
whiteboard-canvas.test.tsx17/17 (was 16; +1 convert: select → Convert button enabled → dialog → pick "task" →convertSelection("p1",{elementIds:["s1"],target:"task",parentId:null})+router.push("/w/ws1/page/newpage1")). Integrationwhiteboard.integration.test.ts15/15 (was 12; +3: page-target conversion assertskind:"page", title default,blockCount, the created page'sGET /contenttext (order preserved, shapes/empty/unknown ids contribute nothing),page.whiteboard_convertedaudit =1, source board untouched; task-target assertskind:"task",task.status:"todo",task.pageId= new page, listed onGET /workspaces/:id/tasks?scope=all,page.task_convertedaudit =1; error/authz: 400VALIDATION_ERRORwhen nothing has text, 404 on a non-whiteboard page, GUEST read-OK/convert-403).openapi.contract.test.ts6/6. APItscEXIT 0. Web unit 351/351 (was 350; +1).apps/webtsc= 80 = baseline (delta 0).next buildEXIT 0. - Recorded deviations/notes: (1) Conversion maps only text-bearing elements to blocks — shapes/connectors/freehand/groups are skipped (the spec says "convert selected objects into tasks or documentation"; their visual form has no block equivalent, so the honest behavior is text→blocks, not a lossy raster). (2) Each text element contributes one block per line (a multi-line sticky → multiple paragraphs), not one block per element. (3)
parentIdis passed asnull(convert lands at top level of the board's space); the dialog doesn't expose a parent picker (v1). (4) E2Econfluence-whiteboard.spec.ts+ 2,000-object perf gate remain pending (need the dev stack) — unchanged from T-168/169/170. - Pending: none for T-171's own scope.
T-172…T-175 — AI knowledge assistant (five capabilities)
- Status: done (API+web, 2026-10-11) · Deps: AI stack (existing: gateway + RAG + permissions + budget/rate guard)
- API (
apps/api, newsrc/modules/ai/assistant/+ module wiring): five plain-JSON one-shot capabilities (no SSE, no new conversation surface) — T-172 extract-actions (EDIT-gated; notes → real tasks via the existing TasksService, as the requesting user), T-173 generate-faqs (VIEW-gated; visible pages → an FAQ draft PAGE with one H2 + paragraph per Q/A via the domain page/block services), T-174 find-duplicates (VIEW; most-recent-page candidate pool + optional search narrowing → grouped likely-duplicates), T-174 find-stale (VIEW; server-side updatedAt cutoff → flagged stale pages), T-175 suggest-metadata + apply (VIEW suggest; apply re-checks EDIT through the normal page PATCH, and each field must EXACTLY equal the stored suggestion — no smuggling). - Security (invariant L-1 honored): retrieval for every capability is a live per-page
actorLevelVIEW re-check over a capped Postgres candidate pool (CANDIDATE_CAP=200) — the authoritative permission layer, the same IDOR-guard pattern asassembleWritingContext(NOT OpenSearch: no snapshot staleness, no 503 dependency, deterministic). Model-emitted page ids are validated against the permission-filtered set and dropped, never fabricated (the find-duplicates forge seam proves the drop). Each capability has a negative security test (a page the caller lacks VIEW on is never retrieved/cited/returned — asserted on the response AND the persisted assistant metadata). Hidden per-callassistant:*conversations (filtered out oflistConversations). zod-validated tool output → retry-once → 422 with no partial write (hostile seam). Budget guard (402) + shared rate limit (429) before the gateway. Audit on all writes:ai.assistant_extract_actions/_faq_page/_duplicates_found/_stale_found/_metadata_suggested/_metadata_applied. - Provider + seams (
packages/ai+gateway-provider.ts+ internal test-seam):MockProvidergains amode:"assistant"branch (deterministic tool call per action, parsing the REAL prompt markers);gateway-providergainsarmMockAssistantSeam/clearMockAssistantSeam(PERSISTENT map — retry-once makes 2 calls) consumed in the provider registry + cleared byresetWorkspaceCache; the internal test-seam exposesPOST /internal/workspaces/:id/ai/mock-assistant {hostile?, forgeId?}(INTERNAL_SERVICE_TOKEN+INTERNAL_TEST_SEAMgated, like the other seams). - Web (
apps/web):assistantApiinlib/ai.ts(five typed one-shots);AssistantDialog(one thin view over the five modes, reusing theDbAiDialoga11y chrome — focus trap, Escape, aria-live, error banner for 429/402/422/403);ui-storeassistantOpen/assistantContext/setAssistant; command-palette entries (extract for non-GUEST, the four VIEW ones for all) + page-scoped block context-menu items (generate-faqs / suggest-metadata); mounted at the workspace shell next toGeneratePageDialog. - Tests (all green): integration
assistant.integration.test.ts19/19 (5 capability happy paths incl. real-task + FAQ-page assertions; per-capability security negative — the NONE page never retrieved/cited; forged-id drop; 422 hostile no-write; 429 shared rate window; 402 budget on a fresh member; hidden conversations absent from the list but present in the ledger).assistant-dialog.test.tsx6/6 (closed-until-opened; extract disabled→run→tasks+toast; 422 banner+Retry re-dispatch; find-stale days=90 + links; suggest Apply sends the exact suggested fields; Dismiss never applies).openapi.contract.test.ts6/6 after regen (211 paths / 290 ops — +6 assistant routes). API tsc EXIT 0; API AI unit 95/95; ai-chat integration regression 10/10. Web unit 357/357 (was 351; +6).apps/webtsc = 80 = baseline (delta 0).next buildEXIT 0. - Recorded deviations/notes (rule 29): (1) suggest-metadata scope conflict — spec §15 says "suggest owners, tags, and review dates," but the Page model has NO tag/label/review-date/owner columns (only title/description). Per the architecture-over-spec rule, suggest-metadata suggests title + description only; owners/tags/review-dates deferred (recorded here + in the
assistant-prompts.tsheader). (2) No embedding-similarity for find-duplicates — the spec names embedding similarity overai_embeddings, but the retrieval decision (per-page live VIEW re-check over the capped Postgres pool, same as the other capabilities) keeps the deterministic mock testable without a vector dependency; OpenSearch is used ONLY as the optional query narrowing. Recorded, not guessed. (3) Web E2E — DONE (2026-10-12,confluence-ai-assistant.spec.ts, 3/3 GREEN, 6.7 s) — the three palette-reachable capabilities are proven through the real user surface (topbar ⌘K command palette →assistant-dialog) against a temporarilyAI_PROVIDER=mockAPI (the recorded CI/dev mock decision; the API was restored toossand re-verified after the run): extract-actions (notes → dialog lists both bullets AND the server persisted them as real tasks, verified via the task list), find-stale (two Prisma-backdated pages are listed with the 90-day group label), find-duplicates (four pages → two "Overlapping content (mock)" groups).generate-faqs/suggest-metadatahave no web entry point (only the three modes above are wired to the command palette; the dialog header's "page-scoped block context-menu items" claim is stale — recorded, not guessed); their web view (Apply/Dismiss) is proven byassistant-dialog.test.tsxand the API byassistant.integration.test.ts. - Pending: none for T-172…T-175's own scope.
T-176/T-177/T-178 — Admin health tabs
- Status: done (API+web, 2026-10-11) · Deps: T-156 (T-177 reindex trigger)
- Done (backend):
- T-176 Jobs —
OrgJobsService(apps/api/src/modules/org/jobs.service.ts):GET …/admin/jobs(per-queue BullMQ counts),GET …/admin/jobs/failed(DLQ view, queue filter + cap 1…100),POST …/admin/jobs/requeue(@HttpCode(200)quick sync mutation; re-run ONE failed job viaJob.retry, else 409job_not_failed). Requeue is the ONLY mutating route and IS audited (jobs.failed_job_requeued, resourceTypequeue, metadata{orgId,queue,jobId,jobName}); reads are audit-free (house pattern). Queue names are a server-side enum (mirrors workerQUEUES, kept as a literal so the API never imports the worker pkg; nonew Queue(<untrusted>)). - T-177 Search health —
OrgSearchService.health()(org/search.service.ts): source-of-truth count from the PostgressearchDocumentmirror (the SAME rows the worker reconcile compares against) vsSearchClient.countDocs()(whole docs, chunks excluded, org/tenant-filtered,size:0),drift = docCount − sourceCount,lagMs= now − newestindexedAt,lastReconcileAtfrom a new best-effort Redis stampsearch:reconcile:last(ISO string, 30d TTL, set in the worker reconcile path). When OpenSearch is down the route returns 200{healthy:false, docCount:null, error:"SEARCH_UNAVAILABLE"}— never 500 (proven via thesetClientForTestingdead-client seam). - T-178 Notifications health —
OrgNotificationsService.health()(org/notifications.service.ts): in-app volume (inAppTotal/inAppUnread) + email-queue state.emailQueuedis a cluster-wide BullMQ count (documented limitation — org-scoping is impossible for queued counts);emailFailed/failedJobsare org-scoped by correlating each job's payloadnotificationIdsto the org's notification ids (no-leak: another org's failed emails never surface). SearchClient.countDocs(workspaceIds?)added topackages/search(2 new unit tests).- Wired: 5 new routes on
OrgAdminController+OrgModuleproviders (OrgJobsService,OrgNotificationsService,SearchModuleimport forSearchService). All routesassertOrgAdmin-gated server-side (404 unknown org / 403requires_org_admin).
- T-176 Jobs —
- Done (web):
org-admin.tstyped client fns (jobsOverview/jobsFailed/jobsRequeue/searchHealth/searchReindex/notificationsHealth) + 3 tabs (JobsTab,SearchHealthTab,NotificationsTab) following the house tab pattern (react-query +Section+ loading/empty/error states +data-testid).SearchHealthTabwires the EXISTING T-156 reindex trigger. Page:TABS+3 (jobs,search-health,notifications-health) +?tab=deep-link. - Security: denied-default server authz on all 5 routes; no-leak (404 unknown org; T-178 org-scoped by payload correlation); requeue is the only mutation and is audited.
- Design notes: (1) Jobs tab is cluster-scoped by design — BullMQ is shared across all orgs, so the payload carries
scope:"cluster"and the UI labels the counts cluster-wide; the org-admin role is the access boundary (not a data boundary). (2) T-178emailQueuedis a cluster-wide count (only the FAILURES are org-scoped, via the payloadnotificationIdscorrelation); there is no delivery table, so all metrics are derived. (3)lastReconcileAtisnull/“unknown” until the first worker reconcile writes the Redis stamp (additive observability, no migration). - ⚠️ Recorded conflict (rule 29) — jobId colon bug in the worker's instant-email path (pre-existing) — ✅ FIXED 2026-10-12: the worker's
email-decision.processor.tsenqueued the instant notification email withjobId: \email:${notificationId}`— a **two-segment colon id that BullMQ 5.x REJECTS at enqueue** (Job.validateOptions@job.js:1075:Custom Id cannot contain :; only a 3-segment colon id is legal, for repeatable compatibility — confirmed in the vendoredbullmq@5.81.5dist: non-repeataddJobpassesopts.jobIdstraight toJob.create→ validation; repeatables get a checksummed key first). So **every instant notification email threw at enqueue** (the stream consumer then re-delivered it;emailSentAtnever stamped → it was never deduped). The sibling keys were legal:digest:${userId}:${wsId}(3-seg),automation-email:${runId}:${index}(3-seg), and theai:usage-rollup/ai:reembedrepeatables are decorated by BullMQ'srepeat.jsbefore validation. The worker's unit test only asserted the jobId STRING via a MOCKEDenqueueFn, so it stayed green while the real BullMQ path was broken. **Fix (2026-10-12):** the instant dedup key is now **email_${notificationId}** (colon-free; the underscore keeps the human-readableemailprefix in the Jobs-tab DLQ view — nothing parses the jobId prefix, the T-178 tab correlates on the payloadnotificationIds). Verified: (a) worker unit-test expectation updated (email-decision.test.ts); (b) NEW real-BullMQ integration spec **tests/integration/notification-email-jobid.integration.test.ts** (1/1 SOLO):handleNotificationCreatedwith the REALenqueue(no seam) no longer throws, the job lands WAITING underemail_{id}, a redelivery re-enqueue is DEDUPED by BullMQ (one job), and theemailworker (injectedsmtpseam) processes it and stampsNotification.emailSentAt— the pipeline the old bug broke, proven end-to-end against the live test Redis. Docs corrected:prisma/schema.prisma(Notification comment),docs/database/redis-keys.md(the staleemail:{notificationId}Redis-key row did not exist in code — replaced with the real mechanism; also added thesearch:reconcile:lastkey in its correct section),notifications.service.ts` (both modules) + this file. The T-178 tab is unaffected (payload correlation, as designed). - Affected (graphify):
apps/api/src/modules/org/{jobs.service.ts (new), search.service.ts, notifications.service.ts (new), org-admin.controller.ts, org.module.ts},packages/search/src/{client.ts, client.test.ts},apps/worker/src/processors/search-index/processor.ts(reconcile Redis stamp),apps/web/src/lib/org-admin.ts,apps/web/src/components/admin/org-admin/{JobsTab.tsx (new), SearchHealthTab.tsx (new), NotificationsTab.tsx (new)},apps/web/src/app/org/[orgId]/admin/page.tsx,tests/integration/org-admin-health.integration.test.ts(new). - Evidence (2026-10-11):
org-admin-health.integration.test.ts7/7 SOLO (guard matrix owner+admin 200 / member+outsider 403requires_org_admin/ unknown org 404 across all 3 GETs; jobs overviewscope:"cluster"+ 7 queue names + 4 numeric counters; search/health healthy with sourceCount=2=docCount=2, drift 0, lagMs ≥0; search/health down → 200healthy:falseerror:"SEARCH_UNAVAILABLE"docCount:nullvia thesetClientForTestingdead-client; notifications/health in-app ≥1 + email failed job correlated by payloadnotificationIds(jobId = the UUID BullMQ assigned); jobs/failed lists the failed email job; requeue member 403 + admin 200{queued:true}+ job leaves failed + 1 audit rowjobs.failed_job_requeued). Regression SOLO:org-admin.integration.test.ts10/10 ·search-reindex.integration.test.ts2/2 ·openapi.contract.test.ts6/6 (spec↔live-routes, 216 paths / 295 ops incl. the 5 new routes). Search proven against the dev/CI OpenSearch at SEARCH_URL (house pattern —search.integration.test.ts), with a top-level Docker+OpenSearch stack gate that SELF-SKIPS when absent (the dev index can be read-only under disk flood-stage, so this task does NOT spin a fresh container). apitsc0 · workertsc0 · search pkgclient.test.ts13/13 (incl. 2 newcountDocs) · webtsc80 (baseline, delta 0) · webvitest363 pass (40 files, incl. newhealth-tabs.test.tsx6/6) · webnext buildEXIT 0 · OpenAPI regenerated 216/295. - Evidence (E2E, 2026-10-12):
tests/e2e/specs/org-admin.spec.tsPart 5 (new) GREEN — a fresh org owner (the spec'ssetupOrgpattern) opens the console's three health tabs single-origin (E2E_BASE_URL=E2E_API_URL=https://notion.filps.com) and asserts what renders: Jobs → all sevenjobs-queue-{files,search-index,webhooks,automations,email,ai,maintenance}cells with their wait/active/fail counters + the "Failed jobs" section (the "All queues" filter; the DLQ body is intentionally NOT asserted — the shared dev cluster accumulates real failed jobs,aialone has thousands, so the empty-state text is state-dependent; row-level requeue is proven at the API layer). Search health →search-health-ok"Index reachable" (the dev OpenSearch is up; the down path is proven at the API layer via the dead-client seam) + the counts/freshness labels + the wiredsearch-health-reindexbutton. Notifications → the four derived stat labels + the "Recent failed email jobs" section. API shapes asserted alongside:jobs={scope:"cluster"}+ the exact 7-queue array;search/health={healthy:true}. Full-spec run: Parts 2–5 GREEN (2.8–7.4 s each).- ⚠️ Recorded conflict (rule 29) — SCIM is a machine surface, not the browser surface — ✅ FIXED in the spec (2026-10-12): the spec ran every call (incl.
/scim/v2/*) againstE2E_API_URL. In this topology the browser originnotion.filps.comhas NO/scim/*Caddy route (catch-all → web :3000 → Next 307 →/login), so Part 2's SCIM create 307'd to the login page and returned{}— a pre-existing red, unrelated to any code change (verified: raw:4000/scim/v2/Usersanswers 401 with no auth;aiops.filps.com/scim/v2/Usersanswers 201). The Caddyfile is explicit:/scim/v2/*belongs to the API/machine host (aiops.filps.com, "NOT browser-facing — the browser uses notion.filps.com only"). Fix: the spec now takes an optionalE2E_SCIM_URL(default =API, which is correct on the single-origin local default where:4000serves both), and theScimClientroutes through it; the run passesE2E_SCIM_URL=https://aiops.filps.com. Part 2 is GREEN with this. - ⚠️ Recorded conflict (rule 29) — Part 1 (SAML) has a pre-existing, FLAKY SSO→hard-nav session race — NOT fixed (2026-10-12, out of this task's scope): Part 1 (written in an earlier phase) logs the JIT user in via the SAML IdP (
ssoLogin) and thengotos the console directly. An instrumented probe proved the SSO hand-off completes correctly (JIT lands on/w/defaultholding a validfactory_refreshcookie on the web origin), but the subsequent hardgototo/org/:orgId/adminloses the in-memory access token and the cookie→refresh re-establishment does not win the boot race → the console's fetch 401s → the auth gate redirects to/login(the failure screenshot is a bare login page). It passed once and failed in 3 subsequent runs (run-order/latency-dependent), so it is a flake, not a regression — and the Part-5 diff provably does not touch Part 1's path (git diff: only theSCIMconst +ScimClientrouting + Part 5). Fix candidates (deferred): (a) giveopenConsolea retry/relaunch-style re-login when it lands on/login, or (b) have the console page re-establish the session from the refresh cookie BEFORE rendering the forbidden/overview gate. Recorded per rule 29 rather than guessed-at mid-task.
- ⚠️ Recorded conflict (rule 29) — SCIM is a machine surface, not the browser surface — ✅ FIXED in the spec (2026-10-12): the spec ran every call (incl.
- Pending: E2E (needs the dev stack + a running worker to observe a reindexed job + the three tabs in the browser).
T-179 — Org analytics (Phase 8: governance & production hardening — "Complete analytics")
- Status: DONE (API+web, 2026-10-11) · Deps: T-133 org-admin console (the tab it joins)
- Why: the org console (P7) had overview/members/audit + the 3 health tabs but NO usage/health analytics — the spec's Phase 8 "Complete analytics" item. CSV import/export (databases) and the audit-log search + export already existed, so the concrete gap was the analytics surface.
- API (
apps/api/src/modules/org/analytics.service.ts, new):GET /orgs/:orgId/admin/analytics— read-only, org-scoped,assertOrgAdmin(server-side, house guard; unknown org → 404, non-admin member → 403requires_org_admin). SinglePromise.allbatch (no N+1): workspaces (incl. archived viaarchivedAt), org-member role mix, page counts by kind (non-trashed), new pages 7d/30d (one 30-day fetch, 7d sliced in memory), new databases 30d, open + 30d comments, file count/storage/30d-bytes, audit events 30d + top-10 actions, member join dates — plus four ZERO-FILLED 30-day UTC-day trend series (newPages / storage-bytes / members / audit) in the overview service's no-rollup-table precedent. Content scoping =workspace.organizationId(same as overview); audit rows =workspace IN orgOR org-level (workspaceId: null). - Web:
orgAdminApi.analytics+OrgAnalyticstype inlib/org-admin.ts;AdminAnalyticsTab(components/admin/org-admin/AnalyticsTab.tsx, new) — stat cards (workspaces/members/pages/databases/comments/files), Content mix (by kind + growth), Top audit actions (30d), four CSS sparklines; reuses the Overview tab's house chrome (Section, CSSSparkline,formatBytes) — zero new deps (A-001 discipline). Console page:TABS+1 (analytics, between Overview and Members) +?tab=analyticsdeep-link. - Security: denied-default
assertOrgAdmin(same as every other org-admin route); READ-ONLY (no audit event needed — no mutation, no sensitive export; the spec's audit rules target security-RELEVANT events, and a read by an already-authed admin is not one); no new PII surface beyond what the members/audit tabs already expose to the same role. - Tests (all green): integration
org-admin.integration.test.ts11/11 (new "analytics" test: owner 200 withworkspaces:1,members:3,pages.page:2,newPages.last30d:2, audittopActionsincl.page.created(real rows from the two API-created pages — proves the org-scoped audit trend), all four 30-length zero-filled ISO-day trends; member/guest/non-member → 403requires_org_admin; anon → 401; unknown org → 404) · webtsc80 = baseline (delta 0) · web vitest 405/405 · cleannext buildRC=0 · E2Eorg-admin.spec.tsPart 6 (new) GREEN (2026-10-11, 3.8 s) — fresh org + bound workspace + 2 API pages → the browser Analytics tab renders (Content mix, New pages (30 days), Top audit actions, Storage growth headings; thepagekind row) + the API contract (org-scopedworkspaces:1,pages.page:2, 30-day trends) + a MEMBER's direct 403requires_org_admin. Full regression (2026-10-11): Parts 2, 3, 4, 5, 6 GREEN in one run (19.3 s) — Part 4 (retention) flaked ONCE under parallel load and passed both solo and in this sequential run (read-independent of this read-only change); Part 1 (SAML) remains the pre-existing flaky session race, untouched by this diff. - Recorded note (rule 29): the analytics trends are point-in-time reads over the source tables (no rollup) — the 30-day window is a
createdAt >= now-29dfilter on indexed columns; at the current org scale (≤ low thousands of rows/day) this is a few indexed scans, and the overview service (same pattern, T-133) is the established precedent. If org scale grows past ~1M audit rows, add a daily rollup table (recorded upgrade path, not built now — Ponytail). - Affected:
apps/api/src/modules/org/{analytics.service.ts (new), org-admin.controller.ts, org.module.ts},apps/web/src/lib/org-admin.ts,apps/web/src/components/admin/org-admin/AnalyticsTab.tsx(new),apps/web/src/app/org/[orgId]/admin/page.tsx,tests/integration/org-admin.integration.test.ts,tests/e2e/specs/org-admin.spec.ts.
T-180 — Page/content Markdown export (Phase 8: "Export authorized content" — journey #13)
- Status: DONE (API+web, 2026-10-12) · Deps: none (reuses the page read gate +
captureSubtree) - Why: Phase 8's "Export authorized content" item. CSV import/export (databases) and the org audit CSV already existed, but a single page (the core Confluence unit) could not be exported. The export must be gated by the SAME permission level as reading the page (VIEW) — what you can read, you can export; nothing more.
- API:
GET /pages/:id/export/markdown(?includeChildren=1opt-in) —PageService.exportMarkdownreusesgetPage(unknown → 404) +assertCan(..., "VIEW", {resource:{pageId}})(restricted page → 403) + the existingcaptureSubtree(recursive block fetch, so the export never re-implements tree walking). A pure, dependency-free serializerpage-export.ts(renderPageMarkdown/exportBlockText/exportFilename) maps the block types to markdown; non-text blocks (media/embeds/table/chart/…) degrade to stable*[Type: label]*placeholders — never silently dropped (recorded design, rule 29). The controller setsContent-Type: text/markdown+Content-Disposition: attachment; filename="<sanitized title>.md"(mirrors the database CSV route). Every export writes apage.exportedaudit row (format, includeChildren, byte count) — rule 27. - Web:
pageApi.exportMarkdown(raw fetch → blob/anchor download, the house CSV pattern) + a topbar "More" → "Export as Markdown…" item (between Duplicate and Delete) + a success/error toast. - Security: server-side VIEW gate (the export is a READ, so it takes the read level — no new privilege); explicit-NONE page grants remain a hard stop (verified); anonymous → 401; unknown page → 404. Audit on every successful export (rule 27).
- Tests (all green): unit
page-export.test.ts14/14 (per-type rendering, position order, TOGGLE/COLUMN nesting indentation, HTML-string content shape, includeChildren on/off, filename sanitize/fallback/80-cap) · integrationpages.integration.test.tsexport case GREEN (owner 200 + attachment + rendered markdown +page.exportedaudit row; GUEST VIEW 200; explicit-NONE member 403; unknown 404) · apitsc0 · api unit 445/445 (was 431 + 14) · webtsc79 (delta 0 vs 80 baseline; my files clean) · web vitest 405/405 · cleannext buildRC=0 · E2Econfluence-page-export.spec.ts2/2 GREEN (2026-10-12) — browser More → Export → a real Playwrightdownloadevent whose bytes contain# Export Doc/# Section/Body textin order and whose filename isExport-Doc.md+ a non-member (NONE) 403 on the endpoint. - Recorded notes (rule 29): (a) a body
HEADING_1renders#(same as the title) — a literal level mapping, no shift, a plain export; (b) the four PRE-EXISTINGpages.integration.test.tsfailures (create-root position defaultAvsa0, create-nested grandparent denorm, purge, version-bump) are NOT this diff — proven by re-running them with the T-180 service/controller code removed: identical 4 failures. They are stale assertions in the uncommitted working tree (a pre-existing red, likesearch.spec.ts). - Affected:
apps/api/src/modules/pages/{page-export.ts (new), page-export.test.ts (new), page.service.ts, pages.controller.ts},apps/web/src/lib/api.ts,apps/web/src/components/layout/topbar.tsx,tests/integration/pages.integration.test.ts,tests/e2e/specs/confluence-page-export.spec.ts,docs/api/openapi.yaml(219 paths / 298 ops).
T-181 — Backup/restore VALIDATION (Phase 8: "backup/restore validation")
- Status: DONE (decision-logic unit gate, 2026-10-12) · Deps: T-0135
scripts/backup-health.ts(the gate this validates) - Why:
scripts/backup-health.tsis the release-pipeline HARD GATE (stage 9) that fails a release when a backup stopped working (NFR-B1…B7: RPO ≤ 5 min, RTO ≤ 1 h; deployment.md §8 runbook). Its DECISION matrix is the safety-critical part (a stale backup quietly passing, or an auth failure going silent, is a real DR hole), but it was untested — the logic was tangled inmain()alongside live S3/OpenSearch fetches, so no test could reach it. Phase 8's "backup/restore validation" = make that gate's verdict provably correct. - Change (Ponytail, additive): extracted the PURE, side-effect-free decision functions —
ageMsSince,fmtAge,isAuthError,recordUnavailable(…, strict),verdictFor(…),CheckResult— into a new co-located modulescripts/backup-health-decisions.ts(no env reads, no I/O, no top-level execution → importable from a test).backup-health.tsnow DELEGATES every verdict decision to that module (single source of truth); it keeps only the live S3/OS checks + env + I/O.recordUnavailablegained an explicitstrictparam (the script threads its module-levelSTRICT) so both modes are testable. - Tests (all green):
tests/security/backup-health.test.ts13/13 — the full decision matrix against the pure module: (1) REACHABLE-but-STALE → FAIL + exit 1 in EVERY mode (the threshold is inclusive on the limit and flips just past it); (2) AUTH error (403/SignatureDoesNotMatch/AccessDenied/InvalidAccessKey/InvalidClientTokenId/Credentials/unauthorized) → FAIL in BOTH dev and strict (never silent, even on a dev laptop) — with the non-auth unreachability strings asserted to NOT match; (3) UNREACHABLE dev → SKIP, report-only, "NOT a pass", exit 0; (4) the SAME UNREACHABLE strict → FAIL, exit 1; plus theverdictForexit-code contract (any FAIL → 1; SKIPs-only → 0 report-only; all PASS → 0; empty → 0) andageMsSince/fmtAgeboundaries. The live script re-run confirms the refactor preserved behavior: dev mode with no backup stack → 3 SKIPs,OK with 3 report-only skip(s) … No failures, exit 0. - Recorded notes (rule 29): (a) the 8
tests/securitylive-app failures (headersexact live-web CSP,depspnpm audit/pnpm licenseslockfile state,auth-pathslive-API cookie flags) are pre-existing, env-dependent and NOT this diff — the change is additive (a new module + new test + a script refactor), adds NO dependencies (lockfile + root package.json untouched), and none of the 8 import or execute the touched files. (b) thetests/securitytsc gate surfaces 11 PRE-EXISTING errors inpackages/database-engine/src/formula/*(untouched this session); 0 in the files changed here. (c)recordUnavailable'sstrictparam threads the script'sSTRICT— the module stays a pure function of its arguments (no hidden env read), which is what makes the two-mode behavior testable. - Affected:
scripts/backup-health-decisions.ts(new),scripts/backup-health.ts(delegates verdicts to the module),tests/security/backup-health.test.ts(new).
T-182 — Accessibility pass (Phase 8: "accessibility" — the topbar action menu)
- Status: DONE (topbar "More" menu, 2026-10-12) · Deps: T-180 (the menu gained an item) · Scope note: the P8 a11y requirement ("Accessible keyboard navigation" + "Accessible dialogs and forms") is app-wide; this task makes the single highest-value interaction — the topbar More menu (page Copy/Rename/Move/Duplicate/Export/Delete) — conform to the WAI-ARIA menu-button pattern and pins that contract in a DOM test. A broader app-wide audit (every dialog/form focus trap) is a recorded follow-up, not a guess-at-this-task.
- Why: the More menu is the core per-page action surface (and the one I extended in T-180). Before this it was a plain
<div role="menu">with NO trigger contract (aria-haspopup/aria-expandedabsent) and NO keyboard navigation — a keyboard user could open it but could not move through or dismiss it, and a screen-reader user got no "menu button, expanded" announcement. - Change (additive,
topbar.tsxonly):TopbarButton→forwardRef(React 18); the trigger now carriesref+aria-haspopup="menu"+aria-expanded={moreOpen}; amoreMenuRef+moreTriggerRef; the existing click-outsideuseEffectnow ALSO (a) moves focus to the first menuitem on open, (b) handles ArrowDown/ArrowUp (wrap), Home/End, Escape (close + return focus to trigger), and Tab (preventDefault — the standard menu focus-trap, Escape is the dismiss path). Behavior for mouse users is unchanged (click-outside + item clicks as before). - Tests (all green): NEW unit
topbar-more-menu.test.tsx6/6 (jsdom) — trigger is a menu button (haspopup+expanded=falseclosed); open →expanded=true+role=menupresent + focus on FIRST item; ArrowDown/ArrowUp navigate with wrap + Home/End jump; Escape → menu unmounted +expanded=false+ focus returned to trigger; Tab is trapped (defaultPrevented); click-outside still closes. · web vitest 411/411 (48 files; +6) · cleannext buildRC=0 · webtsc: 0 errors in the touched file (the 80th bare-tsccount is a Next-GENERATED.next/types/.../layout.tsfalse positive — source-file counts match the 79 baseline exactly;next build, the type-check source of truth, is green). - E2E regression (all green, 2026-10-12, 12.5 s): the menu-driven specs still pass with the new ARIA + keyboard wiring —
confluence-duplicate-related2/2 (More → Duplicate),confluence-page-export2/2 (More → Export as Markdown download),confluence-versions2/2,confluence-templates1/1. Confirms the keyboard/ARIA additions do not perturb the mouse-driven flows. - Recorded notes (rule 29): (a) the destructive
confirm-dialog(components/ui/confirm-dialog.tsx) was ALREADY accessible (role=dialog,aria-modal,aria-labelledby, Escape-to-close) — verified, no change; (b) the WAI-ARIA menu pattern traps focus (Tab prevented) with Escape as the dismiss path — the standard forrole=menu, not an oversight; (c) app-wide a11y (every dialog/form focus trap, heading order, color contrast) remains a follow-up task — recorded here rather than scope-crept into this one. - Affected:
apps/web/src/components/layout/topbar.tsx(forwardRef + ARIA + keyboard),apps/web/src/components/layout/__tests__/topbar-more-menu.test.tsx(new).
T-183 — Ops documentation refresh (Phase 8: "operational documentation")
- Status: DONE (machine-docs gate green, 2026-10-12) · Deps: none
- Why: Phase 8's "operational documentation" item. The repo's machine-checked definition of "docs are current" is
scripts/docs-completeness.mjs(a CI gate: every package ships a README, every OpenAPI path group is documented or explicitly tracked, README links resolve, ROADMAP Phase 15 DoD present). Two genuine drift items had slipped in as the P1–P8 work landed; the backup/restore runbook (deployment.md§8: wal-g PITR, S3 versioning, OpenSearch snapshot, the ~10 min / ~2 min PITR-drill offsets, reindex-reconciliation) already exists and is current. - Fixes (additive docs / gate hygiene): (1)
packages/whiteboard/README.md(new) — the whiteboard core package (T-167/A-001) had NO README while all 22 sibling packages do; added an accurate one (canvas v1{version, elements: Record<id, CanvasElement>}layout, the 7 element types, the four modulesschema/geometry/ops/historywith their exports, the invariants the renderer must not re-derive, the 43/43 unit + 2,000-object perf-gate notes, zero-dep / source-first / no-UI facts). (2)scripts/docs-completeness.mjs— removedtemplatesfromKNOWN_UNDOCUMENTED_GROUPS: that group's paths (/templates/{id}/apply|preview|versions…) are documented indocs/api/pages.md(verified), so the entry was stale and the gate's "list can only shrink as docs land" contract says it must go. The comment block was updated to match. - Result (verified):
node scripts/docs-completeness.mjsnow 8/9 PASS —all 23 package dirs ship a real README.md✓,every spec path group documented or tracked (6 tracked gaps: debug, docs, health, notifications, share, shares)✓,KNOWN_UNDOCUMENTED_GROUPS matches the spec (no stale entries)✓, 18 core groups documented ✓, OpenAPI 219 paths ✓, README links resolve ✓. - Recorded note (rule 29) — the ONE remaining FAIL is by-design, not a defect:
ROADMAP.md Phase 15 boxes are ALL ticked — run in default mode only while Phase 15 is in flight. Phase 15 (Production Hardening) is genuinely complete (7/7 DoD boxes ticked), so the DEFAULT (in-flight) mode is SUPPOSED to trip now; the COMPLETION gatenode scripts/docs-completeness.mjs --require-phase-15-tickedpasses check #3. This is the gate telling the operator the release is complete and the default in-flight mode should be retired — I did NOT un-tick a roadmap box or special-case the gate to silence it (that would falsify the state). Recorded rather than guessed-at. - Affected:
packages/whiteboard/README.md(new),scripts/docs-completeness.mjs(removed the staletemplatestracked-gap entry).
T-184 — Post-P8 core-suite repair: the 4 pre-existing pages.integration.test.ts reds
- Status: DONE (green, 2026-10-12) · Deps: none
- Why: These 4 failures were carried through P1–P8 as "pre-existing (NOT this change)" — repeatedly worked around, never root-caused. With P0–P8 complete this is the natural next increment: turn the core page suite green. Each failure was classified as genuine bug vs stale assertion against the spec (rule 29: follow the architecture, record the conflict — don't guess) before any edit.
- Finding — 1 genuine bug (implemented) + 3 stale assertions (fixed):
grandparentIdwas never maintained — a real, spec-required gap (FIXED, code). Spec TASK-0035 (docs/tasks/phase-03-pages.md): thePagemodel carries "grandparentIddenormalized" and "grandparentIdmaintained by the service (parent's parentId) on create/move". The column exists in the schema + live DB, but 0 reads and 0 writes in app code —create()setparentIdbut never the grandparent, andupdate()'s reparent path never updated it. The nested-page test (gc?.grandparentId).toBe(parent)) was RIGHT; the implementation was missing. Fix: computegrandparentId = parent.parentIdincreate()(the parent row is already fetched viaassertInWorkspace) and inupdate()'s reparent branch (the new parent is already fetched for the cycle check), and write it in bothpage.create/page.updatepayloads.parent.parentIdwas already inPAGE_SELECT, so no select change. The move (reparent) test was strengthened to pin both halves of "create/move": reparent under a root → grandparentnull, AND reparent under a nested parent → grandparent = the nested parent's own parent (previously the test only assertednullon both sides — a false green that could not have caught the missing reparent write).create root … position "a0"— STALE assertion (fixed test). The position scheme is the current LexoRank (positions.first()→"A", capital; appends viabetween()), not the legacy lowercasea0grid. The test's"a0"predates thebetween()/rebalancePositionsrework. Fixed the expectation to"A"+ a comment.purge: hard deleteusedDELETE /pages/:id?purge=true— STALE assertion (fixed test). The canonical page purge contract isDELETE /pages/:id/permanent(the web trash UI calls exactly this,trash/page.tsx→apiFetch(\/pages/${id}/permanent`, DELETE)). The old?purge=truewas **silently ignored** by the trash route (which soft-deleted and returned 200), so the row persisted andgonewas non-null. **Rule 29 (recorded, not fixed):** this is a cross-resource inconsistency — blocks & files purge viaDELETE /:id?purge=true, pages via/permanent. The page consumer contract (/permanent`) is what the UI + worker retention path use, so I aligned the TEST to the implementation rather than adding a second, competing page-purge route (that would duplicate functionality, violating the Ponytail/reuse rules). Flagged here for the API owner to reconcile the block/file vs page convention in a dedicated task.version … writes a snapshot— STALE assertion (fixed test). The test expected every content-changingPATCHto auto-write aPageVersionsnapshot. Spec TASK-0043 is explicit: snapshots are manual (POST /versions) or auto every 100 block ops only; "the page's counter is NOT bumped by a snapshot (snapshots observe, they don't write the page)". APATCHbumpsPage.version(optimistic concurrency) but creates no snapshot. Rewrote the test to pin the actual contract: two title updates →version +2and zero snapshot rows, then one manualPOST /versions→ one row whosesnapshotis present and whose counter does not move. Rule 29 (recorded): the spec example says the snapshot "aligns withPage.version", but that only holds in its single-step example (each patch→one bump→one snapshot); the page counter is also bumped by non-snapshot writes, so strict alignment is impossible in general. The implementation uses a separate monotonic snapshot sequence (max(PageVersion.version)+1), which is what the E2Econfluence-versions.spec.tsalready pins ([1,2]). The test now asserts that (snapshot version1after two updates), not the unachievable alignment.
- Verification (all green):
pages.integration.test.ts11/11 (was 7/11; the 4 reds green, the 7 others — incl. my T-180 export case — unaffected, incl. the strengthened move test) ·apps/apitsc --noEmit0 ·apps/apiunit vitest 445/445 (create/update blast radius clean) · live E2Econfluence-duplicate-related.spec.ts2/2 on the restarted dev stack (page duplicate/copy + related — the nearest user-surface regression net for the create/move change) ·grandparentIdnow asserted non-null at the grandchild (create) AND at the reparented-under-nested page (move). - Affected:
apps/api/src/modules/pages/page.service.ts(grandparent on create + reparent — the one genuine fix),tests/integration/pages.integration.test.ts(3 stale assertions corrected to the spec-conformant contract). - Live: dev API (tsx one-shot) must be restarted for the
grandparentIdwrite to take effect on the running :4000 process.
T-185 — Search suggest case-sensitivity (genuine bug) + search.spec.ts stale setup
- Status: DONE (green, 2026-10-12) · Deps: none
- Why:
search.spec.tshad 2 carried "pre-existing" failures. Root-causing (rule 29: classify genuine bug vs stale test before editing) found one genuine bug in the search layer and one stale test setup. - Finding — 1 genuine bug (FIXED, code) + 1 stale setup (fixed test):
- Autocomplete returned
[]for any differently-cased title — a real bug (FIXED).buildPrefixBody(packages/search/src/queries.ts) lowercases the prefix and matches it against the case-sensitivetitle.keywordsub-field. An uppercase/mixed-case title (every E2E token isPREFIX-lowercase, e.g.SUGGEST-abc) therefore never prefix-matches its own lowercased token, so theGET /search/suggestendpoint returned[]while the full-textGET /search/queryfound the same page (the analyzedtitlefield lowercases). Live probe confirmed the exact split (full-text: 2 hits, suggest: 0, after the index caught up — not an indexing-lag issue; the dialog's suggest path has no retry grace, so the E2E's 10 s poll still saw 0). Spec violation: search-architecture.md §4.2 mandates a CASE-INSENSITIVE prefix via afactory_prefixanalyzer (standard+lowercase+trim) on atitle.prefixsub-field; the implementedmapping.tsships only the case-sensitivetitle.keyword(the analyzer/sub-field were dropped). Fix (minimal, no reindex): added amatch_phrase_prefixon the analyzed (already-lowercased)titlefield to theshouldclause — it restores the spec'd case-insensitive behavior without rolling the index forward to add the analyzer (impossible in place; analyzer settings are immutable on the livefactory-contentindex; thescripts/opensearch-reindex.tsroll-forward + the spec'sfactory_prefixsub-field remain the recorded upgrade path). Kept the exacttitle.keywordprefix as the fast path for already-lowercase titles and theidprefix. Phrase (not wildcard) semantics keep it tight — all prefix tokens must appear in order (last as a prefix), so a hyphenated token is not over-matched the way a plainwildcardon the text field would be (the analyzer would splitSUGGEST-abcinto separate terms). filter results by typehit a non-existent route — STALE setup (fixed test). The test created a database viaPOST /workspaces/:id/databases, a route that does not exist → 404 → the whole test aborted. A database is a page oftype: "database"(POST /workspaces/:id/pages— the canonical path, used byai-database.spec.ts'screateDbViaPage, which already records this exact conflict). The page's kind surfaces as thedatabasesearch type facet, so the type-filter assertions still hold. Fixed the setup to the correct route.
- Autocomplete returned
- Verification (all green):
packages/searchunit 102/102 (was 101/101; +1 newbuildPrefixBodycase-insensitivity assertion) · live probe: suggest now returns hits for an uppercase token (2 in ~2 s) ·search.spec.ts4/4 (was 2/4: "filter results by type" + "suggestions appear while typing" both green) ·confluence-search-sort.spec.ts2/2 (no regression) · dev API restarted to load the@factory/searchsource change (the shared package is source-first; the running tsx process had the old query cached — the no-op probe before restart confirmed it). - Affected:
packages/search/src/queries.ts(buildPrefixBody— the genuine fix),packages/search/src/queries.test.ts(+1 case-insensitivity assertion),tests/e2e/specs/search.spec.ts(database setup → correct route).
T-186 — pages.spec.ts + editor.spec.ts: testid-obsolete, superseded (recorded, not rewritten)
- Status: RECORDED (superseded), 2026-10-12 · Deps: none
- Why: Two more carried "pre-existing" E2E failures. The
/p/URL the old specs used is dead (the real page route is/w/{ws}/page/{id}— every working confluence-* spec uses it;presence-cursors.spec.tsdocuments that there is "NO /p/ route and no middleware rewrite"). I aligned the URLs first (correct and necessary), but the suites still fail 22/22 — for a deeper reason. - Finding — testid obsolescence, not a product bug (recorded, per the
blocks.integration.test.tsprecedent): 30 of the 33data-testids these two specs reference no longer exist in the web app (sidebar-new-page,subpage-create,slash-menu-item-*(7),page-title,page-icon,icon-picker,page-cover,cover-picker,cover-color-*,page-context-menu,context-menu-*(4),move-dialog/move-dialog-confirm,page-versions/page-version-entry/page-more-history/page-more-menu,block-context-menu). The current shell reworked the page-creation, slash-menu, title/icon/cover, context-menu, move, and versions surfaces (confluence work P1–P8); the app now creates pages via the API in the working specs and pins the same user flows with the current testids. - Why not rewritten: Rewriting 22 tests against the new testid surface would (a) duplicate coverage the green confluence-* specs already pin (page tree/versions/editor are covered by
confluence-versions.spec.ts1/1,editor-table-toc.spec.ts2/2,confluence-editor-table-tocfamily, plus the API-driven confluence-* suite), and (b) is a non-mechanical surface re-derivation (the old specs assume a sidebar "new page" button + per-item slash-menu testids that the new shell expresses differently). That is the same shape as the recordedblocks.integration.test.ts(old API contract, needs the area owner's sign-off on the new contract) — so it is recorded, not silently fixed. - The URL fix is kept: the
/w/{ws}/p/{id}→/w/{ws}/page/{id}alignment inpages.spec.ts+editor.spec.tsis correct and necessary (the old route 404s); it is committed as part of this record even though the suites remain red on the testid drift.comments.spec.ts+performance.spec.tsreceived the same URL alignment (they also used/p/). - Affected (URL alignment only):
tests/e2e/specs/{pages,editor,comments,performance}.spec.ts(/p/→/page/). Recorded, not rewritten:pages.spec.ts,editor.spec.ts(testid-obsolete; superseded by the confluence-* suite).
T-192 — pages.spec.ts (DONE 8/8) + editor.spec.ts true baseline (13F/1P, recorded surface)
- Status: DONE (pages.spec green, 2026-10-12) · editor.spec baseline recorded · Deps: T-186 (its URL alignment is what made these suites runnable)
- Why: The goal's next increment.
pages.spec.tswas 8-red after T-186's URL fix;editor.spec.tswas expected to be "only the turn test red". Root-causing both (rule 29) surfaced 5 genuine app bugs (fixed in-app), a set of stale/racy tests (aligned to the architecture), and — behind a masked editor.spec — the T-186 recorded surface precisely root-caused. - Genuine app bugs (FIXED, code — each verified by the E2E that exercises it):
- Cover field split (
covervscoverUrl).pageApi.getmappedcoverUrl→coverbutpageApi.updatereturned the bare server row (nocover), whichuseUpdatePage.onSuccesscached into["pages", id]— so after ANY cover pick thepage-coverband unmounted (the cached page hadcover: undefined). Also, CSS-gradient cover tokens fail the API's ZodcoverUrl.url()→ cover tokens are now data-URI SVGs (160×90,btoa'd), which.url()accepts. Fix:pageApi.get+pageApi.updateboth normalizecover: coverUrl ?? cover ?? nullat the boundary. E2Eicon + covergreen. - Page tree never rendered a child created after its parent loaded.
hasChildrenwas derived from the stale entry-levelhasMore/childrensnapshot, not the node's own loaded children — a child created after the parent's tree row mounted never rendered (the nested test'ssidebar-page-{id}assert could never pass). Fix:hasChildren = children.length > 0 || entry.hasMore || (expanded && isLoading)wherechildrencomes from the node's ownchildrenQuery. E2E nested green. - Context-menu Trash didn't reflect in an already-mounted Trash view.
useDeletePage.onSuccessinvalidated the page tree but not the trash list → a context-menu "Move to Trash" left the Trash view stale. Fix: also invalidate["workspaces", wsId, "trash"]. E2Etrash → restore → purgegreen. - Spec-mandated versions UI missing (TASK-0043 Phase 3 = MANUAL snapshots).
POST /pages/:id/versions { note? }existed on the API but the history popover had no snapshot button. Fix:pageApi.snapshotVersion()+ "Snapshot now" (version-snapshot-button,canEdit= non-guest) inhistory-popover.tsx. (Auto-snapshot on a 100-op counter is Phase 4 — recorded, not built.) E2Eversion snapshotgreen. - Title commit race — a real data-corruption bug (the big one). The title contenteditable committed on every keystroke (
onInput→updatePage.mutate), firing N concurrentPATCH /pages/:idwith partial titles ("C","Ch","Chi","Chil",…"Child Page"). The server applies them in arbitrary order — a live screenshot showed the UI title "Child Page" while the server/breadcrumb held the intermediate "Chil" (an earlier partial overwrote the later full commit). This affects every real rename, not just tests. Fix: debounce the commit (one per 600 ms typing burst) + flush on blur/Enter/Escape/unmount (soft-nav unmount flushes so the last-typed value still wins); the commit targets the routepageId(always current —page?.idlags the load). Adata-page-idattribute was added to the title element as the deterministic "child page is loaded" gate for E2E (the persistent contenteditable's text is unreliable as a gate). pages.spec then stable: 8/8 across 6 consecutive runs (pre-fix: flaked 3 of 5 runs).
- Cover field split (
- Stale/racy tests (aligned to the architecture — app correct):
- nested test raced the
router.pushclient-nav (typed "Child Page" into the still-mounted PARENT title → "ParentChild Page Page"). Aligned: click → poll the server for the child row (the create commits before the push) → hardpage.goto(childId)(full document load) → gate ondata-page-id→ type → sidebarsidebar-page-{id}(stable node testid; a title substring races the per-keystroke tree-refetch churn, which transiently shows "Chil") → poll the server until the row's title settles (the commit burst lands out of order on the wire; with the app fix the final value always wins, but the poll absorbs the settle time). - move/restore/purge asserted server state while the UI dialog/row was still mid-transition → added UI-side-effect gates (dialog
not.toBeVisiblebefore the GET; rowtoHaveCount(0)before the 404). - favorite fixture "Favorites Space" collided with the "Favorites" nav label via
getByText's case-insensitive substring matching → renamed to "Star Space" (the fixture's own label was the bug). - versions test assumed Phase-4 AUTO snapshots after an edit (TASK-0043 defers auto-snapshot) → aligned to the Phase-3 manual "Snapshot now" (×2, count 1→2).
- trash block-survival asserted a fixed block count of 1, but page create seeds a title block (content + title = 2) → assert on block content text ("precious content" survives), not count.
- nested server checks called the non-existent
GET /pages/:id/children(404 →.findcrash) → the documentedGET /workspaces/:id/pages?parentId=({ data }envelope); block-list unwrapped viablocks.data.
- nested test raced the
- editor.spec.ts — the "only the turn test" expectation was wrong; the true baseline was MASKED by two harness bugs (both FIXED, test-only):
- 401 on every test (S10 auth-model violation).
openFreshPagecreated the workspace/page viapage.request— a cookie-only context — but the S10 model is Bearer-only (the access token is JS-memory on the web client, header on API contexts; the httpOnlyfactory_refreshcookie is the session source, not a direct-auth surface for these calls). EveryPOST /workspaces401'd → 14/14 red before any app UI loaded. Fix: build a Bearer context the same way theapifixture does (freshLoginToken+bearerApi, now exported fromfixtures.ts— reuse, not duplication). - Stale wrapper destructuring (D4-repair casualty). After the 201, the helper read
{ workspace }/{ page }from the responses — but both endpoints return bare objects (workspaces.service.create→{ ...workspace, role };page.service.create→{ ...page, databaseId, blockIds }), exactly as the sharedcreateWorkspace/createPagehelpers' own comments document ("the oldbody.workspaceread was undefined and crashed every consumer").openFreshPagewas never D4-repaired →TypeError: Cannot read properties of undefined (reading 'id'). Fix: read the bare shapes.
- True baseline (post-harness-fix): 13 failed / 1 passed. Root cause of the 13 (a failure screenshot is decisive): a fresh page's only top-level block is the seeded
isTitleparagraph (verified live:POST /pages→ blocks list = 1 row,type PARAGRAPH, properties.isTitle, content = the title), and the render loop maps all ofsorted— soblocks.first()is the title block, there is no empty content block, and the slash menu (which triggers only on an empty block start) never opens. The 1 "pass" ("type a paragraph") passes by accident: it types into the title block and asserts the first block contains the text. The title also renders twice (headerpage-title+ body block). This is the T-186 recorded surface (testid-obsolete + title-block-in-body), now precisely root-caused. Classification (rule 29): the render-list question is an architectural decision — the app already treatsisTitleas non-content in two places (contentBlockCountexcludes it; the writing-action handler skips it) and the headerpage-titleis the canonical title, so filtering it from the render loop is the consistent reading; BUT removing the only body block leaves zero content blocks on a fresh page (the server seeds no empty paragraph), which changes the empty-state/AI-prompt behavior and the header↔body title relationship — a server+web+spec change, not a one-line repair. Decision: recorded, not changed this increment (rule 16 — no silent behavior removal; the same user flows are pinned green byeditor-table-toc.spec.ts2/2 +confluence-versions.spec.ts1/1, re-verified this round; fixing the 13 tests requires the title-surface decision + a rewrite, which is the T-186 "non-mechanical surface re-derivation" already recorded). The 2 harness fixes are KEPT (they are unambiguously correct — S10 Bearer + D4 bare shapes — and are a prerequisite for ANY editor E2E to run at all).
- 401 on every test (S10 auth-model violation).
- Web tsc:
tsc --noEmittotal = 82 errors, all in known-debt areas (database-page-view, share/comments/batch-queue, test files). The 2 errors in files touched this round (page-view.tsx:1284DraggableAttributes→gripProps;page-tree-node.tsx:271read-only ref assign) were verified pre-existing via diff-hunk analysis (neither line is in this round's hunks: page-view hunks at L34/332/791/1075/1298; page-tree-node's only functional change ishasChildren). Zero new tsc errors. - Evidence:
pages.spec.ts8/8 — 6 consecutive green runs + 3 more single runs, after the title-commit fix (pre-fix: flaked 3 of 5).editor-table-toc2/2,confluence-versions1/1 (supersession re-verified). No git commit (dsh-sync auto-commits). - Affected:
apps/web/src/lib/api.ts(cover mapping get+update,snapshotVersion),src/hooks/use-pages.ts(trash invalidation),src/components/editor/page-view.tsx(cover tokens,data-page-id, title debounce/flush),src/components/layout/page-tree-node.tsx(hasChildren),src/components/layout/sidebar.tsx(autoExpandIds),src/components/editor/history-popover.tsx(Snapshot now),src/components/layout/topbar.tsx(canEdit),tests/e2e/specs/pages.spec.ts(8 tests aligned),tests/e2e/specs/editor.spec.ts(harness: Bearer + bare shapes),tests/e2e/fixtures.ts(exportedbearerApi/freshLoginToken). - Follow-up surfaced (T-194): the editor title-surface decision — (a) filter
isTitlefrom the render loop + the server seeds an initial empty paragraph (or the app auto-creates one client-side) + header/body title unification, or (b) keep the body title block and fix the editor E2E contract to target the real surface. Either way the 13 editor.spec tests are a rewrite against the decided surface (T-186 scope), not a repair.
T-193 — production next build for apps/web (DONE — builds + served in prod mode)
- Status: DONE (2026-10-13) · Deps: T-190 (current post-override lockfile)
- Why: The Oct-12 entry recorded the prod build as broken in this environment (Node 24 → React-18 prerender
useContextcrash on every static page, noprerender-manifest.json; Node 20 →ERR_UNKNOWN_FILE_EXTENSION: .tsloadingnext.config.mjs's source-first@factory/sharedimport) and left the live web onnext dev. Re-attempting it (rule 29 — investigate, don't trust the record) with a controlled setup: the dev server stopped (itsnext-serverchild re-parented to init and was still holding :3000 — the exact T-136-recorded trap), clean env (repo.env+NODE_ENV=production+ the three deployed-originNEXT_PUBLIC_*), current node_modules (post-T-190). - Finding (the repro fails to reproduce):
next buildon Node 24.15.0 exits 0 — full route table (all○static +ƒdynamic),BUILD_IDwritten,prerender-manifest.jsonpresent with every static page prerendered (the artifact whose ABSENCE defined the old crash). Node 24's type-stripping also loads the source-first@factory/shared/security-headersimport innext.config.mjsfine — the Node-20 config crash is moot on the system Node. Classification of the Oct-12 failure (rule 29 — recorded, not guessed): it occurred withnext devserving from the SAME.nextdirectory (dev compile + build writing concurrently) and/or on the pre-T-190 node_modules; neither confounder is present now, and the crash does not reproduce. The "built under conditions not reproducible here" now has a reproducible condition: stop the dev server → build →next start. - Serving change (the point of the exercise): the live web is now
next start -p 3000(production mode) instead ofnext dev— boot 442 ms vs 3.8 s, compiled server, no dev compile-on-request.next startruns as anext-serverchild of thenode …/nextlauncher; BOTH must be killed on relaunch (group-kill alone misses the re-parented child — the recorded trap). The restart script (~/.dsh-scratch/stack/start-stack.sh, outside the repo) now kills the recorded process group AND the :3000 port holder before relaunching, and runs api/worker/realtime inNODE_ENV=production(web is the one forced back to development, becausenext dev+NODE_ENV=production500s on every page — Next 14 edge-runtime sandbox: "Code generation from strings disallowed for this context" — recorded for future maintainers). - Verification (all green):
tests/security100 passed / 2 skipped / 0 failed against the prod-served web (live-web CSP tests assert the deployed-origin policy the compiled server sends; cookie-flags green — see T-195, which this same restoration surfaced) · E2E chromium-desktop:confluence-versions.spec.ts1/1 +editor-table-toc.spec.ts2/2 (3/3, 7.3 s) againsthttps://notion.filps.comon the prod build · live checks:/→ 307 →/login200,/api/v1/healthok, S3 shim 403 anonymous (by design), CSP/XFO/nosniff headers present on 200/redirect/404. - Residual (recorded, NOT a T-193 DoD gate): one
chromium-mobilerun of the versions project failed once (screenshot: versions panel context) while the desktop run of the same test passed; the pinned-green evidence for T-150/151 is the desktop project (all prior green runs were chromium-desktop), so this is treated as a mobile-project layout/flake to watch, not a prod-build regression. Re-run status: INCONCLUSIVE — the mobile-only re-run (2026-10-13) hung at 0% CPU in a TTY-less background job (the config'shtmlreporter opens a GUI browseron-failure, which cannot in this env — useCI=1/reporter=listwhen running E2E headless-in-background). To be re-verified on the mobile project from a TTY/CI before any escalation. - Affected: no source change (the build fix is operational: stop dev → build → start; the only code touched this round is T-195's cookie fix).
docs/implementation/FEATURE_TRACKER.md(this row + entry).
T-195 — genuine prod-only cookie bug: clear cookie missing Secure (FIXED)
- Status: DONE (2026-10-13) · Deps: none (surfaced by the T-193 stack restoration)
- Why: Restoring the live stack to
NODE_ENV=production(so the API sets aSecurerefresh cookie — the contracttests/security/auth-paths.test.tsasserts) made the "clear Set-Cookie" test go red in a way it had never been seen:Secure (NODE_ENV=production): expected false to be trueon the logout/clear path only, while login + rotation were green. Rule 29 (genuine bug = fix, not paper-over): traced toapps/api/src/modules/auth/auth.service.tsclearRefreshCookie, which built the clear cookie with a hardcodedsecure: falsewhile the set path (setRefreshCookie) is env-driven (env.NODE_ENV === "production"). - The bug (real, prod-only, session/security): on logout/invalid-refresh the API emits a
Set-CookiewithMax-Age=0to deletefactory_refresh— but a cookie is only deleted if the Path/Domain/Secure attributes match the one that was set. In production the set cookie isSecure; the clear cookie was not, so browsers treated them as different cookies and the live session cookie was never actually deleted — it persisted until itsMax-Age/expiry. Dev (plain HTTP, noSecure) masked it because both set and clear happened to agree (neitherSecure). This is exactly the "no silent data removal / session must end" concern the S10 auth model exists to guarantee. - Fix (one line, root cause, shared boundary):
clearRefreshCookienow readsloadEnv()and passessecure: env.NODE_ENV === "production"— the SAME env-driven flag as the set path, so set and clear always share attributes. No new abstraction, no test change (the test already encoded the correct contract; the code was the one out of line). - Verification (all green):
tests/security100 passed / 2 skipped / 0 failed — the "clear Set-Cookie … Max-Age=0" cookie-flag test now green and would go red again if the flag regressed (assertsSecure== isProd on the clear cookie) · login + rotation cookie-flag tests green · CSRF route-walk 15/15 · no API code path beyondclearRefreshCookietouched (oauth controller only READS the cookie, never sets it — verified). - Affected:
apps/api/src/modules/auth/auth.service.ts(clearRefreshCookieonly). No migration, no DTO, no frontend change (the cookie is httpOnly; the web never reads it).
T-187 — database-engine filter area: stale FilterOperator enum (genuine type bug, fixed)
- Status: DONE (green for the filter area, 2026-10-12) · Deps: none
- Why: The recorded red was "database-engine formula/* 11 tsc errors". Root-causing (rule 29) found the package actually fails
tsc --noEmitwith 54 errors across 13 files (the "11 formula" note under-counted — it was only whattests/security's tsc gate surfaced). The errors split into two distinct root causes; this increment fixes the clean, spec-anchored filter one and records the rest as T-188 (below). - Finding — 3 filter-area bugs (all FIXED):
filterOperatorSchema(shared DTO) was stale — a genuine type/validation bug (FIXED). The sharedFilterOperatorenum listed 11 leaf operators, but the TASK-0067 filter engine implements the full 21-operator matrix (docs/tasks/phase-05-databases.md #TASK-0067:isChecked/isUncheckedcheckbox,has_files/has_no_filesfile,isBetween/isBefore/isAfter/isOnOrAfter/isOnOrBeforedate,doesNotContaintext/multi-select/relation — all already inpackages/database-engine/src/filter/index.tsand used by its own tests). Two real consequences: (a) everyop === "<leaf>"comparison in the engine was a TS2367 "no overlap" error (12 in filter/index.ts); (b) — the important one — the API'sfilterNodeSchemasilently rejected valid filter trees at the wire boundary (a checkboxisCheckedor filehas_filesfilter 400s) even though the engine fully supports them. Fixed by extending the enum to the full 21-op set the engine implements (the engine's operator strings are the wire format — the web builder'sis_empty/is_not_empty/starts_withusage confirms it; the spec's camelCaseisNot/startsWithvariants appear in no code, recorded as a naming-conflict note).toComparablereturn type missingDateRangeValue(FIXED). Thedate_rangecase returns{start,end}, which the narrowunknown[] | nullunion rejected (2 × TS2322). AddedDateRangeValueto the return type (already imported).- Duplicate barrel export
GroupNode(FIXED).src/index.tsre-exports bothfilter/index.jsandquery/group.js, and both declared aGroupNode(TS2308 ambiguous re-export). The query-side one was a redundantexport type { GroupMeta as GroupNode }alias (line 207) with zero consumers —GroupMetais already exported by name, and the web'sisGroupNodeis an unrelated local type guard. Deleted the dead alias (deletion beats addition); the filter's realGroupNodeand the query'sGroupMetaboth remain exported.
- Verification (all green / no regression):
packages/database-enginetsc --noEmit54 → 37 (all 17 filter-area + barrel errors cleared; the filter/ + index areas are now type-clean) ·packages/sharedtsc --noEmit0 ·apps/apitsc --noEmit0 (the shared-DTO change has no blast radius —filterOperatorSchema/FilterOperatorare consumed only by the engine + the DTO) · database-engine unit tests: same 3 pre-existing behavioral failures, no new ones (86/89 — the 3 fails are in untouched areas and are type-annotation-invariant: az.enumaddition + a return-type widening + deleting a dead type alias cannot change date-window math, grouping counts, or option filtering). - Affected:
packages/shared/src/dto/database.dto.ts(enum 11→21 ops),packages/database-engine/src/filter/index.ts(toComparablereturn type),packages/database-engine/src/query/group.ts(deleted the deadGroupNodealias).
T-188 — database-engine remaining tsc reds (37) + 3 pre-existing unit failures (DONE — all classified, zero genuine code bugs)
- Status: DONE (green, 2026-10-12) · Deps: T-187
- Why: After T-187 the package failed
tsc --noEmitwith 37 errors in the formula/query/rollups/values/views cluster plus 3 pre-existing unit-test failures (behavioral, not type). Classifying per rule 29 (genuine bug = fix; stale test = align to architecture; env = record) found one root-cause representation-level TYPE error in the formula engine (a type was lying about the runtime representation — the code was right) and three stale tests (each asserting a contract the architecture deliberately does not provide). No engine behavioral bug. - Classification + fixes (rule 29):
EValuearray representation — GENUINE TYPE error, fixed (the bulk: 11 errors informula/). At runtime anEValuewitht:"array"carriesEValue[](the walker:ArrayLiteral→node.elements.map(e => walk(...))), butEValue.vwas typedFormulaValuewhose array branch is rawFormulaValue[]. SinceEValue[]andFormulaValue[]are disjoint, everyv as EValue[]cast was a TS2352 "may be a mistake" and everyreturn { v: EValue[], t:"array" }a TS2322. Fix (formula/types.ts): introducedEValuePayload = number | string | boolean | null | EValue[](the array branch = typed child values, honest about the runtime) and retypedEValue.v: EValuePayload.FormulaValue(the raw wire/AST shape) is untouched —toWire/API JSON round-trips unchanged. Ripple:evaluator.tsliteral-widening annotated(a): EValue;functions.tsnormalizePropertyarray branch maps(x): EValuewithString(x)(was{v: x as any}— a real honesty improvement) + terminalreturn err(...)(TS2366 undernoImplicitReturns);formula/index.tsevalFormulaValuenow demotes typed→raw via a small recursivedemote()(EValue[]→FormulaValue[]) instead of returningvalue.vdirectly (the array branch is no longer literally aFormulaValue). Security note (rule 18): noeval/new Functionintroduced; the closed-function-table dispatch, op/timeout budgets, and error surface are all unchanged — the change is type-level + 3 minimal behavioral lines, each golden-tested (formula.test.ts29/29 incl. array + timeout cases).values.test.tsmulti_select orphan — STALE test (align to architecture). The test assertedfromWire(["opt-a","bogus","opt-b"])→["opt-a","opt-b"](orphan dropped). But the TASK-0065 R1 codec interface isfromWire(json: unknown)— config-free (noconfigparam), sofromWirecan only do shape normalization and cannot validate againstconfig.options. The spec's "orphan option id → treated as empty, never an error" lives at the config-awaredisplay/comparelevel — where the implementation already drops orphans (and where the siblingselectorphan test already pins it:display("opt-gone", selectConfig)→""). Fix: the test now assertsfromWirekeeps all three (shape) anddisplay(["opt-a","bogus","opt-b"], selectConfig)→"A, B"(orphan dropped, never errors) — pinning the spec behavior at the correct level. Also fixed 3 stalecompare(a,b)/display(v)arity calls (missing the interface-requiredconfigarg).filter.test.tsdate=+ "this week" — STALE test (self-contradictory fixture).relativeWindow("this week")is relative toctx.now(ISO Monday-start, Sunday = last day). The modulectx.now = 2025-06-15is a Sunday, whose week is Mon 06-09 → Sun 06-15 — so row date 2025-06-16 (a Monday, the next week) is correctly NOT in it; the test's comment ("06-16 in Mon 06-16 week") described the week containing the row date, contradicting the fixture's ownnow. The engine is internally consistent and ISO-correct. Fix: the window assertions now use a localctxwithnow = 2025-06-17(Tue, inside Mon 06-16 → Sun 06-22), which pins the intended behavior:="this week"→ true,="last week"→ false.query.test.tsgrouped count2 vs 3— STALE test (stale expectation).runRecordQueryexcludes archived rows before grouping; the only status-less row (r5) is archived, so the non-archived base set (r1–r4) all have a status → 2 groups, no empty group. The3expected the empty group that only appears when grouping the FULL row set directly viagroupRecords(which the sibling "select grouping" test already pins:["todo",2],["done",2],[__empty,1]). Fix:toBe(3)→toBe(2)with a comment explaining the archived-row interaction.exactOptionalPropertyTypes+ stale fixtures — aligned. Root tsconfig hasexactOptionalPropertyTypes: true; bareT?fields rejectT | undefinedassignments. Widened (declaredT | undefined, matching theFormulaErrorfield pattern):formula/types.tsFormulaError.pos/property+FormulaContext.propertyTypes;values/types.tsEvalContext.actorUserId(the query pipeline assigns astring | undefinedfield). Test fixtures brought onto the current signatures:query.test.tsrow()valuesRecord<string, PropertyValue>+ 7direction-missingSortSpec/GroupSpecfixtures (the shared zod.default("asc")infersdirectionrequired in the output type; the engine's pure functions don't apply the zod default — the API does at parse time);rollups.test.tsMap<string, PropertyValue | undefined>;views.test.tsrow()values;values/registry.tsfilescodec (TS 5.6 doesn't narrowunknownthrough a barenever-statement guard → positive-guard block +FileValue-typed map,displaydowncast) andmulti_selectdisplay filter predicate.query/sort.tsvalidateSortKeyswidened toreadonly SortKey[](matchessortRecords, only reads).
- Verification (all green):
packages/database-enginetsc --noEmit0 (was 37) · unit 89/89 (was 86/89; the 3 reds now pass, no regressions) ·apps/apitsc0 + unit 445/445 (the API's live formula path loadspackages/database-engine/src/formula/index.tsdirectly —loadFormulaEngine()— so the changed files are the production path; the 2apps/webbatch-queue.tstsc errors are pre-existing baseline, unrelated — no database-engine import, not in this diff) ·packages/sharedtsc 0 · integration:ai-database.integration.test.ts8/8 — the live engine-validated formula happy path (if(prop("Title") == "Done", 100, 0)→ samples[100, 0, 100]through the real evaluator) + hostile parse/budget paths all pass. - Follow-up surfaced (T-191): fixing
databases.integration.test.ts'ssetupDb(the non-existentPOST /workspaces/:id/databases→ canonicalPOST /pages {type:"database"}) unmasked a whole-file stale contract in that suite (records CRUD name-keyed{record}; thequery()helper's{records,total}+offsetGET contract vs the real cursor-paginatedPOST /databases/:id/query{data:{records,totalCount,nextCursor}}; the filterchildrendialect vs the API'sconditions+OP_ALIASES). It is a separate pre-existing red (never reached before the 404), distinct from T-188 — recorded for the next increment (T-191), not silently expanded into this one.
T-191 — databases.integration.test.ts: align the whole stale suite to the live API (DONE — 10/10)
- Status: DONE (green, 2026-10-12) · Deps: T-188 (its
setupDbfix unmasked this) - Why: A pre-existing stale test file (predating the TASK-0124 query-DSL repair + the canonical record CRUD surface), masked for months because every test 404'd at
setupDbbefore reaching any assertion. Classification (rule 29): stale test, aligned to the architecture — the live API is the truth (TASK-0124 §6 + the controllers); no API change was made. Five root causes:setupDbduplicate property (409). A fresh database auto-seedsTitle+Status(SELECT: To Do/In Progress/Done) + aTABLEview (DEFAULT_DB_PROPERTIES, constants.ts:122). The suite added its ownStatus→409 CONFLICTon duplicate name. Fix: on 409, reuse the seeded property andPATCH /databases/:id/properties/:piditsconfigto the options the test's records/filters rely on (todo/done); other names still POST.- Records CRUD contract.
POST /databases/:id/recordstakespropertieskeyed by propertyId (name keys →400 Unknown property: Title) + a separatetitlefield, and returns the DTO directly ({id, title, properties: {propertyId→value}}) — not{record}. Update/delete are top-levelPATCH/DELETE /records/:id(there is no/databases/:id/records/:idroute → the suite's URL 404'd). Fix:addRecordhelper resolves name→propertyId (title name →titlefield) so call sites stay readable; update uses propertyId; both mutation URLs corrected. The "unknown property → 400" test now genuinely asserts 400 (the 404 was the bad URL, not the record lookup). - Query endpoint + DSL. The
{records,total}+offsetGET contract doesn't exist. The real surface isPOST /databases/:id/query(TASK-0124 §6) →{data:{records,totalCount,nextCursor}}(records branch) /{data:{groups,totalCount,nextCursor}}(group branch), cursor-paginated (no offset),filtera native tree: branches{op:"and"|"or",conditions:[]}/{op:"not",conditions:[one]}, leaves{propertyId,op,value}witheq/gt/lt/...ops (OP_ALIASES; the engine's=/>/<and the web'schildrenkey are both rejected). Fix: thequery()helper now POSTs the §6 body; all fixtures useconditions+eq/gt/lt; SELECT filters stay name-based (the API canonicalizes option name → deterministic id on both write and read —resolveOptionRef, so a name filter matches name-written rows). - Group shape.
GET ?group=(string-encoded) →POST {group:[{propertyId,direction}]}; asserts the documented{groups,totalCount}with per-groupkey.label+count(todo:2, done:1). - 10k perf floor.
POST /queryis a bounded read (take: 5000, TASK-0135) — over a 10k settotalCountreports the cap (5000), not 10_000; the stale10_000expectation pinned the wrong thing. The stale "filtered slice" sub-assertion is also un-runnable as written:seedRecordsstores titles as a{title}object with no page row, sotoText/actualScalarrender"[object Object]"and acontainson the TITLE matches 0 rows (a seed-shape defect, out of scope — the sharedseedRecordshelper is used by other suites and is not changed here). Fix: pin the documented bound (total === 5000,records.length === 50, cursor set,ms < 2000), drop the seed-shape-dependent slice, rename the test to "within the documented bound".
- Rule-29 record (spec vs implementation):
docs/tasks/phase-05-databases.mdspec describes record archive (POST .../records/:rid/archive→ setsarchivedAt, excluded from default queries) — the implementation has no archive endpoint;DELETE /records/:idis a hard delete (removes the row + its 1:1 page + blocks, cascades properties/links). The test's "soft delete /archivedAtnot null" assertion was stale; it now asserts the implemented hard delete (row and page gone,total === 0). The unimplemented archive endpoint is recorded, not silently dropped, and not added here (no spec'd client uses it; adding it is a feature, out of repair scope). - Evidence:
tests/integration/databases.integration.test.ts10/10 (was 0/10 pre-T-188; 2/10 after T-188 unmasked the rest; 4/10 after the first alignment pass; 9/10 after the delete fix; 10/10 final) — run via the standard Testcontainers recipe (INTERNAL_TEST_SEAM=1, ~28s). No API code touched (this increment is test-only; the API was already the correct, spec-named surface). The 10k test logs[perf] 10k-record page query = <ms>ms (soft floor 2000ms)for the perf floor.
T-189 — tests/security live-app reds: classify env-vs-real + fix the two real ones
- Status: DONE (green, 2026-10-12) · Deps: none
- Why: The carried "pre-existing" security reds (headers exact-CSP, deps lockfile, auth-paths cookies). Ground truth:
headers3 failed,deps2 failed,auth-paths15/15 green (the "cookies" red was flaky/env — auth-paths needs no change). Classifying per rule 29 (env-dependent = record; genuine = fix): - Findings — 1 env-dependent (recorded) + 2 stale/genuine (fixed) + 1 flake (recorded):
deps.test.ts"license allowlist" + "audit gate" — ENV-DEPENDENT on pnpm (recorded, no code change needed). Both failed withSyntaxError: Unexpected end of JSON inputbecausepnpmis not on PATH in this sandbox shell —execFileSync("pnpm", …)returns empty stdout. Proven env-only: with acorepack pnpmshim on PATH the license allowlist passes immediately (all licenses in the tree are allowed/exceptioned). CI has pnpm (the repo'spackageManagerfield) so this is green there.deps.test.ts"runtime audit gate" — GENUINE: 3 NEW high advisories (fixed per the documented exception process). With pnpm available, the audit gate found 3 high advisories published after the 2026-10-03 baseline:GHSA-Q7RR-3CGH-J5R3(Prometheus exporter crash —exporter-prometheus@0.57.0via@factory/observability),GHSA-45RX-2JWX-CXFR(JaegerPropagator DoS —propagator-jaeger@1.30.0via@factory/observability),GHSA-68FV-2MGG-JV7Q(source-map-js event-loop DoS —source-map-js@1.2.1vianext>postcss, patched>=1.2.2). The repo's own process (the test's failure message +license-allowlist.md§4.2 note: "add a dated exception … OR fix the dep") was followed: all 3 added as dated exceptions (2026-10-12) in bothAUDIT_EXCEPTIONS(deps.test.ts) and the §4.2 doc table (18 → 21). The actual dep fixes are a tracked follow-up (T-190):source-map-js >=1.2.2is a cleanpnpmoverride; the two OTel ones need the@factory/observabilitySDK upgrade (patched only inexporter-prometheus >=0.217.0/propagator-jaeger >=2.9.0). Low practical exposure recorded in the doc (telemetry goes OUT to our own collector, not untrusted inbound).headers.test.tslive-web CSP — STALE test (origin-coupled; fixed). The 3 live-web tests hardcode the dev CSP (connect-src … http://localhost:4000 ws://localhost:4001) and compare it to the live web — but the web is a compiled server whose CSPnext.config.mjsfroze at build time fromNEXT_PUBLIC_API_URL/NEXT_PUBLIC_WS_URL(the deployed originhttps://notion.filps.com/wss://notion.filps.com). The app is correct (env-driven, matches the static test that asserts the config reads those vars); serving a localhost CSP on the public https origin would be the bug. The static policy tests (origin-independent: default-src, XFO, nosniff, referrer, permissions, no-third-party-origin) already passed; only the live comparison was origin-coupled. Fix: compute the live expectation from the sameNEXT_PUBLIC_API_URL/NEXT_PUBLIC_WS_URL(dev defaults → a dev-stack/CI run behaves identically to before), exactly asnext.config.mjsderives them. The non-origin headers are asserted unchanged.auth-paths.test.ts— GREEN (15/15), no change. The recorded "cookies" red was a transient/flake (cookie-flag + CSRF-route-walk + refresh-reuse all pass deterministically against the live stack). Recorded as a flake, not fixed.
- Verification (all green): full
tests/securitysuite 100 passed / 2 skipped / 0 failed (was 5 failed: 3 headers + 2 deps; the 2 skips are by-design ingitleaks-config+images):headers11/11 (the 3 live-web CSP tests now assert the deployed-origin CSP the server actually sends),deps3/3 (audit gate matches the 21 documented exceptions; license allowlist passes with pnpm present; new-high fixture proves the machinery fails),auth-paths15/15 · thedepsenv-only note: on a pnpm-less shell the two pnpm-driven tests error with empty-JSON (documented; CI has pnpm via thepackageManagerfield) · no other suite touched. - Affected:
tests/security/deps.test.ts(3 dated audit exceptions, 2026-10-12),docs/security/license-allowlist.md§4.2 (18 → 21, +2 dated rows),tests/security/headers.test.ts(live-web CSP asserted against the deployed-origin expectation, dev defaults preserved). Follow-up: T-190 — source-map-js>=1.2.2override DONE 2026-10-12 (exception closed; security 100/2/0); OTel pair re-scoped to T-190b (a full OTel 2.x core migration + Prisma 6.x decision — not the 1.3x patch originally assumed; the 2 OTel exceptions stay dated until then).
T-190 — clear the T-189 audit exceptions by fixing the deps (PARTIAL: source-map-js DONE, OTel pair re-scoped to T-190b)
- Status: PARTIAL (2026-10-12) — 1 of the 3 exceptions cleared (source-map-js); the OTel pair is re-scoped to T-190b (a full OTel 2.x core migration, not the 1.3x patch this entry originally assumed).
- Done this round —
source-map-js(the clean patch):- Root
package.json→pnpm.overrides = { "source-map-js": "1.2.2" }(1.2.2 is both the latest AND the patched version). pnpm install— applied the override and regeneratedpnpm-lock.yaml(theoverrides:block +source-map-js@1.2.2resolution appear in the lockfile;node_modules/.pnpm/source-map-js@1.2.2present). Note: the shell exportsNODE_ENV=production, which made the first reinstall skip devDependencies (workspace members'vitest/.binlinks went missing); re-running withNODE_ENVunset restored them. Recorded so a future maintainer who installs in this shell isn't surprised by the missing test bins.pnpm audit --prod—GHSA-68FV-2MGG-JV7Q(source-map-js event-loop DoS) is gone from the live high/critical set.- Closed the exception in both places (exact-equality gate, both directions):
AUDIT_EXCEPTIONSintests/security/deps.test.ts(removedGHSA-68FV-2MGG-JV7Q) +license-allowlist.md§4.2 (removed the source-map-js row, 21 → 20). Had I left the now-resolved exception in, the gate would have failed — which is the gate re-arming exactly as designed. - Evidence (all green): full
tests/securitysuite 100 passed / 2 skipped / 0 failed (the 2 skips are by-design in gitleaks-config + images) ·packages/observability(untouched version, reinstalled tree) tsc 0 + unit 41/41 · live stack survived the node_modules relink — api:4000/notion.filps.com200, worker:4003+ realtime:4001/:4002alive (a running process keeps its already-loaded modules;source-map-jsis build-time/next→postcss so the compiled web is unaffected) ·search.spec.ts4/4 smoke E2E. No git commit by the agent (the repo'sdsh-syncautomation auto-commits; the agent runs nogit commit).
- Root
- Web-build side investigation (surfaced while doing the source-map-js build-evidence step — see T-193):
- The source-map-js override itself is safe and effective (security suite is its verification; the dep is build-time/next→postcss with zero runtime effect on the served app). But while proving it with a
next build, I discovered the production web build is broken in this environment (Node-24 React-18 prerender crash; Node-20 source-first config crash) — pre-existing, not caused by the reinstall (reproduces on the pre-override lockfile). I had to rebuild the web to serve the correct origin after an earlier mistaken rebuild, and the only reproducible path in this env isnext dev(the prod.nextfrom Oct 11 can't be rebuilt here). - Net state (verified this round): live web in
next devmode with the correct deployed-originNEXT_PUBLIC_*(APIhttps://notion.filps.com/api/v1, WSwss://notion.filps.com, apphttps://notion.filps.com) — login 200 both origins, workspace + pages fully functional, security headers correct (security suite 100/2/0 re-verified in this exact state). api/worker/realtime all healthy. - Recorded (rule 29 — don't silently change how the live web is served): the dev-vs-prod serving difference is an environment constraint (Node 24 vs repo's Node-20 target; source-first shared package), tracked as T-193. The
source-map-jssecurity fix stands regardless (it is a lockfile/dependency pin, independent of how the web is served).
- The source-map-js override itself is safe and effective (security suite is its verification; the dep is build-time/next→postcss with zero runtime effect on the served app). But while proving it with a
- Re-scoped to T-190b — the OTel pair (Q7RR-3CGH-J5R3 / 45RX-2JWX-CXFR), and why it is NOT a clean patch:
- This entry originally scoped the OTel fix as "roll 0.57/1.30 → the patched 0.2xx/2.x line" (a same-core patch). That was wrong (recorded per rule 29 — investigate, don't guess). Verified against the live registry: the minimum
sdk-nodethat clears both advisories is0.220.0(propagator-jaeger 2.9.0,exporter-prometheus 0.220.0,core 2.9.0);0.219.0still haspropagator-jaeger 2.8.0(NOT clear). That is a full 1.x → 2.x core migration of the whole OTel SDK (core 1.30 → 2.12, extensions 0.57 → 0.223), not a 1.3x patch. - De-risking facts confirmed (read-only):
@opentelemetry/sdk-trace-basestill exists at 2.12.0 (not renamed away, so the code'sBasicTracerProvider/SpanProcessor/ReadableSpanimports stay valid);@opentelemetry/apistays 1.9.1 (sdk-node peers>=1.3.0 <1.10.0); the ioredis/redis instrumentations' latest (0.71.0) is from the 0.223.0 train (all extensions cohere at latest). - The new blocker (a real decision, not just churn):
@prisma/instrumentation@5.22.0(pinned, used by@factory/observabilityfor Prisma span integration) peers@opentelemetry/sdk-trace-base ^1.22+instrumentation ^0.49–0.53— i.e. it is coupled to the 1.x line. A full 2.x OTel train therefore conflicts with the pinned Prisma instrumentation, and resolving it "properly" means bumping Prisma (5.22 → a 6.x line whose instrumentation targets OTel 2.x) — a separate, larger migration (Prisma client + schema + migrations + the whole api), out of scope for a security-exception increment. - Decision (rule 29 — record, don't guess): the OTel pair stays as the 2 dated exceptions (T-189's process, 2026-10-12, low practical exposure — outbound telemetry to our own collector) until T-190b is scheduled as a first-class increment that pairs the OTel 2.x train with the Prisma 6.x decision. Not attempted this round (a half-migrated OTel+Prisma coupling is a broken build and would destabilize the live stack with no way to close the exceptions cleanly).
- This entry originally scoped the OTel fix as "roll 0.57/1.30 → the patched 0.2xx/2.x line" (a same-core patch). That was wrong (recorded per rule 29 — investigate, don't guess). Verified against the live registry: the minimum
- T-190b replay plan (for when it is scheduled): (1) pair the Prisma 6.x bump (client +
@prisma/instrumentation) with (2) rollingpackages/observability/package.jsonOTel to the coherent 0.223/2.12 train (api 1.9.1, core/sdk-* 2.12.0, exporter-* + instrumentation-http 0.223.0, ioredis/redis 0.71.0, semantic-conventions 1.43.0, propagator-jaeger 2.12.0); (3) fullpnpm install(NODE_ENV unset) + rebuild observability + api/worker/realtime; (4) remove the 2 OTel GHSAs fromAUDIT_EXCEPTIONS+ §4.2 (20 → 18); (5) full security suite + observability tsc/unit + a smoke E2E; no git commit. DoD:pnpm audit --prodshows 0 of Q7RR/45RX, gate green, live stack healthy. - Risk note: the two OTel advisories are outbound-telemetry DoS/crash vectors (malicious upstream collector, not inbound user data) — low practical exposure, which is why dated exceptions (not an emergency hotfix) were the correct T-189 interim and remain correct until T-190b. The
source-map-jsone is fully closed this round.
Graphify hygiene (standing rule for this expansion)
- Before editing any area: run the affected-map query for that area
(node script over
graphify-out/graph.json— see IMPLEMENTATION_ROADMAP "cross-cutting" notes; keep a copy of the per-task map in the task's entry above). - After code lands:
graphify update .(no API cost), then re-verify the touched files' affected sets before merge. - The map is a starting point (Ponytail: "grep every caller") — combine
with
grepfor string-based references (event names, route strings, DTO field names) that the AST graph does not capture.
Open risks ledger
| Risk | Mitigation | Owner task |
|---|---|---|
| Whiteboard renderer is new owned geometry code | Property tests + incremental E2E + 2,000-object perf gate | T-168/T-169 |
| Notifications module has a 24-file affected set (hub) | Additive-only changes; no refactors; hub tests stay green | T-157/T-163 |
| Databases module 34-file affected set | Edits confined to controller/query.service/dto (lib/csv-parser.ts, common/csv-import-body.ts) + view ops in database.service.ts (T-166: additive pageSize write + reorder event/audit) |
T-165 (done) · T-166 (done) |
| Migration against live dev data | Upgrade run on DB copy + full integration suite (T-144) | T-144 |
| Backlinks scan cost at scale | Cache + measured upgrade trigger (A-008) | T-140 |