jazari 0.6.0 → 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: 0c3e5b0a2641a7939f7353e3bec9cb18336272f6e0a9f333155cc36a09e888cd
4
- data.tar.gz: cf778b406814acf997a9716bb5128dce9070d934d5fb61912906f6f5534f5eb0
3
+ metadata.gz: a2e678c6397f2809010bf357f7bce0f95a6dd41ec910b4f3b9ffad7999c910f4
4
+ data.tar.gz: 34213c339b09aacbac45a878d878935f8160a55f13e8c0a5f2189cd70d96ef75
5
5
  SHA512:
6
- metadata.gz: 4985319ede9e2196979829901c3184e1f1ddba0ba2191b64ac9e4e3b2d498bd5cd568bf8d43664a3a2c6440fc3327ae7e8e940c3fa4319fad94529a542bb1eb9
7
- data.tar.gz: f90b41f05765d28295d8ba704eba10ba92f730bcc246b30b879ed3fd4db2dee9476e2c08febda3137948b11986579ef0daa6c0e0fcd25501937f8ca3e3295b89
6
+ metadata.gz: b880ae1c427030d149979921ea489c637a71cec89bd42018dec05bee415d4baa852c8a6a08ce4e49ac7dff2a24cc07a84fafec91e7d515276928c64476586f1b
7
+ data.tar.gz: 47fb2f0a403a85a1b6394116d6e3b577481b326a31485c28193a3697ce1ca397b9046d5487e96a9d79e6edd3fa5b8bf8e06e5fee29c99f865751758332f42f57
data/CHANGELOG.md CHANGED
@@ -8,6 +8,52 @@ 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
+
11
57
  ## [0.6.0] - 2026-08-15
12
58
 
13
59
  ### Added
@@ -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)
@@ -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
@@ -187,13 +187,19 @@ module Jazari
187
187
 
188
188
  raise ItemNotFound, "unknown checklist item #{item_id}"
189
189
  end
190
- raise InvalidRunbook, "run #{record.id} snapshot exceeds #{Checklist::MAX_ITEMS} items" if
191
- snapshot.length >= Checklist::MAX_ITEMS
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 ])
192
198
 
193
199
  # Marked, so a reader can tell what the run opened against from what it
194
200
  # picked up along the way. Widening silently would make the snapshot a
195
201
  # record of the present, which is the opposite of its job.
196
- item.merge("post_snapshot" => true)
202
+ widened
197
203
  end
198
204
  private_class_method :admit_late_item!
199
205
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Jazari
4
- VERSION = "0.6.0"
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.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq