Editor review system + specify-your-editor upsell — build plan & decisions
Planning doc. No product code lands with this PR. Scopes two operator-requested
features into dispatchable packets. Every "already built" claim below is cited to
current main (worktree at origin/main, HEAD e67de7c). Verify migration
numbering and live-DB state at build time (rule 8 — probe, don't trust docs).
Two features, one shared spine:
- Editor performance review + supervisory dispatch control — a client-facing post-delivery rating combined with the existing quality counters into a per-editor score that drives dispatch consequences (warn → throttle → suspend, with reinstate), governed by a per-tenant configurable policy and an ops supervisory surface (roster + scorecard + manual override + audit trail). Editors in good standing keep the "editors choose their work" model untouched; only sub-policy editors are throttled/suspended.
- "Specify your editor" upsell — a purchasable add-on letting a client re-request the (pseudonymous) editor they worked with before.
The linkage is deliberate: the rating in (1) is the signal that makes (2) worth buying ("you rated this editor highly — want them again?"), and both read/write the same editor scorecard. Build (1) first.
Operator ruling (D1a, RESOLVED): the review system ships as a standard engine feature with real dispatch consequences (not informational-only), and the whole supervisory policy is configurable per tenant (rule 5 — no hard-coded thresholds/actions in
src/). Engine ships sensible operator-placeholder defaults. See §Feature 1 for the packets and the config schema.
BUILD STATUS (2026-07-17) — mostly SHIPPED to main (a1cbd64)
| Packet | Status | PR / migration |
|---|---|---|
1A rating store + rate_job RPC + aggregate |
✅ SHIPPED | #74 (migration 0037) |
| 1B client capture surface | ✅ SHIPPED | #77 |
| 1C composite score + config policy engine (pure) | ✅ SHIPPED (two-critic) | #76 (src/lib/supervision.ts) |
| 1D supervision persistence + dispatch hooks + audit + confirm queue | ✅ SHIPPED (two-critic) | #78 (migration 0039) |
| 1E ops supervisory surface (roster/scorecard/manual override) | 🚧 IN A PR — not merged | — |
| 2A/2B specify-editor upsell + refund-on-miss | ✅ SHIPPED | #75 (migration 0038) |
| DX surface the upsell after a high (5★) rating | ✅ SHIPPED | #79 |
Notes: the supervision layer is DORMANT — dispatch_config.supervision ships
OFF (enabled/auto_actions default false); operator enables + tunes. The upsell
is LIVE on wakecut (£10 / 48h hold, refund-on-miss; EUR hidden). Live DB at 0039.
The packets below are the as-built plan (grounded at authoring HEAD); trust main
and STATUS.md over any numbering here (the actual migrations were 0037–0039, not the
0036/0037 the draft anticipated).
0. Ground truth — what already exists (cite before you build)
0.1 Pseudonymity — the load-bearing wall (governs feature 2 entirely)
Editors are double-blind to clients. Confirmed walls on main:
- The only editor label a client ever sees is
profiles.pseudonym—supabase/migrations/0001_init.sql:60(pseudonym text -- editors: the only name clients ever see). Name/email/stripe_account_idnever cross to a client. - Tenant API never emits editor identity.
API_JOB_FIELDSis an allowlist (src/lib/api-response.ts:54-61);editor_idand every identity column are excluded by omission, with the rationale spelled out atsrc/lib/api-response.ts:34-53("EDITOR IDENTITY is the double-blind"). - Outbound webhooks project the same allowlist.
projectJobFieldsiterates onlyAPI_JOB_FIELDS(src/lib/webhooks/dispatch.ts:153-155) — editor identity cannot leak via a webhook payload. - The client job page fetches the pseudonym via the service role only, never a
name/email (
src/app/jobs/[jobId]/page.tsx:183-205, comment at 183-184). - Editor quality is an admin-only surface —
EditorQualityis documented "ADMIN surface only (/ops job detail)" (src/components/ui/editor-quality.tsx:1).
Consequence for feature 2: the client selects/re-requests by pseudonym, never by name and never by browsing a roster. A client with no prior editor has no pseudonym to show — the upsell must degrade cleanly (see §2).
0.2 Quality counters — feature 1's foundation (partly built)
- Counter columns live on
editor_profiles:qc_pass_count,qc_reject_count,revision_count,on_time_count,late_count(supabase/migrations/0001_init.sql:65-75). - They are trigger-fed, never app-written.
bump_editor_counters()fires oneventsrows of typestatus_changeonly (supabase/migrations/0012_quality_priority.sql:55-102). Forged counters are blocked: participants may insert events but notstatus_change(0012:104-115) — the trigger is the single writer, so ops can't drift from what the board sees. - Derived readout is pure arithmetic over the columns —
editorQuality()insrc/lib/quality.ts:41-51(QC-pass %, on-time %, revision %), rendered byEditorQuality(src/components/ui/editor-quality.tsx). - Where it surfaces today: the ops job-detail page only, for the assigned
editor (
src/app/ops/jobs/[jobId]/page.tsx:67-81, 299). There is no per-editor roster / leaderboard / standalone scorecard page — feature 1C is genuinely new UI. platform_settings.board_priorityalready gates board visibility by QC-pass rate (ships disabled) —0012:20-28, applied inpool_jobs_for_editor(0012:261-272). This is the exact precedent for the supervisory dispatch hooks (Packet 1D): a config-driven predicate that alters board visibility, with new editors (no history) never penalised.
Answer to "does an editor scorecard already exist?": a per-job, admin-only, counter-derived one does. It has no client-rating input, no per-editor aggregate view, and no dispatch consequence. Feature 1 adds the rating input, the composite score, the config-driven supervisory policy, the dispatch hooks, and the ops supervisory surface.
0.3 Editor model + dispatch
profiles(role/tenant/pseudonym,0001:52-63),editor_profiles(vetting_status,skill_tags,max_concurrent_jobsdefault 2,available, the counters, NDA cols;0001:65-75+0012:119-121),editor_tenants(per-vertical approval,0001:80-84).- Self-claim job board:
pool_jobs_for_editor()RPC (latest body0012:150-306; earlier0011,0018). Concurrent cap enforced asactive.n < me.max_concurrent_jobs(0012:276). - Managed vs pool dispatch (hybrid): effective mode is
coalesce(jobs.dispatch_override, packages.dispatch_mode, 'pool')— enforced in the board RPC (0012:290) and re-checked on self-claim (src/lib/transitions.ts:424-428). Amanagedjob is invisible to the board; an admin assigns it. - Admin assign path:
transitionJobNEW→ASSIGNED withtargetEditorId(src/lib/transitions.tsassignment branch466-495); admin assignment ignores continuity holds by design (transitions.ts:430-432). Cap re-checked on assign (487-494). - Ops admin editor picker already lists candidates by pseudonym + live
load + cap (
src/app/ops/jobs/[jobId]/page.tsx:131-170) — the surface feature 2's managed path would reuse if it forces managed dispatch.
0.4 Continuity ("same-editor") — feature 2's core is already built
This is the single most important finding. DIFFERENTIATORS-PLAN B2 shipped a same-editor continuity mechanism that is a pseudonymity-clean re-request path:
client_editor_preferences(client_idPK,tenant_id,editor_id) —supabase/migrations/0011_continuity.sql:19-24.set_preferred_editor(uuid)RPC — SECURITY DEFINER, and it verifies the editor actually delivered for this client (a shared job in APPROVED/DELIVERED) before it will pin them (0011:43-80). A client cannot pin an arbitrary editor id — this guard is exactly the "you may only re-request someone you've worked with" rule feature 2 needs, already enforced server-side.jobs.held_for_editor_id+jobs.hold_expires_at(paired by CHECK) —0011:90-97. While unexpired, the board shows the NEW job only to that editor and rejects others' self-claims.resolveContinuityHold()—src/lib/dispatch.ts:53-112. Reads the preference, checks the editor is currently claimable (isHoldEligible: vetting=approved, available, approved-for-tenant,activeJobs < cap, NDA-current —dispatch.ts:28-35, 90-106), and returns the hold columns ornull. Called at both mint paths: entitlement (src/lib/orders.ts:348) and Stripe webhook (src/app/api/webhooks/stripe/route.ts:128).- Window is tenant config —
billing_config.continuity_hold_hours;0/absent = feature off (dispatch.ts:11-16; seed dummy12). - Self-claim re-check: a held job throws
"reserved"for anyone but the held editor (transitions.ts:434-445). - Fallback today = silent expiry. No cron: the hold is a query-time comparison,
so on expiry the job simply opens to the pool; if the held editor dismisses it,
it falls through immediately (
0012:245-253). Nothing is refunded or notified — acceptable for a free feature, not acceptable for a paid one. - Client-facing affordance already exists:
ContinuityPrompt("Work with this editor again?") on APPROVED/DELIVERED jobs, showing pseudonym only, POSTing to/api/preferences/editor→set_preferred_editor(src/app/jobs/[jobId]/continuity-prompt.tsx, gated atpage.tsx:186-205).
Consequence: feature 2 is not green-field. It is "monetize + harden" the B2 continuity path: turn a free, silently-expiring soft hold into a paid, refund-backed, order-time-captured request. The pseudonymity design is already correct — reuse it, don't reinvent it.
0.5 Extras/upsell system — where a paid add-on plugs in
package_extrascatalogue:key,label,price_gbp/price_eur,editor_fare_delta_gbp(uplift folded into the editor fare),deliverable_kinds,active,unique(tenant_id, key)—supabase/migrations/0013_extras_b2b.sql:23-40.- Selection is snapshotted, not FK'd onto
orders.extras/jobs.extrasjsonb (0013:62-70) — a later catalogue edit never mutates a sold job. - Pricing path: order carries
extraIds(src/lib/orders.ts:39-48) →computeExtras()resolves against the catalogue, drops unknown/unpriced ids, returns snapshots +priceTotal+fareDelta(src/lib/billing.ts:31-63); the fare delta folds intofare_snapshot. - Client-forge guard: the client insert policy forbids client-set
extras(extras = '[]'), money snapshots, and held columns (0013:85-98,0011:110-119). - Order form renders extras (
src/app/order/order-form.tsx); bothPOST /api/ordersandPOST /api/v1/ordersfunnel throughsrc/lib/orders.ts.
Consequence: the upsell is a package_extras row (key = 'specify_editor'),
but the extras system snapshots a catalogue line; it does not carry which
editor was requested. Feature 2 needs one new piece of order state: the requested
editor id (see §2).
Feature 1 — editor performance review + supervisory dispatch control
Design summary (incorporating operator ruling D1a)
Rating source = the client, post-delivery. After APPROVED → DELIVERED the
client is prompted on the job surface (beside the existing ContinuityPrompt) to
rate their edit / editor. The rating attaches to jobs.editor_id server-side;
the client sees a pseudonym and their own experience, never the editor's identity
and never other clients' ratings. The rating combines with the existing quality
counters into one editor scorecard — it does not replace them. It is not a
QC-admin pass and not purely auto-derived.
Forge-proofing (non-negotiable, mirrors 0012): the client must not be able to
write editor_id or a counter directly. Capture goes through a SECURITY DEFINER
RPC that derives editor_id from the job server-side and verifies the caller owns
the job and the job is in a rateable terminal state — exactly the shape of
set_preferred_editor (0011:43-80). Aggregates are maintained by a trigger
(the single writer), like bump_editor_counters — so the scorecard can't drift.
Supervisory dispatch control (D1a ruling). The score does not just display —
it acts. A config-driven policy engine (per-tenant, dispatch_config. supervision) maps the composite score to an action ladder: warn → throttle → suspend, with a reinstate path. Actions apply to the dispatch surfaces the
engine already owns — the self-claim board (pool_jobs_for_editor) and managed
dispatch / ops assign. A supervisor sees everything on an ops surface and can
manually warn/throttle/suspend/reinstate; auto-action-vs-human-confirm is itself
config. Two hard rules carry through every packet below:
- Good standing is untouched. Throttle/suspend affect only editors below policy. An editor at/above threshold sees the board and claims exactly as today — "editors choose their work" is preserved for everyone in good standing.
- Never punish on thin data. Below a configurable sample-size floor (N
rated/delivered jobs) an editor is UNSCORED and can never be
auto-actioned — the same "new editors wait, proven quality only" rule the board
priority window already encodes (
0012:255-259). This is the primary guard against an accidental mass-suspend.
The dispatch-gating packet (1D) is fairness/income-sensitive — a wrongly suspended editor loses work. It ships with: the sample-size floor, an auto-action circuit breaker (cap on auto-suspends per window), a full audit trail of every supervisory action (auto or human), and a reinstate path. Two-critic review is mandatory on 1C (the score/policy math) and 1D (the dispatch hooks).
Packet 1A — rating store + capture RPC + aggregate
- Scope: New migration (
editor_reviewstable) +rate_job()SECURITY DEFINER RPC + aggregate columns oneditor_profiles(client_rating_sum bigint,client_rating_count int) bumped by aneditor_reviewsAFTER trigger.editor_reviews:job_idunique (one rating per job),client_id,editor_id,tenant_id,rating smallint CHECK 1..5,comment text null,created_at,updated_at. RLS: client SELECTs own rows; admin all; no direct client INSERT/UPDATE — mutation only via the RPC (mirrorclient_editor_preferences,0011:30-37).rate_job(p_job_id, p_rating, p_comment)verifiesget_user_role()='client', the caller owns the job,status IN ('APPROVED','DELIVERED'),editor_idis set; upserts onjob_id; keeps the aggregate correct on re-rate (trigger diffs old/new). - Invariant: a client can only ever rate their own job, and
editor_idis read from the job by the RPC — never accepted from the caller. No path lets a client (or a tenant API key) write another client's rating or set the editor. - Non-scope: UI (1B); ops surface (1C); any dispatch effect (1D); free-text moderation beyond a length cap.
- Fail-under-broken gate (unit + e2e):
node --teston the RPC-shape/aggregate arithmetic; an e2e (tests/e2e/) that (i) rates an APPROVED job and assertseditor_profiles.client_rating_count/sumadvanced, (ii) attempts to rate a job the caller doesn't own → rejected, (iii) attempts to POST aneditor_reviewsrow directly via PostgREST with a forgededitor_id→ RLS-denied. Must fail if the RPC trusts a caller-suppliededitor_idor the direct-insert policy is missing. - Output: migration file + a probe-and-skip note (pattern:
editor-landing.spec.ts) since the shared dev/prod DB won't have the migration until the operator pastes it.
Packet 1B — client capture surface
- Scope: A star-rating prompt (1–5) + optional comment on the post-delivery job
surface, co-located with
ContinuityPrompt(src/app/jobs/[jobId]/page.tsx:223, same APPROVED/DELIVERED gate) and/or the review surface (src/app/jobs/[jobId]/review/client-review.tsx). New thin routePOST /api/jobs/[jobId]/rating→rate_jobRPC (mirror/api/preferences/editor,src/app/api/preferences/editor/route.ts). Shows the editor pseudonym only; shows the client their own prior rating if present. - Invariant: the surface renders the pseudonym via the same service-role path already used for continuity — no name/email reaches the client bundle.
- Non-scope: ops aggregate view (1E); the scoring/policy engine (1C). Rating is skippable (config default — never block approval/delivery on it).
- Fail-under-broken gate (e2e): drive approve→delivered, submit a rating, assert it persists and the pseudonym (not a name) is displayed.
- Output: component + route + e2e.
Packet 1C — composite score + config-driven policy engine (PURE) — two-critic
- Scope: A pure
src/lib/supervision.ts(no I/O, on thenode --testgraph — same discipline asdispatch.ts/quality.ts) that: (a) parsestenants.dispatch_config.supervisiondefensively (theme.ts spirit: every absent/junk/out-of-range value falls back to an engine default; a malformed block can never take dispatch down) — see the schema in §Config below; (b) computes the composite score from the editor's metrics — the client rating (avg over the score window) combined with the quality counters (QC-pass, on-time, revision) via configurable weights, each normalised to [0,1]; (c) evaluates the action ladder → a proposed standing (good | warned | throttled | suspended) + throttle magnitude; (d) enforces the sample-size floor (below N →unscored,nullaction) and returns whether the action is auto or needs-confirm per config. Extendsrc/lib/quality.tsto foldclient_rating_sum/countinto the readout used by the scorecard (avg rating + n). - Invariant: an editor below the sample-size floor is
unscoredand yields no auto-action — provably (a dedicated test). Weights that don't sum to 1 are renormalised, not trusted blindly. The engine ships operator-placeholder defaults; nothing vertical/tenant is hard-coded insrc/(rule 5). - Non-scope: applying the standing to any surface (1D); persistence/audit (1D); the ops UI (1E). This packet only decides.
- Fail-under-broken gate (unit): table-driven
node --test— thin-sample editor →unscored/no-action; a below-suspend-threshold editor with ≥ floor samples →suspend; at-threshold → no worse thanwarn; a config with the layer disabled → every editorgood. Must fail if the floor is skipped or a disabled config still actions anyone. - Output:
src/lib/supervision.ts+ tests. Two independent critics (the fairness math is load-bearing).
Packet 1D — supervisory persistence + dispatch hooks (audit) — two-critic
- Scope: Migration 0037 (see numbering note) adding:
editor_supervision— per (editor_id, tenant_id) standing:standing text,composite_score numeric,effective_cap int null,sample_size int,auto boolean,pending_action text null(set when config is confirm-mode and a supervisor hasn't approved yet),evaluated_at,updated_at. Per-tenant because policy is per-tenant and ratings are naturally per-tenant (see the scoping note in §Config). Standing is materialised here by the engine so the board RPC reads columns, not jsonb — mirroring the counters/quality split.supervisory_actions— the audit trail:editor_id,tenant_id,action(warn|throttle|suspend|reinstate),reason text,actor('system'or an admin uuid),score_snapshot numeric,sample_snapshot int,created_at. Append-only; every auto and manual action writes one row.- The evaluation writer (service-role job that runs 1C and upserts
editor_supervision+ appendssupervisory_actions), triggered where the inputs change: on a new rating (1A), on a counter bump (after QC/deadline transitions), and on demand from the ops surface. Reuse an existing cron/sweep surface (src/lib/sla.ts/retention.ts) rather than adding infrastructure. - The dispatch hooks: extend
pool_jobs_for_editorso asuspended(editor, tenant) sees none of that tenant's board, and athrottledone is de-prioritised and/or capped byeffective_cap(mirror the board-priority predicate shape,0012:261-272); surface the standing on the managed/ops assign path (src/app/ops/jobs/[jobId]/page.tsx:131-170) so a supervisor is warned before assigning a suspended editor. RLS on both tables: admin-write only; an editor may SELECT their owneditor_supervisionrow (self-visibility) but not others';anonrevoked (rule 7 / 0005).
- Invariant: the hooks are config-gated and standing-gated — with the
supervisory layer disabled, or for a
good/unscorededitor, the board and assign behave byte-identically to today. No supervisory action ever occurs without ansupervisory_actionsrow (auto or human). The circuit breaker caps auto-suspends per window (config) — a bad batch cannot mass-suspend the pool. - Non-scope: the ops UI (1E); hard-blocking payout on an in-flight job (a suspension changes future dispatch only, never claws back live work).
- Fail-under-broken gate (e2e, sentinel-bound): a sentinel suspended editor is absent from that tenant's board and present on another tenant's (per-tenant scoping); a throttled editor's effective cap is honoured; a good-standing editor is unaffected; every applied action leaves exactly one audit row; the circuit breaker blocks the (N+1)th auto-suspend in a window. Must fail if a disabled/good path changes behaviour, an action skips the audit trail, or the breaker is absent.
- Output: migration 0037 +
pool_jobs_for_editorbody swap + assign-surface read + evaluation writer + e2e. Two independent critics (income-sensitive).
Packet 1E — ops supervisory surface (roster + scorecard + manual override)
- Scope: A new admin roster/scorecard (
src/app/ops/editors/…) listing each editor by pseudonym with QC-pass %, on-time %, revision %, avg client rating + n, composite score, current standing, and sample size — extending the existing per-jobEditorQuality(src/components/ui/editor-quality.tsx) into a per-editor view. Supervisor actions: warn / throttle / suspend / reinstate (each writes asupervisory_actionsrow via an admin-gated route/RPC), a confirm queue forpending_actionitems when config is confirm-mode, and a read-only audit log per editor. Reinstate follows config (manual always available; auto-reinstate is a 1C/1D concern). - Invariant: admin-only (engine-dark, DESIGN-BRIEF §5); every figure reads the materialised columns (never recomputes from the event/review log); every manual action is audited with the acting admin's uuid.
- Non-scope: exposing any rating/standing to clients (never); the editor's own view of their standing beyond the self-SELECT in 1D.
- Fail-under-broken gate (e2e): a sentinel editor with known counters + rating + standing renders the exact expected figures; a manual suspend writes an audit row and flips the standing; a confirm-mode pending action is approvable and only then takes effect (house sentinel-binding pattern).
- Output: ops page(s) + admin action route/RPC + e2e.
Config — tenants.dispatch_config.supervision (per-tenant, no migration)
Lives in the existing tenants.dispatch_config jsonb (already the home of the
pushback/dispatch knobs — src/lib/dispatch.ts:238-246), so no migration for the
policy itself (only 1D's standing + audit tables need 0037). Parsed by a total,
defensive resolver in supervision.ts (like parseDispatchConfig): any missing or
malformed field falls back to the engine default; the whole layer is off unless
enabled. All values below are operator-placeholder defaults — flag them; the
operator tunes.
"supervision": {
"enabled": false, // whole supervisory layer on/off (default OFF)
"auto_actions": false, // true = act automatically; false = queue for supervisor confirm
// (D1-auto: whether this ships ON or OFF is a GENUINE open decision)
"min_sample_size": 5, // rated+delivered jobs before a score is ACTIONABLE (the floor)
"score_window": { "kind": "recent_jobs", "n": 20 }, // or { "kind": "days", "n": 90 }
"weights": { // composite inputs, each normalised to [0,1]; renormalised if they don't sum to 1
"client_rating": 0.40, // avg client rating / 5 (feature-1 input)
"qc_pass": 0.30, // qc_pass / (qc_pass + qc_reject) (0012 counter)
"on_time": 0.20, // on_time / (on_time + late) (0012 counter)
"revision": 0.10 // 1 - (revision / qc_pass), inverted: fewer send-backs = better
},
"ladder": { // action thresholds on the composite [0,1]; evaluated worst-first
"warn": { "below_score": 0.70 },
"throttle": { "below_score": 0.55, "cap_to": 1, "deprioritise": true },
"suspend": { "below_score": 0.40 }
},
"reinstate": {
"mode": "manual", // "manual" | "auto"
"auto_above_score": 0.65 // used only when mode = "auto"
},
"guards": {
"max_auto_suspends_per_day": 2 // circuit breaker against accidental mass-suspend
}
}
Scoping note (flag for confirm, not a build-blocker): policy + client ratings are
naturally per-tenant; the 0012 quality counters are global on editor_profiles
(supply is platform-scoped, 0012:7-9). v1 computes a per-(editor, tenant) standing
using that tenant's policy over per-tenant ratings + the shared global counters — so an
editor can be in good standing for tenant A and throttled for tenant B (the board already
filters by editor_tenants and job.tenant_id, so this is enforceable). If the operator
wants counters scoped per-tenant too, that's a follow-up slice (per-tenant counter columns
or a per-tenant events rollup) — recommend deferring; confirm the v1 stance.
Feature 2 — "specify your editor" upsell
Recommended pseudonymity-respecting design
Re-request, not browse. The upsell is offered only to a client who already
has an eligible prior editor (a delivered APPROVED/DELIVERED shared job — the exact
set_preferred_editor guard, 0011:61-67). The client sees "Work with
[Pseudonym] again — +£X" at checkout. Selecting it:
- attaches the paid
package_extrasline (key='specify_editor'), and - records which editor was requested on the order (new
orders.requested_editor_id, snapshotted tojobs.requested_editor_id), validated server-side by the same delivered-for-this-client guard.
At mint, dispatch holds the new job for that specific requested editor — reusing
jobs.held_for_editor_id / hold_expires_at — for a (longer, configurable) window.
Because it's paid, the fallback is not silent expiry: on window expiry with
no claim (or if the editor is ineligible), the extra is auto-refunded and the job
opens to the pool, with a client notification.
Client with no prior editor: the extra is simply not shown / disabled at checkout, with copy like "available once you've completed an order with an editor." No roster, no names — nothing to disclose.
Why keep it on the pool-hold path, not forced managed auto-assign (recommended):
the platform's model is "real editors choose the jobs they take"
(src/lib/dispatch.ts:302-303, the pushback copy). Auto-assigning a specific editor
without their claim breaks that and the cap/consent model. A held-for-me job already
bypasses the board-priority window (0012:262) and is shown first, so a paid
hold is a strong offer without conscripting the editor. Forced managed dispatch is a
decision (D2f) — recommend against for v1.
Distinction from the existing free continuity: free continuity is a soft, best-effort, silently-expiring board hold driven by the standing preference; paid specify-editor is an order-scoped, stronger/longer, refund-backed request for a specific editor chosen at checkout. Both share the same hold columns and board machinery.
Packet 2A — catalogue entry + order-time capture
- Scope: Migration adding
orders.requested_editor_id+jobs.requested_editor_id(nullable FK →profiles,ON DELETE SET NULLlike the hold cols,0011:90-91) and extending the client-forge guard to forbid a client-setrequested_editor_idon the insert path (extend0013:85-98). Seed aspecify_editorpackage_extrasrow (operator sets price). Order form change: when the buyer has ≥1 eligible prior editor, offer "Work with [Pseudonym] again (+£X)"; on select, passrequested_editor_idwith the order.src/lib/orders.tsvalidates it server-side against the delivered-for guard (reuse theset_preferred_editorquery, or a newassert_requestable_editorRPC) and drops it if the paid extra isn't also selected. - Invariant:
requested_editor_idis accepted only when (a) thespecify_editorextra is inextraIds, and (b) the editor has a delivered job for this client. The client bundle only ever handles the pseudonym; the id is resolved server-side. A no-prior-editor client can neither see nor buy the extra. - Non-scope: the hold/refund behaviour (2B); managed-dispatch variant (D2f).
- Fail-under-broken gate (unit + e2e): unit on the "extra-required + delivered-for"
validation (accepts valid, drops id when extra absent, rejects a non-delivered editor);
e2e that an order with the extra records the requested editor and one from a client with
no history never shows the option. Must fail if
requested_editor_idis honoured without the paid extra or without the delivered-for check. - Output: migration + orders.ts change + order-form change + tests.
Packet 2B — dispatch honouring + refund-backed fallback
- Scope: At both mint paths, prefer the order's
requested_editor_idover the standing preference. FactorresolveContinuityHoldso a paid request uses a separate (longer) window (newbilling_config.specify_editor_hold_hours) and targets the requested editor via the existing eligibility check (isHoldEligible,dispatch.ts:28-35). Add the refund-on-miss path: when the paid hold expires unclaimed (or the editor was ineligible at mint), refund thespecify_editorline (Stripe refund via the verified-webhook/refund path — never a client-side call, CLAUDE.md rule 6) and open the job to the pool, notifying the client (Resend, Phase 7 machinery). Expiry has no cron today (query-time), so the refund trigger needs an explicit mechanism — reuse the retention/SLA cron surface (src/lib/retention.ts/src/lib/sla.ts) or an on-claim/on-board-read sweep; pick one in the packet and gate it. - Invariant: a paid request never both charges and fails silently — either the
requested editor delivers, or the extra is refunded. The hold never black-holes a job
(mirror the existing eligibility guard that already refuses to hold for a
non-claimable editor,
dispatch.ts:88-106). - Non-scope: partial refunds; re-offering to a different preferred editor on miss (open to pool is the v1 fallback).
- Fail-under-broken gate (e2e): (i) requested editor eligible → job held, claim succeeds, no refund; (ii) requested editor over-cap/unavailable → hold not placed (or expires) → extra refunded + job pooled. Must fail if a miss leaves the client charged.
- Output: dispatch.ts refactor + refund path + cron/sweep wiring + e2e.
Packet 2C — client-facing surface + copy
- Scope: Order-confirmation + job-page copy reflecting the request ("Requested [Pseudonym]" / "Your editor is handling this"); refund/reassignment notice copy when the fallback fires. All tenant-configurable per rule 5 (engine-neutral defaults; no yacht/vertical wording, no hex).
- Invariant: every string defaults engine-neutral and is overridable via tenant
config (theme.ts spirit, like the pushback copy in
dispatch.ts:301-312). - Non-scope: new dispatch behaviour.
- Fail-under-broken gate (e2e): the surface shows the pseudonym-based request state and the fallback notice on a refunded order; no name/email present.
- Output: copy config + surface + e2e.
Cross-feature
The linkage
Feature 1's rating is the buy signal for feature 2. Recommended tie-ins (each a decision, D-X below): after a high rating, surface the re-request upsell more prominently; optionally show the client their own prior rating of an editor at checkout ("You rated [Pseudonym] 5★"). Never show an editor's aggregate or other clients' ratings to a client — pseudonymity + not-a-marketplace.
Reuse map (lean on existing / genuinely new)
| Concern | Reuse unchanged | New |
|---|---|---|
| Pseudonym display | profiles.pseudonym, service-role fetch (page.tsx:191-199) |
— |
| Editor identity redaction | API_JOB_FIELDS, projectJobFields |
— |
| Rating store/forge-proofing | pattern of set_preferred_editor + bump_editor_counters |
editor_reviews + rate_job RPC + aggregate trigger (1A) |
| Scorecard math | src/lib/quality.ts, EditorQuality |
rating fold-in + composite score + per-editor roster (1C/1E) |
| Supervisory policy config | tenants.dispatch_config jsonb + parseDispatchConfig defensive-parse pattern (dispatch.ts:238-246) |
supervision config block + src/lib/supervision.ts engine (1C) |
| Dispatch gating | platform_settings.board_priority config-driven predicate (0012:261-272), pool_jobs_for_editor body |
editor_supervision standing + board/assign hooks + supervisory_actions audit (1D) |
| Standing storage/audit | pattern of counters materialised on editor_profiles for the board to read |
migration 0037: editor_supervision + supervisory_actions tables (1D) |
| Re-request eligibility | set_preferred_editor delivered-for guard (0011:61-67) |
reuse for requested_editor_id validation (2A) |
| Editor hold on the board | jobs.held_for_editor_id, resolveContinuityHold, board RPC hold predicate |
separate longer window + refund-on-miss (2B) |
| Paid add-on plumbing | package_extras, computeExtras, orders/jobs.extras snapshot |
specify_editor seed row + requested_editor_id order state (2A) |
| Refund | Stripe verified-webhook/refund path | trigger the refund on hold-miss (2B) |
Risks
- Fairness / income (the load-bearing risk). A wrongly throttled/suspended editor
loses real income. Guards baked into 1C/1D: the sample-size floor (never action on
thin data), the auto-suspend circuit breaker (a bad batch can't mass-suspend), a
full audit trail (
supervisory_actions, every auto + manual action), a reinstate path, human-confirm mode (config), and a suspension changes only future dispatch (never claws back an in-flight, already-claimed job or its payout). Two-critic review mandatory on 1C and 1D. - Forged/tanked ratings. A client rating the wrong editor or spamming to tank a score.
Mitigate: RPC derives
editor_idfrom the job, one row per job, rateable-state gate, aggregate via trigger only (never client-writable) — §1A invariant. The sample-size floor blunts a single malicious client; consider a per-client rating weight cap (future). - Config that takes dispatch down. A malformed
supervisionblock must never break the board. Mitigate: the resolver is total/defensive (theme.ts spirit) — every field falls back to a default, and the layer is off unlessenabled; the board RPC reads a materialised standing column, never parses jsonb in SQL. - Platform-vs-tenant scoping. Counters are global, policy/ratings per-tenant (see the §Config scoping note). Getting this wrong means an editor is mis-scored across tenants — flagged as a confirm item, with a recommended per-(editor,tenant) standing.
- Free-text PII / abuse / GDPR.
editor_reviews.commentis client-authored and attaches to an editor. Must be in erasure scope (src/lib/erasure.ts) and length-capped; decide visibility (D1-vis). Editors are pseudonymous, so a comment must not become a channel for a client to identify/contact an editor. - Paid-request black-holing. A held-for job hidden from the pool with no eligible
claimant. The existing
isHoldEligibleguard already refuses to hold for a non-claimable editor; 2B must preserve that and add the refund path so a paid miss is never silent. - Refund trigger has no home. Expiry is query-time with no cron today. 2B must pick a concrete mechanism (cron sweep vs on-read) or the refund never fires.
- Bottleneck on a popular editor. Everyone re-requests one editor → holds pile up, windows expire, refunds spike. Surface it in the ops scorecard; consider a cap on concurrent paid holds per editor (decision D2-cap).
- Rush/priority interaction. A held-for-me job already bypasses the priority window
(
0012:262); confirm a paid hold shouldn't also jump rush ordering unfairly. - Migration numbering / live-DB drift. Migrations on
mainrun through0035, and0036is CLAIMED by PR #71 (arming-prep, open) — so this build's first migration is0037(1D'seditor_supervision+supervisory_actions). The supervisory policy needs no migration (it lives in the existingdispatch_configjsonb). Verify the next-free number againstsupabase/migrations/and open PRs at build time (rule 8).
Recommended safe build order
- 1A rating store + RPC + aggregate (foundation; nothing depends on it being pretty).
- 1B client capture surface (starts producing ratings → real data to score against).
- 1C composite score + policy engine (pure; two-critic) — validates the fairness math in isolation before anything touches dispatch.
- 1D supervisory persistence + dispatch hooks + audit (migration 0037; two-critic; income-sensitive) — the load-bearing packet, a one-slice-worker-cycle candidate.
- 1E ops supervisory surface (roster/scorecard + manual override + audit view).
- 2A upsell catalogue + order capture (independent of Feature 1; can parallelize from step 2 onward).
- 2B dispatch honouring + refund fallback (load-bearing; one-slice-worker-cycle).
- 2C client copy/surface polish.
Feature-1 order is strict (1A→1B→1C→1D→1E — each feeds the next); Feature 2 (2A→2B→2C) is an independent track that can run in parallel once ratings exist. 1C, 1D, and 2B each get two independent critics (fairness math, income-sensitive dispatch, and money-touching refund respectively).
Each packet is a self-contained agent dispatch (worktree, background, PR-not-merge). Two-critic review is required on 1A (forge-proof rating store), 1C (fairness/score math), 1D (income-sensitive dispatch hooks), and 2B (money-touching refund).
DECISIONS NEEDED — operator checklist
Feature 1 — review/performance
- D1a — RESOLVED (operator ruling). The review system ships as a standard engine feature with real dispatch consequences (warn → throttle → suspend + reinstate), governed by a per-tenant configurable policy and an ops supervisory surface with human override + audit. Not informational-only. Packets 1C–1E are in scope.
Now CONFIG DEFAULTS (operator-placeholder — tune, don't block the build). All of these
live in dispatch_config.supervision (§Config); the engine ships the defaults shown there.
The operator sets real values after validation:
- thresholds —
min_sample_size(the floor),score_window, and the ladder score cutoffs (warn/throttle/suspendbelow_score); - the composite weights (how the client rating combines with the QC/on-time/revision counters);
- the throttle magnitude (
cap_to,deprioritise); - reinstate rules (
manualvsauto+ the improvement threshold); - the circuit-breaker cap (
max_auto_suspends_per_day); - rating mandatory vs skippable (recommend skippable) and editor self-visibility of their own standing/score (recommend yes, own aggregate only) — UX defaults, not blockers.
Genuine OPEN decisions:
- D1b — Rating shape. Stars only, or stars + optional written comment, or multi-dimension (quality / communication / timeliness)? (Recommend: 1–5 stars + one optional comment; multi-dimension deferred. This one shapes the 1A schema — decide before 1A.)
- D1-auto — Do supervisory auto-actions ship ON or OFF by default? i.e. the default of
supervision.auto_actions(act automatically vs queue every action for a supervisor to confirm). (Recommend: OFF / human-confirm at launch — safest for an income-sensitive layer; flip to auto once thresholds are validated on real data.) - D1-scope (confirm) — Per-tenant standing over global counters. Confirm the v1 stance
(per-
(editor, tenant)standing using per-tenant ratings + shared global counters; §Config scoping note). (Recommend: accept v1; defer per-tenant counters.)
Minor confirms (recommendations stand unless the operator objects): 1–5 scale, one rating
per job re-ratable until close; a client sees only their own past rating, never an
aggregate/another client's; the free-text comment is ops + editor-aggregate context, length-
capped, and in erasure scope (src/lib/erasure.ts).
Feature 2 — specify-your-editor upsell
- D2a — Mechanism. Re-request a prior editor only (recommended, reuses the delivered-for guard) vs a broader preferred-editor list? (Recommend: prior-editor re-request only.)
- D2b — Paid vs free. Charge for what free continuity does today, or keep free continuity and add this as a stronger paid tier (longer/guaranteed hold + refund)? (Recommend: paid tier layered on top; leave free continuity as-is.)
- D2c — Price + currency + editor uplift.
specify_editorprice (GBP, and EUR?), and does the requested editor get a fare uplift (editor_fare_delta_gbp) for the paid request? (Operator placeholder — do not invent.) - D2d — Fallback on miss (editor unavailable / over-cap / inactive / NDA-stale): auto-refund the extra + open to pool (recommended), queue-and-notify, or extend the hold? (Recommend: refund + pool + notify.)
- D2e — Hold window. Separate
specify_editor_hold_hours(longer than the freecontinuity_hold_hours)? value? (Operator placeholder.) - D2f — Forced managed dispatch? Does buying it auto-assign (managed) to the editor, or keep the pool-hold model where the editor still claims? (Recommend: pool-hold — preserve "editors choose their jobs.")
- D2g — One-shot vs standing. Does buying it apply to this order only, or also set the standing preference? (Recommend: order-scoped; optionally offer to also set the standing preference.)
- D2h — Client handle + no-prior-editor copy. Confirm the client sees "[Pseudonym]" and that the extra is hidden/disabled for a client with no eligible prior editor, with the "available once you've completed an order" copy.
- D2-cap — Concurrent paid-hold cap per editor, to prevent a popular editor becoming a refund-generating bottleneck? (Recommend: a small cap, config-driven; decide value.)
Cross-feature
- DX — Linkage surfacing. After a high rating, do we actively surface the re-request upsell (e.g. show "You rated [Pseudonym] 5★ — request them again" at checkout)? (Recommend: yes, using the client's own rating only.)
All money values, hold windows, prices, and copy remain operator placeholders — seeds use obvious dummies; the operator sets real values after validation (CLAUDE.md §Operator placeholders).