stralos-domain exports the shared Party contract from src/types.ts. This page documents that contract and the modelling decisions behind it.
Background
Before the companies register, clients, suppliers, and contractors were separate entities in separate tables. A business that both supplies materials and subcontracts work required duplicate records with no reconciliation path. The companies register replaces those three tables with a singleparties row carrying three role booleans.
The UI label is Companies. The code name is parties — organisation already names the tenant that owns all data and is the column every RLS policy keys off, so reusing it here would collide with the security model.
Client, Supplier, and Contractor are still exported unchanged and remain load-bearing in five repos until the M6 consumer sweep retires them. M4 is purely additive.
Core entities
Party
A company. Carries at least one role — all three booleans false is not a representable state on the wire (CHECK parties_role_required).
PartyContact
A person at a company. Retiring the company does not write onto its contacts (ruling 2026-09-24, replacing the D24 cascade): they stop being usable by association. Reinstating a company is a future PRD with no path yet; because nothing is written onto the children, a future reinstate needs no child writes. A contact’s own status is inactive only when it was itself deleted (DELETE /party-contacts/:id keeps the row).
Role enums and filters
PartyRole
GET /parties?role=.
Approval status
PartyApprovalStatus is advisory metadata about the subcontractor relationship (§6.2). It gates nothing — a suspended company still prices, still appears in pickers, and still resolves rates.
"expired" is not a stored value. Expiry is derived from approvedUntil at render time (§6.2) to avoid clock skew between rows; the list page derives its chip client-side.
GET /parties has no approval filter. The domain’s PartyApprovalFilter type was removed in domain 4.0.0, and api-global does not read an approval query parameter.
List requests
PartyListDto
The shape returned by GET /parties extends PartyRow with two extra fields. These fields are present on every list row and are absent from GET /parties/:id (where the caller already holds the full collections).
Each count reads exactly what the company record’s own panel reads.
locationCount counts effectively active locations (own-status active on a live company, zero on a retired one whose every location is retired by association). contactCount counts active contacts only, because DELETE /party-contacts/:id sets a contact inactive rather than removing the row and GET /parties/:id/contacts hides inactive ones. A register row whose count disagreed with the panel would be the copy nobody trusted.
The query uses two grouped reads restricted to the ids on the page (length ≤ 200), not a correlated subquery per row. Work is bounded by the page size rather than by the total register size.
GET /parties query
GET /parties query parameters. search covers name and business number (§8.2). Pagination follows the service’s hand-rolled parser: start >= 0, length defaults to 50 and is capped at 200.
GET /locations owner filter
GET /locations supports an owner filter.
Location.partyId
Location gains a nullable partyId field:
null means the organisation’s own site. When partyId is set, the location belongs to a specific company. GET /locations returns both kinds by default; pass owner=ours or owner=theirs to narrow the result.
partyId cannot be changed through the location PUT endpoint: the PUT schema has no partyId field, so moving a site between owners cannot silently re-attribute cached routes. Ownership is fixed by the creation path: POST /locations creates an organisation-owned location with partyId: null, while POST /parties/:id/locations creates a location owned by that company.
The field is optional rather than required so existing consumers’ Location fixtures keep compiling until they adopt it.
Party location endpoints
GET /parties/:id/locations and POST /parties/:id/locations manage the sites owned by a specific company.
Both endpoints return the same geoTag-nested shape that GET /locations and POST /locations use. Address and coordinate fields are nested under geoTag:
POST /parties/:id/locations takes the same body schema as POST /locations. The party id comes from the URL path, not the body — a partyId in the body is not accepted. The company must exist in the caller’s organisation; an unknown or foreign-organisation id returns 404.
The new location is immediately selectable in the Rates picker for that company.
GET /parties/:id/locations returns { items: LocationDto[] }, unpaginated, ordered by name.
A location’s (name, addressLine1) is unique per owner (one company, or the organisation’s own sites), not per organisation (migration 0099). A same-owner clash returns 409 location_duplicate; the frontend shows it under Name rather than as a toast. Another owner at the same address is allowed: create and update responses carry sharedAddressWith, and GET /locations/shared-address answers the same question before a save. See Locations and Shared address warning.
Client logos
Each company can have one active logo, printed beside the client’s name on a Tender download PDF. The routes (GET /parties/:id/logo, POST /parties/:id/logo-uploads, POST /parties/:id/logo-uploads/:uploadId/confirm, DELETE /parties/:id/logo) live in src/routes/party-logos.ts and reuse the company branding flow and file rules. Unlike the company branding logo, which is admin-only, any organisation user may write a client logo, following the register’s write rule. A retired company’s logo is frozen (409 party_retired), so historic tenders keep printing it. GET /parties/:id and GET /projects/:id/tender-details both carry the active logo with a five-minute URL. See Client logos.
The company record shows the card as CompanyLogoCard (stralos-feature-workspace): the chosen file is previewed and only uploaded on Save logo, and a retired company shows the logo read-only.
Combined client create
POST /parties accepts optional mainContact and address objects and creates the company, its main contact and its first site in one transaction, so the New client step in the New project sheet can never leave a client without the contact that POST /projects now requires. A main contact needs a phone or an email (422 contact_reach_required), and an address must be a complete picked one (422 location_address_required). The contact is a company-level contact (locationId is not set). PUT /parties/:id does not take these fields. See POST /parties.
Access control for party writes
Write operations on the register (POST /parties, PUT /parties/:id, DELETE /parties/:id, POST /parties/:id/contacts, PUT /party-contacts/:id, DELETE /party-contacts/:id, POST /parties/:id/locations) require a user token. Any signed-in organisation user, admin or member, may call them. Tenant service tokens are refused with 403 user_token_required, even when they carry an organisation context. Read operations accept any authenticated organisation token (user or service).
Organisation-owned location creation
POST /locations creates a site owned by the organisation (no partyId). The location is immediately selectable in the Rates picker.
Supplier pricing identity
A supplier company’s ownid is the stable identifier used by the Rates service to store and resolve supplier-scoped rate options. There is no separate rate source id for supplier companies. Use GET /parties/:id to read the company, and use isSupplier && status === "active" to determine whether it is currently available for pricing.
Rates deep link from a company record
The Open in Rates button on a company record navigates to/rates?supplierId=<company.id>. The Rates catalogue reads supplierId as a filter and shows only that company’s prices.
The button is shown when isSupplier && status === "active". A company that is ticked as a supplier but has been retired does not show the link.
Party.rateSource was removed from the GET /parties/:id response in migration 0059, which dropped the rate_sources table. The deep link previously used rateSourceId=<rateSource.id>; because every managed rate source was inserted with id equal to the party’s own id, the value the link carried was always the company id. Only the query parameter name changed.
