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
data/SPEC.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  ## 1. What textus is
12
12
 
13
- A storage convention and JSON wire protocol that lets humans, agents, and runners read and write structured project memory **deterministically**, with addressable dotted keys, schema validation, role-based write gates, declarative compute, and copy-based publish targets.
13
+ A storage convention and JSON wire protocol for humans, agents, and runners to read and write structured project memory **deterministically**. It provides addressable dotted keys, schema validation, role-based write gates, declarative compute, and copy-based publish targets.
14
14
 
15
15
  The storage lives in a `.textus/` directory at the project root. Each entry is a Markdown file with YAML frontmatter. A manifest binds dotted keys to subtrees and declares which roles may write to each zone. Schemas (also YAML) define what frontmatter shape each entry must have. Derived entries are computed from other entries via pure projections and a vendored Mustache template engine, then optionally published to repo-relative paths as byte-for-byte file copies. The CLI surface (`textus get/put/list/where/schema/build/...` `--output=json`) returns a versioned envelope any caller can parse without knowing Markdown.
16
16
 
@@ -68,10 +68,10 @@ The root is `.textus/` at the project working directory. A typical tree:
68
68
  .textus/
69
69
  manifest.yaml # internal: key → subtree mapping + zones declarations
70
70
  audit.log # internal, append-only NDJSON log of every successful write
71
- role # internal, role token (one line, e.g. "human")
72
71
  schemas/ # internal: YAML schema files
73
72
  templates/ # internal: Mustache templates referenced by derived entries
74
- parsers/ # internal: project-local parser extensions
73
+ hooks/ # internal: one Ruby file per hook
74
+ sentinels/ # internal: bookkeeping for byte-copied publish targets (see §5.3)
75
75
  zones/ # ALL user content lives here
76
76
  identity/ # zone: identity (human-only)
77
77
  working/ # zone: working (human, agent, runner)
@@ -80,7 +80,7 @@ The root is `.textus/` at the project working directory. A typical tree:
80
80
  output/ # zone: output (builder only — computed outputs)
81
81
  ```
82
82
 
83
- Textus internals (`manifest.yaml`, `audit.log`, `role`, `schemas/`, `templates/`, `parsers/`) live directly under `.textus/`. **All user content lives under `.textus/zones/`.** Manifest `path:` fields are relative to `.textus/zones/` — they do **not** include the `zones/` prefix. Implementations MUST prepend `zones/` to every `path:` when resolving a key to a filesystem location.
83
+ Textus internals (`manifest.yaml`, `audit.log`, `schemas/`, `templates/`, `hooks/`, `sentinels/`) live directly under `.textus/`. **All user content lives under `.textus/zones/`.** Manifest `path:` fields are relative to `.textus/zones/` — they do **not** include the `zones/` prefix. Implementations MUST prepend `zones/` to every `path:` when resolving a key to a filesystem location.
84
84
 
85
85
  Zone directories under `zones/` are conventional; their write semantics are declared in the manifest, not the directory name.
86
86
 
@@ -106,14 +106,19 @@ version: textus/3
106
106
 
107
107
  zones:
108
108
  - name: identity
109
+ kind: origin
109
110
  write_policy: [human]
110
111
  - name: working
112
+ kind: origin
111
113
  write_policy: [human, agent, runner]
112
114
  - name: intake
115
+ kind: quarantine
113
116
  write_policy: [runner]
114
117
  - name: review
118
+ kind: queue
115
119
  write_policy: [agent, human]
116
120
  - name: output
121
+ kind: derived
117
122
  write_policy: [builder]
118
123
 
119
124
  entries:
@@ -138,6 +143,10 @@ entries:
138
143
  rules:
139
144
  - match: intake.**
140
145
  refresh: { ttl: 6h, on_stale: warn }
146
+
147
+ audit:
148
+ max_size: 10485760 # bytes before rotating (default: 10 485 760 = 10 MiB)
149
+ keep: 5 # rotated files to retain (default: 5)
141
150
  ```
142
151
 
143
152
  Zone names are conventional — the manifest is the source of truth for write permissions; rename freely.
@@ -197,16 +206,27 @@ A leaf at `working.skills.writing.voice-writer` (authored at `.textus/zones/work
197
206
 
198
207
  Each zone declares which **roles** may write to it via `write_policy:` in the manifest. An optional `read_policy:` (default `[all]`) gates reads. Writes are gated; reads are unrestricted by default.
199
208
 
200
- | Zone | `write_policy` | Use case |
201
- |---|---|---|
202
- | `identity` | `[human]` | Identity, voice, immutable principles — things only a human edits. |
203
- | `working` | `[human, agent, runner]` | Active project state: notes, decisions, network — what humans and agents update day-to-day. |
204
- | `intake` | `[runner]` | Declared external inputs (calendar, feeds, scraped pages). Refreshed by external runner scripts; never by humans or agents directly. |
205
- | `review` | `[agent, human]` | Agent-generated proposals awaiting human review via `textus accept`. Lets agents stage changes without touching `working`. |
206
- | `output` | `[builder]` | Computed outputs (catalogs, indexes, published context). Written only by the build runner via `textus build`. |
209
+ | Zone | `kind` | `write_policy` | Use case |
210
+ |---|---|---|---|
211
+ | `identity` | `origin` | `[human]` | Identity, voice, immutable principles — things only a human edits. |
212
+ | `working` | `origin` | `[human, agent, runner]` | Active project state: notes, decisions, network — what humans and agents update day-to-day. |
213
+ | `intake` | `quarantine` | `[runner]` | Declared external inputs (calendar, feeds, scraped pages). Refreshed by external runner scripts; never by humans or agents directly. |
214
+ | `review` | `queue` | `[agent, human]` | Agent-generated proposals awaiting human review via `textus accept`. Lets agents stage changes without touching `working`. |
215
+ | `output` | `derived` | `[builder]` | Computed outputs (catalogs, indexes, published context). Written only by the build runner via `textus build`. |
207
216
 
208
217
  A write is gated by the caller's **role**, supplied via `--as=<role>`. If the role is not in the target zone's `write_policy` list, the write returns `write_forbidden`.
209
218
 
219
+ Every zone MUST declare a `kind:` describing its role in the data-flow graph.
220
+ The vocabulary is closed: `origin` (authored truth), `quarantine` (external
221
+ bytes pending validation), `queue` (proposals awaiting promotion), `derived`
222
+ (computed from other zones). A manifest MUST declare at most one `queue` zone,
223
+ and a zone's `kind:` MUST agree with its writers (`derived` ⇒ a `generator`
224
+ writer, `queue` ⇒ a `proposer`, `quarantine` ⇒ a `runner`; `origin` is
225
+ unconstrained). Coordination is keyed off the declared kind: a zone is derived
226
+ only if it declares `kind: derived`, and proposals route to the declared
227
+ `queue` zone — there is no name-based fallback. A manifest with a kind-less
228
+ zone is rejected at load.
229
+
210
230
  ### 5.1 Role resolution
211
231
 
212
232
  The effective role for any CLI invocation is resolved in this order; the first match wins:
@@ -397,7 +417,7 @@ In intake mode the handler MUST return one of three shapes, all normalized by th
397
417
 
398
418
  **Refresh paths.** Two are supported:
399
419
 
400
- 1. **In-process** — `textus refresh KEY --as=runner` resolves the entry's `intake.handler`, invokes the registered `:resolve_intake` hook with `(config:, store:, args: {})`, and writes the result under role `runner`.
420
+ 1. **In-process** — `textus refresh KEY --as=runner` resolves the entry's `intake.handler`, invokes the registered `:resolve_intake` hook with `(caps:, config:, args: {})`, and writes the result under role `runner`.
401
421
  2. **External runner** — a cron job or agent harness reads `textus list --zone=intake --stale --output=json`, fetches the source out of band, and pipes bytes back through `textus put KEY --as=runner --stdin`. The CLI verb `textus refresh stale [--prefix=K] [--zone=Z]` drives this loop in one shot.
402
422
 
403
423
  Both paths share the same role gate, audit-log entry, and `:entry_refreshed` event. User-supplied hooks live in `.textus/hooks/**/*.rb` and auto-load at `Store#initialize` — see §5.10 for the full hook contract.
@@ -439,6 +459,35 @@ Schema (one JSON object per line, no interior whitespace):
439
459
 
440
460
  For `mv`, the structural fields `from_key`, `to_key`, and `uid` appear at the top level of the JSON object. Remaining verb-specific data (e.g. `from_path`, `to_path`) is nested under an `extras` key. The `extras` key is omitted entirely when empty.
441
461
 
462
+ **Rotation.** After every successful append the implementation checks whether `audit.log` exceeds `max_size` bytes (checked inside the held `flock`, so the check sees the post-write size). If it does, the active log is rotated:
463
+
464
+ 1. The seq range (`min_seq`, `max_seq`) of the active log is scanned, and a JSON sidecar (`audit.log.1.meta.json`) is written with those values plus a `rotated_at` ISO 8601 timestamp.
465
+ 2. Existing rotated files are shifted: `audit.log.(N)` → `audit.log.(N+1)` for N = `keep-1` down to 1 (with their `.meta.json` sidecars).
466
+ 3. `audit.log` is renamed to `audit.log.1`.
467
+ 4. The file that would be shifted to `audit.log.(keep+1)` — i.e., `audit.log.keep` and its sidecar — is deleted before the shift.
468
+ 5. The next append creates a fresh `audit.log` via `O_CREAT`. Seq numbering continues from the previous maximum; there is no reset.
469
+
470
+ Rotation is triggered by **byte size only** — there is no row-count or time-based trigger.
471
+
472
+ **Rotation knobs** (configured via the optional `audit:` block in `manifest.yaml`):
473
+
474
+ | Key | Default | Meaning |
475
+ |------------|--------------|---------|
476
+ | `max_size` | `10485760` | Maximum size of `audit.log` in bytes (10 MiB) before rotation is triggered. |
477
+ | `keep` | `5` | Number of rotated files retained on disk. When this limit is exceeded the oldest rotated file and its sidecar are deleted. |
478
+
479
+ Both keys are optional. Omitting `audit:` entirely uses the defaults above.
480
+
481
+ **`CursorExpired`.** When `audit --seq-since=N` or `pulse --since=N` is called with a cursor `N`, the implementation checks whether `N` is below the oldest sequence number still available on disk (`min_available_seq`, derived from the oldest retained rotated file's sidecar). The condition that raises `CursorExpired` is:
482
+
483
+ ```
484
+ N < min_available_seq - 1
485
+ ```
486
+
487
+ The error includes `requested` (the supplied cursor value) and `min_available` (the oldest seq still on disk).
488
+
489
+ **Recommended caller behavior on `CursorExpired`.** Call `textus boot` (without `--since`) to obtain a fresh `latest_seq` from the current audit log state, then resume `pulse` calls using that new cursor. Do not attempt to replay from an expired cursor — the intervening rows are gone.
490
+
442
491
  ### 5.7 Security bounds
443
492
 
444
493
  textus enforces fixed bounds to keep behavior predictable under hostile or buggy input:
@@ -478,7 +527,7 @@ evolution:
478
527
 
479
528
  **Defaults:** when `fields:` and `evolution:` are absent, `schema.maintained_by(field)` returns `nil` for every field and `schema.evolution` returns `{}`.
480
529
 
481
- **Override rule:** the role `human` is permitted to write any `maintained_by` field, regardless of declared owner. This preserves human authority over agent/runner-managed data humans curating canon over agent-written embeddings is a feature, not a bug. All other role mismatches are reported by `doctor --check=schema_violations` with code `role_authority`, including fields `key`, `field`, `expected`, and `last_writer`.
530
+ **Override rule:** the role `human` is permitted to write any `maintained_by` field, regardless of declared owner. Humans override agent-maintained fields by design: schema field ownership (`maintained_by:`) makes the boundary explicit, not implicit. All other role mismatches are reported by `doctor --check=schema_violations` with code `role_authority`, including fields `key`, `field`, `expected`, and `last_writer`.
482
531
 
483
532
  ### 5.9 Row transforms
484
533
 
@@ -495,11 +544,11 @@ The subdirectory layout under `hooks/` is organizational only; the registered ev
495
544
  ```ruby
496
545
  # Canonical form — works for every event:
497
546
  Textus.hook do |reg|
498
- reg.on(:resolve_intake, :my_source) { |config:, args:, **| … }
499
- reg.on(:transform_rows, :rank_by_recency) { |rows:, **| … }
500
- reg.on(:validate, :storage_writable) { |store:| … }
501
- reg.on(:entry_put, :audit, keys: ["working.*"]) { |key:, envelope:, **| … }
502
- reg.on(:file_published, :git_add, keys: ["derived.*"]) { |target:, **| `git add #{target.shellescape}` }
547
+ reg.on(:resolve_intake, :my_source) { |caps:, config:, args:, **| … }
548
+ reg.on(:transform_rows, :rank_by_recency) { |caps:, rows:, **| … }
549
+ reg.on(:validate, :storage_writable) { |caps:| … }
550
+ reg.on(:entry_put, :audit, keys: ["working.*"]) { |ctx:, key:, envelope:, **| … }
551
+ reg.on(:file_published, :git_add, keys: ["derived.*"]) { |ctx:, target:, **| `git add #{target.shellescape}` }
503
552
  end
504
553
  ```
505
554
 
@@ -509,21 +558,21 @@ end
509
558
 
510
559
  | Event | Mode | Args | Return | Failure |
511
560
  |-------------------------|---------|-----------------------------------------------------------|-----------------------|---------------|
512
- | `:resolve_intake` | rpc | store:, config:, args: | {_meta:, body:} | aborts op |
513
- | `:transform_rows` | rpc | store:, rows:, config: | rows array | aborts op |
514
- | `:validate` | rpc | store: | issues array | aborts doctor |
515
- | `:entry_put` | pubsub | store:, key:, envelope: | (discarded) | logged |
516
- | `:entry_deleted` | pubsub | store:, key: | (discarded) | logged |
517
- | `:entry_refreshed` | pubsub | store:, key:, envelope:, change: | (discarded) | logged |
518
- | `:build_completed` | pubsub | store:, key:, envelope:, sources: | (discarded) | logged |
519
- | `:proposal_accepted` | pubsub | store:, key:, target_key: | (discarded) | logged |
520
- | `:file_published` | pubsub | store:, key:, envelope:, source:, target: | (discarded) | logged |
521
- | `:entry_renamed` | pubsub | store:, key:, from_key:, to_key:, envelope: | (discarded) | logged |
522
- | `:proposal_rejected` | pubsub | store:, key:, target_key: | (discarded) | logged |
523
- | `:store_loaded` | pubsub | store: | (discarded) | logged |
524
- | `:refresh_started` | pubsub | store:, key:, mode: | (discarded) | logged |
525
- | `:refresh_failed` | pubsub | store:, key:, error_class:, error_message: | (discarded) | logged |
526
- | `:refresh_backgrounded` | pubsub | store:, key:, started_at:, budget_ms: | (discarded) | logged |
561
+ | `:resolve_intake` | rpc | caps:, config:, args: | {_meta:, body:} | aborts op |
562
+ | `:transform_rows` | rpc | caps:, rows:, config: | rows array | aborts op |
563
+ | `:validate` | rpc | caps: | issues array | aborts doctor |
564
+ | `:entry_put` | pubsub | ctx:, key:, envelope: | (discarded) | logged |
565
+ | `:entry_deleted` | pubsub | ctx:, key: | (discarded) | logged |
566
+ | `:entry_refreshed` | pubsub | ctx:, key:, envelope:, change: | (discarded) | logged |
567
+ | `:build_completed` | pubsub | ctx:, key:, envelope:, sources: | (discarded) | logged |
568
+ | `:proposal_accepted` | pubsub | ctx:, key:, target_key: | (discarded) | logged |
569
+ | `:file_published` | pubsub | ctx:, key:, envelope:, source:, target: | (discarded) | logged |
570
+ | `:entry_renamed` | pubsub | ctx:, key:, from_key:, to_key:, envelope: | (discarded) | logged |
571
+ | `:proposal_rejected` | pubsub | ctx:, key:, target_key: | (discarded) | logged |
572
+ | `:store_loaded` | pubsub | ctx: | (discarded) | logged |
573
+ | `:refresh_started` | pubsub | ctx:, key:, mode: | (discarded) | logged |
574
+ | `:refresh_failed` | pubsub | ctx:, key:, error_class:, error_message: | (discarded) | logged |
575
+ | `:refresh_backgrounded` | pubsub | ctx:, key:, started_at:, budget_ms: | (discarded) | logged |
527
576
 
528
577
  The three `:refresh_*` lifecycle events report the progress and failures of background (timed_sync) refreshes.
529
578
 
@@ -533,14 +582,19 @@ The three `:refresh_*` lifecycle events report the progress and failures of back
533
582
 
534
583
  **`:refresh_backgrounded`** fires when a `timed_sync` refresh exceeds its budget and is handed off to a background thread. `started_at:` is an ISO-8601 UTC string; `budget_ms:` is the configured deadline as an integer.
535
584
 
536
- **Signature invariant** — every hook receives `store:` as its first keyword argument. Event-specific kwargs follow in stable left-to-right order. The primary entity is always `key:` (for `:proposal_accepted`, `key:` is the pending key being accepted and `target_key:` is the destination). For `:entry_renamed`, `key:` is present and equals `to_key:` — it is the entry's post-move home, present so `keys:` glob filters route correctly; `from_key:` is the prior key. For `:proposal_rejected`, `key:` is the pending key being rejected. For `:store_loaded`, no key — the event observes store readiness, not an entry.
585
+ **Signature invariant** — hooks receive a capability handle as their first keyword argument; the name depends on the mode:
586
+
587
+ - **RPC hooks** (`rpc` mode) receive `caps:` — a `Textus::Container`. Event-specific kwargs (`config:`, `args:`, `rows:`) follow in the stable order shown in the table above.
588
+ - **Pub-sub hooks** (`pubsub` mode) receive `ctx:` — a `Textus::Hooks::Context` that exposes a narrow surface: `get`, `list`, `deps`, `freshness` (reads), `put`, `delete`, `audit` (authorized writes), `publish_followup`, plus `role` and `correlation_id`. The raw `Store` is not handed out.
589
+
590
+ Declaring `store:` instead of `caps:` in an RPC callable will pass registration but raise `UsageError` at call time (`Hooks::RpcRegistry#invoke` rejects `store:` — there is no shim).
591
+
592
+ The primary entity is always `key:` (for `:proposal_accepted`, `key:` is the pending key being accepted and `target_key:` is the destination). For `:entry_renamed`, `key:` is present and equals `to_key:` — it is the entry's post-move home, present so `keys:` glob filters route correctly; `from_key:` is the prior key. For `:proposal_rejected`, `key:` is the pending key being rejected. For `:store_loaded`, no key — the event observes store readiness, not an entry.
537
593
 
538
594
  **RPC mode** — exactly one handler per (event, name). The manifest references the handler by name (`intake.handler: NAME`, `compute.transform: NAME`). Failure or timeout aborts the calling operation.
539
595
 
540
596
  **Pub-sub mode** — zero or more handlers per event. All matching handlers fire. The `keys:` option restricts a handler to keys matching one of the given globs (`File.fnmatch?` rules). Absence of `keys:` fires on every event of that type. Handler failures and 2s timeouts are logged to `audit.log` as `event_error` rows; they NEVER abort the triggering operation.
541
597
 
542
- The `store:` argument is always a read-only store proxy. Write attempts raise `UsageError`.
543
-
544
598
  Each handler runs under `Timeout.timeout(2)`.
545
599
 
546
600
  ### 5.11 Rules
@@ -568,7 +622,15 @@ rules:
568
622
  | `refresh` | `{ ttl, on_stale, sync_budget_ms }` | Freshness budget for intake entries. `on_stale` is `warn` (default), `sync`, or `timed_sync`. |
569
623
  | `intake_handler_allowlist` | list of strings | Constrains which `intake.handler:` names may be used by entries matched by this block. Enforced by `textus doctor`. |
570
624
  | `promotion` | `{ requires: [...] }` | Predicates a `review` entry must satisfy before `textus accept` will promote it. Built-in predicates: `schema_valid` (entry passes schema validation) and `human_accept` (the accepting role must be `human`). Additional predicates may be registered via `:validate` hooks. Enforced — `textus accept` refuses if any predicate fails. |
571
- | `retention` | (reserved) | Slot reserved for future retention policy (cap by age / count). Implementations parse it but otherwise ignore. |
625
+ | `retention` | `{ expire_after:, archive_after: }` | Pruning policy for matched leaves. Duration strings: `30s`, `90m`, `12h`, `30d`, or bare integer seconds. `textus retain --as=ROLE` sweeps matched leaves: `expire_after` is checked first, so a leaf older than `expire_after` is deleted (and audited); otherwise a leaf older than `archive_after` is copied to `<store>/archive/<relative-path>` and then deleted. Age is measured from the leaf file's modification time. The `--as` role must be allowed to write the matched zone. |
626
+
627
+ Both retention windows are optional, and `expire_after` is evaluated before
628
+ `archive_after` — so when both apply, a leaf past the (longer) `expire_after`
629
+ window is deleted rather than archived. The usual configuration is therefore
630
+ `archive_after < expire_after` (archive a leaf, then delete it once older).
631
+ `textus retain --as=ROLE` runs the sweep; `--prefix` and `--zone` narrow it, and
632
+ any leaf whose zone the `--as` role cannot write is reported as a failure rather
633
+ than aborting the run.
572
634
 
573
635
  **Match grammar.** `match:` is a single glob using `*` (single segment) and `**` (any depth). A literal segment ranks more specifically than `*`; `*` ranks more specifically than `**`.
574
636
 
@@ -899,14 +961,13 @@ Plugin authors interact only with the Hook DSL (`Textus.hook { |reg| reg.on(:res
899
961
 
900
962
  Both read and write paths flow through the application layer:
901
963
 
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.
964
+ - **Reads** flow through `Textus::Read::Get` (pure read + freshness annotation) or `Read::GetOrRefresh` (composes Get with `Write::RefreshOrchestrator`). Each takes a `container:` and a `call:`.
965
+ - **Writes** flow through `Textus::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 `Textus::Envelope::IO::Writer`.
966
+ - `Textus::Call` is the slim per-invocation record: `role`, `correlation_id`, `now`, `dry_run`. Ports come from `Textus::Container`, not from the Call.
967
+ - `Textus::Store` is the composition root and verb dispatcher. CLI verbs and the
968
+ MCP gate call `store.<verb>(..., role:)` (or `store.as(role).<verb>(...)`).
969
+ Verbs are looked up in the static `Textus::Dispatcher::VERBS` table; adding a
970
+ use case is a single entry in `VERBS` plus the class.
910
971
 
911
972
  See `ARCHITECTURE.md` for an ASCII diagram and the full read-path walkthrough.
912
973
 
@@ -914,7 +975,7 @@ See `ARCHITECTURE.md` for an ASCII diagram and the full read-path walkthrough.
914
975
 
915
976
  - **Locking on `put`:** the reference impl uses sha256 etags. Should the spec also define a file-lock fallback for systems where read-before-write is racy?
916
977
  - **Schema imports:** can one schema reference another (`type: $ref: person`)?
917
- - **Internationalization:** non-ASCII in keys? Spec currently restricts segments to `[a-z0-9_-]`. Revisit if community wants Unicode.
978
+ - **Internationalization:** non-ASCII in keys? Spec currently restricts segments to `[a-z0-9][a-z0-9-]*`. Revisit if community wants Unicode.
918
979
  - **Generated content in `derived/`:** the spec says `schema: null` is allowed, but should there be a separate marker (`generated: true`) for clarity?
919
980
 
920
981
  ## 15. Implementation checklist
data/docs/conventions.md CHANGED
@@ -128,11 +128,11 @@ For multi-writer environments, **always pass `if_etag`** on `put`. The gem treat
128
128
 
129
129
  ## Application layering
130
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).
131
+ The application layer is organised around three shapes — `Manifest` as a composition record, a single `Container` capability record handed to every use case, and a split envelope reader/writer. See [ADR 0018](architecture/decisions/0018-manifest-carving.md), [ADR 0017](architecture/decisions/0017-envelope-io-split.md), [ADR 0022](architecture/decisions/0022-container-call-dispatcher.md), and [ADR 0023](architecture/decisions/0023-uniform-use-case-shape.md).
132
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(...)`).
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)`.
134
+ - **Use cases are plain `(container:, call:)` classes.** Each is a single class under `lib/textus/{read,write,maintenance}/` with `def initialize(container:, call:)` and a `#call(...)` method; verbs are looked up in the static `Textus::Dispatcher::VERBS` table. `Container` is a `Data.define` record bundling the wired ports + manifest (`manifest`, `file_store`, `schemas`, `root`, `audit_log`, `events`, `rpc`, `authorizer`); `Call` is the immutable per-invocation value (`role`, `correlation_id`, `now`, `dry_run`). A use case that emits events derives its `Hooks::Context` from `(container, call)` — nothing is injected. Use cases pull only what they need into ivars; nobody passes the raw `Store` around the application layer. `store.as(role)` returns a `RoleScope` that forwards verbs to the dispatcher.
135
+ - **Write path is split**: `Envelope::IO::Reader` owns read/parse (existing-uid lookup, raw read, parse), and `Envelope::IO::Writer` owns put/delete/move + the audit-append invariant (every public method's final action is `@audit_log.append(...)`).
136
136
 
137
137
  The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/3`) are unchanged.
138
138
 
data/lib/textus/boot.rb CHANGED
@@ -120,7 +120,7 @@ module Textus
120
120
  "summary" => "delta since cursor — changed entries, stale, pending review, doctor summary" },
121
121
  ].freeze
122
122
 
123
- def self.agent_quickstart(manifest, session)
123
+ def self.agent_quickstart(manifest, audit_log)
124
124
  proposer_roles = manifest.policy.roles_with_kind(:proposer)
125
125
  agent_role = proposer_roles.first
126
126
 
@@ -128,14 +128,14 @@ module Textus
128
128
  acc << zname if agent_role && writers.include?(agent_role)
129
129
  end
130
130
 
131
- propose_zone = writable_zones.find { |z| z.include?("review") } || writable_zones.first
131
+ propose_zone = manifest.policy.propose_zone_for(agent_role)
132
132
 
133
133
  {
134
134
  "read_verbs" => %w[boot get list audit pulse freshness doctor],
135
135
  "write_verbs" => agent_role ? ["put KEY --as=#{agent_role} --stdin"] : [],
136
136
  "writable_zones" => writable_zones,
137
137
  "propose_zone" => propose_zone,
138
- "latest_seq" => session.write_caps.audit_log.latest_seq,
138
+ "latest_seq" => audit_log.latest_seq,
139
139
  }
140
140
  end
141
141
 
@@ -150,18 +150,18 @@ module Textus
150
150
  )
151
151
  end
152
152
 
153
- def self.run(session)
154
- manifest = session.read_caps.manifest
153
+ def self.build(container:)
154
+ manifest = container.manifest
155
155
  {
156
156
  "protocol" => PROTOCOL_ID,
157
- "store_root" => session.read_caps.root,
157
+ "store_root" => container.root,
158
158
  "zones" => zones_for(manifest),
159
159
  "entries" => entries_for(manifest),
160
- "hooks" => hooks_for(session),
160
+ "hooks" => hooks_for_container(container),
161
161
  "write_flows" => write_flows_for(manifest),
162
162
  "cli_verbs" => CLI_VERBS.map(&:dup),
163
163
  "agent_protocol" => agent_protocol(manifest),
164
- "agent_quickstart" => agent_quickstart(manifest, session),
164
+ "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
165
165
  "docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
166
166
  }
167
167
  end
@@ -169,6 +169,8 @@ module Textus
169
169
  def self.zones_for(manifest)
170
170
  manifest.data.zones.map do |name, writers|
171
171
  row = { "name" => name, "writers" => Array(writers) }
172
+ kind = manifest.policy.declared_kind(name)
173
+ row["kind"] = kind.to_s if kind
172
174
  purpose = ZONE_PURPOSES[name]
173
175
  row["purpose"] = purpose if purpose
174
176
  row
@@ -177,7 +179,7 @@ module Textus
177
179
 
178
180
  def self.entries_for(manifest)
179
181
  manifest.data.entries.map do |e|
180
- derived = manifest.policy.zone_kinds(e.zone).include?(:generator)
182
+ derived = manifest.policy.derived_zone?(e.zone)
181
183
  {
182
184
  "key" => e.key,
183
185
  "zone" => e.zone,
@@ -193,13 +195,17 @@ module Textus
193
195
  end
194
196
  end
195
197
 
196
- def self.hooks_for(session)
198
+ def self.hooks_for_container(container)
199
+ hooks_for_container_internal(rpc: container.rpc, events: container.events)
200
+ end
201
+
202
+ def self.hooks_for_container_internal(rpc:, events:)
197
203
  sections = {}
198
204
  Hooks::RpcRegistry::EVENTS.each_key do |event|
199
- sections[event.to_s] = session.rpc.names(event).map(&:to_s).sort
205
+ sections[event.to_s] = rpc.names(event).map(&:to_s).sort
200
206
  end
201
207
  Hooks::EventBus::EVENTS.each_key do |event|
202
- sections[event.to_s] = session.events.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
208
+ sections[event.to_s] = events.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
203
209
  end
204
210
  sections
205
211
  end
@@ -53,6 +53,10 @@ module Textus
53
53
  end
54
54
 
55
55
  module Pipeline
56
+ Deps = Data.define(
57
+ :manifest, :reader, :lister, :rpc, :template_loader, :transform_context, :inject_boot
58
+ )
59
+
56
60
  def self.renderers
57
61
  @renderers ||= {
58
62
  "markdown" => Renderer::Markdown,
@@ -62,37 +66,34 @@ module Textus
62
66
  }
63
67
  end
64
68
 
65
- # rubocop:disable Metrics/ParameterLists
66
- def self.run(mentry:, manifest:, reader:, lister:, rpc:, template_loader:,
67
- transform_context: nil, inject_boot: nil)
69
+ def self.run(mentry:, deps:)
68
70
  # 1. Load sources + project + reduce
69
71
  data =
70
72
  if mentry.is_a?(Textus::Manifest::Entry::Derived) && mentry.projection?
71
- Application::Projection.new(
72
- reader: reader,
73
+ Textus::Projection.new(
74
+ reader: deps.reader,
73
75
  spec: mentry.source.to_h.transform_keys(&:to_s),
74
- lister: lister,
75
- rpc: rpc,
76
- transform_context: transform_context,
76
+ lister: deps.lister,
77
+ rpc: deps.rpc,
78
+ transform_context: deps.transform_context,
77
79
  ).run
78
80
  else
79
81
  { "entries" => [], "count" => 0, "generated_at" => Time.now.utc.iso8601 }
80
82
  end
81
- data = data.merge("boot" => inject_boot.call) if mentry.inject_boot && inject_boot
83
+ data = data.merge("boot" => deps.inject_boot.call) if mentry.inject_boot && deps.inject_boot
82
84
 
83
85
  # 2. Render
84
86
  klass = renderers[mentry.format] or
85
87
  raise UsageError.new("builder: unsupported format #{mentry.format.inspect} for '#{mentry.key}'")
86
- bytes = klass.new(template_loader: template_loader).call(mentry: mentry, data: data)
88
+ bytes = klass.new(template_loader: deps.template_loader).call(mentry: mentry, data: data)
87
89
 
88
90
  # 3. Write (idempotent: skip if only generated_at would differ)
89
- target_path = Key::Path.resolve(manifest.data, mentry)
91
+ target_path = Key::Path.resolve(deps.manifest.data, mentry)
90
92
  FileUtils.mkdir_p(File.dirname(target_path))
91
93
  write_if_changed(target_path, bytes, mentry.format)
92
94
 
93
95
  target_path
94
96
  end
95
- # rubocop:enable Metrics/ParameterLists
96
97
 
97
98
  def self.write_if_changed(target_path, bytes, format)
98
99
  if File.exist?(target_path)
@@ -0,0 +1,28 @@
1
+ require "securerandom"
2
+
3
+ module Textus
4
+ # Immutable per-invocation value. Carries who is acting (role), the
5
+ # request correlation id, the wall clock, and the dry_run flag — the
6
+ # bits Use Cases need that are not part of the Container.
7
+ Call = Data.define(:role, :correlation_id, :now, :dry_run) do
8
+ def self.build(role:, correlation_id: nil, now: nil, dry_run: false)
9
+ new(
10
+ role: role.to_s,
11
+ correlation_id: correlation_id || SecureRandom.uuid,
12
+ now: now || Textus::Ports::Clock.now,
13
+ dry_run: dry_run,
14
+ )
15
+ end
16
+
17
+ def dry_run? = dry_run
18
+
19
+ def with_role(new_role)
20
+ self.class.new(
21
+ role: new_role.to_s,
22
+ correlation_id: correlation_id,
23
+ now: now,
24
+ dry_run: dry_run,
25
+ )
26
+ end
27
+ end
28
+ end
@@ -15,7 +15,7 @@ module Textus
15
15
 
16
16
  def call(store)
17
17
  ops = session_for(store)
18
- since_time = since && Textus::Application::Read::Audit.parse_since(since, now: ops.ctx.now)
18
+ since_time = since && Textus::Read::Audit.parse_since(since, now: Time.now)
19
19
  rows = ops.audit(
20
20
  key: key_filter,
21
21
  zone: zone,
@@ -5,7 +5,7 @@ module Textus
5
5
  command_name "boot"
6
6
 
7
7
  def call(store)
8
- emit(Textus::Boot.run(Textus::Session.for(store)))
8
+ emit(store.boot)
9
9
  end
10
10
  end
11
11
  end
@@ -7,9 +7,9 @@ module Textus
7
7
  option :prefix, "--prefix=K"
8
8
 
9
9
  def call(store)
10
- Textus::Infra::BuildLock.with(root: store.root) do
10
+ Textus::Ports::BuildLock.with(root: store.root) do
11
11
  role = store.manifest.policy.roles_with_kind(:generator).first || "builder"
12
- ops = store.session(role: role)
12
+ ops = store.as(role)
13
13
  result = ops.publish(prefix: prefix)
14
14
  emit(result)
15
15
  end
@@ -8,7 +8,7 @@ module Textus
8
8
 
9
9
  def call(store)
10
10
  check_list = checks&.split(",")&.map(&:strip)
11
- res = Textus::Doctor.run(Textus::Session.for(store), checks: check_list)
11
+ res = store.doctor(checks: check_list)
12
12
  emit(res, exit_code: res["ok"] ? 0 : 1)
13
13
  end
14
14
  end
@@ -29,12 +29,8 @@ module Textus
29
29
  Role.resolve(flag: as_flag, env: ENV, root: store.root)
30
30
 
31
31
  begin
32
- Timeout.timeout(Textus::Application::Write::RefreshWorker::FETCH_TIMEOUT_SECONDS) do
33
- store.rpc.invoke(:resolve_intake, name, caps: nil, config: {}, args: args)
34
- end
35
- rescue Timeout::Error
36
- raise UsageError.new(
37
- "hook run '#{name}' exceeded #{Textus::Application::Write::RefreshWorker::FETCH_TIMEOUT_SECONDS}s timeout",
32
+ Textus::Write::IntakeFetch.invoke(
33
+ rpc: store.rpc, handler: name, config: {}, args: args, label: "hook run",
38
34
  )
39
35
  rescue Textus::Error
40
36
  raise
@@ -17,19 +17,10 @@ module Textus
17
17
  raw = @stdin.read
18
18
  payload =
19
19
  if fetch_name
20
- result =
21
- begin
22
- Timeout.timeout(Textus::Application::Write::RefreshWorker::FETCH_TIMEOUT_SECONDS) do
23
- store.rpc.invoke(:resolve_intake, fetch_name,
24
- caps: nil,
25
- config: { "bytes" => raw },
26
- args: {})
27
- end
28
- rescue Timeout::Error
29
- raise UsageError.new(
30
- "fetch '#{fetch_name}' exceeded #{Textus::Application::Write::RefreshWorker::FETCH_TIMEOUT_SECONDS}s timeout",
31
- )
32
- end
20
+ result = Textus::Write::IntakeFetch.invoke(
21
+ rpc: store.rpc, handler: fetch_name,
22
+ config: { "bytes" => raw }, args: {}, label: "fetch"
23
+ )
33
24
  basename = key.split(".").last
34
25
  {
35
26
  "_meta" => {
@@ -46,7 +37,7 @@ module Textus
46
37
  meta = payload["_meta"] || {}
47
38
  body = payload["body"] || ""
48
39
  if_etag = payload["if_etag"]
49
- result = store.session(role: role).put(key, meta: meta, body: body, if_etag: if_etag)
40
+ result = store.as(role).put(key, meta: meta, body: body, if_etag: if_etag)
50
41
  emit(result.to_h_for_wire)
51
42
  end
52
43
  end
@@ -0,0 +1,19 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Retain < Verb
5
+ command_name "retain"
6
+
7
+ option :prefix, "--prefix=KEY"
8
+ option :zone, "--zone=Z"
9
+ option :as_flag, "--as=ROLE"
10
+
11
+ def call(store)
12
+ result = session_for(store).retention_sweep(prefix: prefix, zone: zone)
13
+ emit(result)
14
+ result["ok"] ? 0 : 1
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -18,7 +18,7 @@ module Textus
18
18
  end
19
19
  row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
20
20
  row["promotion"] = { "requires" => b.promote.requires } if b.promote
21
- row["retention"] = b.retention if b.retention
21
+ row["retention"] = { "expire_after" => b.retention.expire_after, "archive_after" => b.retention.archive_after } if b.retention
22
22
  row
23
23
  end
24
24
  emit({ "verb" => "policy_list", "policies" => policies })
@@ -96,16 +96,16 @@ module Textus
96
96
  Role.resolve(flag: flag, env: ENV, root: store.root)
97
97
  end
98
98
 
99
- # Returns an Application::Context bound to the resolved role.
100
- # Convenience for verbs whose only pre-call boilerplate is
101
- # resolving the role and wrapping it in a context.
99
+ # Returns a Call value bound to the resolved role. Convenience for
100
+ # verbs whose only pre-call boilerplate is resolving the role and
101
+ # wrapping it in a Call.
102
102
  def context_for(store)
103
- store.session(role: resolved_role(store)).ctx
103
+ Textus::Call.build(role: resolved_role(store))
104
104
  end
105
105
 
106
- # Returns a Session instance bound to the resolved role.
106
+ # Returns a RoleScope bound to the resolved role.
107
107
  def session_for(store)
108
- store.session(role: resolved_role(store))
108
+ store.as(resolved_role(store))
109
109
  end
110
110
  end
111
111
  end