Skip to content

Commit 2f59f90

Browse files
author
codex-release
committed
merge: flows-meetings-optional — delivers #1496
2 parents 37572d5 + c66d249 commit 2f59f90

11 files changed

Lines changed: 385 additions & 35 deletions

File tree

core/flows/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,37 @@ once per process; a caller sending both is answered on the new one.
101101
An unreachable identity answers **503**, never 401: not being able to ask who somebody is has not
102102
established that their credential is bad.
103103

104+
## Meetings is optional, and so is the agent domain
105+
106+
Two of the three domains flows can reach are **capability** doors, and their absence is a shape of
107+
deployment rather than a misconfiguration (PRD decision 40.7; decision 5 for meetings, agreed by
108+
the founder). Unset means *that domain is not deployed*: the process boots, admits facts and serves
109+
its queue, and the steps that would have reached the absent domain answer `<domain>:not_present`
110+
terminal, with the reason on the reaction, never a retry loop against a door that is not there.
111+
112+
| domain | key | steps that declare it |
113+
|---|---|---|
114+
| agent | `VEXA_FLOWS_AGENT_API_URL` | thirteen, listed in `tests/test_no_agents.py` |
115+
| meetings | `VEXA_FLOWS_GATEWAY_URL` | `await_start` · `dispatch_bot` · `run_meeting` · `process_meeting` · `email_minutes` · `email_attendees` · `drop_to_attendees` · `prepare_meeting` |
116+
117+
**Presence is a configuration fact, never a probe.** A health check would make *"meeting-api is
118+
restarting"* and *"there is no meeting-api"* the same answer, and only the second is a supported
119+
product — the first is an outage, and it keeps the retry path it has always had.
120+
121+
Two things a reader should not have to discover. The meetings key still says **GATEWAY** because
122+
flows reaches meetings *through the edge* today, which ADR-0037 forbids and which is a separate
123+
change: the gateway resolves the caller's key and enriches every forward with the user's scopes,
124+
workspaces and **limits**, and `POST /bots` enforces the per-user concurrent-bot cap out of that
125+
last one — so calling meeting-api directly is not a rename. And the door resolves **at access**,
126+
never at import (`flows_steps.common.meetings_door`): `from .common import GATEWAY` used to run the
127+
refusal while `flows_steps/meeting.py` was still loading, so an unset door was an ImportError for
128+
the whole step vocabulary, including every step with no interest in meetings.
129+
130+
**The class change does not close flows' `gate:domain-doors` entry, and the gate is right.** That
131+
gate refuses a domain naming an EDGE before it looks at any class, so the entry closes on the
132+
de-hop above and on nothing else; `scripts/domain-doors.allow.json` now says so, in place of a
133+
ruling that promised this change would close it.
134+
104135
## Configuration
105136

106137
Every environment key this brick reads is declared once in `src/flows_config.py`, with its class

core/flows/src/config.v1.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,9 @@
102102
},
103103
{
104104
"key": "VEXA_FLOWS_GATEWAY_URL",
105-
"class": "required-explicit",
106-
"description": "The meetings gateway, on the same terms.",
105+
"class": "capability",
106+
"description": "The meetings domain, reached through the gateway. UNSET MEANS THE MEETINGS DOMAIN IS NOT DEPLOYED (PRD decision 40.7 + decision 5) — a supported shape of deployment, not a misconfiguration, which is exactly what the `capability` class means. `required-explicit` was a refusal to BOOT: preflight named this door and exited before anything about meetings was asked, so flows+identity with no meetings could not start at all. The key still says GATEWAY because flows reaches meetings through the edge today; that hop is ADR-0037's to close and is not this class change.",
107+
"capability": "meetings",
107108
"targets": [
108109
"compose"
109110
]
@@ -380,6 +381,10 @@
380381
"description": "Harness overrides used by the dogfood rig and the offline suites.",
381382
"mode": "any",
382383
"when_unconfigured": "The real instance gate and the real transcript. This is the deployment state."
384+
},
385+
"meetings": {
386+
"description": "This deployment carries the meetings domain — scheduling a bot into a call and reading back what it recorded (PRD decision 40.7; decision 5 for meetings).",
387+
"when_unconfigured": "The meetings domain is not deployed. Flows still boots, still admits facts and still serves its queue; every flow step that would schedule or read a bot answers `meetings:not_present` — terminal, with the reason on the reaction, never a retry loop against a door that is not there."
383388
}
384389
},
385390
"surface_only": [

core/flows/src/flows_config.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,18 @@
7575
#
7676
# A deployment default may name the SERVICE (`http://admin-api:8057`, which resolves only
7777
# inside the deployment's own network) and lives in compose/helm, never here.
78-
"VEXA_FLOWS_GATEWAY_URL": ("required-explicit", None, "the meetings gateway."),
78+
# THE MEETINGS DOOR (PRD decision 40.7 + decision 5, founder-agreed: flows → meetings is a
79+
# declared OPTIONAL dependency that degrades). `capability`, which is what the class means
80+
# here: unset is not a misconfiguration, it is a deployment that runs no meetings domain, and
81+
# every step that would schedule or read a bot answers `not_present` instead of knocking.
82+
# `required-explicit` was a refusal to BOOT — `preflight()` names the door and exits before
83+
# anything about meetings is asked, so a flows+identity deployment could not start at all.
84+
# The key still says GATEWAY because flows reaches meetings through the edge today; that hop
85+
# is ADR-0037's to close and is not this class change (see flows_steps.common.MEETINGS_DOOR).
86+
"VEXA_FLOWS_GATEWAY_URL": ("capability", None,
87+
"the meetings domain, reached through the gateway. UNSET MEANS THE "
88+
"MEETINGS DOMAIN IS NOT DEPLOYED — see "
89+
"flows_steps.common.domain_present."),
7990
"VEXA_FLOWS_ADMIN_API_URL": ("required-explicit", None, "admin-api's admin tier."),
8091
# THE LINK PORT, and `capability` is what makes it one (PRD decision 4, founder 2026-09-03
8192
# 09:56Z: *"fine as a port + adapter (P16): flows owns a link port, the terminal is one
@@ -229,6 +240,9 @@ class ConfigError(RuntimeError):
229240
#: in the table above and nowhere else.
230241
DOOR_KEYS = ("VEXA_FLOWS_GATEWAY_URL", "VEXA_FLOWS_ADMIN_API_URL", "VEXA_UI_URL",
231242
"VEXA_FLOWS_AGENT_API_URL")
243+
# `missing_doors` filters this tuple on the CLASS, so the two capability doors (meetings, agent)
244+
# drop out of the preflight by the same line that declares them optional — there is no second list
245+
# to keep in step, which is how the two would drift.
232246

233247

234248
def require(name: str) -> str:

core/flows/src/flows_defs/production.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -485,9 +485,14 @@ def emit_prep(ctx: StepCtx):
485485
{**ctx.refs, "uid": ctx.prior["ensure_user"]["uid"]})
486486
return Done({})
487487

488-
reg.step(mt.await_start)
489-
reg.step(mt.dispatch_bot)
490-
reg.step(mt.run_meeting)
488+
# THE THREE THAT ARE THE MEETING (PRD decision 40.7 + decision 5). They wait for a call to
489+
# start, put a bot in it, and read back what it recorded — there is nothing to degrade to and
490+
# nothing to fake, so a deployment with no meetings domain answers `meetings:not_present` and
491+
# the reaction ends there, carrying the reason. Declared at registration rather than checked in
492+
# the body, for the reason `Registry.step` gives: a body that has to remember to ask cannot be
493+
# enumerated, and the next step somebody adds will not remember.
494+
for _fn in (mt.await_start, mt.dispatch_bot, mt.run_meeting):
495+
reg.step(needs=("meetings",))(_fn)
491496

492497
@reg.step
493498
def emit_completed(ctx: StepCtx):
@@ -600,7 +605,8 @@ def require_workspace(ctx: StepCtx):
600605
# REACHES THE AGENT DOMAIN (PRD decision 40.7). Declared, not checked inside the body:
601606
# the engine answers `not_present` for this step without entering it when a deployment
602607
# does not run agents, so the absent door is never knocked on.
603-
@reg.step(needs=("agent",))
608+
# ALSO REACHES MEETINGS — reads the room order, the meeting row and the transcript (`mt.room_order`, `mt.meeting_row`, `mt.transcript_text`).
609+
@reg.step(needs=("agent", "meetings"))
604610
def process_meeting(ctx: StepCtx):
605611
"""ONE REAL AGENT TURN on session meet-<id>, producing ONE SHARED ARTEFACT: the meeting's
606612
report, the same words for everybody who was in the room.
@@ -817,7 +823,8 @@ def _shared_report_rules(room_read: list, group: str) -> str:
817823
# REACHES THE AGENT DOMAIN (PRD decision 40.7). Declared, not checked inside the body:
818824
# the engine answers `not_present` for this step without entering it when a deployment
819825
# does not run agents, so the absent door is never knocked on.
820-
@reg.step(needs=("agent",))
826+
# ALSO REACHES MEETINGS — stamps the meeting's date and mints the link's refs (`_meeting_stamp` → `mt.meeting_start`).
827+
@reg.step(needs=("agent", "meetings"))
821828
def email_minutes(ctx: StepCtx):
822829
"""Send the committed note VERBATIM in the body + the feedback ask + ONE link into the
823830
minutes terminal, already primed on this meeting. Cannot run before the commit: its input
@@ -1047,7 +1054,8 @@ def _attendees(ctx) -> list:
10471054
# REACHES THE AGENT DOMAIN (PRD decision 40.7). Declared, not checked inside the body:
10481055
# the engine answers `not_present` for this step without entering it when a deployment
10491056
# does not run agents, so the absent door is never knocked on.
1050-
@reg.step(needs=("agent",))
1057+
# ALSO REACHES MEETINGS — reads the meeting row and mints a transcript share per attendee (`mt.meeting_row`, `mt.mint_transcript_share`).
1058+
@reg.step(needs=("agent", "meetings"))
10511059
def email_attendees(ctx: StepCtx):
10521060
"""Every inside-domain ATTENDEE gets the follow-up plus ONE button into a chat the click
10531061
composes. Cannot run before the note: its input is process_meeting's receipt.
@@ -1338,7 +1346,8 @@ def _write_if_changed(their_uid: str, path: str, content: str) -> bool:
13381346
# REACHES THE AGENT DOMAIN (PRD decision 40.7). Declared, not checked inside the body:
13391347
# the engine answers `not_present` for this step without entering it when a deployment
13401348
# does not run agents, so the absent door is never knocked on.
1341-
@reg.step(needs=("agent",))
1349+
# ALSO REACHES MEETINGS — stamps the meeting's day and its scaffold refs (`_meeting_stamp` → `mt.meeting_start`).
1350+
@reg.step(needs=("agent", "meetings"))
13421351
def drop_to_attendees(ctx: StepCtx):
13431352
"""The meeting's ARTEFACT into every desk in the room — the organiser's included. Plain
13441353
code, no agent turn, no LLM (founder decisions 20 and 22).
@@ -1476,7 +1485,8 @@ def drop_to_attendees(ctx: StepCtx):
14761485
# REACHES THE AGENT DOMAIN (PRD decision 40.7). Declared, not checked inside the body:
14771486
# the engine answers `not_present` for this step without entering it when a deployment
14781487
# does not run agents, so the absent door is never knocked on.
1479-
@reg.step(needs=("agent",))
1488+
# ALSO REACHES MEETINGS — ensures the meeting row exists before the call (`mt.ensure_meeting_row`).
1489+
@reg.step(needs=("agent", "meetings"))
14801490
def prepare_meeting(ctx: StepCtx):
14811491
"""The front door of the loop whose back door is email_minutes: one short note asking
14821492
whether they want to walk in ready, carrying `?ask=prep&meeting=<ref>`.

core/flows/src/flows_steps/common.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,21 @@
1919
#: convenience.
2020
AGENT_API = flows_config.get("VEXA_FLOWS_AGENT_API_URL")
2121

22+
#: THE MEETINGS DOOR, and the name is a defect this change does not close. The key still says
23+
#: GATEWAY because flows reaches meetings THROUGH THE EDGE today — eleven call sites, all of them
24+
#: `{gateway}/meetings`, `{gateway}/bots`, `{gateway}/transcripts/…`. ADR-0037 forbids that hop
25+
#: ("fronting a sibling's door with the edge does not make it not-an-edge") and it is a separate
26+
#: change with its own consequences, measured and stated on this PR: the gateway resolves the
27+
#: caller's key and enriches every forward with X-User-Id, X-User-Scopes, X-User-Workspaces and
28+
#: X-User-Limits — and `POST /bots` enforces the per-user concurrent-bot cap out of that last one
29+
#: (`meeting_api/bot_spawn/router.py:208` `_resolve_max_concurrent`). Calling meeting-api directly
30+
#: without carrying those forward would silently drop the cap, so the de-hop is not a rename.
31+
#:
32+
#: What IS decided here (decision 5, founder-agreed) is the DEPENDENCY: meetings is optional, and
33+
#: its absence is a supported configuration rather than a refusal to boot.
34+
MEETINGS_DOOR = "VEXA_FLOWS_GATEWAY_URL"
35+
MEETINGS_API = flows_config.get(MEETINGS_DOOR)
36+
2237

2338
def domain_present(domain: str) -> bool:
2439
"""Is this domain deployed alongside flows? (PRD decision 40.7.)
@@ -39,6 +54,8 @@ def domain_present(domain: str) -> bool:
3954
return True
4055
if domain == "agent":
4156
return bool((AGENT_API or "").strip())
57+
if domain == "meetings":
58+
return bool((MEETINGS_API or "").strip())
4259
return True
4360

4461

@@ -65,6 +82,38 @@ def agent_door() -> str:
6582
return base.rstrip("/")
6683

6784

85+
class MeetingsDomainAbsent(RuntimeError):
86+
"""A helper that reaches the meetings domain was called in a deployment that does not run it.
87+
88+
The sibling of `AgentDomainAbsent`, and the same second line of defence: the engine answers
89+
`not_present` for a step that declared `needs=("meetings",)` without entering its body
90+
(`flows/loop.tick`), so this should never fire. It exists because the first line is a
91+
DECLARATION, and the next step somebody adds will not remember to make it.
92+
93+
Deliberately NOT `flows_config.ConfigError`. That one says *this deployment is misconfigured*,
94+
and an absent optional domain is a supported configuration — the whole point of the class
95+
change behind it. A refusal that names the wrong cause sends an operator to fix a door that
96+
was never supposed to be there."""
97+
98+
99+
def meetings_door() -> str:
100+
"""The meetings base, or `MeetingsDomainAbsent`. Reads the MODULE attribute so a test can set
101+
the world with one `monkeypatch.setattr`, exactly as `agent_door` does.
102+
103+
RESOLVED AT ACCESS, never at import. `from .common import GATEWAY` ran `flows_config.require`
104+
while `flows_steps/meeting.py` was still loading, so an unset door was an ImportError for the
105+
entire step vocabulary — every step, including the ones with no interest in meetings. An
106+
optional domain has to be absent-able at boot, at import AND at the step; this is the middle
107+
one."""
108+
base = (MEETINGS_API or "").strip()
109+
if not base:
110+
raise MeetingsDomainAbsent(
111+
f"this deployment does not run the meetings domain ({MEETINGS_DOOR} is unset). "
112+
"A flow step that needs it must declare `needs=(\"meetings\",)` so the engine answers "
113+
"`not_present` instead of reaching for a door that is not there.")
114+
return base.rstrip("/")
115+
116+
68117
def _door(name: str) -> str:
69118
"""A required door, resolved at ACCESS time and refused when unnamed (see flows_config.require).
70119
@@ -82,8 +131,11 @@ def __getattr__(name: str) -> str: # PEP 562
82131
raise AttributeError(name)
83132

84133

85-
_DOORS = {"GATEWAY": "VEXA_FLOWS_GATEWAY_URL",
86-
"ADMIN_API": "VEXA_FLOWS_ADMIN_API_URL",
134+
# `GATEWAY` is GONE from this map on purpose. `__getattr__` resolves a name here through
135+
# `flows_config.require`, which REFUSES an empty value — correct for a door the process cannot work
136+
# without, and exactly wrong for an optional domain. The meetings door is reached through
137+
# `meetings_door()` above, which answers with a typed absence instead.
138+
_DOORS = {"ADMIN_API": "VEXA_FLOWS_ADMIN_API_URL",
87139
"UI_URL": "VEXA_UI_URL"}
88140

89141

0 commit comments

Comments
 (0)