Stage 7 · Master
Phase 13 — Staff Service
Vendors
Model vendors as externally accountable parties with contract windows and verification state, owned by staff-service but only ever referenced by ID from Complaint and Maintenance.
A Vendor Is Accountable, Not Employed
Staff are on Meridian's payroll and follow the lifecycle from the previous lesson. Vendors — the plumbing contractor, the elevator maintenance company, the pest-control service — are external parties an organization pays per job. They have no hire date, no attendance record, and no RBAC role inside Meridian. What they do have is a contract window and a verification status, because an organization must be able to prove, later, that a vendor was authorized to be on site on a given date.
| Property | Staff | Vendor |
|---|---|---|
| Lifecycle | hired → active → on_leave/suspended → terminated | pending → verified/rejected, independent of contract dates |
| Time bound | Open-ended until terminated | contract_start / contract_end, possibly open-ended |
| Assignable to work | Rostered for shifts | Assignable only while verified and contract is active |
| Authenticates into Meridian | Yes, via the Authentication phase | No — vendors act through the organization's own staff |
A Time-Bound Value Object Guards Eligibility
package domain
import (
"errors"
"time"
)
type VerificationStatus string
const (
VerificationPending VerificationStatus = "pending"
VerificationVerified VerificationStatus = "verified"
VerificationRejected VerificationStatus = "rejected"
)
var ErrInvalidContractWindow = errors.New("contract end must be after contract start")
type ContractWindow struct {
start time.Time
end *time.Time
}
func NewContractWindow(start time.Time, end *time.Time) (ContractWindow, error) {
if end != nil && !end.After(start) {
return ContractWindow{}, ErrInvalidContractWindow
}
return ContractWindow{start: start, end: end}, nil
}
func (w ContractWindow) ActiveAt(at time.Time) bool {
if at.Before(w.start) {
return false
}
return w.end == nil || at.Before(*w.end)
}
type Vendor struct {
id string
organizationID string
companyName string
category string
window ContractWindow
verification VerificationStatus
}
func Onboard(id, organizationID, companyName, category string, window ContractWindow) *Vendor {
return &Vendor{id, organizationID, companyName, category, window, VerificationPending}
}
func (v *Vendor) Verify() { v.verification = VerificationVerified }
func (v *Vendor) Reject() { v.verification = VerificationRejected }
// EligibleForAssignment returns false for anything but a verified vendor
// with a contract window that covers the given instant. Callers never
// check the two conditions separately, which would let a stale contract
// sneak through.
func (v *Vendor) EligibleForAssignment(at time.Time) bool {
return v.verification == VerificationVerified && v.window.ActiveAt(at)
}
func (v *Vendor) ID() string { return v.id }
func (v *Vendor) OrganizationID() string { return v.organizationID }EligibleForAssignment merges verification and contract-window checks into one predicate on purpose — a caller that checked only verification status would keep assigning a vendor whose contract quietly expired last month.
Assignment Is a Service-to-Service Call, Not a User Action
Complaint and Maintenance own the lifecycle of a work order; they know nothing about a vendor beyond its ID. When Complaint assigns a vendor, it calls staff-service's internal endpoint, which authenticates with a service credential rather than a resident or staff JWT. staff-service records the assignment as its own fact — a history entry it owns — without needing to understand what a complaint is.
CREATE TYPE vendor_verification AS ENUM ('pending', 'verified', 'rejected');
CREATE TABLE vendors (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL, -- validated through organization-service
company_name text NOT NULL,
category text NOT NULL,
contract_start date NOT NULL,
contract_end date,
verification vendor_verification NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE vendor_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
vendor_id uuid NOT NULL REFERENCES vendors (id),
external_reference text NOT NULL, -- opaque complaint or work-order ID; staff-service does not join to it
assigned_at timestamptz NOT NULL DEFAULT now(),
released_at timestamptz
);
CREATE INDEX idx_vendor_assignments_vendor ON vendor_assignments (organization_id, vendor_id, released_at);external_reference is stored as an opaque string, never a foreign key into another service's schema. Two services sharing a table would be a hidden boundary violation; referencing an ID they both agree on is not.
package httpadapter
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/hoa-platform/backend/pkg/servicecreds"
"github.com/hoa-platform/backend/services/staff-service/internal/application"
)
type VendorAssignmentHandler struct {
assign *application.AssignVendor
}
func NewVendorAssignmentHandler(assign *application.AssignVendor) (*VendorAssignmentHandler, error) {
if assign == nil {
return nil, errors.New("assign vendor use case is required")
}
return &VendorAssignmentHandler{assign: assign}, nil
}
type assignVendorRequest struct {
ExternalReference string `json:"external_reference" binding:"required"`
}
// Assign is only reachable through the internal service mesh: servicecreds
// verifies a signed service-identity token, not a resident or staff JWT.
func (h *VendorAssignmentHandler) Assign(c *gin.Context) {
caller, err := servicecreds.FromContext(c.Request.Context())
if err != nil || caller.ServiceName != "complaint-service" {
c.JSON(http.StatusForbidden, gin.H{"code": "service_not_authorized"})
return
}
var req assignVendorRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"code": "validation_failed"})
return
}
assignment, err := h.assign.Execute(c.Request.Context(), application.AssignVendorCommand{
TenantID: caller.TenantID,
VendorID: c.Param("vendorID"),
ExternalReference: req.ExternalReference,
})
switch {
case errors.Is(err, application.ErrVendorNotEligible):
c.JSON(http.StatusConflict, gin.H{"code": "vendor_not_eligible"})
return
case errors.Is(err, application.ErrNotFound):
c.JSON(http.StatusNotFound, gin.H{"code": "vendor_not_found"})
return
case err != nil:
c.JSON(http.StatusInternalServerError, gin.H{"code": "internal_error"})
return
}
c.JSON(http.StatusCreated, assignment)
}The check on caller.ServiceName is deliberately narrow: only complaint-service may create assignments today. Widening it to any authenticated service would let an unrelated service assign vendors it has no business assigning.
A vendor whose contract lapsed last week is a well-formed request pointing at a real vendor that is simply no longer eligible — a conflict with current state, not a malformed payload. vendor_not_eligible keeps that distinction visible to the caller.
Confirm Contract and Verification Both Gate Assignment
Applied exercise
Design vendor category-specific eligibility
The elevator maintenance category legally requires an annual safety certificate on file, in addition to the standard verification and contract checks.
- Decide whether the certificate expiry belongs on the Vendor struct or a separate value object.
- Extend EligibleForAssignment (or replace it) so an elevator vendor with an expired certificate is rejected the same way an expired contract is.
- Keep the rule invisible to other categories — a plumbing vendor must not require a certificate.
- Write one test per category proving the rule only applies where it should.
Deliverable
An updated vendor.go plus tests distinguishing category-specific eligibility from the general rule.
Completion checks
- Non-elevator categories are unaffected by the new check.
- The certificate rule lives in the domain package, not in the HTTP handler.
- A single EligibleForAssignment-style call site still expresses the full rule.
Why does vendor_assignments store external_reference as an opaque text column instead of a foreign key to the complaints table?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.