Architecture and contracts
- Lesson 6
- 90 min
- Module 2: Prepare data and architecture
Lesson 6 of 22 · Portfolio Capstone: Build, Evaluate and Ship an AI ProductArchitecture and contracts
1. Select and bound the project
2. Prepare data and architecture
3. Build one complete user journey
4. Add reliability and access control
5. Evaluate and improve
6. Deploy and operate
7. Prepare the portfolio presentation
What you will be able to do
Write down how the parts of your product talk to each other and what they store, before you build any of them. Parts built on one written agreement fit together, and the shop never gets lost between them.
Before you startWhat to know and to have ready
What you need to know
- Your data pack from Synthetic data pack:
python -m pytestprints18 passed. - Resources, paths and status codes, from Resource and endpoint design, and one error shape for every failure, from Validation and error responses, in Build and Deploy Your First API.
- Tables, keys and constraints, from Entities and relationships and Integrity constraints, and a migration runner, from Migrations, in Databases and Storage for AI Applications.
- A job with states and a worker that claims it, from Job lifecycle in Scaling APIs and AI Workloads.
- The metadata that every chunk keeps, from Preserve metadata in the RAG course.
- Docker Compose and a named volume, from Persistent data in Docker and Cloud Essentials for AI Developers.
What you need on your computer
- Docker Desktop, running. The database image takes about 690 MB of disk, downloaded once.
- Port 5432 free on your computer. If another PostgreSQL uses it, the guided practice shows what to change.
No account and no key. Nothing calls a model.
Tested with Python 3.12.13, Docker Engine 29.6.1 and PostgreSQL 18.6 with pgvector 0.8.7, on macOS (Apple silicon). The Windows (PowerShell) and Linux commands are written, not tested.
An order slip
Section titled “An order slip”In a busy restaurant, the waiter and the cook rarely talk. The order slip talks for them. It has the table number, the dishes, and notes such as “no nuts”. The waiter knows what to write, and the cook knows what to read. Two new staff members can start on Monday without meeting, because the slip is the same for everyone.
If the slip has no table number, the cook still cooks, and the food is still good. It goes to the wrong table, and nobody notices until a guest complains.
The problem: parts built apart do not fit
Section titled “The problem: parts built apart do not fit”Sam will build the product in pieces over the next three modules: the API, the page, the ingestion worker, the search and the answer step. Different pieces reach different reviewers on different days. Tomás wants to skip the design: “We will see what each part needs when we write it.”
Here is what happens without written agreements. These are failures that the earlier courses measured or that this pack makes certain:
- The shop falls out of a key. Bramble’s
gift-cards.mdand Larkfield’sgift-cards.mdshare the document keygift-cards. The RAG course built its chunk IDs from the document key, the version and the position. That was right for one shop. With two shops, the same file uploaded by both would get the same chunk IDs. The second upload would collide with the first, or worse, replace it. - Two embedding models mixed. The RAG course searched its e5 index with question vectors from another model of the same size. Both had 384 numbers, so nothing failed. The hit rate fell to 0.068: almost every search returned the wrong passages, with no error message.
- The wrong version. In the same course, without a date filter, 5 passages from an expired version reached the top 5.
- The page and the API disagree. The page waits for an answer to an upload. The API returns at once, and indexes later. The page shows “done” for a document that then fails.
None of these is a hard bug to fix. Each one is expensive to find later, because nothing crashes.
The idea: a contract at every boundary
Section titled “The idea: a contract at every boundary”A contract is a written agreement at the boundary between two parts. It says what one part sends, what the other part returns, which errors can happen, and who may ask. It is the order slip. Each side can be built and tested alone against it.
Sam’s design has four contracts, one for each place where parts meet:
- The API contract (between the page and the API): methods, paths, request and response shapes, error codes, and the role that may call each one.
- The database schema (between the code and its data): tables, keys, and the rules that the database itself enforces.
- The ingestion states (between the upload and the worker): which states a document can be in, and what moves it from one to the next.
- The retrieval metadata (between the index and the search): the facts that every chunk carries, and the filters that use them.
A contract is not code. It is a page in the design pack, docs/design-pack/architecture.md, that reviewers read before any code exists. Kwame, the solution architect, reviews the parts and the API. Mei, the data engineer, reviews ingestion and deletion.
A tiny example: one key, every value
Section titled “A tiny example: one key, every value”Start with the smallest contract: the ID of one chunk. The first section of Bramble’s gift-cards.md is Amounts. After parsing, it runs from character 12 to character 79 of the document’s text. Suppose that both shops upload the same file. The project’s later chunker builds the ID from these values, in this order:
| Shop | The text that is hashed | First 12 characters of its SHA-256 |
|---|---|---|
| (none, the RAG course’s rule) | gift-cards|1|12|79 |
987c93cee1ea, for both shops |
| Bramble Books | bramble|gift-cards|1|12|79 |
a1e046fa0990 |
| Larkfield | larkfield|gift-cards|1|12|79 |
eaef0328d7a2 |
You can check one row yourself:
python -c "import hashlib; print(hashlib.sha256(b'larkfield|gift-cards|1|12|79').hexdigest()[:12])"eaef0328d7a2Without the shop, both uploads get 987c93cee1ea. With it, the IDs differ, and a chunk can never be mistaken for the other shop’s. The schema then repeats the rule: the primary key of a chunk is (tenant_id, chunk_id). And the key that points from a chunk to its document includes the shop as well:
FOREIGN KEY (tenant_id, document_id) REFERENCES documents (tenant_id, document_id) ON DELETE CASCADESo the database refuses a Bramble chunk that points at a Larkfield document, even if the code has a bug. One of the new tests proves it. The contract is three lines of the design, and it removes a whole class of failure.
Sam’s architecture
Section titled “Sam’s architecture”Sam keeps the architecture as small as the acceptance criteria allow. One API process does everything: it serves the page, answers questions, and runs the ingestion worker in a background thread. One PostgreSQL database with the pgvector extension holds every shop’s documents, files, chunks, vectors and answers. The model provider is behind one adapter, and it replays recorded answers by default.
Text description of the diagram
The browser shows one plain page. It talks only to the API: it uploads a document (202 Accepted), asks for its status, and posts a question (200 with the answer). Inside the API process, the routes check the request, the caller and the membership, then call the answer pipeline or create a document row. That row is the ingestion job. The worker thread claims it, parses, chunks and embeds the document with the local e5 model, and saves the chunks. The answer pipeline searches PostgreSQL with four filters: the shop, the access level, the date and the embedding model. Every row in PostgreSQL has a tenant_id, and row-level security, added in Module 4, separates the shops a second time. Only the question and the passages go to the model provider. The practice identity provider arrives in Module 4.
Each part has one job, and one thing that it must never do. The second column is the part of the contract that reviewers check most:
| Part | Its one job | It must never |
|---|---|---|
| Page | let a member upload, follow the status, ask, read the evidence | put server text on the page as HTML; keep a token |
| Routes | check the request, the caller and their membership; call one service | trust a shop or a role sent by the browser |
| Answer pipeline | turn one question into a stored, cited answer | search another shop; send anything but the question and the passages to the model |
| Ingestion worker | index uploaded documents, one at a time | lose a document: a failure ends as failed with a reason |
| PostgreSQL | keep every shop’s data, separated by tenant_id |
let a query of one shop see another shop’s rows |
| Model provider | write an answer from the passages | receive a document that the caller may not read |
“Why not a message queue for the jobs, and a vector database for the search?” asks Tomás. Kwame asks the question back: which acceptance criterion needs them? Two shops upload a few documents a week. A documents row with a status is a complete queue. The worker claims it with FOR UPDATE SKIP LOCKED, as in the scaling course. pgvector searches the 245 passages of both shops in about 9 ms, as Module 3 measures. Each extra system is one more thing to start, secure, back up and explain in a demo. Also, a second store would need its own copy of the tenant rule. Sam writes the decision down with its reason, and the limit at which it would change.
The four contracts
Section titled “The four contracts”The API contract
Section titled “The API contract”Every path with shop data starts with /v1/tenants/{tenant}. The shop in the path is only a request: the caller’s membership decides. Every error has one shape, {"code", "message", "request_id"}, as in the API course. Here are four rows of Sam’s contract table. The full table is in docs/design-pack/architecture.md.
| Method and path | Who may call it | Success | Errors |
|---|---|---|---|
POST /v1/tenants/{t}/documents |
owner, staff | 202 {document, duplicate: false} and a Location header |
401, 403, 404, 409 conflict, 413 file_too_large, 415 unsupported_file, 422 |
GET /v1/tenants/{t}/documents/{id} |
every member | 200 {document} with status: uploaded, parsing, indexed or failed (and error) |
401, 404 |
POST /v1/tenants/{t}/answers |
every member | 200 {answer} with claims, citations, cost and time |
401, 404, 422, 504 provider_timeout, and others |
GET /v1/tenants/{t}/chunks/{chunk_id} |
every member, at their access level | 200 {chunk}: the evidence behind a citation |
401, 404 |
Two choices in this table matter later. An upload answers 202 Accepted, not 200: the API has saved the file, and the indexing has not started. So the page must ask for the status, and show “indexing” until the document is indexed or failed. And the API answers 404 for another shop’s data, never 403, so a stranger cannot learn whether a document exists.
The database schema
Section titled “The database schema”The schema is migrations/001_core.sql: seven tables. Every table with shop data has tenant_id, and every key that points at another row includes it. The documents table carries the facts from the data pack’s inventory: doc_key, version, access, effective_from and effective_to. It also stores sha256, the fingerprint of the file, and embedder, the name of the model that made its vectors. UNIQUE (tenant_id, doc_key, version) allows returns-policy version 1 in both shops, and refuses a second one in the same shop.
The database also checks the values that the contract allows. A status other than the four states, or an access level other than public and staff, is refused by a CHECK constraint. So a typo in the code cannot invent a fifth state.
The ingestion states
Section titled “The ingestion states”The documents row is also the ingestion job. There is no separate jobs table. Its status moves through four states:
Text description of the diagram
An upload creates the document in the state uploaded and answers 202. The worker claims it, which moves it to parsing and gives the worker a lease of 5 minutes: a claim that ends by itself, as in the scaling course’s job lifecycle. If the parse, the chunking, the embedding and the saving all work, one transaction moves it to indexed. If the file cannot be parsed, it moves to failed at once, with the reason, because trying again cannot help. Any other error, such as a lost database connection, moves it back to uploaded for another attempt, at most 3. If a worker dies, its lease ends and another claim takes the document again. Deleting an indexed or failed document removes it with its file, chunks and vectors.
The contract also says what the person sees. uploaded and parsing show as “indexing”, and the page asks again every 1.5 seconds. indexed shows the number of passages. failed shows the reason, for example “This file is not a readable PDF”. Mei checks one rule above all: no path ends with a document stuck in parsing forever.
The retrieval metadata
Section titled “The retrieval metadata”Each chunk is one section of one document version, and its text starts with the document title. The search filters before it takes the top 5 passages:
- the shop:
tenant_id; - the access level: a read-only member searches public documents only;
- the date:
effective_from <= as_of <= effective_to, so that an expired version never competes; - the embedding model: the document’s
embeddermust match the model that embedded the question.
Each of these four filters answers one failure from the problem section. Each passage given to the model also gets a header with its ID, title, type, version and dates. The model can then cite it, and see which version it reads.
Guided practice: the schema in a real database
Section titled “Guided practice: the schema in a real database”You copy this lesson’s files, start PostgreSQL in Docker, apply the schema and run the new tests.
-
Copy the files of this lesson from the snapshot
end-of-m02:Terminal window S=../nextia-learning-labs/C27/snapshots/end-of-m02mkdir -p migrations docs/design-packcp "$S/doc_assistant/config.py" "$S/doc_assistant/migrate.py" doc_assistant/cp "$S/migrations/001_core.sql" migrations/cp "$S/tests/conftest.py" "$S/tests/test_schema.py" tests/cp "$S/docs/design-pack/architecture.md" docs/design-pack/cp "$S/compose.yaml" "$S/.env.example" .Terminal window $S = "..\nextia-learning-labs\C27\snapshots\end-of-m02"New-Item -ItemType Directory -Force migrations, docs\design-pack | Out-NullCopy-Item "$S\doc_assistant\config.py", "$S\doc_assistant\migrate.py" doc_assistant\Copy-Item "$S\migrations\001_core.sql" migrations\Copy-Item "$S\tests\conftest.py", "$S\tests\test_schema.py" tests\Copy-Item "$S\docs\design-pack\architecture.md" docs\design-pack\Copy-Item "$S\compose.yaml", "$S\.env.example" . -
Make your
.envfile from the example. It holds the database password, so Git ignores it.Terminal window cp .env.example .envTerminal window Copy-Item .env.example .env -
Open
.envin VS Code. Replacechange-me-on-your-computerwith a password of your own, in all three places. If port 5432 is in use on your computer, add the lineDB_PORT=5433, and change5432to5433in both URLs. -
Start the database.
--waitreturns when PostgreSQL is ready to accept connections:Terminal window docker compose up -d --wait dbThe first time, Docker downloads the image (about 690 MB on disk). Docker creates a network, the volume
pgdataand the container, and waits. The last line says that the database is ready:Container doc-assistant-db-1 HealthyWithout
--wait, the command returns as soon as the container starts. A migration run at that moment can fail, because PostgreSQL is still starting. In the author’s run, it stopped with “This probably means the server terminated abnormally before or while processing the request.” Run it again a few seconds later, or use--wait. -
Apply the schema:
Terminal window python -m doc_assistant.migrateapplied 001_core1 migration(s) applied. -
Run the command again. A migration runs only once:
Terminal window python -m doc_assistant.migratepython -m doc_assistant.migrate --statusThe database is up to date.001_core applied -
Run the tests:
Terminal window python -m pytest....................... [100%]23 passed in 0.25sThe five new tests run in a separate database,
assistant_test, which they make new each time. They check four things. The tables exist. A Bramble chunk cannot point at a Larkfield document. The same document key may exist in two shops. And the database refuses a made-up state or access level.
To see the tables, ask PostgreSQL inside its container:
docker compose exec db psql -U assistant -d assistant -c '\dt' List of tables Schema | Name | Type | Owner--------+-------------------+-------+----------- public | answers | table | assistant public | chunks | table | assistant public | document_files | table | assistant public | documents | table | assistant public | memberships | table | assistant public | schema_migrations | table | assistant public | tenants | table | assistant public | users | table | assistant(8 rows)schema_migrations is the runner’s own record: the name and the SHA-256 of each applied file.
When to write contracts, and what they cost
Section titled “When to write contracts, and what they cost”Write them after the data pack and before the first integration. Written before the code, a contract lets you build the page and the worker against it, on different days. Write only what a reviewer or a test can check: paths, shapes, codes, keys, states and filters.
The cost is an afternoon of writing, and the contracts must stay true: when Module 4 adds sign-in, the table changes first, then the code. The tenant in every key costs little speed: the authentication course measured about 0.1 ms per request for row-level security. The simplification has a limit too. At hundreds of uploads a minute, a separate worker process would be the first change; python -m doc_assistant.worker exists for that from Module 3.
Common mistakes
Section titled “Common mistakes”Common mistake
The tests run before the database
Symptom: the new tests stop with five errors:
E psycopg.OperationalError: connection failed: connection to server at "127.0.0.1", port 5432 failed: could not receive data from server: Connection refused...ERROR tests/test_schema.py::test_the_core_tables_exist - psycopg.OperationalE...ERROR tests/test_schema.py::test_a_chunk_cannot_point_at_another_shops_documentERROR tests/test_schema.py::test_the_same_document_key_may_exist_in_two_shopsERROR tests/test_schema.py::test_states_and_access_levels_are_checked[status-done]ERROR tests/test_schema.py::test_states_and_access_levels_are_checked[access-secret]18 passed, 5 errors in 0.23sCause: .env sets TEST_DATABASE_URL, so the tests try to connect, and no PostgreSQL is listening. Docker Desktop is closed, or the container is stopped.
Fix: start Docker Desktop, then docker compose up -d --wait db, and run the tests again. To run only the tests without a database, remove TEST_DATABASE_URL from .env: the five tests are then skipped, with a reason.
Common mistake
An applied migration is edited
Symptom: Tomás adds a line to 001_core.sql after it was applied. --status and the runner both show it, and the runner applies nothing (exit code 1):
001_core CHANGED after it was appliedStopped: 001_core changed after it was applied. Write a new migration.Cause: your database already has the old 001_core. Changing the file does not change the database. The runner compares the SHA-256 of each applied file with its record, as in the databases course, and stops at the first difference.
Fix: restore the file (git checkout migrations/001_core.sql). A change to an applied schema is a new file: 002_...sql. The contract in architecture.md lists the migrations in order, so a reviewer sees each change.
Your project
Section titled “Your project”On both tracks, write docs/design-pack/architecture.md for your product, from docs/templates/architecture.md. On the standard track, start from Sam’s, and change what your product does differently.
- Draw your parts. Start from the Mermaid block in Sam’s file, and remove each part that no acceptance criterion needs. For each part, write its one job and the one thing it must never do.
- Write the contract table: one row per endpoint, with who may call it, the success response and every error code.
- Write the data model. Mark the column that says which tenant each row belongs to, and every key that must include it.
- Draw the ingestion states. For each state, write what moves a document out of it, and what the person sees.
- List the retrieval metadata, and the filter that uses each field.
- Write
migrations/001_core.sqlfrom your data model. Apply it, and runpython -m doc_assistant.migrate --status. - Ask a reviewer, or reread it the next day, against your acceptance criteria. Remove one part or one field that no criterion needs.
Evidence to keep: architecture.md, the output of python -m doc_assistant.migrate --status, and the test output.
Checklist
- Every part has one job and one “must never”.
- Every endpoint has its callers, its success response and its errors, in one error shape.
- Every table with tenant data has a tenant column, and every key between such tables includes it.
- The ingestion states have no dead end: every document ends
indexed,failedor deleted. - The search filters by tenant, access, date and model before it takes the top results.
- I removed at least one part or field that no acceptance criterion needs, and wrote down why.
Your turn: download the original file
Section titled “Your turn: download the original file”Change one condition. Ines asks for a new feature: a member can download the original file of a document, to check the full text. Write the contract row for it. Then answer: which table holds the bytes, which key finds them, and what does a read-only member get for a staff-only document?
Show a possible answer
| Method and path | Who may call it | Success | Errors |
|---|---|---|---|
GET /v1/tenants/{t}/documents/{id}/file |
every member, at their access level | 200 with the file’s bytes and its content_type; the file name in a Content-Disposition header |
401, 404 |
The bytes are in document_files, found by (tenant_id, document_id): the shop is part of the key, so a Larkfield path cannot reach a Bramble file. A read-only member who asks for a staff-only document gets 404, the same body as for a document that does not exist. A 403 would tell them that the document exists. A failed document has its file too, so the person can see what they uploaded.
Knowledge check
Section titled “Knowledge check”Check your understanding
Why does an upload answer 202 Accepted instead of 200 OK, and what must the page do because of it?
Show the answer
202 means “saved, not finished”: the API keeps the file and creates the document row, and the worker indexes it later. Indexing can take seconds, and it can fail. So the page must ask for the document’s status with GET /v1/tenants/{t}/documents/{id}. It shows “indexing” while the status is uploaded or parsing. Then it shows the number of passages, or the reason for failed. A 200 would make the page claim success too early.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Predict: Ines uploads Bramble's returns policy version 1 a second time, as a new file with a small change. The schema has UNIQUE (tenant_id, doc_key, version). What happens, and what should she do instead?
Show the answer
The database refuses the second row with the same shop, key and version, and the API answers 409 conflict. That is the right result: two different texts must not both be “version 1”. She should upload the changed text as a new version, with its own effective_from. Larkfield can still have its own returns-policy, because the shop is part of the unique key.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Practical check: show that your schema refuses a chunk that points at another tenant's document. Which constraint does it, and which test proves it?
Show the answer
The foreign key (tenant_id, document_id) REFERENCES documents (tenant_id, document_id) does it: a chunk with tenant_id = 'bramble' and a Larkfield document’s ID has no matching row. In Sam’s project, tests/test_schema.py::test_a_chunk_cannot_point_at_another_shops_document inserts one and expects ForeignKeyViolation. Your answer should name your own constraint and test, and show the test passing.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
- A contract is a written agreement at a boundary: what is sent, what comes back, which errors, and who may ask. Write it before the code.
- Sam has four: the API contract, the database schema, the ingestion states and the retrieval metadata, all in
docs/design-pack/architecture.md. - The shop is in every key: chunk IDs, primary keys and foreign keys. The database refuses a chunk that points at another shop’s document.
- An upload answers 202. The
documentsrow is the job:uploaded,parsing, thenindexedorfailedwith a reason. - The search filters by shop, access, date and embedding model before the top 5.
- One process and one database are enough for two small shops. Each extra part needs a criterion that asks for it.
python -m doc_assistant.migrateapplies001_core. The tests print23 passed.
Next, you look at the same design as an attacker would, and you set a budget.
Next: Threat and cost review