Plans · design record & review

Plans

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-writerhost.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 ↔ mobileinvite/join; both devices generate items, edit text, mark done/undone, delete, and converge; duplicate-name handling by id (M1)
mobile ↔ desktopparity 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 ↔ headlessheadless 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 ↔ headlessinvite created on headless, joined from desktop; owner-control from desktop; generated, edited, completed, and deleted content syncs both directions
3-wayall 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 boundaryblind-storage instance replicates but cannot decrypt view.get() (C2); trusted participant can read
invite safetylink-join requires confirmation (H2); expired/exhausted invite rejected (H3)
owner-controlreplayed/expired/out-of-scope command rejected; revoked device blocked (H1)
restart / persistenceeach 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

  1. @listam/domain owns domain types, Redux slices, selectors, migrations, and business rules.
  2. @listam/protocol owns command and event contracts for UIs, backend services, and future relay devices.
  3. @listam/backend owns Bare-compatible Autobase/Corestore/Hyperswarm service code.
  4. @listam/client owns mobile worklet RPC, Pear Desktop IPC, and headless P2P owner-control adapters.
  5. @listam/logging owns append-only log writing, the shared log row schema, redaction, rotation, diagnostics readers, and export helpers.
  6. @listam/secrets owns shared secret names, key fingerprints, migration contracts, redaction helpers, and platform secret-store interfaces.
  7. @listam/i18n owns typed UI message catalogs, locale detection/selection, fallback rules, plural/date/number formatting helpers, and pseudo-locale test utilities.
  8. @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

  1. Add Redux Toolkit and create the initial mobile store first.
  2. Move list state, sync status, join status, peer count, invite key, preferences, locale choice, and loyalty-card metadata handles into slices.
  3. Keep short-lived interaction state local when it only affects a modal, input, or gesture.
  4. Replace direct useState ownership of replicated list data with Redux selectors and actions.
  5. Move backend command side effects into listener middleware or typed thunks.
  6. 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

  1. The UI dispatches a Redux action such as add, update, delete, create invite, or join invite.
  2. Redux listener middleware calls the active @listam/client adapter instead of importing platform APIs directly.
  3. The adapter translates the command into mobile worklet RPC, Pear Desktop IPC, or the headless P2P owner-control stream.
  4. The backend validates writability, appends to Autobase, updates the materialized view, and emits protocol events.
  5. The adapter receives events through IPC callbacks or encrypted P2P owner-control events and dispatches normalized Redux events.
  6. 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

  1. Use QR code, terminal pairing code, LAN, Bluetooth, USB, Tailscale/MagicDNS, or CLI/SSH only to establish trust for the first connection.
  2. Create a private owner-control topic between the trusted mobile/desktop app and the headless instance.
  3. Send signed, nonce-protected, capability-scoped configuration commands over encrypted P2P streams instead of requiring a long-lived HTTP server to be reachable.
  4. Keep owner-control separate from list replication topics, relay topics, and storage topics.
  5. Use setup/recovery transports only when pairing is new, broken, or manually being repaired.

Headless first-run setup

  1. The headless app starts on an owned always-on device and asks which capabilities should be enabled.
  2. 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.
  3. The user pairs the headless instance from a trusted app and establishes an encrypted P2P owner-control topic.
  4. 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.
  5. 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

  1. A user joins someone else's list from mobile or desktop using the normal invite flow.
  2. After that device becomes writable or receives the allowed access mode, the app offers to add the user's known headless instances.
  3. The user selects one or more owned devices and chooses an access mode such as writer, storage, or relay.
  4. The joined device creates a short-lived delegated invite scoped to the target owned device and access mode.
  5. The headless instance accepts the delegated invite over its trusted owner-control channel.
  6. 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.
  7. Delegated co-invites inherit lifetime, use-count, expiration, revocation, audit logging, and redaction rules from normal invites.
  8. 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

  1. The developer enables diagnostics sharing for a trusted device group or owner-control relationship.
  2. A mobile, desktop, or headless instance sends requestLogBundle over the trusted debug or owner-control channel.
  3. Peers return bounded, redacted JSONL bundles for the requested time range, levels, and components.
  4. The requester merges rows by timestamp while preserving app, instanceName, and instanceId.
  5. 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#101212canvas
--surface-1#F5F3F3#161919sidebar, panels
--surface-2#EFEDED#1D2121fills, hover, receded rows
--surface-3#E3E2E2#252A2Apressed, selected-mute
--card#FFFFFF#181B1Bitem rows, kv rows
--ink#1B1C1C#F1EFEFprimary text, selection fill
--ink-mute#5D5F5F#B0ABABsecondary text
--ink-faint#7E7576#8A8485metadata, mono labels (AA-large only)
--line#CFC4C5#34393Ahairline of last resort (dense tables only)
--signal#C3F400#C3F400live / active / success
--on-signal#161E00#161E00text on acid
--danger#BA1A1A#FFB4ABdestructive, 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.

ModuleBusPins
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.
descriptionstringRigor-required (short description).
checklist{id,text,done}[]Rigor-required (≥1).
estimatedHoursnumberRigor-required (>0).
estimatedComplexitynumber (1–100)Rigor-required.
prioritylow | medium | high | urgentOptional.
assigneestring (writer-key hex)Optional.
createdBy / completedBystring (writer-key hex)Attribution, stamped write-side (self-asserted, honest-client).
inProgressMsnumberCumulative accumulator.
inProgressSincenumber | nullWall-clock ms of the current in-progress entry.
actualInProgressHoursnumberFrozen at Done.
timelinesson_time | overtime | undertimeFrozen at Done.
blocksBlock[]Block-based body: markdown, image, table, links, checklist, numberedList, callout, code.
kanbanVersionnumber (=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 commitBlocksRPC_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)

  1. domain/kanban.mjs + tests (pure, no UI/backend).
  2. board-config.mjs + tests; protocol constants; apply()/checkpoint/state wiring.
  3. item.mjs add-fields + time/timeliness; backend apply gate; integration tests.
  4. Desktop: icons + i18n; ticket.mjs; app.css; board (read-only) → DnD → detail (split + full) → block editor → rigor dialog → settings/board-config (owner-gated) → congruency.
  5. 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-configboardConfigReceived 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

  1. @listam/domain/list-registry + tests (pure).
  2. Mobile state: boardConfigSlice, registry projection, preferences.defaultListId, _useWorklet decoder + RPC re-exports + tests.
  3. Nav: listNav + tests → useListPagerListSwipePager + PositionIndicatorListsMenuindex.tsx/Header.tsx wiring → ListItem delete flip.
  4. Kanban: port ticket.ts + tests → board (tap columns) → ticket detail + status change → rigor create → congruency → new-list sheet.
  5. i18n nav keys (6 catalogs) + monochrome/green pass; simulator + two-device verification.
listam-kanban-plan.md