feat(learn): a private studyset goes to an assistant only once agreed

ADR 0021, D9 and D17. Before a question sends a private studyset's
content to a remote model, through the parts chosen or a wider reach
with private studysets switched on, a warning names the studysets, the
provider's host and its terms (where checked: Anthropic, OpenAI,
Google, OpenRouter), and is asked once per studyset and assistant: the
agreement is kept in the person's sealed settings (schema 13,
privateAgreements, folded only where both rows hold it), shown in the
studyset's privacy dialog with Take back, and marked on the folded
button while it holds. Follow-ups and Try again ask again once taken
back, as the scope keeps where the parts came from (partsFrom). No
warning for an own assistant on this machine (loopback or localhost,
halkyon_llm's LlmEndpoint.onThisMachine); the local network may be
shared.

Texts that step 3 made untrue: the consent sheet now says what an
assistant looks up goes too and that conversations are kept encrypted
for the person's devices; the privacy dialogs no longer mention
showing a studyset to Claude; the privacy page says what is looked up
and when a private studyset goes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-25 21:00:39 +01:00
commit bf46f8c460
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 1488 additions and 107 deletions

View file

@ -183,6 +183,7 @@ void main() {
final parts = AssistantTopicCard(
studysetId: 'japanese',
studysetTitle: 'Japanese',
cardStudysetId: 'japanese',
card: card,
).parts;

View file

@ -10,12 +10,15 @@ import 'package:halkyon_learn_flutter/src/feature/assistant/data/conversation_re
import 'package:halkyon_learn_flutter/src/feature/assistant/data/web_search.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_conversation.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/assistant_field.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/settings/model/person_settings.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_conversation_gateway.dart';
import '../../support/fake_settings_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/memory_device_settings_store.dart';
import '../../support/pump_app.dart';
@ -24,7 +27,7 @@ import '../../support/test_dependencies.dart';
// Asking an assistant from the field (ADR 0021, D47 to D52): who answers
// picked in the field, how far it may look widened only by the person, the
// conversation in a sheet over the screen, kept conversations listed in the
// field.
// field; a private studyset's content sent only once agreed to.
final _agreed = DateTime.utc(2026, 10, 25);
@ -54,6 +57,8 @@ void main() {
late FakeAssistantGateway assistants;
late FakeConversationGateway kept;
late FakeStudysetGateway studysets;
late FakeSettingsGateway settings;
late FakeLlmClient llm;
late MemoryDeviceSettingsStore store;
late List<Uri> searched;
@ -63,7 +68,15 @@ void main() {
kept = FakeConversationGateway();
studysets = FakeStudysetGateway()
..receiveRemoteChange('japanese', _studyset('Japanese'))
..receiveRemoteChange('verbs', _studyset('Verbs'), parentId: 'japanese');
..receiveRemoteChange('verbs', _studyset('Verbs'), parentId: 'japanese')
..receiveRemoteChange(
'diary',
_studyset('Diary'),
position: 'a1',
isPrivate: true,
);
settings = FakeSettingsGateway();
llm = FakeLlmClient();
searched = [];
});
@ -83,6 +96,8 @@ void main() {
assistantGateway: assistants,
conversationGateway: kept,
studysetGateway: studysets,
settingsGateway: settings,
llm: llm,
webSearch: WebSearch(
open: (url) async {
searched.add(url);
@ -120,11 +135,18 @@ void main() {
}
/// Unfolds the floating button, an assistant answering.
Future<void> unfold(WidgetTester tester) async {
await tester.tap(find.byTooltip('Ask'));
Future<void> unfold(WidgetTester tester, {String tooltip = 'Ask'}) async {
await tester.tap(find.byTooltip(tooltip));
await tester.pumpAndSettle();
}
/// The settings the person's devices last wrote.
PersonSettings lastSettings() =>
decodeSettingsPayload(settings.writes.last.$2);
const warning = 'Send a private studyset to Claude?';
const marked = 'Ask\nClaude may be sent this private studyset';
testWidgets("Home's field asks the assistant granted, and the answer comes "
'in a sheet over Home, kept', (tester) async {
await showApp(tester);
@ -338,4 +360,211 @@ void main() {
expect(find.text('Now it works.', findRichText: true), findsOneWidget);
expect(find.text('Try again'), findsNothing);
});
testWidgets("a private studyset's content goes to a remote assistant only "
'once the person agreed, which is asked once per studyset and '
'assistant, the button marked while it holds (D9, D17)', (tester) async {
await showApp(tester, location: RoutePaths.studyset('diary'));
await unfold(tester);
await tester.tap(find.text('Studyset: Diary'));
await tester.pump();
await ask(tester, 'What is this about?');
expect(find.text(warning), findsOneWidget);
expect(find.textContaining('to api.anthropic.com'), findsOneWidget);
expect(find.text("The provider's terms"), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(assistants.asked, isEmpty);
expect(find.text('What is this about?'), findsOneWidget);
assistants.answers.add(_says('Your diary.'));
await tester.tap(
find.descendant(
of: find.byType(AssistantField),
matching: find.byTooltip('Ask'),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Send'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(assistants.asked, hasLength(1));
expect(find.text('Your diary.', findRichText: true), findsOneWidget);
expect(lastSettings().agreedToSend('diary', 'assistant:claude'), isTrue);
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
expect(find.byTooltip(marked), findsOneWidget);
await unfold(tester, tooltip: marked);
await tester.tap(find.text('Studyset: Diary'));
await tester.pump();
assistants.answers.add(_says('Still your diary.'));
await ask(tester, 'And now?');
expect(find.text(warning), findsNothing);
expect(assistants.asked, hasLength(2));
});
testWidgets('a wider reach with private studysets switched on warns of '
'those within it, and sends nothing unless agreed (D8, D9)', (
tester,
) async {
await showApp(tester);
Finder inField(String text) => find.descendant(
of: find.byType(AssistantField),
matching: find.text(text),
);
await tester.tap(inField('Only what I choose'));
await tester.pumpAndSettle();
await tester.tap(find.text('My whole library'));
await tester.pumpAndSettle();
await tester.tap(inField('Library'));
await tester.pumpAndSettle();
await tester.tap(find.text('Include private studysets'));
await tester.pumpAndSettle();
expect(inField('Library, private ones too'), findsOneWidget);
await ask(tester, 'What do I study?');
expect(find.text(warning), findsOneWidget);
expect(find.text('Diary'), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(assistants.asked, isEmpty);
expect(settings.writes, isEmpty);
});
testWidgets('an own assistant on this machine is sent a private studyset '
'without a warning; one elsewhere on the network is not (D9)', (
tester,
) async {
assistants.offer = const AssistantsOffer(ownKeys: true);
settings.receiveRemoteRow(
'settings-own',
encodeSettingsPayload(
const PersonSettings(
ownAssistants: [
OwnAssistant(
id: 'local',
name: 'Local',
protocol: LlmProtocol.openai,
baseUrl: 'http://127.0.0.1:11434/v1',
model: 'qwen3',
apiKey: '',
),
OwnAssistant(
id: 'desk',
name: 'Desk',
protocol: LlmProtocol.openai,
baseUrl: 'http://192.168.1.20:11434/v1',
model: 'qwen3',
apiKey: '',
),
],
),
),
);
await showApp(tester, location: RoutePaths.studyset('diary'));
await unfold(tester);
expect(find.text('Ask Local…'), findsOneWidget);
await tester.tap(find.text('Studyset: Diary'));
await tester.pump();
llm.replyText('A diary.');
await ask(tester, 'What is this?');
expect(find.textContaining('Send a private studyset'), findsNothing);
expect(llm.sent, hasLength(1));
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
await unfold(tester);
await tester.tap(find.byTooltip('Who answers'));
await tester.pumpAndSettle();
await tester.tap(find.text('Desk'));
await tester.pumpAndSettle();
await tester.tap(find.text('Studyset: Diary'));
await tester.pump();
await ask(tester, 'And this?');
expect(find.text('Send a private studyset to Desk?'), findsOneWidget);
expect(
find.textContaining('goes straight to 192.168.1.20'),
findsOneWidget,
);
expect(find.text("The provider's terms"), findsNothing);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(llm.sent, hasLength(1));
});
testWidgets("the studyset's privacy lists the assistants it may go to, "
'and takes one back (D17)', (tester) async {
settings.receiveRemoteRow(
'settings-agreed',
encodeSettingsPayload(
const PersonSettings(
privateAgreements: {
'diary': {'assistant:claude', 'assistant:gone'},
},
),
),
);
await showApp(tester, location: RoutePaths.library);
await tester.tap(find.byTooltip('Private'));
await tester.pumpAndSettle();
expect(find.text('“Diary” is private'), findsOneWidget);
expect(find.text('Assistants it may go to'), findsOneWidget);
expect(find.text('Claude'), findsOneWidget);
expect(find.text('An assistant no longer offered'), findsOneWidget);
await tester.tap(find.text('Take back').first);
await tester.pumpAndSettle();
expect(find.text('Claude'), findsNothing);
expect(lastSettings().privateAgreementsOf('diary'), {'assistant:gone'});
});
testWidgets('a follow-up sends the conversation again, so it asks again '
'once the agreement was taken back (D17)', (tester) async {
final conversations = ConversationRepository(kept);
addTearDown(conversations.dispose);
final id = await conversations.start(
question: 'What is my diary about?',
assistant: const AssistantRef(id: 'claude'),
scope: const AssistantScope(studysetId: 'diary', partsFrom: {'diary'}),
);
await conversations.keep(id, [
LlmMessage.user(const [
LlmText('What is my diary about?'),
LlmText('\n\nStudyset:\nDiary'),
]),
LlmMessage.assistant(const [LlmText('Days.')]),
], LlmUsage.none);
await showApp(tester);
await tester.tap(find.byTooltip('Conversations'));
await tester.pumpAndSettle();
await tester.tap(find.text('What is my diary about?'));
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
await tester.enterText(find.widgetWithText(TextField, 'Ask more…'), 'Why?');
await tester.pump();
await tester.tap(
find.descendant(
of: find.byType(BottomSheet),
matching: find.byTooltip('Ask'),
),
);
await tester.pumpAndSettle();
expect(find.text(warning), findsOneWidget);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(assistants.asked, isEmpty);
expect(find.text('Why?'), findsOneWidget, reason: 'the question stays');
});
}

View file

@ -202,6 +202,99 @@ void main() {
});
});
group('knows which private studysets a question sends (D9), each by the '
'studyset its tree is private by,', () {
// Beside the others: Korean, private, with Verbs inside it.
final withKorean = AssistantLookups(
studysets: () async => [
..._studysets,
_studyset('ko', 'Korean', position: 'a3', private: true),
_studyset('ko-verbs', 'Korean verbs', parent: 'ko', private: true),
],
cardsOf: (id) async => const [],
notesOf: (id) async => const [],
);
Future<List<String>> sent(AssistantScope scope) async => [
for (final studyset in await withKorean.privateSent(scope)) studyset.id,
];
test('none for parts of what is readable', () async {
expect(
await sent(const AssistantScope(studysetId: 'ja', partsFrom: {'ja'})),
isEmpty,
);
});
test("a private topic's own, a card of it asked about in a session of "
'the readable studyset around it', () async {
expect(
await sent(
const AssistantScope(studysetId: 'ja', partsFrom: {'ja', 'kanji'}),
),
['kanji'],
);
});
test("a private tree's top, for parts of a topic inside it", () async {
expect(
await sent(
const AssistantScope(studysetId: 'ko-verbs', partsFrom: {'ko-verbs'}),
),
['ko'],
);
});
test('none within a wider reach while private studysets are off', () async {
expect(
await sent(const AssistantScope(reach: AssistantReach.library)),
isEmpty,
);
expect(
await sent(
const AssistantScope(
reach: AssistantReach.studyset,
studysetId: 'ko',
),
),
isEmpty,
);
});
test(
'those within reach once they are on, never one locked here',
() async {
expect(
await sent(
const AssistantScope(
reach: AssistantReach.library,
includePrivate: true,
),
),
['kanji', 'ko'],
);
expect(
await sent(
const AssistantScope(
reach: AssistantReach.studyset,
studysetId: 'ja',
includePrivate: true,
),
),
['kanji'],
);
},
);
test('none for what is only chosen, private studysets on or not', () async {
expect(
await sent(
const AssistantScope(studysetId: 'ja', includePrivate: true),
),
isEmpty,
);
});
});
test('names the studyset asked on, or its top, only within reach '
'(D8)', () async {
Future<String?> title(AssistantScope scope) => lookups.titleOf(scope);

View file

@ -18,6 +18,7 @@ void main() {
reach: AssistantReach.tree,
studysetId: 'studyset-1',
includePrivate: true,
partsFrom: {'studyset-1', 'topic-2'},
);
final conversation = decodeConversationPayload(
'c-1',

View file

@ -603,4 +603,93 @@ void main() {
expect(here.foldedWith(there).ownAssistants, [claude, local]);
});
});
group("the agreements to send a private studyset's content to an "
'assistant (ADR 0021, D9, D17)', () {
test('are given for studysets at once, held per studyset and '
'assistant, and taken back one by one', () {
final settings = const PersonSettings()
.withPrivateAgreements(['ja', 'ko'], 'assistant:claude')
.withPrivateAgreements(['ja'], 'own:own-1');
expect(settings.agreedToSend('ja', 'assistant:claude'), isTrue);
expect(settings.agreedToSend('ko', 'assistant:claude'), isTrue);
expect(settings.agreedToSend('ko', 'own:own-1'), isFalse);
expect(settings.privateAgreementsOf('ja'), {
'assistant:claude',
'own:own-1',
});
final taken = settings.withoutPrivateAgreement('ja', 'assistant:claude');
expect(taken.agreedToSend('ja', 'assistant:claude'), isFalse);
expect(taken.agreedToSend('ja', 'own:own-1'), isTrue);
expect(
taken
.withoutPrivateAgreement('ko', 'assistant:claude')
.privateAgreements
.keys,
['ja'],
reason: 'a studyset agreed for none is left out',
);
});
test('survive a round trip, in the thirteenth schema, left out while '
'there are none; a payload from before has none', () {
final settings = const PersonSettings()
.withPrivateAgreements(['ja'], 'assistant:claude')
.withPrivateAgreements(['ja', 'ko'], 'own:own-1');
expect(settingsSchemaVersion, greaterThanOrEqualTo(13));
expect(decodeSettingsPayload(encodeSettingsPayload(settings)), settings);
expect(
decodePayload(
encodeSettingsPayload(const PersonSettings()),
newestSchema: settingsSchemaVersion,
).keys,
isNot(contains('privateAgreements')),
);
expect(
decodeSettingsPayload(
encodePayload(12, {'studyTags': <String, Object?>{}}),
).privateAgreements,
isEmpty,
);
});
test('that are malformed are skipped, keeping the rest', () {
final read = decodeSettingsPayload(
encodePayload(settingsSchemaVersion, {
'studyTags': <String, Object?>{},
'privateAgreements': {
'ja': ['assistant:claude', 3],
'ko': 'assistant:claude',
'zh': <Object?>[],
},
}),
);
expect(read.privateAgreements, {
'ja': {'assistant:claude'},
});
});
test("hold, as two devices' settings fold, only where both hold them, "
'as Count me: an older row, or one written before a Take back, '
'never sends a private studyset again', () {
final both = const PersonSettings()
.withPrivateAgreements(['ja', 'ko'], 'assistant:claude')
.withPrivateAgreements(['ja'], 'own:own-1');
final takenBack = const PersonSettings().withPrivateAgreements([
'ja',
], 'assistant:claude');
expect(both.foldedWith(takenBack).privateAgreements, {
'ja': {'assistant:claude'},
});
expect(takenBack.foldedWith(both).privateAgreements, {
'ja': {'assistant:claude'},
});
expect(both.foldedWith(both), both);
});
});
}

View file

@ -9,12 +9,14 @@ Studyset studyset(
String? parent,
String position = 'a0',
DateTime? archivedAt,
bool private = false,
}) => Studyset(
id: id,
content: StudysetContent.titled(id),
parentId: parent,
position: position,
archivedAt: archivedAt,
isPrivate: private,
);
Iterable<String> ids(List<Studyset> studysets) =>
@ -128,6 +130,26 @@ void main() {
});
});
test('a private studyset is private by its tree, or by itself where it '
'is a topic in a readable one (ADR 0019)', () {
final all = [
studyset('japanese', private: true),
studyset('kana', parent: 'japanese', private: true),
studyset('german'),
studyset('verbs', parent: 'german', private: true),
studyset('strong', parent: 'verbs', private: true),
studyset('a', parent: 'b', private: true),
studyset('b', parent: 'a', private: true),
];
expect(privateTopOf('kana', all), 'japanese');
expect(privateTopOf('japanese', all), 'japanese');
expect(privateTopOf('strong', all), 'verbs');
expect(privateTopOf('german', all), isNull);
expect(privateTopOf('missing', all), isNull);
expect(privateTopOf('b', all), anyOf('a', 'b'), reason: 'a cycle ends');
});
group('what a session on a studyset studies', () {
test('is its subtree in tree order, without archived studysets and '
'what is inside them', () {