☰ MenuEditor Review Plan

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:

  1. 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.
  2. "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 DORMANTdispatch_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:

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)

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

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:

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

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:

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

Packet 1B — client capture surface

Packet 1C — composite score + config-driven policy engine (PURE) — two-critic

Packet 1D — supervisory persistence + dispatch hooks (audit) — two-critic

Packet 1E — ops supervisory surface (roster + scorecard + manual override)

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:

  1. attaches the paid package_extras line (key='specify_editor'), and
  2. records which editor was requested on the order (new orders.requested_editor_id, snapshotted to jobs.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

Packet 2B — dispatch honouring + refund-backed fallback

Packet 2C — client-facing surface + copy


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

Recommended safe build order

  1. 1A rating store + RPC + aggregate (foundation; nothing depends on it being pretty).
  2. 1B client capture surface (starts producing ratings → real data to score against).
  3. 1C composite score + policy engine (pure; two-critic) — validates the fairness math in isolation before anything touches dispatch.
  4. 1D supervisory persistence + dispatch hooks + audit (migration 0037; two-critic; income-sensitive) — the load-bearing packet, a one-slice-worker-cycle candidate.
  5. 1E ops supervisory surface (roster/scorecard + manual override + audit view).
  6. 2A upsell catalogue + order capture (independent of Feature 1; can parallelize from step 2 onward).
  7. 2B dispatch honouring + refund fallback (load-bearing; one-slice-worker-cycle).
  8. 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

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:

Genuine OPEN decisions:

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

Cross-feature

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).