feat(assistant)!: no hand-off and no connector; the field searches the web (ADR 0022)

The developer decided on 23 Oct to drop the hand-off to an AI assistant
outside the app, and Claude's connector with it, in v0.19.0, the app and
the server together: build 22 raises minimumAppBuild anyway, so no app
calling the connector's endpoints uses the instance after it. An instance
is to have one way to an assistant, its operator's (ADR 0021, proposed).

The app: the field at the foot of Home, and the button in the Library, on
a studyset and while studying, search the web alone, through the engine
chosen in Settings or Android's own search, with the parts of a card the
person picks; a studyset's name and description are never offered. Gone:
the assistants a question opened in (AiAssistant, AssistantHandOff), the
switch between them and the web, Settings' choice of assistant, Connect
Claude with its route, cubit, repository and gateway, and their texts. A
settings file from before keeps the old keys until the next change of a
setting writes it anew; nothing reads them.

The server: lib/src/assistant/ (MCP, its OAuth, the five tools, connect
codes and tokens), AssistantEndpoint, the daily forgetting of the
connector's secrets (its scheduled call cancelled at start), its lines in
the deletion hook, instance.info().assistantConnectorUrl (nullable, so an
older app reads it as none), and Caddy's forwarding of /mcp, /oauth/* and
/.well-known/oauth-*. The migration drops the connector's five tables,
children first:

  DROP TABLE "assistant_token" CASCADE;       -- tokens of a connection
  DROP TABLE "assistant_grant" CASCADE;       -- OAuth codes of a connection
  DROP TABLE "assistant_connection" CASCADE;  -- a person's connected client
  DROP TABLE "assistant_connect_code" CASCADE;
  DROP TABLE "assistant_client" CASCADE;      -- OAuth clients registered

No other table references them, so CASCADE drops only their own foreign
keys. For the minute between the maintenance run and the switch, the old
server answers the connector with errors and fails to delete an account
(its hook still deletes from these tables); ADR 0022 says so.

BREAKING CHANGE: a connected Claude gets no answer from the deploy on, and
the app no longer opens a question in an AI assistant.

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-23 15:33:53 +02:00
commit ca24eb9cf9
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 16550 additions and 10700 deletions

View file

@ -1,96 +0,0 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// A code for connecting an assistant, as the app shows it (ADR 0006, rule 3).
abstract class AssistantCode
implements _isc.SerializableModel, _isc.ProtocolSerialization {
AssistantCode._({
required this.code,
required this.expiresAt,
});
factory AssistantCode({
required String code,
required DateTime expiresAt,
}) = _AssistantCodeImpl;
factory AssistantCode.fromJson(Map<String, dynamic> jsonSerialization) {
return AssistantCode(
code: jsonSerialization['code'] as String,
expiresAt: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['expiresAt'],
),
);
}
/// The code, as `K7QX-M2PD`.
String code;
/// When it stops working, in UTC.
DateTime expiresAt;
/// Returns a shallow copy of this [AssistantCode]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
AssistantCode copyWith({
String? code,
DateTime? expiresAt,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'AssistantCode',
'code': code,
'expiresAt': expiresAt.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'AssistantCode',
'code': code,
'expiresAt': expiresAt.toJson(),
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _AssistantCodeImpl extends AssistantCode {
_AssistantCodeImpl({
required String code,
required DateTime expiresAt,
}) : super._(
code: code,
expiresAt: expiresAt,
);
/// Returns a shallow copy of this [AssistantCode]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
AssistantCode copyWith({
String? code,
DateTime? expiresAt,
}) {
return AssistantCode(
code: code ?? this.code,
expiresAt: expiresAt ?? this.expiresAt,
);
}
}

View file

@ -1,143 +0,0 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// A connected assistant, as the app lists it.
abstract class AssistantConnectionInfo
implements _isc.SerializableModel, _isc.ProtocolSerialization {
AssistantConnectionInfo._({
required this.id,
required this.name,
required this.host,
required this.connectedAt,
this.lastUsedAt,
});
factory AssistantConnectionInfo({
required int id,
required String name,
required String host,
required DateTime connectedAt,
DateTime? lastUsedAt,
}) = _AssistantConnectionInfoImpl;
factory AssistantConnectionInfo.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return AssistantConnectionInfo(
id: jsonSerialization['id'] as int,
name: jsonSerialization['name'] as String,
host: jsonSerialization['host'] as String,
connectedAt: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['connectedAt'],
),
lastUsedAt: jsonSerialization['lastUsedAt'] == null
? null
: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['lastUsedAt'],
),
);
}
/// The connection's id, to disconnect it.
int id;
/// What the assistant calls itself.
String name;
/// The host the person was sent back to on connecting, e.g. `claude.ai`.
String host;
/// When the person connected it.
DateTime connectedAt;
/// When it last read something; null before.
DateTime? lastUsedAt;
/// Returns a shallow copy of this [AssistantConnectionInfo]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
AssistantConnectionInfo copyWith({
int? id,
String? name,
String? host,
DateTime? connectedAt,
DateTime? lastUsedAt,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'AssistantConnectionInfo',
'id': id,
'name': name,
'host': host,
'connectedAt': connectedAt.toJson(),
if (lastUsedAt != null) 'lastUsedAt': lastUsedAt?.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'AssistantConnectionInfo',
'id': id,
'name': name,
'host': host,
'connectedAt': connectedAt.toJson(),
if (lastUsedAt != null) 'lastUsedAt': lastUsedAt?.toJson(),
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _AssistantConnectionInfoImpl extends AssistantConnectionInfo {
_AssistantConnectionInfoImpl({
required int id,
required String name,
required String host,
required DateTime connectedAt,
DateTime? lastUsedAt,
}) : super._(
id: id,
name: name,
host: host,
connectedAt: connectedAt,
lastUsedAt: lastUsedAt,
);
/// Returns a shallow copy of this [AssistantConnectionInfo]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
AssistantConnectionInfo copyWith({
int? id,
String? name,
String? host,
DateTime? connectedAt,
Object? lastUsedAt = _Undefined,
}) {
return AssistantConnectionInfo(
id: id ?? this.id,
name: name ?? this.name,
host: host ?? this.host,
connectedAt: connectedAt ?? this.connectedAt,
lastUsedAt: lastUsedAt is DateTime? ? lastUsedAt : this.lastUsedAt,
);
}
}

View file

@ -15,10 +15,6 @@ import 'dart:typed_data' as _idt;
import 'package:halkyon_base_client/halkyon_base_client.dart' as _ihk4p5t7;
import 'package:halkyon_learn_client/src/protocol/asset/file_usage.dart'
as _iimptq0q;
import 'package:halkyon_learn_client/src/protocol/assistant/assistant_code.dart'
as _itx3eecd;
import 'package:halkyon_learn_client/src/protocol/assistant/assistant_connection_info.dart'
as _iplrdti2;
import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
as _ieqh4yr9;
import 'package:halkyon_learn_client/src/protocol/dictionary/device_dictionary_file.dart'
@ -140,43 +136,6 @@ class EndpointAsset extends _isc.EndpointRef {
);
}
/// Connecting assistants to the signed-in person's account (ADR 0006): a
/// code to type on an assistant's authorization page, and the assistants
/// connected, to disconnect. Anonymous people too: the code is how they
/// connect without signing in anywhere.
/// {@category Endpoint}
class EndpointAssistant extends _isc.EndpointRef {
EndpointAssistant(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'assistant';
/// A new code for connecting an assistant, valid for ten minutes and
/// once; the person's earlier codes stop working. Only its hash is kept.
_ida.Future<_itx3eecd.AssistantCode> connectCode() =>
caller.callServerEndpoint<_itx3eecd.AssistantCode>(
'assistant',
'connectCode',
{},
);
/// The assistants connected to the person's account, the newest first.
_ida.Future<List<_iplrdti2.AssistantConnectionInfo>> connections() =>
caller.callServerEndpoint<List<_iplrdti2.AssistantConnectionInfo>>(
'assistant',
'connections',
{},
);
/// Disconnects the person's assistant [id]: its tokens stop working at
/// once. Someone else's connection is one that doesn't exist.
_ida.Future<void> disconnect(int id) => caller.callServerEndpoint<void>(
'assistant',
'disconnect',
{'id': id},
);
}
/// Your devices: the sessions you're signed in with, and signing one of them
/// out, for example a lost phone.
///
@ -801,7 +760,9 @@ class EndpointInbox extends _isc.EndpointRef {
/// What this instance offers. Public: the app asks before anyone is signed in.
/// Released apps read the oldest build the server works with here, so it
/// keeps every field it had: halkyon_base's own basics fill those it shares.
/// keeps every field they need: halkyon_base's own basics fill those it
/// shares. A nullable one may go, as an older app reads it as none (the
/// connector's address, ADR 0022).
/// {@category Endpoint}
class EndpointInstance extends _isc.EndpointRef {
EndpointInstance(_isc.EndpointCaller caller) : super(caller);
@ -812,7 +773,7 @@ class EndpointInstance extends _isc.EndpointRef {
/// This instance's offer: GitHub sign-in only with the operator's OAuth
/// app, email sign-in only where it can mail codes, where its source is,
/// the oldest app build it works with, the domain its people's handles
/// are on, where assistants connect, the Jellyfin server it suggests, and
/// are on, the Jellyfin server it suggests, and
/// where it keeps people's data and its backups.
_ida.Future<_i26u8vl4.InstanceInfo> info() =>
caller.callServerEndpoint<_i26u8vl4.InstanceInfo>(
@ -944,7 +905,6 @@ class Client extends _isc.ServerpodClientShared {
httpClientOverride: httpClientOverride,
) {
asset = EndpointAsset(this);
assistant = EndpointAssistant(this);
devices = EndpointDevices(this);
dictionary = EndpointDictionary(this);
discussions = EndpointDiscussions(this);
@ -960,8 +920,6 @@ class Client extends _isc.ServerpodClientShared {
late final EndpointAsset asset;
late final EndpointAssistant assistant;
late final EndpointDevices devices;
late final EndpointDictionary dictionary;
@ -987,7 +945,6 @@ class Client extends _isc.ServerpodClientShared {
@override
Map<String, _isc.EndpointRef> get endpointRefLookup => {
'asset': asset,
'assistant': assistant,
'devices': devices,
'dictionary': dictionary,
'discussions': discussions,

View file

@ -21,7 +21,6 @@ abstract class InstanceInfo
required this.sourceCodeUrl,
this.minimumAppBuild,
this.accountDomain,
this.assistantConnectorUrl,
this.jellyfinUrl,
this.emailSignIn,
this.dataLocation,
@ -33,7 +32,6 @@ abstract class InstanceInfo
required String sourceCodeUrl,
int? minimumAppBuild,
String? accountDomain,
String? assistantConnectorUrl,
String? jellyfinUrl,
bool? emailSignIn,
String? dataLocation,
@ -46,8 +44,6 @@ abstract class InstanceInfo
sourceCodeUrl: jsonSerialization['sourceCodeUrl'] as String,
minimumAppBuild: jsonSerialization['minimumAppBuild'] as int?,
accountDomain: jsonSerialization['accountDomain'] as String?,
assistantConnectorUrl:
jsonSerialization['assistantConnectorUrl'] as String?,
jellyfinUrl: jsonSerialization['jellyfinUrl'] as String?,
emailSignIn: jsonSerialization['emailSignIn'] == null
? null
@ -75,11 +71,6 @@ abstract class InstanceInfo
/// it.
String? accountDomain;
/// Where assistants such as Claude connect to read people's studysets (ADR
/// 0006): the MCP endpoint, as `https://learn.halkyon.eu/mcp`. Null where
/// the instance offers none, and from servers that predate it.
String? assistantConnectorUrl;
/// The Jellyfin server this instance suggests to its people, which the
/// app offers to sign in to (decided 2026-10-13): the operator's
/// JELLYFIN_URL, else jellyfin. on the domain registered for the
@ -109,7 +100,6 @@ abstract class InstanceInfo
String? sourceCodeUrl,
int? minimumAppBuild,
String? accountDomain,
String? assistantConnectorUrl,
String? jellyfinUrl,
bool? emailSignIn,
String? dataLocation,
@ -123,8 +113,6 @@ abstract class InstanceInfo
'sourceCodeUrl': sourceCodeUrl,
if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild,
if (accountDomain != null) 'accountDomain': accountDomain,
if (assistantConnectorUrl != null)
'assistantConnectorUrl': assistantConnectorUrl,
if (jellyfinUrl != null) 'jellyfinUrl': jellyfinUrl,
if (emailSignIn != null) 'emailSignIn': emailSignIn,
if (dataLocation != null) 'dataLocation': dataLocation,
@ -140,8 +128,6 @@ abstract class InstanceInfo
'sourceCodeUrl': sourceCodeUrl,
if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild,
if (accountDomain != null) 'accountDomain': accountDomain,
if (assistantConnectorUrl != null)
'assistantConnectorUrl': assistantConnectorUrl,
if (jellyfinUrl != null) 'jellyfinUrl': jellyfinUrl,
if (emailSignIn != null) 'emailSignIn': emailSignIn,
if (dataLocation != null) 'dataLocation': dataLocation,
@ -163,7 +149,6 @@ class _InstanceInfoImpl extends InstanceInfo {
required String sourceCodeUrl,
int? minimumAppBuild,
String? accountDomain,
String? assistantConnectorUrl,
String? jellyfinUrl,
bool? emailSignIn,
String? dataLocation,
@ -173,7 +158,6 @@ class _InstanceInfoImpl extends InstanceInfo {
sourceCodeUrl: sourceCodeUrl,
minimumAppBuild: minimumAppBuild,
accountDomain: accountDomain,
assistantConnectorUrl: assistantConnectorUrl,
jellyfinUrl: jellyfinUrl,
emailSignIn: emailSignIn,
dataLocation: dataLocation,
@ -189,7 +173,6 @@ class _InstanceInfoImpl extends InstanceInfo {
String? sourceCodeUrl,
Object? minimumAppBuild = _Undefined,
Object? accountDomain = _Undefined,
Object? assistantConnectorUrl = _Undefined,
Object? jellyfinUrl = _Undefined,
Object? emailSignIn = _Undefined,
Object? dataLocation = _Undefined,
@ -206,9 +189,6 @@ class _InstanceInfoImpl extends InstanceInfo {
accountDomain: accountDomain is String?
? accountDomain
: this.accountDomain,
assistantConnectorUrl: assistantConnectorUrl is String?
? assistantConnectorUrl
: this.assistantConnectorUrl,
jellyfinUrl: jellyfinUrl is String? ? jellyfinUrl : this.jellyfinUrl,
emailSignIn: emailSignIn is bool? ? emailSignIn : this.emailSignIn,
dataLocation: dataLocation is String? ? dataLocation : this.dataLocation,

View file

@ -12,8 +12,6 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_base_client/halkyon_base_client.dart' as _ihk4p5t7;
import 'package:halkyon_learn_client/src/protocol/assistant/assistant_connection_info.dart'
as _iplrdti2;
import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
as _ieqh4yr9;
import 'package:halkyon_learn_client/src/protocol/dictionary/device_dictionary_file.dart'
@ -44,8 +42,6 @@ import 'asset/asset.dart' as _ipb7vkxr;
import 'asset/file_allowance.dart' as _i9uv0f1n;
import 'asset/file_limit_exceeded_exception.dart' as _it5d94hq;
import 'asset/file_usage.dart' as _iurwo9k8;
import 'assistant/assistant_code.dart' as _i8xi9jqo;
import 'assistant/assistant_connection_info.dart' as _icukzjbz;
import 'auth/device.dart' as _i6c5ny7n;
import 'card/card.dart' as _ipiw1cmx;
import 'card/card_revision.dart' as _ipbyuef1;
@ -101,8 +97,6 @@ export 'asset/asset.dart';
export 'asset/file_allowance.dart';
export 'asset/file_limit_exceeded_exception.dart';
export 'asset/file_usage.dart';
export 'assistant/assistant_code.dart';
export 'assistant/assistant_connection_info.dart';
export 'auth/device.dart';
export 'card/card.dart';
export 'card/card_revision.dart';
@ -2409,12 +2403,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _iurwo9k8.FileUsage) {
return _iurwo9k8.FileUsage.fromJson(data) as T;
}
if (t == _i8xi9jqo.AssistantCode) {
return _i8xi9jqo.AssistantCode.fromJson(data) as T;
}
if (t == _icukzjbz.AssistantConnectionInfo) {
return _icukzjbz.AssistantConnectionInfo.fromJson(data) as T;
}
if (t == _i6c5ny7n.Device) {
return _i6c5ny7n.Device.fromJson(data) as T;
}
@ -2584,16 +2572,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _isc.getType<_iurwo9k8.FileUsage?>()) {
return (data != null ? _iurwo9k8.FileUsage.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i8xi9jqo.AssistantCode?>()) {
return (data != null ? _i8xi9jqo.AssistantCode.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_icukzjbz.AssistantConnectionInfo?>()) {
return (data != null
? _icukzjbz.AssistantConnectionInfo.fromJson(data)
: null)
as T;
}
if (t == _isc.getType<_i6c5ny7n.Device?>()) {
return (data != null ? _i6c5ny7n.Device.fromJson(data) : null) as T;
}
@ -2824,12 +2802,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
.toList()
as T;
}
if (t == List<_iplrdti2.AssistantConnectionInfo>) {
return (data as List)
.map((e) => deserialize<_iplrdti2.AssistantConnectionInfo>(e))
.toList()
as T;
}
if (t == List<_ieqh4yr9.Device>) {
return (data as List)
.map((e) => deserialize<_ieqh4yr9.Device>(e))
@ -2927,8 +2899,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
_i9uv0f1n.FileAllowance => 'FileAllowance',
_it5d94hq.FileLimitExceededException => 'FileLimitExceededException',
_iurwo9k8.FileUsage => 'FileUsage',
_i8xi9jqo.AssistantCode => 'AssistantCode',
_icukzjbz.AssistantConnectionInfo => 'AssistantConnectionInfo',
_i6c5ny7n.Device => 'Device',
_ipiw1cmx.Card => 'Card',
_ipbyuef1.CardRevision => 'CardRevision',
@ -3005,10 +2975,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'FileLimitExceededException';
case _iurwo9k8.FileUsage():
return 'FileUsage';
case _i8xi9jqo.AssistantCode():
return 'AssistantCode';
case _icukzjbz.AssistantConnectionInfo():
return 'AssistantConnectionInfo';
case _i6c5ny7n.Device():
return 'Device';
case _ipiw1cmx.Card():
@ -3155,12 +3121,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'FileUsage') {
return deserialize<_iurwo9k8.FileUsage>(data['data']);
}
if (dataClassName == 'AssistantCode') {
return deserialize<_i8xi9jqo.AssistantCode>(data['data']);
}
if (dataClassName == 'AssistantConnectionInfo') {
return deserialize<_icukzjbz.AssistantConnectionInfo>(data['data']);
}
if (dataClassName == 'Device') {
return deserialize<_i6c5ny7n.Device>(data['data']);
}