textus 0.18.0 → 0.20.2

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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +43 -48
  3. data/CHANGELOG.md +238 -0
  4. data/SPEC.md +35 -2
  5. data/lib/textus/application/context.rb +20 -58
  6. data/lib/textus/application/policy/predicates/accept_authority_signed.rb +33 -0
  7. data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
  8. data/lib/textus/{domain → application}/policy/promotion.rb +18 -6
  9. data/lib/textus/application/projection.rb +91 -0
  10. data/lib/textus/application/reads/audit.rb +4 -4
  11. data/lib/textus/application/reads/blame.rb +9 -8
  12. data/lib/textus/application/reads/deps.rb +14 -3
  13. data/lib/textus/application/reads/freshness.rb +10 -8
  14. data/lib/textus/application/reads/get.rb +10 -8
  15. data/lib/textus/application/reads/get_or_refresh.rb +3 -3
  16. data/lib/textus/application/reads/list.rb +3 -3
  17. data/lib/textus/application/reads/policy_explain.rb +3 -3
  18. data/lib/textus/application/reads/published.rb +5 -3
  19. data/lib/textus/application/reads/rdeps.rb +15 -3
  20. data/lib/textus/application/reads/schema_envelope.rb +5 -4
  21. data/lib/textus/application/reads/stale.rb +3 -3
  22. data/lib/textus/application/reads/uid.rb +11 -3
  23. data/lib/textus/application/reads/validate_all.rb +10 -6
  24. data/lib/textus/application/reads/validator.rb +5 -3
  25. data/lib/textus/application/reads/where.rb +3 -3
  26. data/lib/textus/application/refresh/all.rb +15 -11
  27. data/lib/textus/application/refresh/orchestrator.rb +9 -8
  28. data/lib/textus/application/refresh/worker.rb +56 -32
  29. data/lib/textus/application/writes/accept.rb +43 -16
  30. data/lib/textus/application/writes/authority_gate.rb +26 -0
  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 +25 -12
  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 +4 -6
  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/put.rb +2 -3
  47. data/lib/textus/cli/verb/refresh_stale.rb +1 -2
  48. data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
  49. data/lib/textus/doctor/check/hooks.rb +2 -2
  50. data/lib/textus/doctor/check/illegal_keys.rb +7 -7
  51. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  52. data/lib/textus/doctor/check/manifest_files.rb +1 -1
  53. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  54. data/lib/textus/doctor/check/templates.rb +4 -3
  55. data/lib/textus/doctor.rb +3 -4
  56. data/lib/textus/domain/authorizer.rb +37 -0
  57. data/lib/textus/domain/policy/promote.rb +4 -2
  58. data/lib/textus/domain/policy/refresh.rb +2 -0
  59. data/lib/textus/domain/staleness/generator_check.rb +8 -7
  60. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  61. data/lib/textus/hooks/builtin.rb +6 -6
  62. data/lib/textus/hooks/bus.rb +155 -0
  63. data/lib/textus/hooks/context.rb +38 -0
  64. data/lib/textus/hooks/fire_report.rb +23 -0
  65. data/lib/textus/hooks/loader.rb +3 -3
  66. data/lib/textus/infra/audit_subscriber.rb +4 -4
  67. data/lib/textus/infra/event_bus.rb +3 -3
  68. data/lib/textus/infra/refresh/detached.rb +1 -1
  69. data/lib/textus/init.rb +3 -2
  70. data/lib/textus/intro.rb +51 -27
  71. data/lib/textus/manifest/entry/base.rb +38 -0
  72. data/lib/textus/manifest/entry/derived.rb +25 -0
  73. data/lib/textus/manifest/entry/intake.rb +19 -0
  74. data/lib/textus/manifest/entry/leaf.rb +16 -0
  75. data/lib/textus/manifest/entry/nested.rb +39 -0
  76. data/lib/textus/manifest/entry/parser.rb +58 -31
  77. data/lib/textus/manifest/entry/validators/events.rb +3 -2
  78. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
  79. data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
  80. data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
  81. data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
  82. data/lib/textus/manifest/entry.rb +0 -72
  83. data/lib/textus/manifest/resolver.rb +112 -0
  84. data/lib/textus/manifest/role_kinds.rb +21 -0
  85. data/lib/textus/manifest/schema.rb +46 -2
  86. data/lib/textus/manifest.rb +24 -101
  87. data/lib/textus/operations.rb +131 -74
  88. data/lib/textus/schema/tools.rb +10 -3
  89. data/lib/textus/store.rb +6 -6
  90. data/lib/textus/version.rb +1 -1
  91. metadata +18 -14
  92. data/lib/textus/application/writes/build.rb +0 -78
  93. data/lib/textus/cli/verb/key_normalize.rb +0 -19
  94. data/lib/textus/dependencies.rb +0 -23
  95. data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
  96. data/lib/textus/domain/policy.rb +0 -7
  97. data/lib/textus/hooks/dispatcher.rb +0 -71
  98. data/lib/textus/hooks/registry.rb +0 -85
  99. data/lib/textus/manifest/resolution.rb +0 -5
  100. data/lib/textus/migrate_keys.rb +0 -187
  101. data/lib/textus/projection.rb +0 -89
  102. 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: 35d3b6540fc043048133df0874e76b36e522d844da783b69a5e8993418157ae4
4
+ data.tar.gz: e0293b87efb32c1edf2d6c0103dcd94289d91031a4be570f8fdd40fb681c1e79
5
5
  SHA512:
6
- metadata.gz: 457d079d8ebf25922f9389086ec2de99b96808f23fba7f600655fb2ef055a5a987dd1ea63924aaf9904c7f4e600b4aee33a8c2082028bbb9025068aa00de3b0e
7
- data.tar.gz: a1192a6027b80582723b0cb4679b870ed78101864e22700ba7630308912f46000e774dbd69c589418516047dff2f8cdbf7a26e69e47d3a7ffcef89f917397a23
6
+ metadata.gz: '039b6f44941ea52ac8d956297d9619c4cc8b2346e7d8bced24bc5671506726a44348da66791aa6d032e56dd403353d1b34e9b2fee37eaec05cd6c83d5defc715'
7
+ data.tar.gz: e6e5e8f97f5f9a07a03813d61f9efa2088fb8f1338af89ba1298bf307c6c72e89f1028aa5a67ffdf8f97f2151c0af1273f82ff80751c59c11aa93ef2953323a9
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,244 @@ 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.2 — 2026-05-27
13
+
14
+ ### Fixed
15
+ - Promotion predicate `accept_authority_signed` now checks the role's *kind*
16
+ via `manifest.role_kind`, so manifests with a renamed authority role (e.g.
17
+ `owner` instead of `human`) pass the promotion gate. The internal class
18
+ `Predicates::HumanAccept` was renamed to `Predicates::AcceptAuthoritySigned`.
19
+ - `textus schema migrate` now writes as the manifest's declared
20
+ `accept_authority` role instead of the literal `"human"`, and raises a
21
+ clear `UsageError` (with a YAML hint) when no `accept_authority` role is
22
+ declared.
23
+ - `textus accept` / `textus reject` no longer claim "only human role can
24
+ accept" when the manifest declares zero `accept_authority` roles — the
25
+ error now says "no role with accept_authority kind is declared in this
26
+ manifest; accept/reject is disabled".
27
+ - `textus build` now resolves the build role from the manifest's declared
28
+ `generator` kind instead of hardcoding `"builder"`, so renamed generator
29
+ roles work correctly.
30
+ - Manifest validator's "exactly one accept_authority" error message now
31
+ matches what the schema actually enforces.
32
+
33
+ ### Removed
34
+ - Legacy `human_accept` promotion-predicate alias (string and symbol forms).
35
+ Manifests using `rules[].promotion.requires: [human_accept]` must change
36
+ to `[accept_authority_signed]`. The error on the old form is actionable:
37
+ `unknown promotion predicate: 'human_accept' (known: schema_valid,
38
+ accept_authority_signed)`.
39
+ - `textus key normalize` verb and the underlying
40
+ `Textus::Application::Tools::MigrateKeys` module. Files dropped into nested
41
+ zones with illegal basenames are still reported by `textus doctor` with a
42
+ `key.illegal` finding; fix them by hand. The `--upgrade-manifest` flag and
43
+ its `Textus::Application::Tools::MigrateManifestToKinds` module (one-shot
44
+ 0.19→0.20 manifest upgrader) are removed for the same reason — dead weight.
45
+ - The `migrate-keys` audit-log payload string is no longer emitted (no writer
46
+ produces it).
47
+
48
+ ### Internal
49
+ - Final cleanup of role-name leaks identified by the 0.20.2 architecture
50
+ audit (follow-on to 0.20.1 role-kinds refactor).
51
+
52
+ ## 0.20.1 — 2026-05-27
53
+
54
+ ### Added
55
+ - Optional `roles:` block in `manifest.yaml` lets users rename roles without
56
+ breaking engine semantics. Each declared role maps to one of four engine
57
+ kinds: `accept_authority`, `generator`, `proposer`, `runner`. (#72)
58
+ - `Manifest#role_kind`, `Manifest#roles_with_kind`, `Manifest#zone_kinds`
59
+ accessors for engine integrations.
60
+
61
+ ### Changed
62
+ - `accept` / `reject` now gate on `accept_authority` kind, not the literal
63
+ `"human"` role. Error messages cite the configured role name.
64
+ - `validator` last-writer trust check uses `accept_authority` kind.
65
+ - Entry `in_generator_zone?` / `in_proposal_zone?` query `zone_kinds`.
66
+ - `Intro` derives `write_flows` and `agent_protocol.role_resolution.roles`
67
+ from the manifest's role mapping.
68
+ - Promote DSL predicate `:human_accept` renamed to `:accept_authority_signed`;
69
+ the old symbol still works as an alias.
70
+ - Schema rejects zone writers that reference an undeclared role when `roles:`
71
+ is declared.
72
+
73
+ ### Compatibility
74
+ - No wire protocol change (`textus/3`).
75
+ - Existing manifests without a `roles:` block behave identically to 0.20.0.
76
+
77
+ ## 0.20.0 — architecture redesign (2026-05-27)
78
+
79
+ **BREAKING (pre-1.0):** Public top-level utility modules removed,
80
+ `Manifest` routing methods extracted into a dedicated resolver,
81
+ `Hooks::Dispatcher`/`Hooks::Registry` collapsed into a single bus, and
82
+ pubsub hook payloads now ship `ctx:` (a `Textus::Hooks::Context`)
83
+ instead of the raw store. External hook files written against the 0.19
84
+ `register(event, name, ...)` API continue to work unchanged; pubsub
85
+ hook bodies must update signatures from `|store:, ...|` to `|ctx:, ...|`
86
+ and use `ctx.put`/`ctx.get`/`ctx.audit`/`ctx.publish_followup` in place
87
+ of direct `store.*` access. RPC events (`transform_rows`, `resolve_intake`,
88
+ `validate`) keep `store:`.
89
+
90
+ ### Added
91
+ - `Textus::Hooks::Context` — narrow handle for user pubsub hooks. Exposes
92
+ `role`, `correlation_id`, `get`, `list`, `deps`, `freshness`, `put`,
93
+ `delete`, `audit`, and `publish_followup`. All writes route back through
94
+ `Operations` so authorization, audit, and validation cannot be bypassed.
95
+
96
+ ### Removed
97
+ - `Textus::Dependencies` — use `Operations#deps`, `#rdeps`, `#published`.
98
+ - `Textus::Refresh` — use `Operations#refresh`. The `normalize_action_result`
99
+ helper is now a private class method on `Application::Refresh::Worker`.
100
+ - `Textus::Hooks::Dispatcher` and `Textus::Hooks::Registry` classes.
101
+
102
+ ### Changed
103
+ - `Textus::Projection` moved to `Textus::Application::Projection`.
104
+ - `Textus::MigrateKeys` moved to `Textus::Application::Tools::MigrateKeys`.
105
+ - `Manifest#resolve`, `#enumerate`, and `#suggestions_for` removed from
106
+ the public `Manifest` API. Use `manifest.resolver.resolve(key)` etc.
107
+ via the new `Manifest::Resolver`. `Manifest` retains the data accessors
108
+ (`entries`, `zones`, `rules`, `permissions`, `validate_key!`).
109
+ - `Store` constructs one `Hooks::Bus`; `Store#registry` removed (use
110
+ `Store#bus`). `Hooks::Builtin.register_all(bus)` and
111
+ `Hooks::Loader.new(bus:)` now take a Bus instead of a Registry.
112
+ `Operations.for` no longer accepts `registry:`. Use cases
113
+ (`Refresh::Worker`, `Refresh::All`) take `bus:`.
114
+ - All pubsub events declare `ctx:` instead of `store:` in their kwargs
115
+ schema. Every `bus.publish` call site passes `ctx: hook_context`.
116
+ `Operations#hook_context` builds the per-`Operations` `Hooks::Context`.
117
+ - Manifest entries gain a required `kind:` field
118
+ (`leaf | nested | derived | intake`). Run
119
+ `textus key normalize --upgrade-manifest` to add it to existing
120
+ manifests — the inference is deterministic and lossless.
121
+ - Internal: `Manifest::Entry` is now an abstract namespace; concrete
122
+ classes are `Entry::Leaf`, `Entry::Nested`, `Entry::Derived`,
123
+ `Entry::Intake`. The fields `projection`, `generator`, `compute`,
124
+ `intake_handler`, `intake_config` are removed from the entry
125
+ interface; `Entry::Derived` carries a typed `source`
126
+ (`Projection` or `External`) and `Entry::Intake` carries `handler`
127
+ / `config`. Use-case code dispatches on entry type rather than
128
+ probing optional fields.
129
+ - `Application::Writes::Build` removed. `Application::Writes::Publish` now
130
+ materializes derived entries (template + projection + external runner)
131
+ AND copies leaf/nested entries to their publish targets in a single pass.
132
+ `Operations#build` is gone; use `Operations#publish` — the `textus build`
133
+ CLI verb is unchanged and produces the same
134
+ `{protocol, built, published_leaves}` JSON shape.
135
+
136
+ ## 0.19.1 — drop textus/2 migration hint (2026-05-27)
137
+
138
+ **BREAKING (pre-1.0):** Users on gem ≤0.10 (manifest protocol `textus/2`)
139
+ no longer receive a stepping-stone hint pointing at 0.11.x. The manifest
140
+ parser and `textus doctor` now emit the generic "unsupported version"
141
+ error. Users on ≤0.10 should install 0.11.x first (still on RubyGems)
142
+ to run the migrator, then upgrade to 0.19.1+.
143
+
144
+ ### Changed
145
+ - `Textus::Manifest` no longer special-cases `textus/2`; `TEXTUS_2_HINT`
146
+ and `version_hint_for` removed.
147
+ - `Doctor::Check::ProtocolVersion` hint/fix text simplified; no longer
148
+ links to the 0.11.x CHANGELOG anchor.
149
+
150
+ ### Removed
151
+ - Two redundant manifest specs (the `Manifest.load` duplicate and the
152
+ `textus/2`-specific hint assertion) collapsed into one generic case.
153
+
154
+ ## 0.19.0 — 2026-05-27
155
+
156
+ ### Breaking
157
+
158
+ - `Application::Context` is now a slim value object (`role`,
159
+ `correlation_id`, `now`, `dry_run`). Migration table:
160
+
161
+ | Was | Now |
162
+ |-----|-----|
163
+ | `Application::Context.new(store:, role:)` | `Operations.for(store, role:)` (common case) or `Application::Context.build(role:)` (pure call state) |
164
+ | `Application::Context.system(store)` | Pass `store` directly to hooks |
165
+ | `ctx.store` / `ctx.manifest` / `ctx.file_store` etc. | Construct use cases with the explicit port kwargs |
166
+ | `ctx.authorize_write!(mentry)` | `Domain::Authorizer.new(manifest:).authorize_write!(mentry, role:)` |
167
+ | `Put.new(ctx:).call(..., suppress_events: true)` | Use `EnvelopeIO#write` directly |
168
+ | `store.role` inside a hook | Read `role:` from the event payload |
169
+
170
+ - `Operations.new(ctx:, manifest:, file_store:, schemas:, audit_log:, bus:, registry:, root:, store:)`
171
+ is the primary constructor. `Operations.for(store, role:)` remains
172
+ a convenience.
173
+
174
+ - `Application::Writes::Put#call` no longer accepts `suppress_events:`.
175
+
176
+ - `Domain::Policy::Predicates::*` moved to `Application::Policy::Predicates::*`.
177
+ `Domain::Policy::Promotion` moved to `Application::Policy::Promotion`.
178
+ `Promotion#evaluate` now takes `entry:, schemas:, manifest:, role:`
179
+ instead of `store:`.
180
+
181
+ - Hooks/intakes/transforms receive the actual `Store` as `store:`
182
+ (previously a Context impersonating one). Event payloads
183
+ (`:entry_put`, `:entry_deleted`, `:entry_renamed`, `:proposal_accepted`,
184
+ `:proposal_rejected`, `:entry_refreshed`, `:refresh_started`,
185
+ `:refresh_failed`, `:refresh_backgrounded`, `:file_published`,
186
+ `:build_completed`) now carry `role:` directly so hooks can observe
187
+ the actor without reaching through the `store:` handle.
188
+
189
+ - `Application::Refresh::All` is a class, not a module function. Callers
190
+ go through `Operations#refresh_all`.
191
+
192
+ ### Added
193
+
194
+ - `Domain::Authorizer` — single source of truth for permission checks.
195
+ - `Application::Policy::Promotion` and `Application::Policy::Predicates::*` —
196
+ the policy evaluator and predicates now live with the Application code
197
+ that loads envelope bytes off disk to evaluate them.
198
+ - `Application::Writes::EnvelopeIO#move` — full move pipeline (replaces
199
+ the in-`Mv` file move + audit).
200
+ - `Application::Writes::EnvelopeIO#read_envelope(key)` — internal
201
+ convenience for callers that need to inspect a pre-move envelope.
202
+
203
+ ### Internal
204
+
205
+ - `Builder::Pipeline` no longer re-enters `Operations.for(store)`; it
206
+ takes reader/lister callables from the caller.
207
+ - CLI verbs construct `Operations`, never `Context` directly.
208
+ - `Operations` no longer memoizes per-use-case factories; only
209
+ `envelope_io`, `refresh_worker`, and `orchestrator` are shared.
210
+ - Wire format `textus/3` unchanged. Audit-log NDJSON unchanged.
211
+
212
+ ### Known follow-ups
213
+
214
+ - CLI verbs `hook run`, `put --fetch`, `hooks list`, and `rule list`
215
+ still reach into `store.manifest` / `store.registry` directly. A
216
+ future release adds `Operations` projections (e.g. `manifest_view`,
217
+ `hooks_view`, `run_intake`) so these verbs route through the
218
+ Operations boundary.
219
+ - `Application::Writes::Delete#call` retains a `suppress_events:` kwarg
220
+ (used internally by `Reject`). A future release either lifts the
221
+ suppression into `EnvelopeIO`-direct usage (matching the `Mv` path)
222
+ or formalizes per-event suppression as part of the public hook API.
223
+
224
+ ## 0.18.1 — 2026-05-27
225
+
226
+ ### Fixed
227
+
228
+ - `Hooks::Dispatcher` no longer uses `Timeout.timeout`, which can interrupt a
229
+ hook mid-syscall or mid-`ensure` and leave Ruby state corrupted. Each
230
+ subscriber now runs in a worker thread joined with a deadline; on overrun
231
+ the thread is killed and the hook is recorded as `timed_out` (distinct
232
+ from `errored`).
233
+
234
+ ### Added
235
+
236
+ - `Hooks::FireReport` — value object returned from `bus.publish`. Lists
237
+ `fired`, `errored`, and `timed_out` subscriber names; exposes `#ok?` and
238
+ `#failures`. Backwards-compatible: callers that ignore the return value
239
+ (the entire current codebase) keep working.
240
+ - `Hooks::Dispatcher#publish` accepts `strict: true`, which re-raises the
241
+ first failure after every subscriber has been attempted. Intended for
242
+ test setups that want loud hooks; default remains `false`.
243
+
244
+ ### Internal
245
+
246
+ - No public API surface changes. CLI behavior, `Operations` methods, wire
247
+ format `textus/3`, and the audit-log NDJSON shape are unchanged. Stores
248
+ written by 0.18.0 round-trip through 0.18.1 byte-for-byte.
249
+
12
250
  ## 0.18.0 — 2026-05-27
13
251
 
14
252
  Port extraction finishes the hexagonal trajectory. `Store::Reader` and
data/SPEC.md CHANGED
@@ -229,6 +229,40 @@ Unknown role values are rejected with `invalid_role`.
229
229
 
230
230
  Every successful write records the resolved role and a wall-clock timestamp in `.textus/audit.log`, so reviewers can later distinguish a human edit from an agent edit even though both live in the same file.
231
231
 
232
+ #### 5.1.1 Role kinds (engine semantics)
233
+
234
+ Internally the engine recognizes four **role kinds** — abstract capability
235
+ markers — rather than the four default role names. A manifest may declare a
236
+ `roles:` block to map any role name to a kind:
237
+
238
+ ```yaml
239
+ roles:
240
+ - { name: owner, kind: accept_authority }
241
+ - { name: compiler, kind: generator }
242
+ - { name: proposer, kind: proposer }
243
+ - { name: fetcher, kind: runner }
244
+ ```
245
+
246
+ Kind allow-list: `accept_authority`, `generator`, `proposer`, `runner`.
247
+ At most one role may have `accept_authority`. When `roles:` is declared,
248
+ every entry in `zones[*].write_policy` must be a declared role name.
249
+
250
+ When the `roles:` block is omitted, the default mapping applies:
251
+
252
+ | Default name | Kind |
253
+ |---|---|
254
+ | `human` | `accept_authority` |
255
+ | `agent` | `proposer` |
256
+ | `builder` | `generator` |
257
+ | `runner` | `runner` |
258
+
259
+ This means existing manifests continue to work byte-for-byte. Wire protocol
260
+ `textus/3` is unchanged — kinds are an internal-semantics concept and never
261
+ appear on the wire.
262
+
263
+ The promotion DSL predicate `:human_accept` is now `:accept_authority_signed`;
264
+ the old symbol works as an alias for backwards compatibility.
265
+
232
266
  ### 5.2 Compute layer (derived entries)
233
267
 
234
268
  Derived entries live in a zone whose `write_policy:` list includes `builder` — `output` in the default scaffold. They are not authored by hand; their body is produced by projecting over other entries. A derived entry declares a `compute:` block with a `kind:` discriminator.
@@ -399,7 +433,7 @@ Schema (one JSON object per line, no interior whitespace):
399
433
  {"ts":"<iso8601-utc>","role":"<role>","verb":"<verb>","key":"<key>","etag_before":<etag-or-null>,"etag_after":<etag-or-null>}
400
434
  ```
401
435
 
402
- `ts` is the wall-clock timestamp in UTC with second precision. `role` is the resolved role for the invocation. `verb` is the audit-log payload string identifying the operation (`put`, `delete`, `accept`, `compute`, `migrate-keys`, `mv`, ...). Note that `migrate-keys` here is the on-disk payload key — the CLI surface is `textus key migrate`; the payload string is retained for log stability. `key` is the affected entry key. `etag_before` and `etag_after` are the entry etags before and after the write, or JSON `null` when not applicable (e.g. create has no before-etag, delete has no after-etag). `key migrate --write` emits one line per renamed file (with payload `verb: "migrate-keys"`) using the new key as `key` and the file's pre- and post-rename etags.
436
+ `ts` is the wall-clock timestamp in UTC with second precision. `role` is the resolved role for the invocation. `verb` is the audit-log payload string identifying the operation (`put`, `delete`, `accept`, `compute`, `mv`, ...). `key` is the affected entry key. `etag_before` and `etag_after` are the entry etags before and after the write, or JSON `null` when not applicable (e.g. create has no before-etag, delete has no after-etag).
403
437
 
404
438
  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.
405
439
 
@@ -705,7 +739,6 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
705
739
  | `init` | write | `human` |
706
740
  | `schema {show,init,diff,migrate}` | read/write | `human` for writes |
707
741
  | `key mv OLD NEW [--as=R] [--dry-run]` | write | per zone (same-zone only) |
708
- | `key normalize [--dry-run\|--write]` | write (with `--write`) | `human` |
709
742
  | `key uid K` | read | any |
710
743
 
711
744
  **`put` input** (read from stdin when `--stdin` is given):
@@ -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,33 @@
1
+ module Textus
2
+ module Application
3
+ module Policy
4
+ module Predicates
5
+ # Promotion predicate: the role driving the promotion must have
6
+ # role_kind == :accept_authority in the active manifest.
7
+ #
8
+ # Accept/Reject already gate on this kind before reaching the
9
+ # promotion policy, so in the default control-flow this predicate
10
+ # trivially passes. It is kept so manifests can express the
11
+ # requirement explicitly in `rules[].promotion.requires`.
12
+ class AcceptAuthoritySigned
13
+ attr_reader :reason
14
+
15
+ def name
16
+ "accept_authority_signed"
17
+ end
18
+
19
+ def call(role:, manifest:, entry: nil) # rubocop:disable Lint/UnusedMethodArgument
20
+ role_str = role&.to_s
21
+ return true if role_str.nil? || role_str.empty?
22
+
23
+ kind = manifest.role_kind(role_str)
24
+ return true if kind == :accept_authority
25
+
26
+ @reason = "role '#{role_str}' has kind '#{kind.inspect}', expected ':accept_authority'"
27
+ false
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ 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") || {}