textus 0.5.0 → 0.8.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 (124) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/README.md +29 -21
  4. data/SPEC.md +75 -142
  5. data/docs/architecture.md +42 -23
  6. data/lib/textus/builder/pipeline.rb +56 -0
  7. data/lib/textus/builder/renderer/json.rb +42 -0
  8. data/lib/textus/builder/renderer/markdown.rb +22 -0
  9. data/lib/textus/builder/renderer/text.rb +14 -0
  10. data/lib/textus/builder/renderer/yaml.rb +42 -0
  11. data/lib/textus/builder/renderer.rb +17 -0
  12. data/lib/textus/builder.rb +9 -114
  13. data/lib/textus/cli/group/hook.rb +11 -0
  14. data/lib/textus/cli/group/key.rb +12 -0
  15. data/lib/textus/cli/group/schema.rb +13 -0
  16. data/lib/textus/cli/verb/accept.rb +15 -0
  17. data/lib/textus/cli/verb/build.rb +13 -0
  18. data/lib/textus/cli/verb/delete.rb +16 -0
  19. data/lib/textus/cli/verb/deps.rb +12 -0
  20. data/lib/textus/cli/verb/doctor.rb +15 -0
  21. data/lib/textus/cli/verb/get.rb +12 -0
  22. data/lib/textus/cli/verb/hook_run.rb +48 -0
  23. data/lib/textus/cli/verb/hooks.rb +50 -0
  24. data/lib/textus/cli/verb/init.rb +14 -0
  25. data/lib/textus/cli/verb/intro.rb +11 -0
  26. data/lib/textus/cli/verb/list.rb +14 -0
  27. data/lib/textus/cli/verb/migrate_keys.rb +16 -0
  28. data/lib/textus/cli/verb/mv.rb +17 -0
  29. data/lib/textus/cli/verb/published.rb +11 -0
  30. data/lib/textus/cli/verb/put.rb +50 -0
  31. data/lib/textus/cli/verb/rdeps.rb +12 -0
  32. data/lib/textus/cli/verb/refresh.rb +15 -0
  33. data/lib/textus/cli/verb/schema.rb +12 -0
  34. data/lib/textus/cli/verb/schema_diff.rb +12 -0
  35. data/lib/textus/cli/verb/schema_init.rb +16 -0
  36. data/lib/textus/cli/verb/schema_migrate.rb +16 -0
  37. data/lib/textus/cli/verb/stale.rb +14 -0
  38. data/lib/textus/cli/verb/uid.rb +12 -0
  39. data/lib/textus/cli/verb/where.rb +12 -0
  40. data/lib/textus/cli.rb +23 -42
  41. data/lib/textus/doctor/check/audit_log.rb +50 -0
  42. data/lib/textus/doctor/check/hooks.rb +29 -0
  43. data/lib/textus/doctor/check/illegal_keys.rb +49 -0
  44. data/lib/textus/doctor/check/manifest_files.rb +38 -0
  45. data/lib/textus/doctor/check/schema_violations.rb +22 -0
  46. data/lib/textus/doctor/check/schemas.rb +26 -0
  47. data/lib/textus/doctor/check/sentinels.rb +57 -0
  48. data/lib/textus/doctor/check/templates.rb +26 -0
  49. data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
  50. data/lib/textus/doctor/check.rb +30 -0
  51. data/lib/textus/doctor.rb +22 -288
  52. data/lib/textus/entry/base.rb +30 -0
  53. data/lib/textus/entry/json.rb +5 -1
  54. data/lib/textus/entry/markdown.rb +1 -1
  55. data/lib/textus/entry/text.rb +1 -1
  56. data/lib/textus/entry/yaml.rb +5 -1
  57. data/lib/textus/entry.rb +0 -5
  58. data/lib/textus/envelope.rb +30 -0
  59. data/lib/textus/hooks/builtin.rb +70 -0
  60. data/lib/textus/hooks/dispatcher.rb +49 -0
  61. data/lib/textus/hooks/loader.rb +26 -0
  62. data/lib/textus/hooks/registry.rb +73 -0
  63. data/lib/textus/init.rb +13 -10
  64. data/lib/textus/intro.rb +14 -16
  65. data/lib/textus/key/distance.rb +55 -0
  66. data/lib/textus/key/grammar.rb +33 -0
  67. data/lib/textus/key/path.rb +17 -0
  68. data/lib/textus/manifest/entry.rb +199 -0
  69. data/lib/textus/manifest.rb +10 -34
  70. data/lib/textus/migrate_keys.rb +1 -1
  71. data/lib/textus/projection.rb +5 -4
  72. data/lib/textus/proposal.rb +1 -1
  73. data/lib/textus/refresh.rb +11 -11
  74. data/lib/textus/schema/tools.rb +89 -0
  75. data/lib/textus/store/audit_log.rb +71 -0
  76. data/lib/textus/store/mover.rb +19 -16
  77. data/lib/textus/store/reader.rb +67 -0
  78. data/lib/textus/store/staleness.rb +10 -19
  79. data/lib/textus/store/validator.rb +11 -8
  80. data/lib/textus/store/view.rb +29 -0
  81. data/lib/textus/store/writer.rb +132 -0
  82. data/lib/textus/store.rb +25 -221
  83. data/lib/textus/version.rb +1 -1
  84. data/lib/textus.rb +14 -67
  85. metadata +73 -40
  86. data/lib/textus/audit_log.rb +0 -67
  87. data/lib/textus/builtin_actions.rb +0 -68
  88. data/lib/textus/cli/accept.rb +0 -13
  89. data/lib/textus/cli/action.rb +0 -51
  90. data/lib/textus/cli/build.rb +0 -11
  91. data/lib/textus/cli/delete.rb +0 -14
  92. data/lib/textus/cli/deprecated_alias.rb +0 -31
  93. data/lib/textus/cli/deps.rb +0 -10
  94. data/lib/textus/cli/doctor.rb +0 -13
  95. data/lib/textus/cli/extension_group.rb +0 -9
  96. data/lib/textus/cli/extensions.rb +0 -49
  97. data/lib/textus/cli/get.rb +0 -10
  98. data/lib/textus/cli/init.rb +0 -12
  99. data/lib/textus/cli/intro.rb +0 -9
  100. data/lib/textus/cli/key_group.rb +0 -10
  101. data/lib/textus/cli/list.rb +0 -12
  102. data/lib/textus/cli/migrate.rb +0 -41
  103. data/lib/textus/cli/migrate_keys.rb +0 -19
  104. data/lib/textus/cli/mv.rb +0 -20
  105. data/lib/textus/cli/published.rb +0 -9
  106. data/lib/textus/cli/put.rb +0 -48
  107. data/lib/textus/cli/rdeps.rb +0 -10
  108. data/lib/textus/cli/refresh.rb +0 -13
  109. data/lib/textus/cli/schema.rb +0 -10
  110. data/lib/textus/cli/schema_diff.rb +0 -15
  111. data/lib/textus/cli/schema_group.rb +0 -33
  112. data/lib/textus/cli/schema_init.rb +0 -19
  113. data/lib/textus/cli/schema_migrate.rb +0 -19
  114. data/lib/textus/cli/stale.rb +0 -12
  115. data/lib/textus/cli/uid.rb +0 -15
  116. data/lib/textus/cli/where.rb +0 -10
  117. data/lib/textus/extension_registry.rb +0 -61
  118. data/lib/textus/extensions.rb +0 -33
  119. data/lib/textus/key_distance.rb +0 -53
  120. data/lib/textus/manifest_entry.rb +0 -185
  121. data/lib/textus/migrate_v2.rb +0 -27
  122. data/lib/textus/schema_tools.rb +0 -87
  123. data/lib/textus/store/events.rb +0 -31
  124. data/lib/textus/store_view.rb +0 -27
data/SPEC.md CHANGED
@@ -1,7 +1,7 @@
1
- # textus/1 — Specification
1
+ # textus/2 — Specification
2
2
 
3
- **Status:** Draft v1.0 (2026-05-19)
4
- **Protocol identifier:** `textus/1`
3
+ **Status:** Draft v2.0 (2026-05-19)
4
+ **Protocol identifier:** `textus/2`
5
5
  **Reference implementation:** Ruby gem `textus`
6
6
 
7
7
  > *textus* — Latin for "the fabric a text is woven from," same root as *context* (from *con-texere*, "to weave together"). This spec defines a storage shape and wire protocol for that fabric.
@@ -31,7 +31,7 @@ textus is organized as five composable layers. Each layer has a single responsib
31
31
  ## 2. Goals and non-goals
32
32
 
33
33
  **Goals**
34
- - Stable wire format (`textus/1`) any language can speak.
34
+ - Stable wire format (`textus/2`) any language can speak.
35
35
  - Deterministic read/write of structured Markdown via a CLI returning JSON.
36
36
  - Schema-validated frontmatter using YAML schemas as data.
37
37
  - Role-based write gates (humans, scripts, AI, build runners get different permissions per zone).
@@ -91,7 +91,7 @@ The manifest declares: (a) which zones exist and which roles may write to each,
91
91
 
92
92
  ```yaml
93
93
  # .textus/manifest.yaml
94
- version: textus/1
94
+ version: textus/2
95
95
 
96
96
  zones:
97
97
  - name: canon
@@ -139,7 +139,7 @@ zones:
139
139
 
140
140
  Old manifests written against textus/1 draft v0.1 therefore parse without modification, and any tooling expecting `fixed`/`state`/`derived` continues to work.
141
141
 
142
- **Key grammar (enforced from v1.2):** dotted segments matching `/^[a-z0-9][a-z0-9-]*$/`. Segments are joined by `.`. A key has at most 8 segments; each segment is at most 64 characters. Segments MUST NOT contain dots, slashes, uppercase letters, or underscores. Example: `working.projects.acme.dashboard`. Enforcement points: manifest load (rejects illegal `key:` declarations and illegal nested file/directory names), `put` (rejects illegal keys before any write), `enumerate` (filters and warns on illegal filenames so existing trees still load with a clear migration message). Run-once migration: `textus migrate-keys --dry-run` then `--write` (see §audit).
142
+ **Key grammar (enforced from v1.2):** dotted segments matching `/^[a-z0-9][a-z0-9-]*$/`. Segments are joined by `.`. A key has at most 8 segments; each segment is at most 64 characters. Segments MUST NOT contain dots, slashes, uppercase letters, or underscores. Example: `working.projects.acme.dashboard`. Enforcement points: manifest load (rejects illegal `key:` declarations and illegal nested file/directory names), `put` (rejects illegal keys before any write), `enumerate` (filters and warns on illegal filenames so existing trees still load with a clear migration message). Run-once migration: `textus key migrate --dry-run` then `--write` (see §audit).
143
143
 
144
144
  **Per-entry `format:` (enforced from v1.2):** an entry MAY declare `format:` to be one of `markdown` (default), `json`, `yaml`, or `text`. The `format` controls the on-disk shape and which path extension is required:
145
145
 
@@ -250,38 +250,36 @@ A sentinel is written for each published file at `<store_root>/sentinels/<target
250
250
 
251
251
  **Per-leaf publishing.** A nested entry MAY declare `publish_each:` instead of `publish_to:` (see §4). When the build runs, every leaf reachable under the nested entry is byte-copied to the path produced by substituting `{leaf}` / `{basename}` / `{key}` / `{ext}` in the template, with a sentinel written under `<store_root>/sentinels/` at the mirrored target path. The build envelope grows a `published_leaves` array — one row per leaf, with `key`, `source`, and `target` — alongside the existing `built` array. Targets that would resolve outside the repo root are refused.
252
252
 
253
- ### 5.4 Intake (declared, refreshed via registered action)
253
+ ### 5.4 Intake (declared, refreshed via registered fetch hook)
254
254
 
255
- Intake entries declare an external source by naming an **action** — a registered, named function that pulls data into the entry. textus itself still makes no implicit network calls: an action only runs in intake mode when explicitly invoked by `textus refresh KEY --as=script`. The declaration is data only:
255
+ Intake entries declare an external source by naming a **fetch hook** — a registered, named function that pulls data into the entry. textus itself still makes no implicit network calls: a fetch hook only runs in intake mode when explicitly invoked by `textus refresh KEY --as=script`. The declaration is data only:
256
256
 
257
257
  ```yaml
258
258
  - key: intake.calendar.events
259
259
  zone: intake
260
260
  source:
261
- action: ical-events
261
+ fetch: ical-events
262
262
  config:
263
263
  url: "https://calendar.google.com/.../basic.ics"
264
264
  ttl: 6h
265
265
  ```
266
266
 
267
- `action` names a registered action; `config` is an opaque hash handed to the action; `ttl` is the staleness budget. Implementations MUST reject legacy `source.from`, `source.parse`, and `source.fetcher` with a clear usage error.
267
+ `fetch` names a registered `:fetch` hook (see §5.10 for the hook contract); `config` is an opaque hash handed to the hook; `ttl` is the staleness budget. Implementations MUST reject legacy `source.from`, `source.parse`, `source.fetcher`, and `source.action` with a clear usage error.
268
268
 
269
- **Action contract (intake mode).** An action is registered via `Textus.action(:name) do |config:, store:, args:| ... end`. In intake mode the action MUST return one of three shapes, all normalized by the store into its internal `{frontmatter, body, content}` representation (§5.12):
269
+ In intake mode the hook MUST return one of three shapes, all normalized by the store into its internal `{_meta, body, content}` representation (§5.12):
270
270
 
271
- - `{ frontmatter:, body: }` — markdown-friendly.
271
+ - `{ _meta:, body: }` — markdown-friendly; `_meta` becomes the entry's parsed metadata hash.
272
272
  - `{ content: }` — for `format: json|yaml` entries; the parsed object becomes the entry's content.
273
273
  - `{ body: }` — raw bytes for `text` or for any format that prefers verbatim writes; the store re-parses and validates per `format:`.
274
274
 
275
- The `store:` argument is a writable `Textus::StoreView` (§5.11) bound to the calling role; the `args:` argument is `{}` in intake mode (it carries CLI flags in verb mode§5.11). Every action call is wrapped in `Timeout.timeout(2)`; exceptions and timeouts surface as `usage` errors that abort the refresh.
276
-
277
- **Built-in actions.** `json`, `csv`, `markdown-links`, `ical-events`, `rss` are always available. They expect raw bytes in `config["bytes"]` and produce structured frontmatter/body. Built-ins do not perform I/O themselves — the caller (or an outer action) is responsible for supplying bytes.
275
+ **Built-in fetch hooks.** `json`, `csv`, `markdown-links`, `ical-events`, `rss` are always available. They expect raw bytes in `config["bytes"]` and produce structured `_meta`/body. Built-ins do not perform I/O themselvesthe caller (or an outer hook) is responsible for supplying bytes.
278
276
 
279
277
  **Refresh paths.** Two are supported:
280
278
 
281
- 1. **In-process** — `textus refresh KEY --as=script` resolves the entry's `source.action`, invokes it with `(config:, store:, args: {})`, and writes the result under role `script`.
279
+ 1. **In-process** — `textus refresh KEY --as=script` resolves the entry's `source.fetch`, invokes the registered `:fetch` hook with `(config:, store:, args: {})`, and writes the result under role `script`.
282
280
  2. **External runner** — a cron job or agent harness reads `textus list --zone=intake --stale --format=json`, fetches the source out of band, and pipes bytes back through `textus put KEY --as=script --stdin`.
283
281
 
284
- Both paths share the same role gate, audit-log entry, and `:refresh` event (§5.10). User-supplied actions live in `.textus/extensions/*.rb` and auto-load at `Store#initialize` (§5.11).
282
+ Both paths share the same role gate, audit-log entry, and `:refresh` event. User-supplied hooks live in `.textus/hooks/*.rb` and auto-load at `Store#initialize` — see §5.10 for the full hook contract.
285
283
 
286
284
  ### 5.5 Pending / accept workflow
287
285
 
@@ -314,7 +312,7 @@ Schema (one JSON object per line, no interior whitespace):
314
312
  {"ts":"<iso8601-utc>","role":"<role>","verb":"<verb>","key":"<key>","etag_before":<etag-or-null>,"etag_after":<etag-or-null>}
315
313
  ```
316
314
 
317
- `ts` is the wall-clock timestamp in UTC with second precision. `role` is the resolved role for the invocation. `verb` is the CLI verb (`put`, `delete`, `accept`, `compute`, `migrate-keys`, `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). `migrate-keys --write` emits one line per renamed file using the new key as `key` and the file's pre- and post-rename etags.
315
+ `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.
318
316
 
319
317
  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.
320
318
 
@@ -355,7 +353,7 @@ evolution:
355
353
  OLD_FIELD: NEW_FIELD
356
354
  ```
357
355
 
358
- `textus schema-migrate NAME` consults `evolution.migrate_from` when invoked without `--rename=OLD:NEW`, applying every declared rename across affected entries in one pass. An explicit `--rename` flag overrides the schema-declared map for that invocation.
356
+ `textus schema migrate NAME` consults `evolution.migrate_from` when invoked without `--rename=OLD:NEW`, applying every declared rename across affected entries in one pass. An explicit `--rename` flag overrides the schema-declared map for that invocation.
359
357
 
360
358
  **Backwards compat:** v1.0 schemas (no `fields:`, no `evolution:`) continue to parse and behave identically. `schema.maintained_by(field)` returns `nil` for every field; `schema.evolution` returns `{}`.
361
359
 
@@ -363,110 +361,43 @@ evolution:
363
361
 
364
362
  ### 5.9 Reducers (v1.2)
365
363
 
366
- Reducers are pure, named functions that shape projection rows into projection rows. Registered via the module-level DSL:
367
-
368
- ```ruby
369
- Textus.reducer(:rank_by_recency) do |rows:, config:|
370
- rows.sort_by { |r| r["updated_at"].to_s }.reverse
371
- end
372
- ```
373
-
374
- **Declaration.** A projection opts into a reducer via `projection.reducer`, with optional `projection.reducer_config`:
375
-
376
- ```yaml
377
- projection:
378
- select: [working.projects]
379
- pluck: [name, status, updated_at]
380
- reducer: rank_by_recency
381
- reducer_config: { tiebreak: name }
382
- sort_by: updated_at
383
- limit: 50
384
- ```
385
-
386
- The reducer runs **between pluck and sort**. `config:` receives the manifest's `reducer_config` hash (or `{}`). Rows in, rows out.
364
+ Reducers are RPC hooks on the `:reduce` event. See §5.10.
387
365
 
388
- **Purity.** A reducer MUST NOT perform I/O or mutate the store; no `store:` kwarg is passed.
366
+ ### 5.10 Hooks
389
367
 
390
- **Timeout.** Each invocation is wrapped in `Timeout.timeout(Textus::Projection::REDUCER_TIMEOUT_SECONDS)` (2s). Timeouts, exceptions, and unknown names raise `usage` errors and abort the build.
368
+ textus has a single extension verb: `Textus.hook(event, name, **opts) { ... }`. The EVENTS table below defines every extension point. Files in `.textus/hooks/*.rb` are `load`ed at `Store#initialize` in lexical order.
391
369
 
392
- **Auto-load.** Reducers register from `.textus/extensions/*.rb`, loaded at `Store#initialize` in lexical order (§5.11). The registry is per-Store; reducers do not share state across `Store` instances.
370
+ | Event | Mode | Args | Return | Failure |
371
+ |----------|---------|-----------------------------------|---------------|---------|
372
+ | :fetch | rpc | store:, config:, args: | {_meta:, body:} | aborts op |
373
+ | :reduce | rpc | store:, rows:, config: | rows array | aborts op |
374
+ | :check | rpc | store: | issues array | aborts doctor |
375
+ | :put | pubsub | store:, key:, envelope: | (discarded) | logged |
376
+ | :delete | pubsub | store:, key: | (discarded) | logged |
377
+ | :refresh | pubsub | store:, key:, envelope:, change: | (discarded) | logged |
378
+ | :build | pubsub | store:, key:, envelope:, sources: | (discarded) | logged |
379
+ | :accept | pubsub | store:, key:, target_key: | (discarded) | logged |
393
380
 
394
- ### 5.10 Events (v1.2)
381
+ **Signature invariant** — every hook receives `store:` as its first keyword argument. Event-specific kwargs follow in stable left-to-right order. The primary entity is always `key:` (for `:accept`, `key:` is the pending key being accepted and `target_key:` is the destination).
395
382
 
396
- Lifecycle events fire in-process. Subscribers register via `Textus.hook(:event, :name) do |**kwargs| ... end`. Hooks are fire-and-forget: return values are discarded.
397
-
398
- **Event set and kwargs:**
399
-
400
- | Event | Fired by | Kwargs |
401
- |-----------|-------------------------|--------------------------------------------------------------|
402
- | `:put` | `Store#put` | `key:, envelope:, store:` |
403
- | `:delete` | `Store#delete` | `key:, store:` |
404
- | `:refresh`| `Refresh.call` | `key:, envelope:, store:, change:` (`:created` or `:updated`)|
405
- | `:build` | `Builder#materialize` | `key:, envelope:, store:, sources:` |
406
- | `:accept` | `Proposal.accept` | `pending_key:, target_key:, store:` |
407
-
408
- `:refresh` with `change: :unchanged` does NOT fire — only `:created` and `:updated` are emitted. The `store:` kwarg is always a `Textus::StoreView` (§5.11).
409
-
410
- **Timeout and isolation.** Each hook runs under `Timeout.timeout(2)`. Hook errors and timeouts are recorded as `event_error` rows in `.textus/audit.log` (NDJSON with an `extras` object carrying `event`, `hook`, `error`) but do NOT abort the triggering operation. The store write that fired the event is already committed by the time hooks run.
411
-
412
- **Manifest declarations.** A manifest entry MAY declare external-runner hooks under an `events:` block, keyed by event name:
413
-
414
- ```yaml
415
- events:
416
- refresh:
417
- - { exec: scripts/reindex.sh, as: script }
418
- build:
419
- - { exec: scripts/rebuild-index.sh, as: build }
420
- ```
383
+ **RPC mode** exactly one handler per (event, name). The manifest references the handler by name (`source.fetch: NAME`, `projection.reduce: NAME`). Failure or timeout aborts the calling operation.
421
384
 
422
- Textus does NOT invoke these they surface only through `textus extensions list --kind=hook` for orchestrators (lefthook, cron, CI) to consume. Each entry has `exec` (opaque runner-resolvable string) and `as` (role to claim, defaults to `script`).
385
+ **Pub-sub mode** zero or more handlers per event. All matching handlers fire. The `keys:` option restricts a handler to keys matching one of the given globs (`File.fnmatch?` rules). Absence of `keys:` fires on every event of that type. Handler failures and 2s timeouts are logged to `audit.log` as `event_error` rows; they NEVER abort the triggering operation.
423
386
 
424
- **Removed.** The v1.1 `on_stale` event is removed in 0.2. Staleness is a poll, surfaced by `textus stale`. The `on_`-prefix convention from v1.1 is gone; events are bare symbols.
387
+ The `store:` argument is always a read-only store proxy. Write attempts raise `UsageError`.
425
388
 
426
- ### 5.11 Extension surface (v1.3)
427
-
428
- Four DSL verbs cover all user-supplied code:
429
-
430
- ```
431
- Textus.action(:name) do |config:, store:, args:| ... end # intake mode: returns content; verb mode: writes via store.put
432
- Textus.reducer(:name) do |rows:, config:| ... end # returns rows
433
- Textus.hook(:event, :name) do |**kwargs| ... end # side effects; return ignored
434
- Textus.doctor_check(:name) do |store:| ... end # returns array of issue hashes
435
- ```
436
-
437
- Files in `.textus/extensions/*.rb` are loaded at `Store#initialize`, in lexical order, with the registry installed as the current registry for that store. Registries are per-Store: two Store instances in the same process do not share state.
438
-
439
- **Action invocation modes.**
440
-
441
- | Mode | Invoked by | `config:` | `store:` | `args:` | Return |
442
- |---------|---------------------------|------------------------|------------------------|----------------------|------------------------------------------|
443
- | intake | `textus refresh KEY` | manifest `source.config` | writable view (role from `--as`) | `{}` | required; normalized into entry write |
444
- | verb | `textus action NAME ...` | `{}` | writable view (role from `--as`) | parsed CLI kv hash | ignored |
445
- | put-fetch | `textus put K --action=N --stdin` | `{ "bytes" => stdin }` | read-only view | `{}` | required; merged into the put payload |
446
-
447
- **Failure modes:**
448
-
449
- | Surface | Timeout | Exception | Bad return |
450
- |-----------------|------------|---------------------------------------------|------------|
451
- | action | aborts op | aborts op (wrapped as `UsageError`) | aborts op |
452
- | reducer | aborts op | aborts op | aborts op |
453
- | hook | logged | logged (audit `event_error` row) | n/a |
454
- | doctor_check | reported as `doctor_check.timeout` issue | reported as `doctor_check.failed` issue | reported as `doctor_check.bad_return` |
455
-
456
- Actions and reducers are pure transforms in modes where their return matters; their return values flow into the store. Hooks and doctor_checks shape side outputs (event chain, doctor report) — only doctor_check return values are merged.
457
-
458
- The `store:` argument is always a `Textus::StoreView`. In intake and verb modes it is writable and bound to the calling role; in put-fetch mode and inside doctor_checks it is read-only. Write attempts on a read-only view raise `Textus::UsageError`.
389
+ Each handler runs under `Timeout.timeout(2)`.
459
390
 
460
391
  ### 5.12 Storage formats (v1.2)
461
392
 
462
- An entry's `format:` selects a storage strategy. All strategies expose the same `parse(bytes) → {frontmatter, body, content}` and `serialize(frontmatter:, body:, content:) → bytes` contract. The store, audit, etag, and projection layers operate on the parsed shape; only (de)serialization differs.
393
+ An entry's `format:` selects a storage strategy. All strategies expose the same `parse(bytes) → {_meta, body, content}` and `serialize(meta:, body:, content:) → bytes` contract. The store, audit, etag, and projection layers operate on the parsed shape; only (de)serialization differs.
463
394
 
464
- - **markdown** — YAML frontmatter between `---` fences, free-form body. Parse: Psych `safe_load` on the front matter; body is the remainder. Serialize: emit `---\n<yaml>\n---\n<body>`. `content` is always `nil`.
465
- - **json** — entire file is a JSON document. Parse: `JSON.parse`. Serialize: `JSON.pretty_generate(content)` + trailing newline. `frontmatter` is populated from a top-level `_meta` hash (if present, else `{}`); `body` is the raw bytes; `content` is the parsed object.
466
- - **yaml** — entire file is a YAML mapping. Parse: `YAML.safe_load(bytes, permitted_classes: [Date, Time], aliases: false)`; anchors/aliases rejected. Serialize: `YAML.dump(content).sub(/\A---\n/, "")`. Same `_meta` / `frontmatter` / `body` / `content` rules as JSON.
467
- - **text** — raw UTF-8 bytes. Parse: body is the file verbatim, `frontmatter` is `{}`, `content` is `nil`. Serialize: write `body` bytes (with trailing newline if missing).
395
+ - **markdown** — YAML frontmatter between `---` fences, free-form body. Parse: Psych `safe_load` on the frontmatter block; body is the remainder. Serialize: emit `---\n<yaml>\n---\n<body>`. `content` is always `nil`. `_meta` holds the parsed frontmatter hash.
396
+ - **json** — entire file is a JSON document. Parse: `JSON.parse`. Serialize: `JSON.pretty_generate(content)` + trailing newline. `_meta` is populated from the top-level `_meta` hash (if present, else `{}`); `body` is the raw bytes; `content` is the parsed object with `_meta` stripped.
397
+ - **yaml** — entire file is a YAML mapping. Parse: `YAML.safe_load(bytes, permitted_classes: [Date, Time], aliases: false)`; anchors/aliases rejected. Serialize: `YAML.dump(content).sub(/\A---\n/, "")`. Same `_meta` / `body` / `content` rules as JSON.
398
+ - **text** — raw UTF-8 bytes. Parse: body is the file verbatim, `_meta` is `{}`, `content` is `nil`. Serialize: write `body` bytes (with trailing newline if missing).
468
399
 
469
- **Envelope shape.** Every envelope carries `format:` (always present, defaults to `markdown` for back-compat). For `json|yaml`, the envelope additionally carries `content:` (parsed object). `body` is always the raw on-disk bytes. `frontmatter` always exists, and for `json|yaml` mirrors the `_meta` block (`{}` if absent). `text` always has `frontmatter: {}` and no `content`.
400
+ **Envelope shape.** Every envelope carries `format:` (always present, defaults to `markdown` for back-compat). For `json|yaml`, the envelope additionally carries `content:` (parsed object). `body` is always the raw on-disk bytes. `_meta` always exists in the envelope: for `markdown` it holds the parsed YAML frontmatter; for `json|yaml` it mirrors the top-level `_meta` block (`{}` if absent); for `text` it is `{}`.
470
401
 
471
402
  **`_meta` convention.** Derived structured entries (json, yaml) embed a `_meta` hash as the first top-level key. Builder-injected keys appear in a fixed order for etag stability:
472
403
 
@@ -533,13 +464,13 @@ Every successful CLI response (`--format=json`) is a single JSON envelope:
533
464
 
534
465
  ```json
535
466
  {
536
- "protocol": "textus/1",
467
+ "protocol": "textus/2",
537
468
  "key": "working.network.org.jane",
538
469
  "zone": "working",
539
470
  "owner": "textus:network",
540
471
  "path": "/absolute/path/to/.textus/zones/working/network/org/jane.md",
541
472
  "format": "markdown",
542
- "frontmatter": { "name": "jane", "relationship": "peer", "org": "acme" },
473
+ "_meta": { "name": "jane", "relationship": "peer", "org": "acme" },
543
474
  "body": "Short body in Markdown.\n",
544
475
  "etag": "sha256:8f3c…",
545
476
  "schema_ref": "person",
@@ -548,13 +479,13 @@ Every successful CLI response (`--format=json`) is a single JSON envelope:
548
479
  ```
549
480
 
550
481
  **Field rules:**
551
- - `protocol` MUST be the exact string `textus/1`.
482
+ - `protocol` MUST be the exact string `textus/2`.
552
483
  - `key` MUST be the canonical resolved key.
553
484
  - `zone` MUST be one of the zones declared in the manifest (`canon`, `working`, `intake`, `pending`, `derived` for the default v1.0 model; legacy v0.1 manifests synthesize `fixed`, `state`, `derived` per §4).
554
485
  - `path` MUST be an absolute filesystem path.
555
486
  - `format` MUST be one of `markdown`, `json`, `yaml`, `text` (§5.12). Absent envelopes are treated as `markdown` for back-compat.
556
487
  - `body` is the raw on-disk bytes as a UTF-8 string for every format.
557
- - `content` is present only when `format` is `json` or `yaml`; equals the parsed object. For `json|yaml`, `frontmatter` mirrors the top-level `_meta` (or `{}` if absent).
488
+ - `content` is present only when `format` is `json` or `yaml`; equals the parsed object. For `json|yaml`, `_meta` mirrors the top-level `_meta` block (or `{}` if absent). For `markdown`, `_meta` holds the parsed YAML frontmatter. For `text`, `_meta` is `{}`.
558
489
  - `etag` MUST be `sha256:<hex>` of the raw file bytes, computed identically for every format.
559
490
  - `schema_ref` MAY be `null` for entries in subtrees with `schema: null`.
560
491
  - `uid` is the stable Textus UID (§7) if the entry carries one, else `null`. Always present in the envelope.
@@ -563,7 +494,7 @@ Errors use a distinct envelope:
563
494
 
564
495
  ```json
565
496
  {
566
- "protocol": "textus/1",
497
+ "protocol": "textus/2",
567
498
  "ok": false,
568
499
  "code": "write_forbidden",
569
500
  "message": "zone 'canon' is not writable by role 'ai' for key 'canon.identity'",
@@ -594,29 +525,29 @@ All verbs accept `--format=json` and emit a canonical envelope (success or error
594
525
  | `list [--prefix=K] [--zone=Z] [--stale]` | read | any |
595
526
  | `where K` | read | any |
596
527
  | `get K` | read | any |
597
- | `schema K` | read | any |
528
+ | `schema show K` | read | any |
598
529
  | `stale [--prefix=K] [--strict]` | read | any |
599
530
  | `deps K` / `rdeps K` | read | any |
600
531
  | `published` | read | any |
601
- | `doctor --check=schema_violations` | read | any |
602
- | `put K --stdin --as=R [--action=NAME]` | write | per zone |
532
+ | `hook list` | read | any |
533
+ | `doctor [--check=NAME[,NAME]] [--format=json]` | read | any |
534
+ | `intro [--format=json]` | read | any |
535
+ | `put K --stdin --as=R [--fetch=NAME]` | write | per zone |
603
536
  | `delete K --if-etag=E --as=R` | write | per zone |
604
537
  | `refresh K --as=script` | write | per zone (typically `script`) |
605
538
  | `build [--prefix=K] [--dry-run]` | write | `build` (default) |
606
539
  | `accept K --as=human` | write | `human` |
607
540
  | `init` | write | `human` |
608
- | `schema-init NAME` / `schema-diff NAME` / `schema-migrate NAME --rename=OLD:NEW` | write | `human` |
609
- | `migrate-keys [--dry-run\|--write]` | write (with `--write`) | `human` |
610
- | `mv OLD NEW [--as=R] [--dry-run]` | write | per zone (same-zone only) |
611
- | `uid K` | read | any |
612
- | `extensions list [--kind=action\|reducer\|hook]` | read | any |
613
- | `doctor [--check=NAME[,NAME]] [--format=json]` | read | any |
614
- | `intro [--format=json]` | read | any |
541
+ | `schema init NAME` / `schema diff NAME` / `schema migrate NAME [--rename=OLD:NEW]` | write | `human` |
542
+ | `key migrate [--dry-run\|--write]` | write (with `--write`) | `human` |
543
+ | `key mv OLD NEW [--as=R] [--dry-run]` | write | per zone (same-zone only) |
544
+ | `key uid K` | read | any |
545
+ | `hook run NAME` | write | any |
615
546
 
616
547
  **`put` input** (read from stdin when `--stdin` is given):
617
548
 
618
549
  ```json
619
- { "frontmatter": { "name": "jane", "relationship": "peer", "org": "acme" },
550
+ { "_meta": { "name": "jane", "relationship": "peer", "org": "acme" },
620
551
  "body": "Short body.\n",
621
552
  "if_etag": "sha256:8f3c…" }
622
553
  ```
@@ -641,7 +572,7 @@ All verbs accept `--format=json` and emit a canonical envelope (success or error
641
572
 
642
573
  `textus init` scaffolds a fresh `.textus/` tree (manifest, zones, schemas, audit log) under the current directory with a default manifest. Customize by editing `.textus/manifest.yaml` after init.
643
574
 
644
- `textus schema-init NAME` writes a stub schema. `schema-diff NAME` compares the on-disk schema against entries that claim it and prints the deltas. `schema-migrate NAME --rename=OLD:NEW` rewrites the frontmatter key `OLD` to `NEW` across every entry that uses the named schema, in a single transactional sweep that logs each touched file.
575
+ `textus schema show K` prints the schema for entry `K`. `textus schema init NAME` writes a stub schema. `textus schema diff NAME` compares the on-disk schema against entries that claim it and prints the deltas. `textus schema migrate NAME --rename=OLD:NEW` rewrites the `_meta` key `OLD` to `NEW` across every entry that uses the named schema, in a single transactional sweep that logs each touched file.
645
576
 
646
577
  ## 10. ETag semantics
647
578
 
@@ -653,16 +584,17 @@ Every `Textus::Error` exposes `code`, `message`, and an optional `hint:`. The hi
653
584
 
654
585
  ## 10.2 `textus doctor`
655
586
 
656
- `textus doctor` returns a health-check envelope: `{ "protocol": "textus/1", "ok": bool, "issues": [...], "summary": {error, warning, info} }`. Each issue carries `code`, `level` (`error|warning|info`), `subject`, `message`, and optionally `fix`. `ok` is true iff no error-level issues are present; warnings and info do not flip the bit. Checks include manifest sanity, missing schemas/templates, extension load failures, illegal nested keys (with proposed normalisation), sentinel drift/orphans, and audit-log line corruption. Exit code is 0 on `ok`, 1 otherwise.
587
+ `textus doctor` returns a health-check envelope: `{ "protocol": "textus/2", "ok": bool, "issues": [...], "summary": {error, warning, info} }`. Each issue carries `code`, `level` (`error|warning|info`), `subject`, `message`, and optionally `fix`. `ok` is true iff no error-level issues are present; warnings and info do not flip the bit. Builtin checks: `manifest_files`, `schemas`, `templates`, `hooks`, `illegal_keys`, `sentinels`, `audit_log`, `unowned_schema_fields`, `schema_violations`. Additional registered `:check` hooks (§5.10) run after the builtin set. Exit code is 0 on `ok`, 1 otherwise.
657
588
 
658
589
  ## 11. Versioning
659
590
 
660
- - The wire string `textus/1` is the protocol version.
661
- - Backward-compatible additions (new fields, new error codes, new schema types) MAY be made under `textus/1`.
662
- - Breaking changes (renamed/removed fields, zone semantics, key grammar) require a new wire string `textus/2`.
591
+ - The current wire string is `textus/2`. It was introduced in gem v0.5, which unified the `_meta` block across all storage formats (markdown, json, yaml, text) and replaced the legacy TSV audit-log write path with NDJSON.
592
+ - `textus/1` was the original protocol (gem v0.4). Manifests declaring `version: textus/1` are still accepted for backward compatibility (§4).
593
+ - Backward-compatible additions (new fields, new error codes, new schema types) MAY be made under `textus/2`.
594
+ - Breaking changes (renamed/removed envelope fields, zone semantics, key grammar) require a new wire string `textus/3`.
663
595
  - Implementations MUST reject envelopes whose `protocol` they do not recognize.
664
596
 
665
- The reference Ruby gem follows semver independently. Gem 1.x speaks `textus/1`.
597
+ The reference Ruby gem follows semver independently. The current gem version is `0.8.0`, which speaks `textus/2`.
666
598
 
667
599
  ## 12. Conformance fixtures
668
600
 
@@ -690,7 +622,7 @@ Given a derived entry with a `template` clause referencing a `.mustache` file an
690
622
  Given a manifest entry with `publish_to: <path>`, a successful `textus build` for that entry leaves a plain file at `<path>` whose contents are byte-identical to the in-store artifact at `.textus/zones/<...>`, accompanied by a sentinel at `.textus/sentinels/<path>.textus-managed.json` recording `source`, `target`, `sha256`, and `mode: "copy"`. Re-running `build` is idempotent.
691
623
 
692
624
  **Fixture H — Audit log format:**
693
- Every successful write verb (`put`, `delete`, `build`, `accept`, `schema-migrate`) appends exactly one line per affected key to the audit log, in the canonical format defined in §audit (timestamp, actor role, verb, key, etag-before, etag-after). No write produces zero or multiple lines per key.
625
+ Every successful write verb (`put`, `delete`, `build`, `accept`, `schema migrate`) appends exactly one line per affected key to the audit log, in the canonical format defined in §audit (timestamp, actor role, verb, key, etag-before, etag-after). No write produces zero or multiple lines per key.
694
626
 
695
627
  **Fixture I — Pending → accept:**
696
628
  Given a pending entry `pending.canon.identity.patch` proposing a change to `canon.identity`, `textus accept canon.identity --as=human` copies the patch body into `canon.identity`, deletes the pending entry, and appends two audit lines (one for the canon write, one for the pending delete) in that order.
@@ -699,19 +631,19 @@ Given a pending entry `pending.canon.identity.patch` proposing a change to `cano
699
631
 
700
632
  - **Why not MCP?** MCP is a transport; textus is a data model. The two compose: a 50-line MCP server can wrap `textus get/put` as tools. textus exists because the *shape* of agent-readable project memory deserves a standalone spec, separate from how it's served.
701
633
 
702
- - **Why doesn't textus execute generator commands itself?** textus is a dataflow oracle, not a build runner. The moment a spec includes process execution, it inherits shell-injection surface, OS-portability concerns, and signal-handling semantics — and ends up duplicating whatever build system the consumer already runs (make, rake, just, lefthook, CI). Keeping execution external means a Python or TypeScript port of `textus/1` only has to parse YAML and emit JSON; it doesn't have to spawn processes safely. Build runners stay the executor; textus stays a data tool.
634
+ - **Why doesn't textus execute generator commands itself?** textus is a dataflow oracle, not a build runner. The moment a spec includes process execution, it inherits shell-injection surface, OS-portability concerns, and signal-handling semantics — and ends up duplicating whatever build system the consumer already runs (make, rake, just, lefthook, CI). Keeping execution external means a Python or TypeScript port of `textus/2` only has to parse YAML and emit JSON; it doesn't have to spawn processes safely. Build runners stay the executor; textus stays a data tool.
703
635
 
704
636
  - **Why not plain Markdown vaults (Obsidian / Foam)?** No schema enforcement, no write-gating, no addressable wire format. Fine for human notes; underspecified for agents that must act on the contents deterministically.
705
637
 
706
638
  - **Why not Notion / Coda?** Closed, hosted, lossy export. textus is local-first, plain-files, diffable in git.
707
639
 
708
- - **Why not JSON Schema for the schemas?** Considered. Bespoke YAML chosen for v1: simpler implementation, lighter dependency footprint, matches the reference impl's house language. JSON Schema MAY be added as an alternate schema-language adapter in a future minor revision without breaking `textus/1`.
640
+ - **Why not JSON Schema for the schemas?** Considered. Bespoke YAML chosen for v1: simpler implementation, lighter dependency footprint, matches the reference impl's house language. JSON Schema MAY be added as an alternate schema-language adapter in a future minor revision without breaking `textus/2`.
709
641
 
710
642
  - **Why not a database (SQLite, kv store)?** textus's whole point is that the storage is plain files agents and humans both read. A binary store loses git-diff, grep, and editor support.
711
643
 
712
644
  - **Why not vector embeddings?** Different problem. textus is for facts agents act on deterministically; embeddings are for fuzzy retrieval. They compose — index a textus tree into a vector store if you need both.
713
645
 
714
- ## 14. Open questions (v1.x scope)
646
+ ## 14. Open questions (v2.x scope)
715
647
 
716
648
  - **Locking on `put`:** the reference impl uses sha256 etags. Should the spec also define a file-lock fallback for systems where read-before-write is racy?
717
649
  - **Schema imports:** can one schema reference another (`type: $ref: person`)? Defer to v1.1.
@@ -720,21 +652,22 @@ Given a pending entry `pending.canon.identity.patch` proposing a change to `cano
720
652
 
721
653
  ## 15. Implementation checklist
722
654
 
723
- A v1 implementation MUST:
655
+ A `textus/2` implementation MUST:
724
656
 
725
- - [ ] Parse `.textus/manifest.yaml` and validate the `version: textus/1` declaration.
657
+ - [ ] Parse `.textus/manifest.yaml` and accept `version: textus/2` (and `textus/1` for backward compat per §11).
726
658
  - [ ] Resolve keys via longest-prefix match against manifest entries.
727
- - [ ] Read frontmatter + body from `.md` files; validate against the named schema.
659
+ - [ ] Read `_meta` + body from `.md` files; validate against the named schema.
660
+ - [ ] Read `_meta` from the top-level `_meta` hash in `.json` / `.yaml` files; validate against the named schema.
728
661
  - [ ] Compute `sha256:<hex>` etags over raw file bytes.
729
662
  - [ ] Refuse writes whose resolved role is not in the target zone's `writable_by` list with `write_forbidden`.
730
- - [ ] Return envelopes matching the shape in §8 exactly.
663
+ - [ ] Return envelopes matching the shape in §8 exactly (with `_meta`, not `frontmatter`).
731
664
  - [ ] Use the error codes in §8 and the exit-code table.
732
665
  - [ ] Implement `textus stale` per §5.1 and §9, comparing each derived entry's `generator.sources` against its `generated.at` timestamp without invoking any commands.
733
666
  - [ ] Pass the conformance fixtures A–I in §12.
734
667
 
735
- A v1 implementation MAY:
668
+ A `textus/2` implementation MAY:
736
669
 
737
- - Add additional CLI verbs (e.g. `move`, vendor-specific reporters) beyond the v1.0 set in §9.
670
+ - Add additional CLI verbs (e.g. `move`, vendor-specific reporters) beyond the current set in §9.
738
671
  - Provide alternate output formats (`--format=yaml`, `--format=table`) for human use.
739
672
  - Support additional schema field types beyond §6, marked as `vendor:<name>` extensions.
740
673
 
data/docs/architecture.md CHANGED
@@ -7,15 +7,19 @@ The codebase is a flat graph of small modules under one CLI dispatcher, not a st
7
7
  ## At a glance
8
8
 
9
9
  ```
10
- exe/textus → Textus::CLI ──┬──► Store (verb impl: get/put/list/stale/refresh/accept/…)
11
- ├──► Builder (build verb)
10
+ exe/textus → Textus::CLI ──┬──► Store (facade delegates to Reader/Writer/Mover)
11
+ ├──► Store::Reader (get/list/where/uid/deps/published/stale/validate_all)
12
+ │ ├──► Store::Writer (put/delete/accept)
13
+ │ ├──► Store::Mover (mv)
14
+ │ └──► Hooks::Dispatcher (lifecycle publish/subscribe)
15
+ ├──► Builder (build verb — Pipeline + per-format renderers)
12
16
  ├──► Refresh (refresh verb)
13
17
  ├──► Doctor (doctor verb)
14
18
  ├──► Init (init verb)
15
19
  ├──► Intro (intro verb)
16
- ├──► MigrateKeys (migrate-keys, mv verbs)
17
- ├──► SchemaTools (schema-init/diff/migrate verbs)
18
- ├──► StoreView (read-only projection over Store)
20
+ ├──► MigrateKeys (key migrate, key mv verbs)
21
+ ├──► Schema::Tools (schema init/diff/migrate verbs)
22
+ ├──► Store::View (read-only projection over Store::Reader)
19
23
  └──► Role (role gate)
20
24
  ```
21
25
 
@@ -25,26 +29,40 @@ CLI is the single entry point. It parses argv and dispatches each verb to whiche
25
29
 
26
30
  ### 1. Request path — core read/write verbs
27
31
 
28
- `Store` (617 LOC) owns the `get`, `put`, `list`, `delete`, `stale`, and proposal-acceptance verbs. It is the largest module and the only one that touches the working-store filesystem for primary read/write. It uses:
32
+ `Store` is a thin facade (~110 LOC) that holds `Manifest`, `Hooks::Registry`, `Hooks::Dispatcher`, and the lazy `Store::AuditLog`, then delegates verbs to a small set of focused collaborators:
29
33
 
34
+ - **`Store::Reader`** — owns `get`, `list`, `where`, `uid`, `deps`, `rdeps`, `published`, `schema_envelope`, `stale`, `validate_all`. The only module that reads working-store entry files.
35
+ - **`Store::Writer`** — owns `put`, `delete`, `accept`. Handles serialization, uid minting, etag check, role gate, audit append, and event publication. The only module that writes working-store entry files.
36
+ - **`Store::Mover`** — owns `mv` (same-zone rename) with uid preservation and one audit row.
37
+ - **`Store::Validator`** / **`Store::Staleness`** — back the `validate_all` / `stale` reads. Take explicit collaborators (`reader:`, `manifest:`, `audit_log:`, `schema_for:`) instead of the full store.
38
+
39
+ Shared value modules and primitives consumed by Reader/Writer/Mover:
40
+
41
+ - **`Textus::Key::Path`** — `Key::Path.resolve(manifest, mentry)` returns the absolute leaf path for a manifest entry. Single source for zone-path construction; used by `Manifest`, `Staleness`, `Builder`, and Writer.
42
+ - **`Textus::Envelope`** — `Envelope.build(...)` returns the canonical envelope hash (protocol, key, zone, owner, path, format, `_meta`, body, etag, schema_ref, uid, optional content). Single source for envelope shape across `get` and `put`.
30
43
  - **`Manifest`** — parses `.textus/manifest.yaml`; resolves a dotted key to a path via longest-prefix match. `nested: true` entries treat unmatched suffix segments as `/`-joined subdirs, with `.md` appended. Resolution is path-only; existence is the verb's concern.
31
44
  - **`Schema`** — loads YAML schema files; validates frontmatter shape and surfaces unknown-key warnings (the §6 forward-compat rule).
32
- - **`Entry`** + format adapters (`entry/markdown.rb`, `entry/text.rb`, `entry/json.rb`, `entry/yaml.rb`) — splits raw bytes on `---\n`, feeds the YAML chunk to `YAML.safe_load` (no aliases, restricted classes). The frontmatter `name:` field is enforced against the file basename inside `Store` (on read and on write) — mismatch raises `bad_frontmatter`.
45
+ - **`Entry`** + format adapters (`entry/markdown.rb`, `entry/text.rb`, `entry/json.rb`, `entry/yaml.rb`) — splits raw bytes on `---\n`, feeds the YAML chunk to `YAML.safe_load` (no aliases, restricted classes). The frontmatter `name:` field is enforced against the file basename in Reader/Writer (on read and on write) — mismatch raises `bad_frontmatter`.
33
46
  - **`Etag`** — `sha256:<hex>` over raw file bytes. `put` accepts optional `if_etag:`; mismatch raises `etag_mismatch`. No locking, no temp-file-and-rename — v1 leaves stronger guarantees to v1.x.
34
- - **`Role`** — agent-vs-human gate. `Store#put` checks `ManifestEntry#agent_writable?` (true only for `state`) before doing anything else; otherwise raises `write_forbidden`.
35
- - **`AuditLog`** — append-only NDJSON; every successful write emits one line.
47
+ - **`Role`** — agent-vs-human gate. Writer checks `Manifest::Entry#zone_writers` before doing anything else; otherwise raises `write_forbidden`.
48
+ - **`Store::AuditLog`** — append-only NDJSON; every successful write emits one line.
36
49
  - **`Proposal`** — `accept` verb flow for promoting a pending entry into its target zone.
37
50
  - **`Dependencies`** — `deps`/`rdeps`/`published` verb backing; walks manifest declarations.
38
51
 
39
52
  ### 2. Build / publish pipeline
40
53
 
41
- Separate from the request path. Owns derived-entry materialization and byte-copy publish.
54
+ Separate from the request path. Owns derived-entry materialization and byte-copy publish. `Builder` orchestrates per-entry materialization through `Builder::Pipeline`, which runs an ordered step list and dispatches the rendering step to one of four format-specific renderers. Adding a new output format is a single-file change under `lib/textus/builder/renderer/`.
42
55
 
43
56
  ```
44
- Builder ──► Projection ──► Mustache ──► Entry ──► Publisher ──► (sentinel)
57
+ Builder ──► Pipeline ──► LoadSources ──► Project ──► Render (per-format) ──► Write ──► Publisher ──► (sentinel)
58
+
59
+ └──► Renderer::{Markdown, Text, Json, Yaml}
45
60
  ```
46
61
 
47
- - **`Builder`** — iterates `zone: derived` entries, materializes each by running its declared template + projection, parses the rendered output back through `Entry`, and hands the bytes to `Publisher`.
62
+ - **`Builder`** — iterates `zone: derived` entries, hands each to `Pipeline.run`, then handles `Publisher` copy-out and fires the `:build` event. Holds no format-specific logic.
63
+ - **`Builder::Pipeline`** — `Pipeline.run(store:, mentry:, template_loader:)` is the orchestrator: runs the projection, merges `intro` if `inject_intro: true`, dispatches to the matching renderer, writes the bytes to the derived path.
64
+ - **`Builder::InjectMeta`** — builds the `_meta` block (`generated_at`, `from`, `template`, `reduce`) and threads it onto JSON/YAML content as the first key per SPEC §6 ordering.
65
+ - **`Builder::Renderer::{Markdown,Text,Json,Yaml}`** — one class per format, inheriting `Builder::Renderer`. Receives a template-loader lambda and `(mentry:, data:)`; returns rendered bytes. Markdown/Text always require a template; JSON/YAML optionally accept one (otherwise default-shape the projection rows).
48
66
  - **`Projection`** — collects rows from manifest-declared source keys, applies optional reducer, sorts and positions. Pure data shaping.
49
67
  - **`Mustache`** — minimal mustache renderer for templates in `.textus/templates/`.
50
68
  - **`Publisher`** — byte-copy from store path to external target path. Refuses to overwrite unmanaged targets; writes a sentinel in `.textus/sentinels/` to track managed targets.
@@ -53,33 +71,34 @@ Builder ──► Projection ──► Mustache ──► Entry ──► Publi
53
71
 
54
72
  Declared in the manifest, loaded on demand, dispatched by `Store` and `Refresh`.
55
73
 
56
- - **`Extensions`** — declarative manifest schema for action/reducer/hook/doctor_check extensions.
57
- - **`ExtensionRegistry`** — loads one `.rb` per extension from `.textus/extensions/`, registers callables under their declared names.
58
- - **`BuiltinActions`** — ships built-in actions (e.g. json, csv, ical-events, rss) available without user extensions.
59
- - **`Refresh`** — `refresh` verb: looks up the action for a key, invokes it, normalizes the result by declared format, writes through `Store` with an etag check.
74
+ - **`Hooks::Registry`** — loads one `.rb` per hook from `.textus/hooks/`, registers callables under their `(event, name)`. Single source of truth via the `EVENTS` table (rpc vs pubsub, arg shape, failure semantics). For pub-sub events it also forwards registrations to the `Hooks::Dispatcher`.
75
+ - **`Hooks::Dispatcher`** — first-class pub/sub for lifecycle events (`:put`, `:delete`, `:refresh`, `:build`, `:accept`). Owns the 2-second per-handler timeout and the audit-on-failure middleware (raising handlers do not abort the write; they produce an `event_error` audit row). Embedded callers can `store.dispatcher.subscribe(:put, :name) { ... }` outside `.textus/hooks/`.
76
+ - **`Hooks::Builtin`** — ships built-in `:fetch` hooks (e.g. json, csv, ical-events, rss) available without user-supplied hooks.
77
+ - **`Refresh`** — `refresh` verb: looks up the `:fetch` hook for a key, invokes it, normalizes the result by declared format, writes through `Store::Writer` with an etag check.
60
78
 
61
79
  ### 4. Operational tooling
62
80
 
63
81
  First-class CLI verbs that don't fit the read/write/build axes. Read-mostly; side modules off CLI.
64
82
 
65
- - **`Doctor`** — `doctor` verb: validates manifest, schemas, extensions, and (via `MigrateKeys`) suggests key migrations. Talks to Manifest/Schema/Entry/ExtensionRegistry directly.
66
- - **`MigrateKeys`** — `migrate-keys` and `mv` verbs; computes renames against the manifest.
67
- - **`SchemaTools`** — `schema-init`, `schema-diff`, `schema-migrate` verbs.
83
+ - **`Doctor`** — `doctor` verb: orchestrator that runs 9 builtin checks under `Doctor::Check::*`. Talks to Manifest/Schema/Entry/Hooks::Registry directly.
84
+ - **`Doctor::Check`** — explicit base class for doctor checks. Each of the 9 builtin checks is its own file under `lib/textus/doctor/check/`.
85
+ - **`MigrateKeys`** — `key migrate` and `key mv` verbs; computes renames against the manifest.
86
+ - **`Schema::Tools`** — `schema init`, `schema diff`, `schema migrate` verbs.
68
87
  - **`Init`** — `init` verb: scaffolds `.textus/` with the five zone directories, baseline schemas, empty audit log, starter manifest.
69
88
  - **`Intro`** — `intro` verb: emits the human/agent-facing onboarding payload.
70
- - **`StoreView`** — read-only projection over `Store` for code that should not mutate.
71
- - **`KeyDistance`** — Levenshtein-ish suggestion for `did-you-mean` on unknown keys.
89
+ - **`Store::View`** — read-only projection over `Store::Reader` for hook code that should not mutate.
90
+ - **`Key::Distance`** — Levenshtein-ish suggestion for `did-you-mean` on unknown keys.
72
91
 
73
92
  ### 5. Primitives
74
93
 
75
94
  - **`Errors`** — `Textus::Error` subclasses, each carrying a stable `code`, a `details` hash, and an `exit_code`. `CLI` catches them at the top level and emits the §8 error envelope on stdout. In `--format=json` mode, errors are **never** written to stderr — agents read stdout.
76
- - **`version`** — gem semver string (independent of the wire protocol `textus/1`).
95
+ - **`version`** — gem semver string (independent of the wire protocol `textus/2`).
77
96
 
78
97
  ## Invariants
79
98
 
80
99
  - **CLI is the only entry point.** No public API surface guarantees outside the verbs CLI exposes.
81
100
  - **Manifest is pure.** Reads at load, no mutation.
82
- - **Store is the only module that writes to working-store entry files.** Init, MigrateKeys, Publisher, Builder, AuditLog write to **other** parts of `.textus/` (scaffolding, sentinels, audit log, derived targets) — they do not edit existing entry files behind Store's back.
101
+ - **Store::Writer is the only module that writes to working-store entry files.** Reader reads them; Mover moves them within a zone. Init, MigrateKeys, Publisher, Builder, AuditLog write to **other** parts of `.textus/` (scaffolding, sentinels, audit log, derived targets) — they do not edit existing entry files behind the Store facade's back.
83
102
  - **`name:` frontmatter matches file basename.** Enforced on read and write.
84
103
  - **Zone semantics live in the manifest, not in directory names.** A project may rename `state/` to anything; the manifest declares which zone each entry belongs to.
85
104
  - **`stale` does not execute anything.** It walks `zone: derived` entries with a `generator:` block, compares `generated.at` against source mtimes, and returns offenders **plus their declared `command`**. Build runners execute. This is the §5.1 "dataflow oracle, not executor" boundary.
@@ -0,0 +1,56 @@
1
+ require "fileutils"
2
+ require "time"
3
+
4
+ module Textus
5
+ class Builder
6
+ module InjectMeta
7
+ # Returns a new hash with _meta as the first key, per SPEC §6 ordering.
8
+ def self.call(content_hash, mentry)
9
+ meta = { "generated_at" => Time.now.utc.iso8601 }
10
+ from = Array(mentry.projection&.fetch("select", nil)).compact
11
+ meta["from"] = from unless from.empty?
12
+ meta["template"] = mentry.template if mentry.template
13
+ reduce = mentry.projection&.dig("reduce")
14
+ meta["reduce"] = reduce if reduce
15
+
16
+ out = { "_meta" => meta }
17
+ content_hash.each { |k, v| out[k] = v unless k == "_meta" }
18
+ out
19
+ end
20
+ end
21
+
22
+ module Pipeline
23
+ def self.renderers
24
+ @renderers ||= {
25
+ "markdown" => Renderer::Markdown,
26
+ "text" => Renderer::Text,
27
+ "json" => Renderer::Json,
28
+ "yaml" => Renderer::Yaml,
29
+ }
30
+ end
31
+
32
+ def self.run(store:, mentry:, template_loader:)
33
+ # 1. Load sources + project + reduce
34
+ data =
35
+ if mentry.projection
36
+ Projection.new(store, mentry.projection).run
37
+ else
38
+ { "entries" => [], "count" => 0, "generated_at" => Time.now.utc.iso8601 }
39
+ end
40
+ data = data.merge("intro" => Intro.run(store)) if mentry.inject_intro
41
+
42
+ # 2. Render
43
+ klass = renderers[mentry.format] or
44
+ raise UsageError.new("builder: unsupported format #{mentry.format.inspect} for '#{mentry.key}'")
45
+ bytes = klass.new(template_loader: template_loader).call(mentry: mentry, data: data)
46
+
47
+ # 3. Write
48
+ target_path = Key::Path.resolve(store.manifest, mentry)
49
+ FileUtils.mkdir_p(File.dirname(target_path))
50
+ File.binwrite(target_path, bytes)
51
+
52
+ target_path
53
+ end
54
+ end
55
+ end
56
+ end