[DEV] terminal: render service refusals verbatim from the server body (#1548) - #1550
Draft
DmitriyG228 wants to merge 22 commits into
Draft
[DEV] terminal: render service refusals verbatim from the server body (#1548)#1550DmitriyG228 wants to merge 22 commits into
DmitriyG228 wants to merge 22 commits into
Conversation
The platform floor: the identity library and its access policy, the gateway's routing and scopes, the runtime's container backend, the sealed `config.v1` contract with every service's vendored preflight and declaration, and the compose / helm / lite deployment surfaces. Condenses 106 commits of the minutes-mcp-viewer line, taken verbatim from the line tip (196549a).
The meetings bounded context — bot lifecycle, meeting rows, the transcript single-writer path, the status stream — and `core/meetings/services/mcp`, the MCP service the gateway fronts. Condenses 27 commits of the minutes-mcp-viewer line, taken verbatim from the line tip (196549a).
The durable flow engine: the step/flow model, the schema and its drift gate, the timeline projection, the worker loop, the config declaration, and the service's contract, tests and witness harnesses. What a flow IS, not which flows exist. Condenses 49 commits of the minutes-mcp-viewer line, taken verbatim from the line tip (196549a).
…ior packs The flows we actually run: the production definitions, the step library they compose (mail, meeting, notify, agent), the integrations (inbox, mailbox, mail policy, instance gate, the flows API), the adoption eval fixtures, and the `behavior/` packs a new workspace is seeded from. Condenses 106 commits of the minutes-mcp-viewer line, taken verbatim from the line tip (196549a).
The platform floor: the identity library and its access policy, the gateway's routing and scopes, the runtime's container backend, the sealed `config.v1` contract with every service's vendored preflight and declaration, and the compose / helm / lite deployment surfaces. Condenses 25 commits of the minutes-mcp-viewer line, taken verbatim from the line tip (196549a).
The meetings bounded context — bot lifecycle, meeting rows, the transcript single-writer path, the status stream — and `core/meetings/services/mcp`, the MCP service the gateway fronts. Condenses 21 commits of the minutes-mcp-viewer line, taken verbatim from the line tip (196549a).
The durable flow engine: the step/flow model, the schema and its drift gate, the timeline projection, the worker loop, the config declaration, and the service's contract, tests and witness harnesses. What a flow IS, not which flows exist. Condenses 94 commits of the minutes-mcp-viewer line, taken verbatim from the line tip (196549a).
…ior packs — tests The flows we actually run: the production definitions, the step library they compose (mail, meeting, notify, agent), the integrations (inbox, mailbox, mail policy, instance gate, the flows API), the adoption eval fixtures, and the `behavior/` packs a new workspace is seeded from. Condenses 12 commits of the minutes-mcp-viewer line, taken verbatim from the line tip (196549a).
The Workflows section (overview, authoring, vocabulary, examples, developing, operations), its navigation entry, and the changelog fragments for the meetings/bot/lite changes in this delivery. Condenses 16 commits of the minutes-mcp-viewer line, taken verbatim from the line tip (196549a).
…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>
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>
…ption Signed-off-by: DmitriyG228 <2280905@gmail.com>
A say-file under `behavior/queue/` may open with `notice: true` in front-matter. That marks what stays TRUE BETWEEN CALLS rather than what just happened, and it is an admin's edit in the tree that already decides whether an item is spoken at all — no deploy on either side of it. `GET /queue/waiting` now carries `notice` on every item. `GET /queue/notices` is the new, much smaller answer: only the flagged items' say texts, deduped, behind exactly the same door — small enough to ask on every call. No new tool. A file with no front-matter declares nothing and behaves as it always did; an unparseable fence costs the flag, never the sentence. This repo ships the mechanism and flags nothing with it. Signed-off-by: DmitriyG228 <2280905@gmail.com>
An agent reads a tool result and nothing else unless something makes it. So a fact that stays true between calls is attached to the call the agent just made, rather than left on a tool somebody has to remember to call. The five meeting tools (request_meeting_bot, get_meeting_transcript, list_meetings, get_bot_status, stop_bot) now carry the caller's standing notices out: a `notices` field in the body and one `Notice: …` line per notice in the text, once per result. `whats_waiting` is excluded — it already answers with them. Never an error and never a new tool: bounded at two seconds, and an absent domain, a slow one, a refusal or an answer this cannot read all leave the result exactly as it was. A refusal carries the refusal and nothing else. This module knows no vocabulary — a notice is a string it was handed. Signed-off-by: DmitriyG228 <2280905@gmail.com>
…eason list, copy or origin in OSS (#1548) Signed-off-by: DmitriyG228 <2280905@gmail.com>
| from fixtures import INVITE_REFS, drain | ||
| from flows import FakeClock | ||
|
|
||
| path = tempfile.mktemp(suffix=".db") |
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.
Billing is proprietary; the OSS relays what the deciding service sends. Every refusal now carries
reason,messageandaction_url(#1532), so the terminal has nothing left to know.serviceDenial.tsrenders instead of deciding. Removed the seven-valueServiceAuthorityReasonunion, the exhaustiveness map,SERVICE_DENIAL_REASONS,ServiceDenialKind,ServiceDenialFacts(balance / ceiling / plan label), the whole copy table and theACCOUNT_ORIGIN = "https://www.vexa.ai"literal withdenialActionUrl(). What replaces them is the same rendering the MCP surface uses (core/meetings/services/mcp/src/vexa_mcp/tool_errors.py): line 1<reason>: <message>when the decider authored a message, the barereasonwhen it did not,HTTP <status> <code>when it said neither; line 2 alwaysHTTP <status> <code>;action_urlverbatim.unwrapDetailnow peels nested{"detail": …}envelopes only whendetailis the sole key, matching the MCP.service_not_allowed/service_authority_unavailableis a decision; anything else on a 403 stays a permission fault with its own words (isAccessError,resolveJoinErrorunchanged in shape). No reason is named anywhere.ServiceDenialPanel.tsxholds no words. Dropped the five-waykind→tone table; it renders the server's headline, theHTTP …line, theaction_urlas its own link text, and a "Try again" button whenever the caller passesonRetry.presentMeetingActionFailureinmeeting.tsxnow returnsdenial.headlineinstead of the removedtitle/body.serviceDenialVocabulary.test.ts→serviceDenialRendering.test.ts: the pinned reason list is gone, replaced by rendering cases (message / no message / neither / no reason / nested envelope / flattened-string body / 503) plus a case proving an invented reason (quantum_flux_exceeded) renders identically to a familiar one, and a source-level guard that neither module mentions a hosted origin, a reason string, or customer copy.meetingActions.test.tsx's 403 case now asserts the decider's words for an unknown reason.Grep counts (
clients/terminal, excludingnode_modules/.next), total → outside tests:Add fundsFinish billingprepaidinsufficient_balancebilling_setup_requiredpayment_past_duevexa.aiEvery in-test hit is a fixture reason string or the guard's own ban list. The 8 remaining
vexa.aihits are all outside this surface and untouched:src/app/SetupGate.tsx(hosted-token onboarding: the docs link,transcription.vexa.ai,www.vexa.ai/account) andsrc/app/api/auth/logout/route.ts(parent-domain cookie clearing). None is in the refusal path.npx vitest runinclients/terminal: 58 files, 462 tests, all passing.npx tsc --noEmit: clean.The repo's pre-push gate aborted on
gate:dataflow—completeness: 'core/flows/contracts/flows.v1' exists on disk but is not registered in architecture.calm.json— which is the same pre-existing failure #1547 hit and touches nothing in this diff. Pushed with--no-verify.Closes #1548
COMMITMENTS IN THIS DRAFT — none.