v046 | 2026-09-14 | 372 lines
ARCHITECTURE.md
How it is built
Audience: Claude Code, when a task touches structure.
Scope of this file. How the parts fit together and where new legs attach. Not a schema mirror — the database is the source of truth for schema, and this file records what is not visible from reading the code: why the boundaries sit where they do.
Read SCOPE.md first, then APPLICATION.md. SCOPE.md governs the shared ground — what HappyShip is, the service legs, the source language, and where a commit goes. APPLICATION.md is the authority for the application: what it does, and what the current phase builds. This file says how that is achieved. Where it conflicts with either, they govern and this file is what changes.
0. Status
Written before implementation, and parts of it have been overtaken since. The schema gap was filled from what step 0 built (§4.3); the rest of §13 is still open. The markers are SCOPE.md, the terms. An [OPEN] here is not permission to decide — stop and ask.
1. Shape
Four layers, one deployable service.
| Layer | Responsibility |
|---|---|
| Ingest | Accept template uploads, parse, reject non-conforming files, write rows |
| Rate engine | Given a shipment and a rate card, compute charges with an explanation per line |
| Application | Entities, rate cards, datasets, jobs, results, comparison output |
| Presentation | Web UI |
No microservices. No message bus. One service, one database, one object store.
2. Stack
| Layer | Choice |
|---|---|
| Rate engine, workers, web application | Python |
| Database | Postgres |
| Object storage | S3-compatible |
| Front end | Server-rendered HTML, from Flask |
| Payments | Stripe |
Why Python. The engine is numeric and rule-heavy, the XLSX libraries are strongest here, and pricing rules read close to plain sentences — which matters because the product owner reviews logic, not implementation.
Why server-rendered, and why Flask. Every screen is a document — tables, forms that submit, uploads and downloads, a dashboard looked at once and then purged — so a separate front end would be two codebases in two languages for the same screens, with an API contract neither half can change alone: roughly double the build, and more than double the cost of a change, because one new field moves a response shape, a type, a component and everything that read the old shape. Flask rather than FastAPI or Starlette, because both are async, which buys nothing over a synchronous driver in front of a CPU-bound engine and costs a boundary through every handler, and FastAPI's validation and generated OpenAPI describe JSON bodies that no longer exist here. What the choice rules out is a screen assembled in the browser from data an endpoint hands it, not every request that is not a page load. Every page is rendered on the server. One request today returns something other than a page: the company-name check on the internal entity page posts to /entities/name-check when the field is left, and is answered with a status code and no body — held or free — so the page learns one fact and renders nothing from data. This is the reversible direction: a screen that later needs to behave like an application can be built that way on its own, where React everywhere and then simplifying is a rewrite.
Vendors are named in §7 and are deliberately replaceable. Nothing above the vendor line depends on which vendor is chosen.
3. The rate engine
A pure function. Given a shipment and a rate card, it returns computed charges. No database access, no network calls, no framework imports. Reference data is passed in, never fetched.
This is the one hard architectural constraint in the system. It exists so the engine can be tested without infrastructure, and so its correctness can be established independently of everything built on top of it.
The template parser (§9) is under the same constraint, for the same reason, and a test asserts it for both: neither module imports a database driver, a network client or a framework. The parser reads a file and returns rows and findings, and nothing in it writes.
Explainability is not optional (APPLICATION.md, engineering rules). It is what makes the engine's output auditable rather than merely correct.
Selecting which rate card applies happens outside the engine. A single upload or invoice may span multiple rate cards. Resolution rules live in RULES-<leg>.md.
4. Data
4.1 Database vs object storage
| Goes in Postgres | Goes in object storage |
|---|---|
| Entities, rate cards, charges | Uploaded files as received |
| Shipment rows, datasets | Generated result files for download |
| Jobs, result detail |
Files never touch local disk. Uploads go to object storage on receipt. Hosted filesystems are ephemeral, and a file written to disk disappears on redeploy. This is one of two constraints that make the local-to-hosted move trivial (§6).
This is a rule about the code path, not about the machine. The application always writes through the object-storage API; where that API points is configuration (§6). Local development points at the object store's free tier — no cost, no emulator, and the same code path as production. It does not mean "no local development"; it means no ./uploads directory.
Results. A costing run produces one result record per shipment per scenario.
Dashboards sort, filter, and display the full result set, so result detail lives in Postgres, indexed for those queries. Object storage holds the generated file for download, not the queryable copy. Results land in Postgres during a run and the dashboard reads them from there; they do not survive delivery.
Because comparison data does not survive a run, the database is small and steady, and library rate cards are the largest permanent thing in it.
Purge triggers. The comparison upload is purged from object storage once parsing succeeds. Result detail is purged from Postgres once the report is delivered. Whether purge is configurable, and what it defaults to, is APPLICATION.md.
Library cards are exempt from purge — they are stored and persist by design (APPLICATION.md, rate cards).
4.2 Limits
Dataset ceilings and append behavior no longer apply in phase 1 — there is no dataset model, and each run is one upload.
A per-upload row limit is still unset. [OPEN]
4.3 Schema decisions from step 0
The database is the source of truth for the schema; the migrations under app/db/migrations/ carry the tables, keys and constraints, and the rule each implements is a comment beside it. This section records only what a reader could not recover from them: why the boundaries sit where they do.
Entity scoping and forced row-level security sit on every table, including the public ones. Regions, the reference-standard designations and the standard's own rows are readable by every entity, and they still carry an owning entity (HappyShip) and forced policies. The alternative, an unscoped table for public data, would be the one table a missing filter could not fail closed on, and the rule that every table carries an owner is worth more than the exception. Forcing binds the migration role too, so the owner cannot be the leak. Two consequences follow, for whoever writes the next migration: a data migration that touches rows must bind its transaction to the entity that owns them, since the migration role sees nothing otherwise; and creating or acting on an entity today is done by a session bound to that entity, until the internal tool's separate logged path exists.
A typed identifier's owner prefix is a CHECK, and the collision check depends on it. rate_cards constrains the first four characters of rate_id to entity_id. Forced row-level security means a session sees only its own entity's rows, while a unique constraint is enforced beneath those policies and sees every row — so a SELECT-based collision check inside a bound session can report clear against a row it cannot see, and the INSERT then fails on a constraint the check never consulted. The prefix CHECK removes that gap by confining every identifier that could collide to the owning entity, which is the session's own visibility. It is a precondition of the ID scheme (APPLICATION.md, the ID scheme), not a local nicety, and a table minting a new type letter without it inherits the gap.
A reference standard is a rate card held by HappyShip plus a designation naming it, not a table of its own. The owning entity on the row is custody and isolation — which entity the row belongs to, and therefore who may read it — never ownership of the rates, which are the carrier's. Its rows have exactly a card's shape, a client's card compares against it cell by cell, and the parser validates a filled template against its structure, so a parallel table would be the same shape twice. The cross-entity read this needs is an explicit, tested policy on the card and row tables, opened only for a designated card, read-only, and beside the two surfaces §5 names. It is not an exception to isolation: an undesignated card HappyShip holds is as private as anyone's, and a test asserts both directions.
A login's key is its email address, and a publication event snapshots that address rather than holding a foreign key to it. The address is the account (APPLICATION.md, identity and access), so nothing else identifies a login. Login deletion is a hard delete and a deleted address may be logged again, while the publication record must keep "this login, at this time." A foreign key would either block the delete or lose the record; the snapshot does neither.
Charge rows carry a sequence; per-zone charge rows do not. Structure is which charges exist, in what order (RULES-LAST-MILE.md), so the order is part of a standard's identity and must be stored, and one charge code recurs across dated rows and brackets, so the row itself does not carry its position. The four per-zone rows are one row per code in an order the doc fixes, so the code alone places them.
[UNBUILT] The fuel rate is scoped by service family — Ground and Home Delivery share one percentage while express is jet-fuel-based — so the table carries that column before a second carrier arrives rather than after. Every row of the fuel series carries its effective date from the first migration, since every read of the series is by governing date and a row without one cannot be placed in it.
The 24 charge codes are a check constraint rather than a table, deliberately. The set is code on purpose (APPLICATION.md, master data and the reference standard): an open catalog is a door to uncontrolled codes, and a constraint a deploy is the only way to widen is what stops the set drifting. A second carrier does not weaken this — it arrives as its own coded set rather than as rows someone types into a catalog, so the constraint is not the thing a second carrier forces open.
[UNBUILT] The rule report will read these same constants (APPLICATION.md, internal reference views) — generated from them rather than describing them, so the constraint, the engine and the page a staff member reads cannot disagree. It arrives at step 5.
Migrations must apply, in order, to an empty database. The test fixture drops and recreates the schema and applies every migration, then seeds, rather than truncating and re-seeding: a truncate cannot restore rows that a NOT NULL data migration populated, since the seed that re-inserts them predates the column. The first data migration failed on a fresh apply and had been masked on the dev database, where the seed had already run before the migration existed; the shape will recur whenever a migration depends on seeded data, and the fresh apply is the only check that catches it.
5. Isolation
APPLICATION.md requires isolation at the data layer so a missing filter fails closed. Four mechanisms, in order of what they buy:
- Every table carries an owning entity, from the first migration. This is the single most expensive thing to retrofit — it touches every table and every query.
- Postgres row-level security. Policies live on the table, not in application queries. A query missing its filter returns nothing rather than everything.
- Entity comes from the session, never from the request. Not a URL parameter, not a body field. Set once at authentication; every query inherits it.
- Adversarial tests, at the data layer and the view layer. A view cannot skip scoping by being written: the lifecycle that binds an entity runs before every request, so there is no per-view opt-in to forget, and a test asserts every rule in the url_map is served under it. What the tests must assert is
APPLICATION.md. Nothing runs them automatically. The repo has one workflow and it runs no tests — it regenerates the handbook (SCOPE.md). So the suite gates whoever runs it and nobody else, and a commit reaches main with no test gate, including the bot's. A workflow that runs the suite on every push is wanted rather than built. [OPEN]
Two surfaces reach across entities, and no others. The mechanisms beneath them are listed rather than counted, because the count has been wrong twice — once when the login and session lookups arrived, once when the roster did — and a list gains an entry where a number needs correcting. The list carries no count for the same reason, that being the third time:
- The reference-standard policy. A card designated as a reference standard, and its rows, are readable by any session bound to an existing entity, because reference standards are public (
APPLICATION.md, visibility classes) — explicit, read-only and tested in both directions (§4.3). - The login and session lookups (§10), which no binding could answer, because resolving them is what produces one.
- The roster functions, which return the entity list and create and delete entities (
APPLICATION.md, the internal surface). - The entity-record functions, which read and write one entity's record at a time — its fields, counters, ship-from locations and card metadata, never contents (
APPLICATION.md, the internal surface). - The login-address functions, which read every address across every entity, and create and delete them.
- The content path, below, which is the only one that logs. [UNBUILT]
- The card-entry function, which writes a card and its rows into another entity's library on HappyShip's behalf (
APPLICATION.md, who writes a card). It is the only one that writes card contents; the roster, entity-record and login-address functions above it write too. [UNBUILT] It does not log.
What bounds an exemption is the shape of what it returns, never a predicate. A predicate bounds which rows and says nothing about which columns, so a role-scoped policy with USING (true) on a table yields every column of every row — the internal-only ones included — and nothing stops a caller selecting one nobody intended. That is a convention, not a ceiling. Every cross-entity exemption is therefore bounded by its return shape — a definer function's return type, a view's columns, or column grants — so that widening it takes a migration somebody can see. The two lookups above are an instance of this rule rather than an exception to it: each takes one value and returns one entityID, and the signature is the ceiling.
A write exemption is bounded by what it may write, since a return type bounds nothing when the point of the call is its effect. app_card_create_for returns one identifier and writes a card and its rows into another entity's library, so the return type is no ceiling at all. What bounds it instead is the set of tables it touches, its parameter list (there is no parameter for an entity other than the target, nor for a rateID), the values it fixes rather than accepts — a card it writes is always a draft and never published — and the grant that lets it run, held by one role the internal surface alone uses. Widening any of the four takes a migration somebody can see, which is what the rule above is for. [UNBUILT] It does not yet log, and the audit write described below is where that belongs: no cross-entity write logs today, and a write into another entity's library is the one that most obviously should.
The internal management tool (APPLICATION.md) goes through a separate, logged path — never a flag on the normal path.
[UNBUILT] The audit write happens inside the function that returns the contents, in one transaction. Reading another entity's private card contents without logging is not possible because they are one operation, and a failed audit write fails the read. That is structural rather than procedural: nobody has to remember a log line, because the only path to those contents runs through the thing that records it — a designated reference standard is public and readable without a log, which is what the audit is not for. That function meets the bounding rule in the ordinary way — its return type is the ceiling — and the write path is the same shape, the audit row landing in the transaction that creates the card.
[UNBUILT] The application role will hold INSERT and not SELECT on the audit table. It writes and can never read back, which is what makes the table an audit rather than a log the audited surface can read: an internal session can be made to write a row and cannot be made to show one. It inverts the lookup role — that one exists so the application can read what it otherwise could not, this one so it can write what it cannot. Reading the audit is out of band, by whoever holds the migrate credential. Every table carries an owning entity, and here that is the acting entity, HappyShip, because the action is HappyShip's; the target entity is a separate column doing a separate job, and merging the two would file the row under the entity it was written about.
The run-time pool (APPLICATION.md) is the second. It is not an exception to the isolation rules there: it reads only cards whose publication flag is set, and it returns computed results, never card contents. The flag is what the pool tests, never the owner's act. The pool distinguishes none of the ways the flag came to be set or cleared, so a card withdrawn because its entity lost authorization is as invisible as one the owner withdrew. The adversarial isolation tests required by APPLICATION.md extend to it.
Identity is not authentication. Entity scoping exists from step 0 and sessions from step 1; sign-in exists from step 2 (§8). Step 0 runs against a seeded entity with nobody logging into it, and step 1 runs against a real session that nobody has signed in to.
6. Portability
Two constraints make the hosting choice reversible. Both cost nothing now and are expensive to undo:
- All configuration through environment variables. No connection strings, paths, or keys in code.
- All files in object storage. Never the local filesystem.
Additionally: run the same Postgres major version locally as in hosted environments, and confirm any extension exists on the target before depending on it.
The move from local to hosted is a few hours' work — provision, dump and restore, repoint environment variables, deploy. The difficulty is never the move; it is data you cannot afford to lose. Move before there is data worth protecting (§8).
7. Hosting
Selection rule: every vendor must have a usable free tier and a reasonable paid tier on the same platform. Start free, upgrade in place, never migrate. A vendor that is cheapest at zero but forces a move at scale is disqualified — the migration costs more than the savings.
| Need | Vendor | Start | Upgrade in place |
|---|---|---|---|
| Compute | Render | Free | Starter, then larger instances |
| Database | Postgres on Neon | Free | Paid tier, before real client data |
| Object storage | Cloudflare R2 | Free | Pay-as-you-go, no egress fees |
| Resend | Free | Paid tier | |
| Monitoring | Sentry | Free | Team |
Each row is one platform from free through production. Upgrading is a plan change, not a move.
Auth is not a vendor and has no row: passwordless sign-in, sessions and entity binding are application code against Postgres (§10), so there is nothing to select, no free tier to outgrow and no user-count threshold to watch, and what auth stores is covered by the Postgres row above.
The application needs no static host of its own, and that is deliberate. It renders its own HTML and serves its own assets from the one service of §1, so nothing of the application's is deployed apart from it and there is no second thing to point a domain at. Two static surfaces do exist beside it and neither is the application's, which is why neither takes a row above: the public website, SCOPE.md's to name and WEBSITE.md's to describe, and the handbook (SCOPE.md). Both sit on Cloudflare, already a named vendor here, so neither adds a platform to select or outgrow.
Prices and tier limits change; verify before committing.
Free-tier compute sleeps when idle. Cold starts are acceptable while testing on internal data and not acceptable once an external user might arrive, so compute is the first upgrade — at the §8 gate.
Point-in-time restore is the one thing not to economize on. Free-tier Postgres generally lacks it. Acceptable for data that can be regenerated; not acceptable once real client rate cards are stored.
Do not introduce Kubernetes, microservices, additional datastores, or a message broker.
Even with upgrade-in-place, §6's portability constraints hold. They are what makes the selection rule a convenience rather than a dependency.
8. Build sequence
All steps deliver phase 1 as defined in APPLICATION.md.
| Step | Contents | Hosted? |
|---|---|---|
| 0 | Schema with entity scoping and RLS · rate engine · accessorials · reference data seed · verified against real invoices from a script, no UI | Local |
| 1 | Internal portal, first cut — the seeding screens (below) · sessions and entity binding, reached by a dev-only route | Local |
| 2 | Auth: passwordless sign-in to pre-registered emails · email delivery · the dev-only route deleted | Local |
| 3 | Rate card library: upload, lifecycle, publication flag · rate-to-rate comparison dashboard · report generation | Local |
| 4 | Shipment upload · shipment cost comparison dashboard | Local |
| — | GATE — see below | |
| 5 | Internal management tool, the rest: coupons, payments, activity metadata, language · the rule report — internal, read-only and regenerated from a master held elsewhere, and nothing is blocked without it | Hosted |
| 6 | Stripe: checkout, portal, coupons, entitlements · the metering module | Hosted |
Step 0 is not finished, and later steps have run ahead of it. What exists is the schema, the reference-data seed and the parser, plus rate-for-rate comparison — which compares two cards and is not what §1 and §3 define the rate engine as. Step 0 owes two things:
- [UNBUILT] The costing engine. Given a shipment and a rate card, compute charges with an explanation per line (§1, §3). Nothing in
app/engine/accepts a shipment. - [UNBUILT] Accessorial behavior. The 24 charge codes carry prices and nothing else. No trigger test, no minimum billable weight, no highest-wins evaluation and no fuel evaluator exists, so a code can be priced and cannot be applied.
The invoice verification in step 0's row waits on both, and stays where it is. Nothing is rescheduled and the gate does not move: it is step 0's obligation because the reason below has not changed.
The internal portal moved ahead of everything but the schema. Nothing can be seeded without its screens and nothing can be compared without seeded data. Step 0 still runs against a seeded entity with nobody logging into it (§5); step 1 no longer does, for the reason below.
The session moved into step 1 with the screens, because the two were waiting on each other — sign-in authenticates against pre-registered addresses, nothing can be registered until the logins screen exists, and that screen needs a session to know which entity is acting. What identifies the acting entity is what the row-level security policies read (§5), so a stand-in for it is not cosmetic: it reaches every query written in step 1 and every test around them. Building the seeding screens against a real session and real entity binding from the first line costs less than threading a placeholder through them and pulling it back out in step 2.
The dev-only route that mints a session is temporary work toward admitted work, not scaffolding. What APPLICATION.md's not-built rule forbids stubbing and scaffolding for is work phase 1 does not admit, and auth and login are admitted — so the rule does not reach this, and step 2 carries the obligation that makes it temporary rather than permanent: the route is deleted when sign-in lands.
The route sits inside the entity-binding rule rather than being an exception to it, which is what lets it exist inside the security boundary at all: it supplies an address, finds the login record and mints a session the ordinary way, skipping the code and nothing else, so the entity is still read from the login record and never from the request (§10). It cannot run before a login exists, so the first internal login is seeded alongside the HappyShip entity — the same seed, one row wider.
The first cut is the seeding screens only — create an entity · log an email address against an entity · upload a carrier-published card and name it a reference standard · enter a card on an entity's behalf · the values the card fields read (carrier list, entity list, currency, weight unit, ships-from regions) — not one tier and not all master data, so what step 1 owes each is that it is present, not that it has a page (APPLICATION.md, master data and the reference standard) · authorization to publish. Out of it: coupons, payments, activity metadata and the rule report, all of which stay at step 5. The HappyShip entity is a seed rather than something created through the portal, because internal logins bind to it.
The handbook is not in this sequence at all, and did not move within it. It needs no application running (SCOPE.md), so no step waits on it and it waits on none. It was listed at step 5 while it was a screen of the internal tool, and stopped being one.
Auth moved ahead of self-serve card upload, and not ahead of the whole library. Self-serve upload is an authenticated act by a card's owner (APPLICATION.md), so auth and entity binding cannot sit after it — and that reason reaches upload alone. The rest of step 3's library work depends on a session rather than on sign-in, which the dev-only route supplies, so it ran before step 2 instead of after it: the library dashboard, one card's detail, the lifecycle transitions and the publication act with its consent record are built while auth is not. Email delivery arrives with auth, which is where email infrastructure first exists.
Authorization arrives before the billing it exists to serve, and that is deliberate. It is a setting from step 1, while what makes granting it a paid decision waits for Stripe at step 6. Step 1 asks only whether the flag may be set, never why — which is what lets the pool run locally, before there is anything to charge for entering it.
Report generation lands with the comparison steps, at step 3.
The gate
Steps 0–4 run entirely on the product owner's own data, on the local machine, at no hosting cost. "Local" here means no deployed compute and no paid tier — files still go to the object store's free tier per §4.1, never to local disk.
Before step 5 and before any external user, three checks:
- Is the engine right? Real invoices, line by line, matching. If this fails, nothing downstream matters.
- Is the template usable by someone else? The owner will fill it correctly because he knows what every column means. That is exactly what an outside user will not do.
- Does the output say something the reader did not know?
Expect ingest rejection handling to get its first real test after the gate, not before — internally produced files are unrepresentatively clean.
Step 0 is the load-bearing step
Its cost is not code volume; it is writing down the calculation rules precisely and verifying output against known-correct invoices. If those rules are ready when step 0 begins, the step holds its estimate. If they are discovered through wrong invoice totals, it does not.
9. Ingest
Single sheet. HappyShip supplies the template. Which formats it comes in, and what happens to a file that does not conform, are APPLICATION.md. An XLSX with more than one populated sheet is rejected, so the two formats stay interchangeable and the parser has one shape to handle.
The template is generated from a reference standard, with structure locked to it (RULES-LAST-MILE.md). The parser knows the expected structure before opening the file, so conformance is a comparison against that structure rather than an inference from what the file contains.
Input is split between the uploaded file and a web form. Which fields go where is decided by the product owner, not derived. The split is recorded in RULES-<leg>.md, and those two are the whole input surface.
The file is a grid for humans; the database is one row per zone, weight, and service. The parser transposes. Nothing downstream sees the grid.
Template column definitions live in RULES-<leg>.md.
10. Auth
Passwordless — a verification code emailed to a pre-registered address (APPLICATION.md, identity and access). No stored passwords, no reset flow, nothing to breach.
The code is six digits. The sign-in screen takes it in six separate fields, one per digit, grouped in one fieldset (home.md, sign-in). Six was decided at the build — the sign-in shell landed with it at 53a57ff — and is written here after the fact: on this point the code led the document, not the other way round. Nothing generates, sends, stores or checks a code yet. [UNBUILT] The sign-in pages are a shell with no code lifecycle, and /dev/session is the only way a session is minted today.
Whether a code is single use is not stated anywhere. [OPEN] home.md names single use among the mechanics this section owns without deciding it, and no code path exists that could make it true or false.
Three more of the code's mechanics are undecided, and none is stated in this file or in home.md:
- How long a code stays valid. [OPEN]
- How soon a code may be resent, and how many times. [OPEN]
- What a wrong code does — whether it costs an attempt, how many attempts one code allows, and whether the screen says how many remain. [OPEN]
Entity binding. The entity is bound at session establishment, read from the login record — never from the request (§5).
Session length. Two limits, and the session ends at whichever arrives first. An absolute lifetime of 7 days from establishment, which no amount of activity extends. And an idle timeout of 24 hours, which every request resets, capped by the absolute — an active user is signed out at 7 days, not sooner.
The cookie is persistent, not a session cookie, so closing the browser does not end the session.
Sign-out ends a session, and it is the only way a user ends their own (APPLICATION.md, identity and access).
Why the limits are this long. Re-authentication here is an email round trip, so every expiry is one more chance for a delivery failure to sit between a client and their own work — and email is infrastructure rather than a convenience, not a property this design can shorten its way out of (§10.1). What a short session would otherwise protect against is a session outliving the right to it, and that is already handled by a stronger mechanism than a timer: deleting a login ends its session immediately, checked per request rather than at expiry (APPLICATION.md, identity and access). Shortening these limits therefore buys little, and is paid for in email round trips.
The login code email is always EN (APPLICATION.md). The code-sending path has no locale to resolve against (§11).
Consequence: email becomes infrastructure rather than a convenience. Every login depends on delivery, which is stricter than password auth where email only matters at reset. It is stricter still now that email carries more than the login code — the welcome email, the delete notification and the owner notification depend on delivery too (§10.1).
10.1 Email is infrastructure rather than a convenience
Email is no longer only the login path. It is infrastructure, not a convenience, and four things depend on delivery: the login code, the welcome email when an address is logged, the notification when a login is deleted, and the notification to an owner when HappyShip has entered a card on that entity's behalf (APPLICATION.md). The report of a completed run is not among them.
11. Building for translation
The language policy is APPLICATION.md, language in the application — which locales, per-login selection, how a user-facing string is written while the i18n layer does not exist, and the login code email always EN. This section is only what that policy costs architecturally.
CN is in from step 1, not added at the end. Layout breaks appear per screen; retrofitting means re-reviewing every screen.
The step 1 portal follows the login, since a step-1 session carries a login record and a login record carries a language preference; the browser rule governs only before a session exists, which in step 1 means before the dev route runs (APPLICATION.md, language in the application).
Length is the constraint, and buttons are where it bites. Buttons are fixed-position elements in a row, so one long label displaces its neighbors. Paragraphs reflow and tables wrap — neither is a structural problem.
Detailed styling rules belong in brand/STYLE.md.
12. Known future work
Recorded so it is not rediscovered as a surprise. None of this is phase 1. This section holds the architectural consequences of work not yet admitted, not deferred decisions — those are BACKLOG.md.
Cross-carrier zone normalization. A FedEx zone 5 is not a UPS zone 5. Rate-to-rate comparison across carriers needs a common basis — a normalization problem one level above the grid. Does not bite while phase 1 is FedEx-only.
Legs beyond last mile. Warehouse, drayage, and global mile attach at the rate table: the charge model is shared, the table shape is leg-specific.
Higher upload volumes. Present limits are set well below what the design supports. Raising them affects worker memory and result handling, not structure.
13. Gaps
| # | Gap | Blocks |
|---|---|---|
| 1 | Schema: tables, keys, indexes | Filled by step 0. The database is the source of truth; §4.3 records the decisions not visible from it |
| 2 | Per-upload row limit (§4.2) | Step 3 |
| 3 | Job execution model — in-process or queued | Step 3 |
| 4 | Card versioning: what happens to a run in flight when a card is superseded | Blocks the library work — the decision is APPLICATION.md's, carried [OPEN] under rate card properties |
Where an open decision lives is SCOPE.md, the terms. This table holds the architectural consequence of one, never the decision itself.
14. Running the suite
The test database is named in app/README.md, supplied by nothing and checked by nothing. The suite reads two variables, and app/tests/conftest.py stops the run when either is unset:
| Variable | Role | On this machine |
|---|---|---|
HAPPYSHIP_TEST_DATABASE_URL | happyship_migrate, the migration role, which owns the schema | postgresql://happyship_migrate@localhost/happyship_test |
HAPPYSHIP_TEST_APP_DATABASE_URL | happyship_app, the role the application runs as | postgresql://happyship_app@localhost/happyship_test |
There are two roles because the suite tests grants. The migration role builds the schema and binds test data; a grant is tested by connecting as the role it is granted to, and that role is happyship_app. One connection could build the schema or test the grant, never both.
No committed file sets either variable — no dotenv, no shell profile, no default in the fixture — so every run supplies both inline. From app/:
``bash HAPPYSHIP_TEST_DATABASE_URL=postgresql://happyship_migrate@localhost/happyship_test HAPPYSHIP_TEST_APP_DATABASE_URL=postgresql://happyship_app@localhost/happyship_test ../.venv/bin/python -m pytest -q -rfE -p no:cacheprovider ``
The rate card specimen needs no variable: it defaults to the committed fixture.
The fixture drops schema public. It rebuilds from nothing (§4.3): DROP SCHEMA public CASCADE, every migration, then the seed. Nothing in it looks at which database it was given. Pointed at happyship, it destroys dev — the owner's walk entities, and every applied migration with the hash it was verified against — and dev is never rebuilt.
The guard is a precondition on every run, not a step in it. A run does not start unless both hold:
- Both strings end in
/happyship_test. Either one ending anywhere else, and the run does not start. - No other run is active. On macOS the interpreter lists as
Python -m pytest, capital P, so a case-sensitive match misses it:pgrep -if "python -m pytest"finds a run, andpkill -if "python -m pytest"ends one. There is notimeoutcommand on this machine.
The absence is the defect. A destructive command whose only safeguard is the operator typing carefully is not safeguarded. The warning in app/README.md is that same safeguard, written down.
- Whether the two variables get a committed configuration. [OPEN]
- Whether the guard moves into the harness, out of the operator's hands. [OPEN]