17 KiB
Tactility Voice Messages — Backend Implementation Plan
For Hermes: Use subagent-driven-development skill to implement this plan task-by-task.
Status: planning draft — no service, database, public route, device app, or deployment has been created.
Goal: Build a private, family-only voice-message backend, initially hosted on the Mac mini, that links a Tactility device to one family member at a time; persists messages until and after recipient-device delivery; and can securely serve an internet-connected device in Mexico.
Architecture: A dedicated API on the Mac mini owns the SQLite metadata database and a private immutable audio archive. A public HTTPS reverse-proxy route forwards only to that API. Tactility devices hold a per-device credential and their selected person in app data on the SD card; every API request is authenticated as a device, then authorized only for the device’s active person. The device app is a separate Voice Messages app that reuses the existing local Voice Recorder WAV capture/playback patterns without reusing the unrelated Hermes/Pipecat conversation gateway.
Proposed stack: Python 3.9-compatible FastAPI service; SQLAlchemy + Alembic; SQLite; private filesystem audio storage; launchd service on the Mac mini; HTTPS/WSS reverse proxy; C/C++ Tactility app. Containerization and a FamReynaServer migration are deferred but the service must keep configuration, database, and audio storage portable.
Confirmed product decisions
| Area | Decision |
|---|---|
| Initial authoritative host | Mac mini. Design for a later migration to FamReynaServer. |
| First-run identity | Family people are prepopulated on the backend. A device displays the roster and stores its selected active person on SD card. |
| Device relationship | One person may have only one linked device at a time. |
| Reassignment | The device may switch people freely from Settings, but the server refuses assignment to a person already linked to another device. The device then clears its local inbox cache. |
| Remote reachability | Must support a stable internet URL for a device in Mexico. |
| Inbox | Private: a person can access only messages they sent or received. Newest first; sender filter applies inside this private view. |
| Audio/metadata | Audio-first v1. Each list row shows sender, recipient(s), timestamp, duration, and delivery state. No transcript or text search. |
| Delivery state | Track delivery only. A message becomes delivered only after the recipient device has durably saved its audio locally and acknowledges that fact. Do not track listening/playback. |
| Server retention | Keep audio and metadata indefinitely after delivery as a family archive. |
| Deletion | No deletion or hiding in v1. The archive is append-only. |
Provisional defaults made after interview timeouts
These are intentionally isolated so they can be changed without changing the confirmed requirements.
| Area | Default | Rationale |
|---|---|---|
| Recipients | One or more people per message | Supports a message to both grandparents; delivery remains per recipient. |
| Maximum message length | 2 minutes | Bounds transfer and storage while remaining useful for family updates. |
| Administration | Protected server-side seed/management command | Smallest v1 surface; no public admin UI. |
| Public connection | HTTPS/WSS subdomain through the existing family reverse proxy | Provides a stable URL without publishing a raw Mac mini listener. |
| Client boundary | New Voice Messages app |
Preserves the existing personal local Voice Recorder behavior. |
| Audio archival format | Retain validated source WAV | Device code already records/plays WAV. Add transcoding only after v1 delivery works. |
Source discovery evidence
- Existing application repository:
/Users/adolforeyna/Projects/electronics/tactility/tactility_apps. - Existing recorder:
Apps/VoiceRecorder/manifest.propertiesidentifiesone.tactility.voicerecorder, visible nameVoice Recorder. Apps/VoiceRecorder/main/Source/main.crecords WAV files under/sdcard/memos/and has no network/upload implementation.- Recorder source actually sets
SAMPLE_RATEto 44100 and writes that rate intoWavHeader; the top comment calling it 16 kHz is stale. Two minutes of 44.1 kHz mono PCM16 is approximately 10.09 MiB. Do not rely on the stale comment. - Existing
Apps/PipecatVoiceis a separate live conversational client with a private-LAN raw-WebSocket protocol and a hardws://-only endpoint parser. It must not be repurposed for the public message service. - An older PocketBase voice-memo prototype exists on FamReynaServer, but its documented collection rules were public and its model is device-based/
played-based. It is reference material only, not a production starting point.
Security model
- Before a device can connect, an administrator creates an approved device record with an opaque device ID and a high-entropy bootstrap secret. Never use a display name, MAC address, or SD-card person selection as authentication.
- The installer places only the public server URL, device ID, and bootstrap secret in the device’s private app data on the SD card. Nothing is compiled into firmware/source.
- Over TLS, the device exchanges its bootstrap secret once for a rotatable device credential. The server stores only a secure verifier/hash, not a plaintext reusable secret.
- Each request authenticates the physical device. Server authorization derives the active person from the server’s device-assignment record; a client-supplied person ID is never trusted for read/write access.
- A
switch active personrequest requires authenticated device credentials, rejects people who are assigned elsewhere, creates an append-only assignment audit event, and invalidates cached authorization state. - The public reverse proxy permits only the message-service host/path and forwards to a narrowly bound Mac-mini listener. No Hermes
:8642, Pipecat, or internal API key is exposed. TLS certificate validation on the ESP32 is mandatory; the new client must supporthttps:///wss://, unlike the old Pipecat app. - Logs contain IDs, status codes, and bounded error categories—not credentials, full URLs with credentials, or audio/transcript content.
Data model
people
id, unique slug, display name, active flag, created/updated timestamps.
devices
id, unique opaque device ID, human-readable device label, credential verifier/version, active-person foreign key nullable, revoked timestamp nullable, created/updated timestamps. A unique constraint on non-null active_person_id enforces one active device per person.
device_assignment_events
id, device ID, prior person nullable, new person nullable, actor type (device or admin), timestamp, reason. Append-only audit trail.
messages
UUID id, sender person ID, immutable audio relative path, SHA-256 digest, detected audio format/rate/channels/bit depth, server-measured duration, byte count, creation timestamp. No mutable title, transcript, read status, or deletion flag in v1.
message_recipients
id, message ID, recipient person ID, created timestamp. Unique (message_id, recipient_person_id); reject a sender as recipient unless that behavior is deliberately added later.
deliveries
id, message-recipient ID, recipient device ID captured at send time, state (pending, delivered), delivered timestamp nullable, idempotency key, acknowledged audio digest. A recipient assignment change never rewrites historical delivery records.
request_audit
Bounded operational audit: request ID, device ID, endpoint class, result, timestamp. Apply retention separately to this operational log; it is not message content.
API contract v1
All endpoints are versioned under /v1. Payloads use JSON except audio upload/download. Every non-bootstrap endpoint requires authenticated device credentials.
-
POST /v1/device/bootstrap- Input: device ID + bootstrap secret.
- Output: rotated device credential and current device assignment, if any.
- Rate-limit and record failures without exposing whether a person exists.
-
GET /v1/device/state- Returns the authenticated device, active person, roster of active family people, and current API capability/version values.
-
PUT /v1/device/assignment- Input: selected person ID.
- Atomically validates that the person is active and unassigned, changes assignment, appends audit event, and returns the new assignment.
-
POST /v1/messages- Multipart upload authenticated as the active sender.
- Metadata: idempotency key and recipient IDs; audio part only.
- Server streams upload to a temporary private file, caps size at 11 MiB, parses the WAV header, validates mono PCM16/allowed rate and a maximum measured duration of 120 seconds, hashes it, atomically commits it to archive storage, then writes the message/recipient/delivery rows in one database transaction.
- Never create a visible message record for partial/failed audio uploads.
-
GET /v1/messages?folder=inbox|sent&sender_id=&cursor=&limit=- Newest-first stable cursor pagination.
inbox: active person is a recipient;sent: active person is sender. Sender filter is only applied after this access constraint.- Returns no audio bytes.
-
GET /v1/messages/{message_id}/audio- Allowed only for the sender or an intended recipient’s active device.
- Supports resumable Range download if the device runtime can support it; otherwise v1 uses full-download-to-temp-file, hash verification, atomic SD rename, then acknowledgment.
-
POST /v1/deliveries/{delivery_id}/ack- Allowed only to the exact recipient device captured in that delivery row.
- Input includes message SHA-256. It is idempotent and transitions only
pending -> deliveredafter matching the server digest.
-
GET /healthzandGET /readyz- Local/proxy monitoring only; no family data.
Client behavior that the backend depends on
- On first launch, bootstrap using device configuration, fetch roster, and present the name selection screen.
- Persist the selected person and credential through the platform user-data path, not a compiled constant. On assignment change, erase only the Voice Messages app’s cached inbox/audio—not the separate Voice Recorder’s local memos.
- On send: record to a temporary SD WAV, show recipients, upload with a generated idempotency key, and retain the local temporary file until the server returns a committed message ID. Retry safely with the same idempotency key after connectivity loss.
- On receive: sync metadata when the app opens/refreshes; for a pending delivery, download audio to a temporary file, verify SHA-256 and WAV validity, atomically rename to the app cache, then acknowledge delivery. A failed download/SD write must not be acknowledged.
- The client is foreground-polling in v1. No background listener, wake-word behavior, tracking, transcription, or read receipts are included.
Proposed repository and service layout
Create a new repository only after approval:
/Users/adolforeyna/Projects/tactility-voice-messages/
pyproject.toml
src/tactility_voice_messages/
app.py
config.py
db.py
models.py
auth.py
wav_validation.py
archive.py
routes/
bootstrap.py
devices.py
messages.py
deliveries.py
services/
assignments.py
message_uploads.py
inbox.py
migrations/
tests/
test_bootstrap.py
test_assignment.py
test_authorization.py
test_message_upload.py
test_inbox.py
test_delivery_ack.py
test_wav_validation.py
test_archive_atomicity.py
scripts/
seed_family.py
backup_verify.py
run_dev.sh
deploy/
com.reyna.tactility-voice-messages.plist
reverse-proxy-route.json
restore-reverse-proxy-route.sh
docs/
protocol-v1.md
operations.md
Runtime data is deliberately outside the repository, e.g. ~/Library/Application Support/TactilityVoiceMessages/ with a 0700 parent:
TactilityVoiceMessages/
data.sqlite3
media/aa/bb/<message-uuid>.wav
incoming/ # same-filesystem temporary uploads
backups/
logs/
Delivery and migration design
- Launch the API as a Mac-mini user LaunchAgent, bound only to the private LAN interface required by the reverse proxy—not
0.0.0.0. - The reverse proxy terminates TLS and has a durable route definition plus restore script. It forwards only the exact host to the Mac mini port.
- Backups are consistent SQLite backups plus content-addressed/media manifest verification. A backup job must verify database integrity, every archive file’s hash/size, and a restoration drill before being called ready.
- The future FamReynaServer migration copies the stopped/consistent SQLite database plus media archive, updates only endpoint infrastructure, runs the same migration/version checks, and verifies authorized send/download/ack flows from a device. Do not migrate by copying an active SQLite/WAL database blindly.
TDD implementation sequence
Task 1: Scaffold configuration and health checks
Create the repository, 3.9-compatible dependency lock, validated configuration, healthz, readyz, and focused tests. Verify startup with an ephemeral SQLite database and a private temp directory.
Task 2: Implement schema and migrations
Write failing tests for unique active-person assignment, immutable message rows, recipient uniqueness, and delivery state. Create migrations and prove upgrade from empty database.
Task 3: Implement device bootstrap and request authentication
Test bootstrap rate limits, secret hashing, credential rotation, revoked-device rejection, and zero credential leakage in errors/logs. Implement the minimal device auth middleware.
Task 4: Implement assignment and roster authorization
Test unassigned selection, rejection of second-device claim, reassignment audit events, and roster access restricted to authenticated approved devices.
Task 5: Implement WAV validation and private atomic archive
Test malformed RIFF headers, non-PCM, wrong channel/bit-depth/rate, oversized files, over-two-minute content, hash mismatch, cleanup after DB failure, and archive path traversal rejection.
Task 6: Implement message creation and fan-out deliveries
Test sender-only creation, recipient deduplication, atomic creation of one delivery per recipient, no visible partial upload, and idempotent retry with the same key.
Task 7: Implement private inbox/sent listing and sender filter
Test cursor stability, newest-first ordering, no cross-person leakage, sent visibility, incoming visibility, and filter behavior.
Task 8: Implement authorized download and delivery acknowledgment
Test that only sender/intended recipient can download, reassigned devices cannot claim historical delivery, acknowledgment digest matching, and idempotent pending -> delivered transition.
Task 9: Implement seed/admin command, backup verification, and operations docs
Test seed-file validation, duplicate IDs/slugs rejection, safe credential handling, backup manifest verification, and a test restoration into a new data directory.
Task 10: Deploy only after separate approval
Prepare but do not activate the LaunchAgent, reverse-proxy route, DNS, or public endpoint without user approval. Before go-live, test with a nonproduction device against HTTPS, prove certificate validation, send a real audio message, verify an offline recipient remains pending, then verify download-to-SD precedes one successful delivery acknowledgment.
Acceptance tests
- An approved blank device can select an unassigned family person; a second device cannot select that same person.
- Reassigning a device makes its old cached inbox inaccessible and returns only the new person’s permitted messages.
- A sender can send one validated WAV to multiple recipients; every recipient has an independent pending delivery.
- A recipient offline for days receives the pending message when it next syncs; server retention continues afterward.
- Sender and recipient cannot access another person’s messages through altered IDs, query filters, direct audio URLs, or replayed credentials.
- A network interruption during upload creates no message; a retry with the same idempotency key creates exactly one message.
- A network interruption or full SD card during download does not set delivered. A completed verified local file sets delivered exactly once.
- The Mexico-device path uses valid TLS and reaches only the dedicated service; invalid/revoked credentials cannot enumerate people, messages, or audio.
- A verified backup can restore the full metadata/audio archive and preserve SHA-256 validation.
Explicit non-goals for v1
- Hermes/Pipecat integration, live conversation streaming, speech-to-text, voice recognition, push notifications, background device listener, read/listen receipts, delete/edit/forward, a browser inbox, public account creation, free device switching to already assigned people, or automatic audio transcoding.