Skip to content

Tenir 1st QA pass: fix every critical/high finding (XERK-236) - #139

Merged
xerhab merged 10 commits into
mainfrom
XERK-236
Aug 9, 2026
Merged

Tenir 1st QA pass: fix every critical/high finding (XERK-236)#139
xerhab merged 10 commits into
mainfrom
XERK-236

Conversation

@xerhab

@xerhab xerhab commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Ticket: XERK-236 — "Tenir 1st QA pass".

Five adversarial QA agents swept the app front to back (api, web, Even G2 glasses, Android, Veiller miniapp, deploy/CI), each driving a real running stack rather than reading code. They produced ~60 reproduced findings. This PR fixes every critical and high, plus the security-relevant mediums; the rest are triaged into qa.md with file references and repro steps.

A sixth QA agent then ran as an adversarial gate on these fixes and returned FAIL. Everything it found is fixed here too (last commit).

The five worst

Severity Defect Evidence
CRITICAL A dropped session that nobody resumed was never finalized and its audio was discarded. _grace_close() calls close(), and close() cancelled self._grace_task — the task calling it. The CancelledError landed on the first await, so _persist() never ran: the conversation stayed live forever and the whole retained buffer went with the process. This is exactly the path even/README.md documents as the safety net for an abnormal exit — phone out of range, app killed, dead BLE link — so every one of those lost the recording. 64 KB of captured audio discarded; re-verified with a real TCP abort
CRITICAL The Android release APK could not reach any http:// server. No usesCleartextTraffic, no network security config, targetSdk 34 — only the debug manifest allowed cleartext. The published APK could not talk to this project's own documented deployment. Invisible in dev, because npm run android builds debug. gate built the release APK, installed it on the AVD, signed in over http://10.0.2.2 and drove a full capture
CRITICAL Any link could silently switch the signed-in web account. adoptTokenFromUrl took any #token= value, overwrote the stored token with no consent, and scrubbed the fragment. The victim's history vanished, everything they recorded afterwards landed in the attacker's household. #token=garbage destroyed a session just as quietly. test fails before / passes after
HIGH Cross-household audio corruption. A client-supplied sessionId shaped ../other-hh/<their-id> never leaves the audio root, so the store's escape check passed it: the api read another household's WAV as this session's resume offset, then prepended and rewrote it. reproduced live: household A's file went 64044 → 128044 bytes, md5 changed
HIGH A fatal WS error left the microphone open with the UI still claiming to record. running stayed true across web, Android and both glasses clients. For a product whose promise is "recorded and stored", believing you are being recorded while nothing is captured is the worst failure available. test fails before / passes after; gate confirmed the mic track goes live → ended

Also fixed

Auth / security — deleted users kept up to 30 days of access (no revocation on any surface, including the capture socket); API_AUTH_SECRET="" booted and made admin tokens trivially forgeable; 30-day bearer tokens were written to unrotated container logs in cleartext; Postgres and LiteLLM published on 0.0.0.0 with hardcoded tenir/tenir; a NUL byte in a REST id returned 500 with a traceback.

Correctness — an unbounded backoff-free re-login storm (167 logins in 15 s against a real api, even/ + veiller/); rows stranded live by an OOM kill or reboot never healed; displayServerUrl silently upgraded ws://wss:// and signed Android users out; any transport failure showed mobile users an empty login screen; three web panels reported "everything is fine" while the API was failing; deleting the conversation you were recording was offered unwarned and discarded everything said afterwards.

ConfigAPI_STT_BACKEND=banana booted "healthy" then failed every session; API_AUTH_TOKEN_TTL_SECONDS=0 issued already-expired tokens; probe interval 0 gave a falsely-green /status.

Release pipelineevery minor CHANGELOG rollup has been empty since 0.2.0 (see CHANGELOG.md: 0.2.0–0.6.0 all say "No changes." across 117 tags). release.yml passed the post-bump base to changelog-cli.js, so the start tag was the line being opened — one that doesn't exist — and the range was empty. Also: schema.sql ships inside the api image but mapped to no component, so a schema-only merge cut no release and never reached a deployment; version.compare could be swapped for a lexical compare with all 42 tests green.

Infra — the root compose file could not start on Linux/macOS at all (invalid volume specification on the Windows bind mounts), so the README quick start was broken and the stack could not be QA'd; .env rode along in every build context; no restart policy, no log rotation, and a 10 s stop grace that SIGKILLed the api mid-persist.

What the gate caught in my own fixes

Its FAIL verdict was correct and is worth reading:

  • npm run typecheck exited 2Core and Web would have been red on this PR. My own new test didn't compile. I missed it because I ran the gate as ... | tail, and a pipe swallows the exit status. qa.md now says to check $?.
  • My /ready healthcheck was a regression, not a fix. This host runs autoheal in label=all mode, so a Postgres blip bounced the api every ~60–75 s — each bounce killing live captures and their audio, i.e. the exact loss two other fixes here exist to prevent. Reverted to /health, with the reasoning written down since "probe /ready instead" is the obvious next idea.
  • The token redaction was bypassable twice. ?%74oken= authenticates (Starlette decodes query keys) and evaded the literal regex. I fixed the regex — and the container still leaked it, because the filter kept a "token=" in arg fast-path guard that skipped the record before the corrected regex ran. The function's unit test passed the whole time.
  • 8 of 22 mutations of my fixes survived their suites, and three fixes had no test at all. All now covered, each verified by reverting the fix and watching the new test go red.

Verification

All by exit code, not tail output.

  • api: 474 passed, 95.14 % coverage (85 % gate). npm typecheck / test / build all exit 0 (client-core 137, even 292, mobile 163, web 115). veiller: typecheck + 118 tests + build. Release scripts: 48.
  • Live stack (rebuilt image, docker-compose.qa.yml): scripts/functional_test.py 10/10; a cross-client walk (glasses-shaped WS capture → web/mobile REST read, export, WAV download, range request, search, delete) 11/11.
  • Traversal: refused across 16 hostile id spellings against a real second household; the victim's WAV byte-identical throughout.
  • Grace window: real TCP abort with SO_LINGER 0, plus four grace-boundary offsets — one conversation, audio intact, no hang, no double-persist.
  • Stale sweep: real docker kill -s KILL mid-capture; the row that would have been stuck live came back ready with a back-dated ended_at. Idempotent; ready rows untouched.
  • Token redaction: 0 occurrences of a live token in the container log across token, %74oken, %74%6Fken, TOKEN.
  • Release APK: built, installed on the turma228 AVD, signed in over http://10.0.2.2, drove a capture to session … ready, no cleartext exception in logcat.
  • CHANGELOG: 0 entries → 10 entries; verified for patch, minor and major without triggering a real run.
  • Time bombs: both suites re-run under faked 2027/2028/2029 clocks and TZ=Pacific/Kiritimati / Pacific/Honolulu.
  • Compose: docker compose config renders the Windows deploy shape byte-identically when TENIR_DATA_DIR is unset.

Deliberately not done

  • The api image still runs as root. I built the non-root version; it broke audio writes against the pre-existing root-owned API_AUDIO_DIR bind mount, and the new /ready probe caught it. Shipping it would silently lose recordings on the deployed host until someone chowned the data dir. Needs an entrypoint that fixes ownership before dropping privileges, plus a release note — noted in api/Dockerfile and qa.md.
  • Feature-shaped gaps (history pagination, export UI, change-password, deep-linkable detail routes), the remaining #token= exposure into a signed-out browser (needs a server-issued one-time code), plaintext credential storage on the clients, and the LOW UX items. All in qa.md with repro steps.

Verified against the real models

The GPU STT server came back mid-review, so this is no longer a stub-only pass. Everything below ran against the real nvidia/parakeet-tdt-0.6b-v3 (TrueNAS :9401) and the real gpt-oss:120b (:9402), driven with synthesized speech — no household audio was used.

  • Transcription: three spoken turns → one final each, first partial under 3 s, 3.2 % word error against the script (the only miss: "Tenir" heard as "Tenure"). Segments time-ordered and non-overlapping, no duplicates, searchable by a spoken word, audio retained and replayable as a valid WAV spanning the session.
  • The critical grace-window fix, with real audio: a socket aborted with SO_LINGER 0 and no close frame — a dead BLE link — still finalized, with its real transcript and audio intact.
  • The traversal fix against a real recording: the victim's WAV is byte-identical after the attack; the id is neither echoed back nor treated as a resume.
  • Real cues: an accurate, grounded cue generated off a spoken exchange.
  • Real translation: a Spanish turn detected as es and translated to English, persisted on its segment, with translation.done closing the run.

This exposed two real gaps, now fixed (last commit):

  1. Deleting a user closed their session but not their socket. The first round finalized the Session, which left the WS handler parked in receive() still feeding audio into a session that no longer existed — a removed member went on streaming until they chose to hang up. Only visible driving a real socket; TestClient runs WS and HTTP on separate event loops, so the unit test could not see it.
  2. scripts/functional_test.py could only pass against the stub. Its stated job is smoke-testing the deployed stack — which runs parakeet — but it sends silence and asserted a caption.final, so it reported a failure for the real backend behaving as designed. It is now backend-aware: 10/10 against parakeet.

One thing that looked like a product bug and was not: my first Spanish fixture wrote "manana" without the ñ to keep the source ASCII. Parakeet transcribed it faithfully as "maana", which drops stt/langid.py below its evidence floor, so the turn came back lang=None and no translation fired. The detector is correct; the fixture was wrong. qa.md warns about it.

Still not verified

Music ID (needs a song playing; shazamio reaches an external service). iOS (no Xcode project in the repo), physical G2 hardware, non-Chromium browsers, and the release workflow end to end (simulated locally against real tags; nothing pushed or tagged).

qa.md

The ticket asks for whatever makes the next pass easier. qa.md carries the setup that actually works (stub STT yields captions from silence — the single most useful fact), the compose overlay, per-agent project names, a browser without sudo, the emulator incantation, the Veiller simulator gotcha, the traps that cost this pass real time, and the full triaged backlog.

xerhab added 10 commits August 8, 2026 19:10
Three things found standing the whole stack up for the first QA pass:

* api/tests/test_cue_rss.py pinned its feed fixture to a fixed 2026-07-24
  pubDate, but ingest_once() also PRUNES at keep_days=14 — so the test began
  failing on 2026-08-08, 14 days after it was written, on main and with no
  code change. Re-stamp the ingest fixture relative to now; the parse tests
  that assert an exact date keep the fixed one.

* docker-compose.yml is the deployed shape: `C:/docker/tenir/...` bind mounts
  and a local GPU parakeet server. Neither exists on a Linux dev box, so the
  stack could not be brought up to QA it at all. docker-compose.qa.yml is an
  overlay that swaps bind mounts for named volumes, drops the GPU service,
  moves the published ports off the defaults and runs under its own project
  name, so a QA stack can never adopt or clobber a real one.

* scripts/functional_test.py hardcoded localhost:8080 (now TENIR_BASE) and
  raced the server on `session.end`: finalization is offloaded to threads, so
  a GET issued the instant the socket closes legitimately reads "live". Poll
  for "ready" instead of asserting on the first read.

Verified: api 430 passed (95% cov); npm typecheck/test/build green; veiller
115 bun tests green; suites re-run under faked 2026-12/2027/2028 clocks and
under UTC+14/-10 timezones to rule out further time bombs; functional_test.py
10/10 against the running stack.
Two auth/tenancy holes the QA pass reproduced against a running stack.

1. HIGH — a client-supplied `sessionId` could address another household's
   retained audio. `session.start` takes an optional resume id and it was used
   verbatim as both the conversation key and the audio object key
   (`{household}/{id}.wav`). An id shaped `../other-hh/<their-id>` never leaves
   the audio root, so the store's escape check passed it: the api read that
   household's WAV back as this session's resume offset (leaking its duration,
   and reporting resumed=true), then on session.end prepended and rewrote it.
   Reproduced end to end — household B doubled household A's stored recording,
   64044 -> 128044 bytes, md5 changed.

   Fixed at two layers. The WS boundary now honours only ids the server could
   have issued (canonical uuid4 text); anything else is logged, counted
   (`sessions.bad_resume_id`) and dropped, so the client still gets a working
   session under a fresh id. The audio store independently refuses a key that
   is not exactly one household segment plus one id segment — `resolve()`
   collapses `..` before any depth check can see it, so the raw segments are
   validated first.

2. MEDIUM — deleting a user did not revoke their token. Principals resolved on
   signature + expiry alone, and tokens last 30 days by default (sliding
   renewal keeps an active device's fresh indefinitely), so a removed member
   kept full household access for up to a month. Every authenticated entry
   point — REST, the query-token audio route, and the WS handshake — now
   resolves through `principal_from_live_token`, which also checks the account
   still exists. Renewal already did this lookup; it was the base access path
   that didn't.

Tests: new `test_session_id_isolation.py` drives the real attack over the WS
and asserts the victim's audio is byte-identical, plus store-level cases for
the sideways hop and the key builder. `test_deleted_user_token_is_revoked_
immediately` covers REST, the query-token route and the socket. The store
tests fail against the old code and pass against the new; the previous test
that asserted a deleted user's token still worked now asserts it doesn't.

api: 436 passed, 95.38% coverage. Re-ran the live repro against the fixed api:
the traversal id is refused, B gets a server-issued uuid, A's file untouched.
…K-236)

The critical one first.

* CRITICAL — a dropped session that nobody resumed was never finalized and its
  audio was thrown away. `_grace_close()` calls `close()`, and `close()`
  cancelled `self._grace_task` unconditionally — i.e. the task that was calling
  it. The CancelledError landed on the first await inside close() (the pump
  join), so `_persist()` never ran: the conversation stayed `live` forever and
  the entire retained audio buffer went with the process. Reproduced with 64 kB
  of captured audio discarded. This is precisely the path even/README and
  even/src/lens/controller.ts document as the safety net for an abnormal exit —
  phone out of range, app killed, dead BLE link — so every one of those lost the
  recording. close() now spares the grace task when that task IS the caller.

* Rows already stranded `live` by an OOM kill or host reboot never healed —
  nothing revisited them, so they showed as permanently recording in every
  client's history. `finish_stale()` sweeps them once at startup, before any new
  session registers; the SQL version back-dates `ended_at` from the last
  segment's offset rather than stamping "now".

* Bearer tokens were written to the access log in cleartext. The WS handshake
  and the audio download both carry the token in the query string (neither
  caller can set a header), and uvicorn logs the full request line — so every
  connect and every download put a live 30-day credential into an unrotated
  container log. The URL constraint is real, so redact on the way out instead.

* `assert_secure_auth_config` only compared against the literal shipped default,
  so `API_AUTH_SECRET=""` booted — and an empty HMAC key makes an admin token
  for any household trivially forgeable. Now requires a non-blank secret of at
  least 32 chars.

* Config accepted values that boot "healthy" and fail as something else:
  `API_STT_BACKEND=banana` served a clean /status and then failed every session
  with a generic "could not start session"; `API_AUTH_TOKEN_TTL_SECONDS=0`
  issued a token from /auth/login that was already expired on the next request;
  `API_STATUS_PROBE_INTERVAL_SECONDS=0` left /status green with nothing probed.
  The backend selector and every duration are now validated at config time,
  matching what the persistence and audio selectors already did.

Tests: test_qa_hardening.py covers all five, including that an explicit close
still cancels a pending grace task. The conftest secret grew to satisfy the new
minimum. api: 465 passed, 95.31% coverage.
…(XERK-236)

CRITICAL

* Android's release APK could not reach any http:// server. The release
  manifest declared neither `usesCleartextTraffic` nor a network security
  config, and targetSdk 34 means the platform blocks cleartext by default —
  only the DEBUG manifest allowed it. So the APK that CI publishes, and that
  the in-app updater installs, could not talk to the project's own documented
  deployment (`docker compose up` on :8080 over http, and the seeded
  ws://localhost:8080/ws): sign-in failed with a bare "could not reach the
  server" and no request ever left the device. Invisible in development,
  because `npm run android` builds debug. Adds a network security config that
  permits cleartext deliberately — the server is arbitrary user-supplied
  infrastructure we cannot pin.

* Any link could silently switch the signed-in web account. `adoptTokenFromUrl`
  took any `#token=` value, overwrote the stored token with no validation or
  consent, and scrubbed the fragment so nothing looked wrong. The victim's
  history vanished, everything they recorded afterwards landed in the
  attacker's household, and `#token=garbage` destroyed a working session just
  as quietly. It now refuses to replace an existing session (the XERK-82
  glasses handoff into a signed-out browser still works), and strips the
  fragment either way. The residual — a handoff link logging a fresh browser
  into the attacker's account — needs a server-issued one-time code; filed in
  qa.md.

HIGH

* A fatal error left the microphone open with the UI still claiming to record.
  `captureSession` recorded the message and nothing else, so `running` stayed
  true: Stop/Pause stayed up, the browser's recording indicator and Android's
  mic foreground service stayed lit, and the glasses lens read "listening…"
  while PCM streamed into a socket that no longer had a session. For a product
  whose promise is "recorded and stored", believing you are being recorded
  while nothing is captured is the worst failure available. A fatal error now
  tears the capture down through the normal stop path. The two glasses clients
  additionally stop on any non-auth error that arrives before a session exists
  — which is exactly what the api sends when the STT backend is down.

* An unbounded, backoff-free re-login storm. `connect()` reset the one-shot
  `reauthAttempted` guard, and the unauthorized handler calls `connect()` after
  a silent re-login — so the guard was re-armed every time round the loop. A
  server that keeps rejecting the token got 167 logins + 167 upgrades in 15 s
  (~5000 in 10 s with no network latency). `onReady` already resets the guard,
  which is the correct place: a session that actually started is the only proof
  the re-auth worked. Same bug, same fix, in even/ and veiller/.

* `displayServerUrl` dropped an insecure `ws://` scheme, but a bare host
  normalizes back to `wss://` — so pressing Connect in Settings without editing
  anything silently upgraded a plain-HTTP self-hosted server and signed the
  user out. The first-run seed had the same trap.

* Mobile treated any transport failure as "signed out": a briefly-unreachable
  server dropped the user onto an empty login screen, though their token was
  fine and relaunching later signed them straight back in. It now says the
  server is unreachable and offers a retry.

* Three web panels claimed everything was fine while the API failed: Status
  rendered "no components are configured to monitor" on a 500 (the one screen
  that exists to report trouble), Users rendered a blank page on any load error
  (an admin reads that as "my household is empty"), and a failed delete left a
  phantom row that could never be cleared. Deleting the conversation you are
  recording right now was also offered unwarned — it took the row out from
  under the running session and discarded everything said afterwards; the
  control is now disabled while a conversation is live.

Verified: npm typecheck/test/build green (client-core 137, even 288, mobile
163, web 108); veiller 115 bun tests + build. The captureSession and web
config regressions fail against the unfixed code. Rebuilt the app image and
re-ran both end-to-end suites against the live stack: functional_test 10/10,
cross-client walk 11/11. Token redaction confirmed in the running container's
logs — the real token appears 0 times, every request line reads
`token=<redacted>`.
…ERK-236)

Release pipeline

* Every minor CHANGELOG rollup has been empty since 0.2.0 — see CHANGELOG.md,
  where 0.2.0 through 0.6.0 all read "_No changes._" across 117 tags.
  changelog-cli.js documents BASE_MAJOR/BASE_MINOR as the PRE-bump line (the one
  being closed), but plan.js exported the post-bump base, so the start tag was
  the line being OPENED — a tag that does not exist yet — and the range fell
  back to empty. Proven both ways: base 0.7 gives "0 entries since v0.7.0",
  base 0.6 gives "10 entries since v0.6.0". plan.js now also exports the
  pre-bump base and release.yml passes that to the rollup, while VERSION still
  gets the bumped one.

* schema.sql mapped to no component, so a schema-only merge cut no release and
  never reached a running deployment — even though api/Dockerfile bakes it into
  the image and the store applies it on every pool open (that is how an existing
  data dir gains an additive column). It now maps to api, triggers a release,
  and runs the api gate, which already has tests for it.

* docker-compose*.yml, schema.sql and litellm/config.yaml ran under no PR gate
  at all. Added to api.yml's paths.

* version.compare could be swapped for a lexical string compare with the whole
  42-test suite staying green, despite the code comment ("NOT lexical — v0.3.10
  > v0.3.9") calling out the hazard. Lexical ordering picks v0.1.9 over v0.1.29,
  which hands the release the wrong diff range and refuses legitimate versions.
  Pinned with double-digit cases; the mutation now fails 2 tests.

* The release.yml/changes.js drift guard parsed only consecutive `- "…"` lines,
  so a comment inside the paths list silently truncated it — hiding the very
  drift the test exists to catch. It now skips comments.

* Pinned hadolint (was `:latest` in a gate) and added least-privilege
  `permissions: contents: read` to core.yml and contract-drift.yml, the only two
  workflows without one.

Deploy

* The root compose file could not start on Linux or macOS at all: every bind
  mount was a literal Windows path, so the README's own quick start died with
  "invalid volume specification". Parameterized as ${TENIR_DATA_DIR:-C:/docker/
  tenir}, which leaves the Windows deploy host byte-identical.

* Postgres and LiteLLM published on 0.0.0.0 with hardcoded tenir/tenir and no
  TLS — every household's transcripts and the users table were readable and
  writable from the LAN. Both now bind loopback by default; nothing outside the
  compose network needs them.

* The app healthcheck probed /health, which is pure liveness and answers "ok"
  with Postgres down — so the container stayed green while logins hung 30 s and
  500ed, and autoheal and depends_on saw healthy. It probes /ready now. (This
  immediately paid for itself: it caught a non-root experiment breaking audio
  writes, which the old check would have reported as healthy.)

* Added restart: unless-stopped (nothing came back after a host reboot), 10m×5
  log rotation (json-file was unbounded, and the logs carry request lines), and
  stop_grace_period: 30s — the api waits up to 15 s finalizing a session and
  docker's default 10 s SIGKILLed it mid-persist.

* .env rode along in every build context (verified with a COPY . probe).
  Excluded, with the Android keystore.

* Dropped the setuptools build tree and the duplicate source copy from the
  runtime image. Running as a non-root user is NOT included: API_AUDIO_DIR is a
  root-owned bind mount on deployed hosts, so changing the runtime uid silently
  breaks audio writes until an operator chowns it. Filed in qa.md with what a
  safe version needs.

* QA overlay: bound to loopback, documented that `up --build` builds the GPU
  image despite replicas: 0, and dropped a dead depends_on override.

Verified: release-script tests 42→44 pass; lexical-compare mutation now caught.
Rebuilt the image and re-ran everything against the live stack — api 465 passed
(95.31%), functional_test 10/10, cross-client 11/11, /ready true, healthcheck
healthy. Hard-killed the container mid-capture with SIGKILL and restarted: the
row that would have been stuck "live" forever came back `ready` with a
back-dated endedAt.
The ticket asks for whatever makes the next QA pass easier. Two things do:
the setup that actually works (model-free stub STT captions from silence, the
compose overlay, per-agent project names, a browser without sudo, the emulator
incantation, the Veiller simulator gotcha), and the traps that cost this pass
real time (time-bomb fixtures, session.end finalizing asynchronously, jsdom
dropping a frame sent in the same tick as close()).

It also carries the triaged backlog: every reproduced finding the pass did not
fix, with file references — feature-shaped gaps (no pagination, no export UI,
no change-password), security follow-ups (the remaining #token= exposure,
plaintext credentials on the clients, the root image), correctness/UX defects,
and the test-suite escapes found by mutation testing.
The adversarial gate returned FAIL on my own changes. Everything it found:

* **`npm run typecheck` exited 2** — `Core` and `Web` would have gone red on the
  PR. The regression test I added for the fatal-error fix didn't compile (`code`
  widened to `string`; only `type` had `as const`). I missed it because I ran
  the gate as `npm run typecheck 2>&1 | tail`, and a pipe swallows the exit
  status. Now typed against the generated `ErrorMessage`, and qa.md says to
  check `$?`.

* **The `/ready` healthcheck was a regression, not a fix.** This host runs
  autoheal in `label=all` mode, so unhealthy means restart — a Postgres blip
  bounced the api every ~60-75 s for the length of the outage, and every bounce
  killed live captures and their in-memory audio: exactly the loss the
  grace-window and stale-sweep fixes exist to prevent. Reverted to `/health`
  with the reasoning written down, since "probe /ready instead" is the obvious
  thing for the next person to try.

* **The token redaction was bypassable, twice.** Starlette percent-decodes query
  keys, so `?%74oken=` authenticates and did not match the literal `token=`
  regex — a live 30-day admin token in the container log. I fixed the regex to
  match on the decoded parameter name, and the container STILL leaked it,
  because the filter kept a `"token=" in arg` fast-path guard that skipped the
  record before the corrected regex ever ran. The function's own unit test
  passed throughout. Both fixed; the new test drives the whole filter, and the
  running container now shows 0 occurrences across `token`/`%74oken`/`%74%6Fken`
  /`TOKEN`.

* **`API_SESSION_RESUME_GRACE_SECONDS=0` became a hard boot failure.** 0 means
  "resume disabled" — config.py documents it and `detach()` has a branch for it,
  which my validator turned into dead code. Now `>= 0` for that field alone.

* **`_persist()` was unguarded**, so an audio-store failure propagated out of
  `close()` and skipped `finish()` — losing the recording AND the record of it.
  Audio retention is now best-effort; finalizing is not.

* **A NUL byte in a REST conversation id** reached psycopg and returned 500 with
  a traceback. The WS path got a shape guard in the first round and the REST
  path got nothing; now both reject a non-uuid id as 404.

* **Deleting a user didn't end their in-flight capture socket** — auth is
  checked at the handshake only, so a removed member kept recording into the
  household. Their live sessions are now closed (finalized normally, so nothing
  captured is lost).

* **Android's first run opened on "Can't reach your server"** — telling a
  brand-new user they may still be signed in. Gated on a stored token existing.

* **8 of 22 mutations of my own fixes survived their suites**, and three fixes
  shipped with no test at all. Added: web History live-delete + failed-delete
  reload, Status non-network failure, Users load error; even/ and veiller/
  reauth-storm and non-auth-error teardown; plan.js's outputs (the CHANGELOG fix
  itself was untested, so the "_No changes._" bug could walk right back in); and
  the redaction *installation* and startup stale-sweep *wiring*, which were both
  correct and unreachable-if-unwired. Each was verified by reverting the fix and
  watching the new test go red.

* Two API tests **hung** instead of failing when mutated, burning the CI timeout
  rather than reporting; their receive loops now have deadlines. `finish_stale`
  now documents that it assumes one api process per database.

Also verified by the gate and unchanged: the traversal fix against 16 hostile
ids and a real second household, the grace-window fix against a real TCP abort
with SO_LINGER 0 and at four grace-boundary offsets, deleted-token revocation on
all three surfaces, the stale sweep against a real SIGKILL, and the release APK
signing in over http:// on the emulator and driving a full capture.

Gates (by exit code, not tail): api 467 passed / 95.12% (gate 85%); npm
typecheck, test and build all exit 0 (client-core 137, even 292, mobile 163,
web 115); veiller typecheck + 118 tests + build; release scripts 48.
functional_test 10/10 and the cross-client walk 11/11 against the rebuilt
image; NUL-byte paths now 404 where they were 500.
Both were mine, and both were caught by gates doing their job:

* `hadolint/hadolint:2.14.0` does not exist — I pinned a tag I hadn't checked,
  and the lint job died on `manifest unknown` rather than on anything about the
  Dockerfile. Pinned to `v2.12.0`, which is real; verified locally against both
  Dockerfiles (exit 0, warnings only).

* `api/tests/test_no_committed_secrets.py` flagged the percent-encoded literal
  in my new redaction test as a generated-looking password. That scanner is
  correct — an opaque `'%74%6Fken'` is exactly the shape it exists to catch — so
  the encoded spellings are now built from "token" instead of written out, which
  also reads better.

api: 471 passed, 95.12% coverage. npm typecheck/test and the release scripts all
exit 0.
… (XERK-236)

The GPU STT server came back, so the biggest hole in this PR's verification is
now filled: everything below ran against the real `nvidia/parakeet-tdt-0.6b-v3`
(TrueNAS, :9401) and the real `gpt-oss:120b` (:9402), driven with synthesized
speech rather than the silence the stub accepts. No household audio was used.

Two real gaps this exposed:

* **`revoke()`: deleting a user closed their session but not their socket.** The
  first round finalized the Session, which left the WS handler parked in
  receive() and still feeding audio into a session that no longer existed — a
  removed member went on streaming until they chose to hang up. Only visible
  driving a real socket; the unit test couldn't see it because TestClient runs
  WS and HTTP on separate event loops. The endpoint now registers how to drop
  its transport, and revoke() finalizes FIRST and hangs up second, so the
  revoked member keeps what they had already said. Verified live: socket closed
  with 1008, token 401s, recording finalized.

* **`scripts/functional_test.py` could only pass against the stub.** Its whole
  stated job is smoke-testing the DEPLOYED stack — which runs parakeet — but it
  sends silence and asserted a `caption.final`, so it reported a failure for the
  real backend behaving exactly as designed. It now reads `stt_backend` from
  /health and asserts what is true of each: 10/10 against parakeet, and it says
  plainly that silence exercises the capture path, not accuracy.

What the real stack now demonstrates, end to end:

* three spoken turns → one final each, first partial under 3 s, **3.2 % word
  error** against the script (the only miss: "Tenir" heard as "Tenure"),
  segments time-ordered and non-overlapping, transcript searchable by a spoken
  word, audio retained and replayable as a valid WAV spanning the session.
* **the critical grace-window fix, with real audio**: a socket aborted with
  SO_LINGER 0 and no close frame — a dead BLE link — still finalized, with the
  real transcript ("This recording must survive an abrupt disconnection.") and
  its audio intact.
* **the traversal fix against a real recording**: the victim's WAV is
  byte-identical after the attack, the id is neither echoed nor resumed.
* **real cues**: an accurate, grounded cue off a spoken exchange about Jupiter.
* **real translation**: a Spanish turn detected as `es` and translated to
  "Hello, good morning. We're going to the beach tomorrow afternoon with the
  family.", persisted on its segment, with `translation.done` closing the run.

One thing that looked like a product bug and was not: my first Spanish fixture
wrote "manana" without the ñ to keep the source ASCII. Parakeet transcribed it
faithfully as "maana", which drops `stt/langid.py` below its evidence floor, so
the turn came back lang=None and no translation fired. The detector is right;
the fixture was wrong. qa.md now warns about it, and records that STT and the
LLM live on two different hosts (the workspace CLAUDE.md has the STT address
wrong).

api: 474 passed, 95.14% coverage. npm typecheck/test, veiller and the release
scripts all exit 0.
@xerhab
xerhab merged commit 15bffbc into main Aug 9, 2026
11 checks passed
@xerhab
xerhab deleted the XERK-236 branch August 9, 2026 11:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant