Architecture
Eight domains in one database, how a stage gate is enforced without a cross-domain join, how document revisions are protected, and what is published.
One binary, services/construction/cmd/api, serving the HTTP API and running the outbox
publisher in the same process. One Postgres database. No listener and no worker yet.
The domains
Each domain has its own tables, its own sqlc package, and its own Transactional outbox: Each domain inserts events into its own <domain>_events table in the same transaction as the state change; a publisher drains unpublished rows to Azure Service Bus., following the table-prefix convention in docs/decisions/0015-domain-scoped-outbox-and-table-prefixes.md.
| Domain | What it holds |
|---|---|
project | The project and its seven stages, packages, relations to other projects, milestones, weekly updates, baselines, and the tasks with their project links, dependencies, notes, and required documents |
document | Controlled document: A document whose changes are tracked formally, such as a drawing, a specification, or the project schedule. It keeps one permanent code for its whole life; a change adds a revision rather than a new document.s, their Revision: One version of a controlled document. Internal revisions (A, B, C, or R0-A after the first release) are working copies that are prepared, reviewed and approved. A published revision (R0, R1, and so on) is the official one people work from. Only one revision is published at a time, and publishing a new one supersedes the old.s, the prepared, reviewed and approved trail, links to planning records, and the recorded impact of each publication |
variation | Variation: A change to the cost or schedule agreed at award, for example extra work a new drawing revision needs. It is approved through an approval, and incorporating it writes a new baseline.s: changes to the award baseline's cost or schedule, with their approval and incorporation |
budget | Budget lines per category, and committed and actual cost events |
decision | Questions the project has to answer, the alternatives considered, what was chosen, and what that cost |
approval | Formal reviews with named reviewers, over a stage gate, a decision, a budget, or a variation |
risk | The risk register: likelihood, impact, owner, exposure, mitigation |
vendors | Which projects each outside firm is on, and its design fees and invoices. The firms themselves are in the vendors service |
They are separate domains because they change for different reasons and at different rates. A risk register is edited weekly by one person; the task list is edited hourly by many. They are one service because they cannot usefully fail independently: advancing a stage reads an approval, completing a task reads the document domain, publishing a revision can draft a variation, and incorporating a variation writes the project's baseline.
Tasks are in the project domain, not a domain of their own, because the progress view
joins tasks to packages and a SQL join across domains is not allowed
(docs/decisions/0023-per-domain-sqlc-packages.md). Their routes are
still /v1/tasks, because a task can belong to several projects and is not nested under
one.
The stage gate
A project leaves initiation, product_approval, tender or award only if an approved
Stage gate: An approval that has to be granted before a project can leave initiation, final product approval, tender, or award. Without an approved gate the advance is refused, which stops a project drifting into the next stage before the previous one is signed off. exists for the stage it is leaving. That rule is enforced in one
transaction, which matters: if the check and the move were two transactions, an approval
could be withdrawn in between and the project would advance on a gate that no longer
existed.
The handler for POST /v1/development-projects/{projectId}/advance opens one
Command: An HTTP POST that changes state, recorded with an Idempotency-Key so retries do not double-apply; not CQRS or a command bus. transaction and builds both domains' query sets from the same
pgx.Tx:
q := projectdb.New(tx)
p, err := project.Get(ctx, q, projectID)
if p.CurrentStage != from {
return AdvanceResult{}, project.ErrStageMoved
}
var gateID *uuid.UUID
if _, gated := project.Gates[from]; gated {
gate, err := approval.FindApprovedGate(ctx, approvaldb.New(tx), projectID, string(from))
if err == nil {
gateID = &gate.ID
}
}
out, err := project.Advance(ctx, q, actorFID, now, project.AdvanceInput{
ProjectID: projectID, GateApprovalID: gateID, Award: award, Why: in.Body.Why,
})project.Advance refuses with 409 no_approved_gate when the stage has a gate and no
approval was found. There is no SQL join between project and approval. A query that
selected from both would couple their schemas and make either one hard to move to its own
service later, which is why cross-domain reads pass a transaction into the other domain's
Go function instead.
from_stage in the request body is a staleness check. A console that has been open for
an hour sends the stage it believes the project is in; if it has moved, the answer is 409
stage_moved rather than an advance nobody looked at.
Leaving award
Leaving award also needs the award itself: the firm the construction contract went to,
the contract value, the construction start and finish, and optionally the budget and the
published document revisions the award was made on. In the same transaction the handler
checks that the firm is on the project and that every named revision is published. The
advance then writes the project's first Baseline: A saved record of what was agreed at a point in time: the budget, the construction start and finish, and the published document revisions. The first is written when the project passes the award gate; each incorporated variation adds another. Earlier baselines are kept.. From then on, the
approved budget and the finish date change only through a variation.
Document control
A document has one permanent code. Changing it adds a revision, and the database makes two rules impossible to break, in services/construction/migrations/0008_document.sql:
- One published revision. A partial unique index on
document_revision (document_id) where state = 'published'means a second published revision of the same document is a constraint violation, not a bug somebody has to notice. - A published revision never changes. A trigger refuses any update or delete of a
published revision, except the one change publishing a newer revision makes: moving its
state from
publishedtosuperseded. The trail of who prepared, reviewed and approved a revision is append-only, and another trigger refuses updates and deletes there.
An internal revision moves through draft, internal_review, reviewed,
pending_approval and approved, or ends rejected. Each step is one action in the
trail, with the person, the time, and a comment. A step out of order is 409
invalid_transition. Only an approved internal revision can be published (409
revision_not_approved), and one started from a revision that has since been superseded
is refused with 409 revision_out_of_date.
Publishing creates the next published revision (R0, R1, and so on) from the internal one, supersedes the previous published revision, and records the impact: which projects and tasks the change affects, whether it changes cost or schedule, and whether it needs a variation. When it does, the publish handler drafts the variation through the variation domain's Go function in the same transaction. Why the trail is in the document domain rather than reusing approvals is in docs/decisions/0092-planning-and-document-control.md.
The same pattern connects a variation to its approval. Submitting a variation requests an
approval of kind variation. When the last required reviewer answers, the approval
handler records the variation's result in the same transaction. Incorporating an approved
variation raises the project's budget, moves its forecast finish, and writes a new
baseline; every earlier baseline is kept.
Who may do what
For now the service sits behind Cloudflare Access, the way workspace and intelligence do,
and does not use identity's grants or Cedar. common/cfaccess verifies the Access token on
every /v1 request, and the caller is the email in it, lowercased.
- Anyone Access admits may read every route.
- Only the emails in
CONSTRUCTION_EDITORSmay change anything. Anyone else gets 403. - Responding to an approval also needs the caller to be one of its named reviewers, matched by email.
The *_fid columns hold that email until the service moves to identity's FIDs. Each
handler still names the action it performs, such as construction::PlanProject, in
services/construction/internal/handlers/authz.go. A name starting
with Read is a read and every other name is a change, so moving back to Cedar changes one
function rather than every route. Why the service is on this model, and the steps to move
it back, are in docs/decisions/0087-project-scope-for-grants.md.
Outside firms
The vendor portal is a second Cloudflare Access application with its own audience,
CONSTRUCTION_VENDOR_ACCESS_AUD. The verifier in
services/construction/internal/auth/verifier.go picks the audience
by path: a request under /v1/vendor/ is checked only against the vendor application,
and every other /v1 request only against the internal one. A vendor's token cannot
read the internal API, and a caller on a vendor route is never an editor. How the
vendor routes then limit a firm to its own tasks and documents is on
Vendor portal.
The firms, their contacts and their insurance are in the vendors service (docs/decisions/0091-vendors-service.md). Construction calls it over HTTP, before any transaction opens: to check a firm exists and is active before assigning it, to name the firms on a project in one batch read, and to find the firm behind a vendor portal sign-in. It sends its own service token and names the person it acts for; it never forwards the caller's token. If the vendors service does not answer, those routes answer 502 and the rest of construction keeps working.
Attachments
Files are kept by the documents service. Construction stores no file bytes and no blob
names. Documents has no authorization of its own yet, so construction is its only caller
for these files: it checks that the caller may see the record, then calls documents over
HTTP, at CONSTRUCTION_DOCUMENTS_URL, to create upload and download links and to record
and list files. A file is recorded with ref_type set to construction.<record type>
and ref_id set to the record's id. Neither app talks to documents directly.
The files are private blobs in the storage account named by CONSTRUCTION_STORAGE_ACCOUNT
and the container named by CONSTRUCTION_STORAGE_CONTAINER. Locally that is the
default-private account. Dev and prod use the construction-documents container in
flowappsprivatedev01 and flowappsprivateprod01.
Files go on a task or on a document revision. A revision takes files only while it is a
draft or in internal review (409 revision_locked afterwards), so the files behind a
published revision cannot change. A published revision shows the files of the internal
revision it was made from.
Without CONSTRUCTION_DOCUMENTS_URL the attachment routes answer 503, and the rest of the
service works as normal.
Events
One fanout topic per domain, construction-<domain>-fanout, declared in
services/construction/.azure/bus/topology.yaml. Nothing consumes
them yet.
| Event | When |
|---|---|
project.created | A project is created, with its seven stages |
project.stage_advanced | The project moved to its next stage, with the gate approval when there was one |
project.baseline_set | A baseline was written, at award or by an incorporated variation |
project.completed | The project was completed from closeout |
project.milestone_completed | A milestone got an actual date |
project.update_posted | The weekly update was posted |
task.created, task.progress_updated, task.completed | A task was created, moved or had its progress set, or was finished |
document.created | A document was registered |
document.revision_created, document.revision_actioned | A revision was started, or took a step in its review |
document.revision_published | A revision was published, with the revision it superseded and the recorded impact |
variation.drafted, variation.submitted, variation.decided, variation.incorporated | A variation moved through its steps |
budget.line_set, budget.cost_recorded | A budget line was set, or money was committed or spent |
decision.opened, decision.recorded | A question was raised, or answered with what it cost |
approval.requested, approval.responded, approval.decided | A review was asked for, one reviewer answered, or every required reviewer has answered |
risk.raised, risk.status_changed | A risk was added, or moved between open, mitigating, closed, and happened |
vendor.assigned, vendor.unassigned | A firm was put on a project or taken off it |
Task events go through the project domain's outbox, because tasks are in that domain. What each payload carries is in docs/construction/construction.md.
approval.responded and approval.decided both exist because they answer different
questions. A request with three required reviewers publishes three responses and one
decision.
A person appears in a payload by the identifier they authenticated with, which behind
Cloudflare Access is an email. Construction follows workspace, which also puts emails in
its events and marks them contains_pii = false, so there is no PII topic. Treat the
owner, reviewer and actor fields as personal data until the service moves to
FID: The id of a principal. For a person it is their Kratos identity id, a lowercase UUID; for a service it is its OAuth2 client_id. It arrives as the verified sub claim of a Hydra access token and becomes the actor on the request.s, which are PII-free.
What is not here
The design in docs/construction/construction.md maps services that do not exist yet — feasibility and yield, geometry, project cost, procurement, assets — and says what belongs in each. That mapping is the point: without it, work that belongs in a cost service lands in this one because this one is the only one that exists. The budget here is deliberately simple, and moves to the project cost service when that is built.