Flow
ServicesConstruction Service

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-api

The 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 seed needs: 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 dev before 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"
    }
  }' | jq

kind 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"}}' | jq

This 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}]
    }
  }' | jq

Then 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"}}' | jq

Attach 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"]}}' | jq

Then 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"}}' | jq

The console's Vendors tab does the same three steps, against both services. Then start the portal:

./localenv up vendor-app     # http://localhost:3509

It 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]" | jq

Change the database

SQL lives in services/construction/queries/<domain>.sql. The Go under db/ is generated and must never be edited:

go tool sqlc generate

Test 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.

On this page