textus 0.22.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +195 -48
  3. data/CHANGELOG.md +178 -0
  4. data/README.md +55 -13
  5. data/SPEC.md +79 -42
  6. data/docs/conventions.md +10 -0
  7. data/lib/textus/boot.rb +31 -29
  8. data/lib/textus/builder/pipeline.rb +13 -12
  9. data/lib/textus/call.rb +28 -0
  10. data/lib/textus/cli/group/mcp.rb +9 -0
  11. data/lib/textus/cli/group/zone.rb +9 -0
  12. data/lib/textus/cli/verb/accept.rb +1 -1
  13. data/lib/textus/cli/verb/audit.rb +2 -2
  14. data/lib/textus/cli/verb/blame.rb +1 -1
  15. data/lib/textus/cli/verb/boot.rb +1 -1
  16. data/lib/textus/cli/verb/build.rb +3 -3
  17. data/lib/textus/cli/verb/delete.rb +1 -1
  18. data/lib/textus/cli/verb/deps.rb +1 -1
  19. data/lib/textus/cli/verb/doctor.rb +1 -1
  20. data/lib/textus/cli/verb/freshness.rb +1 -1
  21. data/lib/textus/cli/verb/get.rb +1 -1
  22. data/lib/textus/cli/verb/hook_run.rb +3 -4
  23. data/lib/textus/cli/verb/hooks.rb +11 -14
  24. data/lib/textus/cli/verb/key_delete.rb +24 -0
  25. data/lib/textus/cli/verb/list.rb +1 -1
  26. data/lib/textus/cli/verb/mcp_serve.rb +17 -0
  27. data/lib/textus/cli/verb/migrate.rb +18 -0
  28. data/lib/textus/cli/verb/mv.rb +11 -3
  29. data/lib/textus/cli/verb/published.rb +1 -1
  30. data/lib/textus/cli/verb/pulse.rb +1 -1
  31. data/lib/textus/cli/verb/put.rb +8 -6
  32. data/lib/textus/cli/verb/rdeps.rb +1 -1
  33. data/lib/textus/cli/verb/refresh.rb +1 -1
  34. data/lib/textus/cli/verb/refresh_stale.rb +1 -1
  35. data/lib/textus/cli/verb/reject.rb +1 -1
  36. data/lib/textus/cli/verb/rule_explain.rb +1 -1
  37. data/lib/textus/cli/verb/rule_lint.rb +18 -0
  38. data/lib/textus/cli/verb/schema.rb +1 -1
  39. data/lib/textus/cli/verb/uid.rb +1 -1
  40. data/lib/textus/cli/verb/where.rb +1 -1
  41. data/lib/textus/cli/verb/zone_mv.rb +19 -0
  42. data/lib/textus/cli/verb.rb +7 -7
  43. data/lib/textus/cli.rb +0 -7
  44. data/lib/textus/container.rb +23 -0
  45. data/lib/textus/dispatcher.rb +49 -0
  46. data/lib/textus/doctor/check/audit_log.rb +2 -2
  47. data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
  48. data/lib/textus/doctor/check/hooks.rb +4 -3
  49. data/lib/textus/doctor/check/illegal_keys.rb +2 -2
  50. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  51. data/lib/textus/doctor/check/manifest_files.rb +2 -2
  52. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  53. data/lib/textus/doctor/check/refresh_locks.rb +2 -2
  54. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  55. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  56. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  57. data/lib/textus/doctor/check/schemas.rb +2 -2
  58. data/lib/textus/doctor/check/sentinels.rb +11 -9
  59. data/lib/textus/doctor/check/templates.rb +2 -2
  60. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  61. data/lib/textus/doctor/check.rb +12 -3
  62. data/lib/textus/doctor.rb +24 -27
  63. data/lib/textus/domain/authorizer.rb +6 -6
  64. data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
  65. data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
  66. data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
  67. data/lib/textus/domain/sentinel.rb +9 -65
  68. data/lib/textus/domain/staleness/generator_check.rb +46 -26
  69. data/lib/textus/domain/staleness/intake_check.rb +20 -12
  70. data/lib/textus/domain/staleness.rb +4 -4
  71. data/lib/textus/envelope/io/reader.rb +44 -0
  72. data/lib/textus/{application/writes/envelope_io.rb → envelope/io/writer.rb} +32 -58
  73. data/lib/textus/hooks/builtin.rb +14 -14
  74. data/lib/textus/hooks/context.rb +30 -13
  75. data/lib/textus/hooks/error_log.rb +32 -0
  76. data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
  77. data/lib/textus/hooks/loader.rb +29 -3
  78. data/lib/textus/hooks/rpc_registry.rb +77 -0
  79. data/lib/textus/key/path.rb +7 -3
  80. data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
  81. data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
  82. data/lib/textus/maintenance/migrate.rb +51 -0
  83. data/lib/textus/maintenance/rule_lint.rb +56 -0
  84. data/lib/textus/maintenance/zone_mv.rb +51 -0
  85. data/lib/textus/maintenance.rb +15 -0
  86. data/lib/textus/manifest/data.rb +79 -0
  87. data/lib/textus/manifest/entry/base.rb +38 -18
  88. data/lib/textus/manifest/entry/derived.rb +8 -9
  89. data/lib/textus/manifest/entry/nested.rb +7 -9
  90. data/lib/textus/manifest/entry/parser.rb +2 -2
  91. data/lib/textus/manifest/entry/validators/events.rb +2 -2
  92. data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
  93. data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
  94. data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
  95. data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
  96. data/lib/textus/manifest/entry/validators.rb +2 -2
  97. data/lib/textus/manifest/entry.rb +0 -5
  98. data/lib/textus/manifest/policy.rb +48 -0
  99. data/lib/textus/manifest/resolver.rb +14 -14
  100. data/lib/textus/manifest/rules.rb +1 -1
  101. data/lib/textus/manifest.rb +47 -110
  102. data/lib/textus/mcp/errors.rb +32 -0
  103. data/lib/textus/mcp/server.rb +126 -0
  104. data/lib/textus/mcp/session.rb +40 -0
  105. data/lib/textus/mcp/tool_schemas.rb +71 -0
  106. data/lib/textus/mcp/tools.rb +129 -0
  107. data/lib/textus/mcp.rb +6 -0
  108. data/lib/textus/{infra → ports}/audit_log.rb +1 -1
  109. data/lib/textus/{infra → ports}/audit_subscriber.rb +7 -8
  110. data/lib/textus/{infra → ports}/build_lock.rb +1 -1
  111. data/lib/textus/{infra → ports}/clock.rb +1 -1
  112. data/lib/textus/{infra → ports}/publisher.rb +6 -6
  113. data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
  114. data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
  115. data/lib/textus/ports/sentinel_store.rb +67 -0
  116. data/lib/textus/ports/storage/file_stat.rb +19 -0
  117. data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
  118. data/lib/textus/projection.rb +91 -0
  119. data/lib/textus/read/audit.rb +111 -0
  120. data/lib/textus/read/blame.rb +81 -0
  121. data/lib/textus/read/boot.rb +18 -0
  122. data/lib/textus/read/deps.rb +24 -0
  123. data/lib/textus/read/doctor.rb +19 -0
  124. data/lib/textus/read/freshness.rb +101 -0
  125. data/lib/textus/read/get.rb +66 -0
  126. data/lib/textus/read/get_or_refresh.rb +69 -0
  127. data/lib/textus/read/list.rb +15 -0
  128. data/lib/textus/read/policy_explain.rb +37 -0
  129. data/lib/textus/read/published.rb +15 -0
  130. data/lib/textus/read/pulse.rb +89 -0
  131. data/lib/textus/read/rdeps.rb +25 -0
  132. data/lib/textus/read/schema_envelope.rb +16 -0
  133. data/lib/textus/read/stale.rb +17 -0
  134. data/lib/textus/read/uid.rb +20 -0
  135. data/lib/textus/read/validate_all.rb +22 -0
  136. data/lib/textus/read/validator.rb +84 -0
  137. data/lib/textus/read/where.rb +16 -0
  138. data/lib/textus/role_scope.rb +49 -0
  139. data/lib/textus/schema/tools.rb +14 -10
  140. data/lib/textus/store.rb +25 -11
  141. data/lib/textus/version.rb +1 -1
  142. data/lib/textus/write/accept.rb +86 -0
  143. data/lib/textus/write/authority_gate.rb +24 -0
  144. data/lib/textus/write/delete.rb +54 -0
  145. data/lib/textus/write/materializer.rb +48 -0
  146. data/lib/textus/write/mv.rb +123 -0
  147. data/lib/textus/write/publish.rb +66 -0
  148. data/lib/textus/write/put.rb +59 -0
  149. data/lib/textus/write/refresh_all.rb +44 -0
  150. data/lib/textus/write/refresh_orchestrator.rb +102 -0
  151. data/lib/textus/write/refresh_worker.rb +138 -0
  152. data/lib/textus/write/reject.rb +54 -0
  153. data/lib/textus.rb +7 -1
  154. metadata +75 -46
  155. data/lib/textus/application/context.rb +0 -34
  156. data/lib/textus/application/projection.rb +0 -91
  157. data/lib/textus/application/reads/audit.rb +0 -94
  158. data/lib/textus/application/reads/blame.rb +0 -82
  159. data/lib/textus/application/reads/deps.rb +0 -26
  160. data/lib/textus/application/reads/freshness.rb +0 -88
  161. data/lib/textus/application/reads/get.rb +0 -67
  162. data/lib/textus/application/reads/get_or_refresh.rb +0 -51
  163. data/lib/textus/application/reads/list.rb +0 -17
  164. data/lib/textus/application/reads/policy_explain.rb +0 -39
  165. data/lib/textus/application/reads/published.rb +0 -17
  166. data/lib/textus/application/reads/pulse.rb +0 -63
  167. data/lib/textus/application/reads/rdeps.rb +0 -27
  168. data/lib/textus/application/reads/schema_envelope.rb +0 -18
  169. data/lib/textus/application/reads/stale.rb +0 -15
  170. data/lib/textus/application/reads/uid.rb +0 -23
  171. data/lib/textus/application/reads/validate_all.rb +0 -24
  172. data/lib/textus/application/reads/validator.rb +0 -86
  173. data/lib/textus/application/reads/where.rb +0 -18
  174. data/lib/textus/application/refresh/all.rb +0 -52
  175. data/lib/textus/application/refresh/orchestrator.rb +0 -78
  176. data/lib/textus/application/refresh/worker.rb +0 -116
  177. data/lib/textus/application/writes/accept.rb +0 -89
  178. data/lib/textus/application/writes/authority_gate.rb +0 -26
  179. data/lib/textus/application/writes/delete.rb +0 -33
  180. data/lib/textus/application/writes/materializer.rb +0 -50
  181. data/lib/textus/application/writes/mv.rb +0 -105
  182. data/lib/textus/application/writes/publish.rb +0 -81
  183. data/lib/textus/application/writes/put.rb +0 -37
  184. data/lib/textus/application/writes/reject.rb +0 -50
  185. data/lib/textus/infra/event_bus.rb +0 -27
  186. data/lib/textus/operations.rb +0 -176
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 38a9a929bb63f94d5a3cdc4708f09ce5c8e0eca28928d5733b8d842d90ece8b8
4
- data.tar.gz: 26a8aeb22666788cd30e949eb25c7336db1de9ef7f4110aaf700f7abecdec644
3
+ metadata.gz: 0e8208a516e0a7760953e7c44a74fb459f168192ac4b0a20059b8686285f137a
4
+ data.tar.gz: 063deb01f793cec399a68620ce23bf1e9daf6d797edb985d919cb3bd6b9a1f87
5
5
  SHA512:
6
- metadata.gz: 9b4ce0da2828f2623f60601cdd0ac8a69e51cc9670c5de80b92e3b41e0f955361154664b8cb0a7de0e838b5403336ff26589698201885b44001a83cfcd2c3f21
7
- data.tar.gz: 672d948ab0ccdea1470dcb38c87c233ed717a538d4e4902191a32224dac8475fe1269b4569be00e5eb1f40e9c6146f8867d557d6455601c13fc2e0ce2c381cae
6
+ metadata.gz: 29d392ea08bbd23f460761c1849c90d144a97b9e9208355818b364acbf72c708e2129c650ecf032a562b62271064094dac96ed5f6497c97128666e004959d47d
7
+ data.tar.gz: 2260fe709abe9685285cad7171e36def69a6e25047762c2077dcb4c70e670f8232123156b33b5811dfd79393592b563470819c641ff9508ec2b795f3b87065ad
data/ARCHITECTURE.md CHANGED
@@ -2,88 +2,235 @@
2
2
 
3
3
  ```
4
4
  ┌─ Interface ────────────────────────────────────────────────┐
5
- │ CLI verbs: ops = Operations.for(store, role:)
6
- ops.<name>(...) # flat methods, one per use
7
- │ # case (put/get/refresh/…)
5
+ │ CLI verbs: store.<verb>(..., role:)
6
+ store.as(role).<verb>(...)
7
+ │ # (put/get/refresh/…)
8
8
  │ │
9
- Or, for embedders bringing their own ports:
10
- │ Operations.new(ctx:, manifest:, file_store:, │
11
- │ schemas:, audit_log:, bus:, │
12
- │ registry:, root:, store:) │
9
+ MCP gate: textus mcp serve same use cases, JSON-RPC.
13
10
  └──────────────────────┬─────────────────────────────────────┘
14
11
 
15
12
  ┌─ Application ────────▼─────────────────────────────────────┐
16
- Context (slim Data: role, correlation_id, now, │
13
+ Call (slim Data: role, correlation_id, now, │
17
14
  │ dry_run — request state only) │
18
- Operations (flat facade; inline use-case factories) │
15
+ Container (single record wired ports + manifest) │
16
+ │ Dispatcher (static VERBS table: verb → use-case) │
17
+ │ RoleScope (Store#as(role) — forwards verb calls) │
19
18
  │ │
20
- reads/{get,list,where,uid,schema_envelope,deps,rdeps,
21
- published,stale,validate_all,freshness,audit, │
22
- blame,policy_explain,get_or_refresh}.rb
23
- writes/{put,delete,mv,accept,reject,build,publish,
24
- envelope_io}.rb
25
- refresh/{worker,orchestrator,all}.rb
26
- policy/{promotion,predicates/{schema_valid,human_accept}}
19
+ read/{get,get_or_refresh,list,where,uid,schema_envelope,
20
+ deps,rdeps,published,stale,validate_all,boot,doctor,│
21
+ freshness,audit,blame,policy_explain,pulse}.rb
22
+ write/{put,delete,mv,accept,reject,publish,
23
+ materializer,authority_gate,
24
+ refresh_worker,refresh_orchestrator,refresh_all}
25
+ maintenance/{migrate,key_mv_prefix,key_delete_prefix,
26
+ │ zone_mv,rule_lint}.rb │
27
+ │ envelope/io/{reader,writer}.rb (split: parse vs persist) │
28
+ │ projection.rb │
27
29
  └──────────┬───────────────────────────────┬─────────────────┘
28
30
  │ uses domain │ uses ports
29
31
  ┌─ Domain ─▼─────────────────────────────────────────────────┐
30
32
  │ Authorizer (manifest + role → allow / deny) │
31
33
  │ Permission (write/read predicate per zone) │
32
34
  │ Freshness::{Policy,Verdict,Evaluator} │
33
- Action Outcome
34
- Policy::{Promote,Refresh,Matcher,HandlerAllowlist}
35
+ Staleness (Generator/Intake checks)
36
+ Action Outcome Sentinel
37
+ │ Policy::{Promote,Refresh,Matcher,HandlerAllowlist, │
38
+ │ Predicates::{SchemaValid,AcceptAuthoritySigned}} │
35
39
  └──────────────────────────────────────────┬─────────────────┘
36
40
  │ implements
37
41
  ┌─ Infrastructure ─────────────────────────▼─────────────────┐
38
- │ Store (composition root — wires ports)
42
+ │ Store (composition root — wires ports,
43
+ │ vends a Container + dispatches verbs) │
39
44
  │ Storage::FileStore (bytes-only port: read/write/delete/ │
40
45
  │ exists?/etag) │
41
- │ Manifest (Entry, Rules, Schema, permission_for)
46
+ │ Manifest (Data, Resolver, Policy, Rules)
42
47
  │ Schemas (eager-load cache) │
43
- │ AuditLog
44
- Hooks::{Registry,Dispatcher,Loader,FireReport}
45
- Infra::{Publisher,EventBus,Clock,Refresh::Lock,
46
- Refresh::Detached,BuildLock,AuditSubscriber}
48
+ Ports::{AuditLog,AuditSubscriber,Publisher,Clock,
49
+ Refresh::Lock,Refresh::Detached,BuildLock}
50
+ Hooks::{EventBus,RpcRegistry,Loader,Context,FireReport,
51
+ Builtin,ErrorLog}
47
52
  │ Entry::{Markdown,Json,Yaml,Text} (format strategies) │
48
53
  └────────────────────────────────────────────────────────────┘
49
54
 
50
- Dependency rule: arrows point DOWN. Domain has zero outbound
51
- imports. Application imports Domain + Infra (via ports).
52
- Use cases declare their real ports in their constructor.
55
+ Dependency rule: arrows point DOWN. Domain performs no direct
56
+ File/Dir/Time.now I/O all disk and clock access is routed through
57
+ injected ports (FileStat, Clock). Pure path math (File.join/dirname/
58
+ absolute_path?/expand_path/basename), Digest hashing of injected
59
+ bytes, and Time.parse of stored strings are NOT I/O and are allowed.
60
+ Application imports Domain + Ports.
61
+ Use cases are plain classes on (container:, call:).
62
+ Verbs are looked up in the static Dispatcher::VERBS table.
53
63
  ```
54
64
 
55
- ## Read path (`ops.get(key)`)
65
+ ## How a verb becomes a method
56
66
 
57
- 1. CLI verb (or any external caller) builds `ops = Textus::Operations.for(store, role:)` then `ops.get(key)`.
58
- 2. `Operations#get` constructs `Application::Reads::Get.new(ctx:, manifest:, file_store:)` and calls it.
59
- 3. `Reads::Get#call(key)` resolves the path through `@manifest`, reads bytes via `@file_store`, parses the envelope.
60
- 4. Looks up the refresh policy via `@manifest.rules_for(key)`. If absent, returns the envelope annotated fresh.
67
+ Each application use case is a plain class under `lib/textus/{read,write,maintenance}/`. The shape is uniform:
68
+
69
+ ```ruby
70
+ module Textus
71
+ module Read
72
+ class Get
73
+ def initialize(container:, call:)
74
+ @container = container
75
+ @call = call
76
+ end
77
+
78
+ def call(key)
79
+ ...
80
+ end
81
+ end
82
+ end
83
+ end
84
+ ```
85
+
86
+ Verbs are looked up in a static frozen table (`Textus::Dispatcher::VERBS`) that maps `:get → Textus::Read::Get`, `:put → Textus::Write::Put`, etc. `Store#put` / `Store#get` / `Store#as(role).<verb>(...)` instantiate the use case on `(container:, call:)` and invoke `#call`. Adding a new verb is **one entry in `Dispatcher::VERBS`** plus the class — no metaprogramming.
87
+
88
+ `boot` and `doctor` are read verbs like any other: `Read::Boot` / `Read::Doctor`
89
+ are thin `(container:, call:)` use cases that delegate to the `Textus::Boot` /
90
+ `Textus::Doctor` report-builder libraries (`build(container:, ...)`). They are
91
+ reached through `Dispatcher::VERBS`, not a special method on `RoleScope`.
92
+
93
+ Two collaborators live outside the dispatcher because they're composed by other use cases, not invoked as verbs:
94
+
95
+ - `Write::RefreshOrchestrator` — composes `RefreshWorker` with the freshness `Action` returned by `Domain::Freshness`.
96
+ - `Envelope::IO::{Reader,Writer}` — own the parse and persist halves of the write pipeline; the audit-append-as-final-step invariant lives in `Writer`.
97
+
98
+ ## Container
99
+
100
+ Use cases never see the raw `Store`. `Textus::Container` is a single record holding the wired collaborators:
101
+
102
+ ```ruby
103
+ Container = Data.define(
104
+ :manifest, :file_store, :schemas, :root,
105
+ :audit_log, :events, :rpc, :authorizer
106
+ )
107
+ ```
108
+
109
+ The `Store` builds one `Container` at boot; every use case receives it via `(container:, call:)`. RPC hook callables (`:resolve_intake`, `:transform_rows`, `:validate`) receive `caps: <Container>` — field names match what the prior `WriteCaps` exposed, so handlers reading `caps.manifest`, `caps.events`, etc. continue to work.
110
+
111
+ ## Ports
112
+
113
+ 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.
114
+
115
+ | Class | Role |
116
+ |---|---|
117
+ | `Ports::Storage::FileStore` | Bytes-only FS I/O — `read`, `write`, `delete`, `exists?`, `etag`. No knowledge of envelopes or schemas. |
118
+ | `Ports::AuditLog` | Append-only structured log (`audit.log`). Owns seq numbering, file-locking, and rotation. |
119
+ | `Ports::Clock` | Supplies `Time.now` — a module-function so tests can swap it without dependency injection boilerplate. |
120
+ | `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. |
121
+ | `Ports::Refresh::Lock` | Non-blocking `flock`-backed lock per key — prevents concurrent refresh workers from racing on the same entry. |
122
+ | `Ports::Refresh::Detached` | Spawns a background thread for async refresh; the caller receives a `refresh_backgrounded` event instead of blocking. |
123
+ | `Ports::BuildLock` | Process-exclusive `flock` guard over the materializer build pipeline. Raises `BuildInProgress` if a build is already running. |
124
+
125
+ Application use cases access ports only through `Container` fields — never through the raw `Store`.
126
+
127
+ ### EnvelopeIO
128
+
129
+ `Envelope::IO::Reader` and `Envelope::IO::Writer` split the envelope pipeline into read-only parse and write-with-audit halves.
130
+
131
+ **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`.
132
+
133
+ **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`).
134
+
135
+ The three public methods are `put`, `delete`, and `move`; all follow the same validate → write → audit sequence.
136
+
137
+ ## Manifest carving
138
+
139
+ 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.
140
+
141
+ `Manifest` itself is a `Data.define` struct — a composition record with four named members:
142
+
143
+ | Member | Class | Responsibility |
144
+ |---|---|---|
145
+ | `data` | `Manifest::Data` | Frozen value: `raw`, `root`, `zones`, `entries`, `audit_config`, `role_mapping`. Structural data only — no behaviour beyond accessors and key validation. |
146
+ | `resolver` | `Manifest::Resolver` | Key → `Resolution(entry, path, remaining)`. Handles nested entry enumeration and fuzzy-match suggestions. |
147
+ | `policy` | `Manifest::Policy` | Zone/role authority — `zone_writers`, `zone_kinds`, `permission_for`, `role_kind`, `roles_with_kind`. Derived from a `Data` snapshot; no filesystem I/O. |
148
+ | `rules` | `Manifest::Rules` | Pattern-matched rule engine. `rules.for(key)` returns a `RuleSet(refresh, handler_allowlist, promote, retention)` by evaluating all `match:` blocks against the key. |
149
+
150
+ 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.
151
+
152
+ 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.
153
+
154
+ ## Read path (`store.get(key)`)
155
+
156
+ 1. CLI verb (or MCP tool) calls `store.get(key, role:)` (or `store.as(role).get(key)`).
157
+ 2. `Store#get` looks up `Dispatcher::VERBS[:get] → Read::Get`, builds a `Call`, instantiates `Read::Get.new(container:, call:).call(key)`.
158
+ 3. `Read::Get#call` resolves the path through `container.manifest`, reads bytes via `container.file_store`, parses the envelope.
159
+ 4. Looks up the refresh policy via `container.manifest.rules.for(key)`. If absent, returns the envelope annotated fresh.
61
160
  5. Otherwise `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`; the envelope is annotated with `stale`, `reason`, `refreshing: false`.
62
161
 
63
- `ops.get_or_refresh(key)` composes `Reads::Get` with `Refresh::Orchestrator` to optionally refresh on stale — same as 0.18.x.
162
+ `store.get_or_refresh(key)` composes `Read::Get` with `Write::RefreshOrchestrator` to optionally refresh on stale.
64
163
 
65
- ## Write path (`ops.put(key, ...)`)
164
+ ## Write path (`store.put(key, ...)`)
66
165
 
67
- 1. CLI verb calls `ops = Operations.for(store, role:)` then `ops.put(key, meta:, body:, content:, if_etag:)`.
68
- 2. `Writes::Put#call` validates the key, resolves the manifest entry, and calls `@authorizer.authorize_write!(mentry, role: @ctx.role)` — raises `WriteForbidden` if denied.
69
- 3. Delegates persistence to `EnvelopeIO#write`, which serializes, schema-validates, etag-checks (raises `EtagMismatch` on conflict), writes via the `FileStore` port, and appends the audit row.
70
- 4. Publishes `:entry_put` via `@bus` with `store: @store`, `key:`, `envelope:`, `role: @ctx.role`, `correlation_id: @ctx.correlation_id`.
166
+ 1. CLI verb calls `store.put(key, meta:, body:, content:, if_etag:, role:)`.
167
+ 2. `Write::Put#call` validates the key, resolves the manifest entry, and calls `container.authorizer.authorize_write!(mentry, role: call.role)` — raises `WriteForbidden` if denied.
168
+ 3. Delegates persistence to `Envelope::IO::Writer#put`, which serializes, schema-validates, etag-checks (raises `EtagMismatch` on conflict), writes via the `FileStore` port, and appends the audit row.
169
+ 4. Publishes `:entry_put` via `container.events` with `ctx: <Hooks::Context>`, `key:`, `envelope:`.
71
170
 
72
- `Writes::{Delete,Mv,Accept,Reject,Build,Publish}` follow the same shape: explicit ports, `Authorizer` for authz, `EnvelopeIO` for persistence (where applicable), event published with `store: real Store + role:` in payload.
171
+ `Write::{Delete,Mv,Accept,Reject,Publish}` follow the same shape: explicit container, `Authorizer` for authz, `Envelope::IO::Writer` for persistence (where applicable), event published with the `Hooks::Context` handle.
73
172
 
74
- `Writes::Mv` delegates the file-move + audit to `EnvelopeIO#move`, then publishes `:entry_renamed` itself. UID injection (when the source lacks one) goes through `EnvelopeIO#write` directly — no `Put` bypass.
173
+ `Write::Mv` delegates the file-move + audit to `Envelope::IO::Writer#move`, then publishes `:entry_renamed` itself. UID injection (when the source lacks one) goes through `Envelope::IO::Writer#write` directly — no `Put` bypass.
75
174
 
76
- ## Refresh path (`ops.refresh(key)`)
175
+ ## Refresh path (`store.refresh(key)`)
77
176
 
78
- 1. CLI `Verb::Refresh` builds `ops = Operations.for(store, role: "runner")` then calls `ops.refresh(key)`.
79
- 2. `Refresh::Worker#run(key)`:
80
- - Resolves the manifest entry, looks up the intake handler via `@registry.rpc_callable(:resolve_intake, mentry.intake_handler)`.
81
- - Publishes `:refresh_started` with `role:` in the payload.
177
+ 1. CLI `Verb::Refresh` calls `store.refresh(key, role: "runner")`.
178
+ 2. `Write::RefreshWorker#run(key)`:
179
+ - Resolves the manifest entry, looks up the intake handler via `container.rpc.callable(:resolve_intake, mentry.handler)`.
180
+ - Publishes `:refresh_started` with the hook context.
82
181
  - Invokes the handler under a 30s thread-join deadline.
83
182
  - On any error: publishes `:refresh_failed`, then re-raises.
84
- - On success: applies `@authorizer.authorize_write!` and persists via `EnvelopeIO#write` directly (no `Put` round-trip); publishes `:entry_refreshed` unless etag is unchanged.
85
- 3. `ops.refresh_all(prefix:, zone:)` lists stale entries via `Reads::Stale` and runs `Worker#run` per entry; returns `{ refreshed:, failed:, skipped: }`.
183
+ - On success: applies `container.authorizer.authorize_write!` and persists via `Envelope::IO::Writer#write` directly (no `Put` round-trip); publishes `:entry_refreshed` unless etag is unchanged.
184
+ 3. `store.refresh_all(prefix:, zone:)` lists stale entries via `Read::Stale` and runs `Worker#run` per entry; returns `{ refreshed:, failed:, skipped: }`.
86
185
 
87
186
  ## Hook payload contract
88
187
 
89
- Hooks/intakes/transforms receive the actual `Textus::Store` (the composition root) as `store:`. Every write/refresh event payload carries `role:` directly so hook authors observe the actor without reaching through `store:`.
188
+ Pub-sub hooks (`:entry_put`, `:entry_refreshed`, …) 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.
189
+
190
+ RPC hooks (`:resolve_intake`, `:transform_rows`, `:validate`) receive `caps:` — a `Textus::Container`. They are gem-internal: the framework calls them, not user pub-sub.
191
+
192
+ ## Agent surface (boot + pulse + MCP)
193
+
194
+ Agents and plugins talk to a textus store through three layers:
195
+
196
+ ```
197
+ soul (skill/agent) ──▶ gate (CLI | MCP) ──▶ Store ──▶ memory (.textus/)
198
+ ```
199
+
200
+ Two transports, one façade:
201
+
202
+ - **CLI** — human/script surface. `textus boot`, `textus pulse --since=N`, `textus get/put/...`.
203
+ - **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, manifest_etag) is server-side.
204
+
205
+ Both transports call `store.<verb>(..., role:)` (or `store.as(role).<verb>(...)`). No duplicate logic.
206
+
207
+ The agent loop (cadence guide in `docs/agent-integration.md`):
208
+
209
+ 1. **Session start:** `boot()` → contract envelope (zones, entries, schemas, write_flows, agent_quickstart with `latest_seq`).
210
+ 2. **Per turn:** `pulse(since=cursor)` → `{cursor, changed, stale, pending_review, doctor}`.
211
+ 3. **On demand:** `get`, `put`, `propose`, `refresh`, `schema`, `rules`.
212
+
213
+ Manifest drift surfaces as `ContractDrift` (manifest_etag mismatch); audit cursor falls off the keep window as `CursorExpired`. Both signal "call `boot` again."
214
+
215
+ ## Hooks::EventBus event catalog
216
+
217
+ RPC (single handler, declares `caps:`):
218
+ - `resolve_intake(caps:, config:, args:)` — intake fetch handler.
219
+ - `transform_rows(caps:, rows:, config:)` — row transform for intakes.
220
+ - `validate(caps:)` — custom doctor validator.
221
+
222
+ Pub-sub (0..N handlers, declare `ctx:`):
223
+ - `entry_put(ctx:, key:, envelope:)`
224
+ - `entry_deleted(ctx:, key:)`
225
+ - `entry_refreshed(ctx:, key:, envelope:, change:)`
226
+ - `entry_renamed(ctx:, key:, from_key:, to_key:, envelope:)`
227
+ - `build_completed(ctx:, key:, envelope:, sources:)`
228
+ - `proposal_accepted(ctx:, key:, target_key:)`
229
+ - `proposal_rejected(ctx:, key:, target_key:)`
230
+ - `file_published(ctx:, key:, envelope:, source:, target:)`
231
+ - `store_loaded(ctx:)`
232
+ - `refresh_started(ctx:, key:, mode:)`
233
+ - `refresh_failed(ctx:, key:, error_class:, error_message:)`
234
+ - `refresh_backgrounded(ctx:, key:, started_at:, budget_ms:)`
235
+
236
+ Authoritative source: `lib/textus/hooks/event_bus.rb` `EVENTS`.
data/CHANGELOG.md CHANGED
@@ -9,6 +9,184 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
9
9
  bump is a breaking change that requires a store migration; the gem version
10
10
  tracks both additive improvements and breaking protocol bumps independently.
11
11
 
12
+ ## 0.29.0 — 2026-05-29
13
+
14
+ A domain-purity pass that routes all filesystem and wall-clock I/O through injected ports. Breaking changes are Ruby-API only; the wire format (`textus/3`) and CLI are unchanged.
15
+
16
+ ### Breaking
17
+
18
+ - `Domain::Staleness#initialize` now requires `file_stat:` and `clock:` (was `manifest:` only).
19
+ - `Domain::Staleness::IntakeCheck#initialize` now requires `file_stat:` and `clock:`.
20
+ - `Domain::Staleness::GeneratorCheck#initialize` now requires `file_stat:` (no clock — `GeneratorCheck` has no wall-clock dependency).
21
+ - `Domain::Sentinel` is now a pure value object. Its persistence class methods (`write!`, `load`, `sentinel_path`) and `SUFFIX`/`DIR` constants have moved to the new `Ports::SentinelStore`.
22
+ - `Domain::Sentinel#orphan?` and `#drift?` now take a `file_stat` argument.
23
+ - `Textus::Boot.run_via(container:, role:)` → `Textus::Boot.build(container:)` (the `role:` parameter was unused).
24
+ - `Textus::Doctor.run_via(container:, role:, checks:)` → `Textus::Doctor.build(container:, checks:)` (the `role:` parameter was unused).
25
+ - `RoleScope#boot` / `#doctor` are removed as special cases; `boot` and `doctor` are now entries in `Dispatcher::VERBS`. `store.boot`, `store.doctor`, and `store.as(role).boot` are unchanged.
26
+
27
+ ### Added
28
+
29
+ - `Ports::Storage::FileStat` — read-only filesystem query port (`exists?`, `directory?`, `read`, `mtime`, `glob`); the narrow interface pure domain logic depends on (distinct from the write-side `FileStore`).
30
+ - `Ports::SentinelStore` — sentinel persistence + path-layout adapter, extracted from `Domain::Sentinel`.
31
+ - `Read::Boot` and `Read::Doctor` — dispatched use-case classes on the uniform `(container:, call:)` shape.
32
+
33
+ ### Changed
34
+
35
+ - `manifest_etag` (in `pulse` output and the MCP session drift token) is now the system-standard `sha256:`-prefixed etag, computed via `FileStore#etag`, instead of a bare SHA-256 hex digest. The token is opaque (compared for equality, never parsed).
36
+
37
+ ### Internal
38
+
39
+ - The domain layer no longer performs direct filesystem or wall-clock I/O; all disk/clock access is routed through injected ports (`FileStat`, `Clock`). Enforced by a new `spec/domain_purity_spec.rb` that fails on any regression.
40
+ - Freshness request timestamps now originate from `Ports::Clock` (via `Call.build`) rather than a bare `Time.now`.
41
+ - Cosmetic refactors: deduped the audit limit guard; made `RefreshWorker.normalize_action_result` a public class method (dropped a `send`); extracted staleness guard helpers.
42
+ - New guard spec `spec/no_handrolled_manifest_etag_spec.rb` forbids `Digest::SHA256.hexdigest(File.read(...))` from reappearing in `lib/` (exempt: `etag.rb` and `sentinel_store.rb`, the latter being a wire-pinned integrity checksum, not an etag).
43
+ - See [ADR 0024](docs/architecture/decisions/0024-domain-purity-ports.md) for the design rationale.
44
+
45
+ ## 0.28.0 — 2026-05-29
46
+
47
+ A consistency-and-cleanup pass that finishes the seams [ADR 0022](docs/architecture/decisions/0022-container-call-dispatcher.md) left behind. Breaking changes are Ruby-API only.
48
+
49
+ ### Breaking
50
+
51
+ - Use-case constructors no longer accept `hook_context:`. Use cases that emit events derive their `Hooks::Context` internally from `(container, call)` via the new `Textus::Hooks::Context.for(container:, call:)` factory. Every use case now has the uniform shape `def initialize(container:, call:)`.
52
+ - `Textus::Envelope::IO::Writer` and `Textus::Write::RefreshOrchestrator` constructors take `call:` instead of `ctx:` (both received a `Call` already; the kwarg name is corrected).
53
+ - `Read::Audit#call` now accepts filter keywords and builds a `Read::Audit::Query` value object internally — keyword callers (`store.audit(key:, limit:)`) are unchanged.
54
+ - `Builder::Pipeline.run` takes `(mentry:, deps:)` where `deps` is a `Builder::Pipeline::Deps` record, instead of eight loose keyword collaborators.
55
+ - Removed the `CLI::VERBS` const-missing shim (use `CLI.verbs`).
56
+ - Removed the `Manifest::Entry::PUBLISH_EACH_VARS` / `PUBLISH_EACH_VAR_RE` re-exports (use `Manifest::Entry::Validators::PublishEach::KNOWN_VARS` / `::VAR_RE`).
57
+
58
+ ### Internal
59
+
60
+ - Removed the runtime `initialize`-parameter reflection from both `RoleScope` and `Doctor::Check`; verb dispatch is now an unconditional `klass.new(container:, call:).call(...)`.
61
+ - `Lint/UnusedMethodArgument` disables dropped from 27 to 20; two `Metrics/ParameterLists` (and two complexity) disables removed by the value-object refactors. `Metrics/ParameterLists` ceiling documented and kept at `Max 6` (the honest ceiling for value-object constructors, `AuditLog#append`, and the public `put` API).
62
+ - `ARCHITECTURE.md`'s "uniform `(container:, call:)`" claim is now accurate; active docs refreshed to the 0.27/0.28 vocabulary.
63
+ - No wire-format change. Protocol stays at `textus/3`. CLI verb signatures unchanged. Hook callable surfaces (`ctx:` for pub-sub, `caps:` for RPC) unchanged.
64
+ - See [ADR 0023](docs/architecture/decisions/0023-uniform-use-case-shape.md) for the design rationale.
65
+
66
+ ## 0.27.0 — 2026-05-29
67
+
68
+ ### Breaking
69
+
70
+ - Removed `Textus::Session`. Use `store.as(role).put(...)` or `store.put(..., role:)` instead of `store.session(role:).put(...)`.
71
+ - Removed `Textus::Application::UseCase` registry. Verb dispatch is now via the static `Textus::Dispatcher::VERBS` table.
72
+ - Replaced `Textus::Application::ReadCaps` / `WriteCaps` / `HookCaps` with a single `Textus::Container` record (field names preserved: `manifest`, `file_store`, `schemas`, `root`, `audit_log`, `events`, `rpc`, `authorizer`).
73
+ - Renamed `Textus::Application::Context` to `Textus::Call`. Field shape identical.
74
+ - Use-case classes are no longer `module Foo; def self.call; Impl.new(...).call; end`. They are plain classes: `class Foo; def initialize(container:, call:); def call(...); end`.
75
+ - Flattened `Textus::Application::Write::*` → `Textus::Write::*`, `Application::Read::*` → `Read::*`, `Application::Envelope::*` → `Envelope::IO::*`, `Application::Maintenance::*` → `Maintenance::*`, `Application::Projection` → `Projection`.
76
+ - Renamed `Textus::Infra::*` → `Textus::Ports::*`.
77
+ - `Manifest::Entry::Base#zone_writers` / `#in_generator_zone?` / `#in_proposal_zone?` now take an explicit `policy` argument; entries no longer carry an `@manifest` back-reference.
78
+ - `PublishContext` shrunk from 12 fields to `(container, call, reader)` with derived accessors. Custom derived entries that destructured `pctx.caps` / `pctx.session` / `pctx.ctx` / `pctx.bus` need to use `pctx.container` / construct a `RoleScope` / `pctx.call` / `pctx.events`.
79
+ - Hook RPC callables (`:resolve_intake`, `:transform_rows`, `:validate`) receive `caps: container` (a `Textus::Container`) instead of `caps: <WriteCaps>`. Field names preserved, so handlers reading `caps.manifest` / `caps.events` / etc. continue to work.
80
+
81
+ ### Internal
82
+
83
+ - ~600 LOC removed net across ~60 files.
84
+ - No wire-format change. Protocol stays at `textus/3`.
85
+ - CLI verb signatures unchanged. No envelope shape changes.
86
+ - See [ADR 0022](docs/architecture/decisions/0022-container-call-dispatcher.md) for the design rationale.
87
+
88
+ ## 0.26.0 — 2026-05-28
89
+
90
+ ### Breaking
91
+ - Split `Textus::Hooks::Bus` into `Textus::Hooks::EventBus` (pubsub) and `Textus::Hooks::RpcRegistry` (named callables). The `Hooks::Bus` constant is removed.
92
+ - Replaced `Textus::Application::Ports` with three capability records: `Textus::Application::ReadCaps`, `WriteCaps`, `HookCaps`.
93
+ - Renamed `Textus::Operations` to `Textus::Session`. Access via `store.session(role:)`. `Operations.for(store, ...)` is removed.
94
+ - Hook RPC callables (`resolve_intake`, `transform_rows`, `validate`) no longer accept `store:` — declare `caps:` (a `WriteCaps` for `resolve_intake`/`validate`, `ReadCaps` for `transform_rows`).
95
+ - Removed all `Manifest` top-level deprecation shims (`zones`, `entries`, `zone_writers`, `permission_for`, etc.). Use `manifest.data.*` / `manifest.policy.*` / `manifest.resolver.*` / `manifest.rules.*`.
96
+ - Moved `Textus::Application::Writes::EnvelopeReader`/`EnvelopeWriter` to `Textus::Application::Envelope::Reader`/`Writer`.
97
+ - Renamed `Textus::Application::Writes` → `Textus::Application::Write`; `Textus::Application::Reads` → `Textus::Application::Read`; `Textus::Application::Restructure` → `Textus::Application::Maintenance`.
98
+ - Merged `Textus::Application::Refresh::*` into `Textus::Application::Write::Refresh{Worker,Orchestrator,All}`.
99
+ - Moved `Textus::Application::Policy::Promotion` and predicates to `Textus::Domain::Policy::Promotion`/`Predicates`.
100
+
101
+ ## 0.25.1 — 2026-05-28
102
+
103
+ ### Internal refactors
104
+
105
+ - **ADR 0018**: `Manifest` is now a composition record over `Data`,
106
+ `Resolver`, `Policy`, `Rules`. Top-level methods like
107
+ `Manifest#permission_for` are deprecated; use
108
+ `manifest.policy.permission_for(zone)`. One-cycle bridge — shims
109
+ warn until 0.26.0.
110
+
111
+ - **ADR 0016**: Application use cases take a single `ports:` kwarg
112
+ bundling six adapters + the store root. Hook DSL callables that
113
+ declare `|store:|` continue to work with a one-shot deprecation
114
+ warning per (event, hook_name); declare `|ports:|` to silence it.
115
+
116
+ - **ADR 0017**: `Application::Writes::EnvelopeIO` split into
117
+ `EnvelopeReader` (parse) and `EnvelopeWriter` (put/delete/move
118
+ + audit). Every public `EnvelopeWriter` method now ends with an
119
+ audit-row append — the write-without-audit failure mode is gone.
120
+
121
+ ### Breaking (internal)
122
+
123
+ - `Operations#store` accessor removed. There is no clean deprecation
124
+ shim because `Ports` cannot reconstruct a `Store`. External
125
+ callers should use `ops.ports.X` directly.
126
+
127
+ - `Textus::Manifest::Entry::Base::PublishContext` struct shape
128
+ changed: `:store` removed, `:ports` + `:boot` added. Affects
129
+ third-party plugins that build custom derived entries.
130
+
131
+ - `transform_context` passed to `transform_rows` RPC callables is
132
+ now an `Application::Ports`, not a `Store`. Transforms that treat
133
+ it as opaque continue to work; transforms that reach `.x` need
134
+ updates.
135
+
136
+ No CLI verb signatures changed. No wire envelopes changed.
137
+ Protocol remains `textus/3`.
138
+
139
+ ## 0.25.0 — 2026-05-28
140
+
141
+ ### Added (additive — backward-compatible pulse fields)
142
+ - `pulse.manifest_etag` — sha256 of `manifest.yaml`; lets agents detect contract drift without a second verb.
143
+ - `pulse.next_due_at` — soonest `next_due_at` across all entries with a refresh policy. Schedulers sleep until this timestamp instead of polling.
144
+ - `pulse.hook_errors` — recent hook failures since cursor; bounded in-memory ring on `Hooks::Bus#error_log` (default 256).
145
+
146
+ ### Changed
147
+ - `Application::Reads::Freshness` memoizes the evaluator verdict by `(key, last_refreshed_at)` per request — pulse no longer pays O(N) evaluator calls when nothing has changed.
148
+ - `Application::Refresh::Orchestrator` gains a cooperative-cancel fallback for `RefreshTimed` when `fork(2)` is unavailable (Windows). Previously degraded to `Failed("timed_sync requires fork")`; now executes within the budget on a Thread, killing it on budget exceeded.
149
+
150
+ ### Protocol
151
+ - No wire-format change. `textus/3` envelopes are unchanged. Pulse fields are additive — existing consumers ignoring unknown keys continue to work.
152
+
153
+ ## 0.24.0 — 2026-05-28
154
+
155
+ ### Added
156
+ - **Context-structure ergonomics** (ADR 0015 Phase 2):
157
+ - `textus key mv --prefix OLD NEW` — bulk rename leaves under a prefix; preserves UIDs.
158
+ - `textus key delete --prefix P` — bulk delete leaves.
159
+ - `textus zone mv FROM TO` — rename a zone; refuses if destination exists; rewrites manifest + moves files.
160
+ - `textus rule lint --against=FILE` — diff candidate manifest YAML's `rules:` block against the live manifest.
161
+ - `textus migrate PLAN.yaml` — run a multi-op declarative migration plan (ops: `key_mv_prefix`, `key_delete_prefix`, `zone_mv`).
162
+ - All five operations also surface as MCP tools (`key_mv_prefix`, `key_delete_prefix`, `zone_mv`, `rule_lint`, `migrate`).
163
+ - `Textus::Application::Restructure` module with `Plan` value object and one use case per operation.
164
+
165
+ ### Protocol
166
+ - No wire-format change. `textus/3` envelopes are unchanged.
167
+
168
+ ## 0.23.0 — 2026-05-28
169
+
170
+ ### Added
171
+ - **Agent gate (MCP transport).** `textus mcp serve` — stdio JSON-RPC 2.0
172
+ server speaking MCP draft 2024-11-05. Wraps `Textus::Operations` as ten
173
+ auto-derived tools (`boot`, `tick`, `find`, `read`, `write`, `propose`,
174
+ `refresh`, `refresh_stale`, `schema`, `rules`). Session state (cursor,
175
+ role, manifest_etag) held server-side. Manifest drift surfaces as
176
+ `ContractDrift` (-32001); cursor expiry as `CursorExpired` (-32002).
177
+ See [`docs/mcp.md`](docs/mcp.md) and [ADR 0015](docs/architecture/decisions/0015-agent-gate-mcp.md).
178
+ - `examples/claude-plugin/.mcp.json` and migrated skills/commands/agents —
179
+ zero `textus <verb>` shell strings remain in plugin markdown.
180
+
181
+ ### Changed (docs)
182
+ - `ARCHITECTURE.md`: fixed stale `registry` references (now `bus`),
183
+ added Agent Surface section and complete Hooks::Bus event catalog.
184
+ - `docs/agent-integration.md`: documents three transports (CLI, Ruby API,
185
+ MCP); points agent authors at the MCP transport by default.
186
+
187
+ ### Protocol
188
+ - No wire-format change. `textus/3` envelopes are unchanged.
189
+
12
190
  ## 0.22.0 — 2026-05-28
13
191
 
14
192
  ### Changed (internal — no manifest-schema impact)
data/README.md CHANGED
@@ -5,20 +5,62 @@
5
5
  [![Ruby](https://img.shields.io/badge/ruby-%E2%89%A53.3-CC342D.svg)](https://www.ruby-lang.org/)
6
6
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
7
 
8
- A context store for codebases that humans and AI agents both have to read and write. Dotted keys, schema-validated entries, role-gated writes, byte-copy publish, an audit log of every change. Built so an agent landing in your repo can run one command (`textus boot`) and know what to read, what to write, and what's off-limits.
8
+ **Durable, multi-writer context for codebases that humans and AI agents both touch.** Your agent forgets everything between sessions; your runbooks and `CLAUDE.md` get edited by whoever ran last; nobody can reconstruct who wrote what. textus is the memory that survives the model, the session, and the vendor a shared workspace where humans, agents, and runners write into separate lanes, propose changes through a review queue, and leave an audit trail behind every byte.
9
9
 
10
- Reference implementation in Ruby. Wire format `textus/3`. SPEC: [`SPEC.md`](SPEC.md). Implementation notes: [`docs/`](docs/).
10
+ *textus* is Latin for "the fabric a text is woven from" — same root as *context*, from *con-texere*, "to weave together." The protocol weaves human edits, agent proposals, and runner intake into one durable fabric. The shape of that fabric is yours; the rules for writing into it are textus's.
11
11
 
12
- ## Versioning
12
+ ## The idea
13
13
 
14
- Two versions, deliberately independent:
14
+ Three actors write to your repo today:
15
15
 
16
- - **Protocol wire string:** `textus/3`. Breaking changes require `textus/4`.
17
- - **Gem version:** semver, currently `0.18.0`. Decoupled from the protocol string internal refactors bump the gem; only wire-format changes bump the protocol.
16
+ - **Humans** you, your team. Authoritative on identity, decisions, voice.
17
+ - **Agents** Claude, Cursor, custom assistants. Smart, fast, forgetful, and not always right.
18
+ - **Runners** — cron jobs, fetchers, CI. Bring outside data in.
18
19
 
19
- Envelope payloads carry the `protocol` field. The gem version is irrelevant to the wire format.
20
+ Without coordination, they overwrite each other and nothing remembers why. textus gives each actor a **lane** (a zone), routes everything they can't write directly through a **review queue**, and writes every successful change to an **append-only audit log**. The lanes are enforced at the protocol level, not by convention.
20
21
 
21
- See [CHANGELOG.md](CHANGELOG.md) for per-release notes.
22
+ ```
23
+ identity/ human only — who you are, what you decide, how you sound
24
+ working/ human + agent + runner — day-to-day catalog
25
+ intake/ runner only — declared external inputs
26
+ review/ agent + human — proposals waiting on a human accept
27
+ output/ builder only — computed, published artifacts
28
+ ```
29
+
30
+ An agent that tries to write directly into `working/` or `identity/` gets `write_forbidden`. It writes to `review/` instead. You accept the good proposals; textus promotes them, records the move, and audits both halves. Stable per-entry `uid:` means a reorganization doesn't break references. A monotonic audit cursor (`textus pulse --since=N`) means the next session — possibly a different agent, possibly a different model — picks up exactly where the last one left off.
31
+
32
+ That's the load-bearing claim: **coordination is a protocol invariant, not a library convenience.**
33
+
34
+ ## See it in four commands
35
+
36
+ ```sh
37
+ gem install textus
38
+ textus init # creates .textus/ with zones + schemas
39
+ # agent proposes a change to review/
40
+ echo '{"_meta":{"name":"oncall","proposal":{"target_key":"working.notes.oncall","action":"put"}},"body":"Patrick on call.\n"}' \
41
+ | textus put review.notes.oncall --as=agent --stdin
42
+ # you accept it — textus promotes to working/ and audits the move
43
+ textus accept review.notes.oncall --as=human
44
+ ```
45
+
46
+ Try the gate the other way (`textus put working.notes.X --as=agent`) and you get `write_forbidden`, with the role that *would* be allowed named in the error. That refusal is the whole point.
47
+
48
+ ## Try it
49
+
50
+ - **5-command worked demo** — single terminal scroll, no MCP, no schemas: [`examples/hello/`](examples/hello/)
51
+ - **Wire textus into Claude Code via MCP** — 4 steps, ~5 minutes: [`INTEGRATE_WITH_CLAUDE.md`](INTEGRATE_WITH_CLAUDE.md)
52
+ - **Use textus as your own project's context store**: [`examples/project/`](examples/project/)
53
+ - **Use textus to author a Claude plugin** (textus is the source-of-truth, build publishes to `agents/`, `skills/`, `commands/`): [`examples/claude-plugin/`](examples/claude-plugin/)
54
+
55
+ ## Protocol, not just a gem
56
+
57
+ This Ruby gem is the reference implementation of **`textus/3`** — a wire format and storage convention any language can speak. The protocol owns the envelope shape, the role/zone gate, the audit log format, and the key grammar. The gem version (semver, see badge) and the protocol version (`textus/3`) move independently; envelopes carry the `protocol` field so consumers can pin to the contract, not the implementation.
58
+
59
+ - Specification: [`SPEC.md`](SPEC.md)
60
+ - Architecture: [`ARCHITECTURE.md`](ARCHITECTURE.md)
61
+ - Per-release notes: [`CHANGELOG.md`](CHANGELOG.md)
62
+
63
+ A second implementation in another language would share the same `.textus/` directory and the same audit log. That's deliberate.
22
64
 
23
65
  ## Install
24
66
 
@@ -75,15 +117,15 @@ textus audit --limit=20 # query the audit log
75
117
 
76
118
  For the full shape — Claude plugin with agents, skills, commands, pending walkthrough, intake action — see [`examples/claude-plugin/`](examples/claude-plugin/).
77
119
 
78
- ## What ships today
120
+ ## What's shipped
79
121
 
80
122
  - **Per-entry formats.** `format: markdown | json | yaml | text` on a manifest entry. `cat .textus/zones/output/marketplace.json | jq .` works without going through textus — the in-store file *is* the consumer-shaped artifact. Structured outputs carry `_meta` at the top level (`generated_at`, `from`, `template`, `transform`).
81
123
  - **Per-leaf publishing.** Nested entries declare `publish_each: "skills/{basename}/SKILL.md"`. Every leaf byte-copies to its consumer location on `textus build`. No more hand-mirrored `agents/` / `skills/` / `commands/` directories.
82
- - **Split build/publish (v0.14.0).** `Application::Writes::Build` materializes generator-zone entries; `Application::Writes::Publish` copies nested leaves to `publish_each` targets. The `textus build` CLI verb calls both and merges results, so wire output is unchanged.
83
- - **Typed envelopes (v0.14.0).** `Textus::Envelope` is a `Data.define` value object with typed accessors (`.meta`, `.body`, `.etag`, `.uid`, `.freshness`, …). Ruby API callers get IDE help and `NoMethodError` on typos. The CLI JSON wire format is preserved byte-for-byte via `envelope.to_h_for_wire`.
124
+ - **Build and publish in one pass.** `Textus::Write::Publish` materializes generator-zone entries and copies nested leaves to their `publish_each` targets. The `textus build` CLI verb dispatches to it; the wire envelope is unchanged.
125
+ - **Typed envelopes.** `Textus::Envelope` is a `Data.define` value object with typed accessors (`.meta`, `.body`, `.etag`, `.uid`, `.freshness`, …). Ruby API callers get IDE help and `NoMethodError` on typos. The CLI JSON wire format is preserved byte-for-byte via `envelope.to_h_for_wire`.
84
126
  - **Stable identity (`uid:`).** 16-char hex, auto-minted on first `put`, preserved across writes and moves. `textus key mv old.key new.key` renames in place — uid survives, audit row records `from_key`, `to_key`, `uid`. Reorganising a tree no longer breaks references.
85
127
  - **Strict key grammar.** `/^[a-z0-9][a-z0-9-]*$/`, max 8 segments × 64 chars. `textus key normalize --dry-run|--write` rewrites existing stores with illegal segments deterministically.
86
- - **`textus boot`.** One-shot store orientation: zones with writers + purposes, entry families with schemas and publish targets, loaded hooks, write flows per role, the full CLI verb table, and an `agent_quickstart` block (read/write verbs, writable zones, propose zone, latest audit seq). The boot signal for any agent — one tool call and it knows your store.
128
+ - **`textus boot`.** One-shot store orientation: zones with writers + purposes, entry families with schemas and publish targets, loaded hooks, write flows per role, the full CLI verb table, and an `agent_quickstart` block (read/write verbs, writable zones, propose zone, latest audit seq).
87
129
  - **`textus pulse [--since=N]`.** Per-turn heartbeat for agents: changed entries since cursor N, stale keys, pending review proposals, and a doctor summary. Cursor is a monotonic seq stamped on every audit row; rotation keeps the last 5 files (configurable via `audit:` in the manifest) and raises `CursorExpired` when the requested cursor has fallen off disk.
88
130
  - **`textus doctor`.** Health check across 9 categories: missing schemas/templates, broken hooks, illegal nested keys, sentinel drift, audit log readability, unowned schema fields, schema violations, and missing manifest files. Returns `ok: true` only when nothing is wrong; warnings and info don't flip the bit.
89
131
  - **Actionable hints on every error.** `UnknownKey` carries ranked "did you mean" suggestions. `WriteForbidden` names the role that *would* be allowed. `BadFrontmatter` tells you exactly what to rename. Printed to stderr alongside the JSON envelope on stdout.
@@ -163,7 +205,7 @@ See [`docs/agent-integration.md`](docs/agent-integration.md) for the agent boot
163
205
  bundle exec rspec
164
206
  ```
165
207
 
166
- ~637 examples; includes conformance fixtures A–I from SPEC §12.
208
+ ~880 examples; includes conformance fixtures A–I from SPEC §12.
167
209
 
168
210
  ## Code quality
169
211