Mirroring a federated peer's notes: what blocks it

Status: problem statement, not a plan. Nothing here is built. No decision taken.

The goal

A note carries a map in frontmatter — sync.<kb_id>: { <remote_note_path>: <local_path> }
— and something walks it, pulling each remote note from a federated peer into the
local instance as plain local markdown, assets included, so links keep working.

The natural first shape was a fleet role: sync.* is config, the walk is
mechanical, and the fleet already writes notes back with a delivery-scoped token.

What is NOT a problem (ruled out by reading the code)

Federated calls are not privileged. builtinToolHandlers()
(internal/case/mcp/resolve.go:195-228) registers federated_search,
federated_similar, federated_note_html and federated_expand with no
authorization wrapper. Only graphql_introspection and graphql_request are
gated on mcpAdminToolsEnabled. Any caller that authenticates at all — API key,
personal token, even anonymous within its own read scope — can make federated
calls.

The fleet already holds a credential that works. /_system/mcp resolves
t2g_* personal tokens through req.UserToken() (internal/case/mcp/endpoint.go:80,
internal/appreq/request.go:215-243), and the fleet is started with
--trip2g-admin-personal-token. So the fleet process can call
federated_note_html today with zero server-side change. A role would reach it
through a fleet-side tool (remote_read(kb_id, path) or similar) implemented on
the admin lane — the same trust boundary remoteKB.Write/.Patch already use,
where the token lives in the Go host and the sandbox only ever sees the
agentruntime.KB interface. Cost of that: any role holding such a tool reads
every peer with the fleet's authority, so it needs a per-role allowlist. The
sync map is the natural place to declare it.

Writing notes and assets locally is already solved. updateNotes returns
updated[] { path, versionId } (internal/graph/schema.graphqls:1871), and
uploadNoteAsset already enforces scoped-token write_patterns
(internal/case/uploadnoteasset/resolve.go:46, TestResolve_ScopedToken). The
delivery token covers both.

pass_api_key is a misleading name. It does not pass an API key. It makes the
delivery mint payload.api_token, a shortapitoken signed with
ShortAPITokenSecret carrying depth and read/write patterns
(internal/case/backjob/delivercronwebhook/resolve.go:139,
deliverchangewebhook/resolve.go:96). The fleet hardcodes it on for both webhook
kinds (cmd/fleet/internal/fleet/reconcile.go:202,277).

That token is not one of the three credential types /_system/mcp understands —
it is stamped only in the GraphQL middleware (internal/graph/handler.go:163) —
so it reaches /_system/graphql and nothing else. That is a fact about which
lane the role's own credential can use, not a missing permission.

Problem 1 — markdown does not cross federation

handleNoteHTML returns string(note.HTML) (internal/case/mcp/resolve.go:769).
There is no format parameter; model.MCPNoteHTMLParams
(internal/model/mcp_params.go:48) has no such field.

The GraphQL side does not help either. federated_graphql_request restricts peers
to note, search, similarNotes, viewer
(internal/case/mcp/graphql_tools.go:65-70), and the note root field returns
PublicNote (internal/graph/schema.graphqls:1522), which carries html but
neither content nor assetReplaces. notePaths is excluded from the allowlist
on purpose — it returns unfiltered paths.

There is no raw-markdown HTTP route anywhere on the public surface.

The peer holds everything needed: model.NoteView (internal/model/note.go:161-213)
has Content []byte and AssetReplaces sitting next to the HTML that is already
served. PublicNote is declared
@goExtraField(name: "NoteView", type: "*model.NoteView"), so the object already
carries the struct — exposing markdown is a resolver, not data plumbing.

Options

Option Cost Notes
Gated content + assets on PublicNote S Field + resolver off obj.NoteView + gqlgen regen; gate on CurrentFederatedScope(ctx) so the anonymous web never sees it. No new MCP method, federation_protocol.md untouched, multi-hop rides the existing graphql hop routing. Requires MCPFederatedGraphQLEnabled on the peer (default off).
New federated_note_markdown MCP tool M Six Go files (model/federation.go, internal/federation/client.go, mcp/tools.go, mcp/resolve.go, mcp/federation_handlers.go, mcp/endpoint.go) plus docs; widens the third-party adapter contract. Precedent for size: federated_expand. Only advantage: does not need the graphql flag on the peer.
A format param on note_html Reject. An older peer silently ignores an unknown JSON field and returns HTML where markdown was asked for. A new method name fails loud.
HTML → markdown on the consumer Reject. Frontmatter is gone, wikilinks/callouts/embeds are already rendered, asset URLs have to be re-derived by parsing HTML.

The ACL story is the same for the first two: canReadMCPNote
(internal/case/mcp/resolve.go:401) routes a federated caller through
canreadnote.ResolveWithSubgraphs, which allows note.Free or an
intersection between the note's SubgraphNames and the peer's grant. Federation
already serves non-public content; markdown adds exactly one new thing over HTML
frontmatter, which is where operational config lives (routes, webhook
config, role config, the sync maps themselves). Bounded, but real, and it should
be a deliberate choice rather than a side effect.

Problem 2 — asset bytes do not cross federation

Two halves, both missing:

Enumeration. The list lives on NoteView.assetReplaces { id url hash absolutePath },
which is not on PublicNote. Solved for free if Problem 1 is solved by carrying
an asset list in the same payload.

Bytes. GET /_system/assets/{sha256}/{fileName} (internal/case/serveasset)
serves anonymously only when an owning note is publicly readable; a valid
X-API-Key grants access outright; otherwise a session plus CanReadNote is
required. There is no federation branch. So a note shared with a peer by subgraph
grant has readable text and unreachable images — inconsistent with what federation
already allows for HTML.

Options

Option Cost Notes
Anonymous content-addressed GET none Works only for publicly readable notes.
Sealed peer X-API-Key on the fetching side none serveasset honours a valid API key outright. Coarse (full read on the peer) but operationally honest while one operator runs both instances.
Federation branch on serveasset M Accept the federation credential and gate via ResolveWithSubgraphs, same check canReadMCPNote already does. Keeps streaming, content-addressing and immutable caching. See the caveat below.
Short-lived signed asset URLs minted by the peer M Peer returns per-asset URLs with temporary access inside the note payload. Keeps serveasset free of federation identity; adds a signing/expiry scheme and a way to verify it.
Base64 bytes inside the MCP result Reject. internal/federation/client.go:23 caps responses at 1 MB (defaultMaxResponseBody) with a 2s per-request timeout; base64 adds ~33%. serveasset deliberately streams to avoid buffering.
Hotlink remote URLs without copying Rejected against the stated goal: the mirror stops being self-contained, and private assets 403 for local readers.

Caveat on the federation branch. The federation JWT has no aud claim
(internal/case/mcp/federation_helpers.go:78-84); its claims are iss/iat/exp/rid/bh,
and bh binds the token to the request-body digest. bodyDigest returns empty
for an empty body (:351-357) and verifyInbound:156 tolerates an empty bh by
design. A body-less GET therefore gets a bearer with a 30-second replay window
and no binding. On top of that, verifyInbound does a
ListFederationSecretSubgraphsByKID lookup — a database hit per asset request, on
a route built around immutable caching that deliberately skips api_key_log
because it is hot. Workable, but it is new auth surface, not free reuse.

Problem 3 — the sync.<kb_id>: {...} frontmatter shape does not survive transport

Only relevant if the walker is a fleet role; a server-side job reads RawMeta
directly and is unaffected.

noteViewResolver.Meta (internal/graph/schema.resolvers.go:3032-3040) emits
NoteViewMeta { key, raw } with Raw: fmt.Sprintf("%v", value) over top-level
RawMeta keys. There is no dot-flattening anywhere. A nested YAML map arrives as
Go's map print — map[remote/a.md:local/a.md] — unquoted and unordered, and not
recoverable once a key or value contains a space, comma or colon. Discovery then
copies it into a flat map[string]string
(cmd/fleet/internal/fleet/discovery.go:69-71) before ParseRole ever sees it.

This already bit once: cmd/fleet/internal/fleet/role.go:276-288 carries a lossy
fallback parser with the comment "trip2g's note meta.raw renders a YAML list as
space-joined inside brackets"
. Nested maps are the same disease, one step worse.

Ways out:

  • Author the map as a single flow/JSON string under one flat key
    (sync_kb: '{"remote/a.md":"local/a.md"}'). Zero code.
  • JSON-encode non-scalar values in the resolver, leaving scalars byte-for-byte.
    Nothing in the frontend breaks: the only consumer of meta.raw is
    assets/ui/admin/noteview/graph/graph.view.ts:303, which groups graph nodes by
    a scalar field. A list-valued grouping label would change from [a b] to
    ["a","b"].
  • JSON-encode everything including scalars. Cleaner typing, but that one admin
    line then needs a parse-with-fallback, since a scalar would arrive quoted.

Either encoding change also implies Role.Frontmatter becoming
map[string]interface{} and touching discovery.go, render.go (Jet vars) and
the input bag.

Open question raised but not resolved

A third shape for Problem 1 + 2 together: a single new federation method — note
/ federated_note — returning markdown and asset links carrying temporary
access, so one call answers both halves and serveasset never learns about
federation. Not priced here. It sits between the two Problem-1 options (it is a
new MCP method, so it widens the adapter contract) and the signed-URL row of
Problem 2.

Host: role or server-side job

Not decided. Both consulted models independently argued for a server-side mirror
job over a fleet role: the walk is deterministic, there is no LLM judgment
anywhere in it, and a role pays tokens per run for a mechanical copy. The job
would also be the consumer loop that docs/dev/2026-07-02_federation_subscription.md
already describes, and it makes Problem 3 disappear.

The counter-argument is that the fleet needs no new credential and no new server
query to reach federation (see above), so a role is not blocked on anything a job
would not also need.

A hybrid was floated: a server-side job produces raw local copies, and a fleet
role transforms local→local afterwards, knowing nothing about federation.

What any first version would deliberately not support

Multi-hop asset fetch (markdown hops through graphql routing; assets are
single-hop or public-only), hide/delete propagation, conflict handling on locally
edited mirrors (one-way overwrite), assets served by non-trip2g adapters, and any
content transformation on ingest.