Mirror tool-call metadata into Temporal Cloud from the telemetry seam, allowlisted and fire-and-forget #887

Closed
opened 2026-08-17 04:18:27 +00:00 by coilyco-ops · 1 comment
Member

Outcome

Deep emits a durable record of its tool-call trajectory into Temporal Cloud, carrying metadata only, hooked at the same place that starts OTel spans.

Design settled with Kai 2026-08-16. This is mirroring, not orchestration. Temporal observes the turn, it does not run it.

Why this shape rather than the one in #430

#430 records a decision to make the turn a workflow and every tool call an activity. That is control-flow integration and it carries two hazards this design does not:

  • Retry multiplication. A Temporal activity retry policy stacked on the agent-proxy fallback for sirens-echo/deepseek compounds, turning a 502 into a slow expensive 502. Mirroring keeps Temporal out of the control path, so nothing compounds.
  • Latency in the user-visible turn. An activity round trip per tool call sits in front of a Discord user. An async mirror does not. Dead air is the demo failure mode.

Mirroring is also the more honest claim. "We mirror our agent's tool-call trajectory into Temporal so there is a durable audit record" is true and specific. "Our harness runs on Temporal" would not have been.

The seam

internal/community/telemetry.go:

func (t *Telemetry) StartSpan(ctx, name, attributes ...attribute.KeyValue)

StartSpan is content-blind by construction. The only attribute it adds is sirens_echo.job.id. That makes it a safe place to hook.

It is a variadic passthrough, so it is not a safe place to copy from. Every other attribute comes from callers. Mirroring the attribute slice means mirroring whatever a caller passed, sight unseen, forever.

Requirements

  1. Allowlist the emitted keys. Never pass through the attribute slice. The failure this prevents: someone later adds attribute.String("message.text", ...) to a span, and Discord member content starts flowing to a third-party SaaS with no change at the mirror and nothing to notice it. #385 already ruled that the disclosure footer never echoes tool arguments, for this same class of reason.

  2. Use the existing metadata vocabulary as the allowlist. The Record* functions are already curated and carry zero content:

    • RecordToolCall - mcp.server.name, mcp.tool.name, outcome
    • RecordTurn, RecordModelCall - outcome
    • RecordAdmission - outcome, transport
    • RecordFailure - stage
    • RecordAccess - reason
    • StartJobSpan - job.id, job.kind, job.transport, job.attempt

    RecordToolCall's triple plus timing is the payload this issue wants.

  3. Never await it, never let it raise. StartSpan is on the hot path for every span in the harness. A mirror that blocks or propagates an error becomes a new way for turns to die silently, which is the shape of #137 and #190. Fire and forget, hard timeout, swallow the error, and count the drops so the silence is measurable.

  4. Key off tool calls, not every span. For scale reference, agent-proxy logged roughly 68,900 http receive spans against about 4,300 real requests over 30 days. Hooking span-start indiscriminately points a firehose at a service that bills per action, and #431 already records per-turn cost being understated about ninefold.

  5. No message content, no tool arguments, no tool results, no principal identifiers. #310 and #180 are open on principal-ID disclosure. Nothing in this mirror should be capable of carrying one.

Scope boundary

  • Deep only. Echo is out of scope.
  • The isolated owl-glass surface, not the demo Discord instance, until this is proven.
  • Deployment wiring is tracked separately in coilyco-bridge/deploy.
  • Credential provisioning is #444.

Acceptance

  • A tool call produces a Temporal Cloud record carrying server name, tool name, outcome, and timing.
  • Grepping the mirrored payload finds no message text, tool arguments, tool results, or user identifiers.
  • An induced Temporal outage leaves turns unaffected, and the dropped-mirror count is observable.
  • Mirror volume is roughly one action per tool call rather than one per span.
  • Adding a content-bearing attribute to any span does not change what the mirror emits.

Next owner

Engineer.

## Outcome Deep emits a durable record of its tool-call trajectory into Temporal Cloud, carrying **metadata only**, hooked at the same place that starts OTel spans. Design settled with Kai 2026-08-16. This is **mirroring, not orchestration.** Temporal observes the turn, it does not run it. ## Why this shape rather than the one in #430 https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/430 records a decision to make the turn a workflow and every tool call an activity. That is control-flow integration and it carries two hazards this design does not: * **Retry multiplication.** A Temporal activity retry policy stacked on the agent-proxy fallback for `sirens-echo/deepseek` compounds, turning a 502 into a slow expensive 502. Mirroring keeps Temporal out of the control path, so nothing compounds. * **Latency in the user-visible turn.** An activity round trip per tool call sits in front of a Discord user. An async mirror does not. Dead air is the demo failure mode. Mirroring is also the more honest claim. "We mirror our agent's tool-call trajectory into Temporal so there is a durable audit record" is true and specific. "Our harness runs on Temporal" would not have been. ## The seam `internal/community/telemetry.go`: ```go func (t *Telemetry) StartSpan(ctx, name, attributes ...attribute.KeyValue) ``` `StartSpan` is content-blind by construction. The only attribute it adds is `sirens_echo.job.id`. That makes it a safe place to hook. **It is a variadic passthrough, so it is not a safe place to copy from.** Every other attribute comes from callers. Mirroring the attribute slice means mirroring whatever a caller passed, sight unseen, forever. ## Requirements 1. **Allowlist the emitted keys. Never pass through the attribute slice.** The failure this prevents: someone later adds `attribute.String("message.text", ...)` to a span, and Discord member content starts flowing to a third-party SaaS with no change at the mirror and nothing to notice it. https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/385 already ruled that the disclosure footer never echoes tool arguments, for this same class of reason. 2. **Use the existing metadata vocabulary as the allowlist.** The `Record*` functions are already curated and carry zero content: * `RecordToolCall` - `mcp.server.name`, `mcp.tool.name`, `outcome` * `RecordTurn`, `RecordModelCall` - `outcome` * `RecordAdmission` - `outcome`, `transport` * `RecordFailure` - `stage` * `RecordAccess` - `reason` * `StartJobSpan` - `job.id`, `job.kind`, `job.transport`, `job.attempt` `RecordToolCall`'s triple plus timing is the payload this issue wants. 3. **Never await it, never let it raise.** `StartSpan` is on the hot path for every span in the harness. A mirror that blocks or propagates an error becomes a new way for turns to die silently, which is the shape of #137 and #190. Fire and forget, hard timeout, swallow the error, and count the drops so the silence is measurable. 4. **Key off tool calls, not every span.** For scale reference, agent-proxy logged roughly 68,900 `http receive` spans against about 4,300 real requests over 30 days. Hooking span-start indiscriminately points a firehose at a service that bills per action, and #431 already records per-turn cost being understated about ninefold. 5. **No message content, no tool arguments, no tool results, no principal identifiers.** #310 and #180 are open on principal-ID disclosure. Nothing in this mirror should be capable of carrying one. ## Scope boundary * Deep only. Echo is out of scope. * The isolated owl-glass surface, not the demo Discord instance, until this is proven. * Deployment wiring is tracked separately in `coilyco-bridge/deploy`. * Credential provisioning is https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/444. ## Acceptance * A tool call produces a Temporal Cloud record carrying server name, tool name, outcome, and timing. * Grepping the mirrored payload finds no message text, tool arguments, tool results, or user identifiers. * An induced Temporal outage leaves turns unaffected, and the dropped-mirror count is observable. * Mirror volume is roughly one action per tool call rather than one per span. * Adding a content-bearing attribute to any span does not change what the mirror emits. ## Next owner Engineer.
Author
Member

The identity field defeats requirement 5, and the allowlist cannot see it

Recorded by Olaf (ops seat). Found by leaking one, so this is demonstrated rather than theorized.

While probing Write permission against coilyco.gcdqf I started and terminated a throwaway workflow. Its termination event:

{
  "type": "workflowExecutionTerminatedEventAttributes",
  "reason": "ops write-permission probe, cleanup",
  "identity": "temporal-cli:kai@kais-macbook-pro-2.local"
}

A username and a hostname, written into a third-party SaaS, by a command that passed no such thing.

Why this defeats the design as written

Requirement 1 says allowlist the emitted keys and never pass the attribute slice through. Requirement 2 anchors that allowlist to the curated Record* vocabulary. Both are correct and neither helps here.

identity is not an attribute. It is SDK-level metadata that Temporal stamps onto workflow starts, signals, activity completions, and terminations, underneath whatever payload the caller constructs. An allowlist over RecordToolCall's mcp.server.name, mcp.tool.name, and outcome never sees it, because it is not in the same layer.

The consequence lands directly on the acceptance criteria:

Grepping the mirrored payload finds no message text, tool arguments, tool results, or user identifiers.

That criterion passes while the record carries a host identifier. The payload is clean. The envelope is not. A check written against the payload confirms exactly the wrong thing, and confirms it convincingly.

What Deep would actually emit

The Go SDK defaults Identity to <pid>@<hostname>. In sirens-deep the hostname is the pod name, so every mirrored record would carry something shaped like:

1@sirens-deep-5b6c89959c-87sq2

Not a user identifier, so requirement 5's exact wording survives. It is a host identifier and a ReplicaSet hash, which #310 and #180 are open about as a class, and which the fleet safety rule against host identifiers in durable artifacts covers directly. It also silently republishes cluster topology to a third party on every tool call.

The fix

Set Identity explicitly in the client options to a fixed constant, for example sirens-deep-mirror. One line, and it must be deliberate because the default is the leak.

Suggested additions to acceptance, since the current wording would not have caught this:

  • The mirror sets a constant Identity and never the SDK default.
  • Grepping the mirrored event, envelope included rather than payload alone, finds no hostname, pod name, username, or principal identifier.

The second matters more than the first. The current criterion is scoped to the payload, which is the one place the design already guarantees is clean.

Housekeeping

The probe workflow ops-write-probe-1 is terminated. I attempted to delete its history and could not confirm the deletion - temporal workflow delete hung with no output and the execution still lists as Terminated. It ages out with the namespace's 720h retention. The leaked value is a laptop hostname rather than anything operational, so this is untidy rather than harmful, but it is not cleaned up and should not be recorded as such.

## The `identity` field defeats requirement 5, and the allowlist cannot see it Recorded by Olaf (ops seat). Found by leaking one, so this is demonstrated rather than theorized. While probing Write permission against `coilyco.gcdqf` I started and terminated a throwaway workflow. Its termination event: ```json { "type": "workflowExecutionTerminatedEventAttributes", "reason": "ops write-permission probe, cleanup", "identity": "temporal-cli:kai@kais-macbook-pro-2.local" } ``` A username and a hostname, written into a third-party SaaS, by a command that passed no such thing. ## Why this defeats the design as written Requirement 1 says allowlist the emitted keys and never pass the attribute slice through. Requirement 2 anchors that allowlist to the curated `Record*` vocabulary. Both are correct and neither helps here. **`identity` is not an attribute.** It is SDK-level metadata that Temporal stamps onto workflow starts, signals, activity completions, and terminations, underneath whatever payload the caller constructs. An allowlist over `RecordToolCall`'s `mcp.server.name`, `mcp.tool.name`, and `outcome` never sees it, because it is not in the same layer. The consequence lands directly on the acceptance criteria: > Grepping the mirrored payload finds no message text, tool arguments, tool results, or user identifiers. **That criterion passes while the record carries a host identifier.** The payload is clean. The envelope is not. A check written against the payload confirms exactly the wrong thing, and confirms it convincingly. ## What Deep would actually emit The Go SDK defaults `Identity` to `<pid>@<hostname>`. In `sirens-deep` the hostname is the pod name, so every mirrored record would carry something shaped like: ``` 1@sirens-deep-5b6c89959c-87sq2 ``` Not a user identifier, so requirement 5's exact wording survives. It is a host identifier and a ReplicaSet hash, which https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/310 and https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/180 are open about as a class, and which the fleet safety rule against host identifiers in durable artifacts covers directly. It also silently republishes cluster topology to a third party on every tool call. ## The fix Set `Identity` explicitly in the client options to a fixed constant, for example `sirens-deep-mirror`. One line, and it must be deliberate because the default is the leak. Suggested additions to acceptance, since the current wording would not have caught this: * The mirror sets a constant `Identity` and never the SDK default. * Grepping the mirrored **event**, envelope included rather than payload alone, finds no hostname, pod name, username, or principal identifier. The second matters more than the first. The current criterion is scoped to the payload, which is the one place the design already guarantees is clean. ## Housekeeping The probe workflow `ops-write-probe-1` is terminated. **I attempted to delete its history and could not confirm the deletion** - `temporal workflow delete` hung with no output and the execution still lists as `Terminated`. It ages out with the namespace's 720h retention. The leaked value is a laptop hostname rather than anything operational, so this is untidy rather than harmful, but it is not cleaned up and should not be recorded as such.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo#887
No description provided.