How to read this page
Every number quoted elsewhere in this wiki should be checkable here. Each block below names its provenance:
- measured here — a command run while writing this page, output quoted as it came back.
- build phase — a receipt from the build and deploy work of 2026-08-20, recorded but not re-run here. Named as such so nobody mistakes it for a standing gate.
- not proven — an unverifiable-on-this-host claim, with the reason it cannot be closed. These are listed, never smoothed over.
Dates and counts are of 2026-08-20. A later change invalidates this page rather than updating it silently; the reproduce block at the bottom regenerates every number.
Gate results
Parser contract, build phase:
npx vitest runin the app directory: 1 test file, 33 of 33 tests passed.- the test subject is a frozen fixture, never the live document:
src/lib/mdframes/fixtures/qwizz-tour.md. - contract asserted by those tests, on the fixture:
- 76 sections: 11 at level 2, 65 at level 3, no h1.
- 324 frames: 321 image, 2 table, 1 code.
- of the images, 316 resolve under
/shots/and 5 under/boards/. - 66 frames carry the badge
finding; the sections carry 139 findings between them. - all 321 image frames carry a non-empty
sub. - with
includeText: true: 77 sections and 381 frames, the extra 57 being text frames.
npm run build: exit 0, zero accessibility warnings.
Fixture identity, measured here:
$ cd /home/loca/dev/tandem-audit
$ md5sum src/lib/mdframes/fixtures/qwizz-tour.md
a15fb4f0ffb6d07e8256402563193d15 src/lib/mdframes/fixtures/qwizz-tour.md
$ wc -l < src/lib/mdframes/fixtures/qwizz-tour.md
1619
That md5 is what makes the contract numbers durable: they describe one exact 1619-line byte sequence, so they never need re-baselining. md-parser explains the rules those numbers encode.
Live deck census
Measured here, from GET /api/decks/qwizz-2026-08-20:
deck id : qwizz-2026-08-20
sections : 76 by level: {2: 11, 3: 65}
frames : 323 {'code': 1, 'table': 2, 'image': 320}
images : 320 shots: 315 boards: 5 with sub: 320
finding badge: 65 section findings: 139
sample src : /api/decks/qwizz-2026-08-20/assets/boards/board-A-public.png
Assets on disk, measured here: 320 regular files under the deck’s asset directory, in exactly two
subdirectories, shots/ and boards/. The import that created them reported 320 copied and 0
missing (build phase), which agrees with the file count.
The live deck is one image, one shot and one finding badge below the fixture contract for a single
reason: the frozen fixture predates the withdrawal of v01-d-p02-player-intro.png from the source
gallery. Everything else matches, which is the whole point of freezing a fixture rather than
tracking a living document. index carries the line-level citation for the withdrawal.
Bus proof
One session carries the bus evidence: 127d38c42c95512f, 18 events, events.jsonl 1981 bytes,
doneSections reported as ["boards"] by GET /api/sessions (measured here). Every event kind the
bus defines except status was exercised.
| Kind | Seq | Produced by | Receipt |
|---|---|---|---|
| say | 1 | POST to the session events route | text tandem link up; also observed replayed on the SSE stream by curl -N. Not visually confirmed in the dialog rail. |
| nav | 2-4, 6-9, 11-13, 17 | the app’s own scroll tracking, not by hand | 11 events, section ids only |
| verdict | 5 | browser, real DOM click on the frame card icon button labelled Keep | verdict: keep on frame boards/1 |
| note | 10 | browser, the Note icon, text typed into the modal textarea, submitted with Ctrl+Enter | text ui smoke note on boards/1 |
| task | 14 | browser, the Task icon, title typed into the task title input, submitted with Enter | text ui smoke task on boards/1 |
| speech | 15 | POST to the session events route | text spoken smoke transcript. The event kind is proven, the microphone path is not. |
| route_done | 16 | POST to the session events route | section boards, which is why doneSections is ["boards"] |
| scribble | 18 | browser end to end: the Scribble icon, a stroke drawn with genuine pointer input, then Save scribble | file boards-1--18.png |
The scribble is the one event with an artifact, so it gets checked rather than trusted. Measured
here: boards-1--18.png is 82403 bytes, a 1500x2128 PNG with colour type 6, so it has a real alpha
channel. Image inspection finds one thin saturated crimson polyline forming a chevron — two straight
segments of uniform width meeting at an apex, no other marks, text or noise anywhere in the frame.
The stroke is there; the canvas is not blank.
The same event is the privacy receipt. events.jsonl for that session contains zero occurrences of
dataUrl or data:image (measured here by grep), and the whole file is 1981 bytes, which cannot
hold an 82 KB data URL under any encoding. Only the filename was logged.
Seq integrity, build phase: 60 concurrent appendEvent calls against one session produced seqs
exactly 1 through 60, with no gap and no duplicate. The mechanism is a per-session promise chain
that serialises the seq assignment and the append together (src/lib/server/store.ts:302-320).
Safety probes
Every probe below was measured here against https://tndm.loca.zone, with the response quoted as
received.
- import outside the dev root:
POST /api/decks/importwith{"md_path":"/etc/passwd","id":"probe"}- HTTP 400, body
{"ok":false,"error":"md_path must be inside /home/loca/dev/"} - the same for
/home/loca/.bashrc, one directory above the allowed root
- the containment gate proven by the errors that are not it:
{"md_path":"/home/loca/dev"}gives HTTP 400{"ok":false,"error":"md_path is not a regular file"}{"md_path":"/home/loca/dev/nope.md"}gives HTTP 400{"ok":false,"error":"md_path not found"}- both are downstream failures, so containment had already passed. The gate accepts inside the root and refuses outside it, rather than refusing everything.
- non-http(s)
md_url:POST /api/decks/importwith{"md_url":"file:///etc/passwd","id":"probe"}- HTTP 400, body
{"ok":false,"error":"md_url must be http(s)"}
- asset traversal, percent-encoded so the segments survive the client and reach the handler:
GET /api/decks/qwizz-2026-08-20/assets/%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd- HTTP 400, body
{"ok":false,"error":"resolved path escapes the data directory"} - the
..%2f..%2f..%2f..%2fetc%2fpasswdspelling returns the identical 400
- asset traversal, literal
../segments:GET /api/decks/qwizz-2026-08-20/assets/../../../../etc/passwd, with and without curl’s--path-as-is- HTTP 404, and the body is the application’s own not-found page, not a file. Worth stating
precisely: this one never reaches the asset handler. The service journal records it as
[404] GET /etc/passwd, so the dot segments were collapsed before routing and the traversal guard was never consulted. It is the router refusing the path. The guard is the probe above.
- malformed collab link:
POST /api/sessions/127d38c42c95512f/collabwith{"link":"https://example.com/not-a-collab-link"}- HTTP 400, body
{"ok":false,"error":"link is not a recognizable collab link"}, and the rejected input is not echoed anywhere in the response
- collab link not set on a session:
GET /api/sessions/127d38c42c95512f/collab- HTTP 200, body
{"link":null}
- prototype key offered as an event kind:
POST /api/sessions/127d38c42c95512f/eventswith{"actor":"agent","kind":"__proto__","text":"x"}- HTTP 400, body
{"ok":false,"error":"kind must be one of note|task|verdict|speech|say|scribble|nav|route_done|status"}
- prototype key offered as a verdict:
POST /api/sessions/127d38c42c95512f/eventswith{"actor":"user","kind":"verdict","verdict":"constructor"}- HTTP 400, body
{"ok":false,"error":"verdict must be 'keep', 'kill' or 'recapture'"} - both of the above pass because membership is tested with
Object.hasOwn, notin(src/lib/server/store.ts:269,:291)
- unknown session:
GET /api/sessions/deadbeefdeadbeef/events- HTTP 404, body
{"ok":false,"error":"no such session: deadbeefdeadbeef"}
- unparseable
after:GET /api/sessions/127d38c42c95512f/events?after=abc- HTTP 400, body
{"ok":false,"error":"after must be a non-negative integer"}
- attempt to mutate the log in place:
DELETE /api/sessions/127d38c42c95512f/events- HTTP 405, body
DELETE method not allowed. There is no edit or delete path for an event; the route file exports only GET and POST.
- a legitimate asset, as a control:
GET /api/decks/qwizz-2026-08-20/assets/boards/board-A-public.png- HTTP 200,
image/png, 329992 bytes
None of this is authentication. Every probe above was made without a credential of any kind, which is exactly the open question security describes.
Deploy probes
All measured here.
| Probe | Command | Result |
|---|---|---|
| service | systemctl is-active tndm | active |
| binding | ss -ltn | LISTEN 0 511 127.0.0.1:51818 0.0.0.0:* — loopback only, no wildcard listener |
| app over TLS | curl to https://tndm.loca.zone/ | HTTP 200, curl ssl_verify_result=0 |
| wiki over TLS | curl to https://wiki.tndm.loca.zone/ | HTTP 200, curl ssl_verify_result=0 |
| certificate | sudo certbot certificates | name tndm.loca.zone, ECDSA, domains tndm.loca.zone wiki.tndm.loca.zone, expiry 2026-11-18 07:46:12+00:00, reported VALID with 89 days left |
The two hosts share one certificate, so one renewal covers both. operations owns the unit, the vhosts and the renewal detail.
Not proven
These are open. Nothing below should be quoted as working.
- browser text to speech. The host has no audio device at all:
ls /dev/sndreturnsls: cannot access '/dev/snd': No such file or directory(measured here), andomp sayfails withno Linux playback backend available(build phase). Headless Chromium additionally ships zerospeechSynthesisvoices, so even a silent success cannot be distinguished from a no-op. What is proven is only the degradation path:ttsSupported()gates every call (src/lib/voice.ts:59-64) and the module is documented as degrading silently so callers render a disabled icon rather than catch errors (src/lib/voice.ts:8-10). Confirming speech needs a real desktop Chrome with audio. - push to talk. Headless Chromium exposes no microphone, so
SpeechRecognitioncannot be driven. Thespeechevent at seq 15 was posted over HTTP: it proves the event kind and that the bus stores it, and says nothing about the recogniser. - the dialog rail rendering an agent
say. Seq 1 was confirmed inevents.jsonland on the SSE stream, not visually in the rail. The rail code path exists and is cited in index; a screenshot of it does not. - a real collab room connection. What is proven: the three collab routes are live and answer JSON
(measured here, including
{"link":null}when unset), a malformed link is rejected with 400 without echoing the input, the link is stored on the session record and never written toevents.jsonl(src/routes/api/sessions/[id]/collab/+server.ts:12-14andsrc/lib/server/store.ts:360-379), and the relay origin does not block framing —curl -sI https://my.omp.sh/returns HTTP/2 200 with nox-frame-optionsand nocontent-security-policyheader of any kind (measured here). What is not proven: an actual room. That needs a live link from a running OMP collab session, which nothing on this host currently holds. The iframe mount at 383x811 with its open-in-new-tab, copy and unlink actions was observed during the build phase; what the frame displayed was not part of that receipt. - the count of accepted and rejected collab link forms. The build phase recorded 15 documented forms
accepted and 13 malformed ones rejected with 400, exercised against the exported
_parseCollabLink. That was a one-off run:npx vitest runcovers exactly one test file, the parser’s, so no checked-in test defends those counts. Treat them as a measurement, not a gate. The authoritative link-format reference isomp read omp://collab.md.
Reproduce
Everything on this page, in order. The app directory is the checkout of the SvelteKit app; S is a
session id.
cd /home/loca/dev/tandem-audit
# gates
npx vitest run # 1 file, 33/33
npm run build # exit 0, no a11y warnings
md5sum src/lib/mdframes/fixtures/qwizz-tour.md # a15fb4f0ffb6d07e8256402563193d15
wc -l < src/lib/mdframes/fixtures/qwizz-tour.md # 1619
# live deck census
python3 - <<'PY'
import json, urllib.request, collections
d = json.load(urllib.request.urlopen("https://tndm.loca.zone/api/decks/qwizz-2026-08-20"))
secs = d["sections"]; frames = [f for s in secs for f in s["frames"]]
imgs = [f for f in frames if f["type"] == "image"]
print("sections", len(secs), dict(sorted(collections.Counter(s["level"] for s in secs).items())))
print("frames", len(frames), dict(collections.Counter(f["type"] for f in frames)))
print("shots", sum("/shots/" in (f.get("src") or "") for f in imgs),
"boards", sum("/boards/" in (f.get("src") or "") for f in imgs),
"with sub", sum(bool((f.get("sub") or "").strip()) for f in imgs))
print("badged", sum("finding" in (f.get("badges") or []) for f in frames),
"findings", sum(len(s.get("findings") or []) for s in secs))
PY
find /home/loca/tndm/decks/qwizz-2026-08-20/assets -type f | wc -l # 320
# bus
S=127d38c42c95512f
curl -s "https://tndm.loca.zone/api/sessions/$S/events" # 18 events, seq 1..18
wc -c /home/loca/tndm/sessions/$S/events.jsonl # 1981
grep -c 'dataUrl\|data:image' /home/loca/tndm/sessions/$S/events.jsonl # 0
stat -c '%s' /home/loca/tndm/sessions/$S/scribbles/boards-1--18.png # 82403
# safety probes
p() { curl -s -w ' HTTP %{http_code}\n' "$@"; }
I=https://tndm.loca.zone/api/decks/import
H='content-type: application/json'
A=https://tndm.loca.zone/api/decks/qwizz-2026-08-20/assets
p -X POST $I -H "$H" -d '{"md_path":"/etc/passwd","id":"probe"}'
p -X POST $I -H "$H" -d '{"md_path":"/home/loca/.bashrc","id":"probe"}'
p -X POST $I -H "$H" -d '{"md_path":"/home/loca/dev","id":"probe"}'
p -X POST $I -H "$H" -d '{"md_path":"/home/loca/dev/nope.md","id":"probe"}'
p -X POST $I -H "$H" -d '{"md_url":"file:///etc/passwd","id":"probe"}'
p "$A/%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd"
p --path-as-is "$A/../../../../etc/passwd" -o /dev/null
sudo journalctl -u tndm -n 3 --no-pager -o cat # [404] GET /etc/passwd
curl -s -o /dev/null -w 'HTTP %{http_code} %{content_type} %{size_download}\n' \
"$A/boards/board-A-public.png"
p -X POST "https://tndm.loca.zone/api/sessions/$S/collab" -H "$H" \
-d '{"link":"https://example.com/not-a-collab-link"}'
p "https://tndm.loca.zone/api/sessions/$S/collab"
p -X POST "https://tndm.loca.zone/api/sessions/$S/events" -H "$H" \
-d '{"actor":"agent","kind":"__proto__","text":"x"}'
p -X POST "https://tndm.loca.zone/api/sessions/$S/events" -H "$H" \
-d '{"actor":"user","kind":"verdict","verdict":"constructor"}'
p "https://tndm.loca.zone/api/sessions/deadbeefdeadbeef/events"
p "https://tndm.loca.zone/api/sessions/$S/events?after=abc"
p -X DELETE "https://tndm.loca.zone/api/sessions/$S/events"
# deploy
systemctl is-active tndm
ss -ltn | grep 51818
curl -s -o /dev/null -w 'HTTP %{http_code} tls=%{ssl_verify_result}\n' https://tndm.loca.zone/
curl -s -o /dev/null -w 'HTTP %{http_code} tls=%{ssl_verify_result}\n' https://wiki.tndm.loca.zone/
sudo certbot certificates 2>/dev/null | grep -A5 'Certificate Name: tndm.loca.zone'
# the unprovable ones, for completeness: both of these are expected to fail here
ls /dev/snd
curl -sI https://my.omp.sh/ | grep -iE 'x-frame-options|content-security-policy'Where to read next
- quickstart — the shortest path from nothing to a session, if you arrived here first.
- api — the endpoint table and the event schema these probes exercise.
- agent-cookbook — the same API as runnable agent recipes, each verified against the instance.
- security — what the absence of authentication means and the options still open.
- operations — the unit, the vhosts, the data directory and the durability gaps.
- first-deck — the document behind the census numbers.
- md-parser — what the frozen fixture numbers actually assert.