feat(base,learn): suggest changes within a shared studyset

ADR 0044's step 6b (D44, D46 to D48): a Viewer, and an agent, suggest
changes within a shared studyset, which its Owner and Editors accept or
decline.

A suggestion or report is written as Explore's are, in its writer's own
synced space, with kinds of its own (space-suggestion, space-report)
and the space's id in feedback_thread's publicationId, so no synced
table changes and older apps, skipping kinds they don't know, keep
syncing. Where the studyset is private it is sealed with its key, bound
to the space; a reply to it is sealed for its thread.

Learn's server gathers a space's threads for its members alone
(feedback.threadsInSpace, NotFound for anyone else): only threads and
replies by members now, decisions only from rows in the space itself,
where the engine lets its Owner and Editors alone write; sealed
payloads pass in base64 with their key and nonce. The inbox adds the
threads its caller decides on or wrote. The module tells a host a
person's spaces and each space's members (sharedSpacesOf,
membersOfSpaces). No migration: no table changes.

In the app, a Viewer's card and note offer Suggest a change: the fields
write into the suggestion alone, sent with a message. A shared
studyset's menu lists its Suggestions, where its Owner and Editors
accept a change as a revision, or a new card or note into the
studyset, and decide in the space. A suggestion naming a file that
isn't the studyset's, or bringing files of its own, isn't taken.

Whats-New: In a studyset shared with you as a viewer, suggest a change to a card or note; its owner and editors find your suggestions under Suggestions in its menu and accept or decline them.
Whats-New-De: In einem Lernset, das du nur ansehen darfst, schlägst du Änderungen an Karteikarten und Notizen vor; Besitzer:in und Bearbeiter:innen finden sie im Menü unter „Vorschläge“ und nehmen sie an oder lehnen sie ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 20:34:06 +01:00
commit 82c777b1a5
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 2949 additions and 427 deletions

View file

@ -672,6 +672,18 @@ class EndpointFeedback extends _isc.EndpointRef {
'threadsOf',
{'publicId': publicId},
);
/// The suggestions and reports within the shared space [space] (Halkyon's
/// ADR 0044, D46, D47), for its members alone, the most recently active
/// first. Throws [NotFoundException] for anyone else, as for a space that
/// doesn't exist.
_ida.Future<List<_idpwh21d.FeedbackView>> threadsInSpace(
_isc.UuidValue space,
) => caller.callServerEndpoint<List<_idpwh21d.FeedbackView>>(
'feedback',
'threadsInSpace',
{'space': space},
);
}
/// The person's inbox of reports and suggestions: those on their published
@ -686,7 +698,9 @@ class EndpointInbox extends _isc.EndpointRef {
String get name => 'inbox';
/// The threads on the caller's published studysets and those they wrote,
/// the most recently active first.
/// and within the shared spaces they decide in, as an Owner or Editor,
/// and those they wrote there (Halkyon's ADR 0044, D46), the most
/// recently active first.
_ida.Future<List<_idpwh21d.FeedbackView>> inbox() =>
caller.callServerEndpoint<List<_idpwh21d.FeedbackView>>(
'inbox',

View file

@ -16,7 +16,8 @@ import 'package:serverpod_database/serverpod_database.dart' as _isd;
/// What a published studyset's author decided about a thread of reports and
/// suggestions (FeedbackThread): 'closed', 'reopened', 'hidden', 'shown',
/// 'accepted' or 'declined' (a suggestion). Written in the author's own
/// space, and only the author's count; the latest of each pair (by its id,
/// space, and only the author's count; in a shared space, written in that
/// space, where only its Owner and Editors write (Halkyon's ADR 0044, D46); the latest of each pair (by its id,
/// a UUID v7) stands. Never changed. Synced.
abstract class FeedbackDecision
implements _isd.TableRow<_isc.UuidValue?>, _isc.ProtocolSerialization {

View file

@ -72,13 +72,15 @@ abstract class FeedbackMessage
/// The thread replied in.
_isc.UuidValue threadId;
/// The encryption key's id; null, plaintext, the only value (ADR 0002).
/// The encryption key's id; null, plaintext, but in a private shared
/// studyset (ADR 0002, ADR 0019).
String? keyId;
/// Its content: UTF-8 JSON with a `schemaVersion` and its text.
_idt.ByteData payload;
/// The encryption nonce; null, as for keyId.
/// The encryption nonce, or a readable row's signature; null, as for
/// keyId.
_idt.ByteData? nonce;
@override
@ -259,13 +261,15 @@ class FeedbackMessageTable extends _isd.Table<_isc.UuidValue?> {
/// The thread replied in.
late final _isd.ColumnUuid threadId;
/// The encryption key's id; null, plaintext, the only value (ADR 0002).
/// The encryption key's id; null, plaintext, but in a private shared
/// studyset (ADR 0002, ADR 0019).
late final _isd.ColumnString keyId;
/// Its content: UTF-8 JSON with a `schemaVersion` and its text.
late final _isd.ColumnByteData payload;
/// The encryption nonce; null, as for keyId.
/// The encryption nonce, or a readable row's signature; null, as for
/// keyId.
late final _isd.ColumnByteData nonce;
@override

View file

@ -22,6 +22,8 @@ abstract class FeedbackMessageView
required this.by,
required this.fromAuthor,
required this.payload,
this.keyId,
this.nonce,
required this.createdAt,
});
@ -30,6 +32,8 @@ abstract class FeedbackMessageView
required _i4q3oto5.FeedbackPerson by,
required bool fromAuthor,
required String payload,
String? keyId,
String? nonce,
required DateTime createdAt,
}) = _FeedbackMessageViewImpl;
@ -43,6 +47,8 @@ abstract class FeedbackMessageView
jsonSerialization['fromAuthor'],
),
payload: jsonSerialization['payload'] as String,
keyId: jsonSerialization['keyId'] as String?,
nonce: jsonSerialization['nonce'] as String?,
createdAt: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['createdAt'],
),
@ -51,14 +57,21 @@ abstract class FeedbackMessageView
_isc.UuidValue id;
/// Who wrote it, and whether that is the published studyset's author.
/// Who wrote it, and whether that is the published studyset's author
/// (in a shared space, its Owner or an Editor).
_i4q3oto5.FeedbackPerson by;
bool fromAuthor;
/// What it says: its payload as written.
/// What it says: its payload as written; sealed (keyId), in base64.
String payload;
/// The key a reply in a private shared studyset is sealed with, and its
/// nonce in base64; null while readable.
String? keyId;
String? nonce;
/// When, in UTC.
DateTime createdAt;
@ -70,6 +83,8 @@ abstract class FeedbackMessageView
_i4q3oto5.FeedbackPerson? by,
bool? fromAuthor,
String? payload,
String? keyId,
String? nonce,
DateTime? createdAt,
});
@override
@ -80,6 +95,8 @@ abstract class FeedbackMessageView
'by': by.toJson(),
'fromAuthor': fromAuthor,
'payload': payload,
if (keyId != null) 'keyId': keyId,
if (nonce != null) 'nonce': nonce,
'createdAt': createdAt.toJson(),
};
}
@ -92,6 +109,8 @@ abstract class FeedbackMessageView
'by': by.toJsonForProtocol(),
'fromAuthor': fromAuthor,
'payload': payload,
if (keyId != null) 'keyId': keyId,
if (nonce != null) 'nonce': nonce,
'createdAt': createdAt.toJson(),
};
}
@ -102,18 +121,24 @@ abstract class FeedbackMessageView
}
}
class _Undefined {}
class _FeedbackMessageViewImpl extends FeedbackMessageView {
_FeedbackMessageViewImpl({
required _isc.UuidValue id,
required _i4q3oto5.FeedbackPerson by,
required bool fromAuthor,
required String payload,
String? keyId,
String? nonce,
required DateTime createdAt,
}) : super._(
id: id,
by: by,
fromAuthor: fromAuthor,
payload: payload,
keyId: keyId,
nonce: nonce,
createdAt: createdAt,
);
@ -126,6 +151,8 @@ class _FeedbackMessageViewImpl extends FeedbackMessageView {
_i4q3oto5.FeedbackPerson? by,
bool? fromAuthor,
String? payload,
Object? keyId = _Undefined,
Object? nonce = _Undefined,
DateTime? createdAt,
}) {
return FeedbackMessageView(
@ -133,6 +160,8 @@ class _FeedbackMessageViewImpl extends FeedbackMessageView {
by: by ?? this.by.copyWith(),
fromAuthor: fromAuthor ?? this.fromAuthor,
payload: payload ?? this.payload,
keyId: keyId is String? ? keyId : this.keyId,
nonce: nonce is String? ? nonce : this.nonce,
createdAt: createdAt ?? this.createdAt,
);
}

View file

@ -75,19 +75,24 @@ abstract class FeedbackThread
int? spaceId;
/// The published studyset it is on (its public id): a column, for the
/// server to gather by.
/// server to gather by; for a thread in a shared space, the space's id
/// (Halkyon's ADR 0044, D46).
_isc.UuidValue publicationId;
/// 'report' or 'suggestion'; a kind this app doesn't know is skipped.
/// 'report' or 'suggestion'; 'space-report' or 'space-suggestion' in a
/// shared space, sealed with its studyset's key where that is private.
/// A kind an app doesn't know is skipped.
String kind;
/// The encryption key's id; null, plaintext, the only value (ADR 0002).
/// The encryption key's id; null, plaintext, but in a private shared
/// studyset (ADR 0002, ADR 0019).
String? keyId;
/// Its content: UTF-8 JSON with a `schemaVersion`.
_idt.ByteData payload;
/// The encryption nonce; null, as for keyId.
/// The encryption nonce, or a readable row's signature; null, as for
/// keyId.
_idt.ByteData? nonce;
@override
@ -281,19 +286,24 @@ class FeedbackThreadTable extends _isd.Table<_isc.UuidValue?> {
late final _isd.ColumnInt spaceId;
/// The published studyset it is on (its public id): a column, for the
/// server to gather by.
/// server to gather by; for a thread in a shared space, the space's id
/// (Halkyon's ADR 0044, D46).
late final _isd.ColumnUuid publicationId;
/// 'report' or 'suggestion'; a kind this app doesn't know is skipped.
/// 'report' or 'suggestion'; 'space-report' or 'space-suggestion' in a
/// shared space, sealed with its studyset's key where that is private.
/// A kind an app doesn't know is skipped.
late final _isd.ColumnString kind;
/// The encryption key's id; null, plaintext, the only value (ADR 0002).
/// The encryption key's id; null, plaintext, but in a private shared
/// studyset (ADR 0002, ADR 0019).
late final _isd.ColumnString keyId;
/// Its content: UTF-8 JSON with a `schemaVersion`.
late final _isd.ColumnByteData payload;
/// The encryption nonce; null, as for keyId.
/// The encryption nonce, or a readable row's signature; null, as for
/// keyId.
late final _isd.ColumnByteData nonce;
@override

View file

@ -16,8 +16,9 @@ import '../feedback/feedback_message_view.dart' as _irmjyw1o;
import '../feedback/feedback_person.dart' as _i4q3oto5;
/// A thread of reports and suggestions as the app shows it: on which
/// published studyset, by whom, what it says (its payload's JSON), its
/// replies, and where it stands.
/// published studyset, or in which shared space (Halkyon's ADR 0044, D46),
/// by whom, what it says (its payload's JSON, or sealed), its replies, and
/// where it stands.
abstract class FeedbackView
implements _isc.SerializableModel, _isc.ProtocolSerialization {
FeedbackView._({
@ -28,6 +29,9 @@ abstract class FeedbackView
required this.kind,
required this.writer,
required this.payload,
this.space,
this.keyId,
this.nonce,
required this.createdAt,
required this.messages,
required this.status,
@ -45,6 +49,9 @@ abstract class FeedbackView
required String kind,
required _i4q3oto5.FeedbackPerson writer,
required String payload,
_isc.UuidValue? space,
String? keyId,
String? nonce,
required DateTime createdAt,
required List<_irmjyw1o.FeedbackMessageView> messages,
required String status,
@ -69,6 +76,11 @@ abstract class FeedbackView
jsonSerialization['writer'],
),
payload: jsonSerialization['payload'] as String,
space: jsonSerialization['space'] == null
? null
: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['space']),
keyId: jsonSerialization['keyId'] as String?,
nonce: jsonSerialization['nonce'] as String?,
createdAt: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['createdAt'],
),
@ -86,22 +98,33 @@ abstract class FeedbackView
_isc.UuidValue threadId;
/// The published studyset: its public id, title and location.
/// The published studyset: its public id, title and location; in a
/// shared space, the space's id, and neither title nor location.
_isc.UuidValue publicId;
String studysetTitle;
String location;
/// 'report' or 'suggestion'.
/// 'report' or 'suggestion'; 'space-report' or 'space-suggestion' in a
/// shared space.
String kind;
/// Who wrote it.
_i4q3oto5.FeedbackPerson writer;
/// What it says: its payload as written.
/// What it says: its payload as written; sealed (keyId), in base64.
String payload;
/// The shared space it is in (D46); null on a published studyset.
_isc.UuidValue? space;
/// The key a thread in a private shared studyset is sealed with, and
/// its nonce in base64; null while readable.
String? keyId;
String? nonce;
/// When it was written, in UTC.
DateTime createdAt;
@ -114,7 +137,8 @@ abstract class FeedbackView
/// Whether the author hid it: then only its writer and the author see it.
bool hidden;
/// Whether the caller wrote it; whether the caller is the author.
/// Whether the caller wrote it; whether the caller is the author (in a
/// shared space, its Owner or an Editor, who decide on it).
bool mine;
bool forMe;
@ -133,6 +157,9 @@ abstract class FeedbackView
String? kind,
_i4q3oto5.FeedbackPerson? writer,
String? payload,
_isc.UuidValue? space,
String? keyId,
String? nonce,
DateTime? createdAt,
List<_irmjyw1o.FeedbackMessageView>? messages,
String? status,
@ -152,6 +179,9 @@ abstract class FeedbackView
'kind': kind,
'writer': writer.toJson(),
'payload': payload,
if (space != null) 'space': space?.toJson(),
if (keyId != null) 'keyId': keyId,
if (nonce != null) 'nonce': nonce,
'createdAt': createdAt.toJson(),
'messages': messages.toJson(valueToJson: (v) => v.toJson()),
'status': status,
@ -173,6 +203,9 @@ abstract class FeedbackView
'kind': kind,
'writer': writer.toJsonForProtocol(),
'payload': payload,
if (space != null) 'space': space?.toJson(),
if (keyId != null) 'keyId': keyId,
if (nonce != null) 'nonce': nonce,
'createdAt': createdAt.toJson(),
'messages': messages.toJson(valueToJson: (v) => v.toJsonForProtocol()),
'status': status,
@ -189,6 +222,8 @@ abstract class FeedbackView
}
}
class _Undefined {}
class _FeedbackViewImpl extends FeedbackView {
_FeedbackViewImpl({
required _isc.UuidValue threadId,
@ -198,6 +233,9 @@ class _FeedbackViewImpl extends FeedbackView {
required String kind,
required _i4q3oto5.FeedbackPerson writer,
required String payload,
_isc.UuidValue? space,
String? keyId,
String? nonce,
required DateTime createdAt,
required List<_irmjyw1o.FeedbackMessageView> messages,
required String status,
@ -213,6 +251,9 @@ class _FeedbackViewImpl extends FeedbackView {
kind: kind,
writer: writer,
payload: payload,
space: space,
keyId: keyId,
nonce: nonce,
createdAt: createdAt,
messages: messages,
status: status,
@ -234,6 +275,9 @@ class _FeedbackViewImpl extends FeedbackView {
String? kind,
_i4q3oto5.FeedbackPerson? writer,
String? payload,
Object? space = _Undefined,
Object? keyId = _Undefined,
Object? nonce = _Undefined,
DateTime? createdAt,
List<_irmjyw1o.FeedbackMessageView>? messages,
String? status,
@ -250,6 +294,9 @@ class _FeedbackViewImpl extends FeedbackView {
kind: kind ?? this.kind,
writer: writer ?? this.writer.copyWith(),
payload: payload ?? this.payload,
space: space is _isc.UuidValue? ? space : this.space,
keyId: keyId is String? ? keyId : this.keyId,
nonce: nonce is String? ? nonce : this.nonce,
createdAt: createdAt ?? this.createdAt,
messages: messages ?? this.messages.map((e0) => e0.copyWith()).toList(),
status: status ?? this.status,