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
data/SPEC.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  ## 1. What textus is
12
12
 
13
- A storage convention and JSON wire protocol that lets humans, agents, and runners read and write structured project memory **deterministically**, with addressable dotted keys, schema validation, role-based write gates, declarative compute, and copy-based publish targets.
13
+ A storage convention and JSON wire protocol for humans, agents, and runners to read and write structured project memory **deterministically**. It provides addressable dotted keys, schema validation, role-based write gates, declarative compute, and copy-based publish targets.
14
14
 
15
15
  The storage lives in a `.textus/` directory at the project root. Each entry is a Markdown file with YAML frontmatter. A manifest binds dotted keys to subtrees and declares which roles may write to each zone. Schemas (also YAML) define what frontmatter shape each entry must have. Derived entries are computed from other entries via pure projections and a vendored Mustache template engine, then optionally published to repo-relative paths as byte-for-byte file copies. The CLI surface (`textus get/put/list/where/schema/build/...` `--output=json`) returns a versioned envelope any caller can parse without knowing Markdown.
16
16
 
@@ -68,10 +68,10 @@ The root is `.textus/` at the project working directory. A typical tree:
68
68
  .textus/
69
69
  manifest.yaml # internal: key → subtree mapping + zones declarations
70
70
  audit.log # internal, append-only NDJSON log of every successful write
71
- role # internal, role token (one line, e.g. "human")
72
71
  schemas/ # internal: YAML schema files
73
72
  templates/ # internal: Mustache templates referenced by derived entries
74
- parsers/ # internal: project-local parser extensions
73
+ hooks/ # internal: one Ruby file per hook
74
+ sentinels/ # internal: bookkeeping for byte-copied publish targets (see §5.3)
75
75
  zones/ # ALL user content lives here
76
76
  identity/ # zone: identity (human-only)
77
77
  working/ # zone: working (human, agent, runner)
@@ -80,7 +80,7 @@ The root is `.textus/` at the project working directory. A typical tree:
80
80
  output/ # zone: output (builder only — computed outputs)
81
81
  ```
82
82
 
83
- Textus internals (`manifest.yaml`, `audit.log`, `role`, `schemas/`, `templates/`, `parsers/`) live directly under `.textus/`. **All user content lives under `.textus/zones/`.** Manifest `path:` fields are relative to `.textus/zones/` — they do **not** include the `zones/` prefix. Implementations MUST prepend `zones/` to every `path:` when resolving a key to a filesystem location.
83
+ Textus internals (`manifest.yaml`, `audit.log`, `schemas/`, `templates/`, `hooks/`, `sentinels/`) live directly under `.textus/`. **All user content lives under `.textus/zones/`.** Manifest `path:` fields are relative to `.textus/zones/` — they do **not** include the `zones/` prefix. Implementations MUST prepend `zones/` to every `path:` when resolving a key to a filesystem location.
84
84
 
85
85
  Zone directories under `zones/` are conventional; their write semantics are declared in the manifest, not the directory name.
86
86
 
@@ -138,6 +138,10 @@ entries:
138
138
  rules:
139
139
  - match: intake.**
140
140
  refresh: { ttl: 6h, on_stale: warn }
141
+
142
+ audit:
143
+ max_size: 10485760 # bytes before rotating (default: 10 485 760 = 10 MiB)
144
+ keep: 5 # rotated files to retain (default: 5)
141
145
  ```
142
146
 
143
147
  Zone names are conventional — the manifest is the source of truth for write permissions; rename freely.
@@ -397,7 +401,7 @@ In intake mode the handler MUST return one of three shapes, all normalized by th
397
401
 
398
402
  **Refresh paths.** Two are supported:
399
403
 
400
- 1. **In-process** — `textus refresh KEY --as=runner` resolves the entry's `intake.handler`, invokes the registered `:resolve_intake` hook with `(config:, store:, args: {})`, and writes the result under role `runner`.
404
+ 1. **In-process** — `textus refresh KEY --as=runner` resolves the entry's `intake.handler`, invokes the registered `:resolve_intake` hook with `(caps:, config:, args: {})`, and writes the result under role `runner`.
401
405
  2. **External runner** — a cron job or agent harness reads `textus list --zone=intake --stale --output=json`, fetches the source out of band, and pipes bytes back through `textus put KEY --as=runner --stdin`. The CLI verb `textus refresh stale [--prefix=K] [--zone=Z]` drives this loop in one shot.
402
406
 
403
407
  Both paths share the same role gate, audit-log entry, and `:entry_refreshed` event. User-supplied hooks live in `.textus/hooks/**/*.rb` and auto-load at `Store#initialize` — see §5.10 for the full hook contract.
@@ -439,6 +443,35 @@ Schema (one JSON object per line, no interior whitespace):
439
443
 
440
444
  For `mv`, the structural fields `from_key`, `to_key`, and `uid` appear at the top level of the JSON object. Remaining verb-specific data (e.g. `from_path`, `to_path`) is nested under an `extras` key. The `extras` key is omitted entirely when empty.
441
445
 
446
+ **Rotation.** After every successful append the implementation checks whether `audit.log` exceeds `max_size` bytes (checked inside the held `flock`, so the check sees the post-write size). If it does, the active log is rotated:
447
+
448
+ 1. The seq range (`min_seq`, `max_seq`) of the active log is scanned, and a JSON sidecar (`audit.log.1.meta.json`) is written with those values plus a `rotated_at` ISO 8601 timestamp.
449
+ 2. Existing rotated files are shifted: `audit.log.(N)` → `audit.log.(N+1)` for N = `keep-1` down to 1 (with their `.meta.json` sidecars).
450
+ 3. `audit.log` is renamed to `audit.log.1`.
451
+ 4. The file that would be shifted to `audit.log.(keep+1)` — i.e., `audit.log.keep` and its sidecar — is deleted before the shift.
452
+ 5. The next append creates a fresh `audit.log` via `O_CREAT`. Seq numbering continues from the previous maximum; there is no reset.
453
+
454
+ Rotation is triggered by **byte size only** — there is no row-count or time-based trigger.
455
+
456
+ **Rotation knobs** (configured via the optional `audit:` block in `manifest.yaml`):
457
+
458
+ | Key | Default | Meaning |
459
+ |------------|--------------|---------|
460
+ | `max_size` | `10485760` | Maximum size of `audit.log` in bytes (10 MiB) before rotation is triggered. |
461
+ | `keep` | `5` | Number of rotated files retained on disk. When this limit is exceeded the oldest rotated file and its sidecar are deleted. |
462
+
463
+ Both keys are optional. Omitting `audit:` entirely uses the defaults above.
464
+
465
+ **`CursorExpired`.** When `audit --seq-since=N` or `pulse --since=N` is called with a cursor `N`, the implementation checks whether `N` is below the oldest sequence number still available on disk (`min_available_seq`, derived from the oldest retained rotated file's sidecar). The condition that raises `CursorExpired` is:
466
+
467
+ ```
468
+ N < min_available_seq - 1
469
+ ```
470
+
471
+ The error includes `requested` (the supplied cursor value) and `min_available` (the oldest seq still on disk).
472
+
473
+ **Recommended caller behavior on `CursorExpired`.** Call `textus boot` (without `--since`) to obtain a fresh `latest_seq` from the current audit log state, then resume `pulse` calls using that new cursor. Do not attempt to replay from an expired cursor — the intervening rows are gone.
474
+
442
475
  ### 5.7 Security bounds
443
476
 
444
477
  textus enforces fixed bounds to keep behavior predictable under hostile or buggy input:
@@ -478,7 +511,7 @@ evolution:
478
511
 
479
512
  **Defaults:** when `fields:` and `evolution:` are absent, `schema.maintained_by(field)` returns `nil` for every field and `schema.evolution` returns `{}`.
480
513
 
481
- **Override rule:** the role `human` is permitted to write any `maintained_by` field, regardless of declared owner. This preserves human authority over agent/runner-managed data humans curating canon over agent-written embeddings is a feature, not a bug. All other role mismatches are reported by `doctor --check=schema_violations` with code `role_authority`, including fields `key`, `field`, `expected`, and `last_writer`.
514
+ **Override rule:** the role `human` is permitted to write any `maintained_by` field, regardless of declared owner. Humans override agent-maintained fields by design: schema field ownership (`maintained_by:`) makes the boundary explicit, not implicit. All other role mismatches are reported by `doctor --check=schema_violations` with code `role_authority`, including fields `key`, `field`, `expected`, and `last_writer`.
482
515
 
483
516
  ### 5.9 Row transforms
484
517
 
@@ -495,11 +528,11 @@ The subdirectory layout under `hooks/` is organizational only; the registered ev
495
528
  ```ruby
496
529
  # Canonical form — works for every event:
497
530
  Textus.hook do |reg|
498
- reg.on(:resolve_intake, :my_source) { |config:, args:, **| … }
499
- reg.on(:transform_rows, :rank_by_recency) { |rows:, **| … }
500
- reg.on(:validate, :storage_writable) { |store:| … }
501
- reg.on(:entry_put, :audit, keys: ["working.*"]) { |key:, envelope:, **| … }
502
- reg.on(:file_published, :git_add, keys: ["derived.*"]) { |target:, **| `git add #{target.shellescape}` }
531
+ reg.on(:resolve_intake, :my_source) { |caps:, config:, args:, **| … }
532
+ reg.on(:transform_rows, :rank_by_recency) { |caps:, rows:, **| … }
533
+ reg.on(:validate, :storage_writable) { |caps:| … }
534
+ reg.on(:entry_put, :audit, keys: ["working.*"]) { |ctx:, key:, envelope:, **| … }
535
+ reg.on(:file_published, :git_add, keys: ["derived.*"]) { |ctx:, target:, **| `git add #{target.shellescape}` }
503
536
  end
504
537
  ```
505
538
 
@@ -509,21 +542,21 @@ end
509
542
 
510
543
  | Event | Mode | Args | Return | Failure |
511
544
  |-------------------------|---------|-----------------------------------------------------------|-----------------------|---------------|
512
- | `:resolve_intake` | rpc | store:, config:, args: | {_meta:, body:} | aborts op |
513
- | `:transform_rows` | rpc | store:, rows:, config: | rows array | aborts op |
514
- | `:validate` | rpc | store: | issues array | aborts doctor |
515
- | `:entry_put` | pubsub | store:, key:, envelope: | (discarded) | logged |
516
- | `:entry_deleted` | pubsub | store:, key: | (discarded) | logged |
517
- | `:entry_refreshed` | pubsub | store:, key:, envelope:, change: | (discarded) | logged |
518
- | `:build_completed` | pubsub | store:, key:, envelope:, sources: | (discarded) | logged |
519
- | `:proposal_accepted` | pubsub | store:, key:, target_key: | (discarded) | logged |
520
- | `:file_published` | pubsub | store:, key:, envelope:, source:, target: | (discarded) | logged |
521
- | `:entry_renamed` | pubsub | store:, key:, from_key:, to_key:, envelope: | (discarded) | logged |
522
- | `:proposal_rejected` | pubsub | store:, key:, target_key: | (discarded) | logged |
523
- | `:store_loaded` | pubsub | store: | (discarded) | logged |
524
- | `:refresh_started` | pubsub | store:, key:, mode: | (discarded) | logged |
525
- | `:refresh_failed` | pubsub | store:, key:, error_class:, error_message: | (discarded) | logged |
526
- | `:refresh_backgrounded` | pubsub | store:, key:, started_at:, budget_ms: | (discarded) | logged |
545
+ | `:resolve_intake` | rpc | caps:, config:, args: | {_meta:, body:} | aborts op |
546
+ | `:transform_rows` | rpc | caps:, rows:, config: | rows array | aborts op |
547
+ | `:validate` | rpc | caps: | issues array | aborts doctor |
548
+ | `:entry_put` | pubsub | ctx:, key:, envelope: | (discarded) | logged |
549
+ | `:entry_deleted` | pubsub | ctx:, key: | (discarded) | logged |
550
+ | `:entry_refreshed` | pubsub | ctx:, key:, envelope:, change: | (discarded) | logged |
551
+ | `:build_completed` | pubsub | ctx:, key:, envelope:, sources: | (discarded) | logged |
552
+ | `:proposal_accepted` | pubsub | ctx:, key:, target_key: | (discarded) | logged |
553
+ | `:file_published` | pubsub | ctx:, key:, envelope:, source:, target: | (discarded) | logged |
554
+ | `:entry_renamed` | pubsub | ctx:, key:, from_key:, to_key:, envelope: | (discarded) | logged |
555
+ | `:proposal_rejected` | pubsub | ctx:, key:, target_key: | (discarded) | logged |
556
+ | `:store_loaded` | pubsub | ctx: | (discarded) | logged |
557
+ | `:refresh_started` | pubsub | ctx:, key:, mode: | (discarded) | logged |
558
+ | `:refresh_failed` | pubsub | ctx:, key:, error_class:, error_message: | (discarded) | logged |
559
+ | `:refresh_backgrounded` | pubsub | ctx:, key:, started_at:, budget_ms: | (discarded) | logged |
527
560
 
528
561
  The three `:refresh_*` lifecycle events report the progress and failures of background (timed_sync) refreshes.
529
562
 
@@ -533,14 +566,19 @@ The three `:refresh_*` lifecycle events report the progress and failures of back
533
566
 
534
567
  **`:refresh_backgrounded`** fires when a `timed_sync` refresh exceeds its budget and is handed off to a background thread. `started_at:` is an ISO-8601 UTC string; `budget_ms:` is the configured deadline as an integer.
535
568
 
536
- **Signature invariant** — every hook receives `store:` as its first keyword argument. Event-specific kwargs follow in stable left-to-right order. The primary entity is always `key:` (for `:proposal_accepted`, `key:` is the pending key being accepted and `target_key:` is the destination). For `:entry_renamed`, `key:` is present and equals `to_key:` — it is the entry's post-move home, present so `keys:` glob filters route correctly; `from_key:` is the prior key. For `:proposal_rejected`, `key:` is the pending key being rejected. For `:store_loaded`, no key — the event observes store readiness, not an entry.
569
+ **Signature invariant** — hooks receive a capability handle as their first keyword argument; the name depends on the mode:
570
+
571
+ - **RPC hooks** (`rpc` mode) receive `caps:` — a `Textus::Container`. Event-specific kwargs (`config:`, `args:`, `rows:`) follow in the stable order shown in the table above.
572
+ - **Pub-sub hooks** (`pubsub` mode) receive `ctx:` — a `Textus::Hooks::Context` that exposes a narrow surface: `get`, `list`, `deps`, `freshness` (reads), `put`, `delete`, `audit` (authorized writes), `publish_followup`, plus `role` and `correlation_id`. The raw `Store` is not handed out.
573
+
574
+ Declaring `store:` instead of `caps:` in an RPC callable will pass registration but raise `UsageError` at call time (`Hooks::RpcRegistry#invoke` rejects `store:` — there is no shim).
575
+
576
+ The primary entity is always `key:` (for `:proposal_accepted`, `key:` is the pending key being accepted and `target_key:` is the destination). For `:entry_renamed`, `key:` is present and equals `to_key:` — it is the entry's post-move home, present so `keys:` glob filters route correctly; `from_key:` is the prior key. For `:proposal_rejected`, `key:` is the pending key being rejected. For `:store_loaded`, no key — the event observes store readiness, not an entry.
537
577
 
538
578
  **RPC mode** — exactly one handler per (event, name). The manifest references the handler by name (`intake.handler: NAME`, `compute.transform: NAME`). Failure or timeout aborts the calling operation.
539
579
 
540
580
  **Pub-sub mode** — zero or more handlers per event. All matching handlers fire. The `keys:` option restricts a handler to keys matching one of the given globs (`File.fnmatch?` rules). Absence of `keys:` fires on every event of that type. Handler failures and 2s timeouts are logged to `audit.log` as `event_error` rows; they NEVER abort the triggering operation.
541
581
 
542
- The `store:` argument is always a read-only store proxy. Write attempts raise `UsageError`.
543
-
544
582
  Each handler runs under `Timeout.timeout(2)`.
545
583
 
546
584
  ### 5.11 Rules
@@ -899,14 +937,13 @@ Plugin authors interact only with the Hook DSL (`Textus.hook { |reg| reg.on(:res
899
937
 
900
938
  Both read and write paths flow through the application layer:
901
939
 
902
- - **Reads** flow through `Application::Read::Get` (pure read + freshness annotation) or `Read::GetOrRefresh` (composes Get with `Write::RefreshOrchestrator`). Each takes a `caps:` slice and an `Application::Context`.
903
- - **Writes** flow through `Application::Write::{Put,Delete,Mv,Accept,Reject,Publish,RefreshWorker}`. Permission checks happen at the use-case layer (via `Domain::Authorizer#authorize_write!`); the audit-append invariant lives in `Application::Envelope::Writer`.
904
- - `Application::Context` is the slim request record: `role`, `correlation_id`, `now`, `dry_run`. Ports come from a `Caps` record (Read/Write/Hook), not from the Context.
905
- - `Textus::Session` is the factory CLI verbs and the MCP gate use to dispatch
906
- use cases. `Session.for(store, role:)` returns a per-call object exposing one
907
- method per registered use case (`#put`, `#get`, `#refresh`, …); methods are
908
- generated from `Application::UseCase.entries` so adding a use case is a
909
- single `UseCase.register(...)` line.
940
+ - **Reads** flow through `Textus::Read::Get` (pure read + freshness annotation) or `Read::GetOrRefresh` (composes Get with `Write::RefreshOrchestrator`). Each takes a `container:` and a `call:`.
941
+ - **Writes** flow through `Textus::Write::{Put,Delete,Mv,Accept,Reject,Publish,RefreshWorker}`. Permission checks happen at the use-case layer (via `Domain::Authorizer#authorize_write!`); the audit-append invariant lives in `Textus::Envelope::IO::Writer`.
942
+ - `Textus::Call` is the slim per-invocation record: `role`, `correlation_id`, `now`, `dry_run`. Ports come from `Textus::Container`, not from the Call.
943
+ - `Textus::Store` is the composition root and verb dispatcher. CLI verbs and the
944
+ MCP gate call `store.<verb>(..., role:)` (or `store.as(role).<verb>(...)`).
945
+ Verbs are looked up in the static `Textus::Dispatcher::VERBS` table; adding a
946
+ use case is a single entry in `VERBS` plus the class.
910
947
 
911
948
  See `ARCHITECTURE.md` for an ASCII diagram and the full read-path walkthrough.
912
949
 
@@ -914,7 +951,7 @@ See `ARCHITECTURE.md` for an ASCII diagram and the full read-path walkthrough.
914
951
 
915
952
  - **Locking on `put`:** the reference impl uses sha256 etags. Should the spec also define a file-lock fallback for systems where read-before-write is racy?
916
953
  - **Schema imports:** can one schema reference another (`type: $ref: person`)?
917
- - **Internationalization:** non-ASCII in keys? Spec currently restricts segments to `[a-z0-9_-]`. Revisit if community wants Unicode.
954
+ - **Internationalization:** non-ASCII in keys? Spec currently restricts segments to `[a-z0-9][a-z0-9-]*`. Revisit if community wants Unicode.
918
955
  - **Generated content in `derived/`:** the spec says `schema: null` is allowed, but should there be a separate marker (`generated: true`) for clarity?
919
956
 
920
957
  ## 15. Implementation checklist
data/docs/conventions.md CHANGED
@@ -128,11 +128,11 @@ For multi-writer environments, **always pass `if_etag`** on `put`. The gem treat
128
128
 
129
129
  ## Application layering
130
130
 
131
- The application layer is organised around three patterns — `Manifest` as a composition record, capability slices (`Caps`) handed to use cases, and a split envelope reader/writer. See [ADR 0018](architecture/decisions/0018-manifest-carving.md), [ADR 0017](architecture/decisions/0017-envelope-io-split.md), [ADR 0020](architecture/decisions/0020-capability-records.md), and [ADR 0021](architecture/decisions/0021-session-and-module-use-cases.md).
131
+ The application layer is organised around three shapes — `Manifest` as a composition record, a single `Container` capability record handed to every use case, and a split envelope reader/writer. See [ADR 0018](architecture/decisions/0018-manifest-carving.md), [ADR 0017](architecture/decisions/0017-envelope-io-split.md), [ADR 0022](architecture/decisions/0022-container-call-dispatcher.md), and [ADR 0023](architecture/decisions/0023-uniform-use-case-shape.md).
132
132
 
133
- - **`Manifest` is a composition record** (`Data.define(:data, :resolver, :policy, :rules)`). Reach individual concerns through the field accessors: `manifest.data.entries`, `manifest.policy.permission_for(zone)`, `manifest.resolver.resolve(key)`, `manifest.rules.for(key)`. (The legacy top-level shims were removed in 0.26.0.)
134
- - **Application use cases take `session:`, `ctx:`, `caps:`** and are registered with `Application::UseCase.register(:verb, mod, caps: :read|:write)`. Each use case is a module with a `self.call(...)` entry point and a nested `class Impl` for state. `Caps` is a `Data.define` slice of the Store (`ReadCaps`, `WriteCaps`, `HookCaps`) use cases pull only what they need into ivars; nobody passes the raw `Store` around the application layer.
135
- - **Write path is split**: `Application::Envelope::Reader` owns read/parse (existing-uid lookup, raw read, parse), and `Application::Envelope::Writer` owns put/delete/move + the audit-append invariant (every public method's final action is `@audit_log.append(...)`).
133
+ - **`Manifest` is a composition record** (`Data.define(:data, :resolver, :policy, :rules)`). Reach individual concerns through the field accessors: `manifest.data.entries`, `manifest.policy.permission_for(zone)`, `manifest.resolver.resolve(key)`, `manifest.rules.for(key)`.
134
+ - **Use cases are plain `(container:, call:)` classes.** Each is a single class under `lib/textus/{read,write,maintenance}/` with `def initialize(container:, call:)` and a `#call(...)` method; verbs are looked up in the static `Textus::Dispatcher::VERBS` table. `Container` is a `Data.define` record bundling the wired ports + manifest (`manifest`, `file_store`, `schemas`, `root`, `audit_log`, `events`, `rpc`, `authorizer`); `Call` is the immutable per-invocation value (`role`, `correlation_id`, `now`, `dry_run`). A use case that emits events derives its `Hooks::Context` from `(container, call)` — nothing is injected. Use cases pull only what they need into ivars; nobody passes the raw `Store` around the application layer. `store.as(role)` returns a `RoleScope` that forwards verbs to the dispatcher.
135
+ - **Write path is split**: `Envelope::IO::Reader` owns read/parse (existing-uid lookup, raw read, parse), and `Envelope::IO::Writer` owns put/delete/move + the audit-append invariant (every public method's final action is `@audit_log.append(...)`).
136
136
 
137
137
  The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/3`) are unchanged.
138
138
 
data/lib/textus/boot.rb CHANGED
@@ -120,7 +120,7 @@ module Textus
120
120
  "summary" => "delta since cursor — changed entries, stale, pending review, doctor summary" },
121
121
  ].freeze
122
122
 
123
- def self.agent_quickstart(manifest, session)
123
+ def self.agent_quickstart(manifest, audit_log)
124
124
  proposer_roles = manifest.policy.roles_with_kind(:proposer)
125
125
  agent_role = proposer_roles.first
126
126
 
@@ -135,7 +135,7 @@ module Textus
135
135
  "write_verbs" => agent_role ? ["put KEY --as=#{agent_role} --stdin"] : [],
136
136
  "writable_zones" => writable_zones,
137
137
  "propose_zone" => propose_zone,
138
- "latest_seq" => session.write_caps.audit_log.latest_seq,
138
+ "latest_seq" => audit_log.latest_seq,
139
139
  }
140
140
  end
141
141
 
@@ -150,18 +150,18 @@ module Textus
150
150
  )
151
151
  end
152
152
 
153
- def self.run(session)
154
- manifest = session.read_caps.manifest
153
+ def self.build(container:)
154
+ manifest = container.manifest
155
155
  {
156
156
  "protocol" => PROTOCOL_ID,
157
- "store_root" => session.read_caps.root,
157
+ "store_root" => container.root,
158
158
  "zones" => zones_for(manifest),
159
159
  "entries" => entries_for(manifest),
160
- "hooks" => hooks_for(session),
160
+ "hooks" => hooks_for_container(container),
161
161
  "write_flows" => write_flows_for(manifest),
162
162
  "cli_verbs" => CLI_VERBS.map(&:dup),
163
163
  "agent_protocol" => agent_protocol(manifest),
164
- "agent_quickstart" => agent_quickstart(manifest, session),
164
+ "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
165
165
  "docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
166
166
  }
167
167
  end
@@ -193,13 +193,17 @@ module Textus
193
193
  end
194
194
  end
195
195
 
196
- def self.hooks_for(session)
196
+ def self.hooks_for_container(container)
197
+ hooks_for_container_internal(rpc: container.rpc, events: container.events)
198
+ end
199
+
200
+ def self.hooks_for_container_internal(rpc:, events:)
197
201
  sections = {}
198
202
  Hooks::RpcRegistry::EVENTS.each_key do |event|
199
- sections[event.to_s] = session.rpc.names(event).map(&:to_s).sort
203
+ sections[event.to_s] = rpc.names(event).map(&:to_s).sort
200
204
  end
201
205
  Hooks::EventBus::EVENTS.each_key do |event|
202
- sections[event.to_s] = session.events.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
206
+ sections[event.to_s] = events.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
203
207
  end
204
208
  sections
205
209
  end
@@ -53,6 +53,10 @@ module Textus
53
53
  end
54
54
 
55
55
  module Pipeline
56
+ Deps = Data.define(
57
+ :manifest, :reader, :lister, :rpc, :template_loader, :transform_context, :inject_boot
58
+ )
59
+
56
60
  def self.renderers
57
61
  @renderers ||= {
58
62
  "markdown" => Renderer::Markdown,
@@ -62,37 +66,34 @@ module Textus
62
66
  }
63
67
  end
64
68
 
65
- # rubocop:disable Metrics/ParameterLists
66
- def self.run(mentry:, manifest:, reader:, lister:, rpc:, template_loader:,
67
- transform_context: nil, inject_boot: nil)
69
+ def self.run(mentry:, deps:)
68
70
  # 1. Load sources + project + reduce
69
71
  data =
70
72
  if mentry.is_a?(Textus::Manifest::Entry::Derived) && mentry.projection?
71
- Application::Projection.new(
72
- reader: reader,
73
+ Textus::Projection.new(
74
+ reader: deps.reader,
73
75
  spec: mentry.source.to_h.transform_keys(&:to_s),
74
- lister: lister,
75
- rpc: rpc,
76
- transform_context: transform_context,
76
+ lister: deps.lister,
77
+ rpc: deps.rpc,
78
+ transform_context: deps.transform_context,
77
79
  ).run
78
80
  else
79
81
  { "entries" => [], "count" => 0, "generated_at" => Time.now.utc.iso8601 }
80
82
  end
81
- data = data.merge("boot" => inject_boot.call) if mentry.inject_boot && inject_boot
83
+ data = data.merge("boot" => deps.inject_boot.call) if mentry.inject_boot && deps.inject_boot
82
84
 
83
85
  # 2. Render
84
86
  klass = renderers[mentry.format] or
85
87
  raise UsageError.new("builder: unsupported format #{mentry.format.inspect} for '#{mentry.key}'")
86
- bytes = klass.new(template_loader: template_loader).call(mentry: mentry, data: data)
88
+ bytes = klass.new(template_loader: deps.template_loader).call(mentry: mentry, data: data)
87
89
 
88
90
  # 3. Write (idempotent: skip if only generated_at would differ)
89
- target_path = Key::Path.resolve(manifest.data, mentry)
91
+ target_path = Key::Path.resolve(deps.manifest.data, mentry)
90
92
  FileUtils.mkdir_p(File.dirname(target_path))
91
93
  write_if_changed(target_path, bytes, mentry.format)
92
94
 
93
95
  target_path
94
96
  end
95
- # rubocop:enable Metrics/ParameterLists
96
97
 
97
98
  def self.write_if_changed(target_path, bytes, format)
98
99
  if File.exist?(target_path)
@@ -0,0 +1,28 @@
1
+ require "securerandom"
2
+
3
+ module Textus
4
+ # Immutable per-invocation value. Carries who is acting (role), the
5
+ # request correlation id, the wall clock, and the dry_run flag — the
6
+ # bits Use Cases need that are not part of the Container.
7
+ Call = Data.define(:role, :correlation_id, :now, :dry_run) do
8
+ def self.build(role:, correlation_id: nil, now: nil, dry_run: false)
9
+ new(
10
+ role: role.to_s,
11
+ correlation_id: correlation_id || SecureRandom.uuid,
12
+ now: now || Textus::Ports::Clock.now,
13
+ dry_run: dry_run,
14
+ )
15
+ end
16
+
17
+ def dry_run? = dry_run
18
+
19
+ def with_role(new_role)
20
+ self.class.new(
21
+ role: new_role.to_s,
22
+ correlation_id: correlation_id,
23
+ now: now,
24
+ dry_run: dry_run,
25
+ )
26
+ end
27
+ end
28
+ end
@@ -15,7 +15,7 @@ module Textus
15
15
 
16
16
  def call(store)
17
17
  ops = session_for(store)
18
- since_time = since && Textus::Application::Read::Audit.parse_since(since, now: ops.ctx.now)
18
+ since_time = since && Textus::Read::Audit.parse_since(since, now: Time.now)
19
19
  rows = ops.audit(
20
20
  key: key_filter,
21
21
  zone: zone,
@@ -5,7 +5,7 @@ module Textus
5
5
  command_name "boot"
6
6
 
7
7
  def call(store)
8
- emit(Textus::Boot.run(Textus::Session.for(store)))
8
+ emit(store.boot)
9
9
  end
10
10
  end
11
11
  end
@@ -7,9 +7,9 @@ module Textus
7
7
  option :prefix, "--prefix=K"
8
8
 
9
9
  def call(store)
10
- Textus::Infra::BuildLock.with(root: store.root) do
10
+ Textus::Ports::BuildLock.with(root: store.root) do
11
11
  role = store.manifest.policy.roles_with_kind(:generator).first || "builder"
12
- ops = store.session(role: role)
12
+ ops = store.as(role)
13
13
  result = ops.publish(prefix: prefix)
14
14
  emit(result)
15
15
  end
@@ -8,7 +8,7 @@ module Textus
8
8
 
9
9
  def call(store)
10
10
  check_list = checks&.split(",")&.map(&:strip)
11
- res = Textus::Doctor.run(Textus::Session.for(store), checks: check_list)
11
+ res = store.doctor(checks: check_list)
12
12
  emit(res, exit_code: res["ok"] ? 0 : 1)
13
13
  end
14
14
  end
@@ -29,12 +29,12 @@ module Textus
29
29
  Role.resolve(flag: as_flag, env: ENV, root: store.root)
30
30
 
31
31
  begin
32
- Timeout.timeout(Textus::Application::Write::RefreshWorker::FETCH_TIMEOUT_SECONDS) do
32
+ Timeout.timeout(Textus::Write::RefreshWorker::FETCH_TIMEOUT_SECONDS) do
33
33
  store.rpc.invoke(:resolve_intake, name, caps: nil, config: {}, args: args)
34
34
  end
35
35
  rescue Timeout::Error
36
36
  raise UsageError.new(
37
- "hook run '#{name}' exceeded #{Textus::Application::Write::RefreshWorker::FETCH_TIMEOUT_SECONDS}s timeout",
37
+ "hook run '#{name}' exceeded #{Textus::Write::RefreshWorker::FETCH_TIMEOUT_SECONDS}s timeout",
38
38
  )
39
39
  rescue Textus::Error
40
40
  raise
@@ -19,7 +19,7 @@ module Textus
19
19
  if fetch_name
20
20
  result =
21
21
  begin
22
- Timeout.timeout(Textus::Application::Write::RefreshWorker::FETCH_TIMEOUT_SECONDS) do
22
+ Timeout.timeout(Textus::Write::RefreshWorker::FETCH_TIMEOUT_SECONDS) do
23
23
  store.rpc.invoke(:resolve_intake, fetch_name,
24
24
  caps: nil,
25
25
  config: { "bytes" => raw },
@@ -27,7 +27,7 @@ module Textus
27
27
  end
28
28
  rescue Timeout::Error
29
29
  raise UsageError.new(
30
- "fetch '#{fetch_name}' exceeded #{Textus::Application::Write::RefreshWorker::FETCH_TIMEOUT_SECONDS}s timeout",
30
+ "fetch '#{fetch_name}' exceeded #{Textus::Write::RefreshWorker::FETCH_TIMEOUT_SECONDS}s timeout",
31
31
  )
32
32
  end
33
33
  basename = key.split(".").last
@@ -46,7 +46,7 @@ module Textus
46
46
  meta = payload["_meta"] || {}
47
47
  body = payload["body"] || ""
48
48
  if_etag = payload["if_etag"]
49
- result = store.session(role: role).put(key, meta: meta, body: body, if_etag: if_etag)
49
+ result = store.as(role).put(key, meta: meta, body: body, if_etag: if_etag)
50
50
  emit(result.to_h_for_wire)
51
51
  end
52
52
  end
@@ -96,16 +96,16 @@ module Textus
96
96
  Role.resolve(flag: flag, env: ENV, root: store.root)
97
97
  end
98
98
 
99
- # Returns an Application::Context bound to the resolved role.
100
- # Convenience for verbs whose only pre-call boilerplate is
101
- # resolving the role and wrapping it in a context.
99
+ # Returns a Call value bound to the resolved role. Convenience for
100
+ # verbs whose only pre-call boilerplate is resolving the role and
101
+ # wrapping it in a Call.
102
102
  def context_for(store)
103
- store.session(role: resolved_role(store)).ctx
103
+ Textus::Call.build(role: resolved_role(store))
104
104
  end
105
105
 
106
- # Returns a Session instance bound to the resolved role.
106
+ # Returns a RoleScope bound to the resolved role.
107
107
  def session_for(store)
108
- store.session(role: resolved_role(store))
108
+ store.as(resolved_role(store))
109
109
  end
110
110
  end
111
111
  end
data/lib/textus/cli.rb CHANGED
@@ -14,13 +14,6 @@ module Textus
14
14
  .to_h { |k| [k.command_name, k] }
15
15
  end
16
16
 
17
- # Backward-compat constant; callers should prefer `CLI.verbs`.
18
- def self.const_missing(name)
19
- return verbs.freeze if name == :VERBS
20
-
21
- super
22
- end
23
-
24
17
  def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd)
25
18
  new(stdin: stdin, stdout: stdout, stderr: stderr, cwd: cwd).run(argv)
26
19
  end
@@ -0,0 +1,23 @@
1
+ module Textus
2
+ # Single capability record handed to every use case. Replaces the
3
+ # ReadCaps/WriteCaps/HookCaps trio from 0.26.x. Built once per Store.
4
+ Container = Data.define(
5
+ :manifest, :file_store, :schemas, :root,
6
+ :audit_log, :events, :rpc, :authorizer
7
+ )
8
+
9
+ class Container
10
+ def self.from_store(store)
11
+ new(
12
+ manifest: store.manifest,
13
+ file_store: store.file_store,
14
+ schemas: store.schemas,
15
+ root: store.root,
16
+ audit_log: store.audit_log,
17
+ events: store.events,
18
+ rpc: store.rpc,
19
+ authorizer: Textus::Domain::Authorizer.new(manifest: store.manifest),
20
+ )
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,49 @@
1
+ module Textus
2
+ # Static verb → use-case map. Canonical lookup as of 0.27.0; replaces the
3
+ # Application::UseCase registry whose entries were populated by file-load
4
+ # side effects in 0.26.x.
5
+ module Dispatcher
6
+ VERBS = {
7
+ # Write
8
+ put: Textus::Write::Put,
9
+ delete: Textus::Write::Delete,
10
+ mv: Textus::Write::Mv,
11
+ accept: Textus::Write::Accept,
12
+ reject: Textus::Write::Reject,
13
+ publish: Textus::Write::Publish,
14
+ refresh: Textus::Write::RefreshWorker,
15
+ refresh_all: Textus::Write::RefreshAll,
16
+
17
+ # Read
18
+ get: Textus::Read::Get,
19
+ get_or_refresh: Textus::Read::GetOrRefresh,
20
+ list: Textus::Read::List,
21
+ where: Textus::Read::Where,
22
+ uid: Textus::Read::Uid,
23
+ blame: Textus::Read::Blame,
24
+ audit: Textus::Read::Audit,
25
+ freshness: Textus::Read::Freshness,
26
+ stale: Textus::Read::Stale,
27
+ deps: Textus::Read::Deps,
28
+ rdeps: Textus::Read::Rdeps,
29
+ pulse: Textus::Read::Pulse,
30
+ policy_explain: Textus::Read::PolicyExplain,
31
+ published: Textus::Read::Published,
32
+ schema_envelope: Textus::Read::SchemaEnvelope,
33
+ validate_all: Textus::Read::ValidateAll,
34
+ doctor: Textus::Read::Doctor,
35
+ boot: Textus::Read::Boot,
36
+
37
+ # Maintenance
38
+ migrate: Textus::Maintenance::Migrate,
39
+ zone_mv: Textus::Maintenance::ZoneMv,
40
+ key_mv_prefix: Textus::Maintenance::KeyMvPrefix,
41
+ key_delete_prefix: Textus::Maintenance::KeyDeletePrefix,
42
+ rule_lint: Textus::Maintenance::RuleLint,
43
+ }.freeze
44
+
45
+ def self.fetch(verb)
46
+ VERBS.fetch(verb.to_sym) { raise UsageError.new("unknown verb: #{verb.inspect}") }
47
+ end
48
+ end
49
+ end
@@ -4,7 +4,7 @@ module Textus
4
4
  class AuditLog < Check
5
5
  def call
6
6
  path = File.join(root, "audit.log")
7
- Textus::Infra::AuditLog.new(root).verify_integrity.map do |v|
7
+ Textus::Ports::AuditLog.new(root).verify_integrity.map do |v|
8
8
  {
9
9
  "code" => "audit.parse_error",
10
10
  "level" => "warning",
@@ -3,7 +3,7 @@ module Textus
3
3
  class Check
4
4
  class SchemaViolations < Check
5
5
  def call
6
- res = @session.validate_all
6
+ res = dispatch(:validate_all)
7
7
  res["violations"].map do |v|
8
8
  fix = v["expected"] &&
9
9
  "field '#{v["field"]}' should be written by '#{v["expected"]}' (last writer: #{v["last_writer"]})"