The CV says every word on it is a row read from an API. This is that claim in detail: the tiers a request passes through, what each of the four verbs does on the way down and back — pick one and the diagram traces it — and the schema at the bottom of it, reflected out of the database rather than drawn from memory.
The system, end to end
Five tiers, and only two boxes in the whole diagram ship JavaScript to the browser. The middle two are separate columns because of the seam between them: nothing in apps/web imports the API client or knows the service’s origin — it asks a port, and one adapter answers. Pick a call below and the diagram shows you its route through all five.
counted from the diagram
tiers
5
parts
13
calls between them
12
ship JavaScript
2
Pick a call to trace it through the diagram.
reads · public
writes · bearer token
a box marked “client” runs in the browser
GET
Reading the page
Public
The only flow a visitor causes. Nothing is fetched from the browser: the server component asks the repository port, the answer becomes HTML, and what arrives is already finished.
GET /api/v1/profile
GET /api/v1/jobs
GET /api/v1/projects
GET /api/v1/nodes
GET /api/v1/skills/categories
GET /api/v1/schema/erd
GET /api/v1/trace/snapshot
1
Browser
Asks for a route. This is the last thing the browser does on the read path — there is no client fetch behind it.
GET /
2
Next.js server
The route's server component reads everything it needs in one Promise.all, through the port rather than the API.
cvRepository.listJobs()
3
@cv/data
HttpCvRepository is the only module that knows the API's origin. It calls the typed client generated from the OpenAPI schema, and attaches Next's revalidation window here.
next: { revalidate: 60 }
4
FastAPI
A read route, so no token is asked for. The handler is a lookup and a response model; the service beneath it has done the work.
GET /api/v1/jobs
5
Service → repository
The service composes, the repository holds the SQLAlchemy query. The trace snapshot is the one that earns the split: it resolves the whole graph in a single pass rather than in 36 round trips.
6
PostgreSQL → the page
Rows come back, Pydantic serialises them, the adapter narrows tone and status into domain types, and the section renders on the server. The trace snapshot is dehydrated into the client cache so the one client island starts warm.
POST
Adding a row
Bearer token
The editor's create path. The browser never learns the API's address or its token: it posts to a proxy on this origin, and the proxy is where the credential is attached.
POST /api/v1/jobs
POST /api/v1/projects
POST /api/v1/nodes
POST /api/v1/skills
POST /api/v1/skills/categories
1
Admin editor
The form posts to this origin. Same-origin, so no CORS — and no credential within reach of any script the page loads.
POST /api/admin/jobs
2
Next route handler
A catch-all that validates rather than forwards: the resource name must be one of five, and every segment is checked against a character class, so nothing can walk out of the namespace.
parseTarget(segments)
3
Next route handler
Reads the httpOnly session cookie. That token has never been readable by a script on the page, which is the whole reason this hop exists.
readAdminToken()
4
@cv/data
The write port is built from that token per request, not once from the environment — a module-level singleton would mean one process-wide credential shared by everyone who opens the editor.
repository.create(resource, body)
5
FastAPI
The write gate compares the bearer token in constant time. No token configured answers 503, a wrong one answers 401 — an operator problem and a caller problem are not the same thing.
6
Service → PostgreSQL
Pydantic validates the shape; the service refuses a payload naming a skill that does not exist, then commits the row, its bullets and its skill relations as one unit of work.
422 · Unknown skill(s) in uses: COBOL
PATCH
Editing a row
Bearer token
The same door as a create, with one difference that matters: the payload is partial. A field that is not sent is left alone rather than nulled, so editing a role's dates cannot silently erase its bullets.
PATCH /api/v1/profile
PATCH /api/v1/jobs/{id}
PATCH /api/v1/projects/{id}
PATCH /api/v1/nodes/{id}
PATCH /api/v1/skills/{id}
PATCH /api/v1/skills/categories/{id}
1
Admin editor
The form sends only the fields the editor changed.
PATCH /api/admin/jobs/{id}
2
Next route handler
The profile is the one row addressed without an id — it is a single row, so it has no create and no delete — and it is matched before the id check every other resource goes through.
3
@cv/data
The same per-request write port and the same bearer token, with a different verb.
repository.update(resource, id, body)
4
FastAPI
The write gate again, then the row is loaded — or the request 404s before anything has been changed.
self.jobs.require(job_id)
5
Service
Only the fields actually supplied are copied across. Lists are the exception: bullets and skill relations are replaced wholesale, because merging an ordered list by index would quietly reorder it.
payload.model_dump(exclude_unset=True)
6
PostgreSQL
One commit. A role plus its bullets plus its two sets of skill rows either all land or none of them do.
DELETE
Removing a row
Bearer token
The shortest flow, and the one with the widest blast radius, because the join tables cascade. Deleting a skill removes it from every role, project and architecture card that referenced it.
DELETE /api/v1/jobs/{id}
DELETE /api/v1/projects/{id}
DELETE /api/v1/nodes/{id}
DELETE /api/v1/skills/{id}
DELETE /api/v1/skills/categories/{id}
1
Admin editor
No body to send, so there is nothing to validate on the way in.
DELETE /api/admin/jobs/{id}
2
Next route handler
An id is required here: the same handler refuses a delete addressed at a collection, which is the one mistake in this shape of API that cannot be undone.
3
@cv/data
A 204 carries no body, so the write port returns undefined rather than trying to parse one, and the proxy answers 204 in turn.
repository.remove(resource, id)
4
FastAPI
Gated like every other write, then loaded — deleting something that was already gone is a 404, not a quiet success.
5
Service → PostgreSQL
The row goes, and ON DELETE CASCADE takes the join rows with it. A category is the exception: it is refused while it still holds skills, because taking a category out from under them would delete work nobody asked to delete.
6
The page
Nothing else has to be told. The trace graph, the skill rail and the ERD are all derived from rows, so they are correct on the next revalidation without a line changing.
The schema
Drawn from /api/v1/schema/erd, which the service reflects out of its own SQLAlchemy metadata — so this is the schema rather than a picture of it, and a migration redraws it. The tinted boxes are join tables: a role’s skills are rows in job_skills, not a list stored on the role, which is what lets the trace be a query.
as the API served it
tables
14
columns
59
foreign keys
15
join tables
5
● primary key ○ foreign key
Every file and route named on this page is in the repository. If one of them stops being true, that is a bug in the page, and the same one the whole project exists to avoid.