textus 0.54.1 → 0.55.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 (175) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -1
  3. data/README.md +61 -27
  4. data/SPEC.md +167 -296
  5. data/docs/architecture/README.md +20 -8
  6. data/docs/reference/conventions.md +40 -56
  7. data/exe/textus +1 -1
  8. data/lib/textus/action/accept.rb +23 -21
  9. data/lib/textus/action/audit.rb +24 -61
  10. data/lib/textus/action/base.rb +9 -9
  11. data/lib/textus/action/blame.rb +18 -36
  12. data/lib/textus/action/boot.rb +2 -4
  13. data/lib/textus/action/data_mv.rb +20 -31
  14. data/lib/textus/action/deps.rb +3 -18
  15. data/lib/textus/action/doctor.rb +2 -9
  16. data/lib/textus/action/drain.rb +11 -19
  17. data/lib/textus/action/enqueue.rb +14 -30
  18. data/lib/textus/action/get.rb +12 -56
  19. data/lib/textus/action/ingest.rb +141 -77
  20. data/lib/textus/action/jobs.rb +6 -15
  21. data/lib/textus/action/key_delete.rb +6 -16
  22. data/lib/textus/action/key_delete_prefix.rb +8 -17
  23. data/lib/textus/action/key_mv.rb +54 -61
  24. data/lib/textus/action/key_mv_prefix.rb +13 -22
  25. data/lib/textus/action/list.rb +7 -21
  26. data/lib/textus/action/propose.rb +16 -26
  27. data/lib/textus/action/published.rb +3 -5
  28. data/lib/textus/action/pulse.rb +20 -27
  29. data/lib/textus/action/put.rb +15 -29
  30. data/lib/textus/action/rdeps.rb +3 -18
  31. data/lib/textus/action/reject.rb +12 -21
  32. data/lib/textus/action/rule_explain.rb +12 -22
  33. data/lib/textus/action/rule_lint.rb +10 -16
  34. data/lib/textus/action/rule_list.rb +5 -9
  35. data/lib/textus/action/schema_envelope.rb +3 -10
  36. data/lib/textus/action/uid.rb +3 -17
  37. data/lib/textus/action/where.rb +3 -18
  38. data/lib/textus/boot.rb +8 -16
  39. data/lib/textus/contract/arg.rb +10 -0
  40. data/lib/textus/contract/dsl.rb +88 -0
  41. data/lib/textus/contract/spec.rb +25 -0
  42. data/lib/textus/contract.rb +0 -162
  43. data/lib/textus/doctor/check/audit_log.rb +2 -2
  44. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  45. data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
  46. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  47. data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
  48. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  49. data/lib/textus/doctor/check/schema_violations.rb +2 -2
  50. data/lib/textus/doctor/check/schemas.rb +1 -1
  51. data/lib/textus/doctor/check/sentinels.rb +4 -4
  52. data/lib/textus/doctor/check/templates.rb +1 -1
  53. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  54. data/lib/textus/doctor/check.rb +4 -7
  55. data/lib/textus/doctor.rb +1 -1
  56. data/lib/textus/errors.rb +6 -0
  57. data/lib/textus/format/base.rb +0 -4
  58. data/lib/textus/format/json.rb +5 -6
  59. data/lib/textus/format/markdown.rb +5 -6
  60. data/lib/textus/format/shared.rb +17 -0
  61. data/lib/textus/format/text.rb +5 -4
  62. data/lib/textus/format/yaml.rb +30 -6
  63. data/lib/textus/format.rb +6 -0
  64. data/lib/textus/gate/auth.rb +2 -17
  65. data/lib/textus/gate/binder.rb +50 -0
  66. data/lib/textus/gate.rb +64 -88
  67. data/lib/textus/init.rb +2 -4
  68. data/lib/textus/jobs.rb +3 -9
  69. data/lib/textus/manifest/capabilities.rb +3 -3
  70. data/lib/textus/manifest/entry/base.rb +1 -1
  71. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
  72. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -5
  73. data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
  74. data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
  75. data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
  76. data/lib/textus/manifest/schema/semantics.rb +11 -216
  77. data/lib/textus/meta.rb +54 -0
  78. data/lib/textus/{ports → port}/audit_log.rb +44 -4
  79. data/lib/textus/{ports → port}/build_lock.rb +2 -2
  80. data/lib/textus/{ports → port}/clock.rb +1 -1
  81. data/lib/textus/{ports → port}/publisher.rb +5 -5
  82. data/lib/textus/{ports → port}/sentinel_store.rb +3 -3
  83. data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
  84. data/lib/textus/{ports → port}/storage/file_store.rb +2 -2
  85. data/lib/textus/port/store.rb +93 -0
  86. data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
  87. data/lib/textus/produce/engine.rb +1 -1
  88. data/lib/textus/schema/tools.rb +11 -7
  89. data/lib/textus/store/compositor.rb +34 -0
  90. data/lib/textus/store/container.rb +43 -0
  91. data/lib/textus/store/cursor.rb +26 -0
  92. data/lib/textus/store/envelope/reader.rb +43 -0
  93. data/lib/textus/store/envelope/writer.rb +195 -0
  94. data/lib/textus/store/geometry.rb +81 -0
  95. data/lib/textus/store/index/builder.rb +74 -0
  96. data/lib/textus/store/index/lookup.rb +60 -0
  97. data/lib/textus/store/jobs/base.rb +13 -0
  98. data/lib/textus/store/jobs/index.rb +15 -0
  99. data/lib/textus/store/jobs/materialize.rb +15 -0
  100. data/lib/textus/store/jobs/plan.rb +11 -0
  101. data/lib/textus/store/jobs/planner.rb +104 -0
  102. data/lib/textus/store/jobs/queue.rb +154 -0
  103. data/lib/textus/store/jobs/registry.rb +19 -0
  104. data/lib/textus/store/jobs/retention.rb +50 -0
  105. data/lib/textus/store/jobs/sweep.rb +21 -0
  106. data/lib/textus/store/jobs/worker.rb +64 -0
  107. data/lib/textus/store/session.rb +37 -0
  108. data/lib/textus/store.rb +21 -13
  109. data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
  110. data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
  111. data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
  112. data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
  113. data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
  114. data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
  115. data/lib/textus/{surfaces → surface}/cli/runner.rb +10 -63
  116. data/lib/textus/surface/cli/sources.rb +41 -0
  117. data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +4 -6
  118. data/lib/textus/{surfaces → surface}/cli/verb/get.rb +4 -4
  119. data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
  120. data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
  121. data/lib/textus/{surfaces → surface}/cli/verb/put.rb +6 -11
  122. data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
  123. data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
  124. data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
  125. data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
  126. data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
  127. data/lib/textus/{surfaces → surface}/cli.rb +1 -1
  128. data/lib/textus/{surfaces → surface}/mcp/catalog.rb +9 -26
  129. data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -1
  130. data/lib/textus/{surfaces → surface}/mcp/server.rb +5 -5
  131. data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
  132. data/lib/textus/surface/projector.rb +27 -0
  133. data/lib/textus/{surfaces → surface}/role_scope.rb +1 -1
  134. data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
  135. data/lib/textus/value/call.rb +30 -0
  136. data/lib/textus/value/command.rb +16 -0
  137. data/lib/textus/value/envelope.rb +89 -0
  138. data/lib/textus/value/etag.rb +39 -0
  139. data/lib/textus/value/result.rb +26 -0
  140. data/lib/textus/value/role.rb +38 -0
  141. data/lib/textus/value/types.rb +13 -0
  142. data/lib/textus/{uid.rb → value/uid.rb} +9 -7
  143. data/lib/textus/version.rb +1 -1
  144. data/lib/textus/workflow/loader.rb +4 -4
  145. data/lib/textus/workflow/runner.rb +4 -18
  146. data/lib/textus.rb +9 -10
  147. metadata +100 -62
  148. data/lib/textus/action/write_verb.rb +0 -44
  149. data/lib/textus/call.rb +0 -28
  150. data/lib/textus/command.rb +0 -41
  151. data/lib/textus/container.rb +0 -26
  152. data/lib/textus/contract/around.rb +0 -29
  153. data/lib/textus/contract/binder.rb +0 -88
  154. data/lib/textus/contract/resources/build_lock.rb +0 -17
  155. data/lib/textus/contract/resources/cursor.rb +0 -26
  156. data/lib/textus/contract/sources.rb +0 -39
  157. data/lib/textus/contract/view.rb +0 -15
  158. data/lib/textus/cursor_store.rb +0 -24
  159. data/lib/textus/envelope/reader.rb +0 -46
  160. data/lib/textus/envelope/writer.rb +0 -209
  161. data/lib/textus/envelope.rb +0 -79
  162. data/lib/textus/etag.rb +0 -36
  163. data/lib/textus/jobs/base.rb +0 -23
  164. data/lib/textus/jobs/materialize.rb +0 -20
  165. data/lib/textus/jobs/plan.rb +0 -9
  166. data/lib/textus/jobs/planner.rb +0 -101
  167. data/lib/textus/jobs/retention.rb +0 -48
  168. data/lib/textus/jobs/sweep.rb +0 -27
  169. data/lib/textus/jobs/worker.rb +0 -67
  170. data/lib/textus/layout.rb +0 -83
  171. data/lib/textus/ports/job_store/job.rb +0 -65
  172. data/lib/textus/ports/job_store.rb +0 -123
  173. data/lib/textus/role.rb +0 -36
  174. data/lib/textus/session.rb +0 -35
  175. data/lib/textus/types.rb +0 -15
@@ -6,7 +6,8 @@
6
6
  ```mermaid
7
7
  flowchart TD
8
8
  surfaces["Surfaces — CLI · MCP · RoleScope"]
9
- contract["Contract — per-verb DSL (source of truth for public interfaces)"]
9
+ contract["Contract — per-verb DSL (Arg, Spec, DSL)"]
10
+ dispatch["Dispatch — Binder · Around · CommandBuilder · Dispatcher"]
10
11
  gate["Gate — routing (VERB_COMMAND) · Auth"]
11
12
  action["Actions — per-verb use cases (action/)"]
12
13
  jobs["Jobs — Worker · Planner · Materialize · Retention · Sweep"]
@@ -15,8 +16,10 @@ flowchart TD
15
16
  manifest["Manifest — declarative config, no IO (policy/, schema/, entry/)"]
16
17
  core["Core — pure value types (Freshness, Retention, Duration, Sentinel)"]
17
18
  ports["Ports — IO adapters (FileStore, AuditLog, JobStore, Publisher…)"]
18
- surfaces --> contract
19
- contract --> gate
19
+ surfaces --> dispatch
20
+ surfaces -.->|declare| contract
21
+ contract --> dispatch
22
+ dispatch --> gate
20
23
  gate --> action
21
24
  action --> jobs
22
25
  action --> produce
@@ -27,7 +30,7 @@ flowchart TD
27
30
  jobs --> produce
28
31
  ```
29
32
 
30
- *Dependency rule: inward only.* Surfaces → Contract → Gate → Actions → inner layers. Actions never reference surfaces.
33
+ *Dependency rule: inward only.* Surfaces → Dispatch → Gate → Actions → inner layers. Actions never reference surfaces.
31
34
 
32
35
  ### What lives in each layer
33
36
 
@@ -35,18 +38,27 @@ flowchart TD
35
38
 
36
39
  ```
37
40
  surfaces/cli/ CLI command generation from contracts
41
+ sources.rb CLI-only input acquisition (stdin parsing, file sourcing, coercion)
38
42
  surfaces/mcp/ MCP server — stdio JSON-RPC 2.0, tools derived from contracts
39
43
  surfaces/role_scope.rb
40
44
  (Store#as(role)) — holds (container, role, dry_run, correlation_id);
41
45
  all verb methods injected via define_method in textus.rb
42
46
  ```
43
47
 
44
- **Contract**
48
+ **Contract + Dispatch**
45
49
 
46
50
  ```
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.
51
+ contract/ Per-verb DSL — verb, summary, surfaces, arg, view, around.
52
+ Pure declaration: Spec and DSL only.
53
+
54
+ dispatch/ Runtime machinery consumed by every surface.
55
+ binder.rb Binder — validates required args, resolves session/literal defaults.
56
+ around.rb Around — registry of stateful wrappers (cursor, build_lock).
57
+ view.rb View.render(spec, surface, result, inputs) — output shaping.
58
+ command_builder.rb
59
+ CommandBuilder — command construction + role injection.
60
+ dispatcher.rb Dispatcher — unified pipeline: Around → Binder → Command → Gate.
61
+ resources/ Around resource implementations (Cursor, BuildLock).
50
62
  ```
51
63
 
52
64
  **Gate + Auth**
@@ -8,11 +8,11 @@ Guidelines for shaping a `.textus/` tree, naming keys, organising schemas, and i
8
8
  ## Key naming
9
9
 
10
10
  - **Segments are lowercase, kebab- or snake-case.** The grammar `^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$` is the hard limit. Prefer `acme-dashboard` over `acmedashboard` when there's a natural word break.
11
- - **Lead with the zone in the key path.** `working.projects.acme.dashboard`, not `projects.acme.dashboard`. The zone prefix makes it obvious from the key alone whether a write will be accepted.
11
+ - **Lead with the lane in the key path.** `working.projects.acme.dashboard`, not `projects.acme.dashboard`. The lane prefix makes it obvious from the key alone whether a write will be accepted.
12
12
  - **Mirror the directory structure.** If `working.projects.acme.dashboard` resolves to `working/projects/acme/dashboard.md`, do not invent shortcuts that diverge.
13
13
  - **Don't pluralise the leaf.** `working.network.org.jane`, not `working.network.org.janes`. Pluralise the container, not the entry.
14
14
 
15
- ## Zone layout
15
+ ## Lane layout
16
16
 
17
17
  Recommended top-level layout — the spec allows alternatives, but this is what tooling will default to:
18
18
 
@@ -20,12 +20,14 @@ Recommended top-level layout — the spec allows alternatives, but this is what
20
20
  .textus/
21
21
  manifest.yaml
22
22
  schemas/ # YAML schema definitions
23
- zones/
23
+ workflows/ # Textus.workflow DSL files for produced entry acquisition
24
+ templates/ # ERB templates for publish rendering
25
+ data/
24
26
  knowledge/ # authored truth: identity, voice, decisions — author-holders write
25
27
  notebook/ # agent's own durable lane (workspace) — keep-holders write
26
- feeds/ # automation-fed external inputs (fetch)
27
28
  proposals/ # AI proposals awaiting accept (propose)
28
- artifacts/ # generated by build automation — never edit by hand
29
+ artifacts/ # computed outputs produced by drain — never edit by hand
30
+ raw/ # write-once external source material (ingest)
29
31
  ```
30
32
 
31
33
  Inside `knowledge/`, group by **domain** (identity, people, projects, decisions, runbooks), not by file type or date. `knowledge.identity.*` is the convention for slow-changing identity facts. Inside `artifacts/`, group by **producer** (`artifacts/catalogs/`, `artifacts/indexes/`) so it's clear which build job owns what.
@@ -47,75 +49,57 @@ The `owner:` field in the manifest is **advisory metadata**, not an ACL. Use it
47
49
 
48
50
  Tooling around `git blame` or audit logs may filter on owner; the gem itself only echoes it back in envelopes.
49
51
 
50
- ## Derived entries
52
+ ## Produced entries
51
53
 
52
- A derived entry declares a `source:` block with a `from:` discriminator (ADR 0093/0094). A `source:` acquires **data** — it never renders; rendering is a publish concern (below). Two `from:` values for derived entries:
53
-
54
- **`source: { from: project }`** — textus computes the entry's data on `textus drain` from other store entries. Declarative; nothing shells out. Projection fields are flat under `source:`.
54
+ A produced entry declares `source: { from: external, command: "true", sources: [] }` and a matching `Textus.workflow` block in `.textus/workflows/**/*.rb`. The `source:` acquires **data** — it never renders; rendering is a publish concern (below).
55
55
 
56
56
  ```yaml
57
- - key: artifacts.derived.people
58
- path: artifacts/derived/people.md
59
- zone: artifacts
60
- schema: null
61
- owner: automation:catalog-people
62
- source:
63
- from: project
64
- select: knowledge.network.org # prefix or list of prefixes
65
- pluck: [name, relationship, org]
66
- sort_by: name
67
- format: json # the stored form is data
57
+ - key: artifacts.feeds.skills
58
+ lane: artifacts
59
+ kind: produced
60
+ format: json
61
+ source: { from: external, command: "true", sources: [] }
68
62
  publish:
69
- - { to: docs/people.md, template: people.mustache } # render the data through a template
70
- ```
71
-
72
- **`source: { from: command }`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `doctor`'s `generator_drift` check can compare source mtimes against the file's `_meta.generated.at`. The role running the build must hold `converge` (default: `automation`).
73
-
74
- ```yaml
75
- - key: artifacts.derived.skills
76
- path: artifacts/derived/skills.md
77
- zone: artifacts
78
- owner: automation:catalog-skills
79
- source:
80
- from: command
81
- command: "rake catalog:skills" # informational; the automation invokes it
82
- sources: [knowledge.projects, knowledge.network]
63
+ - { to: docs/reference/skills.md, template: feeds/skills.erb }
83
64
  ```
84
65
 
85
- The build automation is responsible for writing the `generated:` frontmatter block (`by`, `at`, `from`) when it produces the file. `generated.from` SHOULD match `source.sources` — same list, recorded twice so a diff proves what was consumed.
86
-
87
- Full contract for both shapes is in [`../../SPEC.md` §5.2.1 and §5.2.2](../../SPEC.md). Data transforms (`source.transform:`) and publishing (a `publish:` list of `{ to, template? }` / `{ tree }` targets — render or copy the data) are covered in §5.2–§5.3.
66
+ The matching workflow block:
88
67
 
89
- ## Intake and freshness
68
+ ```ruby
69
+ # .textus/workflows/feeds/agentskills.rb
70
+ Textus.workflow "agentskills" do
71
+ match "artifacts.feeds.skills"
90
72
 
91
- External inputs land via `:resolve_handler` hooks, not shell commands. Each intake entry declares `source: { from: handler, handler: <name>, ttl: <dur> }`; re-pull is system-pushed via `drain` (scheduled sweep) and `hook run` (event push) — a `get` never refreshes (ADR 0089):
73
+ step :fetch do |_, _ctx|
74
+ { "content" => { "skills" => [...], "count" => 1 } }
75
+ end
92
76
 
93
- ```sh
94
- textus pulse --output=json # `stale` lists expired entries; `next_due_at` is the soonest deadline
95
- textus drain --as=automation # re-pulls every intake entry past its source.ttl
77
+ publish
78
+ end
96
79
  ```
97
80
 
98
- The re-pull cadence is the entry's own `source.ttl`. Age-based garbage collection is the orthogonal `retention:` rule slot in the top-level `rules:` block, matched by glob (`{ ttl, action: drop | archive }`); the two compose — re-pull hourly *and* archive at 90 days:
81
+ `drain` discovers workflows, matches entries, runs the steps, and writes the result back to the entry's data path. Publishing then copies or renders each `publish:` target.
99
82
 
100
- ```yaml
101
- - key: artifacts.feeds.notion.events
102
- kind: produced # produce-method (intake) read from source.from: handler
103
- zone: artifacts
104
- source: { from: handler, handler: notion, config: { ... }, ttl: 6h }
83
+ Age-based GC uses a `retention:` rule in the top-level `rules:` block:
105
84
 
85
+ ```yaml
106
86
  rules:
107
- - match: artifacts.feeds.notion.**
87
+ - match: artifacts.feeds.**
108
88
  retention: { ttl: 90d, action: archive }
109
89
  ```
110
90
 
111
- A typical scheduled integration runs `drain` on a cron to re-pull every
112
- expired feed:
91
+ Full contract in [`../../SPEC.md` §5.2](../../SPEC.md).
92
+
93
+ ## Freshness
94
+
95
+ A `get` is always a pure read (ADR 0089) — it never triggers a re-produce:
113
96
 
114
97
  ```sh
115
- textus drain --as=automation # in cron / CI re-pulls all stale feeds
98
+ textus pulse --output=json # `stale` lists entries past their ttl
99
+ textus drain --as=automation # re-produces stale entries
116
100
  ```
117
101
 
118
- See [`./zones.md` §6](zones.md) for the full intake contract and [`../how-to/writing-hooks.md`](../how-to/writing-hooks.md) for writing custom handlers.
102
+ See [`./lanes.md`](lanes.md) for lane semantics and [`../how-to/configuring-lanes.md`](../how-to/configuring-lanes.md) for setting up produced entries and workflows.
119
103
 
120
104
  ### Read vs. refresh
121
105
 
@@ -141,11 +125,11 @@ For multi-writer environments, **always pass `if_etag`** on `put`. The gem treat
141
125
 
142
126
  The application layer is organised around three shapes — `Manifest` as a composition record, a single `Container` capability record handed to every use case, and a split envelope reader/writer. See [ADR 0018](../architecture/decisions/0018-manifest-carving.md), [ADR 0017](../architecture/decisions/0017-envelope-io-split.md), [ADR 0022](../architecture/decisions/0022-container-call-dispatcher.md), and [ADR 0023](../architecture/decisions/0023-uniform-use-case-shape.md).
143
127
 
144
- - **`Manifest` is a composition record** (`Data.define(:data, :resolver, :policy, :rules)`). Reach individual concerns through the field accessors: `manifest.data.entries`, `manifest.policy.permission_for(zone)`, `manifest.resolver.resolve(key)`, `manifest.rules.for(key)`.
145
- - **Use cases are plain `(container:, call:)` classes.** Each is a single class under `lib/textus/{read,write,maintenance}/` with `def initialize(container:, call:)` and a `#call(...)` method; verbs are looked up in the static `Textus::Dispatcher::VERBS` table. `Container` is a `Data.define` record bundling the wired ports + manifest (`manifest`, `file_store`, `schemas`, `root`, `audit_log`, `events`, `rpc`); `Call` is the immutable per-invocation value (`role`, `correlation_id`, `now`, `dry_run`). A use case that emits events derives its `Hooks::Context` from `(container, call)` — nothing is injected. Use cases pull only what they need into ivars; nobody passes the raw `Store` around the application layer. `store.as(role)` returns a `RoleScope` that forwards verbs to the dispatcher.
128
+ - **`Manifest` is a composition record** (`Data.define(:data, :resolver, :policy, :rules)`). Reach individual concerns through the field accessors: `manifest.data.entries`, `manifest.policy.permission_for(lane)`, `manifest.resolver.resolve(key)`, `manifest.rules.for(key)`.
129
+ - **Use cases are plain `(container:, call:)` classes.** Each is a single class under `lib/textus/action/` with `def initialize(...)` (args from contract) and a `#call(container:, call:)` method; verbs are looked up in the static `Textus::Action::VERBS` table. `Container` is a `Dry::Struct` record bundling the wired ports + manifest (`manifest`, `file_store`, `schemas`, `root`, `audit_log`, `workflows`, `gate`); `Call` is the immutable per-invocation value (`role`, `correlation_id`, `now`, `dry_run`). All dispatch routes through `Gate#dispatch(spec:, inputs:, role:, correlation_id:, session:)` — the single seam that binds inputs via `Gate::Binder`, checks auth, and invokes the action. `store.as(role)` returns a `RoleScope` that forwards verbs to `Gate::dispatch`.
146
130
  - **Write path is split**: `Envelope::IO::Reader` owns read/parse (existing-uid lookup, raw read, parse), and `Envelope::IO::Writer` owns put/delete/move + the audit-append invariant (every public method's final action is `@audit_log.append(...)`).
147
131
 
148
- The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/3`) are unchanged.
132
+ The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/4`) are unchanged.
149
133
 
150
134
  ## Pairing with other tools
151
135
 
data/exe/textus CHANGED
@@ -1,4 +1,4 @@
1
1
  #!/usr/bin/env ruby
2
2
  $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
3
3
  require "textus"
4
- exit Textus::Surfaces::CLI.run(ARGV)
4
+ exit Textus::Surface::CLI.run(ARGV)
@@ -2,42 +2,44 @@
2
2
 
3
3
  module Textus
4
4
  module Action
5
- class Accept < WriteVerb
6
- extend Textus::Contract::DSL
7
-
5
+ class Accept < Base
8
6
  verb :accept
9
7
  summary "apply a queued proposal to its target zone; requires the author capability"
10
8
  surfaces :cli, :mcp
11
9
  cli "accept"
12
10
  arg :pending_key, String, required: true, positional: true, description: "the queued proposal's key"
13
11
 
14
- def initialize(pending_key:)
15
- super()
16
- @pending_key = pending_key
17
- end
12
+ def self.call(container:, call:, pending_key:)
13
+ env = container.compositor.read(pending_key)
14
+ parsed = proposal_from(env, key: pending_key)
15
+ return parsed if parsed.is_a?(Dry::Monads::Result::Failure)
18
16
 
19
- def call(container:, call:)
20
- env = Textus::Action::Get.new(key: @pending_key).call(container: container, call: call)
21
- proposal = env.meta["proposal"] or raise Textus::ProposalError.new("entry has no proposal block: #{@pending_key}")
22
- target = proposal["target_key"] or raise Textus::ProposalError.new("proposal missing target_key")
23
- action = proposal["action"] || "put"
17
+ target = parsed[:target_key]
18
+ action = parsed[:proposal]["action"] || "put"
24
19
 
25
20
  case action
26
21
  when "put"
27
- Textus::Action::Put.new(
28
- key: target,
29
- meta: env.meta["_meta"] || {},
30
- body: env.body,
31
- ).call(container: container, call: call)
22
+ mentry = container.manifest.resolver.resolve(target).entry
23
+ container.compositor.write(
24
+ target,
25
+ mentry: mentry,
26
+ payload: Textus::Store::Envelope::Writer::Payload.new(
27
+ meta: env.meta["_meta"] || {},
28
+ body: env.body,
29
+ content: nil,
30
+ ),
31
+ call: call,
32
+ )
32
33
  when "delete"
33
- Textus::Action::KeyDelete.new(key: target).call(container: container, call: call)
34
+ container.compositor.delete(target, call: call)
34
35
  else
35
- raise Textus::ProposalError.new("unknown action: #{action}")
36
+ return Failure(code: :proposal_error, message: "unknown action: #{action}")
36
37
  end
37
38
 
38
- Textus::Action::KeyDelete.new(key: @pending_key).call(container: container, call: call)
39
+ container.compositor.delete(pending_key, call: call)
39
40
 
40
- { "protocol" => Textus::PROTOCOL, "accepted" => @pending_key, "target_key" => target, "action" => action }
41
+ Success("protocol" => Textus::PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action,
42
+ "cascade_key" => target)
41
43
  end
42
44
  end
43
45
  end
@@ -6,8 +6,6 @@ require "time"
6
6
  module Textus
7
7
  module Action
8
8
  class Audit < Base
9
- extend Textus::Contract::DSL
10
-
11
9
  verb :audit
12
10
  summary "Query the audit log with optional filters."
13
11
  surfaces :cli
@@ -24,42 +22,26 @@ module Textus
24
22
  arg :limit, Integer, required: false, description: "maximum number of rows to return"
25
23
  view(:cli) { |rows, _i| { "verb" => "audit", "rows" => rows } }
26
24
 
27
- def initialize(**kwargs)
28
- super()
29
- @query = Query.build(**kwargs.slice(:key, :lane, :role, :verb, :since, :seq_since, :correlation_id, :limit))
30
- end
31
-
32
- def args
33
- @query.to_h.compact
34
- end
35
-
36
- def call(container:, **)
37
- @manifest = container.manifest
38
- @root = container.root
39
- @log_path = Textus::Layout.audit_log(container.root)
40
- @audit_log = container.audit_log
41
-
42
- query = @query
43
- check_cursor_expiry!(query.seq_since)
25
+ def self.call(container:, key: nil, lane: nil, role: nil, verb: nil, since: nil, seq_since: nil, correlation_id: nil, limit: nil, **) # rubocop:disable Metrics/ParameterLists
26
+ audit_log = container.audit_log
27
+ manifest = container.manifest
44
28
 
45
- files = all_log_files
46
- return [] if files.empty?
29
+ cursor_check = check_cursor_expiry(seq_since, audit_log)
30
+ return cursor_check if cursor_check.is_a?(Dry::Monads::Result::Failure)
47
31
 
48
- rows = []
49
- files.each do |file|
50
- File.foreach(file) do |line|
51
- parsed = parse_row(line.chomp)
52
- next unless parsed
53
- next unless query.matches?(parsed)
54
- next if query.lane && !key_in_lane?(parsed["key"], query.lane)
55
-
56
- rows << parsed
57
- break if limit_reached?(rows, query)
58
- end
59
- break if limit_reached?(rows, query)
60
- end
32
+ Success(audit_log.scan(
33
+ seq_since: seq_since,
34
+ key: key,
35
+ role: role,
36
+ verb: verb,
37
+ correlation_id: correlation_id,
38
+ limit: limit,
39
+ ).select do |row|
40
+ next false if lane && !key_in_lane?(row["key"], lane, manifest)
41
+ next false if since && (row["ts"].nil? || Time.parse(row["ts"]) < since)
61
42
 
62
- rows
43
+ true
44
+ end)
63
45
  end
64
46
 
65
47
  def self.parse_since(str, now: Time.now.utc)
@@ -91,37 +73,18 @@ module Textus
91
73
  end
92
74
  end
93
75
 
94
- private
95
-
96
- def limit_reached?(rows, query) = query.limit && rows.length >= query.limit
97
-
98
- def check_cursor_expiry!(seq_since)
76
+ def self.check_cursor_expiry(seq_since, audit_log)
99
77
  return unless seq_since
100
78
 
101
- log = @audit_log || Textus::Ports::AuditLog.new(@root)
102
- min = log.min_available_seq
103
- raise Textus::CursorExpired.new(requested: seq_since, min_available: min) if min && seq_since < min - 1
104
- end
105
-
106
- def all_log_files
107
- rotated = Dir.glob(File.join(Textus::Layout.audit_dir(@root), "audit.log.*"))
108
- .reject { |path| path.end_with?(".meta.json") }
109
- .sort_by { |path| -path.scan(/\d+$/).first.to_i }
110
- active = File.exist?(@log_path) ? [@log_path] : []
111
- rotated + active
112
- end
113
-
114
- def parse_row(line)
115
- return nil if line.empty?
116
- return nil unless line.start_with?("{")
79
+ min = audit_log.min_available_seq
80
+ return unless min && seq_since < min - 1
117
81
 
118
- JSON.parse(line)
119
- rescue JSON::ParserError
120
- nil
82
+ Failure(code: :cursor_expired, message: "requested seq #{seq_since} is below minimum available #{min}",
83
+ details: { requested: seq_since, min_available: min })
121
84
  end
122
85
 
123
- def key_in_lane?(key, lane)
124
- mentry = @manifest.resolver.resolve(key).entry
86
+ def self.key_in_lane?(key, lane, manifest)
87
+ mentry = manifest.resolver.resolve(key).entry
125
88
  mentry && mentry.lane == lane
126
89
  rescue Textus::Error
127
90
  false
@@ -20,22 +20,22 @@ module Textus
20
20
  end
21
21
 
22
22
  class Base
23
+ extend Contract::DSL
24
+ extend Dry::Monads[:result]
25
+
23
26
  def self.inherited(subclass)
24
27
  super
25
28
  Textus::Action.register(subclass) if subclass.name
26
29
  end
27
30
 
28
- def call(**)
29
- raise NotImplementedError.new("#{self.class}#call")
31
+ def self.call(**)
32
+ raise NotImplementedError.new("#{name}.call")
30
33
  end
31
34
 
32
- def args
33
- params = self.class.instance_method(:initialize).parameters
34
- names = params.select { |t,| %i[key keyreq].include?(t) }.map(&:last)
35
- names.each_with_object({}) do |name, h|
36
- val = instance_variable_get(:"@#{name}")
37
- h[name] = val unless val.nil?
38
- end
35
+ def self.proposal_from(env, key:)
36
+ proposal = env.meta&.dig("proposal") or return Failure(code: :proposal_error, message: "entry has no proposal block: #{key}")
37
+ target = proposal["target_key"] or return Failure(code: :proposal_error, message: "proposal missing target_key")
38
+ { proposal:, target_key: target }
39
39
  end
40
40
  end
41
41
  end
@@ -5,8 +5,6 @@ require "open3"
5
5
  module Textus
6
6
  module Action
7
7
  class Blame < Base
8
- extend Textus::Contract::DSL
9
-
10
8
  verb :blame
11
9
  summary "Annotate audit rows for a key with the git commit that introduced each file state."
12
10
  surfaces :cli
@@ -15,59 +13,43 @@ module Textus
15
13
  arg :limit, Integer, required: false, description: "maximum number of audit rows to return"
16
14
  view(:cli) { |rows, inputs| { "verb" => "blame", "key" => inputs[:key], "rows" => rows } }
17
15
 
18
- def initialize(key:, limit: nil)
19
- super()
20
- @key = key
21
- @limit = limit
22
- end
23
-
24
- def call(container:, **)
25
- @container = container
26
- @manifest = container.manifest
27
- @root = container.root
16
+ def self.call(container:, key:, limit: nil, **)
17
+ manifest = container.manifest
18
+ root = container.root
28
19
 
29
- audit_rows = Textus::Action::Audit.new(key: @key, limit: @limit).call(container: container)
30
- path = resolve_path(@key)
31
- return audit_rows.map { |row| row.merge("git" => nil) } unless git_tracked?(path)
20
+ audit_result = Textus::Action::Audit.call(container: container, key: key, limit: limit)
21
+ audit_rows = Value::Result.unwrap(audit_result)
22
+ path = resolve_path(key, manifest: manifest)
23
+ return Success(audit_rows.map { |row| row.merge("git" => nil) }) unless git_tracked?(path, root: root)
32
24
 
33
- audit_rows.map { |row| row.merge("git" => git_commit_at(path, timestamp: row["ts"])) }
25
+ Success(audit_rows.map { |row| row.merge("git" => git_commit_at(path, timestamp: row["ts"], root: root)) })
34
26
  end
35
27
 
36
- def self.new(*args, **kwargs)
37
- return super(**kwargs) unless args.any?
38
-
39
- positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
40
- mapped = positional.zip(args).to_h
41
- super(**mapped.merge(kwargs))
42
- end
43
-
44
- private
45
-
46
- def resolve_path(key)
47
- res = @manifest.resolver.resolve(key)
28
+ def self.resolve_path(key, manifest:)
29
+ res = manifest.resolver.resolve(key)
48
30
  mentry = res.entry
49
31
  path = res.path
50
- path || Textus::Key::Path.resolve(@manifest.data, mentry)
32
+ path || Textus::Key::Path.resolve(manifest.data, mentry)
51
33
  rescue Textus::Error
52
34
  nil
53
35
  end
54
36
 
55
- def git_tracked?(path)
37
+ def self.git_tracked?(path, root:)
56
38
  return false if path.nil?
57
39
  return false unless File.exist?(path)
58
- return false unless git_repo?
40
+ return false unless git_repo?(root)
59
41
 
60
42
  _out, _err, status = Open3.capture3(
61
43
  "git", "ls-files", "--error-unmatch", path,
62
- chdir: @root
44
+ chdir: root
63
45
  )
64
46
  status.success?
65
47
  rescue Errno::ENOENT
66
48
  false
67
49
  end
68
50
 
69
- def git_repo?
70
- dir = @root
51
+ def self.git_repo?(root)
52
+ dir = root
71
53
  loop do
72
54
  return true if File.directory?(File.join(dir, ".git"))
73
55
 
@@ -78,11 +60,11 @@ module Textus
78
60
  end
79
61
  end
80
62
 
81
- def git_commit_at(path, timestamp:)
63
+ def self.git_commit_at(path, timestamp:, root:)
82
64
  args = ["git", "log", "-1"]
83
65
  args << "--before=#{timestamp}" if timestamp
84
66
  args += ["--format=%H%x09%an%x09%aI%x09%s", "--", path]
85
- out, _err, status = Open3.capture3(*args, chdir: @root)
67
+ out, _err, status = Open3.capture3(*args, chdir: root)
86
68
  return nil unless status.success?
87
69
 
88
70
  sha, author, date, subject = out.strip.split("\t", 4)
@@ -3,14 +3,12 @@
3
3
  module Textus
4
4
  module Action
5
5
  class Boot < Base
6
- extend Textus::Contract::DSL
7
-
8
6
  verb :boot
9
7
  summary "Return the orientation contract: lanes, agent_quickstart, agent_protocol, and pre-computed artifacts."
10
8
  surfaces :cli, :mcp
11
9
 
12
- def call(container:, **)
13
- Textus::Boot.build(container: container)
10
+ def self.call(container:, **)
11
+ Success(Textus::Boot.build(container: container))
14
12
  end
15
13
  end
16
14
  end
@@ -5,8 +5,6 @@ require "yaml"
5
5
  module Textus
6
6
  module Action
7
7
  class DataMv < Base
8
- extend Textus::Contract::DSL
9
-
10
8
  verb :data_mv
11
9
  summary "Rename a data lane — manifest + files. Refuses if destination exists."
12
10
  surfaces :cli, :mcp
@@ -19,48 +17,39 @@ module Textus
19
17
  "defaults to false, so omitting it applies the move immediately"
20
18
  view { |v, _i| v.to_h }
21
19
 
22
- def initialize(from:, to:, dry_run: false)
23
- super()
24
- @from = from
25
- @to = to
26
- @dry_run = dry_run
27
- end
28
-
29
- def call(container:, **)
20
+ def self.call(container:, call:, from:, to:, dry_run: false, **) # rubocop:disable Lint/UnusedMethodArgument
30
21
  manifest = container.manifest
31
- root = container.root
22
+ geom = container.geometry
32
23
 
33
- raise UsageError.new("from and to required") if @from.nil? || @to.nil? || @from.empty? || @to.empty?
34
- raise UsageError.new("data lane '#{@from}' not declared") unless manifest.data.declared_lane_kinds.key?(@from)
24
+ return Failure(code: :usage_error, message: "from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
25
+ return Failure(code: :usage_error, message: "data lane '#{from}' not declared") unless manifest.data.declared_lane_kinds.key?(from)
35
26
 
36
- dest_dir = File.join(root, "data", @to)
37
- raise UsageError.new("destination 'data/#{@to}' already exists") if File.exist?(dest_dir)
27
+ dest_dir = geom.lane_path(to)
28
+ return Failure(code: :usage_error, message: "destination 'data/#{to}' already exists") if File.exist?(dest_dir)
38
29
 
39
- affected_keys = manifest.data.entries.select { |entry| entry.lane == @from }.map(&:key)
30
+ affected_keys = manifest.data.entries.select { |entry| entry.lane == from }.map(&:key)
40
31
 
41
- steps = [{ "op" => "rename_zone", "from" => @from, "to" => @to }]
32
+ steps = [{ "op" => "rename_zone", "from" => from, "to" => to }]
42
33
  steps += affected_keys.map do |key|
43
- { "op" => "mv", "from" => key, "to" => "#{@to}#{key[@from.length..]}" }
34
+ { "op" => "mv", "from" => key, "to" => "#{to}#{key[from.length..]}" }
44
35
  end
45
36
 
46
- plan = Textus::Jobs::Plan.new(steps: steps, warnings: [])
47
- return plan if @dry_run
37
+ plan = Textus::Store::Jobs::Plan.new(steps: steps, warnings: [])
38
+ return Success(plan) if dry_run
48
39
 
49
- rewrite_manifest!(root)
50
- FileUtils.mv(File.join(root, "data", @from), dest_dir)
51
- plan
40
+ rewrite_manifest!(geom, from:, to:)
41
+ FileUtils.mv(geom.lane_path(from), dest_dir)
42
+ Success(plan)
52
43
  end
53
44
 
54
- private
55
-
56
- def rewrite_manifest!(root)
57
- path = File.join(root, "manifest.yaml")
45
+ def self.rewrite_manifest!(geom, from:, to:)
46
+ path = geom.manifest_path
58
47
  raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
59
- raw["lanes"].each { |lane| lane["name"] = @to if lane["name"] == @from }
48
+ raw["lanes"].each { |lane| lane["name"] = to if lane["name"] == from }
60
49
  raw["entries"].each do |entry|
61
- entry["lane"] = @to if entry["lane"] == @from
62
- entry["key"] = entry["key"].sub(/\A#{Regexp.escape(@from)}(\.|\z)/, "#{@to}\\1")
63
- entry["path"] = entry["path"].sub(%r{\A(data/)?#{Regexp.escape(@from)}(/|\z)}, "\\1#{@to}\\2")
50
+ entry["lane"] = to if entry["lane"] == from
51
+ entry["key"] = entry["key"].sub(/\A#{Regexp.escape(from)}(\.|\z)/, "#{to}\\1")
52
+ entry["path"] = entry["path"].sub(%r{\A(data/)?#{Regexp.escape(from)}(/|\z)}, "\\1#{to}\\2")
64
53
  end
65
54
  File.write(path, YAML.dump(raw))
66
55
  end