Getting started
Run construction locally, create a project, and watch a stage advance get refused without an approved gate.
Start it
make dev-up
./localenv up construction-api vendors-apiThe service listens on localhost:8145. Its database is
construction on the local Postgres. Swagger UI is at
localhost:8145/api-docs when SWAGGER_ON=true, which
the local runner sets.
Say who you are
Deployed, the service sits behind Cloudflare Access, and the caller is the email in the
Access token. Locally there is no Access, so the runner starts the service with
DEV_AUTH=1 and the caller is whoever the X-Dev-Principal header names. Without the
header it is DEV_PRINCIPAL, which defaults to [email protected] or DEV_ACTOR from
the repository-root .env. The service refuses DEV_AUTH unless APP_ENV=local.
Anyone Access admits can read. Only the emails in CONSTRUCTION_EDITORS can change
anything, and locally that list is the dev principal. The examples below set the header
explicitly:
Demo data
./localenv up loads services/construction/testdata/dev-seed.sql and
services/vendors/testdata/dev-seed.sql into the local databases. To load them by hand,
run ./localenv dev-seed construction vendors. The data is the Flow signage rollout from
the Project Signage Master Sheet:
- Eleven capital improvement projects, one per property, in Saudi Arabia and the USA.
- Each sign is three tasks: submit to the authority, authority approval, and install and hand over.
- Two signage fabricators, Bright Line Signs and Najd Signage Works.
Task statuses are set as of 7 October 2026, so some projects are on track, some at risk,
and some off track. Two stage gates and one variation are waiting on [email protected].
The seed already registers [email protected] as the Bright Line Signs contact, so
the vendor portal shows that firm's tasks without the steps further down this page. If you
follow those steps against the seeded data, the contact step reports a conflict because
the contact already exists.
Rerunning the seed leaves existing rows alone. To start again from clean data, stop the
stack and reset both databases:
./localenv down && ./localenv reset db construction -y && ./localenv reset db vendors -y.
To load the same data into the dev environment, run
./localenv dev-seed --dev construction. Before it runs:
- It needs what
./localenv seedneeds:sops,az login, and a network route to the dev Postgres server. - It reads the dev database URL from
services/construction/.azure/env/secrets.dev.yaml. - It refuses to run unless the dev database is at the same migration as your branch.
- It asks you to type
devbefore it writes anything.
Vendors is not deployed to dev, so in dev the seeded vendor IDs do not resolve to firms.
Create a project
curl -s -X POST localhost:8145/v1/development-projects \
-H "X-Dev-Principal: $ME" \
-H "Idempotency-Key: $(uuidgen)" \
-H 'Content-Type: application/json' \
-d '{
"data": {
"code": "RIV-01",
"name": "Riverside Phase 1",
"kind": "ground_up",
"region": "Texas",
"site_location_id": "01931f00-0000-7000-8000-000000000001",
"approved_budget_minor": 4200000000,
"planned_start": "2026-04-01",
"planned_finish": "2028-06-30"
}
}' | jqkind is one of master_plan, ground_up, conversion, or capital_improvement, and
is fixed once the project exists. site_location_id is a location owned by the locations
service. approved_budget_minor is
integer minor units — 4200000000 is $42,000,000, not forty-two million dollars and a
rounding error.
Creating a project also creates its seven stages, in order. The new project is in
initiation.
Try to advance without a gate
curl -s -X POST "localhost:8145/v1/development-projects/$ID/advance" \
-H "X-Dev-Principal: $ME" \
-H "Idempotency-Key: $(uuidgen)" \
-H 'Content-Type: application/json' \
-d '{"data": {"from_stage": "initiation"}}' | jqThis is refused with 409 and the code no_approved_gate. That refusal is the point of the
service: a project does not leave a stage because somebody clicked a button.
Get a gate approved, then advance
Request the gate, naming the stage as the subject and at least one reviewer:
curl -s -X POST localhost:8145/v1/approvals \
-H "X-Dev-Principal: $ME" \
-H "Idempotency-Key: $(uuidgen)" \
-H 'Content-Type: application/json' \
-d '{
"data": {
"project_id": "'"$ID"'",
"kind": "stage_gate",
"subject_type": "stage",
"subject_id": "initiation",
"title": "Initiation approved",
"reviewers": [{"fid": "[email protected]", "required": true}]
}
}' | jqThen answer it as that reviewer and repeat the advance, which now succeeds and publishes
project.stage_advanced. Reviewers are named by email, and the reviewer answering is the
authenticated caller, never a field in the body. Answering also needs the caller to be an
editor.
curl -s -X POST "localhost:8145/v1/approvals/$APPROVAL_ID/response" \
-H "X-Dev-Principal: $ME" \
-H "Idempotency-Key: $(uuidgen)" \
-H 'Content-Type: application/json' \
-d '{"data": {"response": "approved"}}' | jqAttach files
Attachments are stored by the documents service. The local runner points construction at
it with CONSTRUCTION_DOCUMENTS_URL, which defaults to http://127.0.0.1:8102. Start it
with ./localenv up documents-api; it needs the Azurite emulator from make dev-up.
Without documents running, the attachment routes answer 502 and everything else works.
Sign in as an outside firm
The firms and their contacts are in the vendors service,
so vendors-api has to be running; construction finds it with CONSTRUCTION_VENDORS_URL,
which defaults to http://127.0.0.1:8146. Without it, assigning a firm, the project's
firm list and the vendor portal answer 502.
The vendor portal (Vendor portal)
signs in as a contact at a firm. The firm, the contact, and the firm's place on the
project have to exist first, or every vendor route answers 403
not_a_vendor_contact. Create the firm in vendors-api as an editor:
curl -s -X POST localhost:8146/v1/vendors \
-H "X-Dev-Principal: $ME" \
-H "Idempotency-Key: $(uuidgen)" \
-H 'Content-Type: application/json' \
-d '{"data": {"name": "Acme Structural", "kind": "engineer", "disciplines": ["structural"]}}' | jqThen add the contact the portal signs in as, and put the firm on the project:
curl -s -X POST "localhost:8146/v1/vendors/$VENDOR_ID/contacts" \
-H "X-Dev-Principal: $ME" \
-H "Idempotency-Key: $(uuidgen)" \
-H 'Content-Type: application/json' \
-d '{"data": {"email": "[email protected]", "name": "Vendor Local"}}' | jq
curl -s -X POST "localhost:8145/v1/vendors/$VENDOR_ID/assignments" \
-H "X-Dev-Principal: $ME" \
-H "Idempotency-Key: $(uuidgen)" \
-H 'Content-Type: application/json' \
-d '{"data": {"project_id": "'"$ID"'", "role": "Structural engineer of record", "manager_fid": "'"$ME"'", "started_on": "2026-03-02"}}' | jqThe console's Vendors tab does the same three steps, against both services. Then start the portal:
./localenv up vendor-app # http://localhost:3509It signs in as VENDOR_DEV_PRINCIPAL, which defaults to [email protected]. Set
it in the repository-root .env to try a different contact. The portal ignores
DEV_ACTOR, because that names a Flow employee and the vendor routes refuse anyone who
is not a contact. To call a vendor route with curl, send the contact's email:
curl -s localhost:8145/v1/vendor/inbox -H "X-Dev-Principal: [email protected]" | jqChange the database
SQL lives in services/construction/queries/<domain>.sql. The Go under db/ is
generated and must never be edited:
go tool sqlc generateTest it
go test -race -count=1 ./services/construction/...The integration tests need the local Postgres and skip without it. They cover the stage gates and the award baseline, milestones driven by another project, progress from tasks, health and freshness, revision labels and the single published revision, publishing with its impact and a variation, a variation through its approval, a required document blocking done, task links and project relations, attachments against a fake documents service, and that one firm's contact gets 404 on another firm's tasks and documents.
Construction Service
Planning and document control for development projects — seven stages with gates, tasks that measure progress, and controlled documents with a revision history.
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.