# Instant Matching Engine — Build Spec
**Author:** Product Agent (Rivet)
**Date:** 2026-02-06
**Status:** APPROVED — Ready for Builder Agent
**Decisions by:** Michael Arundell

---

## 1. What This Is

The core engine of RateRight. A contractor posts a job, the system finds matching available workers within minutes, workers see the rate and accept, contractor taps hire, pays $50, and gets the worker's contact details. Wages are between contractor and worker — we don't touch them.

This is the entire product. There is no MVP vs Phase 2. Build all of it.

---

## 2. Matching Flow — End to End

### Step 1: Contractor Posts a Job

Contractor must have a card on file (Stripe) before posting. This filters bad actors.

```
INPUT:
  trade: "steelfixer"
  location: "Parramatta"  → geocoded to lat/lng
  date: "2026-02-07"
  time: "6:00am - 4:00pm"
  rate: $45/hr
  workers_needed: 3
```

For multi-hire (e.g., "I need 3 steelfixers"), this posts as a **single job** with `workers_needed = 3`. The first 3 workers to accept fill the slots. No separate posts.

### Step 2: Instant Match Query (< 2 seconds)

System executes in sequence:

1. **Geo query** — PostGIS `ST_DWithin` on `worker_availability.location` for workers within 30km with matching trade. Target: < 100ms.
2. **Availability filter** — Workers with status `available` whose availability window overlaps the job date. Target: < 50ms.
3. **Score & rank** — Composite score on all passing workers. Target: < 100ms.
4. **Select top N** — Take top 10 results (or fewer if supply is thin).

Total query budget: **< 500ms**.

### Step 3: Push Notification Blast (< 10 seconds after post)

All matched workers receive a push notification simultaneously. Not one at a time — speed beats exclusivity.

```
🔨 New job near you!
Steel Fixer — Parramatta
Tomorrow 6am–4pm | $45/hr
[View & Accept] [Pass]
```

**Workers MUST see the offered rate before accepting.** The rate is always visible in the notification and on the job detail screen. No hidden rates.

When a worker taps "View & Accept," they see:
- Trade, site suburb, date/time
- Offered hourly/daily rate
- Contractor name + rating
- Distance from their current/home location
- [Accept] [Pass] buttons

### Step 4: Workers Accept → Contractor Sees Matches

As workers accept, the contractor's screen updates in real-time via Supabase Realtime (WebSocket):

```
┌──────────────────────────────────┐
│ Steel Fixer — Parramatta         │
│ Need 3 · 1 of 3 filled          │
│                                  │
│ ✅ Jake M. — 98% reliable       │
│    12 min away · $45/hr         │
│    [Hire]                        │
│                                  │
│ ⏳ Waiting for more workers...  │
│    2 more needed                 │
└──────────────────────────────────┘
```

For multi-hire batch jobs: the first 3 acceptances auto-fill the slots. Workers 4+ who try to accept get: "Sorry, this job has been filled."

### Step 5: Contractor Hires → $50 Charge

Contractor taps [Hire] on each worker (or [Hire All] for batch).

On hire confirmation:
1. **Stripe charges $50** per worker hired to the contractor's card on file.
2. Both parties receive each other's phone number and name.
3. Worker gets a confirmation push: "You're booked! Site details below."
4. Contractor gets a confirmation: "Jake M. confirmed for tomorrow 6am."

**That's it.** Wages are direct between contractor and worker. RateRight is not a payment intermediary. No escrow. No wage processing.

### Step 6: Fallback Cascade (if < enough matches)

| Time | Action |
|------|--------|
| 0–5 min | Push to top 10 within 30km |
| 5–10 min | Expand to 50km, re-blast to new matches |
| 10–30 min | SMS blast to offline workers matching criteria |
| 30–60 min | Suggest to contractor: adjust rate, broaden trade, change date |
| 1hr+ | Job stays posted, passive matching continues |

---

## 3. Scoring Algorithm

```
match_score = (
    reliability_score × 0.35 +
    proximity_score   × 0.25 +
    rate_score         × 0.15 +
    repeat_hire_bonus  × 0.10 +
    priority_badge     × 0.15
)
```

All sub-scores normalized to 0–100.

### Reliability Score (35%)

Based on completed jobs, no-show rate, cancellation rate, and average rating:

```
reliability = (
    (completed_jobs / total_offered) × 50 +
    average_rating_normalized × 30 +
    recency_bonus × 20
)
```

New workers with no history start at 50 (neutral). First few jobs weight heavily.

### Proximity Score (25%)

Calculated from worker's location to job site using PostGIS `ST_Distance`:

| Commute | Score |
|---------|-------|
| < 10 min | 100 |
| 10–20 min | 80 |
| 20–30 min | 60 |
| 30–45 min | 40 |
| 45–60 min | 20 |
| > 60 min | 0 |

Commute time estimated at 40 km/h average (Sydney traffic assumption). Can be refined with Google Maps API later.

### Rate Score (15%)

| Worker's preferred rate vs offered rate | Score |
|----------------------------------------|-------|
| Worker rate ≤ offered rate | 100 |
| Worker rate 1–10% above | 50 |
| Worker rate > 10% above | 0 |

### Repeat Hire Bonus (10%)

| Condition | Score |
|-----------|-------|
| Previously hired by this contractor, rated 4+ stars | 100 |
| Previously hired, rated 3 stars | 50 |
| Never worked together | 0 |

### Priority Badge Bonus (15%)

Workers who opt in to background location tracking receive a **"Priority Matching" badge** and get a 15-point boost in matching. This is the incentive for sharing location.

| Condition | Score |
|-----------|-------|
| Background location enabled + location updated < 30 min ago | 100 |
| Background location enabled + location updated < 2 hrs ago | 60 |
| Location from last app open only | 0 |

---

## 4. Background Location — Opt-In Only

Background location is **opt-in**. Workers are never required to share location to use the platform.

**How it works:**
- During onboarding, workers are prompted: "Enable live location to get matched faster and earn a Priority Matching badge."
- Workers who opt in: app collects GPS every 15 minutes when the app is backgrounded.
- Workers who don't opt in: location is captured only when the app is open. Their last-known location is used for matching.

**Priority Matching badge:**
- Visible on the worker's profile card when contractors view matches.
- Workers with the badge score higher in matching (15% weight — see algorithm above).
- Badge appears as: ⚡ Priority Matching

**Privacy:**
- Location data is used only for matching. Not shared with contractors (they see suburb + distance, not GPS coordinates).
- Worker can revoke opt-in at any time in Settings.
- Location data older than 7 days is purged.

---

## 5. Payment Model

**Revenue:** Contractor pays RateRight **$50 per hire confirmation** via Stripe.

That's the only revenue event. Everything else is free.

| What | Cost |
|------|------|
| Worker signup | Free |
| Contractor signup | Free |
| Posting a job | Free |
| Getting matched | Free |
| Hiring a worker | **$50** charged to contractor |
| Worker wages | Direct between contractor and worker — not our business |

**Card on file required:** Contractors must add a payment method (Stripe) before posting their first job. This serves two purposes:
1. Reduces friction at hire time (no checkout flow, just tap and charge).
2. Filters out bad actors and tire-kickers.

**Stripe integration:**
- Use Stripe Customer + PaymentMethod (card on file).
- On hire confirmation, create a PaymentIntent for $50 with `confirm: true` and `off_session: true`.
- If payment fails, hire is not confirmed. Contractor is prompted to update their card.
- Refunds handled manually via Stripe dashboard for disputes.

**What we do NOT do:**
- No escrow
- No wage intermediary
- No insurance
- No percentage-based fees
- No subscription

---

## 6. Trust & Worker Protection Model

No escrow. No insurance. Trust is enforced through **reputation and consequences**.

### Ratings

After every completed job, both sides rate each other (1–5 stars + optional comment):
- **Contractor rates worker:** Reliability, skill, professionalism
- **Worker rates contractor:** Payment reliability, site conditions, communication

Ratings are public. Average rating is displayed on profiles.

### Contractor Accountability

| Trigger | Consequence |
|---------|-------------|
| Average rating drops below 3.0 stars | Warning notification + deprioritized in matching (workers see higher-rated contractors first) |
| 1 non-payment report from a worker | Flagged for review. Warning issued to contractor. |
| 2 non-payment reports from different workers | **Account suspended.** Cannot post jobs until resolved with RateRight support. |
| Pattern of low ratings (< 2.5 avg over 10+ jobs) | Permanently deprioritized — only matched when no better options exist |

### Worker Accountability

| Trigger | Consequence |
|---------|-------------|
| No-show without cancellation | Reliability score tanks. Moved to bottom of match pool. |
| 2+ no-shows in 30 days | Temporarily suspended (7 days). |
| Consistent low ratings (< 2.5 avg) | Deprioritized in matching. |

### Report & Block

- Either party can report the other (non-payment, safety issue, harassment).
- Either party can block the other (never matched again).
- Reports are reviewed by RateRight team (during WoZ phase, this is Michael/Rivet).
- Blocking is instant and permanent between those two users.

---

## 7. Wizard of Oz Playbook

Until supply catches up (target: 500 active workers in Sydney), matching is **manually assisted behind the scenes**. The user experience looks automated. It isn't.

### How It Works

**What the contractor sees:** They post a job, get matched in minutes, tap hire. Seamless.

**What actually happens behind the scenes:**

```
1. Contractor posts job
2. System runs the matching algorithm (even if results are thin)
3. IF algorithm returns 3+ strong matches (score > 60):
   → Automated flow. Push notifications sent. No human needed.
4. IF algorithm returns < 3 matches OR no matches:
   → Alert fires to Rivet (Telegram notification to #rateright topic)
   → Manual matching begins:
     a. Rivet/Michael checks the worker database
     b. Cross-references with known workers not yet on platform
     c. Sends personal SMS/WhatsApp to workers who might fit
     d. If worker agrees, Rivet creates match in system
     e. Worker appears in contractor's match list as if auto-matched
5. Contractor never knows it was manual
```

### WoZ Alert System

Every job post triggers a check. If auto-matching produces < `workers_needed` matches within 5 minutes:

```
TELEGRAM ALERT → #rateright topic:
━━━━━━━━━━━━━━━━━━━━━━
🚨 MANUAL MATCH NEEDED
Job: 3× Steel Fixer — Parramatta
Rate: $45/hr | Date: Tomorrow 6am
Auto-matches found: 1 of 3
Contractor: Smith Constructions (⭐ 4.5)
━━━━━━━━━━━━━━━━━━━━━━
```

### WoZ Tools

During the manual phase, the admin dashboard includes:

1. **Worker search** — Filter all workers by trade, location, availability (even those who haven't been active recently).
2. **SMS/WhatsApp outreach** — One-click send: "Hi [name], got a steelfixing job in Parramatta tomorrow at $45/hr. Interested? Reply YES."
3. **Manual match inject** — Create a match record in the database, triggering the same hire flow for the contractor.
4. **Match override** — Manually adjust match scores or insert workers into the match list.

### When to Turn Off WoZ

Exit criteria — all must be true:
- 500+ active workers in Sydney
- 70%+ of jobs auto-match to 3+ workers within 10 minutes
- Manual intervention rate < 20% of jobs posted
- Consistent for 2+ consecutive weeks

At that point, WoZ alerts shift to exception-only (zero matches after 30 min).

---

## 8. Cold Start Strategy — First 500 Workers

The matching engine is worthless without supply. Here's how we get to 500 active workers in Sydney.

### Channel 1: Direct Outreach (Target: 150 workers)

- **Facebook groups** — Post in Sydney construction worker groups, Irish/British backpacker groups ("Just landed in Sydney? Need work?")
- **Gumtree/Indeed scraping** — Find workers already advertising themselves. Reach out directly with a sign-up link.
- **WhatsApp groups** — Construction worker WhatsApp groups are common. Get referrals in.
- **Hostel partnerships** — Partner with backpacker hostels in Sydney CBD, Bondi, Surry Hills. QR code posters: "Find construction work near you."

### Channel 2: Contractor-Supplied Workers (Target: 200 workers)

- Every contractor who signs up gets asked: "Want to invite your existing workers? They'll be on the platform when you need extras."
- One-click SMS invite to their crew.
- Workers sign up with the contractor as their first connection.
- This seeds supply AND gives workers an immediate positive experience (they already have a contractor relationship).

### Channel 3: Agency Partnerships (Target: 100 workers)

- Partner with 1-2 small labour hire agencies. They list their available workers on RateRight.
- Agencies get the $50 fee waived for their first 20 placements in exchange for seeding supply.
- Workers on the agency roster are tagged as agency-sourced (for internal tracking, not visible to contractors).

### Channel 4: Referral Loop (Target: 50+ workers)

- Every worker who completes a job gets: "Know a mate who needs work? Share your link. You both get priority matching for a week."
- No cash incentive needed at this stage — priority matching is the carrot.

### Sequence

1. **Weeks 1-2:** Direct outreach blitz. Target 100 signups.
2. **Weeks 2-4:** Onboard first 10 contractors. Ask each to invite their workers. Target 150 more.
3. **Weeks 3-6:** Agency partnerships live. Target 100 more.
4. **Ongoing:** Referral loop compounds.

---

## 9. Technical Requirements

### Stack

| Component | Technology | Purpose |
|-----------|-----------|---------|
| Database | Supabase PostgreSQL + PostGIS | Geospatial worker queries, all data storage |
| Geo queries | PostGIS `ST_DWithin`, `ST_Distance` | Find workers within radius of job site |
| Real-time updates | Supabase Realtime (WebSocket) | Live match updates on contractor's screen |
| Push notifications | Expo Push Notifications / FCM | Alert workers to new matching jobs |
| SMS fallback | Twilio | Reach offline workers, WoZ outreach |
| Payments | Stripe (Customer + PaymentIntent) | $50 hire fee, card on file |
| Background location | React Native Background Geolocation | Opt-in location tracking for priority workers |
| Scoring engine | Supabase Edge Function | Match scoring algorithm |
| Admin/WoZ dashboard | Next.js admin panel | Manual matching tools, alerts, overrides |
| Geocoding | Google Maps Geocoding API | Convert addresses to lat/lng |

### Key Technical Constraints

1. **Match query must complete in < 500ms.** The PostGIS spatial index on `worker_availability.location` makes this achievable. Tested pattern: `ST_DWithin(location, ST_MakePoint(lng, lat)::geography, 30000)` returns in ~50ms for 10k rows.

2. **Push notifications must fire within 10 seconds of job post.** Edge Function triggers on `jobs` table insert → queries matches → calls Expo Push API in batch.

3. **Real-time contractor screen.** Supabase Realtime subscription on `matches` table filtered by `job_id`. When a worker accepts, new row appears → contractor's UI updates instantly.

4. **Background location battery impact.** Use `distanceFilter: 500` (meters) and `interval: 900000` (15 min) to minimize battery drain. Workers on iOS will see the location indicator; we must explain this clearly during opt-in.

5. **Stripe off-session charging.** Requires SCA-compliant setup. Use `setup_intent` during card-on-file registration with `usage: 'off_session'`. PaymentIntents created with `confirm: true, off_session: true`.

---

## 10. Database Schema

```sql
-- =============================================
-- WORKER AVAILABILITY (live matching data)
-- =============================================
CREATE TABLE worker_availability (
    worker_id UUID PRIMARY KEY REFERENCES profiles(id),
    status TEXT NOT NULL DEFAULT 'offline'
        CHECK (status IN ('available', 'busy', 'offline')),
    available_from TIMESTAMPTZ,
    available_until TIMESTAMPTZ,
    location GEOGRAPHY(POINT, 4326),
    location_updated_at TIMESTAMPTZ,
    location_opt_in BOOLEAN NOT NULL DEFAULT FALSE,
    preferred_radius_km INT DEFAULT 30,
    preferred_rate_min DECIMAL(6,2),
    preferred_rate_max DECIMAL(6,2),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Spatial index for fast geo queries (critical for < 500ms matching)
CREATE INDEX idx_worker_location
    ON worker_availability USING GIST(location);

-- Filter index for available workers only
CREATE INDEX idx_worker_available
    ON worker_availability(status)
    WHERE status = 'available';

-- =============================================
-- JOBS (with multi-hire support)
-- =============================================
CREATE TABLE jobs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    contractor_id UUID NOT NULL REFERENCES profiles(id),
    company_id UUID NOT NULL REFERENCES companies(id),
    title TEXT NOT NULL,
    description TEXT,
    trade TEXT NOT NULL,
    location GEOGRAPHY(POINT, 4326) NOT NULL,
    suburb TEXT,
    site_address TEXT,
    rate DECIMAL(6,2) NOT NULL,
    rate_type TEXT NOT NULL DEFAULT 'hourly'
        CHECK (rate_type IN ('hourly', 'daily')),
    start_date DATE NOT NULL,
    start_time TIME,
    end_time TIME,
    workers_needed INT NOT NULL DEFAULT 1,
    workers_filled INT NOT NULL DEFAULT 0,
    certifications_required TEXT[],
    status TEXT NOT NULL DEFAULT 'active'
        CHECK (status IN ('active', 'filled', 'cancelled', 'expired')),
    created_at TIMESTAMPTZ DEFAULT NOW(),
    filled_at TIMESTAMPTZ
);

-- Index for active jobs (matching queries)
CREATE INDEX idx_jobs_active
    ON jobs(status, trade)
    WHERE status = 'active';

-- =============================================
-- MATCHES (the core matching records)
-- =============================================
CREATE TABLE matches (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    job_id UUID NOT NULL REFERENCES jobs(id),
    worker_id UUID NOT NULL REFERENCES profiles(id),
    match_score DECIMAL(5,2) NOT NULL,
    worker_distance_km DECIMAL(5,1),
    notification_sent_at TIMESTAMPTZ,
    notification_type TEXT CHECK (notification_type IN ('push', 'sms')),
    worker_response TEXT DEFAULT 'pending'
        CHECK (worker_response IN ('pending', 'accepted', 'declined', 'expired')),
    worker_responded_at TIMESTAMPTZ,
    hired BOOLEAN NOT NULL DEFAULT FALSE,
    hired_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT NOW(),

    -- Prevent duplicate matches
    UNIQUE(job_id, worker_id)
);

-- Index for real-time subscription (contractor watching their job)
CREATE INDEX idx_matches_job
    ON matches(job_id, worker_response);

-- =============================================
-- RATINGS (bidirectional)
-- =============================================
CREATE TABLE ratings (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    match_id UUID NOT NULL REFERENCES matches(id),
    from_user_id UUID NOT NULL REFERENCES profiles(id),
    to_user_id UUID NOT NULL REFERENCES profiles(id),
    score INT NOT NULL CHECK (score BETWEEN 1 AND 5),
    comment TEXT,
    created_at TIMESTAMPTZ DEFAULT NOW(),

    -- One rating per direction per match
    UNIQUE(match_id, from_user_id)
);

-- Index for looking up a user's received ratings
CREATE INDEX idx_ratings_to_user ON ratings(to_user_id);

-- =============================================
-- REPORTS (non-payment, safety, harassment)
-- =============================================
CREATE TABLE reports (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    reporter_id UUID NOT NULL REFERENCES profiles(id),
    reported_id UUID NOT NULL REFERENCES profiles(id),
    match_id UUID REFERENCES matches(id),
    report_type TEXT NOT NULL
        CHECK (report_type IN ('non_payment', 'safety', 'harassment', 'no_show', 'other')),
    description TEXT,
    status TEXT NOT NULL DEFAULT 'open'
        CHECK (status IN ('open', 'investigating', 'resolved', 'dismissed')),
    resolved_by UUID REFERENCES profiles(id),
    resolved_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Index for checking non-payment report count
CREATE INDEX idx_reports_non_payment
    ON reports(reported_id, report_type)
    WHERE report_type = 'non_payment' AND status != 'dismissed';

-- =============================================
-- BLOCKS (bidirectional blocking)
-- =============================================
CREATE TABLE blocks (
    blocker_id UUID NOT NULL REFERENCES profiles(id),
    blocked_id UUID NOT NULL REFERENCES profiles(id),
    created_at TIMESTAMPTZ DEFAULT NOW(),
    PRIMARY KEY (blocker_id, blocked_id)
);

-- =============================================
-- PAYMENTS ($50 hire fee only)
-- =============================================
CREATE TABLE payments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    match_id UUID NOT NULL REFERENCES matches(id),
    contractor_id UUID NOT NULL REFERENCES profiles(id),
    amount DECIMAL(6,2) NOT NULL DEFAULT 50.00,
    currency TEXT NOT NULL DEFAULT 'aud',
    stripe_customer_id TEXT NOT NULL,
    stripe_payment_intent_id TEXT,
    status TEXT NOT NULL DEFAULT 'pending'
        CHECK (status IN ('pending', 'succeeded', 'failed', 'refunded')),
    failure_reason TEXT,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    charged_at TIMESTAMPTZ
);

-- =============================================
-- CONTRACTOR STRIPE SETUP (card on file)
-- =============================================
CREATE TABLE contractor_stripe (
    contractor_id UUID PRIMARY KEY REFERENCES profiles(id),
    stripe_customer_id TEXT NOT NULL,
    stripe_payment_method_id TEXT NOT NULL,
    card_last4 TEXT,
    card_brand TEXT,
    setup_completed_at TIMESTAMPTZ DEFAULT NOW()
);

-- =============================================
-- PUSH TOKENS (for Expo Push / FCM)
-- =============================================
CREATE TABLE push_tokens (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES profiles(id),
    token TEXT NOT NULL,
    platform TEXT NOT NULL CHECK (platform IN ('ios', 'android', 'web')),
    active BOOLEAN NOT NULL DEFAULT TRUE,
    created_at TIMESTAMPTZ DEFAULT NOW(),

    UNIQUE(user_id, token)
);
```

### Key RPC Functions (Edge Functions)

```sql
-- Find matching workers for a job (called by matching engine)
CREATE OR REPLACE FUNCTION find_matching_workers(
    p_job_id UUID,
    p_trade TEXT,
    p_location GEOGRAPHY,
    p_radius_m INT DEFAULT 30000,
    p_limit INT DEFAULT 10
)
RETURNS TABLE (
    worker_id UUID,
    distance_km DECIMAL,
    match_score DECIMAL
) AS $$
    SELECT
        wa.worker_id,
        ROUND(ST_Distance(wa.location, p_location)::DECIMAL / 1000, 1) AS distance_km,
        0 AS match_score  -- Calculated in application layer
    FROM worker_availability wa
    JOIN worker_profiles wp ON wp.profile_id = wa.worker_id
    WHERE wa.status = 'available'
      AND p_trade = ANY(wp.trades)
      AND ST_DWithin(wa.location, p_location, p_radius_m)
      AND wa.worker_id NOT IN (
          SELECT blocked_id FROM blocks WHERE blocker_id = (
              SELECT contractor_id FROM jobs WHERE id = p_job_id
          )
      )
      AND wa.worker_id NOT IN (
          SELECT blocker_id FROM blocks WHERE blocked_id = (
              SELECT contractor_id FROM jobs WHERE id = p_job_id
          )
      )
    ORDER BY ST_Distance(wa.location, p_location)
    LIMIT p_limit;
$$ LANGUAGE SQL;
```

---

## 11. Edge Function: Matching Pipeline

Triggered on `jobs` table insert (via Supabase webhook or database trigger).

```
FUNCTION: match_and_notify(job_id)

1. Load job details (trade, location, rate, workers_needed, contractor_id)

2. Run find_matching_workers(job_id, trade, location, 30000m, 10)

3. For each matched worker:
   a. Calculate full match_score using algorithm (Section 3)
   b. Check worker is not blocked by/blocking contractor
   c. Insert row into matches(job_id, worker_id, match_score, worker_distance_km)

4. Sort matches by score descending

5. Send push notification to all matched workers via Expo Push API:
   - Title: "🔨 New job near you!"
   - Body: "{trade} — {suburb} | {date} | ${rate}/hr"
   - Data: { job_id, match_id }

6. IF matches.count < workers_needed:
   → Fire WoZ alert to Telegram (see Section 7)

7. Set timeout: 5 minutes
   → IF still unfilled, expand radius to 50km and repeat steps 2-6
   → At 10 minutes, send SMS to offline matching workers
```

---

## 12. Real-Time Subscription (Contractor View)

```javascript
// Contractor subscribes to their job's matches
supabase
  .channel(`job:${jobId}`)
  .on('postgres_changes', {
    event: '*',
    schema: 'public',
    table: 'matches',
    filter: `job_id=eq.${jobId}`
  }, (payload) => {
    // Update UI when workers accept/decline
    updateMatchList(payload.new);
  })
  .subscribe();
```

The contractor's screen shows:
- Number of slots filled vs needed
- Each accepted worker with: name, reliability %, distance, rate
- [Hire] button per worker, or [Hire All] for batch
- Live counter: "2 of 3 filled"

When `workers_filled === workers_needed`, job status auto-updates to `filled`.

---

## 13. Hire Confirmation Flow

```
FUNCTION: confirm_hire(match_id, contractor_id)

1. Validate: match exists, contractor owns the job, worker_response = 'accepted'

2. Charge $50 via Stripe:
   a. Load contractor's stripe_customer_id and payment_method_id
   b. Create PaymentIntent($50 AUD, off_session, confirm: true)
   c. IF payment fails → return error, prompt card update
   d. IF payment succeeds → continue

3. Update match: hired = true, hired_at = NOW()

4. Update job: workers_filled += 1
   → IF workers_filled >= workers_needed → set status = 'filled'

5. Insert payment record (match_id, stripe_payment_intent_id, status: 'succeeded')

6. Send push to worker: "✅ You're booked! {trade} at {suburb}, {date} {time}"
   Include: contractor name, phone number, site address

7. Send push to contractor: "✅ {worker_name} confirmed for {date}"
   Include: worker name, phone number

8. Notify remaining unmatched workers if job is now filled:
   → "This job has been filled. We'll notify you of new opportunities."
```

---

## 14. Success Metrics

| Metric | Month 1 | Month 3 | Month 6 |
|--------|---------|---------|---------|
| Active workers (Sydney) | 100 | 300 | 500+ |
| Active contractors | 20 | 80 | 200 |
| Jobs posted / month | 30 | 150 | 500 |
| Hires / month | 15 | 100 | 400 |
| Revenue / month | $750 | $5,000 | $20,000 |
| Median time to first match | < 30 min | < 10 min | < 5 min |
| % jobs filled within 1 hour | 30% | 50% | 70% |
| Push notification accept rate | 15% | 25% | 35% |
| WoZ intervention rate | 70% | 40% | < 20% |
| Contractor retention (post 3+ jobs) | 40% | 55% | 65% |
| Average contractor rating | > 3.5 | > 4.0 | > 4.0 |
| Average worker rating | > 3.5 | > 4.0 | > 4.0 |

### Key Leading Indicators

- **Supply density:** Workers per suburb in Greater Sydney. Target: 5+ workers per major suburb.
- **Response rate:** % of notified workers who accept within 10 minutes. Below 15% = problem.
- **Repeat hire rate:** % of contractors who hire the same worker again. Above 30% = strong trust signal.
- **Non-payment report rate:** Must stay below 2% of hires. Above = trust model failing.

---

## 15. Open Questions — Resolved

| Question | Decision |
|----------|----------|
| Background location required? | **No. Opt-in only.** Workers who enable get Priority Matching badge + scoring boost. |
| Workers see rate before accepting? | **Yes. Always.** Rate is in the push notification and job detail screen. |
| Multi-hire: separate posts or batch? | **Batch.** Single post with `workers_needed = N`. First N acceptances fill it. |
| Wizard of Oz phase approved? | **Yes.** Manual matching behind the scenes until supply catches up. |
| Escrow / payment intermediary? | **No.** No escrow, no insurance, no wage processing. |
| Payment model? | **$50 Stripe charge on hire confirmation.** Wages are direct between contractor and worker. |
| Worker protection? | **Reputation-based.** Ratings + deprioritization for bad contractors. 2 non-payment reports = suspended. |
| Card on file required? | **Yes.** Must add payment method before posting first job. |
| MVP vs Phase 2? | **No split.** This entire spec is the product. Build all of it. |

---

*This spec is complete and approved. Hand to the Builder Agent for implementation.*
