Skip to content

feat(workspace): the report-back contract reaches the Workspace (ent#457 AC#3) - #2386

Merged
dolho merged 17 commits into
devfrom
feature/ent457-portal-report-back
Aug 28, 2026
Merged

feat(workspace): the report-back contract reaches the Workspace (ent#457 AC#3)#2386
dolho merged 17 commits into
devfrom
feature/ent457-portal-report-back

Conversation

@dolho

@dolho dolho commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Scope: AC #3 only — the contract, not the card

ent#457's AC #5 gates implementation on an approved design pass. Its backend half isn't a visual, so it didn't wait: this PR is the report-back contract. The card and pipeline view (AC #1/#2) are in a design pass published for the Intelligence Design weekly and no card code exists until it's approved.

Design pass: https://claude.ai/code/artifact/4bdd3af6-440b-4c2a-83c9-070da0b1eea2

What was actually missing

"Dispatch → monitor → report back is a contract, not a habit" — the machinery has existed since ent#224/#265 for Slack and Telegram: a terminal chokepoint (#1578), a destination on the row (ent#117), a resolver per channel, an effect guard for at-most-once.

The Workspace was excluded by one missing field. #2157 stamped portal executions with the surface (source_channel = "portal") and never a destination, so every portal terminal died here:

if not source_channel or not source_channel_chat_id:
    return False                      # nothing to report back to

This gives the row its session id at both creation sites and adds a portal resolver. Delegated work then inherits the destination through the existing ent#265 chain — no new inheritance, no new transport, no new table, no migration.

Two rules carried over, because both are how this goes wrong

  • No double-post. A Workspace turn is synchronous — portal_chat persists the reply itself — so public joins INLINE_CHANNEL_TRIGGERS. Without it, every chat message gains a duplicate "done". Public links and x402 share that trigger and are unaffected: they carry no chat id, so they never reach the check.
  • The recipient comes from the session row, not the stamp. The stamp is a string that rode an inheritance chain; the session row is the platform's own record of whose chat this is. A delegated child may execute as a different agent (A asks B) — the message is filed under A, whose chat it is, and names B in the body.

Consent is by construction, as for a Telegram DM: the session belongs to one client and the report goes into their own conversation, so there's no third party and no flag to consult. Delivery is a persisted assistant message read through history the client already polls — which is why AC #7's "degrades to poll" holds here with no new transport at all.

This supersedes half of a #2157 invariant — deliberately

test_portal_source_channel_is_not_a_messaging_channel asserted the portal must miss both the voice service's supported set and _CHANNEL_RESOLVERS.

  • The voice half is permanent and untouched: there's no outbound audio leg, which is why send_voice_reply answers portal_client_narrated there.
  • The completion half was true only because the row carried no destination — which is exactly what ent#457 (operator ruling 2026-08-22, committed to the release cut) changes.

The guard is narrowed to what remains true, and now also pins the no-double-post rule. The reasoning lives in the test, not only in this PR. Flagging it prominently because overturning another feature's stated invariant should be a decision someone sees, not a side effect.

Verified live

delegated portal execution (triggered_by=mcp)  → delivered: True
  → thread gains: "**Finished** — Reconciled 42 invoices; 3 need review."
same execution, reported again                 → delivered: False   (effect guard)
  → report messages in the thread: still 1
the turn's OWN execution (triggered_by=public) → reported: False    (no double-post)

Tests

tests/unit/test_ent457_portal_completion_report.py (27) — the no-double-post rule and that the channel triggers are unchanged; portal as a resolver entry rather than a special case; the recipient read from the session row; a vanished session and a client-less session both suppressing rather than guessing; the message filed under the chat's agent while naming the executing one; failure honest; long results truncated; an empty result still reporting the outcome; and both row-creation sites stamping the chat id (the #2157 FR-7 rule, applied to the destination). Six of them drive report_completion itself (review finding 8) — the sanitizer, the recipient guard and the inline-trigger gate exercised together through the real entry point rather than by calling _portal_body directly, each verified to fail against a mutant.

572 passed across the completion-report, portal, voice and #2157 suites.

What this does not do

AC #1 (live card), AC #2 (pipeline steps), AC #4 (Activity tab) — all gated on the design pass. The pass also carries three questions the review has to answer: whether to ship an honest lesser control instead of the step-level restart Trinity can't do; whether an un-instrumented pipeline degrades silently; and whether the Activity tab builds against today's roster scope or waits for ent#367.

Related to Abilityai/trinity-enterprise#457

🤖 Generated with Claude Code

@dolho

dolho commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

/review Report

Branch: feature/ent457-portal-report-backdev
Files Changed: 6 (+356/−6)
Scope: CLEAN
Plan Completion: AC#3 done · AC#5 delivered as a design pass (published, 3 open questions) · AC#1/#2/#4 explicitly out of this PR


Critical Findings

[C1] Conditional Side Effects: a delivered report never moved its thread (Confidence: 9/10) — FIXED in 9ad90d7

File: src/backend/services/channel_completion_report.py (_resolve_portal.deliver)

Evidence — the product's own writer, client_portal/service.py:

db.add_portal_message(uuid.uuid4().hex, agent_name, email, "assistant", reply, cost, now, session_id=session_id)
db.touch_portal_session(session_id, now, added=1)

against what this PR shipped:

portal_db.add_portal_message(_uuid.uuid4().hex, session_agent, client_email, "assistant", body, None, utc_now_iso(), session_id=chat_id)
return True

Issue: every other writer of a portal message pairs it with touch_portal_session; this one didn't. last_message_at never advanced and message_count drifted.

Why it matters, stated precisely rather than at maximum volume: the unread badge was never at risk — ent#359's count_unread_by_session counts message rows newer than a per-thread read cursor, and the report is a real row. But last_message_at is what orders the sidebar, so the badge landed on a thread sitting exactly where it already was. A notification pointing at the middle of a list is the same silence this contract exists to end — the feature would have looked like it worked (message in thread) while missing its purpose.

Fix applied + pinned by test_delivery_moves_the_thread_in_the_sidebar. Verified live: message_count 1 → 2, last_message_at advances.


Informational Findings

[I1] Auth Boundary: a new unscoped session reader (Confidence: 7/10) — accepted, contained

File: src/backend/client_portal/db.py::get_portal_session_by_id

client_portal/db.py's other session readers are all email-scoped; this one takes a session id alone. That's correct here — the reporter is asking whose thread is this, not may this caller read it, and there is no caller to scope to — and it has exactly one call site, none of them on a request path (verified by grep). Left as-is because the docstring already carries the constraint as a rule for the next person rather than a description of today:

Any future caller must justify the same: an unscoped session read in a path that serves a request is an IDOR waiting to be written.

Worth knowing this exists; no action.

[I2] The "public" addition to INLINE_CHANNEL_TRIGGERS — claim verified, not assumed (Confidence: 9/10)

The PR body asserts public-link/x402 executions are unaffected. Checked rather than trusted, two ways: no code path anywhere passes source_channel="public" (grep across src/backend, zero hits, including routers/public.py and routers/paid.py), and the resolver lookup returns False on a NULL source_channel before control reaches the trigger check. So the only rows the new entry can suppress are those carrying source_channel="portal" + triggered_by="public" — exactly the portal turn it's meant to suppress. Confirmed live earlier: the turn's own execution reports False.


Clean Categories

  • SQL/data safety — one new SELECT by primary key, parameterized (:session_id); no migration in this PR.
  • Enum completeness (4.12)SUPPORTED_CHANNELS derives from _CHANNEL_RESOLVERS keys, so adding "portal" cannot leave a stale sibling list. The only other _SUPPORTED_CHANNELS in the tree (voice_reply_service.py) is a distinct private set for a different question, and bug(voice): agents tell Workspace clients the surface is text-only — portal TTS is never advertised, and the channel/portal voice gates disagree #2157 already answers portal there with portal_client_narrated.
  • Idempotency — delivery stays inside the existing effect_guard; second call returns delivered: False, verified live.
  • Credential exposure — the report body is built from status + summary only; no session token, email, or execution internals reach it.
  • Test gaps — 13 tests including the vanished-session, wrong-agent-stamp, and now sidebar-ordering paths; provenance derived from the session row rather than the stamp, so a forged stamp can't redirect a report.

Note on the guard I narrowed

test_2157_portal_narration.py asserted portal not in _CHANNEL_RESOLVERS. That half is superseded by the operator ruling behind ent#457, not worked around — the voice half (portal is not a voice delivery leg) stays permanent and untouched. The narrowed guard now pins the replacement rule: portal in _CHANNEL_RESOLVERS and "public" in INLINE_CHANNEL_TRIGGERS. Flagged in the commit, the PR body, and the issue so it can be reversed on sight if the ruling changes.


Summary

  • Critical: 1 found, 1 fixed (9ad90d7) — none outstanding
  • Informational: 2 — both accepted with reasons above, no action
  • Scope: clean

Live verification: delegated portal execution (triggered_by=mcp) → delivered: True, thread gains "Finished — Reconciled 42 invoices; 3 need review.", session moves; repeat call → delivered: False (effect guard); the turn's own public execution → reported: False. 399 passed across the completion-report/portal/voice/#2157 suites.

@dolho

dolho commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

/review Report — pass 2 (re-review)

Branch: feature/ent457-portal-report-backdev @ 8dc8225
Files Changed: 6 (+359/−5) — was 7 (+360/−6); see [C2]
Scope: was DRIFT → now CLEAN
Prior findings: C1 fixed and verified · I1, I2 re-confirmed, no action


Critical Findings

[C2] Scope: an entire private-repo deliverable was riding this PR (Confidence: 10/10) — FIXED in 7b99a69

File: src/backend/enterprise (gitlink)

Evidence — the merge-base diff, before the fix:

-Subproject commit a4198127da107d0749b6cc098f427434d691ff2d
+Subproject commit e2ef835c7fc888d4d556267153894cd1972453df
$ git -C src/backend/enterprise log a4198127..e2ef835c --oneline
e2ef835 docs(catalog): stop the module catalog drifting, and guard it (ent#443)
a90ce35 refactor(rooms): remove shared_sessions — it now lives in OSS core (ent#443)

The feature commit silently bumped the enterprise submodule, carrying two ent#443 commits dev does not have — one of which deletes the enterprise rooms module. dev's pointer is a4198127; mine was e2ef835c, so this branch moved it.

Why this is critical despite being correct code: it appears in --stat as src/backend/enterprise | 2 +-, which reads like a whitespace edit. Merging #2386 would have landed an ent#443 deliverable under an ent#457 title, with no reviewable record and no reviewer plausibly having read it — and there is no open ent#443 PR for it to have been stolen from, so nobody would have noticed the completion had shipped.

Checked before dropping it, rather than assuming: dev already carries OSS src/backend/shared_sessions/ and mounts both routers, and main.py includes them before register_enterprise, so an un-bumped submodule mounts both and the ungated OSS one wins the match order — pinned by test_ent443_rooms_oss_core.py. Deferring is therefore designed-safe, not a gamble. Dropped via git update-index --cacheinfo so the local checkout is undisturbed.

The bump is still needed — it completes ent#443 — and now wants its own one-line PR. Say the word and I'll open it.


Informational Findings

[I3] The insertion split a two-line comment across ~95 lines (Confidence: 9/10) — FIXED in 8dc8225

_resolve_portal landed between the halves of the D10 dispatch comment, leaving line 268 reading as a header for _resolve_portal (it documents _CHANNEL_RESOLVERS) and line 365 as an orphan fragment: "entry, not another hand-rolled if/else." Rejoined above the dict, count updated to a fourth channel.

[I1], [I2] from pass 1 — the unscoped get_portal_session_by_id and the "public" trigger addition — re-checked, unchanged, still accepted with the reasons given there.


Re-verified this pass

  • Effect-guard failure semanticsdeliver() runs inside the existing effect_guard async with; a raise propagates and releases the in-flight claim rather than burning it. Shared machinery, byte-identical to the Slack/Telegram path — portal inherits it, adds nothing.
  • Markdown actually renders_portal_body emits **Finished**; PortalConversation.vue:105 renders assistant content through renderMarkdown (DOMPurify). The user sees bold, not asterisks. Checked rather than assumed, since the user message on line 88 is deliberately plain-text.
  • Enum completenessSUPPORTED_CHANNELS derives from _CHANNEL_RESOLVERS keys; the only other _SUPPORTED_CHANNELS in the tree is voice_reply_service's private set for a different question, where bug(voice): agents tell Workspace clients the surface is text-only — portal TTS is never advertised, and the channel/portal voice gates disagree #2157 already answers portal.
  • C1 (session touch) — fix present, pinned, live-verified: message_count 1 → 2, last_message_at advances.

Summary

@github-actions

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

@github-actions

Copy link
Copy Markdown

⚠️ Live-instance suite skipped — merge conflict against dev.

Resolve by merging dev locally and pushing the result; the next nightly re-tests.

@obasilakis

Copy link
Copy Markdown
Contributor

/validate-pr Report

Branch: feature/ent457-portal-report-backdev · 6 files (+365/−7) · Linked: abilityai/trinity-enterprise#457 (P1, feature)

Category Status Notes
Commit messages 6 commits, conventional, scoped
Base branch targets dev
PR size 6 files
Mergeable CONFLICTING — and it skipped a CI gate
Status auto-promotion no closing keyword — correct here, see below
Requirements extends an existing subsystem, no new capability
Architecture architecture.md:228 now stale
Feature flows channel-completion-report.md not updated
Security check clean
Build / config packaging no new top-level module, no new os.getenv()
Test adequacy 13 tests, non-happy-path heavy
Code quality focused, no scope drift
Reviews zero independent reviews

Critical

1. Merge conflict with dev, and it cost a gate. The conflict is only docs/memory/learnings.md — both branches appended entries at EOF, so it is trivial to resolve. The real cost is that the nightly unit-suite check was skipped because of it, so one gate never evaluated. The branch is 2 commits behind (c7320a13 #2385, 36877bd5 #2370), neither touching this code.

2. No independent review. reviewDecision: REVIEW_REQUIRED, reviews: []. The two /review reports in this thread are the author's own, so the gate is unmet.

Warnings

3. architecture.md:228 is stale. The subsystem line still reads "(ent#224 Slack, ent#265 Telegram)… binding-agent consent + delivery". Portal is now a third channel with a different consent model — by construction, no allow_proactive flag, because the session has exactly one owner. That divergence is the interesting part of this PR, and the doc currently contradicts the code.

4. Feature flow not updated. docs/memory/feature-flows/channel-completion-report.md documents this subsystem; the PR adds a resolver and mutates INLINE_CHANNEL_TRIGGERS without touching it.

5. No labels on the PR.

Not a defect — flagging so nobody "corrects" it

"Related to" rather than "Fixes" is right. This is AC#3 of a seven-AC issue, with #1/#2/#4 gated on the design pass. A closing keyword would close an issue that has four ACs outstanding. ent#457 correctly stays status-in-progress, and no manual status bump is needed after merge.

Verified rather than taken on trust

Suggestion

PR body says 12 tests; there are 13.

Recommendation

REQUEST CHANGES — narrowly. The code is good and the reasoning is unusually well documented. Four small things:

  • Rebase on dev (learnings.md EOF collision only) so the nightly unit-suite gate actually runs
  • Update architecture.md:228 — portal is a third channel, and its consent model differs from the other two
  • Update docs/memory/feature-flows/channel-completion-report.md for the portal resolver and the "public" inline trigger
  • Add labels

Then it needs a reviewer who is not the author.

🤖 Generated with Claude Code

@dolho
dolho force-pushed the feature/ent457-portal-report-back branch from b803c3d to 92becca Compare August 25, 2026 10:47
@dolho

dolho commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Code review — re-review at 92becca

Read every hunk plus the surrounding machinery: report_completion, effect_guard, the ent#265 inheritance path, both portal row-creation sites, and the Workspace frontend that is supposed to surface the report.

The join itself is sound. The stamp is set at both creation sites, "public" in INLINE_CHANNEL_TRIGGERS is genuinely inert for public links and x402 (they never set source_channel, so they die at the earlier gate), and the recipient really is read from the session row rather than from the inherited stamp. The previous round's asks — architecture.md:228 and the flow doc — are addressed.

Five findings. The first is the one I would not merge without.

High

1. channel_completion_report.py:358_portal_body skips credential sanitization, so a failure terminal persists raw secrets into a client-readable message.

_summarize() (Slack/Telegram) runs sanitize_text() over a 2× window before truncating, and its own comment says why: "a failure terminal's error text can carry secrets… A channel is a persistent, externally hosted, human-visible surface, so this is the last place to skip it." _portal_body() does detail = (summary_or_error or "").strip() and slices. No sanitizer anywhere on the path.

The failure call sites pass raw text: _write_terminal_and_gate passes error, and apply_result's failure branch passes envelope.error — only the SUCCESS branch passes an already-sanitized sanitized_resp. So: agent A delegates to B for a client; B fails with a traceback carrying ANTHROPIC_API_KEY=sk-ant-… or a https://x:ghp_…@github.com/… clone URL. That string is written verbatim into enterprise_portal_messages for an external client, is permanent, and is replayed into the agent's own history context on the next cold turn. It also contradicts #2320's rule that raw failure text is operator-only.

Fix: the _summarize shape — sanitize_text over raw[:_MAX_REPORT_CHARS * 2], then truncate — rather than a bare slice.

Medium

2. channel_completion_report.py:327 — an exception inside deliver() releases the effect-guard claim, so a re-delivered terminal double-posts.

effect_guard's contract: clean exit complete()s; an exception calls fail(decision), which releases the claim so the attempt can retry (idempotency_service.py:380-386). _resolve_portal's deliver() has no try/except and always returns True, so a raise from add_portal_message / touch_portal_session propagates.

add_portal_message commits, then touch_portal_session hits a transient SQLite database is locked (separate transactions). The guard releases the claim; a #1083 re-delivered callback or the lease-reaper re-runs report_completion, the guard sees no claim, and a second identical report is appended. Both existing resolvers catch and return False for exactly this reason — the file documents it as "D4: failed send claims completed — the at-most-once bias; never blind-retry an ambiguous send." The portal leg is the one that opted out.

3. channel_completion_report.py:294 — "the Workspace polls its threads" is not true, so an idle client never sees the report.

The docstring and the architecture.md paragraph added in this PR both rest on it. But PortalConversation.vue calls store.fetchHistory only from loadThread() (mount, or an agent/session prop change) and from the in-turn reply poll — no idle interval, no visibilitychange, no onActivated refresh. stores/clientPortal.js:597 says outright that "refreshThreads() is event-driven, not periodic", so the touch_portal_session sidebar reorder this PR adds does not surface either. The only interval in the Workspace is views/Portal.vue:816, the 20 s asks poll — a different surface.

Client asks A, A delegates and says "on it", client leaves the tab open on that thread. B finishes, the row lands, and the client sees nothing until they reload or switch threads — precisely the "forgot to come back" silence this contract exists to end. Either add an idle history/threads poll (the asks poll is the obvious precedent) or downgrade the claim in the docstring, the doc, and the AC-#7 assertion.

4. channel_completion_report.py:331 — a second writer of assistant messages breaks the turn's reply detection, so a report can be rendered as the turn's answer.

PortalConversation.vue reads const baseline = await persistedAssistantCount(...) before dispatch (:782), and awaitPersistedReply then decides the reply landed with assistants.length > baselineAssistants, returning assistants[assistants.length - 1]. That runs on the primary path (after the SSE stream ends, :823) as well as on reattach (:441). The heuristic assumed portal_chat was the only writer of assistant rows in a session.

Client asks A; A dispatches B via async /task and keeps working. B finishes first and this new code persists its report into the same session. The count exceeds baseline, so the poll returns the report body as A's reply and stops; A's real answer persists afterwards and never renders for that turn. The user sees "Finished (recon) — …" where their answer should be.

Needs a discriminator: tag the report row so the client can exclude it from reply detection, or have awaitPersistedReply match on the execution rather than on a count delta.

5. channel_completion_report.py:341 — synchronous SQLAlchemy work on the event loop inside an async deliver.

get_portal_session_by_id (resolver) and add_portal_message / touch_portal_session (inside async def deliver) each open sync get_engine() connections directly on the loop; both existing resolvers do their I/O with await (httpx). architecture.md records this exact rule for the ent#433 headroom write: "a sync SQLAlchemy write on the loop stalls everything for up to the 30s busy timeout when it lands during the 03:30 backup or 04:30 VACUUM, and try/except handles errors but not blocking."

A batch of delegated terminals firing at 03:30 while db_backup_service holds SQLite's read lock blocks the single backend event loop for up to the 30 s busy timeout per task — stalling every in-flight chat, heartbeat and WS fan-out on that worker. asyncio.to_thread around the writes (and ideally the session read).

🤖 Generated with Claude Code

@obasilakis obasilakis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review at 92becca

Independent review, not a re-read of the reports in this thread. I re-checked the rebase and CI state, the three documentation asks from the earlier /validate-pr, the whole of _resolve_portal / _portal_body / deliver, report_completion against effect_guard's exception contract, all three spawn_completion_report call sites, the ent#265 inheritance guard in chat_execution_service.py, both portal row-creation sites, the narrowed #2157 guard, and the Workspace frontend that has to surface the result.

Earlier feedback — per-item verdict

Addressed:

  • Rebase. Done. The merge-base is now dev's tip, the branch is zero commits behind, GitHub reports MERGEABLE, and the full check set ran green — pytest base and head across all three seeds, schema-parity, prod-image-smoke, verify-non-root, CodeQL, gitleaks. The skipped nightly gate that motivated the ask is unblocked.
  • architecture.md:228. Rewritten, and it does the thing the ask was actually about: it names the Workspace leg and states plainly that its consent model differs from the other two, rather than quietly appending a third channel to a sentence about binding consent.
  • docs/memory/feature-flows/channel-completion-report.md. Updated properly — the trigger-set paragraph, a Workspace resolver section, the dispatch map, the test inventory and the change log.
  • Your pass-1 C1 (touch_portal_session) is present at channel_completion_report.py:341 with a test. Pass-2 C2 (the enterprise submodule bump) is gone: git diff origin/dev...92becca shows no gitlink change. I3 (the split D10 comment) is rejoined at :364.

Not addressed:

  • Labels. Still none on the PR.
  • Test count. The PR body still says 12; the file has 13 and the flow doc now says 13, so the two claims disagree with each other. Trivial, but worth one edit.
  • The five findings in your own comment of 11:54 are all still in the tree. The head commit is 92becca at 10:47 and has not moved since you posted them. I re-derived each one from the source rather than taking it on trust; verdicts are below and four of the five hold. Flagging the state explicitly only because the PR was handed over as "responded to feedback", and a self-review written as a list of diagnoses reads very much like a list of fixes.

Blocking

1. _portal_body skips credential sanitization, so a failure terminal writes raw agent output into an external client's permanent thread.

Confirmed independently. _summarize (channel_completion_report.py:88-105) runs sanitize_text over a 2× window before truncating, and its own comment states the reason: a failure terminal's error text can carry secrets, and a bare slice can cut a secret so the redaction pattern no longer matches. _portal_body (:347-361) does detail = (summary_or_error or "").strip() and slices. There is no sanitizer anywhere on that path.

The failure call sites pass raw text — _write_terminal_and_gate passes error, and apply_result's failure branch passes envelope.error; only the success branch passes an already-sanitized sanitized_resp. And that raw text is genuinely arbitrary: _extract_agent_error falls back to error_msg = response.text[:500] (task_execution_service.py:474), i.e. the agent's raw HTTP body, or str(detail) on a dict body.

Two things make this worse here than on the channel legs it is modelled on. The destination is a persisted row in enterprise_portal_messages rather than a message on someone else's server, so it is permanent, it is replayed into the thread's next cold-turn context, and it is readable by an external client indefinitely. And it departs from this surface's own established line: every other failure the Workspace shows a client is authored copy — _turn_failed_detail (client_portal/service.py:424) returns one of two fixed sentences, and record_turn_outcome (:1793) publishes a bounded category plus message. Raw agent failure text has not previously reached a portal client at all.

The fix is _summarize's shape: sanitize over raw[:_MAX_REPORT_CHARS * 2], then truncate. Worth a test with a credential-shaped string in summary_or_error, since the current suite calls _portal_body directly and would not have caught this.

2. Nothing binds the destination session to the client who started the parent turn, so one agent can route a report into a different client's thread.

This is the finding I would most like an answer on, because it lands squarely on the claim the whole design rests on.

_resolve_portal (:301-318) reads the session by id and files under session.client_email, which is right as far as it goes. The argument for having no consent gate is that a portal session belongs to exactly one client, so there is no third party. But the only thing establishing that the executed work belongs to that client is the ent#265 provenance guard on inheritance, and that guard checks the agent, not the client — chat_execution_service.py:771-772 refuses only when parent_agent != agent_principal.

So for an agent A shared with clients X and Y:

  • execution_id on chat_with_agent is an ordinary optional LLM-supplied tool argument (src/mcp-server/src/tools/chat.ts:470-477), forwarded verbatim as parent_execution_id (:296).
  • A, running a turn for Y, passes the execution id of one of X's portal turns. parent_agent == A == agent_principal, so the guard passes.
  • The child inherits source_channel = "portal" and X's session id, and its terminal — triggered_by = "mcp", so not in INLINE_CHANNEL_TRIGGERS — reports into X's thread, with a body derived from a message A chose.

Agent-scoped keys can enumerate their own executions across all of the agent's clients, so obtaining X's execution id is not a barrier. Before this PR portal rows carried no source_channel_chat_id, so this path was inert for the Workspace; it is live now.

The same shape exists for Slack and Telegram, and I am not asking this PR to fix those. The difference is that there allow_proactive on the binding stands as a second gate, whereas here consent-by-construction is the entire argument — and cross-client-within-one-agent is precisely the third party it asserts cannot exist.

I have not executed this; it is derived from reading the guard and the tool schema, so please treat it as a claim to disprove rather than a confirmed exploit. If it holds, the fix seems cheap and in the spirit of the rest of the PR, which already distrusts the stamp and re-reads the session row: both portal creation sites already stamp source_user_email (client_portal/service.py:1456, :2014), so the originating client is recoverable, and the resolver could suppress on a mismatch against session.client_email the same way it already suppresses a vanished session.

Medium

3. An exception inside deliver() releases the effect-guard claim, so a later terminal on the same execution can post a second report.

Confirmed. effect_guard calls fail(decision) on any exception out of the body and re-raises (idempotency_service.py:380-384), which releases the claim so the attempt can retry. _resolve_portal's deliver() (:327-342) has no try/except and unconditionally returns True, so a raise from either write propagates. Both existing resolvers return False instead — their send primitives absorb the failure — and the file documents the intent at :451-453 as "failed send claims completed — the at-most-once bias; never blind-retry an ambiguous send".

The concrete shape: add_portal_message commits in its own transaction, then touch_portal_session opens a second one and hits a transient database is locked. The message row is already durable, the claim is released, and a subsequent CAS-won terminal on the same execution — a FAILED row later overwritten by a late SUCCESS is the documented case — posts a second report. Not high-frequency, but it is the one contract the module exists to hold, and this is the only resolver that opted out of it. Wrapping the two writes and returning False on failure would match the neighbours.

The three call sites are all correctly gated on the CAS won branch (task_execution_service.py:948, :2053, :2181), so that half of the rule is fine and unchanged by this PR.

4. "The Workspace polls its threads" is not true, and three artefacts in this PR now assert it.

Confirmed. PortalConversation.vue calls store.fetchHistory from loadThread() and from the in-turn reply poll only — there is no idle interval, no visibilitychange handler and no onActivated refresh. stores/clientPortal.js:597 states outright that refreshThreads() is event-driven and not periodic. The only interval in the Workspace is views/Portal.vue:816, and it calls store.fetchAsks() and nothing else; the comment immediately above it at :805 says the Workspace has no WebSocket.

So the sidebar reorder that finding C1 added does not surface for an idle client either. The user asks A, A delegates and says "on it", the user leaves the tab open on that thread, B finishes, the row lands — and nothing changes on screen until they reload or switch threads. That is the same silence the contract is meant to end, and it is asserted as resolved in the _resolve_portal docstring (:295-297), in the paragraph added to architecture.md:228, in the new flow-doc section, and in the narrowed test_2157_portal_narration.py docstring. Either add an idle poll — the 20s asks poll is the obvious precedent and is already visibility-aware — or downgrade the claim in all four places. A doc that overstates delivery is worse than one that admits the gap, because the next person builds on it.

5. The report is a second writer of assistant rows, and the turn's reply detection is a count delta over exactly those rows.

Confirmed. PortalConversation.vue:776 reads const baseline = await persistedAssistantCount(...) before dispatch; awaitPersistedReply decides the reply arrived with assistants.length > baselineAssistants (:981) and returns assistants[assistants.length - 1]. That runs on the primary path after the SSE stream ends (:817) and on reattach (:432-435), where the baseline is the on-screen count. The heuristic was sound while portal_chat was the only writer of assistant rows in a session; this PR adds a second.

The window: the client asks A, A dispatches B asynchronously and keeps working, B finishes first, this code persists B's report into the same session. The poll sees the count exceed baseline, returns the report body as A's reply, and stops — and at :858 that gets pushed as the assistant turn, and narrated by speak() if voice mode is on. A's real answer persists afterwards and never renders for that turn.

enterprise_portal_messages carries no kind or execution column (db/schema.py:543-553), so there is no discriminator available to the client today. Worth deciding deliberately whether that is acceptable for now, or whether the reply detection should match on execution rather than on a count.

Low

6. config.py:101 now contradicts the code it documents. The PORTAL_SOURCE_CHANNEL docstring reads: "It is NOT a messaging channel: portal rows carry no source_channel_chat_id, so every channel consumer (the completion-report resolver map, voice_reply_service's supported set) already ignores it." This PR makes both halves of that sentence false. The narrowed test carries the corrected reasoning; the constant's own definition site does not, and a stale comment at a definition site is read as authoritative by everyone who lands there next.

7. Sync SQLAlchemy writes on the event loop inside async def deliver. Real — add_portal_message and touch_portal_session each open a get_engine() connection directly — but I would not block on it, because it is this module's existing practice: portal_chat does exactly the same at client_portal/service.py:1547-1548 from inside a coroutine, and both other resolvers do sync DB reads in their resolver bodies too. The only genuinely new part is that this runs from a fire-and-forget task rather than a request-scoped turn. Worth an asyncio.to_thread if you are touching the closure for finding 3 anyway; not a reason to hold the PR on its own.

8. Test shape. The 13 tests are well-chosen for the join and for the suppression paths, and test_both_portal_row_creation_sites_name_the_chat is a good structural pin. But they exercise _portal_body and _resolve_portal directly and never drive report_completion end-to-end with a portal row, which is why findings 1 and 3 are both invisible to a green suite. One test that goes through report_completion with a stubbed portal DB would cover the sanitizer, the guard interaction, and the inline-trigger gate together.

What holds up

Worth saying, because it is most of the PR. The stamp is genuinely set at both creation sites and pinned by a structural test. "public" in INLINE_CHANNEL_TRIGGERS is provably inert for public links and x402 — I re-grepped source_channel= and the only writers are the two portal sites, message_router.py:737, and the ent#265 inheritance at chat_execution_service.py:1117, so neither surface reaches the gate. Reading the recipient from the session row rather than the inherited stamp is the right call and has a pleasant side effect the PR does not claim: enterprise_portal_sessions.agent_name is rewritten by the rename cascade, so a rename mid-flight resolves correctly where a cached stamp would not. No schema change, so no dual-track migration is owed, and I confirmed the columns already exist. The #2157 supersession is handled the right way round — narrowed to what remains true, argued in the test rather than only in the PR body, and traceable to a ruling rather than to preference.

Verdict

Requesting changes, on findings 1 and 2. Finding 1 puts unsanitized agent failure output — potentially credential-bearing, per _summarize's own stated rationale — into a permanent, externally readable store, and it is a small fix. Finding 2 is the one I would like argued rather than patched if you think I have it wrong; if it holds, the consent-by-construction claim needs either a gate or a narrower statement.

Findings 3 and 5 are correctness issues on a client-facing surface that I would want resolved or consciously deferred with a note. Finding 4 is a documentation-accuracy issue that happens to be load-bearing for the AC, so it needs either the poll or the honest downgrade. Findings 6 to 8 are cheap and can ride along.

dolho and others added 8 commits August 25, 2026 16:13
…457 AC#3)

"Dispatch → monitor → report back is a contract, not a habit" — the issue's
words. The machinery has existed since ent#224/#265 for Slack and Telegram: a
terminal chokepoint (#1578), a destination on the row (ent#117), a resolver per
channel, an effect guard for at-most-once. The Workspace was excluded by ONE
missing field.

#2157 stamped portal executions with the SURFACE (`source_channel = "portal"`)
and never a destination, so every portal terminal died at `report_completion`'s
`if not source_channel_chat_id` gate. This gives the row its session id at both
creation sites (#2157 FR-7's rule: both, or which path made the row decides
whether the promise holds) and adds a portal resolver. Delegated work then
inherits the destination through the EXISTING ent#265 chain — no new
inheritance, no new transport, no new table.

Two rules carried over deliberately, because both are the ways this goes wrong:

* **No double-post.** A Workspace turn is synchronous — `portal_chat` persists
  the reply itself — so `public` joins INLINE_CHANNEL_TRIGGERS. Without it every
  chat message would gain a duplicate "done". Public links and x402 share that
  trigger and are unaffected: they carry no chat id, so they never reach the
  check.
* **The recipient comes from the session row, not the stamp.** The stamp is a
  string that rode an inheritance chain; the session row is the platform's own
  record of whose chat this is. A delegated child may execute as a different
  agent (A asks B) — the message is filed under A, whose chat it is, and names B
  in the body.

Consent is by construction, as for a Telegram DM: the session belongs to one
client and the report goes into their own conversation, so there is no third
party and no flag to consult. Delivery is a persisted assistant message read
through the history the client already polls, which is why AC #7's "degrades to
poll" holds here with no new transport.

**This supersedes half of a #2157 invariant, deliberately.**
`test_portal_source_channel_is_not_a_messaging_channel` asserted the portal must
miss BOTH the voice service's supported set and `_CHANNEL_RESOLVERS`. The voice
half is permanent — there is no outbound audio leg. The completion half was true
only because the row carried no destination, which is precisely what ent#457
(operator ruling 2026-08-22, committed to the release cut) changes. The guard is
narrowed to what remains true and now also pins the no-double-post rule; the
reasoning is in the test, not only in this message.

Verified live: a delegated portal execution posts "**Finished** — Reconciled 42
invoices…" into the client's thread; a second call delivers nothing (the effect
guard holds, still one message); the turn's own `public` execution reports
nothing at all.

Scope: this is AC #3 only. The execution card and pipeline view (AC #1/#2) are
gated on AC #5's design pass, which is published separately for review — no card
code until it is approved.

Related to Abilityai/trinity-enterprise#457

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

Caught in review. Every other writer of a portal message pairs
add_portal_message with touch_portal_session; the report-back writer did
not, so a delivered report left last_message_at untouched.

The unread badge was never at risk — ent#359 counts message ROWS against
a per-thread read cursor — but last_message_at is what orders the sidebar,
so the badge landed on a thread sitting wherever it already was. A
notification pointing at the middle of a list is the same silence this
contract exists to end.

Verified live: message_count 1 -> 2, last_message_at advances.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An accidental pointer bump rode the feature commit: this branch carried the
ent#443 enterprise-side commits (rooms module removal + catalog docs) that dev
does not have. They are correct and still needed, but they are an ent#443
deliverable and belong in their own reviewable change, not landed invisibly
under an ent#457 title — a submodule bump does not show up as a file diff to
anyone skimming the PR.

Safe to defer: the OSS half is already on dev, and main.py mounts the OSS
rooms routers BEFORE register_enterprise, so an un-bumped submodule mounts
both and the ungated OSS one wins the match order (pinned by
test_ent443_rooms_oss_core.py).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two-line D10 comment above _CHANNEL_RESOLVERS got separated by ~95 lines
when _resolve_portal landed between its halves: the first line read as a
header for _resolve_portal, the second as an orphan sentence fragment above
the dict. Rejoined, and the count updated (a fourth channel now).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ave to say so (ent#457)

Review asks. `architecture.md`'s subsystem line named only ent#224 Slack and
ent#265 Telegram, so the doc contradicted the code on the one point that makes
this leg interesting: the Workspace's consent model differs by construction. A
portal session belongs to exactly one client, so there is no third party for an
`allow_proactive` bit to protect and no flag to consult — which is precisely why
the recipient is read from the session ROW rather than from the execution's
inherited stamp. The line now says that, and says why `"public"` joining
`INLINE_CHANNEL_TRIGGERS` cannot regress public links or x402 (they stamp no
`source_channel_chat_id`, so they never reach the gate).

`channel-completion-report.md` documents this subsystem and had not been touched
while the PR added a resolver and mutated the trigger set. It now carries: the
four-trigger no-double-post rule with the `public` exception argued rather than
asserted; the updated dispatch map; a `### Workspace (_resolve_portal)`
subsection filed beside Slack's and Telegram's, covering consent-by-
construction, the suppression paths, filing under the session's agent while
naming the executing one, the honest failure wording, why it needs no new
transport, and the paired `touch_portal_session`; the ent#457 test file with its
real count; and a revision-history entry.

Rebased on dev so the nightly unit-suite gate, skipped on the conflict, actually
runs — the collision was `learnings.md` at EOF only.

Related to Abilityai/trinity-enterprise#457
…vent loop (ent#457)

**A failure terminal wrote raw secrets into an external client's thread.**
`_summarize` (Slack/Telegram) runs `sanitize_text` over a 2x window before
truncating, and its own comment says why. `_portal_body` did a bare strip and
slice — no sanitizer anywhere on its path. The failure call sites pass raw text
(`_write_terminal_and_gate` passes `error`; `apply_result`'s failure branch
passes `envelope.error` — only the success branch passes something already
sanitized), so a traceback carrying `ANTHROPIC_API_KEY=sk-ant-…` or a
`https://x:ghp_…@github.com/…` clone URL landed verbatim in
`enterprise_portal_messages` for a CLIENT, permanently, and was replayed into
the agent's own history context on the next cold turn. It also contradicted
#2320's rule that raw failure text is operator-only.

The rule is now `_sanitized_detail`, shared by both — extracted rather than
copied, because a second implementation of a redaction rule is a second place to
forget it, which is how this happened once. Order stays load-bearing: sanitise
over the 2x window BEFORE truncating, or a slice can cut a secret so the pattern
no longer matches and the tail survives.

**A raise in `deliver()` released the effect-guard claim, so a re-delivery
double-posted.** `effect_guard` calls `fail()` on an exception, which frees the
claim; `add_portal_message` commits before `touch_portal_session` runs, so a
transient "database is locked" on the second write is exactly that shape, and a
#1083 callback or the lease-reaper then appends a second identical report. Both
existing resolvers catch and return False for this reason — the file documents
it as "D4: failed send claims completed — the at-most-once bias". The portal leg
was the one that opted out.

**The writes ran on the event loop.** Sync SQLAlchemy, while both existing
resolvers do their I/O with `await`. A batch of delegated terminals at 03:30,
while `db_backup_service` holds SQLite's read lock, blocks the single backend
loop for up to the 30s busy timeout per task — stalling every in-flight chat,
heartbeat and WS fan-out on that worker. Now `asyncio.to_thread`. The session
READ is left on the loop deliberately: it is one indexed SELECT, and moving it
would make the resolver async and change the dispatch contract for all three
channels.

**"The Workspace polls its threads" was false**, and the docs said it twice.
`PortalConversation.vue` loads history only on mount and on a prop change,
`refreshThreads()` is documented as event-driven, and the only interval in the
Workspace is the 20s asks poll on a different surface. The report is durable but
not immediately visible; the docstring and architecture.md now say so, and stop
claiming AC #7's "degrades to poll" holds by construction. An idle history poll
is the follow-up.

NOT fixed, and recorded rather than bodged: a report landing mid-turn can be
returned AS that turn's answer, because the client detects a reply by an
assistant-row count delta and this is now a second writer of those rows. The
honest fix needs a per-row discriminator `enterprise_portal_messages` does not
carry (no `execution_id`), i.e. a dual-track migration; overloading `role` was
rejected because two server-side readers and the client's rendering branch on
it. Written into the docstring and architecture.md where the next reader will
hit it.

Related to Abilityai/trinity-enterprise#457
@dolho
dolho force-pushed the feature/ent457-portal-report-back branch from 92becca to 5fdc224 Compare August 25, 2026 13:18
@dolho

dolho commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Code review — re-review at 5fdc224

Read the full diff plus report_completion, _inherited_channel_context, chat.ts, client_portal/db.py, PortalConversation.vue, and the config.py / requirements text that states the old invariant.

The previous round's fixes hold: _sanitized_detail is a clean, equivalent extraction, the both-creation-sites stamp is correct, triggered_by="public" really is the trigger at both sites (so the no-double-post rule holds), public-link and x402 rows genuinely carry no source_channel, SUPPORTED_CHANNELS has no other consumer, the add_portal_message / touch_portal_session signatures match, and the engine is NullPool + check_same_thread=False, so asyncio.to_thread is safe. No submodule pointer rides along.

Seven findings. The first two matter.

High

1. channel_completion_report.py:290 — the mid-turn collision is not a narrow race, and a cheap mitigation already exists in this codebase.

The previous round recorded this as a known limitation needing a migration. Re-reading the client, the window is much wider than "if a child happens to finish first": PortalConversation.awaitPersistedReply (:998) starts polling the instant the SSE stream ends, and stream_end fires when the agent finishes — before execute_task returns and before portal_chat persists the reply. So there is a reliable interval on every turn during which the last assistant row is not yet the answer.

And delegation puts a writer in exactly that interval: MCP chat_with_agent with parallel: true passes parent_execution_id (chat.ts:296), so a child's terminal lands during the parent turn. On any Workspace turn that delegates, the client is likely shown **Finished (recon)** — … as the answer to their message, with the agent's real reply dropped from the view until a reload.

The migration is still the complete fix, but portal_inflight:{session} (mark_turn_inflight / is_turn_inflight) is already the platform's "a turn is running on this thread" signal and can gate or defer the write today, without a schema change.

2. channel_completion_report.py:345 — the recipient is derived from the session row, but nothing checks the session belongs to the execution's own client.

_inherited_channel_context's agent arm requires only parent_agent == current_user.agent_name. For Slack and Telegram that meant "a chat this agent is bound to". Portal sessions are per client, so the same predicate now spans every client of that agent.

Concretely: a prompt-injected agent A, running client C's turn, calls list_recent_executions, takes an execution id belonging to client D's portal session, passes it as parent_execution_id, and the child's terminal writes attacker-influenced agent text into D's private thread. Requiring the session's client_email to match the originating execution's source_user_email closes it.

Low

3. config.py:101 — the comment now states a false invariant. It reads: "portal rows carry no source_channel_chat_id, so every channel consumer (the completion-report resolver map, voice_reply_service's supported set) already ignores it." Both halves stopped being true in this PR — portal rows carry a chat id and are in _CHANNEL_RESOLVERS. docs/memory/requirements/public-access.md:799-800 repeats it verbatim and was also missed (the feature-flow doc was updated). This is the one place that defines the constant, so a future reader reasons from a stale invariant.

4. channel_completion_report.py:345 — the session READ is still on the event loop, in the same function that deliberately moves its writes to asyncio.to_thread for that exact reason. The last round called this out as a conscious trade ("one indexed SELECT"); under the stated 03:30 backup scenario — DELETE journal mode, 30s busy timeout — a batch of delegated terminals blocks the loop on the read just as it would on the write. Cheap to include now that the thread hop exists.

5. channel_completion_report.py:432 — the delegated worker's name is written into an external client's thread. who = f" ({executing_agent})". That agent is typically not on the client's roster, and the portal surface otherwise answers a uniform 404 precisely so a client cannot learn a non-rostered agent exists (Invariant #8). Honest attribution was the goal; disclosing the internal name to an external party is the cost, and it is worth deciding deliberately rather than inheriting from the Slack/Telegram wording.

6. channel_completion_report.py:378 — reports are replayed to the model as its own prior utterances. They are persisted with role="assistant", so _format_history_context (client_portal/service.py:1171) renders them on the next cold turn as You: **Didn't finish** — failed …. The agent reads platform-authored status lines as things it said (and may conclude it has already reported), and they consume history window.

7. tests/unit/test_ent457_portal_completion_report.py:91 — leaked event loops. asyncio.get_event_loop_policy().new_event_loop().run_until_complete(...) appears four times and never closes the loop, leaking a selector/eventfd per call. asyncio.run(...) — or @pytest.mark.asyncio — does the same job without it.

🤖 Generated with Claude Code

Review blocking #2 (@obasilakis), and the finding was exact: nothing bound the
destination session to the client who started the parent turn.

The inheritance guard that lets a child carry a portal context checks the
AGENT — `_inherited_channel_context` refuses only when parent_agent !=
agent_principal. So for an agent A shared with clients X and Y, A serving Y
could pass the execution id of one of X's portal turns: same agent, guard
passes, the child inherits X's session, and its terminal files a body A chose
into X's permanent thread. A holds both clients' data, so it is a disclosure
between two different people — and it went live with this PR, since portal rows
carried no chat id before it.

The client identity now rides WITH the context instead of being re-derived from
it: a new nullable `schedule_executions.source_channel_client`, stamped at both
portal creation sites, inherited alongside the other three channel columns, and
checked in `_resolve_portal` against the session's own `client_email`.

`source_user_email` was rejected as the carrier: routers/public_memory.py reads
it to decide whose MEM-001 memory blob a turn writes into, so overloading it
would silently redirect memory writes.

Fails CLOSED. Every row predating the column reports NULL, and `_norm_email`
maps a missing value on either side to '' so 'unknown == unknown' can never read
as agreement. Case- and padding-insensitive, so a legitimate report is never
refused over the shape of an address.

`context_client` is passed to EVERY resolver, not to the portal one alone — a
per-channel call shape is how a resolver ends up silently not receiving a field
that was added for it. The channel legs ignore it: their destination is a chat
id on someone else's server, not a per-person thread this platform owns.

Dual-track: sqlite channel_report_client + alembic 0047 (single head, verified).
Blocking #1 (sanitization) was already fixed in 5fdc224, after the review.

452 passed across the channel, chat-execution and portal suites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dolho
dolho force-pushed the feature/ent457-portal-report-back branch from 49d2915 to af91532 Compare August 26, 2026 07:18
dolho and others added 2 commits August 26, 2026 10:25
…ique (ent#457)

#2384 mints an 0047 off the same parent. Ids are strings so the prefix is only
a human ordering cue, but a duplicate one is what makes a forked graph hard to
read later (ent#443 precedent). Both still declare down_revision = 0046, so
whichever merges SECOND forks the graph and needs an alembic merge revision —
check_alembic_heads fails loudly there rather than silently applying nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fresh PostgreSQL database is built from db/schema.py's DDL — which declares
the column — and only THEN runs the revisions, so a bare add_column raised
DuplicateColumn on every fresh install. Matches 0046 and the rest of this line.
Caught by pg-migrations, which exists to exercise exactly that boot path.

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

`portal_chat` passes `source_channel_client=email` into `run_resumable_turn`,
which splats **execute_kwargs straight into `TaskExecutionService.execute_task`
— a signature with no `**kwargs`. So the new keyword was not a dropped column,
it was a TypeError at call-binding time on EVERY Workspace turn, sync and
streaming alike:

    TaskExecutionService.execute_task() got an unexpected keyword argument
    'source_channel_client'

Accept it, and persist it in the row-creation branch — accepting without the
paired write would leave `_resolve_portal` failing closed on every row the sync
path creates, which is the same outage one layer along.

The regression test derives the forwarded keyword set from the actual call
rather than hardcoding a name, so the next addition is covered without anyone
remembering to extend a list. 408 existing portal/completion-report tests pass
with the bug present — the two suites mock each other's side of this call, and
this is the only place the two signatures meet.

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

dolho commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Re-review — fresh pass over 88efbe7d..d6a4bc10

Reviewed the diff against the merge-base (88efbe7d), not the base tip. Verdict per prior finding first, then what this pass turned up.


Prior blocking #1_portal_body skipped credential sanitization → RESOLVED

Verified from the code, not the commit message. _sanitized_detail is now the single implementation and both channels route through it:

def _sanitized_detail(summary_or_error: Optional[str]) -> str:
    raw = (summary_or_error or "").strip()
    window = raw[: _MAX_REPORT_CHARS * 2]
    cleaned = sanitize_text(window)
    truncated = len(cleaned) > _MAX_REPORT_CHARS or len(raw) > len(window)

_summarize and _portal_body both call it, and _portal_body no longer references sanitize_text at all — so there is one redaction rule, not two. Order is right and the boundary arithmetic holds: raw exactly 2*MAX is not flagged truncated (len(raw) > len(window) is False), and raw past 2*MAX is flagged even when redaction shrinks cleaned below MAX — which is the case a naive len(cleaned) > MAX test would silently drop the ellipsis on. Good.


Prior blocking #2 — nothing bound the destination session to the client → the check is in, but it does not block the attack its own docstring describes

Severity: Major · Confidence: high (traced end-to-end in code; no runtime repro attempted).

The check reads the client off the execution row and compares it to the session row:

# channel_completion_report.py:388
if _norm_email(context_client) != _norm_email(client_email):
# channel_completion_report.py:539
context_client=getattr(row, "source_channel_client", None),

But context_client and chat_id are populated from the same parent row, in the same tuple, in the same statement:

# chat_execution_service.py:791-804
return (
    src_channel,
    getattr(parent, "source_channel_chat_id", None),   # -> the session
    ...
    getattr(parent, "source_channel_client", None)     # -> the client
    or getattr(parent, "source_user_email", None),
)

Now replay the scenario the migration docstring, the _resolve_portal comment and test_a_report_for_another_client_is_refused all say is fixed — "an agent shared with clients X and Y could cite one of X's portal execution ids while serving Y":

  1. Agent A knows an execution id from one of X's portal turns (it is handed execution_id on every turn for effect_guard / send_voice_reply / write_user_memory, and can also list its own executions).
  2. While serving Y, A posts /task with parent_execution_id = P_X.
  3. _inherited_channel_context: agent_principal == parent_agent == Aguard passes (unchanged).
  4. The child inherits chat_id = session_X and source_channel_client = X — both off P_X.
  5. _resolve_portal: session_X.client_email == X, context_client == Xthey matchdelivers.

So the new column blocks a (chat_id, client) mismatch, and no writer in the tree can produce one: both portal creation sites set the pair together, and the only other writer copies the pair atomically. The passing test constructs context_client="y@example.com" against client_email="x@example.com" by hand — a state the inheritance path cannot reach.

The residual capability this PR newly creates is real and worth naming plainly: agent A can append an arbitrary assistant message to any of its clients' permanent Workspace threads at a moment of its choosing, by citing an old execution id — including while being driven by a different client's prompt. That is the cross-client influence path the finding was about; it survives.

Two ways forward, neither of which I'd fix under a reviewer's hand:

  • Bind the inheritance to the turn, not to an id. The feature's premise is "A delegates during this turn". Requiring the parent to be running (or terminal within a short window) in _inherited_channel_context would make a week-old id fail, and it is one predicate at the one chokepoint. This is the control that actually matches the intent.
  • At minimum, restate the claim. As shipped the check is a consistency assertion + a rolling-deploy/future-writer guard — genuinely useful, just not what three docstrings and a migration say it is. Leaving the stronger claim in place is worse than having no comment, because the next reader stops looking.

NEW — CRITICAL: every Workspace chat turn raised TypeError · fixed and pushed as d6a4bc10

Confidence: certain (reproduced). portal_chat hands the new keyword to the resume engine:

# client_portal/service.py:1461
source_channel_client=email,

run_resumable_turn names a few parameters of its own and splats everything else straight through:

# session_turn_service.py:470-476
result = await service.execute_task(
    agent_name=agent_name, message=message, triggered_by=triggered_by,
    resume_session_id=resumed_with, persist_session=True,
    **execute_kwargs,
)

…and TaskExecutionService.execute_task has no such parameter and no **kwargs (source_channel, source_channel_chat_id, source_channel_thread only). Reproduced against the branch:

execute_task accepts source_channel_client: False
has **kwargs: False
TypeError: TaskExecutionService.execute_task() got an unexpected keyword argument
'source_channel_client'. Did you mean 'source_channel_thread'?

Not a dropped column — a bind-time raise, before the agent is contacted, on both portal paths (the sync POST .../chat route and the streaming route, which runs the same portal_chat coroutine in the background). Workspace chat is fully down.

db.create_task_execution accepts it (this PR added it) and start_portal_turn calls that directly, so the streaming row-creation stamp is fine — the gap is only the execute_task layer sitting between the portal and the DB, which is exactly the paired-write class learnings.md gained an entry for in this same PR.

Fixed on the branch: execute_task accepts it and passes it into its row-creation branch (accepting without persisting would leave _resolve_portal failing closed on every row the sync path creates — the same outage one layer along). The regression test derives the forwarded keyword set from the actual AST of the call rather than hardcoding a name, so the next addition is covered without anyone remembering a list.

Worth noting why CI was green: 408 existing portal / completion-report tests pass with the bug present (measured, with the fix stashed). The portal suites mock the engine and the engine suites never call it the portal's way; this call is the only place the two signatures meet.


NEW — Minor: two stale comments now assert the opposite of what the code does

src/backend/config.py:101 is load-bearing prose for the next reader and is now false in both halves:

# NOT a messaging channel: portal rows carry no `source_channel_chat_id`, so every
# channel consumer (the completion-report resolver map, `voice_reply_service`'s
# supported set) already ignores it.

Portal rows now carry a chat id, and the resolver map now has a "portal" entry. (The behaviour is still correct — routers/agents.py:1418 gates on channel not in ("telegram","slack","whatsapp") before reading the chat id, so the voice leg is genuinely unaffected — but the comment's stated reason no longer is, and it is the reason someone will rely on.)

docs/memory/architecture.md:1927-1931 — the schedule_executions DDL block gains no source_channel_client line, and still says:

source_channel TEXT,   -- ...also 'portal' (#2157) — a surface stamp, NOT a delivery leg (no chat id)

The prose entry for channel_completion_report.py was updated thoroughly (and honestly — the "durable, not immediately visible" retraction is the right call), so this is just the schema block being missed.


NEW — Minor: three of the new tests assert source text where a behavioural test is one line away

test_a_failed_portal_write_claims_the_effect_rather_than_releasing_it asserts "except Exception" in src and "return False" in src — both would pass with the except attached to something unrelated. The suite already has the fixture to do it for real: monkeypatch add_portal_message to raise, then assert await deliver() is False. Same for test_the_portal_writes_do_not_run_on_the_event_loop (a literal-substring assert).

test_the_docstring_no_longer_claims_the_workspace_polls asserts four phrases of prose. It will break on any harmless rewording and proves nothing about behaviour. I understand the intent — the false polling claim is what stopped anyone building the poll — but a # TODO(ent#xxx) plus the architecture.md entry already carries that, and this one costs a future editor a red build for a synonym.

Not blocking; the rest of the suite is genuinely good (the boundary cases on truncation, the "filed under the agent whose chat it is" case, and the two-creation-sites count check all earn their place).


NEW — Minor (already disclosed by the author): a report landing mid-turn can be read as that turn's answer

Called out in _resolve_portal's own docstring and in architecture.md — PortalConversation detects a reply by an assistant-row count delta and this resolver is now a second writer of those rows. Flagging only so it is on the record as a shipped functional defect rather than a note, since the client-visible symptom is a wrong answer. The stated fix (a per-row discriminator, i.e. a dual-track migration) is the right shape; a cheap interim would be filing the report with a distinguishing marker the client can skip.


Checked and clean — and what proved it

Area Result
Alembic head Ran scripts/ci/check_alembic_heads.py src/backend/migrations/versions against the branch tree → 48 revision(s), 1 head (0048_channel_report_client) — PASS.
Cross-PR fork hazard Context, not a defect of this PR: #2384 mints an 0047 off the same 0046_report_audience parent, so whichever merges second forks the graph and needs an alembic merge. check_alembic_heads is an unconditional required gate, so it fails loudly rather than silently applying nothing. The revision docstring already says exactly this.
Fresh PostgreSQL build Correct, and the IF NOT EXISTS is genuinely required: init_database()'s non-SQLite branch calls only upgrade_to_head(), and 0001_baseline.upgrade() executes db/schema.py's TABLES DDL — which now declares the column — before 0048 runs. A bare add_column would DuplicateColumn on every fresh install. Matches 0046's pattern.
SQLite track _migrate_channel_report_client uses _safe_add_column (PRAGMA pre-check + duplicate-column race catch), registered last in MIGRATIONS; the runner commits per migration at migrations.py:169, so the absent conn.commit() in the migration body is a no-op, not a bug. db/tables.py, db/schema.py, db_models.py and the row mapper's row_keys guard are all updated together — schema_parity tests pass.
5-tuple fan-out _inherited_channel_context / _NO_INHERITED_CONTEXT have exactly one consumer (chat_execution_service.py:1111), updated. No test unpacks it. Grepped src and tests.
INLINE_CHANNEL_TRIGGERS gains "public" No suppression regression. A delegated child's trigger can only be self_task|agent|mcp|manual|event, never "public" — so no inheriting row is newly excluded; and public-link / x402 rows exit one gate earlier at if not source_channel or not source_channel_chat_id.
False-negative suppression _norm_email lowercases and strips both sides, and maps every unusable value to "" so NULL == NULL never reads as agreement — the right call. Pre-column rows carry no source_channel_chat_id either (the chat-id stamp is new in this PR), so they exit before the check and lose nothing they had.
Idempotency effect_guard identity is {channel, chat_id, thread} + execution_id with the executing agent — unchanged shape, portal's thread=None normalises to "". The added try/except → return False in deliver() is the correct D4 bias (a raise would fail() the claim and let a re-delivered terminal post a second copy).
Blocking I/O asyncio.to_thread(_write) — right, and the sync-SQLAlchemy-on-the-loop rationale matches the ent#433 precedent in architecture.md.
Unscoped session read get_portal_session_by_id is deliberately unscoped with a docstring saying so and one non-request caller. Correct for "whose thread is this"; the warning against future request-path callers is the right guard rail.
Write signatures add_portal_message(mid, agent, email, role, content, cost, now, session_id=) and touch_portal_session(sid, now, added=) match the call sites; added=1 is right for a single row.
New disclosure surface None — source_channel_client is not added to ExecutionResponse or any API model.
Test run pytest -k 'ent457 or completion_report or portal or 2157 or schema_parity or alembic'524 passed, 3 skipped (includes the new regression file).

CI's cancelled pytest (base, seed 12345) is the known ~25m base-side infrastructure timeout (#2395) — not treated as a finding.


Summary

The sanitization fix is solid and correctly factored. The recipient check is well-built but does not close the scenario it claims to — the two values it compares are copied from the same row, so the "cite another client's execution id" path still delivers; that needs either a recency bound on inheritance or an honest restatement. And this pass turned up a P0 that would have taken Workspace chat down entirely on merge, which I've fixed and pushed to the branch (d6a4bc10) with a derived-not-hardcoded regression test, since 408 existing tests were green with it present.

Blocking on the Major above (decide: real control, or corrected claim). Everything else is minor cleanup.

…eview)

My own fix was wrong, and the re-review was right to say so.

`_resolve_portal` compared the inherited `source_channel_client` against the
session's `client_email` and called that a binding. It is not: BOTH values are
read off the same parent row, which the CALLER names. So in the attack it was
written to stop — agent A, shared with clients X and Y, cites one of X's portal
execution ids while serving Y — the child inherits X's session AND X's email,
they match, and the report is delivered into X's thread exactly as before. A
comparison between two values from one source cannot fail.

That is the same defect the fix was meant to close, one level up. The test that
"proved" it hand-built a (chat_id, client) mismatch no writer in the tree can
produce, which is why it passed.

The only input here the caller does not choose is TIME. ent#265's premise is
that A delegates DURING a turn it is currently serving, so a parent that has
already finished is not that. Inheritance now requires the parent execution to
be `running`, which removes "any historical execution of any client of this
agent" from the attack surface — the property that made the portal case
reachable at all.

RESIDUAL, stated rather than implied: this does not make the portal leg
airtight. If X has a turn genuinely in flight at that moment, A can still name
it. "All history" -> "a concurrent live turn" is a real narrowing, not a proof.
Closing it needs the child to learn its own client from something other than
the caller's argument — a trusted runtime injection of the executing turn's
identity, which is the #1084 execution_id work and not this change.

The guard had NO direct test before this, which is why the tautology survived
review: ent#265's 44 tests cover the reporting side, not the inheritance
decision. Added one, and mutation-checked it — removing the bound fails 7 of 8.

427 passed across the portal, channel and chat-execution suites.

Related to Abilityai/trinity-enterprise#457

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

# Conflicts:
#	docs/memory/learnings.md
…ng the opposite of what ships (ent#457)

Review finding 8. Every existing assertion reached `_portal_body` or
`_resolve_portal` directly, and the three fixes from the first round are pinned
by `inspect.getsource`. That is exactly why the unsanitized body and the raise
that released the effect claim both survived a green suite: nothing drove
`report_completion` with a portal row, so nothing exercised the gate ordering,
the recipient guard and the sanitizer together.

Six end-to-end cases now do, stubbing only the two edges the module does not own
(the execution row and the portal DB): a delegated terminal reaching the thread;
a credential-shaped failure string proven absent from what is persisted; a
report whose inherited client disagrees with the session refusing BEFORE any
write; a NULL client failing closed; the inline turn still refused at the gate;
and a vanished session writing nothing. Each was checked against a mutant — the
sanitizer removed, then the recipient guard removed — and each fails there, so
they are not restating the source.

Review finding 6. `config.py`'s `PORTAL_SOURCE_CHANNEL` comment still said the
stamp is "NOT a messaging channel: portal rows carry no
`source_channel_chat_id`, so every channel consumer already ignores it". Both
halves are false as of this PR: portal rows carry the session id and
`_CHANNEL_RESOLVERS` has a `"portal"` entry. A stale comment at a definition
site is read as authoritative by whoever lands there next, and this one would
have argued against the feature above it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dolho dolho added complexity-high Complexity: high (board points 13) priority-p1 Critical path theme-ui-ux Theme: UI/UX type-feature New functionality labels Aug 27, 2026
@dolho

dolho commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Review findings addressed — 87e51f4b

Findings 1, 2, 3, 5 and 7 landed in commits pushed after your 92becca re-review (5fdc2248, af915329, 30f78b1c), so this commit clears what was left. Verdicts against the current head:

# Status Where
1 — _portal_body skips sanitization fixed (5fdc2248) _sanitized_detail, shared with the channel legs
2 — nothing binds the destination to the originating client fixed (af915329, 30f78b1c) source_channel_client rides the inheritance chain; _resolve_portal compares it to session.client_email and fails closed on NULL
3 — an exception in deliver() releases the effect claim fixed (5fdc2248) both writes wrapped, returns False like its neighbours
4 — "the Workspace polls its threads" is untrue fixed — claim downgraded in all four places resolver docstring, architecture.md:228, the flow doc, the narrowed test_2157 docstring
5 — the report is a second writer of assistant rows documented, not fixed named as a known limitation in the resolver docstring and architecture.md; a real fix needs a per-row discriminator enterprise_portal_messages does not carry, i.e. a dual-track migration
6 — config.py:101 contradicts the code fixed here
7 — sync writes on the event loop fixed (5fdc2248) asyncio.to_thread
8 — no test drives report_completion fixed here
labels / test count fixed labels applied; body said 12, file has 27

Finding 6

The comment read: "It is NOT a messaging channel: portal rows carry no source_channel_chat_id, so every channel consumer (the completion-report resolver map, voice_reply_service's supported set) already ignores it." Both halves are false as of this PR — portal rows carry the session id, and _CHANNEL_RESOLVERS has a "portal" entry. It now states what is actually true, which is narrower than either version: a real destination for the completion-report leg, not one for outbound voice — and it says why voice_reply_service still declines (the Workspace narrates client-side, #2157), so the next reader does not infer that the stamp is inert. Your framing was right: it would have argued against the feature sitting above it.

Finding 8

Six cases now drive report_completion end to end, stubbing only the two edges the module does not own — the execution row and the portal DB — and letting the gate ordering, the recipient guard and the sanitizer run for real:

  • a delegated terminal reaching the thread (the happy path nothing covered);
  • a credential-shaped failure string proven absent from what is persisted;
  • a report whose inherited client disagrees with the session refusing before any write;
  • a NULL source_channel_client failing closed;
  • the inline turn still refused at the gate — proven where it runs, not by asserting the constant's membership;
  • a vanished session writing nothing.

Because your objection was that the existing tests restate the source, I mutated the source and re-ran rather than asserting they are better:

--- sanitizer removed from _portal_body ---
FAILED test_end_to_end_the_body_is_sanitized_before_it_is_persisted
--- recipient guard removed from _resolve_portal ---
FAILED test_end_to_end_a_report_for_another_client_never_writes
FAILED test_end_to_end_a_row_with_no_recorded_client_fails_closed
--- restored ---  27 passed

66 pass across test_ent457_* and test_2157_portal_narration.

Finding 5 — deliberately deferred, and why

The reply-detection collision is real and I have not fixed it. PortalConversation decides a reply arrived by an assistant-row count delta and this resolver is a second writer of those rows, so a delegated child finishing mid-turn can have its report returned as the parent's answer. Discriminating needs a column enterprise_portal_messages does not have; overloading role was rejected because two server-side readers and the client's rendering all branch on it. It is stated as a known limitation at the resolver and in architecture.md rather than left for the next reader to discover — but it is a deferral, not a resolution, and it is the one thing here I would not want read as closed.

Same for finding 4's underlying gap: the claim is downgraded everywhere, the idle poll is not built.

…report-back

# Conflicts:
#	docs/memory/learnings.md

@obasilakis obasilakis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review of the ent#457 commits since the last round. Five of the six prior findings are genuinely fixed, and I re-derived each from the current source rather than reading the commit subjects. One is fixed in half the places it needed to be, which is why this is still requesting changes — it is a two-line documentation edit.

Prior findings — verdicts

1. _portal_body skipped credential sanitization. FIXED.
channel_completion_report.py:98-104 now takes _summarize's shape exactly: sanitize_text over raw[:_MAX_REPORT_CHARS * 2], then truncate. The 2x window matters and is preserved, so a secret cannot be cut such that the redaction pattern stops matching before the sanitizer runs.

2. Nothing bound the destination session to the client the work was for. FIXED, and at the right layer.
The new schedule_executions.source_channel_client column rides the inheritance chain, and _resolve_portal compares it against the session row's own client_email via _norm_email before delivering. It fails closed on NULL, so every pre-column row refuses rather than delivering unverified. That closes it at the resolver rather than at the ent#265 guard, which is the correct place given the guard checks the agent and the exposure was cross-client within one agent.

Dual-track is honoured: SQLite channel_report_client in migrations.py plus Alembic 0048_channel_report_client, and the column is nullable with no default.

3. An exception in deliver() released the effect-guard claim. FIXED.
The two writes are wrapped and return False on failure, matching both sibling resolvers and the module's stated at-most-once bias. Finding 7 rides along — asyncio.to_thread(_write) gets the sync SQLAlchemy writes off the event loop, which matters more here than in portal_chat because this runs from a fire-and-forget task.

5. The report is a second writer of assistant rows. DEFERRED, correctly.
Recorded as a Known Limitation in the _resolve_portal docstring, with the real fix named (a per-row execution_id, i.e. a dual-track migration) and the cheap alternative explicitly rejected with a reason (overloading role is read by two server-side readers and the client's rendering). That is the deliberate decision I asked for rather than a silent carry-forward.

6. config.py contradicted the code. FIXED. 8. No end-to-end test through report_completion. FIXED.

Blocking

4. The "Workspace polls its threads" claim is downgraded in two of the four places it was asserted

Fixed in the _resolve_portal docstring and in architecture.md:228 — both now say plainly that the Workspace does not poll, that the client sees the report at their next reload or thread switch, and that an idle history poll is a tracked follow-up. That wording is exactly right.

Still asserting the false claim:

  • docs/memory/feature-flows/channel-completion-report.md:243"No new transport: the Workspace polls its threads, so 'degrades to poll' holds"
  • tests/unit/test_2157_portal_narration.py:309-311"the assistant message the client reads on the next poll … AC #7's 'degrades to poll' is the only mode it has"

Neither is true. PortalConversation.vue loads history on mount and on a prop change; stores/clientPortal.js states outright that refreshThreads() is event-driven and not periodic; the only interval in the Workspace is the 20s asks poll in views/Portal.vue:816, which calls fetchAsks() and nothing else.

These two are the ones that matter most of the four. The flow doc is what the next person builds on, and a test docstring reads as verified rather than as claimed — a green suite next to that sentence is what makes the gap durable. Same edit as the other two places.

Merge-order dependency, not a defect

0048_channel_report_client and #2384's 0047_workspace_ratings both declare down_revision = "0046_report_audience". Whichever merges second forks the graph into two heads, and alembic upgrade head resolves its target before applying anything — so zero revisions apply on PostgreSQL, silently, including every revision merged since the fork. Not just the offending one.

This PR's migration docstring documents the situation and names check_alembic_heads as the loud failure. Two things worth adding out loud, because the note lives here and the risk does not:

  • #2384 carries no such note and is already approved, so the ordering knowledge exists in only one of the two PRs that need it.
  • check_alembic_heads fires once both revisions are on one branch. It does not fire on either PR in isolation, which is exactly why both are green right now.

Whichever lands second needs a re-parent or an alembic merge revision before merge, not after.

What holds up

Reading the recipient from the session row rather than the inherited stamp remains the right call, and the new client check is layered on top of it rather than replacing it — both the thread and the person are now verified from the platform's own records. INLINE_CHANNEL_TRIGGERS gaining "public" is still provably inert for public links and x402, since neither stamps a source_channel_chat_id. The end-to-end test through report_completion is the one that closes findings 1 and 3 against regression, and it was the right test to add — both were invisible to the previous suite precisely because it exercised _portal_body and _resolve_portal directly.

Verdict

Requesting changes on finding 4 only, and only for the two remaining places. Everything else is either fixed or deliberately deferred with the reasoning written down where the next reader will find it.

…nd converge the migration line (ent#457)

Review finding 4, the half that was left. The claim was downgraded in the
`_resolve_portal` docstring and in `architecture.md`, and left standing in the
two places the reviewer rightly called the ones that matter most:

- `channel-completion-report.md` — "the Workspace polls its threads, so
  'degrades to poll' holds by construction". It is what the next person builds
  on.
- `test_2157_portal_narration.py` — "the assistant message the client reads on
  the next poll … AC #7's 'degrades to poll' is the only mode it has". A
  sentence in a test docstring reads as VERIFIED rather than as claimed, and a
  green suite beside a false one is what makes the gap permanent.

Neither is true: `PortalConversation.vue` loads history on mount and on a prop
change, `stores/clientPortal.js` says outright that `refreshThreads()` is
event-driven rather than periodic, and the Workspace's only interval is the 20s
asks poll, which fetches asks and nothing else. Both now say what actually
happens — the row is durable and arrives at the client's next reload or thread
switch — and neither claims the delivery the follow-up poll would provide.

## The migration fork, now decidable

0047 (#2384, ent#366) and this 0048 both declared
`down_revision = "0046_report_audience"`. That is a fork, and a fork is worse
than it looks: `alembic upgrade head` is singular and resolves its target BEFORE
applying anything, so two heads apply ZERO revisions — not merely the offending
one, but everything merged since the fork — and PostgreSQL boots on a schema
that has silently stopped advancing. `check_alembic_heads` cannot catch it on
either PR alone, which is why both were green.

#2384 merged first, so the order is no longer hypothetical: 0047 is on `dev` and
may already be applied, while this revision is applied nowhere. Re-parenting the
unapplied one onto the applied one converges the line and needs no merge
revision — `alembic merge` is for the case where BOTH forked revisions may exist
in some database, and taking it here would leave a permanent extra node for
nothing. Verified: `check_alembic_heads` goes from FAIL (2 heads, forking at
0046) to PASS (1 head).

Merges `origin/dev`, which also brings 0047 and its SQLite twin; both migration
functions are registered and both ledger entries kept.

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

dolho commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Finding 4 closed in the two remaining places — e3cd6183

Both now say what actually happens instead of what would be convenient:

  • channel-completion-report.md — the "the Workspace polls its threads, so 'degrades to poll' holds by construction" sentence is gone. It now records that PortalConversation.vue loads history on mount and on a prop change, that stores/clientPortal.js states refreshThreads() is event-driven rather than periodic, and that the only interval is the 20s asks poll which fetches asks alone — so the row is durable and arrives at the client's next reload or thread switch, and the idle poll is named as the follow-up rather than claimed.
  • test_2157_portal_narration.py — same correction, and it says why it is being made there: a sentence in a test docstring reads as verified rather than as claimed, and a green suite beside a false one is what makes the gap permanent. That was your argument and it is the right one.

I also swept the tree for the other spellings; the remaining hits are unrelated subsystems (host telemetry, the scheduler firing list).

The migration fork — resolved, not just documented

You were right that the ordering knowledge lived in only one of the two PRs. It is now decidable: #2384 merged first (2026-08-27 11:16), so 0047 is on dev and may already be applied, while 0048 is applied nowhere.

That makes re-parenting the correct move rather than alembic merge: the unapplied revision moves onto the applied one and the line converges with no permanent extra node. alembic merge is for the case where both forked revisions may already exist in some database, which is not this one. 0048 now declares down_revision = "0047_workspace_ratings", and the docstring records the reasoning — including the part worth keeping: two heads apply zero revisions, not just the offending one, so everything merged since the fork stops arriving and PostgreSQL boots on a schema that silently stopped advancing.

Verified rather than assumed — check_alembic_heads before and after:

FAIL — resolves to 2 heads across 49 revision(s); exactly 1 is required.
  • 0047_workspace_ratings
  • 0048_channel_report_client
They fork at: 0046_report_audience
                    ↓
PASS — 49 revision(s), 1 head (0048_channel_report_client)

This also merges origin/dev, which brings 0047 and its SQLite twin; both migration functions are registered in the runner list and both ledger entries were kept.

121 pass across test_ent457_*, test_2157_portal_narration, test_schema_parity and test_alembic_revision_id_length.

@dolho

dolho commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Ready for re-review — the requested changes are pushed

Flagging state rather than adding findings: the CHANGES_REQUESTED on this PR predates the work that answers it. The review landed 08:39–08:41 UTC; the fix commit is 12:24–12:33 UTC. GitHub does not clear the label on a push, so it reads as outstanding when it is not.

What landed: finding 4 corrected in the two remaining places (the flow doc and the test_2157 docstring), and the Alembic fork resolved — 0048 re-parented onto 0047 now that #2384 merged first, taking check_alembic_heads from FAIL (2 heads) to PASS (1 head).

The fuller response is in the comment above this one. Nothing further is pending from my side.

(Disclosure: I wrote the fix commit on this PR, so this note is a status flag — not a self-review and not an approval.)

@obasilakis obasilakis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

/validate-pr re-review — approving. Clearing my earlier CHANGES_REQUESTED.

I re-derived the outstanding finding against the current branch source. It is fixed, and the surrounding work has held up under three rounds.

The remaining finding — the Workspace does not poll — is fixed by removing the claim

_resolve_portal's docstring no longer asserts that the client sees the report while they watch. It now states what is actually true: PortalConversation.vue loads history on mount and on an agent/session prop change, stores/clientPortal.js says outright that refreshThreads() is event-driven rather than periodic, and the only interval in the Workspace is the 20s asks poll on a different surface. So the row is durable and never lost, the client sees it at their next reload or thread switch, and AC #7's "degrades to poll" does not hold by construction here. Naming the follow-up (an idle history poll, with the asks poll as the precedent) rather than quietly leaving the claim in is the right resolution — the alternative was code that reads as though a guarantee exists.

The mid-turn known limitation is stated the same way and for the same reason: PortalConversation decides a reply arrived from assistants.length > baseline and returns the last assistant row, which assumed portal_chat was the only writer of assistant rows in a session, and this resolver is now a second one. Documenting it with the reason a proper fix needs a dual-track migration (enterprise_portal_messages carries no execution_id) beats overloading role, which three readers branch on.

The security work from the earlier rounds, re-checked

  • source_channel_client and the recipient check. The session id says which thread; it does not say the work was for the person who owns it, and _inherited_channel_context only compares the agent. An agent shared with clients X and Y could have cited one of X's portal executions while serving Y — same agent, guard passes, and a body the agent chose lands in X's permanent thread. _norm_email returning empty for anything unusable is what stops "unknown equals unknown" reading as agreement, and failing closed on NULL is right for every pre-column row.
  • Rejecting source_user_email as the carrier because routers/public_memory.py reads it to decide whose MEM-001 blob a turn writes into. That is the kind of overload that would have been invisible until someone's memory went to the wrong place.
  • _sanitized_detail extracted rather than copied. The failure call sites pass raw text — _write_terminal_and_gate passes error, apply_result's failure branch passes envelope.error, and only the success branch passes something already sanitized — so a traceback carrying a key was being written verbatim into an external client's permanent thread. Sanitising over a 2x window before truncating, and deciding truncation on what was actually cut rather than against raw, are both the #1578 chokepoint's rules and both matter.
  • get_portal_session_by_id deliberately unscoped, with the reason in the docstring and a standing instruction that any future caller justify the same. An unscoped session read in a request-serving path is an IDOR waiting to be written; saying so at the definition is where that belongs.
  • "public" added to INLINE_CHANNEL_TRIGGERS. A Workspace turn is synchronous and portal_chat persists its own reply, so without this every chat message gains a duplicate "done". Public links and x402 share the trigger and carry no chat id, so they never reach the check.

Schema and migration

Dual-track is complete and correct: db/migrations.py, Alembic 0048_channel_report_client, plus db/schema.py and db/tables.py so fresh builds stay right. Nullable with no default, so no backfill and no lock.

The re-parenting onto 0047_workspace_ratings is right and the note explaining it is worth keeping. Both revisions were written against 0046_report_audience while each PR was open, which is a fork — and alembic upgrade head is singular and resolves its target before applying anything, so two heads apply zero revisions, not just the offending one. Since #2384 merged first and this revision is not applied anywhere yet, re-parenting the unapplied one converges the line without a merge node. I verified the chain resolves to a single head, and pg-migrations is green.

Also worth noting the config.py comment was rewritten rather than left: ent#457 changed what PORTAL_SOURCE_CHANNEL means, the old comment asserted the opposite in both halves, and the replacement states the split honestly — a real destination for the completion-report leg, not one for outbound voice, with no single answer covering both.


Checklist: base dev, ent#457 resolves in the private tracker with correct labels, security scan clean (the only secret-shaped hit is the synthetic all-A fixture in the sanitizer test, which is the test proving the sanitizer works), the submodule pointer bump was correctly dropped, architecture and the channel-completion-report flow both updated, learnings.md carries the paired-write entry. CI green including pg-migrations and prod-image-smoke.

Two notes, neither blocking:

_migrate_channel_report_client ends with no conn.commit() and no blank-line separation before _migrate_workspace_ratings. Harmless — run_all_migrations commits after each migration function and after the schema_migrations insert — but every sibling in that file commits explicitly, and the missing separator makes the new function read as though it bleeds into the next one.

Cross-tracker close is manual. Fixes abilityai/trinity-enterprise#457 would be wrong here since this is AC #3 only and the card work is gated on the design pass, so the absence of a closing keyword is correct. Just remember ent#457 needs its status-in-dev set by hand — the automation is same-repo only.

@dolho
dolho merged commit 56c0404 into dev Aug 28, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

complexity-high Complexity: high (board points 13) priority-p1 Critical path theme-ui-ux Theme: UI/UX type-feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants