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:
WHAT
Postcon (internal · live), Vestra (homeowner · live), trade-partner shell (new). Same D1, same R2, JWT-scoped views.
WHY THIS SHAPE
A defect, a booking, a warranty event lives once in HOP. Each app reads its scope. No middleware syncs anything.
WHAT'S NEW
Trade-partner SvelteKit app + shared packages: db, adapters, events, quote-engine. Pulled out of postcon as they're built.
8 WEEKS TO LIVE
S1 schema + auth · S2 trade-partner shell + HSQ pilot · S3 supplier adapters (Reece + MIDDY's) · S4 Vestra handshake.
Three SvelteKit apps on top of one Workers API and one D1. Shared bindings, shared session KV, single event stream via Durable Objects.
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.
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.
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
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.
@hop/db · Derek (schema authority — every change is a Drizzle migration PR)@hop/adapters · one engineer per supplier · template pattern below@hop/events · Derek (DO is the hard part)@hop/quote-engine · pure-function package, easy to write tests againstapps/trade-partner · one engineer can own end-to-end, leans on the packagesDrizzle, 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.
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.
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.
{
"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
}
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
};
}
trade.hillbrook.co/onboard?token=…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.
// @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>;
}
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
};
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;
};
hop-cache with price:<supplierKey>:<vendorSku>:<tradePartnerId> keyssearch() → reshape into NormalizedProduct → write to D1 price_quote + KV// @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 …
};
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.
// 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');
}
}
workOrderId (idFromName) · same id reaches the same DO from all three appsexport 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' };
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.
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.
# 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
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.
The 88 Glen Iris HWS valve defect — same scenario as the Jeremy mockup. Shows what hits what.
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
Demo: auth from postcon & Vestra against the new consolidated DB · no regression
S2 · WEEKS 3–4 · TRADE SHELL
Demo: Solomon installs PWA · sees his real bookings · clock-in works
S3 · WEEKS 5–6 · ADAPTERS
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
Demo: live 88 Glen Iris DLP defect run on real plumber · Antoniou opens Vestra · sees Jeremy arrive on his actual phone
| Risk | Severity | Mitigation |
|---|---|---|
| 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. |
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.