Stage 7 · Master
Phase 12 — Visitor Service
Approval Workflow
Model pre-authorized arrivals and unscheduled walk-ins as different flows, then make timeout a deliberate deny-by-default decision anchored to database time.
Why walk-ins need a different state machine from pre-authorized guests
A pre-authorized guest arrives with a credential the host already created, so the guard's job is mostly validation and check-in. A walk-in is the opposite: there is no credential yet, only a person at the gate claiming a destination. Treating those as the same flow is how teams accidentally let guards type around security rules during rush hour. The safer design is two explicit paths: invitation lookup for expected visitors, and approval-request creation for unexpected ones.
| Arrival path | Who acts first | What the guard needs | Default result if nobody responds |
|---|---|---|---|
| Pre-authorized | Resident creates the invitation before arrival | A valid token inside its time window | No check-in happens unless the invitation is still valid |
| Walk-in | Guard creates a pending approval request at the gate | A reachable host and a signed approve or deny action | Timed out requests become deny-by-default after the expiry window |
That distinction also changes the failure mode. If the resident's phone is in airplane mode, a walk-in should not silently degrade into 'probably okay'. In an HOA, the most common real-world timeout reasons are exactly the cases where default-allow would be unsafe: the host is asleep, the host is away, or the network to the host is temporarily unavailable.
CREATE TABLE visitor_approval_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
flat_id UUID NOT NULL,
host_user_id UUID NOT NULL,
visitor_name TEXT NOT NULL,
purpose TEXT NOT NULL,
requested_by_guard_user_id UUID NOT NULL,
status TEXT NOT NULL,
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
resolved_at TIMESTAMPTZ,
resolution_note TEXT,
CONSTRAINT visitor_approval_requests_status_chk CHECK (
status IN ('PENDING', 'APPROVED', 'DENIED', 'TIMED_OUT')
)
);
CREATE INDEX visitor_approval_requests_pending_idx
ON visitor_approval_requests (org_id, host_user_id, expires_at)
WHERE status = 'PENDING';
CREATE INDEX visitor_approval_requests_guard_idx
ON visitor_approval_requests (org_id, requested_by_guard_user_id, requested_at DESC);expires_at is materialized in the table so the approval status remains auditable long after the request is resolved.
DROP TABLE IF EXISTS visitor_approval_requests;Rollback stays additive-safe because this table has no pre-existing callers in earlier modules.
Why expiry must be checked against database time
Gate tablets are exactly the devices most likely to have bad clocks: they reboot, lose NTP, sit on unreliable Wi-Fi, and may be carried between guard desks. If the gate app compares valid_until to its own local clock, one fast tablet could admit expired visitors and one slow tablet could reject valid ones. The service therefore performs approval and invitation expiry checks against PostgreSQL now(), the only clock every replica and every API process can agree on for this decision.
That same rule applies when the host taps approve. A signed action link is not enough by itself; the UPDATE that flips PENDING to APPROVED must still include expires_at > now() so a stale link cannot resurrect a timed-out request.
package visitor
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type ApprovalStatus string
const (
ApprovalStatusPending ApprovalStatus = "PENDING"
ApprovalStatusApproved ApprovalStatus = "APPROVED"
ApprovalStatusDenied ApprovalStatus = "DENIED"
ApprovalStatusTimedOut ApprovalStatus = "TIMED_OUT"
)
type ApprovalRequest struct {
ID uuid.UUID
OrgID uuid.UUID
FlatID uuid.UUID
HostUserID uuid.UUID
VisitorName string
Purpose string
RequestedByGuardUserID uuid.UUID
Status ApprovalStatus
RequestedAt time.Time
ExpiresAt time.Time
ResolvedAt *time.Time
ResolutionNote string
}
type CreateWalkInInput struct {
FlatID uuid.UUID
HostUserID uuid.UUID
VisitorName string
Purpose string
}
var (
ErrApprovalRequestNotFound = errors.New("visitor: approval request not found")
ErrApprovalTimedOut = errors.New("visitor: approval request timed out")
ErrApprovalAlreadyResolved = errors.New("visitor: approval request already resolved")
ErrApprovalSignatureInvalid = errors.New("visitor: approval action signature invalid")
)
type ApprovalStore interface {
InsertApprovalRequest(ctx context.Context, params InsertApprovalRequestParams) (ApprovalRequest, error)
ResolveApproval(ctx context.Context, orgID, requestID, hostUserID uuid.UUID, status ApprovalStatus) (ApprovalRequest, error)
MarkTimedOut(ctx context.Context) (int64, error)
LookupApproval(ctx context.Context, orgID, requestID uuid.UUID) (ApprovalRequest, error)
}
type InsertApprovalRequestParams struct {
OrgID uuid.UUID
FlatID uuid.UUID
HostUserID uuid.UUID
VisitorName string
Purpose string
RequestedByGuardUserID uuid.UUID
Timeout time.Duration
}
type ApprovalService struct {
store ApprovalStore
flats FlatClient
residents ResidentClient
signer *ActionLinkSigner
timeout time.Duration
}
func NewApprovalService(store ApprovalStore, flats FlatClient, residents ResidentClient, signer *ActionLinkSigner, timeout time.Duration) *ApprovalService {
if timeout <= 0 {
timeout = 5 * time.Minute
}
return &ApprovalService{store: store, flats: flats, residents: residents, signer: signer, timeout: timeout}
}
func (s *ApprovalService) CreateWalkInRequest(ctx context.Context, orgID, guardUserID uuid.UUID, input CreateWalkInInput) (ApprovalRequest, string, string, error) {
exists, err := s.flats.ExistsInOrg(ctx, orgID, input.FlatID)
if err != nil {
return ApprovalRequest{}, "", "", err
}
if !exists {
return ApprovalRequest{}, "", "", ErrFlatNotFound
}
eligible, err := s.residents.IsActiveOccupant(ctx, orgID, input.FlatID, input.HostUserID)
if err != nil {
return ApprovalRequest{}, "", "", err
}
if !eligible {
return ApprovalRequest{}, "", "", ErrHostNotEligible
}
request, err := s.store.InsertApprovalRequest(ctx, InsertApprovalRequestParams{
OrgID: orgID,
FlatID: input.FlatID,
HostUserID: input.HostUserID,
VisitorName: input.VisitorName,
Purpose: input.Purpose,
RequestedByGuardUserID: guardUserID,
Timeout: s.timeout,
})
if err != nil {
return ApprovalRequest{}, "", "", err
}
approveLink, err := s.signer.Build(request, ApprovalStatusApproved)
if err != nil {
return ApprovalRequest{}, "", "", err
}
denyLink, err := s.signer.Build(request, ApprovalStatusDenied)
if err != nil {
return ApprovalRequest{}, "", "", err
}
return request, approveLink, denyLink, nil
}
func (s *ApprovalService) Resolve(ctx context.Context, orgID, requestID, hostUserID uuid.UUID, status ApprovalStatus, signature string, expiresAt time.Time) (ApprovalRequest, error) {
if err := s.signer.Verify(orgID, requestID, hostUserID, status, expiresAt, signature); err != nil {
return ApprovalRequest{}, err
}
return s.store.ResolveApproval(ctx, orgID, requestID, hostUserID, status)
}
func (s *ApprovalService) ExpirePendingRequests(ctx context.Context) (int64, error) {
return s.store.MarkTimedOut(ctx)
}
type PGApprovalStore struct {
pool *pgxpool.Pool
}
func NewPGApprovalStore(pool *pgxpool.Pool) *PGApprovalStore {
return &PGApprovalStore{pool: pool}
}
// Walk-in approval asks the same two eligibility questions the invitation path
// does, and answers them the same way — through FlatClient and ResidentClient,
// never through this pool.
func (s *PGApprovalStore) InsertApprovalRequest(ctx context.Context, params InsertApprovalRequestParams) (ApprovalRequest, error) {
var request ApprovalRequest
err := s.pool.QueryRow(
ctx,
"INSERT INTO visitor_approval_requests (org_id, flat_id, host_user_id, visitor_name, purpose, requested_by_guard_user_id, status, expires_at) "+
"VALUES ($1, $2, $3, $4, $5, $6, 'PENDING', now() + ($7 * interval '1 second')) "+
"RETURNING id, org_id, flat_id, host_user_id, visitor_name, purpose, requested_by_guard_user_id, status, requested_at, expires_at, resolved_at, coalesce(resolution_note, '')",
params.OrgID,
params.FlatID,
params.HostUserID,
params.VisitorName,
params.Purpose,
params.RequestedByGuardUserID,
int(params.Timeout.Seconds()),
).Scan(
&request.ID,
&request.OrgID,
&request.FlatID,
&request.HostUserID,
&request.VisitorName,
&request.Purpose,
&request.RequestedByGuardUserID,
&request.Status,
&request.RequestedAt,
&request.ExpiresAt,
&request.ResolvedAt,
&request.ResolutionNote,
)
return request, err
}
func (s *PGApprovalStore) ResolveApproval(ctx context.Context, orgID, requestID, hostUserID uuid.UUID, status ApprovalStatus) (ApprovalRequest, error) {
var request ApprovalRequest
err := s.pool.QueryRow(
ctx,
"UPDATE visitor_approval_requests "+
"SET status = $4, resolved_at = now(), resolution_note = '' "+
"WHERE org_id = $1 AND id = $2 AND host_user_id = $3 AND status = 'PENDING' AND expires_at > now() "+
"RETURNING id, org_id, flat_id, host_user_id, visitor_name, purpose, requested_by_guard_user_id, status, requested_at, expires_at, resolved_at, coalesce(resolution_note, '')",
orgID,
requestID,
hostUserID,
status,
).Scan(
&request.ID,
&request.OrgID,
&request.FlatID,
&request.HostUserID,
&request.VisitorName,
&request.Purpose,
&request.RequestedByGuardUserID,
&request.Status,
&request.RequestedAt,
&request.ExpiresAt,
&request.ResolvedAt,
&request.ResolutionNote,
)
if errors.Is(err, pgx.ErrNoRows) {
existing, lookupErr := s.LookupApproval(ctx, orgID, requestID)
if lookupErr != nil {
return ApprovalRequest{}, lookupErr
}
if existing.Status == ApprovalStatusPending {
return ApprovalRequest{}, ErrApprovalTimedOut
}
return ApprovalRequest{}, ErrApprovalAlreadyResolved
}
return request, err
}
func (s *PGApprovalStore) MarkTimedOut(ctx context.Context) (int64, error) {
result, err := s.pool.Exec(
ctx,
"UPDATE visitor_approval_requests SET status = 'TIMED_OUT', resolved_at = now(), resolution_note = 'host response timeout' WHERE status = 'PENDING' AND expires_at <= now()",
)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
func (s *PGApprovalStore) LookupApproval(ctx context.Context, orgID, requestID uuid.UUID) (ApprovalRequest, error) {
var request ApprovalRequest
err := s.pool.QueryRow(
ctx,
"SELECT id, org_id, flat_id, host_user_id, visitor_name, purpose, requested_by_guard_user_id, status, requested_at, expires_at, resolved_at, coalesce(resolution_note, '') FROM visitor_approval_requests WHERE org_id = $1 AND id = $2",
orgID,
requestID,
).Scan(
&request.ID,
&request.OrgID,
&request.FlatID,
&request.HostUserID,
&request.VisitorName,
&request.Purpose,
&request.RequestedByGuardUserID,
&request.Status,
&request.RequestedAt,
&request.ExpiresAt,
&request.ResolvedAt,
&request.ResolutionNote,
)
if errors.Is(err, pgx.ErrNoRows) {
return ApprovalRequest{}, ErrApprovalRequestNotFound
}
return request, err
}
type ActionLinkSigner struct {
baseURL string
secret []byte
}
func NewActionLinkSigner(baseURL string, secret []byte) *ActionLinkSigner {
return &ActionLinkSigner{baseURL: baseURL, secret: secret}
}
func (s *ActionLinkSigner) Build(request ApprovalRequest, status ApprovalStatus) (string, error) {
payload := fmt.Sprintf("%s|%s|%s|%s|%d", request.OrgID, request.ID, request.HostUserID, status, request.ExpiresAt.Unix())
mac := hmac.New(sha256.New, s.secret)
if _, err := mac.Write([]byte(payload)); err != nil {
return "", err
}
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return fmt.Sprintf(
"%s/v1/visitor-approvals/%s/resolve?org_id=%s&host_user_id=%s&decision=%s&expires_at=%d&sig=%s",
s.baseURL,
request.ID,
request.OrgID,
request.HostUserID,
status,
request.ExpiresAt.Unix(),
signature,
), nil
}
func (s *ActionLinkSigner) Verify(orgID, requestID, hostUserID uuid.UUID, status ApprovalStatus, expiresAt time.Time, signature string) error {
payload := fmt.Sprintf("%s|%s|%s|%s|%d", orgID, requestID, hostUserID, status, expiresAt.Unix())
mac := hmac.New(sha256.New, s.secret)
if _, err := mac.Write([]byte(payload)); err != nil {
return err
}
expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(signature)) {
return ErrApprovalSignatureInvalid
}
return nil
}The link signature prevents tampering, but the row still expires in SQL with expires_at > now(), so a stale or delayed phone tap cannot approve a timed-out visitor.
If the host is asleep, offline, or unreachable, default-allow would turn a transient notification failure into physical access. In an HOA, the absence of an approval must resolve to deny, not to optimism.
What the guard sees when nobody answers in time
The operator-facing outcome should be boring and deterministic: the request stays PENDING until expires_at, the timeout worker flips it to TIMED_OUT, and the guard UI shows that the host did not respond within policy. That is different from an invalid host or a foreign flat. Those are creation-time validation failures. Timeout is a legitimate runtime state, and keeping it explicit makes later audit and support work far easier.
A common failure mode here is checking timeout with the API server's clock in ResolveApproval but using PostgreSQL now() in the background sweeper. If those clocks diverge, the host can see an approval banner while the guard sees a timeout. The safest rule is to let the database decide both transitions.
Applied exercise
Trace a delivery driver's walk-in when the host phone is offline
A delivery driver arrives unexpectedly at 11:55 PM, the host's phone is in airplane mode, and the guard must decide whether the package can enter the society.
- Describe the rows and statuses created from the moment the guard submits the walk-in request.
- Explain how the signed approve link is generated even though the host never taps it.
- Identify the exact condition that turns PENDING into TIMED_OUT and why that condition must compare against database now().
- Write the final operator outcome the guard should see and explain why default-allow would be the wrong HOA posture.
Deliverable
A sequence-style trace from request creation to timeout, including the database state transition and the rationale for fail-closed behavior.
Completion checks
- Your trace includes both notification dispatch and the timeout worker rather than skipping directly to denial.
- The explanation for database time mentions clock drift or bad tablet clocks explicitly.
- The final outcome is a deny-by-timeout state, not an ambiguous 'no response' label.
Why is fail-closed the correct default for an unanswered walk-in approval request?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.