Skip to content

feat(temporal): validate and encode at the wire edges with the canonical contract - #296

Open
imaustink wants to merge 5 commits into
feat/proto-contractfrom
feat/temporal-canonical-protocol
Open

imaustink wants to merge 5 commits into
feat/proto-contractfrom
feat/temporal-canonical-protocol

Conversation

@imaustink

@imaustink imaustink commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

Stack 4/6 — based on #295 (proto contract) ← #294 ← #293.

⚠️ Deviation from the plan: edge adoption, not a struct swap

The plan said "swap the engine's hand-written mirrors for the generated Go types." Doing that wholesale would have been unsafe. messaging.Event, agentrun.UpMessage and TurnEvent aren't just parsing types: they are Temporal signal payloads, activity results and workflow state, so their JSON is recorded in workflow history. Replacing them with generated structs would:

  • change how in-flight workflows decode on replay. Prod runs this engine, so that would affect live conversations on deploy.
  • route each tool's result through google.protobuf.Value, which reorders keys, reformats numbers and rounds integers past 2^53 before the model sees them

So the engine adopts the contract at its wire edges:

Edge Before Now
Tool callback (gateway/callback.go → ParseEvent) hand-written Event.Validate() (drifted) messaging.CheckContract (generated protocolv1.Event + protovalidate), then the same encoding/json decode as before
Agent up-message (agentrun bridge) json.Unmarshal only contract gate first; violations are dropped (same outcome an undecodable message always had)
Agent down-message (bridge publish) json.Marshal(DownMessage) built as protocolv1.AgentDownMessage, validated, protojson-encoded

The contract decides validity; the existing structs carry what passed. Raw result bytes pass through untouched (pinned by a test).

Bugs this fixed (each pinned by a test that fails on the old code)

  • Fractional pct dropped the whole message. UpMessage.Pct was *int, so an agent reporting 42.5 was "undecodable". It's now *float64, which still decodes old history ints.
  • An empty prompt was published without message. omitempty dropped it, and the agent's schema requires it.
  • UpMessage encoded a non-final reply without final. omitempty again.

Behavior tightening (please sanity-check)

Both edges now enforce the full contract, exactly as the LangGraph engine already does. Its callback receiver and nats-agent-channel.ts drop anything failing the zod schemas, and the #295 conformance fixtures hold zod and the proto contract identical.

Tool events (callbacks): url on accepted and stage on progress. JobEmitter.accepted(url) / progress(stage) take these as required arguments.

Agent up-messages (NATS bridge). Before, the Go bridge forwarded these with missing fields zero-valued; now it drops them. The required fields per type:

type required
progress / warning message
reply message, final
failed code, message
tool_call callId, tool, input
opencode_response requestId, status
session_idle liveUntil
session_ended reason

No real producer can omit these. The agent SDK and opencode-swe-agent (the only direct producers) build every up-message through the typed AgentUpMessage, so a missing field is a compile error. TestEveryValidUpMessageTypePassesTheGate pins all 12 shapes they emit: each reaches the workflow, concluding ones are acked, narration isn't. A dropped concluding message is not acked, so the agent re-offers it until its reply-ack timeout and then gives up, the same as if the bridge were unreachable.

Only test fixtures were non-conformant, and they're updated.

How the engine gets the contract

It requires github.com/imaustink/agent-controller/framework/protocol/go by version from GitHub (no replace), the same way a third-party orchestrator would. Image builds with context engines/temporal (skaffold, and your external pipeline) are unchanged. Verified by building cmd/worker + cmd/gateway from a copy of the directory outside the repo.

📌 After #295 merges, bump this to main's commit:
cd engines/temporal && go get github.com/imaustink/agent-controller/framework/protocol/go@main && go mod tidy
It currently points at #295's branch commit (c8a087c), which the Go proxy has already cached.
For local cross-changes to the .proto and the engine, use an uncommitted go work or a temporary replace.

Dependency bumps from MVS after adding protovalidate-go 1.4: testify 1.11→1.12, x/text, x/sync, yaml, genproto (all patch/minor).

Not changed

TurnEvent has no external wire edge in this engine yet: it lives in workflow state and query results. Its internal skill_id tags stay, because changing them would affect continue-as-new payloads. When ADR 0004 B2 exposes events to clients, encode through protocolv1.TurnEvent there. The generated decoder already accepts the old snake_case spelling (a #295 fixture covers it).

Verification

gofmt clean · go vet ./... clean · go test ./...: all 16 packages pass · the new tests fail against the pre-change files (checked by swapping them back in).

🤖 Generated with Claude Code

https://claude.ai/code/session_012JeZxPBqi4AMN2YXFrQcYz

…cal contract

The engine now judges every inbound wire message by the canonical contract
(framework/protocol) instead of its own hand-written rules, which had drifted
from the TypeScript side: tool callbacks (ParseEvent) and agent up-messages
must pass messaging.CheckContract before they are decoded, and down-messages
are built as protocolv1.AgentDownMessage and encoded through it.

The engine's protocol structs stay. They are Temporal signal payloads,
activity results and workflow state, recorded in history, so swapping them
for generated types would change how in-flight workflows decode on replay;
and passing a tool result through google.protobuf.Value would reorder its
keys and round large integers before the model saw them. The contract
decides validity; the existing structs carry what passed.

Fixes found on the way:
- an agent's fractional `pct` made its up-message undecodable (*int); it is
  now a float and is signalled
- an empty prompt was published without `message` (omitempty), which the
  agent rejects; prompts now always carry it
- UpMessage encoded a non-final reply without `final`

The contract module is consumed by version from GitHub, as a third party
would, so image builds whose context is engines/temporal are unchanged
(verified by building a copy of the directory in isolation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JeZxPBqi4AMN2YXFrQcYz
@k5s-bot

k5s-bot Bot commented Oct 8, 2026

Copy link
Copy Markdown

To continue, please link your Claude Remote Control account. This is a one-time step -- send any message once you're done.

@k5s-bot k5s-bot Bot removed the ai-review label Oct 8, 2026
@k5s-bot

k5s-bot Bot commented Oct 10, 2026

Copy link
Copy Markdown

🤖 Starting work on this now.

@k5s-bot

k5s-bot Bot commented Oct 10, 2026

Copy link
Copy Markdown

🤖 Watch live or take over the session here: https://claude.ai/code/session_01RiD6p8jBWwFWZ1edGpqKNs

@k5s-bot k5s-bot Bot removed the ai-review label Oct 10, 2026
Comment on lines +121 to +124
// The wire contract decides validity (see messaging.CheckContract); UpMessage
// is only how a valid message is carried into the workflow. Dropping an
// invalid message is the same outcome an undecodable one has always had.
if err := messaging.CheckContract(data, &protocolv1.AgentUpMessage{}); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The comment's claim — "the same outcome an undecodable one has always had" — holds only for reply/failed. For every other up-type it's a new, silent drop of messages that used to flow: before this change handleUp did json.Unmarshal only, which never failed on missing optional fields, so the message was forwarded. Running the contract (protocolv1.AgentUpMessage + protovalidate) now rejects, and drops:

  • progress / warning → require message
  • session_idle → requires liveUntil
  • session_ended → requires reason
  • tool_call → requires callId, tool, input
  • failed → requires code and message

(verified by running protovalidate against each type.) The "behavior tightening" section of the PR only lists the event/callback side (url on accepted, stage on progress) — it doesn't mention these up-message requirements, and they aren't covered by a positive test (the gate tests only exercise reply and the invalid cases).

Two concrete failure modes if a real SDK message ever omits one of these:

  • Non-concluding types (progress, warning, session_idle, session_ended, tool_call) are dropped permanently — only reply/failed are held-and-reacked, so there's no re-offer; the narration/lifecycle event is simply lost.
  • A failed missing code/message is dropped and, being concluding, never acked, so the agent re-offers it forever and the turn never concludes — strictly worse than the pre-change "forward it" behavior.

Please confirm the agent SDK always populates all of these for every up-type, and add positive gate tests for the non-reply types (tool_call, session_idle, session_ended, ready, opencode_event). If SDK compliance can't be guaranteed, consider logging-but-forwarding (or at least not silently dropping) non-concluding up-messages.

🤖 Prompt to fix this with an AI agent
In imaustink/agent-controller on PR #296, the up-message contract gate in
engines/temporal/internal/agentrun/bridge.go (handleUp, around line 124,
messaging.CheckContract against protocolv1.AgentUpMessage) now silently drops
up-messages that the pre-change code forwarded. protovalidate requires:
message on progress/warning, liveUntil on session_idle, reason on
session_ended, callId/tool/input on tool_call, code+message on failed.
Non-concluding messages that fail are lost with no re-offer; a failed missing
code/message is dropped yet never acked, so the agent re-offers forever and the
turn never concludes.
1) Verify (against the agent SDK in the framework/ TS sources) that every
   up-message type always carries the fields the contract requires. If any type
   can legitimately omit a field, change handleUp so non-concluding up-messages
   that fail the contract are logged and still forwarded (or otherwise not
   silently dropped), while keeping the gate for concluding reply/failed.
2) Add positive bridge tests that a valid ready, tool_call, session_idle,
   session_ended and opencode_event up-message passes the gate and is signalled
   to the workflow. Do not change unrelated behavior.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Thanks — the comment was wrong and the description incomplete; both fixed in 8bbeba2. I kept the gate rather than log-and-forward, for three reasons:

  1. It's the LangGraph engine's existing behavior. agent-orchestrator/src/agents/nats-agent-channel.ts (decode) drops any up-message failing AgentUpMessageSchema, and feat(protocol): canonical .proto wire contract + TS/Go codegen + conformance suite #295's conformance fixtures hold that zod schema and the proto contract identical (no divergences in agent_up.json). Forwarding would make the two engines disagree again.
  2. No real producer can send an invalid one. The only producers — the agent SDK (runtime.ts) and opencode-swe-agent — build every up-message through the typed AgentUpMessage, so a missing message/liveUntil/reason/callId… is a compile error.
  3. The failure mode is bounded: a dropped failed isn't re-offered forever — the agent gives up at its reply-ack timeout, as it would if the bridge were down.

Added TestEveryValidUpMessageTypePassesTheGate: all 12 shapes those producers emit (ready, progress ×2, warning, reply ×2, failed, tool_call, opencode_event, opencode_response, session_idle, session_ended) reach the workflow; concluding ones are acked, narration isn't. Mutation-tested (gating against the wrong message type fails all 12). The PR description's behavior-tightening section now lists every up-message requirement.

@k5s-bot k5s-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed the edge-adoption approach — it's the right call, and the three bug fixes (Pct → *float64, final/message no longer omitempty) are each well-targeted and test-pinned. I verified locally: go test ./internal/{agentrun,messaging,gateway}/... passes, and the result-bytes-pass-through test confirms the up-path preserves large integers / key order.

One substantive concern, left inline: the new handleUp contract gate silently drops up-messages that the pre-change code forwarded, for more types than the "behavior tightening" section lists (progress/warning need message, session_idle needs liveUntil, session_ended needs reason, tool_call needs callId/tool/input). For non-concluding types this is unrecoverable (no re-offer), and a failed missing code/message would be dropped-but-never-acked, stalling the turn. Worth confirming SDK compliance across all up-types and adding positive gate tests.

I also checked the down tool_result path (result routed through structpb.Value): it's fine in practice because ToolResult always json.Marshals the result as a string, so the Value only ever holds a StringValue — no integer rounding or key reordering. No change needed there.

Not approving/blocking — just flagging the one item to sanity-check.

@k5s-bot

k5s-bot Bot commented Oct 10, 2026

Copy link
Copy Markdown

Review posted to PR #296.

Summary

I checked out feat/temporal-canonical-protocol, read the full diff against its base feat/proto-contract, and verified behavior locally (the affected agentrun/messaging/gateway packages all pass; I also probed the generated protocolv1 validation rules directly to confirm claims).

Verdict: The edge-adoption design is sound, and the three bug fixes (Pct → *float64, dropping omitempty on final and message) are correct and each pinned by a test that genuinely exercises the old failure.

One substantive issue, posted inline at bridge.go:124: the new handleUp contract gate silently drops up-messages that the old json.Unmarshal-only path forwarded. protovalidate on AgentUpMessage requires more than the PR's "behavior tightening" section lists — message on progress/warning, liveUntil on session_idle, reason on session_ended, callId/tool/input on tool_call, code+message on failed. For non-concluding types a failing message is lost with no re-offer; a failed missing code/message is dropped yet never acked, so the agent re-offers forever and the turn stalls. The inline comment carries a copy-pasteable agent prompt to verify SDK compliance and add positive gate tests.

One thing I explicitly cleared (noted in the summary, no change needed): the down tool_result path routes result through structpb.Value, which would normally reorder keys / round large integers — but Bridge.ToolResult…


✅ Opened pull request: imaustink/agent-controller#296

imaustink and others added 4 commits October 10, 2026 06:14
Review feedback: the bridge's up-message gate rejects more than the PR
described (message on progress/warning, liveUntil on session_idle, reason on
session_ended, callId/tool/input on tool_call, code+message on failed), and
only reply and invalid cases were tested.

Keeps the gate, because it is what the LangGraph engine already does: its
nats-agent-channel drops any up-message failing AgentUpMessageSchema, and the
conformance fixtures hold that schema and the proto contract identical. Every
real producer (the agent SDK and opencode-swe-agent) builds up-messages
through the typed AgentUpMessage, so the required fields are compile-time
guaranteed. Adds a positive test that all 12 up-message shapes those
producers emit reach the workflow (concluding ones acked, narration not), and
corrects the handleUp comment, which wrongly called the drop pre-existing
behavior and implied an unacked message is re-offered forever (the agent
gives up at its reply-ack timeout).

Mutation-tested: gating against the wrong message type fails all 12 cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012JeZxPBqi4AMN2YXFrQcYz
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant