Flow
ServicesConstruction Service

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.

DomainWhat it holds
projectThe 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
documentControlled 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
variationVariation: 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
budgetBudget lines per category, and committed and actual cost events
decisionQuestions the project has to answer, the alternatives considered, what was chosen, and what that cost
approvalFormal reviews with named reviewers, over a stage gate, a decision, a budget, or a variation
riskThe risk register: likelihood, impact, owner, exposure, mitigation
vendorsWhich 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 published to superseded. 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_EDITORS may 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.

EventWhen
project.createdA project is created, with its seven stages
project.stage_advancedThe project moved to its next stage, with the gate approval when there was one
project.baseline_setA baseline was written, at award or by an incorporated variation
project.completedThe project was completed from closeout
project.milestone_completedA milestone got an actual date
project.update_postedThe weekly update was posted
task.created, task.progress_updated, task.completedA task was created, moved or had its progress set, or was finished
document.createdA document was registered
document.revision_created, document.revision_actionedA revision was started, or took a step in its review
document.revision_publishedA revision was published, with the revision it superseded and the recorded impact
variation.drafted, variation.submitted, variation.decided, variation.incorporatedA variation moved through its steps
budget.line_set, budget.cost_recordedA budget line was set, or money was committed or spent
decision.opened, decision.recordedA question was raised, or answered with what it cost
approval.requested, approval.responded, approval.decidedA review was asked for, one reviewer answered, or every required reviewer has answered
risk.raised, risk.status_changedA risk was added, or moved between open, mitigating, closed, and happened
vendor.assigned, vendor.unassignedA 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.

On this page