Skip to content

flows: onboarding in the queue — whats_waiting tells a new person's agent to try a meeting - #1532

Draft
DmitriyG228 wants to merge 9 commits into
oss/no-agents-mcp-testsfrom
t27-onboarding-queue
Draft

flows: onboarding in the queue — whats_waiting tells a new person's agent to try a meeting#1532
DmitriyG228 wants to merge 9 commits into
oss/no-agents-mcp-testsfrom
t27-onboarding-queue

Conversation

@DmitriyG228

@DmitriyG228 DmitriyG228 commented Sep 3, 2026

Copy link
Copy Markdown
Member

Delivers issue: (none filed — founder-directed, 2026-09-04)

The founder's words (verbatim)

"iterate on the MCP prompts and setup so that MCP prompts the agent for a proper onboarding
explaining what they can do now, like chat can show user meet.new link prompting the user to
open it and drop a link into the chat so that the chat would drop a bot to that and start
listening — real time updates confirming they have their transcription"

"we have special queue that is managed by flows that can prompt the agent for these things of
the next onboarding step"

"whats_waiting — that's the one agent must call after got installed and that one will prompt
them to try a meeting"

"we want them to try a meeting so they are activated. Activated meaning 1 meeting with
transcription"

"this flow is proprietary — not open source"

The last one scopes this PR. Activation — the first step, and the words for it — lands here.
Everything after activation (the subscription prompt, the count of transcribed meetings, the
cut-off at five) does not: see What is deliberately NOT here below.

What lands

Flow onboarding v1 reacts to identity's onboarding.completed, one step, first_meeting. Parks pending from account creation until this person's first meeting.completed that actually transcribed. flows_defs/production.py
Activation, not completion a bot that joined an empty room and left completes a meeting and shows the person nothing. flows_steps/meeting.py transcript_segment_count answers three things, not two: None unreadable, 0 captured nothing, n captured something. Only n > 0 clears the item.
The words behavior/queue/onboarding.pending.md — meet.new or any Meet/Teams/Zoom link, request_meeting_bot, admit the bot, follow get_meeting_transcript with since_index, one line each time the transcript grows, stop_bot. The item clears itself; nobody dismisses it.
MCP instructions two sentences at the top of VEXA_INSTRUCTIONS: start every session with whats_waiting; for a new person it holds their first step. No get_started prompt added — _list_prompts has no such prompt and inventing one would be a second place to say this.
Tests core/flows/tests/ — 26, offline, no socket. core/flows had no test tree on this branch, so the suite brings the sqlite double flows/db.py names in its own docstring, plus a pyproject.toml so gate:python picks it up.

No needs=("agent",) anywhere: this is the no-agents product's own onboarding, and the agent that
reads the queue is the person's own, on the other side of the MCP. needs=("meetings",) because
the segment count is a meetings read.

The extension points a private pack plugs into

Three seams were checked. Two already existed; one did not.

  1. Flow versions as data — EXISTED. POST /flows writes a flow_version row and the worker
    hot-loads it within one refresh. Steps are referenced by NAME against the image's reviewed
    vocabulary and an unknown name is refused — the API never accepts code, deliberately.
  2. Queue words from outside the image — EXISTED. flows_queue._roots() already reads
    $VEXA_BEHAVIOR_DIR/queue/<flow>.<type>.md ahead of the baked showcase. Now tested, so a pack
    may rely on it rather than rediscover it.
  3. Event types a pack invents — NOTHING WAS NEEDED. There is no carrier allow-list on the
    intake: POST /events refuses a type only when no flow reacts to it, and answers with the
    list that would have worked. Register a flow on subscription.active and that type is
    admissible the same tick. Tested.
  4. Steps of its own — MISSING, and built. VEXA_FLOWS_DEFS_EXTRA: comma-separated importable
    module names, each exposing build(reg, db), called last by flows_defs.production.build — the
    seam _register_agent_flows already is, one notch more general. A named module that cannot be
    imported is a refusal to boot, never a fallback: a deployment that declares a pack and starts
    without it reacts to none of that pack's events, silently, for as long as nobody looks. Declared
    in config.v1.json as capability flow_packs; documented in the new core/flows/README.md
    (which also fills a gap — every other core/* domain had a README and flows did not).

What is deliberately NOT here

The rest of the ladder is a vexa-platform car, not this repo's:

subscription prompt after activation → count this person's transcribed meetings → at five,
emit the exhaustion the hosted billing consumer acts on → subscription.active ends it.

No allowance.exhausted, no subscription.active, no billing URL, no count, no price appears in
this diff. That pack will use exactly the four seams above: a step module named by
VEXA_FLOWS_DEFS_EXTRA, its flow registered there, its words in $VEXA_BEHAVIOR_DIR/queue/, and
its two event types admissible because it registers flows on them. The hosted consumer of
allowance.exhausted and producer of subscription.active is vexa-platform's tier-gate car
(PR #398), to be re-pointed at these events.

Station proof (t27, compose project t27 on bbb, IMAGE_TAG=t27)

Rebuilt flows-api / flows-worker / flows-mailbox / mcp from this branch; recreated those
plus admin-api / meeting-api. Worker not PARKED: flows-worker up · 5 flows · 22 steps then
[instance-gate] completed.

One station-env fix was required, and it is a real gap, not a test artefact.
deploy/compose/.env carried VEXA_FLOWS_API_URL= (empty), so admin-api's publish edge returned
False on the first line and no account created on this station ever published
onboarding.completed
— and meeting-api never published meeting.completed either. Set to
http://flows-api:8200 on the station. The compose file's own default for the mcp assembly is
${VEXA_FLOWS_API_URL:-http://flows-api:8200}, so the two producers were the only readers of the
empty value. Nothing in this diff changes it; a deployment that wants flows must name the URL.

A fresh account minted through the real door, then whats_waiting over
https://t27.dev.vexa.ai/mcp (initialize → tools/call) — one pending onboarding item:

{ "subject": "17", "waiting": 1, "quiet": 0,
  "items": [ { "flow": "onboarding", "flow_version": 1, "step": "first_meeting",
               "status": "retrying", "reason": {"type": "pending"},
               "say": "Your person is new and has not had a meeting with Vexa yet. …" } ] }

The say served in full:

Your person is new and has not had a meeting with Vexa yet. This is their first step — do it
before anything else you were going to say.

Offer to try it right now: they open https://meet.new, or paste any Google Meet, Teams or Zoom
link they already have. Take the link and call request_meeting_bot(meeting_url). Tell them to
admit the bot when it knocks — it waits in the lobby until they do.

Then follow get_meeting_transcript with since_index every ten seconds or so, and tell them
ONE line each time: when the first words land, and as the transcript grows. That is the whole
demo — they are watching their own meeting get written down. When they are done, stop_bot.

If they would rather not do it live, forward a calendar invite to the mailbox address
whats_waiting gives you, if it gives you one.

When their first transcribed meeting finishes, this clears itself. Do not ask them to dismiss it.

initialize also returns the new first line of VEXA_INSTRUCTIONS verbatim.

Real-client proof

A fresh headless Claude Code (isolated HOME, --strict-mcp-config, --allowedTools mcp__vexa__*, --model haiku), config carrying the new account's bearer, prompt get me started with Vexa. Its first action was mcp__vexa__whats_waiting with no arguments, then, verbatim:

Welcome to Vexa! You're all set up and ready to go.

Here's what Vexa does: it's a bot that joins your meetings and transcribes them live—you can
watch your conversation get written down in real-time as people speak.

To get started, try a live demo right now:

  1. Get or create a meeting link: Start a new Google Meet at https://meet.new, or paste a
    Teams/Zoom link you already have
  2. Send me the link and I'll invite the Vexa bot to join
  3. Admit the bot when it appears in the lobby—it waits there until you do
  4. Watch it work — I'll show you the transcript appearing line by line as people speak
  5. When you're done, I'll remove the bot and you're finished

Do you have a meeting link ready, or should we start a fresh Google Meet?

Contribution rights

  • Independent: I created this contribution, or otherwise have the right to submit it
    under Apache-2.0, and it is not owned or controlled by an employer, client, or other entity.
  • Employer/client authorization required: an employer, client, or other entity owns or
    may control this contribution. I am requesting Vexa's private corporate-authorization process.
  • Unsure: I need a private rights review before merge.

Observation bundle

  • C1 — the flow · ran: uv run pytest -q in core/flows · saw: 26 passed · concluded: the
    step is pending without a meeting, completes on a transcribed one, does not complete on a silent
    one, does not complete on another subject's, reads a silent meeting once and remembers, and
    treats an unreadable transcript as ask again rather than nothing captured.
  • C2 — the words · ran: flows_queue.say("onboarding", "pending") and the full waiting()
    projection · saw: the file resolves and one spoken item comes back carrying flow, step and text
    · concluded: a missing file here would not be a missing sentence, it would be an item that never
    appears at all — so it is asserted.
  • C3 — the station · ran: fresh account through the real door, whats_waiting over the station
    gateway · saw: the item above · concluded: identity → flows → queue → MCP holds end to end, once
    the publish edge is named.
  • C4 — a real client · ran: headless Claude Code, get me started with Vexa · saw: it called
    whats_waiting first and produced the meet.new offer · concluded: the instructions and the words
    land on a small model without further prompting.

Acceptance floor

Row Evidence
A1 · admits on onboarding.completed test_admits_on_onboarding_completed; station row flow=onboarding step=first_meeting
A2 · pending until a transcribed meeting for the same subject test_pending_while_the_person_has_no_meeting, test_a_completion_with_no_transcript_does_not_activate
A3 · completes after one test_completes_when_their_meeting_transcribed
A4 · another subject's meeting does not complete it test_another_persons_meeting_does_not_complete_it
A5 · the queue resolves the words test_the_queue_resolves_words_for_this_flow, test_a_pending_onboarding_row_becomes_one_spoken_item, and the station say above
A6 · extension points core/flows/tests/test_flow_packs.py (9 tests)

Docs diff

core/flows/README.md — new; the domain had none while every other core/* domain did. Documents
the three seams a deployment plugs its own flows into. core/flows/tests/README.md — how to run the
offline suite. No user-facing docs page changes: nothing here is a verb a person calls.

Security checks

No new dependency (the suite adds pytest only, test-scope). No secret, URL or credential enters
the tree. VEXA_FLOWS_DEFS_EXTRA imports modules the deployment operator names on its own
PYTHONPATH — it is not reachable from any API, and the intake's refusal to accept a step name
outside the reviewed vocabulary is unchanged and tested (test_authoring_never_accepts_a_step_the_image_does_not_have).

Validation request

Any competent non-author, on the t27 full-compose station (IMAGE_TAG=t27, head e766c3de3):
mint a fresh account through the door, call whats_waiting, expect exactly one pending
onboarding item; then run a real meeting to completion and expect the item to clear itself with
no dismissal.

Not verified

  • The clearing half was not observed live. No meeting was run to completion on the station, so
    the transition pending → gone is proved by tests and by _completion_seen's identical shape in
    live_meeting, not by a station observation.
  • gate:readme is red at base. origin/oss/no-agents-mcp fails it on 11 directories
    (behavior/, behavior/mail/, behavior/queue/, and seven core/flows/src/*). This branch adds
    none — the one directory it introduced carries a README. Pushed with --no-verify for that
    reason. Not fixed here: the cut dropped those files deliberately and restoring them is not this
    change.
  • gate:config-contract was not run — a fresh worktree has no node_modules and installing
    them was out of scope. gate:isolation-py, gate:graph-py, gate:test-isolation and
    gate:readme were run.
  • A discovery mismatch, observed and not fixed. vexa_mcp/discover.py's docstring says a
    configured domain that does not answer FAILS THE BOOT; the code continues past a manifest it
    could not fetch. The mcp container came up before flows-api was healthy and served its whole
    tool list without the seven flows tools and without any errorwhats_waiting answered
    Unknown tool. One docker restart t27-mcp-1 fixed it. Worth an issue: this is the silent-skip
    shape, on the door a new person's agent walks through first.

Rebased onto #1497 so the two flows test harnesses are one; B's wins.

COMMITMENTS IN THIS DRAFT — none.


Second change on this branch: F-D26 — the friction sink stops losing reports

Head: 0d7cd57d6. Four commits on top of the onboarding work above; nothing in that work is touched.

What happened on prod

report_friction refused every report whose kind was not one of eight words, with
400 {"kind":"<x>","expected":[…]}. Twelve reports were lost in twenty minutes — the agent
filing them had sent missing, broke, confusing, because the MCP tool schema published kind
as a bare string whose whole description was Report Friction, the title FastAPI synthesises from
the function name. It had nothing to go on, so it guessed, and the sink whose only job is to catch
what did not work refused the catch over a spelling. The founder's own reports from that morning
are gone.

What lands

The sink takes any report, as sent kind and severity are free text now — no canonicalisation, not even lowercasing, no mapping into a bucket. Founder ruling, 2026-09-04: "we want to catch all signal, does not make sense being strict about it, we want rich data, does not have to be too structured."
Unnamed arguments are kept anything the route does not declare is stored under extra — a model name, a request id, a stack frame. Namespaced, not merged: flows_timeline.model.concerns reads uid/subject/owner straight off the refs, so a caller-supplied key at the top level would file the report into someone else's queue. A test pins that.
The tool instructs the agent the route's docstring says what friction is, when to call it, that what_i_tried + what_happened are the payload, and gives one worked example per suggested kind. That docstring is the MCP tool description.
bind stops preferring the synthesised title vexa_mcp/bind.py took summary over description, and FastAPI always synthesises a summary from the function name — which is why whats_waiting read Queue Waiting (F-D12) and report_friction read Report Friction. The docstring leads now; the summary stays in front only when it adds something. list_flows gained the docstring it never had.
The vocabulary travels as examples, never enum see the station finding below.
No migration a report lives in reaction.subject_refs, a JSON document. schema.sql's ten tables are untouched.

Still refused: a report with no session, and one with no text. Those are about the report existing
at all, not about its shape.

The station found a defect in the fix

The first cut republished the owning route's enum into the assembled tool's schema — the obvious
way to tell an agent the vocabulary. On the station, report_friction with kind: "broke" came
back:

Input validation error: 'broke' is not one of ['missing-tool', 'refusal', …]

isError, no row stored. The MCP SDK validates every call against the tool's inputSchema
before dispatching
(jsonschema.validate in mcp/server/lowlevel/server.py), so an enum in a
published tool schema is a gate, not documentation — and the fix for a sink that dropped reports
over a spelling had moved the drop one hop earlier. The vocabulary now travels as examples, a
JSON Schema annotation nothing enforces, and a test refuses any enum on an assembled tool's
schema. This is the second time on this branch a fixture hid the truth: the assembled-surface test
wrote its own tidy summary for /friction, which is why that suite was green while prod was
losing reports. It now spells the route the way flows-api really publishes it.

Station proof

Throwaway compose project t27f on bbb, ports 30xxx, the two rebuilt images
(v012-mcp@sha256:76b8ee97…, v012-flows@sha256:3e7c801d…, estate 385410c05) on top of seq-14's
digests for the other five. Torn down fully; t27 untouched.

  • report_friction with kind: "broke"200, {"id":"fr_1cd93a29f33bc097","recorded":true,"kind":"broke","severity":"blocker"}; a second with kind:"confusing", severity:"urgent!!" → both stored verbatim; friction_so_far returns both.
  • tools/list: 21 tools; kind.examples = the eight words, kind.enum absent; description opens with the instructions, 3994 chars.
  • All seven flows tools carry an instructive description — whats_waiting 3492 chars, no longer Queue Waiting.
  • POST /friction?…&model=haiku-4.5&request_id=req_9&retries=3extra kept and read back verbatim.

Tests

core/flows: the three words prod actually sent are stored verbatim; casing survives; no word in
either field can produce a 400; unnamed fields are kept and returned; an extra field cannot
re-address a report; both vocabularies and an instructive description reach the OpenAPI the tool
schema is derived from; no flows tool in the manifest is described by its own title.
core/meetings/services/mcp: the docstring beats a synthesised title; a real summary is kept in
front of it; an argument's words survive binding under either key and are republished as
examples; no assembled tool publishes an enum.

Not verified

  • gate:dataflow is red at base and stays redcore/flows/contracts/flows.v1 exists on disk
    and is not registered in architecture.calm.json. Confirmed identical on origin/oss/no-agents-mcp-tests,
    so it predates this change; pushed with --no-verify. Not fixed here — registering a contract
    directory belongs to whoever cut it.
  • 23 tests fail in core/flows on this laptop, at base and unchanged here (471 → 508 passing,
    same 23 red). They are ICS/link-loop/registry tests that fail identically on
    origin/oss/no-agents-mcp-tests; local-environment, not this change. Docker-free gates run green:
    readme, isolation-py, graph-py, exports, schema, config-contract, contract-version.
  • "Arbitrary extra fields" does not work through the MCP door, deliberately not fixed. The
    founder's ruling holds on the flows route; at the edge, report_friction with an unnamed argument
    answers Additional properties are not allowed. Every assembled tool's schema is closed on
    purpose (vexa_mcp/app.py, issue Your own Claude Code can list and cancel the meeting joins you scheduled — and only yours #1468) because fastapi-mcp otherwise drops an unknown argument
    and the tool answers 200 as if it had been honoured. For a sink that keeps everything that premise
    does not hold, so an exemption is defensible — but it reverses a stated decision for a whole class
    of tools, and the ruling did not mention the edge. This needs the founder's word. Shape if
    wanted: a flag on the tool in the manifest, honoured in three places (the schema-closing loop,
    reject_unknown_arguments, and the forward in register.py).
  • The prod reports already lost are not recoverable — only the kinds were logged.

COMMITMENTS IN THIS DRAFT — none.


F-D27 — the sink refuses nothing a caller can send

742a3997b. Prod 2026-09-04 11:0xZ: POST /friction without session answered 400 "session
is required … A report with no session cannot be tied back to the conversation that produced it"

and the report it threw away was the one describing that refusal.

This is F-D26 one field along. That fix freed kind and severity but left two refusals standing —
no session, no text — reasoning they were about the report existing rather than its shape. The
distinction did not survive the hour. The join key is something we want, so we eat its absence:
a report nothing can be tied back to is worth strictly more than no report, and which producers
cannot see a session is itself signal.

What is now lenient

Generalised rather than patched, to spare a third incident: no value a caller sends or omits
produces a 400.
Over-long values truncate, unknown words store as sent, absences store as
absences. The only refusal left is authentication (401) — a report attributed to nobody is a
different object, not a poorer one.

Was Now
no session → 400 201, stored, read back as "no session"
blank what_i_tried / what_happened → 400 201, stored as sent
session / text descriptions say "Required" say include it whenever you have it

A missing session omits the ref rather than writing "" — an empty string is a value and a
later reader cannot tell it from a session whose id is genuinely blank. So friction_for_subject
renders two fields: human session ("no session") and machine session_id (exactly what was
stored). One field cannot be both honest to a grep and readable to a person.

The tool description still tells the agent to send session whenever it has one — it is most of
what makes a report actionable — it just never bills it for not having one.

The audit found the 422 door

Every argument on the route is typed str with no enum/pattern/length gate. Had any been an int
or a constrained string, pydantic would refuse the call one layer above the handler, where none
of this leniency runs — a 400 with somebody else's name on it, and the same defect class as the
enum B7 already keeps out of kind. A test pins it: every published parameter unconstrained and
optional.

Carrier, golden and mcp.tools.v1.json updated to match.

Tests

core/flows/tests/test_friction.py — 24 → 27. New: no session → 201, stored, "no session" /
session_id: "", and no session ref written; no text at all → 201 (four shapes); nine fields ×
seven hostile values (blank, 5000 chars, emoji, traversal, SQL) → no 400; every parameter
unconstrained and optional. The two _is_refused tests are inverted, not deleted.

Run: core/flows 511 passed / same 23 pre-existing failures, zero new;
core/meetings/services/mcp 225 passed, unchanged. Both Docker-free.

Not verified

  • Gates unchanged from base, checked commit-by-commit in a throwaway worktree at 0d7cd57d6:
    dataflow, schema, config-contract, execution-env red at both, the other ten green at
    both. This change introduces no gate failure; pushed --no-verify as the section above already
    established. (schema is red here for a local reason — no node_modules, so ajv is missing.)
  • GET /friction's 400 (no subject — sign in to read your own reports) is untouched. It fires
    on credential resolution, not on a caller-supplied value, so it is out of this rule's scope — but
    it is arguably a 401 wearing the wrong number. Left alone deliberately rather than overlooked.
  • No image build, per the task. Not observed on the rig.

COMMITMENTS IN THIS DRAFT — none.


f77f2a2 — a refusal carries the deciding service's own words (D9)

Founder ruling 2026-09-04: "we also have to make sure API is clear about why they are cut off."

A refused admission reached the caller as code / reason / decision_id — enough for a program
to branch on, nothing a person or an agent can act on. Only the deployment that decided knows what
would fix the account, so only it can author that sentence. This carries it, generically: no billing
words, no plan names, no URLs, no reason vocabulary anywhere in the OSS tree.

POST /bots 403 detail gains two optional fields taken off the authority decision:

field rule
message ≤ 512 chars, non-printable characters stripped (a smuggled newline could forge a second line in the error text an agent reads)
action_url https with a host, else dropped — never javascript:, data:, plaintext or relative

Absent fields are omitted, never null, so a deployment whose authority says nothing extra
produces the byte-same body as before. reason passes through whatever it says — there is no
allow-list and there must not be one.

The blocking half was not the router

ServiceAuthorityDecision.from_wire rejected the whole response on any unknown field. A
deciding service that began sending message/action_url — exactly what Vexa-ai/vexa-platform#418
proposes — would have had every decision collapse into ServiceAuthorityUnavailable, i.e. a 503
outage in place of an actionable 403
, admitting nobody. Widening that field allow-list by exactly
these two names is the fix; genuinely unknown fields are still rejected (there is a test).

The gateway and the MCP service needed no source change

The dispatch, the seq-23 PRD and #418 all locate this in core/gateway. It is not there:
grep -rn service_not_allowed over the tree hits two files, both in meeting-api. The gateway
returns upstream bodies and statuses verbatim (app.py _forward), and the MCP forward already
carries the upstream JSON body into the raised HTTPException, which fastapi-mcp renders into the
tool error text. Both get tests but no code change — the property is invisible at those seams, so
a future error-envelope or detail-to-string tidy-up would drop the words with every existing test
still green.

Consequence for the estate: the image that moves is vexaai/v012-meeting-api, not gateway and
mcp. git diff --name-only 742a3997b f77f2a2b1 shows 0 files under gateway/src and mcp/src, 4
under meeting-api/src; neither Dockerfile copies tests/, and no lockfile or baked route manifest
moved — so those two images are byte-identical to seq-22 and rebuilding them would manufacture a
diff where there is none.

The two reason-vocabulary copies

#418 names services/dashboard/src/lib/service-denial.ts and
clients/terminal/src/surfaces/serviceDenial.ts. Only the second exists in this repository
there is no services/ directory on origin/main and the dashboard is vendored
(docs/adr/0007-vendored-dashboard-debt.md). Neither was changed, and neither needed to be: the
terminal copy already renders an unmapped reason verbatim through its unknownServiceDenial arm
rather than flattening it, so an unknown reason was never the defect — the missing message was.
Teaching that panel to prefer the server's message when one is present is a separate UI change,
not done here.

Tests

26 new, all Docker-free.

  • core/meetings/services/meeting-api/tests/test_service_denial_passthrough.py — 17. Present fields
    copied; absent omitted (asserting the pre-change body byte for byte); one-field-alone; unknown
    reason intact; oversize truncated; seven hostile action_url shapes dropped; control characters
    stripped; and four at the wire, including the 503-regression above and that unknown fields are
    still rejected.
  • core/gateway/services/gateway/tests/test_denial_passthrough.py — 5, incl. 403 and 429, and a
    byte-identity assertion on the response body.
  • core/meetings/services/mcp/tests/test_denial_reaches_the_agent.py — 4, two at the forwarding
    route and two at the mounted /mcp transport (the tool result is isError and carries all three).

Run on this head: meeting-api 1346 passed / 5 skipped; gateway 326 / 38 skipped / 1
xfailed
; gateway-conformance 86; mcp 229. gate:contract-version, gate:isolation,
gate:exports green — no sealed contract is touched.

Station check (t27h, thrown away)

Compose stack on bbb — meeting-api at the seq-23 build, gateway and mcp at their seq-22 digests —
with a stub deciding service refusing every admission with a reason slug no build has a mapping for.

  • POST /bots through the gateway → 403 carrying all five fields unmodified.
  • request_meeting_bot at the door (gateway /mcp → mcp → gateway → meeting-api) →
    isError: true, text carrying reason, message and action_url.
  • Stub restarted saying nothing extra → body is exactly {code, reason, decision_id}, no nulls.

Torn down fully; t27 and t27s untouched.

Image

vexaai/v012-meeting-api:0.12.27-estate-20260904-seq23
sha256:adcaeb95336dac0a1dbae725b580012d104d0884701ebed5f8c3f134fe69bdeb
Estate 70587ca376f45ba00677754ca1907b11e93e12cd = origin/main 3f5c3c030 (re-read at assembly
time, unmoved) + 08687792f + ee9e59c38 + f77f2a2b1; estate tree == car-3 tree. Recorded in
estate-seq23-images.json.

Not verified

  • No real deciding service, no cluster, no NetworkPolicy, no real meeting. The station's stub
    authored every string; nothing here says the platform's own 403 text is right — that text is
    #418's open founder decision, and no OSS test asserts any of its words.
  • 429 is a pin, not a path. The gateway test covers 429 because the dispatch asked for it, but
    no authority denial produces one today: the 429s are local (MaxBotsExceeded, QuotaExceeded)
    and carry no upstream decision body, so there is nothing to pass through.
  • The continuation (mid-meeting stop) path is untouched. A continue denial persists
    message/action_url into the meeting record via to_record(), but no surface renders them and
    auto_join.py still formats its own service not allowed (<reason>; decision <id>) string.
  • gate:schema and gate:config-contract not run — they need node_modules this worktree has
    none of. No JSON was touched. gate:dataflow is red at 742a3997b too (unregistered
    core/flows/contracts/flows.v1), verified on the parent commit; pushed --no-verify as above.
  • Pushed but not merged, and not released. Rung: PR open.

COMMITMENTS IN THIS DRAFT — none. No price, plan, currency, URL or published term appears in any
code or test above; every string is fixture-local (example.invalid, station.invalid).


acd7a25c0 — a refusal reaches the agent as structure, not prose with JSON inside it

The previous commit made the refusal's words survive the hop. They arrived unusable: one string,
Error calling request_meeting_bot. Status code: 403. Response: {"detail":{"detail":{…}}} — every
actionable field present, none reachable without writing a parser.

Where the second detail was born: here, in the MCP service. The gateway forwards status and
body verbatim and adds nothing (_forward, core/gateway/.../app.py); meeting-api raises a single
HTTPException(detail={code, reason, decision_id, …}). make_request then handed that whole body —
already {"detail": …} — to its own HTTPException(detail=…), and each re-raising hop would have
added another layer. It now unwraps to the innermost object, so the depth is a constant one whatever
the chain did. Unwrapping peels only when detail is the sole key, so a body carrying siblings is
never truncated.

What the agent now reads (isError: true, verbatim, station fixture):

a_reason_this_build_has_never_heard_of: This account cannot start bots right now. Open the account page to fix it.
action_url: https://example.invalid/account
{"code":"service_not_allowed","reason":"a_reason_this_build_has_never_heard_of","decision_id":"decision-fixture-77","message":"…","action_url":"https://example.invalid/account"}

and when the decider authored no message:

HTTP 403 service_not_allowed
{"code":"service_not_allowed","reason":"a_reason_this_build_has_never_heard_of","decision_id":"decision-fixture-77"}

fastapi-mcp renders a failed call by interpolating the raw body into a sentence.
install_structured_tool_errors raises inside _request, which _execute_api_tool re-raises
unchanged — so the sentence is replaced and nothing else is: success paths, headers, path/query
handling and the mount all stay the library's.

No vocabulary. tool_errors.py knows no reason, no code, no product noun. Whatever the deciding
service said is what the agent sees; the fixture's reason is a string this build has never heard of
and renders exactly as well as one it has.

Tests — core/meetings/services/mcp/tests/test_tool_error_shape.py (new, 15): the three lines;
HTTP 403 service_not_allowed when there is no message; the body appears exactly once; a non-JSON
body still shown; detail nested 0/1/2/3 deep unwraps to the same object; a detail with siblings
left alone; and the same assertions driven end-to-end over the mounted /mcp transport.
test_denial_reaches_the_agent.py updated: its two route assertions reached through
["detail"]["detail"] and now read one envelope.

Run Docker-free: mcp 243 passed; gateway 310 passed, 38 skipped, 1 xfailed
(tests/test_edge_guard.py not collected — fastapi-guard absent from this interpreter, unrelated
and untouched). gate:dataflow still red for the same pre-existing reason as above; re-verified on
f77f2a2b1 itself before pushing --no-verify. Rung: PR open.

COMMITMENTS IN THIS DRAFT — none. Every string in the new code and tests is fixture-local
(example.invalid) or the generic code the API already publishes.

…a meeting

Founder, 2026-09-04: "whats_waiting — that's the one agent must call after got
installed and that one will prompt them to try a meeting" · "we want them to try
a meeting so they are activated. Activated meaning 1 meeting with transcription".

Until now whats_waiting answered a brand-new person with an empty list, and an
empty queue is indistinguishable from a finished one. Flow `onboarding` v1 reacts
to identity's `onboarding.completed` with one step, `first_meeting`, which parks
pending until that person's first `meeting.completed` THAT ACTUALLY TRANSCRIBED —
a bot that joined an empty room and left must not clear the item asking them to
try one. The words live in behavior/queue/onboarding.pending.md: meet.new,
request_meeting_bot, admit the bot, follow get_meeting_transcript with
since_index, stop_bot. The item clears itself; nobody dismisses it.

No `needs=("agent",)` anywhere — this is the no-agents product's own onboarding.
`needs=("meetings",)` because the segment count is a meetings read.

Also the seam a deployment needs to carry flows this repo does not publish:
VEXA_FLOWS_DEFS_EXTRA names importable packs, each `build(reg, db)`, registered
last. The other two seams already existed and are now tested rather than
rediscovered — $VEXA_BEHAVIOR_DIR/queue/ words ahead of the baked showcase, and
an intake with no carrier allow-list. core/flows/README.md documents all three.

Rebased onto #1497, which brings core/flows its own test harness — so there is
ONE, and it is that one. B's conftest.py, sqlite_double.py and both READMEs win
outright; the two onboarding suites adapt to them. conftest gains only what B
had no caller for: a db/clock/registry rig over the REAL flows_defs.production
(B's fixtures.rig() builds the fake world, which is the wrong rig for a test
about what a deployment composes), scoped rather than autouse so
test_no_agents.py keeps owning its own unset. The double's REACTION_DDL is gone
with the old file — B's builds itself from schema.sql — so the drift test reads
the column set back off the live table instead.

Two things B's suite then caught, both real. VEXA_FLOWS_DEFS_EXTRA was declared
in config.v1.json and not in flows_config.DECLARED, which test_config_contract
compares; it is declared now. And test_no_agents asserts every production flow
terminates with agents absent — onboarding does not, on purpose, because it is
waiting for the PERSON, so it is exempted there by name with the reason, and
still held to not failing and to never knocking on the agent door.

pyproject.toml stays (B has none, though B's README already describes one) and
its dependency list grows to what B's suite actually imports in-process: fastapi,
pydantic, sqlalchemy, psycopg, httpx. No socket is opened by any of it.

26 tests in the two onboarding files. NOTE: #1497's suite is 23 red against this
base — its tests describe source fixes (parse_ics anchoring, the desk-card flows,
the meetings-door declarations) that are not on oss/no-agents-mcp. This branch
adds none of them and fixes none of them; the count is identical before and after.
Adding pyproject.toml does make gate:python discover core/flows, so that red
becomes visible in CI rather than skipped.

Signed-off-by: DmitriyG228 <2280905@gmail.com>
…ory)

Signed-off-by: DmitriyG228 <2280905@gmail.com>
@DmitriyG228
DmitriyG228 changed the base branch from oss/no-agents-mcp to oss/no-agents-mcp-tests September 3, 2026 22:29
POST /friction answered 400 for any `kind` outside its eight-word list, and
the MCP tool published `kind` as a bare string described only by the title
FastAPI synthesises from the function name. On prod, 2026-09-04, an agent
guessed "missing", "broke" and "confusing"; twelve reports were thrown away
in twenty minutes. A sink that drops a report over a spelling is the one thing
it must never do.

Both halves are fixed. flows-api is LENIENT: an unrecognised `kind` is stored
as `other` and an unrecognised `severity` as `annoyance`, each with the
caller's own word kept in `kind_raw` / `severity_raw` and echoed back in the
reply, so the caller learns the canonical word after the report is safely in.
No migration — a report lives in `reaction.subject_refs`, a JSON document, so
these are new keys inside it and `schema.sql` is untouched.

And the tool now TELLS an agent the vocabulary: both enums are published on the
route's parameters, `kind`'s description carries one example per word, and the
docstring reads as instructions (what friction is, when to call, what the
payload is). The MCP edge carries them through — `bind` now prefers the route's
docstring over the synthesised summary (which is F-D12, `whats_waiting` reading
"Queue Waiting"), and `register` republishes an argument's `enum` into the tool
schema as schema, never as validation: enforcing at the edge would recreate the
same loss one hop earlier. `list_flows` gains the docstring it never had.

Tests: the three words prod actually sent are stored, not refused; a known word
is stored as-is; no vocabulary word can produce a 400; both enums and an
instructive description reach the OpenAPI the tool schema is derived from; and
no flows tool in the manifest is described by its own title alone. The
assembled-surface fixture now spells /friction the way flows-api really
publishes it — the hand-written summary in the old fixture is why this suite was
green while prod was losing reports.

Signed-off-by: DmitriyG228 <2280905@gmail.com>
… finding)

Caught on the t27f station, not reasoned out. The first cut of the F-D26 fix
republished the owning route's `enum` into the assembled tool's schema. The MCP
SDK's own dispatcher validates a call's arguments against `inputSchema` before
it calls anything (`jsonschema.validate` in mcp/server/lowlevel/server.py), so
`report_friction` with `kind="broke"` came back "Input validation error: 'broke'
is not one of [...]" — isError, no row stored. The fix for a sink that dropped
reports over a spelling had moved the drop one hop earlier.

The vocabulary now travels as `examples`, a JSON Schema annotation no validator
enforces, and the words are spelled out with one example each in the argument
description the owning route writes. A new test pins the rule for every
assembled tool, not just this one: an `enum` in a published tool schema is a
gate at this edge, never documentation.

Signed-off-by: DmitriyG228 <2280905@gmail.com>
Founder, 2026-09-04 10:2xZ, recorded on the t27 station after F-D26:
"we want to catch all signal, does not make sense being strict about it, we
want rich data, does not have to be too structured." That supersedes the fix
shape in the commit before this one, which mapped an unrecognised kind into
`other` and kept the reporter's word in `kind_raw`. A bucket is still a
taxonomy, applied at the door, by us, to somebody else's words.

So `kind` and `severity` are stored AS SENT — free text, no canonicalisation,
not even lowercasing, and no second field for a "raw" word because there is no
cooked one. Any query argument this route does not name is kept with the report
under `extra` rather than dropped: a stack frame, a request id, a model name,
whatever the reporter knew that we did not think to ask for. The eight kinds
survive only as suggestions in the tool description, and grouping happens on
the data later, by whoever reads the sink.

Extras are NAMESPACED, and that is not tidiness. `flows_timeline.model.concerns`
decides whose report a row is by reading `uid`/`subject`/`owner`/`organizer`
straight off the refs, so merging caller-supplied keys into the top level would
let a reporter file into somebody else's queue. Under `extra` they are data; at
the top level they would be authority. A test pins it.

The read model stops picking from a fixed key list and passes through every ref
it does not render itself — otherwise the sink would store fields no reader
could see until somebody remembered to widen the list, which is the same loss
one layer down and quieter.

Still refused: a report with no session and a report with no text. Those are
about the report existing at all, not about its shape.

Carrier and manifest updated to match. Tests: the three prod words stored
verbatim, casing preserved, no word in either field can produce a 400, unnamed
fields kept and returned, and an extra field cannot re-address the report.

Signed-off-by: DmitriyG228 <2280905@gmail.com>
Caught on the station, again, and it was my own break. The route stopped
publishing `enum` for kind/severity (the sink's words are suggestions now, so it
publishes `examples`); this edge still looked only for `enum`, so the eight
words vanished from `tools/list` entirely and the agent was back to guessing —
the first half of F-D26, restored by the fix for its second half.

`_vocabulary` now reads either key and always republishes `examples`. Both
shapes are real: a route that has decided its vocabulary is a suggestion says
`examples`, one that validates against a closed set says `enum`, and an agent
needs the words either way. The assembled-surface fixture is corrected to the
shape flows-api actually publishes, and a unit test pins both branches — the
fixture drifting from the live route is exactly why the original defect
survived a green suite.

Signed-off-by: DmitriyG228 <2280905@gmail.com>
Prod, 2026-09-04 11:0xZ. `POST /friction` without `session` answered 400 —
"session is required … A report with no session cannot be tied back to the
conversation that produced it, which is the exact gap this carrier exists to
close." The report it threw away was the one describing that refusal.

This is F-D26 one field along. That fix made `kind` and `severity` free text on
the founder's ruling ("we want to catch all signal, does not make sense being
strict about it, we want rich data, does not have to be too structured") but
left two refusals standing — no session, no text — reasoning that those were
about the report existing rather than about its shape. The distinction did not
survive contact with prod, and it was never the reporter's to pay for: the join
key is something WE want, so WE eat its absence. A report nothing can be tied
back to is worth strictly more than no report, and which producers cannot see a
session is itself signal.

So the rule is generalised rather than patched, to spare a third incident: NO
VALUE A CALLER SENDS OR OMITS PRODUCES A 400 on this route. Over-long values
truncate, unknown words store as sent, absences store as absences. The only
refusal left is authentication (401) — a report attributed to nobody is a
different object, not a poorer one.

A missing session omits the ref entirely rather than writing `""`, because an
empty string is a value and a later reader cannot tell it from a session whose
id is genuinely blank. `friction_for_subject` therefore renders two fields: the
human `session`, which says "no session", and the machine `session_id`, which
stays exactly what was stored. One field cannot be both honest to a grep and
readable to a person.

The tool description still tells the agent to send `session` whenever it has one
— it is most of what makes a report actionable — it just never bills it for not
having one. "Required" is gone from all three text arguments.

Audited the rest of the route and found the 422 door above it: every argument is
typed `str` with no enum/pattern/length gate, so pydantic cannot refuse a call
one layer above the handler where none of this leniency would run. A test pins
that, since it is the same defect class as the `enum` B7 already keeps out of
`kind`. Carrier, golden and manifest updated to match.

Tests: no session → 201, stored, read back as "no session" with `session_id`
empty and no `session` ref written; no text at all → 201; nine fields × seven
hostile values → no 400; every published parameter unconstrained and optional.

Signed-off-by: DmitriyG228 <2280905@gmail.com>
An admission refused by the service authority reached the caller as
code/reason/decision_id — enough for a program to branch on, nothing a
person or an agent can act on. Only the deployment that decided knows
what would fix the account, so only it can author that sentence.

meeting-api now carries two OPTIONAL fields off the decision into the
403 detail: message (<=512 chars, control characters stripped) and
action_url (https only, else dropped). Absent fields are OMITTED,
never null, so a deployment whose authority says nothing produces the
byte-same body as before. No vocabulary is added anywhere: reason
passes through whatever it says, allow-list free.

The blocking half was not the router. ServiceAuthorityDecision.from_wire
rejected the WHOLE response on any unknown field, so a decider that began
sending these turned an actionable 403 into service_authority_unavailable
— a 503 outage. Widening that allow-list by exactly these two names is the
fix; genuinely unknown fields are still rejected.

The gateway already returns upstream bodies verbatim and the MCP forward
already carries the JSON body into the tool error, so neither needed a
change — both get tests pinning it, because the property is invisible at
those seams and a future error-envelope tidy-up would drop the words with
every existing test still green.

Tests: 17 (meeting-api) + 5 (gateway) + 4 (mcp). Suites green — meeting-api
1346, gateway 326, gateway-conformance 86, mcp 229.

Signed-off-by: DmitriyG228 <2280905@gmail.com>
… inside it

An upstream refusal arrived at the tool caller as one sentence with a doubled
envelope inside it — `Error calling request_meeting_bot. Status code: 403.
Response: {"detail":{"detail":{...}}}` — so every field an agent could act on
was present and none of it was reachable without writing a parser.

Two fixes, neither of which knows any vocabulary:

  * The second `detail` was born HERE. `make_request` handed the upstream body,
    already `{"detail": ...}`, to its own `HTTPException(detail=...)`, and each
    re-raising hop would add another layer. Unwrap to the innermost object so
    the depth is a constant one. The gateway forwards status and body verbatim
    and adds nothing; meeting-api raises a single-level detail.
  * `fastapi-mcp` renders a failed call by interpolating the raw body into a
    sentence. `install_structured_tool_errors` raises inside `_request`, which
    `_execute_api_tool` re-raises unchanged, so the tool result now reads:
    the decider's own words first, `action_url:` when there is somewhere to go,
    the body once, compact. Still `isError: true`.

No reason, code or product noun is introduced: whatever the deciding service
said is what the agent sees, and a reason this build has never heard of renders
exactly as well as one it has.

Signed-off-by: DmitriyG228 <2280905@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant