Architecture

How CareerOps works,and why it was built this way.

CareerOps reads a mailbox, a calendar and a set of interview recordings, keeps the state of a job search across all of them, and works out what deserves attention next. It runs on AWS, it costs almost nothing while nobody is using it, and the part that a model is responsible for is deliberately smaller than the shape of the product would suggest.

  • AWS Serverless
  • Event-driven
  • PostgreSQL RLS
  • Model Context Protocol
  • Structured extraction
  • OAuth 2.0
  • Infrastructure as code
  • Cost as a constraint
CDK stacks
6

Data, auth, API, web, cost, CI.

Agents
11

Each with a versioned prompt, an output schema and a risk class.

Tables under RLS
28

Enabled and forced, out of 33. The five left out resolve identity.

Tests
1,080

1,050 in the API, 30 in the MCP package. No mocked database.

The system

Nine parts, and what each one is responsible for.

Left to right is the direction work travels: something arrives, something slow happens to it, something is written down. Nothing here is drawn — the boxes are text on a grid, so they reflow on a phone and can be read aloud.

Reaching it

Web application

Next.js on Amplify Hosting, server-rendered, billed per request.

  • Next.js 16 App Router
  • Amplify WEB_COMPUTE
  • Same-origin /api rewrite

Identity

Cognito for the owner; a separate session mechanism for the demo.

  • Cognito hosted UI
  • Server-side session cookie
  • Role → permission grants

API

One FastAPI Lambda behind an HTTP API. Every route declares a permission.

  • API Gateway HTTP API
  • FastAPI on Lambda (ARM64)
  • Services and domain logic

Doing the work

Asynchronous work

Two queues, two workers, a DLQ each, and a four-hourly sweep.

  • SQS events + ai
  • Dead-letter queues
  • EventBridge schedule
  • Transactional outbox

Agents

Eleven agents, all reached through one runner that cannot be bypassed.

  • ai/runner.py
  • Versioned prompts
  • Schema validation
  • Approval gate

Integrations

Gmail, Google Calendar and Granola, polled rather than pushed.

  • Gmail API
  • Google Calendar API
  • Granola REST
  • Secrets Manager tokens

Keeping the state

Data

Aurora Serverless v2 over the RDS Data API. Isolation is a database policy.

  • Aurora PostgreSQL 17
  • RDS Data API (no VPC)
  • Row-level security, forced
  • S3 documents, versioned

MCP server

Ten read-only tools over stdio, for any MCP client.

  • stdio transport
  • GET-only HTTP client
  • No database driver

Operations

Structured logs, cost metrics, alarms, and deploys with no stored keys.

  • CloudWatch logs + EMF
  • Budget and DLQ alarms
  • CDK
  • GitHub Actions via OIDC
The browser talks to a Next.js application on Amplify Hosting, which proxies every API call through its own origin to an HTTP API in front of a single FastAPI Lambda. That function reads and writes Aurora PostgreSQL through the RDS Data API, and hands slow work to one of two SQS queues. The events worker drains the outbox and runs a four-hourly sweep; the AI worker is the only process that calls a model, and the only one that may read an integration's OAuth token. A separate MCP server exposes ten read-only tools to external AI clients by calling the same authenticated API a browser does.

At a glance

Five rules the rest of the page is downstream of.

Each one was a decision with a cost, and each is enforced by something other than intention — a pure function, a database policy, a boot-time assertion, or a capability that simply does not exist.

Agents propose, code decides

A model reads prose, estimates fit and drafts a reply. It never computes a priority, moves a stage, or sends anything. Whether a proposal may apply itself is a pure function of its risk class and confidence — and that function never asks the model that produced it.

ai/approval.py

The boundary is below the application

Isolation between workspaces is a PostgreSQL policy, not a WHERE clause somebody remembered. Row-level security is enabled and forced on every workspace-owned table, and the application connects as a role that does not own them — so it cannot turn the policy off.

db/rls.py

Nothing slow happens in a request

A model call takes tens of seconds. The request enqueues and returns 202, and the outcome lands on the row it was about. That also keeps a 10MB model SDK out of the function that serves every request, where it would be paid for as cold-start latency by features that never call a model.

ADR 0015

Provenance travels with the value

Every derived value carries who asserted it, how confident they were, and what it came from. A user's correction always outranks a machine's inference, and a dimension nothing can compute stays null rather than taking a default — because inventing a number puts something on screen that looks measured and is not.

ADR 0005

The absent capability is the reliable one

There is no send method in the Gmail client and no write method in the MCP server. Both could have been guarded by a check; a capability that does not exist cannot be reached by a future caller who forgets the rule, and does not depend on anybody remembering it.

integrations/gmail.py, apps/mcp

Workflows

Five things that happen without anybody watching.

All five run in production. Each step is labelled by what is responsible for it, because how much of a workflow a model actually owns is the question this page exists to answer honestly.

The one thing it creates without asking

CareerOps proposes rather than creates, everywhere but here. A confirmation of your own application is not the system having an opinion — the decision was already made, by you, before the mail arrived.

  1. CodeThe four-hourly sweep enqueues a sync for each connected mailbox.
  2. CodeThe AI worker stores a page of 25 messages and enqueues one task per message.
  3. ModelEach message is classified on its own invocation, returning a category, a confidence and any company and role it can read.
  4. CodeFour guards, all of which must hold: the category is an application confirmation and nothing else, confidence is high, both company and role were read off the message, and no existing opportunity matched.
  5. CodeThe opportunity, its company, the message's link to it, the timeline entry and the audit row commit in one transaction.
  6. YouThe timeline says the system did it, so it reads as a machine action you can close or delete.

Web application

Not involved

Identity

Not involved

API

Involved in this workflow

Asynchronous work

Involved in this workflow

Agents

Involved in this workflow

Integrations

Not involved

Data

Involved in this workflow

MCP server

Not involved

Operations

Not involved

Why it is built this way

An opportunity created without being asked is a row somebody has to notice and delete, and the funnel counts opportunities — so a wrong one is not cosmetic. The guards are what make this the exception rather than a loosening of the rule: medium confidence is enough to ask and not enough to act, and every other category of mail still only proposes.

services/email_pipeline.py, services/detection.py

From signal to action

The one thing it creates without asking, and the guards on it.

CareerOps proposes rather than acts, everywhere but here. Understanding why this is the exception is most of understanding the product.

  1. Signal

    A message arrives on a scheduled sync.

  2. Normalise

    Stored verbatim, with a cursor recorded so a retry resumes.

  3. Classify

    A model returns a category, a confidence, and anything it could read.

  4. Associate

    Arithmetic over sender domain, thread, company and role title. No model.

  5. Decide

    Four guards, all of which must hold. Anything short of all four proposes instead.

  6. Execute

    Opportunity, company, link, timeline entry and audit row, in one transaction.

  7. Audit

    Attributed to the system, so it reads as a machine action you can undo.

Everything before the decision is evidence-gathering; everything after it is a state change. The guards are the line between them.

The four guards

  1. The category is an application confirmation, and nothing else. Recruiter outreach, job alerts and interview requests keep proposing — whether you want a role is your call.
  2. Confidence is high. Medium is enough to ask and not enough to act.
  3. Both the company and the role were read off the message. A confirmation naming neither is a digest, not a submission.
  4. Nothing already matched. An existing opportunity is the better answer, and the matcher has already had its chance.

The reasoning behind the general rule is that an opportunity created without being asked is a row somebody has to notice and delete — and the pipeline analytics count opportunities, so a wrong one is not cosmetic. What makes this the exception is that a confirmation of your own application is not the system having an opinion. You decided before the mail arrived, and asking you to confirm it is asking you to agree with yourself.

services/email_pipeline.py · services/detection.py

Model Context Protocol

AI that can use tools, holding no privilege of its own.

CareerOps runs a Model Context Protocol server, so any MCP client can read this workspace where the person already is. The interesting part is not that it exposes tools — it is what the server is structurally unable to do.

  1. AI client

    Claude Desktop, Claude Code, anything speaking the protocol.

  2. stdio

    A local process. No listening port, no public surface, no credential in transit.

  3. CareerOps MCP server

    Ten tools, each declared read-only, non-destructive and closed-world.

  4. Session cookie

    The same credential a browser carries. The server holds no privilege of its own.

  5. CareerOps API

    Every route declares the permission it requires, or the process refuses to boot.

  6. Row-level security

    The workspace is applied to the transaction, and the policy decides what the query may see.

  7. Structured JSON

    Derived values carry their source and confidence. A null stays a gap.

The model chooses which tool. Everything after that is the application deciding what is allowed and how it executes — which is the distinction the protocol exists to draw.

It cannot write

Every tool is a GET, and the HTTP client has no method that is not. A test asserts the absence, because an absence nobody tests is a comment.

It cannot reach the database

The package depends on the protocol library and an HTTP client, and never on the application. No driver, no session factory — isolation is inherited from the authenticated API rather than re-implemented beside it.

It holds no privilege of its own

Authentication is the same session cookie a browser carries. The server sees exactly what the signed-in person sees, because it is the signed-in person.

The ten tools

The read-only tools the CareerOps MCP server exposes
ToolWhat it answers
whats_nextWhat deserves attention now, ranked, each with the reason it is where it is.
searchEvery kind of record, in one call. Full-text, and calls no model.
list_opportunitiesThe pipeline, filterable by stage or status.
get_opportunityOne opportunity in full.
get_opportunity_intelligenceThe score broken down, with the sentence explaining each dimension.
get_opportunity_timelineWhat has actually happened, oldest first.
list_interviewsInterviews across the workspace, or on one opportunity.
get_interview_reviewWhat was asked and how it was answered, citing the raw notes.
list_interview_questionsThe question library, accumulated across every reviewed interview.
list_actionsOpen commitments and suggested next steps, marked by who proposed them.

An eleventh tool answers a question in prose, and is registered only when explicitly enabled — every tool above answers out of PostgreSQL and costs the operator nothing, and that one spends their model budget at the request of whoever is driving the client. That is a reasonable thing to switch on deliberately and a poor thing to inherit by default.

The protocol runs in the other direction too: a command-line importer is an MCP client against Granola’s hosted server, over OAuth with dynamic client registration. It cannot own the schedule — consent needs a browser, and there is nobody at a keyboard when a Lambda runs — so the scheduled sync stays on the REST API and this is the path for when somebody is present. The reader and the writer share only their authentication, and deliberately not a class: a writer reachable by inheritance would have turned “the MCP server cannot write” into “happens not to”.

apps/mcp · ADR 0024

Agentic and deterministic

Eleven agents, and not one of them decides anything.

Each agent has its own versioned prompt, its own output schema and its own risk class, and every call goes through one runner — which is what makes the audit row, the budget and the approval gate unconditional rather than something each call site remembers.

What a model does

Reading prose, and saying how sure it is.

  • Classify a message, and say how sure it is
  • Read a job posting into structured requirements
  • Read interview notes into questions, answers and concerns
  • Estimate role fit and strategic value against a career profile
  • Judge whether a role has drifted from what was posted
  • Draft a reply in the person's own register
  • Choose a retrieval intent from a closed menu
  • Write prose over an evidence bundle it was handed

What code does

Everything with a consequence.

  • Decide whether a proposal may apply itself
  • Compute every priority, from configured weights
  • Decide which opportunity a message or an event belongs to
  • Enforce the permission a route requires, or refuse to boot
  • Scope every query to a workspace, in the database
  • Delete any citation the retrieval did not actually supply
  • Enforce the model budget, the burst limit and the audit row
  • Move a stage, close an opportunity, or send anything at all

There is no planner and no free-running loop. Which agent runs next is an ordinary branch in Python, and the mail pipeline states its step budget as a constant asserted against its own step list — so adding a step without raising it fails loudly rather than quietly extending what one message may cost. The approval engine is a pure function of risk class, confidence and who asserted it: an action that leaves the system is refused at every confidence level, a low-confidence state change is never applied, and a change you made is not gated at all. A model can argue itself into confidence; it cannot argue its way past a function that never asks it.

ai/runner.py · ai/approval.py · ADR 0006 · ADR 0014

Data

A relational domain, with isolation enforced beneath the application.

An opportunity joins to a company, its applications, its interviews, the people in them and the mail about it — and every useful question is a join across all of that. The store was chosen for the domain rather than the domain bent to fit the store.

The process

  • Opportunity
  • Company
  • Contact
  • Application
  • Stage definition

An opportunity is the spine. Everything else attaches to it, and stages are configuration rather than an enum.

What happened

  • Timeline event
  • Domain event
  • Audit log
  • Action
  • Waiting item

State, timeline, domain event and audit row commit in one transaction, or none of them do.

What was said

  • Email message
  • Interview
  • Interview note
  • Interview question
  • Meeting import

The raw capture is never rewritten. A review cites it by id, so an edited note would make the citation false.

What was derived

  • Opportunity score
  • Signal
  • Risk
  • Fit analysis
  • Career profile

Every derived value carries its source and its confidence, and a user's assertion outranks a machine's.

What was sent

  • Document
  • Document version
  • Job description

A version is granted select and insert only, and one an application references cannot be deleted.

What it cost

  • AI call
  • Ask question
  • Integration
  • Processed event

Every model call writes a row, including the ones that were refused or never happened.

  1. Authenticated request

    A session cookie, resolved against the identity tables.

  2. Workspace context

    Carried explicitly, never ambiently — a copied context is worse than none.

  3. Transaction setting

    Applied per transaction, so a reused connection cannot carry one workspace into another's request.

  4. The policy

    Enabled and forced. It governs what may be read and what may be written.

  5. Authorised rows

    A missing context matches nothing rather than everything.

The application connects as a role that does not own the tables, so it cannot turn the policy off. That is the difference between a boundary and a convention.

It fails closed, and that took getting right

The obvious way to write the policy has a trap in it. PostgreSQL returns a never-set connection setting as null, but reverts one that has been set to the empty string — and casting an empty string to an identifier raises, so a reused connection would fail with a type error instead of the clean empty result the policy promises. Collapsing both cases to null is what makes a missing workspace match no rows.

Three tiers of write access, not one

Most tables take ordinary reads and writes. The audit log is insert-only, so the code being audited cannot rewrite the record. Interview notes may be inserted and deleted and never updated — a review cites a note by id, so an edited note would make its citation false, while deletion stays allowed because a raw capture is the one place somebody may paste what they did not mean to keep. A document version an application references cannot be deleted at all.

A finding worth publishing

A full-text index is unusable on any table in this database, and that is not a bug. The function behind the match operator is not marked leakproof, so PostgreSQL evaluates the workspace policy before it — and an index condition is by definition evaluated first. Measured rather than inferred: the same index on a table without the policy is used immediately. What made search fast instead was precomputing the searchable document as a stored column, which took a query over five thousand rows from 214ms to 2.5ms.

db/rls.py · services/retrieval.py · ADR 0003 · ADR 0020

Security

Connecting a mailbox is the largest ask this product makes.

So the controls are specifics rather than a badge, and most of them are structural — a capability that does not exist, or a process that refuses to start.

Authentication
Cognito's hosted UI for the workspace owner, exchanged server-side for a session cookie. A demo visitor gets a session bound to the demo workspace and no Cognito token at all — deliberately a different mechanism, so a demo session can never produce a credential the private API would accept.
Authorisation
Every route names the permission it requires and the process refuses to start if one does not. A demo visitor holds no permission with an external side effect, asserted when the module is imported rather than checked at a call site.
Isolation
Row-level security, enabled and forced, on every workspace-owned table. The policy governs both what is visible and what may be written, so a forged insert into another workspace fails as well as a read. A missing workspace context matches no rows rather than all of them. The five tables without a policy are the ones authentication reads before a workspace is known — a policy keyed on the workspace could not resolve on them, so they are guarded by the session layer instead.
Credentials
An OAuth grant is not a column. The database keeps only a reference to a secret; the token lives in Secrets Manager. Disconnecting deletes the secret rather than flagging a row, so revocation takes effect immediately.
Least privilege
The three functions hold different verbs on those secrets. The API function may create and delete an integration's token and may not read it — it writes the token during the OAuth callback and never needs it again. The AI worker may read and not write. The events worker gets nothing.
Scopes
Read-only on the calendar. On Gmail, read plus the narrowest scope that permits drafts — which also permits sending, because Google publishes no draft-only scope. So the guarantee is structural instead: there is no send method to call, and sending is classed high-risk, which the approval engine refuses at every confidence level.
Browser
A per-request nonce and strict-dynamic on every page holding a session, so an injected inline script is refused. Prerendered public pages cannot carry a nonce and get a weaker policy — a bounded loss, since they hold no session, may call no other origin and may post nowhere. A check in CI asserts that split still matches what the build produced.
Audit
Everything consequential writes an append-only row in the same transaction as the change. The application role holds no update or delete on that table, so the code being audited cannot rewrite the record. Sensitive values are redacted at the logging processor rather than at every call site.

There is no SOC 2 report, no penetration test and no security team — this is one person's project, and claiming otherwise would undercut everything above it. What there is: a small surface, no third-party scripts, encryption in transit and at rest, and decisions written down where somebody can check them.

The plainer version, written for somebody deciding whether to connect a mailbox, is on the security page.

Integrations

Four services, and how each one is actually reached.

The method column is here because this page would otherwise commit the error it warns about. CareerOps operating an MCP server does not make its integrations MCP — three of these are OAuth or REST on a schedule.

External services CareerOps integrates with, how each is reached, and what it contributes
ServiceMethodCadenceWhat it contributes
GmailOAuth 2.0, RESTPolled, four-hourlyJob-search mail, classified and filed against the right process — and a drafted reply for the ones that deserve an answer. Never sent.
Google CalendarOAuth 2.0, REST, read-only scopePolled, four-hourlyInterviews, their reschedules and cancellations, and everyone on the invitation as a contact. No model is called on any of it.
GranolaREST API keyPolled, four-hourlyRecorded meetings, whose transcripts become interview notes and then reviews.
Granola (MCP)MCP over streamable HTTP, OAuth 2.0 with dynamic client registrationRun by handA second import path. It cannot own the schedule: consent needs a browser, and there is nobody at a keyboard when a Lambda runs.
AnthropicHTTPS from the AI worker onlyOn demandThe eleven agents. Reached through one runner, so the audit row, the budget and the approval gate are unconditional.
CareerOps MCP serverMCP over stdioLaunched by the clientTen read-only tools, so an AI client can read this workspace where the person already is.

integrations/ · services/scheduling.py · ADR 0019 · ADR 0022

Asynchronous work

What happens when something fails.

Two queues, because a model call taking two minutes must not sit in front of ordinary event dispatch. Each has its own dead-letter queue, and each dead-letter queue has an alarm — without which the whole asynchronous design would be quietly conditional.

  1. Enqueued

    The request returns 202. The outcome will land on the row it was about.

  2. Claimed

    The event-and-handler pair is claimed in the database, so a redelivery is a no-op.

  3. Failed

    A failing message is retried alone rather than taking its healthy siblings with it.

  4. Retried

    Three deliveries. A terminal failure — a refusal, an over-budget call — is not retried at all.

  5. Dead letter

    Kept for a fortnight, with enough context to retry safely.

  6. Alarm

    Threshold zero. One dead letter is a bug, not a rate to tolerate.

A message that fails three times is an outcome that will never land. Until the alarm existed, the person saw a row stuck on pending with no way to tell slow from dead.

Redelivery is a no-op, not a duplicate

A queue delivers at least once. Each event-and-handler pair is claimed in the database before the handler runs, so two concurrent workers cannot both win the race — and an extraction refuses to overwrite a result already there, which makes a repeat delivery cheap rather than merely harmless.

One bad message does not take its siblings

Batches report per-item failures, so a failing message is retried alone. After three attempts it moves to a dead-letter queue that keeps it for a fortnight.

A dead letter pages somebody

One message in a dead-letter queue trips an alarm — the threshold is zero, not a rate, because three deliveries have already failed. Without that, the asynchronous design was quietly conditional: a row would sit on pending for ever and the person would see only that it was slow.

Failures that a retry cannot fix are not retried

A refusal, an over-budget call or an answer that would not validate writes a terminal status and returns normally. The money is already spent and the next attempt fails identically. Only errors a retry could plausibly fix reach the queue as a failure.

The database is woken, not hoped at

Aurora auto-pauses, and the resume raises rather than waits. Every entry point wakes it explicitly, because the connection-level fallback fires too late to prevent the first failure. The deploy does the same before it migrates.

Two rate limits, aimed at different things

The edge throttles requests, which protects a shared account. A per-workspace ceiling of thirty model calls a minute — counted from the audit rows, because separate Lambdas share no memory — is the brake on a loop, since the monthly budget is a ceiling a bug reaches in minutes.

infra/lib/api-stack.ts · events/dispatcher.py · ADR 0004 · ADR 0015

Observability

Aimed at the things a chart would hide.

A failed model call costs nothing, so it is invisible in any spend figure. A call that never happened because a workspace was over budget is invisible in everything. Both are recorded, and both are alarmed.

Every model call writes a row

Agent, prompt version, model, tokens, cost, latency, validation status and what the approval engine decided. Including the failures, and including the calls that never happened because the workspace was over budget — those are the interesting ones, because nobody notices them until a feature has been broken for a week.

Cost is a metric, not a surprise

Model spend is invoiced by Anthropic, so no filter on the AWS bill can ever see it. Each call emits its cost as a CloudWatch metric written as a log line rather than an API call — it adds no latency, needs no permission, and cannot fail the request it is measuring.

Attribution is free, and deliberately not in CloudWatch

The metrics carry no agent dimension, because CloudWatch bills per name-and-dimension combination and the observability bill would then grow with every agent added. Per-agent and per-prompt-version spend is served from the audit rows instead.

An automatic action is traceable end to end

An opportunity created without being asked can be walked back: the timeline entry, the audit row, the message it came from, the classification that read it with its confidence and prompt version, and the model call that produced it with what it cost.

Alarms on the things a chart would hide

Daily model spend, repeated model failures — which cost nothing and so are invisible in any spend figure — and one alarm per dead-letter queue. The join between an alarm and the queue it watches is by name, so a check in CI asserts it across the synthesised templates: an alarm watching a renamed queue looks exactly like a healthy one.

Readiness includes the schema

The readiness endpoint reports the applied migration revision. A process that is up but pointed at an un-migrated database is not ready, and saying otherwise would let a deploy roll forward onto a broken schema.

ai/runner.py · ai/metrics.py · infra/lib/cost-stack.ts · scripts/check_alarms.py

Infrastructure and delivery

Six stacks, one definition, and no stored keys.

Everything in the diagram at the top of this page is declared in code and deployed by a workflow that holds no long-lived AWS credential.

Six stacks, one definition

Data, auth, API, web, cost and CI, in AWS CDK. Guardrails are their own stack so they can be deployed first, before anything exists that could run up a bill.

No stored AWS keys

GitHub Actions federates through OIDC and assumes a role scoped to this repository — pinned to the numeric owner and repository ids rather than their names, because a rename changes the names and leaves the ids alone.

Migrations run before the code that needs them

The order is not cosmetic. Migrations here are additive, so new tables sitting unused under the previous release cost nothing; new code reaching for tables that do not exist yet is a 500 for every request in the window.

The build refuses what it cannot afford

CI fails if a synthesised template ever contains a NAT gateway, a VPC endpoint, a container service or a cache cluster. That is the shape of this architecture written down as a test rather than as an intention.

The database in CI is real

Tests run against PostgreSQL with both roles created, because row-level security is the thing under test and a mocked database would test nothing. Migrations are also applied to an empty database on every run.

infra/ · .github/workflows/ · docs/deployment.md

Economics

Architecture proportional to the workload.

Not the cheapest possible system — a production shape whose fixed costs were each argued about rather than accepted. The interesting consequence is that the schedule which keeps mail arriving is the largest line on the bill, and it is stated rather than hidden.

Nothing runs when nobody is using it

No Lambda runs inside a VPC, so there is no NAT gateway and no interface endpoints — together the largest fixed cost this shape could have had. The database is reached over the Data API instead, which is also what lets CI migrate a database in an isolated subnet with no bastion and no VPN.

The database pauses to zero

Aurora Serverless v2 at a floor of zero capacity, which is the single decision that makes a relational database affordable for a workload with one person on it. The auto-pause window is an hour rather than the five-minute default, because stepping away for a coffee should not mean the next click pays for a resume.

A schedule is priced, not chosen

What a sweep costs is not its second of compute — it is the hour of warm database that follows, because the idle clock restarts on every statement. Once a day is about $1.80 a month; every four hours is about $10.80; every hour never sleeps and is about $43. Four-hourly was chosen knowing it roughly triples idle spend, because it caps the wait at four hours rather than the fourteen an overnight gap produces.

Bundle size is latency

Two Lambda packages from one source. Only the worker that actually calls a model carries the model SDK, so a 10MB dependency is paid for by the function that needs it rather than by every request in the system.

The cheapest model where it is not the lever

Classification runs on the small model; the work that reasons over a transcript or a fit judgement runs on the capable one. The difference at this volume is a few dollars a month, which is why the decision was made on quality rather than price — and recorded either way.

infra/lib/cost-stack.ts · services/scheduling.py · ADR 0009 · ADR 0012

Why it was built this way

Eight decisions, and what each one cost.

From the twenty-four recorded in the repository, chosen for the ones where the trade-off is genuinely uncomfortable — a decision whose alternatives were all obviously worse teaches nobody anything.

0003Tenant isolation belongs in the database
Decision
Every workspace-owned table has row-level security enabled and forced, and the application connects as a role that does not own those tables.
Why
Application-level filtering is one forgotten clause away from a breach, and the forgotten clause is invisible in review because the code looks like every other query. A policy in the database applies to every statement, including the ones nobody thought about. Forced matters because a table owner otherwise bypasses its own policies — so the application is deliberately not the owner, which is the difference between a boundary and a convention.
Alternatives considered
Filtering in the ORM, a base query class, or a repository layer that every read has to go through. All rely on discipline, and none survives somebody writing one raw query.
What it costs
Two database roles to manage, a bootstrap step CloudFormation cannot perform, and the policy must be added by migration for every new table. It also makes a full-text index unusable — the function behind the match operator is not marked leakproof, so the tenant policy is evaluated first and the index is never used. That was measured, not inferred, and search was made fast by precomputing the document instead.
0010Aurora Serverless v2 through the RDS Data API
Decision
PostgreSQL rather than a key-value store, reached over an HTTPS data API rather than a socket, with capacity that falls to zero.
Why
The domain is relational in the way that actually matters: an opportunity joins to a company, its applications, its interviews, the people in them and the mail about it, and the useful questions are joins across all of that. Row-level security is a PostgreSQL feature, and the isolation invariant rests on it. Going over the data API is what keeps every function out of the VPC, which is what removes the NAT gateway from the bill entirely.
Alternatives considered
DynamoDB, which would have made every cross-entity question an application-side join and had no equivalent of row-level security. Or Postgres reached normally from inside the VPC, which costs a NAT gateway at four times the entire budget for this system.
What it costs
It is a narrower protocol than a driver, and three of its limits have each cost an outage: no array parameters, a case-insensitive text type that fails the whole statement, and timestamps returned without their offset. One is worse than the others — an empty list in an IN clause compiles to something legal on a normal driver and fatal here, so it passes the entire local suite and fails the first time it runs in production. And the first request after a pause waits ten to fifteen seconds for the cluster to resume.
0015Model calls run in a worker, not in the request
Decision
A request that needs a model enqueues and returns 202. The outcome lands on the row it was about.
Why
Two reasons, and both are load-bearing. A model call takes tens of seconds and has no business blocking somebody watching a spinner. And the model SDK is around 10MB with its dependencies — carried on the API function, that weight would be paid as cold-start latency by every request in the system, including the majority that never call a model.
Alternatives considered
Calling inline and streaming a response, which does not survive an API gateway timeout. Or one bundle for everything, which is simpler and makes every request pay for a feature it is not using.
What it costs
The user experience is now asynchronous, which is a genuine cost: something is pending and the page has to say so honestly. It also means the queue is part of the correctness argument rather than an optimisation — which is why the dead-letter alarms exist, since a message that fails three times is an outcome that will never land.
0006Bounded orchestration, not free-running agents
Decision
Ordinary Python with explicit branches decides what runs next. No model plans, and no loop is unbounded.
Why
The interesting engineering in this system is routing, validation and approval, and all three are testable without spending a cent when they are deterministic. A planner would move exactly those decisions into a place that cannot be unit tested and cannot explain itself. The step budget is asserted against the pipeline's own step list, so adding a step without raising it fails loudly rather than quietly extending what one message may cost.
Alternatives considered
A tool-calling agent with a planner and a step limit. It would demo better and would put the parts most worth auditing behind a probability.
What it costs
New capability means writing a branch rather than adding a tool description, so the system cannot handle a case nobody anticipated. That is a real ceiling, and it is accepted knowingly: this product's failure mode is a wrong fact in somebody's job search, not a missing feature.
0017Scoring is arithmetic; only two of seven inputs are inferred
Decision
Seven dimensions combine through configured weights in a module that cannot call a model and cannot reach a database. A model estimates role fit and strategic value; it never computes a priority.
Why
A ranked queue is the product's central claim, and a number a model produced cannot be argued with — you cannot ask it why, and you cannot change the weighting you disagree with. Arithmetic over weights somebody set can be shown, checked, and adjusted.
Alternatives considered
Asking a model to rank the pipeline directly, which is fewer moving parts and produces a number nobody can audit or reproduce.
What it costs
The weights are somebody's judgement, encoded, and defending them is now the author's job rather than the model's. A dimension nothing can compute stays null and the weights renormalise — never a default, because inventing the most heavily weighted dimension puts a number on screen that looks measured and is not. Below four measurable dimensions the priority is labelled provisional rather than presented as a finished judgement.
0020Lexical retrieval, and citations validated against the bundle
Decision
Answers are prose over an evidence bundle assembled by deterministic, workspace-scoped queries. Any citation the model returned that was not in that bundle is deleted before anybody sees it.
Why
A footnote that cannot be followed looks like provenance and is not, and that is worse than no footnote — it borrows the credibility of a citation without earning it. The router picks an intent from a closed menu and names entities resolved against this workspace's own rows, so it can retrieve the wrong records and never somebody else's.
Alternatives considered
Vector search, which needs a second AI vendor at a per-write cost, against a monthly target of a few dollars, to search a corpus that is one person's job search. What reasons semantically here is the model reading the bundle; retrieval only has to be good recall.
What it costs
Lexical retrieval misses a paraphrase that an embedding would catch, which is a real loss on questions phrased in words the records do not use. Deleting a citation can also leave a claim looking unsupported — which is the correct outcome and does not always read as one.
0024The MCP server goes through the API, not the database
Decision
A separate package with no dependency on the application, ten read-only tools, stdio transport, and authentication by the same session cookie a browser carries.
Why
Isolation should be inherited, not re-implemented. Every read goes through the same authenticated API every other client uses, and the database policy scopes it there — so a bug in the MCP server can return the wrong record and cannot return another workspace's. It also keeps the protocol library's transitive weight out of both Lambda bundles.
Alternatives considered
Querying the database directly, which is fewer moving parts and puts the isolation invariant in a second place. Or write tools, which would have made the read-only guarantee a promise instead of an absence.
What it costs
Every tool pays an HTTP round trip and a cold Lambda over a resuming database. Stdio means it is local-only — a remote server would need a listening port, a credential in transit, and an authorisation story this product does not have yet. And the one tool that spends the operator's own model budget is off unless explicitly enabled, which is a rough edge rather than a design.
0009Cost is a design constraint, not a report
Decision
No Lambda inside a VPC, a database that pauses to zero, budget alarms deployed before anything that could bill, and a build that fails if always-on infrastructure appears in a template.
Why
Every component here is pay-per-use, which is what makes it cheap when idle and what makes a mistake expensive. A constraint that is only ever reviewed afterwards has already been violated by the time anybody looks.
Alternatives considered
Provisioned capacity and a private network, which is the conventional shape and costs more per month idle than this system costs in a year.
What it costs
The savings are paid for in latency and in effort. A resume costs ten to fifteen seconds; every function is outside the VPC and reaches the database over a narrower protocol; and the schedule that keeps mail arriving costs about eleven dollars a month in warm database time alone — roughly double what everything else costs together. That is stated rather than hidden, because a cost figure that omits its largest line is not a cost figure.

Every one of the twenty-four is written down in the repository, dated when the decision was made rather than reconstructed afterwards — and several record what was tried first and was wrong. That is the part a reader actually needs: a record listing only the winning option explains nothing that was not already obvious from the code.

Stack

What it is made of.

Text rather than a wall of logos. Each entry is something in a manifest or a template, not something considered.

Web

  • Next.js 16 (App Router)
  • React 19
  • TypeScript
  • Tailwind CSS v4
  • Geist

API

  • Python 3.13
  • FastAPI
  • SQLAlchemy
  • Pydantic
  • Alembic
  • structlog

Models

  • Anthropic API
  • Claude Opus 5 (reasoning)
  • Claude Haiku 4.5 (classification)
  • Versioned prompts, schema-validated output

MCP

  • Python MCP SDK
  • stdio server, 10 read-only tools
  • OAuth client for Granola
  • httpx

Data

  • Aurora Serverless v2, PostgreSQL 17
  • RDS Data API
  • Row-level security
  • Postgres full-text search
  • S3, versioned

AWS

  • Lambda (ARM64)
  • API Gateway HTTP API
  • SQS + dead-letter queues
  • EventBridge
  • Cognito
  • Secrets Manager
  • CloudWatch
  • Amplify Hosting
  • Budgets

Integrations

  • Gmail API
  • Google Calendar API
  • Granola REST
  • Granola MCP

Delivery

  • AWS CDK
  • GitHub Actions
  • OIDC federation
  • uv
  • pnpm
  • ruff
  • mypy

In summary

What this system demonstrates.

CareerOps is a real product with one person using it every day. It is also the artifact — the thing that shows how these decisions get made when somebody has to live with them.

  • AI-native application architecture, with the probabilistic part bounded
  • Model Context Protocol, on both sides of the boundary
  • Event-driven and asynchronous processing with real failure handling
  • Multi-tenant isolation enforced below the application
  • Relational modelling of a genuinely relational domain
  • OAuth 2.0 integrations, and least privilege between functions
  • Infrastructure as code, with cost asserted by the build
  • Observability aimed at what a chart would hide
  • Human-in-the-loop controls that are structural rather than promised
  • Trade-offs recorded when they were made, including the ones that were wrong first