textus 0.53.0 → 0.54.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 (160) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/README.md +53 -73
  4. data/SPEC.md +2 -5
  5. data/docs/architecture/README.md +149 -159
  6. data/lib/textus/action/accept.rb +0 -9
  7. data/lib/textus/action/audit.rb +0 -2
  8. data/lib/textus/action/blame.rb +0 -2
  9. data/lib/textus/action/boot.rb +2 -11
  10. data/lib/textus/action/data_mv.rb +1 -3
  11. data/lib/textus/action/deps.rb +1 -15
  12. data/lib/textus/action/doctor.rb +0 -2
  13. data/lib/textus/action/drain.rb +4 -6
  14. data/lib/textus/action/enqueue.rb +3 -5
  15. data/lib/textus/action/get.rb +1 -3
  16. data/lib/textus/action/ingest.rb +135 -0
  17. data/lib/textus/action/jobs.rb +1 -3
  18. data/lib/textus/action/key_delete.rb +0 -10
  19. data/lib/textus/action/key_delete_prefix.rb +1 -3
  20. data/lib/textus/action/key_mv.rb +1 -15
  21. data/lib/textus/action/key_mv_prefix.rb +1 -3
  22. data/lib/textus/action/list.rb +0 -2
  23. data/lib/textus/action/propose.rb +0 -2
  24. data/lib/textus/action/published.rb +0 -2
  25. data/lib/textus/action/pulse.rb +7 -38
  26. data/lib/textus/action/put.rb +1 -12
  27. data/lib/textus/action/rdeps.rb +2 -12
  28. data/lib/textus/action/reject.rb +0 -12
  29. data/lib/textus/action/rule_explain.rb +0 -4
  30. data/lib/textus/action/rule_lint.rb +1 -3
  31. data/lib/textus/action/rule_list.rb +0 -4
  32. data/lib/textus/action/schema_envelope.rb +0 -2
  33. data/lib/textus/action/uid.rb +0 -2
  34. data/lib/textus/action/where.rb +0 -2
  35. data/lib/textus/action/write_verb.rb +4 -18
  36. data/lib/textus/boot.rb +40 -109
  37. data/lib/textus/command.rb +6 -1
  38. data/lib/textus/container.rb +21 -5
  39. data/lib/textus/core/freshness/evaluator.rb +18 -34
  40. data/lib/textus/core/freshness/verdict.rb +1 -1
  41. data/lib/textus/doctor/check/notebook_sources.rb +50 -0
  42. data/lib/textus/doctor/check/protocol_version.rb +6 -6
  43. data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
  44. data/lib/textus/doctor/check/stale_reviewed_stamp.rb +54 -0
  45. data/lib/textus/doctor/check.rb +0 -1
  46. data/lib/textus/doctor.rb +3 -42
  47. data/lib/textus/envelope/reader.rb +46 -0
  48. data/lib/textus/envelope/writer.rb +209 -0
  49. data/lib/textus/envelope.rb +19 -6
  50. data/lib/textus/errors.rb +9 -5
  51. data/lib/textus/{entry → format}/base.rb +1 -6
  52. data/lib/textus/{entry → format}/json.rb +1 -4
  53. data/lib/textus/{entry → format}/markdown.rb +2 -6
  54. data/lib/textus/{entry → format}/text.rb +2 -6
  55. data/lib/textus/{entry → format}/yaml.rb +1 -4
  56. data/lib/textus/{entry.rb → format.rb} +9 -10
  57. data/lib/textus/gate/auth.rb +58 -12
  58. data/lib/textus/gate.rb +2 -0
  59. data/lib/textus/init.rb +16 -79
  60. data/lib/textus/jobs/base.rb +23 -0
  61. data/lib/textus/jobs/materialize.rb +20 -0
  62. data/lib/textus/{background → jobs}/plan.rb +1 -1
  63. data/lib/textus/jobs/planner.rb +101 -0
  64. data/lib/textus/jobs/retention.rb +48 -0
  65. data/lib/textus/jobs/sweep.rb +27 -0
  66. data/lib/textus/{background → jobs}/worker.rb +2 -2
  67. data/lib/textus/jobs.rb +15 -0
  68. data/lib/textus/key/path.rb +1 -1
  69. data/lib/textus/manifest/entry/base.rb +4 -19
  70. data/lib/textus/manifest/entry/parser.rb +27 -10
  71. data/lib/textus/manifest/entry/produced.rb +13 -34
  72. data/lib/textus/manifest/entry/publish/to_paths.rb +4 -4
  73. data/lib/textus/manifest/entry/publish.rb +0 -12
  74. data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
  75. data/lib/textus/manifest/entry/validators.rb +0 -1
  76. data/lib/textus/manifest/policy/source.rb +14 -62
  77. data/lib/textus/manifest/policy.rb +2 -5
  78. data/lib/textus/manifest/resolver.rb +2 -2
  79. data/lib/textus/manifest/schema/contract.rb +61 -0
  80. data/lib/textus/manifest/schema/keys.rb +6 -20
  81. data/lib/textus/manifest/schema/semantics.rb +232 -0
  82. data/lib/textus/manifest/schema/validator.rb +24 -302
  83. data/lib/textus/manifest/schema/vocabulary.rb +1 -0
  84. data/lib/textus/manifest/schema.rb +1 -4
  85. data/lib/textus/manifest.rb +1 -3
  86. data/lib/textus/ports/{queue → job_store}/job.rb +1 -1
  87. data/lib/textus/ports/{queue.rb → job_store.rb} +6 -13
  88. data/lib/textus/produce/engine.rb +50 -0
  89. data/lib/textus/produce/render.rb +20 -0
  90. data/lib/textus/session.rb +15 -8
  91. data/lib/textus/store.rb +5 -14
  92. data/lib/textus/surfaces/mcp/catalog.rb +23 -11
  93. data/lib/textus/surfaces/mcp/errors.rb +0 -9
  94. data/lib/textus/surfaces/mcp/server.rb +87 -110
  95. data/lib/textus/surfaces/role_scope.rb +0 -4
  96. data/lib/textus/surfaces/watcher.rb +3 -3
  97. data/lib/textus/types.rb +15 -0
  98. data/lib/textus/version.rb +2 -2
  99. data/lib/textus/workflow/collector.rb +27 -0
  100. data/lib/textus/workflow/context.rb +5 -0
  101. data/lib/textus/workflow/dsl.rb +44 -0
  102. data/lib/textus/workflow/loader.rb +17 -0
  103. data/lib/textus/workflow/pattern.rb +18 -0
  104. data/lib/textus/workflow/registry.rb +21 -0
  105. data/lib/textus/workflow/runner.rb +99 -0
  106. data/lib/textus/workflow.rb +28 -0
  107. data/lib/textus.rb +12 -1
  108. metadata +67 -66
  109. data/lib/textus/action/pulse/scanner.rb +0 -118
  110. data/lib/textus/background/job/base.rb +0 -27
  111. data/lib/textus/background/job/materialize.rb +0 -31
  112. data/lib/textus/background/job/refresh.rb +0 -22
  113. data/lib/textus/background/job/sweep.rb +0 -31
  114. data/lib/textus/background/job.rb +0 -19
  115. data/lib/textus/background/planner/plan.rb +0 -113
  116. data/lib/textus/background/retention/apply.rb +0 -50
  117. data/lib/textus/doctor/check/handler_permit.rb +0 -34
  118. data/lib/textus/doctor/check/hooks.rb +0 -25
  119. data/lib/textus/doctor/check/intake_registration.rb +0 -46
  120. data/lib/textus/envelope/io/reader.rb +0 -48
  121. data/lib/textus/envelope/io/writer.rb +0 -211
  122. data/lib/textus/events.rb +0 -21
  123. data/lib/textus/init/templates/machine_intake.rb +0 -49
  124. data/lib/textus/init/templates/orientation_reducer.rb +0 -21
  125. data/lib/textus/manifest/entry/validators/events.rb +0 -22
  126. data/lib/textus/manifest/policy/handler_permit.rb +0 -17
  127. data/lib/textus/pipeline/acquire/handler.rb +0 -29
  128. data/lib/textus/pipeline/acquire/intake.rb +0 -132
  129. data/lib/textus/pipeline/acquire/projection.rb +0 -129
  130. data/lib/textus/pipeline/acquire/serializer/json.rb +0 -31
  131. data/lib/textus/pipeline/acquire/serializer/text.rb +0 -16
  132. data/lib/textus/pipeline/acquire/serializer/yaml.rb +0 -31
  133. data/lib/textus/pipeline/acquire/serializer.rb +0 -17
  134. data/lib/textus/pipeline/engine.rb +0 -97
  135. data/lib/textus/pipeline/render.rb +0 -25
  136. data/lib/textus/ports/audit_subscriber.rb +0 -42
  137. data/lib/textus/projection.rb +0 -103
  138. data/lib/textus/step/base.rb +0 -35
  139. data/lib/textus/step/builtin/csv_fetch.rb +0 -19
  140. data/lib/textus/step/builtin/ical_events_fetch.rb +0 -30
  141. data/lib/textus/step/builtin/json_fetch.rb +0 -18
  142. data/lib/textus/step/builtin/markdown_links_fetch.rb +0 -20
  143. data/lib/textus/step/builtin/rss_fetch.rb +0 -26
  144. data/lib/textus/step/builtin.rb +0 -22
  145. data/lib/textus/step/catalog.rb +0 -37
  146. data/lib/textus/step/context.rb +0 -74
  147. data/lib/textus/step/discovery.rb +0 -24
  148. data/lib/textus/step/error_log.rb +0 -32
  149. data/lib/textus/step/event_bus.rb +0 -113
  150. data/lib/textus/step/fetch.rb +0 -13
  151. data/lib/textus/step/fire_report.rb +0 -23
  152. data/lib/textus/step/loader.rb +0 -108
  153. data/lib/textus/step/observe.rb +0 -31
  154. data/lib/textus/step/registry_store.rb +0 -66
  155. data/lib/textus/step/signature.rb +0 -31
  156. data/lib/textus/step/transform.rb +0 -12
  157. data/lib/textus/step/validate.rb +0 -11
  158. data/lib/textus/step.rb +0 -10
  159. data/lib/textus/surfaces/mcp/session.rb +0 -9
  160. data/lib/textus/surfaces/mcp/tool_schemas.rb +0 -17
@@ -1,139 +1,154 @@
1
1
  # Textus architecture
2
2
 
3
3
  > **Explanation** · for contributors · **read this first** for orientation before SPEC
4
- > **SSoT for** the Ruby implementation layout (layers, container, ports, dispatch/pipeline paths) · **reviewed** 2026-06 (v0.46)
4
+ > **SSoT for** the Ruby implementation layout (layers, container, ports, dispatch paths) · **reviewed** 2026-06 (v0.54)
5
5
 
6
6
  ```mermaid
7
7
  flowchart TD
8
- surfaces["Surfaces — CLI verbs · MCP gate (JSON-RPC) · RoleScope"]
8
+ surfaces["Surfaces — CLI · MCP · RoleScope"]
9
9
  contract["Contract — per-verb DSL (source of truth for public interfaces)"]
10
- dispatch["DispatchGate · Auth · Ledger · Executor · Actions<br/>planner/ · pipeline/ · runtime/ · catalog/"]
10
+ gate["Gaterouting (VERB_COMMAND) · Auth"]
11
+ action["Actions — per-verb use cases (action/)"]
12
+ jobs["Jobs — Worker · Planner · Materialize · Retention · Sweep"]
13
+ produce["Produce — Engine · Render"]
14
+ workflow["Workflow — Runner · Registry · Loader · DSL"]
11
15
  manifest["Manifest — declarative config, no IO (policy/, schema/, entry/)"]
12
- core["Core — pure value types (Freshness, Job, Duration, Sentinel)"]
13
- ports["Ports — IO adapters (FileStore, AuditLog, Queue, Publisher…)"]
14
- step["Step — user-injectable wrappers (Fetch, Transform, Validate, Observe)"]
16
+ core["Core — pure value types (Freshness, Retention, Duration, Sentinel)"]
17
+ ports["Ports — IO adapters (FileStore, AuditLog, JobStore, Publisher…)"]
15
18
  surfaces --> contract
16
- contract --> dispatch
17
- dispatch --> manifest
18
- dispatch --> core
19
- dispatch --> ports
20
- dispatch --> step
19
+ contract --> gate
20
+ gate --> action
21
+ action --> jobs
22
+ action --> produce
23
+ produce --> workflow
24
+ action --> manifest
25
+ action --> core
26
+ action --> ports
27
+ jobs --> produce
21
28
  ```
22
29
 
23
- *Dependency rule: inward only.* `dispatch/planner/`, `dispatch/pipeline/`, and `dispatch/runtime/` are private sub-namespaces of `dispatch/` never referenced directly from `surfaces/` or `contract/`. Use cases are plain classes receiving `(container:, call:)`. Verbs are looked up in the static `Dispatcher::VERBS` table.
30
+ *Dependency rule: inward only.* Surfaces Contract Gate Actions inner layers. Actions never reference surfaces.
24
31
 
25
32
  ### What lives in each layer
26
33
 
27
- **Interface**
34
+ **Surfaces**
28
35
 
29
36
  ```
30
- CLI verbs: store.<verb>(..., role:)
31
- store.as(role).<verb>(...) # (put/get/fetch/…)
37
+ surfaces/cli/ CLI command generation from contracts
38
+ surfaces/mcp/ MCP server — stdio JSON-RPC 2.0, tools derived from contracts
39
+ surfaces/role_scope.rb
40
+ (Store#as(role)) — holds (container, role, dry_run, correlation_id);
41
+ all verb methods injected via define_method in textus.rb
42
+ ```
43
+
44
+ **Contract**
32
45
 
33
- MCP gate: textus mcp serve — same use cases, JSON-RPC.
46
+ ```
47
+ contract/ Per-verb DSL — verb, summary, surfaces, arg, view.
48
+ Contract::Binder.inputs_from_ordered splits the uniform inputs hash
49
+ into positional/keyword args for every surface.
34
50
  ```
35
51
 
36
- The CLI is a **projection of the per-verb `Contract`** (ADR 0063), the operator
37
- mirror of `MCP::Catalog`. The contract now owns the whole request lifecycle —
38
- `acquire → bind → invoke → render` (ADRs 0066–0068): one `Contract::Binder.bind`
39
- splits the uniform by-name `inputs` hash into the use-case's positional/keyword
40
- args for every surface; per-surface `view`s shape the output (`view` for
41
- MCP/Ruby, `view(:cli)` for the operator envelope); declarative `source:`/
42
- `coerce:`/`cli_stdin` populate inputs from files and stdin; `around:` resources
43
- wrap the single dispatch site (`RoleScope#dispatch_bound`) for stateful verbs;
44
- and `cli_default:` declares a CLI default that diverges from the agent default.
45
- `CLI::Runner` generates a command per `:cli` contract, dispatching `contract.verb`
46
- by construction. Only verbs with genuine *behavior* — `put` (entry persistence),
47
- `get` (UnknownKey + resolver suggestions, CLI-only), and `doctor` (not yet
48
- generatable) — stay hand-authored, plus commands with no dispatcher verb (`init`,
49
- `hook`, `mcp serve`, `schema diff/init`). `boot` is auto-generated from its
50
- contract. Total reconciliation specs make name/dispatch/facet drift unrepresentable.
52
+ **Gate + Auth**
51
53
 
52
- **Surfaces**
54
+ ```
55
+ gate.rb VERB_COMMAND (verb symbol → Command class)
56
+ Gate#dispatch(cmd) — Auth → action call
57
+ gate/auth.rb Authorization engine — FLOOR predicates + rule guards
58
+ ```
59
+
60
+ **Actions**
53
61
 
54
62
  ```
55
- CLI verbs: store.<verb>(..., role:)
56
- store.as(role).<verb>(...)
63
+ action/{get,list,put,key_delete,key_mv,accept,reject,propose,
64
+ drain,enqueue,audit,blame,deps,rdeps,published,boot,doctor,
65
+ rule_explain,rule_list,rule_lint,pulse,
66
+ data_mv,key_mv_prefix,key_delete_prefix,
67
+ schema_envelope,where,uid,jobs}.rb
68
+ ```
57
69
 
58
- MCP gate: textus mcp serve — same actions, JSON-RPC.
59
- RoleScope (Store#as(role) — builds Call, forwards to Dispatcher)
70
+ **Jobs**
71
+
72
+ ```
73
+ jobs/planner.rb Rules-driven job planning — seeds materialize/refresh/sweep jobs
74
+ jobs/worker.rb Queue drain — leases and runs jobs from JobStore
75
+ jobs/materialize.rb Per-key produce job → Produce::Engine.converge
76
+ jobs/refresh.rb Per-key refresh job (retention-rule-triggered)
77
+ jobs/retention.rb Retention policy check
78
+ jobs/sweep.rb Lane sweep job
60
79
  ```
61
80
 
62
- **Dispatch (all runtime)**
81
+ **Produce**
63
82
 
64
83
  ```
65
- Gate (thin coordinator: Auth → Ledger → Executor)
66
- Auth (authorization engine FLOOR predicates + rule guards)
67
- Ledger (append event to audit before execution)
68
- Executor (sync/async routing per action BURN mode)
69
- Event (Data.define: name, actor, target, payload, actions)
70
-
71
- actions/{get,list,put,key_delete,key_mv,accept,reject,propose,
72
- drain,materialize,refresh_data,sweep,observe,
73
- enqueue,audit,blame,deps,rdeps,published,boot,doctor,
74
- rule_explain,rule_list,rule_lint,pulse,
75
- data_mv,key_mv_prefix,key_delete_prefix,
76
- schema_envelope,where,uid,jobs}.rb
77
-
78
- planner/{planner,scheduler,seeder}.rb (rules-driven job planning)
79
- pipeline/{engine,render,acquire/{intake,handler,projection,serializer}}.rb
80
- runtime/{worker,watch,retention/apply,plan}.rb
81
- catalog/events.rb (dotted event name constants)
84
+ produce/engine.rb Engine.converge(container:, call:, keys:)
85
+ For each key: looks up a registered workflow and runs it,
86
+ or publishes existing bytes for entries with publish targets.
87
+ produce/render.rb Mustache template expansion for publish targets
88
+ ```
89
+
90
+ **Workflow**
91
+
92
+ ```
93
+ workflow/runner.rb Runs a Definition: build_context → execute_steps → publish
94
+ workflow/registry.rb In-memory list of registered Definitions; Registry#for(key)
95
+ workflow/loader.rb Loads .textus/workflows/*.rb files via Collector DSL
96
+ workflow/dsl.rb Textus.workflow "name" { match "...", step :fetch { ... }, publish }
97
+ workflow/context.rb Context passed to each step (key, entry, config, container, call)
98
+ workflow/pattern.rb Key-matching pattern (glob via File.fnmatch?)
99
+ workflow/errors.rb NotFound, StepFailed
82
100
  ```
83
101
 
84
102
  **Core (pure value types)**
85
103
 
86
104
  ```
87
105
  Freshness::{Verdict,Evaluator}
88
- Jobs::Job (immutable job value object)
89
- Duration Sentinel
106
+ Core::Duration Core::Sentinel
90
107
  ```
91
108
 
92
109
  **Infrastructure**
93
110
 
94
111
  ```
95
- Store (composition root — wires ports,
96
- vends a Container + dispatches verbs)
112
+ Store (composition root — wires ports, vends Container)
97
113
  Storage::FileStore (bytes-only port: read/write/delete/exists?/etag)
98
114
  Manifest (Data, Resolver, Policy, Rules)
99
115
  Schemas (eager-load cache)
100
- Ports::{AuditLog,AuditSubscriber,Publisher,Clock,
101
- BuildLock,Queue,SentinelStore,WatcherLock}
102
- Step::{EventBus,RegistryStore,Loader,Context,FireReport,
103
- Signature,Builtin,ErrorLog,Fetch,Transform,Validate,Observe}
116
+ Ports::{AuditLog,JobStore,Publisher,Clock,
117
+ BuildLock,SentinelStore}
104
118
  Entry::{Markdown,Json,Yaml,Text} (format strategies)
105
- Doctor::Validator (schema + role-authority validation — called by doctor check)
106
119
  ```
107
120
 
108
121
  ## How a verb becomes a method
109
122
 
110
- All actions live under `lib/textus/dispatch/actions/`. The shape is uniform:
123
+ All actions live under `lib/textus/action/`. The shape is uniform:
111
124
 
112
125
  ```ruby
113
126
  module Textus
114
- module Dispatch
115
- module Actions
116
- class Get < Base
117
- BURN = :sync
118
-
119
- def call(container:, call:)
120
- ...
121
- end
127
+ module Action
128
+ class Get < Base
129
+ extend Textus::Contract::DSL
130
+
131
+ verb :get
132
+ summary "Read an entry by key."
133
+ surfaces :cli, :mcp
134
+ arg :key, String, required: true, positional: true
135
+
136
+ BURN = :sync
137
+
138
+ def initialize(key:)
139
+ super()
140
+ @key = key
141
+ end
142
+
143
+ def call(container:, call:)
144
+ ...
122
145
  end
123
146
  end
124
147
  end
125
148
  end
126
149
  ```
127
150
 
128
- Verbs are looked up in a static frozen table (`Textus::Dispatcher::VERBS`) that maps `:get → Dispatch::Actions::Get`, `:put → Dispatch::Actions::Put`, etc. Adding a new verb is **one entry in `Dispatcher::VERBS`** plus the class — no metaprogramming.
129
-
130
- The instantiate-and-call step lives in `Dispatcher.invoke`. `RoleScope` builds the `Call` (request state) and delegates to `Dispatcher.invoke`. Every system interaction flows through `Dispatch::Gate#fire(event)` — surfaces, internal cascades (rdeps), and async job workers all use the same path. Gate runs Auth → Ledger → Executor in sequence.
131
-
132
- `boot` and `doctor` are actions like any other — reached through `Dispatcher::VERBS`.
133
-
134
- One collaborator lives outside the dispatcher because it's composed by actions, not invoked as a verb:
135
-
136
- - `Envelope::IO::{Reader,Writer}` — own the parse and persist halves of the write pipeline; the audit-append-as-final-step invariant lives in `Writer`.
151
+ Verbs are looked up in a static frozen table (`Textus::Action::VERBS`) that maps `:get → Action::Get`, `:put → Action::Put`, etc. Adding a new verb is one entry in `VERBS` plus the class — no metaprogramming.
137
152
 
138
153
  ## Container
139
154
 
@@ -142,130 +157,105 @@ Use cases never see the raw `Store`. `Textus::Container` is a single record hold
142
157
  ```ruby
143
158
  Container = Data.define(
144
159
  :manifest, :file_store, :schemas, :root,
145
- :audit_log, :steps, :gate
160
+ :audit_log, :workflows, :gate
146
161
  )
147
162
  ```
148
163
 
149
- The `Store` builds one `Container` at boot; every action receives it via `(container:, call:)`. Step handlers (Fetch, Transform, Observe) receive `caps: <Container>` they access `caps.manifest`, `caps.steps`, etc.
164
+ The `Store` builds one `Container` at boot; every action receives it via `(container:, call:)`. Workflow steps receive a `Workflow::Context` (key, entry, config, container, call).
150
165
 
151
166
  ## Ports
152
167
 
153
- Ports are infrastructure adapters with an interface defined by the domain. Each port is independently replaceable — swap the implementation for tests or alternative runtimes without touching application or domain code.
154
-
155
168
  | Class | Role |
156
169
  |---|---|
157
- | `Ports::Storage::FileStore` | Bytes-only FS I/O — `read`, `write`, `delete`, `exists?`, `etag`. No knowledge of envelopes or schemas. |
158
- | `Ports::AuditLog` | Append-only structured log (`audit.log`). Owns seq numbering, file-locking, and rotation. |
159
- | `Ports::Clock` | Supplies `Time.now` — a module-function so tests can swap it without dependency injection boilerplate. |
160
- | `Ports::Publisher` | Copies a built artifact to a repo-relative consumer path and writes a sentinel so the next publish can confirm the target is managed. |
161
- | `Ports::BuildLock` | Process-exclusive `flock` guard over the produce pipeline. Raises `BuildInProgress` if a build is already running. |
162
- | `Ports::Queue` | Persistent job queue used by `drain`/`watch` workers; tracks ready/leased/done/failed jobs and powers async dispatch actions (`materialize`, `observe`). |
163
- | `Ports::SentinelStore` | Reads and writes the per-target sentinel file that `Publisher` uses to detect unmanaged overwrites. |
164
- | `Ports::WatcherLock` | Single-watcher `flock` guard used by `Dispatch::Runtime::Watch` to ensure only one watcher loop is active per store root. |
170
+ | `Ports::Storage::FileStore` | Bytes-only FS I/O — `read`, `write`, `delete`, `exists?`, `etag`. |
171
+ | `Ports::AuditLog` | Append-only structured log (`audit.log`). Owns seq numbering, file-locking, rotation. |
172
+ | `Ports::Clock` | Supplies `Time.now` — module-function so tests can swap without DI boilerplate. |
173
+ | `Ports::Publisher` | Copies a built artifact to a repo-relative consumer path and writes a sentinel. |
174
+ | `Ports::BuildLock` | Process-exclusive `flock` guard over the produce pipeline. |
175
+ | `Ports::JobStore` | Persistent job queue used by `drain` workers; tracks ready/leased/done/failed jobs. |
176
+ | `Ports::SentinelStore` | Reads and writes per-target sentinel files for managed-file detection. |
165
177
 
166
178
  Application use cases access ports only through `Container` fields — never through the raw `Store`.
167
179
 
168
- ### EnvelopeIO
169
-
170
- `Envelope::IO::Reader` and `Envelope::IO::Writer` split the envelope pipeline into read-only parse and write-with-audit halves.
180
+ ### Envelope
171
181
 
172
- **Reader** (`lib/textus/envelope/io/reader.rb`) resolves a key through `manifest.resolver`, reads bytes via `FileStore`, delegates parsing to the format strategy (`Entry.for_format`), and returns an `Envelope`. No audit, no events, no permission checks. Also used by `Writer` for the existing-uid lookup on `put`.
182
+ `Envelope::Reader` and `Envelope::Writer` split the envelope pipeline into read-only parse and write-with-audit halves.
173
183
 
174
- **Writer** (`lib/textus/envelope/io/writer.rb`) — owns the full write pipeline: serialize schema-validate → etag-check → `FileStore#write` `AuditLog#append`. The class comment states the invariant directly: every public method's final action is `@audit_log.append(...)`. If the audit append fails, the caller sees the underlying error — the byte write already happened, but the pipeline contract treats audit as the commit step. No permission check, no event firing — those stay in the calling use case (`Write::Put`, `Write::Delete`, `Write::Mv`).
184
+ **Reader** (`lib/textus/envelope/reader.rb`) — resolves a key through `manifest.resolver`, reads bytes via `FileStore`, parses via the format strategy, returns an `Envelope`. No audit, no permissions.
175
185
 
176
- The three public methods are `put`, `delete`, and `move`; all follow the same validate → write → audit sequence.
186
+ **Writer** (`lib/textus/envelope/writer.rb`) owns the full write pipeline: serialize schema-validate etag-check`FileStore#write``AuditLog#append`. The audit append is the commit step.
177
187
 
178
- Both are built from a `Container` via named constructors — `Writer.from(container:, call:)` (which builds its own `Reader.from`) and `Reader.from(container:)` (ADR 0026). Write use cases call `Writer.from` rather than reconstructing the object graph by hand, so a change to the Writer's dependencies is a one-line edit in one place.
188
+ Both are built from a `Container` via named constructors — `Writer.from(container:, call:)` and `Reader.from(container:)`.
179
189
 
180
190
  ## Manifest carving
181
191
 
182
- Manifest carving means slicing the parsed manifest YAML into four purpose-specific sub-objects. Each consumer sees only the fields it needs; none reach into the full raw document.
183
-
184
- `Manifest` itself is a `Data.define` struct — a composition record with four named members:
192
+ `Manifest` is a `Data.define` struct with four named members:
185
193
 
186
194
  | Member | Class | Responsibility |
187
195
  |---|---|---|
188
- | `data` | `Manifest::Data` | Frozen value: `raw`, `root`, `zones`, `entries`, `audit_config`, `role_caps` (role name → capability set). Structural data only — no behaviour beyond accessors and key validation. |
189
- | `resolver` | `Manifest::Resolver` | Key → `Resolution(entry, path, remaining)`. Handles nested entry enumeration and fuzzy-match suggestions. |
190
- | `policy` | `Manifest::Policy` | Zone/capability authority — `verb_for_zone` (zone-kind → required verb), `roles_with_capability(verb)`, `zone_writers` (derived: roles holding the verb the zone's kind requires), `permission_for`, `declared_kind`, `proposer_role`, `propose_zone_for(role)`. Write authority is derived from capabilities × zone-kind (ADR 0030); no filesystem I/O. `propose_zone_for` returns the single `kind: queue` zone when the role can write it (ADR 0027). |
191
- | `rules` | `Manifest::Rules` | Pattern-matched rule engine. `rules.for(key)` returns a `RuleSet(fetch, handler_allowlist, guard, retention)` by evaluating all `match:` blocks against the key. |
192
-
193
- Rationale: cleaner test seams — a use case that only needs key resolution constructs a `Manifest::Resolver` from a stub `Data`; one that only needs rule lookup constructs a `Manifest::Rules` directly. No consumer is forced to build the full manifest to exercise one sub-view.
194
-
195
- The four members are wired in `Manifest.build` (`lib/textus/manifest.rb`). `Manifest::Data` constructs `Policy` internally during `initialize`; the others are assembled by the loader and handed in as named arguments.
196
+ | `data` | `Manifest::Data` | Frozen value: raw, root, lanes, entries, audit_config, role_caps. |
197
+ | `resolver` | `Manifest::Resolver` | Key → `Resolution(entry, path, remaining)`. Nested entry enumeration and fuzzy-match suggestions. |
198
+ | `policy` | `Manifest::Policy` | Zone/capability authority — `verb_for_lane`, `roles_with_capability`, `propose_lane_for(role)`. |
199
+ | `rules` | `Manifest::Rules` | Pattern-matched rule engine. `rules.for(key)` `RuleSet(guard, retention, react)`. |
196
200
 
197
201
  ## Read path (`store.get(key)`)
198
202
 
199
- `Read::Get` is the single public read verb. It is a **pure read** (ADR 0089): it resolves the path, reads bytes, parses the envelope, and annotates a freshness verdict it NEVER ingests and NEVER mutates. The read-through that once refreshed a stale entry in-process (ADR 0062) is removed; quarantine freshness is system-pushed via `drain` (scheduled sweep) and `hook run` (event push).
203
+ `Action::Get` is a **pure read** it resolves the path, reads bytes, parses the envelope, and annotates a freshness verdict. It never ingests and never mutates.
200
204
 
201
- 1. CLI verb (or MCP tool) calls `store.get(key, role:)` (or `store.as(role).get(key)`).
202
- 2. `Store#get` looks up `Dispatcher::VERBS[:get] Read::Get`, builds a `Call`, instantiates `Read::Get.new(container:, call:).call(key)`. The verb takes only `key` — there is no `fetch` flag on any surface.
203
- 3. `Read::Get#call(key)` resolves the path through `container.manifest`, reads bytes via `container.file_store`, parses the envelope, and annotates a freshness verdict (`stale`, `reason`, `fetching: false`). When the key has no `upkeep` rule, the envelope is annotated fresh. A stale entry with `upkeep: { ttl:, action: refresh }` is returned **stale** — the read does not refresh it; the next `drain` does.
205
+ 1. CLI/MCP surface calls `store.as(role).get(key)`.
206
+ 2. `Gate#dispatch` runs Auth → `Action::Get#call`.
207
+ 3. `Get` resolves path via `manifest.resolver`, reads bytes via `file_store`, parses the envelope, annotates `freshness` based on retention-rule TTL (if any).
204
208
 
205
- Because the read is always pure, every caller — interactive reads, dashboards, and the direct in-process callers (accept/reject/publish, materializer, uid, schema/tools, hooks/context) gets the same orchestrator-free, side-effect-free read. The prior read-through path (`get_or_fetch`, then the `fetch:`-flagged `Read::Get`, ADR 0062) and its `Write::FetchOrchestrator` are gone (ADR 0089).
209
+ Staleness is age-based (retention-rule TTL vs file mtime). A stale entry is returned stale the read does not refresh it; `drain` does.
206
210
 
207
211
  ## Write path (`store.put(key, ...)`)
208
212
 
209
- 1. CLI/MCP surface calls `store.as(role).put(key, meta:, body:, content:, if_etag:)`.
210
- 2. `Surfaces::RoleScope#dispatch_bound` fires `Gate.fire(Event.new("entry.put", actor: role, ...))`.
211
- 3. `Dispatch::Gate` runs Auth Ledger Executor. `Auth#check_event!` evaluates FLOOR predicates (`lane_writable_by`) plus any rule-declared guards raises `WriteForbidden` / `GuardFailed` on failure.
212
- 4. `Actions::Put#call` validates the key, resolves the manifest entry, delegates persistence to `Envelope::IO::Writer#put` (serialize schema-validate → etag-check → `FileStore#write` → `AuditLog#append`).
213
- 5. Publishes `:entry_written` via `container.steps` and fires a cascade Gate event for rdep materialization.
214
-
215
- `Actions::{KeyDelete,KeyMv,Accept,Reject,Propose}` follow the same shape. All write actions inherit `WriteVerb#run_with_cascade`, which enqueues `materialize` jobs for rdeps after the write completes.
213
+ 1. CLI/MCP surface calls `store.as(role).put(key, meta:, body:)`.
214
+ 2. `Gate#dispatch` runs Auth `Action::Put#call`.
215
+ 3. `Put` validates, resolves manifest entry, delegates to `Envelope::Writer#put` (serialize schema-validate etag-check FileStore#write AuditLog#append).
216
+ 4. `WriteVerb#cascade_to_rdeps` enqueues `materialize` jobs for any entries with publish_tree that depend on the written key.
216
217
 
217
- ## Pipeline path (`drain` + reactive `entry.written`)
218
+ `Action::{KeyDelete,KeyMv,Accept,Reject,Propose}` follow the same shape.
218
219
 
219
- The pipeline handles two concerns — **acquire** (pull live data via an intake handler) and **render** (template-driven artifact publish) — unified under `Dispatch::Pipeline::Engine`.
220
+ ## Workflow path (`drain` + materialize jobs)
220
221
 
221
- `Pipeline::Engine.converge(container:, call:, keys:)` is the entry point `Actions::Materialize` calls. Both the batch path (`drain` seeds jobs via `Planner::Seeder`) and the reactive path (write actions enqueue `materialize` jobs via `WriteVerb#cascade_to_rdeps`) flow through the queue worker into `converge`.
222
+ The workflow system owns the produce pipeline. `Produce::Engine.converge(container:, call:, keys:)` is the entry point that `Jobs::Materialize` calls.
222
223
 
223
224
  For each key, `Engine#produce_one`:
224
225
 
225
- 1. **Acquire phase** `Pipeline::Acquire::Intake#run(key)`:
226
- - Resolves the manifest entry; looks up the step handler via `container.steps`.
227
- - Publishes `:entry_fetch_started` via `container.steps`.
228
- - Invokes the `Step::Fetch` handler under a timeout deadline.
229
- - On error: publishes `:entry_fetch_failed`, re-raises.
230
- - On success: normalises the handler result, checks auth, persists via `Envelope::IO::Writer`, publishes `:entry_fetched` unless the etag is unchanged.
231
- - `Acquire::Handler` resolves and invokes the step under the timeout deadline. (The sibling **projection** sub-path — `from: derive` entries — runs `Acquire::Projection`, which renders data files through `Acquire::Serializer::{Json,Yaml,Text}` before persisting.)
232
- 2. **Render phase** — `entry.publish_via(context)` calls `Pipeline::Render#bytes_for(target:, data:, boot:)` to expand the Mustache template and copy the result to the publish target via `Ports::Publisher`. Returns `nil` if no publish is configured (skipped).
233
-
234
- Per-entry failures are published as `:produce_failed` by `Actions::Materialize` after `Engine.converge` returns. A held `BuildLock` is a soft miss — the in-flight build already produces fresh output.
235
-
236
- Reactive produce is enqueued as `materialize` jobs onto `Ports::Queue` when `entry_written`/`entry_deleted`/`entry_renamed` fires; a worker (`drain`/`serve`) runs them through `converge`. A held `BuildLock` is a soft miss — the in-flight build already produces fresh output.
226
+ 1. Looks up a registered workflow via `container.workflows.for(key)` (Pattern matching against the key).
227
+ 2. If a workflow matches: `Workflow::Runner.new(definition, container:, call:).run(key)`.
228
+ - `Runner` builds a `Workflow::Context`, executes each step in sequence, then publishes.
229
+ - **Built-in publish**: writes the step output to the entry via `Envelope::Writer`, then calls `entry.publish_via(pctx)` for any `publish_to` or `publish_tree` targets.
230
+ 3. If no workflow matches but the entry has publish targets: runs `publish_only` — publishes existing store bytes via `entry.publish_via` without re-fetching.
237
231
 
238
- ## Hook payload contract
232
+ **Workflow files** live in `.textus/workflows/*.rb`. The DSL:
239
233
 
240
- Pub-sub hooks (`:entry_written`, `:entry_fetched`, …) receive `ctx:` — a `Textus::Hooks::Context` that exposes a narrow surface (`get`, `list`, `put`, `delete`, `audit`, `publish_followup`, plus `role` and `correlation_id`). The raw `Store` is not handed out.
241
-
242
- RPC hooks (`:resolve_handler`, `:transform_rows`, `:validate`) receive `caps:` — a `Textus::Container`. They are gem-internal: the framework calls them, not user pub-sub.
234
+ ```ruby
235
+ Textus.workflow "my-producer" do
236
+ match "artifacts.derived.*"
237
+ step :fetch do |data, ctx|
238
+ { content: { "key" => ctx.key } }
239
+ end
240
+ publish # uses built-in publish (writes to entry, then to publish targets)
241
+ end
242
+ ```
243
243
 
244
244
  ## Agent surface (boot + pulse + MCP)
245
245
 
246
- Agents and plugins talk to a textus store through three layers:
247
-
248
246
  ```
249
- soul (skill/agent) ──▶ gate (CLI | MCP) ──▶ Store ──▶ memory (.textus/)
247
+ agent/plugin ──▶ gate (CLI | MCP) ──▶ Store ──▶ memory (.textus/)
250
248
  ```
251
249
 
252
250
  Two transports, one façade:
253
251
 
254
252
  - **CLI** — human/script surface. `textus boot`, `textus pulse --since=N`, `textus get/put/...`.
255
- - **MCP** — agent surface. `textus mcp serve` runs a stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. Tools are auto-derived from the manifest. Session state (cursor, role, contract_etag) is server-side.
256
-
257
- Both transports call `store.<verb>(..., role:)` (or `store.as(role).<verb>(...)`). No duplicate logic.
258
-
259
- The agent loop (cadence guide in [`agents-mcp.md`](../how-to/agents-mcp.md)):
260
-
261
- 1. **Session start:** `boot()` → contract envelope (zones, entries, schemas, write_flows, agent_quickstart with `latest_seq`).
262
- 2. **Per turn:** `pulse(since=cursor)` → `{cursor, changed, stale, pending_review, doctor}`.
263
- 3. **On demand:** `get`, `put`, `propose`, `fetch`, `schema_show`, `rule_explain`.
264
-
265
- Contract drift surfaces as `ContractDrift` (contract_etag mismatch — a change to the manifest, hooks, or schemas; ADR 0074); audit cursor falls off the keep window as `CursorExpired`. Both signal "call `boot` again."
253
+ - **MCP** — agent surface. `textus mcp serve` runs a stdio JSON-RPC 2.0 server. Tools are auto-derived from contracts. Session state (cursor, role, contract_etag) is server-side.
266
254
 
267
- ## Hooks event catalog
255
+ The agent loop:
268
256
 
269
- `Hooks::Signature` is the single home of callable keyword-introspection — both `EventBus` (pub-sub dispatch) and `RpcRegistry` (RPC dispatch) delegate to it for `accepts_keyrest?`, `declared_keys`, `missing`, and `filter` rather than each maintaining a hand-rolled copy (ADR 0027). RPC handlers declare `caps:` (single handler); pub-sub handlers declare `ctx:` (0..N handlers).
257
+ 1. **Session start:** `boot()` contract envelope (lanes, entries, workflows, write_flows, agent_quickstart).
258
+ 2. **Per turn:** `pulse(since=cursor)` → `{cursor, changed, stale, pending_review, doctor, next_due_at}`.
259
+ 3. **On demand:** `get`, `put`, `propose`, `schema_show`, `rule_explain`, `drain`.
270
260
 
271
- The event names, payloads, and per-verb firing order are documented once in [`reference/events.md`](../reference/events.md) (the friendly SSoT); the authoritative source is `lib/textus/step/catalog.rb` (`Catalog::RPC` and `Catalog::PUBSUB`) and `lib/textus/dispatch/catalog/events.rb` (dotted Gate event name constants).
261
+ Contract drift surfaces as `ContractDrift` (contract_etag mismatch a change to manifest/schemas); audit cursor expiry as `CursorExpired`. Both signal "call `boot` again."
@@ -11,8 +11,6 @@ module Textus
11
11
  cli "accept"
12
12
  arg :pending_key, String, required: true, positional: true, description: "the queued proposal's key"
13
13
 
14
- BURN = :sync
15
-
16
14
  def initialize(pending_key:)
17
15
  super()
18
16
  @pending_key = pending_key
@@ -39,13 +37,6 @@ module Textus
39
37
 
40
38
  Textus::Action::KeyDelete.new(key: @pending_key).call(container: container, call: call)
41
39
 
42
- container.steps.publish(
43
- :proposal_accepted,
44
- ctx: Textus::Step::Context.for(container: container, call: call),
45
- key: @pending_key,
46
- target_key: target,
47
- )
48
-
49
40
  { "protocol" => Textus::PROTOCOL, "accepted" => @pending_key, "target_key" => target, "action" => action }
50
41
  end
51
42
  end
@@ -24,8 +24,6 @@ module Textus
24
24
  arg :limit, Integer, required: false, description: "maximum number of rows to return"
25
25
  view(:cli) { |rows, _i| { "verb" => "audit", "rows" => rows } }
26
26
 
27
- BURN = :sync
28
-
29
27
  def initialize(**kwargs)
30
28
  super()
31
29
  @query = Query.build(**kwargs.slice(:key, :lane, :role, :verb, :since, :seq_since, :correlation_id, :limit))
@@ -15,8 +15,6 @@ module Textus
15
15
  arg :limit, Integer, required: false, description: "maximum number of audit rows to return"
16
16
  view(:cli) { |rows, inputs| { "verb" => "blame", "key" => inputs[:key], "rows" => rows } }
17
17
 
18
- BURN = :sync
19
-
20
18
  def initialize(key:, limit: nil)
21
19
  super()
22
20
  @key = key
@@ -6,20 +6,11 @@ module Textus
6
6
  extend Textus::Contract::DSL
7
7
 
8
8
  verb :boot
9
- summary "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart."
9
+ summary "Return the orientation contract: lanes, agent_quickstart, agent_protocol, and pre-computed artifacts."
10
10
  surfaces :cli, :mcp
11
- arg :lean, :boolean,
12
- description: "return only orientation essentials (zones, agent_quickstart, contract_etag) for cheap session-start injection"
13
-
14
- BURN = :sync
15
-
16
- def initialize(lean: nil)
17
- super()
18
- @lean = lean
19
- end
20
11
 
21
12
  def call(container:, **)
22
- Textus::Boot.build(container: container, lean: !@lean.nil?)
13
+ Textus::Boot.build(container: container)
23
14
  end
24
15
  end
25
16
  end
@@ -19,8 +19,6 @@ module Textus
19
19
  "defaults to false, so omitting it applies the move immediately"
20
20
  view { |v, _i| v.to_h }
21
21
 
22
- BURN = :sync
23
-
24
22
  def initialize(from:, to:, dry_run: false)
25
23
  super()
26
24
  @from = from
@@ -45,7 +43,7 @@ module Textus
45
43
  { "op" => "mv", "from" => key, "to" => "#{@to}#{key[@from.length..]}" }
46
44
  end
47
45
 
48
- plan = Textus::Background::Plan.new(steps: steps, warnings: [])
46
+ plan = Textus::Jobs::Plan.new(steps: steps, warnings: [])
49
47
  return plan if @dry_run
50
48
 
51
49
  rewrite_manifest!(root)
@@ -11,8 +11,6 @@ module Textus
11
11
  arg :key, String, required: true, positional: true,
12
12
  description: "dotted key of the derived entry whose source keys you want"
13
13
 
14
- BURN = :sync
15
-
16
14
  def initialize(key:)
17
15
  super()
18
16
  @key = key
@@ -20,19 +18,7 @@ module Textus
20
18
 
21
19
  def call(container:, **)
22
20
  entry = container.manifest.data.entries.find { |e| e.key == @key }
23
- deps =
24
- if entry&.derived?
25
- src = entry.source
26
- if src.projection?
27
- Array(src.select).compact
28
- elsif src.external?
29
- Array(src.sources).compact
30
- else
31
- []
32
- end
33
- else
34
- []
35
- end
21
+ deps = entry&.external? ? Array(entry.source&.sources).compact : []
36
22
  { "key" => @key, "deps" => deps.uniq }
37
23
  end
38
24
 
@@ -11,8 +11,6 @@ module Textus
11
11
  cli "doctor"
12
12
  arg :checks, Array, required: false, description: "subset of check names to run (default: all)"
13
13
 
14
- BURN = :sync
15
-
16
14
  def initialize(checks: nil)
17
15
  super()
18
16
  @checks = checks
@@ -6,14 +6,12 @@ module Textus
6
6
  extend Textus::Contract::DSL
7
7
 
8
8
  verb :drain
9
- summary "Seed refresh + sweep jobs then drain the queue to empty. " \
9
+ summary "Seed materialize + sweep jobs then drain the queue to empty. " \
10
10
  "Identical to one Watcher tick. Use when no watcher is running."
11
11
  surfaces :cli, :mcp
12
12
  arg :prefix, String, description: "restrict to keys under this dotted prefix"
13
13
  arg :lane, String, description: "restrict to entries in this lane"
14
14
 
15
- BURN = :sync
16
-
17
15
  def initialize(prefix: nil, lane: nil)
18
16
  super()
19
17
  @prefix = prefix
@@ -21,14 +19,14 @@ module Textus
21
19
  end
22
20
 
23
21
  def call(container:, call:)
24
- queue = Textus::Ports::Queue.new(root: container.root)
25
- Textus::Background::Planner::Plan.seed(
22
+ queue = Textus::Ports::JobStore.new(root: container.root)
23
+ Textus::Jobs::Planner.seed(
26
24
  container: container,
27
25
  queue: queue,
28
26
  role: call.role,
29
27
  )
30
28
  queue.reclaim(now: Textus::Ports::Clock.new.now)
31
- summary = Textus::Background::Worker.for(container:, queue:).drain
29
+ summary = Textus::Jobs::Worker.for(container:, queue:).drain
32
30
  {
33
31
  "protocol" => Textus::PROTOCOL,
34
32
  "ok" => summary.failed.zero?,
@@ -14,8 +14,6 @@ module Textus
14
14
  arg :args, Hash, default: {},
15
15
  description: "type-specific arguments (e.g. { key: ... } or { scope: ... })"
16
16
 
17
- BURN = :sync
18
-
19
17
  def initialize(type:, args: {})
20
18
  super()
21
19
  @type = type
@@ -28,7 +26,7 @@ module Textus
28
26
 
29
27
  def call(container:, call:)
30
28
  action_class = begin
31
- Textus::Background::Job.fetch(@type.to_s)
29
+ Textus::Jobs.fetch(@type.to_s)
32
30
  rescue Textus::UsageError
33
31
  raise Textus::UsageError.new("unregistered job type '#{@type}'")
34
32
  end
@@ -41,13 +39,13 @@ module Textus
41
39
  )
42
40
  end
43
41
 
44
- job = Textus::Ports::Queue::Job.new(
42
+ job = Textus::Ports::JobStore::Job.new(
45
43
  type: @type,
46
44
  args: @job_args,
47
45
  enqueued_by: call.role,
48
46
  max_attempts: 3,
49
47
  )
50
- Textus::Ports::Queue.new(root: container.root).enqueue(job)
48
+ Textus::Ports::JobStore.new(root: container.root).enqueue(job)
51
49
  { "protocol" => Textus::PROTOCOL, "ok" => true, "id" => job.id }
52
50
  end
53
51
  end