fix(privacy): publishing and suggestions after the short reading (ADR 0019)

Publishing is refused on the device where anything in the tree is sealed,
before any tag's name goes out: the tree and the tags' links are the
instance's to change. A suggestion leaves sealed files out, as the
instance could name another private tree's file through a planted row.
Docs: ADR 0019's "The short reading (22 Oct 2026)", and the general
residual risk that any device can be held at an earlier genuine record;
Learn's AGENTS.md.

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-22 10:09:50 +02:00
commit 2d772a17e2
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 125 additions and 17 deletions

View file

@ -4,7 +4,7 @@ import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_learn_client/halkyon_learn_client.dart'
show Client, UuidValue;
show Client, PublishRefusal, PublishRefusedException, UuidValue;
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_file_cache.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/serverpod_asset_server_gateway.dart';
@ -453,8 +453,7 @@ Future<PersonDependencies> _openPersonData(
// wraps them), not the key rows the instance hands over (the short
// reading of 22 Oct); an anonymous person's are this device's own.
registerHasKeys: person.isIdentified
? () async =>
(await client.modules.base.account.signInWays()).password
? () async => (await client.modules.base.account.signInWays()).password
: () async => null,
);
final privacy = Privacy(db, contentKeys)..requestSync = sync.requestSync;
@ -535,6 +534,12 @@ Future<PersonDependencies> _openPersonData(
FilePublishedStore(File(p.join(folder.path, 'published.json'))),
tagNames: (studysetId) async {
final tree = subtreeOf(studysetId, await studysets.withDeleted());
// Refused here, before any tag's name goes out, where anything in it
// is private: the tree and the tags' links are the instance's to
// change (the short reading of 22 Oct).
if (await privacy.holdsSealed(tree.toSet())) {
throw PublishRefusedException(reason: PublishRefusal.private);
}
final tagging = await tags.read();
final carried = <String>{
for (final card in await cards.cardsIn(tree))

View file

@ -508,6 +508,74 @@ class Privacy(
return of;
}
/// Whether anything in the studysets [ids] is sealed: a studyset, a card's
/// or a note's revision, a path, a step's revision or a PDF. Publishing
/// refuses on the device then, before any tag's name goes out (the short
/// reading of 22 Oct).
Future<bool> holdsSealed(Set<String> ids) async {
final uuids = {for (final id in ids) _uuid(id)};
if (await protocol.Studyset.db.count(
_db,
where: (t) => t.id.inSet(uuids) & t.keyId.notEquals(null),
) >
0) {
return true;
}
final cards = {
for (final card in await protocol.Card.db.find(
_db,
where: (t) => t.studysetId.inSet(uuids),
))
card.id!,
};
final notes = {
for (final note in await protocol.Note.db.find(
_db,
where: (t) => t.studysetId.inSet(uuids),
))
note.id!,
};
final paths = {
for (final path in await protocol.LearningPath.db.find(
_db,
where: (t) => t.studysetId.inSet(uuids),
))
path.id!,
};
final steps = {
for (final step in await protocol.LearningPathStep.db.find(
_db,
where: (t) => t.pathId.inSet(paths),
))
step.id!,
};
return await protocol.CardRevision.db.count(
_db,
where: (t) => t.cardId.inSet(cards) & t.keyId.notEquals(null),
) >
0 ||
await protocol.NoteRevision.db.count(
_db,
where: (t) => t.noteId.inSet(notes) & t.keyId.notEquals(null),
) >
0 ||
await protocol.LearningPath.db.count(
_db,
where: (t) => t.id.inSet(paths) & t.keyId.notEquals(null),
) >
0 ||
await protocol.LearningPathStepRevision.db.count(
_db,
where: (t) => t.stepId.inSet(steps) & t.keyId.notEquals(null),
) >
0 ||
await protocol.PdfSource.db.count(
_db,
where: (t) => t.studysetId.inSet(uuids) & t.keyId.notEquals(null),
) >
0;
}
/// The ids of [studysetId] and every studyset inside it.
Future<Set<String>> treeOf(String studysetId) async {
final children = <String, List<String>>{};

View file

@ -117,16 +117,11 @@ class FeedbackRepository(
final files = <FeedbackFile>[];
for (final assetId in assetIdsIn(proposed)) {
if (await _assets.fileOf(assetId) case final file?) {
// A sealed file (a private copy's) is uploaded readable too, by the
// hash the author asks for it with: a suggestion is for its author
// to read (the reading of 22 Oct).
// A sealed file is left out: the instance could have named another
// private tree's file through a row it planted, and the suggestion
// would upload it readable (the short reading of 22 Oct).
if (await _assets.keyIdOf(assetId) != null) {
await _assets.add(
file.bytes,
fileName: file.fileName,
mimeType: file.mimeType,
studysetId: null,
);
continue;
}
files.add(
FeedbackFile(