Skip to content

Promote staging → main: 175 commits (doc visibility, meeting recording, security fixes) - #132

Merged
qiuethan merged 175 commits into
mainfrom
staging
Jul 27, 2026
Merged

Promote staging → main: 175 commits (doc visibility, meeting recording, security fixes)#132
qiuethan merged 175 commits into
mainfrom
staging

Conversation

@qiuethan

Copy link
Copy Markdown
Collaborator

Promotes stagingmain (production release). Closes the long-deferred promotion tracked in #124175 commits / 38 PRs of unreleased work.

The divergence is clean: main's 3 "ahead" commits are empty merge commits, and git diff origin/staging...origin/main is empty, so main is a strict content subset. No cherry-picks or conflict resolution.

What ships

Security & authz

Meeting recording (new service)

Bot & directory

Infra & docs

⚠️ Migrations — irreversible, and they run automatically

All three API services run alembic upgrade head as a Railway preDeployCommand, so merging this triggers them against the prod Neon branches with no manual gate:

Service Prod at Applies
team-tracking 005 006_email_provider_multivalued, 007_membership_no_overlap
documentation-system 002 003_doc_grants, 004_docs_url_unique_active
verification 001 none
meeting no DB

Two rewrite live data and cannot be undone:

  • 004_docs_url_unique_active soft-removes (active=false) every duplicate-URL doc except the earliest, then adds the partial unique index. Its downgrade() drops only the index.
  • 007_membership_no_overlap collapses overlapping memberships to zero-length dateranges (updated_by='migration_007_dedup') before adding the gist EXCLUDE constraint.

Both self-backfill, so they won't fail on dirty data — they'll silently rewrite it. Snapshot both prod Neon branches before merging.

Post-merge, required

  1. Register slash commands/add-email, /help, /record, /verify-email are new to prod and stay invisible until npm run register:all. Global registration takes ~1h to propagate.
  2. Smoke test/health on all six services; /whoami and an authenticated /doc show; one /record startstop cycle.

Prod prerequisites (done)

  • meeting service provisioned in the production Railway environment, tracking main, with all 10 vars set.
  • Prod discord-bot has MEETING_BASE_URL + MEETING_API_KEY.
  • Prod llm CONSUMER_KEYS carries both discord-bot and meeting consumers.

Note services/meeting/ does not exist on main until this merges, so prod meeting's current build is red by design and goes green on merge.

🤖 Generated with Claude Code

qiuethan and others added 30 commits July 9, 2026 22:22
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…74)

PG parity tests (RUN_PG_TESTS=1) not run in this environment (no DB available); default suite: 85 passed, 9 skipped (all gated PG tests).
…f-of

Document visibility + on-behalf-of authorization (#73, #74)
…Neon cold-start

/team autocomplete was intermittently returning empty: the discord bot's
principal lookup (discordId -> person) resolves to an anonymous principal
after PRINCIPAL_AUTOCOMPLETE_TIMEOUT_MS. On an idle Neon branch the first
directory lookup hits a compute cold-start (~1.0-1.4s observed in staging/prod
logs), exceeding the old 1000ms budget. Raise it to 2000ms and trim the
team-lookup budget 1500->700ms (the principal lookup has already warmed the
same DB, warm ~80ms), keeping the stacked sum at 2700ms (test bar: <=2800).

Stopgap only; the durable fix is keeping the directory DB warm (disable Neon
autosuspend) or caching the principal/team lookups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mock-timer test ticked a hardcoded 1500ms; with the budget raised to
2000ms the timeout no longer fired and the hung lookup never resolved
(cascading to sibling tests). Import PRINCIPAL_AUTOCOMPLETE_TIMEOUT_MS and
tick past it so the test tracks the constant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pgap

fix(bot): absorb Neon cold-start in autocomplete (1000->2000ms principal budget)
…caller)

After doc-visibility (#74) shipped, `/doc list` returned empty: the bot reads
with a plain docs:read key and no asserted actor, which the doc service now
treats as DENY. Thread the linked caller's person_id through
doc command -> docService -> docClient as an X-On-Behalf-Of header on the read
paths (list + show), so the doc service filters to what that user may see.
Writes are unchanged (docs:write remains the trust boundary).

Requires the bot's doc-system key to carry the act-as-user scope (rotated
separately in staging/prod).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(bot): visibility-filter /doc reads as the caller (X-On-Behalf-Of)
CodeRabbit nitpicks on #89: symmetric getDoc "omits X-On-Behalf-Of" case,
and the fail-closed path where an unlinked caller (principal=null) sends no
onBehalfOf on /doc list and /doc show.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
qiuethan and others added 26 commits July 25, 2026 22:52
…iew follow-up)

When an Opus stream emits 'error' without a following 'end' or 'close', the
userId remained stuck in activeSubscriptions, causing the speaker to be dropped
for the rest of the meeting. Now registering the clearActive handler on 'error'
as well as 'end' and 'close' to ensure cleanup in all terminal-event paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…DING

Data-flow diagram, Live and Stop paragraphs described persistent per-speaker
Transcribe streaming (the deferred model), and the wire-contract section still
showed ?key= WS auth. Updated to the current buffered re-transcription model
(consistent with the "Why stateful" + "Known limitations" sections) and the
first-message {"key":...} WS auth the bot now uses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ce paths (CodeRabbit #119)

Bare patterns only match the build-context root, but every service Dockerfile
COPYs services/<name>/, so nested tests/.env.example still landed in images.
Switched to recursive patterns. Verified the meeting image still builds and
imports cleanly in-container.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat: meeting recording v2 — stateful meeting service + Discord voice surface (live transcription)
…ce E2EE

Discord made end-to-end encryption (the DAVE protocol) mandatory for all
voice connections in March 2026. @discordjs/voice 0.18.0 predates DAVE, so
the voice websocket was rejected with close code 4017 and the bot silently
timed out on join — /record never connected. 0.19.2 pulls in @snazzah/davey
(the maintained DAVE implementation) and negotiates E2EE, so the bot reaches
the READY voice state. Verified live end-to-end on Railway staging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
decode() best-effort-swallows a libopus decode error and returns b"", but
the existing tests only exercised empty (returns before the try/except) and
valid packets -- the except av.error.FFmpegError recovery was never run. Add
a case feeding a non-empty invalid packet, asserting it returns b"" and that
the decoder still handles a valid packet afterward. Also implicitly guards
the av.error.FFmpegError attribute path against a future PyAV upgrade.

Addresses CodeRabbit review on #120.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-decode

fix: Discord voice E2EE (DAVE) + service-side Opus decode — makes /record work end-to-end
The minutes PDF used fpdf2 core fonts (Helvetica/Courier), which are latin-1
only, so `_safe()` replaced every unsupported char -- emoji, Cyrillic, many
accented names -- with '?'. Bundle DejaVu Sans/Mono (regular+bold) and drop the
lossy latin-1 replacement so accented and non-latin (Cyrillic/Greek/...) text
renders correctly. CJK/emoji fall outside DejaVu's coverage and degrade to a
missing-glyph box, never a '?'.

Also polishes layout: page-number footer (Page X of Y), an accent-colored title
with a hairline rule, accent-colored section headings, Unicode bullets, and a
chat-style transcript (bold accent '[ts] Speaker:' prefix, regular mono body,
blank line between turns).

Fonts (DejaVu 2.37, free license) are copied from a local install -- no runtime
download -- and bundled under src/assets/fonts/ with LICENSE_DEJAVU; the path is
resolved relative to __file__ so it works in the container (Dockerfile COPY
already includes them). Tests cover Unicode-font embedding and multi-page
pagination with alias resolution. 71 tests pass, ruff clean.

Closes #122.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Use fpdf2 tables where the data is structured, keeping prose/lists where it
isn't:
- Meta header -> borderless key/value table (bold labels, muted values).
- Action items -> a checkbox (☐) checklist table, so wrapped action text stays
  aligned in its column.
- Transcript -> Time / Speaker / Text table: accent header row repeated on every
  page, monospace times, bold speaker names, zebra striping, wrapping in the
  text column.
Decisions stay bullets and summary stays a paragraph (a table adds nothing to a
plain list / free text). action_items are unstructured strings (no owner field
in the Minutes contract), so no Owner column is fabricated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(meeting-svc): Unicode PDF fonts + minutes/transcript polish (#122)
…cord auth

Three related changes to the meeting-recording feature:

- Auto-stop: wireDiscordClient now listens for voiceStateUpdate and ends the
  recording when the last non-bot member leaves the recorded channel (same path
  as /record stop). meetingSurface stores the voice channel opaquely and exposes
  activeVoiceChannel(guildId) so the head-count check reads from the live
  session and stays discord.js-free.

- No length cap: max_meeting_ms now defaults to None (was 4h). Meetings run
  until /record stop or auto-stop-on-empty. Set MAX_MEETING_MS to re-enable a
  bound. Caveat (until #121's incremental streaming): with no cap a marathon
  meeting buffers all PCM in memory and can OOM; auto-stop-on-empty mitigates the
  common case.

- Auth: /record is a dedicated adapter path that bypasses router.js and thus the
  Policy Enforcement Point, so it never enforced its declared 'linked' policy.
  handleRecordInteraction now re-runs resolvePrincipal -> authorize('linked')
  itself, turning away unlinked callers with the standard message and failing
  closed if the directory is unavailable.

Tests: activeVoiceChannel lifecycle; auth allow/deny/unavailable on /record
start; auto-stop stop/no-op matrix. Bot suite (changed files) + full meeting
service suite pass; ruff clean. (Two unrelated bot tests fail locally on Node
18.12 -- node:test 'mock' / diagnostics.tracingChannel -- and pass on CI's Node.)
Docs updated in docs/MEETING-RECORDING.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From an independent review pass:

- Don't strand a running recording during a directory outage: /record stop and
  /record status are now 'public' (declared per-subcommand in record.js); only
  'start' stays 'linked' (it's the resource-consuming op). stop is de-escalating
  and status is a local read -- gating them fail-closed removed the one memory
  escape hatch exactly when the directory is down. (Important)

- Debounce auto-stop: a single stray 'last member left' event (client blip /
  voice-region failover) no longer irreversibly finalizes a live meeting.
  createAutoStop schedules the stop after AUTO_STOP_GRACE_MS and cancels it if a
  human returns -- on a later event or via a fire-time re-check. (Important)

- Derive the auth policy from command metadata (activeSubcommand.auth ??
  command.auth ?? 'linked') instead of hardcoding 'linked', so the dedicated
  path can't drift from record.js if it's ever retightened. (Minor)

- Note the GuildMembers-intent / member-cache caveat on the head-count; the
  grace-timer re-check is the backstop. (Minor)

- Tests: /record stop+status work unlinked/through an outage; auto-stop debounce
  (schedule, cancel-on-return, fire-time re-check, channel-move, humans-remain,
  not-recording); config max_meeting_ms=None default + gt=0-when-set; sessions
  no-drop when uncapped. Bot changed-file tests + full meeting suite (74) pass;
  ruff clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex review caught a bug in the debounce itself: pending timers keyed only by
guildId let a timer scheduled for recording A terminate a LATER recording B in
the same guild -- A empties and schedules; A is stopped and B starts; B's empty
event hits `pending.has(guildId)` and skips scheduling, so A's stale timer fires
and stops B before B's own grace elapsed.

Fix: meetingSurface exposes `activeSession(guildId)` -> { sessionId, voiceChannel }
(replaces activeVoiceChannel), and createAutoStop binds each pending timer to its
sessionId. A new recording clears a stale timer and schedules its own full-grace
one; a stale timer no-ops at fire time (sessionId mismatch). Added a cross-session
regression test. Bot tests + full meeting suite pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per product call: rather than removing the length cap entirely, keep a 4h
max_meeting_ms as a SAFETY BACKSTOP. The normal end condition is /record stop or
auto-stop-on-empty; the cap only bounds worst-case memory for a forgotten or
marathon meeting (the current design buffers all PCM in memory until #121). Still
overridable via MAX_MEETING_MS, and settable to None to disable. Docs + tests
updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(meeting): auto-stop on empty channel + 4h cap backstop + enforce /record auth
…bers

Auto-stop-on-empty wasn't firing live. Root cause: the head-count read
channel.members, which resolves each occupant to a GuildMember via
guild.members.cache -- a cache only the privileged GuildMembers intent keeps
populated. The bot doesn't request GuildMembers, so that resolution is
unreliable and miscounted occupants (including the recorder bot itself, whose
member often isn't cached), so the channel never looked empty.

Fix: count from guild.voiceStates.cache, which the GuildVoiceStates intent (the
one that powers voice receive -- already enabled) keeps populated with each
occupant's user id + channel id, no member-cache dependency. Exclude the recorder
bot by its own user id (client.user.id, threaded in via getBotId); other bots are
excluded best-effort via any resolved member. GuildMembers stays un-requested.

Confirmed against the discord.js 14.26 docs: 'GuildMembers intent is required to
keep member data synchronized' / methods return incomplete data without it.

Tests updated to model guild.voiceStates.cache + a bot id, incl. a regression
test that a channel containing only the bot (member unresolved) reads as empty.
Bot changed-file suites pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…count

fix(meeting): auto-stop head-count from voiceStates (channel.members needs GuildMembers)
Redesign the minutes PDF around UTMIST branding, and let the LLM title each
meeting instead of the code:

- Branded layout: a cobalt (#002FCB, from the logo) masthead band on page 1 with
  the UTMIST logo in a white chip + "Meeting Minutes", then the meeting title, an
  at-a-glance meta strip (date, duration, N participants, N action items), and a
  branded footer (UTMIST + page numbers) on every page. Sections restyled to the
  brand (cobalt headings/rules, cobalt-header striped transcript table).
- Logo asset: services/meeting/src/assets/brand/utmist-logo.png (cropped, 600px,
  transparent). It's the only image; all text stays real text, so the PDF remains
  fully text-parsable (pdftotext / search / LLM re-ingest).
- LLM title: Minutes gains a "title" field; the minutes prompt now asks for a
  short descriptive title, and the PDF renders it (falling back to a meta title,
  then "Meeting Minutes"). sessions.py no longer invents a "Meeting <uuid>" title.
- Fonts unchanged (bundled DejaVu Unicode): accents/Cyrillic/Greek render; CJK
  and emoji degrade to a missing-glyph box, never a "?".

Tests: _document_title precedence, LLM title parse/absent/outage. 77 pass, ruff
clean. Verified visually (branded masthead + LLM title render correctly).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(meeting): branded UTMIST minutes PDF + LLM-generated title
The voice surface has been exercised on staging through the meeting-recording
v2 series (PRs #119, #120, #127-#130), so /record moves off the testing-guild
beta channel and registers globally like every other command.

Flips `beta` to false in src/commands/record.js and updates the two tests that
asserted record was the sole beta command — the beta partition is now empty.
registerCommands still sends the empty beta body to DISCORD_GUILD_ID, which
clears the stale guild-scoped /record copy on staging.

Docs: adds /record to the root README command list (it was missing entirely)
and adds the `meeting` service row to the services table. Notes in the bot
README that /record needs the `meeting` service deployed with MEETING_BASE_URL
+ MEETING_API_KEY set on the bot in that environment.

Prod prerequisite: `meeting` does not exist in the production Railway
environment yet. It must be provisioned and wired into the bot BEFORE prod
command registration, or /record shows up in the UTMIST server and fails.
Part of #124.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3267dd46-0675-47f3-b761-0b31ede8c204

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch staging

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

2 participants