Business decisions — decide before/at Day 0 (they shape code)
Recommendations are scoped to WIRING_GUIDE v0 (DR/JR instant booking; RS + leads deferred). Full sources + detail in MASTER_INVENTORY.md §(c).
Engineering decisions — decide before the day that builds them
Migration strategy — strangle the auction, don't overload it
We are not "adding a status to Jobs." We grow a new bounded context (Marketplace/) around the legacy Jobs monolith and route a thin slice of traffic (Boston + flag) into it, while the legacy auction runs byte-untouched. That is the Strangler Fig. Every other pattern below is a named mechanic that makes the fig safe. Full write-up: migration/MIGRATION_STRATEGY.md; the entity set it realizes is the Entity model tab and migration/ENTITY_MODEL.md.
Why this wins the skeptic: the team already refactors this way (verified read-only)
- A Strategy seam with unimplemented stubs —
executeBookingStrategy(imports/api/Payment/server/booking.js:56-61) already hascase 'instant'/case 'delta'literally throwing"not implemented yet". We fill the'instant'case — we don't add an if. - A gated parallel-run of two Stripe implementations —
processWithNewStripeImplementationvsprocessWithLegacyStripeImplementation(imports/api/Payment/methods/process.js:399 / :224), switched by a Settings flag at:698. - Three forked job-creation pipelines by source —
createJob(Job/methods/listing/helpers.js:128),createJobForNewUser(…/server/invertedFlow.js:42),createJobFromOscar(Oscar/server/quoteHandler.js:265). - An instant-book path that already bypasses bidding —
bookInstantRate(imports/api/InstantRate/services/server/booking.js:49) with its own durable-timer scheduler (InstantRate/services/futureTasks.js).
You don't have to believe the marketplace journey can be forked safely. The team has already forked job creation three ways, parallel-run two Stripe implementations behind a flag, and stubbed an 'instant' booking strategy waiting to be filled in. We are completing a refactor the codebase started.
Pattern → seam map — every pattern grounded in a real file
(umbrella)
New Marketplace/ bounded context grows beside Jobs; a router sends only Boston+flag traffic into it; the legacy auction is untouched.
SeamRouter/facade = Marketplace/helpers/server/bostonGate.ts (isBostonZip, isMarketplaceJob) gating on flag instant_marketplace (imports/core/Settings/index.js:217) + service zip. New context = imports/api/Marketplace/*.
Abstraction
Add the buy booking behind the existing dispatch abstraction instead of scattering if (isMarketplace) through process.js.
SeamexecuteBookingStrategy({bookingType}) (imports/api/Payment/server/booking.js:49). The 'instant'/'delta' cases (:59-61) are stubs. process.js:511 already computes bookingType and calls the strategy at :570 — we implement a case.
Layer
One translation module isolates the new context from the legacy Jobs shape; buy money/assignment never leak onto the Job.
SeamMarketplace/acl/server/legacyJobBridge.ts (new). Materializes a legacy Job in active shape with a synthesized winningBid at provider accept only; mirrors JOB_COMPLETED back. Translation table: ENTITY_MODEL.md §6b.
Run both models against the same reality during the pilot; reconcile before contracting.
SeamDual-write at accept: Booking (record for money/assignment) + materialized Job (record for completion/invoicing), linked by Booking.legacyJobId. Nightly reconcile on the §4 invariants. Precedent: the gated Stripe parallel-run at process.js:698.
(parallel-change)
Add the new context without breaking legacy (expand); contract the coupling only after it proves out.
SeamExpand: new collections are additive; the legacy Jobs.status enum gains nothing (Job/helpers/status.js untouched — the buy machine is the Booking's own). Contract: default C1 = keep the ACL permanent; C2 (absorb completion) only if retiring the bid model.
Interception
Buy events are a disjoint vocabulary; a booking can never emit a bid event into the auction.
SeamEventBus (imports/core/EventBus). Buy emits MARKETPLACE_* only; the ACL never emits JOB_POSTED (the fan-out discriminator from createJob). Reverse: the ACL listens for JOB_COMPLETED (from the unchanged jobs.complete, Job/methods/jobs.js:475) to mirror completion back.
The router (facade) — how traffic is split
The gate is evaluated at intake, re-checked on every draft mutation, and re-asserted inside the booking service — so no booking can exist outside the gate, and the router never silently re-routes.
bostonGate.isMarketplaceJob(zip)flag instant_marketplace (:217) AND isBostonZip(serviceZip)Marketplace/* · DraftQuote → BookingcreateJob → JOB_POSTED → bids[] → scheduleJob · BYTE-UNTOUCHEDThe ACL boundary — drawn explicitly
Only the fields the shared completion/invoicing/payout pipeline reads cross the boundary, and they cross once, in active shape. Everything money/assignment-shaped stays in the context.
materializeJob
legacyJobBridge.tsstatus:'active', winningBid:synth(payout), winnerBidIDBooking.status='completed'mirrored from eventJOB_COMPLETEDEventBusjobs.completeUNCHANGED · jobs.js:475payment{} (deposit/balance/refund) · assignment{} ladder · market · bookingReference · intake snapshot · payout snapshotCoexistence invariants — break one, isolation breaks
Each is enforced structurally (by construction or guard), not by convention.
- Born active, never open/booked-on-the-legacy-enum. The ACL writes the Job directly in
activewith a synthesizedwinningBid; it never callsscheduleJob, which hard-assertsstatus: STATUS.OPEN.value(Job/bidding/server/index.js:526-528, throws "not in open status"). The bidding matcher, everyopenpublication, andJOB_POSTEDfan-out can never see a buy job. - One terminal pipeline.
active → completed/cancelledis the same legacy code for both flows, hinged on the synthesizedwinningBid/winnerBidID(bid precedent:winnerBidID: this.userId,Job/methods/jobs.js:297). No forked completion, invoicing, chat-auth, or archival. - The legacy enum gains nothing. The buy state machine lives on
Booking.status, notJobs.status.Job/helpers/status.jsis untouched — the sharpest departure from the rejected "add'booked'" plan. - Buy money never lands on the Job.
payment{}lives on theBooking; the legacypaymentType/invoice/PaymentsHistory bid path is byte-identical.stripe.refunds.createis only ever invoked againstBooking.paymentPIs. - Disjoint sub-aggregates. A Job carries
bids[]; a Booking carriesassignment— different collections, never handed between flows. Legacyjobs.hauler.listkeys onbids.audit.user(blind to buy pre-accept); the new provider publication keys onBooking.assignment.providerId. - Disjoint event vocabularies, intercepted. Buy emits
MARKETPLACE_*; the ACL guards a booking never emitsJOB_POSTED. Bid events fire only fromcreateBid/expireJobs, which buy code never reaches. - Crons can't touch buy work.
expireJobsqueriesstatus: {$in:['open','active']}andwinningBid: {$exists:false}(System/server/cronjobs/jobs/expireJobs.js:65-66) — a buy Job is bornactivewith awinningBid, so the second clause excludes it. Verified, no patch needed. - Flag + zip gate, fail-loud. Buy entry requires the flag ON and a Boston service zip, re-asserted at materialization. Flag OFF = byte-identical legacy. Gate failure halts/diverts loudly, never silently re-routes.
- Idempotent money on one ledger. Every buy Stripe call carries a deterministic idempotency key off the shared
correlationIdbase (Payment/methods/process.js:75-85); both flows log toPaymentsHistory. A re-fired timer cannot double-charge.
Phased cutover — and the risk each phase retires
Each phase is a flag-scope widening (team → pilot zips → broader), reversible by narrowing the flag. No big-bang switchover, no schema down-migration anywhere on the path.
Marketplace/ context, bostonGate facade, DraftQuote, Booking, ACL stub. Flag default OFF.
Retires: "does new code destabilize prod?" — flag OFF = byte-identical; staging deploy with flag OFF proves zero behavior change.
Implement 'instant' case in executeBookingStrategy; deposit/balance/refund PIs; webhooks; ladder on FutureTasks; ACL materialization at accept.
Retires: "does the new journey corrupt the shared pipeline?" — ACL writes only active-shape Jobs through the one seam; dogfood catches money bugs first.
Flag ON for Boston pilot zips. Both models written at accept; nightly reconcile on the §4 invariants.
Retires: "do the two models agree on reality?" — reconcile surfaces drift (orphan bookings, Jobs missing winningBid, buy Jobs with bids[]) while the kill switch is one flip away.
After N clean reconcile-weeks: default C1 — keep the ACL permanent; the legacy Job is "the fulfillment record." C2 (absorb completion) only if retiring the bid model.
Retires: "are we carrying duplicate runtime mechanics?" — C1 retires it by design (completion never forked); C2 deferred.
Rollback / kill-switch story
- Primary kill switch (already exists):
Settings.isFeatureEnabled('instant_marketplace')(imports/core/Settings/index.js:217). Flip OFF → the facade short-circuits → every request takes the legacy bid path → the platform is byte-identical to today. No deploy, no migration, instant. - Blast-radius containment: only three existing-file edits ship, all flag-gated — (a) implement the
'instant'case inexecuteBookingStrategy, (b) add ataskTypediscriminator to the FutureTasks startup dispatch (System/server/futureTasks.js), (c) add buy webhook cases to the Stripe webhook switch. None change bid-path behavior; flag OFF, none run. - Data rollback is trivial because data is additive. Buy lives in new collections (
DraftQuotes,Bookings) + additive-optional fields elsewhere. Disabling the pilot strands no legacy data and needs no down-migration. In-flight bookings drain to completion through their materialized Jobs (validactivejobs regardless of the flag). - Reconcile-before-contract: the parallel-run reconcile is the gate on Phase C. If reconciliation drifts, you stop contracting and keep the ACL — you never lose the "legacy Job is the record" fallback.