Skip to content

Cap a reported message's time at the server's clock, so a browser that runs ahead cannot hide what a routine said - #341

Open
kevin9327 wants to merge 1 commit into
CopilotKit:mainfrom
kevin9327:activity-clock-clamp
Open

Cap a reported message's time at the server's clock, so a browser that runs ahead cannot hide what a routine said#341
kevin9327 wants to merge 1 commit into
CopilotKit:mainfrom
kevin9327:activity-clock-clamp

Conversation

@kevin9327

Copy link
Copy Markdown
Contributor

What this changes

POST /api/channels/:channelId/activity takes at from the browser — new Date().toISOString() in channel-chat.tsx's report — and ChannelStore.recordActivity applies it with a forward-only guard (lastMessageAt IS NULL OR lastMessageAt < at), returning silently when nothing moved. The parser's comment said a wrong clock "can lose a report, not corrupt the row". It loses the wrong report only when the clock is behind. When it is ahead, the wrong report lands, the row is stamped into the future, and every correct report after it loses — silently — until real time catches up:

  • the routine runner (server/src/routines/runner.ts, sayrecordActivity with server new Date()), whose whole job, per its own header, is that one report because no browser is open. The reply is in the thread; the roster line and unread dot never move; the run is recorded succeeded;
  • a relayed handoff answer (announce in server/src/index.ts, also server time);
  • every other member's browser, on a channel with more than one person in it.

A browser seven minutes ahead — a laptop that woke up before NTP did — hides seven minutes of everybody else's messages from the roster.

The parser now caps at at the server's own clock: a report may say when, but not later than now. Clamped rather than refused, because clocks are a little ahead all the time and a report a second early is still the report. A stamp in the past is kept as it is, so a person's message and the reply, reported separately by the same clock, still land in the order that clock saw them. now is an injectable parameter with a default, so the test can be about a specific gap.

Where it runs

  • New state that outlives a request? None.
  • What happens on the second replica? Each replica caps against its own clock; the store's forward-only guard is unchanged and still decides in Postgres. Skew between replicas is seconds at worst and was already the ordering this guard tolerated.
  • Anything serialised? Unchanged — the conditional update in recordActivity.
  • Anything fanned out to a browser? Unchanged — pg_notify inside the same transaction.
  • New listener, port, or schedule? No.

Boundary and audit

  • Every acting call still goes through the gateway: this is a roster report, not an action.
  • New refusals and new failures each write a row: nothing new is refused; a future stamp is capped, not rejected.
  • Nothing new is trusted from the client that the server can resolve itself — this is the reverse: one thing the client was trusted with, its clock, is now bounded by what the server knows.

Changelog

  • A section under Unreleased.

Proof

New server/tests/channel-activity-input.test.ts, no database needed. The clamp case fails before the change and passes after:

# before (parser unchanged)
error: expect(received).toBe(expected)
Expected: "2026-09-03T10:00:00.000Z"
Received: "2026-09-03T10:07:00.000Z"
(fail) parsing a reported message > clamps a timestamp from a clock that runs ahead to now
 3 pass
 1 fail

# after
 4 pass
 0 fail

bun run format:check, bun run lint, and the server typecheck pass.

…t runs ahead cannot hide what a routine said

`POST /:channelId/activity` took `at` from the browser and `recordActivity` applied it with a
forward-only guard, returning silently when nothing moved. The parser's comment said a wrong clock
could lose a report, not corrupt the row. That is the clock that is behind. One that is ahead
lands its report, stamps the row into the future, and then every correct report loses until real
time catches up: the routine runner's, which is the one report a headless firing makes; a relayed
handoff answer's; every other member's browser. The reply was in the thread and the roster never
said so, and the run row read `succeeded`.

A reported time is now capped at the server's own clock — a browser may say when, but not later
than now. Clamped rather than refused, because clocks are a little ahead all the time; a stamp in
the past is kept, so a message and its reply reported by the same clock still land in that order.
`now` is injectable, and the test that pins the clamp fails before this change.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant