@gotcos/glasses-server 6.95.0 → 6.98.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.
- package/CHANGELOG.md +157 -0
- package/README.md +4 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +12 -0
- package/server/lib/attached-provider-adapter.ts +35 -1
- package/server/lib/claude-bridge.ts +47 -0
- package/server/lib/codex-bridge.ts +11 -1
- package/server/lib/codex-rollout-watermark.ts +42 -0
- package/server/lib/cursor-bridge.ts +2 -1
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/dictation-clean-models.ts +2 -0
- package/server/lib/dictation-clean.ts +2 -2
- package/server/lib/dictation-polish-pipeline.ts +22 -2
- package/server/lib/dictation-prewarm.ts +16 -0
- package/server/lib/direct-bridge.ts +14 -2
- package/server/lib/direct-sse.ts +6 -1
- package/server/lib/display-bus.ts +1 -1
- package/server/lib/fork-thread.ts +32 -1
- package/server/lib/held-naming-batches.ts +44 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/job-audit-caller.ts +20 -0
- package/server/lib/keyed-sessions.ts +21 -1
- package/server/lib/lens-gist-engines.ts +17 -5
- package/server/lib/live-cos-ask.ts +29 -6
- package/server/lib/live-cue-verdicts.ts +43 -8
- package/server/lib/live-cues-engine.ts +4 -1
- package/server/lib/live-cues-filter.ts +50 -0
- package/server/lib/live-cues-general-prompts.ts +137 -0
- package/server/lib/live-cues-general-topic.ts +245 -0
- package/server/lib/live-cues-general.ts +715 -0
- package/server/lib/live-cues-v2-data.ts +453 -0
- package/server/lib/live-cues-v2-grounded.ts +262 -0
- package/server/lib/live-cues-v2-judge-b.ts +59 -0
- package/server/lib/live-cues-v2-names.ts +49 -0
- package/server/lib/live-cues-v2-notes.ts +8 -4
- package/server/lib/live-cues-v2-open-items.ts +132 -0
- package/server/lib/live-cues-v2-pacing.ts +45 -0
- package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
- package/server/lib/live-cues-v2-settings.ts +89 -10
- package/server/lib/live-cues-v2.ts +570 -47
- package/server/lib/mac-recording-asr.ts +6 -3
- package/server/lib/mac-speaker-call-human.ts +371 -0
- package/server/lib/mac-speaker-call-interval-human.ts +499 -0
- package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
- package/server/lib/mac-speaker-call-original-view.ts +328 -0
- package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
- package/server/lib/mac-speaker-call-producer.ts +539 -0
- package/server/lib/mac-speaker-call-store.ts +264 -0
- package/server/lib/mac-speaker-call-view.ts +373 -0
- package/server/lib/mac-speaker-live-compute.ts +72 -0
- package/server/lib/mac-speaker-live-memory.ts +414 -0
- package/server/lib/mac-speaker-live-projection.ts +126 -0
- package/server/lib/mac-speaker-live-save.ts +90 -0
- package/server/lib/mac-speaker-live-store.ts +71 -0
- package/server/lib/mac-speaker-live-worker.ts +58 -0
- package/server/lib/mac-speaker-live.ts +175 -0
- package/server/lib/mac-speaker-profile-extractor.ts +15 -10
- package/server/lib/mac-speaker-shadow-repair.ts +307 -0
- package/server/lib/mac-voice-assignment.ts +46 -27
- package/server/lib/mac-voice-display.ts +195 -0
- package/server/lib/mac-voice-naming-batch.ts +1067 -0
- package/server/lib/mac-voice-suggestions.ts +548 -0
- package/server/lib/maintenance-lifecycle.ts +2 -0
- package/server/lib/media-store.ts +2 -1
- package/server/lib/meeting-ask-pasted.ts +135 -0
- package/server/lib/meeting-ask-review.ts +8 -1
- package/server/lib/meeting-ask-runner.ts +4 -1
- package/server/lib/meeting-ask-runtime.ts +189 -17
- package/server/lib/meeting-ask-store.ts +2 -0
- package/server/lib/meeting-ask-web.ts +284 -13
- package/server/lib/meeting-batch-progress.ts +51 -5
- package/server/lib/meeting-batch-transcribe.ts +35 -2
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-fact-check.ts +3 -0
- package/server/lib/meeting-finalization-jobs.ts +69 -2
- package/server/lib/meeting-public-topic.ts +6 -1
- package/server/lib/meeting-relabel-enrolment.ts +2 -1
- package/server/lib/meeting-relabel.ts +17 -10
- package/server/lib/meeting-speaker-review.ts +187 -21
- package/server/lib/meeting-store.ts +6 -1
- package/server/lib/parakeet.ts +51 -5
- package/server/lib/pending-batch-retention.ts +58 -0
- package/server/lib/private-reference.ts +38 -0
- package/server/lib/provider-usage.ts +561 -0
- package/server/lib/public-https-url.ts +32 -0
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/rich-media-safety.ts +23 -2
- package/server/lib/session-find-ask.ts +288 -0
- package/server/lib/session-find-collect.ts +715 -0
- package/server/lib/session-find-redact.ts +254 -0
- package/server/lib/session-find.ts +345 -0
- package/server/lib/speaker-calibration-log.ts +3 -2
- package/server/lib/speaker-embeddings.ts +46 -35
- package/server/lib/speaker-name-nfc.ts +37 -0
- package/server/lib/speaker-rename-fanout.ts +7 -6
- package/server/lib/speaker-shadow-decide.ts +59 -22
- package/server/lib/speaker-shadow-pair-source.ts +91 -0
- package/server/lib/speaker-shadow-paired.ts +268 -0
- package/server/lib/speaker-shadow-report.ts +2 -2
- package/server/lib/speaker-shadow-worker.ts +50 -15
- package/server/lib/speaker-shadow.ts +80 -19
- package/server/lib/speaker-trainer.ts +2 -1
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +10 -5
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/lib/web-lookup-ledger.ts +151 -0
- package/server/lib/work-review-runtime.ts +2 -0
- package/server/lib/work-search.ts +3 -1
- package/server/lib/xlsx-text.ts +277 -0
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +515 -61
- package/server/routes/session-find.ts +73 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +28 -18
- package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
- package/shared/live-cues-v2.ts +38 -5
- package/shared/media-attachment.ts +2 -0
- package/shared/meeting-ask.ts +11 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
/** Exact HQ-word human decisions over immutable BASE. No enrollment, source,
|
|
2
|
+
* profile, Markdown, or shared corrections-ledger writes occur in this module. */
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
4
|
+
import { closeSync, constants, existsSync, fsyncSync, lstatSync, openSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
5
|
+
import { dirname } from 'node:path'
|
|
6
|
+
import { applyMacCallHumanAnnotation, isMacCallHumanName, macCallHumanPaths, readMacCallHumanFile, type MacCallHumanFiles } from './mac-speaker-call-human.js'
|
|
7
|
+
import { resolveMacSpeakerCallView, validateMacSpeakerCallPacket, type MacSpeakerCallPacket, type MacSpeakerCallView } from './mac-speaker-call-view.js'
|
|
8
|
+
import { createMacSpeakerCallIntervalProjector } from './mac-speaker-call-interval-projection.js'
|
|
9
|
+
import { isMacSpeakerHumanProtected } from './mac-speaker-protection.js'
|
|
10
|
+
import { isUnattributed } from './meeting-speaker-review.js'
|
|
11
|
+
|
|
12
|
+
const POLICY = 'mac-speaker-call-interval-human-v1'
|
|
13
|
+
const SHA = /^[a-f0-9]{64}$/
|
|
14
|
+
const MAX = 16 * 1024 * 1024
|
|
15
|
+
const hash = (s: string) => createHash('sha256').update(s).digest('hex')
|
|
16
|
+
const digest = (v: unknown) => hash(JSON.stringify(v))
|
|
17
|
+
const equal = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
|
|
18
|
+
function fail(s: string): never { throw new Error(`Mac interval human: ${s}`) }
|
|
19
|
+
export interface MacIntervalHumanMember {
|
|
20
|
+
id: string; text: string; elapsedMs: number; startElapsedMs: number; endElapsedMs: number; wordCount: 1
|
|
21
|
+
}
|
|
22
|
+
interface Word extends MacIntervalHumanMember {
|
|
23
|
+
batchIndex: number; wordIndex: number; wordSHA256: string; rawChunkIndices: number[]
|
|
24
|
+
}
|
|
25
|
+
interface Source {
|
|
26
|
+
baseSHA256: string; markdownSHA256: string; ledgerSHA256: string; legacyAnnotationSHA256: string | null
|
|
27
|
+
}
|
|
28
|
+
export interface MacIntervalHumanScope extends Source {
|
|
29
|
+
kind: 'interval-words' | 'unresolved-words' | 'human-words'; identityId: string; displayLabel: string; scopeSHA256: string
|
|
30
|
+
members: MacIntervalHumanMember[]; fieldCount: number; annotationSHA256: string | null
|
|
31
|
+
/** Each run contains consecutive actual HQ indices from exactly one batch. */
|
|
32
|
+
selectionRuns?: string[][]
|
|
33
|
+
/** Null records that the manual review had no applied automatic packet. */
|
|
34
|
+
projectionPacketSHA256?: string | null
|
|
35
|
+
/** Existing source labels cannot become a new exact-word confirmation. */
|
|
36
|
+
forbiddenNames: string[]
|
|
37
|
+
packetSHA256: string; supportSHA256: string; generation: string; packetRevision: number
|
|
38
|
+
}
|
|
39
|
+
export interface MacIntervalHumanInput {
|
|
40
|
+
baseJSON: string; markdown: string; ledgerJSONL: string; legacyAnnotationJSON: string | null
|
|
41
|
+
annotationJSON: string | null; packet: MacSpeakerCallPacket | null; view: MacSpeakerCallView
|
|
42
|
+
}
|
|
43
|
+
interface NameEvent {
|
|
44
|
+
kind: 'name'; id: string; at: string; identityId: string; displayLabel: string; to: string
|
|
45
|
+
scopeSHA256: string; packetSHA256: string; supportSHA256: string; generation: string; packetRevision: number
|
|
46
|
+
priorAnnotationSHA256: string | null; members: Word[]
|
|
47
|
+
reviewKind?: MacIntervalHumanScope['kind']; supersedesActionId?: string
|
|
48
|
+
projectionPacketSHA256?: string | null
|
|
49
|
+
}
|
|
50
|
+
interface UndoEvent { kind: 'undo'; id: string; at: string; actionId: string; priorAnnotationSHA256: string }
|
|
51
|
+
type Event = NameEvent | UndoEvent
|
|
52
|
+
interface Annotation extends Source {
|
|
53
|
+
version: 1; policy: typeof POLICY; captureId: string; revision: number
|
|
54
|
+
packets: MacSpeakerCallPacket[]; events: Event[]; annotationSHA256: string
|
|
55
|
+
}
|
|
56
|
+
export interface MacIntervalHumanMetadata {
|
|
57
|
+
revision: number; annotationSHA256: string
|
|
58
|
+
activeActions: Array<{ actionId: string; identityId: string; to: string; memberIds: string[] }>
|
|
59
|
+
confirmedWords: Array<{ batchIndex: number; wordIndex: number; label: string; wordSHA256: string }>
|
|
60
|
+
}
|
|
61
|
+
function source(input: Pick<MacIntervalHumanInput, 'baseJSON' | 'markdown' | 'ledgerJSONL' | 'legacyAnnotationJSON'>): Source {
|
|
62
|
+
if (Buffer.byteLength(input.baseJSON) > 32 * 1024 * 1024 || Buffer.byteLength(input.markdown) > MAX || Buffer.byteLength(input.ledgerJSONL) > 8 * 1024 * 1024 || Buffer.byteLength(input.legacyAnnotationJSON ?? '') > MAX) fail('source byte budget')
|
|
63
|
+
return { baseSHA256: hash(input.baseJSON), markdownSHA256: hash(input.markdown), ledgerSHA256: hash(input.ledgerJSONL), legacyAnnotationSHA256: input.legacyAnnotationJSON === null ? null : hash(input.legacyAnnotationJSON) }
|
|
64
|
+
}
|
|
65
|
+
function seal(body: Omit<Annotation, 'annotationSHA256'>): Annotation { return { ...body, annotationSHA256: digest(body) } }
|
|
66
|
+
function packetWords(baseJSON: string, ledger: string, packet: MacSpeakerCallPacket): Map<string, Word & { identityId: string }> {
|
|
67
|
+
validateMacSpeakerCallPacket(baseJSON, ledger, packet)
|
|
68
|
+
if (packet.version !== 2 || packet.projectionPolicy !== 'source-bound-intervals' || packet.admission !== 'qualified' || !packet.intervalSupport) fail('qualified interval packet required')
|
|
69
|
+
const base = JSON.parse(baseJSON)
|
|
70
|
+
const projector = createMacSpeakerCallIntervalProjector({ rawBindings: packet.rawBindings, groups: packet.groups, displayLabels: packet.displayLabels, protectedRawChunkIndices: packet.aliasProtectedRawChunkIndices }, packet.intervalSupport)
|
|
71
|
+
const words = projector.hqWords(base)
|
|
72
|
+
if (words.length > 40000) fail('word budget')
|
|
73
|
+
const out = new Map<string, Word & { identityId: string }>()
|
|
74
|
+
for (const w of words) {
|
|
75
|
+
const batch = base.batchSegments[w.batchIndex], row = batch.speakerWords[w.wordIndex], wordSHA256 = digest(row)
|
|
76
|
+
// The reviewer presents the stored batch clock. A supported capture-piece
|
|
77
|
+
// clock can differ after raw gaps/overlaps; naming must never display a
|
|
78
|
+
// different interval from the one explicitly reviewed. Keep stored timing
|
|
79
|
+
// untouched and admit only clocks agreeing within one 16kHz sample.
|
|
80
|
+
const offset = typeof batch.startElapsed === 'number' && Number.isFinite(batch.startElapsed) ? batch.startElapsed : 0
|
|
81
|
+
if (Math.abs(offset + row.start * 1000 - w.startElapsedMs) > 1 / 16 || Math.abs(offset + row.end * 1000 - w.endElapsedMs) > 1 / 16) continue
|
|
82
|
+
const id = digest({ policy: POLICY, baseSHA256: packet.baseSHA256, batchIndex: w.batchIndex, wordIndex: w.wordIndex, wordSHA256 })
|
|
83
|
+
const member: Word & { identityId: string } = { id, text: row.word.slice(0, 600), elapsedMs: w.startElapsedMs, startElapsedMs: w.startElapsedMs, endElapsedMs: w.endElapsedMs, wordCount: 1, batchIndex: w.batchIndex, wordIndex: w.wordIndex, wordSHA256, rawChunkIndices: w.rawChunkIndices, identityId: w.identityId }
|
|
84
|
+
if (out.has(id) || !Number.isFinite(member.startElapsedMs) || !Number.isFinite(member.endElapsedMs) || member.endElapsedMs <= member.startElapsedMs) fail('word geometry')
|
|
85
|
+
out.set(id, member)
|
|
86
|
+
}
|
|
87
|
+
return out
|
|
88
|
+
}
|
|
89
|
+
function publicMember(w: Word): MacIntervalHumanMember {
|
|
90
|
+
return { id: w.id, text: w.text, elapsedMs: w.elapsedMs, startElapsedMs: w.startElapsedMs, endElapsedMs: w.endElapsedMs, wordCount: 1 }
|
|
91
|
+
}
|
|
92
|
+
function cleanWord(w: Word & { identityId?: string }): Word { const { identityId: _, ...word } = w; return word }
|
|
93
|
+
const actionIdentity = (id: string) => `action-${id.slice(POLICY.length + 1)}`
|
|
94
|
+
function assertContiguousWords(members: Word[]): void {
|
|
95
|
+
if (!members.length || members.some((w, i) => w.batchIndex !== members[0].batchIndex || i > 0 && w.wordIndex !== members[i - 1].wordIndex + 1)) fail('manual selection must be one contiguous range in one batch')
|
|
96
|
+
}
|
|
97
|
+
function effectiveUnresolved(value: unknown): boolean {
|
|
98
|
+
if (!value || typeof value !== 'object' || isMacSpeakerHumanProtected(value)) return false
|
|
99
|
+
const speaker = (value as Record<string, unknown>).speaker ?? ''
|
|
100
|
+
return typeof speaker === 'string' && (isUnattributed(speaker) || /^(?:ext|unknown|unidentified(?: \d+| voice)?|speaker(?: \d+)?|voice [1-9]\d?)$/i.test(speaker.trim()))
|
|
101
|
+
}
|
|
102
|
+
function manualWords(baseJSON: string, packet: MacSpeakerCallPacket): Map<string, Word> {
|
|
103
|
+
const base = JSON.parse(baseJSON), out = new Map<string, Word>()
|
|
104
|
+
if (!packet.intervalSupport) fail('qualified interval packet required')
|
|
105
|
+
const projector = createMacSpeakerCallIntervalProjector({ rawBindings: packet.rawBindings, groups: packet.groups, displayLabels: packet.displayLabels, protectedRawChunkIndices: packet.aliasProtectedRawChunkIndices }, packet.intervalSupport)
|
|
106
|
+
const sources = projector.hqWordSources(base)
|
|
107
|
+
if (sources.length > 40000) fail('manual word budget')
|
|
108
|
+
for (const w of sources) {
|
|
109
|
+
const batch = base.batchSegments[w.batchIndex], row = batch.speakerWords[w.wordIndex]
|
|
110
|
+
if (!effectiveUnresolved(row)) continue
|
|
111
|
+
const offset = typeof batch.startElapsed === 'number' && Number.isFinite(batch.startElapsed) ? batch.startElapsed : 0
|
|
112
|
+
const startElapsedMs = offset + row.start * 1000, endElapsedMs = offset + row.end * 1000, wordSHA256 = digest(row)
|
|
113
|
+
if (!Number.isFinite(startElapsedMs) || !Number.isFinite(endElapsedMs) || startElapsedMs < 0 || endElapsedMs <= startElapsedMs) continue
|
|
114
|
+
const id = digest({ policy: POLICY, baseSHA256: packet.baseSHA256, batchIndex: w.batchIndex, wordIndex: w.wordIndex, wordSHA256 })
|
|
115
|
+
if (out.has(id)) fail('duplicate manual word')
|
|
116
|
+
out.set(id, { id, text: row.word.slice(0, 600), elapsedMs: startElapsedMs, startElapsedMs, endElapsedMs, wordCount: 1, batchIndex: w.batchIndex, wordIndex: w.wordIndex, wordSHA256, rawChunkIndices: w.rawChunkIndices })
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
function qualifiedPacket(input: Pick<MacIntervalHumanInput, 'baseJSON' | 'ledgerJSONL' | 'packet'>): MacSpeakerCallPacket | null {
|
|
121
|
+
try {
|
|
122
|
+
const p = input.packet
|
|
123
|
+
if (!p || p.version !== 2 || p.admission !== 'qualified' || p.projectionPolicy !== 'source-bound-intervals' || !p.intervalSupport) return null
|
|
124
|
+
validateMacSpeakerCallPacket(input.baseJSON, input.ledgerJSONL, p)
|
|
125
|
+
return p
|
|
126
|
+
} catch { return null } // Automatic withdrawal never invalidates an audited human action.
|
|
127
|
+
}
|
|
128
|
+
function legacySource(input: Pick<MacIntervalHumanInput, 'baseJSON' | 'ledgerJSONL' | 'legacyAnnotationJSON'>): MacSpeakerCallView {
|
|
129
|
+
return applyMacCallHumanAnnotation(input.baseJSON, input.ledgerJSONL, input.legacyAnnotationJSON, resolveMacSpeakerCallView(input.baseJSON, input.ledgerJSONL, null))
|
|
130
|
+
}
|
|
131
|
+
/** Inspect all source speaker fields, including protected/nested HQ surfaces.
|
|
132
|
+
* Interval annotations are deliberately excluded, so disjoint exact words may
|
|
133
|
+
* share a name assigned by this mechanism without absorbing old source text. */
|
|
134
|
+
function forbiddenSourceNames(base: unknown, legacy: unknown): string[] {
|
|
135
|
+
const labels = new Set<string>(), pending: unknown[] = [base, legacy]
|
|
136
|
+
let count = 0
|
|
137
|
+
while (pending.length) {
|
|
138
|
+
const value = pending.pop()
|
|
139
|
+
if (!value || typeof value !== 'object') continue
|
|
140
|
+
if (++count > 500000) fail('source field budget')
|
|
141
|
+
if (Array.isArray(value)) { for (const child of value) if (child && typeof child === 'object') pending.push(child); continue }
|
|
142
|
+
const row = value as Record<string, unknown>
|
|
143
|
+
if (typeof row.speaker === 'string' && isMacCallHumanName(row.speaker)) labels.add(row.speaker)
|
|
144
|
+
for (const v of Object.values(row)) if (v && typeof v === 'object') pending.push(v)
|
|
145
|
+
}
|
|
146
|
+
if (labels.size > 40000) fail('source label budget')
|
|
147
|
+
return [...labels].sort()
|
|
148
|
+
}
|
|
149
|
+
function assertFreshTarget(to: string, forbiddenNames: readonly string[]): void {
|
|
150
|
+
const key = (s: string) => s.normalize('NFKC').toLowerCase()
|
|
151
|
+
if (forbiddenNames.some(name => key(name) === key(to))) fail('target name already exists in source')
|
|
152
|
+
}
|
|
153
|
+
function validate(input: Omit<MacIntervalHumanInput, 'packet' | 'view'>): { annotation: Annotation | null; active: NameEvent[]; undone: Set<string> } {
|
|
154
|
+
const binding = source(input)
|
|
155
|
+
if (input.annotationJSON === null) return { annotation: null, active: [], undone: new Set() }
|
|
156
|
+
if (Buffer.byteLength(input.annotationJSON) > MAX) fail('annotation byte budget')
|
|
157
|
+
const a = JSON.parse(input.annotationJSON) as Annotation, base = JSON.parse(input.baseJSON)
|
|
158
|
+
const { annotationSHA256, ...body } = a ?? {}
|
|
159
|
+
if (!a || a.version !== 1 || a.policy !== POLICY || a.captureId !== base.sessionId || !equal(binding, { baseSHA256: a.baseSHA256, markdownSHA256: a.markdownSHA256, ledgerSHA256: a.ledgerSHA256, legacyAnnotationSHA256: a.legacyAnnotationSHA256 }) || !SHA.test(annotationSHA256) || annotationSHA256 !== digest(body) || !Number.isSafeInteger(a.revision) || a.revision < 1 || !Array.isArray(a.events) || a.events.length !== a.revision || a.events.length > 6000 || !Array.isArray(a.packets) || !a.packets.length || a.packets.length > 32) fail('annotation source or integrity changed')
|
|
160
|
+
const referenced = new Set(a.events.filter(e => e?.kind === 'name').map(e => (e as NameEvent).packetSHA256))
|
|
161
|
+
if (a.packets.some(p => !p || !referenced.has(p.packetSHA256))) fail('unreferenced audited packet')
|
|
162
|
+
const packets = new Map<string, MacSpeakerCallPacket>(), words = new Map<string, ReturnType<typeof packetWords>>()
|
|
163
|
+
let projectedWordCount = 0
|
|
164
|
+
for (const p of a.packets) {
|
|
165
|
+
if (packets.has(p.packetSHA256)) fail('duplicate packet')
|
|
166
|
+
const projected = packetWords(input.baseJSON, input.ledgerJSONL, p)
|
|
167
|
+
if ((projectedWordCount += projected.size) > 40000) fail('aggregate audited word budget')
|
|
168
|
+
packets.set(p.packetSHA256, p); words.set(p.packetSHA256, projected)
|
|
169
|
+
}
|
|
170
|
+
// Validate v1 first, including its shared-ledger coverage and protected sources.
|
|
171
|
+
const legacy = legacySource(input), forbiddenNames = forbiddenSourceNames(base, legacy.doc)
|
|
172
|
+
const seen = new Set<string>(), names = new Map<string, NameEvent>(), active = new Map<string, NameEvent>(), occupied = new Set<string>(), usedPackets = new Set<string>(), undone = new Set<string>()
|
|
173
|
+
const manual = new Map<string, Map<string, Word>>()
|
|
174
|
+
const prefixPackets: MacSpeakerCallPacket[] = []
|
|
175
|
+
let memberCount = 0, auditVerificationBytes = 0
|
|
176
|
+
for (const [index, event] of a.events.entries()) {
|
|
177
|
+
if (!event || typeof event.id !== 'string' || !event.id.startsWith(`${POLICY}:`) || seen.has(event.id) || typeof event.at !== 'string' || !Number.isFinite(Date.parse(event.at))) fail('audit event')
|
|
178
|
+
seen.add(event.id)
|
|
179
|
+
if (event.priorAnnotationSHA256 !== null && !SHA.test(event.priorAnnotationSHA256) || index === 0 && event.priorAnnotationSHA256 !== null || index > 0 && event.priorAnnotationSHA256 === null) fail('audit chain')
|
|
180
|
+
if (index > 0) {
|
|
181
|
+
// Replay the exact preceding envelope, adding a retained packet only on
|
|
182
|
+
// its first name event. This authenticates the append-only prefix rather
|
|
183
|
+
// than accepting any syntactically valid predecessor hash. Bound total
|
|
184
|
+
// hashing work as well as retained bytes for adversarial local inputs.
|
|
185
|
+
const prefix = JSON.stringify({ version: 1, policy: POLICY, captureId: a.captureId, ...binding, revision: index, packets: prefixPackets, events: a.events.slice(0, index) })
|
|
186
|
+
if ((auditVerificationBytes += Buffer.byteLength(prefix)) > 256 * 1024 * 1024) fail('audit verification budget')
|
|
187
|
+
if (event.priorAnnotationSHA256 !== hash(prefix)) fail('audit chain predecessor changed')
|
|
188
|
+
}
|
|
189
|
+
if (event.kind === 'undo') {
|
|
190
|
+
const target = names.get(event.actionId)
|
|
191
|
+
if (!target || !active.has(event.actionId)) fail('undo action')
|
|
192
|
+
const ids = new Set(target.members.map(m => m.id))
|
|
193
|
+
if ([...active.values()].some(e => e !== target && a.events.indexOf(e) > a.events.indexOf(target) && e.members.some(m => ids.has(m.id)))) fail('undo overlaps later active edit')
|
|
194
|
+
active.delete(event.actionId)
|
|
195
|
+
target.members.forEach(m => occupied.delete(m.id))
|
|
196
|
+
undone.add(event.actionId)
|
|
197
|
+
if (target.supersedesActionId) {
|
|
198
|
+
const predecessor = names.get(target.supersedesActionId)
|
|
199
|
+
if (!predecessor || undone.has(predecessor.id) || predecessor.members.some(m => occupied.has(m.id))) fail('rename predecessor conflict')
|
|
200
|
+
active.set(predecessor.id, predecessor); predecessor.members.forEach(m => occupied.add(m.id))
|
|
201
|
+
}
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
if (event.kind !== 'name' || !isMacCallHumanName(event.to) || !SHA.test(event.scopeSHA256) || !Array.isArray(event.members) || !event.members.length || (memberCount += event.members.length) > 40000 || new Set(event.members.map(m => m.id)).size !== event.members.length) fail('name event')
|
|
205
|
+
assertFreshTarget(event.to, forbiddenNames)
|
|
206
|
+
const packet = packets.get(event.packetSHA256)
|
|
207
|
+
if (!packet || packet.generation !== event.generation || packet.revision !== event.packetRevision || packet.intervalSupport!.supportSHA256 !== event.supportSHA256) fail('audited packet binding')
|
|
208
|
+
const reviewKind = event.reviewKind ?? 'interval-words'
|
|
209
|
+
let expectedWords: Map<string, Word | (Word & { identityId: string })> = words.get(packet.packetSHA256)!
|
|
210
|
+
if (reviewKind === 'human-words') {
|
|
211
|
+
const predecessor = event.supersedesActionId ? active.get(event.supersedesActionId) : undefined
|
|
212
|
+
if (!predecessor || event.identityId !== actionIdentity(predecessor.id) || event.displayLabel !== predecessor.to || event.packetSHA256 !== predecessor.packetSHA256 || !equal(event.members, predecessor.members)) fail('rename must supersede one whole active action')
|
|
213
|
+
active.delete(predecessor.id); predecessor.members.forEach(m => occupied.delete(m.id))
|
|
214
|
+
expectedWords = new Map(predecessor.members.map(m => [m.id, m]))
|
|
215
|
+
} else if (reviewKind === 'unresolved-words') {
|
|
216
|
+
if (event.supersedesActionId !== undefined || event.identityId !== 'unresolved' || event.displayLabel !== 'Unresolved speech' || event.projectionPacketSHA256 !== null && event.projectionPacketSHA256 !== event.packetSHA256) fail('manual review identity')
|
|
217
|
+
assertContiguousWords(event.members)
|
|
218
|
+
if (!manual.has(packet.packetSHA256)) {
|
|
219
|
+
const candidates = manualWords(input.baseJSON, packet)
|
|
220
|
+
if ((projectedWordCount += candidates.size) > 40000) fail('aggregate audited word budget')
|
|
221
|
+
manual.set(packet.packetSHA256, candidates)
|
|
222
|
+
}
|
|
223
|
+
expectedWords = manual.get(packet.packetSHA256)!
|
|
224
|
+
const projection = event.projectionPacketSHA256 ? resolveMacSpeakerCallView(input.baseJSON, input.ledgerJSONL, packet).doc : base
|
|
225
|
+
for (const m of event.members) if (!effectiveUnresolved(projection.batchSegments?.[m.batchIndex]?.speakerWords?.[m.wordIndex])) fail('manual word was not unresolved')
|
|
226
|
+
} else if (reviewKind !== 'interval-words' || event.supersedesActionId !== undefined || packet.displayLabels[event.identityId] !== event.displayLabel) fail('audited group binding')
|
|
227
|
+
if (!usedPackets.has(packet.packetSHA256)) prefixPackets.push(packet)
|
|
228
|
+
usedPackets.add(packet.packetSHA256)
|
|
229
|
+
for (const member of event.members) {
|
|
230
|
+
const actual = expectedWords.get(member.id)
|
|
231
|
+
const row = legacy.doc.batchSegments?.[member.batchIndex]?.speakerWords?.[member.wordIndex]
|
|
232
|
+
if (!actual || reviewKind === 'interval-words' && (!('identityId' in actual) || actual.identityId !== event.identityId) || !equal(cleanWord(actual), member) || !row || digest(row) !== member.wordSHA256 || isMacSpeakerHumanProtected(row)) fail('audited word source changed')
|
|
233
|
+
// Names are allowed only over anonymous projected words. Earlier active
|
|
234
|
+
// human decisions cannot be overwritten by manufacturing another event.
|
|
235
|
+
if (occupied.has(member.id)) fail('overlapping active human decision')
|
|
236
|
+
}
|
|
237
|
+
event.members.forEach(m => occupied.add(m.id))
|
|
238
|
+
names.set(event.id, event); active.set(event.id, event)
|
|
239
|
+
}
|
|
240
|
+
if (usedPackets.size !== packets.size) fail('unreferenced audited packet')
|
|
241
|
+
if (!equal(a.packets.map(p => p.packetSHA256), prefixPackets.map(p => p.packetSHA256))) fail('audited packet order changed')
|
|
242
|
+
return { annotation: a, active: [...active.values()], undone }
|
|
243
|
+
}
|
|
244
|
+
function canonical(input: Omit<MacIntervalHumanInput, 'view'>): MacSpeakerCallView {
|
|
245
|
+
const automatic = resolveMacSpeakerCallView(input.baseJSON, input.ledgerJSONL, input.packet)
|
|
246
|
+
const legacy = applyMacCallHumanAnnotation(input.baseJSON, input.ledgerJSONL, input.legacyAnnotationJSON, automatic)
|
|
247
|
+
return applyMacIntervalHumanAnnotation({ ...input, view: legacy })
|
|
248
|
+
}
|
|
249
|
+
export function applyMacIntervalHumanAnnotation(input: MacIntervalHumanInput): MacSpeakerCallView {
|
|
250
|
+
const { annotation, active } = validate(input)
|
|
251
|
+
if (!annotation) return input.view
|
|
252
|
+
const doc = structuredClone(input.view.doc), base = JSON.parse(input.baseJSON), confirmedWords: MacIntervalHumanMetadata['confirmedWords'] = []
|
|
253
|
+
for (const event of active) for (const m of event.members) {
|
|
254
|
+
const row = doc.batchSegments?.[m.batchIndex]?.speakerWords?.[m.wordIndex], original = base.batchSegments[m.batchIndex].speakerWords[m.wordIndex]
|
|
255
|
+
// The source-bound decision survives packet withdrawal, but never replaces
|
|
256
|
+
// a new protected claim, changed word, or differently named legacy field.
|
|
257
|
+
if (!row || isMacSpeakerHumanProtected(row) || !equal({ ...row, speaker: original.speaker }, original) || isMacCallHumanName(row.speaker) && row.speaker !== event.to && row.speaker !== original.speaker) fail('current word conflicts with human decision')
|
|
258
|
+
row.speaker = event.to
|
|
259
|
+
confirmedWords.push({ batchIndex: m.batchIndex, wordIndex: m.wordIndex, label: event.to, wordSHA256: m.wordSHA256 })
|
|
260
|
+
}
|
|
261
|
+
const metadata: MacIntervalHumanMetadata = { revision: annotation.revision, annotationSHA256: annotation.annotationSHA256, activeActions: active.map(e => ({ actionId: e.id, identityId: actionIdentity(e.id), to: e.to, memberIds: e.members.map(m => m.id) })), confirmedWords }
|
|
262
|
+
return { ...input.view, doc, metadata: { ...input.view.metadata, intervalHumanAnnotation: metadata } } as MacSpeakerCallView
|
|
263
|
+
}
|
|
264
|
+
export function buildMacIntervalHumanScopes(input: MacIntervalHumanInput): MacIntervalHumanScope[] {
|
|
265
|
+
const binding = source(input), checked = canonical(input), { annotation, active } = validate(input)
|
|
266
|
+
if (!equal(input.view.doc, checked.doc) || input.view.metadata.status !== checked.metadata.status || input.view.metadata.packetSHA256 !== checked.metadata.packetSHA256) fail('unvalidated reviewed view')
|
|
267
|
+
const current = qualifiedPacket(input), packet = current ?? annotation?.packets.at(-1) ?? null
|
|
268
|
+
const forbiddenNames = forbiddenSourceNames(JSON.parse(input.baseJSON), legacySource(input).doc)
|
|
269
|
+
Object.freeze(forbiddenNames)
|
|
270
|
+
const result: MacIntervalHumanScope[] = []
|
|
271
|
+
const add = (kind: MacIntervalHumanScope['kind'], identityId: string, displayLabel: string, members: MacIntervalHumanMember[], basis: MacSpeakerCallPacket, extra: Pick<MacIntervalHumanScope, 'selectionRuns' | 'projectionPacketSHA256'> = {}) => {
|
|
272
|
+
const body = { kind, identityId, displayLabel, members, fieldCount: members.length, forbiddenNames, ...extra, ...binding, annotationSHA256: input.annotationJSON === null ? null : hash(input.annotationJSON), packetSHA256: basis.packetSHA256, supportSHA256: basis.intervalSupport!.supportSHA256, generation: basis.generation, packetRevision: basis.revision }
|
|
273
|
+
const scope = { ...body, scopeSHA256: digest(body) }
|
|
274
|
+
members.forEach(Object.freeze); Object.freeze(members)
|
|
275
|
+
if (extra.selectionRuns) { extra.selectionRuns.forEach(Object.freeze); Object.freeze(extra.selectionRuns) }
|
|
276
|
+
result.push(Object.freeze(scope))
|
|
277
|
+
}
|
|
278
|
+
const applied = current && checked.metadata.status === 'applied' && checked.metadata.packetSHA256 === current.packetSHA256
|
|
279
|
+
if (applied) {
|
|
280
|
+
const words = packetWords(input.baseJSON, input.ledgerJSONL, current), groups = new Map<string, MacIntervalHumanMember[]>()
|
|
281
|
+
for (const w of words.values()) {
|
|
282
|
+
const row = checked.doc.batchSegments?.[w.batchIndex]?.speakerWords?.[w.wordIndex], label = current.displayLabels[w.identityId]
|
|
283
|
+
if (!row || row.speaker !== label || isMacSpeakerHumanProtected(row) || !checked.metadata.groups.some(g => g.identityId === w.identityId && g.label === label)) continue
|
|
284
|
+
const list = groups.get(w.identityId) ?? []; list.push(publicMember(w)); groups.set(w.identityId, list)
|
|
285
|
+
}
|
|
286
|
+
for (const [id, members] of groups) add('interval-words', id, current.displayLabels[id], members, current)
|
|
287
|
+
}
|
|
288
|
+
if (packet) {
|
|
289
|
+
const legacy = legacySource(input), members: MacIntervalHumanMember[] = [], runs: string[][] = []
|
|
290
|
+
let previous: Word | undefined
|
|
291
|
+
for (const word of manualWords(input.baseJSON, packet).values()) {
|
|
292
|
+
const row = checked.doc.batchSegments?.[word.batchIndex]?.speakerWords?.[word.wordIndex], old = legacy.doc.batchSegments?.[word.batchIndex]?.speakerWords?.[word.wordIndex]
|
|
293
|
+
if (!effectiveUnresolved(row) || !old || digest(old) !== word.wordSHA256 || isMacSpeakerHumanProtected(old)) continue
|
|
294
|
+
if (!previous || previous.batchIndex !== word.batchIndex || previous.wordIndex + 1 !== word.wordIndex) runs.push([])
|
|
295
|
+
runs.at(-1)!.push(word.id); members.push(publicMember(word)); previous = word
|
|
296
|
+
}
|
|
297
|
+
if (members.length) add('unresolved-words', 'unresolved', 'Unresolved speech', members, packet, { selectionRuns: runs, projectionPacketSHA256: applied ? current.packetSHA256 : null })
|
|
298
|
+
}
|
|
299
|
+
for (const action of active) {
|
|
300
|
+
const basis = annotation!.packets.find(p => p.packetSHA256 === action.packetSHA256)
|
|
301
|
+
if (!basis) fail('active action packet missing')
|
|
302
|
+
add('human-words', actionIdentity(action.id), action.to, action.members.map(publicMember), basis)
|
|
303
|
+
}
|
|
304
|
+
return result
|
|
305
|
+
}
|
|
306
|
+
export function previewMacIntervalHumanAnnotation(scope: MacIntervalHumanScope, memberIds: string[], to: string): { members: MacIntervalHumanMember[]; fields: number } {
|
|
307
|
+
if (!isMacCallHumanName(to) || !Array.isArray(memberIds) || !memberIds.length || memberIds.length > 40000 || memberIds.some(id => typeof id !== 'string' || !SHA.test(id)) || new Set(memberIds).size !== memberIds.length) fail('explicit name and word IDs required')
|
|
308
|
+
const { scopeSHA256, ...body } = scope
|
|
309
|
+
if (!['interval-words', 'unresolved-words', 'human-words'].includes(scope.kind) || scopeSHA256 !== digest(body)) fail('reviewed scope integrity')
|
|
310
|
+
if (!Array.isArray(scope.forbiddenNames) || scope.forbiddenNames.length > 40000 || scope.forbiddenNames.some(name => typeof name !== 'string')) fail('reviewed source labels')
|
|
311
|
+
assertFreshTarget(to, scope.forbiddenNames)
|
|
312
|
+
const byId = new Map(scope.members.map(m => [m.id, m]))
|
|
313
|
+
let members = memberIds.map(id => byId.get(id) ?? fail('word outside reviewed group'))
|
|
314
|
+
if (scope.kind === 'unresolved-words') {
|
|
315
|
+
const selected = new Set(memberIds), runs = scope.selectionRuns
|
|
316
|
+
members = scope.members.filter(m => selected.has(m.id))
|
|
317
|
+
if (scope.identityId !== 'unresolved' || scope.displayLabel !== 'Unresolved speech' || !Array.isArray(runs) || runs.length > 40000 || runs.some(r => !Array.isArray(r) || !r.length) || !equal(runs.flat(), scope.members.map(m => m.id))
|
|
318
|
+
|| !runs.some(run => { const start = run.indexOf(members[0].id); return start >= 0 && equal(run.slice(start, start + members.length), members.map(m => m.id)) })) fail('manual selection must be one contiguous range in one batch')
|
|
319
|
+
} else if (scope.kind === 'human-words') {
|
|
320
|
+
if (!/^action-[a-f0-9-]{36}$/.test(scope.identityId) || members.length !== scope.members.length) fail('rename requires the whole active action')
|
|
321
|
+
members = scope.members.slice()
|
|
322
|
+
}
|
|
323
|
+
return { members, fields: members.length }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export type MacIntervalHumanFiles = MacCallHumanFiles
|
|
327
|
+
export function macIntervalHumanPaths(files: MacIntervalHumanFiles, captureId: string) {
|
|
328
|
+
const old = macCallHumanPaths(files, captureId), stem = files.sidecarPath.replace(/\.g2-chunks\.json$/, '')
|
|
329
|
+
return { annotation: `${stem}.speaker-call-interval-human.json`, journal: `${stem}.speaker-call-interval-human-transaction.json`, packet: old.packet, legacyAnnotation: old.annotation, ledger: old.ledger, lock: old.lock }
|
|
330
|
+
}
|
|
331
|
+
function directory(path: string) { const dir = dirname(path); if (realpathSync(dir) !== dir || !lstatSync(dir).isDirectory()) fail('canonical parent required'); return dir }
|
|
332
|
+
function syncDirectory(path: string) { const fd = openSync(directory(path), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW | constants.O_NONBLOCK); try { fsyncSync(fd) } finally { closeSync(fd) } }
|
|
333
|
+
function atomic(path: string, text: string, before: () => void) {
|
|
334
|
+
directory(path); const tmp = `${path}.${randomUUID()}.tmp`, fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600)
|
|
335
|
+
try { writeFileSync(fd, text); fsyncSync(fd) } finally { closeSync(fd) }
|
|
336
|
+
try { before(); renameSync(tmp, path); syncDirectory(path) } finally { if (existsSync(tmp)) unlinkSync(tmp) }
|
|
337
|
+
}
|
|
338
|
+
function acquire(path: string, journal: string, recover: boolean, expectedLockSHA256?: string) {
|
|
339
|
+
const old = readMacCallHumanFile(path, 4096, true)
|
|
340
|
+
if (expectedLockSHA256 !== undefined && old?.hash !== expectedLockSHA256) fail('lock drift')
|
|
341
|
+
if (old) {
|
|
342
|
+
if (!recover) fail('capture locked')
|
|
343
|
+
const owner = JSON.parse(old.text)
|
|
344
|
+
if (!Number.isSafeInteger(owner.pid) || owner.pid < 1 || owner.kind !== POLICY || owner.journal !== journal) fail('foreign lock')
|
|
345
|
+
try { process.kill(owner.pid, 0); fail('capture lock owner alive') } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ESRCH') throw e }
|
|
346
|
+
if (readMacCallHumanFile(path, 4096)!.hash !== old.hash) fail('lock drift')
|
|
347
|
+
unlinkSync(path); syncDirectory(path)
|
|
348
|
+
}
|
|
349
|
+
directory(path)
|
|
350
|
+
const text = JSON.stringify({ kind: POLICY, journal, pid: process.pid, token: randomUUID() }), fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600)
|
|
351
|
+
try { writeFileSync(fd, text); fsyncSync(fd) } finally { closeSync(fd) }
|
|
352
|
+
syncDirectory(path)
|
|
353
|
+
const assertOwned = () => { if (readMacCallHumanFile(path, 4096, true)?.text !== text) fail('lock drift') }
|
|
354
|
+
return { assertOwned, release: () => { assertOwned(); unlinkSync(path); syncDirectory(path) } }
|
|
355
|
+
}
|
|
356
|
+
interface Pin { path: string; hash: string | null; maximum: number }
|
|
357
|
+
interface Journal { version: 1; policy: typeof POLICY; captureId: string; phase: 'prepared' | 'complete'; pins: Pin[]; before: string | null; after: string; journalSHA256: string }
|
|
358
|
+
function journalSeal(body: Omit<Journal, 'journalSHA256'>): Journal { return { ...body, journalSHA256: digest(body) } }
|
|
359
|
+
function readSources(files: MacIntervalHumanFiles, captureId: string) {
|
|
360
|
+
const paths = macIntervalHumanPaths(files, captureId)
|
|
361
|
+
const base = readMacCallHumanFile(files.sidecarPath, 32 * 1024 * 1024)!, md = readMacCallHumanFile(files.meetingPath, MAX)!, ledger = readMacCallHumanFile(paths.ledger, 8 * 1024 * 1024, true), legacy = readMacCallHumanFile(paths.legacyAnnotation, MAX, true), packet = readMacCallHumanFile(paths.packet, MAX, true), prior = readMacCallHumanFile(paths.annotation, MAX, true)
|
|
362
|
+
// An unreadable automatic JSON payload is unavailable projection, not a
|
|
363
|
+
// reason to strand a source-bound human undo. Keep its exact bytes pinned.
|
|
364
|
+
// Human/legacy annotations remain strict JSON in their own validators.
|
|
365
|
+
let parsedPacket: MacSpeakerCallPacket | null = null
|
|
366
|
+
if (packet) { try { parsedPacket = JSON.parse(packet.text) as MacSpeakerCallPacket } catch { /* unavailable current automatic packet */ } }
|
|
367
|
+
const input = { baseJSON: base.text, markdown: md.text, ledgerJSONL: ledger?.text ?? '', legacyAnnotationJSON: legacy?.text ?? null, annotationJSON: prior?.text ?? null, packet: parsedPacket }
|
|
368
|
+
const pins: Pin[] = [{ path: files.sidecarPath, hash: base.hash, maximum: 32 * 1024 * 1024 }, { path: files.meetingPath, hash: md.hash, maximum: MAX }, { path: paths.ledger, hash: ledger?.hash ?? null, maximum: 8 * 1024 * 1024 }, { path: paths.legacyAnnotation, hash: legacy?.hash ?? null, maximum: MAX }, { path: paths.packet, hash: packet?.hash ?? null, maximum: MAX }]
|
|
369
|
+
return { paths, input, pins }
|
|
370
|
+
}
|
|
371
|
+
function checkPins(pins: Pin[]) { for (const p of pins) if ((readMacCallHumanFile(p.path, p.maximum, p.hash === null)?.hash ?? null) !== p.hash) fail('source changed') }
|
|
372
|
+
function checkedJournal(files: MacIntervalHumanFiles, captureId: string, value: unknown): Journal {
|
|
373
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail('journal scope or bounds')
|
|
374
|
+
const journal = value as Journal, paths = macIntervalHumanPaths(files, captureId), { journalSHA256, ...body } = journal
|
|
375
|
+
if (journal.version !== 1 || journal.policy !== POLICY || journal.captureId !== captureId || !SHA.test(journalSHA256) || digest(body) !== journalSHA256 || !['prepared', 'complete'].includes(journal.phase) || !Array.isArray(journal.pins) || !equal(journal.pins.map(p => p.path), [files.sidecarPath, files.meetingPath, paths.ledger, paths.legacyAnnotation, paths.packet]) || !equal(journal.pins.map(p => p.maximum), [32 * 1024 * 1024, MAX, 8 * 1024 * 1024, MAX, MAX]) || journal.pins.some(p => p.hash !== null && !SHA.test(p.hash)) || journal.before !== null && typeof journal.before !== 'string' || typeof journal.after !== 'string' || Buffer.byteLength(journal.before ?? '') > MAX || Buffer.byteLength(journal.after) > MAX) fail('journal scope or bounds')
|
|
376
|
+
return journal
|
|
377
|
+
}
|
|
378
|
+
/** Read-only receipt verification shared by readers, publication, and recovery.
|
|
379
|
+
* Completed receipts preserve the exact human postimage and immutable sources;
|
|
380
|
+
* a newer/missing/malformed current automatic packet is allowed after completion.
|
|
381
|
+
* A prepared transaction must still match every original source pin. */
|
|
382
|
+
function verifyJournalSnapshot(files: MacIntervalHumanFiles, captureId: string, value: unknown, expectedText: string): Journal {
|
|
383
|
+
const journal = checkedJournal(files, captureId, value), state = readSources(files, captureId)
|
|
384
|
+
const pins = journal.phase === 'complete' ? journal.pins.slice(0, 4) : journal.pins
|
|
385
|
+
checkPins(pins)
|
|
386
|
+
const current = state.input.annotationJSON
|
|
387
|
+
if (journal.phase === 'complete' ? current !== journal.after : current !== journal.before && current !== journal.after) fail('journal annotation postimage changed')
|
|
388
|
+
validate({ ...state.input, annotationJSON: journal.after })
|
|
389
|
+
if (current !== null && current !== journal.after) validate(state.input)
|
|
390
|
+
// Catch a concurrent replacement during the potentially expensive pure replay.
|
|
391
|
+
checkPins(pins)
|
|
392
|
+
if (readMacCallHumanFile(state.paths.journal, 40 * 1024 * 1024)!.text !== expectedText || (readMacCallHumanFile(state.paths.annotation, MAX, true)?.text ?? null) !== current) fail('journal snapshot changed')
|
|
393
|
+
return journal
|
|
394
|
+
}
|
|
395
|
+
export interface MacIntervalHumanJournalStatus {
|
|
396
|
+
status: 'absent' | 'complete' | 'prepared' | 'recovery-invalid'
|
|
397
|
+
reason?: string
|
|
398
|
+
}
|
|
399
|
+
/** Bounded inspection only: no locks, recovery, file creation, or mutation. */
|
|
400
|
+
export function inspectMacIntervalHumanJournal(files: MacIntervalHumanFiles, captureId: string): MacIntervalHumanJournalStatus {
|
|
401
|
+
try {
|
|
402
|
+
const paths = macIntervalHumanPaths(files, captureId), stored = readMacCallHumanFile(paths.journal, 40 * 1024 * 1024, true)
|
|
403
|
+
if (!stored) {
|
|
404
|
+
const lock = readMacCallHumanFile(paths.lock, 4096, true)
|
|
405
|
+
if (lock) {
|
|
406
|
+
let owner: any
|
|
407
|
+
try { owner = JSON.parse(lock.text) } catch { /* Foreign lock handling belongs to its owner. */ }
|
|
408
|
+
if (owner?.kind === POLICY && owner.journal === paths.journal) return { status: 'recovery-invalid', reason: 'Interval-human lock without a durable intent; explicit dead-owner recovery required' }
|
|
409
|
+
}
|
|
410
|
+
return { status: 'absent' }
|
|
411
|
+
}
|
|
412
|
+
const journal = verifyJournalSnapshot(files, captureId, JSON.parse(stored.text), stored.text)
|
|
413
|
+
return { status: journal.phase }
|
|
414
|
+
} catch (error) { return { status: 'recovery-invalid', reason: error instanceof Error ? error.message : 'Invalid interval annotation journal' } }
|
|
415
|
+
}
|
|
416
|
+
function runJournal(files: MacIntervalHumanFiles, captureId: string, value: Journal, expectedText: string, assertOwned: () => void, afterWrite?: (index: number) => void) {
|
|
417
|
+
const journal = checkedJournal(files, captureId, value), paths = macIntervalHumanPaths(files, captureId), { journalSHA256: _, ...body } = journal
|
|
418
|
+
const fence = () => { assertOwned(); if (readMacCallHumanFile(paths.journal, 40 * 1024 * 1024)!.text !== expectedText) fail('journal changed'); const text = readMacCallHumanFile(paths.annotation, MAX, true)?.text ?? null; if (text !== journal.before && text !== journal.after) fail('later human edit'); return text }
|
|
419
|
+
if (journal.phase === 'complete') { if (fence() !== journal.after) fail('completed annotation changed'); verifyJournalSnapshot(files, captureId, journal, expectedText); assertOwned(); return }
|
|
420
|
+
checkPins(journal.pins)
|
|
421
|
+
const current = readSources(files, captureId)
|
|
422
|
+
validate({ ...current.input, annotationJSON: journal.after })
|
|
423
|
+
if (fence() !== journal.after) atomic(paths.annotation, journal.after, () => { fence(); checkPins(journal.pins) })
|
|
424
|
+
afterWrite?.(0)
|
|
425
|
+
if (fence() !== journal.after) fail('publication changed')
|
|
426
|
+
checkPins(journal.pins)
|
|
427
|
+
const complete = `${JSON.stringify(journalSeal({ ...body, phase: 'complete' }))}\n`
|
|
428
|
+
atomic(paths.journal, complete, () => { if (fence() !== journal.after) fail('publication changed'); checkPins(journal.pins) })
|
|
429
|
+
if (readMacCallHumanFile(paths.journal, 40 * 1024 * 1024)!.text !== complete) fail('journal readback changed')
|
|
430
|
+
}
|
|
431
|
+
function publish(files: MacIntervalHumanFiles, captureId: string, state: ReturnType<typeof readSources>, annotation: Annotation, assertOwned: () => void, afterWrite?: (index: number) => void) {
|
|
432
|
+
const after = `${JSON.stringify(annotation)}\n`
|
|
433
|
+
if (Buffer.byteLength(after) > MAX) fail('annotation byte budget')
|
|
434
|
+
validate({ ...state.input, annotationJSON: after })
|
|
435
|
+
const priorJournal = readMacCallHumanFile(state.paths.journal, 40 * 1024 * 1024, true)
|
|
436
|
+
if (priorJournal) {
|
|
437
|
+
const parsed = JSON.parse(priorJournal.text) as Journal
|
|
438
|
+
if (parsed.phase !== 'complete') fail('annotation recovery required')
|
|
439
|
+
runJournal(files, captureId, parsed, priorJournal.text, assertOwned)
|
|
440
|
+
}
|
|
441
|
+
const journal = journalSeal({ version: 1, policy: POLICY, captureId, phase: 'prepared', pins: state.pins, before: state.input.annotationJSON, after }), text = `${JSON.stringify(journal)}\n`
|
|
442
|
+
atomic(state.paths.journal, text, () => { assertOwned(); checkPins(state.pins); if ((readMacCallHumanFile(state.paths.annotation, MAX, true)?.text ?? null) !== state.input.annotationJSON || (readMacCallHumanFile(state.paths.journal, 40 * 1024 * 1024, true)?.text ?? null) !== (priorJournal?.text ?? null)) fail('publication precondition changed') })
|
|
443
|
+
runJournal(files, captureId, journal, text, assertOwned, afterWrite)
|
|
444
|
+
}
|
|
445
|
+
export function commitMacIntervalHumanAnnotation(input: { files: MacIntervalHumanFiles; captureId: string; scope: MacIntervalHumanScope; memberIds: string[]; to: string; afterWrite?: (index: number) => void }): { correctionId: string; revision: number; members: number; fields: number } {
|
|
446
|
+
previewMacIntervalHumanAnnotation(input.scope, input.memberIds, input.to)
|
|
447
|
+
const paths = macIntervalHumanPaths(input.files, input.captureId), guard = acquire(paths.lock, paths.journal, false)
|
|
448
|
+
try {
|
|
449
|
+
const state = readSources(input.files, input.captureId), view = canonical(state.input), scopes = buildMacIntervalHumanScopes({ ...state.input, view }), scope = scopes.find(s => s.scopeSHA256 === input.scope.scopeSHA256)
|
|
450
|
+
if (!scope || !equal(scope, input.scope)) fail('reviewed source changed')
|
|
451
|
+
const selected = previewMacIntervalHumanAnnotation(scope, input.memberIds, input.to), { annotation: existing, active } = validate(state.input)
|
|
452
|
+
const packet = [qualifiedPacket(state.input), ...(existing?.packets ?? [])].find(p => p?.packetSHA256 === scope.packetSHA256)
|
|
453
|
+
if (!packet) fail('reviewed packet unavailable')
|
|
454
|
+
const predecessor = scope.kind === 'human-words' ? active.find(a => actionIdentity(a.id) === scope.identityId) : undefined
|
|
455
|
+
if (scope.kind === 'human-words' && !predecessor) fail('reviewed active action changed')
|
|
456
|
+
const words = predecessor ? new Map(predecessor.members.map(m => [m.id, m])) : scope.kind === 'unresolved-words' ? manualWords(state.input.baseJSON, packet) : packetWords(state.input.baseJSON, state.input.ledgerJSONL, packet)
|
|
457
|
+
const members = selected.members.map(m => { const word = words.get(m.id); if (!word) fail('reviewed word unavailable'); return cleanWord(word) })
|
|
458
|
+
const id = `${POLICY}:${randomUUID()}`, event: NameEvent = { kind: 'name', id, at: new Date().toISOString(), identityId: scope.identityId, displayLabel: scope.displayLabel, to: input.to, scopeSHA256: scope.scopeSHA256, packetSHA256: packet.packetSHA256, supportSHA256: scope.supportSHA256, generation: scope.generation, packetRevision: scope.packetRevision, priorAnnotationSHA256: existing?.annotationSHA256 ?? null, members, reviewKind: scope.kind, ...(predecessor ? { supersedesActionId: predecessor.id } : {}), ...(scope.kind === 'unresolved-words' ? { projectionPacketSHA256: scope.projectionPacketSHA256 } : {}) }
|
|
459
|
+
const annotation = seal({ version: 1, policy: POLICY, captureId: input.captureId, ...source(state.input), revision: (existing?.revision ?? 0) + 1, packets: existing?.packets.some(p => p.packetSHA256 === packet.packetSHA256) ? existing.packets : [...(existing?.packets ?? []), packet], events: [...(existing?.events ?? []), event] })
|
|
460
|
+
publish(input.files, input.captureId, state, annotation, guard.assertOwned, input.afterWrite)
|
|
461
|
+
return { correctionId: id, revision: annotation.revision, members: selected.members.length, fields: selected.fields }
|
|
462
|
+
} finally { guard.release() }
|
|
463
|
+
}
|
|
464
|
+
export function undoMacIntervalHumanAnnotation(input: { files: MacIntervalHumanFiles; captureId: string; actionId: string; revision: number; annotationSHA256: string; afterWrite?: (index: number) => void }): { correctionId: string; revision: number; undone: boolean } {
|
|
465
|
+
const paths = macIntervalHumanPaths(input.files, input.captureId), guard = acquire(paths.lock, paths.journal, false)
|
|
466
|
+
try {
|
|
467
|
+
const journal = inspectMacIntervalHumanJournal(input.files, input.captureId)
|
|
468
|
+
if (journal.status === 'prepared' || journal.status === 'recovery-invalid') fail('annotation recovery required')
|
|
469
|
+
const state = readSources(input.files, input.captureId), { annotation: existing, active, undone } = validate(state.input)
|
|
470
|
+
if (!existing || !existing.events.some(e => e.kind === 'name' && e.id === input.actionId)) fail('unknown undo action')
|
|
471
|
+
if (!active.some(e => e.id === input.actionId)) {
|
|
472
|
+
if (!undone.has(input.actionId)) fail('action has an active superseding edit; undo latest rename first')
|
|
473
|
+
return { correctionId: input.actionId, revision: existing.revision, undone: false }
|
|
474
|
+
}
|
|
475
|
+
if (existing.revision !== input.revision || existing.annotationSHA256 !== input.annotationSHA256) fail('reviewed annotation changed')
|
|
476
|
+
const event: UndoEvent = { kind: 'undo', id: `${POLICY}:${randomUUID()}`, at: new Date().toISOString(), actionId: input.actionId, priorAnnotationSHA256: existing.annotationSHA256 }
|
|
477
|
+
const { annotationSHA256: _, ...body } = existing, annotation = seal({ ...body, revision: existing.revision + 1, events: [...existing.events, event] })
|
|
478
|
+
publish(input.files, input.captureId, state, annotation, guard.assertOwned, input.afterWrite)
|
|
479
|
+
return { correctionId: input.actionId, revision: annotation.revision, undone: true }
|
|
480
|
+
} finally { guard.release() }
|
|
481
|
+
}
|
|
482
|
+
export function recoverMacIntervalHumanAnnotation(files: MacIntervalHumanFiles, captureId: string, afterWrite?: (index: number) => void): void {
|
|
483
|
+
const paths = macIntervalHumanPaths(files, captureId), journal = readMacCallHumanFile(paths.journal, 40 * 1024 * 1024, true)
|
|
484
|
+
if (!journal) {
|
|
485
|
+
const lock = readMacCallHumanFile(paths.lock, 4096, true)
|
|
486
|
+
if (!lock) return
|
|
487
|
+
let owner: any
|
|
488
|
+
try { owner = JSON.parse(lock.text) } catch { fail('foreign lock') }
|
|
489
|
+
if (owner?.kind !== POLICY || owner.journal !== paths.journal) fail('foreign lock')
|
|
490
|
+
// A crash before the first durable intent moved no annotation. Reclaim
|
|
491
|
+
// only this operation's unchanged lock after a signal-0 ESRCH; a live or
|
|
492
|
+
// unknown owner is never inferred dead, signalled, or automatically resumed.
|
|
493
|
+
const orphanGuard = acquire(paths.lock, paths.journal, true, lock.hash)
|
|
494
|
+
try { if (readMacCallHumanFile(paths.journal, 40 * 1024 * 1024, true)) fail('journal appeared during orphan recovery') } finally { orphanGuard.release() }
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
const guard = acquire(paths.lock, paths.journal, true)
|
|
498
|
+
try { runJournal(files, captureId, JSON.parse(journal.text), journal.text, guard.assertOwned, afterWrite) } finally { guard.release() }
|
|
499
|
+
}
|