Implementation Phases
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.
| 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-ignorepackage-lock.json; add.DS_Store,__pycache__/,*.pyc.package.json: addtest,lint,check:deps,check:secrets, and aggregateciscripts; declare undeclared runtime deps (react-native-svg,qrcode-terminal,@expo/vector-icons,bare-path,bare-url); add dev depseslint,typescript-eslint.package-lock.json(added): committed for reproduciblenpm ci(resolved with--legacy-peer-depsfor a pre-existingreact-native-screensRN-peer conflict).eslint.config.mjs(added):no-consolefor 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_Storefiles andscripts/__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 sendingRPC_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 todeleteLegacyInviteFile; 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 backendconsole.error.
Functions created / updated / deleted
normalizeInvite,extractInviteFromInput,createJoinConfirmationRequest,resolveJoinConfirmation,extractInviteFromUrl: created - isolate and test the pending-invite confirmation contract.AppInnerjoin 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 fromdeleteInvite;deleteInvitedeleted.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-matchedexpo-secure-storedependency.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 toexpo-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: addsRPC_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, andsecretFingerprintinapp/secret-storage-core.ts. - Created:
createLegacySecretFileAdapter,prepareBackendSecretPayload, andpersistBackendSecretFromPayloadinapp/secrets.ts. - Created:
parseBootSecretPayload,getBootSecretBuffer,persistBackendSecret,deleteBackendSecret, andsecretFingerprintinbackend/lib/secrets.mjs. - Updated:
useWorklet/startWorklet,loadAutobaseKey,saveAutobaseKey,loadEncryptionKey,saveEncryptionKey,loadLocalWriterKey,saveLocalWriterKey,initAutobase, andwaitForWritable. - 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 carryownerAuthorityKey, 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, andreduceMembershipLog(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:rebuildListFromPersistedOpsskips membership view entries; addedreadPersistedMembershipRecordsto read the membership recordsapply()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, andreduceMembershipOperation. - Created:
saveOwnerAuthorityKey,loadOwnerAuthorityKey,deleteOwnerAuthorityKey,ensureOwnerMembership,sendInviteKeyToFrontend,reduceMembershipLog, andreadPersistedMembershipRecords. - Updated:
parseBootSecretPayload,getBootSecretBuffer,persistBackendSecret,normalizeSecretValue,prepareBackendSecrets,persistBackendSecretRequest,secretStoreKey,initAutobase,setupBlindPairing,createInvite,rotateInviteAndNotifyFrontend,joinViaInvite,apply,createJoinRollbackSnapshot, andrestoreJoinRollbackSnapshot. - Deleted: no public functions removed; legacy unsigned
add-writerhandling 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 authenticatedepoch-list-opencrypt/decrypt with the epoch number bound as AAD.backend/lib/membership.mjs: owner-signedremove-writerrecords; 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 viaprepareListAppendOperation;enqueueWriteexported 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 besiderpc-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 tolist-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 overlist-identity.mjs; also consumed by the Phase 6listsSlice.backend/backend.mjs:applynormalizes ops, appends versioned view entries, and updates the in-memory list viaapplyOperationToList;RPC_ADDaccepts 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;validateItemdelegates tonormalizeListItem;rebuildListFromPersistedOpsreplays the whole log throughreduceListViewEntries.backend/lib/network.mjs: seed in-memorycurrentListfrom 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 versionedcreateListOperation('list', …).app/components/_types.ts:ListEntrygains optionalid/itemId/listId/listType/updatedAt/timestamp/author.app/components/VisualGridList.tsx,intertial_scroll.tsx: React list keys use the stableidentityKeyinstead oftext+timeOfCompletion.app/listEntry.json(removed): dead/unreferenced schema whoserequiredset had drifted from reality; real validation isvalidateItem/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/domainextraction remains Phase 7. updatedAtis now load-bearing, but not a field-level merge: updates resolve by last-write-wins onupdatedAt(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.tswiring, 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.mjs → 73 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 normalizedlists,sync,preferences,loyaltyCards, anddevicesslices.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, andtoLoyaltyCardHandle. - Updated
useWorklet,AppInner,Header, andMembersDialog; addedparseStoredLoyaltyCards,indexLoyaltyCardPayloads, andserializeLoyaltyCardPayloads.
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 andtest: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, andgroupByCategory. - Created package exports for logging:
redactForLog,redactString,parseLogArgs,formatLogLine,createLogger, andlogger. - Created package exports for secrets:
normalizeSecretValue,secretFingerprint,parseBackendSecretPayload,parseSecretAck,prepareBackendSecrets, andpersistBackendSecretRequest. - 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/backendand@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-platformbare-packbundling 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 consumesdecodeBackendRequestwhile 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, andreconcileLegacyKeyFilesin@listam/backend. - Created
createBareKitPlatform,createNodePlatform, andcreateNodeRpcplatform adapters. - Created
setBackendFsandgetBackendFs; updated backend key/network helpers to use the selected platform filesystem. - Created
decodeBackendRequest,decodeWithClientAdapter,encodePayload,dataToString,nodeClientAdapter, andworkletClientAdapterin@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/i18npackage 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}andapp/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, andassertCompleteCatalogin@listam/i18n. - Created mobile
I18nProvideranduseI18n. - Updated preference hydration and
localeChoiceSetto reject invalid values and fall back tosystem. - Updated
createJoinConfirmationRequestandplanIncomingLinkJointo 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
groupByCategoryto 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, anddeleteLoyaltyCardPayloadin@listam/secrets. - Created mobile wrappers
prepareLoyaltyCards,persistLoyaltyCard,readLoyaltyCard,deleteLoyaltyCard, andappLogger. - Updated
toLoyaltyCardHandle,loyaltyCardsHydrated,loyaltyCardAdded,handleCardScanned,handleSelectCard, andhandleDeleteCardso Redux and AsyncStorage handle indexes never carrydata. - Created
redactForExportandredactDiagnosticBundle; updatedredactStringandredactForLogsensitive-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 plusperformStorageRecovery(retry / owner-approved quarantine-then-fresh-base); the checkpoint resets on base teardown/switch.listam-mobile/packages/backend/backend.mjs: storage lease replaces thewxlock; 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, andquarantineStorageRootinrecovery.mjs. - Created
createStorageLease(acquire/renew/release/startHeartbeat/stopHeartbeat/describeOwner/isHeld) instorage-lease.mjs. - Created
createViewCheckpoint(update/reset) inview-checkpoint.mjsandcreateListReductionin@listam/domain. - Created
enterPendingRecoveryandperformStorageRecoveryinnetwork.mjs; updatedinitAutobaseto park on corruption instead of wiping, and to reset the view checkpoint on teardown. - Updated
rebuildListFromPersistedOpsandreadPersistedMembershipRecordsto share one checkpoint scan; createdresetViewCheckpoint; deletedrmrfSafe(the wipe helper) and the rawwxlock code. - Updated
createBackendPaths(storage namespaces),startBackend(lease acquisition/heartbeat, recovery policy),shutdownBackend(lease release), andhandleFrontendRequest(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 desktoppackage.jsonwithfile: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 committeddesign-guide/.listam-mobile/packages/client:createBackendChannel()— the desktop in-process IPC contract decoding events with the samedecodeBackendRequestas the worklet adapter, with async reply support for the secret-persistence ack; contract tests extended.listam-mobile/packages/backend: newplatform/pear.mjs;platform.bootstrapfeeds every Hyperswarm for private-testnet harness runs; invite-epoch codec inkey-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 (sendsadditional),joinViaInvite(decodes the epoch frompaired.data),removeMemberAndRotateEpoch(rotates the outstanding invite), andapply(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.mjsCLI (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:createFileSecretStorepromoted from the desktop app (shared dev/file tier pending OS keychain/keyring).listam-mobile/packages/backend/platform/node.mjs:options.bootstrappassthrough — 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 helpersrunHeadless/runOneShot. - Created
createFileSecretStorein@listam/secrets; updatedcreateNodePlatform(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 headlessstartOwnerControlserver with its executors and operator ops, and the desktopcreateOwnerControlManager+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 behindLISTAM_MATRIX_FULL=1);src/service.mjsowner-onlyremove-memberop (C1 re-key trigger).listam-mobile/packages/backend/lib/owner-control-client.mjs(new): the worklet owner-control client deferred from Phase 14;backend.mjsRPC_CONTROL_* handlers;packages/protocolRPC_CONTROL_PAIR/COMMAND/LIST;packages/secretscontrolDeviceSeed + 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 itsensureOwnerControlClientbackend wiring; theOwnedDevicesDialogcomponent 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.
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/isBoardTypemirror 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 bothboardand the legacykanbanvalue, 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 forkapply()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_ADDcarries{text, listId, listType};SYNC_LISTstays 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-devicemicroWakeWord"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-connectionprocess.envread under Bare was root-caused as the cause of a worker SIGABRT and replaced with a Bare-safe path plus anuncaughtExceptionkeepalive 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, andbuildProvisioningPayload/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 operatorprovision-leafop, drivingprovisionLeaf()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'scontrolKey/hubAddrvia owner-controlstatus, 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_UPDATEupsert) for flagging items into a daily focus plan;isPlanItemguards every list-projection site. Covered by domain/plan.test.mjs. - Desktop Overview (pinned-rail Focus + Planner) verified live in
?mock=1; mobileOverviewScreen+ plan sheet + swipe-right-to-flag (typecheck-only, needs device). Calendar deferred.
Item reordering
- domain/ordering.mjs (added): a shared last-write-wins
orderfield withsortByOrder/computeReorder; group-by-category / group-by-status reorder internally sosortByOrderre-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/htmlToMarkdownpair 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 BareuncaughtExceptionhandler that keeps the worker alive and logs to<storage>/worker-errors.loginstead 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/domain | 0.10.0 |
@listam/protocol | 0.9.0 |
@listam/backend | 0.10.0 |
@listam/client | 0.9.0 |
@listam/i18n | 0.12.0 |
@listam/owner-control | 0.14.0 |
@listam/provisioning | 0.7.0 |
| listam-desktop | 0.14.0 |
| listam-headless | 0.14.0 |
| listam-mobile | 1.0.1 |