/ HOP CORE / DEREK'S BRIEF

INFRASTRUCTURE BLUEPRINT TRADE PARTNER · MULTI-TRADE · VESTRA HANDSHAKE

FOR · DEREK ZAR (CTO) FROM · TRISTAN DATE · 30 JUN 2026 STATUS · BUILDABLE V1 STACK · CLOUDFLARE WORKERS + SVELTEKIT + D1

This document turns the trade-partner + materials + multi-trade + Vestra-handshake mockups into something the team can sprint on. It assumes the two existing repos (hillbrook-postcon & hillbrook-tenant) stay in place and we add one new app (the trade-partner shell) and four shared packages (db, adapters, events, quote-engine) behind them. Same Cloudflare account, same D1, same R2 bucket. No new vendors. The three apps each become a scoped projection of one canonical schema.

Mockups this blueprint targets:

Postcon mobile shell Trade Partner · HSQ Materials + Labour Builder Multi-Trade Platform Future Flow · Vestra handshake
01

TL;DR · 90-second read

WHAT

One platform · three apps

Postcon (internal · live), Vestra (homeowner · live), trade-partner shell (new). Same D1, same R2, JWT-scoped views.

WHY THIS SHAPE

Row · not silo

A defect, a booking, a warranty event lives once in HOP. Each app reads its scope. No middleware syncs anything.

WHAT'S NEW

4 packages · 1 app

Trade-partner SvelteKit app + shared packages: db, adapters, events, quote-engine. Pulled out of postcon as they're built.

8 WEEKS TO LIVE

4 sprints

S1 schema + auth · S2 trade-partner shell + HSQ pilot · S3 supplier adapters (Reece + MIDDY's) · S4 Vestra handshake.

What we are NOT building

02

System map

Three SvelteKit apps on top of one Workers API and one D1. Shared bindings, shared session KV, single event stream via Durable Objects.

POSTCON
INTERNAL · HEROES
SvelteKit + CF Pages
repo: hillbrook-postcon · LIVE
LIVE
Postcon UI
17 dashboards · site map · daylog
LIVE
Procore OAuth + push
SC / commitment / claim
LIVE
TRADE PARTNER
VENDOR · CO-BRANDED
SvelteKit + CF Pages
repo: hillbrook-trade-partner
NEW · S2
Trade-partner UI
jobs · clients · build · claim · me
NEW · S2
Supplier search + WO builder
cheapest-source picker · own-stock match
NEW · S3
VESTRA
HOMEOWNER · LIGHT SHELL
SvelteKit + CF Pages
repo: hillbrook-tenant · LIVE
LIVE
Vestra UI
handover · defects · warranties
LIVE
DLP handshake panel
live trade arrival · photo push
NEW · S4
— SHARED HOP CORE — used by all three apps —
@hop/db
Drizzle schema · canonical
NEW
@hop/auth
JWT · scope claims · RLS
NEW
@hop/adapters
supplier adapter contracts
NEW
@hop/events
Durable Object + SSE
NEW
@hop/quote-engine
cheapest-source · margin · WO
NEW
@hop/ui
tokens · components · brand
EXTRACT
D1 · hop-core
consolidated db · v1 migration
MIGRATE
R2 · hop-evidence
photos · certs · WO PDFs
CONSOLIDATE
KV · hop-cache
price · stock · session
NEW
Queue · hop-jobs
supplier sync · procore push · email
NEW

Migration strategy · existing D1s

Today there are two D1 dbs: hillbrook-handoffs-db (postcon) and hillbrook-tenant-management-fresh (Vestra). Step 1 of S1 is to merge them into a single hop-core D1 with the canonical schema below, using Drizzle migrations. Both existing apps switch their bindings during the same release. We don't keep them as separate dbs — that's where drift starts.

03

Repos & packages

Three GitHub repos, one shared package registry. Monorepo via pnpm workspaces — keeps Drizzle schema as one source of truth without forcing us to merge three SvelteKit apps into one.

Proposed layout

hop-platform/             # new umbrella repo
├── apps/
│   ├── postcon/             # import-as-submodule or move
│   │   ├── src/
│   │   ├── wrangler.toml      # DB=hop-core, BUCKET=hop-evidence
│   │   └── package.json
│   ├── vestra/              # import-as-submodule or move
│   │   ├── src/
│   │   └── package.json
│   └── trade-partner/       # NEW · sprint 2
│       ├── src/
│       │   ├── routes/
│       │   │   ├── (auth)/login/
│       │   │   ├── (app)/home/
│       │   │   ├── (app)/jobs/
│       │   │   ├── (app)/clients/
│       │   │   ├── (app)/build/      # materials + labour
│       │   │   ├── (app)/claim/
│       │   │   └── (app)/me/
│       │   └── lib/
│       ├── wrangler.toml
│       └── package.json
├── packages/
│   ├── db/                  # Drizzle schema · single source
│   │   ├── schema/
│   │   │   ├── site.ts
│   │   │   ├── defect.ts
│   │   │   ├── booking.ts
│   │   │   ├── trade_partner.ts
│   │   │   ├── vendor_inventory.ts
│   │   │   ├── labour_rate.ts
│   │   │   ├── work_order.ts
│   │   │   ├── product.ts
│   │   │   ├── supplier.ts
│   │   │   └── price_quote.ts
│   │   ├── migrations/
│   │   └── index.ts
│   ├── auth/                # JWT + scope claim parsing
│   ├── adapters/            # supplier adapter contracts + impls
│   │   ├── core/SupplierAdapter.ts
│   │   ├── middys/            # electrical
│   │   ├── voltex/
│   │   ├── reece/             # plumbing
│   │   ├── tradelink/
│   │   └── bunnings/          # cross-trade
│   ├── events/              # DO + SSE channels
│   ├── quote-engine/        # margin · cheapest-source · WO assembly
│   └── ui/                  # tokens + Adecion + components
├── workers/
│   ├── api/                 # shared Workers API for all 3 apps
│   ├── queue-consumer/      # supplier sync · procore push
│   └── job-do/              # Durable Object per active job
├── pnpm-workspace.yaml
├── package.json
└── README.md

Repo decision

Owner-friendly path: keep hillbrook-postcon and hillbrook-tenant as their own repos, create hop-platform as the umbrella with the shared packages + new trade-partner app. Use pnpm overrides / git submodules to consume the shared packages from postcon and Vestra during cut-over, then either fully absorb or stay loose-coupled. Derek's call.

Ownership

04

Canonical data model · D1

Drizzle, SQLite dialect, single hop-core D1. Money in *Cents integers (existing Vestra convention). Timestamps as Unix epoch seconds. Soft-delete via deletedAt only on user-visible rows. UUIDv7 for ids.

Core tables (consolidated · existing + new)

Site & portfolio (existing in postcon)

column
type
description
flags
id
text
UUIDv7 — site identifier
NOT NULL
address
text
Display address e.g. "73 Princes Hwy Werribee"
NOT NULL
stage
text
precon · live · pc · dlp · closed
NOT NULL
procoreId
integer
Procore project id
UNIQUE
homeownerId
text
For Vestra projection · post-handover
NULLABLE
handedOverAt
integer
Unix · when DLP clock starts
NULLABLE
dlpEndsAt
integer
Computed default = handedOverAt + 2y
NULLABLE

Trade partner (NEW)

column
type
description
flags
id
text
UUIDv7 — trade partner identifier (the company)
NOT NULL
name
text
e.g. "HSQ Electrical Pty Ltd"
NOT NULL
abn
text
11-digit ABN
NOT NULL · UNIQUE
tradeKinds
text
JSON array · ["electrical"] or ["plumbing","gas"]
NOT NULL
licenceClaims
text
JSON · {recVic:{no, exp}, vbaPlumb:{no, exp}, …}
NOT NULL
ratingCents
integer
0–500 · running NPS-equivalent · drives booking algo
DEFAULT 0
createdAt
integer
Onboard timestamp
NOT NULL

Trade partner ↔ client (NEW)

column
type
description
flags
id
text
UUIDv7
NOT NULL
tradePartnerId
text
→ trade_partner.id
NOT NULL
clientKind
text
platform_builder · external_builder · private · vestra_homeowner
NOT NULL
builderId
text
→ builder.id when clientKind = platform_builder
NULLABLE
externalName
text
For external/private — display name
NULLABLE
contact
text
JSON · email · phone · address
NULLABLE

Vendor inventory (NEW · own stock · van / garage / yard)

column
type
description
flags
id
text
UUIDv7
NOT NULL
tradePartnerId
text
→ trade_partner.id
NOT NULL
productId
text
→ product.id (canonical SKU)
NOT NULL
location
text
van · garage · yard_ · site_
NOT NULL
qty
real
Float — supports "18m of cable left"
NOT NULL
costBasisCents
integer
Per-unit COGS at time of purchase
NOT NULL
sourceJobId
text
→ work_order.id — over-order from past job
NULLABLE

Labour rate (NEW · per trade partner · rate book)

column
type
description
flags
id
text
UUIDv7
NOT NULL
tradePartnerId
text
→ trade_partner.id
NOT NULL
name
text
"A-grade sparky · standard"
NOT NULL
kind
text
standard · after_hours · weekend · callout · apprentice
NOT NULL
costRateCents
integer
What it costs the trade per hour
NOT NULL
chargeRateCents
integer
What gets charged to client per hour
NOT NULL
clientOverrideId
text
Optional client-specific override
NULLABLE

Product · canonical SKU (NEW · normalised across suppliers)

column
type
description
flags
id
text
UUIDv7 — canonical product
NOT NULL
canonicalSku
text
Brand-mfr SKU when distinct
UNIQUE NULLABLE
brand
text
Clipsal · Bosch · Caroma · etc
NOT NULL
name
text
Display name
NOT NULL
category
text
elec · plumb · carp · hvac · etc (TradeKind enum)
NOT NULL
unit
text
ea · m · pack · drum
NOT NULL
compliance
text
JSON · {as_nzs:[], wels:5, watermark:true,…}
NULLABLE

Supplier + price quote (NEW · adapter cache)

column
type
description
flags
id
text
UUIDv7 — price quote row (one per supplier per SKU per fetch)
NOT NULL
productId
text
→ product.id
NOT NULL
supplierKey
text
"middys" · "reece" · matches adapter id
NOT NULL
vendorSku
text
Supplier's own SKU for cross-ref
NOT NULL
priceExCents
integer
Trade-priced ex GST
NOT NULL
stockQty
integer
Reported on-hand at fetch · -1 = OOS
NOT NULL
leadDays
integer
0 = in stock
DEFAULT 0
fetchedAt
integer
Unix · TTL handled in KV cache layer
NOT NULL

Work order (NEW · cross-cutting · the unit of trade-partner work)

column
type
description
flags
id
text
UUIDv7
NOT NULL
tradePartnerId
text
→ trade_partner.id
NOT NULL
siteId
text
→ site.id (Hillbrook) — NULL for purely private
NULLABLE
clientId
text
→ trade_partner_client.id
NOT NULL
status
text
draft · sent · accepted · in_progress · done · paid · void
NOT NULL
procoreSubcontractId
integer
SC# when posted
NULLABLE
marginPct
integer
Stored % at submit · audit trail
NULLABLE

Work order line (NEW)

column
type
description
flags
id
text
UUIDv7
NOT NULL
workOrderId
text
→ work_order.id
NOT NULL
kind
text
supplier · own_stock · labour · travel · variation
NOT NULL
productId
text
→ product.id when kind=supplier or own_stock
NULLABLE
supplierKey
text
When supplier-sourced
NULLABLE
labourRateId
text
→ labour_rate.id when kind=labour
NULLABLE
qty
real
Float for partial units
NOT NULL
unitCostCents
integer
COGS basis (for margin calc)
NOT NULL
unitChargeCents
integer
Charge to client
NOT NULL

Existing postcon tables (defect, booking, warranty_event, compliance_cert) gain a nullable tradePartnerId column. Existing Vestra tables (homeowner, property) get folded into the consolidated db with a renamed projection view. Drizzle migrations to be reviewed by Derek before S1 ships.

05

Auth · multi-tenant scope

One JWT shape across all three apps. Issuer is a Hillbrook auth Worker (Cloudflare Access in front for Hillbrook staff, magic-link for trade partners and homeowners). Scope claims drive row-level access — no separate dbs, no separate ACL middleware.

JWT payload

{
  "sub": "usr_01HK…",           // user id
  "iss": "auth.hillbrook.co",
  "aud": "hop.hillbrook.co",
  "role": "trade_partner",     // hillbrook_staff | trade_partner | homeowner | builder
  "scope": {
    "tradePartnerId": "tp_01HK…",  // HSQ Electrical
    "tradeKinds": ["electrical"],
    "clientIds": ["tpc_…", "tpc_…"], // optional whitelist
    "licenceScope": ["REC_VIC"]
  },
  "exp": 1751432800,
  "iat": 1751429200
}

Scope enforcement

Every Drizzle query in the Workers API goes through a scopedDb(jwt) wrapper which mounts row filters at query-build time, not at result-filter time. The wrapper is a single file in @hop/auth · ~80 lines · should be the most-reviewed file in the repo.

// @hop/auth/scopedDb.ts (sketch)
export function scopedDb(jwt: Jwt) {
  return {
    workOrders: () => {
      if (jwt.role === "trade_partner") {
        return db.select().from(workOrder)
          .where(eq(workOrder.tradePartnerId, jwt.scope.tradePartnerId));
      }
      if (jwt.role === "homeowner") {
        // Homeowner sees bookings on their site only
        return db.select().from(workOrder)
          .innerJoin(site, eq(workOrder.siteId, site.id))
          .where(eq(site.homeownerId, jwt.sub));
      }
      if (jwt.role === "hillbrook_staff") return db.select().from(workOrder);
      throw new ForbiddenError();
    },
    // …same pattern for site, defect, booking, etc
  };
}

Magic-link onboarding · trade partner

06

Supplier adapter contract

One TypeScript interface every supplier implements. Drop a new adapter into packages/adapters/<supplier>/ and the quote engine, search UI, and POS push all pick it up. No core changes per supplier.

Interface

// @hop/adapters/core/SupplierAdapter.ts
export interface SupplierAdapter {
  readonly id: string;                // 'middys' · 'reece'
  readonly displayName: string;       // "MIDDY's AWM"
  readonly brandHex: string;          // for UI chip
  readonly trades: TradeKind[];       // ['electrical']
  readonly capabilities: Capability[]; // search · stock · order · webhook

  search(q: SearchQuery, ctx: AdapterCtx): Promise<NormalizedProduct[]>;
  getStock(vendorSku: string, ctx: AdapterCtx): Promise<StockQuote>;
  createOrder(order: NormalizedOrder, ctx: AdapterCtx): Promise<OrderResult>;
  // Optional, when the supplier supports webhooks
  verifyWebhook?(req: Request, ctx: AdapterCtx): Promise<WebhookEvent>;
}

Context object

export type AdapterCtx = {
  tradePartnerId: string;       // for trade-discounted pricing
  credentials: SupplierCreds;   // resolved from KV by adapter id + tradePartnerId
  cache: KvCache;               // hop-cache namespace
  logger: Logger;
  fetch: typeof fetch;          // for outbound mocking in tests
};

Normalised product shape

export type NormalizedProduct = {
  productId: string;             // canonical · from product table
  vendorSku: string;
  brand: string;
  name: string;
  unit: 'ea' | 'm' | 'pack' | 'drum';
  priceExCents: number;
  priceIncCents: number;
  stockQty: number;             // -1 = OOS
  leadDays: number;             // 0 = in stock
  branchHint?: string;         // "Williamstown" — for cheapest pickup
  compliance?: Record<string, unknown>;
  fetchedAt: number;
};

Caching · the 80% optimisation

Rate limit etiquette

Example adapter scaffold (Reece)

// @hop/adapters/reece/index.ts
export const reece: SupplierAdapter = {
  id: 'reece',
  displayName: 'Reece',
  brandHex: '#1B4D9B',
  trades: ['plumbing', 'gas', 'hvac'],
  capabilities: ['search', 'stock', 'order'],

  async search(q, ctx) {
    const creds = await ctx.credentials.resolve();
    const r = await ctx.fetch('https://api.reece.com/v1/catalog/search', {
      method: 'POST',
      headers: { Authorization: `Bearer ${creds.token}` },
      body: JSON.stringify({ q: q.text, trade_id: creds.tradeAccount }),
    });
    if (!r.ok) throw new AdapterError('reece', r.status, await r.text());
    const raw = await r.json();
    return raw.items.map(reshape);
  },
  // getStock, createOrder …
};
07

Three-way sync · DO + SSE

One Durable Object per active job. All three apps subscribe to it via SSE for the live "Jeremy arrived" / "photo uploaded" / "cert signed" events. Postcon + trade-partner write. Vestra reads.

DO contract

// workers/job-do/JobDurableObject.ts
export class JobDO {
  state: DurableObjectState;
  subscribers: Set<WritableStreamDefaultWriter> = new Set();

  async fetch(req: Request) {
    const url = new URL(req.url);
    switch (url.pathname) {
      case '/subscribe': return this.subscribe(req); // SSE stream
      case '/emit':     return this.emit(req);
      case '/history':  return this.history();
    }
  }

  // Event types: arrival | photo | eta | cert | signoff | warning
  async emit(req: Request) {
    const event: JobEvent = await req.json();
    await this.state.storage.put(`evt:${event.ts}`, event);
    await writeAudit(event);             // D1 audit_event
    for (const w of this.subscribers) {
      await w.write(SSE.format(event));
    }
    return new Response('ok');
  }
}

Routing

Event shape

export type JobEvent =
  | { kind: 'arrival';  workOrderId: string; tradePartnerId: string; ts: number; geo?: { lat: number; lng: number } }
  | { kind: 'photo';    workOrderId: string; tradePartnerId: string; ts: number; r2Key: string; caption?: string }
  | { kind: 'eta';      workOrderId: string; tradePartnerId: string; ts: number; etaMins: number }
  | { kind: 'cert';     workOrderId: string; tradePartnerId: string; ts: number; certType: CertKind; r2Key: string }
  | { kind: 'signoff';  workOrderId: string; tradePartnerId: string; ts: number; outcome: 'done' | 'rebook' | 'escalate' };
08

API surface

Single Workers API at api.hop.hillbrook.co. REST + SSE. All routes scoped through scopedDb(jwt). SvelteKit apps call the API from server-side load functions and from client actions — same surface for all three.

// Public REST · trade-partner relevant
GET  /v1/me                              // resolved tp + clients + compliance status
GET  /v1/jobs?status=&client=             // scoped to tp
GET  /v1/jobs/:id
POST /v1/jobs/:id/events                  // emit arrival|photo|eta|signoff to DO
GET  /v1/jobs/:id/stream                  // SSE subscribe (proxied to DO)

GET  /v1/clients
POST /v1/clients                          // add platform / external / private

GET  /v1/products/search?q=&trade=        // federated supplier search
GET  /v1/products/:id/quotes              // price strip across suppliers
GET  /v1/inventory                        // own stock by location
POST /v1/inventory/:productId/decrement
GET  /v1/labour-rates
POST /v1/labour-rates                     // rate book CRUD

POST /v1/work-orders                      // create draft
POST /v1/work-orders/:id/lines            // add a line
POST /v1/work-orders/:id/submit           // triggers Procore push + stock decrement

GET  /v1/suppliers                        // marketplace · per-trade
POST /v1/suppliers/:key/connect           // store creds in KV under tp scope

// Postcon / Vestra share the same prefix · different scope claims see different rows.

Versioning

Lock the v1 routes through S4. Breaking changes go to v2 with parallel mount. Vestra's existing API merges into this Worker in S1 — keep current paths under /v1/vestra/… with redirects for one release.

09

Cloudflare bindings + env

# workers/api/wrangler.toml
name = "hop-api"
main = "src/index.ts"
compatibility_date = "2026-06-01"
workers_dev = false
routes = [{ pattern = "api.hop.hillbrook.co/*", zone_name = "hillbrook.co" }]

[[d1_databases]]
binding = "DB"
database_name = "hop-core"
database_id = "<migrated-id>"     # merge handoffs + tenant-mgmt

[[r2_buckets]]
binding = "EVIDENCE"
bucket_name = "hop-evidence"     # photos · certs · WO PDFs

[[kv_namespaces]]
binding = "CACHE"
id = "<new>"                       # supplier price / stock / session

[[kv_namespaces]]
binding = "SHARED_KV"                # keep — used by Vestra
id = "61aaa876d13448b0b9a5652507fc59f4"

[[durable_objects.bindings]]
name = "JOB_DO"
class_name = "JobDO"
script_name = "hop-job-do"

[[queues.producers]]
binding = "JOBS_Q"
queue = "hop-jobs"                  # supplier sync · procore push

[vars]
ENV = "production"
PROCORE_COMPANY_ID = "598134325611799"

[secrets]                                # via wrangler secret put
PROCORE_OAUTH_CLIENT_ID
PROCORE_OAUTH_CLIENT_SECRET
MS_GRAPH_CLIENT_SECRET
JWT_SIGNING_KEY
SUPPLIER_CRED_ENCRYPTION_KEY

Supplier credentials

Each trade partner stores their own supplier credentials. Stored encrypted in KV under creds:<supplierKey>:<tradePartnerId>, sealed with SUPPLIER_CRED_ENCRYPTION_KEY (AES-GCM). Decrypted in the adapter ctx loader only — never logged, never sent to the client. Rotated by manual ops command for now.

10

Worked sequence · DLP defect

The 88 Glen Iris HWS valve defect — same scenario as the Jeremy mockup. Shows what hits what.

VESTRA POSTCON HOP API TRADE APP JOB DO THU POST /v1/defects · photo to R2 THU postcon UI updates · queue routes to plumbing MON 08:00 GET /v1/jobs · "1 booking today" 09:00 POST /v1/jobs/:id/events { kind: 'arrival' } 09:00 DO.emit · audit row 09:00 SSE → Vestra: "Jeremy is here" SSE → Postcon pin updates 09:18 PUT R2 (presigned) + POST event 'photo' SSE photo to Antoniou 10:08 POST event 'signoff' + cert R2 key warranty_event row · DLP clock paused for this defect cert in Antoniou's docs · 5★ prompt
11

Sprint plan · 8 weeks

Four sprints of two weeks. Each sprint ships something the team can demo to Tristan. By end of S4 the HSQ pilot is live on real Hillbrook jobs.

S1 · WEEKS 1–2 · FOUNDATION

Schema, auth, repo

  • Monorepo bootstrappnpm workspaces · CI · TS strict
  • @hop/dbDrizzle schema · migrations for all tables above
  • D1 consolidationmerge handoffs + tenant-mgmt into hop-core
  • @hop/authJWT + scopedDb wrapper · 80% test coverage
  • workers/api skeletonroutes mounted, scope enforced, /v1/me works
  • R2 + KV provisionedhop-evidence, hop-cache

Demo: auth from postcon & Vestra against the new consolidated DB · no regression

S2 · WEEKS 3–4 · TRADE SHELL

Trade-partner app + HSQ pilot

  • apps/trade-partnerSvelteKit scaffold · co-brand · auth
  • Home + Jobs + Job-detail screensmatches mobile mockup
  • Trade directory in postcon"Invite a trade" → magic link
  • Onboarding flow3-step form · compliance docs upload to R2
  • HSQ Electrical seeded1 trade partner · real Hillbrook bookings
  • @hop/events scaffoldingDO defined · no fan-out yet

Demo: Solomon installs PWA · sees his real bookings · clock-in works

S3 · WEEKS 5–6 · ADAPTERS

Suppliers, stock, WO

  • @hop/adapters/corecontract + base class + test harness
  • MIDDY's adaptersearch + getStock + createOrder
  • Reece adaptersame, in parallel · stub if API access slow
  • Bunnings Trade adaptercross-trade wedge
  • @hop/quote-enginecheapest-source picker · margin · own-stock match
  • Materials + Labour Builder UImatches mockup
  • POST WO → Procore SC revround-trip with Caleb's approve queue

Demo: Solomon builds a WO for 73 Princes from a mix of MIDDY's + own stock + labour, submits, Caleb sees it in Procore My Open Items

S4 · WEEKS 7–8 · VESTRA

Three-way handshake

  • JobDO fan-outSSE subscribers in all three apps
  • Vestra DLP handshake panel"Jeremy from Future Flow is here"
  • Trade-app Vestra-watching pillreal homeowner subs visible
  • Photo push UXpresigned R2 upload + DO emit · <2s end-to-end
  • Cert generatorPDF compliance cert from WO + signature
  • Future Flow Plumbing seededsecond trade · proves the template

Demo: live 88 Glen Iris DLP defect run on real plumber · Antoniou opens Vestra · sees Jeremy arrive on his actual phone

12

Risks + open questions

Risk register

RiskSeverityMitigation
Supplier API access (Reece in particular) takes longer than S3 MEDIUM Start commercial conversation in S1. Ship S3 with MIDDY's + Bunnings + a manual CSV fallback for Reece. Plug in real adapter once access lands.
D1 consolidation introduces regression in Vestra or postcon HIGH Shadow-write to both during S1. End-to-end smoke suite per app. Roll back via single binding swap if needed.
JWT scope mis-write leaks data across trade partners HIGH scopedDb is the only path · property-based tests · code review labelled scope-sensitive · prod audit query in dashboard.
Magic-link onboarding spam (random emails get invited) LOW Invites only initiated from postcon (Hillbrook staff). Email allowlist for known domains in S2 · drop later.
Procore push race — two trade-partners submit the same WO twice MEDIUM Idempotency key on submit · stored on WO row · Procore call wrapped in queue with replay.
Compliance doc expiry creates blocked bookings during work hours MEDIUM 30/14/7-day notifications to trade partner. Postcon ops sees a "compliance about to expire" row in Caleb's dashboard.
Homeowner pushes a defect that's not Hillbrook's responsibility LOW Vestra defect form forces a category · auto-routed to trade or out-of-scope path. Daniel triages anything ambiguous.

Open questions for Derek

Q1 · Monorepo or sibling repos?

Recommendation: umbrella hop-platform monorepo for new code + shared packages. Existing repos stay loose-coupled until S3, then absorb. Need your call by end of S1.

Q2 · Auth provider

Magic links via Resend or Cloudflare Email Workers · or do we adopt Cloudflare Access for SaaS for trade-partner SSO? Access is great for internal; magic link is simpler for vendors. Default to magic-link unless you see a reason.

Q3 · Supplier credentials encryption

KV-stored credentials sealed with a single Workers Secret. Acceptable for v1? Alternative: per-trade-partner KEK via Cloudflare Secrets Store when it's GA. Defer answer until the Secrets Store roadmap is clearer.

Q4 · Trade-partner installs

PWA with "Add to home screen" is the v1 plan. Need confirm we're OK without a native shell for camera + push notifications on iOS. Recent iOS PWA push behaviour is good enough — if you've seen issues with the Vestra deploy already, let's revisit.

Q5 · Procore push idempotency

Procore allows duplicate SC revisions if we mis-handle retries. Confirm the v1 plan (idempotency key + queue replay) is what you'd ship. Alternative: lock the WO row at submit and reject re-submits at the API layer.

Q6 · Bold Metals as a "sister trade"

Coverage matrix has Bold Metals occupying the steel/structural row internally. Do we white-label the trade-partner shell for Bold Metals out of S4, or wait until the public trade partner shell is stable in production? Tristan's open to either.