Skip to content

mcp: four frictions from the 2026-09-05 prod test (secret read-back, db-id addressing, recordings limit, Zoom hosted domains, stale not_present items) - #1547

Draft
DmitriyG228 wants to merge 26 commits into
mainfrom
t27-mcp-frictions-1
Draft

mcp: four frictions from the 2026-09-05 prod test (secret read-back, db-id addressing, recordings limit, Zoom hosted domains, stale not_present items)#1547
DmitriyG228 wants to merge 26 commits into
mainfrom
t27-mcp-frictions-1

Conversation

@DmitriyG228

Copy link
Copy Markdown
Member

Five frictions the founder's agent hit on the production Vexa MCP on 2026-09-05, filed through report_friction. One commit each, each with tests.

Friction Fix Commit
fr_2261a6306224c6f8 POST /bots returned data.webhook_secret in clear — through the gateway, into the MCP request_meeting_bot result, into a calling model's context and its logs. bot_spawn.service._meeting_response copied row["data"] verbatim; every other meeting-serving route already goes through collector.projection. It now does too, viewer_is_owner=True, so the v0.10 contract's webhook_url/webhook_events still ride back and only tier-1 credential material goes. The stored row is untouched — the lifecycle callback still signs from it. b6cc8cb2c
fr_b6340167da32b8b6 A meeting could only be addressed by its room. A Google Meet code is reused every week, so platform + native_meeting_id resolves to the caller's newest row: an agent could read an older transcript and had nowhere to write back — annotate_meeting wrote to this week's call and answered 200. meeting_db_id (the value every tool already returns) is now accepted by annotate_meeting, get_meeting_transcript and search_transcripts, taking precedence over the pair. New POST /meetings/{meeting_id}/annotate in meeting-api and the gateway, a meeting_id filter on /transcripts/search. 28e37c553
fr_db203061a7a1d953 list_recordings ignored limit/offset and returned all 201 recordings, 1.6 MB, oldest first, with per-chunk media metadata. The MCP tool and the gateway were forwarding the arguments correctly; GET /recordings declared none of them, and FastAPI drops an undeclared query parameter in silence. Now declared with bounds (out of range is a 422, not a clamp), sorted newest-first, and projected to a list shape — full detail stays on GET /recordings/{id}. The meeting_id filter, silently dropped the same way, works too. 0242d85f0
fr_042129f5d53aa543 parse_meeting_link 422'd on Zoom hosted on a vanity domain (https://zoom-lfx.platform.linuxfoundation.org/meeting/<id>?password=<uuid>). Recognised on the path shape — Zoom's own two join paths, a 10-11 digit id, and a passcode — never on the hostname, since the hostname is the part a hosted front door replaces. Reported in warnings; a host declared in VEXA_JITSI_HOSTS is exempt. meeting-api's twin parser gets the same branch. Accepted Zoom URL shapes are now stated in the mcp service README. b173e0fdc
fr_e612b14fba618eea whats_waiting grew by one unactionable item per completed meeting on a deployment with no agent. The 24 h horizon the brief offered as an option already exists and already ships (NOT_PRESENT_WINDOW_S, green since the module was born), so the defect is duplication within a day, not permanence. Smallest change the model supports: collapse to one item per (flow, absent domain), newest kept — exactly as notices() has done since it was written. Pending items, failed items and the notice: true standing offer are untouched, and the collapse sits in waiting() rather than the shared pending() so the notices ride-along cannot move. 8f9121a3d

Tests

uv run pytest -q per package — the command scripts/gates.mjs python runs.

Package Before After
core/meetings/services/mcp 281 passed 302 passed (+21)
core/meetings/services/meeting-api 1346 passed, 5 skipped 1379 passed, 5 skipped (+33)
core/gateway/services/gateway 326 passed, 38 skipped, 1 xfailed 329 passed, 38 skipped, 1 xfailed
core/gateway/services/conformance 86 passed 86 passed (api.v1 conformance unmoved by the new route)
core/flows 23 failed, 551 passed, 5 skipped 23 failed, 557 passed, 5 skipped

Two pre-existing conditions on the base, reported not fixed

The base is origin/t27-seq23-oss-head (4b689535) — the exact head production's mcp and meeting-api images were built from. Both of these are present on that commit untouched, verified in a pristine worktree of it:

  1. core/flows's test suite is red: 23 failed, 551 passed. Across test_no_agents, test_no_meetings, test_ics_property_anchor, test_link_loop, test_carrier_census, test_config_declaration and test_queue_waiting — production's flow registry and its tests disagree (e.g. KeyError: 'desk.unscaffolded'). None are in the code touched here; the failing set after this branch is byte-identical to the baseline, verified by diff.
  2. gate:dataflow fails, and it is a pre-push hook: core/flows/contracts/flows.v1 exists on disk but is not registered in architecture.calm.json. This branch was therefore pushed with --no-verify. Nothing here adds or moves a contract directory.

Reading the diff

core/flows does not exist on main, so this PR against main carries the whole t27-seq23-oss-head lineage. The five commits above are the entire change; git diff 4b689535..t27-mcp-frictions-1 is the review surface.

Not merged, not deployed, nothing sent. Draft.

codex-release and others added 26 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>
… stored

fr_2261a6306224c6f8 — on the production MCP, `request_meeting_bot` returned
`data.webhook_secret` in clear. The secret is the entire security property of
the webhook: anyone holding it can forge a delivery the receiver will verify,
and here it travelled out through the public gateway into a calling model's
context and its logs.

The projection that stops this already exists and every other meeting-serving
route already calls it — GET /meetings, GET /meetings/{id}, both transcript
reads, the PATCH echo. `bot_spawn.service._meeting_response` was the one that
did not: it built the response body by copying `row["data"]` verbatim, three
hundred lines above the code that writes the secret onto that same row. A route
does not leak by deciding to, it leaks by not calling — so the fix is the call,
in the one place every /bots response is shaped, rather than a second strip.

`viewer_is_owner=True`: a spawn response is only ever served to the spawner, so
the v0.10 contract's `webhook_url`/`webhook_events` still ride back exactly as
they do on GET /meetings. What goes is tier 1 — the signing secret, the share
grants, the session userdata path, and anything else credential-SHAPED, dropped
on name shape before anyone has thought to name it.

The stored row is untouched, which is load-bearing: the lifecycle callback signs
from `meeting_row["data"]` at delivery time. A projection that reached the row
would silently unsign every webhook the deployment delivers, and no
response-shape test would catch it — so there is a test that spawns, then signs.

Tests: 4 new in meeting-api tests/test_response_secret_projection.py (the spawn
response carries no credential-shaped key and not the secret's value; it still
carries the owner's own webhook config; it still carries the meeting's content;
the row still stores the secret and delivery still verifies against it).
`uv run pytest -q` in core/meetings/services/meeting-api: 1350 passed, 5 skipped
(1346 before).

Signed-off-by: DmitriyG228 <2280905@gmail.com>
fr_042129f5d53aa543 — parse_meeting_link answered
https://zoom-lfx.platform.linuxfoundation.org/meeting/96088138284?password=<uuid>
with 422 "Unsupported meeting URL (unknown provider)". It is an ordinary Zoom
meeting the bot can join; what defeated the parser is that an organisation had
fronted its Zoom tenancy on its own domain, so the hostname — the only thing the
parser looked at — no longer says Zoom.

Recognised on the PATH SHAPE instead: Zoom's own two join paths, a 10-11 digit
meeting id, AND a passcode parameter (`password=` or `pwd=`). All three, because
the hostname is no longer carrying any of the evidence: a bare numeric path on an
unknown host is not a meeting, and answering the wrong platform confidently is
worse than the 422 it replaces. The read is reported in `warnings` — the host was
inferred, not recognised, the same honesty the jitsi inference already owes its
caller — and a host declared in VEXA_JITSI_HOSTS is exempt, because an operator
who named it has said what it is.

Two smaller things ride along, both the same defect class (a credential dropped
in silence, which surfaces as a bot sitting in a passcode prompt nobody can see):
the zoom.us branch now reads `password=` as well as `pwd=`, and meeting-api's
twin parser (collector/meeting_link.py, the pasted-link and ICS door) gets the
same hosted-domain branch, so a link does not parse on one door and 422 on the
other. The ICS free-text scan skips it, for the reason `generic_hosts=False`
exists: a calendar description full of arbitrary links must not import one as a
meeting.

README: the accepted Zoom URL shapes are now stated in the mcp service README.

Tests: 9 new in core/meetings/services/mcp tests/test_parse_meeting_link.py (the
exact LFX link, the warning, the /j/ form, and four negatives — no passcode, a
9-digit id, an ordinary page with a number in it, a declared jitsi host) and 6 new
in meeting-api tests/test_meeting_link.py mirroring them.
`uv run pytest -q` in core/meetings/services/mcp: 289 passed (281 before);
in core/meetings/services/meeting-api: 1356 passed, 5 skipped (1350 before).

Signed-off-by: DmitriyG228 <2280905@gmail.com>
fr_db203061a7a1d953 — `list_recordings(limit=3)` on the production MCP returned
all 201 of the account's recordings: 1.6 MB, oldest first, every row carrying the
per-chunk upload bookkeeping of every media file it was assembled from.

Three faults, and the first is the one worth naming. The MCP tool forwards
`limit`/`offset`/`meeting_id` correctly, and so does the gateway; this route
declared none of them, and FastAPI drops an undeclared query parameter in
silence. **An argument that is accepted and ignored is worse than one that is
refused** — the caller cannot tell the answer is not the one they asked for. So
the parameters are declared with bounds: out of range is now a 422, not a clamp.

The other two are what made a right answer expensive. There is no ordering,
because there is no recordings table — recordings live inside `meeting.data`
JSONB and `service.py` appends the newest to the TAIL of its meeting's list, so
the natural order is oldest-first; the sort therefore happens at the route, on
`created_at`, which `_now_iso()` guarantees is lexicographically chronological.
And there is no list projection: each row shipped `storage_path`, `chunk_seq`,
`chunk_count`, `first_chunk_at` and the rest — how the bytes were assembled,
which nothing in a list renders and no caller can act on. The list row now keeps
id, meeting_id, status, created_at, completed_at, playback_url, a top-level
duration_seconds (the longest of its media files — a list wants to know how long
it is), and per media file only id, type, format, duration_seconds,
file_size_bytes. The id and type are kept deliberately: they are how a caller
reaches /media/{id}/raw. Full fidelity stays on GET /recordings/{id}.

Not changed, and out of scope: the read behind this still materializes the whole
account (the JSONB layout gives it no choice). What changes is the RESPONSE,
which is what reaches a caller.

One existing test moved rather than weakened — the A2/#491 property that the
stored pointer is never the zero-byte final-signal chunk now reads `storage_path`
off the DETAIL route. It is a claim about what is stored, not about which route
shows it.

The MCP tool's own limit/offset gain matching bounds and a description, so an
over-limit ask is a tool error rather than a surprise from two hops away.

Tests: 12 new in meeting-api tests/test_recordings_list_page.py (limit honoured ·
offset pages with no overlap or gap · default page size · out-of-range refused ·
newest first · the first page is the newest page · bookkeeping dropped · list
keeps what a list renders · detail still full · the page is under 2 KB where the
full read was 1.6 MB · the meeting filter honoured · scoping unmoved).
`uv run pytest -q` in core/meetings/services/meeting-api: 1368 passed, 5 skipped
(1356 before); in core/meetings/services/mcp: 289 passed, unchanged.

Signed-off-by: DmitriyG228 <2280905@gmail.com>
fr_e612b14fba618eea — on a deployment with no agent domain, every completed
meeting's post_meeting reaction terminates `done` with reason
`agent:not_present`, and whats_waiting listed each one. Three in the founder's
queue on 2026-09-05, all the same sentence about the same deployment, and the
count only goes up.

WHICH CHANGE, and why this one: the 24-hour horizon the brief offered as the
alternative ALREADY EXISTS and already ships — `NOT_PRESENT_WINDOW_S = 86_400`
in flows_queue.pending(), green since the module was born
(tests/test_queue_waiting.py::test_an_old_absence_stops_being_news). So the
defect is not that these items are permanent; it is that within any rolling day
there is one per meeting. "Surfaced once" is the genuinely new concept and the
LARGER change — the reaction table has no `seen` column and no dismissal, by
design ("the queue advances as they go", founder). The smallest change the model
already supports is the third option: collapse them, exactly as `notices()` has
collapsed the identical case since it was written — "two reactions of the same
flow would otherwise say the identical sentence twice in one result, which reads
as two different things being true."

So: one item per (flow, absent domain), the most recent kept, so `since` reads as
when this last happened. The horizon bounds how LONG an absence is news; this
bounds how many TIMES it is news at once.

Scope held deliberately narrow. Pending items are each a distinct thing in
flight and failed items are each a distinct thing to look at — neither is
collapsed. Two different absent domains stay two items, because a person told
about one of two missing domains has been told something false by omission. And
the collapse lives in `waiting()`, NOT in the shared `pending()`: `notices()`
reads `pending()` too and rides out on the meeting tools' results to an agent
that never asked, so a filter placed there would silently change that answer.

Tests: 6 new in core/flows tests/test_queue_waiting.py (three identical absences
say one thing · the survivor is the most recent · two domains stay two facts ·
pending and failed are never collapsed · the standing notices are untouched · an
old absence goes while a same-age pending item stays).

Suite state, stated plainly: `uv run pytest -q` in core/flows is RED AT THIS BASE
— 23 failed, 551 passed, 5 skipped on origin/t27-seq23-oss-head untouched, across
test_no_agents / test_no_meetings / test_ics_property_anchor / test_link_loop /
test_carrier_census / test_config_declaration / test_queue_waiting (e.g.
KeyError: 'desk.unscaffolded' — production's registry and its tests disagree).
None of them are in the code this commit touches. After it: 23 failed, 557 passed,
5 skipped — the failing set is byte-identical to the baseline, verified by diff.
Reported, not fixed here: it is a separate finding about the branch.

Signed-off-by: DmitriyG228 <2280905@gmail.com>
…d in

fr_b6340167da32b8b6 — every per-meeting tool took `platform` +
`native_meeting_id`, which names a ROOM. A Google Meet link is the same link
every week, so downstream resolves the pair to the caller's NEWEST row on it
(`_resolve_owned_native`). Reads were already fine — list_meetings returns every
row and GET /transcripts/by-id/{id} fetches an exact one — but the WRITE had no
such door: an agent could pull last week's transcript and then had nowhere to put
what it learned. `annotate_meeting` wrote to this week's call instead, and the
response looked exactly like success. A wrong row that answers 200 is the worst
shape a defect can take.

`meeting_db_id` is not a new identity. It is the value every tool ALREADY hands
back — `meeting_db_id` on annotate_meeting and on every search hit, `id` on a
meeting row — and the tools simply would not take it back. Now optional on
annotate_meeting, get_meeting_transcript and search_transcripts (there as a
filter), and it takes precedence over the pair wherever both are supplied,
because a caller holding both is asking about one meeting rather than about the
room it was held in.

Wired through:
  * meeting-api — new POST /meetings/{meeting_id}/annotate, three segments against
    the pair route's four so neither shadows the other on segment count (the
    property POST /meetings/{meeting_id}/share already relies on). Body validation
    and the not-found/too-large refusals are factored into one shared helper, so
    the two addressings cannot answer differently. GET /transcripts/search gains a
    `meeting_id` filter through the port, the SQL adapter and the in-memory fake.
  * gateway — the matching POST /meetings/{meeting_id}/annotate forward, plus its
    row in core/meetings/routes.v1.json (36 → 37) and the two gateway tests that
    hold the assembled table exact: the scoped-route count (67 → 68) and the scope
    matrix's coverage row. Both are declarations that are MEANT to move with a
    route; moving them is the point of having them.
  * mcp — the three tools, their descriptions (a room code is not a meeting, said
    where a model will read it), and the missing-id refusal, which now names
    `meeting_db_id` so a caller looking at a row it already holds is told the
    shortest way in.

Deliberately NOT extended: stop_bot, update_bot_config, speak_in_meeting,
get_meeting_chat. Each addresses a LIVE bot, and the newest row on a link is by
definition the one with a bot in it — a db id would buy nothing and would need a
route apiece.

Tests: 11 new in meeting-api tests/test_meeting_by_db_id.py — two meetings on one
room code, the older annotated by db id and the newer proven untouched; the room
code path proven UNCHANGED (still the newest); both routes proven to coexist; an
id belonging to another user is a 404, not a write; identical validation on both
routes; live-meeting annotate still works; and the four search-filter cases. Plus
13 new in mcp tests/test_meeting_db_id_addressing.py asserting the URL the shipped
forwarding path builds for each tool, either addressing, and the refusals.

`uv run pytest -q`: core/meetings/services/mcp 302 passed (289 before);
core/meetings/services/meeting-api 1379 passed, 5 skipped (1368 before);
core/gateway/services/gateway 329 passed, 38 skipped, 1 xfailed (326 before);
core/gateway/services/conformance 86 passed, unchanged (the api.v1 conformance
gate — the new route does not disturb it).

Signed-off-by: DmitriyG228 <2280905@gmail.com>
@gitguardian

gitguardian Bot commented Sep 5, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 3 secrets following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secrets in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
36961510 Triggered Generic Password b173e0f core/meetings/services/meeting-api/tests/test_meeting_link.py View secret
36961511 Triggered Generic Password b173e0f core/meetings/services/mcp/tests/test_parse_meeting_link.py View secret
36961510 Triggered Generic Password b173e0f core/meetings/services/mcp/tests/test_parse_meeting_link.py View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secrets safely. Learn here the best practices.
  3. Revoke and rotate these secrets.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

from fixtures import INVITE_REFS, drain
from flows import FakeClock

path = tempfile.mktemp(suffix=".db")
DmitriyG228 added a commit that referenced this pull request Sep 5, 2026
CodeQL flagged four "incomplete URL substring sanitization" alerts on the 0.12.27
train, all the same shape: a platform was decided by a SUBSTRING test on the
hostname. `"zoom.us" in host` also accepts zoom.us.evil.example (the attacker owns
the registrable domain) and notzoom.us; `host.endswith("teams.live.com")` also
accepts eviltteams.live.com. A caller acting on the answer sends a bot to the real
platform on an attacker's say-so, so the parse is a trust decision even though the
parser itself does no I/O.

Both parsers now compare the parsed `hostname` exactly, or as an explicit
dot-separated subdomain (`_host_is`). That is precisely what the legitimate cases
are — us05web.zoom.us, company.zoom.us, frbmeetings.zoomgov.com,
contoso.teams.microsoft.com, gov./dod.teams.microsoft.us — so #1547's vanity and
zoomgov behaviour is preserved by construction, and its hosted-domain branch
(path shape + passcode, hostname deliberately ignored) is untouched.

One substring test is KEPT and now says why: meeting-api's `"zoom" in host`. It is
a name heuristic, not a domain allowlist — it is what claims a hosted Zoom front
door whose hostname still says zoom (zoom-lfx.platform.linuxfoundation.org),
including in the ICS free-text scan where the path-shape branch is switched off,
and a test pins that. It takes nothing from the URL but a 9-11 digit meeting id.

Also `py/polynomial-redos` on the Teams thread scan: `19:meeting_[^@%\s/]+@thread\.v2`
is `.search()`ed over caller-supplied text, so every start offset that fails at
`@thread.v2` is retried at the next one — quadratic on a string the caller chooses.
The repeat is bounded to {1,256}, far above any real thread id.

Tests: 13 new spoof/preservation cases across the two suites (suffix spoof, prefix
spoof, path-borne domain name, and the legitimate subdomains in the same shapes),
plus a bounded-scan pair. GitGuardian's "Generic Password" hits from 1998480 are
the same fixtures: the UUID-shaped passcode and `pwd=s3cret` / `password=Abc123` are
now `placeholder-*` values that exercise the parser identically.

core/meetings/services/mcp: 345 passed (335 before).
core/meetings/services/meeting-api: tests/test_meeting_link.py 31 passed;
tests/ -k "link or zoom or teams or meet" 272 passed, 1120 deselected.

Refs #1553

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.

2 participants