textus 0.22.0 → 0.26.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 (160) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +148 -45
  3. data/CHANGELOG.md +102 -0
  4. data/README.md +1 -1
  5. data/SPEC.md +12 -12
  6. data/docs/conventions.md +10 -0
  7. data/lib/textus/application/caps.rb +49 -0
  8. data/lib/textus/application/context.rb +2 -2
  9. data/lib/textus/application/envelope/reader.rb +44 -0
  10. data/lib/textus/application/{writes/envelope_io.rb → envelope/writer.rb} +24 -50
  11. data/lib/textus/application/maintenance/key_delete_prefix.rb +44 -0
  12. data/lib/textus/application/maintenance/key_mv_prefix.rb +57 -0
  13. data/lib/textus/application/maintenance/migrate.rb +59 -0
  14. data/lib/textus/application/maintenance/rule_lint.rb +65 -0
  15. data/lib/textus/application/maintenance/zone_mv.rb +60 -0
  16. data/lib/textus/application/maintenance.rb +17 -0
  17. data/lib/textus/application/projection.rb +12 -10
  18. data/lib/textus/application/read/audit.rb +106 -0
  19. data/lib/textus/application/read/blame.rb +91 -0
  20. data/lib/textus/application/read/deps.rb +34 -0
  21. data/lib/textus/application/read/freshness.rb +110 -0
  22. data/lib/textus/application/read/get.rb +75 -0
  23. data/lib/textus/application/read/get_or_refresh.rb +63 -0
  24. data/lib/textus/application/read/list.rb +25 -0
  25. data/lib/textus/application/read/policy_explain.rb +47 -0
  26. data/lib/textus/application/read/published.rb +25 -0
  27. data/lib/textus/application/read/pulse.rb +101 -0
  28. data/lib/textus/application/read/rdeps.rb +35 -0
  29. data/lib/textus/application/read/schema_envelope.rb +26 -0
  30. data/lib/textus/application/read/stale.rb +23 -0
  31. data/lib/textus/application/read/uid.rb +30 -0
  32. data/lib/textus/application/read/validate_all.rb +32 -0
  33. data/lib/textus/application/{reads → read}/validator.rb +2 -2
  34. data/lib/textus/application/read/where.rb +26 -0
  35. data/lib/textus/application/use_case.rb +22 -0
  36. data/lib/textus/application/write/accept.rb +102 -0
  37. data/lib/textus/application/{writes → write}/authority_gate.rb +3 -3
  38. data/lib/textus/application/write/delete.rb +45 -0
  39. data/lib/textus/application/{writes → write}/materializer.rb +14 -15
  40. data/lib/textus/application/write/mv.rb +118 -0
  41. data/lib/textus/application/write/publish.rb +96 -0
  42. data/lib/textus/application/write/put.rb +49 -0
  43. data/lib/textus/application/write/refresh_all.rb +63 -0
  44. data/lib/textus/application/{refresh/orchestrator.rb → write/refresh_orchestrator.rb} +32 -8
  45. data/lib/textus/application/write/refresh_worker.rb +134 -0
  46. data/lib/textus/application/write/reject.rb +62 -0
  47. data/lib/textus/boot.rb +27 -29
  48. data/lib/textus/builder/pipeline.rb +3 -3
  49. data/lib/textus/cli/group/mcp.rb +9 -0
  50. data/lib/textus/cli/group/zone.rb +9 -0
  51. data/lib/textus/cli/verb/accept.rb +1 -1
  52. data/lib/textus/cli/verb/audit.rb +2 -2
  53. data/lib/textus/cli/verb/blame.rb +1 -1
  54. data/lib/textus/cli/verb/boot.rb +1 -1
  55. data/lib/textus/cli/verb/build.rb +2 -2
  56. data/lib/textus/cli/verb/delete.rb +1 -1
  57. data/lib/textus/cli/verb/deps.rb +1 -1
  58. data/lib/textus/cli/verb/doctor.rb +1 -1
  59. data/lib/textus/cli/verb/freshness.rb +1 -1
  60. data/lib/textus/cli/verb/get.rb +1 -1
  61. data/lib/textus/cli/verb/hook_run.rb +3 -4
  62. data/lib/textus/cli/verb/hooks.rb +11 -14
  63. data/lib/textus/cli/verb/key_delete.rb +24 -0
  64. data/lib/textus/cli/verb/list.rb +1 -1
  65. data/lib/textus/cli/verb/mcp_serve.rb +17 -0
  66. data/lib/textus/cli/verb/migrate.rb +18 -0
  67. data/lib/textus/cli/verb/mv.rb +11 -3
  68. data/lib/textus/cli/verb/published.rb +1 -1
  69. data/lib/textus/cli/verb/pulse.rb +1 -1
  70. data/lib/textus/cli/verb/put.rb +8 -6
  71. data/lib/textus/cli/verb/rdeps.rb +1 -1
  72. data/lib/textus/cli/verb/refresh.rb +1 -1
  73. data/lib/textus/cli/verb/refresh_stale.rb +1 -1
  74. data/lib/textus/cli/verb/reject.rb +1 -1
  75. data/lib/textus/cli/verb/rule_explain.rb +1 -1
  76. data/lib/textus/cli/verb/rule_lint.rb +18 -0
  77. data/lib/textus/cli/verb/schema.rb +1 -1
  78. data/lib/textus/cli/verb/uid.rb +1 -1
  79. data/lib/textus/cli/verb/where.rb +1 -1
  80. data/lib/textus/cli/verb/zone_mv.rb +19 -0
  81. data/lib/textus/cli/verb.rb +4 -4
  82. data/lib/textus/doctor/check/audit_log.rb +2 -2
  83. data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
  84. data/lib/textus/doctor/check/hooks.rb +4 -3
  85. data/lib/textus/doctor/check/illegal_keys.rb +2 -2
  86. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  87. data/lib/textus/doctor/check/manifest_files.rb +2 -2
  88. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  89. data/lib/textus/doctor/check/refresh_locks.rb +2 -2
  90. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  91. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  92. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  93. data/lib/textus/doctor/check/schemas.rb +2 -2
  94. data/lib/textus/doctor/check/sentinels.rb +2 -2
  95. data/lib/textus/doctor/check/templates.rb +2 -2
  96. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  97. data/lib/textus/doctor/check.rb +5 -3
  98. data/lib/textus/doctor.rb +24 -27
  99. data/lib/textus/domain/authorizer.rb +4 -4
  100. data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
  101. data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
  102. data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
  103. data/lib/textus/domain/staleness/generator_check.rb +2 -2
  104. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  105. data/lib/textus/domain/staleness.rb +1 -1
  106. data/lib/textus/hooks/builtin.rb +14 -14
  107. data/lib/textus/hooks/context.rb +13 -13
  108. data/lib/textus/hooks/error_log.rb +32 -0
  109. data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
  110. data/lib/textus/hooks/loader.rb +29 -3
  111. data/lib/textus/hooks/rpc_registry.rb +77 -0
  112. data/lib/textus/infra/audit_subscriber.rb +6 -7
  113. data/lib/textus/infra/refresh/detached.rb +1 -1
  114. data/lib/textus/key/path.rb +7 -3
  115. data/lib/textus/manifest/data.rb +78 -0
  116. data/lib/textus/manifest/entry/base.rb +4 -4
  117. data/lib/textus/manifest/entry/derived.rb +4 -5
  118. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  119. data/lib/textus/manifest/policy.rb +48 -0
  120. data/lib/textus/manifest/resolver.rb +14 -14
  121. data/lib/textus/manifest/rules.rb +1 -1
  122. data/lib/textus/manifest.rb +53 -111
  123. data/lib/textus/mcp/errors.rb +32 -0
  124. data/lib/textus/mcp/server.rb +127 -0
  125. data/lib/textus/mcp/session.rb +31 -0
  126. data/lib/textus/mcp/tool_schemas.rb +71 -0
  127. data/lib/textus/mcp/tools.rb +129 -0
  128. data/lib/textus/mcp.rb +6 -0
  129. data/lib/textus/schema/tools.rb +14 -10
  130. data/lib/textus/session.rb +84 -0
  131. data/lib/textus/store.rb +14 -9
  132. data/lib/textus/version.rb +1 -1
  133. data/lib/textus.rb +8 -1
  134. metadata +61 -36
  135. data/lib/textus/application/reads/audit.rb +0 -94
  136. data/lib/textus/application/reads/blame.rb +0 -82
  137. data/lib/textus/application/reads/deps.rb +0 -26
  138. data/lib/textus/application/reads/freshness.rb +0 -88
  139. data/lib/textus/application/reads/get.rb +0 -67
  140. data/lib/textus/application/reads/get_or_refresh.rb +0 -51
  141. data/lib/textus/application/reads/list.rb +0 -17
  142. data/lib/textus/application/reads/policy_explain.rb +0 -39
  143. data/lib/textus/application/reads/published.rb +0 -17
  144. data/lib/textus/application/reads/pulse.rb +0 -63
  145. data/lib/textus/application/reads/rdeps.rb +0 -27
  146. data/lib/textus/application/reads/schema_envelope.rb +0 -18
  147. data/lib/textus/application/reads/stale.rb +0 -15
  148. data/lib/textus/application/reads/uid.rb +0 -23
  149. data/lib/textus/application/reads/validate_all.rb +0 -24
  150. data/lib/textus/application/reads/where.rb +0 -18
  151. data/lib/textus/application/refresh/all.rb +0 -52
  152. data/lib/textus/application/refresh/worker.rb +0 -116
  153. data/lib/textus/application/writes/accept.rb +0 -89
  154. data/lib/textus/application/writes/delete.rb +0 -33
  155. data/lib/textus/application/writes/mv.rb +0 -105
  156. data/lib/textus/application/writes/publish.rb +0 -81
  157. data/lib/textus/application/writes/put.rb +0 -37
  158. data/lib/textus/application/writes/reject.rb +0 -50
  159. data/lib/textus/infra/event_bus.rb +0 -27
  160. 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: e822d94de7ae06481dd8c03eab813a097b62e78121d94c9d1045017675da665c
4
+ data.tar.gz: 3372f74bb31465b28be8e6ce5b4721a3f15cae7536ebdfc8ef9b6c6d7c9f2d88
5
5
  SHA512:
6
- metadata.gz: 9b4ce0da2828f2623f60601cdd0ac8a69e51cc9670c5de80b92e3b41e0f955361154664b8cb0a7de0e838b5403336ff26589698201885b44001a83cfcd2c3f21
7
- data.tar.gz: 672d948ab0ccdea1470dcb38c87c233ed717a538d4e4902191a32224dac8475fe1269b4569be00e5eb1f40e9c6146f8867d557d6455601c13fc2e0ce2c381cae
6
+ metadata.gz: 31e4b83f9a7d3b061d043c447393b23ba404a47e7d79b0a411ad49719625d01ec30116a067f28bf88ac6944ff0262c1825d35c851022340d63b813dca26f98aa
7
+ data.tar.gz: 590cc3419cb7823688bab11c4fce49a011bf62b463ab887b415753dd3c8037cd0704b8ccc4df6e778b21c04bd8101847a28fa5d4819e447e7af3d44febc017f3
data/ARCHITECTURE.md CHANGED
@@ -2,88 +2,191 @@
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: session = Session.for(store, role:)
6
+ session.<name>(...) # one method per
7
+ │ # registered use case
8
+ │ # (put/get/refresh/…) │
8
9
  │ │
9
- Or, for embedders bringing their own ports:
10
- │ Operations.new(ctx:, manifest:, file_store:, │
11
- │ schemas:, audit_log:, bus:, │
12
- │ registry:, root:, store:) │
10
+ MCP gate: textus mcp serve same use cases, JSON-RPC.
13
11
  └──────────────────────┬─────────────────────────────────────┘
14
12
 
15
13
  ┌─ Application ────────▼─────────────────────────────────────┐
16
14
  │ Context (slim Data: role, correlation_id, now, │
17
15
  │ dry_run — request state only) │
18
- Operations (flat facade; inline use-case factories) │
16
+ Caps (Read/Write/Hook records store slices) │
17
+ │ Session (per-call dispatch; methods generated │
18
+ │ from UseCase registry) │
19
+ │ UseCase (registry: verb → module, caps_kind) │
19
20
  │ │
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}}
21
+ read/{get,get_or_refresh,list,where,uid,schema_envelope,
22
+ deps,rdeps,published,stale,validate_all,
23
+ freshness,audit,blame,policy_explain,pulse}.rb
24
+ write/{put,delete,mv,accept,reject,publish,
25
+ materializer,authority_gate,
26
+ refresh_worker,refresh_orchestrator,refresh_all}
27
+ maintenance/{migrate,key_mv_prefix,key_delete_prefix,
28
+ │ zone_mv,rule_lint}.rb │
29
+ │ envelope/{reader,writer}.rb (split: parse vs persist) │
30
+ │ projection.rb │
27
31
  └──────────┬───────────────────────────────┬─────────────────┘
28
32
  │ uses domain │ uses ports
29
33
  ┌─ Domain ─▼─────────────────────────────────────────────────┐
30
34
  │ Authorizer (manifest + role → allow / deny) │
31
35
  │ Permission (write/read predicate per zone) │
32
36
  │ Freshness::{Policy,Verdict,Evaluator} │
33
- Action Outcome
34
- Policy::{Promote,Refresh,Matcher,HandlerAllowlist}
37
+ Staleness (Generator/Intake checks)
38
+ Action Outcome Sentinel
39
+ │ Policy::{Promote,Refresh,Matcher,HandlerAllowlist, │
40
+ │ Predicates::{SchemaValid,AcceptAuthoritySigned}} │
35
41
  └──────────────────────────────────────────┬─────────────────┘
36
42
  │ implements
37
43
  ┌─ Infrastructure ─────────────────────────▼─────────────────┐
38
- │ Store (composition root — wires ports)
44
+ │ Store (composition root — wires ports,
45
+ │ vends Sessions) │
39
46
  │ Storage::FileStore (bytes-only port: read/write/delete/ │
40
47
  │ exists?/etag) │
41
- │ Manifest (Entry, Rules, Schema, permission_for)
48
+ │ Manifest (Data, Resolver, Policy, Rules)
42
49
  │ Schemas (eager-load cache) │
43
- │ AuditLog
44
- Hooks::{Registry,Dispatcher,Loader,FireReport}
45
- Infra::{Publisher,EventBus,Clock,Refresh::Lock,
46
- Refresh::Detached,BuildLock,AuditSubscriber}
50
+ Infra::{AuditLog,AuditSubscriber,Publisher,Clock,
51
+ Refresh::Lock,Refresh::Detached,BuildLock}
52
+ Hooks::{EventBus,RpcRegistry,Loader,Context,FireReport,
53
+ Builtin,ErrorLog}
47
54
  │ Entry::{Markdown,Json,Yaml,Text} (format strategies) │
48
55
  └────────────────────────────────────────────────────────────┘
49
56
 
50
57
  Dependency rule: arrows point DOWN. Domain has zero outbound
51
58
  imports. Application imports Domain + Infra (via ports).
52
- Use cases declare their real ports in their constructor.
59
+ Use cases declare their real collaborators in their Impl
60
+ constructor; UseCase.register hooks them into Session.
53
61
  ```
54
62
 
55
- ## Read path (`ops.get(key)`)
63
+ ## How a verb becomes a method
56
64
 
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.
65
+ Each application use case is a module under `lib/textus/application/{read,write,maintenance}/`. The shape is uniform:
66
+
67
+ ```ruby
68
+ 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
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ Textus::Application::UseCase.register(:get, Textus::Application::Read::Get, caps: :read)
89
+ ```
90
+
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`.
92
+
93
+ Two collaborators live outside the registry because they're composed by other use cases, not invoked as verbs:
94
+
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.
97
+
98
+ ## Caps
99
+
100
+ Use cases never see the raw `Store`. `Application::Caps` defines three role-scoped slices:
101
+
102
+ ```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)
106
+ ```
107
+
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`.
109
+
110
+ ## Read path (`session.get(key)`)
111
+
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.
61
116
  5. Otherwise `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`; the envelope is annotated with `stale`, `reason`, `refreshing: false`.
62
117
 
63
- `ops.get_or_refresh(key)` composes `Reads::Get` with `Refresh::Orchestrator` to optionally refresh on stale — same as 0.18.x.
118
+ `session.get_or_refresh(key)` composes `Read::Get` with `Write::RefreshOrchestrator` to optionally refresh on stale.
64
119
 
65
- ## Write path (`ops.put(key, ...)`)
120
+ ## Write path (`session.put(key, ...)`)
66
121
 
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`.
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:`.
71
126
 
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.
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.
73
128
 
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.
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.
75
130
 
76
- ## Refresh path (`ops.refresh(key)`)
131
+ ## Refresh path (`session.refresh(key)`)
77
132
 
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.
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)`.
136
+ - Publishes `:refresh_started` with the hook context.
82
137
  - Invokes the handler under a 30s thread-join deadline.
83
138
  - 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: }`.
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: }`.
86
141
 
87
142
  ## Hook payload contract
88
143
 
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:`.
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.
145
+
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.
147
+
148
+ ## Agent surface (boot + pulse + MCP)
149
+
150
+ Agents and plugins talk to a textus store through three layers:
151
+
152
+ ```
153
+ soul (skill/agent) ──▶ gate (CLI | MCP) ──▶ Session ──▶ memory (.textus/)
154
+ ```
155
+
156
+ Two transports, one façade:
157
+
158
+ - **CLI** — human/script surface. `textus boot`, `textus pulse --since=N`, `textus get/put/...`.
159
+ - **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
+
161
+ Both transports call `Session.for(store, role:)`. No duplicate logic.
162
+
163
+ The agent loop (cadence guide in `docs/agent-integration.md`):
164
+
165
+ 1. **Session start:** `boot()` → contract envelope (zones, entries, schemas, write_flows, agent_quickstart with `latest_seq`).
166
+ 2. **Per turn:** `pulse(since=cursor)` → `{cursor, changed, stale, pending_review, doctor}`.
167
+ 3. **On demand:** `get`, `put`, `propose`, `refresh`, `schema`, `rules`.
168
+
169
+ Manifest drift surfaces as `ContractDrift` (manifest_etag mismatch); audit cursor falls off the keep window as `CursorExpired`. Both signal "call `boot` again."
170
+
171
+ ## Hooks::EventBus event catalog
172
+
173
+ RPC (single handler, declares `caps:`):
174
+ - `resolve_intake(caps:, config:, args:)` — intake fetch handler.
175
+ - `transform_rows(caps:, rows:, config:)` — row transform for intakes.
176
+ - `validate(caps:)` — custom doctor validator.
177
+
178
+ Pub-sub (0..N handlers, declare `ctx:`):
179
+ - `entry_put(ctx:, key:, envelope:)`
180
+ - `entry_deleted(ctx:, key:)`
181
+ - `entry_refreshed(ctx:, key:, envelope:, change:)`
182
+ - `entry_renamed(ctx:, key:, from_key:, to_key:, envelope:)`
183
+ - `build_completed(ctx:, key:, envelope:, sources:)`
184
+ - `proposal_accepted(ctx:, key:, target_key:)`
185
+ - `proposal_rejected(ctx:, key:, target_key:)`
186
+ - `file_published(ctx:, key:, envelope:, source:, target:)`
187
+ - `store_loaded(ctx:)`
188
+ - `refresh_started(ctx:, key:, mode:)`
189
+ - `refresh_failed(ctx:, key:, error_class:, error_message:)`
190
+ - `refresh_backgrounded(ctx:, key:, started_at:, budget_ms:)`
191
+
192
+ Authoritative source: `lib/textus/hooks/event_bus.rb` `EVENTS`.
data/CHANGELOG.md CHANGED
@@ -9,6 +9,108 @@ 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.26.0 — 2026-05-28
13
+
14
+ ### Breaking
15
+ - Split `Textus::Hooks::Bus` into `Textus::Hooks::EventBus` (pubsub) and `Textus::Hooks::RpcRegistry` (named callables). The `Hooks::Bus` constant is removed.
16
+ - Replaced `Textus::Application::Ports` with three capability records: `Textus::Application::ReadCaps`, `WriteCaps`, `HookCaps`.
17
+ - Renamed `Textus::Operations` to `Textus::Session`. Access via `store.session(role:)`. `Operations.for(store, ...)` is removed.
18
+ - Hook RPC callables (`resolve_intake`, `transform_rows`, `validate`) no longer accept `store:` — declare `caps:` (a `WriteCaps` for `resolve_intake`/`validate`, `ReadCaps` for `transform_rows`).
19
+ - Removed all `Manifest` top-level deprecation shims (`zones`, `entries`, `zone_writers`, `permission_for`, etc.). Use `manifest.data.*` / `manifest.policy.*` / `manifest.resolver.*` / `manifest.rules.*`.
20
+ - Moved `Textus::Application::Writes::EnvelopeReader`/`EnvelopeWriter` to `Textus::Application::Envelope::Reader`/`Writer`.
21
+ - Renamed `Textus::Application::Writes` → `Textus::Application::Write`; `Textus::Application::Reads` → `Textus::Application::Read`; `Textus::Application::Restructure` → `Textus::Application::Maintenance`.
22
+ - Merged `Textus::Application::Refresh::*` into `Textus::Application::Write::Refresh{Worker,Orchestrator,All}`.
23
+ - Moved `Textus::Application::Policy::Promotion` and predicates to `Textus::Domain::Policy::Promotion`/`Predicates`.
24
+
25
+ ## 0.25.1 — 2026-05-28
26
+
27
+ ### Internal refactors
28
+
29
+ - **ADR 0018**: `Manifest` is now a composition record over `Data`,
30
+ `Resolver`, `Policy`, `Rules`. Top-level methods like
31
+ `Manifest#permission_for` are deprecated; use
32
+ `manifest.policy.permission_for(zone)`. One-cycle bridge — shims
33
+ warn until 0.26.0.
34
+
35
+ - **ADR 0016**: Application use cases take a single `ports:` kwarg
36
+ bundling six adapters + the store root. Hook DSL callables that
37
+ declare `|store:|` continue to work with a one-shot deprecation
38
+ warning per (event, hook_name); declare `|ports:|` to silence it.
39
+
40
+ - **ADR 0017**: `Application::Writes::EnvelopeIO` split into
41
+ `EnvelopeReader` (parse) and `EnvelopeWriter` (put/delete/move
42
+ + audit). Every public `EnvelopeWriter` method now ends with an
43
+ audit-row append — the write-without-audit failure mode is gone.
44
+
45
+ ### Breaking (internal)
46
+
47
+ - `Operations#store` accessor removed. There is no clean deprecation
48
+ shim because `Ports` cannot reconstruct a `Store`. External
49
+ callers should use `ops.ports.X` directly.
50
+
51
+ - `Textus::Manifest::Entry::Base::PublishContext` struct shape
52
+ changed: `:store` removed, `:ports` + `:boot` added. Affects
53
+ third-party plugins that build custom derived entries.
54
+
55
+ - `transform_context` passed to `transform_rows` RPC callables is
56
+ now an `Application::Ports`, not a `Store`. Transforms that treat
57
+ it as opaque continue to work; transforms that reach `.x` need
58
+ updates.
59
+
60
+ No CLI verb signatures changed. No wire envelopes changed.
61
+ Protocol remains `textus/3`.
62
+
63
+ ## 0.25.0 — 2026-05-28
64
+
65
+ ### Added (additive — backward-compatible pulse fields)
66
+ - `pulse.manifest_etag` — sha256 of `manifest.yaml`; lets agents detect contract drift without a second verb.
67
+ - `pulse.next_due_at` — soonest `next_due_at` across all entries with a refresh policy. Schedulers sleep until this timestamp instead of polling.
68
+ - `pulse.hook_errors` — recent hook failures since cursor; bounded in-memory ring on `Hooks::Bus#error_log` (default 256).
69
+
70
+ ### Changed
71
+ - `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.
72
+ - `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.
73
+
74
+ ### Protocol
75
+ - No wire-format change. `textus/3` envelopes are unchanged. Pulse fields are additive — existing consumers ignoring unknown keys continue to work.
76
+
77
+ ## 0.24.0 — 2026-05-28
78
+
79
+ ### Added
80
+ - **Context-structure ergonomics** (ADR 0015 Phase 2):
81
+ - `textus key mv --prefix OLD NEW` — bulk rename leaves under a prefix; preserves UIDs.
82
+ - `textus key delete --prefix P` — bulk delete leaves.
83
+ - `textus zone mv FROM TO` — rename a zone; refuses if destination exists; rewrites manifest + moves files.
84
+ - `textus rule lint --against=FILE` — diff candidate manifest YAML's `rules:` block against the live manifest.
85
+ - `textus migrate PLAN.yaml` — run a multi-op declarative migration plan (ops: `key_mv_prefix`, `key_delete_prefix`, `zone_mv`).
86
+ - All five operations also surface as MCP tools (`key_mv_prefix`, `key_delete_prefix`, `zone_mv`, `rule_lint`, `migrate`).
87
+ - `Textus::Application::Restructure` module with `Plan` value object and one use case per operation.
88
+
89
+ ### Protocol
90
+ - No wire-format change. `textus/3` envelopes are unchanged.
91
+
92
+ ## 0.23.0 — 2026-05-28
93
+
94
+ ### Added
95
+ - **Agent gate (MCP transport).** `textus mcp serve` — stdio JSON-RPC 2.0
96
+ server speaking MCP draft 2024-11-05. Wraps `Textus::Operations` as ten
97
+ auto-derived tools (`boot`, `tick`, `find`, `read`, `write`, `propose`,
98
+ `refresh`, `refresh_stale`, `schema`, `rules`). Session state (cursor,
99
+ role, manifest_etag) held server-side. Manifest drift surfaces as
100
+ `ContractDrift` (-32001); cursor expiry as `CursorExpired` (-32002).
101
+ See [`docs/mcp.md`](docs/mcp.md) and [ADR 0015](docs/architecture/decisions/0015-agent-gate-mcp.md).
102
+ - `examples/claude-plugin/.mcp.json` and migrated skills/commands/agents —
103
+ zero `textus <verb>` shell strings remain in plugin markdown.
104
+
105
+ ### Changed (docs)
106
+ - `ARCHITECTURE.md`: fixed stale `registry` references (now `bus`),
107
+ added Agent Surface section and complete Hooks::Bus event catalog.
108
+ - `docs/agent-integration.md`: documents three transports (CLI, Ruby API,
109
+ MCP); points agent authors at the MCP transport by default.
110
+
111
+ ### Protocol
112
+ - No wire-format change. `textus/3` envelopes are unchanged.
113
+
12
114
  ## 0.22.0 — 2026-05-28
13
115
 
14
116
  ### Changed (internal — no manifest-schema impact)
data/README.md CHANGED
@@ -79,7 +79,7 @@ For the full shape — Claude plugin with agents, skills, commands, pending walk
79
79
 
80
80
  - **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
81
  - **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.
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
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`.
84
84
  - **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
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.
data/SPEC.md CHANGED
@@ -888,10 +888,10 @@ Given a review entry `review.identity.self.patch` proposing a change to `identit
888
888
 
889
889
  Textus internals are organized into four layers. The dependency rule is one-way — each layer may only import from the layer beneath it.
890
890
 
891
- - **Interface** (`lib/textus/cli/`) — CLI verbs. Parses flags, calls a use case, formats JSON.
892
- - **Application** (`lib/textus/application/`) — Use cases: `Reads::Get`, `Refresh::Worker`, `Refresh::Orchestrator`, `Refresh::All`. Orchestrate domain + infra; no business rules.
893
- - **Domain** (`lib/textus/domain/`) — Pure values: `Freshness::Policy`, `Action`, `Outcome`, `Freshness::Verdict`, `Freshness::Evaluator`. No I/O, no globals, testable without disk.
894
- - **Infrastructure** (`lib/textus/infra/`) — Adapters: `EventBus`, `Clock`, `Refresh::Lock`, `Refresh::Detached`. Wrap OS / library primitives.
891
+ - **Interface** (`lib/textus/cli/`, `lib/textus/mcp/`) — CLI verbs and the MCP gate. Parses flags / RPC, calls a use case, formats JSON.
892
+ - **Application** (`lib/textus/application/`) — Use cases: `Read::Get`, `Write::Put`, `Write::RefreshWorker`, `Write::RefreshOrchestrator`, `Write::RefreshAll`, `Maintenance::Migrate`, etc. Orchestrate domain + infra; no business rules.
893
+ - **Domain** (`lib/textus/domain/`) — Pure values: `Authorizer`, `Permission`, `Freshness::{Policy,Verdict,Evaluator}`, `Action`, `Outcome`, `Sentinel`, `Staleness`. No I/O, no globals, testable without disk.
894
+ - **Infrastructure** (`lib/textus/infra/`) — Adapters: `Storage::FileStore`, `AuditLog`, `AuditSubscriber`, `Publisher`, `Clock`, `Refresh::Lock`, `Refresh::Detached`, `BuildLock`. Wrap OS / library primitives.
895
895
 
896
896
  The `lib/textus/store/`, `lib/textus/manifest/`, `lib/textus/hooks/` namespaces are infrastructure adapters that predate this split and remain at their existing paths for backward-compat with the plugin DSL.
897
897
 
@@ -899,14 +899,14 @@ Plugin authors interact only with the Hook DSL (`Textus.hook { |reg| reg.on(:res
899
899
 
900
900
  Both read and write paths flow through the application layer:
901
901
 
902
- - **Reads** flow through `Application::Reads::Get`, which takes a `Context` and dispatches refresh via `Application::Refresh::Orchestrator`.
903
- - **Writes** flow through `Application::Writes::{Put,Delete,Build,Accept,Publish}`, each taking a `Context`. Permission checks happen at the use-case layer (via `Context#can_write?`); I/O happens at `Store::Writer#write_envelope_to_disk` (pure).
904
- - `Application::Context` is the universal request object: it carries `store`, `role`, `correlation_id`, `clock`, and `dry_run`. Use cases never thread these as separate kwargs.
905
- - `Textus::Operations` is the factory CLI verbs (and future MCP server / HTTP shim)
906
- use to construct Contexts and use cases. `Operations.for(store, role:)` returns
907
- a flat facade exposing one method per use case (`#put`, `#get`, `#refresh`, …);
908
- internal use-case instances are memoized via `||=` and live under
909
- `lib/textus/application/{reads,writes,refresh}/`.
902
+ - **Reads** flow through `Application::Read::Get` (pure read + freshness annotation) or `Read::GetOrRefresh` (composes Get with `Write::RefreshOrchestrator`). Each takes a `caps:` slice and an `Application::Context`.
903
+ - **Writes** flow through `Application::Write::{Put,Delete,Mv,Accept,Reject,Publish,RefreshWorker}`. Permission checks happen at the use-case layer (via `Domain::Authorizer#authorize_write!`); the audit-append invariant lives in `Application::Envelope::Writer`.
904
+ - `Application::Context` is the slim request record: `role`, `correlation_id`, `now`, `dry_run`. Ports come from a `Caps` record (Read/Write/Hook), not from the Context.
905
+ - `Textus::Session` is the factory CLI verbs and the MCP gate use to dispatch
906
+ use cases. `Session.for(store, role:)` returns a per-call object exposing one
907
+ method per registered use case (`#put`, `#get`, `#refresh`, …); methods are
908
+ generated from `Application::UseCase.entries` so adding a use case is a
909
+ single `UseCase.register(...)` line.
910
910
 
911
911
  See `ARCHITECTURE.md` for an ASCII diagram and the full read-path walkthrough.
912
912
 
data/docs/conventions.md CHANGED
@@ -126,6 +126,16 @@ Build always uses the pure path; injecting refresh into materialization caused t
126
126
 
127
127
  For multi-writer environments, **always pass `if_etag`** on `put`. The gem treats etag-less writes as last-writer-wins on purpose (single-writer scripts, fresh-file creation), but anything resembling a daemon or a long-running agent should round-trip the etag.
128
128
 
129
+ ## Application layering
130
+
131
+ The application layer is organised around three patterns — `Manifest` as a composition record, capability slices (`Caps`) handed to use cases, and a split envelope reader/writer. See [ADR 0018](architecture/decisions/0018-manifest-carving.md), [ADR 0017](architecture/decisions/0017-envelope-io-split.md), [ADR 0020](architecture/decisions/0020-capability-records.md), and [ADR 0021](architecture/decisions/0021-session-and-module-use-cases.md).
132
+
133
+ - **`Manifest` is a composition record** (`Data.define(:data, :resolver, :policy, :rules)`). Reach individual concerns through the field accessors: `manifest.data.entries`, `manifest.policy.permission_for(zone)`, `manifest.resolver.resolve(key)`, `manifest.rules.for(key)`. (The legacy top-level shims were removed in 0.26.0.)
134
+ - **Application use cases take `session:`, `ctx:`, `caps:`** and are registered with `Application::UseCase.register(:verb, mod, caps: :read|:write)`. Each use case is a module with a `self.call(...)` entry point and a nested `class Impl` for state. `Caps` is a `Data.define` slice of the Store (`ReadCaps`, `WriteCaps`, `HookCaps`) — use cases pull only what they need into ivars; nobody passes the raw `Store` around the application layer.
135
+ - **Write path is split**: `Application::Envelope::Reader` owns read/parse (existing-uid lookup, raw read, parse), and `Application::Envelope::Writer` owns put/delete/move + the audit-append invariant (every public method's final action is `@audit_log.append(...)`).
136
+
137
+ The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/3`) are unchanged.
138
+
129
139
  ## Pairing with other tools
130
140
 
131
141
  - **MCP servers**: a thin server that exposes `textus get` and `textus put` as tools is the recommended way to give Claude/agents access. Don't bake MCP into this gem.
@@ -0,0 +1,49 @@
1
+ module Textus
2
+ module Application
3
+ # Capability records: role-scoped slices of the Store handed to use cases.
4
+ # Zeitwerk maps this file to Textus::Application::Caps; the three
5
+ # concrete cap types are also promoted to the Application namespace for
6
+ # concise reference (Application::ReadCaps, etc.).
7
+ module Caps
8
+ ReadCaps = Data.define(:manifest, :file_store, :schemas, :root, :audit_log, :events)
9
+
10
+ WriteCaps = Data.define(
11
+ :manifest, :file_store, :schemas, :root,
12
+ :audit_log, :events, :authorizer
13
+ ) do
14
+ def read
15
+ ReadCaps.new(
16
+ manifest: manifest, file_store: file_store, schemas: schemas, root: root,
17
+ audit_log: audit_log, events: events
18
+ )
19
+ end
20
+ end
21
+
22
+ HookCaps = Data.define(:events, :rpc, :manifest, :root)
23
+ end
24
+
25
+ # Promote to Application namespace for concise reference.
26
+ ReadCaps = Caps::ReadCaps
27
+ WriteCaps = Caps::WriteCaps
28
+ HookCaps = Caps::HookCaps
29
+
30
+ def self.caps_from_store(store)
31
+ read = ReadCaps.new(
32
+ manifest: store.manifest, file_store: store.file_store,
33
+ schemas: store.schemas, root: store.root,
34
+ audit_log: store.audit_log, events: store.events
35
+ )
36
+ write = WriteCaps.new(
37
+ manifest: store.manifest, file_store: store.file_store,
38
+ schemas: store.schemas, root: store.root,
39
+ audit_log: store.audit_log, events: store.events,
40
+ authorizer: Textus::Domain::Authorizer.new(manifest: store.manifest)
41
+ )
42
+ hook = HookCaps.new(
43
+ events: store.events, rpc: store.rpc,
44
+ manifest: store.manifest, root: store.root
45
+ )
46
+ [read, write, hook]
47
+ end
48
+ end
49
+ end
@@ -7,8 +7,8 @@ module Textus
7
7
  # writes should be suppressed (dry_run).
8
8
  #
9
9
  # Collaborators (manifest, file_store, bus, audit log, authorizer) are
10
- # never read from Context — use cases declare them as explicit
11
- # constructor ports, and Operations wires them in from the Store.
10
+ # never read from Context — use cases pull them from a Caps record
11
+ # (Read/Write/Hook) that Session derives from the Store.
12
12
  Context = Data.define(:role, :correlation_id, :now, :dry_run) do
13
13
  def self.build(role:, correlation_id: nil, now: nil, dry_run: false)
14
14
  new(
@@ -0,0 +1,44 @@
1
+ module Textus
2
+ module Application
3
+ module Envelope
4
+ # Read-only counterpart to EnvelopeWriter. Resolves a key, reads the
5
+ # bytes, parses them via the format strategy, and hands back an
6
+ # Envelope. Used by Mv (pre-move inspection) and by EnvelopeWriter
7
+ # (existing-uid lookup for the uid-preservation step in #put).
8
+ #
9
+ # No audit, no events, no permission checks — those live one layer up.
10
+ class Reader
11
+ def initialize(file_store:, manifest:)
12
+ @file_store = file_store
13
+ @manifest = manifest
14
+ end
15
+
16
+ def read(key)
17
+ res = @manifest.resolver.resolve(key)
18
+ path = res.path
19
+ return nil unless @file_store.exists?(path)
20
+
21
+ mentry = res.entry
22
+ raw = @file_store.read(path)
23
+ parsed = Entry.for_format(mentry.format).parse(raw, path: path)
24
+ Textus::Envelope.build(
25
+ key: key, mentry: mentry, path: path,
26
+ meta: parsed["_meta"], body: parsed["body"],
27
+ etag: Etag.for_bytes(raw), content: parsed["content"]
28
+ )
29
+ end
30
+
31
+ def existing_uid(key)
32
+ env = read(key)
33
+ env&.uid
34
+ rescue StandardError
35
+ nil
36
+ end
37
+
38
+ def exists?(key)
39
+ @file_store.exists?(@manifest.resolver.resolve(key).path)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end