textus 0.18.0 → 0.20.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +43 -48
  3. data/CHANGELOG.md +173 -0
  4. data/lib/textus/application/context.rb +20 -58
  5. data/lib/textus/application/policy/predicates/human_accept.rb +30 -0
  6. data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
  7. data/lib/textus/{domain → application}/policy/promotion.rb +20 -3
  8. data/lib/textus/application/projection.rb +91 -0
  9. data/lib/textus/application/reads/audit.rb +4 -4
  10. data/lib/textus/application/reads/blame.rb +9 -8
  11. data/lib/textus/application/reads/deps.rb +14 -3
  12. data/lib/textus/application/reads/freshness.rb +10 -8
  13. data/lib/textus/application/reads/get.rb +10 -8
  14. data/lib/textus/application/reads/get_or_refresh.rb +3 -3
  15. data/lib/textus/application/reads/list.rb +3 -3
  16. data/lib/textus/application/reads/policy_explain.rb +3 -3
  17. data/lib/textus/application/reads/published.rb +5 -3
  18. data/lib/textus/application/reads/rdeps.rb +15 -3
  19. data/lib/textus/application/reads/schema_envelope.rb +5 -4
  20. data/lib/textus/application/reads/stale.rb +3 -3
  21. data/lib/textus/application/reads/uid.rb +11 -3
  22. data/lib/textus/application/reads/validate_all.rb +10 -6
  23. data/lib/textus/application/reads/validator.rb +2 -2
  24. data/lib/textus/application/reads/where.rb +3 -3
  25. data/lib/textus/application/refresh/all.rb +15 -11
  26. data/lib/textus/application/refresh/orchestrator.rb +9 -8
  27. data/lib/textus/application/refresh/worker.rb +56 -32
  28. data/lib/textus/application/tools/migrate_keys.rb +191 -0
  29. data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +31 -0
  30. data/lib/textus/application/writes/accept.rb +38 -15
  31. data/lib/textus/application/writes/delete.rb +13 -10
  32. data/lib/textus/application/writes/envelope_io.rb +64 -4
  33. data/lib/textus/application/writes/materializer.rb +50 -0
  34. data/lib/textus/application/writes/mv.rb +57 -94
  35. data/lib/textus/application/writes/publish.rb +132 -26
  36. data/lib/textus/application/writes/put.rb +15 -14
  37. data/lib/textus/application/writes/reject.rb +20 -11
  38. data/lib/textus/builder/pipeline.rb +21 -15
  39. data/lib/textus/builder/renderer/json.rb +4 -1
  40. data/lib/textus/builder/renderer/markdown.rb +7 -1
  41. data/lib/textus/builder/renderer/yaml.rb +4 -1
  42. data/lib/textus/cli/verb/build.rb +2 -5
  43. data/lib/textus/cli/verb/get.rb +1 -1
  44. data/lib/textus/cli/verb/hook_run.rb +3 -4
  45. data/lib/textus/cli/verb/hooks.rb +5 -5
  46. data/lib/textus/cli/verb/key_normalize.rb +32 -3
  47. data/lib/textus/cli/verb/put.rb +2 -3
  48. data/lib/textus/cli/verb/refresh_stale.rb +1 -2
  49. data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
  50. data/lib/textus/doctor/check/hooks.rb +2 -2
  51. data/lib/textus/doctor/check/illegal_keys.rb +6 -5
  52. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  53. data/lib/textus/doctor/check/manifest_files.rb +1 -1
  54. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  55. data/lib/textus/doctor/check/templates.rb +4 -3
  56. data/lib/textus/doctor.rb +3 -4
  57. data/lib/textus/domain/authorizer.rb +37 -0
  58. data/lib/textus/domain/staleness/generator_check.rb +8 -7
  59. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  60. data/lib/textus/hooks/builtin.rb +6 -6
  61. data/lib/textus/hooks/bus.rb +155 -0
  62. data/lib/textus/hooks/context.rb +38 -0
  63. data/lib/textus/hooks/fire_report.rb +23 -0
  64. data/lib/textus/hooks/loader.rb +3 -3
  65. data/lib/textus/infra/audit_subscriber.rb +4 -4
  66. data/lib/textus/infra/event_bus.rb +3 -3
  67. data/lib/textus/infra/refresh/detached.rb +1 -1
  68. data/lib/textus/init.rb +3 -2
  69. data/lib/textus/intro.rb +7 -7
  70. data/lib/textus/manifest/entry/base.rb +38 -0
  71. data/lib/textus/manifest/entry/derived.rb +25 -0
  72. data/lib/textus/manifest/entry/intake.rb +19 -0
  73. data/lib/textus/manifest/entry/leaf.rb +16 -0
  74. data/lib/textus/manifest/entry/nested.rb +39 -0
  75. data/lib/textus/manifest/entry/parser.rb +64 -31
  76. data/lib/textus/manifest/entry/validators/events.rb +3 -2
  77. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
  78. data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
  79. data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
  80. data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
  81. data/lib/textus/manifest/entry.rb +0 -72
  82. data/lib/textus/manifest/resolver.rb +109 -0
  83. data/lib/textus/manifest/schema.rb +1 -1
  84. data/lib/textus/manifest.rb +3 -100
  85. data/lib/textus/operations.rb +131 -74
  86. data/lib/textus/schema/tools.rb +2 -2
  87. data/lib/textus/store.rb +6 -6
  88. data/lib/textus/version.rb +1 -1
  89. metadata +18 -11
  90. data/lib/textus/application/writes/build.rb +0 -78
  91. data/lib/textus/dependencies.rb +0 -23
  92. data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
  93. data/lib/textus/hooks/dispatcher.rb +0 -71
  94. data/lib/textus/hooks/registry.rb +0 -85
  95. data/lib/textus/migrate_keys.rb +0 -187
  96. data/lib/textus/projection.rb +0 -89
  97. data/lib/textus/refresh.rb +0 -39
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d7915be053bc7e858c821e0e8d8e8cebd3de6acde21dcd8f687ff3fa12db3d1
4
- data.tar.gz: 792cd40df3e8046c954be84dc32d4e53a2b47cdfd9a21029c74953d39e182a87
3
+ metadata.gz: 613e04300389a5f5bf058f4e75c15b5ff19f813fb7ef6102ba38ce53ecd43043
4
+ data.tar.gz: 97d121b40ac753af1e04893429db1abfe4044f791fff5ea02de33910a4cfa00c
5
5
  SHA512:
6
- metadata.gz: 457d079d8ebf25922f9389086ec2de99b96808f23fba7f600655fb2ef055a5a987dd1ea63924aaf9904c7f4e600b4aee33a8c2082028bbb9025068aa00de3b0e
7
- data.tar.gz: a1192a6027b80582723b0cb4679b870ed78101864e22700ba7630308912f46000e774dbd69c589418516047dff2f8cdbf7a26e69e47d3a7ffcef89f917397a23
6
+ metadata.gz: 13d11e92b35b952fc9974293544ab49d50dc6055f7ee80771b33a82f511ba534f6cf3fa8c26a84e886778f6b5ee662a3a540d36c7072c23f2b366540a365e066
7
+ data.tar.gz: 7cbf43d42e37271b73122d5db10a463a1936c39696cc6a5e7eee56646ccdb1c503a645e63b3b40e09bfc911ae531e2258b02d4e715458bcd54a193d9e7ca5b0d
data/ARCHITECTURE.md CHANGED
@@ -5,90 +5,85 @@
5
5
  │ CLI verbs: ops = Operations.for(store, role:) │
6
6
  │ ops.<name>(...) # flat methods, one per use │
7
7
  │ # case (put/get/refresh/…) │
8
+ │ │
9
+ │ Or, for embedders bringing their own ports: │
10
+ │ Operations.new(ctx:, manifest:, file_store:, │
11
+ │ schemas:, audit_log:, bus:, │
12
+ │ registry:, root:, store:) │
8
13
  └──────────────────────┬─────────────────────────────────────┘
9
14
 
10
15
  ┌─ Application ────────▼─────────────────────────────────────┐
11
- │ Context (per-request: store, role, correlation,
12
- clock, dry_run; can_read?/can_write?;
13
- authorize_read!/authorize_write!; bus;
14
- │ Context.system(store) for infra path) │
15
- │ Operations (flat facade — memoized use cases) │
16
+ │ Context (slim Data: role, correlation_id, now,
17
+ │ dry_run — request state only)
18
+ Operations (flat facade; inline use-case factories)
16
19
  │ │
17
20
  │ reads/{get,list,where,uid,schema_envelope,deps,rdeps, │
18
21
  │ published,stale,validate_all,freshness,audit, │
19
- │ blame,policy_explain}.rb
20
- │ writes/{put,delete,mv,accept,reject,build,publish}.rb
22
+ │ blame,policy_explain,get_or_refresh}.rb
23
+ │ writes/{put,delete,mv,accept,reject,build,publish,
24
+ │ envelope_io}.rb │
21
25
  │ refresh/{worker,orchestrator,all}.rb │
26
+ │ policy/{promotion,predicates/{schema_valid,human_accept}} │
22
27
  └──────────┬───────────────────────────────┬─────────────────┘
23
28
  │ uses domain │ uses ports
24
29
  ┌─ Domain ─▼─────────────────────────────────────────────────┐
30
+ │ Authorizer (manifest + role → allow / deny) │
25
31
  │ Permission (write/read predicate per zone) │
26
32
  │ Freshness::{Policy,Verdict,Evaluator} │
27
33
  │ Action Outcome │
28
- │ Policy::Promotion (proposal accept gates)
34
+ │ Policy::{Promote,Refresh,Matcher,HandlerAllowlist}
29
35
  └──────────────────────────────────────────┬─────────────────┘
30
36
  │ implements
31
37
  ┌─ Infrastructure ─────────────────────────▼─────────────────┐
32
- │ Store (pure adapterfilesystem + hooks)
33
- Reader#{get,list,where,uid,deps,rdeps,published,
34
- stale,validate_all,read_raw_envelope,
35
- │ schema_envelope} │
36
- │ Writer#{write_envelope_to_disk,delete_envelope_from_ │
37
- │ disk,existing_uid_for,ensure_uid, │
38
- │ enforce_name_match!,serialize_for_put} │
39
- │ AuditLog, Staleness, Validator, Sentinel │
38
+ │ Store (composition rootwires ports)
39
+ Storage::FileStore (bytes-only port: read/write/delete/
40
+ exists?/etag)
40
41
  │ Manifest (Entry, Rules, Schema, permission_for) │
41
- Hooks::{Registry,Dispatcher,Loader,Dsl,Builtin}
42
+ Schemas (eager-load cache)
43
+ │ AuditLog │
44
+ │ Hooks::{Registry,Dispatcher,Loader,FireReport} │
42
45
  │ Infra::{Publisher,EventBus,Clock,Refresh::Lock, │
43
- │ Refresh::Detached}
46
+ │ Refresh::Detached,BuildLock,AuditSubscriber}
44
47
  │ Entry::{Markdown,Json,Yaml,Text} (format strategies) │
45
48
  └────────────────────────────────────────────────────────────┘
46
49
 
47
50
  Dependency rule: arrows point DOWN. Domain has zero outbound
48
51
  imports. Application imports Domain + Infra (via ports).
52
+ Use cases declare their real ports in their constructor.
49
53
  ```
50
54
 
51
55
  ## Read path (`ops.get(key)`)
52
56
 
53
57
  1. CLI verb (or any external caller) builds `ops = Textus::Operations.for(store, role:)` then `ops.get(key)`.
54
- 2. `Operations#get` delegates to a memoized `Application::Reads::Get.new(ctx:, orchestrator:)` instance bound to the request context.
55
- 3. `Reads::Get#call(key)` reads the bare envelope from disk via `@ctx.store.reader.read_raw_envelope(key)`.
56
- 4. Resolves the manifest rules for the key via `@ctx.store.manifest.rules_for(key)` and extracts the `refresh` policy.
57
- 5. `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`.
58
- 6. If fresh → annotate envelope (`stale: false`, `refreshing: false`) and return.
59
- 7. Otherwise `policy.decide(verdict) Action` (data, not behavior).
60
- 8. `Refresh::Orchestrator#execute(action, key:)` interprets the `Action`:
61
- - `Action::Return` → `Outcome::Skipped`
62
- - `Action::RefreshSync` → run `Refresh::Worker` inline → `Refreshed | Failed`
63
- - `Action::RefreshTimed(budget_ms:)` → race Worker thread vs budget; on timeout, kill thread, fire `:refresh_backgrounded`, fork+detach child, return `Outcome::Detached`
64
- 9. Map outcome → envelope annotations (`stale`, `refreshing`, `refresh_error`) and return.
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.
61
+ 5. Otherwise `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`; the envelope is annotated with `stale`, `reason`, `refreshing: false`.
62
+
63
+ `ops.get_or_refresh(key)` composes `Reads::Get` with `Refresh::Orchestrator` to optionally refresh on stale — same as 0.18.x.
65
64
 
66
65
  ## Write path (`ops.put(key, ...)`)
67
66
 
68
- 1. CLI verb calls `ops = Operations.for(store, role:)` then `ops.put(key, meta:, body:, content:, if_etag:, suppress_events:)`.
69
- 2. `Writes::Put#call` validates the key, resolves the manifest entry, and calls `@ctx.authorize_write!(mentry)` — raises `WriteForbidden` (carrying the zone's writers list) if denied.
70
- 3. Delegates raw I/O to `Store::Writer#write_envelope_to_disk(key, mentry:, payload:, ctx:, if_etag:)`, which:
71
- - Resolves the path via `Manifest#resolve`
72
- - Serializes via `Entry.for_format(...).serialize(...)`
73
- - Validates against schema if declared
74
- - Etag-checks if `if_etag:` provided (raises `EtagMismatch` on conflict)
75
- - Writes to disk via `File.binwrite`
76
- - Appends the audit row
77
- 4. On success, publishes `:entry_put` via `@ctx.bus`, with `store: @ctx.with_role(@ctx.role)`, `key:`, `envelope:`, `correlation_id:`.
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`.
71
+
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.
78
73
 
79
- The same pattern applies to `Writes::{Delete,Mv,Accept,Reject,Build,Publish}`: each takes a `Context`, calls `ctx.authorize_write!` (Mv authorizes both source and destination zones), delegates raw I/O to `Store::Writer` or `Infra::Publisher`, and fires the matching event through `ctx.bus`.
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.
80
75
 
81
76
  ## Refresh path (`ops.refresh(key)`)
82
77
 
83
78
  1. CLI `Verb::Refresh` builds `ops = Operations.for(store, role: "runner")` then calls `ops.refresh(key)`.
84
79
  2. `Refresh::Worker#run(key)`:
85
- - Resolves the manifest entry, looks up the intake handler via `store.registry.rpc_callable(:resolve_intake, mentry.intake_handler)`.
86
- - Publishes `:refresh_started` via the bus.
87
- - Invokes the handler under a 30s `Timeout.timeout` budget.
88
- - On any error: publishes `:refresh_failed`, then re-raises (or wraps in `UsageError`).
89
- - On success: normalizes the return shape, persists via `Application::Writes::Put` with `suppress_events: true`, publishes `:entry_refreshed` (unless the etag is unchanged).
90
- 3. The batch entry point is `Application::Refresh::All.call(ctx, prefix:, zone:)` which lists stale entries via `Application::Reads::Stale`, then runs `Worker#run` per entry, returning a summary envelope `{ refreshed: [...], failed: [...], skipped: [...] }`.
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.
82
+ - Invokes the handler under a 30s thread-join deadline.
83
+ - 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: }`.
91
86
 
92
- ## Infrastructure-side hook dispatch
87
+ ## Hook payload contract
93
88
 
94
- The hook bus needs an `Application::Context` even when fired from inside `Store#initialize` or other infrastructure-side code paths. `Application::Context.system(store)` returns a Context with `role: "human"` and a fresh correlation_id, designed for exactly this case — see `lib/textus/store.rb` (the `:store_loaded` publish), `lib/textus/doctor.rb` (the doctor-check view), and `lib/textus/projection.rb` (the transform_rows view).
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:`.
data/CHANGELOG.md CHANGED
@@ -9,6 +9,179 @@ 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.20.0 — architecture redesign (2026-05-27)
13
+
14
+ **BREAKING (pre-1.0):** Public top-level utility modules removed,
15
+ `Manifest` routing methods extracted into a dedicated resolver,
16
+ `Hooks::Dispatcher`/`Hooks::Registry` collapsed into a single bus, and
17
+ pubsub hook payloads now ship `ctx:` (a `Textus::Hooks::Context`)
18
+ instead of the raw store. External hook files written against the 0.19
19
+ `register(event, name, ...)` API continue to work unchanged; pubsub
20
+ hook bodies must update signatures from `|store:, ...|` to `|ctx:, ...|`
21
+ and use `ctx.put`/`ctx.get`/`ctx.audit`/`ctx.publish_followup` in place
22
+ of direct `store.*` access. RPC events (`transform_rows`, `resolve_intake`,
23
+ `validate`) keep `store:`.
24
+
25
+ ### Added
26
+ - `Textus::Hooks::Context` — narrow handle for user pubsub hooks. Exposes
27
+ `role`, `correlation_id`, `get`, `list`, `deps`, `freshness`, `put`,
28
+ `delete`, `audit`, and `publish_followup`. All writes route back through
29
+ `Operations` so authorization, audit, and validation cannot be bypassed.
30
+
31
+ ### Removed
32
+ - `Textus::Dependencies` — use `Operations#deps`, `#rdeps`, `#published`.
33
+ - `Textus::Refresh` — use `Operations#refresh`. The `normalize_action_result`
34
+ helper is now a private class method on `Application::Refresh::Worker`.
35
+ - `Textus::Hooks::Dispatcher` and `Textus::Hooks::Registry` classes.
36
+
37
+ ### Changed
38
+ - `Textus::Projection` moved to `Textus::Application::Projection`.
39
+ - `Textus::MigrateKeys` moved to `Textus::Application::Tools::MigrateKeys`.
40
+ - `Manifest#resolve`, `#enumerate`, and `#suggestions_for` removed from
41
+ the public `Manifest` API. Use `manifest.resolver.resolve(key)` etc.
42
+ via the new `Manifest::Resolver`. `Manifest` retains the data accessors
43
+ (`entries`, `zones`, `rules`, `permissions`, `validate_key!`).
44
+ - `Store` constructs one `Hooks::Bus`; `Store#registry` removed (use
45
+ `Store#bus`). `Hooks::Builtin.register_all(bus)` and
46
+ `Hooks::Loader.new(bus:)` now take a Bus instead of a Registry.
47
+ `Operations.for` no longer accepts `registry:`. Use cases
48
+ (`Refresh::Worker`, `Refresh::All`) take `bus:`.
49
+ - All pubsub events declare `ctx:` instead of `store:` in their kwargs
50
+ schema. Every `bus.publish` call site passes `ctx: hook_context`.
51
+ `Operations#hook_context` builds the per-`Operations` `Hooks::Context`.
52
+ - Manifest entries gain a required `kind:` field
53
+ (`leaf | nested | derived | intake`). Run
54
+ `textus key normalize --upgrade-manifest` to add it to existing
55
+ manifests — the inference is deterministic and lossless.
56
+ - Internal: `Manifest::Entry` is now an abstract namespace; concrete
57
+ classes are `Entry::Leaf`, `Entry::Nested`, `Entry::Derived`,
58
+ `Entry::Intake`. The fields `projection`, `generator`, `compute`,
59
+ `intake_handler`, `intake_config` are removed from the entry
60
+ interface; `Entry::Derived` carries a typed `source`
61
+ (`Projection` or `External`) and `Entry::Intake` carries `handler`
62
+ / `config`. Use-case code dispatches on entry type rather than
63
+ probing optional fields.
64
+ - `Application::Writes::Build` removed. `Application::Writes::Publish` now
65
+ materializes derived entries (template + projection + external runner)
66
+ AND copies leaf/nested entries to their publish targets in a single pass.
67
+ `Operations#build` is gone; use `Operations#publish` — the `textus build`
68
+ CLI verb is unchanged and produces the same
69
+ `{protocol, built, published_leaves}` JSON shape.
70
+
71
+ ## 0.19.1 — drop textus/2 migration hint (2026-05-27)
72
+
73
+ **BREAKING (pre-1.0):** Users on gem ≤0.10 (manifest protocol `textus/2`)
74
+ no longer receive a stepping-stone hint pointing at 0.11.x. The manifest
75
+ parser and `textus doctor` now emit the generic "unsupported version"
76
+ error. Users on ≤0.10 should install 0.11.x first (still on RubyGems)
77
+ to run the migrator, then upgrade to 0.19.1+.
78
+
79
+ ### Changed
80
+ - `Textus::Manifest` no longer special-cases `textus/2`; `TEXTUS_2_HINT`
81
+ and `version_hint_for` removed.
82
+ - `Doctor::Check::ProtocolVersion` hint/fix text simplified; no longer
83
+ links to the 0.11.x CHANGELOG anchor.
84
+
85
+ ### Removed
86
+ - Two redundant manifest specs (the `Manifest.load` duplicate and the
87
+ `textus/2`-specific hint assertion) collapsed into one generic case.
88
+
89
+ ## 0.19.0 — 2026-05-27
90
+
91
+ ### Breaking
92
+
93
+ - `Application::Context` is now a slim value object (`role`,
94
+ `correlation_id`, `now`, `dry_run`). Migration table:
95
+
96
+ | Was | Now |
97
+ |-----|-----|
98
+ | `Application::Context.new(store:, role:)` | `Operations.for(store, role:)` (common case) or `Application::Context.build(role:)` (pure call state) |
99
+ | `Application::Context.system(store)` | Pass `store` directly to hooks |
100
+ | `ctx.store` / `ctx.manifest` / `ctx.file_store` etc. | Construct use cases with the explicit port kwargs |
101
+ | `ctx.authorize_write!(mentry)` | `Domain::Authorizer.new(manifest:).authorize_write!(mentry, role:)` |
102
+ | `Put.new(ctx:).call(..., suppress_events: true)` | Use `EnvelopeIO#write` directly |
103
+ | `store.role` inside a hook | Read `role:` from the event payload |
104
+
105
+ - `Operations.new(ctx:, manifest:, file_store:, schemas:, audit_log:, bus:, registry:, root:, store:)`
106
+ is the primary constructor. `Operations.for(store, role:)` remains
107
+ a convenience.
108
+
109
+ - `Application::Writes::Put#call` no longer accepts `suppress_events:`.
110
+
111
+ - `Domain::Policy::Predicates::*` moved to `Application::Policy::Predicates::*`.
112
+ `Domain::Policy::Promotion` moved to `Application::Policy::Promotion`.
113
+ `Promotion#evaluate` now takes `entry:, schemas:, manifest:, role:`
114
+ instead of `store:`.
115
+
116
+ - Hooks/intakes/transforms receive the actual `Store` as `store:`
117
+ (previously a Context impersonating one). Event payloads
118
+ (`:entry_put`, `:entry_deleted`, `:entry_renamed`, `:proposal_accepted`,
119
+ `:proposal_rejected`, `:entry_refreshed`, `:refresh_started`,
120
+ `:refresh_failed`, `:refresh_backgrounded`, `:file_published`,
121
+ `:build_completed`) now carry `role:` directly so hooks can observe
122
+ the actor without reaching through the `store:` handle.
123
+
124
+ - `Application::Refresh::All` is a class, not a module function. Callers
125
+ go through `Operations#refresh_all`.
126
+
127
+ ### Added
128
+
129
+ - `Domain::Authorizer` — single source of truth for permission checks.
130
+ - `Application::Policy::Promotion` and `Application::Policy::Predicates::*` —
131
+ the policy evaluator and predicates now live with the Application code
132
+ that loads envelope bytes off disk to evaluate them.
133
+ - `Application::Writes::EnvelopeIO#move` — full move pipeline (replaces
134
+ the in-`Mv` file move + audit).
135
+ - `Application::Writes::EnvelopeIO#read_envelope(key)` — internal
136
+ convenience for callers that need to inspect a pre-move envelope.
137
+
138
+ ### Internal
139
+
140
+ - `Builder::Pipeline` no longer re-enters `Operations.for(store)`; it
141
+ takes reader/lister callables from the caller.
142
+ - CLI verbs construct `Operations`, never `Context` directly.
143
+ - `Operations` no longer memoizes per-use-case factories; only
144
+ `envelope_io`, `refresh_worker`, and `orchestrator` are shared.
145
+ - Wire format `textus/3` unchanged. Audit-log NDJSON unchanged.
146
+
147
+ ### Known follow-ups
148
+
149
+ - CLI verbs `hook run`, `put --fetch`, `hooks list`, and `rule list`
150
+ still reach into `store.manifest` / `store.registry` directly. A
151
+ future release adds `Operations` projections (e.g. `manifest_view`,
152
+ `hooks_view`, `run_intake`) so these verbs route through the
153
+ Operations boundary.
154
+ - `Application::Writes::Delete#call` retains a `suppress_events:` kwarg
155
+ (used internally by `Reject`). A future release either lifts the
156
+ suppression into `EnvelopeIO`-direct usage (matching the `Mv` path)
157
+ or formalizes per-event suppression as part of the public hook API.
158
+
159
+ ## 0.18.1 — 2026-05-27
160
+
161
+ ### Fixed
162
+
163
+ - `Hooks::Dispatcher` no longer uses `Timeout.timeout`, which can interrupt a
164
+ hook mid-syscall or mid-`ensure` and leave Ruby state corrupted. Each
165
+ subscriber now runs in a worker thread joined with a deadline; on overrun
166
+ the thread is killed and the hook is recorded as `timed_out` (distinct
167
+ from `errored`).
168
+
169
+ ### Added
170
+
171
+ - `Hooks::FireReport` — value object returned from `bus.publish`. Lists
172
+ `fired`, `errored`, and `timed_out` subscriber names; exposes `#ok?` and
173
+ `#failures`. Backwards-compatible: callers that ignore the return value
174
+ (the entire current codebase) keep working.
175
+ - `Hooks::Dispatcher#publish` accepts `strict: true`, which re-raises the
176
+ first failure after every subscriber has been attempted. Intended for
177
+ test setups that want loud hooks; default remains `false`.
178
+
179
+ ### Internal
180
+
181
+ - No public API surface changes. CLI behavior, `Operations` methods, wire
182
+ format `textus/3`, and the audit-log NDJSON shape are unchanged. Stores
183
+ written by 0.18.0 round-trip through 0.18.1 byte-for-byte.
184
+
12
185
  ## 0.18.0 — 2026-05-27
13
186
 
14
187
  Port extraction finishes the hexagonal trajectory. `Store::Reader` and
@@ -2,69 +2,31 @@ require "securerandom"
2
2
 
3
3
  module Textus
4
4
  module Application
5
- class Context
6
- attr_reader :store, :role, :correlation_id
7
-
8
- def self.system(store)
9
- new(store: store, role: "human")
10
- end
11
-
12
- def initialize(store:, role:, correlation_id: nil, clock: Time, dry_run: false)
13
- @store = store
14
- @role = role.to_s
15
- @correlation_id = correlation_id || SecureRandom.uuid
16
- @clock = clock
17
- @dry_run = dry_run
18
- @now = nil
19
- end
20
-
21
- def now
22
- @now ||= @clock.now
23
- end
24
-
25
- def dry_run?
26
- @dry_run
27
- end
28
-
29
- def can_write?(zone)
30
- store.manifest.permission_for(zone.to_s).allows_write?(role)
31
- end
32
-
33
- def can_read?(zone)
34
- store.manifest.permission_for(zone.to_s).allows_read?(role)
35
- end
36
-
37
- def bus
38
- @store.bus
39
- end
40
-
41
- def manifest = @store.manifest
42
- def schemas = @store.schemas
43
- def file_store = @store.file_store
44
- def audit_log = @store.audit_log
45
-
46
- def authorize_write!(mentry)
47
- return if can_write?(mentry.zone)
48
-
49
- writers = @store.manifest.zone_writers(mentry.zone)
50
- raise WriteForbidden.new(mentry.key, mentry.zone, writers: writers)
5
+ # A Context describes the call: who is acting (role), what request this
6
+ # is part of (correlation_id), what time it is (now), and whether
7
+ # writes should be suppressed (dry_run).
8
+ #
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.
12
+ Context = Data.define(:role, :correlation_id, :now, :dry_run) do
13
+ def self.build(role:, correlation_id: nil, now: nil, dry_run: false)
14
+ new(
15
+ role: role.to_s,
16
+ correlation_id: correlation_id || SecureRandom.uuid,
17
+ now: now || Time.now,
18
+ dry_run: dry_run,
19
+ )
51
20
  end
52
21
 
53
- def authorize_read!(mentry)
54
- return if can_read?(mentry.zone)
55
-
56
- readers = @store.manifest.zone_readers[mentry.zone]
57
- readers = nil if readers == :all
58
- raise ReadForbidden.new(mentry.key, mentry.zone, readers: readers)
59
- end
22
+ def dry_run? = dry_run
60
23
 
61
24
  def with_role(new_role)
62
25
  self.class.new(
63
- store: @store,
64
- role: new_role,
65
- correlation_id: @correlation_id,
66
- clock: @clock,
67
- dry_run: @dry_run,
26
+ role: new_role.to_s,
27
+ correlation_id: correlation_id,
28
+ now: now,
29
+ dry_run: dry_run,
68
30
  )
69
31
  end
70
32
  end
@@ -0,0 +1,30 @@
1
+ module Textus
2
+ module Application
3
+ module Policy
4
+ module Predicates
5
+ class HumanAccept
6
+ attr_reader :reason
7
+
8
+ def name
9
+ "human_accept"
10
+ end
11
+
12
+ # The role is passed explicitly. In practice, Accept already enforces
13
+ # role == "human" before reaching the promotion gate, so this predicate
14
+ # trivially passes. It documents intent and future-proofs multi-actor
15
+ # accept flows.
16
+ def call(role:, entry: nil) # rubocop:disable Lint/UnusedMethodArgument
17
+ role_str = role&.to_s
18
+ # If we cannot determine the role, trust that Accept has already
19
+ # checked — allow through.
20
+ return true if role_str.nil? || role_str.empty?
21
+
22
+ ok = (role_str == "human")
23
+ @reason = "current role is '#{role_str}', expected 'human'" unless ok
24
+ ok
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Domain
2
+ module Application
3
3
  module Policy
4
4
  module Predicates
5
5
  class SchemaValid
@@ -9,17 +9,17 @@ module Textus
9
9
  "schema_valid"
10
10
  end
11
11
 
12
- def call(entry:, store:) # rubocop:disable Metrics/PerceivedComplexity
13
- return true if entry.nil? || store.nil?
12
+ def call(entry:, schemas:, manifest:) # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
13
+ return true if entry.nil? || manifest.nil? || schemas.nil?
14
14
 
15
15
  target_key = entry.meta&.dig("proposal", "target_key")
16
16
  return true unless target_key
17
17
 
18
- mentry = store.manifest.resolve(target_key).entry
18
+ mentry = manifest.resolver.resolve(target_key).entry
19
19
  schema_ref = mentry&.schema
20
20
  return true unless schema_ref
21
21
 
22
- schema = store.schemas.fetch_or_nil(schema_ref)
22
+ schema = schemas.fetch_or_nil(schema_ref)
23
23
  return true unless schema
24
24
 
25
25
  frontmatter = entry.meta&.dig("frontmatter") || {}
@@ -1,8 +1,13 @@
1
1
  module Textus
2
- module Domain
2
+ module Application
3
3
  module Policy
4
4
  # Promotion evaluates a list of named predicates against a pending-proposal
5
5
  # entry and returns a Result indicating whether all requirements are met.
6
+ #
7
+ # Lives in Application because the predicates it wires up read live state
8
+ # from explicit ports (schemas, manifest, role). The Domain-side rule
9
+ # statement ("this policy requires predicates X and Y") is captured by
10
+ # Textus::Domain::Policy::Promote.
6
11
  class Promotion
7
12
  Result = Struct.new(:ok?, :reasons, keyword_init: true)
8
13
 
@@ -31,14 +36,26 @@ module Textus
31
36
  @predicates.map(&:name)
32
37
  end
33
38
 
34
- def evaluate(entry:, store:)
39
+ def evaluate(entry:, schemas:, manifest:, role:)
35
40
  reasons = []
36
41
  @predicates.each do |pred|
37
- ok = pred.call(entry: entry, store: store)
42
+ ok = invoke(pred, entry: entry, schemas: schemas, manifest: manifest, role: role)
38
43
  reasons << "#{pred.name}: #{pred.reason || "predicate failed"}" unless ok
39
44
  end
40
45
  Result.new(ok?: reasons.empty?, reasons: reasons)
41
46
  end
47
+
48
+ private
49
+
50
+ def invoke(pred, entry:, schemas:, manifest:, role:)
51
+ case pred.name
52
+ when "human_accept"
53
+ pred.call(role: role, entry: entry)
54
+ else
55
+ # Default shape: schema-style predicates that need entry + schemas + manifest.
56
+ pred.call(entry: entry, schemas: schemas, manifest: manifest)
57
+ end
58
+ end
42
59
  end
43
60
  end
44
61
  end
@@ -0,0 +1,91 @@
1
+ require "time"
2
+ require "timeout"
3
+
4
+ module Textus
5
+ module Application
6
+ class Projection
7
+ MAX_LIMIT = 1000
8
+ REDUCER_TIMEOUT_SECONDS = 2
9
+
10
+ # `reader` — a callable `->(key) { envelope_or_nil }`. Caller picks
11
+ # semantics: pure read (`ops.get`) for materialization paths;
12
+ # `ops.get_or_refresh` if you want refresh-on-stale.
13
+ # `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
14
+ # `transform_resolver` — a callable `->(name) { callable_or_raise }`.
15
+ # `transform_context` — `Application::Context` handed to the transform reducer.
16
+ def initialize(reader:, spec:, lister:, transform_resolver:, transform_context:)
17
+ @reader = reader
18
+ @spec = spec || {}
19
+ @lister = lister
20
+ @transform_resolver = transform_resolver
21
+ @transform_context = transform_context
22
+ @limit = (@spec["limit"] || MAX_LIMIT).to_i
23
+ raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
24
+ end
25
+
26
+ def run
27
+ keys = collect_keys
28
+ explicit_pluck = !@spec["pluck"].nil? && @spec["pluck"] != "*"
29
+ rows = keys.map do |key|
30
+ env = @reader.call(key)
31
+ row = pluck(env.meta, env.body)
32
+ explicit_pluck ? row : row.merge("_key" => key)
33
+ end
34
+ reduced = apply_reducer(rows)
35
+ # Reducers may return either an Array of rows (legacy / templated builds)
36
+ # or a Hash that becomes the structured-format payload base. In the Hash
37
+ # case, downstream sort/limit/position markers don't apply, and the
38
+ # builder owns `_meta.generated_at` so we don't stamp it here.
39
+ return reduced if reduced.is_a?(Hash)
40
+
41
+ rows = reduced
42
+ rows = sort(rows)
43
+ rows = rows.first(@limit)
44
+ mark_positions(rows)
45
+ { "entries" => rows, "count" => rows.length, "generated_at" => Time.now.utc.iso8601 }
46
+ end
47
+
48
+ private
49
+
50
+ def apply_reducer(rows)
51
+ name = @spec["transform"] or return rows
52
+ callable = @transform_resolver.call(name)
53
+ Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
54
+ callable.call(store: @transform_context, rows: rows, config: @spec["transform_config"] || {})
55
+ end
56
+ rescue Timeout::Error
57
+ raise UsageError.new("transform_rows '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
58
+ end
59
+
60
+ def collect_keys
61
+ prefixes = Array(@spec["select"])
62
+ prefixes.flat_map { |p| @lister.call(prefix: p).map { |row| row["key"] } }.uniq
63
+ end
64
+
65
+ def pluck(frontmatter, _body)
66
+ fields = @spec["pluck"]
67
+ if fields.nil? || fields == "*"
68
+ frontmatter
69
+ else
70
+ Array(fields).each_with_object({}) { |f, h| h[f] = frontmatter[f] if frontmatter.key?(f) }
71
+ end
72
+ end
73
+
74
+ # Adds `_first`, `_last`, and `_index` markers so templates can emit
75
+ # delimiters (e.g. JSON commas) via {{^_last}},{{/_last}}.
76
+ def mark_positions(rows)
77
+ last_idx = rows.length - 1
78
+ rows.each_with_index do |row, i|
79
+ row["_index"] = i
80
+ row["_first"] = i.zero?
81
+ row["_last"] = (i == last_idx)
82
+ end
83
+ end
84
+
85
+ def sort(rows)
86
+ sb = @spec["sort_by"] or return rows
87
+ rows.sort_by { |r| r[sb].to_s }
88
+ end
89
+ end
90
+ end
91
+ end
@@ -8,9 +8,9 @@ module Textus
8
8
  # correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
9
9
  # rows produce nil and are skipped).
10
10
  class Audit
11
- def initialize(ctx:)
12
- @ctx = ctx
13
- @log_path = File.join(@ctx.store.root, "audit.log")
11
+ def initialize(manifest:, root:)
12
+ @manifest = manifest
13
+ @log_path = File.join(root, "audit.log")
14
14
  end
15
15
 
16
16
  # rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -58,7 +58,7 @@ module Textus
58
58
  end
59
59
 
60
60
  def key_in_zone?(key, zone)
61
- mentry = @ctx.store.manifest.resolve(key).entry
61
+ mentry = @manifest.resolver.resolve(key).entry
62
62
  mentry && mentry.zone == zone
63
63
  rescue Textus::Error
64
64
  false