Plans - phase log

Implementation Phases

Commit-worthy units

Phase Log

These phases are the pause gates for implementing the multi-app plan. A phase is not necessarily a single commit: small phases land as one commit, larger ones (package extraction, desktop, headless) may span several. After each phase's work is committed and verified, implementation pauses and this page receives a collapsible record for it.

Pause rule: finish one phase, verify it, commit it (one or more commits), update the plan and this wiki page with the commit range, then pause before starting the next phase.
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. Each phase lists its dependencies explicitly.
Required in each committed phase record Why it exists
All files modified, with reasons Shows the implementation footprint and why each file changed.
All functions created, updated, or deleted, with reasons Captures behavior-level changes that are easy to miss in file summaries.
General summary of decisions and actions Records tradeoffs, implementation choices, verification, and follow-up risk.
Commit range, commit message(s), and verification commands Connects the wiki record to the exact code state that shipped for the phase. A phase may span several commits; record the full range.
Dependencies satisfied and acceptance signal met Confirms the phase's prerequisites were in place and its acceptance criteria passed before the pause.
Phase record template

Files modified

  • path/to/file: reason for modification

Functions created / updated / deleted

  • functionName: created / updated / deleted - reason for action

Implementation summary

Decisions, actions, verification, follow-up risks, commit range (one or more hashes), commit message(s), dependencies satisfied, and acceptance signal met.

Phase 0 - Test, CI, and repo hygiene bootstrap (listam-mobile commit ef7b181)

Commit boundary: add a reproducible test runner, commit/un-gitignore the lockfile, a dependency-hygiene check that fails on undeclared runtime imports, a secret/log grep gate, a no-console lint rule for production code, and CI entry points; remove committed runtime logs and ignore generated key/invite files. No product behavior change.

Depends on: none. Unblocks: all.

Acceptance: CI installs from the lockfile and runs tests, lint, and the grep gate green; no committed logs or secret-shaped strings remain.

Pause gate: record the committed files/functions and wait before security fixes.

Files modified

  • .gitignore: un-ignore package-lock.json; add .DS_Store, __pycache__/, *.pyc.
  • package.json: add test, lint, check:deps, check:secrets, and aggregate ci scripts; declare undeclared runtime deps (react-native-svg, qrcode-terminal, @expo/vector-icons, bare-path, bare-url); add dev deps eslint, typescript-eslint.
  • package-lock.json (added): committed for reproducible npm ci (resolved with --legacy-peer-deps for a pre-existing react-native-screens RN-peer conflict).
  • eslint.config.mjs (added): no-console for production code, legacy files ratcheted to warn.
  • scripts/check-deps.mjs (added): dependency-hygiene gate.
  • scripts/check-secrets.mjs (added): secret/log grep gate.
  • .github/workflows/ci.yml (added): install + lint + check:deps + check:secrets + test.
  • backend/lib/key.mjs, backend/backend.mjs, backend/lib/network.mjs, app/hooks/_useWorklet.ts: redact raw key/invite/writer/topic material from logs.
  • Untracked from the index: .DS_Store files and scripts/__pycache__/*.pyc.

Functions created / updated / deleted

  • walk, packageName (check-deps.mjs): created.
  • saveAutobaseKey, loadAutobaseKey, saveLocalWriterKey, loadLocalWriterKey (key.mjs): updated — stop logging raw key hex.
  • RPC_JOIN_KEY / add-writer handlers (backend.mjs) and initAutobase / joinViaInvite logging (network.mjs): updated — drop raw secret values from logs.
  • None deleted.

Implementation summary

The repo could not npm install cleanly (no lockfile masked a react-native-screens peer conflict); a committed --legacy-peer-deps lockfile plus npm ci --legacy-peer-deps in CI is the fix. no-console is a ratchet (new code errors, ~148 legacy calls warn, tracked for the Phase 10 logger migration). typecheck is intentionally not in the required gate because of pre-existing type errors (generated itemIconMap.ts duplicate keys; an IPC type mismatch) that are out of Phase 0 scope. Leftover UI changes were preserved; only tooling/hygiene and secret-log redaction were touched. Verification: npm ci --legacy-peer-deps installs from the lockfile; npm run ci is green (lint 0 errors, check:deps OK, check:secrets OK, 3 security tests pass, grocery tests pass). Committed on branch phase-0-bootstrap (not pushed).

Phase 1 - Invite safety and deep-link confirmation (listam-mobile commits ee0dd26, a585c4a)

Commit boundary: stop automatic link joins, add pending-invite confirmation, enforce invite expiry/use limits, remove unused plaintext invite persistence, and cover join cancel/confirm/rollback tests.

Depends on: 0. Unblocks: -.

Acceptance: a link cannot switch bases without explicit confirmation; expired or exhausted invites are rejected; cancel leaves the current base untouched.

Pause gate: record the committed files/functions and wait before the secret-storage foundation.

Files modified

  • README.md: documents Phase 1 invite safety behavior for the mobile repository.
  • app/index.tsx: routes manual joins and incoming deep links through explicit confirmation before sending RPC_JOIN_KEY.
  • app/invite-confirmation.ts (added): pure invite normalization, deep-link extraction, pending-confirmation, confirm, and cancel helper logic.
  • app/app.ios.bundle.mjs, app/assets/backend.android.bundle.mjs: regenerated packed Bare backend bundles so runtime artifacts include the backend changes.
  • backend/backend.mjs: renames the plaintext invite path export to a legacy cleanup path and routes backend logging through the redacting logger boundary.
  • backend/lib/network.mjs: reserves invite use before accepting a candidate, rejects stale/expired/exhausted candidates, rotates invites after use/failure, removes legacy invite files, and restores the previous base/list on join failure.
  • backend/lib/invite-policy.mjs, backend/lib/invite-policy.test.mjs: centralize invite TTL/use policy and add reservation coverage.
  • backend/lib/invite-confirmation.test.mjs (added): tests deep-link confirmation, cancel, confirm, invalid, and busy join decisions.
  • backend/lib/join-rollback.mjs, backend/lib/join-rollback.test.mjs (added): testable join failure rollback snapshots and restoration.
  • backend/lib/key.mjs: renames invite cleanup to deleteLegacyInviteFile; key/encryption persistence logs go through the redacting logger.
  • backend/lib/logger.mjs, backend/lib/logger.test.mjs (added): structured backend logger with key-, invite-, byte-, and item-shaped redaction.
  • backend/lib/item.mjs: routes backend item mutation/rebuild logs through the redacting logger and avoids raw item payload logging.
  • eslint.config.mjs: makes backend production code use the logger boundary while allowing the logger itself to own backend console.error.

Functions created / updated / deleted

  • normalizeInvite, extractInviteFromInput, createJoinConfirmationRequest, resolveJoinConfirmation, extractInviteFromUrl: created - isolate and test the pending-invite confirmation contract.
  • AppInner join helpers/effects: updated - use the helper contract so deep links/manual codes cannot start a join until the user confirms.
  • reserveInviteUse: created - atomically consume the one allowed invite use before asynchronous BlindPairing work.
  • isInviteUsable: updated - delegates to the reservation policy for consistent missing/legacy/expired/exhausted handling.
  • createInvite, rotateInviteAndNotifyFrontend, setupBlindPairing, joinViaInvite: updated - enforce one-use/10-minute invites, close rejected candidates, remove the active invite during reserved candidate processing, and restore previous state after failed joins.
  • createJoinRollbackSnapshot, restoreJoinRollbackSnapshot, cloneBuffer: created - make rollback copying and restoration testable without a live swarm.
  • deleteLegacyInviteFile: created/renamed from deleteInvite; deleteInvite deleted.
  • redactForLog, redactString, parseLogArgs, logger.log, logger.info, logger.warn, logger.error, isBytes, isListItemShape: created - centralize structured backend log redaction.
  • open, apply, addItem, updateItem, deleteItem, syncListToFrontend, rebuildListFromPersistedOps, and key persistence helpers: updated - use the logger boundary and stop logging raw list/key-shaped values.

Implementation summary

Deep links and pasted invite codes now create a pending confirmation instead of immediately joining. Cancel clears only the pending invite; confirm sends the join RPC only if the same invite is still pending. Host invites remain in memory, are single-use, expire after 10 minutes, and are reserved before async candidate acceptance so a second candidate cannot reuse the same invite while the first is being processed. The old lista-invite.json/invite.json path is retained only as a legacy cleanup target; no invite material is written there. Join failure snapshots the previous visible list and base/encryption keys, then restores them through a tested rollback helper. The backend logger boundary was included because the implementation worktree already depended on it; shared-package logger extraction remains Phase 10. Commit range: ef7b181..ee0dd26 (ee0dd26 - Phase 1: invite safety and deep-link confirmation) on listam-mobile branch phase-0-bootstrap (not pushed). Verification: npm run test:security (16 tests pass); npm run ci green (lint 0 errors / 24 grandfathered app console warnings, check:deps OK, check:secrets OK, security tests pass, grocery tests pass); npm run typecheck still fails only on the pre-existing generated itemIconMap.ts duplicate keys and _useWorklet.ts IPC type mismatch; npm run bundle:backend:ios; npm run bundle:backend:android; git diff --check.

Follow-up hardening (commit a585c4a)

A frontend/TS-only follow-up that hardens the invite-safety work without touching the backend or the native Bare bundles, so no runtime artifacts changed. app/invite-confirmation.ts adds parseInviteLink (strict scheme/host parse of incoming deep links) and planIncomingLinkJoin (pure link → confirmation decision), plus a confirmation-open status so a different invite arriving while a dialog is open is suppressed instead of stacking; the confirmation dialog now names the invite source and warns when a link is not from listam.ch/listam://. app/index.tsx extracts presentJoinConfirmation so manual codes and deep links share one thin caller, routes the deep-link effect through planIncomingLinkJoin, and removes the two console.log calls Phase 1 had added (no-console warnings 24 → 22). backend/lib/invite-confirmation.test.mjs closes the deep-link wiring test gap, adding coverage for the H2 acceptance cases that previously had none (cold-start link, foreground link, duplicate-link suppression, non-invite URL ignored, untrusted-host warning, busy-while-joining) plus parseInviteLink trust/host assertions. Verification: npm run ci green (lint 0 errors / 22 grandfathered app console warnings, check:deps OK, check:secrets OK, 21 tests pass — up from 16 — grocery tests pass); npx tsc --noEmit still fails only on the pre-existing itemIconMap.ts duplicate keys and _useWorklet.ts IPC mismatch. Commit a585c4a on listam-mobile branch phase-0-bootstrap (not pushed); full Phase 1 range ef7b181..a585c4a.

Follow-up risks: invite capability remains writer access until Phase 3 membership authority and Phase 4 re-keying. Deferred by decision: user-facing invite revoke/rotate controls and a live lifetime/use-count display (H3's full UI scope) move to a dedicated invite-lifecycle phase rather than expanding Phase 1, and the auto-rotation behavior (a fresh single-use invite minted after each accept) is kept as-is. App-side console warnings remain in _useWorklet.ts and useSubscription.ts; generated backend bundles are still committed runtime artifacts from ee0dd26; pre-existing typecheck failures remain.

Phase 2 - Secret-storage foundation, key material (commits: pending)

Commit boundary: introduce the platform secure-storage boundary in mobile (later extracted to @listam/secrets), migrate existing plaintext key material into secure storage, store fingerprints only in metadata/logs, pass secrets to the backend through the platform adapter, and provide a recovery path when secure storage is unavailable.

Why first: Phases 3-4 mint new long-lived key material (owner authority key, per-epoch encryption keys) that must be born in secure storage, not retrofitted.

Depends on: 0. Unblocks: 3, 4, 10.

Acceptance: no plaintext key files remain after migration; the backend boots from adapter-passed secrets; migration is idempotent and re-readable.

Pause gate: record the committed files/functions and wait before membership authority.

Files modified

  • package.json, package-lock.json: add the Expo SDK-matched expo-secure-store dependency.
  • app/secret-storage-core.ts: adds the testable secure-storage migration core, legacy key-file mapping, metadata fingerprints, idempotent migration, and recovery-mode behavior.
  • app/secrets.ts: bridges the core to expo-secure-store, AsyncStorage metadata, and Expo legacy file reads/deletes.
  • app/hooks/_useWorklet.ts: prepares backend boot secrets before starting Bare, passes them through argv, and handles backend secret-persistence RPCs without logging secret payloads.
  • rpc-commands.mjs: adds RPC_PERSIST_SECRET.
  • backend/lib/secrets.mjs: adds backend boot-payload parsing, secret fingerprints, and RPC requests for secure persistence/deletion.
  • backend/lib/key.mjs: replaces plaintext key-file save/load with secure-adapter save/load helpers while keeping legacy invite-file deletion.
  • backend/backend.mjs: loads the Autobase and encryption keys from adapter-passed boot secrets.
  • backend/lib/network.mjs: persists guest secrets once writable and clears secure secrets during corruption recovery.
  • backend/lib/secret-storage.test.mjs: covers migration, idempotent rereads, recovery mode, and backend persistence/delete requests.
  • app/app.ios.bundle.mjs, app/assets/backend.android.bundle.mjs: regenerated Bare backend bundles.

Functions created / updated / deleted

  • Created: prepareBackendSecrets, persistBackendSecretRequest, secretStoreKey, normalizeSecretValue, and secretFingerprint in app/secret-storage-core.ts.
  • Created: createLegacySecretFileAdapter, prepareBackendSecretPayload, and persistBackendSecretFromPayload in app/secrets.ts.
  • Created: parseBootSecretPayload, getBootSecretBuffer, persistBackendSecret, deleteBackendSecret, and secretFingerprint in backend/lib/secrets.mjs.
  • Updated: useWorklet/startWorklet, loadAutobaseKey, saveAutobaseKey, loadEncryptionKey, saveEncryptionKey, loadLocalWriterKey, saveLocalWriterKey, initAutobase, and waitForWritable.
  • Deleted: no public functions removed.

Implementation summary

Mobile now migrates legacy plaintext key files into platform secure storage before the Bare backend starts. The backend receives only an adapter-built boot payload, persists new key material back through RPC_PERSIST_SECRET, and logs/stores only short fingerprints in metadata. If secure storage is unavailable, migration leaves plaintext files in place for recovery and boots from an adapter-passed recovery payload; generated secrets are cached only for the current app session until secure storage becomes available.

Verification: npm run bundle:backend:ios; npm run bundle:backend:android; npm run ci green with the existing 22 no-console warnings. npm run typecheck still fails only on the pre-existing generated itemIconMap.ts duplicate keys and the existing _useWorklet.ts BareKit IPC type mismatch.

Phase 3 - Membership authority and honest revocation language (commit: c48aab6)

Commit boundary: rename revoke semantics to "revoke invite," introduce owner-signed membership records, reject non-owner writer additions, store the owner authority key via the Phase 2 secrets boundary, and document that true member removal requires re-keying.

Implementation commit: c48aab6 (Phase 3: add owner membership authority) on listam-mobile branch phase-0-bootstrap (not pushed).

Depends on: 2. Unblocks: 4.

Acceptance: a non-owner writer cannot add another writer; legacy single-user bases migrate once without losing the owner device; malformed, unsigned, or replayed membership ops are rejected.

Pause gate: record the committed files/functions and wait before key epochs.

Files modified

  • app/secret-storage-core.ts, app/hooks/_useWorklet.ts: extended the secure-storage boot/persist boundary to carry ownerAuthorityKey, acknowledge durable secret writes, and clear stale invite keys.
  • app/index.tsx, app/invite-confirmation.ts, app/components/Header.tsx: changed user-facing language from member revocation to invite revocation and kept non-owner devices ready without an invite key.
  • backend/lib/membership.mjs: added - owner authority keypair normalization, signed membership record creation, signature verification, replay-aware membership reduction, invite-owner checks, and reduceMembershipLog (rebuild state from the persisted record log).
  • backend/lib/membership.test.mjs: added - covers legacy owner bootstrap, owner-signed writer addition, non-owner/cross-base rejection, malformed/unsigned/tampered records, replay rejection, restart durability (state rebuilt from the log, sequence high-water mark survives, reused-sequence records dropped on full replay, duplicate bootstraps ignored), and that rejected ops add no writer.
  • backend/lib/item.mjs: rebuildListFromPersistedOps skips membership view entries; added readPersistedMembershipRecords to read the membership records apply() persisted into the view.
  • backend/lib/secrets.mjs, backend/lib/key.mjs, backend/lib/state.mjs, backend/backend.mjs: load, persist, redact, and keep current-base owner authority state through the Phase 2 adapter; apply() now persists accepted membership records into the linearized view so the reduced state survives restart.
  • backend/lib/network.mjs: bootstraps owner membership for new/legacy local bases, requires owner authority before creating or accepting invites, appends owner-signed membership records, clears owner authority on successful non-owner joins, restores it on rollback, and rebuilds membership state from the persisted log on init before bootstrapping.
  • backend/lib/join-rollback.mjs, backend/lib/join-rollback.test.mjs, backend/lib/secret-storage.test.mjs, backend/lib/logger.mjs: added owner authority rollback, validation, and redaction coverage.
  • Generated Bare bundles: app/app.ios.bundle.mjs, app/assets/backend.android.bundle.mjs.

Functions created / updated / deleted

  • Created: createMembershipState, cloneMembershipState, createOwnerAuthorityKeyPair, ownerAuthorityPublicKeyHex, ownerAuthoritySecretKeyHex, ownerAuthorityMatchesState, canCreateMembershipInvite, nextMembershipSequence, createOwnerBootstrapRecord, createAddWriterMembershipRecord, createSignedMembershipRecord, isMembershipRecord, and reduceMembershipOperation.
  • Created: saveOwnerAuthorityKey, loadOwnerAuthorityKey, deleteOwnerAuthorityKey, ensureOwnerMembership, sendInviteKeyToFrontend, reduceMembershipLog, and readPersistedMembershipRecords.
  • Updated: parseBootSecretPayload, getBootSecretBuffer, persistBackendSecret, normalizeSecretValue, prepareBackendSecrets, persistBackendSecretRequest, secretStoreKey, initAutobase, setupBlindPairing, createInvite, rotateInviteAndNotifyFrontend, joinViaInvite, apply, createJoinRollbackSnapshot, and restoreJoinRollbackSnapshot.
  • Deleted: no public functions removed; legacy unsigned add-writer handling is now rejected instead of applied.

Implementation summary

Mobile now treats membership as a project/base-level owner authority. Fresh and legacy single-user local bases generate an owner authority keypair through the Phase 2 secret boundary and append one signed membership bootstrap record for the existing owner device. Invite acceptance now appends an owner-signed membership record bound to the current base; apply() only adds writers when the record verifies and advances the membership sequence.

Non-owner writers cannot create or accept usable invites because they do not hold the owner authority secret. Joining another base clears the previous owner authority key, while join rollback restores it. Legacy unsigned add-writer ops, malformed records, unsigned/tampered records, wrong-base records, wrong-owner records, and replayed sequence numbers are rejected. UI/share copy now says invites can be revoked before use and that removing a joined device requires Phase 4 re-keying.

Post-review hardening (membership state durability). The first cut held membership state only in a module global that was reset on every initAutobase and never written to the persisted view. Because Autobase (v7) does not re-run apply over already-applied history on reopen, that state was lost on restart: the owner re-bootstrapped on every launch and the sequence counter reset to 1, so the next writer added after a restart reused a sequence number and was rejected as a replay by any peer replaying the full log — a writer-set divergence. Fix: apply() now persists each accepted membership record into the linearized view, readPersistedMembershipRecords + reduceMembershipLog rebuild membership state from that durable log on init (before bootstrap), and ensureOwnerMembership therefore bootstraps exactly once — satisfying the "migrate once" acceptance criterion the in-memory version did not.

Deviations / deferred (recorded during review): legacy unsigned add-writer is rejected outright rather than accepted during a migration window (stricter; only single-user prototype bases exist today); owner-key loss recovery is deferred to Phase 4's re-key work and called out explicitly rather than left silent; and join remains a destructive, irreversible base switch (the previous base's owner key is deleted on success), so the join confirmation copy now warns that joining gives up ownership of the current list on that device.

Verification: npm run bundle:backend:ios; npm run bundle:backend:android; npm run ci (green: lint 0 errors / existing 22 app console warnings, check:deps OK, check:secrets OK, 36 security tests pass, grocery tests pass); git diff --check. npm run typecheck still fails only on the pre-existing generated itemIconMap.ts duplicate keys and _useWorklet.ts BareKit IPC type mismatch.

Phase 4 - Key epochs and member-removal re-key flow (listam-mobile commits 9b43c23, 8782b7c)

Commit boundary: add app-level membership epochs, encryption-key rotation/re-encryption with epoch keys stored via the secrets boundary, old-epoch retirement, member-removal audit events, and rollback tests for interrupted re-key.

Depends on: 3. Unblocks: -. (End of the membership-crypto track.)

Acceptance: a removed device can no longer decrypt or append in the new epoch; an interrupted re-key restores the prior epoch and never leaves the base unreadable.

Pause gate: record the committed files/functions and wait before the desktop/headless build.

Design: an app-level epoch encryption layer (not Autobase re-keying)

Because the Autobase encryption key cannot be rotated in place (C2), this phase adds an app-level epoch key that encrypts each list operation (XChaCha20-Poly1305) inside the Autobase encryption. Removing a member rotates to a new epoch key, distributes it to the remaining writers as sealed per-writer grants, and re-encrypts the current list as a snapshot under the new key. The removed device keeps the base key (it can still replicate and read history + membership metadata) but never receives the new epoch key, and Autobase removeWriter drops its append capability — so the honest boundary is forward-secrecy of content plus loss of write access, not total eviction.

Files modified / added

  • backend/lib/key-epochs.mjs (added): epoch key generation/hashing, sealed per-writer epoch-key grants, and authenticated epoch-list-op encrypt/decrypt with the epoch number bound as AAD.
  • backend/lib/membership.mjs: owner-signed remove-writer records; epoch advance; removed-writer / stale-epoch / missing-grant / replay rejection; buildMembershipRoster.
  • backend/lib/rekey.mjs (added): performMemberRemovalRekey — re-key orchestration serialized through the list write chain, with pre-commit rollback and a bounded post-commit snapshot retry (degraded success, not silent failure).
  • backend/lib/writer-removal.mjs (added): removeWriterAtConsensus — surfaces Autobase removal failures (unsupported / not-removable / error) instead of swallowing them.
  • backend/lib/owner-recovery.mjs (added): owner-key-loss recovery (deferred from Phase 3). The code is the owner ed25519 seed, verified against the base-recorded owner key; works for pre-Phase-4 bases.
  • backend/lib/item.mjs: list appends epoch-encrypt via prepareListAppendOperation; enqueueWrite exported for re-key serialization; snapshot (list) ops handled on rebuild.
  • backend/lib/network.mjs, backend/backend.mjs, key.mjs, secrets.mjs, state.mjs, logger.mjs: RPC wiring (members/recovery), roster + recovery broadcasts, epoch-secret persistence/rollback, join handshake carries epoch material, recovery-code redaction.
  • app/components/MembersDialog.tsx (added) + Header.tsx, index.tsx, hooks/_useWorklet.ts: a Members screen to view members, remove a member (owner only, confirmed), reveal the owner recovery code, and restore ownership on a device that lost it.
  • Generated Bare bundles regenerated.

Deviations and deferred items

  • Epoch layer, not base re-keying: a removed member keeps the base key and can still replicate/read history + membership metadata; confidentiality is forward-only.
  • Integrity depends on Autobase removeWriter: its failure is now surfaced loudly (and the frontend warned) rather than swallowed.
  • Post-commit snapshot failure is a degraded success: retried and reported, not rolled back; writers joining after such a re-key may need a manual sync.
  • Owner recovery code is a bearer secret: it is the owner seed, shown for offline backup only and redacted from logs.

Verification

npm run bundle:backend:ios; npm run bundle:backend:android; npm run ci (green: lint 0 errors / 22 pre-existing app console warnings, check:deps OK, check:secrets OK, 62 security tests pass, grocery tests pass). npm run typecheck still fails only on the pre-existing generated itemIconMap.ts duplicate keys and the _useWorklet.ts BareKit IPC type mismatch; the new UI/bridge code adds no type errors. New suites: key-epochs.test.mjs, rekey.test.mjs, owner-recovery.test.mjs, writer-removal.test.mjs, plus member-removal reducer and roster tests in membership.test.mjs.

Phase 5 - Stable item IDs and backend reduction migration (listam-mobile commit 9392cfa)

Commit boundary: version list operations, backfill ids for legacy text-only entries, reduce by id when present, keep legacy compatibility, and prove duplicate-name convergence.

Depends on: 0 (independent of the 2-4 membership-crypto track; may run in parallel). Unblocks: 6.

Acceptance: the backend view and Redux projection agree on duplicate names; legacy text-only lists migrate without losing done state or order.

Pause gate: record the committed files/functions and wait before the Redux migration.

Implementation commit: 9392cfa (Phase 5: stable item IDs and id-keyed reduction migration) on listam-mobile branch main (not pushed).

Design: id-keyed reduction over a versioned op log, with one shared identity module

Operations gained a version (1), a listId (default default), and a list type (default shopping). Items reduce by a stable id: an explicit id/itemId when present, otherwise a backfilled legacy-<fnv1a(listId\0text)> derived from legacy text-only entries. The view is a Map keyed by ${listId}\0${id}, partitioned by listId (N=1 today), so two items that share a name no longer collapse into one — the duplicate-name bug of the old text-keyed reduction. Identity, normalization, and last-write-wins live in one pure module, list-identity.mjs, imported by both the backend reducer and the UI projection, so the acceptance ("the view and the projection agree on duplicate names") holds by construction rather than by two implementations happening to match.

Files modified / added

  • list-identity.mjs (added, repo root beside rpc-commands.mjs): the shared source of truth — id/list normalization, legacyItemId, identityKey, isStaleUpdate, and the array projection (upsert/update/deleteListEntry). Pure JS, so it bundles for both the worklet and the UI.
  • backend/lib/list-reducer.mjs (added): the op-version schema and the Map-based id-keyed reduction (createListViewEntry, reduceListViewEntries, applyOperationToList); delegates all identity to list-identity.mjs.
  • backend/lib/list-reducer.test.mjs (added): versioning, legacy-id backfill, duplicate-name convergence, mixed legacy/new replay, listId partitioning, stale-update last-write-wins.
  • backend/lib/list-projection-parity.test.mjs (added): drives one op sequence through the backend reduction and the UI projection and asserts identical id/order/text/done — a guard against drift.
  • app/listProjection.ts: reduced to a thin typed wrapper over list-identity.mjs; also consumed by the Phase 6 listsSlice.
  • backend/backend.mjs: apply normalizes ops, appends versioned view entries, and updates the in-memory list via applyOperationToList; RPC_ADD accepts a bare string (legacy) or { text, listId, listType }.
  • backend/lib/item.mjs: addItem(text, listId, listType) defaults to the implicit shopping list; ops are versioned; validateItem delegates to normalizeListItem; rebuildListFromPersistedOps replays the whole log through reduceListViewEntries.
  • backend/lib/network.mjs: seed in-memory currentList from the rebuilt/replicated list on init and on first writable sync.
  • backend/lib/rekey.mjs + rekey.test.mjs: the member-removal epoch snapshot is now a versioned createListOperation('list', …).
  • app/components/_types.ts: ListEntry gains optional id/itemId/listId/listType/updatedAt/timestamp/author.
  • app/components/VisualGridList.tsx, intertial_scroll.tsx: React list keys use the stable identityKey instead of text+timeOfCompletion.
  • app/listEntry.json (removed): dead/unreferenced schema whose required set had drifted from reality; real validation is validateItem/normalizeListItem.

Deviations and deferred items

  • One shared module instead of two copies: the backend reducer and the UI projection both import list-identity.mjs, so view/projection agreement is structural and parity-tested. Full @listam/domain extraction remains Phase 7.
  • updatedAt is now load-bearing, but not a field-level merge: updates resolve by last-write-wins on updatedAt (a stale edit cannot revert a newer item); concurrent edits to different fields still clobber whole-object. CRDT-style field merge is future work.
  • Rename became an in-place update: it preserves the stable id (convergence holds), done state, and position instead of delete+add; a not-yet-migrated peer keying by text treats it as a new entry until it updates.
  • The UI data-flow lands with Phase 6: the Redux migration replaced the Phase 5 index.tsx/_useWorklet.ts wiring, so this commit carries the backend reduction, the shared module, the component key fixes, and the type/schema changes; the id-keyed reduction's UI consumption and the rename change ride with the Phase 6 commit. The pause gate is otherwise honored — Phase 5 is its own commit.

Verification

node --test backend/lib/*.test.mjs73 pass (was 68; +1 stale-update reducer test, +4 projection-parity tests). npx tsc --noEmit reports only the pre-existing generated itemIconMap.ts duplicate-key errors; the Phase 5 files add no type errors. The Bare bundles were not regenerated and the full npm run ci was not re-run here because the frontend is mid-Phase-6.

Phase 6 - Redux Toolkit migration in mobile, in place (commits: pending)

Commit boundary: add the Redux Toolkit store and move list, sync, preferences, locale choice, loyalty-card metadata handles, and owned-devices state into slices normalized by id; replace direct useState ownership of replicated list data with selectors and actions; keep existing mobile behavior intact. No package extraction yet.

Depends on: 5. Unblocks: 7.

Acceptance: mobile parity is unchanged with replicated state owned by Redux; the loyaltyCards slice holds only non-secret handles, never barcode/QR payloads.

Pause gate: record the committed files/functions and wait before package extraction.

Files modified

  • listam-mobile/package.json, package-lock.json: added Redux Toolkit and React-Redux runtime dependencies.
  • listam-mobile/app/store/: added the app-local Redux store, typed hooks, and normalized lists, sync, preferences, loyaltyCards, and devices slices.
  • listam-mobile/app/hooks/_useWorklet.ts: routes backend RPC events into Redux selectors/actions instead of hook-local list/sync/member state.
  • listam-mobile/app/index.tsx: wraps the app in the Redux provider; migrates preferences, replicated list mutations, and loyalty-card handle hydration to Redux.
  • listam-mobile/app/components/Header.tsx, MembersDialog.tsx: consume normalized card/device types from the store.

Functions created / updated / deleted

  • Created store setup and typed hooks: store, useAppDispatch, useAppSelector.
  • Created list library actions/selectors: selectedListItemsSynced, selectedListItemsReplaced, listItemAdded, listItemUpdated, listItemDeleted, selectSelectedListItems, selectListLibrary.
  • Created sync/preferences/device/card reducers and selectors, including selectSyncState, selectPreferences, selectMembershipRoster, selectLoyaltyCardHandles, and toLoyaltyCardHandle.
  • Updated useWorklet, AppInner, Header, and MembersDialog; added parseStoredLoyaltyCards, indexLoyaltyCardPayloads, and serializeLoyaltyCardPayloads.

Implementation summary

Mobile replicated list state now lives in a normalized Redux lists library shaped for one selected project/folder/list today. Worklet sync status and member roster are Redux-owned; UI preferences include the locale-choice slot; and loyalty-card Redux state contains only non-secret handles. Existing barcode/QR payloads remain in the legacy AsyncStorage payload path and in memory only for viewing, pending the Phase 10 secure-storage move.

Verification: npm run lint passed with 22 existing console warnings; npm run check:deps, npm run check:secrets, and npm run test passed. Full npm run typecheck remains blocked by the pre-existing generated itemIconMap.ts duplicate-key errors; filtering those known errors produced no new TypeScript output from this migration.

Phase 7 - Extract pure shared packages (commits: listam-mobile d71ad36)

Commit boundary: extract @listam/domain, @listam/protocol, @listam/grocery, @listam/logging (redaction helpers and log line format), and @listam/secrets (the Phase 2 boundary) as versioned packages; mobile consumes them; behavior intact.

Depends on: 2, 6. Unblocks: 8, 9, 10.

Acceptance: mobile builds against published package versions and the shared suites run in Node.

Pause gate: record the committed files/functions and wait before the backend/client extraction.

Implementation commit: listam-mobile d71ad36.

Files modified

  • listam-mobile/package.json, package-lock.json: added local workspace packages and test:shared.
  • listam-mobile/packages/domain/: list identity, array projection, list operation reduction, declarations, and shared tests.
  • listam-mobile/packages/protocol/: RPC command ids.
  • listam-mobile/packages/grocery/: grocery text/category logic, generated translation data, grouping, declarations, and shared tests.
  • listam-mobile/packages/logging/: redaction helpers, structured log formatting, logger factory, declarations, and shared tests.
  • listam-mobile/packages/secrets/: secret validation, fingerprints, boot/persistence payload helpers, adapter-neutral secret migration/persistence, declarations, and shared tests.
  • Mobile app/backend wrappers now consume package APIs; React Native asset maps and backend RPC persistence remain app/backend-local.
  • Grocery generators/tests and dependency scanning are package-aware; iOS and Android backend bundles were regenerated.

Functions created / updated / deleted

  • Created package exports for list identity/projection/reduction: normalizeListId, normalizeItemId, identityKey, upsertListEntry, createListOperation, reduceListOperations, and related helpers.
  • Created package exports for RPC command constants.
  • Created package exports for grocery lookup/grouping: normalizeGroceryText, getCategoryForItem, detectDominantLanguage, and groupByCategory.
  • Created package exports for logging: redactForLog, redactString, parseLogArgs, formatLogLine, createLogger, and logger.
  • Created package exports for secrets: normalizeSecretValue, secretFingerprint, parseBackendSecretPayload, parseSecretAck, prepareBackendSecrets, and persistBackendSecretRequest.
  • Updated app/backend wrappers and tests to import package APIs instead of app-local copies.

Implementation summary

Pure shared behavior is now packaged under versioned local packages (0.7.0) and consumed by mobile, backend tests, and bundling through normal package imports. The old root/backend shared modules are thin compatibility adapters. Platform-specific work stays outside the packages: backend RPC persistence remains in backend/lib/secrets.mjs, and React Native image require() maps remain in app/components/itemIconMap.ts. Grocery generated data now lives in @listam/grocery, and the generators write package ESM outputs.

Verification: npm run check:deps, npm run check:secrets, npm run lint, and npm test passed. npm run bundle:backend:ios and npm run bundle:backend:android passed and regenerated both backend bundles. Full npm run typecheck remains blocked only by the pre-existing generated itemIconMap.ts duplicate-key errors.

Phase 8 - Extract backend/client with platform adapter (commits: listam-mobile d71ad36)

Commit boundary: extract @listam/backend and @listam/client and decouple the backend from BareKit globals (BareKit.IPC, Bare.argv, Bare.on('teardown')) behind a platform-services adapter so it runs under the mobile worklet and Node; keep per-platform bare-pack bundling an app-level step.

Depends on: 7. Unblocks: 11, 12, 13.

Acceptance: the backend runs under Node with no BareKit globals, and the same @listam/client contract suite passes on both the worklet and Node adapters.

Pause gate: record the committed files/functions and wait before durability changes.

Implementation commit: listam-mobile d71ad36.

Files modified

  • listam-mobile/package.json, package-lock.json: added local workspace dependencies for @listam/backend and @listam/client.
  • listam-mobile/packages/backend/: extracted backend startup/runtime code, package metadata, BareKit and Node platform adapters, filesystem adapter wiring, and Node smoke tests.
  • listam-mobile/backend/backend.mjs: reduced to the app-level BareKit boot shim; per-platform bare-pack bundling still starts here.
  • listam-mobile/packages/client/: added shared RPC event decoding, worklet/Node adapter fixtures, declarations, and contract tests.
  • listam-mobile/app/hooks/_useWorklet.ts: now consumes decodeBackendRequest while preserving Redux updates, haptics, and notifications.
  • iOS and Android backend bundle outputs were regenerated from the app-level entry.

Functions created / updated / deleted

  • Created createBackendPaths, startBackend, shutdownBackend, handleFrontendRequest, and reconcileLegacyKeyFiles in @listam/backend.
  • Created createBareKitPlatform, createNodePlatform, and createNodeRpc platform adapters.
  • Created setBackendFs and getBackendFs; updated backend key/network helpers to use the selected platform filesystem.
  • Created decodeBackendRequest, decodeWithClientAdapter, encodePayload, dataToString, nodeClientAdapter, and workletClientAdapter in @listam/client.
  • Updated the worklet RPC callback to switch on typed client events for secret persistence, lifecycle messages, sync, item mutations, resets, and invite keys.

Implementation summary

The backend runtime now lives in @listam/backend behind an explicit startBackend(platform) boundary. BareKit globals are limited to the app shim and @listam/backend/platform/bare-kit; the backend package root imports under plain Node with no Bare or BareKit globals. @listam/client owns frontend/backend event decoding, with the same contract suite run against worklet-shaped byte payloads and Node-shaped payloads.

Verification: npm run test:shared, npm run check:deps, npm run check:secrets, npm run lint, and npm test passed. npm run bundle:backend:ios and npm run bundle:backend:android passed and regenerated both backend bundles. A one-off Node startBackend(createNodePlatform(...)) start/shutdown smoke passed outside the sandbox because Hyperswarm socket binding is blocked inside the sandbox. Full npm run typecheck remains blocked only by the pre-existing generated itemIconMap.ts duplicate-key errors.

Phase 9 - UI internationalization foundation (commits: listam-mobile ba5f150)

Commit boundary: add shared UI i18n infrastructure (@listam/i18n or the equivalent shared module), typed message catalogs, locale detection/override in preferences, fallback behavior, plural/date/number formatting helpers, pseudo-locale utilities, and mobile UI string extraction for the parity surfaces. Keep grocery category/item translations in @listam/grocery, but route them through the same selected-locale resolver.

Depends on: 6, 7. Unblocks: 12, 15.

Acceptance: user-facing mobile UI copy is routed through typed message keys; English plus at least one non-English catalog render; missing keys fail CI; pseudo-locale/long-string checks pass for the main list, invite/join confirmation, settings/preferences, diagnostics, and loyalty-card surfaces.

Phase commit: listam-mobile ba5f150 (Phase 9: add UI i18n foundation).

Pause gate: record the committed files/functions and wait before redaction/durability or desktop implementation.

Files modified

  • listam-mobile/packages/i18n/: new shared @listam/i18n package with English/Spanish catalogs, typed key declarations, fallback locale resolution, grocery-locale resolution, plural/date/number helpers, pseudo/long catalog generators, and package tests.
  • listam-mobile/app/i18n.tsx, app/store/preferencesSlice.ts, app/index.tsx: Redux-backed i18n provider, persisted locale override, locale-choice validation, localized invite/share/delete/snackbar copy, and provider installation.
  • listam-mobile/app/components/{Header,JoinDialog,MembersDialog,JoiningOverlay,AddItemBar,EmptyState,SummaryBar,ListItem,GridCard,VisualGridList,intertial_scroll,LoyaltyCardScanner,LoyaltyCardViewer,Paywall}.tsx: parity-surface UI copy moved behind typed message keys; the drawer now exposes system, English, Spanish, pseudo, and long language choices.
  • listam-mobile/app/hooks/{_useWorklet,useSubscription}.ts: diagnostics, backend status, recovery, member-removal, and subscription notifications route through the active translator.
  • listam-mobile/packages/grocery/{category-grouping.mjs,index.d.ts,grocery.test.mjs} and app/components/categoryGrouping.ts: category names accept the selected grocery locale.
  • listam-mobile/scripts/check-i18n.mjs, package.json, package-lock.json: CI catalog-key guard and workspace package install metadata.

Functions created / updated / deleted

  • Created createI18n, resolveLocale, resolveGroceryLocale, getCatalog, translate, selectPluralMessage, formatNumber, formatDate, pseudoLocalizeText, createPseudoCatalog, createLongStringCatalog, and assertCompleteCatalog in @listam/i18n.
  • Created mobile I18nProvider and useI18n.
  • Updated preference hydration and localeChoiceSet to reject invalid values and fall back to system.
  • Updated createJoinConfirmationRequest and planIncomingLinkJoin to accept localized copy while preserving English defaults for existing tests.
  • Updated list, invite/join, settings/preferences, diagnostics, paywall, and loyalty-card components/hooks to call i18n.t(...) for user-facing copy.
  • Updated groupByCategory to accept an explicit preferred grocery language.

Implementation summary

Phase 9 adds the shared UI internationalization foundation with English, Spanish, pseudo, and long-string rendering paths. Main list, invite/join confirmation, settings/preferences, diagnostics/status, paywall, and loyalty-card surfaces now resolve copy through typed message keys. Grocery category translations remain in @listam/grocery but receive the selected locale from the same resolver. Verification: npm run ci passed. npm run typecheck remains blocked only by the pre-existing generated app/components/itemIconMap.ts duplicate-key errors.

Phase 10 - Loyalty-card secrets and redaction routing (listam-mobile commit c63fd9e)

Commit boundary: move loyalty-card barcode/QR payloads into secure storage via the Phase 2 boundary, keep only non-secret handles in the Redux slice, route all logging through @listam/logging redaction, and add export/diagnostic redaction tests.

Depends on: 2, 6, 7. Unblocks: 11.

Acceptance: Redux state, DevTools-style traces, and exports never contain card payloads or raw secrets; the AsyncStorage-to-secure-storage card migration is idempotent.

Pause gate: record the committed files/functions and wait before durability changes.

Files modified

  • listam-mobile/packages/secrets/index.mjs, index.d.ts, secrets.test.mjs: loyalty-card payload refs, secure-storage helpers, handle-index serialization, legacy AsyncStorage migration, and idempotence tests.
  • listam-mobile/app/secrets.ts, app/secret-storage-core.ts: Expo SecureStore/AsyncStorage adapters and shared boundary re-exports.
  • listam-mobile/app/index.tsx, app/store/loyaltyCardsSlice.ts: handle-only hydration, secure payload save/read/delete, and on-demand viewer payload loading.
  • listam-mobile/packages/logging/index.mjs, index.d.ts, logger.test.mjs, app/logger.ts, app/hooks/_useWorklet.ts, app/hooks/useSubscription.ts, eslint.config.mjs: redacted app logging and export/diagnostic redaction coverage.
  • listam-mobile/packages/i18n/catalogs/en.mjs, catalogs/es.mjs, index.d.ts: localized secure-storage failure messages.

Functions created / updated / deleted

  • Created loyaltyCardPayloadRef, loyaltyCardPayloadStoreKey, normalizeLoyaltyCardPayload, normalizeLoyaltyCardHandle, parseLoyaltyCardPayloadList, parseLoyaltyCardHandleList, serializeLoyaltyCardHandles, prepareLoyaltyCardPayloads, persistLoyaltyCardPayload, readLoyaltyCardPayload, and deleteLoyaltyCardPayload in @listam/secrets.
  • Created mobile wrappers prepareLoyaltyCards, persistLoyaltyCard, readLoyaltyCard, deleteLoyaltyCard, and appLogger.
  • Updated toLoyaltyCardHandle, loyaltyCardsHydrated, loyaltyCardAdded, handleCardScanned, handleSelectCard, and handleDeleteCard so Redux and AsyncStorage handle indexes never carry data.
  • Created redactForExport and redactDiagnosticBundle; updated redactString and redactForLog sensitive-key coverage for loyalty-card barcode/QR payload fields.

Implementation summary

Phase 10 moves loyalty-card barcode/QR payloads out of the Redux/AsyncStorage app state path and into confirmed secure-store records keyed by non-secret payload refs. The legacy @lista_loyalty_cards payload array migrates idempotently into secure storage, leaving only handle metadata in AsyncStorage and Redux. Mobile card viewing fetches the payload only when a handle is selected. App diagnostic logging now routes through @listam/logging, and export/diagnostic redaction tests cover loyalty-card payload fields. Verification: npm run ci passed. npm run typecheck remains blocked only by the pre-existing generated app/components/itemIconMap.ts duplicate-key errors.

Phase commit: listam-mobile c63fd9e (Phase 10: loyalty-card secrets in secure storage and logging redaction routing).

Phase 11 - Recovery, snapshots, and storage durability (listam-mobile commit 7c0fd3a)

Commit boundary: replace destructive auto-wipe recovery with backup/quarantine/owner-confirmed recovery, add materialized-view snapshots/checkpoints that resume the id-keyed reduction, isolate storage roots, and add a real lock/lease with stale-lock recovery.

Depends on: 5 (the checkpoint sits on the id-keyed reduction), 8, 10. Unblocks: 12, 13.

Acceptance: a corrupt store never triggers silent deletion, headless nodes refuse auto-wipe, and the rebuild resumes from a checkpoint with bounded join-poll work.

Pause gate: record the committed files/functions and wait before desktop implementation.

Files modified

  • listam-mobile/packages/backend/lib/recovery.mjs (new): corruption-signature detection, recovery-policy gate, and intact quarantine-by-rename with a redacted-fingerprint manifest.
  • listam-mobile/packages/backend/lib/storage-lease.mjs (new): JSON storage lease with owner/role metadata, TTL expiry, heartbeat renewal, stale-lease recovery, lost-lease detection, and own-lease-only release.
  • listam-mobile/packages/backend/lib/view-checkpoint.mjs (new): materialized-view checkpoint that resumes the id-keyed reduction from the last processed index, with tail verification and full-replay fallback on truncation/reorg.
  • listam-mobile/packages/backend/lib/network.mjs: the corruption auto-wipe is replaced by a pendingRecovery state plus performStorageRecovery (retry / owner-approved quarantine-then-fresh-base); the checkpoint resets on base teardown/switch.
  • listam-mobile/packages/backend/backend.mjs: storage lease replaces the wx lock; storage-namespace roots (lista-<ns> + per-root lease); recovery policy from the platform; RPC_RECOVER_STORAGE handler.
  • listam-mobile/packages/backend/lib/item.mjs, lib/state.mjs: rebuilds route through one shared checkpoint per base; pendingRecovery state.
  • listam-mobile/packages/backend/platform/bare-kit.mjs, platform/node.mjs: recovery policy defaults — mobile UI 'interactive', node hosts 'refuse-destructive' — plus storageNamespace/leaseTtlMs passthrough.
  • listam-mobile/packages/domain/list-reducer.mjs, list-reducer.d.ts: createListReduction() incremental id-keyed reduction.
  • listam-mobile/packages/protocol/index.mjs, index.d.ts: RPC_RECOVER_STORAGE = 18.
  • listam-mobile/app/hooks/_useWorklet.ts: recovery-required prompt (retry / start fresh with second destructive confirmation / cancel) and recovery-complete/failed notifications.
  • listam-mobile/packages/i18n/catalogs/en.mjs, catalogs/es.mjs, index.d.ts: recovery copy.
  • listam-mobile/backend/lib/recovery.test.mjs, storage-lease.test.mjs, view-checkpoint.test.mjs (new): 22 tests.
  • listam-mobile/app/app.ios.bundle.mjs, app/assets/backend.android.bundle.mjs: regenerated Bare backend bundles.

Functions created / updated / deleted

  • Created isCorruptionSignature, describeCorruption, normalizeRecoveryPolicy, planRecoveryAction, and quarantineStorageRoot in recovery.mjs.
  • Created createStorageLease (acquire/renew/release/startHeartbeat/stopHeartbeat/describeOwner/isHeld) in storage-lease.mjs.
  • Created createViewCheckpoint (update/reset) in view-checkpoint.mjs and createListReduction in @listam/domain.
  • Created enterPendingRecovery and performStorageRecovery in network.mjs; updated initAutobase to park on corruption instead of wiping, and to reset the view checkpoint on teardown.
  • Updated rebuildListFromPersistedOps and readPersistedMembershipRecords to share one checkpoint scan; created resetViewCheckpoint; deleted rmrfSafe (the wipe helper) and the raw wx lock code.
  • Updated createBackendPaths (storage namespaces), startBackend (lease acquisition/heartbeat, recovery policy), shutdownBackend (lease release), and handleFrontendRequest (RPC_RECOVER_STORAGE).

Implementation summary

Phase 11 makes storage failures recoverable instead of destructive. A corrupt Autobase root now parks the backend in a pending-recovery state — nothing deleted, key material untouched — and the user chooses retry or an explicitly confirmed fresh start that first quarantines the old root intact (rename plus a manifest with redacted fingerprints only). Node/headless hosts refuse the destructive path entirely. The join-poll rebuild resumes from a materialized-view checkpoint with a one-read tail verification (full replay only on truncation/reorg), the storage root and lease are namespaced per app role, and a crashed instance's stale lease is recovered automatically instead of blocking startup. Verification: npm run ci passed (95 security + 30 shared tests, 22 new); end-to-end node smoke tests covered lease refusal/stale recovery/release, headless reset refusal with storage intact, retry reopening live data, and interactive reset quarantining with a redacted manifest. npm run typecheck remains blocked only by the pre-existing generated itemIconMap.ts errors.

Phase commit: listam-mobile 7c0fd3a (Phase 11: recovery, snapshots, and storage durability).

Phase 12 - Desktop parity surface (listam-desktop commit 1bbdd52, listam-mobile commit 67429e7)

Commit boundary: build the Pear Desktop app around the shared packages, match current mobile parity, implement desktop IPC/client contracts, consume the Phase 9 UI catalogs, and compare UI against listam-desktop/design-guide/. The desktop owner-control client is deferred to Phase 14.

Depends on: 8, 9, 11. Unblocks: 15.

Acceptance: desktop reaches mobile parity, syncs with mobile through invite, uses the shared UI i18n catalogs, and matches the desktop design-guide examples in English and pseudo-localized/long-string modes.

Pause gate: record the committed files/functions and wait before headless implementation.

Files modified

  • listam-desktop/ (new repo, root commit): Pear desktop package.json with file: shared-package deps, index.html, app.css (kinetic-minimalist tokens, no CDN fetches), src/ (main, backend-boot, secret-store, store, ui, i18n, icons, mock-backend), eslint config, README, tests, and the committed design-guide/.
  • listam-mobile/packages/client: createBackendChannel() — the desktop in-process IPC contract decoding events with the same decodeBackendRequest as the worklet adapter, with async reply support for the secret-persistence ack; contract tests extended.
  • listam-mobile/packages/backend: new platform/pear.mjs; platform.bootstrap feeds every Hyperswarm for private-testnet harness runs; invite-epoch codec in key-epochs.mjs; join-flow and apply() fixes described below; bundles regenerated.
  • listam-mobile/packages/i18n: desktop chrome keys (nav, peers, diagnostics, shortcuts) in EN+ES with typed declarations.

Functions created / updated / deleted

  • Created createBackendChannel, createPearPlatform, encodeInviteEpochData/decodeInviteEpochData, resetApplyMembershipCheckpoint, and the desktop app modules (createDesktopStore, mountApp, createFileSecretStore, bootDesktopBackend, createMockBackend, locale/icon helpers).
  • Updated createInvite (embeds the current epoch key as signed invite data; retires invites minted for a rotated epoch), the host pairing confirm (sends additional), joinViaInvite (decodes the epoch from paired.data), removeMemberAndRotateEpoch (rotates the outstanding invite), and apply (membership state derived from the view via a truncation-aware checkpoint).

Implementation summary

Listam Desktop runs the same embedded @listam/backend as mobile (own 'desktop' storage root, lease, interactive recovery) behind the new in-process client channel, reduces state through the shared id-keyed reduction, consumes the Phase 9 catalogs (system/EN/ES plus pseudo and long-string modes), groups by @listam/grocery categories, gates paste-joins behind the H2 explicit confirmation, surfaces the Phase 11 recovery prompt, and is keyboard-first — all on the kinetic-minimalist system, compared against the design-guide examples in the browser preview in English, pseudo-locale, and grid modes.

Building the plan's two-instance harness (child-process backends on a private hyperdht testnet) exposed two real cross-device join bugs in the shipping backend, both fixed and regression-tested here: the epoch key passed as extra BlindPairing confirm fields was silently dropped (every real guest join failed) and now travels as the invite's signed additional data; and apply() kept membership state in memory across linearizer reorgs, rejecting re-run history as replays so joined members never became writable — it now re-derives state from the view. The cross-instance test then passed end to end: real invite pairing, pre-join history decryption, writability via the owner-signed membership flow, host→guest sync, and roster convergence. Verification: desktop npm run ci green (lint + 7 tests incl. the testnet suite); mobile npm run ci green (98 security + 35 shared tests). Follow-ups: device-level mobile↔desktop row stays in Phase 15; full bidirectional steady-state assertions behind LISTAM_SYNC_FULL=1 (main-swarm reconnection between two local processes is environment-flaky); desktop secrets in an owner-only file store pending OS-keychain work; pear run GUI verification pending; shared packages still consumed from listam-mobile/packages/* pending the listam-shared extraction.

Phase commits: listam-desktop 1bbdd52 (Phase 12: Pear Desktop parity surface) and listam-mobile 67429e7 (Phase 12 (shared packages): desktop client channel, Pear platform, and two cross-device join fixes).

Phase 13 - Headless service and CLI parity (listam-headless commit d74bfdc, listam-mobile commit c626a40, listam-desktop commit 2673a0b)

Commit boundary: build the Pear Terminal/Bare headless app as a long-lived owned peer, persist the data model, add CLI setup/status/invite/join/export/shutdown commands, enforce blind-helper credential boundaries (a blind helper never receives the encryption key), and add resource quotas on queues and storage. The owner-control protocol is Phase 14.

Depends on: 8, 11. Unblocks: 14, 15.

Acceptance: a blind-storage instance replicates but cannot decrypt view.get(); a restart preserves identity, storage, and status.

Pause gate: record the committed files/functions and wait before the owner-control protocol.

Files modified

  • listam-headless/ (new repo): headless.mjs CLI (setup/run/status with --storage/--bootstrap/--role/--base-key/--max-storage-bytes; run exits on stdin EOF), src/service.mjs (participant role over the shared backend with the headless namespace, lease, refuse-destructive recovery, file-store identity, and the plan's scriptable harness primitives incl. export/import), src/blind.mjs (blind-storage role: Corestore + Hyperswarm only, public-key pins, full live download, own lease, pin/peek diagnostics), src/config.mjs, src/status.mjs, src/quota.mjs, tests, README.
  • listam-mobile/packages/secrets: createFileSecretStore promoted from the desktop app (shared dev/file tier pending OS keychain/keyring).
  • listam-mobile/packages/backend/platform/node.mjs: options.bootstrap passthrough — previously dropped silently, so constructor-passed bootstraps landed on the public DHT instead of the private testnet; bundles regenerated.
  • listam-desktop/src/secret-store.mjs: re-exports the shared store.

Functions created / updated / deleted

  • Created startHeadlessService, startBlindHelper, buildConfig/loadConfig/saveConfig/parseBootstrap/normalizeBaseKeyHex, writeStatus/readStatus, directorySizeBytes/createQuotaMonitor, the CLI dispatcher, and test helpers runHeadless/runOneShot.
  • Created createFileSecretStore in @listam/secrets; updated createNodePlatform (bootstrap passthrough).

Implementation summary

The headless personal server ships in the two honest capability tiers from the C2 re-scope. A participant is a trusted full member: the same backend, durable identity across restarts, and the scriptable command surface the interaction matrix drives. A blind-storage helper holds ciphertext only — it pins cores by public key, replicates and serves blocks it cannot read, and structurally cannot be handed the encryption key (no Autobase, no secret store in that role). Status snapshots expose peer count, join state, base identity fingerprints, item counts, and quota usage; storage quotas make an over-quota helper leave its topics without ever deleting data (queue quotas belong to the deferred async-message role).

Acceptance is test-proven on a private hyperdht testnet with child-process instances: the C2 test shows a blind helper syncing the owner's appends (including post-sync appends) with every stored block opaque and no key material ever present on the helper; the restart test shows the same base-identity fingerprint, item ids, and a live status file across stop/start, with the Phase 11 lease refusing a second instance. Export/import round-trips stable ids, edits, and done state into a fresh base. Verification: headless npm run ci green (lint + 11 tests); mobile green (98 security + 35 shared); desktop green (7). Follow-ups: Pear Terminal/Bare packaging on the same platform seam; blind pins cover the bootstrap core until the Phase 14 owner-control channel hands over the full core set and base-key sharing; exports are plaintext behind an explicit operator action; soak beyond test scale is documented, not CI-bound.

Phase commits: listam-headless d74bfdc, listam-mobile c626a40, listam-desktop 2673a0b.

Phase 14 - Owner-control protocol (listam-mobile commit b9917ac, listam-headless commit ccdc099, listam-desktop commit 4fbe47b)

Commit boundary: establish per-device key pairs at pairing, require every command to carry a nonce/timestamp and signature, model capabilities as separate grants (status:read, diagnostics:read, topics:configure, invite:create, export:create, import:apply, service:shutdown), add device revocation and key rotation, and build the desktop and mobile owner-control clients.

Depends on: 13. Unblocks: 15.

Acceptance: headless refuses unsigned, replayed, expired, or out-of-scope commands, and a diagnostics-only client cannot shutdown, import, export, or configure topics.

Pause gate: record the committed files/functions and wait before full cross-app acceptance.

Files modified

  • listam-mobile/packages/owner-control/ (new shared package): signed envelopes (command id, device id, scope, timestamp, per-device monotonic seq, payload hash, ed25519 signature over a canonical encoding), the seven planned capability grants with a per-command scope map, device registry (revocation, self-rotation with grant/seq inheritance, JSON persistence), single-use TTL pairing codes with server-bound capabilities, and the transport-agnostic client session; 10 unit tests. Plus desktop owned-devices i18n keys.
  • listam-headless/src/control.mjs + headless.mjs: hyperdht control server on a persistent device-local keypair, full authorization before execution, role-scoped executors, persisted device registry, audit ring, and operator-only management ops (control-pair/devices/revoke/audit/info).
  • listam-headless/test/owner-control.test.mjs + scenario helper: the H1 acceptance matrix over real hyperdht on a private testnet (run as a plain child so hyperdht teardown noise is filtered narrowly; nine checkpoints asserted).
  • listam-desktop/src/owner-control.mjs + UI: Pear-only client (device key in the shared file store, pairing, signed status queries) and the OWNED DEVICES section in the Peers pane with an honest unavailable state in the browser preview.

Functions created / updated / deleted

  • Created the protocol surface (createCommandEnvelope/verifyCommandEnvelope/authorizeCommand, createDeviceRegistry, pairing offer/request/verify, rotation helpers, createOwnerControlSession), the headless startOwnerControl server with its executors and operator ops, and the desktop createOwnerControlManager + renderOwnedDevices.

Implementation summary

Phase 14 replaces the unscoped-bearer-token risk H1 names with an authenticated-capability protocol: trust bootstraps through operator-minted single-use pairing codes whose capabilities are fixed server-side, and every subsequent command is a signed envelope verified against the registered device key with double replay protection (freshness window plus a persisted per-device monotonic sequence that survives restarts and rotation). Authorization precedes execution, every decision is audited, and device management stays on the Phase 13 operator surface rather than becoming a remote grant. The acceptance matrix is test-proven over real encrypted hyperdht connections: pairing with single-use enforcement, signed command success, the diagnostics-only client refused shutdown/import/export/topics as out-of-scope, unsigned/replayed/expired/unknown-device envelopes refused with precise reasons, rotation and operator revocation, and an authorized remote shutdown stopping the service cleanly. Verification: headless CI green (12 tests), mobile green (45 shared + 98 security), desktop green (7). Follow-ups: the mobile owner-control UI lands with Phase 15 on the same shared client layer; no remote devices:manage scope by design; the control server's own keypair does not rotate yet; unredeemed pairing offers do not survive restart.

Phase commits: listam-mobile b9917ac, listam-headless ccdc099, listam-desktop 4fbe47b.

Phase 15 - Cross-app acceptance matrix and release readiness (listam-mobile commit 089228b, listam-headless commit 4ded719)

Commit boundary: automate the private-bootstrap cross-instance matrix, prove mobile/desktop/headless convergence, run the cumulative-migration chain on one base (id-backfill, then owner-key adoption, then epoch, then secret-store), document manual acceptance, and close first-milestone release risks.

Depends on: 12, 14. Unblocks: -.

Acceptance: the headless-driven subset of the matrix runs in CI, the cumulative-migration chain passes, and each capability is "done" only when its matrix row passes.

Pause gate: record the committed files/functions and pause for milestone review before adding new list domains.

Files modified

  • listam-headless/test/helpers/matrix-scenario.mjs + test/matrix.test.mjs (new): the headless-driven matrix (CORE in CI, FULL behind LISTAM_MATRIX_FULL=1); src/service.mjs owner-only remove-member op (C1 re-key trigger).
  • listam-mobile/packages/backend/lib/owner-control-client.mjs (new): the worklet owner-control client deferred from Phase 14; backend.mjs RPC_CONTROL_* handlers; packages/protocol RPC_CONTROL_PAIR/COMMAND/LIST; packages/secrets controlDeviceSeed + relaxed parseSecretName.
  • listam-mobile/app/components/OwnedDevicesDialog.tsx (new) + Header/_useWorklet/index + control.* i18n: the RN owned-devices surface.
  • listam-mobile/backend/lib/migration-chain.test.mjs + owner-control-client.{test,scenario}.mjs (new): the cumulative-migration and mobile-client acceptance suites; bundles regenerated.

Functions created / updated / deleted

  • Created createOwnerControlClient (worklet) and its ensureOwnerControlClient backend wiring; the OwnedDevicesDialog component and its index/Header hooks; the matrix and migration-chain scenarios.

Implementation summary

Phase 15 closes the milestone. The headless-driven matrix subset runs in CI on a private testnet with real child-process instances, proving — at the protocol level all surfaces share — invite/join convergence, duplicate-name handling by stable id (M1), and the owner-gated roster (C3); the extended (FULL) tier adds steady-state convergence, member-removal re-key (C1), and 3-way kill/rejoin. The C2 boundary, restart persistence, export/import, invite lifecycle (H3), and owner-control authorization (H1) each have their own automated suites. The cumulative-migration chain proves the hardening migrations stack idempotently on one legacy base (id-backfill → owner adoption → epoch → secret-store), and the mobile owner-control client is proven over real hyperdht. The full per-row matrix status, the migration chain detail, and the consolidated open release risks (sustained-reconnection CI limits, GUI E2E checklist items, shared-package distribution, Pear/Bare packaging, and the deferred-by-design items) are recorded in the plan's Phase 15 entry. Verification: mobile CI green (45 shared + 100 security), headless green (13), desktop green (7).

Phase commits: listam-mobile 089228b, listam-headless 4ded719.

Post-milestone: after Phase 15 closed the first release milestone, the shared packages were extracted out of listam-mobile/packages/* into their own listam-packages/ repo (npm workspaces of @listam/*, consumed by every app via file: links). Phases 16 onward are new list-domain and capability work that builds on the 10 shared packages. Source paths below are package-relative under listam-packages/packages/ unless an app prefix is shown.
Phase 16 - Board list type, dual-read rename, and desktop board surface

Commit boundary: add a structured "board" (kanban) list type to the shared domain layer, rename the product surface from kanban to board with a backward-compatible DUAL-READ wire type, and ship the desktop board surface (status-chip board view, full ticket detail with the multi-block body editor, and the rigor/time-tracking config).

Depends on: 15 (shared-package extraction). Unblocks: 17, 22.

Acceptance: board tickets created on a board-aware peer round-trip through an old peer without forking the base; isBoardType() resolves both the new and legacy type; the desktop board surface renders and edits tickets in ?mock=1.

Files modified / added

  • domain/board.mjs (added): pure, DOM-free ticket and body-block model shared by desktop and mobile - isBoardType (dual-read), BOARD_WRITE_TYPE (= the legacy type so writes stay interop-safe), block helpers, and rigor/time-tracking fields. Covered by domain/board.test.mjs.
  • domain/identity.mjs: isTodoType / isBoardType mirror each other so item-identity and list-projection sites can branch on list type without importing board internals.
  • listam-desktop/src/ui.mjs, app.css: the desktop board surface keyed by (listId, type) - status-chip columns, ticket cards, the full-screen ticket detail, and the 8-block body editor.

Decisions

  • DUAL-READ rename, not a hard rename: isBoardType() accepts both board and the legacy kanban value, but the write-side value stays 'kanban' (BOARD_WRITE_TYPE = LEGACY_BOARD_LIST_TYPE) until the whole mesh (mobile bundle + desktop + headless) ships dual-read; writing the new value before then would fork apply() on old peers.
  • Pure domain logic: all block/ticket reduction lives in domain/board.mjs so the desktop and mobile board UIs share one tested implementation and never diverge.

Verification

node --test packages/domain/board.test.mjs green; desktop npm run ci green and the board surface verified live in ?mock=1 (board view, ticket detail, block editor). Follow-up: the write-side switch from 'kanban' to 'board' is gated on the entire fleet shipping dual-read.

Phase 17 - Net-new 'todo' text list type and mobile typed-list surfaces

Commit boundary: add a text-only todo list type (no grocery intelligence, no grid, no categories) and bring mobile up to the typed-list / groups / registry model with board and todo surfaces, multi-list swipe navigation, and a real network-status header dot.

Depends on: 16. Unblocks: -.

Acceptance: a todo list is plain text with no categorizer/grid; mobile renders grocery, board, and todo surfaces, swipes between named lists, and the header dot reflects real p2p connection state.

Files modified / added

  • domain/identity.mjs: isTodoType - a net-new type, so no dual-read is needed (no legacy peers ever wrote it). Grocery intelligence, grid, and categories are skipped for todo lists.
  • domain/list-registry.mjs, domain/list-nav.mjs: named-list registry meta-items and the active-surface (listId, listType) navigation model shared across apps.
  • app/store/listsSlice.ts, app/index.tsx: typed lists, groups, registry, and the multi-list add/sync contract (RPC_ADD carries {text, listId, listType}; SYNC_LIST stays default-list scoped).
  • app/components/BoardView.tsx, TicketDetail, CreateTicket: the mobile board surface over domain/board.mjs.
  • app/selectors/connectionStatus.ts, Header.tsx: the header readiness dot (green/online, blinking grey/connecting, grey/offline) derived from real swarm DHT-online + connection-count state.

Verification

node --test app/store/listsSlice.test.mjs and the mobile typecheck / ci pass; board and todo surfaces render in the simulator (sim text entry blocked only by the accent-popup keyboard gotcha). Backend bundles regenerated before Metro runs.

Phase 18 - Voice assistant: leaf firmware front-end, on-device "yo" wake word, and host STT pipeline

Commit boundary: build the ESP32-S3 leaf voice front-end (mic capture, dB loudness gate, side-band utterance stream, RGB LED feedback), land an on-device microWakeWord "yo" model that fires on hardware, and run host-side whisper.cpp STT plus intent parsing and the controller that applies recognized commands through normal backend RPCs.

Depends on: 13, 16. Unblocks: 19.

Acceptance: a real spoken "yo add milk" on the leaf -> host whisper -> intent parser -> item added; the leaf never holds an encryption key (it is a blind mirror that only streams raw audio side-band).

Files modified / added

  • listam-hardware/leaf-peer/leaf-esp32/src/voice.rs: mic capture (I2S, 16 kHz), the dB loudness gate, the on-device microWakeWord "yo" runner (int8 streaming MixedNet), and the side-band utterance stream to the host.
  • listam-hardware/leaf-peer/leaf-esp32/src/led.rs: WS2812 RGB feedback (idle / wake-fired / understood / "didn't understand" states).
  • listam-hardware/leaf-peer/leaf-esp32/wakeword/yo.tflite: the trained on-device "yo" wake-word model (fires reproducibly on real ESP32-S3 hardware).
  • domain/voice-intent.mjs, domain/voice-grammar.mjs: pure, host-portable utterance -> intent parsing (add / remove / note), with a lenient retry that skips a wake-word mishear to the first verb; covered by domain/voice-intent.test.mjs.
  • Host STT + controller: whisper.cpp (multilingual medium-class model - size, not cpp-vs-Python, was the accuracy bottleneck) runs on the host, parses the intent, applies it via backend RPCs, and records a training-dataset capture with per-intent execution floors.

Verification

cargo build --release green for the leaf; host intent tests green; the "yo" model fires on real hardware (yellow LED, repeatedly confirmed). End-to-end proven on-device: real spoken "yo add milk" -> leaf mic -> host whisper -> parser -> item added. The leaf is a blind read-only replica throughout - it captures and streams audio but the host does all STT and all base writes.

Phase 19 - Desktop-native voice hosting

Commit boundary: host the whole voice pipeline inside the desktop Pear worker (leaf audio -> whisper via a subprocess -> writes to the desktop's own base) so voice does not need a separate leaf-hub peer or an orphan base.

Depends on: 18. Unblocks: -.

Acceptance: the desktop worker spawns whisper, accepts the leaf audio stream, and writes recognized items into the desktop project; no separate Node leaf-hub is required.

Files modified / added

  • listam-desktop/src/voice-host-worker.mjs (added): the Bare-side voice host - receives the leaf utterance stream, runs whisper as a spawned subprocess, parses with domain/voice-intent.mjs, and applies results through the worker's own backend RPCs.
  • listam-desktop/src/leaf-bridge-manager.mjs, leaf-bridge-config.mjs: the leaf-bridge TCP listener (default :9994 for the audio side-band) and its config; a per-connection process.env read under Bare was root-caused as the cause of a worker SIGABRT and replaced with a Bare-safe path plus an uncaughtException keepalive handler.
  • listam-desktop/src/ui.mjs: the Settings -> Voice section.

Verification

Bare-spawns-whisper proven via a Pear terminal-app spike; desktop npm run ci and lint green; the Voice section verified in ?mock=1. The leaf still streams to :9994 so an on-device test needs no re-provision. Follow-up: leaf Bluetooth pairing UX (Phase 20) and the on-hardware desktop end-to-end run.

Phase 20 - BLE leaf provisioning (@listam/provisioning + RPC_LEAF_PROVISION_INFO=27)

Commit boundary: initialize the ESP32 leaf over Bluetooth from any app instead of editing cfg.toml and USB-flashing - a zero-dependency shared wire contract, a headless operator op, a backend RPC that surfaces the hub's control key and bridge address, and the firmware NimBLE GATT + NVS boot branch.

Depends on: 13, 14. Unblocks: -.

Acceptance: an app reads the hub's controlKey/hubAddr, writes a CRC-checked, chunked provisioning payload over the custom GATT service, and the leaf persists it to NVS and boots from it.

Files modified / added

  • provisioning/index.mjs (new package @listam/provisioning@0.7.0, main only - not on npm): the BLE GATT wire contract - service/characteristic UUIDs, payload schema, CRC16, chunking, and buildProvisioningPayload / validateProvisioningPayload / provisionLeaf. Covered by provisioning/provisioning.test.mjs.
  • protocol/index.mjs: RPC_LEAF_PROVISION_INFO = 27 - the backend surfaces leaf-provisioning context (hub control key + bridge address) to the apps.
  • listam-headless/src/provision-ble.mjs: the operator provision-leaf op, driving provisionLeaf() from the shared package (JS tests green).
  • Mobile (ble-plx + LeafPairingDialog), desktop (Web Bluetooth in ui.mjs), firmware (C NimBLE GATT shim + NVS persistence + a boot branch that prefers a provisioned config): all read the hub's controlKey/hubAddr via owner-control status, not a direct RPC.

Verification

Shared-package JS tests green (provisioning + headless op); desktop verified-boots in ?mock=1 with navigator.bluetooth present; i18n done for all 6 catalogs. Follow-up: firmware, mobile, and desktop BLE paths each need on-hardware verification; trust model is proximity-trust v1.

Phase 21 - Item-level capabilities: overview/day-plan, reordering, cross-list move, encrypted backup, markdown, labels

Commit boundary: a batch of cross-app item capabilities, each a small synced domain channel or a backend RPC, that together round out the list experience - the overview/day-plan channel, item reordering, moving items between lists, encrypted backup/import, the WYSIWYG markdown editor, and synced labels.

Depends on: 16, 17. Unblocks: -.

Acceptance: each capability syncs conflict-free (LWW) across desktop, mobile, and headless without a new fork risk; backups are password-encrypted and import merges by LWW.

Overview / day-plan channel

  • domain/plan.mjs (added): a synced pointer channel (reserved __plan__ bucket, no new RPC - RPC_UPDATE upsert) for flagging items into a daily focus plan; isPlanItem guards every list-projection site. Covered by domain/plan.test.mjs.
  • Desktop Overview (pinned-rail Focus + Planner) verified live in ?mock=1; mobile OverviewScreen + plan sheet + swipe-right-to-flag (typecheck-only, needs device). Calendar deferred.

Item reordering

  • domain/ordering.mjs (added): a shared last-write-wins order field with sortByOrder / computeReorder; group-by-category / group-by-status reorder internally so sortByOrder re-applies per display site. Covered by domain/ordering.test.mjs. Desktop drag + Alt-Up/Down verified across grocery/grid/todo/board; mobile = selector sort + long-press overlay.

Move items between lists / types

  • protocol/index.mjs: RPC_MOVE = 28.
  • domain/list-move.mjs (added): pure move reduction across list types (re-shapes a grocery item into a board ticket and back as needed via isBoardType). Covered by domain/list-move.test.mjs.

Encrypted backup / import + seed export

  • protocol/index.mjs: RPC_EXPORT_DATA = 24, RPC_EXPORT_SEED = 25, RPC_IMPORT = 26.
  • backend/lib/backup-crypto.mjs, backend/lib/backup-payload.mjs, backend/lib/backup.mjs: password-encrypted export/import - Argon2id KDF (crypto_pwhash, interactive tier) + XChaCha20-Poly1305, reusing the same cipher as the epoch layer; import merges by LWW; the seed export is the full instance identity. Settings buttons on desktop and mobile.

WYSIWYG markdown editor

  • domain/markdown.mjs (added): a DOM-free markdownToHtml / htmlToMarkdown pair so the stored value stays markdown while the user edits live WYSIWYG (desktop contentEditable, mobile TipTap-in-webview). Covered by domain/markdown.test.mjs.

Synced labels

  • domain/labels.mjs (added): a registry-style synced meta-item channel for self-asserted peer/device names and cross-device built-in-surface rename (Groceries -> Spesa); every list-projection site skips isLabelItem. Covered by domain/labels.test.mjs.

Verification

Domain unit tests green across plan, ordering, list-move, markdown, and labels; desktop surfaces verified live in ?mock=1; backup export/import round-trips on desktop. Mobile counterparts are typecheck-clean and pending an on-device dev-client rebuild (expo-document-picker for import, TipTap webview for markdown).

Phase 22 - Desktop platform polish: Servers pane, open-empty + 'general' group, collapsible rail, Analytics-in-Settings, worker keepalive

Commit boundary: a set of desktop information-architecture and reliability improvements - a Servers pane that monitors and safely controls remote headless peers over owner-control, an open-empty start with a mandated 'general' group, collapsible rail groups, Analytics folded into Settings, and a worker keepalive that survives Bare exceptions.

Depends on: 14. Unblocks: -.

Acceptance: the desktop app monitors a remote headless peer (status / refresh / invite / export / shutdown) over owner-control; it opens with no list selected; every list has a group and 'general' is protected; the worker stays alive through a Bare exception and logs it.

Files modified / added

  • owner-control/index.mjs (v0.14.0): a persistent hyperdht connection (a connect-per-request latent bug was found and fixed - hyperdht cannot reconnect a torn-down connection) plus a 15-finding adversarial-review pass; this is the wire the desktop Servers pane rides.
  • listam-desktop/src/ui.mjs: the top-level Servers section (status cards, refresh/invite/export/shutdown), live-verified against the Geekom headless peer; the open-empty start; the mandated 'general' group (built-in Groceries/Board/Todo reframed as ordinary deletable lists inside it; deleting a group re-homes its lists to general); collapsible rail groups (preferences.collapsedGroups, summed green badge); Congruency + Activity analytics moved into Settings -> Analytics; the "Live · N peers" status strip removed.
  • listam-desktop/src/backend-worker.mjs: a Bare uncaughtException handler that keeps the worker alive and logs to <storage>/worker-errors.log instead of SIGABRT-crashing the backend.

Verification

Desktop npm run ci green and the panes verified in ?mock=1; the Servers pane live-verified against the Geekom headless peer; the worker keepalive shipped in desktop release 3916.

Published versions (as of 2026-06-25)

Current shared-package and app versions after the single-list-sharing release:

Package / app Version
@listam/domain0.10.0
@listam/protocol0.9.0
@listam/backend0.10.0
@listam/client0.9.0
@listam/i18n0.12.0
@listam/owner-control0.14.0
@listam/provisioning0.7.0
listam-desktop0.14.0
listam-headless0.14.0
listam-mobile1.0.1
plans.html#phases listam-multi-app-plan.md