@@ -361,10 +361,10 @@ def _degraded_decision(stage: str, error: BaseException, tool_name: "str | None"
361361# carve-out below; any drift silently re-introduces the bootstrap-deadlock
362362# these constants are here to prevent.
363363#
364- # _SECRETARY_NAME mirrors bootstrap_marker_writer._SECRETARY_NAME (the
365- # producer-side constant at marker_writer.py:103) and the literal at
366- # commands/bootstrap.md Step 2. Cross- file atomic edits required across
367- # this file, bootstrap_marker_writer.py, AND commands/bootstrap.md.
364+ # _SECRETARY_NAME mirrors the producer-side bootstrap_marker_writer._SECRETARY_NAME
365+ # constant and the literal at commands/bootstrap.md Step 2. Cross-file atomic
366+ # edits required across this file, bootstrap_marker_writer.py, AND
367+ # commands/bootstrap.md.
368368#
369369# _SECRETARY_AGENT_TYPE is the canonical agentType from
370370# commands/bootstrap.md Step 2 — no producer-side mirror in
@@ -386,6 +386,47 @@ def _degraded_decision(stage: str, error: BaseException, tool_name: "str | None"
386386)
387387
388388
389+ def _secretary_in_members (team_name : str ) -> bool :
390+ """Members[]-only JOIN witness: has the secretary actually joined the team
391+ roster? Sole consumer is _is_canonical_secretary_spawn binding 5.
392+
393+ Reads the team config members[] via the shared pact_context._iter_members
394+ helper (already imported at this module's top level) and returns True iff a
395+ member's ``name`` equals _SECRETARY_NAME. This is a JOIN witness — distinct
396+ from bootstrap_marker_writer._team_has_secretary, which is a DISPATCH
397+ witness (members[] OR the secretary's inbox file). The two predicates
398+ answer DIFFERENT questions on purpose (#1023): the marker writer needs
399+ "was the secretary DISPATCHED?" (the inbox is created by
400+ TaskUpdate(owner=secretary) BEFORE the spawn, so a dispatch witness is
401+ correct there); the carve-out needs "has the secretary JOINED members[]?"
402+ (only an actual Agent() spawn-return populates members[]). Reading the
403+ inbox arm here is what re-deadlocked the canonical secretary spawn in
404+ #1021 — the inbox predates the spawn, so binding 5 (`not True`) denied the
405+ very spawn the carve-out exists to permit.
406+
407+ NEVER raises (totality, #989). The body is wrapped in a BROAD
408+ ``except Exception: return False`` rather than a typed tuple because the
409+ members[] read transitively calls pact_context.get_claude_config_dir() ->
410+ Path.home(), which raises RuntimeError when HOME is unresolvable. That
411+ RuntimeError is composed BEFORE _iter_members' own typed try-block, so it
412+ escapes _iter_members uncaught, and it is ALSO absent from binding 5's
413+ outer typed except — without a broad wrap here it would propagate to
414+ main()'s degraded-DENY path and re-deadlock the secretary spawn (the wrong
415+ fail direction). A False return makes the carve-out FIRE (the safe
416+ direction — it only ever permits the canonical secretary spawn, never a
417+ non-secretary tool, which bindings 1/2/3 already exclude). This mirrors the
418+ bare-except seam precedent at shared.pact_context._resolve_aligned_team_name,
419+ which uses a broad except for the same Path.home RuntimeError reason.
420+ """
421+ try :
422+ return any (
423+ member .get ("name" ) == _SECRETARY_NAME
424+ for member in pact_context ._iter_members (team_name )
425+ )
426+ except Exception : # noqa: BLE001 — broad by design: Path.home() RuntimeError seam (see docstring)
427+ return False
428+
429+
389430def _is_canonical_secretary_spawn (input_data : dict ) -> bool :
390431 """Audit anchor: canonical secretary spawn carve-out for #789.
391432
@@ -404,30 +445,62 @@ def _is_canonical_secretary_spawn(input_data: dict) -> bool:
404445 (the orchestrator may still pass a stale arg the platform discards).
405446 The carve-out stays tight via bindings 2/3 (exact subagent_type +
406447 name literals) and binding 5 (one-shot, gated on the REAL team dir).
407- 5. NOT _team_has_secretary(get_team_name()) — one-shot semantic; flips
408- to False the moment the spawned secretary lands in members[]. Reads
409- the REAL session team dir (expected_team), which the empty-team
410- fail-closed below guarantees is a non-empty path segment.
448+ 5. NOT _secretary_in_members(get_team_name()) — members[]-only JOIN
449+ witness (#1023). Reads the REAL session team dir (expected_team),
450+ which the empty-team fail-closed below guarantees is a non-empty path
451+ segment. Uses the gate-local _secretary_in_members helper (a JOIN
452+ witness), NOT bootstrap_marker_writer._team_has_secretary (a DISPATCH
453+ witness that also accepts the secretary's inbox file). See the
454+ join-vs-dispatch note below.
411455
412456 Binding (1) is a hardcoded literal. Bindings (2) and (3) compare against
413457 module constants, not tool_input-derived values. Binding (5) is a disk
414- read of the team config members[]; True after first successful dispatch,
415- so the carve-out fires at most once per session. With binding 4 dropped,
416- the carve-out reads no tool_input-derived team value — the
417- secretary-presence check resolves against the SSOT team dir only.
458+ read of the team config members[]. With binding 4 dropped, the carve-out
459+ reads no tool_input-derived team value — the secretary-presence check
460+ resolves against the SSOT team dir only.
461+
462+ JOIN witness vs DISPATCH witness (#1023): binding 5 calls the gate-local
463+ _secretary_in_members (members[]-ONLY) and deliberately does NOT call
464+ bootstrap_marker_writer._team_has_secretary. The two answer DIFFERENT
465+ questions. The marker writer needs "was the secretary DISPATCHED?" and so
466+ accepts the inbox file as a fallback witness — correct for it, because the
467+ inbox is the config-less Desktop signal (#1019). But the inbox is created
468+ by TaskUpdate(owner=secretary) (bootstrap Step 2) BEFORE the Agent spawn
469+ (Step 3), so reading the inbox here made binding 5 (`not True`) DENY the
470+ very spawn the carve-out exists to permit — the #1021 regression. The
471+ carve-out needs "has the secretary JOINED members[]?", which only an actual
472+ Agent() spawn-return populates. Hence the split: members[]-only here, inbox
473+ fallback left to the marker writer alone.
474+
475+ ONE-SHOT GUARANTEE (D3 decision-record, #1023) — the carve-out's one-shot
476+ semantic MIGRATES from binding-5-self-closure to MARKER-PRESENCE. Under CLI
477+ the old members[] check flipped True once the secretary joined, self-closing
478+ the carve-out. But under config-less Desktop members[] is STRUCTURALLY empty
479+ (no config.json), so binding 5 can no longer self-close — _secretary_in_members
480+ is always False there and the carve-out always fires pre-marker. The durable
481+ one-shot is now MARKER-PRESENCE: the is_marker_set fast-path in
482+ _check_tool_allowed returns None (allow-all) BEFORE the carve-out
483+ (this _is_canonical_secretary_spawn check) is ever reached in
484+ _check_tool_allowed, so once the marker is
485+ written the carve-out is moot. Documented so no future reader restores a
486+ binding-5 one-shot and re-deadlocks Desktop. The Desktop always-fire window
487+ is contained by bindings 1/2/3 (exact Agent + pact-secretary + secretary,
488+ all module constants) plus the marker fast-path that closes it.
418489
419490 On ANY disk-read exception, returns False — caller falls through to
420491 the existing _BLOCKED_TOOLS deny path so the user sees the canonical
421492 _DENY_REASON ("PACT bootstrap required...") rather than the
422493 load-failure variant. Mirrors is_marker_set's silent-on-exception
423- style.
424-
425- SACROSANCT — local-import discipline: _team_has_secretary is imported
426- LOCALLY (function-call time, not module-load time) to break the
427- reciprocal cycle with bootstrap_marker_writer, which imports
428- is_marker_set from this module at its own top-level. Reciprocal
429- top-level import here would deadlock module load and route every
430- tool call through the fail-closed deny path.
494+ style. (_secretary_in_members has its own broad-except totality guard for
495+ the Path.home() RuntimeError seam — see its docstring.)
496+
497+ (The former SACROSANCT local-import discipline note is now OBSOLETE: binding
498+ 5 no longer imports _team_has_secretary from bootstrap_marker_writer, so the
499+ reciprocal-cycle local-import is gone. The members[] read goes through the
500+ top-level-imported shared.pact_context, which bootstrap_marker_writer also
501+ imports at top level — no cycle. The one remaining cross-module edge is
502+ unchanged: bootstrap_marker_writer imports is_marker_set from THIS module at
503+ its own top level.)
431504 """
432505 try :
433506 if input_data .get ("tool_name" ) != "Agent" :
@@ -445,13 +518,14 @@ def _is_canonical_secretary_spawn(input_data: dict) -> bool:
445518 expected_team = pact_context .get_team_name ()
446519 if not expected_team :
447520 return False
448- # Local-import: reciprocal-cycle prevention. bootstrap_marker_writer
449- # imports is_marker_set from this module at its OWN top-level; a
450- # reciprocal top-level import here would deadlock module load and
451- # silently route every tool call through the fail-closed deny path.
452- # See SACROSANCT block in this docstring.
453- from bootstrap_marker_writer import _team_has_secretary
454- return not _team_has_secretary (expected_team )
521+ # Binding 5: members[]-only JOIN witness (#1023). _secretary_in_members
522+ # is a gate-local helper reading shared.pact_context._iter_members (the
523+ # top-level-imported module) — NOT a cross-module import of
524+ # bootstrap_marker_writer._team_has_secretary (the DISPATCH witness,
525+ # which also accepts the inbox file and so re-deadlocked the spawn in
526+ # #1021). The former reciprocal-cycle local-import is gone. See the
527+ # join-vs-dispatch + D3 one-shot notes in this docstring.
528+ return not _secretary_in_members (expected_team )
455529 except (OSError , ValueError , KeyError , TypeError , AttributeError , ImportError ):
456530 return False
457531
0 commit comments