Skip to content

[DEV] terminal: render service refusals verbatim from the server body (#1548) - #1550

Draft
DmitriyG228 wants to merge 22 commits into
mainfrom
t27-terminal-denial-verbatim
Draft

[DEV] terminal: render service refusals verbatim from the server body (#1548)#1550
DmitriyG228 wants to merge 22 commits into
mainfrom
t27-terminal-denial-verbatim

Conversation

@DmitriyG228

Copy link
Copy Markdown
Member

Billing is proprietary; the OSS relays what the deciding service sends. Every refusal now carries reason, message and action_url (#1532), so the terminal has nothing left to know.

  • serviceDenial.ts renders instead of deciding. Removed the seven-value ServiceAuthorityReason union, the exhaustiveness map, SERVICE_DENIAL_REASONS, ServiceDenialKind, ServiceDenialFacts (balance / ceiling / plan label), the whole copy table and the ACCOUNT_ORIGIN = "https://www.vexa.ai" literal with denialActionUrl(). 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 bare reason when it did not, HTTP <status> <code> when it said neither; line 2 always HTTP <status> <code>; action_url verbatim. unwrapDetail now peels nested {"detail": …} envelopes only when detail is the sole key, matching the MCP.
  • The only branch left is structural. A body carrying service_not_allowed / service_authority_unavailable is a decision; anything else on a 403 stays a permission fault with its own words (isAccessError, resolveJoinError unchanged in shape). No reason is named anywhere.
  • ServiceDenialPanel.tsx holds no words. Dropped the five-way kind→tone table; it renders the server's headline, the HTTP … line, the action_url as its own link text, and a "Try again" button whenever the caller passes onRetry. presentMeetingActionFailure in meeting.tsx now returns denial.headline instead of the removed title/body.
  • Tests inverted. serviceDenialVocabulary.test.tsserviceDenialRendering.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, excluding node_modules / .next), total → outside tests:

needle total outside tests
Add funds 1 0
Finish billing 1 0
prepaid 1 0
insufficient_balance 6 0
billing_setup_required 1 0
payment_past_due 4 0
vexa.ai 35 8

Every in-test hit is a fixture reason string or the guard's own ban list. The 8 remaining vexa.ai hits are all outside this surface and untouched: src/app/SetupGate.tsx (hosted-token onboarding: the docs link, transcription.vexa.ai, www.vexa.ai/account) and src/app/api/auth/logout/route.ts (parent-domain cookie clearing). None is in the refusal path.

npx vitest run in clients/terminal: 58 files, 462 tests, all passing. npx tsc --noEmit: clean.

The repo's pre-push gate aborted on gate:dataflowcompleteness: '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.

codex-release and others added 22 commits September 3, 2026 21:24
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")
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.

[DEV] Terminal client carries the billing refusal vocabulary and customer copy: render what the server sends, like the MCP

2 participants