This project is a Flutter app (frontend) backed by a Serverpod server (backend). Always build the app's backend with Serverpod.
Build for multiple users, use Serverpod's built-in authentication, which is already set up in `lib/server.dart`.
The user starts the server and Flutter app with `serverpod start`. There is no need to check if the server is running: make the changes and call the `serverpod` MCP tools as needed. If the server is not running, an informative error message will be received from the MCP server. Then STOP and ask the user to start it. NEVER start the server yourself. The Flutter app is started along with it, or can be launched from the MCP tool `spawn_flutter_app`.
While running, `serverpod start` watches for file changes to run incremental code generation and hot reload both the server and the Flutter app.
Calling `serverpod generate` directly is not needed, but might be useful to troubleshoot when an incremental generation fails.
ALWAYS use the MCP server instead of the command line. Use the MCP server to:
-`create_migration` and `apply_migrations` for database (after you change data models).
-`create_repair_migration` if the database has drifted out of sync with the migrations.
-`tail_server_logs` to read logs from the server.
-`tail_flutter_logs` to read the raw stdout/stderr of the Flutter app.
-`hot_reload` / `hot_restart` to reload or restart the server and the Flutter app. ALWAYS call `hot_restart` after doing changes in the Flutter app that may not work with normal hot reload (which is automatically applied).
-`spawn_flutter_app` to start a Flutter app declared under `serverpod: flutter_apps:` in the server `pubspec.yaml`.
-`get_flutter_app_dtd` (Dart tooling daemon) for connecting to the app through the `dart` MCP.
NEVER edit generated code. The server's `lib/src/generated/` directory and the whole `halkyon_learn_client` package are rewritten by the code generator. Change the `.spy.yaml` models, the endpoints, or `lib/server.dart` instead.
Migrations are a narrow exception: the `migration.sql` of a generated migration MAY be edited by hand when the generated SQL would lose data — to add a data transformation, or to reach a destructive change through non-destructive steps. Never touch the other files in the migration directory, and keep the schema the SQL ends up with identical to `definition.sql` — new databases are created from that file and never run `migration.sql`.
The `serverpod` MCP tools come from `.mcp.json`: `serverpod mcp-server`, a bridge to the running `serverpod start`. They load when a session starts; if they are missing, the server wasn't running or the session predates `.mcp.json`. Like all agent configuration, `.mcp.json` is git-ignored; in a fresh clone, create it with a server `serverpod` running `serverpod` with the arguments `--no-analytics mcp-server --server-dir halkyon_learn_server`.
**Pass `--no-analytics` to every `serverpod` call** (before the command: `serverpod --no-analytics generate`). Otherwise the CLI reports the commands run and the Serverpod features the project uses to PostHog; there is no setting or environment variable to switch that off. The VS Code task and `.mcp.json` pass it already.
Only when the server cannot be started at all, fall back to the CLI in the server package:
-`serverpod generate` to regenerate the client and the generated server code.
-`serverpod create-migration` after changing a model with a `table` (add `--force` for destructive changes). It only writes the migration; `serverpod start` applies pending migrations when it boots the server.
Tests need no Docker. `config/test.yaml` sets `database.dataPath`, so Serverpod starts and manages the test database (an embedded PostgreSQL) itself. Just run `dart test` in the server package.
Two tooling traps: `dart fix --apply` moves `flutter_driver` into the app's `dependencies` (as `any`) on every run, so restore it as a dev dependency (`sdk: flutter`) afterwards; and never `dart format` generated code, only the hand-written paths, as `tool/check.sh` does.
-`tool/check.sh` (CLI): format check, `dart analyze --fatal-infos`, server and app tests. The pre-push hook runs the same script, so a push only goes through when it passes.
- Do `serverpod` MCP `hot_restart` if required (hot reload is done automatically). Will also hot restart Flutter app
- Check `serverpod` MCP `tail_server_logs` and `tail_flutter_logs` for any issues.
If the user asks you to test the app:
1. Use `get_flutter_app_dtd` (`serverpod` MCP) to get the Flutter app's DTD
2. Pass the DTD to `connect_dart_tooling_daemon` (`dart` MCP) to connect to the app
3. Use `flutter_driver` (`dart` MCP) to navigate through the app
The app is launched from `halkyon_learn_flutter/lib/driver.dart`, which starts the Flutter driver extension with text entry emulation turned off so the app stays usable by hand. To let the driver type, set `enableTextEntryEmulation: true` there and `hot_restart` the app.
A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS schedule. Content is rich text (`appflowy_editor` documents) everywhere. Targets: Linux, Windows, macOS, Android, iOS. There is no web build.
- **Packages**: `halkyon_learn_server` (endpoints, models, migrations), `halkyon_learn_client` (generated, never edited), `halkyon_learn_flutter` (the app, ID `app.halkyon.learn`).
- **Instances**: anyone may run one. The app takes the server URL from `--dart-define=SERVER_URL=…`, so never hard-code a host.
- **Working notes**: if `NEXT.md` exists in the repository root, read it first. It holds the current step, the plan and the reasoning behind each decision. It is local and never committed.
- **The agent implements; the developer decides.** Put every decision to the developer before building (design choices, alternatives, framework defaults kept or changed). The first time a new pattern appears (e.g. the first repository/gateway pair, the first synced model, the first endpoint with its denial tests), walk through it in detail with the developer; afterwards, implement exceedingly similar cases directly and point out only deviations.
- **Tests come first** and state the specification. Every access rule gets a test that asserts **denial**, not only permission.
- **Explain every migration's SQL.**
- **Before keeping a framework default, name the alternatives** and recommend one, especially where a choice becomes expensive once users or data exist.
- Every decision carries its reason (in `NEXT.md` or an ADR under `docs/adr/`).
- **Commits** are GPG-signed and carry a DCO `Signed-off-by` trailer (`git commit -s`; a local `prepare-commit-msg` hook adds it automatically).
- **Lints**: LeanCode's strict set (`leancode_lint`), configured once in the root `analysis_options.yaml`, which also enables its analyzer plugin. Plugins can only be enabled at the root of a pub workspace and only reach packages whose options include that file, so the server and the app include it; the generated client doesn't. Every `// ignore:` gets a comment saying why (`document_ignores`). The server switches off the four rules for Dart v3.13's constructor syntax (`use_primary_constructors`, `use_declaring_parameters`, `empty_container_bodies`, `unnecessary_type_name_in_constructor`): Serverpod's code generator (checked with v4.0.3) can't parse that syntax in endpoint files yet.
- **Feature-first.** App: `lib/src/core/` holds infrastructure shared by features, `lib/src/feature/<name>/` everything a user would name. Server: `lib/src/<feature>/` holds that feature's `.spy.yaml` models and endpoints. Code moves into `core/` only once a second feature needs it. Why: a feature is added, changed or removed in one place.
- **Four layers per app feature**, dependencies pointing strictly downward: `widget/` (UI; reads bloc state, sends events) → `bloc/` (UI state; orchestrates repositories) → `data/` (repositories and gateways) → `model/` (immutable domain types; pure Dart, no Flutter, no I/O). A widget never calls a gateway; a model never imports a bloc.
- **Generated protocol classes are not domain models.** They carry the envelope (`payload` as bytes); `model/` holds the decoded content. Mapping between them happens in `data/`.
- **Import `halkyon_learn_client` with a prefix (`as protocol`) or a `show` list.** The generator emits a `<Model>Repository` for every table, and its names clash with the app's own repositories.
- **Gateway / repository.** A gateway is a thin boundary to *one* backend (e.g. the Serverpod client's endpoints) behind an `abstract interface class`. A repository is what features use: it composes gateways and holds the logic. Blocs depend on repositories, never on gateways or the raw client. Why: gateways are the seams tests fake, and adding the offline layer later becomes a new local gateway, not a rewrite of every feature.
- **Manual dependency injection.** One `Dependencies` object (`lib/src/core/dependencies/`) is the composition root, built at startup (`Dependencies.create()`) and exposed through the `DependenciesScope` InheritedWidget. It hands features repositories only. No service locator, no DI framework, no globals (the scaffold's global `client` moved into it). Why: every dependency is a typed, greppable field, and nothing is resolved by magic.
- **One graph per context.** The dependency graph belongs to one instance and one signed-in person; when either changes, a `KeyedSubtree` disposes the whole subtree and rebuilds it. Why: nothing from one person's session (blocs, streams, caches) can leak into another's.
- **App-wide vs. per person.** `Dependencies` holds what outlives a person (the client, auth). Everything of the signed-in person (their local database, its sync, their repositories) is `PersonDependencies`, opened by `PersonScope` inside `SessionGate`'s keyed subtree (`Dependencies.openPerson`) and disposed with it; widgets reach it through `PersonScope.of(context)`.
- **Pushed screens live above that subtree** (the `Navigator` belongs to `MaterialApp`). A screen's static `route(context, …)` reads what it needs from `PersonScope.of(context)` when it is pushed and hands it to the screen's bloc, and `SessionGate` closes every pushed screen when the person changes. A screen pushed from a pushed screen gets the repositories handed along as `route()` parameters (e.g. `CardScreen.route(cards, …)`).
- **Material's `Card` widget clashes with the domain's `Card`**: files that need both import `material_ui` with `hide Card`.
- **State: `flutter_bloc`, uniformly.** Blocs fold repository streams into immutable states with value equality, are provided as close to their consumer as possible, and log through one global `BlocObserver`. Why: the data layer is stream-shaped, event → state machines test without widgets, and one pattern everywhere is itself the point.
- **Value equality comes from `equatable`**, mixed in (`with Equatable`), for models and states alike; `leancode_lint`'s `missing_equatable_props` catches a field left out of `props`.
- **Rich text is `RichContent`** (`lib/src/core/content/`): the editor's document JSON plus a `formatVersion`, pure Dart, so models and the server can use it. `lib/src/core/editor/` converts it to and from the editor's `Document` and holds the one editing widget (`RichContentEditor`) and the one read-only renderer (`RichContentView`).
- **Time comes from `clock.now()`** (`package:clock`), never `DateTime.now()`, in server and app. Why: scheduling, streaks and expiry are deterministic under test (`withClock`, `fakeAsync`), and because Serverpod itself reads `clock.now()` (e.g. session expiry), `withClock` controls its behaviour too.
- **UTC for storing and sending, local time for display and day boundaries.** Serverpod sends `DateTime` as UTC and stores it in `timestamp without time zone` columns. Compare and compute in UTC; call `.toLocal()` only to display. "Due today", streaks and daily goals use the learner's **local** calendar day, computed on the client; the server never needs day boundaries.
- **Build configuration comes from `--dart-define`.** The client holds no secrets, ever.
- **Material comes from `package:material_ui`** (Cupertino from `package:cupertino_ui`), never from `package:flutter/material.dart`, which Flutter deprecates from its November 2026 release on. Packages still on the SDK's copy get the app's theme and localizations through `MaterialUiCompatibilityBridge` in `MaterialApp.builder`; remove it once they have moved.
- **By layer**: models with plain unit tests; repositories against fake gateways; blocs by asserting the emitted state sequence; widgets pumped in isolation, presentation widgets fed with ready-made data.
- **Server endpoints are tested for real** against the embedded Postgres (`withServerpod`, `dart test` in the server package), including the denial tests.
- **Test dependencies come from a test factory**, `testDependencies(...)` in `test/support/test_dependencies.dart`, filling in fakes by default (a factory in `lib/` couldn't reach the fakes in `test/`), so a new dependency changes one place. Don't make dependencies nullable to spare tests: that moves null checks into production code.
- Shared fakes live in `test/support/`; fakes pre-stub their streams so an incidental subscription doesn't fail the test.
- **Editor in widget tests**: host `AppFlowyEditor` in a scrollable body with a fixed-height slot (an over-constrained `Expanded` hangs the test), and `pump(const Duration(seconds: 1))` after editor changes to flush its timers. Test the document ↔ JSON round-trip as plain unit tests.
- **No `Stream.first` on a repository's stream in code that widget tests run**: `first` waits for the subscription's cancel future, which for a broadcast stream is a completed future of the root zone, so under the widget test's fake clock it never completes. Read once with the repository's `all()` instead.
- **Sync tests run against a running server** (ADR 0001, rule 9: the client's and the server's sync engines can't share one process): `halkyon_learn_flutter/test/sync/`, tagged `requires-server`, skipped by `tool/check.sh`; run them with `flutter test -t requires-server` while `serverpod start` runs. Every device is a client with in-memory session storage and its own local database, with the app's real gateway and repository on top; tests sync explicitly. What only the operator could see (a blanked payload, a logged refusal) is checked with SQL against the development database. They live in the app package because the server's Docker build resolves the server's dependencies without the client package. They must not initialize a Flutter test binding (no `testWidgets`), which would replace real HTTP with a mock.
- **Widget tests of the signed-in app** pump `SessionGate` with `testDependencies(...)` and `pumpAndSettle()` until `PersonScope` has opened the person's data; the fake gateways work in memory (`FakeStudysetGateway`, with `failReads` for the error path).
- **Authentication**: server-side sessions (`ServerSideSessionsConfigFromPasswords`), not JWT, for immediate revocation. Scopes are copied onto the session row, so changing a person's scopes means updating their session rows too.
- **Identity**: `AuthUser` is the person; identity providers (anonymous, GitHub) attach to it. Content references `AuthUser`, never a provider account.
- **GitHub keeps only the numeric GitHub ID**: our own slim login (`lib/src/auth/github.dart`) on Serverpod's GitHub helpers; Serverpod's stock GitHub login endpoint, which stores name, email and avatar, is never exposed. GitHub sign-in is on only where the passwords hold `githubClientId` and `githubClientSecret` (in production `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in `deploy/.env`); the app asks `instance.info()`.
- **Linking keeps the person** (`GitHubUpgradeEndpoint`, `requireLogin`): the GitHub ID joins the signed-in anonymous person, who becomes identified and loses the anonymous identity, in one transaction. **Signing in** (`GitHubSignInEndpoint`) only reaches people who have a GitHub account already, and only without a session: Serverpod refuses to issue another person's session inside a signed-in call. Refusals are `GitHubSignInRefusedException` with a reason.
- **Switching devices' person** (the app's `switchToGitHub`): swap the session directly, never through "signed out" (which would start another anonymous account), then end the anonymous session left behind.
- **Authorization lives in endpoint code**; Serverpod has no row-level security. Load content filtered by the session's `authUserId`, never by an owner ID the client sends. A row another person owns must be indistinguishable from a missing one. Features involving other people require the `identified` scope, declared via `requiredScopes`, never checked ad hoc.
- **The reference is `DevicesEndpoint`** (`halkyon_learn_server/lib/src/auth/`) with its tests: the owner check sits in the same query (`WHERE "id" = $1 AND "authUserId" = $2`), zero rows means `NotFoundException` (shared, `lib/src/common/`), and every rule has a test that asserts the refusal. Each query carries its SQL as a comment.
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.
- **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows.
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
- **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes.
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
- **Every synced table indexes `spaceId`** (`indexes: <table>_space_idx: fields: spaceId`, e.g. `studyset.spy.yaml`): deleting a space cascades into every synced table, and Postgres doesn't index foreign keys by itself, so account deletion and pruning would otherwise read whole tables.
- **The client's local migration** (in `halkyon_learn_client/lib/migrations/`, written by the same `create-migration`) creates the device's SQLite schema from the whole definition, so it also contains the server's own tables. Only the synced tables and the bookkeeping ever hold data on a device.
- **Serverpod's version moves together with `serverpod_offline_sync`**, which pins Serverpod exactly: the root `pubspec.yaml` overrides the four core packages to the project's version. Upgrade Serverpod only together with those overrides, after reading the sync package's changelog.
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.
- **Session logs are not persisted** (`persistentEnabled: false` in every run mode) for privacy. Don't re-enable them.
- **Secrets** live in `halkyon_learn_server/config/passwords.yaml` (git-ignored). Never change `serverSideSessionKeyHashPepper`: it would sign everyone out, and anonymous accounts can't sign back in.
- **Linux builds need** `libwebkit2gtk-4.1-dev` and `libsecret-1-dev`. If a Linux build tries to install to `/usr/local`, the CMake cache is stale: delete `build/linux`.