textus 0.26.0 → 0.29.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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +111 -67
  3. data/CHANGELOG.md +76 -0
  4. data/README.md +55 -13
  5. data/SPEC.md +75 -38
  6. data/docs/conventions.md +4 -4
  7. data/lib/textus/boot.rb +14 -10
  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 -2
  15. data/lib/textus/cli/verb/put.rb +3 -3
  16. data/lib/textus/cli/verb.rb +6 -6
  17. data/lib/textus/cli.rb +0 -7
  18. data/lib/textus/container.rb +23 -0
  19. data/lib/textus/dispatcher.rb +49 -0
  20. data/lib/textus/doctor/check/audit_log.rb +1 -1
  21. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  22. data/lib/textus/doctor/check/sentinels.rb +10 -8
  23. data/lib/textus/doctor/check.rb +12 -5
  24. data/lib/textus/doctor.rb +7 -7
  25. data/lib/textus/domain/authorizer.rb +2 -2
  26. data/lib/textus/domain/sentinel.rb +9 -65
  27. data/lib/textus/domain/staleness/generator_check.rb +46 -26
  28. data/lib/textus/domain/staleness/intake_check.rb +18 -10
  29. data/lib/textus/domain/staleness.rb +3 -3
  30. data/lib/textus/{application/envelope → envelope/io}/reader.rb +2 -2
  31. data/lib/textus/{application/envelope → envelope/io}/writer.rb +11 -11
  32. data/lib/textus/hooks/context.rb +30 -13
  33. data/lib/textus/hooks/rpc_registry.rb +1 -1
  34. data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
  35. data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
  36. data/lib/textus/maintenance/migrate.rb +51 -0
  37. data/lib/textus/maintenance/rule_lint.rb +56 -0
  38. data/lib/textus/maintenance/zone_mv.rb +51 -0
  39. data/lib/textus/maintenance.rb +15 -0
  40. data/lib/textus/manifest/data.rb +4 -3
  41. data/lib/textus/manifest/entry/base.rb +38 -18
  42. data/lib/textus/manifest/entry/derived.rb +6 -6
  43. data/lib/textus/manifest/entry/nested.rb +7 -9
  44. data/lib/textus/manifest/entry/parser.rb +2 -2
  45. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  46. data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
  47. data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
  48. data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
  49. data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
  50. data/lib/textus/manifest/entry/validators.rb +2 -2
  51. data/lib/textus/manifest/entry.rb +0 -5
  52. data/lib/textus/manifest.rb +1 -6
  53. data/lib/textus/mcp/server.rb +1 -2
  54. data/lib/textus/mcp/session.rb +10 -1
  55. data/lib/textus/mcp/tools.rb +2 -2
  56. data/lib/textus/mcp.rb +1 -1
  57. data/lib/textus/{infra → ports}/audit_log.rb +1 -1
  58. data/lib/textus/{infra → ports}/audit_subscriber.rb +2 -2
  59. data/lib/textus/{infra → ports}/build_lock.rb +1 -1
  60. data/lib/textus/{infra → ports}/clock.rb +1 -1
  61. data/lib/textus/{infra → ports}/publisher.rb +6 -6
  62. data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
  63. data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
  64. data/lib/textus/ports/sentinel_store.rb +67 -0
  65. data/lib/textus/ports/storage/file_stat.rb +19 -0
  66. data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
  67. data/lib/textus/projection.rb +91 -0
  68. data/lib/textus/read/audit.rb +111 -0
  69. data/lib/textus/read/blame.rb +81 -0
  70. data/lib/textus/read/boot.rb +18 -0
  71. data/lib/textus/read/deps.rb +24 -0
  72. data/lib/textus/read/doctor.rb +19 -0
  73. data/lib/textus/read/freshness.rb +101 -0
  74. data/lib/textus/read/get.rb +66 -0
  75. data/lib/textus/read/get_or_refresh.rb +69 -0
  76. data/lib/textus/read/list.rb +15 -0
  77. data/lib/textus/read/policy_explain.rb +37 -0
  78. data/lib/textus/read/published.rb +15 -0
  79. data/lib/textus/read/pulse.rb +89 -0
  80. data/lib/textus/read/rdeps.rb +25 -0
  81. data/lib/textus/read/schema_envelope.rb +16 -0
  82. data/lib/textus/read/stale.rb +17 -0
  83. data/lib/textus/read/uid.rb +20 -0
  84. data/lib/textus/read/validate_all.rb +22 -0
  85. data/lib/textus/read/validator.rb +84 -0
  86. data/lib/textus/read/where.rb +16 -0
  87. data/lib/textus/role_scope.rb +49 -0
  88. data/lib/textus/schema/tools.rb +3 -3
  89. data/lib/textus/store.rb +16 -7
  90. data/lib/textus/version.rb +1 -1
  91. data/lib/textus/write/accept.rb +86 -0
  92. data/lib/textus/write/authority_gate.rb +24 -0
  93. data/lib/textus/write/delete.rb +54 -0
  94. data/lib/textus/write/materializer.rb +48 -0
  95. data/lib/textus/write/mv.rb +123 -0
  96. data/lib/textus/write/publish.rb +66 -0
  97. data/lib/textus/write/put.rb +59 -0
  98. data/lib/textus/write/refresh_all.rb +44 -0
  99. data/lib/textus/write/refresh_orchestrator.rb +102 -0
  100. data/lib/textus/write/refresh_worker.rb +138 -0
  101. data/lib/textus/write/reject.rb +54 -0
  102. data/lib/textus.rb +1 -2
  103. metadata +54 -50
  104. data/lib/textus/application/caps.rb +0 -49
  105. data/lib/textus/application/context.rb +0 -34
  106. data/lib/textus/application/maintenance/key_delete_prefix.rb +0 -44
  107. data/lib/textus/application/maintenance/key_mv_prefix.rb +0 -57
  108. data/lib/textus/application/maintenance/migrate.rb +0 -59
  109. data/lib/textus/application/maintenance/rule_lint.rb +0 -65
  110. data/lib/textus/application/maintenance/zone_mv.rb +0 -60
  111. data/lib/textus/application/maintenance.rb +0 -17
  112. data/lib/textus/application/projection.rb +0 -93
  113. data/lib/textus/application/read/audit.rb +0 -106
  114. data/lib/textus/application/read/blame.rb +0 -91
  115. data/lib/textus/application/read/deps.rb +0 -34
  116. data/lib/textus/application/read/freshness.rb +0 -110
  117. data/lib/textus/application/read/get.rb +0 -75
  118. data/lib/textus/application/read/get_or_refresh.rb +0 -63
  119. data/lib/textus/application/read/list.rb +0 -25
  120. data/lib/textus/application/read/policy_explain.rb +0 -47
  121. data/lib/textus/application/read/published.rb +0 -25
  122. data/lib/textus/application/read/pulse.rb +0 -101
  123. data/lib/textus/application/read/rdeps.rb +0 -35
  124. data/lib/textus/application/read/schema_envelope.rb +0 -26
  125. data/lib/textus/application/read/stale.rb +0 -23
  126. data/lib/textus/application/read/uid.rb +0 -30
  127. data/lib/textus/application/read/validate_all.rb +0 -32
  128. data/lib/textus/application/read/validator.rb +0 -86
  129. data/lib/textus/application/read/where.rb +0 -26
  130. data/lib/textus/application/use_case.rb +0 -22
  131. data/lib/textus/application/write/accept.rb +0 -102
  132. data/lib/textus/application/write/authority_gate.rb +0 -26
  133. data/lib/textus/application/write/delete.rb +0 -45
  134. data/lib/textus/application/write/materializer.rb +0 -49
  135. data/lib/textus/application/write/mv.rb +0 -118
  136. data/lib/textus/application/write/publish.rb +0 -96
  137. data/lib/textus/application/write/put.rb +0 -49
  138. data/lib/textus/application/write/refresh_all.rb +0 -63
  139. data/lib/textus/application/write/refresh_orchestrator.rb +0 -102
  140. data/lib/textus/application/write/refresh_worker.rb +0 -134
  141. data/lib/textus/application/write/reject.rb +0 -62
  142. data/lib/textus/session.rb +0 -84
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e822d94de7ae06481dd8c03eab813a097b62e78121d94c9d1045017675da665c
4
- data.tar.gz: 3372f74bb31465b28be8e6ce5b4721a3f15cae7536ebdfc8ef9b6c6d7c9f2d88
3
+ metadata.gz: 0e8208a516e0a7760953e7c44a74fb459f168192ac4b0a20059b8686285f137a
4
+ data.tar.gz: 063deb01f793cec399a68620ce23bf1e9daf6d797edb985d919cb3bd6b9a1f87
5
5
  SHA512:
6
- metadata.gz: 31e4b83f9a7d3b061d043c447393b23ba404a47e7d79b0a411ad49719625d01ec30116a067f28bf88ac6944ff0262c1825d35c851022340d63b813dca26f98aa
7
- data.tar.gz: 590cc3419cb7823688bab11c4fce49a011bf62b463ab887b415753dd3c8037cd0704b8ccc4df6e778b21c04bd8101847a28fa5d4819e447e7af3d44febc017f3
6
+ metadata.gz: 29d392ea08bbd23f460761c1849c90d144a97b9e9208355818b364acbf72c708e2129c650ecf032a562b62271064094dac96ed5f6497c97128666e004959d47d
7
+ data.tar.gz: 2260fe709abe9685285cad7171e36def69a6e25047762c2077dcb4c70e670f8232123156b33b5811dfd79393592b563470819c641ff9508ec2b795f3b87065ad
data/ARCHITECTURE.md CHANGED
@@ -2,31 +2,29 @@
2
2
 
3
3
  ```
4
4
  ┌─ Interface ────────────────────────────────────────────────┐
5
- │ CLI verbs: session = Session.for(store, role:)
6
- session.<name>(...) # one method per
7
- │ # registered use case
8
- │ # (put/get/refresh/…) │
5
+ │ CLI verbs: store.<verb>(..., role:)
6
+ store.as(role).<verb>(...)
7
+ │ # (put/get/refresh/…)
9
8
  │ │
10
9
  │ MCP gate: textus mcp serve — same use cases, JSON-RPC. │
11
10
  └──────────────────────┬─────────────────────────────────────┘
12
11
 
13
12
  ┌─ Application ────────▼─────────────────────────────────────┐
14
- Context (slim Data: role, correlation_id, now, │
13
+ Call (slim Data: role, correlation_id, now, │
15
14
  │ dry_run — request state only) │
16
- Caps (Read/Write/Hook recordsstore slices) │
17
- Session (per-call dispatch; methods generated
18
- from UseCase registry)
19
- │ UseCase (registry: verb → module, caps_kind) │
15
+ Container (single recordwired ports + manifest) │
16
+ Dispatcher (static VERBS table: verb → use-case)
17
+ RoleScope (Store#as(role) forwards verb calls)
20
18
  │ │
21
19
  │ read/{get,get_or_refresh,list,where,uid,schema_envelope, │
22
- │ deps,rdeps,published,stale,validate_all,
20
+ │ deps,rdeps,published,stale,validate_all,boot,doctor,│
23
21
  │ freshness,audit,blame,policy_explain,pulse}.rb │
24
22
  │ write/{put,delete,mv,accept,reject,publish, │
25
23
  │ materializer,authority_gate, │
26
24
  │ refresh_worker,refresh_orchestrator,refresh_all} │
27
25
  │ maintenance/{migrate,key_mv_prefix,key_delete_prefix, │
28
26
  │ zone_mv,rule_lint}.rb │
29
- │ envelope/{reader,writer}.rb (split: parse vs persist)
27
+ │ envelope/io/{reader,writer}.rb (split: parse vs persist)
30
28
  │ projection.rb │
31
29
  └──────────┬───────────────────────────────┬─────────────────┘
32
30
  │ uses domain │ uses ports
@@ -42,115 +40,161 @@
42
40
  │ implements
43
41
  ┌─ Infrastructure ─────────────────────────▼─────────────────┐
44
42
  │ Store (composition root — wires ports, │
45
- │ vends Sessions)
43
+ │ vends a Container + dispatches verbs)
46
44
  │ Storage::FileStore (bytes-only port: read/write/delete/ │
47
45
  │ exists?/etag) │
48
46
  │ Manifest (Data, Resolver, Policy, Rules) │
49
47
  │ Schemas (eager-load cache) │
50
- Infra::{AuditLog,AuditSubscriber,Publisher,Clock, │
48
+ Ports::{AuditLog,AuditSubscriber,Publisher,Clock, │
51
49
  │ Refresh::Lock,Refresh::Detached,BuildLock} │
52
50
  │ Hooks::{EventBus,RpcRegistry,Loader,Context,FireReport, │
53
51
  │ Builtin,ErrorLog} │
54
52
  │ Entry::{Markdown,Json,Yaml,Text} (format strategies) │
55
53
  └────────────────────────────────────────────────────────────┘
56
54
 
57
- Dependency rule: arrows point DOWN. Domain has zero outbound
58
- imports. Application imports Domain + Infra (via ports).
59
- Use cases declare their real collaborators in their Impl
60
- constructor; UseCase.register hooks them into Session.
55
+ Dependency rule: arrows point DOWN. Domain performs no direct
56
+ File/Dir/Time.now I/O all disk and clock access is routed through
57
+ injected ports (FileStat, Clock). Pure path math (File.join/dirname/
58
+ absolute_path?/expand_path/basename), Digest hashing of injected
59
+ bytes, and Time.parse of stored strings are NOT I/O and are allowed.
60
+ Application imports Domain + Ports.
61
+ Use cases are plain classes on (container:, call:).
62
+ Verbs are looked up in the static Dispatcher::VERBS table.
61
63
  ```
62
64
 
63
65
  ## How a verb becomes a method
64
66
 
65
- Each application use case is a module under `lib/textus/application/{read,write,maintenance}/`. The shape is uniform:
67
+ Each application use case is a plain class under `lib/textus/{read,write,maintenance}/`. The shape is uniform:
66
68
 
67
69
  ```ruby
68
70
  module Textus
69
- module Application
70
- module Read
71
- module Get
72
- def self.call(*, session:, ctx:, caps:, **)
73
- Impl.new(ctx: ctx, caps: caps).call(*, **)
74
- end
75
-
76
- class Impl
77
- def initialize(ctx:, caps:, ...)
78
- @ctx = ctx; @manifest = caps.manifest; ...
79
- end
80
-
81
- def call(key) ... end
82
- end
71
+ module Read
72
+ class Get
73
+ def initialize(container:, call:)
74
+ @container = container
75
+ @call = call
76
+ end
77
+
78
+ def call(key)
79
+ ...
83
80
  end
84
81
  end
85
82
  end
86
83
  end
87
-
88
- Textus::Application::UseCase.register(:get, Textus::Application::Read::Get, caps: :read)
89
84
  ```
90
85
 
91
- `Session` generates one dispatch method per registered entry (see `lib/textus/session.rb` the `Application::UseCase.each do |entry| ... end` block at the bottom). Adding a new verb is **one `UseCase.register` line** plus the module — no edits to `Session`.
86
+ Verbs are looked up in a static frozen table (`Textus::Dispatcher::VERBS`) that maps `:get Textus::Read::Get`, `:put Textus::Write::Put`, etc. `Store#put` / `Store#get` / `Store#as(role).<verb>(...)` instantiate the use case on `(container:, call:)` and invoke `#call`. Adding a new verb is **one entry in `Dispatcher::VERBS`** plus the class — no metaprogramming.
92
87
 
93
- Two collaborators live outside the registry because they're composed by other use cases, not invoked as verbs:
88
+ `boot` and `doctor` are read verbs like any other: `Read::Boot` / `Read::Doctor`
89
+ are thin `(container:, call:)` use cases that delegate to the `Textus::Boot` /
90
+ `Textus::Doctor` report-builder libraries (`build(container:, ...)`). They are
91
+ reached through `Dispatcher::VERBS`, not a special method on `RoleScope`.
94
92
 
95
- - `Application::Write::RefreshOrchestrator` composes `RefreshWorker` with the freshness `Action` returned by `Domain::Freshness`. Session memoizes one (`session.refresh_orchestrator`).
96
- - `Application::Envelope::{Reader,Writer}` — own the parse and persist halves of the write pipeline; the audit-append-as-final-step invariant lives in `Writer`. Session memoizes both.
93
+ Two collaborators live outside the dispatcher because they're composed by other use cases, not invoked as verbs:
97
94
 
98
- ## Caps
95
+ - `Write::RefreshOrchestrator` — composes `RefreshWorker` with the freshness `Action` returned by `Domain::Freshness`.
96
+ - `Envelope::IO::{Reader,Writer}` — own the parse and persist halves of the write pipeline; the audit-append-as-final-step invariant lives in `Writer`.
99
97
 
100
- Use cases never see the raw `Store`. `Application::Caps` defines three role-scoped slices:
98
+ ## Container
99
+
100
+ Use cases never see the raw `Store`. `Textus::Container` is a single record holding the wired collaborators:
101
101
 
102
102
  ```ruby
103
- ReadCaps = Data.define(:manifest, :file_store, :schemas, :root, :audit_log, :events)
104
- WriteCaps = Data.define(:manifest, :file_store, :schemas, :root, :audit_log, :events, :authorizer)
105
- HookCaps = Data.define(:events, :rpc, :manifest, :root)
103
+ Container = Data.define(
104
+ :manifest, :file_store, :schemas, :root,
105
+ :audit_log, :events, :rpc, :authorizer
106
+ )
106
107
  ```
107
108
 
108
- `Session.for(store, role:)` builds all three via `Application.caps_from_store(store)`; the dispatch method picks `read_caps` or `write_caps` based on the `caps_kind` declared at registration time. RPC hook callables (`:resolve_intake`, `:transform_rows`, `:validate`) receive a `caps:` kwarg that is the appropriate Read/Write slice legacy `store:` is rejected by `Hooks::RpcRegistry#invoke`.
109
+ The `Store` builds one `Container` at boot; every use case receives it via `(container:, call:)`. RPC hook callables (`:resolve_intake`, `:transform_rows`, `:validate`) receive `caps: <Container>` field names match what the prior `WriteCaps` exposed, so handlers reading `caps.manifest`, `caps.events`, etc. continue to work.
110
+
111
+ ## Ports
112
+
113
+ Ports are infrastructure adapters with an interface defined by the domain. Each port is independently replaceable — swap the implementation for tests or alternative runtimes without touching application or domain code.
114
+
115
+ | Class | Role |
116
+ |---|---|
117
+ | `Ports::Storage::FileStore` | Bytes-only FS I/O — `read`, `write`, `delete`, `exists?`, `etag`. No knowledge of envelopes or schemas. |
118
+ | `Ports::AuditLog` | Append-only structured log (`audit.log`). Owns seq numbering, file-locking, and rotation. |
119
+ | `Ports::Clock` | Supplies `Time.now` — a module-function so tests can swap it without dependency injection boilerplate. |
120
+ | `Ports::Publisher` | Copies a built artifact to a repo-relative consumer path and writes a sentinel so the next publish can confirm the target is managed. |
121
+ | `Ports::Refresh::Lock` | Non-blocking `flock`-backed lock per key — prevents concurrent refresh workers from racing on the same entry. |
122
+ | `Ports::Refresh::Detached` | Spawns a background thread for async refresh; the caller receives a `refresh_backgrounded` event instead of blocking. |
123
+ | `Ports::BuildLock` | Process-exclusive `flock` guard over the materializer build pipeline. Raises `BuildInProgress` if a build is already running. |
124
+
125
+ Application use cases access ports only through `Container` fields — never through the raw `Store`.
126
+
127
+ ### EnvelopeIO
128
+
129
+ `Envelope::IO::Reader` and `Envelope::IO::Writer` split the envelope pipeline into read-only parse and write-with-audit halves.
130
+
131
+ **Reader** (`lib/textus/envelope/io/reader.rb`) — resolves a key through `manifest.resolver`, reads bytes via `FileStore`, delegates parsing to the format strategy (`Entry.for_format`), and returns an `Envelope`. No audit, no events, no permission checks. Also used by `Writer` for the existing-uid lookup on `put`.
132
+
133
+ **Writer** (`lib/textus/envelope/io/writer.rb`) — owns the full write pipeline: serialize → schema-validate → etag-check → `FileStore#write` → `AuditLog#append`. The class comment states the invariant directly: every public method's final action is `@audit_log.append(...)`. If the audit append fails, the caller sees the underlying error — the byte write already happened, but the pipeline contract treats audit as the commit step. No permission check, no event firing — those stay in the calling use case (`Write::Put`, `Write::Delete`, `Write::Mv`).
134
+
135
+ The three public methods are `put`, `delete`, and `move`; all follow the same validate → write → audit sequence.
136
+
137
+ ## Manifest carving
138
+
139
+ Manifest carving means slicing the parsed manifest YAML into four purpose-specific sub-objects. Each consumer sees only the fields it needs; none reach into the full raw document.
140
+
141
+ `Manifest` itself is a `Data.define` struct — a composition record with four named members:
142
+
143
+ | Member | Class | Responsibility |
144
+ |---|---|---|
145
+ | `data` | `Manifest::Data` | Frozen value: `raw`, `root`, `zones`, `entries`, `audit_config`, `role_mapping`. Structural data only — no behaviour beyond accessors and key validation. |
146
+ | `resolver` | `Manifest::Resolver` | Key → `Resolution(entry, path, remaining)`. Handles nested entry enumeration and fuzzy-match suggestions. |
147
+ | `policy` | `Manifest::Policy` | Zone/role authority — `zone_writers`, `zone_kinds`, `permission_for`, `role_kind`, `roles_with_kind`. Derived from a `Data` snapshot; no filesystem I/O. |
148
+ | `rules` | `Manifest::Rules` | Pattern-matched rule engine. `rules.for(key)` returns a `RuleSet(refresh, handler_allowlist, promote, retention)` by evaluating all `match:` blocks against the key. |
149
+
150
+ Rationale: cleaner test seams — a use case that only needs key resolution constructs a `Manifest::Resolver` from a stub `Data`; one that only needs rule lookup constructs a `Manifest::Rules` directly. No consumer is forced to build the full manifest to exercise one sub-view.
151
+
152
+ The four members are wired in `Manifest.build` (`lib/textus/manifest.rb`). `Manifest::Data` constructs `Policy` internally during `initialize`; the others are assembled by the loader and handed in as named arguments.
109
153
 
110
- ## Read path (`session.get(key)`)
154
+ ## Read path (`store.get(key)`)
111
155
 
112
- 1. CLI verb (or MCP tool) builds `session = Session.for(store, role:)` then `session.get(key)`.
113
- 2. `Session#get` dispatches to `Application::Read::Get.call(key, session:, ctx:, caps:)`.
114
- 3. `Read::Get::Impl#call` resolves the path through `caps.manifest`, reads bytes via `caps.file_store`, parses the envelope.
115
- 4. Looks up the refresh policy via `caps.manifest.rules.for(key)`. If absent, returns the envelope annotated fresh.
156
+ 1. CLI verb (or MCP tool) calls `store.get(key, role:)` (or `store.as(role).get(key)`).
157
+ 2. `Store#get` looks up `Dispatcher::VERBS[:get] → Read::Get`, builds a `Call`, instantiates `Read::Get.new(container:, call:).call(key)`.
158
+ 3. `Read::Get#call` resolves the path through `container.manifest`, reads bytes via `container.file_store`, parses the envelope.
159
+ 4. Looks up the refresh policy via `container.manifest.rules.for(key)`. If absent, returns the envelope annotated fresh.
116
160
  5. Otherwise `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`; the envelope is annotated with `stale`, `reason`, `refreshing: false`.
117
161
 
118
- `session.get_or_refresh(key)` composes `Read::Get` with `Write::RefreshOrchestrator` to optionally refresh on stale.
162
+ `store.get_or_refresh(key)` composes `Read::Get` with `Write::RefreshOrchestrator` to optionally refresh on stale.
119
163
 
120
- ## Write path (`session.put(key, ...)`)
164
+ ## Write path (`store.put(key, ...)`)
121
165
 
122
- 1. CLI verb calls `session = Session.for(store, role:)` then `session.put(key, meta:, body:, content:, if_etag:)`.
123
- 2. `Write::Put::Impl#call` validates the key, resolves the manifest entry, and calls `@authorizer.authorize_write!(mentry, role: @ctx.role)` — raises `WriteForbidden` if denied.
124
- 3. Delegates persistence to `session.envelope_writer.put`, which serializes, schema-validates, etag-checks (raises `EtagMismatch` on conflict), writes via the `FileStore` port, and appends the audit row.
125
- 4. Publishes `:entry_put` via `caps.events` with `ctx: session.hook_context`, `key:`, `envelope:`.
166
+ 1. CLI verb calls `store.put(key, meta:, body:, content:, if_etag:, role:)`.
167
+ 2. `Write::Put#call` validates the key, resolves the manifest entry, and calls `container.authorizer.authorize_write!(mentry, role: call.role)` — raises `WriteForbidden` if denied.
168
+ 3. Delegates persistence to `Envelope::IO::Writer#put`, which serializes, schema-validates, etag-checks (raises `EtagMismatch` on conflict), writes via the `FileStore` port, and appends the audit row.
169
+ 4. Publishes `:entry_put` via `container.events` with `ctx: <Hooks::Context>`, `key:`, `envelope:`.
126
170
 
127
- `Write::{Delete,Mv,Accept,Reject,Publish}` follow the same shape: explicit caps, `Authorizer` for authz, `Envelope::Writer` for persistence (where applicable), event published with the `Hooks::Context` handle.
171
+ `Write::{Delete,Mv,Accept,Reject,Publish}` follow the same shape: explicit container, `Authorizer` for authz, `Envelope::IO::Writer` for persistence (where applicable), event published with the `Hooks::Context` handle.
128
172
 
129
- `Write::Mv` delegates the file-move + audit to `Envelope::Writer#move`, then publishes `:entry_renamed` itself. UID injection (when the source lacks one) goes through `Envelope::Writer#write` directly — no `Put` bypass.
173
+ `Write::Mv` delegates the file-move + audit to `Envelope::IO::Writer#move`, then publishes `:entry_renamed` itself. UID injection (when the source lacks one) goes through `Envelope::IO::Writer#write` directly — no `Put` bypass.
130
174
 
131
- ## Refresh path (`session.refresh(key)`)
175
+ ## Refresh path (`store.refresh(key)`)
132
176
 
133
- 1. CLI `Verb::Refresh` builds `session = Session.for(store, role: "runner")` then calls `session.refresh(key)`.
134
- 2. `Write::RefreshWorker::Impl#run(key)`:
135
- - Resolves the manifest entry, looks up the intake handler via `caps.rpc.callable(:resolve_intake, mentry.handler)`.
177
+ 1. CLI `Verb::Refresh` calls `store.refresh(key, role: "runner")`.
178
+ 2. `Write::RefreshWorker#run(key)`:
179
+ - Resolves the manifest entry, looks up the intake handler via `container.rpc.callable(:resolve_intake, mentry.handler)`.
136
180
  - Publishes `:refresh_started` with the hook context.
137
181
  - Invokes the handler under a 30s thread-join deadline.
138
182
  - On any error: publishes `:refresh_failed`, then re-raises.
139
- - On success: applies `@authorizer.authorize_write!` and persists via `Envelope::Writer#write` directly (no `Put` round-trip); publishes `:entry_refreshed` unless etag is unchanged.
140
- 3. `session.refresh_all(prefix:, zone:)` lists stale entries via `Read::Stale` and runs `Worker#run` per entry; returns `{ refreshed:, failed:, skipped: }`.
183
+ - On success: applies `container.authorizer.authorize_write!` and persists via `Envelope::IO::Writer#write` directly (no `Put` round-trip); publishes `:entry_refreshed` unless etag is unchanged.
184
+ 3. `store.refresh_all(prefix:, zone:)` lists stale entries via `Read::Stale` and runs `Worker#run` per entry; returns `{ refreshed:, failed:, skipped: }`.
141
185
 
142
186
  ## Hook payload contract
143
187
 
144
- Pub-sub hooks (`:entry_put`, `:entry_refreshed`, …) receive `ctx:` — a `Textus::Hooks::Context` that wraps the session and exposes a narrow surface (`get`, `list`, `put`, `delete`, `audit`, `publish_followup`, plus `role` and `correlation_id`). The raw `Store` is not handed out.
188
+ Pub-sub hooks (`:entry_put`, `:entry_refreshed`, …) receive `ctx:` — a `Textus::Hooks::Context` that exposes a narrow surface (`get`, `list`, `put`, `delete`, `audit`, `publish_followup`, plus `role` and `correlation_id`). The raw `Store` is not handed out.
145
189
 
146
- RPC hooks (`:resolve_intake`, `:transform_rows`, `:validate`) receive `caps:` — a `ReadCaps` or `WriteCaps` slice. They are gem-internal: the framework calls them, not user pub-sub.
190
+ RPC hooks (`:resolve_intake`, `:transform_rows`, `:validate`) receive `caps:` — a `Textus::Container`. They are gem-internal: the framework calls them, not user pub-sub.
147
191
 
148
192
  ## Agent surface (boot + pulse + MCP)
149
193
 
150
194
  Agents and plugins talk to a textus store through three layers:
151
195
 
152
196
  ```
153
- soul (skill/agent) ──▶ gate (CLI | MCP) ──▶ Session ──▶ memory (.textus/)
197
+ soul (skill/agent) ──▶ gate (CLI | MCP) ──▶ Store ──▶ memory (.textus/)
154
198
  ```
155
199
 
156
200
  Two transports, one façade:
@@ -158,7 +202,7 @@ Two transports, one façade:
158
202
  - **CLI** — human/script surface. `textus boot`, `textus pulse --since=N`, `textus get/put/...`.
159
203
  - **MCP** — agent surface. `textus mcp serve` runs a stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. Tools are auto-derived from the manifest. Session state (cursor, role, manifest_etag) is server-side.
160
204
 
161
- Both transports call `Session.for(store, role:)`. No duplicate logic.
205
+ Both transports call `store.<verb>(..., role:)` (or `store.as(role).<verb>(...)`). No duplicate logic.
162
206
 
163
207
  The agent loop (cadence guide in `docs/agent-integration.md`):
164
208
 
data/CHANGELOG.md CHANGED
@@ -9,6 +9,82 @@ 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.29.0 — 2026-05-29
13
+
14
+ A domain-purity pass that routes all filesystem and wall-clock I/O through injected ports. Breaking changes are Ruby-API only; the wire format (`textus/3`) and CLI are unchanged.
15
+
16
+ ### Breaking
17
+
18
+ - `Domain::Staleness#initialize` now requires `file_stat:` and `clock:` (was `manifest:` only).
19
+ - `Domain::Staleness::IntakeCheck#initialize` now requires `file_stat:` and `clock:`.
20
+ - `Domain::Staleness::GeneratorCheck#initialize` now requires `file_stat:` (no clock — `GeneratorCheck` has no wall-clock dependency).
21
+ - `Domain::Sentinel` is now a pure value object. Its persistence class methods (`write!`, `load`, `sentinel_path`) and `SUFFIX`/`DIR` constants have moved to the new `Ports::SentinelStore`.
22
+ - `Domain::Sentinel#orphan?` and `#drift?` now take a `file_stat` argument.
23
+ - `Textus::Boot.run_via(container:, role:)` → `Textus::Boot.build(container:)` (the `role:` parameter was unused).
24
+ - `Textus::Doctor.run_via(container:, role:, checks:)` → `Textus::Doctor.build(container:, checks:)` (the `role:` parameter was unused).
25
+ - `RoleScope#boot` / `#doctor` are removed as special cases; `boot` and `doctor` are now entries in `Dispatcher::VERBS`. `store.boot`, `store.doctor`, and `store.as(role).boot` are unchanged.
26
+
27
+ ### Added
28
+
29
+ - `Ports::Storage::FileStat` — read-only filesystem query port (`exists?`, `directory?`, `read`, `mtime`, `glob`); the narrow interface pure domain logic depends on (distinct from the write-side `FileStore`).
30
+ - `Ports::SentinelStore` — sentinel persistence + path-layout adapter, extracted from `Domain::Sentinel`.
31
+ - `Read::Boot` and `Read::Doctor` — dispatched use-case classes on the uniform `(container:, call:)` shape.
32
+
33
+ ### Changed
34
+
35
+ - `manifest_etag` (in `pulse` output and the MCP session drift token) is now the system-standard `sha256:`-prefixed etag, computed via `FileStore#etag`, instead of a bare SHA-256 hex digest. The token is opaque (compared for equality, never parsed).
36
+
37
+ ### Internal
38
+
39
+ - The domain layer no longer performs direct filesystem or wall-clock I/O; all disk/clock access is routed through injected ports (`FileStat`, `Clock`). Enforced by a new `spec/domain_purity_spec.rb` that fails on any regression.
40
+ - Freshness request timestamps now originate from `Ports::Clock` (via `Call.build`) rather than a bare `Time.now`.
41
+ - Cosmetic refactors: deduped the audit limit guard; made `RefreshWorker.normalize_action_result` a public class method (dropped a `send`); extracted staleness guard helpers.
42
+ - New guard spec `spec/no_handrolled_manifest_etag_spec.rb` forbids `Digest::SHA256.hexdigest(File.read(...))` from reappearing in `lib/` (exempt: `etag.rb` and `sentinel_store.rb`, the latter being a wire-pinned integrity checksum, not an etag).
43
+ - See [ADR 0024](docs/architecture/decisions/0024-domain-purity-ports.md) for the design rationale.
44
+
45
+ ## 0.28.0 — 2026-05-29
46
+
47
+ A consistency-and-cleanup pass that finishes the seams [ADR 0022](docs/architecture/decisions/0022-container-call-dispatcher.md) left behind. Breaking changes are Ruby-API only.
48
+
49
+ ### Breaking
50
+
51
+ - Use-case constructors no longer accept `hook_context:`. Use cases that emit events derive their `Hooks::Context` internally from `(container, call)` via the new `Textus::Hooks::Context.for(container:, call:)` factory. Every use case now has the uniform shape `def initialize(container:, call:)`.
52
+ - `Textus::Envelope::IO::Writer` and `Textus::Write::RefreshOrchestrator` constructors take `call:` instead of `ctx:` (both received a `Call` already; the kwarg name is corrected).
53
+ - `Read::Audit#call` now accepts filter keywords and builds a `Read::Audit::Query` value object internally — keyword callers (`store.audit(key:, limit:)`) are unchanged.
54
+ - `Builder::Pipeline.run` takes `(mentry:, deps:)` where `deps` is a `Builder::Pipeline::Deps` record, instead of eight loose keyword collaborators.
55
+ - Removed the `CLI::VERBS` const-missing shim (use `CLI.verbs`).
56
+ - Removed the `Manifest::Entry::PUBLISH_EACH_VARS` / `PUBLISH_EACH_VAR_RE` re-exports (use `Manifest::Entry::Validators::PublishEach::KNOWN_VARS` / `::VAR_RE`).
57
+
58
+ ### Internal
59
+
60
+ - Removed the runtime `initialize`-parameter reflection from both `RoleScope` and `Doctor::Check`; verb dispatch is now an unconditional `klass.new(container:, call:).call(...)`.
61
+ - `Lint/UnusedMethodArgument` disables dropped from 27 to 20; two `Metrics/ParameterLists` (and two complexity) disables removed by the value-object refactors. `Metrics/ParameterLists` ceiling documented and kept at `Max 6` (the honest ceiling for value-object constructors, `AuditLog#append`, and the public `put` API).
62
+ - `ARCHITECTURE.md`'s "uniform `(container:, call:)`" claim is now accurate; active docs refreshed to the 0.27/0.28 vocabulary.
63
+ - No wire-format change. Protocol stays at `textus/3`. CLI verb signatures unchanged. Hook callable surfaces (`ctx:` for pub-sub, `caps:` for RPC) unchanged.
64
+ - See [ADR 0023](docs/architecture/decisions/0023-uniform-use-case-shape.md) for the design rationale.
65
+
66
+ ## 0.27.0 — 2026-05-29
67
+
68
+ ### Breaking
69
+
70
+ - Removed `Textus::Session`. Use `store.as(role).put(...)` or `store.put(..., role:)` instead of `store.session(role:).put(...)`.
71
+ - Removed `Textus::Application::UseCase` registry. Verb dispatch is now via the static `Textus::Dispatcher::VERBS` table.
72
+ - Replaced `Textus::Application::ReadCaps` / `WriteCaps` / `HookCaps` with a single `Textus::Container` record (field names preserved: `manifest`, `file_store`, `schemas`, `root`, `audit_log`, `events`, `rpc`, `authorizer`).
73
+ - Renamed `Textus::Application::Context` to `Textus::Call`. Field shape identical.
74
+ - Use-case classes are no longer `module Foo; def self.call; Impl.new(...).call; end`. They are plain classes: `class Foo; def initialize(container:, call:); def call(...); end`.
75
+ - Flattened `Textus::Application::Write::*` → `Textus::Write::*`, `Application::Read::*` → `Read::*`, `Application::Envelope::*` → `Envelope::IO::*`, `Application::Maintenance::*` → `Maintenance::*`, `Application::Projection` → `Projection`.
76
+ - Renamed `Textus::Infra::*` → `Textus::Ports::*`.
77
+ - `Manifest::Entry::Base#zone_writers` / `#in_generator_zone?` / `#in_proposal_zone?` now take an explicit `policy` argument; entries no longer carry an `@manifest` back-reference.
78
+ - `PublishContext` shrunk from 12 fields to `(container, call, reader)` with derived accessors. Custom derived entries that destructured `pctx.caps` / `pctx.session` / `pctx.ctx` / `pctx.bus` need to use `pctx.container` / construct a `RoleScope` / `pctx.call` / `pctx.events`.
79
+ - Hook RPC callables (`:resolve_intake`, `:transform_rows`, `:validate`) receive `caps: container` (a `Textus::Container`) instead of `caps: <WriteCaps>`. Field names preserved, so handlers reading `caps.manifest` / `caps.events` / etc. continue to work.
80
+
81
+ ### Internal
82
+
83
+ - ~600 LOC removed net across ~60 files.
84
+ - No wire-format change. Protocol stays at `textus/3`.
85
+ - CLI verb signatures unchanged. No envelope shape changes.
86
+ - See [ADR 0022](docs/architecture/decisions/0022-container-call-dispatcher.md) for the design rationale.
87
+
12
88
  ## 0.26.0 — 2026-05-28
13
89
 
14
90
  ### Breaking
data/README.md CHANGED
@@ -5,20 +5,62 @@
5
5
  [![Ruby](https://img.shields.io/badge/ruby-%E2%89%A53.3-CC342D.svg)](https://www.ruby-lang.org/)
6
6
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
7
 
8
- A context store for codebases that humans and AI agents both have to read and write. Dotted keys, schema-validated entries, role-gated writes, byte-copy publish, an audit log of every change. Built so an agent landing in your repo can run one command (`textus boot`) and know what to read, what to write, and what's off-limits.
8
+ **Durable, multi-writer context for codebases that humans and AI agents both touch.** Your agent forgets everything between sessions; your runbooks and `CLAUDE.md` get edited by whoever ran last; nobody can reconstruct who wrote what. textus is the memory that survives the model, the session, and the vendor a shared workspace where humans, agents, and runners write into separate lanes, propose changes through a review queue, and leave an audit trail behind every byte.
9
9
 
10
- Reference implementation in Ruby. Wire format `textus/3`. SPEC: [`SPEC.md`](SPEC.md). Implementation notes: [`docs/`](docs/).
10
+ *textus* is Latin for "the fabric a text is woven from" — same root as *context*, from *con-texere*, "to weave together." The protocol weaves human edits, agent proposals, and runner intake into one durable fabric. The shape of that fabric is yours; the rules for writing into it are textus's.
11
11
 
12
- ## Versioning
12
+ ## The idea
13
13
 
14
- Two versions, deliberately independent:
14
+ Three actors write to your repo today:
15
15
 
16
- - **Protocol wire string:** `textus/3`. Breaking changes require `textus/4`.
17
- - **Gem version:** semver, currently `0.18.0`. Decoupled from the protocol string internal refactors bump the gem; only wire-format changes bump the protocol.
16
+ - **Humans** you, your team. Authoritative on identity, decisions, voice.
17
+ - **Agents** Claude, Cursor, custom assistants. Smart, fast, forgetful, and not always right.
18
+ - **Runners** — cron jobs, fetchers, CI. Bring outside data in.
18
19
 
19
- Envelope payloads carry the `protocol` field. The gem version is irrelevant to the wire format.
20
+ Without coordination, they overwrite each other and nothing remembers why. textus gives each actor a **lane** (a zone), routes everything they can't write directly through a **review queue**, and writes every successful change to an **append-only audit log**. The lanes are enforced at the protocol level, not by convention.
20
21
 
21
- See [CHANGELOG.md](CHANGELOG.md) for per-release notes.
22
+ ```
23
+ identity/ human only — who you are, what you decide, how you sound
24
+ working/ human + agent + runner — day-to-day catalog
25
+ intake/ runner only — declared external inputs
26
+ review/ agent + human — proposals waiting on a human accept
27
+ output/ builder only — computed, published artifacts
28
+ ```
29
+
30
+ An agent that tries to write directly into `working/` or `identity/` gets `write_forbidden`. It writes to `review/` instead. You accept the good proposals; textus promotes them, records the move, and audits both halves. Stable per-entry `uid:` means a reorganization doesn't break references. A monotonic audit cursor (`textus pulse --since=N`) means the next session — possibly a different agent, possibly a different model — picks up exactly where the last one left off.
31
+
32
+ That's the load-bearing claim: **coordination is a protocol invariant, not a library convenience.**
33
+
34
+ ## See it in four commands
35
+
36
+ ```sh
37
+ gem install textus
38
+ textus init # creates .textus/ with zones + schemas
39
+ # agent proposes a change to review/
40
+ echo '{"_meta":{"name":"oncall","proposal":{"target_key":"working.notes.oncall","action":"put"}},"body":"Patrick on call.\n"}' \
41
+ | textus put review.notes.oncall --as=agent --stdin
42
+ # you accept it — textus promotes to working/ and audits the move
43
+ textus accept review.notes.oncall --as=human
44
+ ```
45
+
46
+ Try the gate the other way (`textus put working.notes.X --as=agent`) and you get `write_forbidden`, with the role that *would* be allowed named in the error. That refusal is the whole point.
47
+
48
+ ## Try it
49
+
50
+ - **5-command worked demo** — single terminal scroll, no MCP, no schemas: [`examples/hello/`](examples/hello/)
51
+ - **Wire textus into Claude Code via MCP** — 4 steps, ~5 minutes: [`INTEGRATE_WITH_CLAUDE.md`](INTEGRATE_WITH_CLAUDE.md)
52
+ - **Use textus as your own project's context store**: [`examples/project/`](examples/project/)
53
+ - **Use textus to author a Claude plugin** (textus is the source-of-truth, build publishes to `agents/`, `skills/`, `commands/`): [`examples/claude-plugin/`](examples/claude-plugin/)
54
+
55
+ ## Protocol, not just a gem
56
+
57
+ This Ruby gem is the reference implementation of **`textus/3`** — a wire format and storage convention any language can speak. The protocol owns the envelope shape, the role/zone gate, the audit log format, and the key grammar. The gem version (semver, see badge) and the protocol version (`textus/3`) move independently; envelopes carry the `protocol` field so consumers can pin to the contract, not the implementation.
58
+
59
+ - Specification: [`SPEC.md`](SPEC.md)
60
+ - Architecture: [`ARCHITECTURE.md`](ARCHITECTURE.md)
61
+ - Per-release notes: [`CHANGELOG.md`](CHANGELOG.md)
62
+
63
+ A second implementation in another language would share the same `.textus/` directory and the same audit log. That's deliberate.
22
64
 
23
65
  ## Install
24
66
 
@@ -75,15 +117,15 @@ textus audit --limit=20 # query the audit log
75
117
 
76
118
  For the full shape — Claude plugin with agents, skills, commands, pending walkthrough, intake action — see [`examples/claude-plugin/`](examples/claude-plugin/).
77
119
 
78
- ## What ships today
120
+ ## What's shipped
79
121
 
80
122
  - **Per-entry formats.** `format: markdown | json | yaml | text` on a manifest entry. `cat .textus/zones/output/marketplace.json | jq .` works without going through textus — the in-store file *is* the consumer-shaped artifact. Structured outputs carry `_meta` at the top level (`generated_at`, `from`, `template`, `transform`).
81
123
  - **Per-leaf publishing.** Nested entries declare `publish_each: "skills/{basename}/SKILL.md"`. Every leaf byte-copies to its consumer location on `textus build`. No more hand-mirrored `agents/` / `skills/` / `commands/` directories.
82
- - **Build and publish in one pass.** `Application::Write::Publish` materializes generator-zone entries and copies nested leaves to their `publish_each` targets. The `textus build` CLI verb dispatches to it; the wire envelope is unchanged.
83
- - **Typed envelopes (v0.14.0).** `Textus::Envelope` is a `Data.define` value object with typed accessors (`.meta`, `.body`, `.etag`, `.uid`, `.freshness`, …). Ruby API callers get IDE help and `NoMethodError` on typos. The CLI JSON wire format is preserved byte-for-byte via `envelope.to_h_for_wire`.
124
+ - **Build and publish in one pass.** `Textus::Write::Publish` materializes generator-zone entries and copies nested leaves to their `publish_each` targets. The `textus build` CLI verb dispatches to it; the wire envelope is unchanged.
125
+ - **Typed envelopes.** `Textus::Envelope` is a `Data.define` value object with typed accessors (`.meta`, `.body`, `.etag`, `.uid`, `.freshness`, …). Ruby API callers get IDE help and `NoMethodError` on typos. The CLI JSON wire format is preserved byte-for-byte via `envelope.to_h_for_wire`.
84
126
  - **Stable identity (`uid:`).** 16-char hex, auto-minted on first `put`, preserved across writes and moves. `textus key mv old.key new.key` renames in place — uid survives, audit row records `from_key`, `to_key`, `uid`. Reorganising a tree no longer breaks references.
85
127
  - **Strict key grammar.** `/^[a-z0-9][a-z0-9-]*$/`, max 8 segments × 64 chars. `textus key normalize --dry-run|--write` rewrites existing stores with illegal segments deterministically.
86
- - **`textus boot`.** One-shot store orientation: zones with writers + purposes, entry families with schemas and publish targets, loaded hooks, write flows per role, the full CLI verb table, and an `agent_quickstart` block (read/write verbs, writable zones, propose zone, latest audit seq). The boot signal for any agent — one tool call and it knows your store.
128
+ - **`textus boot`.** One-shot store orientation: zones with writers + purposes, entry families with schemas and publish targets, loaded hooks, write flows per role, the full CLI verb table, and an `agent_quickstart` block (read/write verbs, writable zones, propose zone, latest audit seq).
87
129
  - **`textus pulse [--since=N]`.** Per-turn heartbeat for agents: changed entries since cursor N, stale keys, pending review proposals, and a doctor summary. Cursor is a monotonic seq stamped on every audit row; rotation keeps the last 5 files (configurable via `audit:` in the manifest) and raises `CursorExpired` when the requested cursor has fallen off disk.
88
130
  - **`textus doctor`.** Health check across 9 categories: missing schemas/templates, broken hooks, illegal nested keys, sentinel drift, audit log readability, unowned schema fields, schema violations, and missing manifest files. Returns `ok: true` only when nothing is wrong; warnings and info don't flip the bit.
89
131
  - **Actionable hints on every error.** `UnknownKey` carries ranked "did you mean" suggestions. `WriteForbidden` names the role that *would* be allowed. `BadFrontmatter` tells you exactly what to rename. Printed to stderr alongside the JSON envelope on stdout.
@@ -163,7 +205,7 @@ See [`docs/agent-integration.md`](docs/agent-integration.md) for the agent boot
163
205
  bundle exec rspec
164
206
  ```
165
207
 
166
- ~637 examples; includes conformance fixtures A–I from SPEC §12.
208
+ ~880 examples; includes conformance fixtures A–I from SPEC §12.
167
209
 
168
210
  ## Code quality
169
211