textus 0.9.2 → 0.10.1

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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +74 -0
  3. data/CHANGELOG.md +123 -0
  4. data/README.md +4 -4
  5. data/SPEC.md +8 -8
  6. data/docs/conventions.md +1 -1
  7. data/lib/textus/application/context.rb +0 -24
  8. data/lib/textus/application/refresh/orchestrator.rb +3 -2
  9. data/lib/textus/application/refresh/worker.rb +1 -1
  10. data/lib/textus/application/writes/accept.rb +3 -2
  11. data/lib/textus/application/writes/build.rb +101 -9
  12. data/lib/textus/application/writes/delete.rb +1 -2
  13. data/lib/textus/application/writes/put.rb +1 -2
  14. data/lib/textus/builder/pipeline.rb +1 -1
  15. data/lib/textus/builder/renderer/json.rb +1 -1
  16. data/lib/textus/builder/renderer/markdown.rb +1 -1
  17. data/lib/textus/builder/renderer/text.rb +1 -1
  18. data/lib/textus/builder/renderer/yaml.rb +1 -1
  19. data/lib/textus/builder/renderer.rb +1 -1
  20. data/lib/textus/cli/verb/accept.rb +1 -2
  21. data/lib/textus/cli/verb/audit.rb +1 -2
  22. data/lib/textus/cli/verb/blame.rb +1 -2
  23. data/lib/textus/cli/verb/delete.rb +1 -2
  24. data/lib/textus/cli/verb/freshness.rb +1 -2
  25. data/lib/textus/cli/verb/get.rb +1 -2
  26. data/lib/textus/cli/verb/hook_run.rb +1 -1
  27. data/lib/textus/cli/verb/mv.rb +1 -2
  28. data/lib/textus/cli/verb/policy_explain.rb +1 -2
  29. data/lib/textus/cli/verb/put.rb +5 -4
  30. data/lib/textus/cli/verb/refresh.rb +1 -2
  31. data/lib/textus/cli/verb/refresh_stale.rb +1 -2
  32. data/lib/textus/cli/verb/reject.rb +1 -2
  33. data/lib/textus/cli/verb.rb +14 -0
  34. data/lib/textus/composition.rb +1 -0
  35. data/lib/textus/doctor.rb +3 -1
  36. data/lib/textus/intro.rb +0 -5
  37. data/lib/textus/manifest/entry.rb +21 -4
  38. data/lib/textus/manifest.rb +0 -11
  39. data/lib/textus/projection.rb +1 -1
  40. data/lib/textus/refresh.rb +1 -2
  41. data/lib/textus/store/staleness.rb +1 -1
  42. data/lib/textus/store/writer.rb +1 -1
  43. data/lib/textus/store.rb +1 -1
  44. data/lib/textus/version.rb +1 -1
  45. metadata +2 -5
  46. data/docs/architecture.md +0 -129
  47. data/lib/textus/builder.rb +0 -99
  48. data/lib/textus/publisher.rb +0 -6
  49. data/lib/textus/store/view.rb +0 -18
@@ -6,8 +6,7 @@ module Textus
6
6
  option :zone, "--zone=Z"
7
7
 
8
8
  def call(store)
9
- role = Role.resolve(flag: nil, env: ENV, root: store.root)
10
- ctx = Textus::Composition.context(store, role: role)
9
+ ctx = context_for(store)
11
10
  rows = Textus::Composition.freshness(ctx).call(prefix: prefix, zone: zone)
12
11
  emit({ "verb" => "freshness", "rows" => rows })
13
12
  end
@@ -6,8 +6,7 @@ module Textus
6
6
 
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("get requires a key")
9
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
10
- ctx = Textus::Composition.context(store, role: role)
9
+ ctx = context_for(store)
11
10
  result = Textus::Composition.reads_get(ctx).call(key)
12
11
  raise Textus::UnknownKey.new(key, suggestions: store.manifest.suggestions_for(key)) if result.nil?
13
12
 
@@ -24,7 +24,7 @@ module Textus
24
24
 
25
25
  role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
26
26
  callable = store.registry.rpc_callable(:intake, name)
27
- view = Store::View.new(store, writable: true, as: role)
27
+ view = Application::Context.new(store: store, role: role)
28
28
 
29
29
  begin
30
30
  Timeout.timeout(Textus::Application::Refresh::Worker::FETCH_TIMEOUT_SECONDS) do
@@ -8,8 +8,7 @@ module Textus
8
8
  def call(store)
9
9
  old_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
10
10
  new_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
11
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
12
- emit(store.mv(old_key, new_key, as: role, dry_run: dry_run || false))
11
+ emit(store.mv(old_key, new_key, as: resolved_role(store), dry_run: dry_run || false))
13
12
  end
14
13
  end
15
14
  end
@@ -4,8 +4,7 @@ module Textus
4
4
  class PolicyExplain < Verb
5
5
  def call(store)
6
6
  key = positional.shift or raise UsageError.new("policy explain requires a KEY")
7
- role = Role.resolve(flag: nil, env: ENV, root: store.root)
8
- ctx = Textus::Composition.context(store, role: role)
7
+ ctx = context_for(store)
9
8
  result = Textus::Composition.policy_explain(ctx).call(key: key)
10
9
  emit({ "verb" => "policy_explain" }.merge(result.transform_keys(&:to_s)))
11
10
  end
@@ -10,7 +10,7 @@ module Textus
10
10
  key = positional.shift or raise UsageError.new("put requires a key")
11
11
  raise UsageError.new("put requires --stdin in v1") unless use_stdin
12
12
 
13
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
13
+ role = resolved_role(store)
14
14
 
15
15
  raw = @stdin.read
16
16
  payload =
@@ -19,7 +19,8 @@ module Textus
19
19
  result =
20
20
  begin
21
21
  Timeout.timeout(Textus::Application::Refresh::Worker::FETCH_TIMEOUT_SECONDS) do
22
- callable.call(config: { "bytes" => raw }, store: Textus::Store::View.new(store), args: {})
22
+ callable.call(config: { "bytes" => raw },
23
+ store: Textus::Application::Context.new(store: store, role: role), args: {})
23
24
  end
24
25
  rescue Timeout::Error
25
26
  raise UsageError.new(
@@ -32,14 +33,14 @@ module Textus
32
33
  "name" => basename,
33
34
  "last_refreshed_at" => Time.now.utc.iso8601,
34
35
  "fetched_with" => fetch_name,
35
- }.merge(result[:_meta] || result["_meta"] || result[:frontmatter] || result["frontmatter"] || {}),
36
+ }.merge(result[:_meta] || result["_meta"] || {}),
36
37
  "body" => result[:body] || result["body"] || "",
37
38
  }
38
39
  else
39
40
  JSON.parse(raw)
40
41
  end
41
42
 
42
- meta = payload["_meta"] || payload["frontmatter"] || {}
43
+ meta = payload["_meta"] || {}
43
44
  body = payload["body"] || ""
44
45
  if_etag = payload["if_etag"]
45
46
  ctx = Textus::Composition.context(store, role: role)
@@ -6,8 +6,7 @@ module Textus
6
6
 
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("refresh requires a key")
9
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
10
- ctx = Textus::Composition.context(store, role: role)
9
+ ctx = context_for(store)
11
10
  emit(Textus::Composition.refresh_worker(ctx).run(key))
12
11
  end
13
12
  end
@@ -7,8 +7,7 @@ module Textus
7
7
  option :as_flag, "--as=ROLE"
8
8
 
9
9
  def call(store)
10
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
11
- ctx = Textus::Composition.context(store, role: role)
10
+ ctx = context_for(store)
12
11
  result = Textus::Application::Refresh::All.call(ctx, prefix: prefix, zone: zone)
13
12
  emit(result)
14
13
  exit(1) unless result["ok"]
@@ -6,8 +6,7 @@ module Textus
6
6
 
7
7
  def call(store)
8
8
  key = positional.shift or raise UsageError.new("reject requires a key")
9
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
10
- emit(store.reject(key, as: role))
9
+ emit(store.reject(key, as: resolved_role(store)))
11
10
  end
12
11
  end
13
12
  end
@@ -57,6 +57,20 @@ module Textus
57
57
  @stdout.puts(JSON.generate(payload))
58
58
  exit_code
59
59
  end
60
+
61
+ # Resolves the active role for this invocation. Honors the verb's
62
+ # `--as` flag if declared, then TEXTUS_ROLE, then the project default.
63
+ def resolved_role(store)
64
+ flag = respond_to?(:as_flag) ? as_flag : nil
65
+ Role.resolve(flag: flag, env: ENV, root: store.root)
66
+ end
67
+
68
+ # Returns an Application::Context bound to the resolved role.
69
+ # Convenience for verbs whose only pre-call boilerplate is
70
+ # resolving the role and wrapping it in a context.
71
+ def context_for(store)
72
+ Textus::Composition.context(store, role: resolved_role(store))
73
+ end
60
74
  end
61
75
  end
62
76
  end
@@ -41,6 +41,7 @@ module Textus
41
41
  bus: ctx.store.bus,
42
42
  store_root: ctx.store.root,
43
43
  store: ctx.store,
44
+ role: ctx.role,
44
45
  )
45
46
  end
46
47
 
data/lib/textus/doctor.rb CHANGED
@@ -52,7 +52,7 @@ module Textus
52
52
 
53
53
  def run_registered_checks(store)
54
54
  out = []
55
- view = Store::View.new(store)
55
+ view = Application::Context.new(store: store, role: "human")
56
56
  store.registry.rpc_names(:check).each do |name|
57
57
  callable = store.registry.rpc_callable(:check, name)
58
58
  begin
@@ -86,5 +86,7 @@ module Textus
86
86
  "fix" => fix,
87
87
  }
88
88
  end
89
+
90
+ private_class_method :run_registered_checks, :fail_issue
89
91
  end
90
92
  end
data/lib/textus/intro.rb CHANGED
@@ -16,11 +16,6 @@ module Textus
16
16
  "inbox" => "declared external inputs; script-refreshed via actions",
17
17
  "review" => "AI proposals awaiting human accept",
18
18
  "output" => "build-computed outputs; never hand-edited",
19
- # legacy 0.9.1 zone names — kept so intro still annotates pre-rename stores
20
- "canon" => "slow-changing identity; human-only writes",
21
- "intake" => "declared external inputs; script-refreshed via actions",
22
- "pending" => "AI proposals awaiting human accept",
23
- "derived" => "build-computed outputs; never hand-edited",
24
19
  }.freeze
25
20
 
26
21
  WRITE_FLOWS = {
@@ -56,15 +56,32 @@ module Textus
56
56
  @publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
57
57
  end
58
58
 
59
+ # Signal-based zone-kind predicates: derive the "kind" of a zone from its
60
+ # writable_by signals rather than its literal name. This keeps detection
61
+ # working when users rename the default zones (canon/intake/pending/derived
62
+ # → identity/inbox/review/output, etc.).
63
+ def in_generator_zone?
64
+ zone_writers.include?("build")
65
+ end
66
+
67
+ def in_proposal_zone?
68
+ zone_writers.include?("ai")
69
+ end
70
+
71
+ # Legacy alias for in_generator_zone?. Retained because internal validation
72
+ # callers (and external tools) read more naturally as `derived?`.
59
73
  def derived?
60
- writers = @manifest.zone_writers(@zone)
61
- writers.include?("build")
62
- rescue UsageError => e
63
- raise UsageError.new("entry '#{@key}': #{e.message}")
74
+ in_generator_zone?
64
75
  end
65
76
 
66
77
  private
67
78
 
79
+ def zone_writers
80
+ @manifest.zone_writers(@zone)
81
+ rescue UsageError => e
82
+ raise UsageError.new("entry '#{@key}': #{e.message}")
83
+ end
84
+
68
85
  def validate_inject_intro!
69
86
  return unless @inject_intro
70
87
 
@@ -136,17 +136,6 @@ module Textus
136
136
  end
137
137
  # rubocop:enable Metrics/AbcSize
138
138
 
139
- # Validates all declared entry keys; raises UsageError listing all offenders.
140
- def validate_keys!
141
- offenders = []
142
- @entries.each do |entry|
143
- validate_key!(entry.key)
144
- rescue UsageError => e
145
- offenders << e.message
146
- end
147
- raise UsageError.new("invalid manifest keys: #{offenders.join("; ")}") unless offenders.empty?
148
- end
149
-
150
139
  def validate_key!(key)
151
140
  raise UsageError.new("empty key") if key.nil? || key.empty?
152
141
 
@@ -40,7 +40,7 @@ module Textus
40
40
  def apply_reducer(rows)
41
41
  name = @spec["reduce"] or return rows
42
42
  callable = @store.registry.rpc_callable(:reduce, name)
43
- view = Store::View.new(@store)
43
+ view = Application::Context.new(store: @store, role: "human")
44
44
  Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
45
45
  callable.call(store: view, rows: rows, config: @spec["reduce_config"] || {})
46
46
  end
@@ -15,8 +15,7 @@ module Textus
15
15
  def self.normalize_action_result(res, format:)
16
16
  res = res.transform_keys(&:to_s) if res.is_a?(Hash)
17
17
  res ||= {}
18
- # Accept both legacy :frontmatter/:_meta key names from intake hooks.
19
- meta_val = res["_meta"] || res["frontmatter"]
18
+ meta_val = res["_meta"]
20
19
  body = res["body"]
21
20
  content = res["content"]
22
21
 
@@ -11,7 +11,7 @@ module Textus
11
11
  def call(prefix: nil, zone: nil)
12
12
  out = []
13
13
  @manifest.entries.each do |mentry|
14
- next unless mentry.zone == "derived"
14
+ next unless mentry.in_generator_zone?
15
15
  next if zone && mentry.zone != zone
16
16
 
17
17
  gen = mentry.generator
@@ -152,7 +152,7 @@ module Textus
152
152
  raise ProposalError.new("only human role can reject proposals; got '#{as}'") unless as == "human"
153
153
 
154
154
  mentry, = @store.manifest.resolve(pending_key)
155
- raise ProposalError.new("reject: '#{pending_key}' is not a pending entry (zone=#{mentry.zone})") unless mentry.zone == "pending"
155
+ raise ProposalError.new("reject: '#{pending_key}' is not in a proposal zone (zone=#{mentry.zone})") unless mentry.in_proposal_zone?
156
156
 
157
157
  env = @store.get(pending_key)
158
158
  proposal = env.dig("_meta", "proposal") or
data/lib/textus/store.rb CHANGED
@@ -102,7 +102,7 @@ module Textus
102
102
  end
103
103
 
104
104
  def fire_event(event, **)
105
- view = Store::View.new(self)
105
+ view = Textus::Application::Context.new(store: self, role: "human")
106
106
  @bus.publish(event, store: view, **)
107
107
  end
108
108
 
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.9.2"
2
+ VERSION = "0.10.1"
3
3
  PROTOCOL = "textus/2"
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.2
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -102,10 +102,10 @@ executables:
102
102
  extensions: []
103
103
  extra_rdoc_files: []
104
104
  files:
105
+ - ARCHITECTURE.md
105
106
  - CHANGELOG.md
106
107
  - README.md
107
108
  - SPEC.md
108
- - docs/architecture.md
109
109
  - docs/conventions.md
110
110
  - exe/textus
111
111
  - lib/textus.rb
@@ -123,7 +123,6 @@ files:
123
123
  - lib/textus/application/writes/delete.rb
124
124
  - lib/textus/application/writes/publish.rb
125
125
  - lib/textus/application/writes/put.rb
126
- - lib/textus/builder.rb
127
126
  - lib/textus/builder/pipeline.rb
128
127
  - lib/textus/builder/renderer.rb
129
128
  - lib/textus/builder/renderer/json.rb
@@ -226,7 +225,6 @@ files:
226
225
  - lib/textus/mustache.rb
227
226
  - lib/textus/projection.rb
228
227
  - lib/textus/proposal.rb
229
- - lib/textus/publisher.rb
230
228
  - lib/textus/refresh.rb
231
229
  - lib/textus/role.rb
232
230
  - lib/textus/schema.rb
@@ -237,7 +235,6 @@ files:
237
235
  - lib/textus/store/reader.rb
238
236
  - lib/textus/store/staleness.rb
239
237
  - lib/textus/store/validator.rb
240
- - lib/textus/store/view.rb
241
238
  - lib/textus/store/writer.rb
242
239
  - lib/textus/version.rb
243
240
  homepage: https://github.com/patrick204nqh/textus
data/docs/architecture.md DELETED
@@ -1,129 +0,0 @@
1
- # Architecture
2
-
3
- How the reference Ruby implementation is organized. The wire protocol itself lives in [`../SPEC.md`](../SPEC.md); this document covers *how* the gem implements that spec.
4
-
5
- The codebase is a flat graph of small modules under one CLI dispatcher, not a strict pyramid. The clusters below describe what each module exists for and which other modules it talks to.
6
-
7
- ## At a glance
8
-
9
- ```
10
- exe/textus → Textus::CLI ──┬──► Store (facade — delegates to Reader/Writer/Mover)
11
- │ ├──► Store::Reader (get/list/where/uid/deps/published/stale/validate_all)
12
- │ ├──► Store::Writer (put/delete/accept)
13
- │ ├──► Store::Mover (mv)
14
- │ └──► Hooks::Dispatcher (lifecycle publish/subscribe)
15
- ├──► Builder (build verb — Pipeline + per-format renderers)
16
- ├──► Refresh (refresh verb)
17
- ├──► Doctor (doctor verb)
18
- ├──► Init (init verb)
19
- ├──► Intro (intro verb)
20
- ├──► MigrateKeys (key migrate, key mv verbs)
21
- ├──► Schema::Tools (schema init/diff/migrate verbs)
22
- ├──► Store::View (read-only projection over Store::Reader)
23
- └──► Role (role gate)
24
- ```
25
-
26
- CLI is the single entry point. It parses argv and dispatches each verb to whichever module owns that capability — there is no single mediator below CLI.
27
-
28
- ## Module clusters
29
-
30
- ### 1. Request path — core read/write verbs
31
-
32
- `Store` is a thin facade (~110 LOC) that holds `Manifest`, `Hooks::Registry`, `Hooks::Dispatcher`, and the lazy `Store::AuditLog`, then delegates verbs to a small set of focused collaborators:
33
-
34
- - **`Store::Reader`** — owns `get`, `list`, `where`, `uid`, `deps`, `rdeps`, `published`, `schema_envelope`, `validate_all`. The only module that reads working-store entry files. (`freshness` lives in `Application::Reads::Freshness` since 0.9.2; the legacy `stale` shim was removed.)
35
- - **`Store::Writer`** — owns `put`, `delete`, `accept`. Handles serialization, uid minting, etag check, role gate, audit append, and event publication. The only module that writes working-store entry files.
36
- - **`Store::Mover`** — owns `mv` (same-zone rename) with uid preservation and one audit row.
37
- - **`Store::Validator`** / **`Store::Staleness`** — back the `validate_all` / `freshness` reads. Take explicit collaborators (`reader:`, `manifest:`, `audit_log:`, `schema_for:`) instead of the full store.
38
-
39
- Shared value modules and primitives consumed by Reader/Writer/Mover:
40
-
41
- - **`Textus::Key::Path`** — `Key::Path.resolve(manifest, mentry)` returns the absolute leaf path for a manifest entry. Single source for zone-path construction; used by `Manifest`, `Staleness`, `Builder`, and Writer.
42
- - **`Textus::Envelope`** — `Envelope.build(...)` returns the canonical envelope hash (protocol, key, zone, owner, path, format, `_meta`, body, etag, schema_ref, uid, optional content). Single source for envelope shape across `get` and `put`.
43
- - **`Manifest`** — parses `.textus/manifest.yaml`; resolves a dotted key to a path via longest-prefix match. `nested: true` entries treat unmatched suffix segments as `/`-joined subdirs, with `.md` appended. Resolution is path-only; existence is the verb's concern.
44
- - **`Schema`** — loads YAML schema files; validates frontmatter shape and surfaces unknown-key warnings (the §6 forward-compat rule).
45
- - **`Entry`** + format adapters (`entry/markdown.rb`, `entry/text.rb`, `entry/json.rb`, `entry/yaml.rb`) — splits raw bytes on `---\n`, feeds the YAML chunk to `YAML.safe_load` (no aliases, restricted classes). The frontmatter `name:` field is enforced against the file basename in Reader/Writer (on read and on write) — mismatch raises `bad_frontmatter`.
46
- - **`Etag`** — `sha256:<hex>` over raw file bytes. `put` accepts optional `if_etag:`; mismatch raises `etag_mismatch`. No locking, no temp-file-and-rename — v1 leaves stronger guarantees to v1.x.
47
- - **`Role`** — agent-vs-human gate. Writer checks `Manifest::Entry#zone_writers` before doing anything else; otherwise raises `write_forbidden`.
48
- - **`Store::AuditLog`** — append-only NDJSON; every successful write emits one line.
49
- - **`Proposal`** — `accept` verb flow for promoting a pending entry into its target zone.
50
- - **`Dependencies`** — `deps`/`rdeps`/`published` verb backing; walks manifest declarations.
51
-
52
- ### 2. Build / publish pipeline
53
-
54
- Separate from the request path. Owns derived-entry materialization and byte-copy publish. `Builder` orchestrates per-entry materialization through `Builder::Pipeline`, which runs an ordered step list and dispatches the rendering step to one of four format-specific renderers. Adding a new output format is a single-file change under `lib/textus/builder/renderer/`.
55
-
56
- ```
57
- Builder ──► Pipeline ──► LoadSources ──► Project ──► Render (per-format) ──► Write ──► Publisher ──► (sentinel)
58
-
59
- └──► Renderer::{Markdown, Text, Json, Yaml}
60
- ```
61
-
62
- - **`Builder`** — iterates `zone: derived` entries, hands each to `Pipeline.run`, then handles `Publisher` copy-out and fires the `:build` event. Holds no format-specific logic.
63
- - **`Builder::Pipeline`** — `Pipeline.run(store:, mentry:, template_loader:)` is the orchestrator: runs the projection, merges `intro` if `inject_intro: true`, dispatches to the matching renderer, writes the bytes to the derived path.
64
- - **`Builder::InjectMeta`** — builds the `_meta` block (`generated_at`, `from`, `template`, `reduce`) and threads it onto JSON/YAML content as the first key per SPEC §6 ordering.
65
- - **`Builder::Renderer::{Markdown,Text,Json,Yaml}`** — one class per format, inheriting `Builder::Renderer`. Receives a template-loader lambda and `(mentry:, data:)`; returns rendered bytes. Markdown/Text always require a template; JSON/YAML optionally accept one (otherwise default-shape the projection rows).
66
- - **`Projection`** — collects rows from manifest-declared source keys, applies optional reducer, sorts and positions. Pure data shaping.
67
- - **`Mustache`** — minimal mustache renderer for templates in `.textus/templates/`.
68
- - **`Publisher`** — byte-copy from store path to external target path. Refuses to overwrite unmanaged targets; writes a sentinel in `.textus/sentinels/` to track managed targets.
69
-
70
- ### 3. Extension surface
71
-
72
- Declared in the manifest, loaded on demand, dispatched by `Store` and `Refresh`.
73
-
74
- - **`Hooks::Registry`** — loads one `.rb` per hook from `.textus/hooks/`, registers callables under their `(event, name)`. Single source of truth via the `EVENTS` table (rpc vs pubsub, arg shape, failure semantics). For pub-sub events it also forwards registrations to the `Hooks::Dispatcher`.
75
- - **`Hooks::Dispatcher`** — first-class pub/sub for lifecycle events (`:put`, `:delete`, `:refresh`, `:build`, `:accept`, `:publish`, `:mv`, `:reject`, `:loaded`). Owns the 2-second per-handler timeout and the audit-on-failure middleware (raising handlers do not abort the write; they produce an `event_error` audit row). Embedded callers can `store.dispatcher.subscribe(:put, :name) { ... }` outside `.textus/hooks/`.
76
- - **`Hooks::Builtin`** — ships built-in `:fetch` hooks (e.g. json, csv, ical-events, rss) available without user-supplied hooks.
77
- - **`Refresh`** — `refresh` verb: looks up the `:fetch` hook for a key, invokes it, normalizes the result by declared format, writes through `Store::Writer` with an etag check.
78
-
79
- ### 4. Operational tooling
80
-
81
- First-class CLI verbs that don't fit the read/write/build axes. Read-mostly; side modules off CLI.
82
-
83
- - **`Doctor`** — `doctor` verb: orchestrator that runs 9 builtin checks under `Doctor::Check::*`. Talks to Manifest/Schema/Entry/Hooks::Registry directly.
84
- - **`Doctor::Check`** — explicit base class for doctor checks. Each of the 9 builtin checks is its own file under `lib/textus/doctor/check/`.
85
- - **`MigrateKeys`** — `key migrate` and `key mv` verbs; computes renames against the manifest.
86
- - **`Schema::Tools`** — `schema init`, `schema diff`, `schema migrate` verbs.
87
- - **`Init`** — `init` verb: scaffolds `.textus/` with the five zone directories, baseline schemas, empty audit log, starter manifest.
88
- - **`Intro`** — `intro` verb: emits the human/agent-facing onboarding payload.
89
- - **`Store::View`** — read-only projection over `Store::Reader` for hook code that should not mutate.
90
- - **`Key::Distance`** — Levenshtein-ish suggestion for `did-you-mean` on unknown keys.
91
-
92
- ### 5. Primitives
93
-
94
- - **`Errors`** — `Textus::Error` subclasses, each carrying a stable `code`, a `details` hash, and an `exit_code`. `CLI` catches them at the top level and emits the §8 error envelope on stdout. In `--format=json` mode, errors are **never** written to stderr — agents read stdout.
95
- - **`version`** — gem semver string (independent of the wire protocol `textus/2`).
96
-
97
- ## Invariants
98
-
99
- - **CLI is the only entry point.** No public API surface guarantees outside the verbs CLI exposes.
100
- - **Manifest is pure.** Reads at load, no mutation.
101
- - **Store::Writer is the only module that writes to working-store entry files.** Reader reads them; Mover moves them within a zone. Init, MigrateKeys, Publisher, Builder, AuditLog write to **other** parts of `.textus/` (scaffolding, sentinels, audit log, derived targets) — they do not edit existing entry files behind the Store facade's back.
102
- - **`name:` frontmatter matches file basename.** Enforced on read and write.
103
- - **Zone semantics live in the manifest, not in directory names.** A project may rename `state/` to anything; the manifest declares which zone each entry belongs to.
104
- - **`freshness` does not execute anything.** It walks every entry, matches it against the top-level `policies:` block, and returns each entry's verdict (`fresh|stale|never_refreshed|no_policy`). Build runners execute. This is the §5.1 "dataflow oracle, not executor" boundary.
105
-
106
- ## Policy resolution
107
-
108
- Top-level `policies:` are parsed into a `Manifest::Policies` collection. Resolution is by key, slot-aware, most-specific-wins:
109
-
110
- ```
111
- Manifest#policies_for(key)
112
- └─► Manifest::Policies#for(key)
113
- ├─► Policy::Matcher (specificity ranking)
114
- └─► returns PolicySet { refresh, handler_allowlist, promote, retention }
115
-
116
-
117
- consumers: Refresh::Worker
118
- Doctor checks
119
- Reads::PolicyExplain
120
- ```
121
-
122
- Two blocks at the same specificity filling the same slot for the same key is a manifest error reported by `doctor` (`policy_ambiguity`). Custom-named zones see no special handling — policies match against the full key.
123
-
124
- ## What this implementation deliberately leaves out
125
-
126
- - **No process spawning.** Even `stale` does not execute. Build runners do that.
127
- - **No transport.** No HTTP server, no socket, no MCP server in this gem. Those are downstream wrappers (see [`./conventions.md`](./conventions.md)).
128
- - **No indexes.** Listing walks the filesystem each time. Premature optimisation for v1.
129
- - **No locking.** Etag is advisory; concurrent writers can still race. Left to v1.x (§14 open question).
@@ -1,99 +0,0 @@
1
- require "fileutils"
2
-
3
- # As of 0.9.1, Textus::Application::Writes::Build is the preferred public
4
- # entry point. This class remains as the implementation home of materialization
5
- # and projection logic; full extraction is deferred to 0.10.0.
6
- module Textus
7
- class Builder
8
- def initialize(store)
9
- @store = store
10
- @manifest = store.manifest
11
- @root = store.root
12
- end
13
-
14
- def build(prefix: nil)
15
- built = []
16
- @manifest.entries.each do |mentry|
17
- next unless derived_zone?(mentry)
18
- next unless mentry.projection || mentry.template
19
- next if prefix && !mentry.key.start_with?(prefix)
20
-
21
- result = materialize(mentry)
22
- built << result
23
- end
24
- published_leaves = publish_leaves(prefix: prefix)
25
- { "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => published_leaves }
26
- end
27
-
28
- private
29
-
30
- def publish_leaves(prefix: nil)
31
- repo_root = File.dirname(@root)
32
- out = []
33
- @manifest.entries.each do |mentry|
34
- next unless mentry.nested && mentry.publish_each
35
- next if prefix && !mentry.key.start_with?(prefix) && !prefix.start_with?("#{mentry.key}.")
36
-
37
- @manifest.enumerate(prefix: mentry.key).each do |row|
38
- next unless row[:manifest_entry].equal?(mentry)
39
- next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
40
-
41
- out << publish_leaf(mentry, row, repo_root)
42
- end
43
- end
44
- out
45
- end
46
-
47
- def publish_leaf(mentry, row, repo_root)
48
- target_rel = mentry.publish_target_for(row[:key])
49
- target_abs = File.expand_path(File.join(repo_root, target_rel))
50
- unless target_abs.start_with?(File.expand_path(repo_root) + File::SEPARATOR)
51
- raise PublishError.new(
52
- "entry '#{mentry.key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
53
- )
54
- end
55
-
56
- Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: @root)
57
- @store.fire_event(:published, key: row[:key], envelope: @store.get(row[:key]),
58
- source: row[:path], target: target_abs)
59
- { "key" => row[:key], "source" => row[:path], "target" => target_abs }
60
- end
61
-
62
- def derived_zone?(mentry)
63
- writers = @manifest.zone_writers(mentry.zone)
64
- writers.include?("build")
65
- end
66
-
67
- def materialize(mentry)
68
- target_path = Pipeline.run(
69
- store: @store,
70
- mentry: mentry,
71
- template_loader: ->(name) { read_template(name) },
72
- )
73
- publish_and_fire(mentry, target_path)
74
- { "key" => mentry.key, "path" => target_path, "published_to" => mentry.publish_to }
75
- end
76
-
77
- def read_template(name)
78
- tpl_path = File.join(@root, "templates", name)
79
- raise TemplateError.new("template not found: #{tpl_path}", template_name: name) unless File.exist?(tpl_path)
80
-
81
- File.read(tpl_path)
82
- end
83
-
84
- def publish_and_fire(mentry, target_path)
85
- envelope = @store.get(mentry.key)
86
- repo_root = File.dirname(@root)
87
-
88
- mentry.publish_to.each do |rel|
89
- target_abs = File.join(repo_root, rel)
90
- Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: @root)
91
- @store.fire_event(:published, key: mentry.key, envelope: envelope,
92
- source: target_path, target: target_abs)
93
- end
94
-
95
- @store.fire_event(:built, key: mentry.key, envelope: envelope,
96
- sources: Array(mentry.projection&.fetch("select", nil)).compact)
97
- end
98
- end
99
- end
@@ -1,6 +0,0 @@
1
- # Deprecated as of 0.9.1: use Textus::Infra::Publisher (or
2
- # Textus::Application::Writes::Publish for the use-case entry point).
3
- # Slated for removal in 0.10.0.
4
- module Textus
5
- Publisher = Infra::Publisher
6
- end
@@ -1,18 +0,0 @@
1
- module Textus
2
- class Store
3
- # Deprecated as of 0.9.1: use Textus::Application::Context instead.
4
- # Removal scheduled for 0.10.0.
5
- class View
6
- def self.new(store, writable: false, as: nil)
7
- unless @warned_once
8
- warn "[textus] Store::View is deprecated; use Application::Context (will be removed in 0.10.0)"
9
- @warned_once = true
10
- end
11
-
12
- raise UsageError.new("writable Store::View requires an as: role") if writable && (as.nil? || as.to_s.empty?)
13
-
14
- Textus::Application::Context.new(store: store, role: as || "human")
15
- end
16
- end
17
- end
18
- end