From 12bdba3677a81393c13a7b3a05c02d989213d7d9 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 4 Sep 2026 04:48:24 -0500 Subject: [PATCH] fix(local): answer the familiar reads that standalone mode cannot serve main does not typecheck. #89 added familiarContract and familiarAnalytics to QueryAdapter; #61 added createLocalQueryAdapter implementing that interface. Neither conflicted textually and each passed CI alone, because #61's run predated #89's merge, so the two were never typechecked together. Both reads answer service_unavailable. A familiar's contract and its execution analytics are Cave's to report -- what a familiar has been granted, and what it has since done -- and a device holding only local conversations has neither record. Returning an empty contract instead would read as "this familiar is permitted nothing", which is a different claim from "nobody asked Cave". Extends the existing status sweep to both members, so a future addition to the interface is caught by a test rather than by main going red, and adds a focused test for the codes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KacEJmkX5GhkViPUhx9Mie --- src/lib/local/local-chat.test.ts | 18 ++++++++++++++++++ src/lib/local/local-query-adapter.ts | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/lib/local/local-chat.test.ts b/src/lib/local/local-chat.test.ts index a4cc7122..cb3dfb8f 100644 --- a/src/lib/local/local-chat.test.ts +++ b/src/lib/local/local-chat.test.ts @@ -416,6 +416,22 @@ describe('local query adapter surface', () => { } }); + it('reports contract and analytics as unavailable rather than inventing them', async () => { + const { store } = await seedConversations(1); + const adapter = createLocalQueryAdapter(store); + + // An empty contract would read as "this familiar is permitted nothing", + // which is a different claim from "nobody asked Cave". + await expect(adapter.familiarContract(LOCAL_FAMILIAR_ID)).resolves.toEqual({ + status: 'error', + code: 'service_unavailable', + }); + await expect(adapter.familiarAnalytics(LOCAL_FAMILIAR_ID)).resolves.toEqual({ + status: 'error', + code: 'service_unavailable', + }); + }); + it('never emits loading, stale, or reconcile_required', async () => { const { store } = await seedConversations(1); const adapter = createLocalQueryAdapter(store); @@ -427,6 +443,8 @@ describe('local query adapter surface', () => { adapter.listConversations(), adapter.getConversation('missing'), adapter.listMessages('missing'), + adapter.familiarContract('missing'), + adapter.familiarAnalytics('missing'), ]) ).map((result) => result.status); diff --git a/src/lib/local/local-query-adapter.ts b/src/lib/local/local-query-adapter.ts index 6b969c1e..04d3d21d 100644 --- a/src/lib/local/local-query-adapter.ts +++ b/src/lib/local/local-query-adapter.ts @@ -1,3 +1,4 @@ +import type { CaveFamiliarAnalytics, CaveFamiliarContract } from '@opencoven/cave-client'; import type { CaveCanonicalFamiliar, CaveConversation, @@ -32,6 +33,19 @@ const INVALID_REQUEST_RESULT = Object.freeze({ code: 'invalid_request', } as const); +/** + * A familiar's contract and its execution analytics are Cave's to report. They + * describe what a familiar has been granted and what it has since done, and a + * device holding only local conversations has neither record. Standalone mode + * answers `service_unavailable` rather than inventing an empty contract, which + * would read as "this familiar is permitted nothing" instead of "nobody asked + * Cave". + */ +const CAVE_ONLY_RESULT = Object.freeze({ + status: 'error', + code: 'service_unavailable', +} as const); + const EMPTY_PAGE = Object.freeze({ data: Object.freeze([]), cursor: Object.freeze({ hasMore: false }), @@ -196,6 +210,14 @@ export function createLocalQueryAdapter(store: ChatStore): QueryAdapter { }); }, + familiarContract() { + return guard(() => CAVE_ONLY_RESULT); + }, + + familiarAnalytics() { + return guard(() => CAVE_ONLY_RESULT); + }, + invalidate() { // Reads go straight to the in-memory index, so there is nothing stale to // drop. Kept to satisfy the interface the shell calls on refresh.