Design record
Plans
The reviewed architecture and hardening plan behind the multi-app milestone, kept as a
historical design record. The plan has since been implemented: phases 0–15 are logged
on the Phases page, and the apps shipped as listam-desktop
v0.13.0 and listam-headless v0.13.1. Two later feature plans follow below the multi-app
plan: the desktop Kanban Boards milestone and the
Mobile Kanban phase that brought it to the phone.
Expansion plan
Multi-App Plan
Implemented — historical plan
Review findings (2026-05-29):
A security/architecture review of this plan against the current listam-mobile
code found that the plan layers a rich role / permission / revocation / "minimum-credential"
vocabulary on top of an Autobase + BlindPairing substrate that, as implemented, only supports
"full writer holding the encryption key" or "no access."
Several headline promises are not achievable without new work. Critical and High items below
should be treated as blocking acceptance criteria and resolved before package extraction or
the desktop/headless split begins.
Critical — substrate cannot honor the promises
- C1 — "Revoke" is impossible today. Autobase membership is append-only; there is no
remove-writer. Revoking an invite does nothing to a peer that already joined. Real removal needs an app-level ACL plus encryption-key rotation/re-encryption.
- C2 — Role-scoped credentials are unsupported. Pairing confirms with both
key and encryptionKey. You either hold the encryption key (full read, one append from writing) or hold nothing (opaque ciphertext). There is no read-only or sync-only credential.
- C3 — Any writer can add writers.
apply honors add-writer from any node and every writer can append. No owner authority, gate, or audit. Needs an owner key that alone authorizes membership, verified by signature.
High — riskiest new/inherited surfaces
- H1 — Owner-control admin channel underspecified. Remote
shutdown/export/import over the DHT with only "require pairing/auth." Needs per-device key pairs, signed commands with replay protection, and scoped capabilities — not bearer tokens.
- H2 — Deep links auto-join with no confirmation. A
listam.ch/join?invite= link calls RPC_JOIN_KEY directly and tears down the current base. Require explicit user confirmation before joining.
- H3 — Invites are reusable, non-expiring, pointlessly persisted.
INVITE_MAX_USES = 10, expires is never checked, and lista-invite.json is written but never read. Default to single-use + enforced expiry; delete the unused file.
Medium — design/sequencing fixes
- M1 — Backend keys items by
text, plan normalizes Redux by id. Migrate the replicated reduction to id-keying before Redux normalization or the two projections diverge.
- M3 —
loyaltyCards slice contradicts secure storage. Keep secret payloads out of Redux; store handles only and read secrets on demand.
- M4 — Corruption recovery silently wipes data. On an Autobase error the code deletes the base and recreates it. Require backup/consent before wipe — never auto-wipe a storage helper.
- M5 — Redaction is bypassable. Raw
console.error prints keys and item payloads today; committed log files exist. Add a no-console lint + CI secret-grep gate and remove committed logs.
| ID |
Finding (current code) |
Required fix |
C1 |
Joining appends add-writer → host.addWriter(key, {indexer:true}); membership is append-only with no removal. |
Separate "revoke invite" from "revoke access"; build ACL + key rotation/re-encryption, or state plainly that writers cannot be removed without re-keying. |
C2 |
Pairing shares autobase.encryptionKey; read access = full decrypt access. |
Re-scope roles: blind storage/relay = ciphertext only; any reader has full read. Resolve before headless co-invite. |
C3 |
apply trusts add-writer from any writer; no owner gate. |
Designated owner key authorizes membership, signature-verified in apply. Milestone deliverable. |
H1 |
Owner-control exposes shutdown/export/import with only "require pairing/auth." |
Per-device key pairs, signed commands + nonce/timestamp replay protection, scoped capabilities, rotatable device-bound tokens. |
H2 |
Linking handler calls startJoinWithInvite() with no confirm; joinViaInvite tears down the current base. |
Explicit user confirmation before any link-initiated join; do not tear down the current base until confirmed. |
H3 |
INVITE_MAX_USES = 10; expires never checked; lista-invite.json written but never read. |
Single-use, enforced expiry by default; delete unused persistence; UI must state that an invite grants permanent writer access. |
M1 |
Add/update/delete and rebuildListFromPersistedOps key by text, not id. |
Migrate reduction to id-keying (backfill legacy ids) before Redux normalization. |
M3 |
Plan stores loyalty cards in Redux and in secure storage (conflict). |
Redux holds non-secret handles only; read secret payloads on demand from secure storage. |
M4 |
Autobase ready() error deletes key file + base storage and recreates a fresh base. |
Backup/export and owner consent before wipe; never auto-wipe a storage-helper node. |
M5 |
Raw console.error prints base/writer/encryption keys and item payloads; logs committed to repo. |
no-console lint + CI secret-grep gate; remove committed logs and gitignore local logs plus generated P2P key/invite files. |
Implementation-plan check:
Every review finding now has a planned remediation path, an acceptance signal, and a test
expectation. The strategy is to harden the current mobile/backend substrate first, prove
the shared package boundaries second, and only then build desktop/headless trust
features on top of those boundaries.
| ID |
Implementation plan |
Acceptance signal |
C1 |
Rename current controls so "revoke invite" only stops future joins; do not ship "remove member" until membership epochs, owner authority, key rotation, re-encryption, and re-invite of remaining members exist. |
Invite revoke blocks new joins without false removal claims; true member removal completes a re-key flow and old members cannot decrypt or append accepted active-epoch operations. |
C2 |
Split current roles from future roles: today a device is either a trusted full participant or a blind helper with no encryption key. Add a separate blind-storage invite path before offering ciphertext-only helpers. |
Docs and UI never promise read-only or sync-only access with the current BlindPairing writer invite; tests prove blind helpers do not receive the Autobase encryption key. |
C3 |
Add owner-signed membership operations with owner key, target writer key, role label, operation id, timestamp, and signature; make apply reject unsigned or non-owner membership writes after migration. |
Non-owner writers cannot add writers; tests cover owner success, non-owner rejection, malformed signatures, replay rejection, and legacy migration. |
H1 |
Define owner-control as a separate signed capability protocol: per-device key pairs, command id, device id, scope, timestamp, nonce, payload hash, signature, replay tracking, and rotatable grants. |
Headless refuses unsigned, replayed, expired, or out-of-scope commands; diagnostics-only clients cannot call shutdown/export/import/topic configuration. |
H2 |
Parse link invites into pending state, show an explicit confirmation describing the base switch, and only call RPC_JOIN_KEY after the user confirms. |
Cold-start and foreground deep links cannot switch bases without confirmation; cancel leaves the current base untouched and failed joins roll back visibly. |
H3 |
Default invites to short-lived single-use credentials, enforce expiry/use count before BlindPairing confirm, delete unused plaintext invite persistence, rotate after use/revoke/expiration, and show permanent-writer warning until C1 is solved. |
Expired or exhausted invites cannot add peers; production does not create plaintext invite files; tests cover expiry, rotation, restart, revoke, and redacted logs. |
M1 |
Version list operations, backfill deterministic ids for legacy text-only entries, reduce by id when present, and emit id-bearing snapshots before Redux normalization. |
Backend and Redux agree for duplicate item names; existing lists migrate without losing order/done state; mixed legacy/new logs replay correctly. |
M3 |
Keep only non-secret loyalty-card handles/safe metadata in Redux; store barcode/QR payloads and sensitive details in platform secure storage, fetched only while rendering/scanning. |
Redux traces and persisted state never contain card payloads; AsyncStorage cards migrate; delete/export/redaction tests pass. |
M4 |
Replace auto-wipe with quarantine, backup/export, owner prompt, and headless owner notification. Headless/storage helpers must never destructively repair themselves without owner approval. |
Corruption never silently deletes storage; tests cover quarantine, backup-before-wipe, user cancel, approved fresh base, and redacted recovery logs. |
M5 |
Use a shared logger plus enforcement: no-console/banned API rule, CI secret-shape scan, committed log removal, explicit ignore rules for local logs and generated P2P key/invite files, redaction helpers, and debug/trace build gates. |
Raw production console logging fails lint; secret-shaped values fail CI if they appear in logs/exports outside explicit redaction tests. |
Lower-severity items (efficiency & correctness the milestone inherits)
Full-view replay on every sync
rebuildListFromPersistedOps replays the view from index 0 inside the 1-second join poll (up to ~120×). Add a materialized-view checkpoint to resume from; acceptance is bounded replay work during joins.
Full-list pushes instead of diffs
SYNC_LIST resends the whole list on poll ticks. Make per-item events the default and snapshots the exception; acceptance is no repeated full-list push during steady-state polling.
Fragile join state machine
Shared _writableCheckTimer across two pollers and a module-global _writeChain never reset across base teardown can send in-flight writes to the wrong base. Add per-base write contexts, reset write queues on teardown, and use distinct timers.
Singleton lock won't survive multi-app
lista.lock (wx, cleanup only on teardown) leaves a stale lock after a crash and cannot coordinate desktop + headless on one machine. Add a lease with owner pid/instance id, heartbeat, stale-lock recovery, and separate storage roots where appropriate.
No resource limits on headless relay
Store-and-forward of others' messages has no quotas/rate limits. Defer third-party relay/storage past milestone 1, then add per-topic quotas, queue caps, TTLs, rate limits, and visible storage usage.
No CI/test bootstrap
Zero tests exist and package-lock.json is gitignored. Stand up milestone-zero CI with a reproducible lockfile, test runner, lint, dependency hygiene, redaction scan, and backend reducer/join/security smoke tests.
Strategy improvements
Milestone zero first
Security hardening, test bootstrap, reproducible installs, and docs/wiki alignment should land before Redux/package extraction.
Repos and packages set up early
Stand up the separate app repositories (listam-mobile, listam-desktop, listam-headless) and the shared listam-shared package repo from milestone 1, and decouple @listam/backend from BareKit globals so the published backend package runs under mobile worklet, Pear Desktop, and headless Bare/Node.
Honest headless roles
The safe current tiers are trusted full participant and blind ciphertext helper. Richer read-only or sync-only roles require membership authority, key epochs, and a new credential model.
Contract tests everywhere
RPC numbers, protocol events, package exports, storage migrations, owner-control commands, and cross-app sync should all have boundary tests before desktop/headless are accepted.
Testing & cross-instance interaction:
Implementation agents should test each app in isolation and prove the instances interact.
Per-app test detail lives on the
Mobile,
Desktop, and
Headless testing sections; the shared harness and the
cross-instance matrix below are the source of truth for "instances interact properly."
Shared local test harness
Separate storage roots
Each instance starts with its own base dir (mobile = Expo document dir; desktop/headless take --storage <dir>). Never share lista-local between instances.
Private DHT bootstrap
Run a local hyperdht bootstrap node and pass it via BOOTSTRAP / --bootstrap so test peers discover each other without the public DHT.
Headless harness commands
Headless exposes scriptable primitives: create-base, print-invite, join <invite>, status, dump-list, add-item, edit-item, mark-done, delete-item, export, import, shutdown.
Content operation suite
Every shared harness run generates content, edits it, marks it done/undone, deletes it, and asserts the resulting snapshot syncs across peers. Assertions compare canonical item ids and deletion state, not display names, so duplicate-name content cannot collapse by text.
Deterministic teardown
Every test closes stores, destroys swarms, releases the lock, and removes temp dirs; seeded keypairs make assertions reproducible.
| Pairing |
Must prove |
| mobile ↔ mobile | invite/join; both devices generate items, edit text, mark done/undone, delete, and converge; duplicate-name handling by id (M1) |
| mobile ↔ desktop | parity sync both directions for generated, edited, completed, and deleted content; desktop stays usable and records edits/deletes while mobile is offline; reconnect sync converges |
| mobile ↔ headless | headless stays online while mobile closed; headless accepts generated/edited/deleted content while mobile is absent; reopen mobile → sync; export/import round-trip preserves ids, done state, edits, and deletions |
| desktop ↔ headless | invite created on headless, joined from desktop; owner-control from desktop; generated, edited, completed, and deleted content syncs both directions |
| 3-way | all converge after concurrent generate/edit/mark-done/delete operations; kill any one, others continue; rejoin reconciles without duplicates or resurrecting deleted items |
| membership (security) | owner add-member works; non-owner add-writer ignored (C3); member-removal re-key — removed instance cannot follow the new epoch (C1) |
| credential boundary | blind-storage instance replicates but cannot decrypt view.get() (C2); trusted participant can read |
| invite safety | link-join requires confirmation (H2); expired/exhausted invite rejected (H3) |
| owner-control | replayed/expired/out-of-scope command rejected; revoked device blocked (H1) |
| restart / persistence | each instance rebuilds identical state from disk after restart |
Decision:
After milestone-zero hardening, use Redux Toolkit before creating the desktop
and headless versions. Autobase/Corestore remains the durable local-first source of truth,
while Redux becomes the shared UI projection, command dispatcher, and app-state model for
mobile and desktop.
Why Redux Toolkit
Future domain fit
Redux Toolkit is planned because Listam is expected to grow into multiple list types:
to-dos, simple tasks, calendar-derived lists, kanban boards, configurable rules, and
state transitions.
Shared app state
Normalized entities, predictable actions, selectors, and audit-friendly event trails
give mobile and desktop one shared app-state model while Autobase/Corestore stays the
durable replicated source of truth.
Boundary discipline
Redux should be the UI projection and command dispatcher, not the database. Backend
snapshots and protocol events reconcile Redux after persistence and replication.
Mobile
Keep the current Expo/React Native app as the first implementation target. Refactor it
to use shared Redux slices, typed backend commands, and platform adapters without
changing the current simple-list behavior.
Desktop
Build an enhanced Pear Desktop app first, with Electron only as a fallback if Pear
blocks a concrete requirement. The first desktop release should match current Listam
behavior while adding large-screen density, keyboard actions, and clearer sync status.
UI implementation should follow listam-desktop/design-guide/.
Headless
Build a Pear Terminal/Bare personal server for always-on devices owned by the user. It
should usually run on another device, such as a Raspberry Pi, mini PC, NAS, or home
server, and act as a durable personal peer rather than a central cloud service.
| Repository |
Role |
First milestone |
listam-mobile |
Existing mobile app |
Current Listam parity after Redux Toolkit refactor. |
listam-desktop |
Pear Desktop app |
Shared backend, invite/join, list/grid views, and desktop-optimized UI. |
listam-headless |
Pear Terminal/Bare personal server |
Always-on owned device with P2P owner-control, setup/recovery CLI, invites, status, persistence, and topic services. |
listam-shared |
Versioned shared npm packages |
Domain, protocol, backend, client adapters, logging, secrets, UI internationalization, and grocery intelligence published for all app repos. |
| Desktop detail |
Planned behavior |
| First milestone |
Current Listam list parity, invite creation/joining, peer and sync status, list/grid views, shared UI internationalization, grocery grouping and icon intelligence, shared Redux/domain logic, and shared backend/client packages. |
| Large-screen improvements |
Denser list and grid layouts, keyboard-first actions, a larger multi-pane structure, clearer sync and peer diagnostics, and tray/status affordances where Pear Desktop supports them. |
| Design system |
Read listam-desktop/design-guide/ before building or changing desktop UI; its design system and example screens are binding references for layout, typography, color, spacing, states, and components. |
| Device operations |
Easier review of invites, peers, and owned headless-device connections from desktop diagnostics and management surfaces. |
Shared package plan
@listam/domain owns domain types, Redux slices, selectors, migrations, and business rules.
@listam/protocol owns command and event contracts for UIs, backend services, and future relay devices.
@listam/backend owns Bare-compatible Autobase/Corestore/Hyperswarm service code.
@listam/client owns mobile worklet RPC, Pear Desktop IPC, and headless P2P owner-control adapters.
@listam/logging owns append-only log writing, the shared log row schema, redaction, rotation, diagnostics readers, and export helpers.
@listam/secrets owns shared secret names, key fingerprints, migration contracts, redaction helpers, and platform secret-store interfaces.
@listam/i18n owns typed UI message catalogs, locale detection/selection, fallback rules, plural/date/number formatting helpers, and pseudo-locale test utilities.
@listam/grocery owns category, translation, grouping, and icon intelligence currently living in UI modules.
| Client adapter |
Where it runs |
Why it is needed |
| Mobile worklet RPC |
Expo/React Native app |
The backend runs inside the mobile process through BareKit IPC, so the app can command it without a local HTTP server. |
| Pear Desktop IPC |
Pear Desktop app |
The desktop app can use an embedded backend while sharing the same app-facing client API as mobile. |
| Headless P2P command stream |
Always-on personal server |
Encrypted request/response commands such as status, create invite, join invite, export, import, shutdown, topic configuration, and owned device management. |
| Headless P2P event stream |
Always-on personal server |
Encrypted live events for peer count, sync state, join progress, topic health, queue depth, backend errors, list updates, storage health, and owned device status. |
Headless difference:
Mobile and desktop are user-facing apps that can embed or start a backend while the user
is actively using them. The headless instance is an always-on service on another owned
device. After initial pairing, its default configuration path should be an encrypted P2P
owner-control channel, so the user's mobile or desktop app can configure topics, inspect
health, and attach it to lists without requiring shared LAN access, exposed ports,
Tailscale, or a screen UI.
| Headless first milestone |
Requirement |
| Long-lived peer |
Run as an owned always-on peer and persist the same Autobase/Corestore data model. |
| Control and status |
Create/join invites, expose peer count, sync state, base identity, storage status, and CLI commands for setup, status, invite, join, export, and shutdown. |
| Security defaults |
Require pairing/auth for control operations and never expose raw control endpoints publicly by default. |
| Setup guidance |
Ask which roles the device should perform and show the connection details needed to pair it with mobile or desktop. |
State-manager phase
- Add Redux Toolkit and create the initial mobile store first.
- Move list state, sync status, join status, peer count, invite key, preferences, locale choice, and loyalty-card metadata handles into slices.
- Keep short-lived interaction state local when it only affects a modal, input, or gesture.
- Replace direct
useState ownership of replicated list data with Redux selectors and actions.
- Move backend command side effects into listener middleware or typed thunks.
- Keep the Bare worklet and RPC boundary as a platform adapter, not the owner of UI state.
| Redux slice |
State domain |
Why split it this way |
lists |
Replicated list entities, ordering, selected list, and optimistic list operations. |
Keeps durable app data separate from UI preferences and connection state. |
sync |
Backend readiness, peer count, invite key, join phase, sync health, and sync errors. |
Makes networking and backend lifecycle visible to mobile, desktop, and headless clients. |
preferences |
Grid/list mode, categories, category headers, icon size, text size, icon style, locale override, and follow-system-language setting. |
Keeps local UI choices portable across mobile and desktop without mixing them into replicated data or another device's language choice. |
loyaltyCards |
Local loyalty-card metadata handles until/unless records are later made replicated; barcode/QR payloads stay in secure storage. |
Allows sensitive local-only data to remain outside shared list state and outside Redux traces. |
ownedDevices |
Known headless instances and future dongles, trust status, supported roles, and last-seen status. |
Supports headless co-invite, personal servers, and later dongle onboarding without bloating list state. |
Why slices:
A Redux slice is a focused Redux Toolkit module with one domain's state, reducers/actions,
and selectors. Slices avoid one giant app state object, make shared mobile/desktop logic
easier to test, and leave clean extension points for tasks, calendar ingestion, kanban
boards, configurable rules, and future personal-life-management features.
UI internationalization direction
Separate UI copy from grocery translation
Grocery category and item translations stay in @listam/grocery. Screen
titles, actions, empty states, errors, confirmations, diagnostics labels, settings copy,
and loyalty-card UI belong in a shared @listam/i18n layer so mobile and
desktop do not grow separate hardcoded strings.
Shared locale behavior
Locale choice lives in local preferences: follow the system language by default, allow an
explicit override, persist it locally, and fall back predictably. The same selected locale
should drive UI messages and the grocery-label resolver.
Layout acceptance
Add pseudo-locale and long-string checks for the main list, invite/join confirmation,
settings, diagnostics, and loyalty-card surfaces. Mobile and desktop screens must be
reviewed against any project-local design-guide/ examples in English and
pseudo-localized modes before acceptance.
Technical architecture notes
Package boundaries
Shared packages should separate pure domain code from platform code. Domain reducers,
selectors, protocol types, migrations, and grocery intelligence should stay usable in
any JavaScript runtime. Bare, Pear, React Native, P2P owner-control, and setup/recovery transport details
belong behind backend/client adapters so each app can swap transport without changing
list logic.
Replicated data authority
Redux should not become the durable database. Replicated list data remains authoritative
in Autobase/Corestore. Redux holds a local projection optimized for rendering,
optimistic interaction, and debugging. Backend snapshots and operation events reconcile
the Redux projection after persistence and peer replication.
Compatibility rule
Public command names, event names, operation versions, and RPC numeric values should be
treated as shared contracts. New behavior should append new commands or versioned fields
instead of changing old meanings, so mobile, desktop, and headless can update on
different schedules.
UI design system rule
Before implementing UI for any app or project, check for a project-local
design-guide/ directory. When it exists, its design system docs, tokens,
component rules, and example screens are the UI source of truth and must guide
implementation and review before generic visual preferences.
| Shared interface |
Expected shape |
Used by |
| Commands |
getStatus, requestListSnapshot, createInvite, joinInvite, addItem, updateItem, deleteItem, exportData |
Mobile, desktop, headless CLI, and trusted admin tools. Snapshot requests hydrate app state; P2P log replication remains automatic below this command layer. |
| Events |
statusChanged, listSnapshot, itemAdded, itemUpdated, itemDeleted, joinPhaseChanged, peerCountChanged, error |
Redux listener middleware, desktop status panels, headless monitors. |
| Storage identity |
Base key fingerprint, local writer key fingerprint, device id, role, and storage root. |
Sync status screens, diagnostics, owned-device management. |
| Owned devices |
Known headless instances and future dongles with trust status, supported roles, last seen, paired control channel metadata, and topic permissions. |
Headless co-invite flow and future relay/storage device onboarding. |
Shared command and event flow
- The UI dispatches a Redux action such as add, update, delete, create invite, or join invite.
- Redux listener middleware calls the active
@listam/client adapter instead of importing platform APIs directly.
- The adapter translates the command into mobile worklet RPC, Pear Desktop IPC, or the headless P2P owner-control stream.
- The backend validates writability, appends to Autobase, updates the materialized view, and emits protocol events.
- The adapter receives events through IPC callbacks or encrypted P2P owner-control events and dispatches normalized Redux events.
- Selectors derive screen-ready state for list views, grid views, sync indicators, device lists, and diagnostics.
| Headless surface |
Technical responsibility |
Notes |
| CLI |
Setup, status, invite creation, joining, export/import, shutdown, and service diagnostics. |
Useful over SSH on always-on devices and for recovery when no desktop UI is available. |
| P2P owner-control |
Default encrypted control plane for trusted user-owned apps after pairing. |
Use it to configure topics, roles, storage policy, invites, queues, and diagnostics without depending on LAN ports or Tailscale. |
| Setup/recovery transports |
Temporary pairing and recovery paths. |
CLI/SSH, QR code, terminal pairing code, LAN HTTP/WebSocket, Bluetooth, USB, or Tailscale/MagicDNS can help establish or repair the P2P owner-control channel. |
| Holepunch peer |
Durable personal peer for bootstrap, Autobase replication, encrypted storage, and async message relay. |
Continues helping selected topics while mobile and desktop apps are closed. |
P2P owner-control strategy
- Use QR code, terminal pairing code, LAN, Bluetooth, USB, Tailscale/MagicDNS, or CLI/SSH only to establish trust for the first connection.
- Create a private owner-control topic between the trusted mobile/desktop app and the headless instance.
- Send signed, nonce-protected, capability-scoped configuration commands over encrypted P2P streams instead of requiring a long-lived HTTP server to be reachable.
- Keep owner-control separate from list replication topics, relay topics, and storage topics.
- Use setup/recovery transports only when pairing is new, broken, or manually being repaired.
Headless first-run setup
- The headless app starts on an owned always-on device and asks which capabilities should be enabled.
- It generates or displays pairing information for the user's mobile or desktop app, such as device id, public key fingerprint, pairing code, and supported roles.
- The user pairs the headless instance from a trusted app and establishes an encrypted P2P owner-control topic.
- The headless instance receives credentials according to the honest current role boundary: full participant credentials for trusted devices, or no encryption key for blind helpers.
- It reports status back over the owner-control event stream: peer count, topic health, storage usage, queue depth, protocol version, and recent errors.
| Headless capability |
What it does |
Why it helps |
| Bootstrap helper |
Stays online on allowed topics and helps the user's devices find a stable owned peer. |
Improves reconnect behavior when phones and laptops are offline or changing networks. |
| Replication helper |
Joins selected Autobase discovery topics and replicates content for lists the user allows. |
Keeps the shared log available even when the primary UI apps are closed. |
| Storage helper |
Retains encrypted replicated content under a configured storage policy; as a blind helper it should not receive the list encryption key. |
Provides redundant user-owned storage without turning Listam into a hosted cloud service or implying read-only credentials that do not exist yet. |
| Async message helper |
Queues encrypted store-and-forward messages for peers in the same allowed topics. |
Lets peers exchange messages or wakeup hints even when they are not online at the same time. |
| Notification/reconnect helper |
Tracks topic activity, queue depth, and peer availability for trusted owner clients. |
Gives mobile and desktop better sync diagnostics and a future path to push-style notifications. |
| Diagnostics helper |
Reports protocol version, storage health, topic health, peer counts, and recent errors. |
Makes an always-on device understandable and maintainable without attaching a monitor. |
Headless co-invite flow
- A user joins someone else's list from mobile or desktop using the normal invite flow.
- After that device becomes writable or receives the allowed access mode, the app offers to add the user's known headless instances.
- The user selects one or more owned devices and chooses an access mode such as writer, storage, or relay.
- The joined device creates a short-lived delegated invite scoped to the target owned device and access mode.
- The headless instance accepts the delegated invite over its trusted owner-control channel.
- For writer mode, the joined device appends
add-writer. For trusted storage under the current substrate, the headless device must be treated as a full trusted participant if it receives list credentials. For blind storage or relay mode, it avoids list encryption credentials.
- Delegated co-invites inherit lifetime, use-count, expiration, revocation, audit logging, and redaction rules from normal invites.
- Future read-only or sync-only roles require the membership-authority and key-epoch work from C1–C3.
Logging principle:
Every app and service should write local append-only JSONL logs with the app or instance
name on each row. Logs are diagnostics, not replicated app state. They can be viewed from
mobile, desktop, headless CLI, or trusted owner-control diagnostics, and development builds
can request redacted log bundles from trusted peers for export.
| Log surface |
Append target |
Visible from |
| Mobile app |
mobile.log.jsonl plus embedded backend logs when available. |
Mobile diagnostics screen, desktop peer diagnostics in development, exported bundles. |
| Desktop app |
desktop.log.jsonl plus embedded backend logs when available. |
Desktop diagnostics screen, mobile peer diagnostics in development, exported bundles. |
| Headless app |
headless.log.jsonl and service/backend logs on the always-on device. |
Headless CLI, trusted mobile/desktop owner-control diagnostics, exported bundles. |
| Backend service |
backend.log.jsonl when the backend runs as an embedded or standalone process. |
The host app diagnostics view and merged development log exports. |
| Future dongle tooling |
dongle.log.jsonl in bridge or companion tooling when storage permits. |
Owner-control diagnostics and hardware debugging exports. |
| Log row field |
Purpose |
Example values |
ts, level |
Sort events and filter by severity. |
2026-05-27T12:00:00.000Z, info, warn, error, audit |
app, instanceName, instanceId |
Keep merged logs readable when several peers contribute rows. |
mobile, desktop, headless, Romme iPhone |
runtime, component, event |
Identify which layer emitted the event. |
react-native, bare, sync, p2p, peer_count_changed |
topicId, baseId, requestId |
Correlate commands, replication, topics, and backend responses without exposing raw keys. |
Short redacted fingerprints and command correlation ids. |
message, details |
Human-readable summary plus structured metadata. |
Redacted storage path, queue depth, peer count, join phase, or protocol version. |
Development peer log request flow
- The developer enables diagnostics sharing for a trusted device group or owner-control relationship.
- A mobile, desktop, or headless instance sends
requestLogBundle over the trusted debug or owner-control channel.
- Peers return bounded, redacted JSONL bundles for the requested time range, levels, and components.
- The requester merges rows by timestamp while preserving
app, instanceName, and instanceId.
- The app exports the bundle as a zip file, plain JSONL, email attachment, or platform share-sheet file.
Logging controls
Levels
Use trace for very detailed development-only command/event flow,
debug for development diagnostics, info for normal lifecycle
milestones, warn for recoverable problems, error for failed
operations, fatal for startup or persistence failures, and
audit for security-relevant owner actions.
Rotation and retention
Start with smaller mobile/desktop retention, such as 10 MB files with 10 rotations,
and larger headless retention, such as 50 MB files with 20 rotations. Development can
keep more debug or trace data; production should default to
info and above.
Redaction
Redact before writing and before export. Owner-control tokens, pairing codes, invite
codes, base keys, writer keys, encryption keys, raw topic keys, auth headers, and local
API tokens should become short fingerprints or omitted values. Full user content should
also be omitted when a diagnostic event is useful without it.
Diagnostics filters
Diagnostics views should filter by app or instance name, level, component, topic/list
fingerprint, request or operation correlation id, time window, and warnings/errors only.
Useful events
Log startup/shutdown, backend readiness, Redux commands, adapter requests, invite and
pairing phases, peer count changes, replication progress, Autobase apply/snapshot,
headless role changes, queue depth, storage usage, exports/imports, and unhandled
errors.
Production boundary
Peer log requests should be disabled by default for normal production users. The user's
own paired headless instance may expose logs through explicit diagnostics actions over
owner-control, but logs should never be fetched from public endpoints.
Key storage update:
Plaintext Autobase key and encryption-key text files are acceptable only as a
development/prototype shortcut. Production apps should migrate sensitive values into
platform secure storage and leave only redacted fingerprints in normal app files, logs,
and diagnostic exports.
| Secret or metadata |
Production handling |
Why |
| Autobase/base key |
Store securely or as protected metadata, and log only a fingerprint. |
It identifies or bootstraps a replicated base and should not appear raw in logs or exports. |
| Autobase encryption key |
Store in platform secure storage, never as a long-lived plaintext app file. |
It protects encrypted values; leaking it can expose replicated content. |
| Writer keys and owner-control tokens |
Use platform secure storage and strict redaction. |
They control write authority and trusted device administration. |
| Invite codes and pairing secrets |
Keep short-lived, redact everywhere, and persist only when explicitly needed. |
They can grant access during onboarding or recovery. |
| Corestore data, indexes, logs, snapshots |
Keep in normal app storage with encrypted values where appropriate. |
These are durable app files, but raw secret material should not live beside them. |
Secret storage plan
Mobile
Use iOS Keychain and Android Keystore-backed storage, such as Expo SecureStore or a
lower-level native adapter if the Bare bridge needs tighter control.
Desktop
Use the OS keychain where possible. If unavailable, use an encrypted key file unlocked
by a device-local key or user passphrase, with strict file permissions.
Headless
Use the OS keyring, TPM-backed secret storage, systemd credentials, encrypted local
key file, or user-provided passphrase depending on the device. Plain files should be
explicit development mode only.
Migration
On startup, detect legacy plaintext key files, validate them, write sensitive values
to the secure store, replace normal metadata with fingerprints, delete plaintext files
after success, and log only migration status. Provide a recovery path if secure storage
is unavailable or the user is moving to a new device.
Dongle tooling
Avoid storing raw application encryption keys on generic relay hardware unless the
device is explicitly acting as trusted storage for the owner.
Reference targets
Mobile secret storage should align with Expo SecureStore, Apple Keychain Services, and
Android Keystore-backed storage.
| Next implementation hardening |
Required update |
Acceptance signal |
| Plaintext secrets |
Move encryption keys, writer keys, owner-control tokens, invite secrets, pairing
secrets, and loyalty card barcode/QR payloads into platform secure storage.
|
Legacy plaintext files and AsyncStorage loyalty records migrate once, then raw secrets are deleted. |
| Production logs |
Replace verbose raw console logging with the shared logging layer,
redacted fingerprints, production log-level gates, and repository ignore rules for
local logs plus generated P2P key/invite files such as autobase-key.txt,
local-writer-key.txt, encryption-key.txt, invite.json,
lista-*.txt, and lista-invite.json.
|
Automated checks prove base keys, encryption keys, writer keys, invite codes, peer keys, item payloads, and loyalty card data do not appear in logs or exports. |
| Invite lifecycle |
Add revoke and rotate controls, visible lifetime, remaining use count, scoped access
mode, expiration, and audit events.
|
Bounded-use invites cannot add more peers after expiration, revocation, rotation, or use exhaustion. |
| Loyalty card privacy |
Treat card names, barcode values, QR values, and barcode types as sensitive
local-only data unless the user explicitly chooses future replication.
|
Diagnostics, logs, Redux traces, and exports redact or exclude card payloads by default. |
| Recovery coverage |
Add tests for item reduction, duplicate handling, category lookup, join rollback,
corruption recovery, storage migration, and invite revoke/rotate behavior.
|
CI exercises migration and recovery paths before desktop/headless extraction. |
| Dependency hygiene |
Add direct dependencies for modules imported by loyalty card rendering, including
react-native-svg and qrcode-terminal, and verify whether
@expo/vector-icons should be direct.
|
A dependency check fails when source files import undeclared runtime packages. |
Implementation constraints
Security boundary
Owner-control tokens, pairing codes, invite codes, base keys, encryption keys, and
writer keys must be redacted from production logs and should not remain in plaintext
app files. Public internet exposure should require an explicit future decision, not a
default headless behavior.
Failure modes
Adapters should report typed failures for backend unavailable, not writable, pairing
timeout, invite expired, incompatible protocol version, storage locked, and auth
rejected. Redux should surface these in sync or ownedDevices
instead of hiding them in console logs.
Migration order
Run milestone-zero hardening first, refactor mobile onto the shared
Redux/protocol/client packages second, then extract the backend package, then build
desktop and headless. That keeps the current app working while each shared boundary is
proven by the existing product.
Future direction
Near-term model rules
Use stable item ids when available, preserve compatibility with legacy text-only
entries, normalize entities in Redux, keep operation contracts append-only and
versioned, and avoid embedding UI-only concepts into replicated backend operations.
Personal-life-management lists
The first milestone should keep current simple Listam behavior, but the domain model
should be ready for to-dos, simple tasks, calendar-ingested lists, kanban-style boards,
configurable rules, and additional personal-life-management features.
Dongle compatibility
Future USB dongles should work with every app using the Holepunch stack, not only
Listam. Keep relay envelopes generic and encrypted so dongles can provide bootstrap,
async messages, blind relay, reliable redundant storage, store-and-forward storage,
push notification relay, reconnection help, media streaming/distribution improvements,
and Bluetooth configuration for relay topics without understanding Listam payloads.
Prototype hardware
The dongle prototype path currently considers Seeed Studio XIAO ESP32S3, ESP32-S3
DevKitC-1 N16R8, SPI microSD card reader modules, and 32 GB microSDHC cards. The plan
keeps those devices as relay/storage appliances around generic Holepunch topics rather
than Listam-only hardware.
First milestone boundary
Prove current Listam parity across mobile, desktop, and headless first. Do not add new
list domains in the first milestone; add the architecture that makes them possible.
All three app surfaces should consume the shared package versions and existing simple list
operations should remain compatible.
Post-milestone delta (shipped since):
With current-Listam parity proven across mobile, desktop, and headless, a wave of features
landed on top of the foundation — exactly the "architecture that makes them possible" the
milestone boundary called for. Shipped since the core milestone (at
listam-desktop v0.13.0 / listam-headless v0.13.1 / listam-mobile v1.0.1):
an
Overview / day-plan surface over a synced pointer channel
(
@listam/domain/plan.mjs);
move-between-lists
(
RPC_MOVE = 28,
@listam/domain/list-move.mjs); cross-list
item reordering via a shared last-write-wins
order field
(
@listam/domain/ordering.mjs);
encrypted backup / import + instance-seed
export (
RPC_EXPORT_DATA = 24,
RPC_EXPORT_SEED = 25,
RPC_IMPORT = 26; Argon2id + XChaCha20 in
backend/lib/backup-crypto.mjs,
backup-payload.mjs,
backup.mjs); a
WYSIWYG markdown editor over the DOM-free
@listam/domain/markdown.mjs;
synced peer / device names + built-in list
rename (
@listam/domain/labels.mjs); the full
voice
assistant (leaf firmware mic + on-device "yo" wake word → host whisper.cpp STT →
intent → append, with desktop-native hosting in the Pear worker);
BLE leaf
provisioning (the zero-dep
@listam/provisioning wire contract +
RPC_LEAF_PROVISION_INFO = 27); a net-new text-only
todo list
type; the desktop
Servers pane for owner-controlling remote headless
peers; and the desktop
open-empty / general-group rework that reframes the
built-in surfaces as ordinary deletable lists. The full per-phase log lives on the
Phases page.
| Test area |
What to prove |
Examples |
| Redux and domain |
Reducers, selectors, preferences, join state, and legacy migration are stable. |
Add/update/delete, grouped selectors, peer status, and text-only item migration. |
| Backend and protocol |
Command/event contracts stay compatible across the separate app repositories. |
Snapshot sync, invite creation, join phases, peer count, errors, and RPC number compatibility. |
| Cross-app sync |
Mobile, desktop, and headless can all join and replicate with each other. |
Mobile-to-desktop, mobile-to-headless, desktop-to-headless, and headless restart persistence. |
| UI internationalization |
Shared message catalogs, locale preference behavior, fallback, formatting, and layout resilience work across user-facing apps. |
Missing-key CI failures, plural/date/number formatting tests, pseudo-locale and long-string screens, and restart-persistent language choice. |
| Manual acceptance |
The first milestone feels like current Listam across all app surfaces. |
Generate content on mobile, edit and complete it on desktop, delete content through headless, keep headless online, reopen mobile and verify sync, and compare implemented UI against any project-local design-guide/ examples in English and pseudo-localized modes. |
| Logging and diagnostics |
Append-only JSONL, redaction, rotation, export, and development peer-log requests are reliable. |
Mobile/desktop/headless labels, secret redaction, retention defaults, merged exports, and trusted owner-control/debug log bundles. |
| Key storage and secrets |
Legacy plaintext keys and AsyncStorage loyalty cards migrate into secure storage. |
Secure startup secret handoff, desktop/headless fallback paths, raw-secret redaction, secure delete/export for loyalty records. |
| Invite lifecycle |
Invite revocation, rotation, bounded use, expiration, and honest delegated headless co-invite modes work. |
Writer/full-participant and blind storage/relay delegated co-invite tests plus exhausted/expired invite rejection. |
| Data recovery |
Core list reduction and recovery paths survive replay, failed join, and corrupted local state. |
Item reduction, duplicate handling, category lookup/grocery intelligence in CI, join rollback, Autobase/Corestore corruption recovery, and undeclared dependency checks. |
Assumptions:
Redux Toolkit is chosen; current Listam parity is the first milestone; milestone 1 uses
separate app repositories (listam-mobile, listam-desktop,
listam-headless) with versioned shared packages from listam-shared;
Pear Desktop and Pear Terminal/Bare are primary targets; Electron is only a fallback; new
list domains come after the foundation; future relay dongles remain generic for
Holepunch-stack apps; plaintext key files are legacy/development-only and should be migrated
before production release.
Phases
Pause gate, not a single commit
Each phase is a pause gate rather than necessarily one commit. Small phases land as a
single commit; larger phases (package extraction, desktop, headless) may span several
commits. Implementation must pause at the phase boundary - after the work is committed,
verified, and recorded - before the next phase begins, and the record must capture the
full commit range.
Two parallel tracks
After Phase 1 the work splits into a membership-crypto track (Phases 2-4: secret-storage
foundation, owner membership authority, key epochs / member-removal re-key) and a
data-model / extraction track (Phases 5-8: stable item IDs, in-place Redux migration,
shared-package extraction). The extraction track depends only on Phases 0-1, not on the
membership-crypto track, so it does not wait behind the re-key flow. Both reconverge
before Phase 11 onward; Phase 9 adds shared UI internationalization and Phase 10 finishes
loyalty-card secret migration and logging redaction before durability and app-surface work.
Required phase record
After each phase, add or update a collapsible subsection in this plan and the dedicated
wiki phase log. Each record must list all files
modified with reasons, all functions created/updated/deleted with reasons, a summary of
implementation decisions and actions, and the commit range, dependencies satisfied, and
acceptance signal met.
Commit-worthy phase sequence
Phase 0 test/CI/repo-hygiene bootstrap; Phase 1 invite safety and deep-link
confirmation; Phase 2 secret-storage foundation; Phase 3 membership authority and honest
revocation language; Phase 4 key epochs and member-removal re-key; Phase 5 stable item
IDs and backend reduction migration; Phase 6 in-place Redux Toolkit migration; Phase 7
pure shared-package extraction; Phase 8 backend/client extraction with platform adapter;
Phase 9 UI internationalization foundation; Phase 10 loyalty-card secrets and redaction
routing; Phase 11 recovery, snapshots, and storage durability; Phase 12 desktop parity;
Phase 13 headless service and CLI parity; Phase 14 owner-control protocol; Phase 15
cross-app acceptance and release readiness.
listam-multi-app-plan.md
phases.html
Desktop redesign · Kinetic Minimalist v2
Dark Theme
Implemented — KM v2 phase 1
Goal (parameter P5 — tonal depth, two themes):
keep the no-shadow surface-ladder DNA, but formalize the desktop tokens so a true dark
theme falls out mechanically rather than being hand-tuned. Before v2 the desktop was
light-only and the Pear window background was hardcoded to #fbf9f9. Every
colour becomes a theme-paired semantic token, so switching themes only swaps the palette,
never the markup.
Token model
Three theme-paired families
Surfaces — the tonal ladder --surface-0 (canvas) →
--surface-3 (pressed / selected-mute), plus --card (row and
kv-row fill). Inks — --ink, --ink-mute,
--ink-faint (metadata, AA-large only). Signals —
--signal / --on-signal (acid) and --danger plus
containers. No third hue: pending and offline states are carried by shape, not by
adding amber.
| Token |
Light |
Dark |
Role |
--surface-0 | #FBF9F9 | #101212 | canvas |
--surface-1 | #F5F3F3 | #161919 | sidebar, panels |
--surface-2 | #EFEDED | #1D2121 | fills, hover, receded rows |
--surface-3 | #E3E2E2 | #252A2A | pressed, selected-mute |
--card | #FFFFFF | #181B1B | item rows, kv rows |
--ink | #1B1C1C | #F1EFEF | primary text, selection fill |
--ink-mute | #5D5F5F | #B0ABAB | secondary text |
--ink-faint | #7E7576 | #8A8485 | metadata, mono labels (AA-large only) |
--line | #CFC4C5 | #34393A | hairline of last resort (dense tables only) |
--signal | #C3F400 | #C3F400 | live / active / success |
--on-signal | #161E00 | #161E00 | text on acid |
--danger | #BA1A1A | #FFB4AB | destructive, attention |
Hard rules
Acid is never text on light surfaces
#C3F400 on white is ~1.4:1. As a fill, acid always pairs with
--on-signal; as text it appears only on --ink-block.
--ink-block is theme-constant near-black and does not invert
Any block carrying acid text (active nav, active locale) uses --ink-block:
#1B1C1C in light, #000000 in dark. The first mockup flipped the
active-nav block to --ink in dark mode, which resolves to near-white and put
acid text on a light fill (~1.4:1) — exactly the failure this rule prevents.
Acid stays identical across themes
It is the brand constant. The adoption PR includes an AA audit table of every
ink × surface pair actually used, and --ink-faint is restricted to ≥14px
mono or non-text.
Theme switching
Mechanism
Themes resolve through html[data-theme="light" | "dark"]; the default
follows prefers-color-scheme with a pre-JS fallback so the first paint is
already correct. The choice persists in preferences next to isGridView,
cycles system → light → dark from the Settings dialog or the T
shortcut, and backend-boot applies the persisted theme to the Pear window
background at startup — replacing the hardcoded #fbf9f9.
Rollout
Phase 1 — Tokens + themes + floors (CSS-only, shippable alone)
Semantic tokens, the dark palette, and the contrast / scrollbar / label floors land
with no markup changes — the biggest visible win for the least risk. Tabler outline
icons inherit ink, so they theme automatically; prefers-reduced-motion and
forced-colors (Windows high-contrast) both pass nearly for free because
state is encoded as shape, not colour. Shipped 2026-06-11 as phases 1–2 of Kinetic
Minimalist v2 (user-approved).
listam-desktop/design-guide/proposals/2026-06-kinetic-minimalist-v2.md
Hardware peer
Leaf Peer — ESP32 Mirror
On hardware — peripherals & voice planned
A leaf is a dumb, always-on blind replica: it mirrors a
project's hypercores over plain TCP and serves them while every real device is offline,
without ever holding the encryption key. It verifies signatures and merkle proofs
but only stores and forwards ciphertext — the same guarantee Holepunch blind peers give.
Two builds share one Rust core: leaf-host (Mac / Pi / VM, disk storage) and
leaf-esp32 (ESP32-S3-N16R8 firmware). The host hub is the TCP leaf bridge in
@listam/backend, shipped in listam-headless and listam-desktop.
How it fits together.
Provisioning is a single value — the control core key, printed by the bridge
at startup. The leaf dials every configured hub, speaks the hypercore v10/v11 wire protocol
(secret-stream + protomux compatible), mirrors the control core, learns the project's core
keys from its {"add":[…]} entries, then download-all mirrors and serves them.
Everything past the control key is learned in-band.
listam app (desktop / headless / mobile)
│ @listam/backend lib/leaf-bridge.mjs
│ - TCP listener, store.replicate(socket) per connection
│ - "leaf control core": hub-written hypercore announcing
│ {"add": ["<core key hex>", ...]} for every project core
▼
TCP (LAN / tailscale / WireGuard — payload is Noise-encrypted)
▲
│ leaf (leaf-host or ESP32)
│ - dials every app, speaks the hypercore v10/v11 wire protocol
│ - mirrors the control core, learns project core keys from it,
│ mirrors everything (download-all), serves requests
Builds & flash
Two builds, one core
leaf-host is the desktop/server binary (disk or in-memory storage);
leaf-esp32 is firmware for the ESP32-S3-N16R8; leaf-core is the
shared, platform-agnostic mirror logic. The firmware is provisioned from cfg.toml
(up to three 2.4GHz WiFi networks + hub address + control key; the real file with
credentials is gitignored).
Which apps can host the bridge
The bridge is transport-pluggable (callers inject a net-compatible module).
Headless = Node, shipped and enabled with LISTAM_LEAF_BRIDGE_PORT.
Desktop = Pear/Bare, shipped with full UI in the Peers & Devices pane
(enable toggle, port field, provisioning-key display/copy, live "N boards connected"), off
by default. Mobile = planned only — a BareKit worklet dies when the app
backgrounds, so the recommended path is an always-on headless hub, not phone-as-hub.
Flash command
After cp cfg.toml.example cfg.toml and . ~/export-esp.sh, run
cargo build --release then
espflash flash --monitor --flash-size 16mb --partition-table partitions.csv …/leaf-esp32
(espflash ignores the IDF table, so pass --partition-table explicitly).
Provisioning & hub-aware roaming.
cfg.toml holds up to three 2.4GHz networks (the S3 has no 5GHz radio). The
firmware scans, joins the strongest known network, and — crucially — if no hub becomes
reachable through it within ~25s, rotates to the next known network. This handles
café/guest APs with client-isolation (a strong WiFi whose AP blocks client-to-client traffic):
the leaf won't sit uselessly on it, it roams to one where the hub actually answers. It also
re-scans and rejoins if WiFi drops, and reconnects (with a connection bounce) when it learns
new project cores.
Shipped: BLE provisioning.
Beyond editing cfg.toml and USB-flashing, a leaf can now be initialized over
Bluetooth from any app. When the firmware is unprovisioned (or booted while
holding the GPIO0/BOOT button) it advertises a NimBLE GATT service named
listam-leaf-XXXX (a MAC-derived suffix, e.g. listam-leaf-3F7A),
accepts a chunked, CRC16-verified JSON config (WiFi credentials, the control key, and the
hub / audio addresses), persists it to NVS (NVS takes precedence over a baked
cfg.toml), then reboots into normal mirror mode. The wire contract — UUIDs,
payload schema, CRC16, and BEGIN/CHUNK/COMMIT framing — is the zero-dep
@listam/provisioning package; the hub-side values an app sends come from
RPC_LEAF_PROVISION_INFO = 27. The status LED is blue while
advertising, green on a successful apply, and red on a bad
payload (CRC or schema failure).
Flash persistence — proven on hardware
~13 MB wear-leveled FAT partition
Mounted at /data via esp_vfs_fat_spiflash_mount_rw_wl; mirrored
cores persist there through a blocking std::fs storage backend
(Storage::new_file_storage) in the vendored hypercore. On boot the leaf reopens
the control core from flash and re-registers every announced core, so after a power cycle it
reloads the whole project and only syncs deltas. RAM (64 KiB PSRAM pages) is the fallback if
the FAT mount fails.
Upstream datrs oplog bug found & fixed
Entry::decode read tree_upgrade and bitfield under
the wrong flag bit (flags & 2 instead of bits 4 and 8), so any entry with
nodes + bitfield but no upgrade (a replicated data block) overran the buffer — invisible
in-memory, fatal on disk reopen. Fixed with regression tests in oplog/entry.rs.
FATFS-specific storage quirks handled
FATFS leaves seek-past-EOF gaps undefined (stale flash) but hypercore assumes
sparse-zero semantics → tree-store checksum corruption on multi-block downloads; fixed by
zero-filling gaps before any past-EOF write. FATFS also has no fd-ftruncate
(EPERM), emulated by a grow-by-zero-write / shrink-by-reopen set_len_compat.
flush_infos now sync_all()s each store (FATFS only persists size on
fsync/close); fresh-format FAT had no /data/cores dir → the mount now creates it.
On-device bugs root-caused & fixed
- Duplicate channel opens. The driver opened each core at the Handshake event AND again on the hub's own Opens; JS protomux pairs only the first, so the duplicate channel goes half-dead and its block requests are never answered. Fixed with a per-connection
opened set and an idempotent command_open.
- Silently-dead-link wedge. The cipher layer swallowed EOF/reset as
Pending, so a vanished hub parked the session forever. Fixed with a 16s receive-silence watchdog (hub keepalives ~5s) plus an in-flight guard so a resync tick can't double-download.
- Multi-channel session panic. ESP-IDF async-io surfaces
NotConnected mid-session, which the handshake crate todo!()-panicked on. Vendored the crate with a catch-all transient arm.
Serial & toolchain gotchas
- USB-Serial-JTAG. Plain device-node reads get nothing — it gates TX on DTE-present; use
espflash monitor (needs a real TTY → wrap in script) or pyserial with dtr=rts=True. Monitor resets the board on attach and detach (--no-reset to peek).
- Don't flash while monitoring. A monitor attach mid-
espflash flash bricks the app partition → always lsof the serial port first; recover via erase-flash + reflash.
- Other handled pitfalls: register
esp_vfs_eventfd at boot; 64 KiB PSRAM pages (the default 1 MiB aborts); vendored snow for ring?/std (no Xtensa ring); milestone logs via the log crate, never tracing's log feature (UART spam).
Vendored crates — v11 manifest support
Why fork the datrs stack
listam cores are v11 manifest cores (key = manifestHash); the upstream
hypercore / hypercore-protocol-rs / hypercore_schema
crates can't do this yet. The vendor/ forks add manifest codecs + hash + verify,
raw-key keyless cores (HypercoreBuilder::raw_key + set_manifest),
Data.manifest flag 16, RequestSeek.padding, the protomux batch
encode fix (Opens/Closes must be channel-0 control messages with type tags — JS
silently drops mistagged opens), and the file-storage / FATFS quirk handling above. All are
candidates for upstreaming.
Pinned to the installed JS
Golden vectors generated from JS hypercore 11.33 (bridge-js/gen-vectors.mjs)
pin the crypto (manifest codec, manifestHash, v1 signables, multisig encoding, signature
verification). The E2E harness (bridge-js/e2e.mjs) proves the contract against
the real headless app, and the board is a first-class --esp32 row in the
cross-device test matrix.
Peripherals — mic & SD card
INMP441 I2S mic + SPI micro-SD
Added to the S3-N16R8 via a solderless screw-terminal shield. The pin map was
adversarially verified clean (it avoids the octal-PSRAM, flash, native-USB, UART0, and
strapping pins). Both modules run on 3V3 only (never 5V), shared GND; the
two buses are independent peripherals that need no coordination.
| Module | Bus | Pins |
| INMP441 mic |
I2S0 |
BCLK = GPIO4, WS = GPIO5, DIN = GPIO6, L/R → GND (left slot); no MCLK, no pull-ups |
| micro-SD |
SPI2 / FSPI |
CS = GPIO10, MOSI = GPIO11, SCK = GPIO12, MISO = GPIO13; 10k pull-ups + 10µF/0.1µF cap at VCC |
Mic bring-up verified on hardware
leaf-esp32/src/bin/mic_test.rs (an isolated bin that touches no leaf code) is
an I2S0 level-meter printing peak / RMS / dBFS per ~100ms window. Flashed and booted clean,
it tracked a full-scale transient (−0.0 dBFS) decaying smoothly to a ~−40 dBFS room floor
with symmetric min/max around 0 = healthy AC PCM. Mic + wiring confirmed recording correctly.
Voice plan — board listens, host transcribes.
On-device full STT is infeasible on the S3, so the split is: the leaf does
wake-word + VAD on-device (a custom microWakeWord "hey listam", or ESP-SR WakeNet9 as the
turnkey fallback) and streams gated PCM to a paired host; the host does
STT + intent + append. Because the leaf opens cores raw_key (read-only, no secret
key) it cannot append — the writable list core lives on a desktop/headless/mobile
peer and the new item replicates back down (an LED ack is the zero-extra-hardware feedback
path). Host STT = whisper.cpp (one multilingual model for all six locales); the recommended
path is QVAC (Tether's Apache-2.0 SDK — same whisper.cpp engine, runs natively
in the Bare worker, distributes models P2P over hyperdrive), spike-gated behind a proven
whisper.cpp-subprocess fallback. ESP-SR MultiNet and Picovoice were both rejected (locale
coverage / never ran on Xtensa).
Known limitations & next steps
Patched multisig verification
Autobase optimistic writers (every invited member's first blocks) fail "Manifest
signature verification failed" — the verifier doesn't reconstruct the patched tree hash yet.
Owner / system / bootstrap cores verify fine (so single-member hubs and the ESP matrix row
pass); porting verifier.js's _verifyMulti patch path is needed only once a
multi-member project's member writer cores must mirror.
SD-card storage
For projects larger than the ~13 MB internal FAT, point MirrorStorage::StdFs
at /sdcard/cores via esp_vfs_fat_sdspi_mount (fallback
SD → internal FAT → RAM). leaf-core is unchanged — StdFs is path-agnostic and
reuses the existing zero-fill / no-ftruncate handling.
Mobile bridge & firmware listen-mode
The mobile bridge plan is written but unimplemented: the real blocker is background
lifecycle (iOS has no supported background TCP listener; Android needs a foreground service),
not wiring. For the offline-LAN niche the preferred direction is to add an ESP firmware
listen-mode and have the phone dial the board, rather than run a background server.
listam-hardware/leaf-peer/README.md
listam-hardware/leaf-peer/docs/mobile-bridge-plan.md
listam-hardware/leaf-peer/leaf-esp32/src/bin/mic_test.rs
tools/cross-device/matrix.mjs
Feature plan · post-parity milestone
Kanban Boards — Rigor Mode, Time Tracking & Congruency
Implemented — desktop shipped
Goal:
Turn Listam's flat grocery/list substrate into a kanban board where tickets are
first-class list items, estimation discipline is creator-controlled and enforceable,
delivery accuracy is measured objectively, and every peer agrees on the numbers. A
full board, two ticket-detail views, and a Properties/States/Automations/Rules config
were mocked first, then implemented on the desktop app.
Product rules
Rigor mode
A board-level mode, on by default, that the board owner can
disable. While on, creating a ticket requires at minimum a short description, a task
checklist (≥1 item), estimated hours (>0), and estimated complexity (1–100%).
Time tracking
Accumulate the time a ticket spends in the In Progress state.
On-time flagging
On reaching Done, freeze a verdict from
delta = (actualInProgressHours − estimatedHours) / estimatedHours:
delta > +10% → overtime (not in time);
delta < −10% → undertime (overestimated); otherwise
on time.
Congruency score (per user)
Calibration accuracy: a user is more congruent the closer their average estimated
complexity is to the share of their tickets that missed the estimate (ran overtime
or undertime).
Authority model (user decision):
The signature of the board creator is the authority. Rigor mode and all
board configuration are stored as an owner-signed record verified against the creator's
authority key — no client (desktop, mobile, headless, leaf) can change them without that
signature. This reuses the proven membership.mjs owner-signed-record pattern.
Load-bearing architecture (verified against source)
Tickets are list items
normalizeListItem spreads ...item before stamping the three
required fields (text, isDone,
timeOfCompletion), so arbitrary extra fields survive the reduction
untouched. A ticket is a list item with listType:'kanban' plus optional
fields — no new op types and no LIST_OPERATION_VERSION bump (stays
1; ops with version>1 are dropped, so keeping it 1 stays
forward-compatible with old peers).
RPC_UPDATE already passes extra fields through
but addItem(text, listId, listType) discards everything but
text. So addItem and the RPC_ADD handler must
be extended to carry ticket fields — the one unavoidable backend change for ticket
creation.
LWW on updatedAt
isStaleUpdate drops any update with a lower updatedAt.
Every ticket mutation (drag, block edit, checklist toggle, property edit) MUST set
updatedAt: Date.now() — forgetting this is the #1 silent-no-op bug.
Owner-signed shared state precedent
membership.mjs provides the template: an owner authority keypair, an
owner check, records persisted to the view as {op, record}, rebuilt via
a reducer plus view-checkpoint.mjs, and normalizeViewEntry
ignoring control records so they never pollute the item reduction. Board config
mirrors this exactly.
Renamed "Board" with a dual-read migration
The surface was renamed from "Kanban" to Board. To avoid forking
apply() across a partially-updated mesh, the migration is
dual-read: isBoardType() accepts both the canonical
'board' value and the legacy 'kanban' value, while tickets are
still written with the legacy wire type 'kanban' until the
whole mesh (including the pre-bundled mobile backend) ships dual-read — only then does the
write-side flip to 'board'. The pure block / ticket / congruency logic now
lives in the shared @listam/domain/board.mjs
(BOARD_LIST_TYPE = 'board', LEGACY_BOARD_LIST_TYPE = 'kanban'),
generic enough to back both desktop and mobile.
| Ticket field |
Type |
Notes |
status |
todo | in_progress | blocked | review | done |
Board column. Keep isDone = (status==='done') so legacy UIs still read done-ness. |
description | string | Rigor-required (short description). |
checklist | {id,text,done}[] | Rigor-required (≥1). |
estimatedHours | number | Rigor-required (>0). |
estimatedComplexity | number (1–100) | Rigor-required. |
priority | low | medium | high | urgent | Optional. |
assignee | string (writer-key hex) | Optional. |
createdBy / completedBy | string (writer-key hex) | Attribution, stamped write-side (self-asserted, honest-client). |
inProgressMs | number | Cumulative accumulator. |
inProgressSince | number | null | Wall-clock ms of the current in-progress entry. |
actualInProgressHours | number | Frozen at Done. |
timeliness | on_time | overtime | undertime | Frozen at Done. |
blocks | Block[] | Block-based body: markdown, image, table, links, checklist, numberedList, callout, code. |
kanbanVersion | number (=1) | Field-level marker, independent of op version. |
Board configuration — one owner-signed record
Single signed record
New module backend/lib/board-config.mjs (models membership.mjs).
A single owner-signed record holds the whole board config — also what the
Properties/States/Automations/Rules config edits:
{ type:'board-config', version:1, sequence, ownerAuthorityKey, createdAt,
rigorOn: true, // default ON
states: [{id,name,color,wipLimit,isDone}], // board columns
properties: [{key,label,kind,options}], // ticket fields / property rail
rules: [{id,kind,params,enforce:'block'|'warn'}],
automations:[{id,trigger,actions,enabled}],
signature }
Verification & defaults
reduceBoardConfigOperation verifies against
body.ownerAuthorityKey and accepts only if it equals the board
creator's authority key; it rejects wrong-base, replay (sequence ≤ highest),
and bad signatures. This is how non-creators are prevented — purely cryptographic,
no client trust. Absence of any record = defaults (rigor ON, the 4 default states).
Wiring
apply() handles isBoardConfigRecord(value) after the
membership branch (reduce, set state, persist {op:'board-config'},
broadcast); view-checkpoint.mjs collects the records and keeps them out
of the item reduction; state.mjs holds boardConfigState,
rebuilt on restart. Owner-only mutation RPC; non-owner attempts notify
{type:'config-denied', reason:'not-owner'}.
Shared domain logic — @listam/domain/board.mjs (pure; renamed from kanban.mjs)
validateTicketDraft(item, config)
→ {ok, missing[]}. When config.rigorOn, requires
description, checklist≥1, estimatedHours>0, estimatedComplexity∈[1,100]. Enforced
on add only, never on update — so status changes of legacy /
grandfathered tickets are never rejected.
applyStatusTransition(existing, incoming, now)
On entering in_progress, set inProgressSince=now; on
leaving, accumulate inProgressMs (clamp negative skew, cap a single
slice). On entering done, flush the open slice, freeze
actualInProgressHours, compute delta, freeze
timeliness, set isDone/timeOfCompletion/completedBy.
Reopen re-arms; re-completion recomputes. Timeliness/time are frozen at the source
writer that owns the wall clock; every peer receives the verdict verbatim and agrees.
computeCongruency(tickets)
Per-user, grouped by completedBy ?? createdBy:
offEstimateRatePct = 100·(#overtime + #undertime)/N;
gap = |avgComplexityPct − offEstimateRatePct|;
raw = 100 − gap; volume-shrunk toward neutral 50 via
score = round(50 + (raw − 50)·N/(N+5)). Also emits on-time/over/under
counts and an optional tendency.
evaluateRules(config, nextItem, prevItem)
→ {blocked[], warnings[]} for WIP limit, required-owner-in-active,
done-gate ("no open checklist items"), and blocked-needs-reason.
| Backend change |
What it does |
protocol/index.mjs |
Add RPC_SET_BOARD_CONFIG = 22, RPC_GET_BOARD_CONFIG = 23; ticket CRUD reuses RPC_ADD/UPDATE/DELETE. Bump package version. |
backend/lib/item.mjs |
Extend addItem to carry ticket fields (stamp createdBy, default status:'todo'); in updateItem diff status and run applyStatusTransition; add readPersistedBoardConfigRecords(). |
backend/lib/board-config.mjs (new) |
Owner-signed config: create state/record, reduce operation/log, isBoardConfigRecord. |
backend/backend.mjs |
apply() board-config branch + rigor add-gate (fail OPEN when indeterminate so a write is never permanently dropped); owner-guarded RPC_SET_BOARD_CONFIG; RPC_GET_BOARD_CONFIG. |
view-checkpoint.mjs · state.mjs |
Collect op:'board-config' records; hold boardConfigState with a setter, rebuilt on restart. |
| Attribution caveat |
createdBy/completedBy/assignee are self-asserted (honest-client). The rigor rule is signature-enforced; per-ticket attribution is not — upgrade path is node.from in apply(). |
Desktop UI — listam-desktop/src/
New pure module src/ticket.mjs
Selectors/helpers that keep ui.mjs presentational and re-export the
shared math: isTicket, isKanbanList,
groupByStatus, ticketBadges, selectBoardConfig,
selectWriterStats (delegates to computeCongruency),
validateRigorDraft.
Board pane & cards
renderBoardPane builds columns from groupByStatus;
renderTicketCard shows priority pill, assignee avatar, due, checklist
count, an in-progress timer, and the on-time / overtime / undertime badge when done.
Two detail presentations + block editor
Three shared pure builders (renderTicketSummary,
renderTicketBody, renderPropertyRail) feed both a right-hand
split panel and a full-screen .ticket-doc grid (no duplication). The
8-block body editor dispatches per type with a hover gutter, / slash
menu, and one commitBlocks → RPC_UPDATE with
updatedAt. Live markdown is a minimal subset (bold/italic/code/links).
Rigor create dialog & owner-gated config
The add-ticket-rigor dialog validates required fields with a
.shake + .rigor-notice; the Settings rigor row and the
Properties/States/Automations/Rules config dialog are owner-gated and write
RPC_SET_BOARD_CONFIG (read-only chip for non-creators).
Drag-and-drop & congruency
Native HTML5 DnD (survives full re-render; feedback via classList; one
RPC_UPDATE on drop; Ctrl+←/→ keyboard fallback).
renderCongruencyPane shows one card per user: completed count,
on-time/over/under bar, score numeral + reason. Board/rigor/stats all derive from
state.items — no duplicated ticket state.
i18n
All new keys land in all 6 catalogs (en/es/de/fr/it/pt) or the
assertCompleteCatalog parity test fails.
Other additions
Activity feed & reopen handling
Persist {op:'activity'} entries (ticket completed + timeliness, config
changed); done→in_progress re-arms timing and re-completion recomputes
timeliness.
listType filtering & mock fixtures
Control records and the grocery list never render kanban tickets and vice-versa
(filter by listType); mock-backend.mjs seeds kanban tickets
(mixed statuses, each timeliness) + a board-config so ?mock=1 exercises
board/rigor/congruency without a backend.
Accessibility & back-compat
Keyboard drag, focus management, prefers-reduced-motion; no op-version
bump, optional fields, default config when no record, old peers ignore unknown fields
and re-emit them intact. Mobile/headless reuse the same shared logic (UI is a
follow-up — see the Mobile Kanban plan below).
Testing & verification
Unit (node:test)
domain/kanban.test.mjs (validate, transition accumulation/freeze/reopen,
congruency gap + shrinkage), board-config.test.mjs (default rigor ON,
creator flips it, non-creator/replay/wrong-base rejected, log rebuild), backend apply
integration (rigor add-gate, checkpoint keeps config out of item reduction), desktop
ticket.test.mjs/store.test.mjs, and the i18n parity test.
End-to-end
npm test in listam-packages + listam-desktop;
?mock=1 board walk (rigor validation, drag, in-progress timer, frozen
badge, congruency); a two-peer cross-device run proves a non-creator rigor toggle is
rejected cluster-wide and a ticket completed on peer A shows the same timeliness on
peer B.
Suggested build order (single milestone)
domain/kanban.mjs + tests (pure, no UI/backend).
board-config.mjs + tests; protocol constants; apply()/checkpoint/state wiring.
item.mjs add-fields + time/timeliness; backend apply gate; integration tests.
- Desktop: icons + i18n;
ticket.mjs; app.css; board (read-only) → DnD → detail (split + full) → block editor → rigor dialog → settings/board-config (owner-gated) → congruency.
- Mock fixtures; manual + two-peer verification.
listam-kanban-plan.md
Feature plan · mobile phase
Mobile Kanban — Typed Lists, Groups, Swipe Nav & Board
Implemented — mobile shipped
Goal:
Bring the shipped desktop kanban to the mobile app (Expo / React Native + Redux Toolkit,
single-screen). The user organizes typed lists (grocery + kanban) into groups,
flags one list default (the app opens there), and navigates by
swiping between lists; kanban lists get a board, ticket detail, rigor
create, time tracking, on-time flagging, and congruency — all reusing the shared
@listam/domain/kanban.
Key architecture decisions (2026-06-15)
Synced list registry via reserved meta-items (no new backend)
List + group metadata are ordinary synced items in a reserved meta list
(listType:'registry'), each carrying
{ kind:'list'|'group', name, type?, groupId?, order }. They flow through
the existing RPC_ADD/UPDATE/DELETE → sync → reduce pipeline (LWW,
encrypted, attributed) with zero backend changes; frontends reduce them into a
registry and filter listType:'registry' out of user-facing views. The
fallback is a dedicated owner-signed list-registry record mirroring
board-config.mjs.
Default list is per-device
"Opens here on launch" is inherently local — preferencesSlice + AsyncStorage, not synced.
Pager on PanResponder + Animated — no new deps
Capture-phase PanResponder + Animated translateX (no
react-native-gesture-handler / pager-view;
check-deps.mjs gates new imports). Content swaps by dispatching
selectedListChanged.
Gesture model
Flip grocery swipe-to-delete to swipe-left and switch kanban columns
by tapping — horizontal swipe is reserved for list navigation.
Shared / domain (small)
@listam/domain/list-registry.mjs (new, pure)
reduceRegistry(metaItems) →
{ groups:[{id,name,order}], lists:[{id,name,type,groupId,order}] } with
LWW by updatedAt, tombstone deletes, stable sort by order then name, and
unfiled lists folded into an implicit "Ungrouped" group last.
REGISTRY_LIST_TYPE='registry'; no backend changes (rides item ops). Used
by mobile now and desktop later.
Mobile state — listam-mobile/app/store/
boardConfigSlice.ts
{ config, canAdminister }; boardConfigReceived/boardConfigReset;
selectBoardConfig falls back to normalizeBoardConfig(null)
(never null).
List registry projection
Reduces synced registry meta-items (via @listam/domain/list-registry)
plus the local default; thunks send RPC_ADD/UPDATE/DELETE for the
meta-items; selectors selectGroupedLists,
selectListsInGroup, selectListIndexInGroup.
preferences & decoder
preferencesSlice.defaultListId (+ AsyncStorage hydration);
_useWorklet.ts decoder gains board-config →
boardConfigReceived and config-denied → snackbar, re-exports
the two board-config RPCs. Registry meta-items arrive through the existing
sync/from-backend cases — no new transport.
Navigation + gesture layer
app/nav/listNav.ts (pure)
step(lib,curId,dir,{jumpGroup,wrap}), nextList/prevList,
crossesGroupBoundary (→ toast group name),
resolveLaunchList (default → first-of-first-group → null; stale default
falls through). No wrap by default.
ListSwipePager.tsx + useListPager.ts
Capture-phase PanResponder claims horizontal only when clearly horizontal (dead-zone
~14px + axis-lock) AND no category drag active AND not in a no-pager zone; animates one
translateX, dispatches on settle; long-press a blank area (~250ms) arms
jumpGroup + haptic; reduced-motion → instant swap.
Indicators, menu & the swipe flip
PositionIndicator.tsx (green active dot, hidden when ≤1);
ListsMenu.tsx (grouped list of every list, opened from the top-left menu;
current highlighted, default starred); ListItem.tsx swipe-to-delete
flipped to left; Header.tsx top-right default star;
index.tsx wraps the list view in the pager and resolves the launch list.
Kanban screens — app/components/kanban/
Ported ticket.ts
From desktop src/ticket.mjs with the DOM/innerHTML markdown
bits dropped — pure reuse of @listam/domain/kanban
(groupByStatus, ticketBadges, buildStatusChange,
validateRigorDraft, formatDuration, block helpers).
Board, detail, create, congruency
Board switches columns by tapping a segmented control (one column at
a time); cards via ticketBadges with a live in-progress timer; a
full-screen ticket detail sends one RPC_UPDATE via
buildStatusChange (backend freezes time/timeliness); the rigor create
form runs validateRigorDraft before RPC_ADD; the congruency
view renders selectWriterStats as monochrome bars; a new-list bottom
sheet (grocery vs kanban) calls createListThunk.
i18n & theme
Only new nav keys
Kanban/ticket/board keys already exist in all 6 @listam/i18n catalogs.
Add only new nav keys (nav.toast.enteredGroup,
nav.hint.jumpArmed, board.configDenied, group/menu/default-star
labels) to all 6 + the MessageKey union; the parity test gates it.
Strictly monochrome + green
Consume useTheme() tokens only; green (colors.accent, the
native #2f9e44 — not the desktop acid green) is reserved for
active/positive: default star, FAB, active column/tab, live timer, done/on-time,
primary actions, slider.
Testing & verification
node --test only (no Jest)
Co-locate .mjs tests under listam-mobile/backend/lib/ and
extract pure logic the .ts imports:
list-registry.test.mjs (LWW, deletes, ordering, Ungrouped),
list-nav.test.mjs (next/prev, cross-boundary toast, jumpGroup, no-wrap,
stale default), kanban-ticket.test.mjs (ported helpers; domain math is
already covered in packages/domain/kanban.test.mjs).
End-to-end
npm test (mobile + packages); Expo simulator: create a grocery and a
kanban list, file them into groups, star a default and relaunch, swipe between lists
(confirm the group-boundary toast), long-press + swipe to jump a group, confirm
grocery delete swipes left, tap kanban columns vs swipe to change list, rigor create
validation, an on-time badge, congruency; a two-device run proves registry sync and
shared timeliness with per-device defaults.
Risks & edge cases
Reserved meta-items must be filtered everywhere
Centralize an isRegistryItem guard; if overloading items feels wrong, switch to the owner-signed list-registry record fallback (more backend work).
Gesture arbitration is the highest risk
Vertical FlatList scroll must never be captured (axis-lock is the invariant); category long-press-drag (~280ms on rows) and jump-arm (~250ms on blank space) are disjoint by surface — verify on device.
Registry races & scope
Any-writer registry edits race under LWW (two peers rename a group → last write wins, acceptable). Desktop multi-list/group UI is out of scope; the shared reducer lets desktop adopt it later for parity.
Suggested build order
@listam/domain/list-registry + tests (pure).
- Mobile state:
boardConfigSlice, registry projection, preferences.defaultListId, _useWorklet decoder + RPC re-exports + tests.
- Nav:
listNav + tests → useListPager → ListSwipePager + PositionIndicator → ListsMenu → index.tsx/Header.tsx wiring → ListItem delete flip.
- Kanban: port
ticket.ts + tests → board (tap columns) → ticket detail + status change → rigor create → congruency → new-list sheet.
- i18n nav keys (6 catalogs) + monochrome/green pass; simulator + two-device verification.
listam-kanban-plan.md