jazari 0.7.0 → 0.8.1

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,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "jazari/version"
5
+ require "jazari/mcp/actions"
6
+
7
+ module Jazari
8
+ module Mcp
9
+ class StdioServer
10
+ PROTOCOL_VERSION = "2025-11-25"
11
+ SUPPORTED_VERSIONS = %w[2024-11-05 2025-03-26 2025-06-18 2025-11-25].freeze
12
+
13
+ def initialize(dispatcher:, version: Jazari::VERSION)
14
+ @dispatcher = dispatcher
15
+ @version = version
16
+ @initialization_response_sent = false
17
+ @initialized = false
18
+ end
19
+
20
+ def serve(input: $stdin, output: $stdout)
21
+ input.each_line do |line|
22
+ next if line.strip.empty?
23
+
24
+ message = JSON.parse(line)
25
+ response = receive(message)
26
+ output.puts(JSON.generate(response)) if response
27
+ output.flush
28
+ rescue JSON::ParserError
29
+ output.puts(JSON.generate(jsonrpc_error(nil, -32_700, "Parse error")))
30
+ output.flush
31
+ rescue StandardError
32
+ id = message.is_a?(Hash) ? message["id"] : nil
33
+ output.puts(JSON.generate(jsonrpc_error(id, -32_603, "Internal error")))
34
+ output.flush
35
+ end
36
+ 0
37
+ end
38
+
39
+ def receive(message)
40
+ id = message.is_a?(Hash) ? message["id"] : nil
41
+ return jsonrpc_error(id, -32_600, "Invalid Request") unless valid_message?(message)
42
+ return receive_notification(message) unless message.key?("id")
43
+
44
+ case message["method"]
45
+ when "initialize" then initialize_response(message)
46
+ when "ping" then empty_response(message)
47
+ else
48
+ return jsonrpc_error(message["id"], -32_002, "Server not initialized") unless @initialized
49
+
50
+ operational_response(message)
51
+ end
52
+ end
53
+
54
+ def operational_response(message)
55
+ case message["method"]
56
+ when "tools/list" then list_tools(message)
57
+ when "tools/call" then call_tool(message)
58
+ else jsonrpc_error(message["id"], -32_601, "Method not found")
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def valid_message?(message)
65
+ message.is_a?(Hash) && message["jsonrpc"] == "2.0" && message["method"].is_a?(String) &&
66
+ (!message.key?("id") || message["id"].is_a?(String) || message["id"].is_a?(Numeric))
67
+ end
68
+
69
+ def initialize_response(message)
70
+ return jsonrpc_error(message["id"], -32_600, "Already initialized") if @initialization_response_sent
71
+ return jsonrpc_error(message["id"], -32_602, "Invalid params") unless valid_initialize_params?(message["params"])
72
+
73
+ requested = message.dig("params", "protocolVersion")
74
+ negotiated = SUPPORTED_VERSIONS.include?(requested) ? requested : PROTOCOL_VERSION
75
+ @initialization_response_sent = true
76
+ jsonrpc_result(
77
+ message["id"],
78
+ protocolVersion: negotiated,
79
+ capabilities: { tools: { listChanged: false } },
80
+ serverInfo: {
81
+ name: "jazari", title: "Jazari", version: @version,
82
+ description: "Addressable operating procedures and run evidence."
83
+ },
84
+ instructions: "Authorize the caller before constructing a target. Jazari records procedure state; it does not execute commands."
85
+ )
86
+ end
87
+
88
+ def valid_initialize_params?(params)
89
+ params.is_a?(Hash) && params["protocolVersion"].is_a?(String) &&
90
+ params["capabilities"].is_a?(Hash) && params["clientInfo"].is_a?(Hash) &&
91
+ params.dig("clientInfo", "name").is_a?(String) &&
92
+ params.dig("clientInfo", "version").is_a?(String)
93
+ end
94
+
95
+ def receive_notification(message)
96
+ if message["method"] == "notifications/initialized" && @initialization_response_sent
97
+ @initialized = true
98
+ end
99
+ nil
100
+ end
101
+
102
+ def empty_response(message)
103
+ return jsonrpc_error(message["id"], -32_602, "Invalid params") unless object_params?(message)
104
+
105
+ jsonrpc_result(message["id"], {})
106
+ end
107
+
108
+ def list_tools(message)
109
+ return jsonrpc_error(message["id"], -32_602, "Invalid params") unless object_params?(message)
110
+
111
+ jsonrpc_result(message["id"], tools: [ tool ])
112
+ end
113
+
114
+ def object_params?(message)
115
+ !message.key?("params") || message["params"].is_a?(Hash)
116
+ end
117
+
118
+ def tool
119
+ {
120
+ name: "jazari",
121
+ title: "Jazari procedure state",
122
+ description: "Resolve or update a procedure and record run evidence.",
123
+ inputSchema: Actions.input_schema(target_schema: target_schema),
124
+ annotations: {
125
+ readOnlyHint: false, destructiveHint: true,
126
+ idempotentHint: false, openWorldHint: false
127
+ }
128
+ }
129
+ end
130
+
131
+ def target_schema
132
+ {
133
+ oneOf: [
134
+ {
135
+ type: "object", required: %w[kind key recipe_id],
136
+ properties: {
137
+ kind: { const: "queue" }, key: { type: "string", minLength: 1 },
138
+ recipe_id: { type: "string", minLength: 1 },
139
+ public_reference: { type: "object" }
140
+ },
141
+ additionalProperties: false
142
+ },
143
+ {
144
+ type: "object", required: %w[kind scope_type scope_id key recipe_id],
145
+ properties: {
146
+ kind: { const: "anchor" }, scope_type: { type: "string", minLength: 1 },
147
+ scope_id: { type: "integer" }, key: { type: "string", minLength: 1 },
148
+ recipe_id: { type: "string", minLength: 1 },
149
+ public_reference: { type: "object" }
150
+ },
151
+ additionalProperties: false
152
+ }
153
+ ]
154
+ }
155
+ end
156
+
157
+ def call_tool(message)
158
+ params = message.fetch("params", {})
159
+ return jsonrpc_error(message["id"], -32_602, "Unknown tool") unless params["name"] == "jazari"
160
+
161
+ arguments = params.fetch("arguments", {})
162
+ return jsonrpc_error(message["id"], -32_602, "Invalid params") unless arguments.is_a?(Hash)
163
+
164
+ action = arguments["action"]
165
+ target = arguments["target"]
166
+ action_arguments = arguments.reject { |key, _| %w[action target].include?(key) }
167
+ response = @dispatcher.call(
168
+ "protocol" => Protocol::Dispatcher::VERSION,
169
+ "request_id" => "mcp:#{message.fetch('id')}",
170
+ "action" => action,
171
+ "target" => target,
172
+ "arguments" => action_arguments
173
+ )
174
+ result = {
175
+ content: [ { type: "text", text: JSON.generate(response) } ],
176
+ structuredContent: response,
177
+ isError: response["ok"] == false
178
+ }
179
+ jsonrpc_result(message["id"], result)
180
+ rescue KeyError, TypeError
181
+ jsonrpc_error(message["id"], -32_602, "Invalid params")
182
+ end
183
+
184
+ def jsonrpc_result(id, result) = { jsonrpc: "2.0", id: id, result: result }
185
+
186
+ def jsonrpc_error(id, code, message)
187
+ { jsonrpc: "2.0", id: id, error: { code: code, message: message } }
188
+ end
189
+ end
190
+ end
191
+ end
@@ -142,6 +142,8 @@ module Jazari
142
142
  create_custom(target, expected_revision, origin: origin) { |current| yield(current) }
143
143
  end
144
144
  resolve(target: target)
145
+ rescue ActiveRecord::StaleObjectError
146
+ raise RevisionConflict, "another writer changed this runbook first"
145
147
  end
146
148
  private_class_method :write
147
149
 
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+ require "active_record"
6
+ require "jazari"
7
+ require "jazari/mcp/handler"
8
+
9
+ module Jazari
10
+ module Protocol
11
+ class InvalidRequest < StandardError; end
12
+
13
+ class Dispatcher
14
+ VERSION = "jazari/v1"
15
+ ACTIONS = ([ "capabilities" ] + Mcp::Actions::NAMES).freeze
16
+ REQUEST_KEYS = %w[protocol request_id action target arguments].freeze
17
+ DOMAIN_ERRORS = %w[
18
+ target_not_found invalid_runbook revision_conflict item_not_found
19
+ item_not_in_snapshot read_only_target run_closed
20
+ ].freeze
21
+
22
+ def initialize(handler: Mcp::Handler.new)
23
+ @handler = handler
24
+ end
25
+
26
+ def capabilities
27
+ adapter = Jazari::ApplicationRecord.connection.adapter_name.downcase
28
+ storage = if adapter.include?("sqlite")
29
+ "sqlite"
30
+ elsif adapter.include?("postgres")
31
+ "postgresql"
32
+ else
33
+ raise ActiveRecord::AdapterNotSpecified, "jazari/v1 does not support the #{adapter} adapter"
34
+ end
35
+ {
36
+ protocol: VERSION,
37
+ storage: storage,
38
+ transports: storage == "sqlite" ? %w[cli mcp-stdio] : %w[ruby],
39
+ features: %w[recipes runbooks runs evidence]
40
+ }
41
+ end
42
+
43
+ def call(input)
44
+ request = stringify(input)
45
+ validate_request!(request)
46
+ action = request.fetch("action")
47
+ result = if action == "capabilities"
48
+ capabilities
49
+ else
50
+ @handler.call(
51
+ action: action,
52
+ target: target_from(request.fetch("target")),
53
+ arguments: request.fetch("arguments")
54
+ )
55
+ end
56
+
57
+ envelope(request, result)
58
+ rescue InvalidRequest => error
59
+ error_envelope(input, "invalid_request", error.message)
60
+ rescue ActiveRecord::RecordNotFound
61
+ error_envelope(input, "target_not_found")
62
+ rescue ActiveRecord::StatementInvalid => error
63
+ code = sqlite_busy?(error) ? "storage_busy" : "storage_failure"
64
+ error_envelope(input, code)
65
+ rescue StandardError
66
+ error_envelope(input, "storage_failure")
67
+ end
68
+
69
+ private
70
+
71
+ def validate_request!(request)
72
+ invalid!("request must be an object") unless request.is_a?(Hash)
73
+ unknown = request.keys - REQUEST_KEYS
74
+ invalid!("unknown request fields: #{unknown.join(', ')}") if unknown.any?
75
+ invalid!("protocol must be #{VERSION}") unless request["protocol"] == VERSION
76
+ invalid!("request_id must be a non-empty string") unless request["request_id"].is_a?(String) && !request["request_id"].empty?
77
+
78
+ action = request["action"]
79
+ invalid!("unknown action #{action.inspect}") unless ACTIONS.include?(action)
80
+ invalid!("arguments is required") unless request.key?("arguments")
81
+ arguments = request["arguments"]
82
+ invalid!("arguments must be an object") unless arguments.is_a?(Hash)
83
+
84
+ if action == "capabilities"
85
+ invalid!("capabilities does not accept a target") if request.key?("target")
86
+ invalid!("capabilities does not accept arguments") unless arguments.empty?
87
+ return
88
+ end
89
+
90
+ invalid!("target is required") unless request["target"].is_a?(Hash)
91
+ validate_arguments!(action, arguments)
92
+ end
93
+
94
+ def validate_arguments!(action, arguments)
95
+ descriptor = Mcp::Actions.fetch(action)
96
+ allowed = descriptor.params.keys.map(&:to_s)
97
+ unknown = arguments.keys - allowed
98
+ invalid!("unknown #{action} arguments: #{unknown.join(', ')}") if unknown.any?
99
+ missing = descriptor.required.map(&:to_s) - arguments.keys
100
+ invalid!("missing #{action} arguments: #{missing.join(', ')}") if missing.any?
101
+
102
+ arguments.each do |key, value|
103
+ validate_type!(key, value, descriptor.params.fetch(key.to_sym))
104
+ end
105
+ end
106
+
107
+ def validate_type!(key, value, schema)
108
+ valid = case schema[:type]
109
+ when "string" then value.is_a?(String)
110
+ when "integer" then value.is_a?(Integer)
111
+ when "boolean" then value == true || value == false
112
+ when "array" then value.is_a?(Array)
113
+ else true
114
+ end
115
+ invalid!("#{key} must be a #{schema[:type]}") unless valid
116
+ invalid!("#{key} must be #{schema[:const].inspect}") if schema.key?(:const) && value != schema[:const]
117
+ invalid!("unknown #{key} #{value.inspect}") if schema[:enum] && !schema[:enum].include?(value)
118
+ invalid!("#{key} must be at least #{schema[:minimum]}") if schema[:minimum] && value < schema[:minimum]
119
+ validate_value!(key, value)
120
+ end
121
+
122
+ def validate_value!(key, value)
123
+ case key
124
+ when "expected_revision", "actor_ref"
125
+ invalid!("#{key} must not be empty") if value.empty?
126
+ when "item_id"
127
+ invalid!("item_id must not be empty") if value.empty?
128
+ when "run_id"
129
+ invalid!("run_id must be positive") unless value.positive?
130
+ when "topic"
131
+ invalid!("topic must not be empty") if value.empty?
132
+ invalid!("topic exceeds #{Operations::MAX_TOPIC}") if value.length > Operations::MAX_TOPIC
133
+ when "description"
134
+ invalid!("description exceeds #{Operations::MAX_DESCRIPTION}") if value.length > Operations::MAX_DESCRIPTION
135
+ when "note"
136
+ invalid!("note exceeds #{Checklist::MAX_TEXT}") if value.length > Checklist::MAX_TEXT
137
+ when "text"
138
+ invalid!("text must not be empty") if value.empty?
139
+ invalid!("text exceeds #{Checklist::MAX_TEXT}") if value.length > Checklist::MAX_TEXT
140
+ when "checklist"
141
+ validate_checklist!(value)
142
+ when "kind"
143
+ invalid!("unknown evidence kind #{value.inspect}") unless Runs::EVIDENCE_KINDS.include?(value)
144
+ when "value"
145
+ invalid!("evidence value exceeds #{Runs::MAX_EVIDENCE}") if value.length > Runs::MAX_EVIDENCE
146
+ when "outcome"
147
+ invalid!("unknown outcome #{value.inspect}") unless Run::OUTCOMES.include?(value)
148
+ when "confirm"
149
+ invalid!("confirm must be true") unless value
150
+ end
151
+ end
152
+
153
+ def validate_checklist!(items)
154
+ items.each do |item|
155
+ invalid!("checklist items must be objects") unless item.is_a?(Hash)
156
+ entry = stringify(item)
157
+ unknown = entry.keys - %w[id text done required]
158
+ invalid!("unknown checklist fields: #{unknown.join(', ')}") if unknown.any?
159
+ invalid!("checklist item text is required") unless entry["text"].is_a?(String) && !entry["text"].empty?
160
+ invalid!("checklist item text exceeds #{Checklist::MAX_TEXT}") if entry["text"].length > Checklist::MAX_TEXT
161
+ invalid!("checklist item id is invalid") if entry.key?("id") &&
162
+ (!entry["id"].is_a?(String) || !entry["id"].match?(Checklist::ID_FORMAT))
163
+ %w[done required].each do |flag|
164
+ invalid!("checklist item #{flag} must be a boolean") if entry.key?(flag) && ![ true, false ].include?(entry[flag])
165
+ end
166
+ end
167
+ end
168
+
169
+ def target_from(target)
170
+ kind = target["kind"]
171
+ case kind
172
+ when "queue"
173
+ exact_keys!(target, %w[kind key recipe_id public_reference])
174
+ QueueTarget.new(
175
+ queue: required_string(target, "key"),
176
+ recipe_id: required_string(target, "recipe_id"),
177
+ public_reference: public_reference(target)
178
+ )
179
+ when "anchor"
180
+ exact_keys!(target, %w[kind scope_type scope_id key recipe_id public_reference])
181
+ scope_type = required_string(target, "scope_type")
182
+ AnchorTarget.new(
183
+ scope_type: scope_type,
184
+ scope_id: required_integer(target, "scope_id"),
185
+ key: required_string(target, "key"),
186
+ recipe_id: required_string(target, "recipe_id"),
187
+ public_reference: public_reference(target)
188
+ )
189
+ else
190
+ invalid!("target kind must be queue or anchor")
191
+ end
192
+ end
193
+
194
+ def exact_keys!(value, allowed)
195
+ unknown = value.keys - allowed
196
+ invalid!("unknown target fields: #{unknown.join(', ')}") if unknown.any?
197
+ end
198
+
199
+ def required_string(value, key)
200
+ result = value[key]
201
+ invalid!("target #{key} must be a non-empty string") unless result.is_a?(String) && !result.empty?
202
+ result
203
+ end
204
+
205
+ def required_integer(value, key)
206
+ result = value[key]
207
+ invalid!("target #{key} must be an integer") unless result.is_a?(Integer)
208
+ result
209
+ end
210
+
211
+ def public_reference(target)
212
+ fallback_keys = %w[kind scope_type scope_id key]
213
+ value = target.fetch("public_reference", target.select { |key, _| fallback_keys.include?(key) })
214
+ invalid!("target public_reference must be an object") unless value.is_a?(Hash)
215
+ value
216
+ end
217
+
218
+ def envelope(request, result)
219
+ normalized = normalize(result)
220
+ if normalized.delete("ok") == false
221
+ code = normalized.fetch("error")
222
+ return error_envelope(request, DOMAIN_ERRORS.include?(code) ? code : "storage_failure")
223
+ end
224
+
225
+ normalized["revision"] = normalized["revision"].to_s if normalized.key?("revision")
226
+ {
227
+ "protocol" => VERSION,
228
+ "request_id" => request.fetch("request_id"),
229
+ "action" => request.fetch("action"),
230
+ "ok" => true,
231
+ "result" => normalized
232
+ }
233
+ end
234
+
235
+ def error_envelope(input, code, detail = nil)
236
+ request = input.is_a?(Hash) ? stringify(input) : {}
237
+ error = { "code" => code }
238
+ error["detail"] = detail.to_s unless detail.to_s.empty?
239
+ {
240
+ "protocol" => VERSION,
241
+ "request_id" => request["request_id"].to_s,
242
+ "action" => request["action"].to_s,
243
+ "ok" => false,
244
+ "error" => error
245
+ }
246
+ end
247
+
248
+ def normalize(value)
249
+ case value
250
+ when Hash then value.to_h { |key, nested| [ key.to_s, normalize(nested) ] }
251
+ when Array then value.map { |nested| normalize(nested) }
252
+ when Time then value.utc.iso8601
253
+ when Date then value.iso8601
254
+ when NilClass then nil
255
+ else value.respond_to?(:to_h) ? normalize(value.to_h) : value
256
+ end
257
+ end
258
+
259
+ def stringify(value)
260
+ return value unless value.is_a?(Hash)
261
+ value.to_h { |key, nested| [ key.to_s, nested.is_a?(Hash) ? stringify(nested) : nested ] }
262
+ end
263
+
264
+ def sqlite_busy?(error)
265
+ error.cause&.class&.name.to_s.include?("Busy") || error.message.include?("database is locked")
266
+ end
267
+
268
+ def invalid!(message) = raise InvalidRequest, message
269
+ end
270
+ end
271
+ end
data/lib/jazari/runs.rb CHANGED
@@ -158,6 +158,8 @@ module Jazari
158
158
  record.save!
159
159
  end
160
160
  record
161
+ rescue ActiveRecord::StaleObjectError
162
+ raise RevisionConflict, "another writer changed this run first"
161
163
  end
162
164
  private_class_method :mutate
163
165
 
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jazari
4
+ module Standalone
5
+ module Database
6
+ TABLES = Jazari::TABLES.keys.freeze
7
+ BUSY_TIMEOUT = 5_000
8
+
9
+ module_function
10
+
11
+ def connect!(path)
12
+ raise ArgumentError, "database path is required" if path.to_s.empty?
13
+ raise ArgumentError, "standalone storage requires a SQLite file, not :memory:" if path.to_s == ":memory:"
14
+
15
+ configure_local_anchors!
16
+ path = File.expand_path(path.to_s)
17
+
18
+ FileUtils.mkdir_p(File.dirname(path))
19
+ ActiveRecord::Base.establish_connection(
20
+ adapter: "sqlite3", database: path, timeout: BUSY_TIMEOUT, pool: 5
21
+ )
22
+ connection = Jazari::ApplicationRecord.connection
23
+ connection.execute("PRAGMA foreign_keys = ON")
24
+ connection.execute("PRAGMA journal_mode = WAL")
25
+ connection.execute("PRAGMA busy_timeout = #{BUSY_TIMEOUT}")
26
+ migrate!(connection)
27
+ connection
28
+ end
29
+
30
+ def migrate!(connection, migration: CreateJazariTables.new)
31
+ connection.raw_connection.transaction(:immediate) do
32
+ existing = TABLES.select { |key| connection.data_source_exists?(Jazari.table_name_for(key)) }
33
+ next if existing.length == TABLES.length
34
+
35
+ if existing.any?
36
+ raise "partial Jazari schema: found #{existing.join(', ')}; refusing an implicit repair"
37
+ end
38
+
39
+ ActiveRecord::Migration.suppress_messages do
40
+ migration.migrate(:up)
41
+ end
42
+ end
43
+ end
44
+
45
+ def configure_local_anchors!
46
+ Jazari.configure do |config|
47
+ config.table_prefix = "jazari_"
48
+ config.table_names = {}
49
+ config.anchor_scopes = { "local" => nil }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "fileutils"
5
+ require "json"
6
+ require "jazari"
7
+ require "jazari/mcp/handler"
8
+
9
+ require_relative "../../app/models/jazari/application_record"
10
+ require_relative "../../app/models/jazari/recipe_record"
11
+ require_relative "../../app/models/jazari/runbook"
12
+ require_relative "../../app/models/jazari/anchor"
13
+ require_relative "../../app/models/jazari/run"
14
+ require_relative "../generators/jazari/install/templates/create_jazari_tables"
15
+ require "jazari/standalone/database"
16
+ require "jazari/protocol/dispatcher"
17
+ require "jazari/mcp/stdio_server"
18
+
19
+ module Jazari
20
+ module Standalone
21
+ class App
22
+ attr_reader :dispatcher
23
+
24
+ def initialize(database:, recipes: [])
25
+ Database.connect!(database)
26
+ Array(recipes).each do |path|
27
+ RecipeRegistry.seed!(RecipeFiles.load(path))
28
+ end
29
+ @dispatcher = Protocol::Dispatcher.new
30
+ end
31
+
32
+ def call(request) = dispatcher.call(request)
33
+ def capabilities = dispatcher.capabilities
34
+ end
35
+ end
36
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Jazari
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.1"
5
5
  end
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/nauman/jazari/protocol/v1/capability.schema.json",
4
+ "title": "Jazari v1 capabilities",
5
+ "type": "object",
6
+ "required": ["protocol", "storage", "transports", "features"],
7
+ "properties": {
8
+ "protocol": { "const": "jazari/v1" },
9
+ "storage": { "enum": ["postgresql", "sqlite"] },
10
+ "transports": {
11
+ "type": "array",
12
+ "items": { "enum": ["ruby", "cli", "mcp-stdio"] },
13
+ "uniqueItems": true
14
+ },
15
+ "features": {
16
+ "type": "array",
17
+ "items": { "enum": ["recipes", "runbooks", "runs", "evidence"] },
18
+ "uniqueItems": true
19
+ }
20
+ },
21
+ "additionalProperties": false
22
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/nauman/jazari/protocol/v1/error.schema.json",
4
+ "title": "Jazari v1 error",
5
+ "type": "object",
6
+ "required": ["code"],
7
+ "properties": {
8
+ "code": {
9
+ "enum": [
10
+ "target_not_found", "invalid_runbook", "revision_conflict",
11
+ "item_not_found", "item_not_in_snapshot", "read_only_target",
12
+ "run_closed", "invalid_request", "storage_busy", "storage_failure"
13
+ ]
14
+ },
15
+ "detail": { "type": "string" }
16
+ },
17
+ "additionalProperties": false
18
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "schema": "jazari-conformance/v1",
3
+ "recipes": [{"id":"actor.v1","topic":"Actor","checklist":[{"id":"first","text":"First"}]}],
4
+ "steps": [
5
+ {"request":{"protocol":"jazari/v1","request_id":"open","action":"start","target":{"kind":"queue","key":"actor","recipe_id":"actor.v1"},"arguments":{"actor_ref":"agent:fixture"}},"expect":{"ok":true}},
6
+ {"request":{"protocol":"jazari/v1","request_id":"tick","action":"tick","target":{"kind":"queue","key":"actor","recipe_id":"actor.v1"},"arguments":{"run_id":"$0.result.run_id","expected_revision":"$0.result.revision","item_id":"first","done":true}},"expect":{"ok":true,"result":{"ticks":[{"actor_ref":"agent:fixture"}]}}}
7
+ ]
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "schema": "jazari-conformance/v1",
3
+ "recipes": [{"id":"daily.v1","topic":"Daily","run_policy":"once_per_calendar_day","checklist":[{"id":"first","text":"First"}]}],
4
+ "steps": [
5
+ {"request":{"protocol":"jazari/v1","request_id":"open-1","action":"start","target":{"kind":"queue","key":"daily","recipe_id":"daily.v1"},"arguments":{"actor_ref":"agent:fixture"}},"expect":{"ok":true,"result":{"created":true,"idempotent_reuse":false}}},
6
+ {"request":{"protocol":"jazari/v1","request_id":"open-2","action":"start","target":{"kind":"queue","key":"daily","recipe_id":"daily.v1"},"arguments":{"actor_ref":"agent:fixture"}},"expect":{"ok":true,"result":{"created":false,"idempotent_reuse":true}},"equal":[["result.run_id",0,"result.run_id"]]}
7
+ ]
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "schema": "jazari-conformance/v1",
3
+ "recipes": [{"id":"evidence.v1","topic":"Evidence","checklist":[{"id":"first","text":"First"}]}],
4
+ "steps": [
5
+ {"request":{"protocol":"jazari/v1","request_id":"open","action":"start","target":{"kind":"queue","key":"evidence","recipe_id":"evidence.v1"},"arguments":{"actor_ref":"agent:fixture"}},"expect":{"ok":true}},
6
+ {"request":{"protocol":"jazari/v1","request_id":"evidence","action":"evidence","target":{"kind":"queue","key":"evidence","recipe_id":"evidence.v1"},"arguments":{"run_id":"$0.result.run_id","expected_revision":"$0.result.revision","item_id":"first","kind":"count","value":"4211"}},"expect":{"ok":true,"result":{"evidence":[{"item_id":"first","kind":"count","value":"4211","actor_ref":"agent:fixture"}]}}}
7
+ ]
8
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "schema": "jazari-conformance/v1",
3
+ "recipes": [{"id":"operator.v1","topic":"Seeded","checklist":[]}],
4
+ "steps": [
5
+ {"request":{"protocol":"jazari/v1","request_id":"default","action":"get","target":{"kind":"anchor","scope_type":"local","scope_id":1,"key":"operator","recipe_id":"operator.v1"},"arguments":{}},"expect":{"ok":true,"result":{"state":"default","topic":"Seeded"}}},
6
+ {"request":{"protocol":"jazari/v1","request_id":"edit","action":"set","target":{"kind":"anchor","scope_type":"local","scope_id":1,"key":"operator","recipe_id":"operator.v1"},"arguments":{"expected_revision":"$0.result.revision","topic":"Operator wording","description":"","checklist":[]}},"expect":{"ok":true,"result":{"state":"custom","topic":"Operator wording"}}},
7
+ {"setup":{"replace_recipe":{"id":"operator.v1","topic":"New seed","checklist":[]}}},
8
+ {"request":{"protocol":"jazari/v1","request_id":"get","action":"get","target":{"kind":"anchor","scope_type":"local","scope_id":1,"key":"operator","recipe_id":"operator.v1"},"arguments":{}},"expect":{"ok":true,"result":{"state":"custom","topic":"Operator wording"}}}
9
+ ]
10
+ }