Module: Jazari::Checklist

Defined in:
lib/jazari/checklist.rb

Overview

Checklist items are validated as a whole document. Item identity is an opaque token, never array position: MCP has to be able to check one item without knowing where it sits.

Constant Summary collapse

MAX_PAYLOAD =

The real bound is BYTES, not rows.

50 was inherited from a host that already had it, never benchmarked. A 50-item procedure measured live serializes to 17,252 bytes at 278 characters per step (codex, on the session-anchor board; the suite's synthetic equivalent is 16,691, since real ids and text are not uniform), so the row cap was rejecting documents four times smaller than what actually hurts — and would have accepted 50 steps of 500 characters, which is nearly twice as large again.

What breaks is a payload: a jsonb column, a run snapshot copied per run, and an MCP response that has to fit in a context window. None of those count rows.

65_536
MAX_TEXT =

There is deliberately NO row cap. The first draft kept one at 200 as a "guard against absurdity" — but 200 was as unmeasured as the 50 it replaced, and it would still have rejected a 201-step document sitting well inside the byte bound for exactly the old reason.

The absurdity case is already answered by measurement: a normalized minimal step is 67 bytes — the generated id is 16 of them — so MAX_PAYLOAD admits 992 of them and no more. One bound, derived, with nothing left to pick out of the air. (An earlier draft of this comment said ~1,090 by estimating the row at 60 bytes instead of measuring it after id generation. Measured, not reasoned, is the whole point of the change.)

500
ID_FORMAT =
/\A[A-Za-z0-9_-]{1,64}\z/
KEYS =

required is part of the schema so a host with per-step gating can carry it through a migration. Widened from the original three keys deliberately (see spec 02) — a legacy three-key item must still validate.

%i[id text done required].freeze

Class Method Summary collapse

Class Method Details

.bound!(items) ⇒ Object



85
# File 'lib/jazari/checklist.rb', line 85

def bound!(items) = bound_bytes!(payload_bytes(items), items)

.bound_bytes!(bytes, items) ⇒ Object



90
91
92
93
94
95
96
97
# File 'lib/jazari/checklist.rb', line 90

def bound_bytes!(bytes, items)
  if bytes > MAX_PAYLOAD
    raise InvalidRunbook,
          "checklist payload is #{bytes} bytes, over the #{MAX_PAYLOAD} limit"
  end

  items
end

.bound_stored!(rows) ⇒ Object

For rows that are already in their stored shape, extra keys included.



88
# File 'lib/jazari/checklist.rb', line 88

def bound_stored!(rows) = bound_bytes!(serialized_bytes(rows), rows)

.freeze_items(items) ⇒ Object



115
116
117
# File 'lib/jazari/checklist.rb', line 115

def freeze_items(items)
  items.map { |item| item.transform_values(&:freeze).freeze }.freeze
end

.generate_idObject



125
# File 'lib/jazari/checklist.rb', line 125

def generate_id = SecureRandom.urlsafe_base64(12)

.normalize(items) ⇒ Object



46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/jazari/checklist.rb', line 46

def normalize(items)
  list = validate!(items)
  seen = []
  normalized = list.map do |item|
    id = item[:id].to_s
    id = generate_id unless id.match?(ID_FORMAT) && !seen.include?(id)
    seen << id
    { id: id, text: item[:text].to_s, done: item[:done] == true,
      required: item.fetch(:required, true) == true }
  end
  bound!(normalized)
end

.payload_bytes(items) ⇒ Object

Measured on the NORMALIZED STORED FORM — the string-keyed rows actually written to jsonb — and never on the caller's input or on an MCP envelope.

That choice is the whole contract. Input arrives in four shapes (symbol keys, string keys, a YAML file, a Ruby literal) and an envelope differs per host, so counting either would make the same document legal in one path and illegal in another. The stored form is the one representation every path converges on, and ids are already assigned by the time it exists — so the number is deterministic and reproducible by a host that wants to check before it calls.



69
# File 'lib/jazari/checklist.rb', line 69

def payload_bytes(items) = serialized_bytes(stored_form(items))

.progress(items) ⇒ Object



119
120
121
122
123
# File 'lib/jazari/checklist.rb', line 119

def progress(items)
  done = items.count { |item| item[:done] }
  total = items.length
  { done: done, total: total, percent: total.zero? ? 0 : (done * 100) / total }
end

.serialized_bytes(rows) ⇒ Object

Counts rows VERBATIM. A run snapshot carries a key the canonical checklist does not (post_snapshot), and projecting it away before measuring would undercount the value actually written — by a margin that grows with every late step. Measure the bytes that land in the column.



75
# File 'lib/jazari/checklist.rb', line 75

def serialized_bytes(rows) = JSON.generate(rows).bytesize

.stored_form(items) ⇒ Object



77
78
79
80
81
82
83
# File 'lib/jazari/checklist.rb', line 77

def stored_form(items)
  items.map do |item|
    row = item.to_h.transform_keys(&:to_s)
    { "id" => row["id"].to_s, "text" => row["text"].to_s,
      "done" => row["done"] == true, "required" => row.fetch("required", true) == true }
  end
end

.validate!(items) ⇒ Object

Raises:



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/jazari/checklist.rb', line 99

def validate!(items)
  raise InvalidRunbook, "checklist must be an array" unless items.is_a?(Array)

  items.map do |item|
    raise InvalidRunbook, "checklist item must be a hash" unless item.is_a?(Hash)

    entry = item.to_h { |key, value| [ key.to_sym, value ] }
    unknown = entry.keys - KEYS
    raise InvalidRunbook, "unknown checklist keys: #{unknown.join(', ')}" if unknown.any?
    raise InvalidRunbook, "checklist item text is required" if entry[:text].to_s.empty?
    raise InvalidRunbook, "checklist item text exceeds #{MAX_TEXT}" if entry[:text].to_s.length > MAX_TEXT

    entry
  end
end