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

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',