In brief
- The feature isn't a chart annotation tool — it's a vault for the annotations traders already make in TradingView, because rebuilding that toolset in-house would be strictly worse than capturing the real thing.
- The schema is a generic `attachments` + `attachment_links` pair in its own `storage` schema, not `journal_attachments` — so a second consumer (trade notes) was added the same day with zero migrations.
- The browser uploads directly to a private Cloudflare R2 bucket via short-lived presigned URLs; the backend never sees the file bytes and never hands out R2 credentials.
- Every write re-derives its own storage key server-side and re-verifies the real object against what the client claims — the client is trusted for nothing, including its own upload.
A trader closes a losing short. Next morning, journaling it, the honest question is: was the setup actually there, or did I talk myself into it? Usually there's an answer — a TradingView chart with the order block marked, the liquidity sweep circled, the FVG boxed in. The chart that would settle the argument already existed.
It just wasn't attached to anything. It was a screenshot in a phone gallery, or a message sent to a Discord channel, disconnected from the journal entry it was evidence for. By the time it might have mattered, it was three scrolls deep and impossible to find.
That's the gap we closed this week: attach a TradingView screenshot directly to a journal entry, or to the exact trade decision — an entry, a stop-loss move, a partial close — that it's evidence for. The interesting part isn't the upload button. It's what we deliberately didn't build, and the shape of what we did.
Why not build a better whiteboard?
The obvious version of this feature is a canvas: let users draw zones, mark liquidity, box in FVGs, directly on LiquidMind's own charts. It's tempting because it looks like the "complete" solution.
It's also the wrong instinct. TradingView already has a decade of annotation tooling — Fibonacci tools, order block indicators, a drawing engine traders have muscle memory for. Re-implementing a worse version of that inside LiquidMind wouldn't add value; it would just be a second, inferior whiteboard competing with the one everyone already uses. Worse: it would be engineering effort spent recreating something that exists, instead of on position management, which is the actual product.
The annotations traders need already exist. They're drawn in TradingView, in the moment, with the right tools. The job isn't to give people a place to draw a worse version of that — it's to give the drawing they already made a permanent, queryable home next to the decision it explains. So the feature is a vault, not a canvas.
What's actually stored, and where
Binary bytes and structured data have different jobs, so they live in different places. Cloudflare R2 holds the image; PostgreSQL holds everything that answers "whose is this, what is it evidence for, and can this specific request see it."
The browser never routes the file through our backend at all:
The backend brokers exactly two things: permission and verification. Everything in between is a straight line from the browser to R2.
The upload itself is bounded on purpose: WebP, PNG or JPEG only; 8 MB max for the original, 500 KB for a client-generated thumbnail; 20 attachments per entity; the presigned PUT expires in 5 minutes, the presigned GET used for viewing in 15. None of that is arbitrary caution — it's sized around what a chart screenshot actually is, so a legitimate upload never notices the ceiling.
Why isn't there a journal_attachments table?
The lazy schema for "let me attach a file to a journal entry" is a journal_entry_id foreign key on a journal_attachments table. It would have shipped a day faster.
It also would have been wrong the moment the second use case showed up — and it showed up the same afternoon. Once screenshots existed for journal entries, the obvious next ask was attaching one directly to a trade decision: the exact moment you moved a stop-loss to breakeven, or took a partial. That's a different table entirely — decisions live in user_decisions, not journal_entries — and a foreign key doesn't generalize; it forks.
So the actual schema is two generic tables:
CREATE TABLE storage.attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL REFERENCES auth.users(id),
storage_key TEXT NOT NULL,
thumbnail_storage_key TEXT,
mime_type TEXT NOT NULL,
file_size_bytes BIGINT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'private', -- private | shareable | public
deleted_at TIMESTAMPTZ
);
CREATE TABLE storage.attachment_links (
attachment_id UUID NOT NULL REFERENCES storage.attachments(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
entity_type TEXT NOT NULL, -- 'journal_entry' | 'user_decision' | …
entity_id TEXT NOT NULL,
relation_type TEXT NOT NULL, -- 'before_entry' | 'after_exit' | 'during_trade' | …
sort_order INT NOT NULL DEFAULT 0,
UNIQUE (attachment_id, entity_type, entity_id, relation_type)
);attachments doesn't know what it's attached to. attachment_links is the only table that does, and it does it generically — entity_type + entity_id, not a column per feature. Adding trade-decision evidence didn't touch the schema at all: it was one new value in the set of supported entity_types and one ownership check (does this user_decision belong to this user?) in the backend. No migration, no new table, no special-cased upload path.
It also means one screenshot can carry structured context about why it was taken. The trade-note modal tags each upload with a relation_type derived from what you were doing: ENTRY maps to before_entry, a partial or full close maps to after_exit, everything else falls to during_trade. That's not decoration — it's the difference between "here's a screenshot" and "here's the screenshot that was true the moment you entered."
If you've read the post on the POI state machine, this is the same house rule wearing different clothes: don't build a mechanism whose shape depends on how it happens to get used. One pair of tables serves journaling today and will serve positions, orders, and AI-generated reports later, without anyone touching a CREATE TABLE statement to get there.
Trusting the browser exactly as much as necessary — which is not at all
Every step above assumes the client is adversarial, because it has to. The browser chooses which bytes to send; nothing forces it to send what it claimed it would.
So the backend never accepts a storage path from the client. It builds one, server-side, from (user_id, attachment_id, mime_type) — users/{user_id}/attachments/{attachment_id}/original.webp — using a strict character whitelist, which makes path traversal a non-starter by construction rather than a validation you have to remember to write. On confirm, it rebuilds that same key and compares:
expected_key = build_storage_key(user_id, attachment_id, "original", ext)
if client_supplied_key and client_supplied_key != expected_key:
raise Forbidden() # a client cannot register an object it doesn't ownThen it goes further than comparing strings — it issues a HEAD request against R2 and takes the real object size as the only size that counts. A client that lies about a file being 2 MB when it's actually 40 MB doesn't get to slip past validation just because the request body said otherwise; the object itself is the source of truth. Every read is scoped the same way: get_attachment(id, user_id) filters by owner in the query, not after the fact, so a wrong ID returns a 404 indistinguishable from a real one — nothing tells an attacker whether the resource exists at all.
None of this is exotic. It's the unglamorous work of not trusting a single thing the client says about itself, applied consistently at every step instead of at the one step someone remembered to secure.
The security policy that blocked its own feature
Shipped, tested locally, deployed — and the first real upload attempt from a production browser went nowhere. No server error. No 403. Just silence, and a screenshot that never appeared.
The culprit was our own Content-Security-Policy header, which had never heard of Cloudflare R2. connect-src didn't list *.r2.cloudflarestorage.com, so the browser refused to even open the connection for the presigned PUT — not a permissions problem on our backend, a policy the browser itself enforces regardless of what our server would have allowed. A second, smaller version of the same issue: local thumbnail previews use URL.createObjectURL(), which produces a blob: URL, and img-src didn't allow blob: either.
Both are one-line fixes. Both are also the point: a strict CSP doesn't know the difference between an attacker's exfiltration attempt and your own legitimate new integration. It blocks by default and makes you explicitly widen the door for every external domain you decide to trust — including the one you just built a feature around. That's the correct failure mode for a security header. It's also a standing tax on every new external service this platform ever talks to, and worth naming so the next one doesn't cost an afternoon of "why is nothing uploading" before someone thinks to check the console.
Architectural decisions, in one place
Scattered through the sections above; worth having them as a single scannable list, because each one was a deliberate rejection of the easier default.
| Decision | Default we rejected | Why |
|---|---|---|
Two generic tables (attachments, attachment_links) in their own storage schema |
A journal_attachments table with a journal_entry_id foreign key |
The FK forks the moment a second consumer appears — which it did, same day, for trade decisions. |
Polymorphic entity_type + entity_id on the link, not a column per entity |
A nullable journal_entry_id / position_id / order_id on attachments |
One attachment can be linked to more than one entity, and adding an entity never touches the schema. |
| Browser uploads straight to R2 via presigned URLs | Proxying the file bytes through our own API | The backend's job is authorization and verification, not moving bytes it has no reason to hold. |
Storage keys built server-side from (user_id, attachment_id, mime_type) |
Accepting a client-supplied path or key | Makes path traversal and cross-user writes impossible by construction, not by a check someone has to remember. |
HEAD-verifying the real object at confirm time |
Trusting the size/type the client declared in the request | A client can say anything about a file; only the object itself is authoritative. |
visibility column shipped now, sharing UI shipped never (yet) |
Building the share-link flow alongside the upload flow | The schema had to be right on day one; the UI for a feature nobody asked for yet doesn't. |
One vault, not a table per feature
The schema's visibility column already supports shareable and public, and there's no sharing UI yet. That's deliberate, not unfinished — the same restraint this blog keeps coming back to: build the part that has to be right the first time (ownership, storage keys, the shape of the schema), and let the part that's easy to add later — a share link, a public embed — stay easy to add later, instead of shipping UI for a need nobody's asked for yet.
What exists today: a trader closes a position, opens the reasoning timeline, and the screenshot that shows the actual order block is sitting right next to the SL adjustment it justified — not in a gallery, not three scrolls into a Discord channel. The chart that would have settled the argument now settles it, because it's attached to the exact decision it's evidence for, and it took the app zero new tables to get there when the second use case showed up uninvited on day one.

Public pen name of LiquidMind's founder and builder. Writing first-hand engineering notes and transparent performance reviews from the system's internal ledger.
Related Posts
Stay Liquid
New posts on transparency, engineering, and the LiquidMind thesis — no noise.
Loading discussion…