jazari 0.6.0 → 0.8.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: b1553aebe3407e26d9ef3813203a9836b0ed5d0dec3169ec2bd4d52153e144d4
4
+ data.tar.gz: f5d8638f94e8bcfa9e1f484962ab7e45c0aff74d3bac6e0627cdf528ec15d4ae
5
5
  SHA512:
6
- metadata.gz: 4985319ede9e2196979829901c3184e1f1ddba0ba2191b64ac9e4e3b2d498bd5cd568bf8d43664a3a2c6440fc3327ae7e8e940c3fa4319fad94529a542bb1eb9
7
- data.tar.gz: f90b41f05765d28295d8ba704eba10ba92f730bcc246b30b879ed3fd4db2dee9476e2c08febda3137948b11986579ef0daa6c0e0fcd25501937f8ca3e3295b89
6
+ metadata.gz: 374ee2a88370020196c89063900b11f1c8e0bf1e945421094087ad3a4c58efa20d68240e262b4120a00685334287ce9c55dc49b0a346a901d7d59ec3f8dc122e
7
+ data.tar.gz: a44421016fbaf7b9ba237bb5a3bc6936ae5d9f8aa5ae08510f6eff9afe65666341d515a149891098aa2872bc153455a211dfffafbd04795e52e47583dfa6b83b
data/CHANGELOG.md CHANGED
@@ -8,6 +8,79 @@ 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.8.0] - 2026-09-05
12
+
13
+ ### Added
14
+
15
+ - An on-demand `jazari` executable backed by SQLite: JSON calls, recipe seeding,
16
+ capability discovery, and MCP stdio with no daemon or network listener.
17
+ - The versioned `jazari/v1` request, response, error, and capability JSON
18
+ Schemas, plus backend-neutral conformance fixtures for revisions, daily
19
+ idempotency, snapshots, attribution, evidence, operator edits, and closed
20
+ runs.
21
+
22
+ ### Changed
23
+
24
+ - The shipped migration now preserves its constraints on PostgreSQL while
25
+ emitting equivalent SQLite types and UTC-date enforcement.
26
+ - Run and runbook optimistic-lock races map to `revision_conflict` on both
27
+ adapters.
28
+ - JSON and MCP schemas now close each action independently, so fields accepted
29
+ by a published schema are accepted by the matching runtime action.
30
+
31
+ ### Fixed
32
+
33
+ - Standalone schema creation is atomic under concurrent first use, and local
34
+ anchor targets cannot register or overwrite a Rails host's anchor scopes.
35
+ - The dispatcher and MCP stdio server own their load-time dependencies and keep
36
+ internal failures inside the closed `jazari/v1` error envelope.
37
+
38
+ ## [0.7.0] - 2026-08-30
39
+
40
+ ### Changed
41
+
42
+ - **The checklist bound is now BYTES, not rows.** `MAX_PAYLOAD = 65_536`,
43
+ measured on the normalized stored form — the string-keyed rows actually
44
+ written to jsonb, never the caller's input and never an MCP envelope. Input
45
+ arrives in four shapes and envelopes differ per host, so counting either
46
+ would make the same document legal on one path and illegal on another.
47
+
48
+ **`MAX_ITEMS` is removed — there is no row cap at all.** The 50 was inherited
49
+ from a host that already had it and was never benchmarked: a measured 50-step
50
+ procedure serializes to ~16.7 KB at 278 characters a step, so the row cap was
51
+ rejecting documents four times smaller than what actually hurts. An interim
52
+ draft kept a cap at 200 as a "guard against absurdity"; that was as unmeasured
53
+ as the 50, and would have rejected a 201-step document sitting well inside the
54
+ byte bound for exactly the old reason. The absurdity case is already answered
55
+ by measurement — a normalized minimal step is 67 bytes, so `MAX_PAYLOAD`
56
+ admits 992 of them and no more. Hosts referencing `Jazari::Checklist::MAX_ITEMS`
57
+ must switch to `MAX_PAYLOAD`.
58
+
59
+ One validator now covers every path — whole-document writes, recipe files,
60
+ and the 0.6.0 late snapshot widening. `add_item` routes through `normalize`
61
+ rather than `validate!`, closing the hole where a document could be walked
62
+ past the ceiling one step at a time.
63
+
64
+ `MAX_TEXT` is unchanged at 500.
65
+
66
+ ### Fixed
67
+
68
+ - **The published MCP `checklist` schema was an untyped `array`.** A client
69
+ generating from the descriptor could infer `string[]`, send `["step one"]`,
70
+ and be rejected by the domain with "checklist item must be a hash" — the
71
+ schema and the validator disagreed, and only the client found out. It now
72
+ publishes the object shape the validator enforces — `text` required with
73
+ `minLength`/`maxLength`, `id` carrying the real `pattern`, `additionalProperties:
74
+ false` matching the key allowlist — and discloses the aggregate byte bound in
75
+ its description, since JSON Schema cannot express it and an undocumented limit
76
+ is the same failure as an untyped array.
77
+
78
+ The contract test asserts **containment**: nothing the published schema admits
79
+ may be rejected by the domain. `done` is the one deliberate exception in the
80
+ safe direction — the schema publishes `boolean` while the domain silently
81
+ coerces `"yes"` to `false`, so a client is told up front rather than misled
82
+ afterwards.
83
+
11
84
  ## [0.6.0] - 2026-08-15
12
85
 
13
86
  ### Added
data/README.md CHANGED
@@ -6,7 +6,8 @@ Recipes as data, per-subject runbooks, stable names for rituals that outlive any
6
6
  record, and per-run evidence — so *"did last night's run actually complete?"*
7
7
  is a query rather than a guess.
8
8
 
9
- Requires Ruby 3.2+, Rails 7.1+, and PostgreSQL.
9
+ Requires Ruby 3.2+. Embed it in Rails 7.1+ with PostgreSQL, or run the
10
+ on-demand standalone CLI with SQLite.
10
11
 
11
12
  ---
12
13
 
@@ -18,6 +19,12 @@ is the operating manual. Its source lives in [`guide/`](guide/), including
18
19
  different than you expect), [adoption](guide/02-adoption.md),
19
20
  [anchors](guide/03-anchors.md), [runs and evidence](guide/04-runs.md),
20
21
  [MCP](guide/05-mcp.md), and [migrating an existing checklist](guide/06-migrating.md).
22
+ [Standalone SQLite, JSON, and MCP stdio](guide/07-standalone.md) covers the
23
+ on-demand executable.
24
+
25
+ The public [`jazari/v1` JSON contract](protocol/v1/) and its conformance fixtures
26
+ are shipped with the gem. Development plans and agent coordination stay out of
27
+ this public repository.
21
28
 
22
29
  ## The problem
23
30
 
@@ -65,6 +72,20 @@ The generator copies one migration. **Jazari never auto-appends migrations** to
65
72
  your schema — a shared operations table appearing in someone's next
66
73
  `db:migrate` without them asking is how a gem loses trust in a production fleet.
67
74
 
75
+ For a local standalone database:
76
+
77
+ ```bash
78
+ jazari recipes seed config/recipes --database .jazari/jazari.sqlite3
79
+ jazari capabilities --database .jazari/jazari.sqlite3 --json
80
+ jazari call --database .jazari/jazari.sqlite3 --request request.json
81
+ jazari mcp stdio --database .jazari/jazari.sqlite3 --recipes config/recipes
82
+ ```
83
+
84
+ The process starts for the command or MCP stdio session and exits with its
85
+ caller. It opens no port, installs no daemon, and stores no credentials. Queue
86
+ and anchor targets are supported; an embedding host still owns authorization
87
+ and record-backed targets.
88
+
68
89
  ## Thirty seconds
69
90
 
70
91
  ```ruby
@@ -211,7 +232,7 @@ Jazari::Mcp::Handler.new.call(action: "get", target: target)
211
232
  with its own subject vocabulary and permissions; this handler is the shared
212
233
  implementation underneath. Domain failures cross the wire as codes from a closed
213
234
  set — `target_not_found`, `invalid_runbook`, `revision_conflict`,
214
- `item_not_found`, `read_only_target`, `run_closed` — never as messages that
235
+ `item_not_found`, `item_not_in_snapshot`, `read_only_target`, `run_closed` — never as messages that
215
236
  could disclose a record or whether a target exists.
216
237
 
217
238
  `Handler.actions_for("read")` returns the read-only subset, so a read-scoped
@@ -251,15 +272,16 @@ That removes the subject's runbook. **Runs are deliberately preserved** — a ru
251
272
  records something that actually happened, and deleting the subject does not
252
273
  un-happen it.
253
274
 
254
- ## PostgreSQL only
275
+ ## PostgreSQL and SQLite
255
276
 
256
- The guarantees lean on Postgres: `jsonb`, `timestamptz`, four CHECK constraints,
257
- and a partial unique index over a `COALESCE`d polymorphic subject. Supporting a
258
- second adapter would make those conditional, which weakens the design. Other
259
- adapters are additive open an issue if you need one.
277
+ Rails hosts keep PostgreSQL's `jsonb`, `timestamptz`, CHECK constraints, and
278
+ partial expression index. Standalone mode uses SQLite JSON columns, UTC date
279
+ checks, foreign keys, WAL, a busy timeout, and the same partial expression
280
+ index. Adapter syntax differs; observable revision, idempotency, snapshot,
281
+ attribution, and evidence behavior does not.
260
282
 
261
- The test suite runs **the migration the gem ships**, so the schema cannot drift
262
- out of coverage.
283
+ The test suite runs **the migration the gem ships** against both adapters and
284
+ replays the same JSON fixtures against PostgreSQL and SQLite.
263
285
 
264
286
  ## What this is not
265
287
 
data/exe/jazari ADDED
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "optparse"
6
+ require "jazari/standalone"
7
+
8
+ module Jazari
9
+ module Standalone
10
+ class CLI
11
+ def initialize(argv, input: $stdin, output: $stdout, error: $stderr)
12
+ @argv = argv.dup
13
+ @input = input
14
+ @output = output
15
+ @error = error
16
+ end
17
+
18
+ def run
19
+ command = @argv.shift
20
+ case command
21
+ when "capabilities" then capabilities
22
+ when "call" then call
23
+ when "recipes" then recipes
24
+ when "mcp" then mcp
25
+ else usage!("unknown command #{command.inspect}")
26
+ end
27
+ rescue OptionParser::ParseError, ArgumentError, Errno::ENOENT, JSON::ParserError => exception
28
+ @error.puts("jazari: #{exception.message}")
29
+ 2
30
+ rescue Jazari::Error => exception
31
+ @error.puts("jazari: #{exception.code}: #{exception.message}")
32
+ 1
33
+ rescue ActiveRecord::ActiveRecordError, RuntimeError => exception
34
+ @error.puts("jazari: #{exception.message}")
35
+ 1
36
+ end
37
+
38
+ private
39
+
40
+ def capabilities
41
+ options = parse_common!
42
+ @output.puts(JSON.pretty_generate(app(options).capabilities))
43
+ 0
44
+ end
45
+
46
+ def call
47
+ options = parse_common!({ request: "-" }) do |parser, values|
48
+ parser.on("--request PATH", "JSON request file, or - for stdin") { |value| values[:request] = value }
49
+ end
50
+ body = options[:request] == "-" ? @input.read : File.read(options[:request])
51
+ response = app(options).call(JSON.parse(body))
52
+ @output.puts(JSON.pretty_generate(response))
53
+ response["ok"] ? 0 : 1
54
+ end
55
+
56
+ def recipes
57
+ usage!("expected `recipes seed PATH`") unless @argv.shift == "seed"
58
+ options = parse_common!(positionals: 1)
59
+ path = @argv.shift or usage!("recipe file or directory is required")
60
+ app(options)
61
+ records = RecipeRegistry.seed!(RecipeFiles.load(path))
62
+ @output.puts(JSON.pretty_generate(protocol: Protocol::Dispatcher::VERSION, seeded: records.length))
63
+ 0
64
+ end
65
+
66
+ def mcp
67
+ usage!("expected `mcp stdio`") unless @argv.shift == "stdio"
68
+ options = parse_common!
69
+ Mcp::StdioServer.new(dispatcher: app(options).dispatcher).serve(input: @input, output: @output)
70
+ end
71
+
72
+ def parse_common!(defaults = {}, positionals: 0)
73
+ options = { database: ENV["JAZARI_DATABASE"], recipes: [] }.merge(defaults)
74
+ parser = OptionParser.new do |flags|
75
+ flags.on("--database PATH", "SQLite database path") { |value| options[:database] = value }
76
+ flags.on("--recipes PATH", "Seed recipes from a file or directory") { |value| options[:recipes] << value }
77
+ flags.on("--json", "Emit JSON (machine commands already do)") {}
78
+ yield flags, options if block_given?
79
+ end
80
+ parser.parse!(@argv)
81
+ raise ArgumentError, "--database PATH or JAZARI_DATABASE is required" if options[:database].to_s.empty?
82
+ extras = @argv.drop(positionals)
83
+ raise OptionParser::InvalidArgument, extras.join(" ") if extras.any?
84
+ options
85
+ end
86
+
87
+ def app(options) = App.new(database: options.fetch(:database), recipes: options.fetch(:recipes))
88
+
89
+ def usage!(message)
90
+ raise ArgumentError, "#{message}\nusage: jazari capabilities|call|recipes seed|mcp stdio --database PATH"
91
+ end
92
+ end
93
+ end
94
+ end
95
+
96
+ exit Jazari::Standalone::CLI.new(ARGV).run
@@ -14,7 +14,7 @@ module Jazari
14
14
 
15
15
  source_root File.expand_path("templates", __dir__)
16
16
 
17
- desc "Copies the jazari migration into the host. PostgreSQL only."
17
+ desc "Copies the jazari migration into a PostgreSQL Rails host."
18
18
 
19
19
  def copy_migration
20
20
  migration_template "create_jazari_tables.rb", "db/migrate/create_jazari_tables.rb"
@@ -27,8 +27,9 @@ module Jazari
27
27
  say " 2. Seed your own recipes — the gem ships none by design."
28
28
  say " 3. Jazari.configure { |c| c.anchor_scopes = { ... } } at boot."
29
29
  say ""
30
- say "PostgreSQL is required: jsonb, timestamptz, CHECK constraints, and a"
31
- say "partial unique index over a COALESCEd polymorphic subject."
30
+ say "Rails hosts use PostgreSQL: jsonb, timestamptz, CHECK constraints, and a"
31
+ say "partial unique index over a COALESCEd polymorphic subject. The standalone"
32
+ say "executable manages its own SQLite schema instead."
32
33
  say ""
33
34
  end
34
35
  end
@@ -3,12 +3,18 @@
3
3
  # Host-adopted deliberately: jazari never auto-appends its migrations.
4
4
  class CreateJazariTables < ActiveRecord::Migration[7.1]
5
5
  def change
6
+ adapter = connection.adapter_name
7
+ unless %w[PostgreSQL SQLite].include?(adapter)
8
+ raise ActiveRecord::AdapterNotSpecified, "jazari supports PostgreSQL and SQLite, not #{adapter}"
9
+ end
10
+ postgres = adapter == "PostgreSQL"
11
+
6
12
  create_table :jazari_recipes do |t|
7
13
  t.string :recipe_id, null: false
8
14
  t.integer :version, null: false, default: 1
9
15
  t.string :topic, null: false
10
16
  t.text :description, null: false, default: ""
11
- t.jsonb :checklist, null: false, default: []
17
+ postgres ? t.jsonb(:checklist, null: false, default: []) : t.json(:checklist, null: false, default: [])
12
18
  t.string :run_policy, null: false, default: "unrestricted"
13
19
  t.timestamps
14
20
  t.index :recipe_id, unique: true
@@ -30,7 +36,7 @@ class CreateJazariTables < ActiveRecord::Migration[7.1]
30
36
  t.string :recipe_id, null: false
31
37
  t.string :topic, null: false
32
38
  t.text :description, null: false, default: ""
33
- t.jsonb :checklist, null: false, default: []
39
+ postgres ? t.jsonb(:checklist, null: false, default: []) : t.json(:checklist, null: false, default: [])
34
40
  # Provenance, host-defined and nullable. A runbook that exists because a
35
41
  # backfill created it is a migration artifact; one that exists because an
36
42
  # operator edited it is a decision. Both differ from the canon, so
@@ -49,21 +55,32 @@ class CreateJazariTables < ActiveRecord::Migration[7.1]
49
55
  t.string :subject_type
50
56
  t.bigint :subject_id
51
57
  t.string :actor_ref, null: false
52
- t.timestamptz :started_at, null: false
58
+ postgres ? t.timestamptz(:started_at, null: false) : t.datetime(:started_at, null: false)
53
59
  t.date :started_on, null: false
54
60
  t.string :idempotency_policy, null: false, default: "unrestricted"
55
- t.timestamptz :finished_at
61
+ postgres ? t.timestamptz(:finished_at) : t.datetime(:finished_at)
56
62
  t.string :outcome
57
- t.jsonb :checklist_snapshot, null: false # no default: every run carries its opening canon
58
- t.jsonb :ticks, null: false, default: []
59
- t.jsonb :evidence, null: false, default: []
63
+ if postgres
64
+ t.jsonb :checklist_snapshot, null: false
65
+ t.jsonb :ticks, null: false, default: []
66
+ t.jsonb :evidence, null: false, default: []
67
+ else
68
+ t.json :checklist_snapshot, null: false
69
+ t.json :ticks, null: false, default: []
70
+ t.json :evidence, null: false, default: []
71
+ end
60
72
  t.integer :lock_version, null: false, default: 0
61
73
  t.timestamps
62
74
  t.index %i[recipe_id started_at]
63
75
  t.index %i[subject_type subject_id started_at]
64
76
  t.check_constraint "idempotency_policy IN ('unrestricted', 'once_per_calendar_day')",
65
77
  name: "jazari_runs_idempotency_policy_chk"
66
- t.check_constraint "started_on = (started_at AT TIME ZONE 'UTC')::date",
78
+ utc_date_expression = if postgres
79
+ "started_on = (started_at AT TIME ZONE 'UTC')::date"
80
+ else
81
+ "started_on = date(started_at)"
82
+ end
83
+ t.check_constraint utc_date_expression,
67
84
  name: "jazari_runs_started_on_utc_chk"
68
85
  t.check_constraint "(subject_type IS NULL AND subject_id IS NULL) OR " \
69
86
  "(subject_type IS NOT NULL AND subject_id IS NOT NULL)",
@@ -14,7 +14,7 @@ module Jazari
14
14
 
15
15
  source_root File.expand_path("templates", __dir__)
16
16
 
17
- desc "Copies the schema changes an already-installed host needs. PostgreSQL only."
17
+ desc "Copies schema changes into an already-installed PostgreSQL Rails host."
18
18
 
19
19
  def copy_migration
20
20
  migration_template "add_jazari_runbook_origin.rb", "db/migrate/add_jazari_runbook_origin.rb"
@@ -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 (the suite's synthetic equivalent is 16,691, since
16
+ # 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,13 @@
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
+ require "jazari/operations"
9
+ require "jazari/runs"
10
+
3
11
  module Jazari
4
12
  module Mcp
5
13
  # Declarative descriptors for every action, so a host can absorb jazari into
@@ -19,18 +27,73 @@ module Jazari
19
27
  # :additive — writes, but cannot destroy prior state
20
28
  # :overwrite — may replace an operator's content; annotate cautiously
21
29
  # :destructive — removes something; gate it
30
+ REQUIRED_ARGUMENTS = {
31
+ "get" => [], "last_run" => [],
32
+ "set" => %i[expected_revision topic checklist],
33
+ "add_item" => %i[expected_revision text],
34
+ "remove_item" => %i[expected_revision item_id],
35
+ "check_item" => %i[expected_revision item_id done],
36
+ "reset" => %i[expected_revision confirm],
37
+ "start" => %i[actor_ref],
38
+ "tick" => %i[run_id expected_revision item_id done],
39
+ "evidence" => %i[run_id expected_revision kind value],
40
+ "finish" => %i[run_id expected_revision outcome]
41
+ }.freeze
42
+
22
43
  Action = Data.define(:name, :scope, :effect, :summary, :params, :confirm) do
23
44
  def read? = scope == :read
24
45
  def confirm? = confirm == true
46
+ def required = REQUIRED_ARGUMENTS.fetch(name)
25
47
  def to_h = { name: name, scope: scope, effect: effect, summary: summary,
26
48
  params: params, confirm: confirm }
27
49
  end
28
50
 
29
51
  REVISION = { type: "string",
52
+ minLength: 1,
30
53
  description: "The revision from the read immediately before this call." }.freeze
31
- ITEM_ID = { type: "string", description: "Opaque checklist item id." }.freeze
32
- RUN_ID = { type: "integer", description: "The run returned by start." }.freeze
33
- ACTOR = { type: "string", description: "Opaque identity of who is acting." }.freeze
54
+ # JSON Schema patterns are ECMAScript, which has no `\A`/`\z`. Publishing
55
+ # Ruby's source verbatim hands validators an anchor they may reject or,
56
+ # worse, read as a literal so translate the anchors and keep everything
57
+ # else derived from ID_FORMAT, which stays the single source of truth.
58
+ ID_PATTERN = Checklist::ID_FORMAT.source.sub('\\A', "^").sub('\\z', "$").freeze
59
+
60
+ ITEM_ID = { type: "string", minLength: 1, description: "Opaque checklist item id." }.freeze
61
+
62
+ # An `array` with no `items` is not a contract, it is a guess. A client
63
+ # generating from that descriptor is free to infer `string[]`, send
64
+ # `["step one"]`, and be rejected by the domain with "checklist item must
65
+ # be a hash" — which is what happened. The schema must publish the shape
66
+ # the validator actually enforces, or the descriptor is documentation
67
+ # that disagrees with the code.
68
+ CHECKLIST_ITEM = {
69
+ type: "object",
70
+ required: [ "text" ],
71
+ properties: {
72
+ id: { type: "string", pattern: ID_PATTERN, maxLength: 64,
73
+ description: "Opaque id. Omit to have one generated; send it back to keep a step " \
74
+ "stable across edits. An id that does not match the pattern is REPLACED " \
75
+ "with a generated one rather than rejected." },
76
+ # minLength/maxLength, not just "string": empty text and 501 characters
77
+ # are both rejected by the domain, and a schema that admits them makes
78
+ # the client discover it at call time.
79
+ text: { type: "string", minLength: 1, maxLength: Checklist::MAX_TEXT,
80
+ description: "The step. Required, non-empty." },
81
+ done: { type: "boolean", description: "Default false." },
82
+ required: { type: "boolean", description: "Whether the step gates completion. Default true." }
83
+ },
84
+ additionalProperties: false
85
+ }.freeze
86
+
87
+ # The one domain rule JSON Schema cannot express is the aggregate byte
88
+ # bound, so it is DISCLOSED rather than left for the client to discover by
89
+ # being rejected. An undocumented limit is the same failure as an untyped
90
+ # array: the schema knows something the caller does not.
91
+ CHECKLIST = { type: "array", items: CHECKLIST_ITEM,
92
+ description: "Full checklist; replaces the existing one. There is no row limit, " \
93
+ "but the serialized checklist must be at most " \
94
+ "#{Checklist::MAX_PAYLOAD} bytes." }.freeze
95
+ RUN_ID = { type: "integer", minimum: 1, description: "The run returned by start." }.freeze
96
+ ACTOR = { type: "string", minLength: 1, description: "Opaque identity of who is acting." }.freeze
34
97
 
35
98
  ALL = [
36
99
  Action.new(name: "get", scope: :read, effect: :read, confirm: false,
@@ -42,13 +105,16 @@ module Jazari
42
105
  Action.new(name: "set", scope: :write, effect: :overwrite, confirm: false,
43
106
  summary: "Replace this subject's procedure. Materialises an override on first use.",
44
107
  params: { expected_revision: REVISION,
45
- topic: { type: "string", description: "Short title." },
46
- description: { type: "string", description: "Markdown body." },
47
- checklist: { type: "array", description: "Full checklist; replaces the existing one." } }),
108
+ topic: { type: "string", minLength: 1, maxLength: Operations::MAX_TOPIC,
109
+ description: "Short title." },
110
+ description: { type: "string", maxLength: Operations::MAX_DESCRIPTION,
111
+ description: "Markdown body." },
112
+ checklist: CHECKLIST }),
48
113
  Action.new(name: "add_item", scope: :write, effect: :additive, confirm: false,
49
114
  summary: "Append one checklist step.",
50
115
  params: { expected_revision: REVISION,
51
- text: { type: "string", description: "The step." },
116
+ text: { type: "string", minLength: 1, maxLength: Checklist::MAX_TEXT,
117
+ description: "The step." },
52
118
  required: { type: "boolean", description: "Whether the step is required. Default true." } }),
53
119
  Action.new(name: "remove_item", scope: :write, effect: :destructive, confirm: false,
54
120
  summary: "Delete one checklist step.",
@@ -56,30 +122,35 @@ module Jazari
56
122
  Action.new(name: "check_item", scope: :write, effect: :additive, confirm: false,
57
123
  summary: "Mark a step done or not done.",
58
124
  params: { expected_revision: REVISION, item_id: ITEM_ID,
59
- done: { type: "boolean", description: "Default true." } }),
125
+ done: { type: "boolean", description: "Whether the step is done." } }),
60
126
  Action.new(name: "reset", scope: :write, effect: :destructive, confirm: true,
61
127
  summary: "Discard this subject's override and reveal the current canon.",
62
128
  params: { expected_revision: REVISION,
63
- confirm: { type: "boolean", description: "Must be true — this discards operator content." } }),
129
+ confirm: { type: "boolean", const: true,
130
+ description: "Must be true — this discards operator content." } }),
64
131
  Action.new(name: "start", scope: :write, effect: :additive, confirm: false,
65
132
  summary: "Open a run. Under a once-per-day recipe this returns the existing run instead of erroring.",
66
133
  params: { actor_ref: ACTOR }),
67
134
  Action.new(name: "tick", scope: :write, effect: :additive, confirm: false,
68
135
  summary: "Record a step done within a run. Does not touch the subject's own checklist.",
69
136
  params: { run_id: RUN_ID, expected_revision: REVISION, item_id: ITEM_ID,
70
- done: { type: "boolean", description: "Default true." },
137
+ done: { type: "boolean", description: "Whether the step is done." },
71
138
  actor_ref: ACTOR,
72
- note: { type: "string", description: "Optional free text." } }),
139
+ note: { type: "string", maxLength: Checklist::MAX_TEXT,
140
+ description: "Optional free text." } }),
73
141
  Action.new(name: "evidence", scope: :write, effect: :additive, confirm: false,
74
142
  summary: "Attach evidence to a run: output, url, sha, count, or note.",
75
143
  params: { run_id: RUN_ID, expected_revision: REVISION, item_id: ITEM_ID,
76
- kind: { type: "string", description: "One of: output, url, sha, count, note." },
77
- value: { type: "string", description: "The evidence itself." },
144
+ kind: { type: "string", enum: Runs::EVIDENCE_KINDS,
145
+ description: "The evidence kind." },
146
+ value: { type: "string", maxLength: Runs::MAX_EVIDENCE,
147
+ description: "The evidence itself." },
78
148
  actor_ref: ACTOR }),
79
149
  Action.new(name: "finish", scope: :write, effect: :additive, confirm: false,
80
150
  summary: "Close a run with an outcome: completed, abandoned, or failed.",
81
151
  params: { run_id: RUN_ID, expected_revision: REVISION,
82
- outcome: { type: "string", description: "completed | abandoned | failed" } })
152
+ outcome: { type: "string", enum: %w[completed abandoned failed],
153
+ description: "How the run ended." } })
83
154
  ].freeze
84
155
 
85
156
  NAMES = ALL.map(&:name).freeze
@@ -116,6 +187,26 @@ module Jazari
116
187
  { enum: actions.map(&:name), properties: properties }
117
188
  end
118
189
 
190
+ # A complete schema for a flat action-enum tool. Each action is its own
191
+ # closed variant, so a field that belongs to `tick` is not accidentally
192
+ # advertised as legal for `get` merely because both share one tool.
193
+ def input_schema(target_schema:, scope: :write)
194
+ {
195
+ type: "object",
196
+ oneOf: all(scope: scope).map do |action|
197
+ {
198
+ type: "object",
199
+ required: [ :action, :target, *action.required ],
200
+ properties: {
201
+ action: { const: action.name },
202
+ target: target_schema
203
+ }.merge(action.params),
204
+ additionalProperties: false
205
+ }
206
+ end
207
+ }
208
+ end
209
+
119
210
  # Human-readable action list for a tool description or a paired skill.
120
211
  def summaries(scope: :write)
121
212
  all(scope: scope).map { |a| "#{a.name} — #{a.summary}" }