jazari 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dcd3c77813c6290564bdaeb857e3e16ccf09381f14ed5042bec05c4c78d34af1
4
+ data.tar.gz: 655b4e0d33237093113ee341b02acc5f7a51b1ad16a781fa4c15faa8ce482af0
5
+ SHA512:
6
+ metadata.gz: dd38fccf2e5720b002824294ae7a178e206d6dccef3489cc61ce953fa507f991069e69ee9ed34b15f3a8de64c29edc7cbce8d8c02dea7c3a351a2d9ce8ab3b8f
7
+ data.tar.gz: e163aa00ba58ef159416ca8f41360ee8bf1ad3743c1b129d61ad0e0e5cfb353111903f890545d5439e449149b9a4c2e0deda8c13b853ff3272fabff1788abfd4
data/CHANGELOG.md ADDED
@@ -0,0 +1,38 @@
1
+ # Changelog
2
+
3
+ All notable changes are recorded here. The release workflow REFUSES to publish
4
+ a version with no entry — see RELEASING.md.
5
+
6
+ Pre-1.0: minor versions may break. The public contract includes the error
7
+ codes, the resolved-value shape, how revisions are computed, and the schema the
8
+ generator emits — changes to any of those are breaking even when the method
9
+ signatures do not move.
10
+
11
+ ## [0.1.0] - 2026-08-10
12
+
13
+ First release. Proven against one host adoption.
14
+
15
+ - Four layers: recipe (the canon), runbook (per-subject override), queue
16
+ (a stable name for a ritual), run (one execution with evidence).
17
+ - Per-recipe idempotency (`unrestricted` / `once_per_calendar_day`), enforced by
18
+ a partial unique index over a `COALESCE`d polymorphic subject and a UTC day.
19
+ - Revision guards on every mutation; digest-based revisions for defaults.
20
+ - Runs are bound to the canon they opened against via a checklist snapshot.
21
+ - `Jazari::Mcp::Handler` — transport-neutral action dispatch; host owns tool identity.
22
+ - `rails g jazari:install` — host-adopted migration; the gem never auto-appends.
23
+ - PostgreSQL only. The suite runs the migration the gem ships.
24
+ - `Jazari.forget_subject` — host-called cleanup; runs deliberately survive.
25
+ - Railtie so models autoload in a host (the gem was unusable without it).
26
+ - Per-table name overrides, for hosts adopting tables they already have.
27
+
28
+ ### Found by the first host adoption
29
+
30
+ Five defects that no amount of unit testing had surfaced, each now
31
+ regression-tested — every one a place the gem assumed it owned something the
32
+ host actually owns:
33
+
34
+ - models never autoloaded in a real application
35
+ - read and write paths resolved anchors differently
36
+ - host resolvers were not told whether they may create, so reads wrote
37
+ - `reset` assumed the gem's own anchor class and left host-owned orphans
38
+ - the recipe registry could disagree with itself across a shim boundary
data/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
data/README.md ADDED
@@ -0,0 +1,255 @@
1
+ # Jazari
2
+
3
+ **Operating procedures you can call, instead of documents you hope someone reads.**
4
+
5
+ Recipes as data, per-subject runbooks, stable names for rituals that outlive any
6
+ record, and per-run evidence — so *"did last night's run actually complete?"*
7
+ is a query rather than a guess.
8
+
9
+ Requires Ruby 3.2+, Rails 7.1+, and PostgreSQL.
10
+
11
+ ---
12
+
13
+ ## The problem
14
+
15
+ You have a procedure. Verify a backup by restoring it. Provision a server.
16
+ Triage an alert before waking anyone.
17
+
18
+ It is written down. It lives in a document, or a wiki, or a comment. And so:
19
+
20
+ - Nothing can **check it off**, so nobody knows how far a run got.
21
+ - Nothing knows whether it is **current**, so it rots silently.
22
+ - Nothing records **who ran it, when, or what they saw**.
23
+ - An agent cannot **call** it, because it has no name — only a location.
24
+
25
+ The usual fix is a checklist attached to a record. That helps, and then it
26
+ runs out: some procedures belong to no record at all, and a checklist you
27
+ `reset` to run again has just destroyed the evidence it ever ran.
28
+
29
+ ## Four layers
30
+
31
+ ```
32
+ RECIPE the canon — how this ritual is done. Data, not code.
33
+ ↓ operator-editable at runtime, digest-versioned
34
+ RUNBOOK one subject's override — how THIS record differs
35
+ ↓ materialised on first edit; reading a default writes nothing
36
+ QUEUE a stable name for a ritual that outlives any record
37
+ ↓ read-only: a ritual has exactly one editable home
38
+ RUN one execution — who, when, which ticks, what evidence
39
+ ```
40
+
41
+ Most systems stop at the second. The third makes a procedure **callable**; the
42
+ fourth makes it **auditable**.
43
+
44
+ ## Install
45
+
46
+ ```ruby
47
+ gem "jazari"
48
+ ```
49
+
50
+ ```bash
51
+ bin/rails generate jazari:install
52
+ bin/rails db:migrate
53
+ ```
54
+
55
+ The generator copies one migration. **Jazari never auto-appends migrations** to
56
+ your schema — a shared operations table appearing in someone's next
57
+ `db:migrate` without them asking is how a gem loses trust in a production fleet.
58
+
59
+ ## Thirty seconds
60
+
61
+ ```ruby
62
+ # 1. Seed a recipe. The gem ships NO content — these are your procedures.
63
+ Jazari::RecipeRegistry.seed!([
64
+ { id: "backup.verify.v1",
65
+ topic: "Prove a backup by restoring it",
66
+ description: "## Purpose\n\nA green schedule is not a verified backup.",
67
+ run_policy: "once_per_calendar_day",
68
+ checklist: [
69
+ { id: "dump", text: "Dump to scratch" },
70
+ { id: "restore", text: "Restore into a throwaway database" },
71
+ { id: "counts", text: "Compare table and row counts" }
72
+ ] }
73
+ ])
74
+
75
+ # 2. Address the ritual by NAME. No record required.
76
+ target = Jazari::QueueTarget.new(
77
+ queue: "backup-verify", public_reference: { kind: "queue" },
78
+ recipe_id: "backup.verify.v1"
79
+ )
80
+
81
+ Jazari.resolve(target: target).progress # => { done: 0, total: 3, percent: 0 }
82
+
83
+ # 3. Open a run, work it, attach what you saw.
84
+ result = Jazari.open_run(target: target, actor_ref: "agent:nightly")
85
+ run = result[:run]
86
+
87
+ Jazari.tick(run: run, expected_revision: run.lock_version,
88
+ item_id: "restore", done: true, actor_ref: "agent:nightly")
89
+
90
+ Jazari.attach_evidence(run: run.reload, expected_revision: run.lock_version,
91
+ item_id: "counts", kind: "count", value: "4211 rows")
92
+
93
+ Jazari.close_run(run: run.reload, expected_revision: run.lock_version,
94
+ outcome: "completed")
95
+
96
+ # 4. The question that started all this:
97
+ Jazari.last_run(target: target).outcome # => "completed"
98
+ ```
99
+
100
+ ## Idempotency belongs to the ritual
101
+
102
+ Verifying a backup should happen **once a day**. Triaging an incident may happen
103
+ five times. So there is no global rule — each recipe declares its own:
104
+
105
+ | `run_policy` | Behaviour |
106
+ |---|---|
107
+ | `unrestricted` (default) | every `open_run` starts a run |
108
+ | `once_per_calendar_day` | one run per recipe + subject + **UTC** day |
109
+
110
+ Under the daily policy a second call **returns the existing run** rather than
111
+ erroring, so a retrying cron converges:
112
+
113
+ ```ruby
114
+ Jazari.open_run(target: target, actor_ref: "cron")
115
+ # => { run: #<Run id: 4412>, created: false, idempotent_reuse: true }
116
+ ```
117
+
118
+ Enforced by a partial unique index, not by application logic — a
119
+ find-then-insert races. Two details that are easy to get wrong and are handled
120
+ here: the index `COALESCE`s the nullable polymorphic subject (otherwise queue
121
+ runs are unconstrained entirely, because `NULL != NULL`), and the day is **UTC**
122
+ via `timestamptz`, so one nightly ritual cannot land on two different days
123
+ depending on which region's machine called it.
124
+
125
+ ## Revision guards
126
+
127
+ Every mutation carries the revision from the read before it:
128
+
129
+ ```ruby
130
+ resolved = Jazari.resolve(target: target)
131
+ Jazari.check_item(target: target, expected_revision: resolved.revision,
132
+ item_id: "dump", done: true)
133
+ ```
134
+
135
+ A customised runbook uses its `lock_version`; a default uses
136
+ `default:<recipe-digest>`. Editing a recipe changes its digest, so anyone
137
+ holding a stale default gets `revision_conflict` instead of silently writing
138
+ onto ground that moved. This matters most when several automated writers share
139
+ one procedure — last-writer-wins is the same defect class as two people
140
+ force-pushing a branch.
141
+
142
+ ## Recipes are data, not code
143
+
144
+ The gem ships **no recipe content** — not one checklist item, only the
145
+ mechanism and an empty fallback. Your procedures are rows: seeded once, then
146
+ operator-owned. Reseeding never overwrites an edit.
147
+
148
+ That means fixing a ritual is a **write, not a deploy** — and a fresh install
149
+ can ship with working procedures instead of an empty text box.
150
+
151
+ ## Runs are bound to the canon they opened against
152
+
153
+ A run snapshots its checklist when it opens. Edit the recipe mid-run and the
154
+ in-flight run still ticks its own steps, and refuses steps that did not exist
155
+ when it started. Without this, an operator improving a procedure silently breaks
156
+ every run in progress.
157
+
158
+ ## MCP
159
+
160
+ `Jazari::Mcp::Handler` maps action names onto the domain and knows nothing about
161
+ transport, auth, or product naming:
162
+
163
+ ```ruby
164
+ Jazari::Mcp::Handler.new.call(action: "get", target: target)
165
+ # => { ok: true, state: "default", topic: "...", progress: {...}, last_run: {...} }
166
+ ```
167
+
168
+ **Tool identity stays yours.** Your app exposes its own flat `action`-enum tool
169
+ with its own subject vocabulary and permissions; this handler is the shared
170
+ implementation underneath. Domain failures cross the wire as codes from a closed
171
+ set — `target_not_found`, `invalid_runbook`, `revision_conflict`,
172
+ `item_not_found`, `read_only_target`, `run_closed` — never as messages that
173
+ could disclose a record or whether a target exists.
174
+
175
+ `Handler.actions_for("read")` returns the read-only subset, so a read-scoped
176
+ connection never advertises mutations.
177
+
178
+ ## You authorize; Jazari never sees an actor
179
+
180
+ The domain accepts no raw IDs, no arbitrary records, and no actor. Your app
181
+ authorizes first, then constructs exactly one immutable target:
182
+
183
+ ```ruby
184
+ Jazari::RecordTarget.new(runbookable: site, public_reference: { kind: "site" },
185
+ recipe_id: "site.maintenance.v1")
186
+ Jazari::QueueTarget.new(queue: "backup-verify", ...) # read-only
187
+ Jazari::AnchorTarget.new(scope_type: "Tree", scope_id: 7, key: "node-x", ...)
188
+ ```
189
+
190
+ `AnchorTarget` covers subjects that are not ActiveRecord rows — a JSON-tree
191
+ node, a file path, a DNS zone. Register the scope at boot; unregistered scopes
192
+ fail closed.
193
+
194
+ ## Deleting a subject
195
+
196
+ Jazari cannot hook your models — a subject may live in a different logical
197
+ database, so no cross-database foreign key is claimed and no cascade exists.
198
+ Call in from your own `after_commit`:
199
+
200
+ ```ruby
201
+ class Site < ApplicationRecord
202
+ after_commit :forget_jazari, on: :destroy
203
+ def forget_jazari = Jazari.forget_subject(self)
204
+ end
205
+ ```
206
+
207
+ That removes the subject's runbook. **Runs are deliberately preserved** — a run
208
+ records something that actually happened, and deleting the subject does not
209
+ un-happen it.
210
+
211
+ ## PostgreSQL only
212
+
213
+ The guarantees lean on Postgres: `jsonb`, `timestamptz`, four CHECK constraints,
214
+ and a partial unique index over a `COALESCE`d polymorphic subject. Supporting a
215
+ second adapter would make those conditional, which weakens the design. Other
216
+ adapters are additive — open an issue if you need one.
217
+
218
+ The test suite runs **the migration the gem ships**, so the schema cannot drift
219
+ out of coverage.
220
+
221
+ ## What this is not
222
+
223
+ Not an execution framework. Jazari holds the *state* of a procedure — the canon,
224
+ the overrides, the runs, the evidence. It does not SSH anywhere or run your
225
+ commands.
226
+
227
+ For the execution half, see [braintree/runbook](https://github.com/braintree/runbook):
228
+ a Ruby DSL for running operational procedures, with resumable state and a dry-run
229
+ mode. It has no data model; Jazari has no executor. They compose.
230
+
231
+ ## Why "Jazari"
232
+
233
+ Ismail **al-Jazari** (1136–1206), engineer at the Artuqid court in Diyarbakır,
234
+ built programmable automata — a hand-washing machine that offered you a towel,
235
+ a clock driven by a water wheel, pumps with the earliest known crankshafts.
236
+
237
+ That is not why the gem carries his name.
238
+
239
+ He also wrote *The Book of Knowledge of Ingenious Mechanical Devices*, finished
240
+ the year he died: fifty machines, each with numbered construction steps and
241
+ drawings detailed enough that a stranger who had never met him could rebuild the
242
+ device. He wrote down the *procedure*, not just the result — including, in his
243
+ own words, the steps he had gotten wrong first.
244
+
245
+ Eight hundred years later people have built working machines from those pages.
246
+
247
+ That is the whole idea here. A procedure is not lore in someone's head or prose
248
+ in a document nobody opens. It is a written, versioned, checkable artefact that
249
+ somebody else can execute and prove they executed.
250
+
251
+ `runbook` was taken on RubyGems — by the execution framework above, fittingly.
252
+
253
+ ## License
254
+
255
+ MIT.
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jazari
4
+ # A stable relational target for a subject that is not an ActiveRecord row:
5
+ # a JSON-tree node, a file path, a DNS zone, a document. The scope is
6
+ # host-registered; the gem ships no default scope naming any real model.
7
+ class Anchor < ApplicationRecord
8
+ self.table_name = "jazari_anchors"
9
+
10
+ has_one :runbook, as: :runbookable, dependent: :destroy
11
+
12
+ validates :scope_type, presence: true
13
+ validates :scope_id, presence: true
14
+ validates :key, presence: true, uniqueness: { scope: %i[scope_type scope_id] }
15
+ end
16
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jazari
4
+ # Isolated base class. A host may point this at its own connection; the gem
5
+ # never assumes it shares a database with the host's own models, which is why
6
+ # no cross-database foreign key is ever claimed (spec 02, D9).
7
+ class ApplicationRecord < ActiveRecord::Base
8
+ self.abstract_class = true
9
+ end
10
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jazari
4
+ # The persisted canon. Deliberately named RecipeRecord, not Recipe: `Recipe`
5
+ # is the immutable value the domain passes around, and letting an ActiveRecord
6
+ # object wear that name is how unsaved records leak into return values.
7
+ class RecipeRecord < ApplicationRecord
8
+ self.table_name = "jazari_recipes"
9
+
10
+ POLICIES = Jazari::RunPolicy::ALL
11
+
12
+ validates :recipe_id, presence: true, uniqueness: true
13
+ validates :version, presence: true
14
+ validates :topic, presence: true, length: { maximum: 120 }
15
+ validates :description, length: { maximum: 20_000 }
16
+ validates :run_policy, inclusion: { in: POLICIES }
17
+
18
+ def to_recipe
19
+ Recipe.new(
20
+ id: recipe_id, version: version, topic: topic, description: description,
21
+ run_policy: run_policy,
22
+ checklist: Array(checklist).map do |item|
23
+ { id: item["id"].to_s, text: item["text"].to_s,
24
+ done: item["done"] == true, required: item.fetch("required", true) == true }
25
+ end
26
+ )
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jazari
4
+ # One execution of a ritual. The layer that makes "did last night's run
5
+ # actually complete?" a query instead of a guess.
6
+ #
7
+ # Immutable once closed. Ticks on a run never touch the subject's checklist —
8
+ # that separation is the whole point: `reset` on a runbook must not be able to
9
+ # destroy the record that the ritual ever ran.
10
+ class Run < ApplicationRecord
11
+ self.table_name = "jazari_runs"
12
+
13
+ belongs_to :subject, polymorphic: true, optional: true
14
+
15
+ OUTCOMES = %w[completed abandoned failed].freeze
16
+
17
+ validates :recipe_id, presence: true
18
+ validates :source_digest, presence: true
19
+ validates :actor_ref, presence: true
20
+ validates :started_at, presence: true
21
+ validates :started_on, presence: true
22
+ validates :outcome, inclusion: { in: OUTCOMES }, allow_nil: true
23
+ validates :idempotency_policy, inclusion: { in: RecipeRecord::POLICIES }
24
+
25
+ def open? = finished_at.nil?
26
+ def closed? = !open?
27
+ end
28
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jazari
4
+ # One subject's override of the canon. Materialized on first customization —
5
+ # never on read.
6
+ class Runbook < ApplicationRecord
7
+ self.table_name = "jazari_runbooks"
8
+
9
+ belongs_to :runbookable, polymorphic: true
10
+
11
+ validates :topic, presence: true, length: { maximum: 120 }
12
+ validates :description, length: { maximum: 20_000 }
13
+ # Recorded so divergence from the canon is queryable rather than invisible.
14
+ validates :recipe_id, presence: true
15
+ end
16
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Jazari
7
+ module Generators
8
+ # Hosts adopt these tables DELIBERATELY. The gem never auto-appends its
9
+ # migrations to a host's schema — a shared operations table appearing in
10
+ # someone's next `db:migrate` without them asking for it is exactly the kind
11
+ # of surprise that makes a gem untrustworthy in a production fleet.
12
+ class InstallGenerator < ::Rails::Generators::Base
13
+ include ::ActiveRecord::Generators::Migration
14
+
15
+ source_root File.expand_path("templates", __dir__)
16
+
17
+ desc "Copies the jazari migration into the host. PostgreSQL only."
18
+
19
+ def copy_migration
20
+ migration_template "create_jazari_tables.rb", "db/migrate/create_jazari_tables.rb"
21
+ end
22
+
23
+ def report
24
+ say ""
25
+ say "jazari: migration copied. Next:"
26
+ say " 1. bin/rails db:migrate"
27
+ say " 2. Seed your own recipes — the gem ships none by design."
28
+ say " 3. Jazari.configure { |c| c.anchor_scopes = { ... } } at boot."
29
+ say ""
30
+ say "PostgreSQL is required: jsonb, timestamptz, CHECK constraints, and a"
31
+ say "partial unique index over a COALESCEd polymorphic subject."
32
+ say ""
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Host-adopted deliberately: jazari never auto-appends its migrations.
4
+ class CreateJazariTables < ActiveRecord::Migration[7.1]
5
+ def change
6
+ create_table :jazari_recipes do |t|
7
+ t.string :recipe_id, null: false
8
+ t.integer :version, null: false, default: 1
9
+ t.string :topic, null: false
10
+ t.text :description, null: false, default: ""
11
+ t.jsonb :checklist, null: false, default: []
12
+ t.string :run_policy, null: false, default: "unrestricted"
13
+ t.timestamps
14
+ t.index :recipe_id, unique: true
15
+ t.check_constraint "run_policy IN ('unrestricted', 'once_per_calendar_day')",
16
+ name: "jazari_recipes_run_policy_chk"
17
+ end
18
+
19
+ create_table :jazari_anchors do |t|
20
+ t.string :scope_type, null: false
21
+ t.bigint :scope_id, null: false
22
+ t.string :key, null: false
23
+ t.timestamps
24
+ t.index %i[scope_type scope_id key], unique: true
25
+ end
26
+
27
+ create_table :jazari_runbooks do |t|
28
+ t.string :runbookable_type, null: false
29
+ t.bigint :runbookable_id, null: false
30
+ t.string :recipe_id, null: false
31
+ t.string :topic, null: false
32
+ t.text :description, null: false, default: ""
33
+ t.jsonb :checklist, null: false, default: []
34
+ t.integer :lock_version, null: false, default: 0
35
+ t.timestamps
36
+ t.index %i[runbookable_type runbookable_id], unique: true
37
+ t.index :recipe_id
38
+ end
39
+
40
+ create_table :jazari_runs do |t|
41
+ t.string :recipe_id, null: false
42
+ t.string :source_digest, null: false
43
+ t.string :subject_type
44
+ t.bigint :subject_id
45
+ t.string :actor_ref, null: false
46
+ t.timestamptz :started_at, null: false
47
+ t.date :started_on, null: false
48
+ t.string :idempotency_policy, null: false, default: "unrestricted"
49
+ t.timestamptz :finished_at
50
+ t.string :outcome
51
+ t.jsonb :checklist_snapshot, null: false # no default: every run carries its opening canon
52
+ t.jsonb :ticks, null: false, default: []
53
+ t.jsonb :evidence, null: false, default: []
54
+ t.integer :lock_version, null: false, default: 0
55
+ t.timestamps
56
+ t.index %i[recipe_id started_at]
57
+ t.index %i[subject_type subject_id started_at]
58
+ t.check_constraint "idempotency_policy IN ('unrestricted', 'once_per_calendar_day')",
59
+ name: "jazari_runs_idempotency_policy_chk"
60
+ t.check_constraint "started_on = (started_at AT TIME ZONE 'UTC')::date",
61
+ name: "jazari_runs_started_on_utc_chk"
62
+ t.check_constraint "(subject_type IS NULL AND subject_id IS NULL) OR " \
63
+ "(subject_type IS NOT NULL AND subject_id IS NOT NULL)",
64
+ name: "jazari_runs_subject_pair_chk"
65
+ end
66
+
67
+ # The idempotency constraint. COALESCE collapses the nullable polymorphic
68
+ # subject so queue runs (both columns NULL) share one uniqueness key —
69
+ # a plain unique index would not constrain them at all, because NULL != NULL.
70
+ execute <<~SQL
71
+ CREATE UNIQUE INDEX jazari_runs_once_per_day_idx
72
+ ON jazari_runs (
73
+ recipe_id,
74
+ COALESCE(subject_type, ''),
75
+ COALESCE(subject_id, 0),
76
+ started_on
77
+ )
78
+ WHERE idempotency_policy = 'once_per_calendar_day';
79
+ SQL
80
+ end
81
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jazari
4
+ # Anchor resolution, shared by every path that needs a subject for an
5
+ # AnchorTarget.
6
+ #
7
+ # This exists because the read path and the write path had drifted: runs
8
+ # resolved anchors through the host's configured resolver, while customizing
9
+ # a runbook created one directly. A host whose anchor table carries its own
10
+ # NOT NULL columns — because it adopted jazari onto a table it already had —
11
+ # could therefore read but never write. One resolver, used everywhere.
12
+ module Anchors
13
+ module_function
14
+
15
+ # strict: a write path; an unresolvable anchor fails closed.
16
+ # a read path passes false — an anchor that does not exist yet is
17
+ # the normal state of an uncustomized subject, not an error.
18
+ # create: may this call materialise the anchor? Only true when the caller
19
+ # is about to persist a customization.
20
+ def resolve(target, strict: true, create: false)
21
+ scopes = Jazari.config.anchor_scopes
22
+ unless scopes.key?(target.scope_type)
23
+ raise TargetNotFound, "anchor scope #{target.scope_type.inspect} is not registered"
24
+ end
25
+
26
+ resolver = scopes[target.scope_type]
27
+ subject =
28
+ if resolver.respond_to?(:call)
29
+ # The host owns creation as well as lookup — its table may demand
30
+ # columns the gem knows nothing about — so it MUST be told whether
31
+ # creation is permitted. Without `create`, a host resolver would
32
+ # materialise an anchor on every read, breaking the rule that
33
+ # resolving a default writes nothing.
34
+ resolver.arity == 1 ? resolver.call(target) : resolver.call(target, create)
35
+ elsif create
36
+ Anchor.create_or_find_by!(scope_type: target.scope_type,
37
+ scope_id: target.scope_id, key: target.key)
38
+ else
39
+ Anchor.find_by(scope_type: target.scope_type,
40
+ scope_id: target.scope_id, key: target.key)
41
+ end
42
+
43
+ return subject if subject.is_a?(ActiveRecord::Base) && subject.persisted?
44
+ return nil unless strict
45
+
46
+ raise TargetNotFound, "anchor #{target.key.inspect} did not resolve to a persisted record"
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Jazari
6
+ # Checklist items are validated as a whole document. Item identity is an
7
+ # opaque token, never array position: MCP has to be able to check one item
8
+ # without knowing where it sits.
9
+ module Checklist
10
+ MAX_ITEMS = 50
11
+ MAX_TEXT = 500
12
+ ID_FORMAT = /\A[A-Za-z0-9_-]{1,64}\z/
13
+
14
+ # `required` is part of the schema so a host with per-step gating can carry
15
+ # it through a migration. Widened from the original three keys deliberately
16
+ # (see spec 02) — a legacy three-key item must still validate.
17
+ KEYS = %i[id text done required].freeze
18
+
19
+ module_function
20
+
21
+ def normalize(items)
22
+ list = validate!(items)
23
+ seen = []
24
+ list.map do |item|
25
+ id = item[:id].to_s
26
+ id = generate_id unless id.match?(ID_FORMAT) && !seen.include?(id)
27
+ seen << id
28
+ { id: id, text: item[:text].to_s, done: item[:done] == true,
29
+ required: item.fetch(:required, true) == true }
30
+ end
31
+ end
32
+
33
+ def validate!(items)
34
+ 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
+
37
+ items.map do |item|
38
+ raise InvalidRunbook, "checklist item must be a hash" unless item.is_a?(Hash)
39
+
40
+ entry = item.to_h { |key, value| [ key.to_sym, value ] }
41
+ unknown = entry.keys - KEYS
42
+ raise InvalidRunbook, "unknown checklist keys: #{unknown.join(', ')}" if unknown.any?
43
+ raise InvalidRunbook, "checklist item text is required" if entry[:text].to_s.empty?
44
+ raise InvalidRunbook, "checklist item text exceeds #{MAX_TEXT}" if entry[:text].to_s.length > MAX_TEXT
45
+
46
+ entry
47
+ end
48
+ end
49
+
50
+ def freeze_items(items)
51
+ items.map { |item| item.transform_values(&:freeze).freeze }.freeze
52
+ end
53
+
54
+ def progress(items)
55
+ done = items.count { |item| item[:done] }
56
+ total = items.length
57
+ { done: done, total: total, percent: total.zero? ? 0 : (done * 100) / total }
58
+ end
59
+
60
+ def generate_id = SecureRandom.urlsafe_base64(12)
61
+ end
62
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jazari
4
+ # A closed taxonomy. Hosts translate these into their own transport envelope;
5
+ # unauthorized, unknown, deleted, and type-mismatched input must all collapse
6
+ # to TargetNotFound so that guessing a target cannot disclose its existence.
7
+ class Error < StandardError
8
+ def code = self.class.name.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
9
+ end
10
+
11
+ class TargetNotFound < Error; end
12
+ class InvalidRunbook < Error; end
13
+ class RevisionConflict < Error; end
14
+ class ItemNotFound < Error; end
15
+ class ReadOnlyTarget < Error; end
16
+ class RunClosed < Error; end
17
+ end