@gotcos/glasses-server 6.46.0 → 6.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +6 -2
  3. package/server/index.ts +76 -0
  4. package/server/lib/cos-operations-meetings.ts +99 -8
  5. package/server/lib/fireflies-client.ts +862 -0
  6. package/server/lib/fireflies-key.ts +182 -0
  7. package/server/lib/g2-ops-handoff.ts +15 -1
  8. package/server/lib/imported-library-rows.ts +616 -0
  9. package/server/lib/imported-meeting-library.ts +608 -0
  10. package/server/lib/maintenance-lifecycle.ts +14 -0
  11. package/server/lib/meeting-actions-store.ts +478 -0
  12. package/server/lib/meeting-actions.ts +2583 -0
  13. package/server/lib/meeting-corrections.ts +32 -1
  14. package/server/lib/meeting-decisions.ts +223 -0
  15. package/server/lib/meeting-engine/align.ts +167 -0
  16. package/server/lib/meeting-engine/attribute.ts +265 -0
  17. package/server/lib/meeting-engine/evidence.ts +428 -0
  18. package/server/lib/meeting-engine/pairing.ts +327 -0
  19. package/server/lib/meeting-engine/render.ts +694 -0
  20. package/server/lib/meeting-engine/split.ts +242 -0
  21. package/server/lib/meeting-engine/worker.ts +238 -0
  22. package/server/lib/meeting-engine-mode.ts +197 -0
  23. package/server/lib/meeting-file-guards.ts +141 -0
  24. package/server/lib/meeting-import.ts +763 -0
  25. package/server/lib/meeting-library-search.ts +146 -11
  26. package/server/lib/meeting-parse.ts +184 -0
  27. package/server/lib/meeting-store.ts +108 -275
  28. package/server/lib/meeting-suggestion-sides.ts +242 -0
  29. package/server/lib/morning-brief-runtime.ts +20 -8
  30. package/server/lib/pipeline-runner.ts +227 -0
  31. package/server/lib/voice-evidence-guard.ts +87 -0
  32. package/server/routes/fireflies-key.ts +102 -0
  33. package/server/routes/meeting-actions.ts +82 -0
  34. package/server/routes/meeting-engine.ts +52 -0
  35. package/server/routes/meeting-import.ts +67 -0
  36. package/server/routes/meeting-suggestions.ts +66 -0
  37. package/server/routes/meeting.ts +117 -10
  38. package/server/routes/meetings.ts +205 -37
  39. package/server/routes/voice.ts +18 -0
@@ -0,0 +1,102 @@
1
+ // Fireflies key endpoints.
2
+ //
3
+ // POST /api/fireflies-key/set store the key, then check it
4
+ // GET /api/fireflies-key/status configured, source, savedAt, validatedAt, lastCheck
5
+ // DELETE /api/fireflies-key remove the stored key
6
+ // POST /api/fireflies-key/check check now (this is the user-initiated one)
7
+ //
8
+ // The key is never in a response, and never in a log line. `status` reads the
9
+ // remembered check rather than calling the vendor, so a settings pane that
10
+ // polls it costs nothing.
11
+
12
+ import { Router } from 'express'
13
+ import { FirefliesKeyError, getFirefliesClient, getFirefliesKeyStore, type FirefliesKeyStore } from '../lib/fireflies-key.js'
14
+ import { getFirefliesImporter } from '../lib/meeting-import.js'
15
+ import type { FirefliesClient, FirefliesKeyCheck } from '../lib/fireflies-client.js'
16
+
17
+ export interface FirefliesKeyRouterDeps {
18
+ store: () => FirefliesKeyStore
19
+ client: () => FirefliesClient
20
+ /** Told whenever the stored key changes, so a sticky invalid_key can clear. */
21
+ onKeyChanged?: (event: { configured: boolean }) => void
22
+ /** Told the result of every check, for the same reason. */
23
+ onKeyChecked?: (check: FirefliesKeyCheck) => void
24
+ }
25
+
26
+ export function createFirefliesKeyRouter(deps: FirefliesKeyRouterDeps): Router {
27
+ const router = Router()
28
+
29
+ const runCheck = async (): Promise<FirefliesKeyCheck> => {
30
+ const client = deps.client()
31
+ const check = await client.checkKey({ userInitiated: true })
32
+ deps.store().recordCheck(check)
33
+ deps.onKeyChecked?.(check)
34
+ return check
35
+ }
36
+
37
+ router.post('/fireflies-key/set', async (req, res) => {
38
+ try {
39
+ const store = deps.store()
40
+ const saved = store.save(req.body?.key)
41
+ deps.client().forgetKeyCheck()
42
+ deps.onKeyChanged?.({ configured: true })
43
+ const check = await runCheck()
44
+ res.json({ ok: true, savedAt: saved.savedAt, status: store.status(), check })
45
+ } catch (error) {
46
+ if (error instanceof FirefliesKeyError) {
47
+ return res.status(error.status).json({ error: { code: error.code, message: error.message } })
48
+ }
49
+ console.error('[fireflies-key] save failed')
50
+ res.status(500).json({ error: { code: 'fireflies_key_save_failed', message: 'The key could not be saved.' } })
51
+ }
52
+ })
53
+
54
+ router.get('/fireflies-key/status', (_req, res) => {
55
+ try {
56
+ const status = deps.store().status()
57
+ const cached = deps.client().cachedKeyCheck()
58
+ res.json({ ...status, ...(cached ? { lastCheck: { ...cached, cached: undefined } } : {}) })
59
+ } catch (error) {
60
+ console.error('[fireflies-key] status failed')
61
+ res.status(500).json({ error: { code: 'fireflies_key_unavailable', message: 'The key status could not be read.' } })
62
+ }
63
+ })
64
+
65
+ router.delete('/fireflies-key', (_req, res) => {
66
+ try {
67
+ const store = deps.store()
68
+ store.delete()
69
+ deps.client().forgetKeyCheck()
70
+ deps.onKeyChanged?.({ configured: store.status().configured })
71
+ res.json({ ok: true, status: store.status() })
72
+ } catch (error) {
73
+ if (error instanceof FirefliesKeyError) {
74
+ return res.status(error.status).json({ error: { code: error.code, message: error.message } })
75
+ }
76
+ console.error('[fireflies-key] delete failed')
77
+ res.status(500).json({ error: { code: 'fireflies_key_delete_failed', message: 'The key could not be removed.' } })
78
+ }
79
+ })
80
+
81
+ router.post('/fireflies-key/check', async (_req, res) => {
82
+ try {
83
+ res.json(await runCheck())
84
+ } catch (error) {
85
+ console.error('[fireflies-key] check failed')
86
+ res.status(500).json({ error: { code: 'fireflies_key_check_failed', message: 'The key could not be checked.' } })
87
+ }
88
+ })
89
+
90
+ return router
91
+ }
92
+
93
+ // A sticky invalid_key is what stops the importer from spending a budget on a
94
+ // key the vendor already refused, so the two things that can make it wrong - a
95
+ // new key, and a check that now succeeds - have to reach the importer from
96
+ // here. Without this the only way out of the sticky state is a restart.
97
+ export const firefliesKeyRouter = createFirefliesKeyRouter({
98
+ store: getFirefliesKeyStore,
99
+ client: getFirefliesClient,
100
+ onKeyChanged: () => getFirefliesImporter().onKeyChanged(),
101
+ onKeyChecked: check => getFirefliesImporter().onKeyChecked(check),
102
+ })
@@ -0,0 +1,82 @@
1
+ // What the engine did, and how to undo it (6.47.0, WS4).
2
+ //
3
+ // GET /api/meeting-actions?limit=
4
+ // GET /api/meeting-actions/:id one action
5
+ // POST /api/meeting-actions/:id/revert { dryRun } or { previewHash }
6
+ // POST /api/meeting-actions/:id/retry a failed action, in its own direction
7
+ // POST /api/meeting-actions/revert-all { dryRun } or { previewHash }
8
+ //
9
+ // TWO CALLS, ALWAYS. A revert asks for the preview first and gets a `previewHash` covering
10
+ // the outputs' CURRENT bytes; sending it back is what proves the person is undoing the thing
11
+ // they were shown. This follows the held-groups enroll gate in `voice.ts`, and for the same
12
+ // reason: the blast radius belongs in the preview, not in an apology afterwards.
13
+ //
14
+ // RETRY EXISTS BECAUSE FAILURE WAS TERMINAL. An action that used its one automatic retry
15
+ // stayed failed until the server restarted, and even a restart only re-drove WAITING rows.
16
+ // Control's Retry button reloaded the list and nothing else. This is the route it needed.
17
+ //
18
+ // `revert-all` is declared BEFORE `/:id/revert`. Express matches in order, and without that
19
+ // ordering `revert-all` is read as an action whose id is the literal string "revert-all".
20
+
21
+ import { Router } from 'express'
22
+ import { getMeetingMergeRunner, type MeetingMergeRunner } from '../lib/meeting-actions.js'
23
+ import { actionRefusal } from './meeting-suggestions.js'
24
+
25
+ export interface MeetingActionsRouterDeps {
26
+ runner: () => MeetingMergeRunner
27
+ }
28
+
29
+ export function createMeetingActionsRouter(deps: MeetingActionsRouterDeps): Router {
30
+ const router = Router()
31
+
32
+ router.get('/meeting-actions', (req, res) => {
33
+ try {
34
+ const raw = Number(req.query.limit)
35
+ const limit = Number.isFinite(raw) && raw > 0 ? Math.min(Math.trunc(raw), 1_000) : 100
36
+ res.set('Cache-Control', 'private, no-store')
37
+ res.json({ actions: deps.runner().listActions(limit) })
38
+ } catch (error) {
39
+ actionRefusal(error, res, 'meeting_actions_unavailable', 'The action log could not be read.')
40
+ }
41
+ })
42
+
43
+ router.get('/meeting-actions/:id', (req, res) => {
44
+ try {
45
+ res.set('Cache-Control', 'private, no-store')
46
+ res.json({ action: deps.runner().getAction(String(req.params.id)) })
47
+ } catch (error) {
48
+ actionRefusal(error, res, 'meeting_action_unavailable', 'That action could not be read.')
49
+ }
50
+ })
51
+
52
+ router.post('/meeting-actions/revert-all', (req, res) => {
53
+ deps.runner().revertAll({ dryRun: req.body?.dryRun === true, previewHash: asHash(req.body?.previewHash) })
54
+ .then(result => res.json(result))
55
+ .catch(error => actionRefusal(error, res, 'meeting_actions_revert_all_failed', 'Those merges could not be undone.'))
56
+ })
57
+
58
+ router.post('/meeting-actions/:id/revert', (req, res) => {
59
+ deps.runner().revert(String(req.params.id), {
60
+ dryRun: req.body?.dryRun === true,
61
+ previewHash: asHash(req.body?.previewHash),
62
+ })
63
+ .then(result => res.json(result))
64
+ .catch(error => actionRefusal(error, res, 'meeting_action_revert_failed', 'That merge could not be undone.'))
65
+ })
66
+
67
+ router.post('/meeting-actions/:id/retry', (req, res) => {
68
+ // Every refusal arrives as a rejection: `retryAction` is async, so a drain, a run in
69
+ // flight and a wrong state all land in the same handler as a real failure.
70
+ deps.runner().retryAction(String(req.params.id))
71
+ .then(result => res.json(result))
72
+ .catch(error => actionRefusal(error, res, 'meeting_action_retry_failed', 'That one could not be tried again.'))
73
+ })
74
+
75
+ return router
76
+ }
77
+
78
+ function asHash(value: unknown): string | undefined {
79
+ return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value) ? value : undefined
80
+ }
81
+
82
+ export const meetingActionsRouter = createMeetingActionsRouter({ runner: getMeetingMergeRunner })
@@ -0,0 +1,52 @@
1
+ // The engine's own state, and the one switch a person may throw (6.47.0, WS4).
2
+ //
3
+ // GET /api/meeting-engine/status mode, what the pipeline sees, mismatch, runs, counts
4
+ // POST /api/meeting-engine/mode { mode: 'advise' | 'apply' }
5
+ //
6
+ // `pipelineSees` IS THE POINT OF THE STATUS ROUTE. Four processes read the mode file, and
7
+ // the one failure a person cannot see for themselves is the server acting on `apply` while
8
+ // the pipeline on the same Mac still believes it owns the blend. The server asks
9
+ // `sync_meetings.py --merge-engine-status` (at most once every five minutes) and reports
10
+ // both answers plus whether they disagree. When the pipeline cannot be asked, the field is
11
+ // null and `mismatch` is false: "not known" is not "disagrees".
12
+ //
13
+ // THE MODE ROUTE REFUSES ON A MAC WITH NO PIPELINE. There is nothing to choose there: the
14
+ // server is in imports mode and does the importing itself.
15
+
16
+ import { Router } from 'express'
17
+ import { getMeetingMergeRunner, type MeetingMergeRunner } from '../lib/meeting-actions.js'
18
+ import type { MeetingEnginePipelineMode } from '../lib/meeting-engine-mode.js'
19
+ import { actionRefusal } from './meeting-suggestions.js'
20
+
21
+ export interface MeetingEngineRouterDeps {
22
+ runner: () => MeetingMergeRunner
23
+ }
24
+
25
+ export function createMeetingEngineRouter(deps: MeetingEngineRouterDeps): Router {
26
+ const router = Router()
27
+
28
+ router.get('/meeting-engine/status', (_req, res) => {
29
+ deps.runner().status()
30
+ .then(status => {
31
+ res.set('Cache-Control', 'private, no-store')
32
+ res.json(status)
33
+ })
34
+ .catch(error => actionRefusal(error, res, 'meeting_engine_status_unavailable', 'The engine status could not be read.'))
35
+ })
36
+
37
+ router.post('/meeting-engine/mode', (req, res) => {
38
+ try {
39
+ // `setMode` refuses synchronously (wrong Mac, bad value, work in flight) and only then
40
+ // returns a promise, so both paths have to be caught or a refusal becomes a 500.
41
+ deps.runner().setMode(req.body?.mode as MeetingEnginePipelineMode)
42
+ .then(result => res.json(result))
43
+ .catch(error => actionRefusal(error, res, 'meeting_engine_mode_failed', 'The mode could not be changed.'))
44
+ } catch (error) {
45
+ actionRefusal(error, res, 'meeting_engine_mode_failed', 'The mode could not be changed.')
46
+ }
47
+ })
48
+
49
+ return router
50
+ }
51
+
52
+ export const meetingEngineRouter = createMeetingEngineRouter({ runner: getMeetingMergeRunner })
@@ -0,0 +1,67 @@
1
+ // Meeting import endpoints.
2
+ //
3
+ // POST /api/meeting-import/fireflies/run { windowDays } -> 202 { runId }
4
+ // GET /api/meeting-import/fireflies/status mode, state, counts, budget
5
+ // POST /api/meeting-import/fireflies/settings { keepImporting, planCap }
6
+ //
7
+ // The run route answers as soon as the run is admitted. A backfill takes
8
+ // minutes and holds a maintenance lease per page, so an HTTP request that
9
+ // waited for it would sit far past COS Control's drain timeout; status is how a
10
+ // surface follows it.
11
+ //
12
+ // Every refusal is a code a surface can render: operations_pipeline_owns_fireflies
13
+ // (this Mac's pipeline owns Fireflies), import_in_progress, fireflies_key_missing,
14
+ // invalid_key, invalid_window, maintenance_drain_active.
15
+
16
+ import { Router } from 'express'
17
+ import { ImportRefusedError, getFirefliesImporter, type FirefliesImporter } from '../lib/meeting-import.js'
18
+
19
+ export interface MeetingImportRouterDeps {
20
+ importer: () => FirefliesImporter
21
+ }
22
+
23
+ export function createMeetingImportRouter(deps: MeetingImportRouterDeps): Router {
24
+ const router = Router()
25
+
26
+ const refusal = (error: unknown, res: import('express').Response, fallback: string): void => {
27
+ if (error instanceof ImportRefusedError) {
28
+ res.status(error.status).json({ error: { code: error.code, message: error.message } })
29
+ return
30
+ }
31
+ console.error(`[meeting-import] ${fallback}:`, error)
32
+ res.status(500).json({ error: { code: fallback, message: 'The import could not be started.' } })
33
+ }
34
+
35
+ router.post('/meeting-import/fireflies/run', (req, res) => {
36
+ try {
37
+ const started = deps.importer().run({ windowDays: req.body?.windowDays })
38
+ res.status(202).json({ accepted: true, runId: started.runId, status: deps.importer().status() })
39
+ } catch (error) {
40
+ refusal(error, res, 'meeting_import_run_failed')
41
+ }
42
+ })
43
+
44
+ router.get('/meeting-import/fireflies/status', (_req, res) => {
45
+ try {
46
+ res.json(deps.importer().status())
47
+ } catch (error) {
48
+ console.error('[meeting-import] status failed:', error)
49
+ res.status(500).json({ error: { code: 'meeting_import_unavailable', message: 'The import status could not be read.' } })
50
+ }
51
+ })
52
+
53
+ router.post('/meeting-import/fireflies/settings', (req, res) => {
54
+ try {
55
+ res.json(deps.importer().settings({
56
+ keepImporting: req.body?.keepImporting,
57
+ planCap: req.body?.planCap,
58
+ }))
59
+ } catch (error) {
60
+ refusal(error, res, 'meeting_import_settings_failed')
61
+ }
62
+ })
63
+
64
+ return router
65
+ }
66
+
67
+ export const meetingImportRouter = createMeetingImportRouter({ importer: getFirefliesImporter })
@@ -0,0 +1,66 @@
1
+ // Suggestions: what the engine wants a person to decide (6.47.0, WS4).
2
+ //
3
+ // GET /api/meeting-suggestions?state=open|confirmed|accepted|dismissed|all
4
+ // POST /api/meeting-suggestions/:id/accept imports mode: make the merge
5
+ // POST /api/meeting-suggestions/:id/confirm advise mode: record the answer only
6
+ // POST /api/meeting-suggestions/:id/dismiss never these two meetings again
7
+ //
8
+ // ACCEPT AND CONFIRM ARE DIFFERENT VERBS BECAUSE THEY MEAN DIFFERENT THINGS. In imports
9
+ // mode "yes" writes a record. In advise mode the server may not write into the operations
10
+ // tree at all, so "yes" is remembered and acted on later, if and when a person switches to
11
+ // apply. One verb doing both would make the advise answer look like it did something.
12
+ //
13
+ // Every refusal is a code a surface renders: suggestion_not_found, suggestion_stale,
14
+ // suggestion_dismissed, advise_mode, maintenance_drain_active.
15
+
16
+ import { Router, type Response } from 'express'
17
+ import { ActionRefusedError, getMeetingMergeRunner, type MeetingMergeRunner } from '../lib/meeting-actions.js'
18
+
19
+ export interface MeetingSuggestionsRouterDeps {
20
+ runner: () => MeetingMergeRunner
21
+ }
22
+
23
+ export function actionRefusal(error: unknown, res: Response, fallback: string, message: string): void {
24
+ if (error instanceof ActionRefusedError) {
25
+ res.status(error.status).json({ error: { code: error.code, message: error.message } })
26
+ return
27
+ }
28
+ console.error(`[meeting-suggestions] ${fallback}:`, error)
29
+ res.status(500).json({ error: { code: fallback, message } })
30
+ }
31
+
32
+ export function createMeetingSuggestionsRouter(deps: MeetingSuggestionsRouterDeps): Router {
33
+ const router = Router()
34
+
35
+ router.get('/meeting-suggestions', (req, res) => {
36
+ try {
37
+ const state = typeof req.query.state === 'string' ? req.query.state : undefined
38
+ res.set('Cache-Control', 'private, no-store')
39
+ res.json({ suggestions: deps.runner().listSuggestions(state) })
40
+ } catch (error) {
41
+ actionRefusal(error, res, 'meeting_suggestions_unavailable', 'The suggestions could not be read.')
42
+ }
43
+ })
44
+
45
+ router.post('/meeting-suggestions/:id/accept', (req, res) => {
46
+ deps.runner().acceptSuggestion(String(req.params.id))
47
+ .then(result => res.json(result))
48
+ .catch(error => actionRefusal(error, res, 'meeting_suggestion_accept_failed', 'That merge could not be made.'))
49
+ })
50
+
51
+ router.post('/meeting-suggestions/:id/confirm', (req, res) => {
52
+ deps.runner().confirmSuggestion(String(req.params.id))
53
+ .then(result => res.json(result))
54
+ .catch(error => actionRefusal(error, res, 'meeting_suggestion_confirm_failed', 'That answer could not be saved.'))
55
+ })
56
+
57
+ router.post('/meeting-suggestions/:id/dismiss', (req, res) => {
58
+ deps.runner().dismissSuggestion(String(req.params.id))
59
+ .then(result => res.json(result))
60
+ .catch(error => actionRefusal(error, res, 'meeting_suggestion_dismiss_failed', 'That answer could not be saved.'))
61
+ })
62
+
63
+ return router
64
+ }
65
+
66
+ export const meetingSuggestionsRouter = createMeetingSuggestionsRouter({ runner: getMeetingMergeRunner })
@@ -10,6 +10,7 @@ import { emitDisplay } from '../lib/display-bus.js'
10
10
  import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
11
11
  import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
12
12
  import { appendCorrection, appliedCorrections, pendingCorrections } from '../lib/meeting-corrections.js'
13
+ import { triggerMeetingMergeRun } from '../lib/meeting-actions.js'
13
14
  import { isSampleFromSession, untraceableSampleCount } from '../lib/training-audio-provenance.js'
14
15
  import { sendAudioFile } from '../lib/send-audio.js'
15
16
  import { adaptivePlaybackAudio } from '../lib/adaptive-playback-audio.js'
@@ -116,11 +117,14 @@ import {
116
117
  } from './transcribe-stream.js'
117
118
  import { getServerInstanceId } from '../lib/server-instance-id.js'
118
119
  import {
120
+ type MeetingLibraryRecord,
119
121
  cosOperationsMeetingsConfigured,
120
122
  findDirectLibraryMeetingBySessionId,
121
123
  findCosOperationsMeetingBySessionId,
122
124
  resolveCosOperationsDir,
123
125
  } from '../lib/cos-operations-meetings.js'
126
+ import { derivedSourcesFor, findDerivedRecord } from '../lib/imported-library-rows.js'
127
+ import { assertVoiceEvidenceSource } from '../lib/voice-evidence-guard.js'
124
128
  import { domainForMeeting, resolveDomains } from '../lib/domains.js'
125
129
  import { getOwnerSpeakerLabel } from '../lib/profile.js'
126
130
  import {
@@ -329,6 +333,10 @@ function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Finalizat
329
333
  await enrichStandaloneMeeting(current.meetingPath, jobStartedAt)
330
334
  }
331
335
  markCanonicalFinalizationState(current.sidecarPath, 'complete', false)
336
+ // The capture is finished and its files are durable, so the merge engine may score it.
337
+ // Fire-and-forget by contract: the runner queues, defers under a drain or a live
338
+ // capture, and never throws back at the finalization path.
339
+ triggerMeetingMergeRun('g2_finalized')
332
340
  runtime.finalizationJobs.remove(current.sessionId)
333
341
  finalizationRetryCounts.delete(key)
334
342
  }).catch(error => {
@@ -686,6 +694,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
686
694
  markCanonicalFinalizationState(saved.sidecarPath, finalizationJob.phase, claimPending)
687
695
  } else if (finalizationJob) {
688
696
  markCanonicalFinalizationState(saved.sidecarPath, 'complete', false)
697
+ triggerMeetingMergeRun('g2_finalized')
689
698
  finalizationJobs.remove(sessionId)
690
699
  finalizationJob = null
691
700
  }
@@ -714,6 +723,71 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
714
723
  }
715
724
  })
716
725
 
726
+ // ── Record identity for the speaker surfaces (6.47.0) ─────────────────
727
+ //
728
+ // One resolver, because the speakers and content routes each carried their own
729
+ // copy of the same three literals and the two had already drifted apart once.
730
+ // `source` and `mutable` always describe the CAPTURE — the file that holds the
731
+ // chunks and the one a correction would rewrite — even when the caller reached
732
+ // it through a merged record's row. `blendedRecordId` is what says "you opened
733
+ // the merged row", so Control can label the panel without pretending the merge
734
+ // is the thing being edited.
735
+ function speakerRecordIdentity(
736
+ sessionId: string,
737
+ operations: { domain: string; month: string; filename: string } | null,
738
+ direct: MeetingLibraryRecord | null,
739
+ blendedRecordId?: string,
740
+ ): {
741
+ source: 'cos_operations' | 'direct_library' | 'standalone_recordings'
742
+ recordId: string
743
+ mutable: boolean
744
+ blendedRecordId?: string
745
+ } {
746
+ return {
747
+ source: operations ? 'cos_operations' : direct ? 'direct_library' : 'standalone_recordings',
748
+ recordId: operations
749
+ ? `ops:${operations.domain}:${operations.month}:${operations.filename}`
750
+ : direct?.recordId ?? `standalone:${sessionId}`,
751
+ mutable: direct == null,
752
+ ...(blendedRecordId ? { blendedRecordId } : {}),
753
+ }
754
+ }
755
+
756
+ /**
757
+ * `?recordId=blended:<h16>` on a read: the caller opened the merged row.
758
+ *
759
+ * Confirmed against the record's OWN inputs rather than trusted, so a wrong or
760
+ * stale id is simply ignored instead of labelling an unrelated capture as part
761
+ * of a merge. Any capture the merge holds is accepted, not only the earliest,
762
+ * because a merge of two captures has two ways in and both are legitimate.
763
+ */
764
+ function blendedRecordFor(value: unknown, sessionId: string): string | undefined {
765
+ if (typeof value !== 'string' || !value.startsWith('blended:')) return undefined
766
+ const record = findDerivedRecord(value)
767
+ if (!record || record.kind !== 'merge') return undefined
768
+ return record.g2SessionIds.includes(sessionId) ? record.recordId : undefined
769
+ }
770
+
771
+ /**
772
+ * Refuse a mutation aimed at an imported or derived record.
773
+ *
774
+ * A derived record is a FUNCTION of its inputs: the next re-derive rewrites it,
775
+ * so an edit here would be silently discarded. The refusal names the capture to
776
+ * correct instead, which is the whole point of answering 409 rather than 404.
777
+ */
778
+ function refuseDerivedMutation(recordId: unknown): { status: number; body: Record<string, unknown> } | null {
779
+ const refusal = assertVoiceEvidenceSource([recordId])
780
+ if (!refusal) return null
781
+ const record = typeof recordId === 'string' ? findDerivedRecord(recordId) : null
782
+ const sourceRecordId = record
783
+ ? derivedSourcesFor(record, store).find(source => source.kind === 'g2')?.recordId
784
+ : undefined
785
+ return {
786
+ status: refusal.status,
787
+ body: { ...refusal.body, mutable: false, ...(sourceRecordId ? { sourceRecordId } : {}) },
788
+ }
789
+ }
790
+
717
791
  // ── Speaker review (6.21.12) ──────────────────────────────────────────
718
792
  // Backs COS Control's naming panel. Read-only: it reports what a saved
719
793
  // meeting's sidecar already contains and never writes. Naming, merging, and
@@ -728,6 +802,15 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
728
802
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
729
803
  return
730
804
  }
805
+ // BEFORE the lookup. The sessionId pattern allows colons, so `blended:<h16>`
806
+ // and `imported:fireflies:<h16>` are shaped like sessions and would otherwise
807
+ // fall through to a store scan that answers 404 — the wrong answer, because
808
+ // the record exists and simply is not a capture.
809
+ const identityRefusal = assertVoiceEvidenceSource([sessionId])
810
+ if (identityRefusal) {
811
+ res.status(identityRefusal.status).json(identityRefusal.body)
812
+ return
813
+ }
731
814
  // Prefer the COS operations copy when configured. The same session exists in
732
815
  // both trees under different names — the standalone store keeps the raw
733
816
  // capture name, operations holds the titled copy — and the meetings LIST
@@ -796,11 +879,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
796
879
  title,
797
880
  domain,
798
881
  filename,
799
- source: operations ? 'cos_operations' : direct ? 'direct_library' : 'standalone_recordings',
800
- recordId: operations
801
- ? `ops:${operations.domain}:${operations.month}:${operations.filename}`
802
- : direct?.recordId ?? `standalone:${sessionId}`,
803
- mutable: direct == null,
882
+ ...speakerRecordIdentity(sessionId, operations, direct, blendedRecordFor(req.query.recordId, sessionId)),
804
883
  ...(saved ? { durationMin: saved.durationMin } : {}),
805
884
  ...review,
806
885
  })
@@ -839,6 +918,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
839
918
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
840
919
  return
841
920
  }
921
+ const identityRefusal = assertVoiceEvidenceSource([sessionId])
922
+ if (identityRefusal) {
923
+ res.status(identityRefusal.status).json(identityRefusal.body)
924
+ return
925
+ }
842
926
  const operations = cosOperationsMeetingsConfigured()
843
927
  ? findCosOperationsMeetingBySessionId(sessionId)
844
928
  : null
@@ -975,11 +1059,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
975
1059
  // no write-up yet.
976
1060
  capturedChars: clip.capturedChars,
977
1061
  domain: clip.domain,
978
- source: operations ? 'cos_operations' : direct ? 'direct_library' : 'standalone_recordings',
979
- recordId: operations
980
- ? `ops:${operations.domain}:${operations.month}:${operations.filename}`
981
- : direct?.recordId ?? `standalone:${sessionId}`,
982
- mutable: direct == null,
1062
+ ...speakerRecordIdentity(sessionId, operations, direct, blendedRecordFor(req.query.recordId, sessionId)),
983
1063
  // So the panel can warn above the write-up, not just the clipboard.
984
1064
  removedNames: clip.removed,
985
1065
  coverage,
@@ -1033,6 +1113,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1033
1113
  res.status(400).json({ error: 'speaker is required', reason: 'invalid_label' })
1034
1114
  return
1035
1115
  }
1116
+ // Enrolment writes to the voice store, so the same two gates as the other
1117
+ // corrections: the session must be a capture, and the record aimed at must
1118
+ // not be a derived one.
1119
+ const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
1120
+ if (evidenceRefusal) {
1121
+ res.status(evidenceRefusal.status).json(evidenceRefusal.body)
1122
+ return
1123
+ }
1036
1124
 
1037
1125
  const operations = cosOperationsMeetingsConfigured()
1038
1126
  ? findCosOperationsMeetingBySessionId(sessionId)
@@ -1091,6 +1179,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1091
1179
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1092
1180
  return
1093
1181
  }
1182
+ // A merged G2 session is NOT refused here: it is a real capture with real
1183
+ // audio, and relabelling it is how its profile gets better. Only an imported
1184
+ // or derived IDENTITY is refused.
1185
+ const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
1186
+ if (evidenceRefusal) {
1187
+ res.status(evidenceRefusal.status).json(evidenceRefusal.body)
1188
+ return
1189
+ }
1094
1190
 
1095
1191
  const from = typeof req.body?.from === 'string' ? req.body.from : ''
1096
1192
  const to = typeof req.body?.to === 'string' ? req.body.to : ''
@@ -1329,6 +1425,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1329
1425
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1330
1426
  return
1331
1427
  }
1428
+ const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
1429
+ if (evidenceRefusal) {
1430
+ res.status(evidenceRefusal.status).json(evidenceRefusal.body)
1431
+ return
1432
+ }
1332
1433
  const label = typeof req.body?.label === 'string' ? req.body.label : ''
1333
1434
  const bad = invalidLabelReason(label)
1334
1435
  if (bad) {
@@ -1415,6 +1516,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1415
1516
  res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1416
1517
  return
1417
1518
  }
1519
+ const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId)
1520
+ if (evidenceRefusal) {
1521
+ res.status(evidenceRefusal.status).json(evidenceRefusal.body)
1522
+ return
1523
+ }
1418
1524
  const from = typeof req.body?.from === 'string' ? req.body.from : ''
1419
1525
  const bad = invalidLabelReason(from)
1420
1526
  if (bad) {
@@ -1992,6 +2098,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1992
2098
  // Control surfaces and warns on before committing a drain.
1993
2099
  await enrichStandaloneMeeting(saved.filepath, Date.now())
1994
2100
  }
2101
+ triggerMeetingMergeRun('orphan_recovered')
1995
2102
  }).catch(error => {
1996
2103
  // The quarantined audio is untouched on failure — retry stays possible
1997
2104
  // until the retention clock clears it.