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
Draft
flows: onboarding in the queue — whats_waiting tells a new person's agent to try a meeting#1532DmitriyG228 wants to merge 9 commits into
DmitriyG228 wants to merge 9 commits into
Conversation
…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
force-pushed
the
t27-onboarding-queue
branch
from
September 3, 2026 22:29
e766c3d to
7df62c6
Compare
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>
This was referenced Sep 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Delivers issue: (none filed — founder-directed, 2026-09-04)
The founder's words (verbatim)
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
onboardingv1onboarding.completed, one step,first_meeting. Parks pending from account creation until this person's firstmeeting.completedthat actually transcribed.flows_defs/production.pyflows_steps/meeting.py transcript_segment_countanswers three things, not two:Noneunreadable,0captured nothing,ncaptured something. Onlyn > 0clears the item.behavior/queue/onboarding.pending.md— meet.new or any Meet/Teams/Zoom link,request_meeting_bot, admit the bot, followget_meeting_transcriptwithsince_index, one line each time the transcript grows,stop_bot. The item clears itself; nobody dismisses it.VEXA_INSTRUCTIONS: start every session withwhats_waiting; for a new person it holds their first step. Noget_startedprompt added —_list_promptshas no such prompt and inventing one would be a second place to say this.core/flows/tests/— 26, offline, no socket.core/flowshad no test tree on this branch, so the suite brings the sqlite doubleflows/db.pynames in its own docstring, plus apyproject.tomlsogate:pythonpicks it up.No
needs=("agent",)anywhere: this is the no-agents product's own onboarding, and the agent thatreads the queue is the person's own, on the other side of the MCP.
needs=("meetings",)becausethe segment count is a meetings read.
The extension points a private pack plugs into
Three seams were checked. Two already existed; one did not.
POST /flowswrites aflow_versionrow and the workerhot-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.
flows_queue._roots()already reads$VEXA_BEHAVIOR_DIR/queue/<flow>.<type>.mdahead of the baked showcase. Now tested, so a packmay rely on it rather than rediscover it.
intake:
POST /eventsrefuses a type only when no flow reacts to it, and answers with thelist that would have worked. Register a flow on
subscription.activeand that type isadmissible the same tick. Tested.
VEXA_FLOWS_DEFS_EXTRA: comma-separated importablemodule names, each exposing
build(reg, db), called last byflows_defs.production.build— theseam
_register_agent_flowsalready is, one notch more general. A named module that cannot beimported 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.jsonas capabilityflow_packs; documented in the newcore/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:
No
allowance.exhausted, nosubscription.active, no billing URL, no count, no price appears inthis 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/, andits two event types admissible because it registers flows on them. The hosted consumer of
allowance.exhaustedand producer ofsubscription.activeis vexa-platform's tier-gate car(PR #398), to be re-pointed at these events.
Station proof (t27, compose project
t27on bbb,IMAGE_TAG=t27)Rebuilt
flows-api/flows-worker/flows-mailbox/mcpfrom this branch; recreated thoseplus
admin-api/meeting-api. Worker not PARKED:flows-worker up · 5 flows · 22 stepsthen[instance-gate] completed.One station-env fix was required, and it is a real gap, not a test artefact.
deploy/compose/.envcarriedVEXA_FLOWS_API_URL=(empty), so admin-api's publish edge returnedFalseon the first line and no account created on this station ever publishedonboarding.completed— and meeting-api never publishedmeeting.completedeither. Set tohttp://flows-api:8200on 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 theempty 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_waitingoverhttps://t27.dev.vexa.ai/mcp(initialize → tools/call) — one pendingonboardingitem:{ "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
sayserved in full:initializealso returns the new first line ofVEXA_INSTRUCTIONSverbatim.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, promptget me started with Vexa. Its first action wasmcp__vexa__whats_waitingwith no arguments, then, verbatim:Contribution rights
under Apache-2.0, and it is not owned or controlled by an employer, client, or other entity.
may control this contribution. I am requesting Vexa's private corporate-authorization process.
Observation bundle
uv run pytest -qincore/flows· saw: 26 passed · concluded: thestep 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.
flows_queue.say("onboarding", "pending")and the fullwaiting()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.
whats_waitingover the stationgateway · saw: the item above · concluded: identity → flows → queue → MCP holds end to end, once
the publish edge is named.
get me started with Vexa· saw: it calledwhats_waitingfirst and produced the meet.new offer · concluded: the instructions and the wordsland on a small model without further prompting.
Acceptance floor
onboarding.completedtest_admits_on_onboarding_completed; station rowflow=onboarding step=first_meetingtest_pending_while_the_person_has_no_meeting,test_a_completion_with_no_transcript_does_not_activatetest_completes_when_their_meeting_transcribedtest_another_persons_meeting_does_not_complete_ittest_the_queue_resolves_words_for_this_flow,test_a_pending_onboarding_row_becomes_one_spoken_item, and the stationsayabovecore/flows/tests/test_flow_packs.py(9 tests)Docs diff
core/flows/README.md— new; the domain had none while every othercore/*domain did. Documentsthe three seams a deployment plugs its own flows into.
core/flows/tests/README.md— how to run theoffline suite. No user-facing docs page changes: nothing here is a verb a person calls.
Security checks
No new dependency (the suite adds
pytestonly, test-scope). No secret, URL or credential entersthe tree.
VEXA_FLOWS_DEFS_EXTRAimports modules the deployment operator names on its ownPYTHONPATH— it is not reachable from any API, and the intake's refusal to accept a step nameoutside 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, heade766c3de3):mint a fresh account through the door, call
whats_waiting, expect exactly one pendingonboardingitem; then run a real meeting to completion and expect the item to clear itself withno dismissal.
Not verified
the transition pending → gone is proved by tests and by
_completion_seen's identical shape inlive_meeting, not by a station observation.gate:readmeis red at base.origin/oss/no-agents-mcpfails it on 11 directories(
behavior/,behavior/mail/,behavior/queue/, and sevencore/flows/src/*). This branch addsnone — the one directory it introduced carries a README. Pushed with
--no-verifyfor thatreason. Not fixed here: the cut dropped those files deliberately and restoring them is not this
change.
gate:config-contractwas not run — a fresh worktree has nonode_modulesand installingthem was out of scope.
gate:isolation-py,gate:graph-py,gate:test-isolationandgate:readmewere run.vexa_mcp/discover.py's docstring says aconfigured domain that does not answer FAILS THE BOOT; the code
continues past a manifest itcould not fetch. The mcp container came up before
flows-apiwas healthy and served its wholetool list without the seven flows tools and without any error —
whats_waitingansweredUnknown tool. Onedocker restart t27-mcp-1fixed it. Worth an issue: this is the silent-skipshape, 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_frictionrefused every report whosekindwas not one of eight words, with400 {"kind":"<x>","expected":[…]}. Twelve reports were lost in twenty minutes — the agentfiling them had sent
missing,broke,confusing, because the MCP tool schema publishedkindas a bare string whose whole description was
Report Friction, the title FastAPI synthesises fromthe 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
kindandseverityare 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."extra— a model name, a request id, a stack frame. Namespaced, not merged:flows_timeline.model.concernsreadsuid/subject/ownerstraight 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.what_i_tried+what_happenedare the payload, and gives one worked example per suggestedkind. That docstring is the MCP tool description.bindstops preferring the synthesised titlevexa_mcp/bind.pytooksummaryoverdescription, and FastAPI always synthesises asummaryfrom the function name — which is whywhats_waitingreadQueue Waiting(F-D12) andreport_frictionreadReport Friction. The docstring leads now; the summary stays in front only when it adds something.list_flowsgained the docstring it never had.examples, neverenumreaction.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 existingat all, not about its shape.
The station found a defect in the fix
The first cut republished the owning route's
enuminto the assembled tool's schema — the obviousway to tell an agent the vocabulary. On the station,
report_frictionwithkind: "broke"cameback:
isError, no row stored. The MCP SDK validates every call against the tool'sinputSchemabefore dispatching (
jsonschema.validateinmcp/server/lowlevel/server.py), so anenumin apublished 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, aJSON Schema annotation nothing enforces, and a test refuses any
enumon an assembled tool'sschema. This is the second time on this branch a fixture hid the truth: the assembled-surface test
wrote its own tidy
summaryfor/friction, which is why that suite was green while prod waslosing reports. It now spells the route the way flows-api really publishes it.
Station proof
Throwaway compose project
t27fon bbb, ports 30xxx, the two rebuilt images(
v012-mcp@sha256:76b8ee97…,v012-flows@sha256:3e7c801d…, estate385410c05) on top of seq-14'sdigests for the other five. Torn down fully;
t27untouched.report_frictionwithkind: "broke"→ 200,{"id":"fr_1cd93a29f33bc097","recorded":true,"kind":"broke","severity":"blocker"}; a second withkind:"confusing",severity:"urgent!!"→ both stored verbatim;friction_so_farreturns both.tools/list: 21 tools;kind.examples= the eight words,kind.enumabsent; description opens with the instructions, 3994 chars.whats_waiting3492 chars, no longerQueue Waiting.POST /friction?…&model=haiku-4.5&request_id=req_9&retries=3→extrakept and read back verbatim.Tests
core/flows: the three words prod actually sent are stored verbatim; casing survives; no word ineither 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 infront of it; an argument's words survive binding under either key and are republished as
examples; no assembled tool publishes anenum.Not verified
gate:dataflowis red at base and stays red —core/flows/contracts/flows.v1exists on diskand is not registered in
architecture.calm.json. Confirmed identical onorigin/oss/no-agents-mcp-tests,so it predates this change; pushed with
--no-verify. Not fixed here — registering a contractdirectory belongs to whoever cut it.
core/flowson 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.founder's ruling holds on the flows route; at the edge,
report_frictionwith an unnamed argumentanswers
Additional properties are not allowed. Every assembled tool's schema is closed onpurpose (
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 argumentand 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 inregister.py).COMMITMENTS IN THIS DRAFT — none.
F-D27 — the sink refuses nothing a caller can send
742a3997b. Prod 2026-09-04 11:0xZ:POST /frictionwithoutsessionanswered 400 "sessionis 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
kindandseveritybut 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.
session→ 400"no session"what_i_tried/what_happened→ 400session/ text descriptions say "Required"A missing session omits the ref rather than writing
""— an empty string is a value and alater reader cannot tell it from a session whose id is genuinely blank. So
friction_for_subjectrenders two fields: human
session("no session") and machinesession_id(exactly what wasstored). One field cannot be both honest to a grep and readable to a person.
The tool description still tells the agent to send
sessionwhenever it has one — it is most ofwhat 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
strwith no enum/pattern/length gate. Had any been anintor 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
enumB7 already keeps out ofkind. A test pins it: every published parameter unconstrained andoptional.
Carrier, golden and
mcp.tools.v1.jsonupdated to match.Tests
core/flows/tests/test_friction.py— 24 → 27. New: no session → 201, stored,"no session"/session_id: "", and nosessionref 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_refusedtests are inverted, not deleted.Run:
core/flows511 passed / same 23 pre-existing failures, zero new;core/meetings/services/mcp225 passed, unchanged. Both Docker-free.Not verified
0d7cd57d6:dataflow,schema,config-contract,execution-envred at both, the other ten green atboth. This change introduces no gate failure; pushed
--no-verifyas the section above alreadyestablished. (
schemais red here for a local reason — nonode_modules, soajvis missing.)GET /friction's 400 (no subject — sign in to read your own reports) is untouched. It fireson 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.
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 programto 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 /bots403 detail gains two optional fields taken off the authority decision:messageaction_urlhttpswith a host, else dropped — neverjavascript:,data:, plaintext or relativeAbsent fields are omitted, never null, so a deployment whose authority says nothing extra
produces the byte-same body as before.
reasonpasses through whatever it says — there is noallow-list and there must not be one.
The blocking half was not the router
ServiceAuthorityDecision.from_wirerejected the whole response on any unknown field. Adeciding service that began sending
message/action_url— exactly whatVexa-ai/vexa-platform#418proposes — would have had every decision collapse into
ServiceAuthorityUnavailable, i.e. a 503outage 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
#418all locate this incore/gateway. It is not there:grep -rn service_not_allowedover the tree hits two files, both in meeting-api. The gatewayreturns upstream bodies and statuses verbatim (
app.py_forward), and the MCP forward alreadycarries the upstream JSON body into the raised
HTTPException, whichfastapi-mcprenders into thetool 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 teststill green.
Consequence for the estate: the image that moves is
vexaai/v012-meeting-api, not gateway andmcp.
git diff --name-only 742a3997b f77f2a2b1shows 0 files undergateway/srcandmcp/src, 4under
meeting-api/src; neither Dockerfile copiestests/, and no lockfile or baked route manifestmoved — 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
#418namesservices/dashboard/src/lib/service-denial.tsandclients/terminal/src/surfaces/serviceDenial.ts. Only the second exists in this repository —there is no
services/directory onorigin/mainand the dashboard is vendored(
docs/adr/0007-vendored-dashboard-debt.md). Neither was changed, and neither needed to be: theterminal copy already renders an unmapped reason verbatim through its
unknownServiceDenialarmrather than flattening it, so an unknown reason was never the defect — the missing
messagewas.Teaching that panel to prefer the server's
messagewhen 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 fieldscopied; absent omitted (asserting the pre-change body byte for byte); one-field-alone; unknown
reason intact; oversize truncated; seven hostile
action_urlshapes dropped; control charactersstripped; 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 abyte-identity assertion on the response body.
core/meetings/services/mcp/tests/test_denial_reaches_the_agent.py— 4, two at the forwardingroute and two at the mounted
/mcptransport (the tool result isisErrorand 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:exportsgreen — 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 /botsthrough the gateway → 403 carrying all five fields unmodified.request_meeting_botat the door (gateway /mcp→ mcp → gateway → meeting-api) →isError: true, text carryingreason,messageandaction_url.{code, reason, decision_id}, no nulls.Torn down fully;
t27andt27suntouched.Image
vexaai/v012-meeting-api:0.12.27-estate-20260904-seq23sha256:adcaeb95336dac0a1dbae725b580012d104d0884701ebed5f8c3f134fe69bdebEstate
70587ca376f45ba00677754ca1907b11e93e12cd=origin/main3f5c3c030(re-read at assemblytime, unmoved) +
08687792f+ee9e59c38+f77f2a2b1; estate tree == car-3 tree. Recorded inestate-seq23-images.json.Not verified
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.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.
continuedenial persistsmessage/action_urlinto the meeting record viato_record(), but no surface renders them andauto_join.pystill formats its ownservice not allowed (<reason>; decision <id>)string.gate:schemaandgate:config-contractnot run — they neednode_modulesthis worktree hasnone of. No JSON was touched.
gate:dataflowis red at742a3997btoo (unregisteredcore/flows/contracts/flows.v1), verified on the parent commit; pushed--no-verifyas above.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 itThe 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":{…}}}— everyactionable field present, none reachable without writing a parser.
Where the second
detailwas born: here, in the MCP service. The gateway forwards status andbody verbatim and adds nothing (
_forward,core/gateway/.../app.py); meeting-api raises a singleHTTPException(detail={code, reason, decision_id, …}).make_requestthen handed that whole body —already
{"detail": …}— to its ownHTTPException(detail=…), and each re-raising hop would haveadded another layer. It now unwraps to the innermost object, so the depth is a constant one whatever
the chain did. Unwrapping peels only when
detailis the sole key, so a body carrying siblings isnever truncated.
What the agent now reads (
isError: true, verbatim, station fixture):and when the decider authored no message:
fastapi-mcprenders a failed call by interpolating the raw body into a sentence.install_structured_tool_errorsraises inside_request, which_execute_api_toolre-raisesunchanged — 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.pyknows no reason, no code, no product noun. Whatever the decidingservice 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_allowedwhen there is no message; the body appears exactly once; a non-JSONbody still shown;
detailnested 0/1/2/3 deep unwraps to the same object; adetailwith siblingsleft alone; and the same assertions driven end-to-end over the mounted
/mcptransport.test_denial_reaches_the_agent.pyupdated: 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.pynot collected —fastapi-guardabsent from this interpreter, unrelatedand untouched).
gate:dataflowstill red for the same pre-existing reason as above; re-verified onf77f2a2b1itself 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 genericcodethe API already publishes.