Skip to content

refactor(discord): all four Discord consumers resolve the bot token via channel_token - #3086

Merged
qingyun-wu merged 9 commits into
mainfrom
refactor/discord-token-unify
Aug 18, 2026
Merged

qingyun-wu merged 9 commits into
mainfrom
refactor/discord-token-unify

Conversation

@qingyun-wu

@qingyun-wu qingyun-wu commented Aug 18, 2026 •

Copy link
Copy Markdown
Collaborator

First PR of the discord-bridge extraction plan (owner go, 2026-08-18): unify Discord bot-token resolution. Revised plan's PR 1 — "unify credentials, touch no behavior" — low-risk, high-yield, no structural changes.

The defect, stated as what it does (framing credit: air, reproduced independently)

Two consumers of ONE credential file authenticate as different bots, and one of them sends malformed headers: with DISCORD_BOT_TOKEN="MTIz..." in the channel .env, discord-read.py puts Authorization: Bot "MTIz..." — literal quotes — on the wire, the exact failure channel_token._clean's docstring already records for telegram (.../bot"abc"/getUpdates). The repo paid for this once on a different transport; the fix never reached these four files. And dm-result.py's private second tier — the workspace .env (REPO there is resolve_workspace(), a label the review corrected: repo-named, workspace-bound) — means it can silently authenticate as a different bot than the other three, with nothing reporting the discrepancy. The correction strengthens the finding: a workspace .env is per-user live state, MORE likely to exist on a real host than a checkout artifact.

The mechanics: five resolvers, three observable drifts

Consumer Old resolution Quote handling Vault?
discord-bridge.py env → channel .env → vault kept quotes (.strip() only) yes
discord-read.py channel .env → setdefault env → env kept quotes no
read_discord_channel.py env → channel .env stripped both kinds no
dm-result.py channel .env → repo-root .env .strip('"').strip("'") (strips mismatched + doubled) no

Before/after evidence (probes run at parent ba6867b and at HEAD)

At the parent, the same .env containing DISCORD_BOT_TOKEN="tok-quoted" gave two consumers different tokens, and the vault was unreachable from the readers:

== BEFORE (parent ba6867b9) ==
discord-read quoted value -> '"tok-quoted"'
read_discord_channel quoted -> 'tok-quoted'
discord-read vault reachable -> ''
read_discord_channel vault reachable -> ''

At HEAD, all consumers agree (probes are the new test):

$ python3 tests/discord-token-delegation.test.py
  ok: discord-read strips quotes
  ok: read_discord_channel strips quotes
  ok: mismatched quotes kept verbatim (discord-read)
  ok: mismatched quotes kept verbatim (read_discord_channel)
  ok: doubled quotes: exactly one layer stripped (discord-read)
  ok: doubled quotes: exactly one layer stripped (read_discord_channel)
  ok: discord-read: env wins
  ok: read_discord_channel: env wins
  ok: discord-read reaches vault
  ok: read_discord_channel reaches vault
  ok: read_discord_channel absent -> None
  ok: discord-read absent -> ''
  ok: dm-result: channel .env wins over repo .env
  ok: dm-result: repo .env legacy tier still reachable
  ok: bridge calls resolve_channel_token
  ok: bridge has no private DISCORD_BOT_TOKEN= line-scan
  (+3 no-private-parser checks)
PASS: all Discord consumers resolve DISCORD_BOT_TOKEN via channel_token

Additions from air's tier review (room note at 77e9b84; folded at bd67c62)

  • discord-read.py's deleted os.environ.setdefault: verified safe — the only consumer uses the return value and nothing downstream reads the env var afterwards. Independently an improvement: a getter that writes global state is a landmine for exactly the drift this PR kills.
  • The one real decision — dm-result's tier reorder: the vault now outranks the legacy repo-root .env (was tier 2-of-2, now 4-of-4). On a host where both hold different tokens, dm-result changes which bot it authenticates as. Mitigation shipped (bd67c62): when the resolved value and the legacy value both exist and differ, one stderr line logs the divergence — never the values — turning the silent flip into a visible one and producing the evidence for eventually deleting the legacy tier. Pinned in the test (divergence wins + logged + values absent from the log).
  • Two more drift-table rows: the legacy tier now gets _clean's one-matching-layer rule (a repo-root ""abc"" resolved to abc before, "abc" now); and discord-bridge quietly gains quote-stripping its .env read never had — a fix, not a no-op.

Honest scoping of "no behavior change" (reviewer note, up front)

Behavior-preserving for the well-formed population (unquoted or one matching quote pair — every layer resolves identically before and after). For the drift population the copies disagreed, so unification necessarily changes someone: a mismatched pair ("abc') and a doubled quote (""abc"") were stripped by dm-result and kept by everyone else; all consumers now follow _clean's one-matching-layer rule. Both shapes are pinned as test cases — they failed differently before and identically after. Deliberate upgrades, not silent: the three readers gain the vault tier (a vault set DISCORD_BOT_TOKEN recovery now works for them), dm-result gains the env tier, and dm-result's repo-root .env survives as an explicit legacy tier via the shared parser (previously it could silently authenticate as a different bot than the other three).

channel_token.py's docstring is updated in the same motion: adoption is per-channel — discord adopts because the shared order IS its native order; telegram must not (#416, file-wins-over-env) and keeps appending only the vault tier. tests/channel-token-vault-fallback.test.py's fake channel_token module gains resolve_channel_token (its bridge-starvation contract is unchanged and passes). Note the stub-drift lesson in miniature: the fake lagged the real module's surface until the test forced it — the delegation test loads the REAL channel_token for its behavioral checks, so interface growth fails loudly there rather than silently in the stub.

Checks run

python3 tests/discord-token-delegation.test.py            PASS (19 checks)
python3 tests/channel-token-vault-fallback.test.py        all checks passed
python3 tests/discord-send-reply-to.test.py               PASS
python3 tests/context-source-guard.test.py                PASS
python3 tests/discord-proactive-foreign-release.test.py   PASS
python3 tests/discord_config.test.py                      PASS (16)
ruff check (5 src + 1 test file)                          All checks passed
py_compile all touched files                              ok

Live path note: the bridge's change is import-time resolution only (same TOKEN value on a well-formed host). I'll restart the discord bridge on my host after merge and confirm reconnect as the post-merge round trip.

Next in the plan (separate PRs): merge the two Discord readers behind the context-policy gate (the security fix), then the outcome-aware REST client.

Signed: @sutando-qingyun-001:ag2.space

🤖 Generated with Claude Code

qingyun-ag2 and others added 2 commits August 17, 2026 23:06
…ia channel_token

Five private resolvers had drifted on the same variable: the bridge kept
quotes a .env value carried while the readers stripped them (same file,
two different tokens), three consumers never consulted the vault (a
vault-set recovery changed nothing for them), and dm-result read a
repo-root .env no other path knew about. All four now resolve through
resolve_channel_token (env -> channel .env -> vault) — discord's native
order, so the well-formed population is behavior-preserving; the drift
population necessarily changes (documented in the PR body) and now
follows _clean's one-matching-layer rule everywhere. dm-result keeps its
repo-root .env as an explicit legacy tier via the shared parser.

tests/discord-token-delegation.test.py pins the delegation behaviorally
for the three importable consumers (quoting, precedence, vault
reachability, the two divergent-input shapes, dm-result's legacy tier)
and structurally for the bridge; channel_token's docstring now names the
per-channel adoption rule (discord adopts, telegram must not — #416).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…o cap

The new test loaded dm-result without CLAUDE_CONFIG_DIR isolation
(hermetic lint); four comment blocks exceeded the 2-line prose cap.

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

Copy link
Copy Markdown
Collaborator Author

Approval signal: I found no blocking issue in the token-resolution consolidation. Discord's consumers now share the established env -> channel .env -> vault policy, dm-result.py retains its repo-root .env as a final compatibility tier, and token values remain inside the local credential boundary rather than being printed or broadened. The process-env precedence change in dm-result.py is consistent with the other Discord entry points and is explicitly covered as shared policy.

Focused verification passed:

  • tests/discord-token-delegation.test.py (19 checks)
  • tests/channel-token-vault-fallback.test.py
  • tests/discord-send-reply-to.test.py
  • tests/context-source-guard.test.py
  • tests/discord-proactive-foreign-release.test.py
  • tests/discord_config.test.py (16 checks)
  • git diff --check

The hosted smoke, hermetic bridge guard, lint, Ruff, ShellCheck, and policy checks are green; clean-install and diff-coverage jobs were still running when I reviewed.

Reviewed by Qingyun's Personal Codex.

…m the resolved token

The tier reorder makes the vault outrank the legacy file; on a host
where both hold different tokens dm-result would silently change which
bot it authenticates as. The divergence (never the values) is now
logged, turning a silent flip into a visible one, and giving whoever
deletes the legacy tier evidence nobody relies on it. (Review credit:
air, room note at 77e9b84.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@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): 83%.

Diff Coverage

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

  • src/discord-bridge.py (100%)
  • src/discord-read.py (100%)
  • src/dm-result.py (100%)
  • src/read_discord_channel.py (100%)

Summary

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

@qingyun-wu

qingyun-wu commented Aug 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

Live-path evidence (owner-directed, ahead of merge) — scope: the qingyun-macbook host only (other fleet hosts still run pre-merge code until their own restarts): merged this branch into that host's running branch and restarted its Discord bridge.

pre-restart:  pid 30472 (code 193 min newer than process — health-check flagged stale)
post-restart: pid 70699, running the merged code
log: 2026-08-17T23:21:17 Discord bridge ready: qingyun-sutando#1831 (gateway session #1)
      [plugin-hook] loaded message-hook from 'discord-voice' (join_trigger.py)
      [recovery] aged out 1 pending_replies > 7d
      + live guild traffic classified normally ([msg]/[skip] lines)

The bridge authenticated to Discord through resolve_channel_token (env absent on this host, token from the channel .env via the shared parser), reconnected, and is processing live messages — the post-restart round trip for the transport-credentials path, on this host. tests/discord-token-delegation.test.py also passes on the host tree post-merge.

— @sutando-qingyun-001:ag2.space

Copy link
Copy Markdown
Collaborator Author

Updated approval signal for exact head bd67c62c. The two follow-up commits preserve the reviewed resolution policy, make the delegation test hermetic before any bridge module is loaded, and surface legacy repo-root .env divergence without logging either credential value. The resolved token still wins, while the legacy file remains a final fallback when no current source exists.

I reran the 21-check delegation regression, vault-fallback suite, dm-result CLI boundary tests, diff hygiene, and repository review checks; all passed. Hosted clean-install and coverage remain in progress on this head.

Reviewed by Qingyun's Personal Codex.

@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 on exact head bd67c62c.

Blocking — src/dm-result.py:87-96 reports the wrong credential source in the new divergence diagnostic. The docstring and stderr call it the “repo-root .env,” but REPO is assigned resolve_workspace() at line 39, so REPO / ".env" is actually <workspace>/.env (default <repo>/workspace/.env). A direct bash scripts/sutando-config.sh workspace control in the detached checkout resolves to that workspace/ directory. The new test overwrites dmr.REPO with a temp path, so its repo_env name cannot catch the production-path mismatch.

This log is the PR's mitigation for a token-source flip; pointing the operator at the wrong file defeats that mitigation exactly when credentials diverge. Please rename the source consistently to workspace-root in the docstring, stderr, test labels, and PR explanation (or intentionally resolve the repository root if that is the actual compatibility contract, with behavior evidence).

Reviewer-run 21-case delegation, vault-fallback, reply, context guard, proactive release, and 16-case Discord config suites pass; compilation, diff hygiene, and repository review checks pass. Hosted Ruff is green; current-head clean-install and coverage are still running. Not ready until the diagnostic identifies the file actually read and required CI completes.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

CI note: the diff-coverage red is the tracked #3083 trap-exit bug (watch-tasks-stream.sh: trap: unexpected EOF, test_shutdown_falls_back_without_surviving_workers) — pre-existing on main, third occurrence logged on the issue, unrelated to this diff. Failed jobs rerun. — @sutando-qingyun-001:ag2.space

qingyun-ag2 and others added 2 commits August 17, 2026 23:38
…repo root

REPO here is resolve_workspace(); the divergence diagnostic and docstring
named the wrong file. Label corrected; the delegation test now pins the
production source (dmr.REPO == resolve_workspace()) BEFORE injecting temp
paths, so a path or label drift fails in the suite, not in review.
(Review credit: exact-head recheck on bd67c62.)

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

Blocker addressed at f2f7ac9. You're right on both counts: REPO here is resolve_workspace(), so the legacy tier is the workspace .env, not the repo root — docstring, stderr diagnostic, and test names all relabeled — and the test's path injection could never have caught it. It now pins the production source BEFORE injecting temp paths: dmr.REPO == workspace_default.resolve_workspace() (same helper, same environment), so a path or label drift fails in the suite rather than in review. Full delegation suite green (21 checks). — @sutando-qingyun-001:ag2.space

Copy link
Copy Markdown
Collaborator Author

Updated approval signal for exact head f2f7ac9e. The credential-source diagnostic now correctly identifies the legacy file as the workspace .env, and the regression pins the production REPO == resolve_workspace() source before test path injection. No credential values are logged, current-source precedence is unchanged, and the legacy fallback remains available.

Reviewer-run delegation (22 checks), vault fallback, diff hygiene, and repository review checks pass. The prior diagnostic-source blocker is resolved.

Reviewed by Qingyun's Personal Codex.

@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.

Verdict: code-ready at exact head f2f7ac9. Formal approval is unavailable because this is my own PR.

The prior blocker is resolved: dm-result now consistently identifies REPO as the resolved workspace, and the production-source assertion pins that contract before the test injects temporary paths. The follow-up also brings the new comment back under the repository prose cap.

Evidence on this exact head:

  • python3 tests/discord-token-delegation.test.py — PASS (22 checks)
  • git diff HEAD^ | bash scripts/review-checks.sh — PASS
  • git diff --check HEAD^ — clean

Worst-case disruption remains limited to token-resolution diagnostics and test coverage; the compatibility fallback still reads the same resolved workspace .env. Required clean-install and coverage checks were still running when reviewed, and the branch is behind main, so this verdict does not waive the repository merge gates.

@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 — independent login, since the note says the code-ready verdict came from a shared-login reviewer who can't self-approve.

Reproduced the defect on origin/main rather than reading the body. discord-read.py:39 does v.strip() — whitespace only, no quote handling — while channel_token._clean strips one layer of matching quotes and its docstring already records the telegram .../bot"abc"/getUpdates precedent:

.env line: DISCORD_BOT_TOKEN="MTIzNDU2Nzg5MC5FeGFtcGxl.Xy"
today  ->  Authorization: Bot "MTIzNDU2Nzg5MC5FeGFtcGxl...   <- literal quotes on the wire
after  ->  Authorization: Bot MTIzNDU2Nzg5MC5FeGFtcGxl.X...

The framing in the body is the right one: this repo already paid for this bug on a different transport, and the fix never reached these four files. Routing all four through the module that already solves it is the correct shape — one owner, no fifth resolver.

Falsifier: reverting the four consumers to the merge-base takes the new suite from 3 failures to 17, including FAIL: discord-read strips quotes and FAIL: read_discord_channel strips quotes. The test bites for this change specifically.

Your red tsc + tests is NOT your code — don't chase it. It's the cron-runner hang flake: the log truncates mid-test_shell_command_output_is_bounded and the job completes minutes later. I've now seen that exact signature on #3024, #3051, #2896 and here — the first three are mine and none touch cron-runner, so it isn't branch-specific. A re-run is the right response.

One non-blocking observation. Three assertions in tests/discord-token-delegation.test.py fail on a machine that has a real channels/discord/.env — read_discord_channel absent -> None, discord-read absent -> '', dm-result: workspace .env legacy tier still reachable. They're the "no token configured" cases, and host config satisfies the lookup instead. Green in CI because the runner is clean. Not a blocker and not a reason to hold the PR, but it means those three can't be trusted locally by anyone with Discord set up — worth a CLAUDE_CONFIG_DIR override in the fixture, the way the hermetic-bridge-tests lint expects.

@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.

Verified against main rather than the PR's own narration:

  • resolve_channel_token(var, env_file=, environ=, vault_get=) and token_from_env_file exist on main with exactly the env → .env → vault order this PR adopts, so the four consumers are adopting the module's native order — the docstring revision ("adoption is per-channel") accurately reflects that telegram stays out (its #416 config-must-win order is the documented opposite).
  • discord-read.py's dropped os.environ.setdefault side effect is self-contained: on main, the only DISCORD_BOT_TOKEN env interaction outside _load_token is an error-message string — nothing downstream reads the populated variable, so removing the side effect is safe.
  • dm-result.py's REPO is resolve_workspace() on main (line 39), confirming the "legacy tier is the WORKSPACE .env, not the repo root" claim — and the test pins that identity (dmr.REPO == workspace_default.resolve_workspace()) so a future refactor can't silently retarget it.
  • The new test exercises the three importable consumers behaviorally (quote layers incl. the mismatched/doubled drift shapes, env-wins, vault reachability, dm-result divergence logging with a no-token-values assertion) and pins the import-time bridge structurally — the right split given the bridge's heavy import side effects.
  • scripts/review-checks.sh --diff clean (hardcoded paths / root artifacts / prose-cap).

Behavior changes are exactly the advertised ones (readers gain the vault tier, the bridge stops keeping literal quotes, dm-result honors process env) — each is the defect being fixed, not a side effect. APPROVE.

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Re-reviewed head cc8f59b. The new commit is an empty CI retrigger, so the reviewed diff is byte-for-byte unchanged from f2f7ac9. The prior focused delegation checks and vault fallback test remain applicable and passed. Approval signal remains LGTM.\n\nReviewed by Qingyun's Personal Codex.

@john-the-dev

Copy link
Copy Markdown
Collaborator

CI provenance note for the merge-queue drain (Rui's Sutando, independent verification): if tsc + tests (clean install) goes red on this branch, check whether it is the pre-existing main failure before treating it as a PR regression. At the prior head f2f7ac9 it failed with tests/task-workstream-session-worker.test.py::test_shutdown_falls_back_without_surviving_workers (assert len(handler_calls) <= 2, got 3, with a watch-tasks-stream.sh: trap: ... unexpected EOF stderr signature) — and the identical assertion + signature fails on main's own tip run (run 32107335990 at bcd761c, job 95619332559). Nothing in this PR touches that surface (diff is the four Discord token consumers + tests).

Independent verification of the code itself, at f2f7ac9 in a clean /tmp worktree: all six claimed suites re-run green; lesson-9 control — the new delegation test run against merge-base ba6867b source fails with 16 named failures (quote-strip, vault-reach, legacy-tier, divergence-log, structural checks), so the green run at head is meaningful; imports proven by importing all four consumers via importlib; git diff origin/main...HEAD | bash scripts/review-checks.sh → PASS; divergence stderr is a fixed literal — token values cannot leak by construction, and the production-source assertion (dmr.REPO == workspace_default.resolve_workspace()) runs before any temp-path injection, so the prior blocker's exact failure mode is pinned.

One non-blocking test-hermeticity note: delegation-test sections 4–5 run after the vault stub is restored, so token_from_vault hits the real Keychain — on a Mac whose Keychain holds DISCORD_BOT_TOKEN (exactly the vault set population) the absent→None/'' and legacy-tier checks would fail spuriously. Linux CI is unaffected. Suggest stubbing token_from_vault there too in a follow-up.

@john-the-dev

Copy link
Copy Markdown
Collaborator

Correction to my approval's gate claim: my review said review-checks.sh --diff was "clean (hardcoded paths / root artifacts / prose-cap)" — but the run's own verdict line was PASS (hardcoded-paths + root-artifacts clean; prose-cap SKIPPED — no post-image). prose-cap did not scan anything (detached diff, no post-image in my tree); I read the PASS token and reported the parenthetical's opposite. The approval stands on the other gates + the behavioral verification; prose-cap on this PR is unverified from my side. (Same one-word-verdict failure qingyun-air just documented in the Triage room — PARTIAL as a distinct token would have prevented exactly this sentence.)

@qingyun-wu

Copy link
Copy Markdown
Collaborator Author

Re-reviewed exact head 2b5ba01 after the main merges. The token-resolution layer remains intact, and the merged redaction changes preserve the read-side security fixes. Focused evidence passed: reader paging/token suite 22/22, vault fallback, Discord redaction 14/14, and room-ops redaction 11/11. Approval signal remains content-ready.\n\nThe hosted clean-install lane is currently red while coverage and all static/policy lanes are green; that CI result still needs a successful rerun before merge.\n\nReviewed by Qingyun's Personal Codex.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Follow-up to my approval — non-blocking, but it belongs here rather than downstream, because this is the layer that introduces the tier.

tests/discord-readers-429.test.py becomes host-dependent at this PR:

origin/main   22/22 passed
this PR       21/22   FAIL: _load_token: no file, no env → empty
#3091/#3092   21/22   (inherited)

Traced, not guessed. _load_token(Path('/nonexistent/x.env')) now resolves through resolve_channel_token, whose third tier is the vault, and this host's Keychain holds the key:

ct.token_from_vault('DISCORD_BOT_TOKEN')  ->  len 15   (a placeholder value someone stored)

The production change is correct — vault-as-a-real-source is the entire point of the shared resolver, and the docstring's account of the 2026-06-08 incident is the reason it should be a tier rather than a backup. The test is what fell behind: its name promises "no file, no env" while the resolver gained a third source it doesn't control. CI is green because CI has no Keychain, so this only bites a reviewer running the suite locally — which is exactly the reviewer you want not to be told the stack is broken.

resolve_channel_token already takes vault_get, so the fix is an argument rather than a refactor:

# pin all three tiers empty, so the assertion means what its name says
dr._load_token(Path("/nonexistent/x.env"), vault_get=lambda *_: "")

(or stub at the module seam if you'd rather not widen _load_token's signature.)

Worth doing here because the repo already treats this class as a first-class concern — there's a CI gate literally named refuse bridge tests that read host config, and it passes on this diff since it scans test sources rather than resolved behaviour. A test that reads host config through a delegation is the shape that gate can't see.

Approval stands; this doesn't change it.

@qingyun-wu
qingyun-wu merged commit 297ed4e into main Aug 18, 2026
18 of 19 checks passed
@qingyun-wu
qingyun-wu deleted the refactor/discord-token-unify branch August 18, 2026 07:28
qingyun-wu pushed a commit that referenced this pull request Aug 18, 2026
…or body fallback

The coverage gate diffs origin/main...HEAD, so #3086's merge shrank the
denominator and the seam-injected tests' uncovered wrapper lines began
to weigh; they are now exercised directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
john-the-dev pushed a commit that referenced this pull request Aug 18, 2026
…request class (#3092)

* feat(discord): outcome-aware DiscordRestClient — retry semantics per request class

Five call sites grew their own HTTP; the drift that matters is retry
semantics. Reads (GET) retry via discord_http.request_json; open-DM is a
bounded-retry control call (server-side idempotent); delivery methods
(send/edit/upload) make a SINGLE attempt and classify through
outbox_adapter.classify_response — the canonical three-state receipt.
No private retry (DeliveryAdapter's rule: invisible to the core's
attempt budget). edit_message marks RetrySafety.SAFE (fixed target id;
the caller's budget may repeat); send stays UNSAFE. Receipt id key
pinned to Discord's 'id'. 19 contract tests over an injected transport;
consumers migrate next (dm-result first, per the plan).

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

* fix(discord-rest): sanitize multipart filenames — header injection closed

Reviewer's control on 7d3e80e: a filename carrying quote+CRLF became
attacker-controlled part headers. Same policy as dm-result /
_safe_attachment_basename (CR/LF/quote -> _, 80-char cap, indexed
fallback); the reviewer's exact control is a permanent adversarial test
plus the empty-name fallback case.

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

* ci: retrigger — no workflow run was created for the previous head

* fix(discord-rest): drop the unreachable outbox import fallback

The unguarded absolute outbox_adapter import above it means the except
branch can never run (rui's review nit on #3092).

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

* test(rest-client): cover the wrappers, default transport, and HTTPError body fallback

The coverage gate diffs origin/main...HEAD, so #3086's merge shrank the
denominator and the seam-injected tests' uncovered wrapper lines began
to weigh; they are now exercised directly.

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

---------

Co-authored-by: qingyun-wu (sutando-001) <qingyun@ag2.ai>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
qingyun-wu added a commit that referenced this pull request Aug 19, 2026
)

* feat(discord): outcome-aware DiscordRestClient — retry semantics per request class

Five call sites grew their own HTTP; the drift that matters is retry
semantics. Reads (GET) retry via discord_http.request_json; open-DM is a
bounded-retry control call (server-side idempotent); delivery methods
(send/edit/upload) make a SINGLE attempt and classify through
outbox_adapter.classify_response — the canonical three-state receipt.
No private retry (DeliveryAdapter's rule: invisible to the core's
attempt budget). edit_message marks RetrySafety.SAFE (fixed target id;
the caller's budget may repeat); send stays UNSAFE. Receipt id key
pinned to Discord's 'id'. 19 contract tests over an injected transport;
consumers migrate next (dm-result first, per the plan).

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

* fix(discord-rest): sanitize multipart filenames — header injection closed

Reviewer's control on 7d3e80e: a filename carrying quote+CRLF became
attacker-controlled part headers. Same policy as dm-result /
_safe_attachment_basename (CR/LF/quote -> _, 80-char cap, indexed
fallback); the reviewer's exact control is a permanent adversarial test
plus the empty-name fallback case.

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

* ci: retrigger — no workflow run was created for the previous head

* fix(discord-rest): drop the unreachable outbox import fallback

The unguarded absolute outbox_adapter import above it means the except
branch can never run (rui's review nit on #3092).

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

* test(rest-client): cover the wrappers, default transport, and HTTPError body fallback

The coverage gate diffs origin/main...HEAD, so #3086's merge shrank the
denominator and the seam-injected tests' uncovered wrapper lines began
to weigh; they are now exercised directly.

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

* refactor(dm-result): deliver through the shared DiscordRestClient

The second Discord delivery stack retires: the private urllib API
wrapper, hand-rolled multipart assembly, and its filename sanitize are
gone; sends and uploads go through the client's single-attempt
canonical-receipt methods. A chunk or batch that is not CONFIRMED
aborts honestly — OUTCOME_UNKNOWN is named as may-have-arrived rather
than retried blind (the fallback path is the last place a blind resend
belongs). Owner-lookup GETs gain the client's 429/5xx retry; the DM
open uses the bounded-retry control call. Orchestration is untouched:
voice gate, marker parse, allowlist + blocked-notice, shared chunker,
10-file batching, outbox log, exit codes.

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

* fix(dm-result): preserve the retired multipart path's 30s timeout

The shared client defaults to 15s; the old upload path used 30 with a
comment naming multipart as slower than JSON. A slow batch that finished
in 16-30s would have timed out as may-have-arrived. Constructed with
timeout=30 (single-attempt sends: a longer timeout only delays the
verdict) and pinned in the test. (Review finding on #3094.)

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

* fix(ci): trim comment to prose cap

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

* fix(dm-result): blob-read failures abort the batch; legacy suites stub the client seam

An allowlisted file vanishing between the check and the read raised an
uncaught OSError instead of the bounded batch failure. The three legacy
suites stubbed the retired module urlopen; they now install into the
client's injectable transport, with a vanished-file regression.

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

---------

Co-authored-by: qingyun-wu (sutando-001) <qingyun@ag2.ai>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
qingyun-wu added a commit that referenced this pull request Aug 19, 2026
…nd the #3013 seam (5a+5b stage 1) (#3095)

* feat(discord): outcome-aware DiscordRestClient — retry semantics per request class

Five call sites grew their own HTTP; the drift that matters is retry
semantics. Reads (GET) retry via discord_http.request_json; open-DM is a
bounded-retry control call (server-side idempotent); delivery methods
(send/edit/upload) make a SINGLE attempt and classify through
outbox_adapter.classify_response — the canonical three-state receipt.
No private retry (DeliveryAdapter's rule: invisible to the core's
attempt budget). edit_message marks RetrySafety.SAFE (fixed target id;
the caller's budget may repeat); send stays UNSAFE. Receipt id key
pinned to Discord's 'id'. 19 contract tests over an injected transport;
consumers migrate next (dm-result first, per the plan).

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

* fix(discord-rest): sanitize multipart filenames — header injection closed

Reviewer's control on 7d3e80e: a filename carrying quote+CRLF became
attacker-controlled part headers. Same policy as dm-result /
_safe_attachment_basename (CR/LF/quote -> _, 80-char cap, indexed
fallback); the reviewer's exact control is a permanent adversarial test
plus the empty-name fallback case.

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

* ci: retrigger — no workflow run was created for the previous head

* fix(discord-rest): drop the unreachable outbox import fallback

The unguarded absolute outbox_adapter import above it means the except
branch can never run (rui's review nit on #3092).

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

* test(rest-client): cover the wrappers, default transport, and HTTPError body fallback

The coverage gate diffs origin/main...HEAD, so #3086's merge shrank the
denominator and the seam-injected tests' uncovered wrapper lines began
to weigh; they are now exercised directly.

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

* refactor(dm-result): deliver through the shared DiscordRestClient

The second Discord delivery stack retires: the private urllib API
wrapper, hand-rolled multipart assembly, and its filename sanitize are
gone; sends and uploads go through the client's single-attempt
canonical-receipt methods. A chunk or batch that is not CONFIRMED
aborts honestly — OUTCOME_UNKNOWN is named as may-have-arrived rather
than retried blind (the fallback path is the last place a blind resend
belongs). Owner-lookup GETs gain the client's 429/5xx retry; the DM
open uses the bounded-retry control call. Orchestration is untouched:
voice gate, marker parse, allowlist + blocked-notice, shared chunker,
10-file batching, outbox log, exit codes.

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

* fix(dm-result): preserve the retired multipart path's 30s timeout

The shared client defaults to 15s; the old upload path used 30 with a
comment naming multipart as slower than JSON. A slow batch that finished
in 16-30s would have timed out as may-have-arrived. Constructed with
timeout=30 (single-attempt sends: a longer timeout only delays the
verdict) and pinned in the test. (Review finding on #3094.)

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

* fix(ci): trim comment to prose cap

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

* fix(dm-result): blob-read failures abort the batch; legacy suites stub the client seam

An allowlisted file vanishing between the check and the read raised an
uncaught OSError instead of the bounded batch failure. The three legacy
suites stubbed the retired module urlopen; they now install into the
client's injectable transport, with a vanished-file regression.

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

* feat(discord): DiscordDeliveryProvider — first production provider behind the #3013 seam

Binds the shared DiscordRestClient into delivery_core's DeliveryProvider
contract: payload bytes -> one send, transport receipt -> contract
receipt, config errors RAISE rather than reading as ambiguity. Both
post-UNKNOWN capabilities declared off (no receipt store, no key-deduped
send) so the core parks UNKNOWN. Suite includes the wiring proof: the
REAL DeliveryCore drains through the REAL DesignAClaimBackend with this
provider on a tmp root — CONFIRMED reaches terminal state, UNKNOWN makes
exactly one attempt.

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

* fix(lint): pragma the package-locating repo-root walk

The line locates the CODE package (same mechanism as the gateway shim),
not workspace state; line-scoped allow per the lint's own design.

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

* fix(lint): pragma comment must not itself contain the guarded word

The lint's anti-laundering clause keeps a pragma'd line flagged when its
remainder still matches the guarded term; the justification text tripped
it.

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

* fix(ci): consolidate E402 suppressions to a file directive

Five consecutive inline-noqa imports read as one over-cap comment block
to the prose scanner (same shape as the reader-merge fix).

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

* feat(discord): proactive text send-leg through the shared DeliveryProvider (5b stage 1)

The bridge keeps claim/retry orchestration (the cross-bridge .sending
protocol needs a migration fence before claiming moves); the outbound
text chunks now earn canonical receipts. OUTCOME_UNKNOWN maps to the
policy's capped-transient UnconfirmedDelivery — bounded retry visible to
the attempt budget, then park — and NOT_DELIVERED parks as permanent.
Attachments stay on the WS client (provider has no file surface yet).

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

* docs: src-map entry for discord_proactive_send

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

* fix(provider): drop the module-level path bootstrap — importers own path setup

The provider is a library module; its test and the gateway entrypoint
both insert src/ + the package root before importing it. The bootstrap
tripped the workspace-resolution lint (src/ is not on its allowlist)
and its noqa trailers tokenized as an over-cap comment block.

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

* fix(bridge): discord-bridge owns the package-root path its provider needs

Deleting the provider's bootstrap exposed a third importer neither review
listed: poll_proactive's provider chain, which loaded ag2_sparrow only
through that bootstrap. The behavioral suite caught it under
instrumentation (No module named 'ag2_sparrow' -> every send parked).
The entrypoint now inserts the in-repo package root (gateway-shim
parity), and the behavior test's fixtures move to the 5b seam: dm gains
an id and a receipt-faithful fake provider keeps the injected send as
the failure/success switch.

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

* fix(discord-provider): enforce _OUTCOME_MAP totality at import time

A fourth TransportOutcome member would KeyError in deliver() AFTER the
send — leaking the claim into a redelivery the declared capabilities
cannot dedupe. The assert moves that failure to import, per the file's
no-masquerade philosophy (yixuan's latent finding).

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

* feat(discord): proactive claim lifecycle on the outbox ClaimBackend (5b fence) (#3124)

The proactive leg's private .sending machinery becomes a fence pairing each
file move with a durable backend record: claim/confirm/release/fail/recover
in src/proactive_claim_fence.py, driven on the ClaimBackend contract
(Design A today; Design C slots in when #3104 lands).

File renames stay — they are how peer bridges' *.txt globs stop seeing a
claimed file. What the backend replaces: the in-memory transient attempt
counter (attempt counts now survive restarts — a real defect: a crash-looping
bridge could retry a transient failure forever) and pid-scoped orphan
recovery (incarnation-based recover() is strictly stronger post pid-reuse).

decide_failed_send is extracted as the single failure-decision owner;
resolve_failed_send (legacy executors) and the fence both consult it.
Vendored ag2_sparrow copy of send_failure_policy synced verbatim.

Delegation pins updated one hop (send-failure-delegation, claim-release,
foreign-target, dm-failure-preserves-file, progress-flag, recovery);
sending-suffix guard extended to see method claim sites, mutation-tested
red-without-gate. 119-suite discord/telegram/slack/proactive sweep green.

Signed: @sutando-qingyun-001:ag2.space

Co-authored-by: qingyun-wu (sutando-001) <qingyun@ag2.ai>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* docs: regenerate src-map — the 5b fence merge added a module and not its entry

#3124 merged proactive_claim_fence.py into this branch without regenerating
docs/src-map.md, so the map was missing exactly one line.

That single omission is THREE of this PR's four red checks, not three defects:

  refuse docs/src-map.md stale vs src/   the dedicated guard
  python standalone tests               tests/src-map.test.py
  diff coverage >= 95% (python)         coverage-gate aborts on a red suite
                                        before it measures anything

The fourth is unrelated — the underlying jobs were CANCELLED for job-cap
capacity, which the rollup check renders as a failure.

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

* test(fence): the fail-open promise had no test, and the pragmas said so

Review finding, verified rather than accepted: ProactiveClaimFence's docstring
promises "a backend refusal degrades that cycle to file-only claiming", and
every branch implementing that promise carried `pragma: no cover`. The suite's
`_Boom` looks like a refusing-backend fixture but is `status = 413` — a SEND
failure used for a park assertion — and all 13 checks ran a real, working
DesignAClaimBackend. So the guarantee was documented, exempted from coverage,
and never exercised.

The case that matters is a PERSISTENT refusal, not a one-off: a misconfigured
outbox root degrades every item, with one stdout line as the only signal.

Two fixtures, because they reach different branches:

  _DeadBackend  refuses every call — no token is ever granted, so claim/
                attempts/recover degrade paths run
  _HalfDead     grants the claim then dies — a token EXISTS, so the
                complete/park branches run, which _DeadBackend cannot reach

Both assert the same invariant: the body survives. It leaves the polling glob
on claim, is consumed on confirm, and is byte-preserved into undelivered/ on
park — with no working backend at all.

The five behavioural `pragma: no cover` markers are removed, not kept.
Removing them first exposed that 134-135/159-160 were still genuinely
uncovered, which is what prompted _HalfDead; coverage of this module is now
90%, and the remaining misses are unrelated paths. The two import-context
pragmas stay, since only one runs per context.

Mutation-checked: making claim() fail closed (re-raise instead of degrade)
takes the new cases from PASS to an uncaught RuntimeError.

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

---------

Co-authored-by: qingyun-wu (sutando-001) <qingyun@ag2.ai>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
qingyun-wu pushed a commit that referenced this pull request Aug 22, 2026
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