From 270cd00129551647e7c764a13836e03e0b2dc4e2 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 18 Jun 2026 14:15:55 -0600 Subject: preserve signature origins across compactions --- libpanto/src/conversation.zig | 59 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) (limited to 'libpanto/src/conversation.zig') diff --git a/libpanto/src/conversation.zig b/libpanto/src/conversation.zig index 4338563..2c63f19 100644 --- a/libpanto/src/conversation.zig +++ b/libpanto/src/conversation.zig @@ -258,6 +258,16 @@ pub const Message = struct { /// commits, read back off the `Message` after `load`. Borrowed; owned by /// whoever set it (the conversation's allocator on the replay path). metadata: ?[]const u8 = null, + /// The wire-format provider identity (`api_style`/`base_url`/`model` and + /// the reasoning/thinking knobs) that actually produced this message. + /// Stamped once, when the message is first persisted, and from the disk + /// stamp on replay. Preserved verbatim through compaction's clone, so a + /// kept-verbatim turn keeps the identity of the model that generated it + /// rather than being re-stamped with the compaction model. This is the + /// per-message source of truth for thinking-signature replay — no need to + /// pin the identity onto each thinking block. When set, the `base_url` + /// and `model` slices are owned by the conversation's allocator. + identity: ?config.WireIdentity = null, pub fn deinit(self: *Message, alloc: Allocator) void { for (self.content.items) |*block| { @@ -265,9 +275,29 @@ pub const Message = struct { } self.content.deinit(alloc); if (self.metadata) |m| alloc.free(m); + if (self.identity) |id| freeWireIdentity(alloc, id); } }; +/// Duplicate a `WireIdentity`'s owned slices (`base_url`, `model`) into +/// `alloc`; scalar fields are copied as-is. The result owns its strings and +/// must be released with `freeWireIdentity`. +pub fn dupeWireIdentity(alloc: Allocator, id: config.WireIdentity) !config.WireIdentity { + const burl = try alloc.dupe(u8, id.base_url); + errdefer alloc.free(burl); + const mdl = try alloc.dupe(u8, id.model); + var out = id; + out.base_url = burl; + out.model = mdl; + return out; +} + +/// Free the owned slices of a `WireIdentity` produced by `dupeWireIdentity`. +pub fn freeWireIdentity(alloc: Allocator, id: config.WireIdentity) void { + alloc.free(id.base_url); + alloc.free(id.model); +} + pub const Conversation = struct { messages: std.ArrayList(Message) = .empty, allocator: Allocator, @@ -396,6 +426,35 @@ pub const Conversation = struct { } }; +/// Whether a thinking block's opaque signature may be replayed to a request +/// targeting `(api_style, base_url, model)`. A signature is portable only +/// back to the exact endpoint/model that produced it; replaying it elsewhere +/// is rejected (Anthropic) or meaningless (OpenAI encrypted reasoning). +/// +/// Provenance is resolved per block first (`signature_origin`, set live by +/// the producing provider), then falls back to the enclosing message's +/// `identity` (the per-message source of truth that survives compaction). A +/// block with no signature, or with no provenance from either source, is not +/// replayable — sending a signature to an unverified endpoint is unsafe. +pub fn thinkingSignatureMatches( + block: ThinkingBlock, + msg_identity: ?config.WireIdentity, + api_style: config.APIStyle, + base_url: []const u8, + model: []const u8, +) bool { + if (block.signature == null) return false; + if (block.signature_origin) |origin| { + return origin.matches(api_style, base_url, model); + } + if (msg_identity) |id| { + return id.api_style == api_style and + std.mem.eql(u8, id.base_url, base_url) and + std.mem.eql(u8, id.model, model); + } + return false; +} + pub fn setThinkingOrigins( allocator: Allocator, blocks: []ContentBlock, -- cgit v1.3