The two IDs
protoWorkstacean uses two IDs to track causality across the bus, the executor layer, and external A2A calls:
| ID | Header | Bus field | Meaning |
|---|---|---|---|
correlationId | X-Correlation-Id | msg.correlationId | Trace ID — identifies the entire logical flow. Never changes. Set once at the entry point. |
parentId | X-Parent-Id | msg.parentId | Span ID — identifies the immediate cause. Changes at each layer boundary. |
This is the same mental model as OpenTelemetry traceId / parentSpanId. The implementation is lightweight (plain strings in bus messages and HTTP headers) but the semantics are equivalent.
Where correlationId is set
correlationId is set once, at message entry:
- DiscordPlugin: generates
crypto.randomUUID()for each @mention - GitHubPlugin: uses the GitHub delivery ID
- POST /publish: caller provides it, or the HTTP handler generates one if omitted
- SchedulerPlugin: generates one per cron fire
Once set, correlationId must not be changed. Every downstream bus message, every A2A call, every executor invocation carries the same value.
Where parentId is set
parentId is the span boundary. It changes at each meaningful processing step:
RouterPlugin publishes
agent.skill.requestwithparentId = inboundMessage.id. The inbound message'sidis the parent span of the skill dispatch.SkillDispatcherPlugin constructs a
SkillRequestwithparentId = busMessage.id. This is the critical linkage: theSkillRequestknows which bus message caused it.A2AExecutor sends
X-Parent-Id: req.parentIdto the external agent. The receiving service should record this as its parent span and generate its own span ID for its own work.Agents that respond via
agent.skill.response.<correlationId>carry the samecorrelationId. They may include their own span information in the response payload, but the bus message'sid(a new UUID generated by the bus) becomes theparentIdfor any subsequent messages triggered by the response.
Flow example: Discord mention → protoPen → response
Discord @mention arrives
bus message {
id: "msg-001",
correlationId: "trace-abc",
parentId: undefined, ← entry point, no parent
topic: "message.inbound.discord.1234"
}
RouterPlugin publishes:
bus message {
id: "msg-002",
correlationId: "trace-abc",
parentId: "msg-001", ← Discord message is the parent
topic: "agent.skill.request",
payload: { skill: "sitrep", parentId: "msg-001", ... }
}
SkillDispatcherPlugin constructs SkillRequest:
{
skill: "sitrep",
correlationId: "trace-abc",
parentId: "msg-002", ← skill.request bus message is the parent
replyTopic: "agent.skill.response.trace-abc"
}
A2AExecutor calls the protoPen server:
POST /a2a
Headers:
X-Correlation-Id: trace-abc
X-Parent-Id: msg-002
X-API-Key: ...
Body: { contextId: "trace-abc", ... }
protoPen processes, publishes response on agent.skill.response.trace-abc:
bus message {
id: "msg-003",
correlationId: "trace-abc",
parentId: "msg-002", ← the A2A response is a child of the skill request
topic: "agent.skill.response.trace-abc"
}
DiscordPlugin posts the reply.The full chain is reconstructable from the log: msg-003 → msg-002 → msg-001 all share trace-abc.
contextId in A2A responses
The A2A spec uses contextId in the response to carry the trace ID back. A2AExecutor sends contextId: req.correlationId in the request. External A2A agents (protoPen, ORBIS, protoContent) propagate this value in the response. workstacean reads it and confirms the response belongs to the right trace.
If the contextId in the response does not match req.correlationId, the response is a stale reply from a previous conversation and is discarded.
Why not use OpenTelemetry directly
OpenTelemetry is the right long-term answer. The current implementation uses plain strings because:
- The bus is the primary observability layer — every message is written to
data/events.dbwith its full payload, andcorrelationId+parentIdare indexed columns - Adding an OTel exporter is a future concern; the IDs are already there when that work happens
- The A2A protocol boundary (HTTP headers) is already compatible with OTel trace context —
X-Correlation-Idmaps totraceparent's trace-id,X-Parent-Idmaps to the parent-id field
The implementation was deliberately kept simple to avoid a dependency on an OTel SDK before the instrumentation requirements are known.