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
13 changed files with 1488 additions and 107 deletions

View file

@ -8,3 +8,4 @@ export 'src/assistant/model/assistant_failed.dart';
export 'src/assistant/model/assistant_grant.dart';
export 'src/assistant/model/assistant_offer.dart';
export 'src/assistant/model/own_assistant.dart';
export 'src/assistant/model/provider_terms.dart';

View file

@ -63,6 +63,7 @@ export 'src/assistant/model/assistant_failed.dart';
export 'src/assistant/model/assistant_grant.dart';
export 'src/assistant/model/assistant_offer.dart';
export 'src/assistant/model/own_assistant.dart';
export 'src/assistant/model/provider_terms.dart';
export 'src/assistant/widget/assistant_grants_card.dart';
export 'src/assistant/widget/assistant_use_view.dart';
export 'src/assistant/widget/assistant_words.dart';

View file

@ -406,11 +406,11 @@
"assistantOwnNotAllowed": "Wer {instance} betreibt, erlaubt hier keine eigenen Schlüssel, also fragt die App diese nicht.",
"assistantConsentTitle": "Bevor du {name} fragst",
"assistantConsentModel": "Es antwortet das Modell {model} bei {host}.",
"assistantConsentWhatGoes": "Dorthin geht nur, was du sendest: deine Frage und was du davon auswählst, worum es geht. Für den Anbieter gelten seine eigenen Bedingungen.",
"assistantConsentWhatGoes": "Dorthin geht nur, was du sendest: deine Frage, was du davon auswählst, worum es geht, und was es nachschlägt, wo du es lässt. Für den Anbieter gelten seine eigenen Bedingungen.",
"assistantConsentImages": "Es liest die Bilder, die du hinzufügst.",
"assistantConsentInstance": "Es geht auf dem Weg durch {instance}, das nichts davon aufbewahrt.",
"assistantConsentOperator": "Wer {instance} betreibt, sieht, wie viel du es nutzt (Anfragen, Tokens, Kosten), nie, was du fragst.",
"assistantConsentConversations": "Deine Unterhaltungen bleiben auf deinen Geräten, verschlüsselt.",
"assistantConsentConversations": "Deine Unterhaltungen werden verschlüsselt aufbewahrt: Nur deine Geräte können sie lesen.",
"assistantConsentAgree": "Zustimmen",
"assistantConsentAgreedOn": "Du hast am {date} zugestimmt.",
"assistantProblemNotGranted": "Dieser Assistent ist für dich nicht mehr freigegeben.",

View file

@ -1225,7 +1225,7 @@
}
}
},
"assistantConsentWhatGoes": "Only what you send goes there: your question and what you pick of what it is about. The provider's own terms apply to it.",
"assistantConsentWhatGoes": "Only what you send goes there: your question, what you pick of what it is about, and what it looks up where you let it. The provider's own terms apply to it.",
"@assistantConsentWhatGoes": {
"description": "What goes to the provider (D27)."
},
@ -1251,7 +1251,7 @@
}
}
},
"assistantConsentConversations": "Your conversations are kept on your devices, encrypted.",
"assistantConsentConversations": "Your conversations are kept encrypted: only your devices can read them.",
"@assistantConsentConversations": {
"description": "Conversations are sealed on the devices (D13)."
},

View file

@ -2542,7 +2542,7 @@ abstract class HalkyonAppLocalizations {
/// What goes to the provider (D27).
///
/// In en, this message translates to:
/// **'Only what you send goes there: your question and what you pick of what it is about. The provider\'s own terms apply to it.'**
/// **'Only what you send goes there: your question, what you pick of what it is about, and what it looks up where you let it. The provider\'s own terms apply to it.'**
String get assistantConsentWhatGoes;
/// For an assistant that reads images (D12).
@ -2566,7 +2566,7 @@ abstract class HalkyonAppLocalizations {
/// Conversations are sealed on the devices (D13).
///
/// In en, this message translates to:
/// **'Your conversations are kept on your devices, encrypted.'**
/// **'Your conversations are kept encrypted: only your devices can read them.'**
String get assistantConsentConversations;
/// The consent sheet's button.

View file

@ -1533,7 +1533,7 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get assistantConsentWhatGoes =>
'Dorthin geht nur, was du sendest: deine Frage und was du davon auswählst, worum es geht. Für den Anbieter gelten seine eigenen Bedingungen.';
'Dorthin geht nur, was du sendest: deine Frage, was du davon auswählst, worum es geht, und was es nachschlägt, wo du es lässt. Für den Anbieter gelten seine eigenen Bedingungen.';
@override
String get assistantConsentImages =>
@ -1551,7 +1551,7 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
@override
String get assistantConsentConversations =>
'Deine Unterhaltungen bleiben auf deinen Geräten, verschlüsselt.';
'Deine Unterhaltungen werden verschlüsselt aufbewahrt: Nur deine Geräte können sie lesen.';
@override
String get assistantConsentAgree => 'Zustimmen';

View file

@ -1510,7 +1510,7 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get assistantConsentWhatGoes =>
'Only what you send goes there: your question and what you pick of what it is about. The provider\'s own terms apply to it.';
'Only what you send goes there: your question, what you pick of what it is about, and what it looks up where you let it. The provider\'s own terms apply to it.';
@override
String get assistantConsentImages => 'It reads the images you add.';
@ -1527,7 +1527,7 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
@override
String get assistantConsentConversations =>
'Your conversations are kept on your devices, encrypted.';
'Your conversations are kept encrypted: only your devices can read them.';
@override
String get assistantConsentAgree => 'Agree';

View file

@ -38,6 +38,10 @@ final class const OwnAssistant({
/// The host requests go to.
String get host => endpoint.host;
/// Whether it runs on this machine, where a private studyset's content
/// goes without a warning (Halkyon Learn's ADR 0021, D9).
bool get onThisMachine => endpoint.onThisMachine;
@override
List<Object?> get props => [
id,

View file

@ -0,0 +1,19 @@
/// The terms a provider's API is used under, by the host an assistant's
/// requests go to, for the warning before a private studyset's content
/// goes there (Halkyon Learn's ADR 0021, D9); null for a host not listed,
/// which the warning names alone. Only addresses checked to lead to the
/// terms themselves (25 Oct 2026): Mistral's lead to an index of its
/// documents, so it is named alone too.
Uri? providerTermsOf(String host) => switch (host.toLowerCase()) {
'api.anthropic.com' => Uri.https(
'www.anthropic.com',
'/legal/commercial-terms',
),
'api.openai.com' => Uri.https('openai.com', '/policies/services-agreement/'),
'generativelanguage.googleapis.com' => Uri.https(
'ai.google.dev',
'/gemini-api/terms',
),
'openrouter.ai' => Uri.https('openrouter.ai', '/terms'),
_ => null,
};

View file

@ -0,0 +1,45 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/assistant_models.dart';
import 'package:halkyon_llm/halkyon_llm.dart' show LlmProtocol;
void main() {
test("links the terms of the providers it knows, by their API's host", () {
expect(
providerTermsOf('api.anthropic.com'),
Uri.parse('https://www.anthropic.com/legal/commercial-terms'),
);
expect(
providerTermsOf('API.OpenAI.com'),
Uri.parse('https://openai.com/policies/services-agreement/'),
);
expect(
providerTermsOf('generativelanguage.googleapis.com'),
Uri.parse('https://ai.google.dev/gemini-api/terms'),
);
expect(
providerTermsOf('openrouter.ai'),
Uri.parse('https://openrouter.ai/terms'),
);
});
test('links nothing for another host, which is named alone', () {
expect(providerTermsOf('api.mistral.ai'), isNull);
expect(providerTermsOf('192.168.1.20'), isNull);
expect(providerTermsOf('anthropic.com.example.org'), isNull);
});
test('knows an own assistant on this machine, never one elsewhere', () {
OwnAssistant at(String? baseUrl) => OwnAssistant(
id: 'own',
name: 'Mine',
protocol: LlmProtocol.openai,
baseUrl: baseUrl,
model: 'm',
apiKey: '',
);
expect(at('http://localhost:11434/v1').onThisMachine, isTrue);
expect(at('http://192.168.1.20:11434/v1').onThisMachine, isFalse);
expect(at(null).onThisMachine, isFalse);
});
}

View file

@ -258,7 +258,8 @@ final _english = _Words(
'${images ? ', which reads images you add' : ''}.',
whatGoes:
'Only what you send from the app goes, once you agreed to it there: '
'your question and what you pick of what it is about. It passes through '
'your question, what you pick of what it is about, and what the '
'assistant looks up where you let it. It passes through '
'the instance on its way to the provider; the instance keeps none of '
'it, and the provider’s own terms apply to it. Each request carries an '
'ID the instance makes for you, which tells the provider nothing of who '
@ -341,7 +342,8 @@ final _german = _Words(
'${images ? ', das Bilder liest, die du hinzufügst' : ''}.',
whatGoes:
'Es geht nur, was du aus der App sendest, nachdem du dort zugestimmt '
'hast: deine Frage und was du davon auswählst, worum es geht. Es geht '
'hast: deine Frage, was du davon auswählst, worum es geht, und was der '
'Assistent nachschlägt, wo du es lässt. Es geht '
'auf dem Weg zum Anbieter durch die Instanz; die Instanz bewahrt nichts '
'davon auf, und für den Anbieter gelten seine eigenen Bedingungen. Jede '
'Anfrage trägt eine ID, die die Instanz für dich bildet und die dem '

View file

@ -56,6 +56,21 @@ final class LlmEndpoint({
/// The host requests go to, which the person agrees to (ADR 0021, D40).
String get host => baseUrl.host;
/// Whether requests stay on this machine: a loopback address, or
/// localhost (RFC 6761), where a private studyset's content may go
/// without a warning (D9). Not the local network, which may be shared.
bool get onThisMachine {
final name = host.toLowerCase();
if (name.contains(':')) {
return name == '::1';
}
final octets = name.split('.').map(int.tryParse).toList();
if (octets.length == 4 && octets.every((octet) => octet != null)) {
return octets.first == 127;
}
return name == 'localhost' || name.endsWith('.localhost');
}
@override
String toString() => 'LlmEndpoint(${protocol.name}, $baseUrl, $model)';

View file

@ -0,0 +1,55 @@
import 'package:halkyon_llm/halkyon_llm.dart';
import 'package:test/test.dart';
LlmEndpoint _at(String baseUrl) => LlmEndpoint(
protocol: LlmProtocol.openai,
model: 'm',
apiKey: '',
baseUrl: baseUrl,
);
void main() {
test("takes the protocol's own address where none is given", () {
final endpoint = LlmEndpoint(
protocol: LlmProtocol.anthropic,
model: 'm',
apiKey: 'k',
);
expect(endpoint.host, 'api.anthropic.com');
expect(endpoint.onThisMachine, isFalse);
});
test('refuses http beyond the same machine or network', () {
expect(() => _at('http://example.com/v1'), throwsFormatException);
expect(_at('http://192.168.1.20:11434/v1').host, '192.168.1.20');
});
test('knows a model on this machine: a loopback address or localhost '
'(ADR 0021, D9)', () {
for (final url in [
'http://127.0.0.1:11434/v1',
'http://127.8.0.1/v1',
'http://[::1]:8080/v1',
'http://localhost:11434/v1',
'http://ollama.localhost/v1',
'https://LOCALHOST/v1',
]) {
expect(_at(url).onThisMachine, isTrue, reason: url);
}
});
test('takes nothing else for this machine: the network may be shared', () {
for (final url in [
'http://192.168.1.20:11434/v1',
'http://10.0.0.2/v1',
'http://[fd00::2]/v1',
'http://ollama/v1',
'http://gpu.local/v1',
'https://localhost.example.com/v1',
'https://api.openai.com/v1',
]) {
expect(_at(url).onThisMachine, isFalse, reason: url);
}
});
}