Internal reference · backend/recruiter

Fifteen agents behind Mira & Atlas.

The Mira backend is a multi-agent system: a conversational voice, a tool-using operator, two end-of-turn planner lanes (brief + identity), a buyer-intent router that fires once when the client approves the brief, a deep-research crew, a personalization pass, a PDF template picker, and a concierge that scouts talent, negotiates with each one, scores their offers, and writes each finalist's pitch. This page maps who each agent is, what context it reads, what it produces, and — most of all — how they are wired to one another.

Engine · OpenAI gpt-5.6-terra (every agent) · gpt-5.6-luna on the regional lane Runs in · the worker, never the web request Push · Pusher channels only Last updated · 2026-09-24

01The mental model

Read these five sentences and you have the whole system. Everything after is detail.

One model, fifteen hats.

Every agent here is the same model — OpenAI gpt-5.6-terra. They diverge only in their system prompt, their tool allow-list, the context injected per call, and decoding knobs (reasoning_effort, temperature, forced output schemas). The three settings keys — agent_model, voice_conversational_model, research_subagent_model — all resolve to the same default. So "which agent" is really "which prompt + which tools + which context."

Two qualifications, both since 3 September. The model is no longer the same for every CLIENT: a browser reporting one of nine countries (IN, PK, BD, NG, BR, LK, EG, MA, KE) puts the whole project on a lighter lane, gpt-5.6-luna on every agent. The country is read from the browser's own timezone (falling back to its locale, then to the edge network), and the lane is decided ONCE when the project is created and frozen on both the project and the person, so a client who travels does not change lanes mid conversation. agent_model is a property over a per-job variable the dispatcher sets, which is why every existing read of it follows the lane with no call site changed. And a system prompt is no longer one string: it is assembled from NAMED SECTIONS, each with a stable identity, so a single section can be dialled down for a share of people or given a second version to compare against the first — see “Rules every agent obeys” below.

And since 24 September, a model can be tried per project. gpt-6-luna is wired behind two experiments, one per lane, both allocated at 0% and moved only from the System Admin slider, so today every project still runs the models above.

The fifteen run across three theaters plus one on-demand job:

  • Theater I — the live turn. One user message fans out into five parallel lanes. MIRA is the only voice the user hears. ATLAS thinks and acts silently with 35 tools. At end-of-turn three planner lanes compose in parallel, each emitting one structured object: MASON the brief, IRIS the "About You" identity file, and PULSE a high/low verdict on the client's hiring intent (a casual client is routed to plain search instead of the concierge). (ATLAS authors none of them — his edit_identity tool is gone.)
  • Theater II — background research. ATLAS fires SAGE and forgets it. SAGE investigates the client, then a silent chain runs: IRIS dedups the findings into the dossier, LENS (vision) places harvested images — the client's homepage screenshot is now pinned straight into the brief's About-the-client section — and STYLO re-skins the workspace. Separately, when the client won't name a budget/timeline, ATLAS fires GAUGE — a background estimator that prices the work off the live Fiverr talent market and open-web rates, and proposes a figure for MIRA to relay.
  • Theater III — the concierge, between turns. After the client settles on a shortlist, SCOUT hunts a deep pool of talent and ranks the strongest, STERLING negotiates with each one, JUNO rules pass or fail on every proposal against the brief, PORTIA writes each finalist's human "why they fit" pitch at the reveal, and HERALD narrates the live dashboard — all behind a client-approval gate and an anonymity wall.
  • On demand — HUE picks the export template (editorial/modern/classic) when the client clicks "Export PDF."

Agents never call each other over HTTP.

They communicate through five quieter channels: two end-of-turn planner lanes (MASON + IRIS, each one structured object) plus PULSE's once-on-approve buyer-intent classification, a fire-and-forget task (ATLAS → SAGE), one tagged notes channel read a turn later (ATLAS · MASON · IRIS → MIRA), shared workspace state (MASON's checklist; PULSE's intent verdict; the activity board), and Pusher pushes to the browser. Section 03 lays these out.

02System map

Where the agents live relative to the runtime. The browser only ever does short REST; the web tier authenticates and enqueues; all agent work happens in the worker; results return to the browser as Pusher events and durable state lands in Postgres.

Browser client + talent SPAs short REST only Web (FastAPI) auth · validate enqueue · return fast job queue WORKER — every agent runs here THEATER I · the live turn — run_agent_dual MIRA ATLAS MASON IRIS PULSE end-of-turn planners: MASON + IRIS THEATER II · background research chain SAGE IRIS LENS STYLO THEATER III · concierge (between turns) SCOUT STERLING JUNO HERALD ON DEMAND · PDF export HUE Postgres workspace · brief · offers Pusher channels client · talent (push only) REST dequeue events pushed to the browser
in-process / sync queued / async agent or service datastore user-visible push
The browser does short REST; web enqueues a job and returns; the worker runs the agents; state persists to Postgres and surfaces to the browser via Pusher. The theaters are entered by different jobs: agent_turn (Theater I, which spawns Theater II), the concierge jobs (Theater III), and brief_export (HUE).

03How agents talk to each other

The most important thing to understand about this system is its communication. No agent makes a network call to another agent. Instead there are exactly these channels — each with different timing and durability. When you read an agent card below, the "Invoked by", "Produces", and "Triggers" rows always map onto one of these.

ChannelWho → whoTimingWhat actually moves
Planner lanes dual_lane → MASON · IRIS end-of-turn, un-gated Two lanes run un-gated, in parallel with ATLAS's tool loop, with nothing gating on them: MASON emits one BriefPlan, IRIS one IdentityPlan. IRIS may first run one optional web_search round before composing (planner_search.gather_web_search — the same OpenAI-hosted search ATLAS/SAGE use, via tools.web_search_raw), filling the dossier from real web facts instead of leaving a gap or waiting on a lagged SAGE note; best-effort, degrades to a no-op, and runs after the fast reply so it never blocks the user. (MASON's round was removed 2026-07-02 — it cost 14–20 s on every turn with ~zero usable results and pushed the brief past the live-update window; SAGE's research briefing feeds MASON instead.) Each then validates and persists itself (persist_architect_plan / persist_identity_plan). A post-complete drain (≤32s) keeps the stream open — and a final queue sweep forwards whatever a planner emitted in its last instants — so a late architect-applied / identity-applied diff still paints this turn. (PULSE is no longer a lane — it runs once on brief-approve via the classify_intent job.)
Fire-and-forget ATLAS → SAGE (→ chain) async, detached deep_buyer_research does asyncio.create_task(...) and returns "STARTED" immediately. The research chain outlives the turn and persists silently.
Tagged notes ATLAS · MASON · IRIS → MIRA verbatim, +1 turn ONE channel for the whole team: each lane's note is stored as a source-tagged event message (metadata.source = atlas|mason|iris). MIRA reads them next turn as [ATLAS NOTE] / [MASON NOTE] / [IRIS NOTE] lines; the client chat filters event messages out. No compression (the old compressor is gone).
Shared state MASON → MIRA · ATLAS · UI · gate same turn MASON writes per-item checklist verdicts + a note_for_mira to checklist_state. That single record drives the enable_search gate, the right-rail UI, and MIRA's next question. Both planners also attach a one-line client-facing rationale to their architect-applied / identity-applied events — the transient "Brief" / "Profile" chips under MIRA's reply.
Activity board SAGE/STYLO → MIRA async Background workers write started/done rows to agent_activity; MIRA reads the board so she can say "I'm still researching" on the next turn.
In-process chain SAGE→IRIS→LENS→STYLO awaited, ordered One background coroutine (_run_deep_research_background) awaits each stage in turn, threading block-ids forward so LENS attaches images to the blocks IRIS just minted.
Job queue Web → Worker durable, retried Every theater is entered by an enqueued job (agent_turn, concierge_discover, concierge, seller_turn, concierge_answer_ingest, brief_export). Idempotent & keyed so a retry can't double-act.
Pusher Worker → Browser push only Client channel (agent stream, concierge.progress, personalization-applied, brief.export.ready) and talent channel (chat + thread nudges). Oversized payloads are slimmed with resync:true → the client re-reads GET /api/workspace.

04Agent index

All fifteen at a glance. "Tools" counts function-tools the agent itself may call; agents with a forced/structured output (MASON, IRIS, STYLO, JUNO, PULSE, PORTIA) emit one schema'd object instead — though IRIS may first take one optional web_search round before composing (MASON's round was removed 2026-07-02 for latency). Each card links to that agent's full system prompt in the repo.

CodenameTheaterRoleToolsFile
MIRA live turn The voice. The only agent the user hears; acks, asks one question, paraphrases ATLAS's note. none agent/conversational.py
ATLAS live turn The thinker. Tool-using operator — research, search prefs, facts, brief signal. Silent; hands a tagged note to MIRA. 35, stage-gated agent/runner.py
MASON live turn The brief composer. Designs the brief as a printable doc at end-of-turn; converges in place. BriefPlan JSON agent/architect_subagent.py
IRIS live turn + research The identity planner — MASON's twin for "About You". End-of-turn IdentityPlan; add/update only, never delete; also reconciles research findings. 1 opt. web_search · IdentityPlan JSON agent/identity_editor_subagent.py
PULSE live turn The buyer-intent router. One binary high/low read of the client's hiring intent each turn; a "low" verdict hard-routes them to plain search instead of the concierge. none · IntentVerdict JSON agent/pulse_subagent.py
SAGE research Deep client research. Fire-and-forget loop that builds the client file from real data + the web. 13 (SEO·web·browser) agent/research_subagent.py
GAUGE estimate Budget & timeline estimator. Fire-and-forget loop that prices the work from the live Fiverr talent market + open-web rates. 5 (market·web·finish) agent/estimation_subagent.py
LENS research Vision image placement. Per harvested image: skip / reference / body. Skips liberally. 1 · place_images forced agent/image_placement_subagent.py
STYLO research Personalization. One write that re-skins the workspace (accent, tone, density, chips, locale). none · StyloPayload JSON agent/personalization_subagent.py
SCOUT concierge Talent scout. An agentic hunt over the gateway — one tool per search engine — building a ≥20 pool, then ranking the top 12. 5 search engines + finish scout/loop.py · prompts.py · tools.py
STERLING concierge Negotiator. Deterministic state machine + LLM brain: writes outreach, judges replies, flags offers. 4 function tools · required concierge/runner.py · sterling.py
JUNO concierge Proposal judge. Rules PASS/FAIL on each talent proposal — the price (hers since 2026-09-23, the code budget gate is deleted), scope credibility + an inside-deadline estimate's believability; only lateness is still a code gate. Verdict is BINARY: PASS → fit_score 100, FAIL → 0 (a validity gate, not a grade). Finalist order is JUNO's verdict in bands of 10, then SCOUT shortlist position, with seller standing demoted to a tiebreak — and that key is the SAME in the badge (rank_offers) and in the client's cards (_project_finalists), which it had not been until 2026-09-14. none · forced JSON concierge/tools.py
HERALD concierge Activity-log narrator. Turns each concierge action into the client's one personalized, anonymity-safe dashboard line. none · emits text concierge/narration.py
PORTIA concierge Finalist-pitch writer. At the reveal, writes each finalist's human "why they fit" paragraph + real skill chips — one call per finalist. none · forced JSON concierge/portia.py
HUE on demand PDF template selector. Picks the export template (editorial/modern/classic); the export is template-only + light-mode. none · emits a template choice agent/brief_design_subagent.py

Deterministic, non-LLM glue (not agents, but load-bearing — see §09): the intent classifier intent.py, the checklist/gate renderer brief_checklist.py, the dual-lane orchestrator + tagged-notes handoff dual_lane.py, the shared conversation projector conversation.py, the SSRF-guarded image harvester site_image.py, the concierge pure-function tools, and the thin LiveSellerAdapter. The lineage's voice-filler and partner-note compressor are gone; ATLAS's edit_identity tool is gone too (identity belongs to the IRIS lane); STYLO is not a per-turn lane (it runs in the research chain).

05Theater I — the live turn, lane by lane

One user message enters run_agent_dual, which spawns five asyncio lanes that share a single event queue, merged into one ordered stream. The fast lane (MIRA) is unlocked so she replies instantly; the slow lane (ATLAS) holds a per-project lock; the three planner lanes — MASON (brief), IRIS (identity), and PULSE (buyer intent) — run un-gated in parallel with ATLAS's tools and each emits one structured object at end-of-turn (MASON a BriefPlan, IRIS an IdentityPlan, PULSE a high/low IntentVerdict). Nothing downstream waits on the three; a post-complete drain keeps the stream open long enough for their writes to land. ATLAS spawns SAGE within his turn; the notes ATLAS, MASON, and IRIS leave become MIRA's tagged reading next turn. Each lane runs in its own agent <name> OTel span, so a turn's trace breaks out per agent.

ONE USER TURN — five parallel lanes over a shared event queue, merged into one stream User message dual_lane.run_agent_dual MIRA fast · unlocked · no tools streams the reply the user sees ATLAS slow · locked · 35 tools silent — runs the tool loop MASON brief planner · waits for ATLAS composes BriefPlan at end-of-turn IRIS identity planner · waits for ATLAS composes IdentityPlan at end-of-turn PULSE intent router · un-gated high/low verdict → routes the CTA classify_intent → ATLAS prompt checklist_state gate · UI · MIRA SAGE → chain Theater II (async) Postgres brief · identity · checklist Pusher client channel spawns (fire-and-forget) BriefPlan + checklist IdentityPlan client_intent (on change) tagged notes atlas·mason·iris verbatim · +1 turn assistant deltas (user-visible) architect-applied · identity-applied (+ "Brief"/"Profile" rationale chips)
user-visible async spawn tagged notes (+1 turn) MASON persists BriefPlan IRIS persists IdentityPlan PULSE persists IntentVerdict
MIRA and ATLAS each read their own workspace snapshot and run in parallel; ATLAS spawns SAGE (async) within his turn. The three planner lanes run un-gated and each persists itself at end-of-turn — MASON a BriefPlan (+ the checklist that gates search), IRIS an IdentityPlan over "About You", and PULSE a high/low IntentVerdict (it patches client_intent only when the verdict changes, routing a casual client to plain search instead of the concierge). MASON's and IRIS's tagged notes ([ATLAS NOTE] / [MASON NOTE] / [IRIS NOTE]) are what MIRA reads next turn; the 1-turn lag is bridged by that shared checklist + the notes, not by intra-turn messaging. PULSE leaves no note — its verdict drives the frontend's routing, not MIRA.

Two things enter this turn that are not a typed message (both landed 2026-08-03).

A hand edit to the brief runs an ORDINARY turn. A client editing a brief block directly used to fire a MIRA-only acknowledgment — she said "got it, budget's now $8,000" and no other lane moved, so ATLAS never re-read the plan against the change. brief_edit_notice now builds an AgentTurnJob from the notice and re-enters agent_turn.run_full_turn, inheriting the whole path (project resolution, the blocked/takeover gates, (turn_id, seq) events, Pusher publish + offload, latency SLOs, the research resync) instead of re-implementing it; run_brief_edit_ack is deleted and brief_edit.py keeps only the wording. Visibility is the one thing that must NOT become ordinary, so inbound_kind ("user" | "event") now says how a turn's inbound row is persisted: a brief edit rides "event", the raw before/after lands as a hidden system note filtered from the client's chat and rendered to every lane as [SYSTEM EVENT], and the abuse gate is skipped, since judging a notice we wrote ourselves could only produce a false flag. run_agent_dual also grew planner_lanes (default True, unchanged for a typed message): brief_edit_notice is the one caller passing False, which never creates the MASON/IRIS tasks at all, because those two exist to rewrite the brief spine and the About You — precisely the surface the client had just written by hand — so the turn would otherwise talk over the edit that triggered it. A hand edit is therefore MIRA + ATLAS: she acknowledges it in one beat, he re-reads the plan and may use tools, and the client's own words stay exactly as they left them.

The pre-agent triage can outrank the stage. The classifier that already reads every client message for frustration, human-asks and chat misuse now answers a fifth question: is this person a CLIENT here to buy, or a TALENT here to sell? The bar is deliberately high in one direction: describing what your own business sells, hiring for your own agency, and doing part of the work yourself all still read as a client, because the expensive failure is telling a real buyer they are on the wrong side. An attention flag and a Slack alert follow either branch below (the arrival rate is worth watching), and the admin console carries a durable "Talent, not a client" tag read over the whole flag history rather than the open set.

The same triage call also reads the WORK SHAPE, free. Since 2026-08-16 the classifier answers one more question on the turn it was already running: is this a one-off project, an ongoing engagement, or a hybrid of both? Riding the existing per-turn call means the shape costs no extra LLM spend. It latches UPGRADES ONLY (a project may become ongoing, never the reverse) and is written onto project metadata BEFORE the dual-lane run, so every lane sees it on the SAME turn it was detected; it then hydrates onto the workspace state as a default-off context block for MIRA, ATLAS and MASON. Only PULSE may settle it back down, at brief approval.

And since 2026-09-09 the same call also reads WHERE the work happens. work_mode is on_site when the talent has to physically be at a place for the work to happen at all (shoot at a venue, work from the client's premises, a role that must be in a named city), hybrid_site when a defined part of it is, and empty otherwise. A preference about where a remote worker sits ("US-based", "same timezone as us") is deliberately NOT this read. It latches by the same upgrades-only rule, with one extra rung: hybrid_site never overwrites on_site. What it buys is a gate rather than a hint, because brief_checklist.is_on_site then forces the location row before any search can run, and SCOUT hard-locks the committed countries for the whole hunt. PULSE settles it at approval and is the only caller that may walk it back to remote.

A seller_intent verdict ASKS rather than asserts (reworked 2026-08-04). Acting on the classifier's read directly put that expensive failure one false positive away, so the FIRST hit in a project short-circuits the turn before the agent runs: agent_turn emits build_seller_check_widget, a fixed two-option decision card ("are you here to hire someone for your business, or are you offering your own services?"), and returns. A wrong verdict costs a genuine buyer one tap — hiring writes seller_check_answered, stands the gate down, and carries on into an ordinary turn with nothing recorded. selling is the only answer that stops anything, and what it stops is the CONVERSATION, not the person: SELLER_CONFIRMED_NOTICE is persisted (fixed copy, not MIRA's, because it is the last message the chat will carry), then the project is closed (projects.closed_at, migration 0117) — the composer disappears, a POST into the turn route 409s, and the worker drops anything still in flight. Deliberately NOT a block or an account state: their other projects keep working, a new one starts clean, and the closed chat stays listed and readable. The card is the product's ONE unskippable widget (allowSkip: False strips the skip link, the escape row, the collapse chevron, the dismiss and the Escape binding; EntryStage derives composerLocked from it), and because a client flag is not enforcement the worker turns away any turn that is not one of the two typed option ids while the card is open (SELLER_CHECK_REMINDER, guarded on inbound_kind == "user" so our own system turns walk past it). The answer rides the turn as an option ID (CreateTurnRequest.widget_option_id), never as the label's words: a lock may not hinge on copy an editor is free to reword. That leaves exactly one route to the old SELLER_INTENT_NOTE — an URGENT private note persisted before run_agent_dual, so it lands in the turn-start snapshot every lane reads — and it is the person who answered "I want to hire talent" and kept pitching, where MIRA holds the line in prose because showing the same card twice reads as nobody having read the answer.

MIRA conversational / voice · the fast lane

The only voice the user hears. Acknowledges intent, asks what she needs to ask, and paraphrases whatever the team did last turn — in one or two spoken sentences.

File agent/conversational.py Prompt full prompt ↗ Model voice_conversational_model · reasoning_effort:none Tools none
Invoked by
The fast lane of run_agent_dual, unlocked, in parallel with ATLAS. Two entry points: stream_conversational_reply (voice, token deltas) and generate_conversational_reply (text routes, may return one widget).
Consumes
Her own workspace read; a slim persona; the live stage + search_enabled gate; the industry tag; the brief checklist verdicts; the activity board; and chat history — where the team's notes arrive as source-tagged [ATLAS NOTE] / [MASON NOTE] / [IRIS NOTE] lines (hidden from the client) and her own past questions appear as assistant turns. Her prompt knows the full team and what each tag means.
Produces
Text deltas the orchestrator wraps as assistant-start/assistant-delta, or one ConversationalReply (text + tappable suggestion chips, or a single widget). Persisted early so a fast follow-up turn can't make her re-ask.
Triggers
Nothing — she is a terminal leaf. She only consumes the team's tagged notes and MASON's checklist.
Hard rules
  • She never agrees to “N seats”, and never splits the client’s pool for them (2026-09-24). On a same-role multi-hire she had said “we’ll treat this as three identical seats” and done the arithmetic herself (“$9,000 across three, so $3,000 each”). Both sound like containment and are the opposite: this search fills ONE seat at a time and the client can pick more than one finalist. She says the count back as what they WANT (“you need three”), never as what the project will DO, and asks for the per-talent budget instead of deciding it.
  • She reads the ABSENCE of a desk line as carefully as the line itself, and she never lists a time. (2026-09-13.) A THE DESK, LIVE block is the only source of truth on where a project stands with the Fiverr desk. With NO such block — the usual case — this client is not routed, there is no call card, there are no times, and none are coming however they ask and even if they insist she sent some before: she may promise nothing, not even that times will appear. Routed and unbooked, she says in one line that a fresh card is landing and lets ATLAS raise it. Booked, the call is pinned above the chat, so she does not restate the time, re-invite them to book, or ask whether they did. The earlier rule said “if a card was offered”, which she had no way to check.
  • A ready brief PROMOTES the optional questions, it does not offer an exit (2026-08-20, replaces the 2026-08-09 two-path offer). The turn the brief landed ready ended on an either/or — “you can review and approve it now, or answer a few optional questions, which would you like?” — which read as helpful and behaved badly: handed an explicit exit the instant the REQUIRED rows closed, clients took it, skipped the optional rows entirely, and the thinner brief matched them to worse talent. The optional rows are the ones that sharpen who comes back, so a one-click way past them worked against the thing the client came for. The choice question is gone: MIRA says the brief is ready in a line, then takes the still-open optional row that would move the match most, says in a few plain words why it is worth answering, and asks THAT. Nothing is blocked by dropping the offer because nothing was blocked to begin with — the approve control is already on screen and ATLAS raises the confirmation the moment the client says the word. Two guards keep the promotion from becoming its own nagging, since this deliberately loosens the July anti-nag work: “it’s ready” is a strictly ONE-TIME beat, and the client’s own word ends the questions on the spot — a “go ahead”, “send it” or a shrug at an optional means she takes it and asks nothing more, no “are you sure?”, no last-chance pitch. Four prompt sites move together (the FLOW map, the SEARCH-READINESS GATE, the live _stage_block overlay, and the BRIEF CHECKLIST block, which used to say an optional row must never be the next question once the required ones are done). Self-review caught the new rule colliding with the NEVER DO clause that a reply closing the conversation is a bug: two rules flatly opposed resolve per turn by whichever the model weights higher, and the likely loser is the new one, whose failure mode is a MANUFACTURED trailing question — precisely the friction this removed. The clause already had the hatch in its own first line (“question OR open-ended invitation”), so the gate closes on a warm invitation and the NEVER DO entry gains the matching carve-out.
  • The budget question is asked in the CLIENT’s unit, including per year (2026-08-19). The widget’s frequency toggle was a hard-coded one-time/monthly pair, so a weekly-lessons project was asked for a MONTHLY budget and a tester asking “hourly or monthly?” could only answer monthly. config.period (a show-the-toggle boolean) became config.rateUnits: the ordered list of buttons MIRA offers, closed to the brief’s own rate_unit vocabulary (hour / day / week / month / deliverable / year) plus one_time, with config.rateNoun naming what a deliverable rate is per (“Per lesson”). The FIRST unit starts selected, which also fixes a monthly ask opening on One-time. The answer rides back in the posted turn in the shapes the ATLAS overlay already parses (“$40 per lesson”, “$50/hour”, “$1,500/month”), so no commit-path change was needed. year joined the vocabulary end to end the same day (a preprod tester asked for it) and the yearly figure is kept AS a yearly figure the whole way down the COMMIT path: nothing divides it into months on its way into the brief, because a monthly number the client never said is a number nobody said. The ops-facing surfaces DO derive a per-month figure from it (one twelfth, 2026-08-22, §10) — but that is a comparison the platform computes and labels as its own, never a number attributed to the client.
  • An ongoing engagement is asked for in hours, never in employment terms (2026-08-16). When the work shape is ongoing or hybrid MIRA asks the engagement set — the rate and the unit it is paid in, the workload, the start, how long, how many people — in hours-and-days language, never framing it as employment, and she says her own guesses OUT LOUD rather than silently assuming them. In the six categories where a stated figure may be the talent’s fee or may include spend that merely passes through them (ads, print, travel and three more), the price beat carries the ONE splitting question, or states the assumption plainly so the client can correct it. At the finalists she offers a small paid trial sized from the stated workload — an offer, never a gate.
  • Never claim a tool action. "I updated your brief," "here are five talents" — that's ATLAS's work, not hers.
  • An URGENT note outranks the stage she is in (added 2026-08-03). The pre-agent triage pass runs before she writes anything, and when it spots a conversation she must not answer the ordinary way it hands her a note persisted ahead of run_agent_dual, so it is in the turn-start snapshot rather than arriving a turn late like the team's tagged notes. Today that fires on seller_intent — a freelancer who walked into the client side trying to sell rather than hire — and she names the mix-up kindly in her own voice, that same turn, and points them at the seller side of Fiverr instead of collecting a brief from someone who was never a client. Reworked 2026-08-04: she no longer sees this on the FIRST hit. That one now short-circuits the turn before she runs and shows a fixed, unskippable two-option card (hire / offer my services); a real buyer's tap costs one turn and records nothing, and "I'm offering my services" closes that project with fixed copy while leaving the person and their other projects untouched. The note is what remains for the person who answered "I want to hire talent" and kept pitching anyway.
  • How many questions she asks is HER call (changed 2026-08-05). The hard one-question-per-turn cap ("never stack two asks") was written for a relaxed client and was wrong for a rushed one: someone typing "what else do you need?" or "why so many questions?" still got the open gaps fed to them one per round. One focused question stays the default; frustration, a hurry, an explicit "what is missing?", or a client answering several things at once is permission to name every still-open REQUIRED piece in one message. Guardrails kept: the anti-auto-advance scope check still ends its turn alone, the brief-edit and date-clash carve-outs are untouched, and a multi-ask message goes out as PLAIN TEXT, because one control captures one answer and a card under a three-part ask silently drops two of them. Still 1–2 short sentences, no markdown or lists (it is read aloud), with exactly one small exception for the batch.
  • The transcript is truth. Never re-ask something already answered, even if the (lagging) checklist or note shows it missing.
  • Paraphrase notes, never recite — and surface genuinely new findings, or an honest heads-up when a note begins FAILED:.
  • A name on file is not shared history (fixed 2026-07-31, Monday 3127188107) — the returning-client opening is now gated on the PAST PROJECTS block alone, not on identity_block or past_block. seed_buyer_identity_from_dossier fills the identity row from the client's Fiverr account at sign-in, before any project exists, so the old gate greeted a brand-new client "good to see you back" on their first ever message. Past projects are also the only evidence that makes the opening's own script ("how did the logo land?") answerable. The identity block still renders — she greets by name and never re-asks it — and now carries the explicit rule that account details are not history; step 1 of THE OPENING in the persona prompt states the same gate.

ATLAS tools / thinking · the slow lane

The tool-using operator. Runs research, manages search preferences, captures facts, shapes the brief signal — silently. His output is never spoken; it becomes his tagged note for MIRA.

File agent/runner.py + prompts/{base,overlays,context}.py Prompt full prompt ↗ Model agent_model Tools 37, stage-gated
Invoked by
The slow lane of run_agent_dual, under a per-project lock. (A legacy single-lane mode exists where ATLAS also speaks and asks; production always runs dual-lane.)
Consumes
His own workspace read; the deterministic classify_intent result; and a 4-part composed prompt — BASE_SYSTEM + per-stage overlay + a live context block (brief snapshot with block-ids, identity, search prefs, checklist) + TURN_RULES, plus a dual-mode overlay. For a signed-in buyer a BUYER PROFILE block (ADR-0005) is appended, rendered from the buyer's own Fiverr-account dossier prefetched at login: identity / company / market facts to personalize with, plus a never-display internal-signals zone (predicted LTV, spend, price affinity) the agent may reason with but must never surface — empty for guests. (The same dossier also seeds the visible "About You" profile at login, filling only fields the buyer hasn't set themselves.) Chat history and attached images are appended.
Produces
In dual-mode he suppresses his own assistant bubble and emits a voice_thinking_text sentinel → stored verbatim as his tagged note to MIRA. Streams tool and mid-turn block-*/stage-change events as tools run.
Triggers →
SAGE via deep_buyer_research (fire-and-forget) — but only with the client's own confirmed website URL (added 2026-07-03): the tool refuses to run URL-less, so a name-only client first gets a web-searched candidate homepage that MIRA asks them to confirm (a "we have no website" answer skips the deep pass for good). He authors neither the brief (MASON's lane) nor the identity file (IRIS's lane — his edit_identity tool was removed), and enable_search only flips a gate; the user clicks Run.
Hard rules
  • Two numbers are not a range (2026-09-24). “$100 for the bug and $100 for the feature” had committed budget_min=100 with budget_max=200, so the brief showed a “$100-$200” budget, half of what the client said. The test is what the second number is FOR: another PART of the work is summed and committed as the total alone; the SAME work at a higher price (“$500 to $1,000 for the site”) is a range, and only then is budget_min set.
  • The call card is HIS to raise, on the client's ask, and the tool books nothing. (2026-09-13.) The specialist-call card had reached a client exactly once per project: the desk's takeover dropped it in, the emission one-shotted, and every id was project-derived, so “send me the times again” and “I need to reschedule” had nowhere to go. offer_schedule_call now lands a fresh card as its own message right after MIRA's reply, over one versioned emission path, with the client's ask as the reason. It refuses with no DESK line (no desk line, no calendar), refuses unasked, refuses for a client asking for human TALENT (that is the hire), and refuses while an unanswered card they have not rejected is still in front of them. A booked call does NOT block it, because wanting a different time is exactly the ask. Only the client's pick on the card books anything.
  • A rate can repeat per YEAR, and what the desks judge is the whole commitment (2026-08-25). cadence_unit held week and month and no year, so “100 hours a year” was said by the client, understood by MIRA, written into the brief by MASON, and then stored nowhere: both cadence columns stayed NULL, the money read as nothing at all, and no desk heard about a $10,000 client. Same gap rate_unit had one field over, and the column is plain text with no CHECK, so it cost no migration. The year cadence travels the tool vocabulary, the phase tool, the checklist predicates on both sides and the four hand-mirrored TS unions; the two ops tiers now judge commitment_usd_cents rather than a monthly slice of it (see §10).
  • A committed RATE is committed MONEY, and the desks now see it (2026-08-22). Since 0156 an ongoing or hybrid project commits a rate and leaves budget_min/max_cents NULL, so every surface judging “what is this client worth” off the fixed bounds read a retainer as no budget: a $6,000/month client tripped neither the $1k big-client alert nor the $6k auto-handoff tier while a $1,000 one-off tripped both. set_search_preference now fires maybe_flag_big_client on EVERY rate half (rate_amount_cents, rate_unit, cadence_count, cadence_unit, budget_hourly_cents) rather than only on the two fixed bounds, because a rate has no comparable value until its unit and workload have landed, and it passes the shared BudgetView instead of raw columns. Re-checking per half is safe: the helper re-reads the whole committed picture, and dedupe lives inside it.
  • The engagement structure is COMMITTED, not inferred (2026-08-16). set_search_preference gained the rate-in-unit family (rate + rate_unit of hour/day/week/month/deliverable, per-deliverable noun, per-person), cadence, start date, duration, headcount, the fee-vs-pass-through split, and the hard location / timezone fields, all whole-USD coerced against a closed vocabulary. The greedy any-spend-ceiling and sum-the-pieces rules are carved out so a RATE never silently becomes a fake TOTAL, and same-role headcount containment now commits the count instead of discarding it.
  • Never invent talents, numbers, or ratings — every fact traces to a tool output or the snapshot.
  • Never trigger search, but he may raise the client's own approve popup — the gate still opens itself once MASON marks every required checklist row done, and ATLAS never unlocks it and never judges readiness. What he gained on 2026-08-05 is approve_brief: on the client's explicit go-ahead in chat ("yes, go ahead", "find me people") he emits a one-shot brief-approve event. Narrowed 2026-08-09 (#816): that event now only OPENS the confirmation dialog, and the client's press on the dialog's own approve button is what runs continueFromBrief — so PULSE's intent verdict, the guest sign-in pitch and the low-intent branch stay defined in one place, and the flow's one irreversible step is never taken without a human press. The tool returns {approved: false, prompted: true} and its summary tells MIRA to ask them to confirm, rather than reporting a handoff under way. The same change removed the dialog's AUTOMATIC trigger: readiness still earns the persistent CTA and its callout, both automatic as before, but nothing except this tool raises the modal, so it can no longer land on a client who is still reading their brief. It is gated on the same condition that RENDERS the control, so an agent call reaches nowhere a client click could not, and its own gate re-reads live state and refuses with a reason rather than lying. Never claim "I ran the search."
  • Don't speak and don't ask (he has no ask tool): state what's needed and why in ≤150 words, lead with what he did — MIRA phrases the question.
  • Tools are stage-scoped. Each call is re-checked against the current stage's allow-list (entry · brief · search · results + the concierge-flow stages, concierge_dashboard through fiverr_handoff) and rejected if out of stage.

MASON brief structure · the composition lane (was ARCHITECT)

Composes the entire brief as a printable document — section spine, ordering, typed blocks, plus the per-item checklist — in one structured call. It replaced ATLAS as the brief-structure author.

File agent/architect_subagent.py Prompt full prompt ↗ Model agent_model · reasoning_effort:none Output forced BriefPlan JSON
Invoked by
One of the three end-of-turn planner lanes (architect_producer) — un-gated: it starts at turn-start in parallel with ATLAS's tool loop and reads the turn-START workspace (the old slow_tools_done_event gate is gone), so nothing downstream waits on it and a post-complete drain (≤32s) lets a late architect-applied diff still paint this turn. Also reused by the concierge's concierge_answer_ingest job, folding each answered clarification into the brief.
Consumes
One signal payload: recent conversation, industry, client name, identity + brief summaries, existing sections & editable blocks (so it can converge), the do-not-touch user-edited block-ids, the client-locked section ids (2026-08-28 — sections the client has taken over, off-limits entirely, flagged inline on each section as well), and the checklist items.
Produces
A BriefPlan: section ops, block add/update/delete ops, one checklist verdict per item, a note_for_mira (delivered on the tagged-notes channel), a client-facing rationale (the "Brief" chip), confidence. The runner validates it, then persists. ai_image ops materialize via a server-side gpt-image-2 call. Emits architect-status/architect-applied.
Triggers
Converges in place (not a chain). Its checklist drives the enable_search gate, the right-rail UI, and MIRA's next question — persisted every turn, even when the plan is empty.
Hard rules
  • A rate that prices a UNIT of work has to say how many (2026-08-24). “$100/hour” is not a budget: at 40 hours a week it is a $16,000-a-month client and at 2 hours a week an $800 one, and the platform deliberately refuses to invent the difference (see §10), so the money read as NOTHING and the silence was invisible — a large hourly client cleared neither the big-client alert tier nor the specialist handoff tier and no surface said why. A conditional REQUIRED checklist row now appears whenever the committed rate prices a unit (hour / day / deliverable); a period rate never grows it, because a monthly retainer already IS the month, and neither does a fixed total. It has two exits, in order: the committed cadence closes it automatically with no MASON round-trip (the columns landing is the very thing the row exists for), and a MASON verdict closes it for the client who genuinely cannot say — “it varies” is a real answer, and a required row nobody can close has already stranded a finished brief here for five days. The waiver is honest about its price: the money stays unreadable and both desks stay quiet. The SPA rail mirrors the row from the same committed fields, since that file decides allRequiredDone.
  • An ongoing brief closes on a START date, not a delivery date (2026-08-16). known_constraints render the rate, cadence, start, duration, headcount and third-party spend in the client’s own words; the legacy hourly column finally reaches the Budget surface; hire mode keys off the work shape deterministically; and for an ongoing shape the Timeline row closes on a start date rather than a delivery date that has no answer.
  • Rule zero — never fabricate. No stated facts → empty plans, confidence ≈ 0.
  • Converge, don't accumulate. Prefer update/noop over add; re-stating an existing idea as a new block is the #1 failure.
  • Mirror the client's voice, but make one editing pass (changed 2026-07-31, Monday 3127188079) — rule 13 used to say "mirror their cadence" while rule zero forbade adding anything, so nothing in the chain ever tidied rough client prose and a deliberately badly-phrased ask landed in the Overview almost verbatim (the persist-layer normalizers are cosmetic only). Rule 13 now asks for one pass over grammar, fragments, filler and noun piles while the client's vocabulary, tools and formality stay. It is editing, not rewriting: polish may never add a requirement, number, tool or qualifier, and a quote block is never touched.
  • Never touch user-edited blocks; ops only target real block UUIDs (also enforced in validation).
  • A hand edit closes the whole SECTION, not just the block (2026-08-28, Monday 3192213154). Editing a block already made THAT block the client’s, but nothing protected the section around it, so MASON stayed free to rewrite the neighbouring block, delete a third, append a fourth and re-title the card — a client who corrected one line came back to a section that had moved underneath their correction, which reads as being overruled. The unit of ownership people actually feel is the SECTION, so any hand edit inside one (a block added, edited or deleted, or the section renamed or rewritten) stamps brief_sections.ai_locked_at (migration 0178) and MASON may then not add, update, delete, rename or re-span anything in it. A columnSpan-only PATCH deliberately does not stamp: width is layout, not content. It is stricter than a user block, where adding alongside is fine; here even the space around their words is theirs. Enforced at the persist seam (repo/architect) rather than only in the plan validator, because the validator judges the workspace as it looked when the turn began while a client can lock mid-turn, and update/delete are gated by where a block ACTUALLY lives rather than by the section the plan names, so pairing a real block id with the wrong section cannot walk through. generate_brief_image asks the same question BEFORE it pays for an image. MASON is told both ways: a locked section carries client_locked: true inline in existing_sections (the reminder at the moment of choosing) and its ids are listed in locked_section_ids (the stated rule, and the only form an add can be refused by, since an add names no block id to withhold). Its own prior writes in there move to reference_blocks: still VISIBLE and full-text, because reconciling the rest of the brief around a locked section needs its content, but with the block_id gone, which is the one handle an op can take. Visibility never means editability. Only the CLIENT can hand a section back (POST .../sections/{id}/unlock, the “Yours” pill on the card); no agent lifts its own lock, since without that exit one early correction in “Budget and timeline” would freeze that section for the life of the project and a budget agreed later in conversation could never reach the brief. When the conversation makes a locked section genuinely wrong, MASON says so in note_for_mira and the client decides. Project export/import carries the flag, so a debug clone keeps the client’s sections closed.
  • Silent. Never speaks to the user; never touches identity, search, or tasks. On failure, falls back to an industry-keyed section skeleton.

IRIS identity planner · the brief lane's twin for "About You"

The "About You" identity planner: reads the full conversation at end-of-turn and emits one structured IdentityPlan — add genuinely-new blocks, update stale ones in place, never delete. The one agent that works in all three theaters.

File agent/identity_editor_subagent.py Prompt full prompt ↗ Model agent_model Output forced IdentityPlan JSON
Invoked by
Three callers. (1) The identity planner lane of run_agent_dual at end-of-turn — MASON's twin, applied by repo.persist_identity_plan; emits identity-applied (+ the client-facing "Profile" chip rationale), and the frontend refetches the dossier. (2) The research chain, reconciling SAGE's findings as candidate_blocks. (3) The concierge's answer-ingest job, folding a client's clarification answer into the dossier (alongside MASON).
Consumes
The full-fidelity identity snapshot (every block's whole payload + section + id), the removed_by_client facts the client deliberately deleted (since 2026-08-18), plus the full conversation (the shared, uncapped conversation.py projector MASON also reads) or the researched batch.
Produces
One IdentityPlan of add/update ops + a note_for_mira (tagged-notes channel). The runner validates and persists it. In the research path it mints the block-ids that LENS later attaches images to; if IRIS can't run, a direct-persist fallback keeps the findings.
Output
Add / update ops only — the plan has no delete op; dedup-aware by design.
Hard rules
  • No duplicates — the single most important job; scan the whole file before adding.
  • Never delete or merge (no delete op exists); an empty plan is the correct answer when everything's captured.
  • A fact the CLIENT deleted stays deleted — IRIS cannot re-add it (2026-08-18). The client's delete was a plain DELETE that left no record of the INTENT behind it, and IRIS reconciles only against the LIVE dossier while SAGE's research briefing is re-attached every turn — so a fact the client had just removed read as genuinely NEW and came back on the next chat turn. The delete now writes a TOMBSTONE in the same statement (buyer_identity_block_tombstones, migration 0163, additive and N-1 safe), and persist_identity_plan DROPS any add that fuzzy-matches one — not as an add, not folded into an update — with the drop logged and metered (record_identity_tombstone_drop, root §1.12). The deep-research blind-append fallback runs the same check, so the gate holds on the path that bypasses the planner entirely. The prompt rule (“REMOVED BY CLIENT IS A HARD NO”) and the removed_by_client signal are best-effort STEERING on top of that gate, never the guarantee: the client saw the fact on file and removed it, and their call outranks any evidence that still supports it. When the only candidate facts this turn are removed ones, an EMPTY plan is the correct plan. The new table is classified in account_scrub (payload scrubbed, the fact of the deletion kept).
  • File into the right section — professional / business / voice / notes.
  • Paraphrase, never paste raw user words.

PULSE buyer-intent classifier · the router

The lead-qualifier. In one shot it reads two independent axes. The PRIMARY one is binary: is this a SERIOUS hire we should hand to the concierge, or a CASUAL / exploratory client we should send to the plain self-serve search? It is deliberately CONSERVATIVE — only a clearly casual client is "low"; everything else stays "high" so a real buyer is never denied the concierge. A "low" verdict hard-routes the client to search even when they're signed in, and skips the concierge sign-in popup for guests. Alongside it PULSE reads the ENGAGEMENT KIND — a one-off project (a scoped deliverable) vs hiring (an ongoing relationship: retainer, recurring work, a long-term collaborator) — which, when "hiring", makes STERLING lead talent outreach with the longer-term opportunity. Since 2026-09-09 the same call settles a THIRD axis that routes nothing and gates everything: work_mode (remote | on_site | hybrid_site) plus on_site_place in the client's own words. When the work happens at a place, the client's countries stop being a preference SCOUT may relax and become a filter the system re-asserts every round; on-site work reaching approval with no country committed is raised as a gap rather than guessed at. The read was proven on 500 real conversations through PULSE's own call path before it was promoted: 11 on-site, 0 hybrid, 489 remote, self-agreement 498/500, and zero false alarms on "US-based" / "prefer local" phrasing.

File agent/pulse_subagent.py Prompt explainer ↗ · source ↗ Model agent_model · effort:none Output forced IntentVerdict JSON (high | low · project | hiring · remote | on_site | hybrid_site)
Invoked by
The classify_intent worker job — run ONCE when the client presses Approve on the brief-ready banner (NOT a per-turn lane). The frontend POSTs /api/projects/{id}/classify-intent; the handler (worker/handlers/classify_intent.py) runs PULSE off the web cycle. Run-once + replay-safe: it no-ops once client_intent is set, and skips entirely when there's no brief (intent is meaningless before there's something to hire for).
Consumes
A turn-start workspace read plus this turn's transcript: the live conversation as native chat turns ending on the client's latest message (the primary signal), plus a "workspace signal" block — the brief, SAGE's research report, the identity summary, and the client's cross-project history (repo.get_buyer_history_summary: past projects, searches, concierge runs, offers, hand-offs). Since 2026-07-07 it also receives the signed-in client's Fiverr account profile, FULL zone (ADR-0005) — including the internal signals (LTV, spend, price affinity, strategic flag) as lead-scoring input, under a raise-only ratchet: strong signals can upgrade a borderline client toward the concierge, but weak or missing signals are never grounds for "low". Guests (no user_id) are still classified, just without the history or profile enrichment. Since 2026-07-30 the signal also carries committed_budget — the FX-converted USD figure ATLAS committed to the project (repo.read_committed_budget) — as its own labelled field, so the $500 floor is judged from one authoritative number instead of a currency figure re-read out of the chat.
Produces
One IntentVerdict — intent (high|low), confidence, an internal reason line (logs + the admin dashboard, never shown to the client), and an orthogonal engagement_type (project|hiring, defaulting to project). It patches client_intent to project metadata and emits a bodyless workspace-snapshot resync so the verdict is ready before the client clicks the continue FAB — a low verdict hard-routes the FAB to plain search (and skips the guest upsell); high/unknown keeps the concierge. The engagement_type is persisted to projects.metadata_json and, when hiring, frozen into the concierge brief_snapshot (via enrich_brief_with_constraints) so STERLING injects a HIRING-INTENT block and pitches the ongoing relationship rather than just this one piece of work.
Triggers →
The frontend's brief-continue CTA (brief-continue.ts) reads the verdict as WorkspaceState.client_intent: a low client is HARD-ROUTED to the plain self-serve search regardless of sign-in state (and a guest is not shown the sign-in popup), while null/high — the safe default — keeps the concierge on offer. PULSE leaves no note for MIRA.
Hard rules
  • A client on the regional lane is ruled LOW by rule, with no model call at all (2026-09-03). A project whose stored model tier is the regional one returns low with source=region ahead of the $500 budget floor and ahead of any LLM call, so the human concierge is never offered on that lane; the staleness rule that can re-open a floor verdict deliberately leaves this one alone, and the outcome is metered as recruiter.pulse.budget_floor{outcome=region_low}.
  • PULSE’s prompt is a committed FILE, not a constant (2026-09-02). The live call builds from tagging/schema/finalize_brief_tagger.json, so the offline finalize_brief_tagger run asks the SAME question the product asks rather than a paraphrase of it. Byte-identical, pinned per block by tests/test_tagger_schema.py. See the doctrine rule in §10.
  • A budget clearing the $500 floor routes to the concierge on its own (2026-08-26). Routing is intent=="high" OR “budget clears the floor”, so a low reached on an UNWORKABLE timeline no longer sends a $4,000–$7,500 client to self-serve. Only a low whose budget is confidently UNDER the floor still stands.
  • A RATE is not a project total, and a floor verdict is not forever (2026-08-20). PULSE’s $500 concierge floor short-circuits before any model call, reading search_preferences.budget_max_cents as the project TOTAL, and nothing stopped an hourly or per-month rate landing in that field — so “$15/hour” was judged as a $15 project and hard-routed to plain search with no LLM call and no appeal, while the prompt’s own “a recurring figure is not a sub-$500 project total” rule never got consulted, because the floor fires FIRST. Four fixes, all on typed fields, no prose parsing anywhere: the floor and its veto defer whenever a per-unit rate is committed (rate_amount_cents/rate_unit, or the legacy budget_hourly_cents), since fixed bounds stay None for hourly engagements and a ceiling sitting beside a rate is upstream corruption rather than a total; the rate reaches the model as its OWN committed_rate signal (amount, unit, deliverable noun, per-person, cadence), kept out of committed_budget because the prompt names that field the floor’s authority, so deferring is useful rather than blind; PulseReport carries source (“floor” | “model”) so floor_verdict_is_stale can re-judge a “high” over a now-sub-floor ceiling, or a floor-produced “low” whose ceiling has moved, on re-approve — a JUDGED “low” is never re-opened by a budget move, and neither is a verdict with no source on record; and classify_intent binds turn_id into the log context, because the trace sink reads it from there and the debug dashboard filters turn_id IS NOT NULL, so PULSE’s rows were landing orphaned and its reason — which survives nowhere else — was unreachable in the one UI built to show it. And PULSE is now the SOLE reader of the $500 line: the start-search must-pick popup had been re-deriving its own tier from read_committed_budget, which reads only the fixed bounds, so since the rate widget shipped a €1,000/month retainer ($1,160.80, PULSE high) was routed silently into the concierge and never asked. start_choice_required now takes the persisted client_intent and nothing else, which also drops a budget query and leaves the route with no repo dependency at all. Two readings of the same money was the defect; there is one reader.
  • PULSE is the ONLY agent allowed to settle the work shape downward (2026-08-16). The shape latches upgrades-only turn by turn (see the triage note in Theater I); PULSE gains the settling authority AT BRIEF APPROVAL and is the sole caller permitted to downgrade it. engagement_type stays DERIVED from the shape, so every existing consumer keeps working unchanged. Its concierge floor reads the FEE alone and ignores pass-through spend, because a large ad budget is not a large engagement.
  • Default to 'high'; only 'low' on clear, converging evidence — wrongly denying a real buyer the concierge is a far worse miss than offering a casual one a path they'll ignore.
  • One missing fact is not low intent — early briefs are legitimately incomplete; look for a pattern of disengagement, not a single gap.
  • The $500 floor is a code gate, symmetric, judged from the committed budget — a committed ceiling under BUDGET_FLOOR_USD ($500) short-circuits to low before the model runs; at or above it the floor SENDS THEM TO THE CONCIERGE ON ITS OWN. Routing is an OR since 2026-08-26: intent="high" or a budget clearing the floor, so every model low above the floor is upgraded at the seam, whatever its reason, with the model’s own line kept inside reason for the trace and the dashboard, and the disagreement metered (recruiter.pulse.budget_floor{outcome=above_floor_high}). Until then only a low that CLAIMED the budget sat under the floor (is_above_500USD=False — the model’s factual budget read, renamed 2026-08-09 from the misfiring introspective budget_floor_applied flag) was vetoed, so a $4,000–$7,500 store redesign ruled low at 0.96 confidence on its 12-day TIMELINE walked straight through with its own budget read correctly true, and a real buyer was hard-routed to self-serve. The prompt is deliberately unchanged: it still returns the full casual/unworkable judgement and the seam decides routing. Added 2026-07-30 after PULSE re-read "ILS 3,000" (committed $978) as sub-$500 at 0.99 confidence and hard-routed a real buyer to self-serve — the prompt already carried the guard and lost; the seam is the guarantee.
  • Judge hiring intent, not brief polish — a short brief from someone clearly trying to hire is 'high'.
  • Read-only, fail-safe — never talks to the client or touches the brief/dossier; on any model failure the prior verdict simply stands.

06Theater II — the background research chain

When ATLAS calls deep_buyer_research — which it may do only once the client's own website URL is confirmed on file (the tool refuses otherwise; added 2026-07-03) — it spawns a detached task and returns instantly. That task — _run_deep_research_background — runs a strict, ordered chain inside the worker. It emits no Pusher events; results land in Postgres and surface on the user's next workspace read. The order matters: IRIS must persist blocks before LENS so the vision pass has real block-ids to attach to.

_run_deep_research_background — awaited, ordered; persists silently (no push) ATLAS create_task SAGE deep research loop 12 tools · ≤24 iters SEO · web · browser IRIS dedup reconcile candidate_blocks mints block-ids site_image harvest (SSRF-safe) og · icon · screenshot LENS vision placement place_images (forced) skip · ref · body STYLO re-skin workspace StyloPayload accent · tone · locale Postgres (silent) agent_activity board started/done → MIRA next turn persist
spawn (fire-and-forget) awaited chain step deterministic helper Postgres (no push)
SAGE returns validated blocks; IRIS reconciles them into "About You" and mints block-ids; site_image harvests the client's own-site imagery (behind an SSRF guard); LENS decides placement against those block-ids; STYLO re-skins. Every stage after SAGE is best-effort and swallows its own errors so the worker loop never crashes. SAGE and STYLO log to the activity board MIRA reads next turn.

SAGE deep client research · fire-and-forget

A second, self-contained tool loop that investigates the client step-by-step — DataForSEO, the web, and a headless browser — and returns a validated "client file" of identity blocks plus a personalization payload.

File agent/research_subagent.py Prompt full prompt ↗ Model research_subagent_model → agent_model Tools 12 · ≤24 iters
Invoked by
ATLAS's deep_buyer_research tool → asyncio.create_task → returns "STARTED IN BACKGROUND." Fire-and-forget; idempotent per (user, project) with an in-flight guard.
Consumes
The client handle, website URL, industry, optional focus hints, and injected callables for web search, fetch, and the four browser tools.
Produces
A report of validated blocks (text/finding/kpi/quote/bullet/callout, each tagged to an About-You section) + a personalization dict + a required site_status verdict on the confirmed website + tool traces + cost. It persists nothing itself; it raises on budget-exceeded or malformed finish (no partial success).
Triggers →
Returns to the orchestrator, which runs IRIS → harvest → LENS → STYLO.
Hard rules
  • The confirmed website is ground truth (added 2026-07-03) — SAGE studies only the client's own site and discards same-named lookalike pages a bare-name web search turns up, so a namesake business never becomes the client's dossier.
  • Every number traces to a tool result — no estimating.
  • Every finding needs a real source_url seen this run; invented URLs fail validation.
  • Never researches, names, or references competitors (added 2026-07-08) — SAGE builds the client's own dossier, not a competitive-landscape analysis; a rival's site never enters the report or the reference panel. The dataforseo_local_competitors tool and the Competitors workflow step were removed.
  • Forbidden from emitting image URLs (it hallucinates them) — imagery is captured deterministically instead.
  • site_status answers "is there a business here", not "did I get in" (added 2026-07-30, split 2026-07-31 — Monday 3121486463, 3128941813). finish requires one of live · blocked · parked · unreachable. parked (a for-sale / placeholder domain) and unreachable (a dead address: DNS or connection failure) are the only two in NO_BUSINESS_SITE_STATUSES — they drop every panel reference, starve the image harvester, and send MIRA the "confirm the right address" script. blocked is a real business site that refused the read (bot wall, captcha, 403/429, login or region gate): turned away by a server that answered is blocked, never unreachable. It keeps its references, since they are ordinary off-site research about a real company, and MIRA is told the address is correct and not to re-ask it. Both seams test membership of that set — never != "live". The early-stop rule is scoped to the two no-business statuses, so a blocked site still runs the off-site web_search steps; before the split, one tester's fiverr.com hit Fiverr's own bot protection, was labelled unreachable, stopped the loop after a single finding, and shipped an About section of two generic sentences with no sources and no screenshot.
  • Our own key dying DEGRADES the run, it no longer kills the chain (added 2026-08-03 — Monday 3133975429). DataForSEO is one of SAGE's four sources and the only one needing an account of ours (web search, fetch and the browser need no key), and it used to be fatal: missing credentials raised at client construction, a rejected account (401/403) re-raised out of the tool dispatch, and _run_deep_research_background caught both and simply returned — costing the client the research report, About You, the harvested imagery, the STYLO re-skin and the references panel over an expired key, silently (correctly silent, since we must never blame their URL for our billing). Now the dataforseo_* tools come off the menu, SEO_DISABLED_NOTICE tells the model to skip workflow Steps A–C, an in-flight SEO call gets a disabled=true envelope (established prompt vocabulary), and SAGE finishes on the other three sources so the whole downstream chain still runs. A rejected account also stops the next SEO call at the door rather than spending four more round trips on a key that cannot heal mid-run. Because nothing fails loudly any more the degrade is metered — record_research_degraded → counter recruiter.research.degraded {reason=no_seo_creds|seo_auth_rejected} (root doctrine 1.12) — since a dead key means every client from that moment on quietly gets a thinner file, and the rate is the only signal that says so. Separately, the in-flight guard against duplicate deep research moved off a process-local task dict onto a Valkey lock (research_inflight:{user}:{norm_url}, 300s TTL, fails OPEN): in prod the turn fans out over many autoscaled workers, each saw an empty local dict, and one real client URL took 4 full duplicate runs in 90 seconds — 4× the research cost plus the browser-lease saturation that then dropped the read of the client's own site.

GAUGE budget & timeline estimator · fire-and-forget

A small, self-contained tool loop that prices a project from TWO sources. When the scope is clear but the client won't give a budget and/or a timeline, ATLAS hands it the job; GAUGE reads the LIVE Fiverr talent market (the real on-platform gig-package prices — its primary anchor, the same pool the matcher will search) and cross-checks the open web for the wider going rate, then proposes a grounded range and/or delivery window for the client to confirm.

File agent/estimation_subagent.py Prompt full prompt ↗ Model estimation_subagent_model → agent_model Tools 5 · ≤8 iters
Invoked by
ATLAS's request_budget_estimate tool → asyncio.create_task → returns immediately. Fire-and-forget; runs ONCE per project (an in-flight guard + an existing live proposal both make a re-trigger a no-op).
Consumes
A scope brief assembled off the live workspace: the project type, a compact dump of the brief sections, the client's own recent chat, the team's research on their business, the currency, and which dimension(s) are missing. Plus injected market callables — search_talent_market (live Fiverr pool summary) and list_talent_market (full talent cards) over the gateway — and web_search / fetch_url.
Produces
An EstimationReport (budget range and/or delivery days + a one-line market basis + a friendly rationale). The runner writes it as an internal BudgetTimelineProposal (chat-only, NOT the brief/prefs), logs GAUGE on the activity board, and leaves MIRA a [GAUGE NOTE] to relay. It persists no brief/pref itself.
Triggers →
MIRA relays the recommendation; on the client's explicit yes/number, ATLAS commits it via set_search_preference (no auto-accept).
Hard rules
  • The rate vocabulary is authored ONCE, after a yearly rate killed a live project (2026-08-20). A client said “$250,000/year” on preprod and the project died: no reply for ten minutes, a reply that had forgotten the whole conversation, then a message never answered at all while the client typed “comeon!!!”. year had shipped as a rate unit the day before across seventeen files, but not into SearchPreferences.rate_unit, whose Literal still named five. The column is plain text with no CHECK, so the WRITE landed and every later READ of that project’s preferences raised ValidationError — forever: both dual lanes died, MIRA’s tools threw, the turn never finished, and the same broken job retried every ten minutes, which is also why she re-asked location and availability three minutes after being told. The row was a tombstone the project could not recover from. The defect was two copies of one vocabulary drifting, so there is now one: models.RateUnit is authored once, RATE_UNITS derives from it, the commit tool and GAUGE’s finish validator read that instead of each carrying their own tuple, and the frontend mirror takes Exclude<RateUnit, "one_time"> rather than spelling the union out. The two stuck preprod rows heal on deploy: nothing about them was ever invalid except the reader. Red first — the repro fails with the exact production error.
  • An ongoing engagement is priced in its OWN unit, never as a lump total (2026-08-16, estimator rule 7). The estimator gained rate + rate_unit (month / hour / year since 2026-08-19, for clients who budget annually) + cadence_note (“8 videos/month”), runs with the work shape and the stated workload in context, and carries the rate through both its proposal and its fallback note. budget_min / budget_max then cover only the one-time part of the work.
  • Ground the number in BOTH sources — one search_talent_market (live Fiverr prices) AND one web_search (open-web rates) before finish; reconcile the two, never invented.
  • Price the scope in front of it — a logo is not a full identity; match the deliverable, not the maximal category.
  • One realistic band, not the whole "$5–$50,000" market; honest currency; only the missing dimension(s).
  • Writes nothing to the brief/prefs — it's a proposal; commit is explicit-acceptance only.

LENS vision image placement

A single-shot vision call that looks at every harvested client image alongside the persisted blocks and decides, per image: skip, attach as a reference thumbnail, or insert as a body image — and to which block.

File agent/image_placement_subagent.py Prompt full prompt ↗ Model agent_model (vision · detail:low) Tools 1 · forced
Invoked by
The image pipeline inside the research chain, after IRIS persists blocks so it can target real block-ids. Synchronous within the background task; degrades to "no placements" on failure.
Consumes
The persisted blocks + a catalog of harvested images (id, kind, source page, alt, downscaled thumbnail). A "matches finding" hint is computed when an image's host matches a finding's source.
Produces
Validated placement decisions → reference ops (append a thumbnail to a block) or body ops (insert an ai_image block). Only images it actually places get stored as assets — skipped ones never hit the Files panel.
Tools
place_images, forced. It picks an integer image-id from the catalog, never a URL; unknown ids/blocks are dropped.
Hard rules
  • Skip liberally — default is skip; icons, glyphs, and tiny files are chrome.
  • The homepage screenshot is the best body candidate — the client's front door.
  • One image per idea; de-dup near-duplicates.
  • Only real ids — reference/body must target a listed block; no inventing.

STYLO workspace personalization · the chain's last step

One structured-JSON call that re-skins the workspace — accent colors, tone, density, corner radius, suggestion chips, copy overrides, currency/locale — from whatever signals exist. Touches nothing in the brief, search, or identity.

File agent/personalization_subagent.py Prompt full prompt ↗ Model agent_model · 12s Output forced StyloPayload
Invoked by
The research chain, as its final, durable step (it used to be a flaky per-turn lane; that lane has since been removed). Best-effort.
Consumes
The freshly-written workspace: industry, client name, identity summary, brief summary, and existing personalization (only honored when ≥3 non-default fields are set).
Produces
A personalization payload the caller persists. The worker path emits no event — the re-skin surfaces on the next workspace sync. A deterministic industry-keyed palette table backs it when the model returns weak output.
Triggers
Terminal — the last step of the chain. Nothing fires after it.
Hard rules
  • Never leave the profile neutral when there's any signal; derive ≥3 chips + tone/density.
  • Agent names & hero motif are off-limits — excluded from the schema entirely.
  • Don't undo user or prior confirmations; currency + locale must match the market.
  • Empty fields + confidence 0 is the correct answer for no-signal input.

07Theater III — the concierge, between turns

After the client settles on what they want, the concierge takes over between turns and talks to talents on their behalf. It is event-driven, not polled: every reactive step is a job enqueued from the web layer (a talent reply, a client answer, an approval). Two invariants dominate — a client-approval gate before anything reaches a talent, and an anonymity wall that keeps the client's view anonymized until the reveal. Two newer wires: a run auto-finalizes the moment its last thread resolves with ≥1 offer (finalize_if_complete — no manual reveal click), and every answered client clarification is also folded back into the brief + About You by MASON + IRIS (concierge_answer_ingest) — the concierge feeds Theater I's planners.

BETWEEN TURNS — discovery is optional; the approval gate is not Client shortlist / approve SCOUT Fiverr seed + LLM pick /discover only · default OFF runner.start drafts outreach threads: queued (unsent) approval gate approve_and_send STERLING deterministic state machine + LLM brain runner.py start·send·tick finalize (deterministic) sterling.py writes msg · judges reply (LLM) Talents talent SPA + DB inbox JUNO offer scorer · + rank_offers (det.) auto-finalize → reveal fires when the last thread lands (≥1 offer) · top-3 ranked · then client picks /discover /start (skips SCOUT) opening msgs reply → seller_turn job anonymity wall — client sees anonymized view until reveal →
concierge action approval gate reveal to client anonymity wall
Two entry points converge on runner.start, which only drafts. Nothing reaches a talent until the client approves. STERLING's runner is deterministic (start/send/tick/finalize); its brain (sterling.py) is the LLM that writes each message and judges each reply. Talent replies arrive as seller_turn jobs; offers are scored by a separate LLM call and ranked deterministically; finalize reveals the top three.

SCOUT concierge talent-scout

The talent scout. Since 2026-08-05/06 discovery is a DETERMINISTIC pipeline the agent STEERS, not an agentic tool-loop it drives: SCOUT emits a FILTERS object (queries, the client-facing role phrase, a whole-dollar budget band, a delivery ceiling, plus any genuinely hard country / language / must-have), the pipeline runs the fixed facet queries across the gig-ful engines, filters candidate packages to a ±15% budget band and grades each priced tier, and SCOUT then JUDGES its own pool and returns finish or refilter — bounded to 5 rounds since 2026-08-18 (raised from 3 when a round’s query list was briefly capped at ONE; the cap came off again on 2026-08-19 and the rounds stayed), re-runs merged into the pool. Since 2026-08-12 an EMPTY pool reaches JUDGE too, instead of short-circuiting the loop into “reached iteration cap”: the summary carries the retrieved / filter-dropped / out-of-band counts, and the prompt orders the widening — budget band first, then an unstated preference, then at most one stated constraint. Since 2026-08-13 the FILTER and JUDGE moves ride ONE append-only thread under a single composed system prompt (SEARCH_SYS = FILTERS_SYS + JUDGE_SYS under a framing preamble), so the judge can see what it already set and already searched instead of re-deriving both from the brief every round; and no_fit is out of the verdict enum entirely, because a judge reading a summary of an already-graded pool is not positioned to declare a no-match — if nothing fits, every candidate grades OFF and the shortlist empties by arithmetic. Grading deliberately stays OUTSIDE that thread, with its own byte-identical cached prefix. The switch bought reproducibility: the cloud now ranks identically to the local bench on the same brief. Since 2026-09-09 there is exactly one thing SCOUT may not steer. When the brief says the work happens at a place (work_mode), the client's countries are re-asserted as required_countries after the opening filter parse and after EVERY judge merge, so omission, replacement and an explicit relax all fail to move them; the agent is told at the top of the brief and again in every round's results that the location is enforced by the system and is not a lever, and the grader is told that being elsewhere is OFF rather than a weaker fit. A seller whose country we could not resolve is KEPT and graded unknown, counted, never silently dropped. It is the one deliberate exception to “no hard gate decides fit”: a place the work needs is a capability, not a taste. SCOUT is still the ranker, additively (fit tier + seller quality + VIP), and that order is what the client sees on both surfaces.

File scout/discovery.py · loop.py · prompts.py · quality_config.py Prompt full prompt ↗ Model agent_model for FILTERS / JUDGE — scout_grade_model (gpt-5.6-luna) for GRADE; that model and scout_max_queries_per_round (0 = unbounded) are runtime settings, both overridable per rerun so two arms can run concurrently off one deploy Tools none — 5 structured calls: ASK · FILTERS · QUERY · GRADE (per package) · JUDGE
Invoked by
Only the no-shortlist path: POST /api/concierge/discover → ConciergeDiscoverJob → worker → discover_and_start (and the self-serve search funnel). The explicit-shortlist /start path skips SCOUT entirely.
Consumes
ONE assembled text, read identically by the query generator and the package grader: the authoritative brief, a budget line built from the structured model-extracted numbers (never a regex over the brief — that silently dropped sub-$100 bands, so $5,000 tiers ranked first), the client↔MIRA conversation, the client's style/brand references including MIRA's vision descriptions of uploaded reference images (a similarity boost at grade time, never a filter), and the buyer dossier as INTERNAL calibration carrying its own NO-ECHO header. On a resume, the question SCOUT asked and the client's answer are folded into the same text. Hard location / timezone / out-of-office / availability prefs were deliberately NOT in it, because deterministic gates enforced them on the pool afterwards; since 2026-08-17 those gates are deleted and the stated constraints reach the grader as EVIDENCE inside this same text, weighed against the real gig content rather than screening anyone out behind its back. Per graded TIER the model also reads an evidence line, and since 2026-08-14 that line finally carries the capability evidence the prompt had always claimed it did: the gig's own description and tags, the seller's skills, portfolio titles, certifications, notable clients, and languages with their proficiency rather than bare codes. Measured on live gateway data, 60 of 97 packages returned for one query listed only boilerplate (“revisions: unlimited”, “commercial use”) and 5 listed nothing at all — so for two thirds of a pool the grader was judging capability from a line that said nothing, and it correctly refused to award EXACT to anyone, capping every pick in three production replays at CORE_FIT. GRADE_SYS had promised that full profile all along, which is worse than promising nothing: a model told the profile is complete reads an absence as a NEGATIVE rather than as missing data. Only CAPABILITY evidence was added — photo, member-since year and online status stay out, because they cannot change whether this talent can do the job and this half of the message is UNCACHED, paid for on every graded tier.
Produces
A pool of best-package-per-seller candidates plus the ranked shortlist (top 12) — each pick {seller, brief-relative rationale, UI signal tags, fit_category, misses_rank, chosen package, quality grade} — ordered by the additive compose_total (fit points + seller quality + VIP), with fiverr_search_query for the back-to-Fiverr link. A degraded pool is a first-class outcome, distinct from an empty result — and since 2026-08-13 it is reported for ANY failed leg, not only one that emptied the pool, since a run that lost a leg to a timeout and still pooled 33 sellers looks exactly like a complete answer. Also persisted, since 2026-08-13: the attrition ladder on scout_searches.funnel — seen → no_gig_data → level_dropped → filter_dropped → unaffordable → over_grade_cap → grade_skipped → graded → pooled → ranked — so an empty hunt says WHY instead of reading as an empty marketplace.
Triggers →
STERLING's runner.start, which drafts outreach (and sends nothing yet).
Hard rules
  • A preference rule that selects clients on a protected class stops filtering the day it is read (2026-09-24). seller_rules, the read the grader uses, drops such a line through recruiter/protected_class.py, so rows already in the table stop acting with no migration. And the brief_summary_extraction A/B ships DARK: one arm per HUNT (a property of the client, resolved once), riding every leg as search_experiments. Its arms differ by whether the raw brief is SENT to the search team’s engine, not by their variant number, because on dev the two variants returned byte-identical sellers while adding the brief changed the list every time.
  • A person requirement is answered by the PERSON, never by one gig title (2026-09-08). graced_pen55 took rank 1 and Mira’s Choice on an audiobook brief whose defining requirement was a MALE voice, off a gig titled “record american male voice over” — while the sibling gig in the same catalogue read “record female voice over” and the seller’s display name was “Grace Studio” (Monday 3187370806). Both facts were in the record; neither reached the grader, because the seller line carried only the handle and the tier carried only its own title — and a title is what a seller CALLS the work, not who does it. The seller line now carries the display name as an aka and a capped line of the seller’s OTHER gig titles (titles only: the point is the shape of the catalogue, not its prices), and GRADE_SYS says what that evidence means — a catalogue selling both voices has confirmed it sells both, which is unknown at best, failed where the name or one-liner contradicts the brief, and never met on the title alone. Same shape as the day before, on the other axis: the record held the answer and the card did not carry it. The tagger schema’s byte-for-byte copy of GRADE_SYS was regenerated in the same commit this time, rather than by a revert twenty minutes later.
  • The grade card says WHERE a seller is, not only which country (2026-09-07). On 3 Sep SCOUT shortlisted a Rhode Island photographer for an on-site role in Fox Island, Washington, inside a 10-mile commute, and nothing had failed: the country filter held US through all three rounds, and the grader marked the on-site condition unknown because the seller line carried US and not one other thing about the place. A country is not a place. The seller’s IANA zone was on the candidate record the whole time, resolved by the gateway for 113 of 113 US sellers probed that day, while GRADE_SYS had promised “country, timezone” on the evidence line since 14 Aug — the same shape as the 14 Aug bug, a prompt describing evidence the builder does not supply. The line now prints the zone beside the country, or “tz unknown” so silence reads as silence; the on-site block says what a zone MEANS (a zone that cannot contain the brief’s location is being elsewhere, OFF, exactly like a stated city out of reach; a zone that does contain it, or none at all, stays unknown and asks the client to confirm, since a matching zone is not proof of being near); and the gig description on the card grows from 420 to 2,000 characters, because the half being cut is where sellers say where they shoot and whether they travel. It shipped, was reverted twenty minutes later because tagging/schema/package_tagger.json carries a byte-for-byte copy of both GRADE_SYS and FAST_GRADE_SYS and the schema test caught the drift, and re-landed with both copies regenerated in the same commit.
  • A package whose talent set a standing boundary is graded ALONE, and the boundary may be about PRICE (2026-09-04). The fence marks the rule as belonging to one package, but a model reading a batch of ten does not have to agree: in prod on 3 Sep one call over three sellers' packages returned pref_conflict=true and OFF on the two sellers who had set NO rule, both notes quoting the third's “$125 per video”, and false with a STRONG tier on the talent who had, who was then ranked 1 and messaged about a $9-per-video brief. The safety clamp cannot repair that, because it only refuses to FORCE an OFF and those OFFs were the model's own tier, so _grade_chunks now isolates every rule-carrying package into its own call (returning each call's OFFSET, because chunks are no longer uniform and the old arithmetic would misattribute every later grade). The fence also names PRICE now: a per-deliverable minimum has no numeric field to live in, so it arrives as prose, and a grader told to read it only as a statement about the KIND of work correctly answers that video editing is work a video editor takes. A conflict claimed on a talent with no rule is still refused, and now metered.
  • Both grading lanes report under ONE tag, because they share ONE prompt (2026-09-02). The fast lane’s batch grader reads SCOUT’s own grading block from tagging/schema/package_tagger.json verbatim: same prompt, same closed vocabulary, different envelope. The lane rides in the subject reference rather than becoming a second tagger, which is what makes “which lane grades better” one chart instead of a research project.
  • The shortlist RE-RANKS every round instead of freezing at the cap, and the round that withheld everything now accounts for itself (2026-08-31). A client who stated a requirement mid-conversation could never see the talent it found. The rail is capped at 18 and the merge was strictly append-only, so once the cap was full from earlier rounds the loop over the previous list returned before it ever looked at the new one, and every later round was discarded WHOLE. On the dev project “ZyG Platform Website Build” the client asked for Spanish speakers: capture, filters and search all did the right thing (seller_languages=["es"], Spanish query legs, a pool of 18 who all declare Spanish) and every one of them was thrown away here, leaving the client reading a list where 14 of 18 spoke no Spanish. Measured again the same day on a second, unrelated project, also on a language requirement. Every round now re-ranks the whole set: this round’s ranked picks, grouped by the fit tier it gave each of them, and within a tier the talents the client has already SEEN keep their previous relative order with this round’s finds behind them; anyone the client has seen whom the round did not rank at all is retained BELOW that, so a thin or degraded round cannot collapse an 18-card list into a 3-card one. Stability is now a property of the RANKING rather than a freeze: sorting on (this round’s tier, previous position) makes an unchanged round byte-identical to the one before it and moves a talent only when the tier they were given actually changed, it needs nothing remembered about the previous grade, and it absorbs the grader’s between-round wobble for free, because seller quality only ever ordered WITHIN a tier and previous position now does that job. Also fixed: why this was invisible. append_only_merge’s full-from-history case returned ABOVE its own log line, so the one round that withheld EVERYTHING reported nothing while every partial case logged its withheld count. It accounts for itself now, and talents dropped from a client’s list are METERED (§1.12, recruiter.shortlist.dropped) rather than only logged. append_only_merge is kept whole and still tested: the switch in lane.py is only a switch if the other side still works.
  • The rail refreshes when ATLAS DECIDES it should, not when state changes, and a project with no definition of its own declines outright (2026-08-31). The rail used to refresh whenever a turn happened to call any of seven state-writing tools, on the theory that committing state IS the instruction to go look. That theory has a hole: a tool call records a FACT, and a fact is not the same as a change in who we should be hiring. It broke in the way that costs money. A returning client’s industry, site, dossier and research report carry into every new project by design, so on the project “Non-Mobile Logo Design” whose client had said only “I’m not sure where to start”, ATLAS read the PREVIOUS project’s research report, committed set_project_industry("Fantasy strategy game studio") plus a game-production checklist, and seventeen seconds later SCOUT was running a full discovery round hunting fantasy-game talent on an empty brief and empty preferences. Both of ATLAS’s calls were correct; neither meant “go look for people”. So the trigger is a JUDGMENT now: the new tool refresh_talent_preview is called at the end of a turn that materially moved WHO we are looking for, with a one-line reason, it writes no state (the call IS the signal), and nothing else refreshes the rail. The precondition is asserted at _maybe_trigger_live_search itself rather than only where the stream loop collects it, so what may start a search is legible at the boundary the SPEND crosses. Underneath it sits a deterministic floor rather than a fifth prompt guard on the same stochastic loop: the rail declines unless the project has a definition of its OWN, a brief section with real content or a committed search preference. Industry alone does not count, because that is precisely the field the previous project fills for free; a client-requested fill is exempt.
  • A brief that hires a PERSON distills to the ROLE, and the discipline check reaches the grader again (2026-08-31). A 12-month, $1,300/month front-end developer hire came back with a Shopify store at #2 and a WordPress landing page at #3. Two fixes, at two layers, and the first is where the bug actually lived: core_deliverable had collapsed the hire into their first task (“build a modern frontend web app with registration and waitlist flows”), and that is the single field every package is ranked against, so STRONG for a WordPress studio was the RIGHT answer to the question it was asked. _INTENT_SYS_PROMPT now says that a brief naming a rate, a start date or a multi-month engagement yields the ROLE with the tasks as must-have capabilities, and carries a worked example. Nothing downstream changed. Separately, the deterministic DISCIPLINE nudge is live again: softened from a cap to an advisory line on 2026-07-28, it lived on the agentic tool-loop’s evidence builder, and the scout_v2 port retired that loop on 2026-08-05 without carrying it across, so for three weeks the pipeline had NEITHER a cap nor a nudge while the function sat in the tree, fully tested and unreachable. Its vocabulary is fixed with it: it used to read the queries SCOUT had just run, which are written from the deliverables and drift with them, so the off-discipline search terms were vouching for the off-discipline results they returned. It now reads the brief’s stated craft and nothing else, stays a refutable NUDGE, and its rate is METERED (recruiter.scout.discipline_nudge), because the actual bug was a check that had silently stopped firing and a ratio pinned at zero is the only thing that could have said so.
  • The grade fan-out narrates itself, so a working search stops reading as a stuck one (2026-08-31). A tester filed a talent search as stuck, 26 seconds before the shortlist landed. Nothing was broken: the run took 2m24s and showed no sign of life for two solid minutes of it, which from the client’s seat is the same thing. The hunt was bracketed by one query_start before it and one dedupe after it, with everything expensive in between: on the measured run, grading ran 112 seconds across ~250 model calls and published nothing at all, so the waiting checklist parked on one row whose label carries the POOL size, a number that lands once and never moves. discover now publishes “reviewed X of Y” as each chunk of grades comes home, on the caller’s own progress callback. Three things keep it honest rather than decorative: it is throttled to one event a second, because ~25 chunks finish in a burst and the client needs a moving number rather than 25 messages in three seconds; the denominator is the round’s FIRST PASS only, since the second pass grades surviving sellers’ remaining tiers and nobody knows how many that is until the first pass answers, so a total covering both would grow mid-round and walk the bar BACKWARDS; and a deadline skip shrinks the denominator and forces a final emit, because a bar parked at 80% for the rest of a search is the same bug in miniature. The client folds it monotonically within a round; a worker that does not send the phase falls back to the pool size exactly as before.
  • A talent’s stated start date is screened against the client’s delivery horizon, and free-before-the-deadline is KEPT (2026-08-26). discovery.unavailable_in_time reads seller_preferences.available_from the way hard_filters.drop_out_of_office reads Fiverr’s vacation window; a brief with no deadline is metered no_deadline, never silently passed. Availability never arrives as grader-facing rule text, which would grade the talent OFF everything (§10). Dormant while the feature is off.
  • The rail’s list only grows, a run that lost the row stops paying, and a brand-new seller reaches the pool (2026-08-25). The lane went back to the real SCOUT at one round per refresh, strictly append-only (see §10). Two waste fixes ride with it: an 8s debounce at the trigger, since the rail fires on every committed definition change and a client typing a sentence bought a search per keystroke; and a still_ours check immediately before the grade fan-out, after 39 refresh triggers in 48h turned out to be 26 superseded and 16 completed, with search_lite_superseded_at_claim at ZERO the whole time because the early exit only ever caught a REDELIVERED older job, never ordinary churn. Separately, the retrieval floor stopped deleting NEW_SELLER: it kept only levels the quality config scores above zero, which is a proxy for evidence and a bad one at this edge — new seller means new to FIVERR, not new to the craft, and on a specialist brief the practitioner of a newer tool is usually a newer seller. One Spline brief lost 8 of its 19 qualifying sellers to it, including the best match in the supply, and the client was told nobody could do the job. KEPT IS NOT PROMOTED: the config still scores NEW_SELLER at 0, so a proven seller on the same grade still leads, pinned by a test that puts a rookie and a TOP RATED seller on identical grades and asserts the rookie is present AND below. NO_LEVEL stays excluded on purpose — that is a seller who reached level one and dropped back, which is evidence about them rather than an absence of it.
  • ADJACENT at the top of a list buys ONE core search, judged rather than listed, and the grader half of that change was reverted the same afternoon (2026-08-24). A logo brief wanting a US-based, Russian-speaking, non-agency designer available in Miami one day a week retrieved 116 sellers, lost 85 to the country filter and 7 to language, had NINE gradable, and shipped “create a thank you card design” and “remove the background from an image” among its finalists. Fiverr has thousands of logo designers; the hunt never asked for them. ADJACENT reaching the ranked slice is almost never a thin market — it is a pool the PERSON filters chose and then ranked by whoever survived, and re-wording a query cannot fix it because a re-worded query searches the same filtered population. A runtime turn in discovery.py (not JUDGE_SYS: it carries that round’s own counts) now answers a finish that still carries ADJACENT with NOT YET and asks for one CORE SEARCH on the LITERAL trade, with the non-core filters off for that round. It fires only at the FINISH (the first version printed every round and dropped one clean hunt from 18/18 country-compliant to 6/18), at most ONCE per hunt (a second finish with ADJACENT still there IS the supply answer), and it names no strip list — the agent judges what is core for THIS brief and is told that WHERE THE WORK IS PERFORMED IS CORE, so the 2026-08-23 rule above holds rather than being contradicted. It is purely ADDITIVE: the standing filter set is snapshotted before the ask and restored the round after, so what the core search FINDS accumulates while what it LOOSENED does not outlive its round. The bar never moves — everyone it returns is graded as before and ranks below every compliant match. Refusals are metered (recruiter.scout.core_search_refused), because a thin MARKET and a narrow SEARCH look identical in a shortlist and only the second is ours. Reverted the same day: the sibling change telling the GRADER that a contradicted stated country is a failed important-to-have earning high miss_severity. Nothing asked for it (the ask was retrieval, aimed at the judge) and it inverted the list it touched, since the CORE_FIT cap plus a heavy severity means CRAFT can never win: on a skincare brief four of six best picks, including the seller whose gig text IS the brief, were replaced by ten US sellers offering Webflow apps, ebooks, Amazon listing optimisation and TikTok coaching, against a ["US"] requirement inferred from prose on a brief for an ISRAELI brand.
  • The live rail runs its OWN engine, and being in the experiment IS the switch (2026-08-24). The strip of cards that refreshes beside the conversation is not the hunt: the hunt is 50 to 70 model calls and ~90 seconds, and the rail has to answer between two sentences. Its default engine is now deterministic with NO model in it (one marketplace query against the committed brief plus the catalogue’s resolved category), with exactly two model calls on top, both in matching/fast_prompts.py rather than scout/prompts.py — the scout module is fingerprinted into every stored search thread, so one string added there would retire every in-flight thread for every user, control included. Call one grades all eighteen at once under GRADE_SYS imported VERBATIM (a fork would drift) plus a short envelope saying only what differs, and it also writes the client-facing line above the deck from EXPLAIN_SYS, so that line costs no third call. Call two reads the concession ladder out of the client’s own words instead of the fixed RELAX_LADDER order, returning complete filter sets that can only widen. Four failures found on its first live day and fixed: the fast engine shipped behind a setting defaulted to graded, so the whole arm spent a day on the engine it exists to replace (74 calls, 67s, $0.26 per refresh) — the default is now fast and the setting is a KILL SWITCH, not what grants the lane; grading was wired to the fill only, so every card the client reads DURING the conversation carried no reason at all, and it now grades every refresh, with the reason printed on the card’s FACE; the roster shows the model @handle while the matcher demanded the bare name, so a call that answered for all eighteen logged graded=0 under a success-shaped log line (normalized key now, and a WARNING with a sample when rows come back and none survive); and adopting the rail’s shortlist was gated on status == ready when a refresh in flight LEAVES the previous cards in place, so nearly every click was refused and bought a fresh 90-second hunt that answered an answered question differently (“not what I saw at the bottom of the search”). What matters is whether there are CARDS, which was already checked one line below; the status now rides the adoption log instead of deciding it, and every refusal is logged as well as metered.
  • A place the WORK happens in is a capability, and the same day-one question is asked at every stage (2026-08-23). An errand brief asked for a shopper to buy items in a Swedish pharmacy and post them to the US. SCOUT derived required_countries: ["SE"] unprompted, held it for three rounds, and dropped it on the fourth: not for want of evidence (“Sweden” appears 65 times in the prompt it was reading, including MIRA’s own “you need a Stockholm-based shopper” and four named Swedish chains) but because rung 4 of the concession ladder is “one thing the client actually STATED” and the round summary was arguing for exactly that. 13 Swedish sellers had been retrieved and 10 graded every round; the shortlist came back with ONE, behind couriers in Italy, Japan, Thailand and Turkey, each carrying “based in sweden” in its own relaxed field at no cost to its rank. A country is now classified when it is first SET: a preference about who does REMOTE work is an ordinary stated constraint and stays on the ladder, while a place the work is PERFORMED IN is part of the job and stays set for the whole hunt. FILTERS decides which kind it is, the JUDGE takes the second kind off the ladder entirely (and its round summary and supply-gap lines stopped arguing for the drop), and the GRADER treats it like a stack or a tool: a talent outside it grades OFF, never EXACT, never STRONG, never CORE_FIT, because CORE_FIT means “right craft, one important thing failed” and somebody who cannot reach the shop does not have the right craft. All four stages ask ONE question, so they cannot disagree: what would the talent physically DO on day one, stand somewhere or open a laptop. When a constitutive place leaves the pool thin, a SHORT list is the honest answer.
  • Continuing continues, “nothing needs to change” is sayable, and a finish stops asking rather than forgetting (2026-08-23). Five silent failures out of one live run: a client narrowed the live rail over eight rounds to six New York guitarists, pressed the hand-over button, and got a different shortlist with a Nigerian seller on it, on a brief whose stated hard requirement was “already based in New York”. (1) The hand-over opened a COLD search — a fresh FILTERS call, no thread — so every round the client had watched converge was discarded at the moment they committed to it; matching/continuation.py now resumes from the rail’s snapshot on a JUDGE turn, and every hop fails soft to the cold search. (2) Strict schema mode requires all sixteen filter fields every round and an omission reads as a deletion, so “unchanged” and “cleared” were the same JSON to write: keep_filters is now a required part of the judge’s answer and keeps the previous set verbatim. (3) finish answers “should I search MORE?”, not “search for what?”, and this run finished with every field empty, deleting its queries, role, sub-category and the country requirement in one answer — any finish now keeps the set, both paths metered. (4) With the filters erased the fallback ran a real gateway leg for the literal string “About the client: Dor is”, graded the 21 sellers it returned and shipped them, with the same string on the client’s own Fiverr link; it now uses the judge’s role, else the previous round’s queries, else nothing, because an honest empty beats a confident wrong list. (5) nationalities_preferred holds a country NAME while every consumer compares the gateway’s alpha-2, the same units mismatch that emptied a Mexico brief in 2026-08-17; normalized at the one seam both engines read, with an unmappable name DROPPED and metered rather than shipped as a filter no seller can satisfy.
  • A finish the pool cannot honour is refused, an away seller is not an option, and the judge learns which query is barren (2026-08-23). Four screens and feedback loops that were all reporting healthy. A pool holding fewer DISTINCT sellers than the 18 the client is shown cannot fill the list however it is ranked, and across 33 replayed briefs four hunts finished on ROUND ONE having graded 7 to 13 people and shipped 4, 7, 8 and 12 picks with rounds left to spend, so such a finish becomes a refilter while rounds remain and the reason travels as the judge’s next TURN rather than as a silent override. drop_out_of_office was written for the agentic loop and the deterministic pipeline that replaced it never called it, so a seller on Fiverr vacation could be shortlisted, contacted, and simply never reply; it is back as a PRE-grading pool screen, unconditional and never relaxable, because the person is not there. The catalogue classifier was being handed the budget line, the engagement block and the whole client conversation, which it rejects over 3,000 characters — and being fail-soft by design, it failed 122 times against 12 successes across 95 minutes of replay while 33 of 35 hunts searched with NO facet vocabulary and nothing surfaced; it now takes the brief sections alone. And the round message gained a per-QUERY line (how many sellers each query was FIRST to surface, how many graded above ADJACENT), because a total cannot name the query at fault: one outreach brief whose duties ended “maintain GDPR-compliant records” spent 3 of its 9 round-1 queries on GDPR and got privacy lawyers, two of them returning the same four sellers. Median response time also rides the ranking now, as the first BETTER-LOWER signal, shipped at ZERO weight until somebody measures it.
  • The grader weighs its OWN misses, and code keeps only the range (2026-08-20). misses_rank was three constants — 12 per failed important_to_have, 2 per unmet nice_to_have, unknown free — and a constant cannot see what it is weighing. Free unknown is RIGHT for a requirement no gig page states and every professional meets (scoring those had put three sellers at 20/20/21 on 2026-08-16, measuring how gig pages are WRITTEN rather than who is the better hire) and WRONG for a condition the client demanded in as many words. On a brief whose client wrote “must have ... knows english and spanish fluint”, ten sellers graded Fluent Spanish: unknown each scored a flawless 0 — “nothing they asked for is unmet” — while the ONE talent whose profile lists Spanish ranked 11th of 18, so provable ignorance outranked provable compliance. The grade now carries miss_severity (0–60) and the MODEL sets it, told what each category costs THIS client: a demanded condition unmet is heavy, the same condition unanswered is real, lighter and never zero, a stated preference is light, a requirement every professional meets is not a miss at all. Code keeps the BOUNDS and the price miss (arithmetic, not judgement), and the tier gap still means severity can only reorder WITHIN a fit tier. Grades are cached per (gig, brief), so one written before the field falls back to the old arithmetic rather than scoring a silent zero, which would rank an unjudged pick as flawless. The evidence line was half the defect: a DECLARED language list and an EMPTY field both rendered as a bare string, so a talent who wrote “EN, SR” was indistinguishable from one who wrote nothing. It now says “languages DECLARED on their profile: ...” against “languages: NONE DECLARED”, because an absence inside a populated field is evidence exactly as a declared “basic” already is, and an absence where nothing was populated is not. A deterministic _UNPROVEN_MUST_WEIGHT penalty was tried first and reverted the same hour: it worked, and it hard-coded a severity no measurement supports while taking the call away from the one reader that can weigh it in context.
  • What a requirement is WORTH depends on the job, and the ladder says what failed COSTS (2026-08-20). Ranked against each other, “demanded is heavy, unanswered is lighter, a preference is light” is right, and it still does not say what any single requirement is worth on THIS brief: fluent Spanish is the job itself on a translation, a voiceover, or a support role talking to Spanish customers, and a communication convenience on a logo brief where the deliverable is a file; a named tool is load-bearing when the client opens the source afterwards and cosmetic when they receive an export; a timezone overlap decides a daily-standup engagement and barely touches a one-off delivery. So the prompt asks the question that actually decides it — what breaks for this client if this talent is hired and this miss turns out to be real — and two picks with identical unmet lists may honestly deserve different numbers. A demand is never ZEROED because the grader judges it unimportant; how much it costs is its call. The rungs get the same treatment, because a failed is not just weight: it caps the pick at CORE_FIT, below every STRONG, and no severity lifts it back across a rung, and the grader had been choosing that consequence without being told it existed. The rungs now also place a demanded CONDITION: unanswered blocks EXACT like any unknown deal breaker, an unanswered demand still carries severity inside STRONG (the rung is not absolution), and a profile positively CONTRADICTING a flat demand sits at CORE_FIT — a person-condition never makes the craft wrong, but answering NO is not the same as silence.
  • The sub-category is settled ONCE per run, by two classifiers, and two categories mean two LEGS (2026-08-20). The catalogue that supplies the facet vocabulary used to ride every search RESPONSE, so a hunt re-resolved it on every leg of every round, about 27 times: a classify stampede, py-converse 504s, an 89% failure rate, and most searches losing their facets entirely. metadata is a FILTER and has to exist before retrieval, so the gateway now resolves the brief’s catalogue up front, concurrently with FILTERS (median 3.40s against the resolve’s 5.03s) — and round 1 can carry a facet leg for the first time. SCOUT also NAMES a sub-category from a closed enum: a model cannot reliably recall a numeric taxonomy node but it can choose a NAME, so taxonomy.py vendors the 301 sellable sub-categories and maps name to ids — vendored because it is read on every hunt and a network call would put an outage on the search path, and a name that no longer maps degrades to “no category”, never a wrong one. BOTH classifications are used and neither is authoritative: measured on 26 briefs they agree 62% of the time, ours better on Local SEO over generic SEO, Lead Generation over Sales and Brand Identity over Business Names, theirs better on Translation over Language Lessons, so category_cohort (agreed | disagreed | theirs_only | from_agent | none), facet_cohort and catalog_calls measure the disagreement continuously instead of a 26-brief sample settling it. TWO CATEGORIES NEVER MERGE INTO ONE VOCABULARY: a facet slug is defined WITHIN a sub-category, so a slug resolved for A is a hard AND that no gig in B satisfies and takes that leg to zero (measured: an inferred facet took a live query 18 → 0). _facet_leg_filters intersects the judge’s picks with each leg’s own facets and drops a leg with nothing left; their RESULTS union at the pool. metadata_extract.py is deleted with its settings and its metric.
  • A widening keeps its old pool one more round, an hourly role gets its engagement cost, and a role is DECOMPOSED into services (2026-08-20). Replaying a real $20/hour ongoing brief: round 1 asked for GB and found good UK sellers, the judge dropped the country constraint, and no later round ever asked for GB again — 200 of 207 graded packages came from a pool that never had to be British, and a 20,801-order UK event-flyer designer never reached the shortlist while ugly christmas sweater design did. The judge is right to be free here; what was missing is that a widening silently RETIRES the search that was working, so the pre-widen set rides one more round as an extra base leg, additive exactly like the facet leg (the pool screen tests the CURRENT filters, so everything it returns passes), ONE set and never a history or round 5 fans out five times, and _widened counts only a dropped pool SCREEN. Separately, search_preferences_payload sent an hourly rate to hourly_rate_usd alone while _render_budget reads rate_usd/rate_unit, so _hourly_engagement_cost was unreachable on the one shape it exists for: the grader was told “$20/hour” and never that the engagement is worth $3,600, read every real package as far over budget, and said so in the rationales. And WHERE TO LOOK illustrated the two-catalogue pair with developer roles only, so on a non-dev role there was nothing to generalise from: measured across five rounds of a real ongoing brief, round 2 fired six queries and every one was a JOB TITLE (“volunteer coordinator”, “community manager”), which is the same catalogue searched twice while the instruction reads as obeyed. Fiverr sellers list the SERVICE they perform and never the role they would fill, so the package half must be BUILT: break the role into its concrete services (“event planning”, “workshop facilitation”, “flyer design”), as a per-round obligation. Ongoing branch only; a one-off brief renders byte-identical. scout_leg_sent now logs the query, source AND filters, because a round record carrying queries and drop counts but no filters makes a pool full of sellers the filters should have excluded undebuggable.
  • Four wordings of “tell the hunt what the client COMMITTED to”, all reverted on 33 replayed briefs (2026-08-20). The gap is real and STAYS OPEN: search_preferences never reach the deterministic hunt (_render_context, the labelled prefs block, has no production caller left), so a demand the client stated once to MIRA arrives only if the model re-reads it out of the brief prose — a client asked for a QA tester in Mexico with languages=['es'] and location_required=True, and SCOUT derived seller_languages: [] and searched with none of it. Every wording bought compliance with something else. “They said this is REQUIRED” read as an ORDER: SCOUT held the country screen every round and three briefs shipped 6, 8 and 5 picks instead of 18, talents DELETED before grading — the one thing the round judge is explicitly told a person requirement must never cause, and the same shape as the run that showed a client “no talent found” with 51 graded sellers behind it. Softening it to “evidence, not a standing order” was WORSE: top-3 language compliance fell to 77%, under the 87% of the build with NO block and the 89% of the live production runs the bundles came from. The Italian-narration brief isolates it, since the block was the only text differing between two otherwise byte-identical replays: without it, step 0 opened ['it'] and searched “italian voice over”; with it, [] with languages_match: any and “new york voice actor”, leading with twelve Americans who cannot narrate in Italian while 43 of the 44 Italian speakers the gateway had already returned sat unshortlisted. Giving production’s own ORDER — build round 1’s filters from the conditions, then relax once you have seen what it costs, and what you drop is the FILTER never the requirement — still lost: across 33 real briefs replayed five times, the build WITHOUT any of it puts the demanded language in the top three 87% of the time against 93%, labels 4 more points of the shortlist top-tier (55% vs 51%), fills lists the same (median 18 both), and takes 176s a hunt against 273s. Similar quality, materially faster, so all five commits came out. The judge’s shortfall rule went with them — it had told the judge how many talents the pool can rank against RANKED_CAP, after four of 33 hunts ended on ROUND ONE shipping 4, 7, 8 and 12 cards with a filter still on and the ladder untouched. The measurements are recorded in scout/ARCHITECTURE.md so the next attempt starts from evidence, and the bar is explicit: compliance WITHOUT the latency, with prompt SIZE as the lever, since every paragraph added here rides every grade call. KEPT: miss_severity, the filled-in-field rule, the task-dependent weighting, the grade prompt’s internal coherence, and the bundle/rerun-input plumbing that made any of it measurable.
  • The grade prompt stopped contradicting itself (2026-08-20). Read end to end after four edits in one day, GRADE_SYS told the grader opposite things in three places. It opened with “YOU DO NOT RETURN A FIT TIER. You return EVIDENCE and the tier is computed from it” and then, four lines later, asked for fit_tier as “YOUR judgement”: the opening was a survivor of the one day in August when the rung really was derived in code, a cure worse than the disease, since it had nowhere to put “right craft, wrong stack” except OFF — the rung that DELETES — and it deleted a 51-seller pool. The opening now says what is true: the tier is yours, and one your own important-to-haves refute is corrected down. It also left standing “unknown carries no ranking weight at all”, the arithmetic BEFORE miss_severity moved to the grader and false two paragraphs above the new rule that an unanswered demand is real and never zero, so a grader reading both had licence to ignore whichever it liked. And it said “return three things” over a list of four, with miss_severity bolted on as “3b” ahead of item 3. Nothing here changes what the grader is asked to do; it changes whether the instructions can be followed as written.
  • Price stops choosing WHICH of a seller’s work is read (2026-08-19). _candidate_packages handed the grader a seller’s two PRICIEST in-band gigs (their two CHEAPEST on the over-ceiling path) — a post-retrieval cut of the grade queue, the same move GRADE_CAP was deleted for, and it fails the same way, because price says NOTHING about which of a seller’s services matches the brief. A live C++/Python robotics hunt reached a Pro studio whose catalogue held “full time c and c plus plus and embedded developer” and “full time python developer” at $6,000 — the brief, verbatim — sitting MID-list in a $5,000–$7,000 catalogue, so BOTH price rules missed them: it graded the PHP and Android gigs, judged them a poor fit (correctly, about what it was shown) and dropped the seller. Every gig is now rendered, one package each (the most scope inside the band, or the cheapest tier when nothing is affordable). THE POOL DOES NOT GROW: the per-seller reduction before ranked_raw already collapsed to one package, and it now ranks its real alternatives on the graded TIER, then _must_miss (so between two STRONG gigs the one covering the stated requirements wins), then price ascending as a deterministic tie-break. Costs 3.05× the grade calls on that hunt, bounded by _ENGINE_LIMIT and _GRADE_DEADLINE_S — the only two bounds the scout doctrine allows, and a count cut is explicitly not one of them.
  • An hourly role bands on what a MONTH costs, and the hunt searches BOTH catalogues (2026-08-19). _per_delivery_usd cannot divide an HOURLY rate (there is no period in it), so an hourly brief reached the model with the bare rate as the only number in sight and it went straight into budget_max: a $100/hour role banded at $100, every real hiring gig read 50× over, nothing landed in band, and _in_band fell through to grading each seller’s CHEAPEST gigs — the failure above. The role line now states what a MONTH of the engagement costs (hours × rate, the stated workload when given, a full-time ~160 assumption otherwise) and SAYS WHICH BASIS IT USED, so a reader can tell a measurement from an assumption; where the brief states a duration it multiplies all the way out ($100/hour, 3–4 months, is a ~$64,000 commitment, not $16,000), and hours stated per MONTH are taken as-is rather than re-scaled by the weeks factor. A lone stated figure now reaches the hunt as a BAND with a floor derived 20% below the ceiling (_DEFAULT_FLOOR_PCT) and the line says WE derived it, so SCOUT can use it without ever quoting it back to the client as their words: it names the TIER being bought, it steers and ranks, it never gates the pool, and price_hard carries a matching carve-out so a floor WE invented can never become a hard boundary. Fiverr also holds the same person in two catalogues — the HIRING shape sells a person’s TIME, the PACKAGE shape sells the WORK — and an ongoing brief was running only the package shape, so it returned project-sized freelancers for a full-time role and missed the specialist who was the better hire. Ongoing briefs now spend queries on both. Ongoing path only: a one-off brief renders byte-identically to before.
  • The facet vocabulary comes from the CATALOGUE, and nothing INFERRED narrows the search (2026-08-19). SCOUT picks metadata facets from a strict enum built out of the brief’s real category catalogue, which the gateway resolves and returns with the results. We used to build those strings ourselves (mh0:<sentence>, an attribute name invented from a list index carrying human prose): nothing matched, and a value Elasticsearch could not parse failed the request outright — 106 production searches returned zero results on an HTTP 500 with no fallback. A strict enum cannot EXPRESS a non-member, so the API layer rejects it before the wire. The catalogue, not the returned pool: harvesting facets off the gigs that came back is circular, since a facet nobody in round 1 tagged is invisible. NO CATALOGUE MEANS NO FIELD, which is the kill switch — an older gateway, an unresolved category and a brief-less search are byte-identical to the behaviour before this existed. It accumulates and never replaces (a facet learned in round 1 must stay selectable in round 4), classification is first-wins across a round’s legs, and a truncation is metered (recruiter.scout.vocabulary_truncated) — which is how _VOCAB_MAX_OPTIONS was caught cutting real vocabulary at a placeholder 24 and raised to 96, measured against a live catalogue where one multi-category hunt accumulated 71 options. In the same pass, the INFERRED leaf category came off the base legs: _with_category had put it on the filter every base leg runs with, making a value nobody stated a hard AND. Re-measured over eight briefs, Fintech UGC went 161 pooled / 18 finalists → 7 / 6, Singapore EV 141 / 18 → 6 / 6, Travel 266 / 18 → 130 / 18; across 21 briefs it left 7 shortlists below the 18-card floor, one with a single card. Narrow briefs gutted, broad ones halved — the signature of a hard AND on an inferred value, which reads to the client as “no talent found”. The classification still rides _metadata_filter, on the union leg where it can only ADD, and the rule is now absolute rather than per-value: nothing inferred touches the legs we already run. (Recorded honestly: the commit note first blamed the category for a same-sample pool collapse the trace shows it could not have caused — those runs were single-round and the vocabulary is learned from responses — and was corrected the same day. The change stands on the arithmetic, not the measurement.)
  • The query cap comes OFF, on the first experiment that could attribute anything (2026-08-19, supersedes the 2026-08-18 rule below). Prod and preprod disagreed about shortlist quality with the cap, the grader model, the brief and the day all different, so nothing was attributable. Both knobs became RUNTIME and per-rerun-settable (Settings.scout_max_queries_per_round, Settings.scout_grade_model, both overridable on the AgentRerunJob payload so two arms run CONCURRENTLY off one deploy), the prompt text is rendered from the live cap (queries_rule(cap), lru_cached so the cached prompt prefix survives), and search_prompt_fingerprint(cap) hashes the RENDERED prompt so switching arms resets a stored rail thread. The 2×2 that followed — 3 real briefs × (grader mini|luna) × (cap 1|unbounded), 12 runs, one env, one hour, one gateway index — settled both: UNCAPPED costs 1.9–2.0× and buys 2.6–2.9× the graded pool (cheaper per package, ~$0.14 a brief against a $0.50 target) and is FASTER in wall clock (136–150s vs 150–210s), because its queries fan out concurrently and the hunt settles in 2 rounds instead of 3–4; the cap was costing latency, not saving it. And since RANKED_CAP is 18 whatever the pool size, a capped hunt ships the same 18 scraped further down — on Ghana the capped arms filled 11–12 of 18 with ADJACENT talent while uncapped Luna shipped none. scout_max_queries_per_round now defaults to 0 (unbounded) and _MAX_ROUNDS stays 5; setting it positive re-caps the code AND the sentence that announces it. Luna is confirmed as the deployed grader by the same matrix: the morning’s revert had been judged on finalist retention, which is worthless here (any two arms overlap ~20%, so it was reading SCOUT’s own run-to-run variance), while on the metric that does separate them — the share of the graded pool landing ADJACENT or OFF, i.e. work paid for and discarded — Luna is best at every cap (58% against mini’s 67–77%) at 2.5–2.7× less money. STILL UNKNOWN, recorded so nobody reads this as settled: EXACT was returned zero times in 3,371 graded packages across every arm, so the top rung is dead and the grader works in three; one run per arm means the variance floor is assumed, not measured.
  • SCOUT explains the hunt TO THE CLIENT, in two sentences (2026-08-19). Discovery now ends every hunt, on EVERY exit path, with one closing turn over its own search thread (EXPLAIN_SYS): a client-voiced account of what it searched for, what it ran into, what it loosened and why. It ships twice — SearchRun.search_explanation above the results deck, and the live rail’s snapshot above the cards as the hunt evolves (migration 0164) — and the per-pick grade note is now written to the client as well, both prose seams stripping long dashes where the model output is parsed. Best-effort by design: a failed closing call logs, meters (recruiter.scout.search_explanations{outcome}) and yields “” rather than sinking a finished hunt — and because it yields an empty string, the store’s ON CONFLICT now COALESCEs it like search_thread beside it, so one degraded refresh cannot wipe the note the client is reading. Hours later the prompt was cut to fit its surface: 3–5 sentences over four beats is a paragraph, and the rail is a narrow strip where the tail is clipped rather than read, so it is now at most TWO sentences, ~35 words, stated as a hard limit rather than a target. The client’s own escape hatch learned the same lesson from the other end: the “search Fiverr myself” link now carries the Fiverr-expressible subset of the final filter set (budget range, delivery buckets, languages, countries, Pro, level) in fiverr.com’s own ref syntax, persisted per run, instead of opening a bare keyword search.
  • A condition the client DEMANDED outranks one nobody asked for (2026-08-18). Asked outright for talent preferences, a client answered “english speaking, based in europe”; SCOUT filtered on it in round 1, the judge dropped the country filter between rounds, and the grader logged “Europe-based: missing” as a NICE-TO-HAVE on every non-European seller. The shortlist came back 10 of 18 outside Europe, 6 of the top 7, with the compliant GB and ES sellers ranked below them. Nothing malfunctioned: GRADE_SYS routed EVERY person-requirement — country, language, timezone, availability — to nice_to_have, on the sound reasoning that they describe the PERSON rather than the deliverable and so must never make the discipline wrong. The missing half is that nice_to_have cannot move the fit tier and misses_rank is only a light tiebreak that never crosses one, so a stated condition carried NO ranking weight at all, quietly breaking the prompt’s own promise that a non-compliant talent is “ranked below every compliant match and labelled”. The routing now splits on whether the client DEMANDED it rather than on whether it is about the person: demanded flatly, in the brief OR in the conversation, becomes an important_to_have marked failed when the profile contradicts it — and reading BOTH sources matters, because the condition that started this never reached the brief sections and existed only in the transcript. Floated (“ideally”, “preferably”, “not a must”) or INFERRED by the grader stays nice_to_have, and ties break toward floated, because over-reading a preference costs a capable talent its rank for a condition nobody insisted on. Promoting these is safe precisely because only a wrong DISCIPLINE reaches OFF: a failed important-to-have forces CORE_FIT — ranked below every compliant match, labelled with what it misses, and still SHOWN — which is the behaviour the prompt already described and could not deliver. Two edits stop it undoing itself: the person-requirement rule ~80 lines below section 2 no longer routes unconditionally, and the “expect ZERO to THREE important-to-haves” ceiling now covers CAPABILITY musts only, or a brief already carrying three capability musts would squeeze the stated conditions straight back out. Price stays carved out, negotiable however flatly it was stated. Pinned by prompt-text assertions, which prove the rule is STATED, never that the model obeys it.
  • One query per round, and more rounds to walk with (2026-08-18, SUPERSEDED 2026-08-19 — the cap is off again; kept because the reasoning below is what the 2×2 above actually tested). Retrieval volume is queries × engines × the per-engine limit and everything retrieved is GRADED, so the query count multiplies the whole grade bill rather than adding to it. A live hunt turned 6 model queries into 12 through the combo pairing, pulled 294 sellers in ONE round and spent $3.53 grading ~700 packages to show 18 cards: 39 graded packages per card the client sees. _MAX_QUERIES_PER_ROUND = 1 with _MAX_ROUNDS raised 3 → 5. This is not searching less — the hunt still fires up to five queries, but each is chosen by the JUDGE after seeing what the last one returned rather than six fired blind at once, and iterative beats parallel here precisely because the judge carries the round’s pool summary in its own thread and can steer on it. Capped in BOTH places a round’s query list is settled (the opening round and every judge refilter), since capping only the opener would let each refilter re-widen and test the opposite of the thing; the freshness check still reads the UNCAPPED list on purpose, because a round proposing only already-fired queries is a no-op round however few survive the cap. Metered, not silent: recruiter.scout.queries_capped counts the dropped queries, each of which is talent nobody looked at, so that counter is the experiment’s denominator. THE RISK, stated plainly: on that same hunt the top three finalists came from three DIFFERENT query families, and one query per round reaches them only if the judge walks to them across rounds. _MAX_QUERIES_PER_ROUND = 0 reverts it.
  • Grading is 95% of the calls, so it gets its own cheaper model — and stops waiting for the slowest chunk (2026-08-18). Grading runs 50 to 70 calls against the judge’s 4, is ~81% of a hunt’s bill, and is the one SCOUT step that is a CLASSIFICATION rather than a strategy call: the brief is already in the prompt, the verdict is a closed enum, and the evidence to cite is a REQUIRED schema field, so the structuring a larger model would otherwise invent is declared for it. Settings.scout_grade_model moved grading onto gpt-5.6-luna, a TENTH of Terra’s output price; FILTERS and JUDGE deliberately stay on agent_model, where the reasoning lives, and that split is pinned by a test because a future refactor that “unifies the model” would silently undo it. Two things made the swap safe rather than quiet: the model was verified against the LIVE API first (Responses API, a reasoning effort, and a STRICT json_schema — the whole surface the grade call uses), because _grade_packages catches a 400 and returns its ADJACENT fallback for every package, so an unsupported grader would grade whole pools ADJACENT, still ship 18 cards and go red NOWHERE; and it was PRICED in the same commit, since price_for returns None for an unknown id and would have silently zeroed the biggest line in the search bill along with the daily-spend alert watching it. Alongside it the grade wave stopped being two half-parallel passes: measured over 45 rounds the judge took 4.3s, gateway and screening 1.2s, and the GRADE WAVE 49.6s, 90% of the wall clock, with its own slowest single call at 26.6s — a 1.86 ratio, because the second pass could not start until the SLOWEST chunk of the first had landed, so every round paid a full tail-wait twice. Each first-pass chunk now dispatches its own deeper tiers the moment it lands. Same packages graded, same rule that a chunk running out of clock leaves a HOLE rather than sliding grades onto the wrong talent; only the schedule changed. The one behavioural edge is that the EXACT_TARGET is now read per chunk rather than once after the whole first pass. A weaker grader is NOT a free win and the caveat is the same as every grade-quality knob: it does not error, it judges worse, and the failure is quiet.
  • NO RULE DECIDES FIT, ONLY THE GRADER (2026-08-17). Every screen that DROPPED or CAPPED a seller on a predicate is gone. Stated countries, languages, levels, Pro/agency flags and delivery ceilings now ride the gateway as recall STEERS and reach the grader as evidence weighed against the real gig text; _enforce_filters keeps exactly ONE rule, the caller's explicit exclude_sellers, which is an instruction about who may be SHOWN rather than a judgement about who FITS, and the post-discovery location, timezone, OOO and availability gates are deleted outright. Every screen removed had a measured incident behind it and in each one the SCREEN was the thing that broke: a “QA tester in Mexico” brief graded 26 packages and named two of them STRONG in the grader's own words, then lost all 19 pooled sellers to the country gate and showed the client nothing, on a units mismatch no predicate could notice (ScoutCandidate.country is alpha-2 MX, required_countries carried the human name Mexico), with an UNRESOLVED country treated as a mismatch rather than as unknown — the same defect that cut 272 of 272 on a German brief on 2026-08-13. Three things landed with it. The fit ladder is the model's again: the previous day's two-value discipline_fit with a Python-derived tier had nowhere to put “right craft, wrong stack” but OFF, the one rung that deletes, so a rare named platform emptied a 51-seller pool to ranked_count: 0 while the grader's own notes conceded the craft; fit_tier is returned directly again across five rungs with ADJACENT as the explicit home of related work on the wrong stack. An empty pool is never an answer: if the round graded nothing or everything graded OFF, finish is unavailable, and the round summary now names in counts which of the agent's OWN filters removed people before anything was graded, so it drops the biggest one instead of re-wording queries against a population that was never there. And the bill is bounded: with nothing screened out, graded packages per search went 41 → 124 median, discovery p90 158s → 327s and grade spend $0.26 → $1.03, so grading effort is pinned rather than left to the model's default, the search is bounded by the clock, and the same brief is never paid for twice.
  • A stated figure is a CEILING, and money moves only against a real price problem (2026-08-16). A single stated number was filling BOTH ends of the band, which asserts the client will not look at anything cheaper and deleted the talent who quoted less; it is now a ceiling with a NULL floor, a floor is set only when the client names one, and no stated budget sets BOTH to null rather than inventing a band. The judge’s relax ladder became a fixed order — (1) widen the QUERIES, (2) widen the MONEY but ONLY when the counts show the band cost the pool, (3) drop an UNSTATED preference, (4) only then one thing the client actually STATED — because an UPSTREAM GAP (sellers returned with no gig or price data) is not evidence of a price problem and no band recovers it. Raising a stated ceiling round after round does not find a better version of the same work, it finds a DIFFERENT kind of work.
  • The agent sets the filters; the pipeline executes them. SCOUT never touches an engine and never writes a search call. Budget IS the agent's call, which is what killed the “$5,000 hunt for a $20 brief”. Since 2026-08-11 the budget screens on the CEILING ONLY — the band used to screen both sides, so a seller whose tiers all sat UNDER the client's budget was dropped as hard as one priced over it, which is backwards: under-budget is not an inability to serve the brief. Where the budget sat above the service's going rate that dropped EVERY seller and collapsed a real 20-seller pool to an empty shortlist. Under-budget picks stay in the pool and _price_miss ranks them beneath the in-band ones, which is what that weight already existed for.
  • Every filter the agent states is ENFORCED — by us, after the union (2026-08-11). Two halves of one bug. Some keys we sent are not in the gateway's vocabulary at all (pro_only/seller_level where it wants prioritize_pro/minimum_seller_level), and an unknown key is dropped in SILENCE — the whole reason an on-site-Israel brief shipped a seller in Greece and a German brief shipped a talent who speaks none. And even a correct key could not be trusted, because we UNION hybrid with brief_search and brief_search enforces nothing: a bogus country code returns the full unfiltered set with kind=success. _enforce_filters now re-screens country, region, language, level, Pro, agency, delivery, excluded sellers and facets on the merged pool, with every drop metered by reason because a filter that quietly empties a pool reads to the client as “no talent found”. Measured on a live 58-seller union pool: 12 of 12 capabilities hard, zero violators. The agent's vocabulary widened to match (min_seller_level, pro_required, exclude_agencies, prioritize_agencies, region_countries, exclude_sellers, budget_type, the taxonomy ids and structured facets), each bounded at the model boundary. So the older “quality, Pro and seller level are NOT filters” rule now holds only as the DEFAULT: ranking still curates them when the brief is silent, but a brief that genuinely demands proven standing can say so and have it enforced. This whole bullet was REVERSED on 2026-08-17 — see the rule below: enforcing a stated constraint on the merged pool is exactly what kept emptying shortlists, and every screen here except exclude_sellers is now gone. It stays on the record because the 2026-08-11 half is still true and still the reason the constraints must ride the GATEWAY correctly: an unknown key is dropped in silence, and brief_search enforces nothing at all, so a constraint that never reaches retrieval is a recall problem no downstream evidence can repair.
  • A stated requirement cannot vanish by not being repeated (2026-08-11). Filters were hard WITHIN a round and soft BETWEEN them: the judge returns a whole filter object on a re-filter and an omitted field read as “cleared”, so a “must speak German” brief screened correctly in round 1, came back thin, and round 2 searched with no language constraint at all — a final shortlist of talent who speak none, with the filter apparently still set. Every hard constraint is now CARRIED FORWARD across re-filters. Queries and the budget band stay the judge's to rewrite (that is what widening a search means), but a stated requirement can only be dropped by naming it in relax, at most one per round, softest first. Two cases the merge gets right that a naive fold does not: a judge relaxing three at once gets exactly ONE applied, and max_delivery_days takes the STRICTER value, since its generous 30-day default would otherwise loosen a 7-day deadline by omission. Each relaxation is metered (recruiter.scout.filter_relaxed) — a filter relaxed on most searches means the briefs want supply that does not exist, a product answer rather than a tuning knob. One field stayed invisible to that fold until 2026-08-14. Every carried key asks “did the new round state this? if not, keep the old one”, which is right for the fields that come back EMPTY when unstated and wrong for languages_match, which never comes back empty because it defaults to all. So the new round's default always won and the previous round's choice was dead code: a round 1 asking for German OR English, coming back thin, then broadening the query without restating the language, kept the two languages and silently flipped them to German AND English — dropping exactly the monolingual talent round 1 had kept. That is relaxation-by-omission in reverse, arriving through the one field the safeguard could not see. The mode now travels with the SET it modifies: carried from the previous round whenever the language set is carried, honoured from the new round only when that round names its own languages.
  • A named tool is hunted directly, and its specialists survive the floors (2026-08-11). A brief naming a tool is “common role plus rare skill”, and a role-shaped query finds only the common half: on a Framer/Webflow-plus-Spline brief, spline alone returned 18 specialists and spline webflow returned 8, while the model's own long queries returned none. FILTERS now emits named_tools as bare tokens and discover() pairs each with the role in the FIRST wave. The same brief had also been losing its specialists to our own gates before grading ever ran: of 19 sellers advertising Spline in a gig title, the seller-level floor took 8 (including the single best match in the supply, because a specialist in a newer tool is usually a newer seller) and the package band took 5 more. A seller who ADVERTISES the named tool now SKIPS the level floor. must_have is the scar in the other direction — it feeds a catalogue-facet match in Elasticsearch, the model was writing whole sentences into it, and the gateway answers an unmatched phrase by failing the WHOLE search with a 500 and zero candidates: 106 production searches died that way. It is bounded in three places now (a 3×32-char schema cap, a re-check that drops anything wordy on the way out, and the prompt explaining what a facet is), because a prompt alone is a request rather than a guarantee.
  • SCOUT is still the ranker — there is no second ranking step; the order it emits is what the client sees. It grades fit into five coarse tiers (EXACT / STRONG / CORE_FIT / ADJACENT / OFF) and sorts on the tier first, proven quality within it — never a hard sort by a fine score. The gate moved on 2026-08-03, after a Roblox-game brief returned weak generalists over a Top-Rated studio whose reviewed, order-backed "Roblox full game" gig proved the discipline but whose headline persona read AR/VR. The tier had been gated on core_profession, a skill-tag persona, so a proven on-brief PACKAGE lost to a self-labelled specialist. The tier is now a property of the chosen package: SCOUT picks the closest gig+package whose service IS the brief's deliverable and grades how fully THAT package covers it (discipline, medium, scope, price-fit, timeline), across the whole ladder — so a reviewed on-brief gig grades EXACT even when the studio's headline is broader, while a bare keyword package with no orders or portfolio behind it stays ADJACENT/OFF. core_profession survives only as an honest card label and a spam check. The other half of the same move: a package delivering only a COMPONENT of the job (a "$50 script, one system" gig against a whole-custom-game brief), or a catalogue whose ceiling cannot reach the brief's scope, is not a low tier — it is not a fit, and DROPS (OFF) rather than surfacing near the bottom, which is why cheap component gigs had been appearing as CORE_FIT. With those removed at the gate the LOW-COST fit cap became redundant and was withdrawn: seller quality (standing, a bargain-floor catalogue) is now a strictly SEPARATE axis moving only the QUALITY-GRADE that orders talents WITHIN a tier — the same deterministic score_by_config points the loop already sorted by, now SURFACED on every evidence line so SCOUT sets the tier while seeing the exact grade the sort will apply. Capability comes before soft preferences; a required location or time-zone and any budget band are hard caps applied at rank, and a client-stated budget floor only ranks below-floor talent lower, it never drops them. What changed on 2026-08-02 is the SEARCH side, not this gate: the prompt used to say a mere preference does NOT go in hard_filters, so a stated "preferring Italy or Spain" never reached the pool at all. A stated preference now rides the FIRST waves as a filter too, is relaxed only once the filtered pool has proved thin (softest first), and a shipped pick that misses one is never free — it carries a light misses_rank weight, always beneath a stated-MUST miss, and is named in that pick's reason. That weight was DEAD until 2026-08-11, which is worth recording rather than quietly fixing: the sort keyed on (-compose_total(fit, quality), misses_rank), so the miss count only ever broke a tie between two identical totals, which never happens. Ranking was really fit tier then seller quality, exactly the two axes that cannot see a missed requirement, while the comment above that sort had always claimed misses rank within a tier. Two production searches scoring ~80/100 exposed it: a five-part brand-system brief returned 15 picks each missing three to seven stated deliverables, led by a logo-only tier missing all seven, and the Spline brief returned 18 picks with no Spline, its own #1 rationale admitting the gap. misses_rank is now a real penalty SUBTRACTED from the total, weighted to dominate the quality spread but capped far below the tier gap so it can only reorder WITHIN a craft tier; _must_miss feeds it from the MUSTs, which nothing had been doing; _candidate_packages stopped picking purely on price, falling back to the cheapest tier ABOVE the band so the tier that actually delivers is visible; and search_score, blind to all of it, now subtracts unmet requirements. Outreach needed no separate gate, taking the top of this same ranking. Two supporting rules landed with it: a missing quality bucket now scores NEUTRAL rather than bottom (absence of an A–E grade is lane coverage, not a fact about the talent; real D/E still score 0), and must_have reaches the engine as ONE OR-facet rather than per-term AND facets, which had re-created the keyword starvation removed on 2026-07-28.
  • One client question, and it comes BEFORE the search — but it is SWITCHED OFF for now. The old in-loop ask_client escape hatch is now a PRE-discovery gate on the signed-in path: SCOUT pauses with ONE question when the brief leaves a genuine fork, and a resume folds the answer into the brief and runs discovery once. It fails safe — any error means no question, never a blocked search — and the prompt is written to prefer not asking. Product call, 2026-08-09: SCOUT_QUESTION_ENABLED = False in discover_and_start, so the concierge never grants the ask gate and every discovery runs straight through without the pause. The pause / resume / timeout machinery stays wired (a run already paused still resumes) and ASK_SYS stays published, so flipping the flag restores the feature.
  • The opener degrades loudly, never quietly. A failed FILTERS call returns an empty pool marked degraded and metered, so the surface says “search hiccup”. A rate limit that reads as “no talent found” is the bug this rule exists for: a 200-brief rerun produced 50 such silent empties on ordinary briefs before it was fixed (2026-08-06). An empty queries array falls back to a bare role query rather than searching nothing.
  • No invented talents — a pick's username must match a real candidate exactly.
  • The back-to-Fiverr search is the CLIENT's words, not SCOUT's angle (added 2026-07-31, Monday 3128443289) — fiverr_search_query is the role the client asked to hire ("website developer"), never a niche angle tried while hunting ("landing page design"), never a badge or filter, never their brand or industry, and never a sentence lifted from the brief. The earlier guard was a badge-word blocklist, useless against a query that is a real service but the wrong one.
  • Anonymity holds — discovery exposes only progress phases over Pusher, never raw talent PII pre-run.

STERLING concierge negotiator · state machine + brain

Two parts. The runner is the deterministic state machine (start / approve-and-send / tick / finalize). The brain is the pure LLM that writes each talent-facing message, judges each reply, and flags offers — acting only through four function tools.

File concierge/runner.py + sterling.py Prompt full prompt ↗ Model agent_model (no reasoning_effort — 400s with tools=) Tools 4 · tool_choice:required
Invoked by
Runner entrypoints driven by worker jobs (start on-cycle; send/tick/finalize enqueued by web routes). Conversational turns run in the separate seller_turn handler, enqueued when a talent replies or the client answers a clarification.
Consumes
A context: the frozen brief + client-card snapshots, the trigger (opening / talent message / client answer), the full thread history, the latest inbound — plus the client↔assistant chat + the full IRIS dossier (load_client_knowledge) and known_answers: every answer the client has given on the run, across all threads, so he never re-asks what another talent already got answered. Since 2026-07-07 the card snapshot also carries the client's Fiverr account profile, frozen at run start (buyer_card_snapshot.fiverr_profile, ADR-0005 — a run outlives the dossier cache's 24h TTL): the safe half personalizes the client-disclosing outreach, the internal signals calibrate the negotiation band and push-back strength behind an absolute never-hint-to-talent wall, and — like the private talent preferences — the section never enters the close-out prompt.
Produces
His tool calls — send_reply / escalate_to_client / register_offer / register_decline — fold into a SterlingOutput: reply text, escalation question, offer_detected, decline_reason, next thread state, guardrail flags. LLM: the prose, the judgment, offer detection (with an LLM fallback extraction when the $-price/timeline regex can't parse a flagged offer). Deterministic: all persistence, push, and offer scoring/ranking.
Triggers →
Talent messaging (the adapter's send is a no-op — the talent SPA + DB are the inbox); escalations → a client clarification, whose answer relays to each asking thread and enqueues one concierge_answer_ingest job (MASON + IRIS fold it into brief + About You); finalize_if_complete auto-reveals ranked offers + a recommended winner when the last thread lands.
Hard rules
  • The delivery date is computed, not requested; a decline keeps the preference tools; and a talent’s “ok” while he waits on the client gets one courteous line (2026-09-24, found by a full scenario baseline, 286/309). (1) register_offer gained work_days: the turnaround the talent quoted, copied. With starts_in_days the handler adds the two, upward only, so timeline_days counts from today; the model had banked the bare turnaround and put every delayed-start offer a week early (metered as recruiter.sterling.timeline_recomputed). (2) The scope-decline block said “send_reply ONLY” and the model read it as a tool ban, so “fine, but stop sending me logo work” was acknowledged and stored nowhere; the decline now carries the same preference-tool exception the closed thread has. A decline REASON that names a kind of work is not a standing rule unless the talent says it keeps arriving. (3) With a question pending with the client, a bare “ok” no longer opens the next block or re-promises the answer: “Sure.” and nothing else. (4) A boundary that selects clients by nationality, race, religion, gender, age or disability is refused by CODE at every seam (see §10), and the preferences readback lists only facets the talent actually set.
  • The overnight opener hold arms only on a 12-hour run (2026-09-23, Monday 3223021861). The send-time working-hours hold defers a talent’s opener up to seven hours so nobody is pitched at 03:00; it now arms only on a group A run whose buyer chose the 12h “Extended” window (buyer_window_seconds, the buyer’s CHOICE, not the headroom-trimmed deadline). On 2h/4h the client’s clock starts before outreach, so the window closed while every opener was still waiting for somebody’s morning (dev, 14 Sep: 0/9 sent on an all-Americas shortlist). Group B and windowless runs send immediately. This had shipped to prod on 15 Sep only as a release-branch cherry-pick and was lost from preprod on 22 Sep; re-landed on main.
  • A run approaches twelve talents, all at once, and never replaces one (2026-09-17, Monday 3228250926). The discover flow keeps SCOUT’s top 12 (WAVE_CAP, off Settings.concierge_wave_cap) and sends every opener in the same dispatch. It used to contact 9 and hold 9 more in reserve, bringing one in after each decline, each 30-minute silence and, in group B, each proposal. All three are gone, and so is the silence timeout, whose only job was that swap: a talent who has not answered is simply still being waited on, never told anything, and free to send a late proposal until the run itself ends (the client’s window, their own exit, or the 24-hour backstop). A talent manager’s additions are contacted inside the 12, taking the last seat when SCOUT did not rank them. Two seams moved with the timeout: a talent who never answered is still awaiting_seller when a run falls back to search (it used to be timed_out), so their late message now gets the polite close by that rule; and the client’s brief unlocks on a fallen-back run whatever its counters say. SCOUT itself still ranks up to 18; the ranks past 12 are logged, never contacted. No prompt changed. The leftovers it kept for the rollout went the same day: the drain-only concierge_silence job, the always-empty reserve fields on the client’s view, and the unused mid-run extend action (its route, job action, runner method and reserve claim).
  • What a talent said is the record, and every save is checked against their own sentences (2026-09-16). The capture prompt (PREF_CAPTURE_SYSTEM + PREF_CAPTURE_PARAMETERS) left the tool literal and is committed as the capture block of sterling_turn_tagger.json, pinned byte-equal, and each live capture reports what it read via report_tags. Its rules come from 110 hand-read prod captures (21 wrong, 12 of those already operator-approved): unit words decide the field, a per-unit price is the boundary, conditions stay in rule_line, a month is not a date, a split day is not one window, and evidence is the TALENT’s sentence, never our reply. A clear is DECLARED (cleared_facets, 0228) and a capture of nothing is REFUSED and metered. One rule comes off by rule_removals (copied back exactly; an unmatched removal is dropped and metered, never matched to the nearest line), because clear_rules had deleted a rule the reply promised to keep. Each figure carries its own currency, and what was NAMED is stored beside the converted cents (stated_amounts, 0230) and shown to the next capture (“$104, which is the 90 EUR they named”), with a category shown by NAME, so a restated figure is not read as a new one and the contradiction gate never asks a talent to approve a cut they did not make.
  • All 92 stored boundaries were read back against the conversation each came from, and 13 of the 86 verifiable ones are not what the talent asked for (2026-09-06). They fail in four shapes the policy had nothing to say about, so each shape is now a rule with its own failure attached. ONE BRIEF IS NOT A CATEGORY: “not interested in these YouTube videos” about ONE brief became a rule filtering every one of them, and “I only look at logo design” became the much narrower “no website-header creation work”; when the words could mean the brief or the category they mean the brief, and nothing is captured. A VAGUE PHRASE MUST NOT REPLACE A NUMBER: a talent who stated $650/month holds “no underpriced daily coaching engagements” with no figure in it, so the floor that filtered was deleted by a sentence that filters nothing; if a figure is already stored and the new message only complains about price, ask for the number or leave the one they gave standing. TAKING A BOUNDARY BACK IS NOT SETTING ONE: “$6,000 isn’t a floor, and I don’t want to be filtered out of smaller work” stored $6,000 anyway, stopped only by a human rejecting it, and the clear_* flags had existed the whole time with nothing ever calling them — the worst outcome this flow has, since a talent asking for MORE work ends up with less and cannot see why. AND A BOUNDARY ABOUT WHO THE CLIENT IS, we do not take: a rule selecting on nationality, origin, race, religion, gender, age or disability is declined in his own words, including when phrased about companies or markets, and he offers only the two things that actually filter, the KIND of work and a minimum budget — explicitly NOT hours or a time zone, which the rule forty lines above already says remove nobody from anything. Said plainly: omission is the largest shape (8 of the 13) and a rule is not what is missing there; that one needs the capture path.
  • The closing line had been sent ZERO times, and he could not see the boundary he had just captured (2026-09-06). CLOSING_LINE appears in none of the 103 threads that captured a preference and in none of 220,661 outbound messages: it is the only line in the feature the model writes rather than the system appending it, and on a closed thread the close-out instruction wins every time — 94% of captures happen on a closed or declined thread, so “every time” is the whole population. The closed-thread carve-out now covers the LAST beat too, where they answer “anything else?” with no. Separately, _pref_hydration read read_current_seller_preference, which answers what is ENFORCED, and nothing is active until an operator approves it, so a talent who set four boundaries and asked what was saved was told “I don’t currently have any preferences saved for you”. It reads the WORKING view now — the same fix already applied forty lines below in the confirm branch on 2026-09-01 and left where the talent actually reads it. No extra caveat was added, because every capture already ends with “memory updates aren’t instant”.
  • He may capture a standing preference on ANY turn the talent spoke on, and “noted” without a tool call is the one thing he must never say (2026-09-01). A QA thread stated nine boundaries on a CLOSED conversation and got nine warm replies, every one ending “noted”, with no tool fired and no row written. The tool list had carried propose_seller_preference the whole time; the prompt said “call send_reply ONLY” on a closed thread, so the model obeyed the prose. The two halves now read ONE predicate, preference_tools_available, the same shape as silence_tool_available beside it and for the same reason, and the closed-thread carve-out is UNCONDITIONAL: it used to require a proposal already pending, which let a talent FINISH a preference conversation begun before the close but never START one after — and after is the likelier case, since the moment somebody says “stop sending me work under $1,000” is usually the moment they were just passed over. The renegotiation and scope-decline re-runs keep the tools too, since that path strips the whole tool list and no prompt could have helped. Only the OPENING turn is excluded, because there is no talent message on it yet.
  • A second boundary is an ADDITION, a contradiction stands ALONE, and a “yes” is a fresh capture (2026-09-01, four numbered QA notes and the data loss underneath them). A MINIMUM was reading back inverted (“approach me for projects over 1000 only” → “Projects under $1,000, noted.”), which names the work they just refused as the work they want on the one message that is a talent’s only window into what we stored. A SECOND boundary opened the same way as the first, so three turns in a row ended on the same word; it is acknowledged as being added, naming the new item. An unclear answer said “should I add THAT as well”, so CLARIFY_LINE now carries an {item} he fills from their own words. Underneath the copy: an ADDITION bundled with a CHANGE (“no logos, and nothing under $500”) was discarded whole by the gate that pauses on the change, so only what would OVERWRITE waits for an answer now; a “yes” to “would you like me to update that?” found no proposed row and saved nothing, because that question deliberately writes nothing and the confirm path was built for the approve gate removed on 2026-08-27, so a yes is a fresh propose_seller_preference with the NEW value; and the same message rendered the ACTIVE-only view, so a talent read “Minimum project size: nothing set” two messages after being told our memory said $1,000 — it reads the WORKING row now, since nothing is active until an operator approves. Two seams the prompt cannot own: _compose_reply makes a contradiction the one block that REPLACES his reply rather than adding to it, because printing the restatement above the overwrite question tells a talent their change is saved AND asks permission for it on one message; and the pending-proposal NOTE now says plainly that the system appends only the closing sentence and never an acknowledgement, after the previous wording (“waiting for approval”) led him to conclude the system would speak for him and write nothing at all.
  • ONE constant gates the talent-boundary feature, and on 2026-09-06 it finally reached the seam that actually drops people (dark 2026-08-26, back ON 2026-08-31, the missed seam 2026-09-06). SELLER_PREFERENCES_ENABLED in concierge/seller_prefs.py gates BOTH halves on purpose: with STERLING mute and enforcement still live, a talent whose preference is active is dropped from every client search with no way to see it, change it or lift it, and cannot even raise the subject because he no longer knows it exists. Invisible filtering with no recourse is worse than either end alone. Off means he has never heard of it: tools_for drops the preference tools (the tool list is the guarantee where a prompt is only a request), build_system_prompt omits the policy, the CURRENT PREFERENCES heading and both NOTEs, the deterministic approve block is never appended, and SCOUT loads no floors, rules, availability or hours. The 12 behavioural scenarios were skipped with a reason naming the constant rather than deleted, which is what let the feature come back on five days later without rebuilding any of them. The gap the daily scan found: discovery gated its reads on the flag, but the OTHER enforcement seam — the outreach floor gate on the reserve draw and hand-picked sends, which never routes through discovery — kept enforcing regardless, so the switch-off it advertised was never the switch-off it performed. Both seams read the one flag now, and the drop it meters is recorded at its real severity rather than merely documented.
  • A standing preference is raised at TWO moments, both of them the talent’s, and ordinary frustration is not one of them (2026-08-25). The invitation shipped that morning as deterministic copy appended to the closing message of EVERY decline, and was removed hours later: that treats a talent passing on ONE project as a talent asking to be filtered forever, and it fires right after somebody said no, which is when they are least inclined to hear a settings question. Nothing fires now unless STERLING called a tool. He raises it in his own words when they are telling him something should stop ARRIVING (“you keep sending me these”, “this is the fourth logo job this month” — the tell is that they describe what keeps showing up, not this one brief) or when they ask about it themselves. Ordinary frustration is named in the prompt as explicitly NOT a signal, with examples, because the failure is asymmetric: a missed signal costs one more unsuitable brief and they can raise it any time, while a false positive filters somebody who was merely annoyed and they stop hearing about work they would have taken, invisibly. Offered ONCE; pref_ask_refused now rides into his context as a NOTE, because the refusal lives in a table rather than the thread he reads, and without it he would re-offer on the next bad brief. He calls propose_seller_preference AND send_reply but never describes the change in prose, since the system appends the exact stored diff for approval. A fuzzy complaint is not a number. The preference tools are the ONE exception to the closed-thread “send_reply ONLY” instruction, because a decline is what closes the thread and the answer always arrives on it. Six live scenarios pin the JUDGEMENT (committed, not run — the suite is ~$55 and needs per-run approval). 2026-08-26 rewrote the reply half and then switched the whole feature OFF (see §10): he now restates the CONSTRAINT he heard, ALL of them when several arrive at once, still never the resulting STATE, and asks “anything else?” exactly once at the end of that first reply; an unclear answer gets one plain question rather than a guess. The talent-facing closing message is the copy Gili wrote and shows the same render_current snapshot they would get by asking what is set, so the two can never describe one stored row differently, with only the CHANGED facet bolded. 2026-08-31 turned the feature back on and cut the reply to two sentences. It was the restatement, then a “Here is what I would save:” table naming every facet, then “Reply yes and I will save it.” The table said in a second voice what the restatement above it already said, and the “yes” gated nothing, since an operator confirms a preference before it filters a single client search (migration 0175) and that gate is untouched. It is now the restatement plus the two sentences Gili pinned, both of them CONSTANTS the handler appends verbatim rather than lines STERLING phrases, and the capture submits itself in the same turn — the only honest reading of “I’ll update my memory” with no “reply yes” to follow, since a row left at proposed would make that sentence false and would never reach the operator queue at all. Asking once also stopped being a property of the model remembering and became a property of state: the handler passes whether a proposal is already pending, so a follow-up folds the new item in and closes rather than re-asking. He also takes WORKING HOURS now, and the prompt is explicit that they are not a filter and must never be offered as one: a talent who states hours is still found, still ranked, still shortlisted on every brief at every hour, and what changes is only when their first message arrives and a small nudge in their favour while they are inside the window. Pointing somebody at the wrong lever leaves them believing they are protected by a boundary that protects nothing. With SELLER_PREFERENCES_ENABLED=False none of this block reaches his prompt at all.
  • The criticality check is FIXED COPY he sends, not a description he paraphrases (2026-08-24). The two sentences are pinned verbatim — “Waiting for a response may affect your chances of making their shortlist. Is this question essential right now, or can it wait until you’ve connected with the client?” — and he may add at most one short warm line reacting to what they asked. This is the second place in his prompt with verbatim copy after the opening outreach, for the same reason: it is product copy, and a paraphrase of it is a different offer to the talent. The check stays METERED rather than enforced (see the rule below); what is unproven is whether the model reproduces the sentences word for word, and if it paraphrases the fix is a seam, not a stronger prompt line.
  • A talent who asks whether the client picked them gets an ANSWER, from a recorded fact (2026-09-09). STERLING carries a CURRENT OPPORTUNITY STATUS block on every turn but the opening: the run status plus two verified booleans, was THIS talent selected and was ANOTHER one. The policy around them is most of the block, because every wrong answer here is a real harm to a real person. He speaks only when ASKED, never as an unsolicited update and never by going back to the client. A later pick of somebody else does NOT undo their own selection, since a client may continue with several talents. Another talent being selected is said kindly and with BOTH qualifications attached, that we cannot confirm an order was placed and that the client may still come back to them, and it is never described as a confirmed hire. Neither flag set means "no selection is recorded", which is explicitly not the same as "the project is over" — a completed, cancelled or fallen-back run proves nothing about a pick, and neither does a revealed shortlist, a closed proposal window or this thread closing. Unavailable information is said to be unavailable rather than filled in from an assumption, and no other talent’s name, price, proposal or selection reason is ever disclosed. The closed-thread branch changed with it: a conversation that has ended no longer implies somebody else was chosen, and a status question there is answered from this block instead of getting the generic closure note.
  • The first outreach greets the talent by NAME, and a close-out is chosen by what they DID (2026-08-24). The opening prompt had said “or the talent’s first name if you know it” while nothing ever told him the name, so every first message opened “Hey there!”. SterlingContext.talent_name now carries SCOUT’s persisted display_name, else the title-cased username — the same precedence the deal push and the finalist card already use — and the opening instruction pins Hey <name>!, which retires the shared generic-opening cache path because the name alone makes every opening talent-specific. Separately, the note a non-finalist receives is chosen by their behaviour rather than by thread STATE: a banked proposal gets declined, someone who wrote but never quoted gets no_proposal (which credits the conversation), and someone who never wrote a word gets no_reply. timeout_closeout is retired: the silence timer measures from the talent’s LAST message, so a talent who exchanged several messages and then went quiet was being handed the ghosted-us wording. The read that decides excludes gateway-marked automation rows, because an inbox auto-responder is not the talent writing back.
  • A question is worth the client’s time only if the TALENT says so, and a finished conversation is allowed to end (2026-08-23). Every question STERLING puts to the client parks that talent’s proposal until an answer comes back, and while they wait the client’s other candidates are finishing theirs, so a talent can lose a shortlist place to a question that did not need asking. He no longer escalates on the spot: the first time any question comes up, his send_reply puts the choice to the person it delays — waiting can hold you up and may lower your chances of making the shortlist, so is this critical for you to estimate the brief, or would you rather settle it with the client when you meet — and only a “critical” answer escalates, on a later turn. It runs ONCE per question, it covers the explicit “please ask the client X” too (never deflected back at the talent, just checked first), and it never applies to anything he can answer from the brief, the chat, the dossier, the files, or an answer the client already gave. The gate is a prompt rule, METERED rather than enforced: escalate_to_client carries a required talent_confirmed_critical, but the handler forwards the question either way, because binning one he has just promised to ask leaves a talent waiting on an answer that never comes — the failure this repo has already paid for twice. Separately, stay_silent(reason): once the proposal is registered, nothing is outstanding either way, and the talent’s last message asks and requests nothing, he sends NOTHING. It closes nothing, and a real question, a request, a new fact or a client answer puts him straight back into replying. The tool is only offered where silence strands nobody, the prompt’s end-slot kicker reads the same predicate so the two cannot disagree, and the silent branch writes NO thread state — the default awaiting_seller is the exact state the silence timers sweep, and a timeout backfills a REPLACEMENT talent, so declining to answer “thanks” must never cost a talent their place.
  • A chat reply is not a NOTE, and the card stops quoting one (2026-08-20). The finalist card renders personal_note in quotation marks, as the talent’s own voice, and STERLING’s last question asks for one. When the talent answered that question with something that was neither a note nor a refusal, the slot never closed and BOTH agents filled it from the transcript instead: a tester saw “YES ALL OF IT” printed on a card as “I can cover all of it.”, and “YES I WANT TO DO THAT FULL SCALE PROJECT” as “I want to take on this full-scale project.” Both prompts allowed the note to be “lightly cleaned up”, and that permission is what turns a two-word reply into a sentence. It is gone: the note may only be what the talent DELIBERATELY wrote when asked, in their own words, and an answer that is not a note means there is NO note. An answer to any other question — scope, coverage, price, delivery, why-you-fit — is never a note, however enthusiastic. Measured live against the reproducing transcript: PORTIA 5/5 fabricated → 0/6, STERLING 1/3 → 0/6, while a talent who DOES write a note still gets it through in their own words (8/8 across both agents).
  • Client-approval gate. start only drafts; approve_and_send refuses without a recorded approval. No path bypasses it.
  • Pure brain, no I/O — and tools only. One LLM call, zero side effects, tool_choice:required; a turn with no usable tool call degrades (ok=False), never guesses. The handler owns all persistence and push.
  • Never invent an offer — if neither the regex nor the LLM fallback can extract one, the thread stays open; no fabricated scorecard.
  • Guardrails: stay on-platform, decline out-of-scope, sanity-check price/timeline vs budget. (Full client disclosure to talents is allowed — a deliberate decision, separate from the client-side anonymity wall.)
  • The proposal is banked at the END OF BLOCK 2, not after the optional extras (changed 2026-08-05). register_offer now fires TWICE. The first call closes block 2 the moment the third validation lands: it carries the terms, JUNO reviews there, the offer is banked and the collection deadline arms. The second is the wrap-up after block 3, carrying the SAME terms plus the finalist-card fields, and it MERGES the card copy onto the banked offer without re-scoring it (deciding that before the scorer matters: scoring and discarding would still spend the JUNO call and still let a re-score reject a proposal the client is already holding). A registration that MOVES price, currency or delivery is still a re-quote and still faces the bar. The bug it fixes: the two optional extras stood between a talent finishing their validations and their proposal existing at all, so a talent who went quiet during those questions left nothing scored behind and their price never reached the client. Because the offer banks earlier, the shortlist can be revealed while block 3 is still running, so a personal note routinely lands on a card the client is already looking at, and PORTIA re-composes that one card when it does, keyed once per thread.
  • Two standing carve-outs from escalation, and one standing FAQ (added2026-08-04). Samples / test runs / trials join credentials as a thing he explains rather than escalates: he cannot arrange one, so promising to check leaves the talent waiting on something that is never coming — instead he says the sample gets agreed directly once client and talent are connected, tells them their estimate may assume a pilot step, and is explicitly forbidden from reading a sample request as a narrower scope (a tester review found him escalating a test-email request the client then agreed to with nothing able to send it, and reading one offered sample page as a talent's whole coverage). A _PRODUCT_FAQ block now rides every chat turn including a closed thread — say plainly he is AI, what the service is, and an honest non-answer to "can I get one" — because a talent asked how to set up an assistant like this one and got an invented answer about posting projects and contacting Fiverr Support. And a bare acknowledgement ("ok", "thanks") is answered with ONE short line that repeats nothing: send_reply is required every turn, so the paragraph it used to earn was the tool policy showing through the voice.

JUNO concierge proposal judge · the verdict

Returns a PASS/FAIL verdict on every talent proposal — never a grade (spec 2026-07-16). It judges three things: whether the price fits the client's budget (since 2026-09-23; before that a code gate did it), whether an inside-deadline estimate is credible, and whether the scope honors the brief. Only a late timeline is still checked by exact arithmetic in code before JUNO sees the proposal. STERLING parses the terms; JUNO judges them.

File concierge/tools.py Prompt full prompt ↗ Model agent_model Output forced JSON verdict
Invoked by
The seller_turn handler the moment STERLING flags an offer — score_offer_terms_with_brief (terms STERLING parsed from the whole thread) or extract_custom_offer_with_brief (extraction path), both routing to the one canonical scorer _score_offer_llm. One verdict per offer, no re-parse.
Consumes
The parsed offer (price, timeline, revisions, inclusions/exclusions), the talent's latest message and any files the TALENT attached, the full concierge⇄talent negotiation thread (talent_conversation — the whole back-and-forth STERLING had with this talent, folded in since 2026-07-19 so a commitment made anywhere in it counts, not only in STERLING's structured terms or the latest message; capped generously at the last 40 messages × 1000 chars), plus a slim brief view — summary, budget_min/budget_max, delivery_max_days, must-haves, industry — the client's intake conversation, excerpts from the client's uploaded files, and the client's private client_preferences (languages, countries, notes). It judges the offer relative to that brief, not on generic heuristics.
Produces
timeline_ok + timeline_note, scope_ok + scope_note, and a one-sentence (18-word max) rationale that must reference the brief and never mention price (read only when the proposal passes everything). The verdict is BINARY (spec 2026-07-21): it maps onto the stored fit_score via _verdict_fit_score — FAIL → 0, PASS → 100. There is no scope_rank grade any more; fit_score is ONLY the validity gate, and order among passers is decided elsewhere (SCOUT shortlist position, then seller standing — swapped 2026-09-14 so the cards and the badge sort alike; see §10). The five sub-scores and scorecard_breakdown were deleted with the grade model; the field stays empty. An LLM error degrades to the code-computed budget+timeline gate — it passes only if both arithmetic checks clear, otherwise it fails — never a mid-band grade.
Triggers →
Feeds the matching engine: only offers clearing OFFER_VALID_FIT_MIN (60) — i.e. the ones that PASSED — count as "valid" and drive the collection window + the present/keep-waiting call. Since 2026-09-08/09 that window is the BUYER's, not the arrivals'. It used to be armed by the first qualifying proposal and SHRINK with every one after it, so how long a client waited was decided by how fast talents happened to answer and a quick third proposal could end the hunt fifteen minutes in. The client now picks the duration up front (2 / 4 / 12 hours), the countdown starts with OUTREACH so the waiting screen can promise a time, and nothing shortens it: the arrival-armed deadline and the all-terminal early reveal both stand down for these runs. At the close every qualifying proposal is presented, uncapped, with the top-N marking the fold rather than the backend declining whoever ranked fourth. The TALENT is never told about that clock (2026-09-10, a product decision that REPLACES the sentence this bullet used to carry). Each talent used to have the window appended to their opening message at the send seam, relative to now rather than as a clock time, with the late wave told what was LEFT; that paragraph, its humaniser and both call sites were removed whole rather than switched off, so STERLING neither knows the run has a window nor mentions one. Her prompts never carried it in the first place, which is why deleting the append is the whole of it. Every talent still hears the close in the copy that matches what they actually did. Ranking itself (rank_offers) is deterministic, and since 2026-08-02 it sorts on (fit_score // 10, scout_rank, standing, received_at): JUNO's offer verdict DOMINATES, in BANDS of 10 (a 2-point wiggle on a 0–100 score is judge noise, not a verdict, which is what keeps the 3100529163 protection — an 82-vs-80 must not beat a Pro's standing), then SCOUT's resolved shortlist position, then the standing ladder (Gili's — Pro/Top-Rated → level → orders → credibility-weighted rating), then arrival. The old key put STANDING first, which let a high-standing seller win Mira's Choice before fit was ever read: the German-designer incident (Monday 3123812501) crowned an EN-only Pro/TRS over the perfect-fit, SCOUT-#1 talent. SCOUT's rank already encodes craft fit, misses of stated musts, and quality (which itself includes standing), so rank outranking raw standing is the correct order. fit_score is still a binary pass/fail gate rather than a grade — banding it is how a binary verdict participates in the sort without becoming one.
Hard rules
  • The PRICE is JUNO’s to judge, and the hard-coded budget gate is gone (2026-09-23, Monday 3241012777). budget_gate and its tolerances are deleted; the same bands (±20% around a single figure, ±5% past either end of a range) and the same rate-when-there-is-no-one-time-budget reading now live in her prompt as a PRICE section with worked examples. She returns price_ok + price_note in her strict schema (offer_tagger.json, regenerated from _OFFER_SCORE_SYS_PROMPT); the note leads the budget mismatch: rationale STERLING re-quotes on. New by product decision: a quoted price RANGE passes when ANY part of it is inside the band. The wording was replayed on 500 prod offers with her exact input first (496 followed their own rules; of the four misses three were right on the business). The late-delivery gate stays code; when her call fails only that gate decides, the price goes unchecked, and the verdict is metered (recruiter.concierge.offer_score_degraded).
  • JUNO’s verdict instruction is a committed FILE (2026-09-02). tagging/schema/offer_tagger.json is the source; the same file feeds the offline offer_tagger, which reports the verdict, her price call (built-in tag offer_price_ok, 2026-09-23) and the one arithmetic gate left in front of her, so “which check failed” is finally a column rather than prose in a rationale string. Since 2026-09-23 the offline tagger replays her EXACT scoring payload (_score_offer_llm reports it as rendered, the tagger resolves it from tag_subjects, the 60,000-char cap there is logged and metered): it used to build a page of its own from the offer row and the frozen brief, with none of the talent conversation, client answers or files she reads, so its answers could not be set against hers.
  • An ongoing quote is compared per PERIOD against the committed rate (2026-08-16). When no one-time bounds exist, JUNO’s budget gate compares a per-period quote against the rate the client committed, its payload carries the engagement context, and an ongoing offer arriving with no delivery estimate defaults to the first cadence period instead of dying at the door (metered).
  • PASS or FAIL, never a grade — the verdict is strictly binary (spec 2026-07-21), with no 0–100 scope_rank behind it. Order among the talents who passed is decided by rank_offers, not inside JUNO — but since 2026-08-02 that order LEADS with JUNO's verdict, banded in tens, then SCOUT's shortlist position, with seller standing demoted to a tiebreak beneath both.
  • The budget was not JUNO's to judge until 2026-09-23 (history; see the rule above) — code checked it first, symmetrically: BUDGET_NUMBER_TOLERANCE (±20%) around a single figure, BUDGET_RANGE_TOLERANCE (±5%) around each bound of a real range. A missing or non-positive budget passes; we never fail an offer over data we don't have. 2026-09-17 — and on a RATE-priced brief the gate compares rate against rate (Monday 3231047488). Prod run 94d7b7de wanted an ongoing developer at $28-$40/hour; STERLING’s register_offer called the price a “Total fixed price” and the phase-plan block said “the TOTAL decides”, so a talent who said “$35 an hour” was asked for a Phase 1 total and registered at $2,800 — which the gate, reading a rate-priced brief PER UNIT, refused as 6900% over $40/hour while JUNO’s model passed it. Six of the run’s twelve quotes went that way, and the gate never saw the client’s $28 FLOOR at all, so $28 was refused as too low. tools.rate_bar is now the ONE reading of “priced per unit” (no one-time bound, a positive rate), shared by the gate, STERLING’s tool and his prompt: tools_for swaps in a register_offer whose price_usd is the RATE, the prompt carries _rate_pricing_block in place of _PHASED_ARC, and the gate takes a rate_min (a stated rate BAND gets ±5% at each end, a lone rate keeps its ±20%). Each offer also logs one offer_verdict line (failed_checks, model_passed, money_bar), because JUNO’s trace holds only the model’s half of the decision.
  • A late estimate is arithmetic, not judgment — an estimate more than TIMELINE_LATE_TOLERANCE (10%, rounded up to whole days) past the deadline fails in code. JUNO only rules on whether an inside-deadline estimate is believable: beating a large scope by a wide margin is suspicious, not impressive.
  • The client's LATER amendments outrank the frozen brief (added 2026-08-05, Monday 3140586171) — JUNO scores against a brief_snapshot frozen at run start, and a client answering one of STERLING's escalated questions mid-run updates the LIVE brief but never that snapshot. The answers now ride the scoring payload as client_amendments (both entry points, last 8 × 600 chars, unanswered questions dropped) and win where they disagree: a requirement the client released is no longer required. They can only ever RELAX the brief, never add to it. The bug this closes cost a real hire on prod that day: a client confirmed placements were not guaranteed and that two items were out of the first month, and seven minutes later a German native speaker quoting exactly that scope at exactly the price was failed for it, while two later proposals with identical commercial terms passed on wording alone.
  • Preferences weigh, they never fail — a mismatch on language, country or seniority makes a talent a weaker fit, but is NEVER grounds to fail a proposal and is never a gap named to the talent, who could not fix it anyway.
  • Do not over-reject — a capable talent who covers the scope passes. Empty inclusion/exclusion lists, no assumption register, a missing pitch, or an unstated revision count are NOT gaps; the concierge collects a staged proposal, not a terms sheet.
  • Two fences on the negotiation thread — reading the full talent_conversation lets a commitment the talent made in an earlier message count, but it must not become a new way to fail: chat is not a terms sheet (nothing merely absent from the conversation can fail a proposal), and the concierge's own words are never the talent's commitments. Both fences (2026-07-19) keep the wider context from tipping JUNO into over-rejection.
  • Notes must be actionable — each note is relayed to the talent as ONE concrete change anchored to the brief. "Weak scope" is useless; "omits the mobile layout the brief requires" is right. A scope_note ends the conversation, so fail scope only when no re-quote could fix it.

PORTIA concierge finalist-pitch writer

The card writer. At the finalist reveal, PORTIA composes each of the ≤3 finalists' client-facing pitch in one LLM call per finalist — the honest, human case for why this talent fits this brief. It replaces STERLING's inline card-field gathering: STERLING negotiates the deal; PORTIA, given the whole picture at reveal, writes the card.

File concierge/portia.py Prompt full prompt ↗ Model agent_model · reasoning_effort: low Output forced JSON card
Invoked by
runner.finalize at the reveal (in the worker) — write_finalist_card per finalist. The same agent + prompt also backs write_search_pitch for a guest/direct search result (minus the offer + negotiation transcript).
Consumes
The brief, the talent's real Fiverr track record from seller_threads.seller_profile_json (skills, gig titles, order/review counts, rating, seller level, Top-Rated standing), and — for a concierge run only — the offer, an internal fit review, and the negotiation transcript. A best-effort candidate_detail enrichment (recent-review themes, portfolio count) is folded in before the tool-free call. Each finalist gets a rotated opening_angle so the ≤3 cards don't read alike.
Produces
A bounded card_json: fit_reasoning (one warm ~40–70-word pitch, the card's single fit prose), 3–5 real skill_chips, a short expertise_title, a fit_badge (only when genuinely exceptional), and a personal_note in the talent's own words. Persisted post-reveal-only on custom_offers.card_json.
Triggers
Terminal — it only writes the card. Any failure returns the caller's fallback_card unchanged, so a reveal never blocks on PORTIA.
Hard rules
  • A chat reply is not a NOTE, and the card stops quoting one (2026-08-20). The finalist card renders personal_note in quotation marks, as the talent’s own voice, and STERLING’s last question asks for one. When the talent answered that question with something that was neither a note nor a refusal, the slot never closed and BOTH agents filled it from the transcript instead: a tester saw “YES ALL OF IT” printed on a card as “I can cover all of it.”, and “YES I WANT TO DO THAT FULL SCALE PROJECT” as “I want to take on this full-scale project.” Both prompts allowed the note to be “lightly cleaned up”, and that permission is what turns a two-word reply into a sentence. It is gone: the note may only be what the talent DELIBERATELY wrote when asked, in their own words, and an answer that is not a note means there is NO note. An answer to any other question — scope, coverage, price, delivery, why-you-fit — is never a note, however enthusiastic. Measured live against the reproducing transcript: PORTIA 5/5 fabricated → 0/6, STERLING 1/3 → 0/6, while a talent who DOES write a note still gets it through in their own words (8/8 across both agents).
  • An ongoing shape is pitched from HIRING evidence, and time means availability (2026-08-16). When the brief carries an ongoing or hybrid shape, PORTIA reasons from recurring-work history and repeat clients (steady orders across time beats one-off volume), capacity for the stated workload, and long-running relationships — and frames time as AVAILABILITY, never as a delivery estimate, because delivery time has no meaning for an ongoing role. A signal it does not have is a signal it does not show.
  • Never invent — skill chips are verbatim real skills; no fabricated stat, client, or personal note.
  • Numbers only when exceptional — never a bare order/review count; a count only paired with a strong rating (e.g. "300+ orders at 4.9★"), else left out.
  • The pitch is the match, not the terms — never restate price, budget, timeline, delivery, or start date shown elsewhere on the card.
  • Degrade, never block — any error (or an empty result) returns the STERLING-drafted fallback card unchanged.

HERALD concierge activity-log narrator

Turns each concierge action into the one personalized line the client reads on the live dashboard ticker — distilling the run, the talent, and what just happened into a de-attributed, anonymity-safe sentence. It used to be MIRA's voice; now it's its own agent.

File concierge/narration.py Prompt full prompt ↗ Model agent_model Output one feed line (text)
Invoked by
The concierge worker handlers (seller_turn, the gated-send + discovery flows) whenever a row that produces a ticker entry is written — a reply, an offer, a decline, a forwarded question. Best-effort, in the worker only.
Consumes
Run + talent summaries (distilled once and cached on the run) plus the specific action detail. Three labelled prompts: herald_run_summary / herald_seller_summary (the reusable summaries) and herald_narrate (each individual line).
Produces
One personalized activity line, persisted next to the row that produces the ticker entry. The dashboard renders it instead of the generic de-attributed template.
Triggers
Terminal — it only narrates. Always degrades to the default template on any failure (doctrine 1.5).
Hard rules
  • Anonymity-safe — never a talent's name or a verbatim quote; a discipline + the gist only.
  • Degrade to the template — any failure falls back to the generic ticker line; never crashes the feed.
  • One line per action — short, client-facing, present-tense.
  • Distill once — run/talent summaries are cached on the run so a later line doesn't re-distill.

08On demand — HUE, the PDF template picker

HUE is not part of any turn. It runs when the client clicks "Export PDF." The button only records a turn and enqueues a job (doctrine: no slow work on the web cycle); the worker then runs HUE — one cheap art-direction call — and stores the chosen base template for the export. The export renders template-only and light-mode; there is no bespoke CSS.

HUE PDF template selector

Looks at a digest of the brief and picks the single base template (editorial/modern/classic) the export renders on. One JSON answer + a one-line rationale — no CSS, so the model can never drop content or emit broken markup.

File agent/brief_design_subagent.py Prompt full prompt ↗ Model agent_model · 75s Output a base_template choice (no tools)
Invoked by
POST /api/workspace/brief.export → BriefExportJob → the brief_export worker handler. Never in a web request.
Consumes
A token-cheap digest of the brief: project + summary, industry, brand accent hex, tone, block-type counts, section titles.
Produces
A result (base template, rationale). The handler persists the chosen template when the pick succeeds, renders the PDF on it, then publishes one brief.export.ready Pusher event carrying the download URL.
Call
One art-direct LLM call returning JSON: base_template ∈ editorial/modern/classic + a one-line rationale. The old design → refine CSS phases were removed.
Hard rules
  • One JSON object, no prose — the reply is parsed for base_template + rationale; anything else is ignored.
  • Choice is constrained to three templates — an unrecognized value falls back to editorial.
  • Template-only + light-mode export — no CSS is generated or inlined, so there is no injection sink to sanitize.
  • Degrade, never fail the export — an LLM error returns ok=false and the export renders on the stored or default template.

09The deterministic glue (not agents, but load-bearing)

Several non-LLM components do the unglamorous work that lets the agents stay focused. They have no prompt and make no model call — but the system breaks without them.

intent.py — intent classifier

Pure keyword/stage rules that label the turn (run_search, analyze_file, small_talk…). Feeds a HINT: line into ATLAS's prompt; the model may override it.

brief_checklist.py — gate & render

Joins the checklist template with MASON's persisted verdicts into the rendered block read by both lanes. The enable_search gate refuses while required items are missing.

dual_lane.py — orchestrator + handoff

Spawns the five lanes over one queue, holds the per-project lock, persists the fast reply early, and writes the team's tagged notes (atlas|mason|iris) for MIRA — verbatim, no compressor.

conversation.py — shared projector

One uncapped conversation projector MASON, IRIS, and PULSE all read, so they compose / classify from the same full transcript — including Mira's widget-asked questions (lifted from metadata.widget.question), not just the buyer's answers.

site_image.py — image harvester

Fans out over the client's own pages for og/twitter/icon/inline imagery (and a homepage screenshot for JS SPAs), behind an SSRF guard, deduped by hash. Feeds LENS.

concierge tools.py — pure functions

The deterministic outreach template, the regex offer-extractor, and rank_offers. The LLM offer-scorer is a separate call layered on top; ranking itself has no model.

LiveSellerAdapter — thin send

The only messaging adapter; its send_message is intentionally a no-op. The talent test-platform SPA and the seller_messages table are the inbox; the mock adapter/scenarios are gone.

agent_identity — canonical names

Stdlib-only telemetry-label → display-agent map (runner→atlas, …) — the single source the debug dashboard, the agent-trace store, and the per-lane OTel spans (gen_ai.agent.name) all name agents by.

Not in the active set

STYLO does not run as a per-turn lane; its durable trigger is the research chain (the old inert no-op lane was removed). The legacy lineage also carried a voice filler and a partner-note compressor; the compressor is gone (notes are verbatim) and the filler is not part of the active set. ATLAS's edit_identity tool is gone as well — identity authoring moved into the IRIS planner lane.

10Rules every agent obeys

Beyond each agent's own prompt, the platform doctrine binds all of them. These are why the design looks the way it does.

  • A rule the model must never follow is enforced where the DATA moves, not asked for in a prompt. Since 2026-09-24. The 6 Sep paragraph telling STERLING to refuse a client boundary on a protected class was live in prod when the same capture happened again on 8 Sep. One detector, recruiter/protected_class.py (a protected term next to a word naming who is being selected: “no Asian companies” goes, “no German to English translation” stays), now runs at four seams: the capture fold (and STERLING’s “noted” is REPLACED by the refusal), every write, the admin editor, and the read SCOUT filters with, which is what neutralises rows already stored. Metered per seam (recruiter.protected_class.rules_refused); the read count should decay to zero.
  • The client’s OWN conversation with a talent is a lane of its own, and no agent answers in it. Since 2026-09-24. Messages the product sends AS the buyer (the rail’s brief push, the concierge’s deal push) open a buyer↔talent Fiverr conversation separate from the bot’s, and until now nobody ever saw a reply. recruiter/inbox_mirror sends, receives and reads it, with files both ways (antivirus-gated links) and Fiverr’s own message kinds labelled (CUSTOM OFFER above the line, never instead of it). It has its own table (buyer_seller_messages) so a direct reply moves none of STERLING’s turns, the silence timer or first_reply_at: a talent answering the client must not look responsive to a bot still waiting on them. A reply with no handoff row is dropped and counted, never given one.
  • A model is swapped by EXPERIMENT, one per lane, never by editing the default. Since 2026-09-24. gpt-6-luna sits behind two config experiments, both at 0%: model.terra_to_luna6 over agent_model_default and model.luna56_to_luna6 over regional_agent_model. Two, because the lanes have different incumbents and one denominator would hide which moved; disjoint for free, because a project’s lane is frozen at creation. Unit is the project, and voice follows the arm. The scenario judge is pinned to the declared default so a cheap arm never marks its own homework.
  • An id the caller already holds is not optional decoration — JUNO judged the price knowing nothing about who is paying, and filed her verdict into a CHECK that dropped it. Since 2026-09-22. The frozen client dossier (the 2026-09-17 rule below, migration 0229) is fetched BY project id, and score_offer_terms_with_brief took none: only the rare text-extraction fallback passed one, while STERLING’s register_offer — the path almost every real offer takes — called the scorer bare. So the wiring that shipped with the tagger buyer-context change reached almost no live offer, and the same $1,800 read identically for a repeat buyer with a $2k average order and for a first-timer. The second cost was invisible rather than merely silent: her offer_tagger report was filed with no project at all, and tag_findings CHECKs that exactly one of project/corpus is set, so the write raised CheckViolationError and the finding was DROPPED, not mislabelled — 96 of them in 14 days on dev, against 2 for every other tagger combined. The scorer now takes project_id/subject_ref and forwards both to _score_offer_llm; the handler passes the run’s project and the offer’s own key, exactly as the extraction path already did. The comments calling those ids “for the tagging report only, the verdict is identical without them” were true when written and stopped being true the day buyer context landed. This changes JUNO’s model input on every offer, so a verdict can shift; the live scenario suite cannot see it (no scenario drives the handler, and every JUNO scenario calls the scorer with no project), so it was skipped deliberately rather than spent for show.
  • A window the client PICKED is a promise about when the shortlist is THERE, so the close is spent inside it, not after it. Since 2026-09-22 (Monday 3226572685). The buyer chooses 2h / 4h / 12h at the start-choice picker. Closing a run is real work — rank_offers plus one PORTIA card per finalist, an LLM call each — so collecting for the full duration started that work AT the promised moment and painted the results screen a beat after it. buyer_deadline_seconds takes REVEAL_HEADROOM_S (60s) off what arm_buyer_collection_window arms, and the reveal spends that minute. The headroom comes off the DEADLINE only: collection_window_hours keeps the buyer’s raw choice, so the waiting copy, the client’s view and repo/shortlist_timeframe still read the hours they actually picked.
  • A figure is a TOTAL to whoever reads it, so a price registered as a rate is quoted with its unit at every surface. Since 2026-09-22 (Monday 3231047488). The 2026-09-17 rate fix made STERLING register the talent’s RATE on a brief the client priced per unit, and every renderer of an offer price still printed the figure alone: on prod (16 Sep, run 94d7b7de) the closing note the client sent the talent they hired read “at $40, within 14 days” for a $40/hour quote, which reads as the price of the whole job. RateBar.suffix is what a price in that unit reads as after the figure ("/hour", "/article", plus " per person"); format_offer_price(offer, rate=) and offer_proposal_phrase append it, a quoted RANGE taking it once at the end since both ends are per hour. deal_push_template takes the run’s brief_snapshot and the ghostwriter’s AGREED OFFER block takes the brief, so the fallback note and the composed Price line both say “$40/hour”. A plain slash, never a long dash (doctrine 1.13) — this string reaches a client and a talent. The finalist CARD is deliberately unchanged: it keys its unit on a HIRING run, so a one-off priced per unit still shows a bare figure.
  • A thread leaves queued exactly ONCE, and a send loop that trusts its own batch is a race the client loses. Since 2026-09-17 (Monday 3226489277). On prod run 1674f407 the client approved outreach and pressed “Stop the headhunting” 12 seconds later. wrap_up closed the eight talents still queued and completed the run — and the send loop, which had read its batch once, kept going: every later dispatch sent its opener and wrote awaiting_seller OVER the closed the stop had just written. Eight talents were messaged after the stop, one banked a proposal nobody could reveal, seven were never told anything. Every writer now leaves queued through one compare-and-set (store.claim_queued_thread, UPDATE ... WHERE state = 'queued'): the dispatch asks the STORE, not its batch, twice — on entry and again after composing, because composing is the slow step — and withholds the opener if the run is over. A lost claim never revives the row, and the losing side is honest either way: if a stop won while the opener was on the wire, the DELIVERED opener is recorded and the talent gets the close-out the stop was too late to send. wrap_up, stop_talent and the reveal’s decline all close through the same claim, and the reveal no longer writes a no_reply row for a talent it never contacted. Both outcomes are METERED (recruiter.seller.dispatch_skipped{reason}, recruiter.seller.dispatch_raced{outcome}), because a race that loses silently is exactly the kind of thing a log cannot tell you the rate of.
  • Ending the SEARCH is not ending the client’s access to who it found. Since 2026-09-17 (Monday 3230391034). The red Stop still has MIRA tell every talent the client did not take over that the search is done; it no longer locks the client out of them. The client stays on the field (the stage settles the moment the stop is ACCEPTED, and the completed snapshot only goes out once MIRA has written to everyone), every talent she reached keeps “Send message”, and a project reopened later offers the same actions. stop_talent(takeover=True) lets a CLOSED thread through when it carries a conversation handle and no takeover note yet (_closed_but_takeable), so the talent hears the plain takeover note after the close-out; a replay, an already-taken-over talent and a thread MIRA never reached stay untouched. And the Stop no longer contradicts a takeover landing while it runs: wrap_up skips a talent the client took over, read afresh before each note and again after STERLING’s compose, so nobody hears “the client is coming to you” and then “not this time”. A PROPOSER taken over after the Stop is still a pick (the 2026-09-15 rule below): the pre-loop uses the same gate, and the gate keeps them reachable until the client’s deal note is actually out (deal_pending), so a transient failure retries instead of silently dropping the note.
  • Every judging model gets the CLIENT as evidence, frozen once per project, in the user half and never in the prompt. Since 2026-09-17. The gateway returns ~80 fields about a buyer; MIRA’s prompts rendered 12 and the TAGGERS rendered none, so a tag could be asked what a client SAID and never about who they ARE. The dossier is a 24h Valkey entry written at sign-in and a tagger asks its question WEEKS later, so a lookup would answer with today’s profile attached to a past event; migration 0229 freezes it once per project (first write wins) and every run reads that copy. Three properties make it load-bearing. It is EVIDENCE, not prompt: the live prompts are committed byte for byte under tagging/schema/ and pinned by a byte-equality test, so buyer context in a prompt would fork the product’s wording and leave the offline run measuring itself — in the user half, the live call and the offline run get the SAME block from the same function. It goes FIRST, because clamp_transcript drops the head, so a long conversation loses old turns rather than losing who it is with. And its header says whose data it is and that nobody SAID any of it, because a judging model is reading two or three parties at once and a tagger asking “did the client say X” must never find its answer here. personal_data is stripped BEFORE the write rather than filtered after; the table carries no bi_reader grant and the scrub map empties it on deletion; facts are bounded by construction (categoricals plus money BANDS, never a raw figure). One resolve per run (store.attach_buyer_snapshots) and one render in the base (with_buyer_context), so no tagger formats buyer data for itself and none can be left out.
  • A fact the ACCOUNT already holds is a column, not a tag — and a menu the definition does not define is a measurement of its own envelope. Since 2026-09-17. The platform could not answer “was this an SMB” for most conversations, so every SMB cut was drawn by hand. finalize_brief_buyer_type, created in the BI console on 2026-09-09, carried a five-value menu with its definition written as a YES/NO question, so the offline envelope asked the model to answer yes or no from [smb, solo_professional, individual_consumer, enterprise, unclear] or not_applicable. It escaped: over 1000 subjects, 252 produced no answer at all and smb fired once. Rewriting the definition to define every value on the menu (the SMB bar kept verbatim) moved that to 10 no-answers on the same scope, measured, not assumed. The deeper half is WHICH source decides: a 1120-buyer SMB cohort (2026-09-17) was 69% identifiable from buyer_identities.company_size alone, 24% from the warehouse’s self-declared size, 7% from Clearbit, while an offline model read of 1000 conversations found FIVE. Clients do not state their headcount; their account often does. So from_company_size decides it and the model fills only the gap, exactly as tagging/ARCHITECTURE.md already required. conv_tagger asks it every turn, the turn latches projects.metadata_json.buyer_type with buyer_type_source recording which decided it — not a ratchet: a later better-informed read replaces an earlier one and unclear never overwrites a decided value — and the admin explorer gets a server-side buyer_types filter, which was the point of the exercise. between_2_and_10 is deliberately NOT smb: the bucket straddles the bar.
  • Retention is a CONTRACT: nothing goes on a clock before its history twin exists, and deletion keeps the facts while erasing the content. Since 2026-09-17 (root doctrine 1.17). Postgres holds current state and the warehouse raw tier MIRRORS it, so a partition drop, an age-based DELETE, a reaper or a BigQuery expiry deletes from BI in the same breath — putting a table on a clock is therefore a contract change with three obligations. History first: mira_history.<table> exists and has loaded, or the migration header declares -- retention: <N>d, no-bi-effect: <reason>. Bank before you drop: any admin number that sums across the horizon reads a LEDGER folded forward behind a lagged watermark under an advisory lock, plus the tail newer than that watermark — and a ledger that borrows another’s watermark loses whatever an older worker advances past, which is why agent_trace_project_ledger keeps its own. Say how erasure reaches history: a history table keeps a row Postgres deleted only if every column it holds is a FACT under the account_scrub split, otherwise the history action has a parity step inside the 48-hour budget. Both gates run in ordinary CI, and an admin label claiming “all time” without a computed horizon fails its own test.
  • An agent handed its own output as a list of NAMES cannot reason about it, and will go and ask the client for what it already knows. Since 2026-09-15. render_context_block's LAST SEARCH RUN section rendered the revealed shortlist as name (id8) and nothing else, so after her own hunt MIRA had no craft, price, delivery, rating or fit line for any of the people she had just put in front of the client — and live over the MCP surface she did the only thing that context allowed and asked the client to send her the portfolios of her top two matches. Each revealed card now gets ONE line (craft, price, delivery, rating + review count, PORTIA's take or the fit summary) for the first five, with the rest of what is revealed NAMED after them up to twelve, so a fully revealed eighteen-card run costs a few hundred tokens rather than a page. The older rule is untouched and is the reason the block is windowed at all: it renders only what the client has actually been SHOWN (revealed_count), so no agent can name a talent the client cannot see. The general form: a context block is the agent's only memory of its own work, and an identifier is not the work.
  • Taking over a talent who has already PROPOSED is a pick, not an introduction. Since 2026-09-15. The waiting field's “Send message” ran one path for every talent: MIRA's takeover note followed by the search-lane job-description push (“I found your profile on Fiverr…”) — sent, in the proposer's case, to somebody MIRA had just finished negotiating a proposal with. stop_talent(takeover=True) on a talent holding an offer now follows MIRA's note with the CLIENT'S own deal-close note (the proposal recap with the brief PDF attached, the same note a carousel pick sends) and writes the buyer↔seller handoff as a concierge pick, sharing notify_pick's helper so the row is written once. The test is the BANKED OFFER ROW, never the thread's lane: a proposer can be parked on the client or back in conversation, so the frontend keys its half on FlowChip.hasProposal (an offer in offers_in_progress) and skips the search-lane push for them. Talents who never proposed are unchanged.
  • An experience that never reveals itself has to announce what the reveal used to announce. Since 2026-09-15. Group B of the waiting experiment shows every proposal on the living field the moment it lands and never fires a reveal, and the concierge's only “proposals are in” inbox ping lived inside runner.finalize — so a group B client who closed the tab heard nothing while proposals piled up, while group A's client got the reveal ping. runner.announce_proposal claims the run's proposals ROUND with an atomic conditional UPDATE (the questions-round shape, one column pair over, migration 0227) and sends a count-free message with a Review-proposals CTA; a proposal landing while the round is open joins it silently, and the round closes when the CLIENT LOOKS (POST /api/concierge/{run}/proposals/seen, sent only while the tab is visible, idempotent, and deliberately NOT a buyer action for the abandonment backstop). The next proposal after a look announces again. One channel, no push/SMS twins; group A is untouched; an ended run announces nothing.
  • A talent's OWN words to the client are the talent's, not the agent's: never normalized, and no longer withheld. Since 2026-09-15. STERLING asks every talent for a short personal note to the client and the finalist card has carried it since migration 0067, but the pre-reveal offer summary withheld it because free text can self-identify. That reason expired on 2026-08-18, when a proposal started carrying the talent's real name and face the moment it lands. AnonymizedOfferSummary now projects card_json.personal_note VERBATIM — no cap and, pointedly, no long-dash normalization, because §1.13 normalizes what an AGENT wrote and never what a person typed (the wall-guard test is flipped on purpose and now pins that an em dash inside a talent's note survives). The field renders it FIRST under the head as a person's message with their face beside it, not as MIRA's prose, and the Matches row previews one line of it.
  • A ranked list and the badge that names its top item sort on ONE key, and a binary judge means the TIEBREAK is the ranking. Since 2026-09-14. The client's finalist cards and the recommended_offer_id that draws “Mira's Choice” are two separate projections of the same proposals, and they had drifted apart: rank_offers was moved months earlier to put SCOUT's shortlist position ahead of raw seller standing (the German-designer fix), and _project_finalists kept standing first. Because JUNO's verdict is a validity GATE rather than a grade (PASS → 100, FAIL → 0), the band ties on nearly every run, so the tiebreak is what actually orders the deck. Preprod run f1d0f795 showed the result plainly: five proposals all at fit=100, the deck led by the talent with the most completed orders, and the badge rendered on card two. Under group A's cap of three the two lists stop agreeing about MEMBERSHIP, not just order. The key is now identical in both places — (pinned, PASSED, scout_rank, standing, received_at), banding replaced by a straight pass/fail on 2026-09-17 so a graded legacy or mock fit_score can no longer reorder passing proposals — because SCOUT already weighed craft fit, the client's stated musts and quality (standing included), so the search's verdict outranks the raw ladder, while standing still breaks an equal or absent SCOUT rank and keeps the zero-standing determinism guard intact. Keep the two keys identical. 2026-09-17, three more benders of that order, and the one case where the badge must name nobody (Monday 3223906785). The order held in the ranking and was bent on the way to the screen: the results deck RE-SEATED the best match into the middle of the opening three on desktop (seatBestFirst / seatedBestIndex, both gone), the concierge lane re-sorted the served list by score in the browser as the search lane had already stopped doing, and a talent approached from SCOUT’s reserve had NO rank at all because shortlist_rank_map read only the first wave (shortlist_seller_ids), so profile standing and arrival time ordered them. The map now reads the run’s whole ranked pool (candidate_pool_json) first and standing orders only talents SCOUT never ranked. And on a group B deck a reveal keeps every scored proposal, JUNO’s failures included, so when none passed rank_offers recommends nobody — while the deck still crowned card one, presenting a partial fit as Mira’s pick. The mapper now carries fit_flag onto the card and the stage names no Mira’s Choice when the first card is flagged (every pass is served ahead of every fail, so a flagged FIRST card means nothing passed). The anchor is that flag, never is_recommended, which a pick overwrites.
  • A commitment is judged ONCE, at the tail of the turn, on what the turn SETTLED — never on a half-written figure. Since 2026-09-14. set_search_preference commits a rate or a range across SEVERAL writes inside one turn, and since the handoff tier became a LEVEL (2026-09-14, 00:06) every reading below the tier released the client's booked call on its own — so the unit arriving before the amount, or a rate with no workload attached yet, cancelled a call the client had already booked. The tool's hook now records INTENT only; the worker judges the settled preferences once at the turn's tail (attention.is_below_handoff_tier), also after a turn that ends without complete, and an unreadable figure is UNKNOWN rather than a drop. lapse_handoff_tier hands the notice back for the worker to stream after complete, the disclaimer's pattern. The mirror half holds too: a later commitment back ABOVE the tier re-offers the card, a reschedule cancels every earlier confirmed booking at the vendor rather than orphaning it, and a burst guard keeps a re-offer from racing a booking.
  • A prompt is a set of NAMED SECTIONS, every section can be dialled or given a second version, and every agent answers to the name the RUNTIME uses. Since 2026-09-03. Fifty four prompts are now assembled from declared sections rather than concatenated constants, each rendering byte for byte what it replaced, so one section can be served to a share of people or replaced outright by a second version (agentkit/declare.py, agentkit/live.py, admin → System Admin → Prompt sections). Three properties are load-bearing, and all three were bugs first. A section that is computed fresh every turn (MIRA’s workspace picture, STERLING’s thirteen runtime blocks, SCOUT’s four capped prompts) is a DYNAMIC slot, a stable identity over a computed body, because otherwise the largest per-turn half of what a person is told has no name at all. A live prompt is slotted at the SEND SITE, around the exact string handed to the model, rather than by the name of the constant that is supposed to hold it, because name binding once attached three call sites to the wrong text by 43,000 characters with everything still rendering fine. And the registry keys on the LABEL the runtime stamps, not the module a prompt lives in: those had drifted on ten of fourteen entries, so the console’s MIRA tab showed ATLAS and the 65,814-character prompt that writes every reply a client reads was not registered at all, which is why the first voice experiment allocated people faithfully onto a prompt nobody was served. Every failure path in this package returns the whole prompt: a bug here may change what is MEASURED and must never change what a client is answered with. 2026-09-05 fixed the unit and the record. Assignment moved from the PERSON to the PROJECT, because the readout measures a project’s outcome and one client with five projects was contributing five correlated observations counted as five independent ones, which understates the spread and makes a null result look significant; no project now means NO ARM rather than a fallback to the person, since a fallback would put a conversation’s first turns on one voice and its later ones on another. The record was wrong in the same direction: the slow lane filed under mira while serving atlas.* sections, so the table that explodes a turn into one row per section and looks the version up BY SECTION KEY produced 21 rows all reading default and the arm never became a row at all. The slow lane records as atlas, the fast lane records its OWN 35 sections and its own arm from the same call that performs the swap, and the arm is reported on the CONTROL too, because “nobody was on the experiment” and “the experiment never ran” are different facts. The same day the console started listing the 25 slots declared at a SEND SITE, which a walk of the registry can never find because their text is built per call; they are listed as a synthetic part per agent and marked NOT dialable on purpose, since a dial needs the builder and these are chosen at the call site, and a slider reporting a number over a prompt it cannot change is the exact failure the console exists to make visible. 2026-09-06 added INJECTIONS, and the shape is the whole idea: an injection is a section whose DEFAULT approach is the empty string, with two competing approaches carrying the text (always, appended every turn; on_event, appended only on a turn where its trigger fired) under ONE roll, so a project is in exactly one of three comparable groups and the question “is detecting the moment worth paying for, or should we just always say it?” becomes askable. Everything else comes free because it is a section, and it needs no new model call: on_event waits on a verdict the worker already computes and already awaits (attention.classify_attention_llm), so the text lands on the turn the moment is about, and whether the reply WAITS for that trigger is declared on the card rather than inferred. It is injected into the LAST static block, not the first, because appending to the first pushed MIRA’s 22,730-character widget prompt past the cache divergence point (agentkit/injections.py).
  • A query on the request cycle is bounded by the PAGE, never by the TABLE — and a LIMIT bounds neither. Since 2026-09-08. /api/admin/users was paged in August and went on returning 504s for another two weeks, because paging bounds the rows RETURNED and not the rows SCANNED. Two shapes cause almost all of it and a LIMIT hides both: an ORDER BY no index can serve (a correlated subquery, a GREATEST, a jsonb extraction) must be computed for every candidate row before it can pick the top 200; and a per-row subquery in the SELECT list is projected at the scan, so it runs once per scanned row whatever the LIMIT says. Measured on a prod-shaped fixture (158k users, 77k projects, 1.6M messages) the first page took 71 seconds; the two-phase rewrite — an inner query that picks the page using only an indexed ordering key, an outer that computes the derived columns for the ~200 rows that survived — made it 220ms with a byte-identical payload. So: the default sort of a paged list is a real indexed column, a per-row probe becomes one grouped pass per source table, and a whole-population aggregate is legitimate work that belongs in the WORKER against the read-only analytics pool, with the request reading the materialized snapshot. Raising the request timeout, indexing to make a full scan tolerable, or capping the rows so it “usually fits” are the same mistake in three costumes. Now doctrine §1.16, with local halves in the repo and web CLAUDE.md files, because paging LOOKED like the fix in August and was not.
  • A widget message is FROZEN the instant it is appended, so anything that can change under it needs a VERSION. Since 2026-09-07. A widget is written into the conversation under an id derived from the project and inserted with ON CONFLICT DO NOTHING, which is exactly what makes a replayed job safe — and exactly what makes the card unchangeable afterwards. The call-booking card exposed the cost: a guest is redirected into sign-in mid-booking, as designed, returns to the same conversation, and the card in front of them still opens the vendor’s pop-up, because signing in has no way to rewrite a message already written. The widget and message ids now take an optional VERSION (v1 is byte-identical to before, so nothing already on screen moves); a sign-in mints v2, a second mints v3, the queue-level idempotency key carries the version so a genuine refresh is never deduped away by the first card’s own key, and the project is recoverable from an id at ANY version so every older card still recognises a click. The live-push turn id is versioned with it, since a v1 turn is claimed and completed forever and a refresh that reused it would find nothing to claim. The refresh is unconditional on whether the client’s capability actually improved — a no-op refresh just reproduces the same card, which is cheaper than precomputing “did anything change”.
  • Knowing WHO somebody is, is not knowing HOW TO REACH them, and a number is neither a name nor an address. Since 2026-09-07. A signed-in Fiverr buyer is a numeric subject to us; their display name is MINTED from that number, and the attested sign-in address had deliberately never been stored, on an identity-hygiene policy written long before anything needed to email a client. The consequences surfaced together on one feature. The booking card asks “do we have somewhere to deliver this” before it offers to book, and that answered NO for nearly every real signed-in buyer, not only for guests, so nearly everyone got a vendor pop-up instead of booking in the conversation. The calendar invitation, meanwhile, was handed the minted name verbatim, so the meeting read as seven digits and the host. Both are fixed by naming the sources rather than by trusting the identifier: the address now resolves from the identifier where that IS an address, then the attested capture at sign-in (a table of its own, one row per person, deliberately NOT a column on the identity table, because that table carries a blanket warehouse read grant this must never ride), then the gateway dossier; and where NO source has one, the card ASKS in the conversation and keeps the answer only if the booking succeeds. The invitee name resolves the way talent-facing copy already did, every candidate gated against the minted default, with the real address as the honest last fallback. A guest, who by construction can never have an address, is sent to sign in rather than to the vendor, so no booking claim is recorded for a booking that could not have happened.
  • Which MODEL an agent runs on is decided by where the client is, once, and frozen there. Since 2026-09-03. Browsers reporting one of nine countries (IN, PK, BD, NG, BR, LK, EG, MA, KE) put the project on gpt-5.6-luna for every agent; PULSE rules those clients low by rule with no model call so the concierge is never offered; and both the project and the person carry the tier as a stored tag the admin Explorer filters, columns and chips. The country comes from the SPA’s own IANA timezone (with the backward aliases Chrome still reports) through a generated zone-to-country table, then the locale’s region, then the edge network’s guess; it is frozen at project creation the way the created-device tag is, and written once per user. Routing is a property over a per-job context variable the dispatcher sets, so every existing settings.agent_model read follows the lane with no call site changed (migration 0195).
  • A classifier’s prompt lives in a committed FILE, and the file IS the prompt. Since 2026-09-02. A tagger’s questions used to live in three places at once: the prompt the product actually sends, a transcription of it kept beside the reporting code, and editable rows in the database. Nothing could tell you when the three stopped agreeing, so editing the real prompt silently made the other two lie and the report kept printing numbers under the old wording. Each prompt is now ONE JSON file under recruiter/tagging/schema/ whose entries carry the EXACT paragraphs, and build_prompt(load_schema(…)) reassembles what the model is sent byte for byte. Nine live call sites read from those files now, ATTENTION (conv_tagger), PULSE (finalize_brief_tagger), JUNO (offer_tagger), the attachment relevance read, the guest funnel’s relevance and experience scorers, the fast lane’s batch grader and the two clarification reads; attention._CLASSIFY_SYSTEM survives only as _CLASSIFY_SYSTEM_FROZEN, a test fixture. All ten blocks were GENERATED from their constants rather than retyped and verified character for character against a checkout of the pre-change code, and tests/test_tagger_schema.py pins it in BOTH directions: a prompt a tagger reports from that no block carries fails the build, so a call whose questions still live only in its own code cannot hide. Editing a question is therefore a visible prompt change, reviewed like any other, instead of a second opinion sitting beside the real one.
  • The reader reports; what a fired event DOES is somebody else’s job. Since 2026-09-02 the tagger is an agent in its own right (agentkit/tagger_agent.py, TAGGER_SYSTEM) with three properties worth stating as doctrine. (1) It runs after the turn and the turn never waits for it — dispatch() is deliberately not a coroutine, the answer is already with the user, and an agent with no declared events costs nothing at all: no task, no model call, no row. (2) The description is the whole recognition rule, so an event can be added or reworded without a deploy touching the agent it watches, and an event the model names that nobody declared is DROPPED rather than stored, because the vocabulary is the declaration and not the model’s memory. (3) An event it cannot decide is not a “no”. A wrong no reads downstream as evidence the thing never happens, which is worse than an honest gap: a gap is visible, a false zero is indistinguishable from a clean result. Events are asked eight at a time because one prompt carrying forty questions gets forty careless answers, and a batch that fails loses only its own answers. Separating reporting from acting is what lets a behaviour be measured first and given an action later. 2026-09-06 — the agentkit half of this was deleted, and the doctrine survives on the other one. The event / tagger / dispatcher / store machine described above (agentkit/tagger_agent.py, events.py, actions.py, catalog.py, TAGGER_SYSTEM) had shipped and never once run: no agent ever declared an event, two of its pieces had no implementation outside a test, and it duplicated recruiter/tagging — a second agent, a second prompt and a second vocabulary for “did X happen in this turn”. The three properties above remain doctrine and are held by the tagging package, which is the one that actually runs; what the machine was BUILT for, acting on a fired event, is now an injection (see the sections rule above), which reuses the verdict the worker already awaits instead of asking a second reader the same question. The eighth tagger, and the mode that could block a turn. sterling_turn_tagger (2026-09-06) is the first reader whose subject is a whole talent THREAD rather than one turn, and the only one whose prompt is not transcribed from a live call, because nothing in the product judges a STERLING thread today — a question nobody has asked yet has no live answer by definition, and the run is what answers it. Its subject kind is load-bearing: whether a talent was brought work they do not take is answerable only from the brief plus what they said back, and the decline carrying the answer is the LAST thing in the conversation. It pays for itself on a number nobody had read: 43% of production’s 6,123 declines are out_of_scope and 31% are budget_too_low, roughly 4,500 threads in which the person best placed to judge the match already said it was wrong, in their own words, in decline_detail. Deleted with it: TagMode.BLOCKING, gate(), gate_timeout_seconds and fail_open, which had zero callers in a year and encoded the pattern this system exists to prevent — a tagger may be sampled, may time out and may be switched off, so nothing the product depends on may ever wait on one.
  • A boundary a talent states is keyed on their USERNAME, and a capture that cannot be keyed FAILS rather than confirming. Since 2026-09-01. Every standing-preference read and write used to key on seller_threads.fiverr_seller_id, and migration 0091 says outright that the column is NULLABLE and that NULL is a NORMAL state: rows predating it have none, and the hand-picked shortlist flow never routes through the gateway that supplies the number. Dev logs that day showed five captures lost across two threads, every one answered “Only projects over $1,000, noted.” Same code, same messages, different thread provenance. The number was never the right key for this table — it was added for a BI join (migration 0111, because the Fiverr warehouse keys on a numeric user id and a username joins to nothing there) and enforcement inherited an analytics requirement — while BOTH ends of enforcement already carry the username and always have: seller_threads.seller_id is TEXT NOT NULL (migration 0018) and CandidateSeller.username is str, with SCOUT’s pool literally keyed by it. A Fiverr username cannot change, so the one argument for the number does not apply. Migration 0181 re-keys both tables and DELETES rather than backfilling them, since the feature had been live one day on dev and was off in production throughout, so no real talent had a boundary worth carrying. Three enforcement seams moved with it: the outreach floor gate, whose own comment admitted a thread with no number “is UNGATABLE here and is deliberately let through”; the finalist card’s availability slot, which silently degraded to “to be confirmed” for the same class of thread; and discovery’s key normaliser, which now lower-cases every map the way the repo does, because the thread’s stored id and the gateway’s username are free text from different systems and a lookup that silently misses is the whole failure being ended here. The half that generalises past preferences is the OTHER one: the unkeyable path used to return "", so no row was written, no block was appended, and a warm confirmation shipped regardless. It RAISES now, the turn fails, no reply is sent, and recruiter.seller_pref.unkeyable moves. Doctrine §1.5 asks for graceful degradation in UX: silence degrades, and the talent writes again while an operator sees the failure. A false promise does not degrade at all — it is indistinguishable from success to the only person who cares.
  • The same agents answer inside Claude and inside ChatGPT, on the same turn pipeline. Since 2026-08-31, extended through 2026-09-02. Mira ships as a remote app on the MCP standard rather than as a second product with the same name: the chat tool claims and enqueues the ORDINARY turn, with the same idempotent claim, the same spend budget and the same turn-slot gates the web route has, waits on it by bounded poll, and assembles the reply and the widget from the persisted events, so nothing about how a turn runs is duplicated. It is its own Cloud Run service off the shared image behind one new role. 2026-09-01 moved the wait onto Mira’s FAST lane: it had been awaiting the entire dual-lane turn, ATLAS and the brief rewrite included, while the SPA shows her reply the moment the fast lane streams, so it returns on the first persisted reply, widget or error and the slow lanes drain into the card afterwards. A state of working with a reply already present is the normal shape there, not an error. Two lessons the surface taught that generalise. (1) Omitting a project id is not starting fresh. The worker’s fallback attaches a turn with no id to the user’s NEWEST project, which is right for a dropped thread and wrong for a new role, so every new hiring need overwrote the previous brief; the tool now takes an explicit new-project flag that creates the row BEFORE the claim, SPA-style, with the id derived from the same content key the claim already dedupes on so a host retry re-creates the SAME row and converges. (2) A destructive tool is a ritual, not a sentence in a prompt. Deleting a project does nothing without a separate confirm parameter and reports what the project holds first, and erasing an account acts only on the literal word DELETE, because a model reading a conversation is one misreading of “clear that out” away from destroying a brief. (3) Tool DESCRIPTIONS are the contract; instructions are a bonus. 2026-09-02 dumped the real tools/list and found the entry point had almost no pull: every description was written for somebody already inside Mira, so none of the words a person opens with (hire, find someone, freelancer, I need a logo, who can build this) appeared anywhere on the surface. Worse, the guardrail the whole relay design rests on — do not improvise hiring advice, budgets or talent suggestions yourself — existed ONLY in the server instructions, which the spec treats as an optional hint a host may simply never show the model. It now leads mira_chat’s description with a test holding it there. The same pass made project plumbing a SILENT rule (hosts had started offering to create projects and asking which to use, mid hiring conversation), carried the talent questions still waiting on the client into every chat turn because a surface with no push has no other way to interrupt, and returned the WHOLE ranked list instead of three at a time, since the reveal is what PORTIA’s per-match pitch is written off. 2026-09-15 split the surface in two and lost the thing that told callers apart. One service now serves TWO connectors rather than one with two doors: /mcp (serverInfo “mira-fiverr”) keeps OAuth with the Fiverr account as its ONLY lane and is the only one where the concierge can message real talent, and /open/mcp (“mira”) has no authentication at all. The Google lane is gone from both, and the Fiverr mount now RAISES AT BOOT rather than serve an authorization server nobody can sign in to. Two mounts, not two Cloud Run services, because they share the pool, the queue client, the card bundle and the kill switch, and what a connector user sees is already two servers. The SDK moved to 2.x, so both serve the 2026-07-28 protocol revision alongside every handshake-era one — and that revision DELETED sessions, which is exactly how the open connector had been telling one conversation from another. The caller became a workspace_key the tools themselves carry: a first call with no key mints one, every result hands it back, and a call naming earlier work WITHOUT the key is refused with an instruction rather than answered out of a fresh workspace. Two consequences worth generalising. (4) Only a ToolError's text reaches the model — 2.x redacts every other exception to “Error executing tool”, so an identity refusal that is meant to INSTRUCT the model must be raised as one. (5) A long tool should hand a HANDLE to a host that can hold one. mira_chat, start_search and download_brief_pdf answer a task-capable host with a task handle the moment the work is enqueued (MCP tasks, SEP-2663, as an SDK Extension); nothing runs in-process, a task is a Valkey record naming durable state so any instance answers any poll, and a host that does not declare the extension gets the tools exactly as before. Then all seventeen tools were driven by hand, and the six defects that fell out are one shape: a well-formed key nobody was ever issued opened a fresh workspace, a still-running search reported that it had found nothing, a reply claimed a Google sign-in on the connector that has none, a count said eighteen while eight cards were returned, approve and search both ran on a one-turn brief, and a DELETED project stayed readable and chattable by id while a garbled one surfaced as an opaque failure — every one of them an agent-facing surface answering confidently about something it never checked. (6) A card is hung in SOMEBODY ELSE'S room. The card painted Mira's own absolute dark (#0f1619 on #06090b), which is DARKER than Claude's warm charcoal, so on a transparent iframe over the host's chat every widget read as a black slab; dark surfaces are now a translucent lift (white at a few percent), lighter than any dark canvas by construction, with every token as var(--host-variable, mira-fallback). And because neither side can tell PER CALL whether a card actually rendered (host capabilities arrive only at initialize, which a stateless server does not keep), the fact that the client can already SEE the reply is stated where the model reads it: a “what the user sees” paragraph in every card-bearing tool description, held there by a test. 2026-09-24: no tool argument is nullable any more. A host that speaks strict function-calling marks every property required, so a str | None argument had to be sent as an explicit null and a first mira_chat was rejected by the host before it reached us. Optional strings now publish as a plain {"type":"string","default":""}; omitted, blank and null all mean “not supplied”.
  • Every behaviour change is an ARM somebody can see, move and stop from one board, it DECLARES the behaviour it ships, and resolving it is what records it. Since 2026-09-10, completing the platform the prompt-sections work started. Six kinds of change now answer to one vocabulary and one console: a prompt section dialled below full, an injection, a slot approach, a product cohort, a branch in the code (arm()), and a Settings value over a closed allowlist (declare_config). The console answers “what IS this” for all six through GET /api/admin/experimentation/{key}/detail, in facts / one body / arms, so per-kind knowledge lives on the server rather than in six branches of a component. Four properties are load-bearing and every one of them was a bug first. (1) Recording is structural, not opt-in. The always-on A/A control had recorded NOTHING for its entire life — zero rows in every ledger and zero arms across 139,883 agent_trace rows — because writing an exposure was opt-in with FOUR hand-wired ways to opt in, and the experiment whose whole job is to prove the measurement pipeline is honest was the one nobody had wired. experimentation/record.py is now the only thing that writes an exposure, every resolver reaches it, and the bespoke call sites were DELETED rather than documented: a call you can forget had to stop existing. The A/A is the pipeline's canary as a result. (2) A dial the console can draw but not move is worse than no dial. A slot exists because a module-level declaration ran, which makes the registry a function of what each PROCESS imported: the worker imports the extraction and concierge code because it RUNS it, the admin API does not because it only reads about it, so seven declared slots drew cards and sliders that 404'd, twenty-eight times in one morning of dev logs with nothing else saying a word. (3) The default is DECLARED, never inferred (2026-09-11, extended to every kind whose default is a decision on 2026-09-13). An inferred default reproduced the exact lie the platform exists to remove: scout.on_site_lock ships ON, so its baseline arm IS the locked path and its other arm is the pre-lock one, yet the register read an absent row as “every arm at zero” and printed “off (0%)” — which is why an operator moved it to 5% believing that harmless and took the lock off 95% of on-site briefs. default_allocation is mandatory with no fallback now, POST /restore-defaults puts a whole environment back in ONE transaction with a dry run first, a full allocation resolves without the hash (hashing SPLITS a population and at 100 there is nothing to split, so an arm at 100 was quietly serving the baseline to any caller with no bound subject), and the canary rule measures DEVIATION from the declared default rather than the raw headline, because {lock: 10} read as a headline looks like a cautious 10% trial while handing 90% of the work to the untested path. (4) An empty row and a row of zeroes are different STATEMENTS (2026-09-13). An absent row means that kind's no-row fallback, which is the definition of the default, so it reads as AT the default and not as drift; a KILL writes explicit zeros, from the guardrail as well as from an operator, so a stopped experiment is never indistinguishable from an untouched one; and the restore sweep reports ORPHAN rows for experiments no declaration names, because “nothing to restore” and “the table is clean” are different claims. Deciding an arm is pinned as a PURE function — the assignment modules may not import the database, the cache, the settings, the clock or a random source — so the same (key, subject, allocation) answers identically in the web tier, the worker, a future service and a notebook reproducing last Tuesday. And the console deliberately does NOT name the measure: it answers what is running, to whom, and whether it is hurting anything, because a closed dropdown beside a dial can only ever be a subset of what BI already does properly, and two places to name a measure is two places to disagree about it.
  • No slow work on the web request. Every agent runs in the worker; the web tier authenticates, enqueues, and returns in milliseconds.
  • No streaming HTTP, no polling. Server→client is Pusher channels only; the agent package itself just yields events, transport-agnostic, and the worker republishes them.
  • Async is durable and idempotent. Jobs carry stable ids; externally-visible effects are keyed (uuid5 + ON CONFLICT DO NOTHING) so a retry can't double-send or double-write.
  • Degrade, don't crash. Every background stage swallows its own errors; LLM failures fall back to deterministic skeletons, palettes, or templates rather than an empty result.
  • The work shape is one vocabulary, spoken end to end. Since 2026-08-16 a project is either a one-off delivery, an ongoing engagement, or both, and that single fact travels the whole chain rather than being re-guessed per agent: triage reads it each turn, PULSE settles it, ATLAS commits the rate-in-unit structure around it, GAUGE prices in that unit, MASON renders a start date instead of a delivery date, SCOUT ranks on ongoing evidence, STERLING tells the talent, JUNO compares like units, and PORTIA pitches availability. The fee and the money that merely passes through a talent are separate fields and are NEVER summed.
  • Committed money has ONE reading, and it is conservative. Since 2026-08-22 recruiter/budget_view.py is the single place that turns the committed fields into a comparable figure, and the big-client alert, the auto-handoff tier, the admin money columns and the funnel’s money-weighted bars all ask it rather than reading columns themselves. The figure is a committed TOTAL, or ONE MONTH of a committed rate (month as-is, week ×4, year ÷12, hour/day/deliverable only against a STATED cadence). Nothing annualizes; headcount never multiplies (the figure is per person); pass-through external_costs_cents never counts, mirroring PULSE’s $500 floor; non-USD yields no value at all rather than an untrustworthy one; and a rate whose workload nobody stated has no value, because a gate must not invent hours. The operator label carries the unit (“$1,500/month”, never a bare “$1,500”). budget_mid_usd in repo/admin_insights.py is the SQL twin of the same arithmetic and tests/test_budget_view.py pins both to one case matrix. SPLIT 2026-08-25, deliberately and into exactly TWO readings: effective_usd_cents keeps every word above and every consumer it had (the admin money columns, the sort, the funnel’s money-weighted bars stay MONTHLY), while a new commitment_usd_cents beside it carries the whole sum the client signed up for and is what the two OPS tiers judge — because “$100/hour for 100 hours a year” is a $10,000 client and reading it as $833 a month is how a real one stayed invisible. A total exists only where the client BOUNDED the work (a fixed price, a stated annual quantity or rate, or a fixed number of months); where they did not, an open-ended retainer has NO total and is judged on the month against the same bar, which is the point rather than a gap, since annualizing it would make a $500/month arrangement a $6,000 client on the strength of a year having twelve months. The alert bar moves $1,000 → $3,000 in the same change (the handoff bar stays $6,000), because a total is a bigger number than a month for exactly these clients and widening what counts while leaving the bar alone would sweep in a band nobody asked for. year also joined the cadence vocabulary in that change — it had been week or month only, so “100 hours a year” was understood, written into the brief, and then stored nowhere. Nothing may grow a THIRD reading.
  • Committing the money is the trigger, not pressing a button. Since 2026-08-24 the big-client alert and the specialist handoff both fire from set_search_preference, on the same read of the same BudgetView at two thresholds, rather than from a search start, a concierge start or an admin approve. All three of those are a PRESS, and most of an intake conversation happens before any press — a client who committed $8,000/month and kept talking to Mira reached none of them, so the desk that exists to meet them never appeared and the booking calendar never opened. The presses stay live and cost nothing beside it, because the takeover stamp one-shots every effect. SCOPED 2026-08-25: the trigger and the two tiers are unchanged, but every CLIENT-FACING limb of the handoff is gone — the booking popup and its embedded desk calendar, the human.takeover Pusher event, the desk ROUTING that chose which calendar to show, and POST /api/workspace/handoff-meeting. A high-value client then saw exactly what everyone else sees and the desk reached them OUT OF BAND, so “the booking calendar never opened” above describes the bug that prompted the change rather than a surface that still existed. RE-SCOPED AGAIN 2026-09-13: a client-facing limb is back, and deliberately a different one. What returned is not the vendor pop-up over the chat but a native card IN the conversation carrying the team's own open times, raised by the desk once and by ATLAS on every later client ask (offer_schedule_call, §ATLAS), with the booking recording the instant and the verification tier and a persistent pill under the topbar keeping a booked call in view. The trigger and the two tiers are unchanged; what changed is that a client who asks a second time can be answered. The two things the feature exists to feed are untouched and are now the whole mechanism: the big_client attention flag with its #mira-prod-big-fish alert, and the BigClientHandoffExportJob row CS works from in the shared BigQuery table. The admin Big-clients desk and its Stop / Pause / Release controls behave as before, and its Desk and Meeting columns are deliberately kept so history stays readable; they simply stop gaining rows. The hook is a callback on ToolContext (the worker injects a closure; a runner with no live publisher injects nothing and still commits the budget), and takeover.py moved from web/ to recruiter/ so the worker may reach it without importing web. 2026-09-14: the tier is a LEVEL, not a latch. A commitment BELOW the tier releases a booked call (cancelled at the vendor, the client told, the metadata cleared, the lapse stamped last) and a later commitment above it re-offers the card, with a mint stamp guarding the burst and a reschedule cancelling every earlier confirmed booking. The lapse is judged ONCE per turn on the SETTLED preferences rather than on each write (§10, “judged once, at the tail of the turn”), because the budget tool commits across several writes and every half-written reading below the tier was cancelling the call on its own.
  • A talent’s standing preferences are THEIRS, a human approves them before they filter anybody, and the hours they work never remove them from anything. 2026-08-31: the feature is back ON — SELLER_PREFERENCES_ENABLED (concierge/seller_prefs.py) is True again, which is the state the seller-complaint card asks for. It spent five days off (2026-08-26), and the way it was switched off is why it could come back in one line: reverted rather than deleted, every line left in place, and the 12 behavioural scenarios skipped with a reason naming the constant rather than removed. The tests now pin BOTH states explicitly — seller_preferences_on was the only fixture, because “off” was simply what the constant said, so a sibling seller_preferences_off pins the dark surface and a future flip in either direction cannot quietly delete a state nobody is asserting. When it is off, STERLING behaves as though the feature never shipped (no tools, no policy block, no CURRENT PREFERENCES heading, no pending/refused NOTEs, no per-turn preference reads) and SCOUT filters nothing. Both halves are still gated by the ONE constant on purpose: with STERLING mute and enforcement live, a talent whose preference is already active is dropped from every client search with no way to see it, change it or lift it, and no way to raise the subject, because he no longer knows it exists — invisible filtering with no recourse is worse than either end alone. Since 2026-08-25 a talent can say what they will and will not be brought — a project floor, an hourly floor, a per-leaf price keyed on the numeric Fiverr sub-category, free-text rules and an availability date — instead of the one all-or-nothing opt-out they had. 2026-08-31 added the last facet, and it is the one that is NOT a screen: the hours they work. Migration 0179 had built the columns and argued the design, and then nothing ever read or wrote them, so a talent who said “I work 10 to 6” got “noted” and nothing stored, or landed the sentence in rule_text, where the grader reads it as a statement about the KIND of work under a prompt ending “if you are unsure, treat it as a conflict” and can take them off every brief there is. The capture REFUSES rather than repairs a bad window (a wrapping “10pm to 6am”, an hour off the clock, an end at or before the start), because this value ends up in a message the talent APPROVES and a window we quietly fixed is one they agreed to without being shown it; and it REPLACES rather than accumulates, because a person has one working day and a second window is a correction of the first. seller_working_hours() is the fourth hot-path read and the only one that drops nobody: hours are a fact about THIS MINUTE, so filtering on them would delete the Americas from every afternoon search and Asia from every morning one, for the same client and the same brief, with the shortlist depending on when the client happened to press the button. They are re-stamped onto in_working_hours right before the sort key reads it, worth 5 points (raised from 2 the same day, the same as being online, because presence is a flag the gateway supplies only sometimes while a clock is always available), and they can never cross a fit tier: craft outranks availability, always. The default window opens at 07:00 rather than 09:00, since these are the hours a message may land without waking somebody and freelancers start before an office does. The three price facets are deliberately NOT convertible: an hourly brief has no project total, a project brief has no rate, and comparing across them drops people by three orders of magnitude, so a brief carrying no comparable number enforces nothing and says so. Approval by the talent SUBMITS rather than activates (proposed → awaiting_review → active): a human clears every one in Talent Management > Preferences, each row deep-linked to the STERLING conversation it came from, because the capture is model-authored off one turn, the blast radius is every future search, and a wrong one is invisible from every side — the talent sees fewer briefs and cannot tell why, the client never learns somebody was filtered, and no error is raised anywhere. The stored rule line is the ONE string crossing from one user’s conversation into another user’s search, so it is held by a one-line validator at capture (120 chars, no newlines, no markup — refused, never truncated, and metered), a data-not-instructions fence in the grader prompt, and a post-grade clamp that lets a conflict only ever REMOVE the talent who stated it. Enforced at two seams, because discovery is not on the path for every send: the search drops below-floor talents before grading, and _dispatch_thread re-checks the floor for a hand-picked shortlist (and every other send), short-circuiting before the opening composer so a suppressed outreach costs no LLM call. Both loaders fail OPEN, and the metric carries kind, seam and severity so a rising SOFT rate reads as a prompt problem rather than a market one. 2026-09-16: the stored row now says what the talent said, not only what we computed. An erasure is declared (cleared_facets) rather than inferred from nulls, so an all-null row is either a named clear or refused outright; a rule is removed one line at a time; a non-dollar figure keeps its named amount and currency beside the converted cents; and the operator queue that gates all of it is keyset-paged with a server-side count, after a hard LIMIT 100 had left every later row unreachable while the page read as the whole backlog.
  • WHEN somebody can start is a DATE, never a sentence of rule text. Since 2026-08-26 “not before September” is captured as seller_preferences.available_from (migration 0177) and screened by discovery.unavailable_in_time against the CLIENT’s own delivery horizon — the same question hard_filters.drop_out_of_office already asks of Fiverr’s vacation window, of a different source. A talent free BEFORE the deadline is kept; that asymmetry is the rule, and a brief with no deadline has no horizon to be late for, so it is metered no_deadline rather than passed over. It is deliberately NOT a rule_line: rule text reaches the SCOUT grader as evidence about the KIND of work somebody will not take, under a prompt ending “if you are unsure, treat it as a conflict”, so a timing sentence there does not degrade to inert — it plausibly grades the talent OFF every brief there is, the client never learns anyone was filtered, and the drop is indistinguishable from a capability mismatch. A date has nowhere to do that. Capture refuses what it cannot trust: a past date screens nobody, a vague answer is a reason to ASK which date, and a date beyond two years is a mis-resolved relative date far more often than a plan.
  • The live rail is ONE continuous search, and every round re-ranks the whole list. Since 2026-08-25 the strip beside the conversation is the real SCOUT at max_rounds=1 — one round per refresh, replaying its own thread, so a conversation accumulates one search instead of a series of unrelated ones. Which lane runs is a CONSTANT in matching/lane.py, not a setting: the previous shape was an env field no environment ever set, so the fast lane shipped and the experiment arm spent a day being measured on the engine it exists to replace, green everywhere and wrong everywhere. It shipped strictly append-only, on the reasoning that the rail is read while it is built and “the third one” has to still be the third one, at the stated cost of a list ordered by when we found people rather than by fit. That cost turned out to be a bug (2026-08-31): the rail is capped at 18, so once the cap was full from earlier rounds the merge returned before it ever looked at the new one and every later round was discarded whole, which is how a client who asked for Spanish speakers mid-conversation got a list where 14 of 18 spoke no Spanish while a pool of 18 who all declare Spanish was thrown away at this seam. So the merge now RE-RANKS every round: this round’s ranked picks grouped by the fit tier it gave them, seen talents keeping their previous relative order within a tier with the round’s new finds behind them, and anyone seen but unranked this round retained below that so a thin round cannot collapse an 18-card list into a 3-card one. Stability became a property of the ranking rather than a freeze: sorting on (this round’s tier, previous position) makes an unchanged round byte-identical and moves a talent only when their tier actually changed, and it absorbs the grader’s between-round wobble for free. append_only_merge is kept whole and still tested, because the switch in lane.py is only a switch if the other side still works, and drops are now metered (recruiter.shortlist.dropped) rather than only logged, after the full-from-history case spent months returning ABOVE its own log line. The hand-over button folds through the same merge_cards, so the rail and the button cannot disagree, and run_concierge_search resumes the rail’s thread rather than opening cold and contacting a different set of people. What may start a refresh is ATLAS’s own judgment since 2026-08-31 (the refresh_talent_preview tool, over a floor that requires the project to carry a definition of its OWN), not the side effect of any state-writing tool call. Waste is cut at both ends: triggers inside an 8s window collapse to one, and discovery.discover takes a still_ours predicate checked immediately before the grade fan-out (~99% of a round’s cost) so a run that has lost the row stops instead of grading 673 packages nobody wants — recorded as superseded and deliberately NOT as an error, so a dead run cannot flip the healthy newer one to degraded.
  • Never invent. Across ATLAS, MASON, SAGE, IRIS, and SCOUT the same line recurs: every fact, number, source, and talent must trace to real evidence.
  • Mind the ~10KB Pusher cap. A full workspace is never pushed; oversized payloads set resync:true and the client re-reads GET /api/workspace.
  • Every call is visible. Each LLM call logs tokens, latency, and dollar cost; each lane runs in its own agent <name> OTel span (gen_ai.agent.name, named via agent_identity) nested under the worker's job span — a turn's distributed trace breaks out per agent.