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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +53 -73
- data/SPEC.md +2 -5
- data/docs/architecture/README.md +149 -159
- data/lib/textus/action/accept.rb +0 -9
- data/lib/textus/action/audit.rb +0 -2
- data/lib/textus/action/blame.rb +0 -2
- data/lib/textus/action/boot.rb +2 -11
- data/lib/textus/action/data_mv.rb +1 -3
- data/lib/textus/action/deps.rb +1 -15
- data/lib/textus/action/doctor.rb +0 -2
- data/lib/textus/action/drain.rb +4 -6
- data/lib/textus/action/enqueue.rb +3 -5
- data/lib/textus/action/get.rb +1 -3
- data/lib/textus/action/ingest.rb +135 -0
- data/lib/textus/action/jobs.rb +1 -3
- data/lib/textus/action/key_delete.rb +0 -10
- data/lib/textus/action/key_delete_prefix.rb +1 -3
- data/lib/textus/action/key_mv.rb +1 -15
- data/lib/textus/action/key_mv_prefix.rb +1 -3
- data/lib/textus/action/list.rb +0 -2
- data/lib/textus/action/propose.rb +0 -2
- data/lib/textus/action/published.rb +0 -2
- data/lib/textus/action/pulse.rb +7 -38
- data/lib/textus/action/put.rb +1 -12
- data/lib/textus/action/rdeps.rb +2 -12
- data/lib/textus/action/reject.rb +0 -12
- data/lib/textus/action/rule_explain.rb +0 -4
- data/lib/textus/action/rule_lint.rb +1 -3
- data/lib/textus/action/rule_list.rb +0 -4
- data/lib/textus/action/schema_envelope.rb +0 -2
- data/lib/textus/action/uid.rb +0 -2
- data/lib/textus/action/where.rb +0 -2
- data/lib/textus/action/write_verb.rb +4 -18
- data/lib/textus/boot.rb +40 -109
- data/lib/textus/command.rb +6 -1
- data/lib/textus/container.rb +21 -5
- data/lib/textus/core/freshness/evaluator.rb +18 -34
- data/lib/textus/core/freshness/verdict.rb +1 -1
- data/lib/textus/doctor/check/notebook_sources.rb +50 -0
- data/lib/textus/doctor/check/protocol_version.rb +6 -6
- data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
- data/lib/textus/doctor/check/stale_reviewed_stamp.rb +54 -0
- data/lib/textus/doctor/check.rb +0 -1
- data/lib/textus/doctor.rb +3 -42
- data/lib/textus/envelope/reader.rb +46 -0
- data/lib/textus/envelope/writer.rb +209 -0
- data/lib/textus/envelope.rb +19 -6
- data/lib/textus/errors.rb +9 -5
- data/lib/textus/{entry → format}/base.rb +1 -6
- data/lib/textus/{entry → format}/json.rb +1 -4
- data/lib/textus/{entry → format}/markdown.rb +2 -6
- data/lib/textus/{entry → format}/text.rb +2 -6
- data/lib/textus/{entry → format}/yaml.rb +1 -4
- data/lib/textus/{entry.rb → format.rb} +9 -10
- data/lib/textus/gate/auth.rb +58 -12
- data/lib/textus/gate.rb +2 -0
- data/lib/textus/init.rb +16 -79
- data/lib/textus/jobs/base.rb +23 -0
- data/lib/textus/jobs/materialize.rb +20 -0
- data/lib/textus/{background → jobs}/plan.rb +1 -1
- data/lib/textus/jobs/planner.rb +101 -0
- data/lib/textus/jobs/retention.rb +48 -0
- data/lib/textus/jobs/sweep.rb +27 -0
- data/lib/textus/{background → jobs}/worker.rb +2 -2
- data/lib/textus/jobs.rb +15 -0
- data/lib/textus/key/path.rb +1 -1
- data/lib/textus/manifest/entry/base.rb +4 -19
- data/lib/textus/manifest/entry/parser.rb +27 -10
- data/lib/textus/manifest/entry/produced.rb +13 -34
- data/lib/textus/manifest/entry/publish/to_paths.rb +4 -4
- data/lib/textus/manifest/entry/publish.rb +0 -12
- data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +0 -1
- data/lib/textus/manifest/policy/source.rb +14 -62
- data/lib/textus/manifest/policy.rb +2 -5
- data/lib/textus/manifest/resolver.rb +2 -2
- data/lib/textus/manifest/schema/contract.rb +61 -0
- data/lib/textus/manifest/schema/keys.rb +6 -20
- data/lib/textus/manifest/schema/semantics.rb +232 -0
- data/lib/textus/manifest/schema/validator.rb +24 -302
- data/lib/textus/manifest/schema/vocabulary.rb +1 -0
- data/lib/textus/manifest/schema.rb +1 -4
- data/lib/textus/manifest.rb +1 -3
- data/lib/textus/ports/{queue → job_store}/job.rb +1 -1
- data/lib/textus/ports/{queue.rb → job_store.rb} +6 -13
- data/lib/textus/produce/engine.rb +50 -0
- data/lib/textus/produce/render.rb +20 -0
- data/lib/textus/session.rb +15 -8
- data/lib/textus/store.rb +5 -14
- data/lib/textus/surfaces/mcp/catalog.rb +23 -11
- data/lib/textus/surfaces/mcp/errors.rb +0 -9
- data/lib/textus/surfaces/mcp/server.rb +87 -110
- data/lib/textus/surfaces/role_scope.rb +0 -4
- data/lib/textus/surfaces/watcher.rb +3 -3
- data/lib/textus/types.rb +15 -0
- data/lib/textus/version.rb +2 -2
- data/lib/textus/workflow/collector.rb +27 -0
- data/lib/textus/workflow/context.rb +5 -0
- data/lib/textus/workflow/dsl.rb +44 -0
- data/lib/textus/workflow/loader.rb +17 -0
- data/lib/textus/workflow/pattern.rb +18 -0
- data/lib/textus/workflow/registry.rb +21 -0
- data/lib/textus/workflow/runner.rb +99 -0
- data/lib/textus/workflow.rb +28 -0
- data/lib/textus.rb +12 -1
- metadata +67 -66
- data/lib/textus/action/pulse/scanner.rb +0 -118
- data/lib/textus/background/job/base.rb +0 -27
- data/lib/textus/background/job/materialize.rb +0 -31
- data/lib/textus/background/job/refresh.rb +0 -22
- data/lib/textus/background/job/sweep.rb +0 -31
- data/lib/textus/background/job.rb +0 -19
- data/lib/textus/background/planner/plan.rb +0 -113
- data/lib/textus/background/retention/apply.rb +0 -50
- data/lib/textus/doctor/check/handler_permit.rb +0 -34
- data/lib/textus/doctor/check/hooks.rb +0 -25
- data/lib/textus/doctor/check/intake_registration.rb +0 -46
- data/lib/textus/envelope/io/reader.rb +0 -48
- data/lib/textus/envelope/io/writer.rb +0 -211
- data/lib/textus/events.rb +0 -21
- data/lib/textus/init/templates/machine_intake.rb +0 -49
- data/lib/textus/init/templates/orientation_reducer.rb +0 -21
- data/lib/textus/manifest/entry/validators/events.rb +0 -22
- data/lib/textus/manifest/policy/handler_permit.rb +0 -17
- data/lib/textus/pipeline/acquire/handler.rb +0 -29
- data/lib/textus/pipeline/acquire/intake.rb +0 -132
- data/lib/textus/pipeline/acquire/projection.rb +0 -129
- data/lib/textus/pipeline/acquire/serializer/json.rb +0 -31
- data/lib/textus/pipeline/acquire/serializer/text.rb +0 -16
- data/lib/textus/pipeline/acquire/serializer/yaml.rb +0 -31
- data/lib/textus/pipeline/acquire/serializer.rb +0 -17
- data/lib/textus/pipeline/engine.rb +0 -97
- data/lib/textus/pipeline/render.rb +0 -25
- data/lib/textus/ports/audit_subscriber.rb +0 -42
- data/lib/textus/projection.rb +0 -103
- data/lib/textus/step/base.rb +0 -35
- data/lib/textus/step/builtin/csv_fetch.rb +0 -19
- data/lib/textus/step/builtin/ical_events_fetch.rb +0 -30
- data/lib/textus/step/builtin/json_fetch.rb +0 -18
- data/lib/textus/step/builtin/markdown_links_fetch.rb +0 -20
- data/lib/textus/step/builtin/rss_fetch.rb +0 -26
- data/lib/textus/step/builtin.rb +0 -22
- data/lib/textus/step/catalog.rb +0 -37
- data/lib/textus/step/context.rb +0 -74
- data/lib/textus/step/discovery.rb +0 -24
- data/lib/textus/step/error_log.rb +0 -32
- data/lib/textus/step/event_bus.rb +0 -113
- data/lib/textus/step/fetch.rb +0 -13
- data/lib/textus/step/fire_report.rb +0 -23
- data/lib/textus/step/loader.rb +0 -108
- data/lib/textus/step/observe.rb +0 -31
- data/lib/textus/step/registry_store.rb +0 -66
- data/lib/textus/step/signature.rb +0 -31
- data/lib/textus/step/transform.rb +0 -12
- data/lib/textus/step/validate.rb +0 -11
- data/lib/textus/step.rb +0 -10
- data/lib/textus/surfaces/mcp/session.rb +0 -9
- data/lib/textus/surfaces/mcp/tool_schemas.rb +0 -17
data/docs/architecture/README.md
CHANGED
|
@@ -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
|
|
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
|
|
8
|
+
surfaces["Surfaces — CLI · MCP · RoleScope"]
|
|
9
9
|
contract["Contract — per-verb DSL (source of truth for public interfaces)"]
|
|
10
|
-
|
|
10
|
+
gate["Gate — routing (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,
|
|
13
|
-
ports["Ports — IO adapters (FileStore, AuditLog,
|
|
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 -->
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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.*
|
|
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
|
-
**
|
|
34
|
+
**Surfaces**
|
|
28
35
|
|
|
29
36
|
```
|
|
30
|
-
CLI
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
**
|
|
81
|
+
**Produce**
|
|
63
82
|
|
|
64
83
|
```
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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,
|
|
101
|
-
BuildLock,
|
|
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/
|
|
123
|
+
All actions live under `lib/textus/action/`. The shape is uniform:
|
|
111
124
|
|
|
112
125
|
```ruby
|
|
113
126
|
module Textus
|
|
114
|
-
module
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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::
|
|
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, :
|
|
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:)`.
|
|
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`.
|
|
158
|
-
| `Ports::AuditLog` | Append-only structured log (`audit.log`). Owns seq numbering, file-locking,
|
|
159
|
-
| `Ports::Clock` | Supplies `Time.now` —
|
|
160
|
-
| `Ports::Publisher` | Copies a built artifact to a repo-relative consumer path and writes a sentinel
|
|
161
|
-
| `Ports::BuildLock` | Process-exclusive `flock` guard over the produce pipeline.
|
|
162
|
-
| `Ports::
|
|
163
|
-
| `Ports::SentinelStore` | Reads and writes
|
|
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
|
-
###
|
|
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
|
-
|
|
182
|
+
`Envelope::Reader` and `Envelope::Writer` split the envelope pipeline into read-only parse and write-with-audit halves.
|
|
173
183
|
|
|
174
|
-
**
|
|
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
|
-
|
|
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:)`
|
|
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
|
|
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:
|
|
189
|
-
| `resolver` | `Manifest::Resolver` | Key → `Resolution(entry, path, remaining)`.
|
|
190
|
-
| `policy` | `Manifest::Policy` | Zone/capability authority — `
|
|
191
|
-
| `rules` | `Manifest::Rules` | Pattern-matched rule engine. `rules.for(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
|
-
`
|
|
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
|
|
202
|
-
2. `
|
|
203
|
-
3. `
|
|
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
|
-
|
|
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
|
|
210
|
-
2. `
|
|
211
|
-
3. `
|
|
212
|
-
4. `
|
|
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
|
-
|
|
218
|
+
`Action::{KeyDelete,KeyMv,Accept,Reject,Propose}` follow the same shape.
|
|
218
219
|
|
|
219
|
-
|
|
220
|
+
## Workflow path (`drain` + materialize jobs)
|
|
220
221
|
|
|
221
|
-
`
|
|
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.
|
|
226
|
-
|
|
227
|
-
-
|
|
228
|
-
-
|
|
229
|
-
|
|
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
|
-
|
|
232
|
+
**Workflow files** live in `.textus/workflows/*.rb`. The DSL:
|
|
239
233
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
255
|
+
The agent loop:
|
|
268
256
|
|
|
269
|
-
|
|
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
|
-
|
|
261
|
+
Contract drift surfaces as `ContractDrift` (contract_etag mismatch — a change to manifest/schemas); audit cursor expiry as `CursorExpired`. Both signal "call `boot` again."
|
data/lib/textus/action/accept.rb
CHANGED
|
@@ -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
|
data/lib/textus/action/audit.rb
CHANGED
|
@@ -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))
|
data/lib/textus/action/blame.rb
CHANGED
|
@@ -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
|
data/lib/textus/action/boot.rb
CHANGED
|
@@ -6,20 +6,11 @@ module Textus
|
|
|
6
6
|
extend Textus::Contract::DSL
|
|
7
7
|
|
|
8
8
|
verb :boot
|
|
9
|
-
summary "Return the orientation contract:
|
|
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
|
|
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::
|
|
46
|
+
plan = Textus::Jobs::Plan.new(steps: steps, warnings: [])
|
|
49
47
|
return plan if @dry_run
|
|
50
48
|
|
|
51
49
|
rewrite_manifest!(root)
|
data/lib/textus/action/deps.rb
CHANGED
|
@@ -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
|
|
data/lib/textus/action/doctor.rb
CHANGED
data/lib/textus/action/drain.rb
CHANGED
|
@@ -6,14 +6,12 @@ module Textus
|
|
|
6
6
|
extend Textus::Contract::DSL
|
|
7
7
|
|
|
8
8
|
verb :drain
|
|
9
|
-
summary "Seed
|
|
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::
|
|
25
|
-
Textus::
|
|
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::
|
|
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::
|
|
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::
|
|
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::
|
|
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
|