jazari 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,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jazari
4
+ module Mcp
5
+ # Transport-neutral adapter between an MCP action name and the domain.
6
+ #
7
+ # It knows nothing about transport, authentication, or product naming. The
8
+ # HOST owns tool identity — one flat `action`-enum tool per product, named
9
+ # for that product — and the host authorizes the actor and constructs the
10
+ # target BEFORE calling here. This class only maps action names onto domain
11
+ # calls and shapes the reply.
12
+ #
13
+ # That split is deliberate: two products sharing this handler still present
14
+ # their own tool, their own subject vocabulary, and their own permissions.
15
+ class Handler
16
+ READ_ACTIONS = %w[get last_run].freeze
17
+ WRITE_ACTIONS = %w[set add_item remove_item check_item reset start tick evidence finish].freeze
18
+ ACTIONS = (READ_ACTIONS + WRITE_ACTIONS).freeze
19
+
20
+ # Advertise only what the granted scope may call. A read-scoped connection
21
+ # should not see mutations in its tool list at all — hiding them is not
22
+ # security, but offering them and refusing is a worse experience.
23
+ def self.actions_for(scope)
24
+ scope.to_s == "read" ? READ_ACTIONS : ACTIONS
25
+ end
26
+
27
+ def call(action:, target:, arguments: {})
28
+ args = symbolize(arguments)
29
+ name = action.to_s
30
+ raise ArgumentError, "unknown runbook action #{action.inspect}" unless ACTIONS.include?(name)
31
+
32
+ reply(public_send(:"handle_#{name}", target, args))
33
+ rescue Jazari::Error => error
34
+ # The closed taxonomy crosses the wire as a code, never as a message
35
+ # that could disclose a record, a query, or whether a target exists.
36
+ { ok: false, error: error.code }
37
+ end
38
+
39
+ # -- read ---------------------------------------------------------------
40
+
41
+ def handle_get(target, _args) = Operations.resolve(target: target)
42
+
43
+ def handle_last_run(target, _args)
44
+ run = Runs.last(target: target)
45
+ run ? run_view(run) : { last_run: nil }
46
+ end
47
+
48
+ # -- runbook ------------------------------------------------------------
49
+
50
+ def handle_set(target, args)
51
+ Operations.customize(
52
+ target: target, expected_revision: args[:expected_revision],
53
+ topic: args[:topic], description: args[:description].to_s,
54
+ checklist: args.fetch(:checklist, [])
55
+ )
56
+ end
57
+
58
+ def handle_add_item(target, args)
59
+ Operations.add_item(target: target, expected_revision: args[:expected_revision],
60
+ text: args[:text], required: args.fetch(:required, true))
61
+ end
62
+
63
+ def handle_remove_item(target, args)
64
+ Operations.remove_item(target: target, expected_revision: args[:expected_revision],
65
+ item_id: args[:item_id])
66
+ end
67
+
68
+ def handle_check_item(target, args)
69
+ Operations.check_item(target: target, expected_revision: args[:expected_revision],
70
+ item_id: args[:item_id], done: args[:done])
71
+ end
72
+
73
+ # Destructive: it discards the operator's customization. The host must
74
+ # gate this on explicit confirmation before calling.
75
+ def handle_reset(target, args)
76
+ unless args[:confirm] == true
77
+ raise InvalidRunbook, "reset discards the customization and requires confirm: true"
78
+ end
79
+
80
+ Operations.reset(target: target, expected_revision: args[:expected_revision])
81
+ end
82
+
83
+ # -- runs ---------------------------------------------------------------
84
+
85
+ def handle_start(target, args)
86
+ result = Runs.open(target: target, actor_ref: args.fetch(:actor_ref))
87
+ run_view(result[:run]).merge(created: result[:created], idempotent_reuse: result[:idempotent_reuse])
88
+ end
89
+
90
+ def handle_tick(_target, args)
91
+ run = Runs.tick(run: args.fetch(:run_id), expected_revision: args[:expected_revision],
92
+ item_id: args[:item_id], done: args.fetch(:done, true),
93
+ actor_ref: args.fetch(:actor_ref), note: args[:note])
94
+ run_view(run)
95
+ end
96
+
97
+ def handle_evidence(_target, args)
98
+ run = Runs.attach_evidence(run: args.fetch(:run_id), expected_revision: args[:expected_revision],
99
+ item_id: args[:item_id], kind: args.fetch(:kind), value: args.fetch(:value))
100
+ run_view(run)
101
+ end
102
+
103
+ def handle_finish(_target, args)
104
+ run = Runs.close(run: args.fetch(:run_id), expected_revision: args[:expected_revision],
105
+ outcome: args.fetch(:outcome, "completed"))
106
+ run_view(run)
107
+ end
108
+
109
+ private
110
+
111
+ def reply(result)
112
+ payload = result.respond_to?(:to_h) ? result.to_h : result
113
+ { ok: true }.merge(payload)
114
+ end
115
+
116
+ def run_view(run)
117
+ {
118
+ run_id: run.id, revision: run.lock_version, recipe: run.recipe_id,
119
+ started_at: run.started_at, started_on: run.started_on,
120
+ finished_at: run.finished_at, outcome: run.outcome, open: run.open?,
121
+ ticks: run.ticks, evidence: run.evidence
122
+ }
123
+ end
124
+
125
+ def symbolize(arguments)
126
+ arguments.to_h { |key, value| [ key.to_sym, value ] }
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jazari
4
+ # The runbook half: resolving a subject's operating truth and customizing it.
5
+ #
6
+ # Reading a default writes NOTHING — no row, no anchor, no audit noise. The
7
+ # first customization materializes a record; reset destroys it and reveals the
8
+ # current canon again, so a recipe correction reaches every subject that never
9
+ # overrode it.
10
+ module Operations
11
+ MAX_TOPIC = 120
12
+ MAX_DESCRIPTION = 20_000
13
+
14
+ module_function
15
+
16
+ def resolve(target:)
17
+ recipe = RecipeRegistry.fetch(target.recipe_id)
18
+ record = find_runbook(target)
19
+ last = Runs.last(target: target)
20
+
21
+ return custom_value(record, target, recipe, last) if record
22
+
23
+ default_value(recipe, target, last)
24
+ end
25
+
26
+ def customize(target:, expected_revision:, topic:, description:, checklist:)
27
+ writable!(target)
28
+ validate_document!(topic, description)
29
+ items = Checklist.normalize(checklist)
30
+ write(target, expected_revision) do |current|
31
+ current.merge(topic: topic, description: description, checklist: items)
32
+ end
33
+ end
34
+
35
+ def add_item(target:, expected_revision:, text:, required: true)
36
+ writable!(target)
37
+ write(target, expected_revision) do |current|
38
+ items = current[:checklist] + [
39
+ { id: Checklist.generate_id, text: text.to_s, done: false, required: required == true }
40
+ ]
41
+ Checklist.validate!(items)
42
+ current.merge(checklist: items)
43
+ end
44
+ end
45
+
46
+ def remove_item(target:, expected_revision:, item_id:)
47
+ writable!(target)
48
+ write(target, expected_revision) do |current|
49
+ remaining = current[:checklist].reject { |item| item[:id] == item_id.to_s }
50
+ raise ItemNotFound, "unknown checklist item #{item_id}" if remaining.length == current[:checklist].length
51
+
52
+ current.merge(checklist: remaining)
53
+ end
54
+ end
55
+
56
+ def check_item(target:, expected_revision:, item_id:, done:)
57
+ writable!(target)
58
+ write(target, expected_revision) do |current|
59
+ found = false
60
+ items = current[:checklist].map do |item|
61
+ next item unless item[:id] == item_id.to_s
62
+
63
+ found = true
64
+ item.merge(done: done == true)
65
+ end
66
+ raise ItemNotFound, "unknown checklist item #{item_id}" unless found
67
+
68
+ current.merge(checklist: items)
69
+ end
70
+ end
71
+
72
+ # Destroys the customization and reveals the current canon. Idempotent on an
73
+ # already-default target only while the supplied default revision matches.
74
+ def reset(target:, expected_revision:)
75
+ writable!(target)
76
+ record = find_runbook(target)
77
+ if record
78
+ record.with_lock do
79
+ verify_custom_revision!(record, expected_revision)
80
+ destroy_with_anchor(record, target)
81
+ end
82
+ else
83
+ verify_default_revision!(target, expected_revision)
84
+ end
85
+ resolve(target: target)
86
+ end
87
+
88
+ # A host calls this from its own after-commit when a subject is destroyed.
89
+ #
90
+ # Jazari cannot hook the host's model itself: the subject may live in a
91
+ # different logical database, so no cross-database foreign key is claimed
92
+ # and no cascade exists. The host calls in; the gem cleans up.
93
+ #
94
+ # Runs are deliberately PRESERVED. A run is an audit record of something
95
+ # that actually happened, and deleting the subject does not un-happen it.
96
+ # Their subject columns keep pointing at the departed record — which is
97
+ # sound precisely because no FK was ever claimed.
98
+ def forget_subject(subject)
99
+ runbook = Runbook.find_by(runbookable: subject)
100
+ runbook&.destroy!
101
+ subject.destroy! if subject.is_a?(Anchor) && subject.persisted?
102
+ Jazari.config.on_subject_destroyed&.call(subject)
103
+ true
104
+ end
105
+
106
+ # -- internals ---------------------------------------------------------
107
+
108
+ # A queue is a stable name for a ritual, and a ritual has exactly one
109
+ # editable home: its recipe. Per-subject customization belongs to subjects.
110
+ def writable!(target)
111
+ raise ReadOnlyTarget, "queues are read-only" if target.is_a?(QueueTarget)
112
+ end
113
+ private_class_method :writable!
114
+
115
+ def write(target, expected_revision)
116
+ record = find_runbook(target)
117
+ if record
118
+ record.with_lock do
119
+ verify_custom_revision!(record, expected_revision)
120
+ document = yield(current_document(record))
121
+ record.update!(
122
+ topic: document[:topic], description: document[:description],
123
+ checklist: store_items(document[:checklist])
124
+ )
125
+ end
126
+ else
127
+ create_custom(target, expected_revision) { |current| yield(current) }
128
+ end
129
+ resolve(target: target)
130
+ end
131
+ private_class_method :write
132
+
133
+ def create_custom(target, expected_revision)
134
+ verify_default_revision!(target, expected_revision)
135
+ recipe = RecipeRegistry.fetch(target.recipe_id)
136
+ document = yield(
137
+ { topic: recipe.topic, description: recipe.description,
138
+ checklist: recipe.checklist.map(&:dup) }
139
+ )
140
+ Runbook.create!(
141
+ runbookable: runbookable_for(target),
142
+ recipe_id: recipe.id,
143
+ topic: document[:topic], description: document[:description],
144
+ checklist: store_items(document[:checklist])
145
+ )
146
+ rescue ActiveRecord::RecordNotUnique
147
+ raise RevisionConflict, "another writer materialized this runbook first"
148
+ rescue ActiveRecord::RecordInvalid => error
149
+ raise InvalidRunbook, error.message
150
+ end
151
+ private_class_method :create_custom
152
+
153
+ def runbookable_for(target)
154
+ case target
155
+ when RecordTarget then target.runbookable
156
+ when AnchorTarget then Anchors.resolve(target, strict: true, create: true)
157
+ else raise TargetNotFound, "unsupported target"
158
+ end
159
+ end
160
+ private_class_method :runbookable_for
161
+
162
+ # Whether the anchor goes with the runbook is decided by the TARGET, not by
163
+ # the anchor's class.
164
+ #
165
+ # The first version asked `anchor.is_a?(Jazari::Anchor)`, which quietly
166
+ # assumed the gem owns the anchor model. A host that adopted jazari onto its
167
+ # own anchor table returns its own class, the check failed, and reset left an
168
+ # orphan anchor behind. The target always knows the truth.
169
+ def destroy_with_anchor(record, target)
170
+ anchor = record.runbookable
171
+ record.destroy!
172
+ anchor.destroy! if target.is_a?(AnchorTarget) && anchor.respond_to?(:destroy!)
173
+ end
174
+ private_class_method :destroy_with_anchor
175
+
176
+ def find_runbook(target)
177
+ case target
178
+ when RecordTarget then Runbook.find_by(runbookable: target.runbookable)
179
+ when AnchorTarget
180
+ anchor = Anchors.resolve(target, strict: false)
181
+ anchor && Runbook.find_by(runbookable: anchor)
182
+ when QueueTarget then nil
183
+ else raise TargetNotFound, "unsupported target"
184
+ end
185
+ end
186
+ private_class_method :find_runbook
187
+
188
+ # Transports deliver the revision as a string; lock_version is an integer.
189
+ def verify_custom_revision!(record, expected_revision)
190
+ return if record.lock_version.to_s == expected_revision.to_s
191
+
192
+ raise RevisionConflict, "expected revision #{expected_revision}, actual #{record.lock_version}"
193
+ end
194
+ private_class_method :verify_custom_revision!
195
+
196
+ def verify_default_revision!(target, expected_revision)
197
+ current = "default:#{RecipeRegistry.fetch(target.recipe_id).digest}"
198
+ return if expected_revision.to_s == current
199
+
200
+ raise RevisionConflict, "expected #{expected_revision}, current default is #{current}"
201
+ end
202
+ private_class_method :verify_default_revision!
203
+
204
+ def validate_document!(topic, description)
205
+ raise InvalidRunbook, "topic is required" if topic.to_s.empty?
206
+ raise InvalidRunbook, "topic exceeds #{MAX_TOPIC}" if topic.to_s.length > MAX_TOPIC
207
+ raise InvalidRunbook, "description exceeds #{MAX_DESCRIPTION}" if description.to_s.length > MAX_DESCRIPTION
208
+ end
209
+ private_class_method :validate_document!
210
+
211
+ def current_document(record)
212
+ { topic: record.topic, description: record.description,
213
+ checklist: stored_items(record.checklist) }
214
+ end
215
+ private_class_method :current_document
216
+
217
+ def custom_value(record, target, recipe, last)
218
+ ResolvedRunbook.new(
219
+ state: "custom", revision: record.lock_version, topic: record.topic,
220
+ description: record.description, checklist: stored_items(record.checklist),
221
+ target_reference: target.public_reference, recipe: recipe.provenance,
222
+ last_run: run_summary(last)
223
+ )
224
+ end
225
+ private_class_method :custom_value
226
+
227
+ def default_value(recipe, target, last)
228
+ ResolvedRunbook.new(
229
+ state: "default", revision: "default:#{recipe.digest}", topic: recipe.topic,
230
+ description: recipe.description, checklist: recipe.checklist,
231
+ target_reference: target.public_reference, recipe: recipe.provenance,
232
+ last_run: run_summary(last)
233
+ )
234
+ end
235
+ private_class_method :default_value
236
+
237
+ # The field that makes "is this ritual actually happening?" answerable from
238
+ # a single read.
239
+ def run_summary(run)
240
+ return nil unless run
241
+
242
+ { id: run.id, outcome: run.outcome, started_at: run.started_at,
243
+ finished_at: run.finished_at, open: run.open? }
244
+ end
245
+ private_class_method :run_summary
246
+
247
+ def stored_items(items)
248
+ Array(items).map do |item|
249
+ row = item.to_h.transform_keys(&:to_s)
250
+ { id: row["id"].to_s, text: row["text"].to_s,
251
+ done: row["done"] == true, required: row.fetch("required", true) == true }
252
+ end
253
+ end
254
+ private_class_method :stored_items
255
+
256
+ def store_items(items)
257
+ items.map do |item|
258
+ { "id" => item[:id], "text" => item[:text],
259
+ "done" => item[:done] == true, "required" => item.fetch(:required, true) == true }
260
+ end
261
+ end
262
+ private_class_method :store_items
263
+ end
264
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Jazari
6
+ # Jazari is a plain gem, NOT an engine: it contributes no routes, controllers,
7
+ # views, or assets, and a host should not have to mount anything to use it.
8
+ #
9
+ # But its models live in `app/models`, and nothing puts that on a host's
10
+ # autoload path by itself. Without this the gem loads, `Jazari.resolve` is
11
+ # callable, and the first call dies on `uninitialized constant
12
+ # Jazari::RecipeRecord` — which is exactly how the first host adoption found
13
+ # it. The gem's own suite had masked it with `require_relative`.
14
+ #
15
+ # A Railtie is the smallest thing that fixes it: autoload paths only, no
16
+ # engine, nothing mounted.
17
+ class Railtie < ::Rails::Railtie
18
+ config.before_configuration do
19
+ models = File.expand_path("../../app/models", __dir__)
20
+ ActiveSupport::Dependencies.autoload_paths << models
21
+ Rails.autoloaders.main.push_dir(models) if Rails.respond_to?(:autoloaders)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Jazari
7
+ # Whether a ritual may run more than once in a day is a property of the
8
+ # RITUAL, not a global rule: verifying a backup should happen once a day;
9
+ # triaging an incident may happen five times.
10
+ module RunPolicy
11
+ UNRESTRICTED = "unrestricted"
12
+ ONCE_PER_CALENDAR_DAY = "once_per_calendar_day"
13
+ ALL = [ UNRESTRICTED, ONCE_PER_CALENDAR_DAY ].freeze
14
+ end
15
+
16
+ # The canon. Content is data, not code — the gem ships no recipe content at
17
+ # all, only this shape and the empty fallback. Editing a recipe changes its
18
+ # digest, so outstanding default revisions conflict instead of drifting
19
+ # silently under someone who is mid-checklist.
20
+ Recipe = Data.define(:id, :version, :topic, :description, :checklist, :run_policy) do
21
+ def initialize(id:, version:, topic:, description:, checklist:,
22
+ run_policy: RunPolicy::UNRESTRICTED)
23
+ unless RunPolicy::ALL.include?(run_policy)
24
+ raise InvalidRunbook, "unknown run_policy #{run_policy.inspect}"
25
+ end
26
+
27
+ super(
28
+ id: id.freeze, version: version, topic: topic.freeze,
29
+ description: description.freeze, checklist: Checklist.freeze_items(checklist),
30
+ run_policy: run_policy.freeze
31
+ )
32
+ end
33
+
34
+ def digest
35
+ Digest::SHA256.hexdigest(
36
+ JSON.generate([ id, version, topic, description, checklist, run_policy ])
37
+ )[0, 16]
38
+ end
39
+
40
+ def once_per_calendar_day? = run_policy == RunPolicy::ONCE_PER_CALENDAR_DAY
41
+
42
+ def provenance = { id: id, version: version, digest: digest }
43
+ end
44
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jazari
4
+ # Recipes are data, not code. The gem owns the lookup mechanism, the
5
+ # digest-based default-revision guard, an idempotent seed, and one
6
+ # content-free fallback. It ships no recipe content.
7
+ #
8
+ # A missing recipe is not an error and performs zero writes.
9
+ module RecipeRegistry
10
+ EMPTY = Recipe.new(
11
+ id: "core.empty.v1", version: 1, topic: "", description: "",
12
+ checklist: [], run_policy: RunPolicy::UNRESTRICTED
13
+ ).freeze
14
+
15
+ module_function
16
+
17
+ def fetch(recipe_id)
18
+ RecipeRecord.find_by(recipe_id: recipe_id.to_s)&.to_recipe || EMPTY
19
+ end
20
+
21
+ # Create-if-missing. Existing records are operator-owned: reseeding never
22
+ # overwrites them, because the operator's edit is the truth once it exists.
23
+ def seed!(entries)
24
+ Array(entries).map do |entry|
25
+ attributes = entry.to_h.transform_keys(&:to_sym)
26
+ items = Checklist.normalize(attributes.fetch(:checklist, []))
27
+ RecipeRecord.find_or_create_by!(recipe_id: attributes.fetch(:id).to_s) do |record|
28
+ record.version = attributes.fetch(:version, 1)
29
+ record.topic = attributes.fetch(:topic)
30
+ record.description = attributes.fetch(:description, "").to_s
31
+ record.run_policy = attributes.fetch(:run_policy, RunPolicy::UNRESTRICTED)
32
+ record.checklist = items.map { |item| item.transform_keys(&:to_s) }
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jazari
4
+ # Every operation returns this. Never an unsaved ActiveRecord object.
5
+ ResolvedRunbook = Data.define(
6
+ :state, :revision, :topic, :description, :checklist, :progress,
7
+ :target_reference, :recipe, :last_run
8
+ ) do
9
+ def initialize(state:, revision:, topic:, description:, checklist:,
10
+ target_reference:, recipe:, last_run: nil)
11
+ items = Checklist.freeze_items(checklist)
12
+ super(
13
+ state: state.freeze, revision: revision, topic: topic.freeze,
14
+ description: description.freeze, checklist: items,
15
+ progress: Checklist.progress(items).freeze,
16
+ target_reference: target_reference.freeze, recipe: recipe.freeze,
17
+ last_run: last_run.freeze
18
+ )
19
+ end
20
+
21
+ def default? = state == "default"
22
+ def custom? = state == "custom"
23
+ end
24
+ end