jazari 0.5.2 → 0.7.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: faa1d648cc4474b0e9dbf576b32f4f856037c1f15592ceb84b73ce46a7aedc8b
4
- data.tar.gz: e74d3afaef8633e4c4fc66122fcfe57f1f1a9d862fe49005e828fd4ed22d9672
3
+ metadata.gz: a2e678c6397f2809010bf357f7bce0f95a6dd41ec910b4f3b9ffad7999c910f4
4
+ data.tar.gz: 34213c339b09aacbac45a878d878935f8160a55f13e8c0a5f2189cd70d96ef75
5
5
  SHA512:
6
- metadata.gz: fed7a9910117ed6a53eebac98b15544b4864741b84da262320c21c4599363b8f017d6a5cd39be54798bff35b7542866802b5545a238dfaf086ed684f41901cc5
7
- data.tar.gz: 0bfa18d167b05a8189c1ed8c53a67303fb8b014eb83fa1ff260fb382a60d028d30aeec1c6372d484f1f1c09cc603c985f9fcfa99af38e95d6fc25943047eec4f
6
+ metadata.gz: b880ae1c427030d149979921ea489c637a71cec89bd42018dec05bee415d4baa852c8a6a08ce4e49ac7dff2a24cc07a84fafec91e7d515276928c64476586f1b
7
+ data.tar.gz: 47fb2f0a403a85a1b6394116d6e3b577481b326a31485c28193a3697ce1ca397b9046d5487e96a9d79e6edd3fa5b8bf8e06e5fee29c99f865751758332f42f57
data/CHANGELOG.md CHANGED
@@ -8,6 +8,75 @@ 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.7.0] - 2026-08-30
12
+
13
+ ### Changed
14
+
15
+ - **The checklist bound is now BYTES, not rows.** `MAX_PAYLOAD = 65_536`,
16
+ measured on the normalized stored form — the string-keyed rows actually
17
+ written to jsonb, never the caller's input and never an MCP envelope. Input
18
+ arrives in four shapes and envelopes differ per host, so counting either
19
+ would make the same document legal on one path and illegal on another.
20
+
21
+ **`MAX_ITEMS` is removed — there is no row cap at all.** The 50 was inherited
22
+ from a host that already had it and was never benchmarked: a measured 50-step
23
+ procedure serializes to ~16.7 KB at 278 characters a step, so the row cap was
24
+ rejecting documents four times smaller than what actually hurts. An interim
25
+ draft kept a cap at 200 as a "guard against absurdity"; that was as unmeasured
26
+ as the 50, and would have rejected a 201-step document sitting well inside the
27
+ byte bound for exactly the old reason. The absurdity case is already answered
28
+ by measurement — a normalized minimal step is 67 bytes, so `MAX_PAYLOAD`
29
+ admits 992 of them and no more. Hosts referencing `Jazari::Checklist::MAX_ITEMS`
30
+ must switch to `MAX_PAYLOAD`.
31
+
32
+ One validator now covers every path — whole-document writes, recipe files,
33
+ and the 0.6.0 late snapshot widening. `add_item` routes through `normalize`
34
+ rather than `validate!`, closing the hole where a document could be walked
35
+ past the ceiling one step at a time.
36
+
37
+ `MAX_TEXT` is unchanged at 500.
38
+
39
+ ### Fixed
40
+
41
+ - **The published MCP `checklist` schema was an untyped `array`.** A client
42
+ generating from the descriptor could infer `string[]`, send `["step one"]`,
43
+ and be rejected by the domain with "checklist item must be a hash" — the
44
+ schema and the validator disagreed, and only the client found out. It now
45
+ publishes the object shape the validator enforces — `text` required with
46
+ `minLength`/`maxLength`, `id` carrying the real `pattern`, `additionalProperties:
47
+ false` matching the key allowlist — and discloses the aggregate byte bound in
48
+ its description, since JSON Schema cannot express it and an undocumented limit
49
+ is the same failure as an untyped array.
50
+
51
+ The contract test asserts **containment**: nothing the published schema admits
52
+ may be rejected by the domain. `done` is the one deliberate exception in the
53
+ safe direction — the schema publishes `boolean` while the domain silently
54
+ coerces `"yes"` to `false`, so a client is told up front rather than misled
55
+ afterwards.
56
+
57
+ ## [0.6.0] - 2026-08-15
58
+
59
+ ### Added
60
+
61
+ - **A step discovered mid-run is legal.** `tick` now accepts an item that was
62
+ added to the subject's own runbook after the run opened: it widens the run's
63
+ `checklist_snapshot` to carry the item and marks both the snapshot entry and
64
+ the tick with `post_snapshot: true`. Reported from a production deploy where
65
+ a host's canonical `check_item` had already committed when `tick` raised —
66
+ the caller was told a write failed while it stood, and retrying a
67
+ half-applied two-phase write is how a double record happens.
68
+
69
+ A **recipe** change mid-run is still refused. Those are different events: the
70
+ canon moving underneath an in-flight run is someone else's edit arriving
71
+ uninvited, and that is what the snapshot exists to hold out. A queue run has
72
+ no subject, so its checklist is the recipe and it never widens.
73
+
74
+ - `Jazari::ItemNotInSnapshot` (code `item_not_in_snapshot`), raised when the
75
+ item exists in the canon but post-dates the run's snapshot. It subclasses
76
+ `ItemNotFound`, so hosts rescuing the old class keep working, and hosts that
77
+ need to choose between failing, retrying and proceeding stop matching on the
78
+ message text.
79
+
11
80
  ## [0.5.2] - 2026-08-12
12
81
 
13
82
  ### Fixed
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require "securerandom"
4
5
 
5
6
  module Jazari
@@ -7,7 +8,31 @@ module Jazari
7
8
  # opaque token, never array position: MCP has to be able to check one item
8
9
  # without knowing where it sits.
9
10
  module Checklist
10
- MAX_ITEMS = 50
11
+ # The real bound is BYTES, not rows.
12
+ #
13
+ # 50 was inherited from a host that already had it, never benchmarked. A
14
+ # 50-item procedure measured live serializes to 17,252 bytes at 278
15
+ # characters per step (codex, on the session-anchor board; the suite's
16
+ # synthetic equivalent is 16,691, since real ids and text are not uniform), so the row cap was rejecting documents four times smaller than
17
+ # what actually hurts — and would have accepted 50 steps of 500 characters,
18
+ # which is nearly twice as large again.
19
+ #
20
+ # What breaks is a payload: a jsonb column, a run snapshot copied per run,
21
+ # and an MCP response that has to fit in a context window. None of those
22
+ # count rows.
23
+ MAX_PAYLOAD = 65_536
24
+
25
+ # There is deliberately NO row cap. The first draft kept one at 200 as a
26
+ # "guard against absurdity" — but 200 was as unmeasured as the 50 it
27
+ # replaced, and it would still have rejected a 201-step document sitting
28
+ # well inside the byte bound for exactly the old reason.
29
+ #
30
+ # The absurdity case is already answered by measurement: a normalized
31
+ # minimal step is 67 bytes — the generated id is 16 of them — so MAX_PAYLOAD
32
+ # admits 992 of them and no more. One bound, derived, with nothing left to
33
+ # pick out of the air. (An earlier draft of this comment said ~1,090 by
34
+ # estimating the row at 60 bytes instead of measuring it after id
35
+ # generation. Measured, not reasoned, is the whole point of the change.)
11
36
  MAX_TEXT = 500
12
37
  ID_FORMAT = /\A[A-Za-z0-9_-]{1,64}\z/
13
38
 
@@ -21,18 +46,58 @@ module Jazari
21
46
  def normalize(items)
22
47
  list = validate!(items)
23
48
  seen = []
24
- list.map do |item|
49
+ normalized = list.map do |item|
25
50
  id = item[:id].to_s
26
51
  id = generate_id unless id.match?(ID_FORMAT) && !seen.include?(id)
27
52
  seen << id
28
53
  { id: id, text: item[:text].to_s, done: item[:done] == true,
29
54
  required: item.fetch(:required, true) == true }
30
55
  end
56
+ bound!(normalized)
57
+ end
58
+
59
+ # Measured on the NORMALIZED STORED FORM — the string-keyed rows actually
60
+ # written to jsonb — and never on the caller's input or on an MCP envelope.
61
+ #
62
+ # That choice is the whole contract. Input arrives in four shapes (symbol
63
+ # keys, string keys, a YAML file, a Ruby literal) and an envelope differs
64
+ # per host, so counting either would make the same document legal in one
65
+ # path and illegal in another. The stored form is the one representation
66
+ # every path converges on, and ids are already assigned by the time it
67
+ # exists — so the number is deterministic and reproducible by a host that
68
+ # wants to check before it calls.
69
+ def payload_bytes(items) = serialized_bytes(stored_form(items))
70
+
71
+ # Counts rows VERBATIM. A run snapshot carries a key the canonical checklist
72
+ # does not (`post_snapshot`), and projecting it away before measuring would
73
+ # undercount the value actually written — by a margin that grows with every
74
+ # late step. Measure the bytes that land in the column.
75
+ def serialized_bytes(rows) = JSON.generate(rows).bytesize
76
+
77
+ def stored_form(items)
78
+ items.map do |item|
79
+ row = item.to_h.transform_keys(&:to_s)
80
+ { "id" => row["id"].to_s, "text" => row["text"].to_s,
81
+ "done" => row["done"] == true, "required" => row.fetch("required", true) == true }
82
+ end
83
+ end
84
+
85
+ def bound!(items) = bound_bytes!(payload_bytes(items), items)
86
+
87
+ # For rows that are already in their stored shape, extra keys included.
88
+ def bound_stored!(rows) = bound_bytes!(serialized_bytes(rows), rows)
89
+
90
+ def bound_bytes!(bytes, items)
91
+ if bytes > MAX_PAYLOAD
92
+ raise InvalidRunbook,
93
+ "checklist payload is #{bytes} bytes, over the #{MAX_PAYLOAD} limit"
94
+ end
95
+
96
+ items
31
97
  end
32
98
 
33
99
  def validate!(items)
34
100
  raise InvalidRunbook, "checklist must be an array" unless items.is_a?(Array)
35
- raise InvalidRunbook, "checklist exceeds #{MAX_ITEMS} items" if items.length > MAX_ITEMS
36
101
 
37
102
  items.map do |item|
38
103
  raise InvalidRunbook, "checklist item must be a hash" unless item.is_a?(Hash)
data/lib/jazari/errors.rb CHANGED
@@ -12,6 +12,13 @@ module Jazari
12
12
  class InvalidRunbook < Error; end
13
13
  class RevisionConflict < Error; end
14
14
  class ItemNotFound < Error; end
15
+
16
+ # The item exists — it just post-dates the snapshot this run froze at open.
17
+ # A subclass, so a host that already rescues ItemNotFound keeps working, and
18
+ # one that wants to tell "no such step" from "not this run's step" can.
19
+ # Without the distinction a caller cannot choose between failing, retrying,
20
+ # and proceeding, and ends up matching on prose.
21
+ class ItemNotInSnapshot < ItemNotFound; end
15
22
  class ReadOnlyTarget < Error; end
16
23
  class RunClosed < Error; end
17
24
  end
@@ -1,5 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # The descriptors quote the domain's own limits rather than restating them, so
4
+ # the schema cannot drift from the validator. That means this file needs
5
+ # Checklist even when it is required on its own — which the standalone-require
6
+ # test exists to catch, and did.
7
+ require "jazari/checklist"
8
+
3
9
  module Jazari
4
10
  module Mcp
5
11
  # Declarative descriptors for every action, so a host can absorb jazari into
@@ -28,7 +34,47 @@ module Jazari
28
34
 
29
35
  REVISION = { type: "string",
30
36
  description: "The revision from the read immediately before this call." }.freeze
37
+ # JSON Schema patterns are ECMAScript, which has no `\A`/`\z`. Publishing
38
+ # Ruby's source verbatim hands validators an anchor they may reject or,
39
+ # worse, read as a literal — so translate the anchors and keep everything
40
+ # else derived from ID_FORMAT, which stays the single source of truth.
41
+ ID_PATTERN = Checklist::ID_FORMAT.source.sub('\\A', "^").sub('\\z', "$").freeze
42
+
31
43
  ITEM_ID = { type: "string", description: "Opaque checklist item id." }.freeze
44
+
45
+ # An `array` with no `items` is not a contract, it is a guess. A client
46
+ # generating from that descriptor is free to infer `string[]`, send
47
+ # `["step one"]`, and be rejected by the domain with "checklist item must
48
+ # be a hash" — which is what happened. The schema must publish the shape
49
+ # the validator actually enforces, or the descriptor is documentation
50
+ # that disagrees with the code.
51
+ CHECKLIST_ITEM = {
52
+ type: "object",
53
+ required: [ "text" ],
54
+ properties: {
55
+ id: { type: "string", pattern: ID_PATTERN, maxLength: 64,
56
+ description: "Opaque id. Omit to have one generated; send it back to keep a step " \
57
+ "stable across edits. An id that does not match the pattern is REPLACED " \
58
+ "with a generated one rather than rejected." },
59
+ # minLength/maxLength, not just "string": empty text and 501 characters
60
+ # are both rejected by the domain, and a schema that admits them makes
61
+ # the client discover it at call time.
62
+ text: { type: "string", minLength: 1, maxLength: Checklist::MAX_TEXT,
63
+ description: "The step. Required, non-empty." },
64
+ done: { type: "boolean", description: "Default false." },
65
+ required: { type: "boolean", description: "Whether the step gates completion. Default true." }
66
+ },
67
+ additionalProperties: false
68
+ }.freeze
69
+
70
+ # The one domain rule JSON Schema cannot express is the aggregate byte
71
+ # bound, so it is DISCLOSED rather than left for the client to discover by
72
+ # being rejected. An undocumented limit is the same failure as an untyped
73
+ # array: the schema knows something the caller does not.
74
+ CHECKLIST = { type: "array", items: CHECKLIST_ITEM,
75
+ description: "Full checklist; replaces the existing one. There is no row limit, " \
76
+ "but the serialized checklist must be at most " \
77
+ "#{Checklist::MAX_PAYLOAD} bytes." }.freeze
32
78
  RUN_ID = { type: "integer", description: "The run returned by start." }.freeze
33
79
  ACTOR = { type: "string", description: "Opaque identity of who is acting." }.freeze
34
80
 
@@ -44,7 +90,7 @@ module Jazari
44
90
  params: { expected_revision: REVISION,
45
91
  topic: { type: "string", description: "Short title." },
46
92
  description: { type: "string", description: "Markdown body." },
47
- checklist: { type: "array", description: "Full checklist; replaces the existing one." } }),
93
+ checklist: CHECKLIST }),
48
94
  Action.new(name: "add_item", scope: :write, effect: :additive, confirm: false,
49
95
  summary: "Append one checklist step.",
50
96
  params: { expected_revision: REVISION,
@@ -38,10 +38,12 @@ module Jazari
38
38
  def add_item(target:, expected_revision:, text:, required: true)
39
39
  writable!(target)
40
40
  write(target, expected_revision) do |current|
41
- items = current[:checklist] + [
42
- { id: Checklist.generate_id, text: text.to_s, done: false, required: required == true }
43
- ]
44
- Checklist.validate!(items)
41
+ # normalize, not validate! — one choke point, so a step appended one at
42
+ # a time cannot walk past a bound that a whole-document write enforces.
43
+ items = Checklist.normalize(
44
+ current[:checklist] + [ { id: Checklist.generate_id, text: text.to_s,
45
+ done: false, required: required == true } ]
46
+ )
45
47
  current.merge(checklist: items)
46
48
  end
47
49
  end
data/lib/jazari/runs.rb CHANGED
@@ -85,12 +85,15 @@ module Jazari
85
85
  end
86
86
 
87
87
  known = snapshot.map { |item| item["id"] }
88
- raise ItemNotFound, "unknown checklist item #{item_id}" unless known.include?(item_id.to_s)
88
+ late = admit_late_item!(record, snapshot, item_id) unless known.include?(item_id.to_s)
89
+ record.checklist_snapshot = snapshot + [ late ] if late
89
90
 
90
91
  ticks = stored(record.ticks).reject { |t| t["id"] == item_id.to_s }
91
- ticks << { "id" => item_id.to_s, "done" => done == true, "at" => now.utc.iso8601,
92
- "actor_ref" => resolve_actor_ref(actor_ref, fallback: record.actor_ref),
93
- "note" => note&.to_s }
92
+ tick = { "id" => item_id.to_s, "done" => done == true, "at" => now.utc.iso8601,
93
+ "actor_ref" => resolve_actor_ref(actor_ref, fallback: record.actor_ref),
94
+ "note" => note&.to_s }
95
+ tick["post_snapshot"] = true if late
96
+ ticks << tick
94
97
  record.ticks = ticks
95
98
  end
96
99
  end
@@ -158,6 +161,64 @@ module Jazari
158
161
  end
159
162
  private_class_method :mutate
160
163
 
164
+ # A step discovered mid-run is the ordinary case, not an anomaly: a deploy
165
+ # is exactly when a missing step is found. Refusing the tick pushes that
166
+ # work outside the record, which is the one thing the record exists to
167
+ # provide — so the snapshot WIDENS, and says that it did.
168
+ #
169
+ # It widens from the SUBJECT'S OWN runbook only, never from the recipe.
170
+ # Those are different events wearing the same shape:
171
+ #
172
+ # * someone performing this run added a step to this subject — legal, and
173
+ # the run should carry it;
174
+ # * the canon moved underneath an in-flight run — not legal, and the
175
+ # snapshot exists precisely to hold that line (see `open`).
176
+ #
177
+ # A queue run has no subject, so its checklist IS the recipe and it can
178
+ # never widen. That is the correct answer there, not a limitation.
179
+ def admit_late_item!(record, snapshot, item_id)
180
+ id = item_id.to_s
181
+ item = subject_checklist(record).find { |i| i["id"] == id }
182
+ unless item
183
+ # Distinguish "no such step anywhere" from "exists in the canon, but
184
+ # this run froze before it did" — the caller's response differs.
185
+ raise ItemNotInSnapshot, "checklist item #{item_id} post-dates run #{record.id}'s snapshot" if
186
+ recipe_item?(record, id)
187
+
188
+ raise ItemNotFound, "unknown checklist item #{item_id}"
189
+ end
190
+ # The same bound as every other write path. A snapshot widened one late
191
+ # step at a time is still a payload — it is copied per run, and it is what
192
+ # an MCP reader has to fit in a context window.
193
+ widened = item.merge("post_snapshot" => true)
194
+ # bound_stored!, not bound! — these rows carry `post_snapshot`, and the
195
+ # canonical projection would drop it and undercount the column by a
196
+ # margin that grows with every late step.
197
+ Checklist.bound_stored!(snapshot + [ widened ])
198
+
199
+ # Marked, so a reader can tell what the run opened against from what it
200
+ # picked up along the way. Widening silently would make the snapshot a
201
+ # record of the present, which is the opposite of its job.
202
+ widened
203
+ end
204
+ private_class_method :admit_late_item!
205
+
206
+ def subject_checklist(record)
207
+ return [] unless record.subject_type && record.subject_id
208
+
209
+ runbook = Runbook.find_by(runbookable_type: record.subject_type,
210
+ runbookable_id: record.subject_id)
211
+ stored(runbook&.checklist)
212
+ end
213
+ private_class_method :subject_checklist
214
+
215
+ def recipe_item?(record, id)
216
+ RecipeRegistry.fetch(record.recipe_id).checklist.any? { |i| i[:id].to_s == id }
217
+ rescue Error
218
+ false
219
+ end
220
+ private_class_method :recipe_item?
221
+
161
222
  def find_days_run(attributes)
162
223
  Run.where(
163
224
  recipe_id: attributes[:recipe_id],
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Jazari
4
- VERSION = "0.5.2"
4
+ VERSION = "0.7.0"
5
5
  end
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.5.2
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq