243 lines
17 KiB
Markdown
243 lines
17 KiB
Markdown
# 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.properties` identifies `one.tactility.voicerecorder`, visible name `Voice Recorder`.
|
||
- `Apps/VoiceRecorder/main/Source/main.c` records WAV files under `/sdcard/memos/` and has no network/upload implementation.
|
||
- Recorder source actually sets `SAMPLE_RATE` to 44100 and writes that rate into `WavHeader`; 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/PipecatVoice` is a separate live conversational client with a private-LAN raw-WebSocket protocol and a hard `ws://`-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
|
||
|
||
1. 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.
|
||
2. 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.
|
||
3. 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.
|
||
4. 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.
|
||
5. A `switch active person` request requires authenticated device credentials, rejects people who are assigned elsewhere, creates an append-only assignment audit event, and invalidates cached authorization state.
|
||
6. 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 support `https://`/`wss://`, unlike the old Pipecat app.
|
||
7. 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.
|
||
|
||
1. `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.
|
||
|
||
2. `GET /v1/device/state`
|
||
- Returns the authenticated device, active person, roster of active family people, and current API capability/version values.
|
||
|
||
3. `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.
|
||
|
||
4. `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.
|
||
|
||
5. `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.
|
||
|
||
6. `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.
|
||
|
||
7. `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 -> delivered` after matching the server digest.
|
||
|
||
8. `GET /healthz` and `GET /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:
|
||
|
||
```text
|
||
/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:
|
||
|
||
```text
|
||
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
|
||
|
||
1. An approved blank device can select an unassigned family person; a second device cannot select that same person.
|
||
2. Reassigning a device makes its old cached inbox inaccessible and returns only the new person’s permitted messages.
|
||
3. A sender can send one validated WAV to multiple recipients; every recipient has an independent pending delivery.
|
||
4. A recipient offline for days receives the pending message when it next syncs; server retention continues afterward.
|
||
5. Sender and recipient cannot access another person’s messages through altered IDs, query filters, direct audio URLs, or replayed credentials.
|
||
6. A network interruption during upload creates no message; a retry with the same idempotency key creates exactly one message.
|
||
7. A network interruption or full SD card during download does not set delivered. A completed verified local file sets delivered exactly once.
|
||
8. The Mexico-device path uses valid TLS and reaches only the dedicated service; invalid/revoked credentials cannot enumerate people, messages, or audio.
|
||
9. 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.
|