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 +4 -4
- data/CHANGELOG.md +46 -0
- data/lib/jazari/checklist.rb +68 -3
- data/lib/jazari/mcp/actions.rb +47 -1
- data/lib/jazari/operations.rb +6 -4
- data/lib/jazari/runs.rb +9 -3
- data/lib/jazari/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a2e678c6397f2809010bf357f7bce0f95a6dd41ec910b4f3b9ffad7999c910f4
|
|
4
|
+
data.tar.gz: 34213c339b09aacbac45a878d878935f8160a55f13e8c0a5f2189cd70d96ef75
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/jazari/checklist.rb
CHANGED
|
@@ -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
|
-
|
|
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/mcp/actions.rb
CHANGED
|
@@ -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:
|
|
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,
|
data/lib/jazari/operations.rb
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
202
|
+
widened
|
|
197
203
|
end
|
|
198
204
|
private_class_method :admit_late_item!
|
|
199
205
|
|
data/lib/jazari/version.rb
CHANGED