appfeat(inbox,two-factor,marketing): channel filter on query tabs, 2FA policy, host routing
Three in-flight threads plus supporting docs, committed together.
- inbox: the channel badge no longer goes empty on a Gmail-query tab —
participatingChannels stops dropping chat channels when the tab rule is
mailOnly, so an explicit single-channel badge asks the sources it names.
- two-factor: policy module with its own tests, wired through session
verification and auth, with the env and runtime-contract keys it needs.
- marketing: host routing for the signed-out root, with a redirect test.
- docs: inbox channel-filter bug writeup, daily agenda templates,
open-tracking v2 notes, and the 2026-08-30 customer feedback audit.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 1, 2026, 11:53 AMcomponentsfix(ui): a menu marks the chosen row with a tick on the right, never a black dot
The agent workspace's Folder and Default file submenus put a solid filled disc
beside the current value, in a left gutter — shadcn's `RadioItem` default,
inherited without anyone choosing it. It out-weighed the label it was marking,
it sat in the lane that belongs to the option's own icon, and it looked nothing
like the checkbox rows in the same menu, which were already ticking on the right.
One mark, one side, for every "which one(s)?" surface: `Check` on the right in a
lane every row reserves. Applied to `DropdownMenuRadioItem`, its checkbox
sibling, both `ContextMenu` items and the cedar-os copy of the menu; `SelectItem`
and `OptionPicker` were already correct. `radio-group.tsx` keeps its dot — there
the dot IS the control, not an annotation on one.
Written into the design system (crystallized.md → Counts, ticks and ordinals,
plus the panel checklist and CLAUDE.md), and guarded by a test, because the dot
is a one-line regression and nothing else in the suite would notice.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 7, 2026, 1:43 PMconversationsfix: claude reviewJun 5, 2026, 11:21 PMfilesfix(types): close 205 typecheck errors by repairing real client/server drift
apps/mail typechecked against a stale apps/server/dist d.ts (the server's
package.json points "types" at dist), so a chunk of the reported drift was
phantom. Rebuilt it and fixed the four script errors that blocked `tsc -b`
in server, which is what regenerates it.
The rest was genuine contract drift, fixed at the source rather than cast
away — routes that dropped fields their callers still read (agentExecutions,
getUpcomingCalendarEvents' conversationId, createCanvas' description),
schema splits the UI never followed (conversationUsers vs
conversationContacts), and enums that grew server-side but not client-side
(TASK_TYPES, ActionStatus, DateFilterOperator).
Also deletes code that was already dead: an unreferenced sort popover whose
store API is gone, a panel importing a deleted module, the system-skill
metadata UI whose mutation was removed deliberately, and a test asserting a
store method that no longer exists.
apps/server: 4 -> 0. apps/mail: 228 -> 23, all remaining errors being
dependency resolution (zod v3/v4, react-router dev/runtime skew, tiptap
v2 via novel) rather than code.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 2, 2026, 7:45 PMlibfix(mail): a link in a chat thread is a link
LinkedIn and WhatsApp send their words as plain text, so a pasted URL
rendered as characters. Slack linkified only its own <url|label> tokens —
which its composer emits and a bot, an unfurl field or pasted text do not.
Bare URLs are now linkified in both, before the mrkdwn formatting pass so
the parser cannot read a URL's underscores as italics. The splitter is
lifted out of the calendar's LinkifiedEventText into lib/linkify.
Links are painted with the action ramp, not primary: primary is near-black
on light and near-white on dark, the colour of the words around them.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 18, 2026, 9:38 AMmodulesfeat: the transcript names what it did, and the table it made is reachable
A chat that spent twenty calls building a LinkedIn tracker recorded almost
none of it: four different people got connection requests under four
identical "Executing linkedin-write" lines, the table itself never became a
context item, and re-entering the chat reopened the deal instead of the
table. Traced end to end; this is the set of causes.
The transcript:
- Tool labels resolve from the ARGUMENTS, not just the name. Every tool is an
action family now, so the name alone collapses a dozen verbs into one row.
Rows expand to who / where / whether a message went with it.
- A select column's declared option order IS its sort ladder; the tool
description now says so, and the transcript shows the ladder.
LinkedIn caps:
- A send's provider-id lookup gets its own budget (connect/dm/inmail_lookup),
mirroring the send it serves. Charged to profile_view, a day of browsing
refused invitations that had 80/week of connect headroom left.
- Connection requests go out with NO note unless the user asked for one —
enforced at execute time, not just described.
Getting back to what the agent made:
- The table tool files a context chip on create/import, as write-document has
always done; delete pulls it back off.
- A thread remembers what it was displaying (chat_threads.context), so
re-entry restores the table rather than re-deriving the deal.
- A fan-out opens a conversation-scoped table inside its deal's Files tab.
- 'table' is a valid write type on the document family — the tool was telling
the agent to pass a value its own schema rejected, which is what drove a
delete-and-rebuild that orphaned the open document.
The grid:
- Title, then description, then the commands — the order the questions arrive
in; a URL or address in a cell is a real link; a deleted table says so
instead of rendering on from the local Y.Doc cache.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 20, 2026, 11:43 PMtimezonefix(crm): "3h ago", not "Today", for everyone west of UTC
`formatRelativeDate` read two different calendars. The day branch built its comparison from
UTC components — deliberately, so a date-only value stored at midnight UTC does not slide
back a day for US viewers — and said so in a comment ending "BOTH sides must read the same
clock". The sub-24h branch three lines above it asked date-fns `isToday`, which reads the
LOCAL calendar. The one branch nobody re-checked was the one the comment was about.
West of UTC the two disagree for most of the evening. At 04:37Z on the 31st — 00:37 in New
York — a touch from 3h41m earlier is 20:55 on the 30th locally but still the 31st in UTC. So
`isToday` was false, the hour branch was skipped, and the day math, which had both on the
31st, answered "Today". A rep opening a deal after 8pm saw a meeting from that afternoon
labelled as though the hour did not matter.
The UTC day values are now computed once, above both branches, and the hour branch tests
those instead of the ambient zone. `isToday` is gone with it.
── Why the suite could not catch this ──
Every CI runner is UTC, and in UTC the two calendars cannot disagree, so all five existing
tests passed on the broken code. The suite even documented the hole — "keys off the viewer's
LOCAL calendar day by design, so it is not timezone-stable" — right under a test asserting
"3h ago", which is exactly the assertion that is not stable. UTC is not a neutral default for
date code; it is the blind spot.
So there is now a second pass, west of UTC: `tests/timezone/`, run by
`pnpm --filter @zero/mail test:tz` and by its own CI step, excluded from the main run so it
cannot pass vacuously there. TZ has to be set before the process starts — V8 caches the zone
on first use, so neither assigning `process.env.TZ` in a test nor a custom testEnvironment can
move it (I tried both; a guard test in the file now fails if the zone ever stops applying).
Verified the way a regression test has to be: the new suite fails on the old implementation
with exactly "Expected 3h ago, Received Today", and passes on the fix.
Co-Authored-By: Claude Opus 5 <<email>>Sep 8, 2026, 1:51 PM