Skip to content

fix(daemon): fence pool-store transcripts on the owning org - #1075

Merged
zfy0701 merged 1 commit into
mainfrom
fix/transcript-org-fence
Aug 16, 2026
Merged

fix(daemon): fence pool-store transcripts on the owning org#1075
zfy0701 merged 1 commit into
mainfrom
fix/transcript-org-fence

Conversation

@zfy0701

@zfy0701 zfy0701 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Item 7 of #1041, the last one open: the daemon pool's shared store had an org-fenced
transcript pair in agentconnect_data_plane that was constructed and never read or
written, while every transcript actually went to the LocalStore mirror tables
transcript / transcript_recipient, which carried no org_id at all. On a pool store
those two tables are shared by every org the pool serves, and platform channel/thread ids
are unique only inside one org — so one org's INSERT OR IGNORE could swallow another
org's message on the same (channel, thread, ts), and a (channel, thread) read could
serve one org's rows to another's console.

This is option B from the issue's decision comment: the live tables gain the fence, and
the never-used data-plane pair is deleted so exactly one store carries it.

The decision

  • transcript and transcript_recipient gain orgId (NOT NULL); it joins the
    transcript_recipient primary key and prefixes every transcript index.
  • Writes resolve it from the agent: the row's recipient, else its sender, else the
    agent that made the thread live. A store no pool shares holds one daemon's threads, so it
    owns one partition forever ('') and its behaviour is unchanged. A shared store resolves
    through the daemon's agent registry (orgForAgent, the same resolver the deleted store
    used) and refuses a row it cannot attribute rather than filing it where anyone may read it
    — a shared LocalStore now requires that resolver at construction.
  • Reads that keyed on (channel, thread) key on (org, channel, thread). The org comes
    from the calling agent; the callers were already agent-scoped, so the only new parameters
    are on the four reads that had dropped the agent on the floor (transcriptSince,
    transcriptSinceRevision, threadTranscriptRevision, transcriptTextAt). No
    placement-kind branch anywhere.
  • One read stays deliberately unfenced and says so in a comment: telegramThreadForMessage
    runs before routing names an agent. Its key is the physical-bot-scoped transcript
    channel — one integration owns that bot, one org owns that integration — and it returns a
    thread id, never content.
  • TranscriptEntry.orgAgentId is attribution only, never a column and never a delivery, for
    the two writers whose rows carry a platform author and no recipient (an observed inbound,
    and provider history replayed into a turn refresh).

Migration and backfill

SCHEMA_VERSION 10 → 11, one new SCHEMA_MIGRATIONS step appended after #1068's. It adds
the columns, drops the five transcript indexes so the CREATE block re-emits them org-first,
and rebuilds transcript_recipient (its primary key gains the org, which ALTER TABLE
cannot do).

Backfill splits by store:

  • A store no pool shares keeps every row: it holds one daemon's threads, so the added
    DEFAULT '' is the backfill, deliveries copy across, and reads see exactly what they saw
    before.
  • A shared store drops its transcript rows. Nothing in the store records an agent's org —
    the daemon learns it from the CP at runtime — so the sessions → agent → org join resolves to
    nothing at migration time, and a kept row would be readable by whichever org reused the
    channel/thread ids. Only test data exists on any shared store today, which is why dropping
    is the accepted price of the fence; the step's comment records that.

The step is plain SQL through the PostgreSQL rewrite path (postgres-store-worker.js): no
window functions, no aliased subqueries. orgId is added to the canonical column list in
postgres-sync-database.ts so read-back maps orgid home.

Deleting the unused fence

PostgresDataPlane.transcripts and PostgresTranscriptStore are gone, together with their
unit and integration tests; the module is renamed postgres-data-plane.ts, since what it
still holds is the pool member's store, not a transcript store. The data-plane schema gets a
second migration dropping transcript, transcript_recipient and
transcript_revision_seq, so an installed data plane gives the tables back rather than
keeping them forever. The pg pool and migrateDataPlaneSchema stay for exactly that reason,
which the class comment now states. docs/designs/k8s-daemon-pool.md §11 pointed at the
deleted file; it now describes what shipped.

Test plan

  • local-store — two orgs holding the same channel/thread key stay independent across
    pages, catch-up, first-message titles, tool bodies, tool updates and the thread revision
    fence; an unattributable row is refused, and a session in the thread is enough to attribute
    an observed inbound.
  • local-store — migration from a v10-shaped fixture: a store no pool shares keeps its rows
    and gains the org-first recipient primary key; a shared store drops them. The v1/v5/v7
    fixtures rewind both fix(daemon): own the session-metadata outbox per member #1068's outbox columns and this step's transcript columns, so each
    still lands on 11.
  • postgres-migrations — the data-plane list drops the tables on an installed schema and
    never creates them on a fresh one.
  • postgres-transcript-org.int (new, DATA_PLANE_TEST_DATABASE_URL) — the same two-org
    isolation through the real SQLite → PostgreSQL rewrite, across two pool members.
  • Run against a throwaway postgres:16-alpine: the pool-store and transcript-org integration
    suites on a fresh schema, plus a manual in-place v10 → v11 upgrade — orgid lands
    NOT NULL DEFAULT '', the recipient primary key rebuilds as
    (orgid, channel, thread, ts, agentid), transcript_text_ts re-emits org-first, the
    shared store's unattributable rows are dropped, and a post-upgrade write reads back through
    the fence. The data-plane drop migration was verified the same way on a schema rewound to
    version 1.
  • pnpm --filter @agentconnect.md/daemon typecheck, the full daemon suite (acp-matrix
    excluded — it dials live runtimes), pnpm lint, pnpm format:check.

Closes #1041.

The daemon pool's shared store keyed transcripts on (channel, thread) alone,
while the org-fenced pair the data plane declared was constructed and never read
or written. Platform channel and thread ids are unique only inside one org, so on
a pool store one org's INSERT OR IGNORE could swallow another org's message on
the same coordinates, and a (channel, thread) read could serve one org's rows to
another org's console.

transcript and transcript_recipient gain orgId (NOT NULL): it joins the recipient
primary key and prefixes every transcript index. Writes resolve it from the row's
recipient, else its sender, else the agent that made the thread live; a store no
pool shares owns one partition forever and behaves exactly as before, while a
shared store resolves through the daemon's agent registry and refuses a row it
cannot attribute. Reads key on (org, channel, thread), taking the org from the
calling agent — the callers were already agent-scoped, and no placement-kind
branch is involved. telegramThreadForMessage stays unfenced by design and says
so: it runs before routing names an agent, its key is the physical-bot-scoped
channel one org owns, and it returns a thread id rather than content.

Schema version 11 adds the columns, rebuilds transcript_recipient for its new
primary key, and drops the transcript indexes so the CREATE block re-emits them
org-first. A store no pool shares keeps every row on its single partition; a
shared store drops its transcript rows, because nothing in the store records an
agent's org and only test data exists on any shared store today.

The data plane's own transcript pair goes away, with a second data-plane
migration dropping the tables and the sequence, so one store carries the fence.

@agentconnect-md-test agentconnect-md-test Bot 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.

Approved — no blocking findings at 92d69c0c84f043e73a9c926e4be606a171d0577c.

The live LocalStore transcript tables now carry the org fence consistently through uniqueness/index keys, write attribution, content-bearing reads, agent-scoped pages/tails, revision cursors, and tool-body lookups. The pre-ingress CP registry barrier supplies the shared store's resolver, local single-owner stores retain their existing partition behavior, and the v9→v10 store migration plus removal of the unused data-plane transcript pair are coherent. The deliberately unfenced Telegram lookup remains limited to a physical-bot-scoped thread-ID locator and does not return transcript content.

Non-blocking rollout warning: if a v9 pool member remains live after a v10 member migrates the shared schema, that old member can still omit orgId and write into the column's default '' partition; v10 org-scoped reads will not see those rows. Given the stated active-development/test-only shared-store state, I do not consider this a blocker, but a coordinated pool drain/restart avoids the mixed-version window.

Verification: git diff --check passed; daemon typecheck passed; 197 focused store, migration, thread-context, and session-manager tests passed. GitHub's current Build, Check, Unit Test, both Integration test shards, and image/sandbox checks are green. A broader local daemon sweep was limited by this review sandbox's /dev/null and child-process execution denials, unrelated to the patch.

sent by review-bot (Codex · gpt-5.6-sol) · open in session

@zfy0701
zfy0701 force-pushed the fix/transcript-org-fence branch from 92d69c0 to 4b4a26f Compare August 16, 2026 04:09
@zfy0701
zfy0701 merged commit 385d1f1 into main Aug 16, 2026
10 of 11 checks passed
@zfy0701
zfy0701 deleted the fix/transcript-org-fence branch August 16, 2026 04:15
@agentconnect-md-test

Copy link
Copy Markdown
Contributor

The PR merged before the formal review could be submitted.

One blocking issue remains in packages/daemon/src/store/local-store.ts:1556: threadSessionAgent can select an arbitrary organization when multiple orgs have sessions with the same channel/thread. An unattributed recordUnrouted message can consequently be stored under the wrong org and disappear from the owning org’s catch-up context.

The ingress-owned org/agent should be carried into this path, or ambiguous fallback attribution should be rejected.

Verification: daemon typecheck and 198 focused tests passed; GitHub checks were green.

sent by review-bot (Codex · gpt-5.6-sol) · open in session

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant