mirrors-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "uri"
5
+ require_relative "snapshot"
6
+ require_relative "agent_context"
7
+
8
+ module MirrorsSDK
9
+ # Canonical evidence and receipt bindings shared by capture, retrieval and heartbeat.
10
+ class Wire
11
+ MODES = %w[active shadow off].freeze
12
+ BASE64 = /\A(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?\z/.freeze
13
+
14
+ def self.encode(value, limit: 1_000_000, deadline: nil)
15
+ Snapshot.encode(value, limit: limit, deadline: deadline)
16
+ end
17
+
18
+ def self.id(value)
19
+ unless value.instance_of?(String) && value.match?(/\A[A-Za-z0-9_.-]{1,128}\z/)
20
+ raise ArgumentError, "Invalid Mirrors identifier"
21
+ end
22
+ value
23
+ end
24
+
25
+ def self.utc(value)
26
+ unless value.instance_of?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\z/)
27
+ raise ArgumentError, "Canonical UTC timestamp required"
28
+ end
29
+ parsed = Time.iso8601(value).utc
30
+ raise ArgumentError, "Invalid UTC date" unless parsed.iso8601(3) == value
31
+
32
+ parsed.to_f
33
+ end
34
+
35
+ def self.snapshot(trace, final: false, terminal: nil, deadline: nil)
36
+ Snapshot.check_deadline(deadline)
37
+ unless (true.equal?(final) || false.equal?(final)) &&
38
+ (nil.equal?(terminal) || (final && Snapshot::CLASS.bind(terminal).call == String &&
39
+ %w[completed failed canceled].include?(String.new(terminal))))
40
+ raise ArgumentError, "Invalid execution ending"
41
+ end
42
+ value = JSON.parse(encode(trace, deadline: deadline))
43
+ Snapshot.check_deadline(deadline)
44
+ fields(value, %w[id messages], %w[conversationId conversationRevision model entryPoint completeness user turnLatencyMs agentContext])
45
+ id(value["id"])
46
+ messages(value["messages"], deadline)
47
+ AgentContextWire.validate(value["agentContext"]) if value.key?("agentContext")
48
+ if value.key?("conversationId") || value.key?("conversationRevision")
49
+ id(value["conversationId"])
50
+ revision = value["conversationRevision"]
51
+ unless revision.instance_of?(Integer) && revision.between?(1, Snapshot::SAFE_INTEGER)
52
+ raise ArgumentError, "Conversation identity requires a positive safe integer revision"
53
+ end
54
+ end
55
+ { "model" => 200, "entryPoint" => 256 }.each do |field, limit|
56
+ text(value[field], limit) if value.key?(field)
57
+ end
58
+ completeness(value["completeness"]) if value.key?("completeness")
59
+ end_user(value["user"]) if value.key?("user")
60
+ if value.key?("turnLatencyMs")
61
+ latency = value["turnLatencyMs"]
62
+ unless final && [Integer, Float].include?(latency.class) && latency.finite? && latency >= 0
63
+ raise ArgumentError, "Turn latency requires a final snapshot and finite nonnegative value"
64
+ end
65
+ end
66
+ Snapshot.check_deadline(deadline)
67
+ value
68
+ end
69
+
70
+ def self.lifetime(value)
71
+ ((utc(value["expiresAt"]) - utc(value["issuedAt"])) * 1000).round / 1000.0
72
+ end
73
+
74
+ def self.mode_response(value, agent_id)
75
+ value.instance_of?(Hash) && value["protocol"].instance_of?(Integer) &&
76
+ value["protocol"] == 1 && value["agentId"] == agent_id && MODES.include?(value["mode"]) &&
77
+ !!id(value["version"]) && dates(value, 3)
78
+ rescue ArgumentError, TypeError
79
+ false
80
+ end
81
+
82
+ def self.context_response(value, request)
83
+ return false unless response_binding(value, request, 1) && echo(value, request, "shadowOnly") &&
84
+ !!id(value["modeVersion"]) && !!text(value["reason"], 128) &&
85
+ dates(value, 5) && value.key?("context")
86
+
87
+ context = value["context"]
88
+ context.nil? || (context.instance_of?(String) && context.bytesize <= 32_768 &&
89
+ value["mode"] == "active" && !request["shadowOnly"])
90
+ rescue ArgumentError, TypeError, KeyError
91
+ false
92
+ end
93
+
94
+ def self.trace_response(value, request)
95
+ return false unless response_binding(value, request, 3) &&
96
+ %w[ordinal final terminal captureOnly].all? { |field| echo(value, request, field) } &&
97
+ value.key?("context") && value.key?("ingestion")
98
+
99
+ utc(value["expiresAt"])
100
+ context = value["context"]
101
+ if !context.nil? && (!context.instance_of?(String) || context.bytesize > 32_768 ||
102
+ value["mode"] != "active" || request["final"] || request["captureOnly"])
103
+ return false
104
+ end
105
+ ingestion = value["ingestion"]
106
+ return !request["final"] if ingestion.nil?
107
+ return false unless request["final"]
108
+
109
+ fields(ingestion, %w[status jobId])
110
+ (ingestion["status"] == "queued" && !!id(ingestion["jobId"])) ||
111
+ (ingestion["status"] == "unsupported_history" && ingestion["jobId"].nil?)
112
+ rescue ArgumentError, TypeError, KeyError
113
+ false
114
+ end
115
+
116
+ def self.fields(value, required, optional = [])
117
+ unless value.instance_of?(Hash) && (required - value.keys).empty? &&
118
+ (value.keys - required - optional).empty?
119
+ raise ArgumentError, "Invalid canonical fields"
120
+ end
121
+ value
122
+ end
123
+
124
+ def self.text(value, limit = nil)
125
+ unless value.instance_of?(String) && (!limit || AgentContextWire.length(value).between?(1, limit))
126
+ raise ArgumentError, "Invalid canonical text"
127
+ end
128
+ value
129
+ end
130
+
131
+ def self.messages(value, deadline)
132
+ unless value.instance_of?(Array) && value.length <= 10_000
133
+ raise ArgumentError, "Invalid message collection"
134
+ end
135
+ value.each do |message|
136
+ Snapshot.check_deadline(deadline)
137
+ fields(message, %w[role content], %w[metadata])
138
+ unless %w[system user assistant tool].include?(message["role"])
139
+ raise ArgumentError, "Invalid message role"
140
+ end
141
+ content = message["content"]
142
+ next if content.instance_of?(String)
143
+ unless content.instance_of?(Array) && content.length <= 1000
144
+ raise ArgumentError, "Invalid message content"
145
+ end
146
+ content.each do |item|
147
+ Snapshot.check_deadline(deadline)
148
+ block(item)
149
+ end
150
+ end
151
+ end
152
+
153
+ def self.block(value)
154
+ raise ArgumentError, "Invalid canonical block" unless value.instance_of?(Hash)
155
+
156
+ case value["type"]
157
+ when "text", "reasoning"
158
+ fields(value, %w[type text], %w[metadata])
159
+ text(value["text"])
160
+ when "tool-call", "tool-result"
161
+ call = value["type"] == "tool-call"
162
+ required = call ? %w[type callId name arguments] : %w[type callId name value error]
163
+ fields(value, required, call ? %w[metadata providerExecuted] : %w[metadata])
164
+ id(value["callId"])
165
+ text(value["name"], 128)
166
+ flag = call ? "providerExecuted" : "error"
167
+ if value.key?(flag) && value[flag] != true && value[flag] != false
168
+ raise ArgumentError, "Invalid tool flag"
169
+ end
170
+ when "media"
171
+ media(value)
172
+ else
173
+ raise ArgumentError, "Unsupported canonical block"
174
+ end
175
+ end
176
+
177
+ def self.media(value)
178
+ fields(value, %w[type kind source], %w[metadata mediaType filename])
179
+ raise ArgumentError, "Invalid media kind" unless %w[image file].include?(value["kind"])
180
+
181
+ source = fields(value["source"], %w[encoding value])
182
+ contents = text(source["value"])
183
+ if source["encoding"] == "url"
184
+ uri = URI.parse(contents)
185
+ unless %w[http https].include?(uri.scheme) && uri.hostname && !uri.hostname.empty?
186
+ raise ArgumentError, "Invalid media URL"
187
+ end
188
+ elsif source["encoding"] != "base64" || !contents.match?(BASE64)
189
+ raise ArgumentError, "Invalid media encoding"
190
+ end
191
+ %w[mediaType filename].each { |field| text(value[field]) if value.key?(field) }
192
+ rescue URI::InvalidURIError
193
+ raise ArgumentError, "Invalid media URL"
194
+ end
195
+
196
+ def self.completeness(value)
197
+ fields(value, %w[history original_content ended])
198
+ unless %w[complete truncated].include?(value["history"]) &&
199
+ %w[complete partial].include?(value["original_content"]) &&
200
+ %w[completed abandoned unknown].include?(value["ended"])
201
+ raise ArgumentError, "Invalid source completeness"
202
+ end
203
+ end
204
+
205
+ def self.end_user(value)
206
+ fields(value, %w[id], %w[segment])
207
+ { "id" => 256, "segment" => 64 }.each do |field, limit|
208
+ next unless value.key?(field)
209
+
210
+ trimmed = text(value[field]).gsub(AgentContextWire::TRIM, "")
211
+ unless AgentContextWire.length(trimmed).between?(1, limit) && !trimmed.match?(/\p{Cc}/)
212
+ raise ArgumentError, "Invalid end user"
213
+ end
214
+ value[field] = trimmed
215
+ end
216
+ end
217
+
218
+ def self.dates(value, maximum)
219
+ duration = lifetime(value)
220
+ duration > 0 && duration <= maximum && utc(value["issuedAt"]) <= Time.now.to_f + 0.1
221
+ end
222
+
223
+ def self.echo(value, request, field)
224
+ value.key?(field) == request.key?(field) && value[field].class == request[field].class &&
225
+ value[field] == request[field]
226
+ end
227
+
228
+ def self.response_binding(value, request, protocol)
229
+ value.instance_of?(Hash) && value["protocol"].instance_of?(Integer) && value["protocol"] == protocol &&
230
+ value["agentId"] == request["agentId"] && value["traceId"] == request.fetch("trace").fetch("id") &&
231
+ value["requestId"] == request["requestId"] && value["role"] == "system" &&
232
+ MODES.include?(value["mode"]) && !!id(value["decisionId"])
233
+ end
234
+
235
+ private_class_method :fields, :text, :messages, :block, :media, :completeness,
236
+ :end_user, :dates, :echo, :response_binding
237
+ end
238
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module MirrorsSDK
6
+ # Independent bounded pools keep optional capture traffic out of the context path.
7
+ class Worker
8
+ attr_reader :transport, :context_executor
9
+
10
+ def initialize(config)
11
+ @config, @lock = config, Monitor.new
12
+ @closed, @enabled, @generation = false, config.enabled, 0
13
+ @wake = @lock.new_cond
14
+ @transport = Transport.new(config)
15
+ @capture = CaptureRuntime.new(@transport, config)
16
+ @prefix = Executor.new(size: 2, capacity: 16, name: "mirrors-capture")
17
+ @final = Executor.new(size: 2, capacity: 16, name: "mirrors-final")
18
+ @context_executor = Executor.new(size: 2, capacity: 16, name: "mirrors-context")
19
+ rescue StandardError
20
+ dispose
21
+ raise
22
+ end
23
+
24
+ def start(context)
25
+ @heartbeat = Thread.new { heartbeat(context) }
26
+ @heartbeat.name = "mirrors-mode"
27
+ @heartbeat.report_on_exception = false
28
+ end
29
+
30
+ def capture(value, deadline:, sequence:)
31
+ @lock.synchronize do
32
+ return nil if @closed || !@enabled
33
+
34
+ runtime = @capture
35
+ pool = value["final"] ? @final : @prefix
36
+ pool.submit { |cancel| runtime.send(value, deadline: deadline, cancel: cancel, sequence: sequence) }
37
+ end
38
+ end
39
+
40
+ def release(trace_id)
41
+ @lock.synchronize { @capture.release(trace_id) }
42
+ end
43
+
44
+ def set_enabled(enabled)
45
+ @lock.synchronize do
46
+ return if @closed || @enabled == enabled
47
+
48
+ @enabled = enabled
49
+ @generation += 1
50
+ @heartbeat_cancel&.cancel("disabled")
51
+ @transport.set_enabled(enabled)
52
+ if enabled
53
+ @capture = CaptureRuntime.new(@transport, @config)
54
+ else
55
+ @capture.clear
56
+ end
57
+ @wake.broadcast
58
+ end
59
+ end
60
+
61
+ def dispose
62
+ @lock.synchronize do
63
+ return if @closed
64
+
65
+ @closed = true
66
+ @heartbeat_cancel&.cancel("closed")
67
+ @capture&.clear
68
+ @transport&.close
69
+ [@prefix, @final, @context_executor].compact.each(&:shutdown)
70
+ @wake.broadcast
71
+ end
72
+ end
73
+
74
+ def diagnostics
75
+ { capture: @prefix.diagnostics, final: @final.diagnostics, context: @context_executor.diagnostics }
76
+ end
77
+
78
+ private
79
+
80
+ def heartbeat(context)
81
+ loop do
82
+ attempt = @lock.synchronize do
83
+ break if @closed
84
+
85
+ if @enabled
86
+ @heartbeat_cancel = Cancellation.new
87
+ [@generation, @heartbeat_cancel]
88
+ end
89
+ end
90
+ break if @closed
91
+
92
+ refresh(context, *attempt) if attempt
93
+ @lock.synchronize do
94
+ break if @closed
95
+
96
+ @wake.wait(@config.heartbeat_interval_ms / 1000.0 * (0.9 + rand * 0.2))
97
+ end
98
+ end
99
+ end
100
+
101
+ def refresh(context, generation, cancel)
102
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
103
+ value = @transport.request("mode", nil, deadline: started + 0.75, cancel: cancel) do |response|
104
+ Wire.mode_response(response, @config.agent_id)
105
+ end
106
+ @lock.synchronize do
107
+ context.observe_mode(value, started) if @enabled && !@closed && @generation == generation
108
+ end
109
+ rescue StandardError
110
+ # A failed refresh never extends the previous lease.
111
+ nil
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mirrors_sdk/version"
4
+ require_relative "mirrors_sdk/configuration"
5
+ require_relative "mirrors_sdk/models"
6
+ require_relative "mirrors_sdk/wire"
7
+ require_relative "mirrors_sdk/executor"
8
+ require_relative "mirrors_sdk/transport"
9
+ require_relative "mirrors_sdk/delivery"
10
+ require_relative "mirrors_sdk/capture"
11
+ require_relative "mirrors_sdk/context"
12
+ require_relative "mirrors_sdk/worker"
13
+ require_relative "mirrors_sdk/client"
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/mirrors_sdk/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "mirrors-sdk"
7
+ spec.version = MirrorsSDK::VERSION
8
+ spec.authors = ["Mirrors"]
9
+ spec.summary = "Background trace capture and optional context for Ruby agents"
10
+ spec.description = "Capture canonical conversation history and retrieve bounded, " \
11
+ "server-selected context while your application owns model calls and tools."
12
+ spec.homepage = "https://mirrors.dev/docs"
13
+ spec.required_ruby_version = ">= 3.3"
14
+ spec.files = Dir.chdir(__dir__) do
15
+ Dir["lib/**/*.rb", "test/**/*", "skills/**/*.md", "README.md", "Gemfile", "Rakefile", "mirrors-sdk.gemspec"]
16
+ .select { |path| File.file?(path) }.sort
17
+ end
18
+ spec.require_paths = ["lib"]
19
+ spec.metadata = {
20
+ "source_code_uri" => "https://github.com/runmirrors/sidecar/tree/main/packages/ruby-sdk",
21
+ "documentation_uri" => "https://github.com/runmirrors/sidecar/blob/main/packages/ruby-sdk/README.md"
22
+ }
23
+ end
@@ -0,0 +1,152 @@
1
+ ---
2
+ name: install-mirrors-ruby-sdk
3
+ description: Install or upgrade the Mirrors Ruby SDK and integrate original trace capture and optional context into an existing Ruby agent.
4
+ ---
5
+
6
+ # Install Mirrors for Ruby
7
+
8
+ Use `MirrorsSDK::Mirrors` from `require "mirrors_sdk"`. Preserve the application's
9
+ prompt assembly, provider calls, tools, streams, errors and cancellation. Network
10
+ workers, heartbeats, protocol handling and retries belong inside the SDK. Read the
11
+ installed package's matching README before adapting existing hooks.
12
+
13
+ ## Select the package
14
+
15
+ Use Ruby 3.3+ and the application's existing dependency manager. The gem is named
16
+ `mirrors-sdk`; the require path is `mirrors_sdk`. Ruby, Python and npm package
17
+ versions are independent.
18
+
19
+ Verify that the intended official Mirrors release is published on the trusted gem
20
+ registry before using a registry install. This source version does not establish
21
+ RubyGems publication. Confirm ownership and source provenance, not merely a matching
22
+ name. When available, add the verified stable version through the existing Gemfile
23
+ and record its resolved version in the application's lockfile.
24
+
25
+ If no official release exists, use an available reviewed checkout and record its
26
+ revision. Replace this placeholder with the actual source directory:
27
+
28
+ ```ruby
29
+ gem "mirrors-sdk", path: "/path/to/sidecar/packages/ruby-sdk", require: "mirrors_sdk"
30
+ ```
31
+
32
+ Then run `bundle install` and verify:
33
+
34
+ ```sh
35
+ bundle exec ruby -rmirrors_sdk -e 'puts MirrorsSDK::VERSION'
36
+ ```
37
+
38
+ Respect constraints and lockfiles; explain conflicts instead of claiming an upgrade
39
+ succeeded. Do not invent a release, substitute another package or publish the gem
40
+ as part of installing it. The built gem includes this skill; find its directory via
41
+ `Gem::Specification.find_by_name("mirrors-sdk").full_gem_path`.
42
+
43
+ ## Resolve the connection
44
+
45
+ Reuse verified server configuration for `api_url`, `agent_id` and `api_key`. Resolve
46
+ missing nonsecret settings from the intended agent's **Agent setup → SDK connection
47
+ settings** in the signed-in dashboard. Use its API origin and generated agent ID
48
+ together; never derive the ID from a display name or key. Load the matching agent's
49
+ key with runtime read/write permissions from server secrets. Never print, commit or
50
+ request a key in chat. Preserve explicit local, staging and self-hosted settings.
51
+
52
+ Before real trace delivery, make one bounded, read-only
53
+ `GET <api_url>/v1/agents/<agent_id>/runtime/mode` using `Authorization: Bearer ...`
54
+ and `X-Mirrors-Protocol: 1`, refusing redirects. The response must identify the same
55
+ agent. Shadow/off are valid modes. Recheck the environment, generated ID and key
56
+ scope on 404. Do not upload customer history merely to test credentials.
57
+
58
+ ## Integrate the existing harness
59
+
60
+ Create one reusable client per agent configuration in each worker **after forking**:
61
+
62
+ ```ruby
63
+ require "mirrors_sdk"
64
+ require "securerandom"
65
+
66
+ mirrors = MirrorsSDK::Mirrors.new(
67
+ api_url: ENV.fetch("MIRRORS_API_URL"),
68
+ api_key: ENV.fetch("MIRRORS_API_KEY"),
69
+ agent_id: ENV.fetch("MIRRORS_AGENT_ID")
70
+ )
71
+ ```
72
+
73
+ Find the original-history logger, user-turn prompt assembly and existing execution
74
+ ending hooks. Preserve complete original canonical messages, roles, reasoning,
75
+ block order and tool associations. Use builtin JSON values and string-key hashes;
76
+ canonical names remain camelCase (`conversationId`, `callId`, `turnLatencyMs`).
77
+ Exclude Mirrors-added context, and never invent associations or truncate evidence.
78
+
79
+ At user-message acceptance, create a fresh execution ID and optional turn timer.
80
+ Keep them through the full model/tool loop and every retry:
81
+
82
+ ```ruby
83
+ turn = mirrors.start_turn
84
+ trace = { "id" => SecureRandom.uuid, "messages" => full_original_history }
85
+ mirrors.capture_trace(trace)
86
+ selected = mirrors.get_context(trace, timeout_ms: 1000, cancel: cancellation)
87
+ # Preserve the host's cancellation check here before calling its provider.
88
+ system = selected.context.empty? ? base_system : "#{base_system}\n\n#{selected.context}"
89
+ response = existing_model_call(system: system, messages: original_messages)
90
+ ```
91
+
92
+ The fragment's history, prompt, model and cancellation variables belong to the
93
+ application. `cancellation` can be a `MirrorsSDK::Cancellation` token; call `cancel`
94
+ at the host's cancellation hook and check `canceled?` before its provider call.
95
+ Do not turn a canceled operation into a provider invocation.
96
+
97
+ `get_context` returns an object with attributes. It synchronously waits within one
98
+ total budget of at most 1,000 ms from entry; use a shorter `timeout_ms` to reduce
99
+ waiting. It does not wait for capture. Event-loop applications should use their
100
+ existing background execution facility. Empty context leaves the prompt unchanged.
101
+ Use returned text promptly for that request, check `freshness.expires_at` if delayed,
102
+ and retrieve again when history changes.
103
+
104
+ Capture updated original snapshots at the logger without awaiting delivery. Only
105
+ after the entire response stream, tools and retries finish, finalize once through
106
+ the existing completion/failure/cancellation hook:
107
+
108
+ ```ruby
109
+ ticket = mirrors.capture_trace(
110
+ trace.merge("messages" => completed_original_history, "turnLatencyMs" => turn.finish),
111
+ final: true,
112
+ terminal: "completed" # Use "failed" or "canceled" at those hooks.
113
+ )
114
+ ```
115
+
116
+ Terminal status describes execution ending, not task success. Put latency only on
117
+ final capture, measure the whole turn and exclude capture delivery. Omit unknown
118
+ duration. With no final evidence, `release_trace(trace["id"])` only cleans up local
119
+ state. Never reuse execution IDs after completion, release, restart or disable.
120
+
121
+ Continuing chats supply both `conversationId` and `conversationRevision` or neither.
122
+ Allocate a positive safe-integer revision atomically in durable storage per
123
+ execution, persist the pair and keep it fixed across snapshots/retries. Timestamps,
124
+ message counts and process counters are invalid. Branches need distinct chat IDs.
125
+ Optional `user` contains an opaque internal `id` and optional cohort `segment`.
126
+
127
+ ## Optional original instructions
128
+
129
+ When the installed SDK and deployed API/worker readers support it, supply original
130
+ instructions on `trace["agentContext"]`: `systemPrompt` and/or `skills`, where each
131
+ skill has `name` and `content`. Read the application's prompt before adding Mirrors
132
+ context; do not load arbitrary files or skills automatically. Include the object on
133
+ the final snapshot: previous values are never inherited. Omit unknown fields; empty
134
+ prompt/skills explicitly mean empty. Respect the installed README's character,
135
+ count and byte limits. Preserve existing system messages without synthesizing copies.
136
+
137
+ ## Verify and shut down
138
+
139
+ Inspect `ticket.receipt` outside the model path; `ticket.wait(2)` waits up to two
140
+ seconds. Other timeout arguments use milliseconds. Accepted ingestion `queued`
141
+ does not mean analysis completed; an unconfirmed write may have committed.
142
+
143
+ At shutdown, stop admitting turns, let active turns finish and call
144
+ `mirrors.close(timeout_ms: 2000)`. `flush` covers only tickets pending at entry;
145
+ inspect accepted/dropped/unconfirmed/pending counts. Background work is best effort
146
+ and no durable queue exists.
147
+
148
+ Verify locally before real delivery: active context appends to a copied prompt;
149
+ original evidence remains unchanged; empty/shadow/off/error/cancellation paths
150
+ preserve host behavior; tools and streams finalize only at their true ending;
151
+ shutdown is bounded. Report the installed version and observed receipt outcomes,
152
+ distinguishing local tests from publication, hosted acceptance and model quality.