textus 0.26.0 → 0.30.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 (157) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +118 -68
  3. data/CHANGELOG.md +132 -0
  4. data/README.md +61 -19
  5. data/SPEC.md +107 -46
  6. data/docs/conventions.md +4 -4
  7. data/lib/textus/boot.rb +18 -12
  8. data/lib/textus/builder/pipeline.rb +13 -12
  9. data/lib/textus/call.rb +28 -0
  10. data/lib/textus/cli/verb/audit.rb +1 -1
  11. data/lib/textus/cli/verb/boot.rb +1 -1
  12. data/lib/textus/cli/verb/build.rb +2 -2
  13. data/lib/textus/cli/verb/doctor.rb +1 -1
  14. data/lib/textus/cli/verb/hook_run.rb +2 -6
  15. data/lib/textus/cli/verb/put.rb +5 -14
  16. data/lib/textus/cli/verb/retain.rb +19 -0
  17. data/lib/textus/cli/verb/rule_list.rb +1 -1
  18. data/lib/textus/cli/verb.rb +6 -6
  19. data/lib/textus/cli.rb +19 -23
  20. data/lib/textus/container.rb +23 -0
  21. data/lib/textus/dispatcher.rb +57 -0
  22. data/lib/textus/doctor/check/audit_log.rb +1 -1
  23. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  24. data/lib/textus/doctor/check/sentinels.rb +10 -8
  25. data/lib/textus/doctor/check.rb +15 -5
  26. data/lib/textus/doctor.rb +7 -7
  27. data/lib/textus/domain/authorizer.rb +2 -2
  28. data/lib/textus/domain/duration.rb +22 -0
  29. data/lib/textus/domain/policy/refresh.rb +1 -15
  30. data/lib/textus/domain/policy/retention.rb +26 -0
  31. data/lib/textus/domain/retention.rb +44 -0
  32. data/lib/textus/domain/sentinel.rb +9 -65
  33. data/lib/textus/domain/staleness/generator_check.rb +46 -26
  34. data/lib/textus/domain/staleness/intake_check.rb +18 -10
  35. data/lib/textus/domain/staleness.rb +3 -3
  36. data/lib/textus/{application/envelope → envelope/io}/reader.rb +6 -2
  37. data/lib/textus/{application/envelope → envelope/io}/writer.rb +19 -11
  38. data/lib/textus/hooks/context.rb +30 -13
  39. data/lib/textus/hooks/event_bus.rb +8 -20
  40. data/lib/textus/hooks/rpc_registry.rb +9 -35
  41. data/lib/textus/hooks/signature.rb +31 -0
  42. data/lib/textus/init.rb +7 -6
  43. data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
  44. data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
  45. data/lib/textus/maintenance/migrate.rb +51 -0
  46. data/lib/textus/maintenance/rule_lint.rb +56 -0
  47. data/lib/textus/maintenance/zone_mv.rb +51 -0
  48. data/lib/textus/maintenance.rb +15 -0
  49. data/lib/textus/manifest/data.rb +9 -4
  50. data/lib/textus/manifest/entry/base.rb +38 -18
  51. data/lib/textus/manifest/entry/derived.rb +6 -6
  52. data/lib/textus/manifest/entry/nested.rb +7 -9
  53. data/lib/textus/manifest/entry/parser.rb +2 -2
  54. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  55. data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
  56. data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
  57. data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
  58. data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
  59. data/lib/textus/manifest/entry/validators.rb +2 -2
  60. data/lib/textus/manifest/entry.rb +0 -5
  61. data/lib/textus/manifest/policy.rb +34 -7
  62. data/lib/textus/manifest/rules.rb +10 -1
  63. data/lib/textus/manifest/schema.rb +54 -4
  64. data/lib/textus/manifest.rb +4 -8
  65. data/lib/textus/mcp/server.rb +2 -11
  66. data/lib/textus/mcp/session.rb +13 -20
  67. data/lib/textus/mcp/tools.rb +2 -2
  68. data/lib/textus/mcp.rb +1 -1
  69. data/lib/textus/{infra → ports}/audit_log.rb +1 -1
  70. data/lib/textus/{infra → ports}/audit_subscriber.rb +2 -2
  71. data/lib/textus/{infra → ports}/build_lock.rb +1 -1
  72. data/lib/textus/{infra → ports}/clock.rb +1 -1
  73. data/lib/textus/{infra → ports}/publisher.rb +6 -6
  74. data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
  75. data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
  76. data/lib/textus/ports/sentinel_store.rb +67 -0
  77. data/lib/textus/ports/storage/file_stat.rb +19 -0
  78. data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
  79. data/lib/textus/projection.rb +91 -0
  80. data/lib/textus/read/audit.rb +111 -0
  81. data/lib/textus/read/blame.rb +81 -0
  82. data/lib/textus/read/boot.rb +18 -0
  83. data/lib/textus/read/deps.rb +24 -0
  84. data/lib/textus/read/doctor.rb +19 -0
  85. data/lib/textus/read/freshness.rb +101 -0
  86. data/lib/textus/read/get.rb +66 -0
  87. data/lib/textus/read/get_or_refresh.rb +69 -0
  88. data/lib/textus/read/list.rb +15 -0
  89. data/lib/textus/read/policy_explain.rb +42 -0
  90. data/lib/textus/read/published.rb +15 -0
  91. data/lib/textus/read/pulse.rb +89 -0
  92. data/lib/textus/read/rdeps.rb +25 -0
  93. data/lib/textus/read/retainable.rb +17 -0
  94. data/lib/textus/read/schema_envelope.rb +16 -0
  95. data/lib/textus/read/stale.rb +17 -0
  96. data/lib/textus/read/uid.rb +20 -0
  97. data/lib/textus/read/validate_all.rb +22 -0
  98. data/lib/textus/read/validator.rb +84 -0
  99. data/lib/textus/read/where.rb +16 -0
  100. data/lib/textus/role_scope.rb +50 -0
  101. data/lib/textus/schema/tools.rb +3 -3
  102. data/lib/textus/store.rb +16 -7
  103. data/lib/textus/version.rb +1 -1
  104. data/lib/textus/write/accept.rb +86 -0
  105. data/lib/textus/write/authority_gate.rb +24 -0
  106. data/lib/textus/write/delete.rb +40 -0
  107. data/lib/textus/write/intake_fetch.rb +23 -0
  108. data/lib/textus/write/materializer.rb +48 -0
  109. data/lib/textus/write/mv.rb +113 -0
  110. data/lib/textus/write/publish.rb +66 -0
  111. data/lib/textus/write/put.rb +45 -0
  112. data/lib/textus/write/refresh_all.rb +44 -0
  113. data/lib/textus/write/refresh_orchestrator.rb +102 -0
  114. data/lib/textus/write/refresh_worker.rb +124 -0
  115. data/lib/textus/write/reject.rb +54 -0
  116. data/lib/textus/write/retention_sweep.rb +55 -0
  117. data/lib/textus.rb +1 -2
  118. metadata +62 -50
  119. data/lib/textus/application/caps.rb +0 -49
  120. data/lib/textus/application/context.rb +0 -34
  121. data/lib/textus/application/maintenance/key_delete_prefix.rb +0 -44
  122. data/lib/textus/application/maintenance/key_mv_prefix.rb +0 -57
  123. data/lib/textus/application/maintenance/migrate.rb +0 -59
  124. data/lib/textus/application/maintenance/rule_lint.rb +0 -65
  125. data/lib/textus/application/maintenance/zone_mv.rb +0 -60
  126. data/lib/textus/application/maintenance.rb +0 -17
  127. data/lib/textus/application/projection.rb +0 -93
  128. data/lib/textus/application/read/audit.rb +0 -106
  129. data/lib/textus/application/read/blame.rb +0 -91
  130. data/lib/textus/application/read/deps.rb +0 -34
  131. data/lib/textus/application/read/freshness.rb +0 -110
  132. data/lib/textus/application/read/get.rb +0 -75
  133. data/lib/textus/application/read/get_or_refresh.rb +0 -63
  134. data/lib/textus/application/read/list.rb +0 -25
  135. data/lib/textus/application/read/policy_explain.rb +0 -47
  136. data/lib/textus/application/read/published.rb +0 -25
  137. data/lib/textus/application/read/pulse.rb +0 -101
  138. data/lib/textus/application/read/rdeps.rb +0 -35
  139. data/lib/textus/application/read/schema_envelope.rb +0 -26
  140. data/lib/textus/application/read/stale.rb +0 -23
  141. data/lib/textus/application/read/uid.rb +0 -30
  142. data/lib/textus/application/read/validate_all.rb +0 -32
  143. data/lib/textus/application/read/validator.rb +0 -86
  144. data/lib/textus/application/read/where.rb +0 -26
  145. data/lib/textus/application/use_case.rb +0 -22
  146. data/lib/textus/application/write/accept.rb +0 -102
  147. data/lib/textus/application/write/authority_gate.rb +0 -26
  148. data/lib/textus/application/write/delete.rb +0 -45
  149. data/lib/textus/application/write/materializer.rb +0 -49
  150. data/lib/textus/application/write/mv.rb +0 -118
  151. data/lib/textus/application/write/publish.rb +0 -96
  152. data/lib/textus/application/write/put.rb +0 -49
  153. data/lib/textus/application/write/refresh_all.rb +0 -63
  154. data/lib/textus/application/write/refresh_orchestrator.rb +0 -102
  155. data/lib/textus/application/write/refresh_worker.rb +0 -134
  156. data/lib/textus/application/write/reject.rb +0 -62
  157. data/lib/textus/session.rb +0 -84
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e822d94de7ae06481dd8c03eab813a097b62e78121d94c9d1045017675da665c
4
- data.tar.gz: 3372f74bb31465b28be8e6ce5b4721a3f15cae7536ebdfc8ef9b6c6d7c9f2d88
3
+ metadata.gz: d84508fac499044df50d5fa793000fbd2249732c6b867f1b0f88535bfab4f083
4
+ data.tar.gz: 5069218d7c3c1a8360f4507bb99f94951e92a7d345ba4fb44ee72819fb4739e4
5
5
  SHA512:
6
- metadata.gz: 31e4b83f9a7d3b061d043c447393b23ba404a47e7d79b0a411ad49719625d01ec30116a067f28bf88ac6944ff0262c1825d35c851022340d63b813dca26f98aa
7
- data.tar.gz: 590cc3419cb7823688bab11c4fce49a011bf62b463ab887b415753dd3c8037cd0704b8ccc4df6e778b21c04bd8101847a28fa5d4819e447e7af3d44febc017f3
6
+ metadata.gz: abf8e5e74b77227f34bbc95dd38c1e9b8d716f56f1cda11664f3da284f417d8581a51e4c27accdcb88173c274ff1aa929443bb027b851a95cbced029fdf34852
7
+ data.tar.gz: bd63c95ae46643c063c1b035fc859957418f185795fc32b54f798b44779d825101d9f71667a85f518dacab6ffbcebe70bc5676c9ae24e80cc9de4fa44e38aa38
data/ARCHITECTURE.md CHANGED
@@ -2,31 +2,29 @@
2
2
 
3
3
  ```
4
4
  ┌─ Interface ────────────────────────────────────────────────┐
5
- │ CLI verbs: session = Session.for(store, role:)
6
- session.<name>(...) # one method per
7
- │ # registered use case
8
- │ # (put/get/refresh/…) │
5
+ │ CLI verbs: store.<verb>(..., role:)
6
+ store.as(role).<verb>(...)
7
+ │ # (put/get/refresh/…)
9
8
  │ │
10
9
  │ MCP gate: textus mcp serve — same use cases, JSON-RPC. │
11
10
  └──────────────────────┬─────────────────────────────────────┘
12
11
 
13
12
  ┌─ Application ────────▼─────────────────────────────────────┐
14
- Context (slim Data: role, correlation_id, now, │
13
+ Call (slim Data: role, correlation_id, now, │
15
14
  │ dry_run — request state only) │
16
- Caps (Read/Write/Hook recordsstore slices) │
17
- Session (per-call dispatch; methods generated
18
- from UseCase registry)
19
- │ UseCase (registry: verb → module, caps_kind) │
15
+ Container (single recordwired ports + manifest) │
16
+ Dispatcher (static VERBS table: verb → use-case)
17
+ RoleScope (Store#as(role) forwards verb calls)
20
18
  │ │
21
19
  │ read/{get,get_or_refresh,list,where,uid,schema_envelope, │
22
- │ deps,rdeps,published,stale,validate_all,
20
+ │ deps,rdeps,published,stale,validate_all,boot,doctor,│
23
21
  │ freshness,audit,blame,policy_explain,pulse}.rb │
24
22
  │ write/{put,delete,mv,accept,reject,publish, │
25
23
  │ materializer,authority_gate, │
26
24
  │ refresh_worker,refresh_orchestrator,refresh_all} │
27
25
  │ maintenance/{migrate,key_mv_prefix,key_delete_prefix, │
28
26
  │ zone_mv,rule_lint}.rb │
29
- │ envelope/{reader,writer}.rb (split: parse vs persist)
27
+ │ envelope/io/{reader,writer}.rb (split: parse vs persist)
30
28
  │ projection.rb │
31
29
  └──────────┬───────────────────────────────┬─────────────────┘
32
30
  │ uses domain │ uses ports
@@ -42,115 +40,165 @@
42
40
  │ implements
43
41
  ┌─ Infrastructure ─────────────────────────▼─────────────────┐
44
42
  │ Store (composition root — wires ports, │
45
- │ vends Sessions)
43
+ │ vends a Container + dispatches verbs)
46
44
  │ Storage::FileStore (bytes-only port: read/write/delete/ │
47
45
  │ exists?/etag) │
48
46
  │ Manifest (Data, Resolver, Policy, Rules) │
49
47
  │ Schemas (eager-load cache) │
50
- Infra::{AuditLog,AuditSubscriber,Publisher,Clock, │
48
+ Ports::{AuditLog,AuditSubscriber,Publisher,Clock, │
51
49
  │ Refresh::Lock,Refresh::Detached,BuildLock} │
52
50
  │ Hooks::{EventBus,RpcRegistry,Loader,Context,FireReport, │
53
- │ Builtin,ErrorLog}
51
+ Signature,Builtin,ErrorLog}
54
52
  │ Entry::{Markdown,Json,Yaml,Text} (format strategies) │
55
53
  └────────────────────────────────────────────────────────────┘
56
54
 
57
- Dependency rule: arrows point DOWN. Domain has zero outbound
58
- imports. Application imports Domain + Infra (via ports).
59
- Use cases declare their real collaborators in their Impl
60
- constructor; UseCase.register hooks them into Session.
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.
61
63
  ```
62
64
 
63
65
  ## How a verb becomes a method
64
66
 
65
- Each application use case is a module under `lib/textus/application/{read,write,maintenance}/`. The shape is uniform:
67
+ Each application use case is a plain class under `lib/textus/{read,write,maintenance}/`. The shape is uniform:
66
68
 
67
69
  ```ruby
68
70
  module Textus
69
- module Application
70
- module Read
71
- module Get
72
- def self.call(*, session:, ctx:, caps:, **)
73
- Impl.new(ctx: ctx, caps: caps).call(*, **)
74
- end
75
-
76
- class Impl
77
- def initialize(ctx:, caps:, ...)
78
- @ctx = ctx; @manifest = caps.manifest; ...
79
- end
80
-
81
- def call(key) ... end
82
- end
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
+ ...
83
80
  end
84
81
  end
85
82
  end
86
83
  end
87
-
88
- Textus::Application::UseCase.register(:get, Textus::Application::Read::Get, caps: :read)
89
84
  ```
90
85
 
91
- `Session` generates one dispatch method per registered entry (see `lib/textus/session.rb` the `Application::UseCase.each do |entry| ... end` block at the bottom). Adding a new verb is **one `UseCase.register` line** plus the module — no edits to `Session`.
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.
92
87
 
93
- Two collaborators live outside the registry because they're composed by other use cases, not invoked as verbs:
88
+ The instantiate-and-call step itself has one home: `Dispatcher.invoke(verb, container:, call:, args:, kwargs:)` (ADR 0026). `RoleScope` builds the `Call` (request state) and delegates the dispatch to `Dispatcher.invoke`; the convention for invoking a uniform-shape use case lives next to the table that maps the verbs, not re-spelled in the caller. `Store`'s own verb loop is separate — it extracts the `role:` keyword and forwards to `as(role)`, a role-selection job distinct from invocation.
94
89
 
95
- - `Application::Write::RefreshOrchestrator` composes `RefreshWorker` with the freshness `Action` returned by `Domain::Freshness`. Session memoizes one (`session.refresh_orchestrator`).
96
- - `Application::Envelope::{Reader,Writer}` own the parse and persist halves of the write pipeline; the audit-append-as-final-step invariant lives in `Writer`. Session memoizes both.
90
+ `boot` and `doctor` are read verbs like any other: `Read::Boot` / `Read::Doctor`
91
+ are thin `(container:, call:)` use cases that delegate to the `Textus::Boot` /
92
+ `Textus::Doctor` report-builder libraries (`build(container:, ...)`). They are
93
+ reached through `Dispatcher::VERBS`, not a special method on `RoleScope`.
97
94
 
98
- ## Caps
95
+ Two collaborators live outside the dispatcher because they're composed by other use cases, not invoked as verbs:
99
96
 
100
- Use cases never see the raw `Store`. `Application::Caps` defines three role-scoped slices:
97
+ - `Write::RefreshOrchestrator` composes `RefreshWorker` with the freshness `Action` returned by `Domain::Freshness`.
98
+ - `Envelope::IO::{Reader,Writer}` — own the parse and persist halves of the write pipeline; the audit-append-as-final-step invariant lives in `Writer`.
99
+
100
+ ## Container
101
+
102
+ Use cases never see the raw `Store`. `Textus::Container` is a single record holding the wired collaborators:
101
103
 
102
104
  ```ruby
103
- ReadCaps = Data.define(:manifest, :file_store, :schemas, :root, :audit_log, :events)
104
- WriteCaps = Data.define(:manifest, :file_store, :schemas, :root, :audit_log, :events, :authorizer)
105
- HookCaps = Data.define(:events, :rpc, :manifest, :root)
105
+ Container = Data.define(
106
+ :manifest, :file_store, :schemas, :root,
107
+ :audit_log, :events, :rpc, :authorizer
108
+ )
106
109
  ```
107
110
 
108
- `Session.for(store, role:)` builds all three via `Application.caps_from_store(store)`; the dispatch method picks `read_caps` or `write_caps` based on the `caps_kind` declared at registration time. RPC hook callables (`:resolve_intake`, `:transform_rows`, `:validate`) receive a `caps:` kwarg that is the appropriate Read/Write slice legacy `store:` is rejected by `Hooks::RpcRegistry#invoke`.
111
+ 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.
112
+
113
+ ## Ports
114
+
115
+ 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.
116
+
117
+ | Class | Role |
118
+ |---|---|
119
+ | `Ports::Storage::FileStore` | Bytes-only FS I/O — `read`, `write`, `delete`, `exists?`, `etag`. No knowledge of envelopes or schemas. |
120
+ | `Ports::AuditLog` | Append-only structured log (`audit.log`). Owns seq numbering, file-locking, and rotation. |
121
+ | `Ports::Clock` | Supplies `Time.now` — a module-function so tests can swap it without dependency injection boilerplate. |
122
+ | `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. |
123
+ | `Ports::Refresh::Lock` | Non-blocking `flock`-backed lock per key — prevents concurrent refresh workers from racing on the same entry. |
124
+ | `Ports::Refresh::Detached` | Spawns a background thread for async refresh; the caller receives a `refresh_backgrounded` event instead of blocking. |
125
+ | `Ports::BuildLock` | Process-exclusive `flock` guard over the materializer build pipeline. Raises `BuildInProgress` if a build is already running. |
126
+
127
+ Application use cases access ports only through `Container` fields — never through the raw `Store`.
128
+
129
+ ### EnvelopeIO
130
+
131
+ `Envelope::IO::Reader` and `Envelope::IO::Writer` split the envelope pipeline into read-only parse and write-with-audit halves.
109
132
 
110
- ## Read path (`session.get(key)`)
133
+ **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`.
111
134
 
112
- 1. CLI verb (or MCP tool) builds `session = Session.for(store, role:)` then `session.get(key)`.
113
- 2. `Session#get` dispatches to `Application::Read::Get.call(key, session:, ctx:, caps:)`.
114
- 3. `Read::Get::Impl#call` resolves the path through `caps.manifest`, reads bytes via `caps.file_store`, parses the envelope.
115
- 4. Looks up the refresh policy via `caps.manifest.rules.for(key)`. If absent, returns the envelope annotated fresh.
135
+ **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`).
136
+
137
+ The three public methods are `put`, `delete`, and `move`; all follow the same validate → write → audit sequence.
138
+
139
+ Both are built from a `Container` via named constructors — `Writer.from(container:, call:)` (which builds its own `Reader.from`) and `Reader.from(container:)` (ADR 0026). Write use cases call `Writer.from` rather than reconstructing the object graph by hand, so a change to the Writer's dependencies is a one-line edit in one place.
140
+
141
+ ## Manifest carving
142
+
143
+ 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.
144
+
145
+ `Manifest` itself is a `Data.define` struct — a composition record with four named members:
146
+
147
+ | Member | Class | Responsibility |
148
+ |---|---|---|
149
+ | `data` | `Manifest::Data` | Frozen value: `raw`, `root`, `zones`, `entries`, `audit_config`, `role_mapping`. Structural data only — no behaviour beyond accessors and key validation. |
150
+ | `resolver` | `Manifest::Resolver` | Key → `Resolution(entry, path, remaining)`. Handles nested entry enumeration and fuzzy-match suggestions. |
151
+ | `policy` | `Manifest::Policy` | Zone/role authority — `zone_writers`, `zone_kinds`, `permission_for`, `role_kind`, `roles_with_kind`, `propose_zone_for(role)`. Derived from a `Data` snapshot; no filesystem I/O. `propose_zone_for` owns the "first writable zone whose name contains `review`" convention used by `MCP::Server` (ADR 0027). |
152
+ | `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. |
153
+
154
+ 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.
155
+
156
+ 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.
157
+
158
+ ## Read path (`store.get(key)`)
159
+
160
+ 1. CLI verb (or MCP tool) calls `store.get(key, role:)` (or `store.as(role).get(key)`).
161
+ 2. `Store#get` looks up `Dispatcher::VERBS[:get] → Read::Get`, builds a `Call`, instantiates `Read::Get.new(container:, call:).call(key)`.
162
+ 3. `Read::Get#call` resolves the path through `container.manifest`, reads bytes via `container.file_store`, parses the envelope.
163
+ 4. Looks up the refresh policy via `container.manifest.rules.for(key)`. If absent, returns the envelope annotated fresh.
116
164
  5. Otherwise `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`; the envelope is annotated with `stale`, `reason`, `refreshing: false`.
117
165
 
118
- `session.get_or_refresh(key)` composes `Read::Get` with `Write::RefreshOrchestrator` to optionally refresh on stale.
166
+ `store.get_or_refresh(key)` composes `Read::Get` with `Write::RefreshOrchestrator` to optionally refresh on stale.
119
167
 
120
- ## Write path (`session.put(key, ...)`)
168
+ ## Write path (`store.put(key, ...)`)
121
169
 
122
- 1. CLI verb calls `session = Session.for(store, role:)` then `session.put(key, meta:, body:, content:, if_etag:)`.
123
- 2. `Write::Put::Impl#call` validates the key, resolves the manifest entry, and calls `@authorizer.authorize_write!(mentry, role: @ctx.role)` — raises `WriteForbidden` if denied.
124
- 3. Delegates persistence to `session.envelope_writer.put`, which serializes, schema-validates, etag-checks (raises `EtagMismatch` on conflict), writes via the `FileStore` port, and appends the audit row.
125
- 4. Publishes `:entry_put` via `caps.events` with `ctx: session.hook_context`, `key:`, `envelope:`.
170
+ 1. CLI verb calls `store.put(key, meta:, body:, content:, if_etag:, role:)`.
171
+ 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.
172
+ 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.
173
+ 4. Publishes `:entry_put` via `container.events` with `ctx: <Hooks::Context>`, `key:`, `envelope:`.
126
174
 
127
- `Write::{Delete,Mv,Accept,Reject,Publish}` follow the same shape: explicit caps, `Authorizer` for authz, `Envelope::Writer` for persistence (where applicable), event published with the `Hooks::Context` handle.
175
+ `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.
128
176
 
129
- `Write::Mv` delegates the file-move + audit to `Envelope::Writer#move`, then publishes `:entry_renamed` itself. UID injection (when the source lacks one) goes through `Envelope::Writer#write` directly — no `Put` bypass.
177
+ `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.
130
178
 
131
- ## Refresh path (`session.refresh(key)`)
179
+ ## Refresh path (`store.refresh(key)`)
132
180
 
133
- 1. CLI `Verb::Refresh` builds `session = Session.for(store, role: "runner")` then calls `session.refresh(key)`.
134
- 2. `Write::RefreshWorker::Impl#run(key)`:
135
- - Resolves the manifest entry, looks up the intake handler via `caps.rpc.callable(:resolve_intake, mentry.handler)`.
181
+ 1. CLI `Verb::Refresh` calls `store.refresh(key, role: "runner")`.
182
+ 2. `Write::RefreshWorker#run(key)`:
183
+ - Resolves the manifest entry, looks up the intake handler via `container.rpc.callable(:resolve_intake, mentry.handler)`.
136
184
  - Publishes `:refresh_started` with the hook context.
137
185
  - Invokes the handler under a 30s thread-join deadline.
138
186
  - On any error: publishes `:refresh_failed`, then re-raises.
139
- - On success: applies `@authorizer.authorize_write!` and persists via `Envelope::Writer#write` directly (no `Put` round-trip); publishes `:entry_refreshed` unless etag is unchanged.
140
- 3. `session.refresh_all(prefix:, zone:)` lists stale entries via `Read::Stale` and runs `Worker#run` per entry; returns `{ refreshed:, failed:, skipped: }`.
187
+ - 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.
188
+ 3. `store.refresh_all(prefix:, zone:)` lists stale entries via `Read::Stale` and runs `Worker#run` per entry; returns `{ refreshed:, failed:, skipped: }`.
141
189
 
142
190
  ## Hook payload contract
143
191
 
144
- Pub-sub hooks (`:entry_put`, `:entry_refreshed`, …) receive `ctx:` — a `Textus::Hooks::Context` that wraps the session and exposes a narrow surface (`get`, `list`, `put`, `delete`, `audit`, `publish_followup`, plus `role` and `correlation_id`). The raw `Store` is not handed out.
192
+ 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.
145
193
 
146
- RPC hooks (`:resolve_intake`, `:transform_rows`, `:validate`) receive `caps:` — a `ReadCaps` or `WriteCaps` slice. They are gem-internal: the framework calls them, not user pub-sub.
194
+ RPC hooks (`:resolve_intake`, `:transform_rows`, `:validate`) receive `caps:` — a `Textus::Container`. They are gem-internal: the framework calls them, not user pub-sub.
147
195
 
148
196
  ## Agent surface (boot + pulse + MCP)
149
197
 
150
198
  Agents and plugins talk to a textus store through three layers:
151
199
 
152
200
  ```
153
- soul (skill/agent) ──▶ gate (CLI | MCP) ──▶ Session ──▶ memory (.textus/)
201
+ soul (skill/agent) ──▶ gate (CLI | MCP) ──▶ Store ──▶ memory (.textus/)
154
202
  ```
155
203
 
156
204
  Two transports, one façade:
@@ -158,7 +206,7 @@ Two transports, one façade:
158
206
  - **CLI** — human/script surface. `textus boot`, `textus pulse --since=N`, `textus get/put/...`.
159
207
  - **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.
160
208
 
161
- Both transports call `Session.for(store, role:)`. No duplicate logic.
209
+ Both transports call `store.<verb>(..., role:)` (or `store.as(role).<verb>(...)`). No duplicate logic.
162
210
 
163
211
  The agent loop (cadence guide in `docs/agent-integration.md`):
164
212
 
@@ -170,6 +218,8 @@ Manifest drift surfaces as `ContractDrift` (manifest_etag mismatch); audit curso
170
218
 
171
219
  ## Hooks::EventBus event catalog
172
220
 
221
+ `Hooks::Signature` is the single home of callable keyword-introspection — both `EventBus` (pub-sub dispatch) and `RpcRegistry` (RPC dispatch) delegate to it for `accepts_keyrest?`, `declared_keys`, `missing`, and `filter` rather than each maintaining a hand-rolled copy (ADR 0027).
222
+
173
223
  RPC (single handler, declares `caps:`):
174
224
  - `resolve_intake(caps:, config:, args:)` — intake fetch handler.
175
225
  - `transform_rows(caps:, rows:, config:)` — row transform for intakes.
data/CHANGELOG.md CHANGED
@@ -9,6 +9,138 @@ 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.30.0 — 2026-05-29
13
+
14
+ Explicit zone kind (strict) and entry retention (ADR 0028, moves 1 & 4). No wire format (`textus/3`) change.
15
+
16
+ ### Changed (BREAKING)
17
+
18
+ - Zone `kind:` is now **required** on every zone (`origin | quarantine | queue | derived`); a manifest with a kind-less zone is rejected at load. The kind is authoritative: a zone is `derived` only if it declares `kind: derived`, and proposals route only to the zone declaring `kind: queue`. The previous writers→kind inference, the `"review"`-name proposal fallback, and boot's arbitrary-zone propose default were removed. No `textus/3` wire-format change; existing manifests must add `kind:` to every zone.
19
+
20
+ ### Added
21
+
22
+ - `Manifest::Policy#declared_kind`, `#queue_zone`, `#derived_zone?`. `propose_zone_for` now resolves through the declared `queue` zone exclusively.
23
+ - `retention:` rule block (`expire_after`, `archive_after`) parsed into `Domain::Policy::Retention`. New `textus retain --as=ROLE` sweep expires (deletes) or archives leaves past their window — `expire_after` deletes, `archive_after` copies to `.textus/archive/` then deletes; age is the leaf's mtime. `--prefix`/`--zone` narrow the sweep; rows whose zone the role can't write surface as failures. Retention appears in `textus rule explain`.
24
+ - `Textus::Domain::Duration.seconds` — shared duration parser (`30s`/`90m`/`12h`/`30d`/bare seconds), now also backing `Refresh#ttl_seconds`.
25
+
26
+ ### Internal
27
+
28
+ - `Manifest::Entry::Base#in_generator_zone?` and `boot` derived/proposal detection route through `Policy#derived_zone?` / `#propose_zone_for`; all `"review"` substring matches and the `Policy#zone_kinds` inference method are removed.
29
+ - Dead `Policy#zone_kinds` method removed.
30
+
31
+ ## 0.29.2 — 2026-05-29
32
+
33
+ Hook-registry convergence and MCP transport de-leak (ADR 0027). Every change is additive or internal — no wire format (`textus/3`) or manifest-schema change, no public class renamed or removed.
34
+
35
+ ### Added
36
+
37
+ - `Textus::Hooks::Signature` — single home of callable keyword-introspection (`accepts_keyrest?`, `declared_keys`, `missing`, `filter`), shared by both `EventBus` and `RpcRegistry`.
38
+ - `Manifest::Policy#propose_zone_for(role)` — owns the "first writable zone whose name contains `review`" convention; `MCP::Server#handle_initialize` delegates to it instead of scanning `manifest.data.zones` inline.
39
+
40
+ ### Internal
41
+
42
+ - `EventBus` and `RpcRegistry` both delegate callable introspection to `Hooks::Signature`; both `shape_check!` copies and the hand-rolled `filter_kwargs`/`invoke` derivations are deleted.
43
+ - Removed the `store:`→`caps:` legacy shim from `RpcRegistry`: a handler declaring `store:` (instead of `caps:`) is now rejected at registration time with an honest message, not at invoke time. Stale in-repo RPC hook fixtures and the `textus init` scaffold example are migrated to `caps:`.
44
+ - `MCP::Server#handle_initialize` no longer iterates `manifest.data.zones`; it calls `policy.propose_zone_for(proposer)`. No zone-selection logic remains in the JSON-RPC transport handler.
45
+ - `MCP::Session` converted from a hand-rolled immutable class to `Data.define(:role, :cursor, :propose_zone, :manifest_etag)`, matching the house convention used by all other value objects.
46
+
47
+ ### Behavior change (non-breaking in practice)
48
+
49
+ - RPC handlers declaring `store:` previously registered successfully and failed only at first invocation (with a misleading message). They now fail at registration time with a message naming the correct kwarg (`caps:`). No handler using `store:` was valid before; only the timing and clarity of the error change.
50
+
51
+ ## 0.29.1 — 2026-05-29
52
+
53
+ Construction-side cleanup of the use-case layer (ADR 0026). Every change is additive or internal — no public class renamed or removed, wire format (`textus/3`) and CLI unchanged.
54
+
55
+ ### Added
56
+
57
+ - `Envelope::IO::Writer.from(container:, call:)` and `Envelope::IO::Reader.from(container:)` — named constructors that build the envelope IO collaborators from a `Container`. `Writer.new`/`Reader.new` are unchanged.
58
+ - `Write::IntakeFetch.invoke(rpc:, handler:, config:, args:, label:, timeout:)` — the transport-side "invoke a `:resolve_intake` handler under a timeout" kernel; now the canonical home of `FETCH_TIMEOUT_SECONDS`.
59
+ - `Dispatcher.invoke(verb, container:, call:, args:, kwargs:)` — single home for the uniform use-case invocation protocol.
60
+
61
+ ### Internal
62
+
63
+ - `Write::{Put,Delete,Mv,RefreshWorker}` no longer hand-wire `Envelope::IO::Writer`/`Reader`; they call `Writer.from`. Removed ~60 lines of byte-identical construction boilerplate.
64
+ - `cli/verb/put.rb` (`--fetch`) and `cli/verb/hook_run.rb` no longer inline `Timeout.timeout { store.rpc.invoke(:resolve_intake, …) }`; both route through `Write::IntakeFetch`. No intake-fetch mechanics remain under `lib/textus/cli/`.
65
+ - `RoleScope`'s verb loop delegates the instantiate-and-call step to `Dispatcher.invoke`; it still builds the `Call`. `Store`'s role-selecting verb loop is unchanged.
66
+ - `RefreshWorker::FETCH_TIMEOUT_SECONDS` is now an alias of `IntakeFetch::FETCH_TIMEOUT_SECONDS`.
67
+
68
+ ## 0.29.0 — 2026-05-29
69
+
70
+ 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.
71
+
72
+ ### Breaking
73
+
74
+ - `Domain::Staleness#initialize` now requires `file_stat:` and `clock:` (was `manifest:` only).
75
+ - `Domain::Staleness::IntakeCheck#initialize` now requires `file_stat:` and `clock:`.
76
+ - `Domain::Staleness::GeneratorCheck#initialize` now requires `file_stat:` (no clock — `GeneratorCheck` has no wall-clock dependency).
77
+ - `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`.
78
+ - `Domain::Sentinel#orphan?` and `#drift?` now take a `file_stat` argument.
79
+ - `Textus::Boot.run_via(container:, role:)` → `Textus::Boot.build(container:)` (the `role:` parameter was unused).
80
+ - `Textus::Doctor.run_via(container:, role:, checks:)` → `Textus::Doctor.build(container:, checks:)` (the `role:` parameter was unused).
81
+ - `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.
82
+
83
+ ### Added
84
+
85
+ - `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`).
86
+ - `Ports::SentinelStore` — sentinel persistence + path-layout adapter, extracted from `Domain::Sentinel`.
87
+ - `Read::Boot` and `Read::Doctor` — dispatched use-case classes on the uniform `(container:, call:)` shape.
88
+
89
+ ### Changed
90
+
91
+ - `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).
92
+
93
+ ### Internal
94
+
95
+ - 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.
96
+ - Freshness request timestamps now originate from `Ports::Clock` (via `Call.build`) rather than a bare `Time.now`.
97
+ - Cosmetic refactors: deduped the audit limit guard; made `RefreshWorker.normalize_action_result` a public class method (dropped a `send`); extracted staleness guard helpers.
98
+ - 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).
99
+ - See [ADR 0024](docs/architecture/decisions/0024-domain-purity-ports.md) for the design rationale.
100
+
101
+ ## 0.28.0 — 2026-05-29
102
+
103
+ 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.
104
+
105
+ ### Breaking
106
+
107
+ - 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:)`.
108
+ - `Textus::Envelope::IO::Writer` and `Textus::Write::RefreshOrchestrator` constructors take `call:` instead of `ctx:` (both received a `Call` already; the kwarg name is corrected).
109
+ - `Read::Audit#call` now accepts filter keywords and builds a `Read::Audit::Query` value object internally — keyword callers (`store.audit(key:, limit:)`) are unchanged.
110
+ - `Builder::Pipeline.run` takes `(mentry:, deps:)` where `deps` is a `Builder::Pipeline::Deps` record, instead of eight loose keyword collaborators.
111
+ - Removed the `CLI::VERBS` const-missing shim (use `CLI.verbs`).
112
+ - Removed the `Manifest::Entry::PUBLISH_EACH_VARS` / `PUBLISH_EACH_VAR_RE` re-exports (use `Manifest::Entry::Validators::PublishEach::KNOWN_VARS` / `::VAR_RE`).
113
+
114
+ ### Internal
115
+
116
+ - Removed the runtime `initialize`-parameter reflection from both `RoleScope` and `Doctor::Check`; verb dispatch is now an unconditional `klass.new(container:, call:).call(...)`.
117
+ - `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).
118
+ - `ARCHITECTURE.md`'s "uniform `(container:, call:)`" claim is now accurate; active docs refreshed to the 0.27/0.28 vocabulary.
119
+ - No wire-format change. Protocol stays at `textus/3`. CLI verb signatures unchanged. Hook callable surfaces (`ctx:` for pub-sub, `caps:` for RPC) unchanged.
120
+ - See [ADR 0023](docs/architecture/decisions/0023-uniform-use-case-shape.md) for the design rationale.
121
+
122
+ ## 0.27.0 — 2026-05-29
123
+
124
+ ### Breaking
125
+
126
+ - Removed `Textus::Session`. Use `store.as(role).put(...)` or `store.put(..., role:)` instead of `store.session(role:).put(...)`.
127
+ - Removed `Textus::Application::UseCase` registry. Verb dispatch is now via the static `Textus::Dispatcher::VERBS` table.
128
+ - 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`).
129
+ - Renamed `Textus::Application::Context` to `Textus::Call`. Field shape identical.
130
+ - 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`.
131
+ - Flattened `Textus::Application::Write::*` → `Textus::Write::*`, `Application::Read::*` → `Read::*`, `Application::Envelope::*` → `Envelope::IO::*`, `Application::Maintenance::*` → `Maintenance::*`, `Application::Projection` → `Projection`.
132
+ - Renamed `Textus::Infra::*` → `Textus::Ports::*`.
133
+ - `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.
134
+ - `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`.
135
+ - 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.
136
+
137
+ ### Internal
138
+
139
+ - ~600 LOC removed net across ~60 files.
140
+ - No wire-format change. Protocol stays at `textus/3`.
141
+ - CLI verb signatures unchanged. No envelope shape changes.
142
+ - See [ADR 0022](docs/architecture/decisions/0022-container-call-dispatcher.md) for the design rationale.
143
+
12
144
  ## 0.26.0 — 2026-05-28
13
145
 
14
146
  ### Breaking
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 only — day-to-day catalog (agents propose via review/, runners feed via intake/)
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
+ printf '%s' '{"_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
 
@@ -57,15 +99,15 @@ You get `.textus/` with all five zone directories, baseline schemas, an empty au
57
99
  output/ # builder only — computed outputs
58
100
  ```
59
101
 
60
- Manifest `path:` fields are relative to `.textus/zones/`. So `working.network.org.jane` lives at `.textus/zones/working/network/org/jane.md`.
102
+ Manifest `path:` fields are relative to `.textus/zones/`. So `working.notes.org.jane` lives at `.textus/zones/working/notes/org/jane.md`.
61
103
 
62
104
  Read and write:
63
105
 
64
106
  ```sh
65
- textus get working.network.org.jane
107
+ textus get working.notes.org.jane
66
108
  textus list --zone=working
67
- echo '{"_meta":{"name":"bob","org":"acme"},"body":"hi\n"}' \
68
- | textus put working.network.org.bob --as=human --stdin
109
+ printf '%s' '{"_meta":{"name":"bob","org":"acme"},"body":"hi\n"}' \
110
+ | textus put working.notes.bob --as=human --stdin
69
111
  textus freshness --zone=output # per-entry fresh/stale/never_refreshed/no_policy
70
112
  textus rule list # show every rule block
71
113
  textus audit --limit=20 # query the audit log
@@ -75,17 +117,17 @@ 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
- - **Build and publish in one pass.** `Application::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.
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
- - **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.
127
+ - **Strict key grammar.** `/^[a-z0-9][a-z0-9-]*$/`, max 8 segments × 64 chars. `textus doctor` flags any illegal segments with a rename hint; `textus key mv old.key new.key` renames in place (uid survives).
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
- - **`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.
130
+ - **`textus doctor`.** Health check across 15 checks — among them: 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.
90
132
  - **Compute.** Derived entries declare `compute: { kind: projection, ... }` (declarative rows + template) or `compute: { kind: external, ... }` (build runner produces the file; textus tracks sources for staleness). Inside projection computes, `transform:` names the row-shaping hook.
91
133
 
@@ -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
+ ~920 examples; includes conformance fixtures A–I from SPEC §12.
167
209
 
168
210
  ## Code quality
169
211