Audit 2026-05-12 F-V3: two related defects in the mid-session TCC
lifecycle.
(1) `CameraCapture.startWatchers()` only observed
`AVCaptureSessionRuntimeError`. macOS Sequoia / 26 Tahoe surface
mid-session Camera TCC revoke and device-stolen-by-other-process events
through `AVCaptureSessionWasInterruptedNotification`, not through
RuntimeError. The watcher therefore sat waiting for a yield that never
came, and the `cameraRevoked` HUD banner never fired.
Fix: add an `AVCaptureSessionWasInterruptedNotification` observer to
`startWatchers()` that yields `.midSessionRevoked` on the degradation
stream. macOS quirk: `AVCaptureSessionInterruptionReasonKey` and the
`InterruptionReason` enum are `API_UNAVAILABLE(macos)` per AVFoundation
headers (iOS / macCatalyst / tvOS / visionOS only), so the observer
cannot discriminate by reason — any macOS interruption means the
session can't continue capturing, and the user should see the revoked
banner. A new `simulateInterruption()` test seam posts a synthetic
notification through the same code path to validate observer wiring.
(2) `CameraCapture.becameAuthorized()` had zero non-doc callsites
outside its own declaration (`grep -rn 'becameAuthorized' --include
'*.swift'` confirmed). A user who denied Camera at first launch and
later granted via Settings while the app was running never recovered
— the capture session stayed dormant until app restart.
Fix: AppDelegate now registers a `NSApplication.didBecomeActive`
observer in `installVision()` that re-checks
`AVCaptureDevice.authorizationStatus(for: .video)` on every foreground
return. On a `non-authorized → .authorized` flip it calls
`capture.becameAuthorized()` to reopen the session. State machinery is
MainActor-isolated and cleaned up alongside the existing camera
degradation task in `applicationWillTerminate`. Skipped under XCTest
(`Self.isRunningAsTestHost`) so the host app doesn't double-register
during the test bundle's app lifecycle.
Tests:
- `CameraCaptureMidSessionRevokeTests.testInterruptionYieldsMidSessionRevoked`
fires a synthetic interruption and asserts the degradation stream
yields `.midSessionRevoked`.
- `CameraCaptureTCCTests.testBecameAuthorizedOpensSessionAfterRegrant`
seeds `.denied` → asserts `open()` throws `tccDenied` → flips the
probe to `.authorized` → asserts `becameAuthorized()` opens the
session.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Audit 2026-05-12 F-V1: AgentOrchestrator.runTurn computed
`t2AvailableForThisTurn = (decision.tier != .t3Cloud)` and passed `true`
on every local-tier vision turn. Production wires `MissingT2Provider`
(the real T2 sidecar is v1.1 work, GH issue #84) whose stream throws
`VisionError.t2ProviderUnavailable`. Combined with the router's
escalate-on-low-confidence heuristic, every short / "I'm not sure" T1
response escalated into MissingT2Provider and killed the turn with a
provider error instead of staying on T1.
The audit's recommended fix (option a / option b) is option (b)
modified: combine the local-tier flag with an honest
`hasRealT2Provider` flag plumbed through `AgentOrchestrator.init`.
Defaults `false`, so test sites and the production wiring both stay on
T1 unless a real T2 provider is opted in. The pre-existing escalation
test in `AgentOrchestratorVisionDispatchTests` flips
`hasRealT2Provider: true` to exercise the swap path against a real mock
T2; a new regression test wires `MissingT2Provider` + the default
`false` flag + a low-confidence T1 response and asserts the turn
finishes cleanly (no .error event, exactly one .turnEnd) — the exact
shape that broke in production.
`AppDelegate.installAgent` passes `hasRealT2Provider: false` explicitly
alongside the `MissingT2Provider` wiring in `installVision`. Flip the
flag when a real T2 provider replaces MissingT2Provider (#84).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Audit 2026-05-12 F-V2: FrameAttachController.confirmSend returned an
ImageBlock but did not clear pendingFrame; the slot was released via the
broadcaster's .turnEnd watcher calling onAssistantTurnComplete(). When
the orchestrator returned .rejected (no turn allocated), no .turnEnd
ever flowed and the JPEG bytes sat in actor memory past the D-15
byte-release window until the next requestAttach overwrote them.
Adds releaseAfterRejectedSubmit() — a dedicated entry point routed
through the SOLE discardFrame() emission site so the D-15
single-emission-site invariant (FrameAttachDiscardSiteGrepTests) still
holds with exactly one `pendingFrame = nil` assignment. AppDelegate's
handleChatSubmit / handleChatCancelAndSubmit call it whenever they
consumed a pending frame and the orchestrator returned .rejected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>