jazari 0.2.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 781a303d10e42b7e3337381b7d167aaeb227a5cf538e4122f1b37c2143216f8f
4
- data.tar.gz: b6c2d6ca19ccc6d097ea30ddc644f2f58144d6fc9d6a74722e2f625c7502ba18
3
+ metadata.gz: c1e44727af4c1cfd7ca851c84618d9015f39aec562c3133301772f6d97b21076
4
+ data.tar.gz: 8c87563ee6acfbe91b8544274c981dbb98fb24ac05e77cb170f7e33033067f2d
5
5
  SHA512:
6
- metadata.gz: 9d42855d9257d25cb193423c6864db7451006ae2b07fe9b013442b2cf3c3c1e8f1ceca6208e5e23b1a8f60d1d57b5502ea31e5859e81734dd3a1412f0e8accd4
7
- data.tar.gz: 156a565d0e8ab2b8f8f51dbb335c0410f60484da523ba9a1de6fd216bf3377f720cc3909722bed78f3854b5a836ea126a89106831e57c7c3ac9fa160802731f9
6
+ metadata.gz: dec279a4cedbcf0377a512298300487316e04a6ac3dfa9059e37415edcfba07ba0015b11d1d8d2b08d6bf4fd33ec557d87a507a216a095d66a52bdc5a4441f6e
7
+ data.tar.gz: 148962f5b5f9762f35cc04ba285485c4e76e5de41ef086ea0dafcf4861cd2e9b1110c299ee39ddd45abe491e13a473c0477b66bac914306c7cc9540b6142e382
data/CHANGELOG.md CHANGED
@@ -8,6 +8,61 @@ codes, the resolved-value shape, how revisions are computed, and the schema the
8
8
  generator emits — changes to any of those are breaking even when the method
9
9
  signatures do not move.
10
10
 
11
+ ## [0.3.0] - 2026-08-11
12
+
13
+ ### Added
14
+
15
+ - **`origin` on the runbook — provenance, so divergence means something.**
16
+ `custom?` answers "does a row exist", which a host adopting jazari cannot use
17
+ as a divergence signal: a backfill materializes a row for every subject at
18
+ once, so the morning after a migration everything reads as diverged and the
19
+ signal carries no information. Comparing content against the recipe does not
20
+ separate them either — a backfilled runbook *genuinely* differs, because it
21
+ carries the steps that subject actually had. Only provenance can.
22
+
23
+ `origin` is a nullable, host-defined string saying **why the row exists**.
24
+ `ResolvedRunbook` gains `#origin`, plus `#inherited?` (a row something claims
25
+ to have manufactured) and `#diverged?` (a row nobody claims, i.e. someone
26
+ decided it). `Jazari.customize` takes an optional `origin:`.
27
+
28
+ Two behaviours make the marker honest rather than decorative:
29
+
30
+ - **`customize` restates it, defaulting to nil.** Rewriting a procedure is a
31
+ decision, so an operator edit clears an inherited marker — the claim about
32
+ how the row came to exist stops being true the moment someone edits it.
33
+ - **Item operations preserve it.** `check_item`, `add_item` and `remove_item`
34
+ leave `origin` untouched, because performing a procedure is not rewriting
35
+ it. Without this, the first person to tick a box would silently convert a
36
+ migration artifact into a deliberate divergence.
37
+
38
+ Raised by a host adoption, where the backfill would otherwise have made six
39
+ of six subjects read as diverged on day one.
40
+
41
+ ### Changed
42
+
43
+ - **`ResolvedRunbook` carries a new member (`origin`).** Positional
44
+ construction and exhaustive destructuring break; keyword construction and
45
+ member access do not. It defaults to nil, so hosts that ignore it are
46
+ unaffected.
47
+
48
+ ### Migration
49
+
50
+ Already installed? `rails g jazari:upgrade` copies the one additive, nullable
51
+ column. It is safe to run ahead of any code that writes it — NULL is truthful
52
+ for every existing row, meaning "this predates provenance". New installs get
53
+ the column from `jazari:install`.
54
+
55
+ ## [0.2.1] - 2026-08-10
56
+
57
+ ### Fixed
58
+
59
+ - **Table names are resolved lazily, not assigned at class-definition time.**
60
+ The binding previously depended on load order: under Zeitwerk a host's
61
+ `Jazari.configure` runs before the model constants autoload, so the
62
+ assignment never happened and the models silently kept the gem's default
63
+ names. A host that had adopted *existing* tables then queried tables that did
64
+ not exist. Found by a third host adoption.
65
+
11
66
  ## [0.2.0] - 2026-08-10
12
67
 
13
68
  ### Added
@@ -5,7 +5,14 @@ module Jazari
5
5
  # a JSON-tree node, a file path, a DNS zone, a document. The scope is
6
6
  # host-registered; the gem ships no default scope naming any real model.
7
7
  class Anchor < ApplicationRecord
8
- self.table_name = "jazari_anchors"
8
+ # Resolved on EVERY call, not assigned at class-definition time.
9
+ #
10
+ # Assigning it eagerly made the binding depend on load order: under Zeitwerk
11
+ # a host's `configure` runs before these constants autoload, so the
12
+ # assignment never happened and the model silently kept the gem's default
13
+ # name. A host adopting existing tables then queried tables that do not
14
+ # exist. Reading config here removes the ordering question entirely.
15
+ def self.table_name = Jazari.table_name_for(:anchors)
9
16
 
10
17
  has_one :runbook, as: :runbookable, dependent: :destroy
11
18
 
@@ -5,7 +5,14 @@ module Jazari
5
5
  # is the immutable value the domain passes around, and letting an ActiveRecord
6
6
  # object wear that name is how unsaved records leak into return values.
7
7
  class RecipeRecord < ApplicationRecord
8
- self.table_name = "jazari_recipes"
8
+ # Resolved on EVERY call, not assigned at class-definition time.
9
+ #
10
+ # Assigning it eagerly made the binding depend on load order: under Zeitwerk
11
+ # a host's `configure` runs before these constants autoload, so the
12
+ # assignment never happened and the model silently kept the gem's default
13
+ # name. A host adopting existing tables then queried tables that do not
14
+ # exist. Reading config here removes the ordering question entirely.
15
+ def self.table_name = Jazari.table_name_for(:recipes)
9
16
 
10
17
  POLICIES = Jazari::RunPolicy::ALL
11
18
 
@@ -8,7 +8,14 @@ module Jazari
8
8
  # that separation is the whole point: `reset` on a runbook must not be able to
9
9
  # destroy the record that the ritual ever ran.
10
10
  class Run < ApplicationRecord
11
- self.table_name = "jazari_runs"
11
+ # Resolved on EVERY call, not assigned at class-definition time.
12
+ #
13
+ # Assigning it eagerly made the binding depend on load order: under Zeitwerk
14
+ # a host's `configure` runs before these constants autoload, so the
15
+ # assignment never happened and the model silently kept the gem's default
16
+ # name. A host adopting existing tables then queried tables that do not
17
+ # exist. Reading config here removes the ordering question entirely.
18
+ def self.table_name = Jazari.table_name_for(:runs)
12
19
 
13
20
  belongs_to :subject, polymorphic: true, optional: true
14
21
 
@@ -4,7 +4,14 @@ module Jazari
4
4
  # One subject's override of the canon. Materialized on first customization —
5
5
  # never on read.
6
6
  class Runbook < ApplicationRecord
7
- self.table_name = "jazari_runbooks"
7
+ # Resolved on EVERY call, not assigned at class-definition time.
8
+ #
9
+ # Assigning it eagerly made the binding depend on load order: under Zeitwerk
10
+ # a host's `configure` runs before these constants autoload, so the
11
+ # assignment never happened and the model silently kept the gem's default
12
+ # name. A host adopting existing tables then queried tables that do not
13
+ # exist. Reading config here removes the ordering question entirely.
14
+ def self.table_name = Jazari.table_name_for(:runbooks)
8
15
 
9
16
  belongs_to :runbookable, polymorphic: true
10
17
 
@@ -31,6 +31,12 @@ class CreateJazariTables < ActiveRecord::Migration[7.1]
31
31
  t.string :topic, null: false
32
32
  t.text :description, null: false, default: ""
33
33
  t.jsonb :checklist, null: false, default: []
34
+ # Provenance, host-defined and nullable. A runbook that exists because a
35
+ # backfill created it is a migration artifact; one that exists because an
36
+ # operator edited it is a decision. Both differ from the canon, so
37
+ # comparing content cannot tell them apart — only provenance can, and NULL
38
+ # honestly means "we did not record why".
39
+ t.string :origin
34
40
  t.integer :lock_version, null: false, default: 0
35
41
  t.timestamps
36
42
  t.index %i[runbookable_type runbookable_id], unique: true
@@ -0,0 +1,10 @@
1
+ # jazari 0.3.0 — provenance on the runbook.
2
+ #
3
+ # Nullable and additive, in one deploy, because nothing reads it yet and NULL is
4
+ # a truthful value: it means "this row predates provenance", which is exactly
5
+ # what every existing row is.
6
+ class AddJazariRunbookOrigin < ActiveRecord::Migration[7.1]
7
+ def change
8
+ add_column :jazari_runbooks, :origin, :string
9
+ end
10
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Jazari
7
+ module Generators
8
+ # For hosts that already installed the tables. Schema changes are copied,
9
+ # never applied on the gem's own initiative, for the same reason install is
10
+ # explicit: a shared operations table changing shape inside someone else's
11
+ # `db:migrate` is not a change they agreed to.
12
+ class UpgradeGenerator < ::Rails::Generators::Base
13
+ include ::ActiveRecord::Generators::Migration
14
+
15
+ source_root File.expand_path("templates", __dir__)
16
+
17
+ desc "Copies the schema changes an already-installed host needs. PostgreSQL only."
18
+
19
+ def copy_migration
20
+ migration_template "add_jazari_runbook_origin.rb", "db/migrate/add_jazari_runbook_origin.rb"
21
+ end
22
+
23
+ def report
24
+ say ""
25
+ say "jazari: upgrade migration copied. It is additive and nullable, so it"
26
+ say "is safe to run ahead of the code that writes the column."
27
+ say ""
28
+ end
29
+ end
30
+ end
31
+ end
@@ -23,11 +23,14 @@ module Jazari
23
23
  default_value(recipe, target, last)
24
24
  end
25
25
 
26
- def customize(target:, expected_revision:, topic:, description:, checklist:)
26
+ # `origin` records WHY this row exists, for hosts that materialize runbooks
27
+ # themselves — a backfill, an import, a template. Leave it nil for an operator
28
+ # edit, which is the default because that is the ordinary case.
29
+ def customize(target:, expected_revision:, topic:, description:, checklist:, origin: nil)
27
30
  writable!(target)
28
31
  validate_document!(topic, description)
29
32
  items = Checklist.normalize(checklist)
30
- write(target, expected_revision) do |current|
33
+ write(target, expected_revision, origin: origin) do |current|
31
34
  current.merge(topic: topic, description: description, checklist: items)
32
35
  end
33
36
  end
@@ -112,25 +115,35 @@ module Jazari
112
115
  end
113
116
  private_class_method :writable!
114
117
 
115
- def write(target, expected_revision)
118
+ # `origin` distinguishes REWRITING the procedure from PERFORMING it, so the
119
+ # sentinel is not cosmetic. Rewriting it (customize) restates why the row
120
+ # exists, and passing nil there is an operator claiming it as their own.
121
+ # Ticking an item is doing the work the row already describes, and must
122
+ # leave that claim alone — otherwise the first person to check a box
123
+ # silently converts a migration artifact into a deliberate divergence.
124
+ KEEP_ORIGIN = :keep
125
+
126
+ def write(target, expected_revision, origin: KEEP_ORIGIN)
116
127
  record = find_runbook(target)
117
128
  if record
118
129
  record.with_lock do
119
130
  verify_custom_revision!(record, expected_revision)
120
131
  document = yield(current_document(record))
121
- record.update!(
132
+ attributes = {
122
133
  topic: document[:topic], description: document[:description],
123
134
  checklist: store_items(document[:checklist])
124
- )
135
+ }
136
+ attributes[:origin] = origin unless origin == KEEP_ORIGIN
137
+ record.update!(attributes)
125
138
  end
126
139
  else
127
- create_custom(target, expected_revision) { |current| yield(current) }
140
+ create_custom(target, expected_revision, origin: origin) { |current| yield(current) }
128
141
  end
129
142
  resolve(target: target)
130
143
  end
131
144
  private_class_method :write
132
145
 
133
- def create_custom(target, expected_revision)
146
+ def create_custom(target, expected_revision, origin: KEEP_ORIGIN)
134
147
  verify_default_revision!(target, expected_revision)
135
148
  recipe = RecipeRegistry.fetch(target.recipe_id)
136
149
  document = yield(
@@ -141,7 +154,9 @@ module Jazari
141
154
  runbookable: runbookable_for(target),
142
155
  recipe_id: recipe.id,
143
156
  topic: document[:topic], description: document[:description],
144
- checklist: store_items(document[:checklist])
157
+ checklist: store_items(document[:checklist]),
158
+ # A brand-new row has no prior claim to keep, so the sentinel means nil.
159
+ origin: (origin == KEEP_ORIGIN ? nil : origin)
145
160
  )
146
161
  rescue ActiveRecord::RecordNotUnique
147
162
  raise RevisionConflict, "another writer materialized this runbook first"
@@ -219,7 +234,7 @@ module Jazari
219
234
  state: "custom", revision: record.lock_version, topic: record.topic,
220
235
  description: record.description, checklist: stored_items(record.checklist),
221
236
  target_reference: target.public_reference, recipe: recipe.provenance,
222
- last_run: run_summary(last)
237
+ last_run: run_summary(last), origin: record.origin
223
238
  )
224
239
  end
225
240
  private_class_method :custom_value
@@ -4,21 +4,35 @@ module Jazari
4
4
  # Every operation returns this. Never an unsaved ActiveRecord object.
5
5
  ResolvedRunbook = Data.define(
6
6
  :state, :revision, :topic, :description, :checklist, :progress,
7
- :target_reference, :recipe, :last_run
7
+ :target_reference, :recipe, :last_run, :origin
8
8
  ) do
9
9
  def initialize(state:, revision:, topic:, description:, checklist:,
10
- target_reference:, recipe:, last_run: nil)
10
+ target_reference:, recipe:, last_run: nil, origin: nil)
11
11
  items = Checklist.freeze_items(checklist)
12
12
  super(
13
13
  state: state.freeze, revision: revision, topic: topic.freeze,
14
14
  description: description.freeze, checklist: items,
15
15
  progress: Checklist.progress(items).freeze,
16
16
  target_reference: target_reference.freeze, recipe: recipe.freeze,
17
- last_run: last_run.freeze
17
+ last_run: last_run.freeze, origin: origin&.freeze
18
18
  )
19
19
  end
20
20
 
21
21
  def default? = state == "default"
22
22
  def custom? = state == "custom"
23
+
24
+ # `custom?` answers "does a row exist", which is not the same question as
25
+ # "did someone decide this". A backfill materializes a row for every subject
26
+ # it touches, so a host reading `custom?` as divergence sees 100% divergence
27
+ # the morning after a migration and learns nothing from it.
28
+ #
29
+ # Comparing content against the canon does not separate them either: a
30
+ # backfilled runbook genuinely differs, because it carries the steps the
31
+ # subject actually had. Only provenance can, so only provenance is asked.
32
+ def inherited? = custom? && !origin.nil?
33
+
34
+ # Divergence someone chose: a row that exists, with nothing claiming to have
35
+ # manufactured it.
36
+ def diverged? = custom? && origin.nil?
23
37
  end
24
38
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Jazari
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/jazari.rb CHANGED
@@ -70,25 +70,11 @@ module Jazari
70
70
  config.table_names[key]&.to_s || "#{config.table_prefix}#{TABLES.fetch(key)}"
71
71
  end
72
72
 
73
- # A host may adopt these tables under existing names rather than renaming
74
- # live tables in the same deploy as the cut-over.
75
- #
76
- # CONSTRAINT: ActiveRecord table names are process-global class state, so the
77
- # prefix is a BOOT-TIME setting for the whole process. It is not per-request,
78
- # per-thread, or per-tenant, and two hosts in one process cannot hold
79
- # different prefixes. Call `configure` once, at boot, after the models are
80
- # loaded; `models_loaded?` reports whether the binding actually took effect
81
- # so a host can assert it instead of silently running on default names.
73
+ # Kept for hosts that call it explicitly; the models now resolve their own
74
+ # names on every call, so nothing depends on this having run.
82
75
  def self.models_loaded? = const_defined?(:RecipeRecord)
83
76
 
84
- def self.apply_table_names!
85
- return false unless models_loaded?
86
-
87
- { RecipeRecord: :recipes, Runbook: :runbooks, Anchor: :anchors, Run: :runs }.each do |klass, key|
88
- const_get(klass).table_name = table_name_for(key)
89
- end
90
- true
91
- end
77
+ def self.apply_table_names! = models_loaded?
92
78
 
93
79
  def self.config = configuration || configure
94
80
 
@@ -117,9 +103,10 @@ module Jazari
117
103
 
118
104
  def resolve(target:) = Operations.resolve(target: target)
119
105
 
120
- def customize(target:, expected_revision:, topic:, description:, checklist:)
106
+ def customize(target:, expected_revision:, topic:, description:, checklist:, origin: nil)
121
107
  Operations.customize(target: target, expected_revision: expected_revision,
122
- topic: topic, description: description, checklist: checklist)
108
+ topic: topic, description: description, checklist: checklist,
109
+ origin: origin)
123
110
  end
124
111
 
125
112
  def add_item(target:, expected_revision:, text:, required: true)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jazari
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq
@@ -56,6 +56,8 @@ files:
56
56
  - app/models/jazari/runbook.rb
57
57
  - lib/generators/jazari/install/install_generator.rb
58
58
  - lib/generators/jazari/install/templates/create_jazari_tables.rb
59
+ - lib/generators/jazari/upgrade/templates/add_jazari_runbook_origin.rb
60
+ - lib/generators/jazari/upgrade/upgrade_generator.rb
59
61
  - lib/jazari.rb
60
62
  - lib/jazari/anchors.rb
61
63
  - lib/jazari/checklist.rb