textus 0.55.1 → 0.55.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1 -1
  3. data/README.md +9 -9
  4. data/SPEC.md +14 -13
  5. data/docs/architecture/README.md +3 -3
  6. data/docs/reference/conventions.md +5 -2
  7. data/lib/textus/boot.rb +64 -85
  8. data/lib/textus/{gate → dispatch}/binder.rb +8 -10
  9. data/lib/textus/dispatch/contracts.rb +63 -0
  10. data/lib/textus/dispatch/handler_registry.rb +21 -0
  11. data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
  12. data/lib/textus/dispatch/middleware/auth.rb +40 -0
  13. data/lib/textus/dispatch/middleware/base.rb +26 -0
  14. data/lib/textus/dispatch/middleware/binder.rb +20 -0
  15. data/lib/textus/dispatch/middleware/cascade.rb +53 -0
  16. data/lib/textus/dispatch/pipeline.rb +35 -0
  17. data/lib/textus/doctor/check/audit_log.rb +1 -1
  18. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  19. data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
  20. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  21. data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
  22. data/lib/textus/doctor/check/sentinels.rb +1 -1
  23. data/lib/textus/doctor/check.rb +8 -6
  24. data/lib/textus/doctor.rb +1 -1
  25. data/lib/textus/errors.rb +2 -0
  26. data/lib/textus/format/base.rb +36 -8
  27. data/lib/textus/format/json.rb +0 -21
  28. data/lib/textus/format/markdown.rb +0 -21
  29. data/lib/textus/format/yaml.rb +0 -21
  30. data/lib/textus/format.rb +16 -1
  31. data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
  32. data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
  33. data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
  34. data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
  35. data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
  36. data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
  37. data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
  38. data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
  39. data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
  40. data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
  41. data/lib/textus/handlers/read/audit_entries.rb +48 -0
  42. data/lib/textus/handlers/read/blame_entry.rb +71 -0
  43. data/lib/textus/handlers/read/deps_entry.rb +17 -0
  44. data/lib/textus/handlers/read/get_entry.rb +68 -0
  45. data/lib/textus/handlers/read/list_keys.rb +36 -0
  46. data/lib/textus/handlers/read/pulse_entries.rb +66 -0
  47. data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
  48. data/lib/textus/handlers/read/uid_entry.rb +18 -0
  49. data/lib/textus/handlers/read/where_entry.rb +18 -0
  50. data/lib/textus/handlers/write/accept_proposal.rb +39 -0
  51. data/lib/textus/handlers/write/data_mv.rb +55 -0
  52. data/lib/textus/handlers/write/delete_key.rb +17 -0
  53. data/lib/textus/handlers/write/enqueue_job.rb +27 -0
  54. data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
  55. data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
  56. data/lib/textus/handlers/write/move_key.rb +80 -0
  57. data/lib/textus/handlers/write/propose_entry.rb +29 -0
  58. data/lib/textus/handlers/write/put_entry.rb +29 -0
  59. data/lib/textus/handlers/write/reject_proposal.rb +29 -0
  60. data/lib/textus/init.rb +5 -5
  61. data/lib/textus/manifest/capabilities.rb +1 -1
  62. data/lib/textus/manifest/entry/base.rb +3 -3
  63. data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
  64. data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
  65. data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
  66. data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
  67. data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
  68. data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
  69. data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
  70. data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
  71. data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
  72. data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
  73. data/lib/textus/manifest/policy/predicates.rb +54 -0
  74. data/lib/textus/manifest/policy/retention.rb +1 -1
  75. data/lib/textus/orchestration.rb +55 -0
  76. data/lib/textus/port/audit_log.rb +6 -6
  77. data/lib/textus/port/build_lock.rb +1 -1
  78. data/lib/textus/{core → port}/sentinel.rb +1 -6
  79. data/lib/textus/port/sentinel_store.rb +3 -3
  80. data/lib/textus/port/storage/file_store.rb +23 -0
  81. data/lib/textus/port/storage/interface.rb +17 -0
  82. data/lib/textus/port/store.rb +58 -2
  83. data/lib/textus/port/watcher_lock.rb +2 -2
  84. data/lib/textus/produce/engine.rb +1 -11
  85. data/lib/textus/produce/publisher.rb +21 -0
  86. data/lib/textus/schema/registry.rb +42 -0
  87. data/lib/textus/schema/tools.rb +3 -10
  88. data/lib/textus/store/container.rb +140 -10
  89. data/lib/textus/store/cursor.rb +1 -1
  90. data/lib/textus/store/{envelope → entry}/reader.rb +8 -4
  91. data/lib/textus/store/{envelope → entry}/writer.rb +53 -29
  92. data/lib/textus/store/envelope/meta.rb +61 -0
  93. data/lib/textus/store/freshness/drift_detector.rb +93 -0
  94. data/lib/textus/store/freshness/evaluator.rb +20 -0
  95. data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
  96. data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
  97. data/lib/textus/store/freshness.rb +8 -0
  98. data/lib/textus/store/index/builder.rb +5 -3
  99. data/lib/textus/store/jobs/planner.rb +27 -7
  100. data/lib/textus/store/jobs/queue.rb +9 -1
  101. data/lib/textus/store/jobs/retention/base.rb +52 -0
  102. data/lib/textus/store/jobs/retention/sweep.rb +55 -0
  103. data/lib/textus/store/jobs/retention.rb +1 -43
  104. data/lib/textus/store/jobs/sweep.rb +2 -2
  105. data/lib/textus/store/{geometry.rb → layout.rb} +19 -3
  106. data/lib/textus/store.rb +53 -30
  107. data/lib/textus/surface/cli/runner.rb +8 -9
  108. data/lib/textus/surface/cli/verb/doctor.rb +3 -2
  109. data/lib/textus/surface/cli/verb/get.rb +5 -3
  110. data/lib/textus/surface/cli/verb/put.rb +5 -3
  111. data/lib/textus/surface/mcp/catalog.rb +26 -62
  112. data/lib/textus/surface/mcp/errors.rb +0 -10
  113. data/lib/textus/surface/mcp/projector.rb +20 -0
  114. data/lib/textus/surface/mcp/server.rb +20 -31
  115. data/lib/textus/{core → value}/duration.rb +1 -4
  116. data/lib/textus/value/envelope.rb +5 -4
  117. data/lib/textus/value/etag.rb +1 -1
  118. data/lib/textus/value/payload.rb +7 -0
  119. data/lib/textus/value/result.rb +36 -16
  120. data/lib/textus/verb_registry.rb +417 -0
  121. data/lib/textus/version.rb +1 -1
  122. data/lib/textus/workflow/loader.rb +1 -1
  123. data/lib/textus/workflow/runner.rb +10 -18
  124. data/lib/textus.rb +0 -64
  125. metadata +70 -70
  126. data/lib/textus/action/accept.rb +0 -46
  127. data/lib/textus/action/audit.rb +0 -94
  128. data/lib/textus/action/base.rb +0 -42
  129. data/lib/textus/action/blame.rb +0 -79
  130. data/lib/textus/action/boot.rb +0 -15
  131. data/lib/textus/action/data_mv.rb +0 -58
  132. data/lib/textus/action/deps.rb +0 -19
  133. data/lib/textus/action/doctor.rb +0 -17
  134. data/lib/textus/action/drain.rb +0 -31
  135. data/lib/textus/action/enqueue.rb +0 -37
  136. data/lib/textus/action/get.rb +0 -34
  137. data/lib/textus/action/ingest.rb +0 -199
  138. data/lib/textus/action/jobs.rb +0 -27
  139. data/lib/textus/action/key_delete.rb +0 -26
  140. data/lib/textus/action/key_delete_prefix.rb +0 -35
  141. data/lib/textus/action/key_mv.rb +0 -122
  142. data/lib/textus/action/key_mv_prefix.rb +0 -48
  143. data/lib/textus/action/list.rb +0 -28
  144. data/lib/textus/action/propose.rb +0 -42
  145. data/lib/textus/action/published.rb +0 -22
  146. data/lib/textus/action/pulse.rb +0 -49
  147. data/lib/textus/action/put.rb +0 -38
  148. data/lib/textus/action/rdeps.rb +0 -24
  149. data/lib/textus/action/reject.rb +0 -28
  150. data/lib/textus/action/rule_explain.rb +0 -81
  151. data/lib/textus/action/rule_lint.rb +0 -62
  152. data/lib/textus/action/rule_list.rb +0 -38
  153. data/lib/textus/action/schema_envelope.rb +0 -22
  154. data/lib/textus/action/uid.rb +0 -19
  155. data/lib/textus/action/where.rb +0 -21
  156. data/lib/textus/contract/arg.rb +0 -10
  157. data/lib/textus/contract/dsl.rb +0 -88
  158. data/lib/textus/contract/spec.rb +0 -25
  159. data/lib/textus/contract.rb +0 -12
  160. data/lib/textus/core/freshness/evaluator.rb +0 -150
  161. data/lib/textus/core/freshness.rb +0 -11
  162. data/lib/textus/core/retention/sweep.rb +0 -57
  163. data/lib/textus/core/retention.rb +0 -11
  164. data/lib/textus/format/shared.rb +0 -17
  165. data/lib/textus/gate/auth.rb +0 -212
  166. data/lib/textus/gate.rb +0 -92
  167. data/lib/textus/meta.rb +0 -54
  168. data/lib/textus/schemas.rb +0 -54
  169. data/lib/textus/store/compositor.rb +0 -34
  170. data/lib/textus/store/session.rb +0 -37
  171. data/lib/textus/surface/projector.rb +0 -27
  172. data/lib/textus/surface/role_scope.rb +0 -34
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 27ac7fc2922b98c97c7873939781a13330e0765b7f0521449aa4871c414fc201
4
- data.tar.gz: 055ed0684590d9ca8cf5e2ad49fc17ee05c70b8cbaa5f8b589b9bb9a3de99f0e
3
+ metadata.gz: 68d4176e50949378dcf62b98617c28bcf6f553c196b24f9263a97d8d5c6f382f
4
+ data.tar.gz: 7de0c2d9ee56d71e09cc0821d19b8ffcc00e3573fccc20ef3e610988ec5ff43c
5
5
  SHA512:
6
- metadata.gz: 437568a688e1ff1b6f9786d1965633e9dfa69be5b6f7450ffeeaecb3c0be5a6729e94851f2066a26e2d313f8d23335d2ff12b83ad5e73718b27e784887c9ceb4
7
- data.tar.gz: a8f4167af7c3ce315301996d3d3fedc13139f8eae4804b445ec4ed930da0d284ec96cab3467af1169ce8f6a64288d6f1d805bbfad60c0acb949f62e03ce1322d
6
+ metadata.gz: 34322a4bfd59c817bdbaec436caba5cd8a25a7896065e16633270eb22bed7db20af339f45f7ee8ed632732f3679469328939d45490e3a6ce3528f654b00feba9
7
+ data.tar.gz: 6c4f94a584d8334e7dc68e543852bb687fa662c6e6eb1334cadf9d70ff01576552f71d773d462f03f3c4f0955496fe094c167df090880f473d3e850469f4c3cd
data/CHANGELOG.md CHANGED
@@ -904,7 +904,7 @@ Protocol remains `textus/3`.
904
904
  `refresh`, `refresh_stale`, `schema`, `rules`). Session state (cursor,
905
905
  role, manifest_etag) held server-side. Manifest drift surfaces as
906
906
  `ContractDrift` (-32001); cursor expiry as `CursorExpired` (-32002).
907
- See [`docs/reference/mcp.md`](docs/reference/mcp.md) and [ADR 0015](docs/architecture/decisions/0015-agent-gate-mcp.md).
907
+ See [ADR 0015](docs/architecture/decisions/0015-agent-gate-mcp.md).
908
908
  - `examples/claude-plugin/.mcp.json` and migrated skills/commands/agents —
909
909
  zero `textus <verb>` shell strings remain in plugin markdown.
910
910
 
data/README.md CHANGED
@@ -39,7 +39,7 @@ flowchart LR
39
39
  end
40
40
 
41
41
  human -->|author| knowledge["knowledge<br/>(canon)"]
42
- agent -->|keep| notebook["notebook<br/>(workspace)"]
42
+ agent -->|keep| scratchpad["scratchpad<br/>(workspace)"]
43
43
  agent -->|propose| proposals["proposals<br/>(queue)"]
44
44
  automation -->|drain| artifacts["artifacts<br/>(machine)"]
45
45
  human -->|ingest| raw["raw<br/>(intake)"]
@@ -68,7 +68,7 @@ The point of those lanes is to **build context you can trust**. Place each lane
68
68
  LOW TRUST HIGH TRUST
69
69
  (unreviewed) (authoritative)
70
70
  ┌──────────────────────────┬───────────────────────────────┐
71
- DURABLE │ notebook │ knowledge ★ the goal │
71
+ DURABLE │ scratchpad │ knowledge ★ the goal │
72
72
  (kept) │ agent's working truth │ canon — a human authors │
73
73
  │ durable, but low-trust │ here · the context you ship │
74
74
  ├──────────────────────────┼───────────────────────────────┤
@@ -84,12 +84,12 @@ Without coordination, they overwrite each other and nothing remembers why. textu
84
84
 
85
85
  ```
86
86
  knowledge/ author only — who you are, what you decide, how you sound
87
- notebook/ keep only — agent's own durable lane (bytes climb to knowledge only via propose→accept)
87
+ scratchpad/ keep only — agent's own durable lane (bytes climb to knowledge only via propose→accept)
88
88
  proposals/ propose (agent+human) — proposals waiting on a human accept
89
89
  artifacts/ converge only — machine-maintained: computed outputs + external inputs
90
90
  ```
91
91
 
92
- An agent that tries to write directly into `knowledge/` gets `write_forbidden`. It writes to `proposals/` (to change authoritative content) or its own `notebook/` (for working memory). You accept the good proposals; textus promotes them, records the move, and audits both halves. Stable per-entry `uid:` means a reorganization doesn't break references. A monotonic audit cursor (`textus pulse --since=N`) means the next session — possibly a different agent, possibly a different model — picks up exactly where the last one left off.
92
+ An agent that tries to write directly into `knowledge/` gets `write_forbidden`. It writes to `proposals/` (to change authoritative content) or its own `scratchpad/` (for working memory). You accept the good proposals; textus promotes them, records the move, and audits both halves. Stable per-entry `uid:` means a reorganization doesn't break references. A monotonic audit cursor (`textus pulse --since=N`) means the next session — possibly a different agent, possibly a different model — picks up exactly where the last one left off.
93
93
 
94
94
  That's the load-bearing claim: **coordination is a protocol invariant, not a library convenience.**
95
95
 
@@ -156,7 +156,7 @@ roles:
156
156
 
157
157
  lanes:
158
158
  - { name: knowledge, kind: canon } # author — canonical truth
159
- - { name: notebook, kind: workspace } # keep — agent's own durable lane
159
+ - { name: scratchpad, kind: workspace } # keep — agent's own durable lane
160
160
  - { name: proposals, kind: queue } # propose — proposals awaiting accept
161
161
  - { name: artifacts, kind: machine } # converge — computed outputs + external inputs
162
162
  ```
@@ -170,7 +170,7 @@ lanes:
170
170
  .gitignore # generated — ignores .state/ and any tracked:false entries
171
171
  data/ # one dir per lane; kinds + capabilities are in the manifest above
172
172
  knowledge/ # e.g. identity (knowledge.identity.*), voice, decisions, notes
173
- notebook/
173
+ scratchpad/
174
174
  proposals/
175
175
  artifacts/ # machine lane: computed outputs + external inputs
176
176
  .state/ # disposable runtime state — gitignored, safe to delete (ADR 0038)
@@ -207,7 +207,7 @@ For a worked store — knowledge entries, a staged proposal, schemas, ERB templa
207
207
  - **Agent loop.** `textus boot` orients a fresh session; `textus pulse --since=N` is the per-turn heartbeat (changed entries, pending proposals, index etag for catalog drift detection). ([docs/how-to/agents-mcp.md](docs/how-to/agents-mcp.md))
208
208
  - **MCP surface.** The official `mcp` Ruby SDK drives the stdio JSON-RPC server; protocol version auto-negotiated up to `2025-11-25`. Wire textus into Claude Code, Cursor, or any MCP host in one config block.
209
209
  - **`textus doctor`.** Health checks across schemas, workflow registrations, keys, sentinels, and the audit log.
210
- - **`raw` lane and `ingest` verb.** Write-once intake lane for external URL bookmarks, files, and binary assets. Three source kinds (`url`/`file`/`asset`); daily key derivation; notebook stub per ingest. See "Intake and ingest" section below.
210
+ - **`raw` lane and `ingest` verb.** Write-once intake lane for external URL bookmarks, files, and binary assets. Three source kinds (`url`/`file`/`asset`); daily key derivation; scratchpad stub per ingest. See "Intake and ingest" section below.
211
211
 
212
212
  ## CLI and lanes
213
213
 
@@ -281,8 +281,8 @@ textus ingest url agentskills-io-brainstorming \
281
281
  # see what landed in the raw lane
282
282
  textus list --lane=raw
283
283
 
284
- # a notebook stub was created alongside — annotate it
285
- textus get notebook.notes.raw
284
+ # a scratchpad stub was created alongside — annotate it
285
+ textus get scratchpad.notes.raw
286
286
  ```
287
287
 
288
288
  Stale produced entries are re-materialised by `drain`, not by reads — `get` is a pure read (ADR 0089).
data/SPEC.md CHANGED
@@ -57,7 +57,7 @@ You **shape your own memory structure** inside `.textus/`. The protocol manages
57
57
  textus/4 names its concepts along six axes. Reviewers who internalize these can map any part of the spec to the right category:
58
58
 
59
59
  - **Actor** — who is interacting: roles such as `human`, `agent`, `automation`, each holding a set of capabilities (`propose`, `author`, `keep`, `converge`).
60
- - **Place** — where data lives: lanes such as `knowledge`, `notebook`, `raw`, `proposals`, `artifacts`.
60
+ - **Place** — where data lives: lanes such as `knowledge`, `scratchpad`, `raw`, `proposals`, `artifacts`.
61
61
  - **Thing** — what is stored: entries, fields, keys.
62
62
  - **Operation** — how you act on things: RPC and CLI verbs (`get`, `put`, `drain`, `serve`, `ingest`, …).
63
63
  - **Event** — what gets fired after an operation: pub-sub events (`:entry_written`, `:entry_produced`, `:entry_published`, …).
@@ -111,7 +111,7 @@ The root is `.textus/` at the project working directory. A typical tree:
111
111
  sentinels/ # byte-copied publish bookkeeping (see §5.3)
112
112
  data/ # ALL user content lives here
113
113
  knowledge/ # lane: knowledge (kind: canon — author-holders write)
114
- notebook/ # lane: notebook (kind: workspace — keep-holders write; agent's own durable lane)
114
+ scratchpad/ # lane: scratchpad (kind: workspace — keep-holders write; agent's own durable lane)
115
115
  proposals/ # lane: proposals (kind: queue — propose-holders write)
116
116
  artifacts/ # lane: artifacts (kind: machine — converge-holders write)
117
117
  raw/ # lane: raw (kind: raw — ingest-holders write; write-once)
@@ -149,7 +149,7 @@ roles:
149
149
  lanes:
150
150
  - name: knowledge
151
151
  kind: canon
152
- - name: notebook
152
+ - name: scratchpad
153
153
  kind: workspace
154
154
  owner: agent # optional, informational — agent's own lane
155
155
  desc: "agent's durable working memory; bytes climb to knowledge only via propose→accept"
@@ -241,7 +241,7 @@ Default scaffold — Setup-1 (roles `human=[author, propose, ingest]`, `agent=[p
241
241
  | Lane | `kind` | Required capability | Writable by (default) | Use case |
242
242
  |---|---|---|---|---|
243
243
  | `knowledge` | `canon` | `author` | `human` | Authored truth: identity, voice, decisions, network. |
244
- | `notebook` | `workspace` | `keep` | `agent` | Agent's own durable working memory. Bytes climb to `knowledge` only via propose→accept. |
244
+ | `scratchpad` | `workspace` | `keep` | `agent` | Agent's own durable working memory. Bytes climb to `knowledge` only via propose→accept. |
245
245
  | `proposals` | `queue` | `propose` | `agent`, `human` | Proposals awaiting human review via `textus accept`. |
246
246
  | `artifacts` | `machine` | `converge` | `automation` | Computed outputs produced by `drain` via the workflow DSL. |
247
247
  | `raw` | `raw` | `ingest` | `human`, `agent`, `automation` | Write-once external source material: URL bookmarks, files, binary assets. |
@@ -284,7 +284,7 @@ Every successful write records the resolved role and a wall-clock timestamp in `
284
284
 
285
285
  #### 5.1.1 Capabilities
286
286
 
287
- Roles declare **capabilities** — verbs from a closed four-element set. A
287
+ Roles declare **capabilities** — verbs from a closed five-element set. A
288
288
  manifest declares a `roles:` block mapping each role name to the capabilities
289
289
  it holds via `can:`:
290
290
 
@@ -296,7 +296,7 @@ roles:
296
296
  - { name: keeper, can: [keep] }
297
297
  ```
298
298
 
299
- Capability allow-list: `propose`, `author`, `keep`, `converge`. The mapping from
299
+ Capability allow-list: `propose`, `author`, `keep`, `converge`, `ingest`. The mapping from
300
300
  lane-kind to its required capability is a **bijection** (ADR 0091, which folded
301
301
  the former `quarantine` + `derived` kinds back into one `machine` kind — undoing
302
302
  the two-kind split of ADR 0090): each capability authorizes exactly one
@@ -308,8 +308,9 @@ lane-kind:
308
308
  | `keep` | `workspace` |
309
309
  | `propose` | `queue` |
310
310
  | `converge` | `machine` |
311
+ | `ingest` | `raw` |
311
312
 
312
- A manifest naming a folded capability — `ingest` or `build`, or the pre-0088
313
+ A manifest naming a folded capability — `build` or the pre-0088
313
314
  spelling `fetch` — in a `can:` list is rejected at load with a hint pointing to
314
315
  `converge` (ADR 0090, 0091, 0111).
315
316
 
@@ -425,7 +426,7 @@ The `raw` lane (`kind: raw`) is a write-once intake lane for external source mat
425
426
 
426
427
  **`access` field** — entries MAY carry `source.access: public | private` (field is `maintained_by: human`). Set `private` for sources not safe to reproduce publicly.
427
428
 
428
- **Notebook stub** — every ingest creates a `notebook.notes` stub with a backlink (`Ingested from raw.<key>`) so the agent or human can annotate the ingested material without touching the write-once record.
429
+ **Notebook stub** — every ingest creates a `scratchpad.notes` stub with a backlink (`Ingested from raw.<key>`) so the agent or human can annotate the ingested material without touching the write-once record.
429
430
 
430
431
  **Example — URL bookmark:**
431
432
 
@@ -689,7 +690,7 @@ Every successful CLI response (`--output=json`) is a single JSON envelope:
689
690
  "schema_ref": "person",
690
691
  "uid": "a1b2c3d4e5f60718",
691
692
  "sources": [
692
- "raw.2026.06.20.url-mcp-spec"
693
+ { "key": "raw.2026.06.20.url-mcp-spec", "etag": "sha256:1a2b…", "suspended": false }
693
694
  ],
694
695
  "stale": false,
695
696
  "stale_reason": null,
@@ -700,7 +701,7 @@ Every successful CLI response (`--output=json`) is a single JSON envelope:
700
701
  **Field rules:**
701
702
  - `protocol` MUST be the exact string `textus/4`.
702
703
  - `key` MUST be the canonical resolved key.
703
- - `lane` MUST be one of the lanes declared in the manifest (`knowledge`, `notebook`, `feeds`, `proposals`, `artifacts` in the default Setup-1 scaffold).
704
+ - `lane` MUST be one of the lanes declared in the manifest (`knowledge`, `scratchpad`, `proposals`, `artifacts`, `raw` in the default Setup-1 scaffold).
704
705
  - `path` MUST be an absolute filesystem path.
705
706
  - `format` MUST be one of `markdown`, `json`, `yaml`, `text` (§5.12). Absent envelopes are treated as `markdown` for back-compat.
706
707
  - `body` is the raw on-disk bytes as a UTF-8 string for every format.
@@ -708,7 +709,7 @@ Every successful CLI response (`--output=json`) is a single JSON envelope:
708
709
  - `etag` MUST be `sha256:<hex>` of the raw file bytes, computed identically for every format.
709
710
  - `schema_ref` MAY be `null` for entries in subtrees with `schema: null`.
710
711
  - `uid` is the stable Textus UID (§7) if the entry carries one, else `null`. Always present in the envelope.
711
- - `sources` is an array of raw-lane key strings. Present only when non-empty. Each string starts with `raw.`.
712
+ - `sources` is an array of source objects. Each object has `key` (the referenced entry's key), `etag` (sha256 snapshot taken at write time, or absent when no snapshot exists), and `suspended` (`true` when the referenced entry's current on-disk etag differs from the stored snapshot — the source changed after this entry was last written). Present only when non-empty.
712
713
  - `stale` is `true` when the entry's `source.ttl` has elapsed and the entry has not yet been re-materialised; `false` otherwise. Only populated for produced entries with a declared `ttl`; always `false` for other entries.
713
714
  - `stale_reason` is a short human-readable string describing why the entry is stale (e.g. `"ttl_exceeded"`, `"never_fetched"`), or `null` when `stale` is `false`.
714
715
  - `fetching` is `true` when a background re-pull is in flight for this entry; `false` otherwise. Callers observing `stale: true, fetching: true` SHOULD retry after a short delay.
@@ -791,7 +792,7 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
791
792
 
792
793
  `read_verbs` is derived from the MCP verb catalog — the verbs the agent can actually call over its transport — so it lists the read/discovery verbs (`schema_show` for an entry's field shape, `rule_explain` for its retention/guard policy, and the graph reads `where`/`deps`/`rdeps`, ADR 0060) and never the CLI-only `audit`/`doctor`, nor `freshness` (the Ruby-only internal lifecycle scan, ADR 0085) (ADR 0056). An agent learns an entry's `_meta` shape by calling the `schema_show` verb before a `put`/`propose`, not by shelling out to a CLI. The graph reads `deps`/`rdeps` return a structured `{key, deps}`/`{key, rdeps}` envelope on every surface (CLI, Ruby, MCP) — a hash, not a bare array, consistent with the other structured read responses such as `where` (ADR 0060 amendment).
793
794
 
794
- The agent's MCP write surface includes the single-key `key_delete` and `key_mv` tools alongside their bulk `key_delete_prefix`/`key_mv_prefix` cousins (ADR 0060 amendment; the single-key tools were renamed from `delete`/`mv` to share the `key_` family stem in ADR 0082, which also removed the `migrate` YAML-plan orchestrator — its `zone_mv`/`key_mv_prefix`/`key_delete_prefix` ops remain individually callable). All of these apply by default; `dry_run: true` is a uniform opt-in preview that returns a Plan without mutating (ADR 0071 verbs are actions, dry-run is opt-in on every surface). Single-key `key_delete` additionally accepts an optional `if_etag` optimistic-concurrency check. The blast-radius reads (`where`/`deps`/`rdeps`) remain on MCP so an agent can look before it leaps. The promotion verbs `accept` and `reject` are also on MCP (ADR 0072): they are gated by the `author_held` capability floor, not by transport absence — a default-`agent` connection is refused, while a connection launched as a role holding `author` (`--as`/`TEXTUS_ROLE`/`.textus/role`, resolved once at launch per ADR 0040) can promote, closing the propose→accept loop over one transport. `drain` is also on MCP (ADR 0076, ADR 0087, ADR 0110): it is caller-agnostic and its produce jobs self-elevate — materialization always runs as the manifest's `converge`-capable actor regardless of the calling role, granting no authority over content (materialization is a pure, idempotent function of already-accepted canon, ADR 0070); the destructive retention sweep runs as the caller. Each produce job self-acquires the single-writer build lock, so a concurrent CLI, reactive, or background pass cannot collide with an MCP-triggered one — a held lock is a graceful soft-miss (ADR 0110).
795
+ The agent's MCP write surface includes the single-key `key_delete` and `key_mv` tools alongside their bulk `key_delete_prefix`/`key_mv_prefix` cousins (ADR 0060 amendment; the single-key tools were renamed from `delete`/`mv` to share the `key_` family stem in ADR 0082, which also removed the `migrate` YAML-plan orchestrator — its `data_mv`/`key_mv_prefix`/`key_delete_prefix` ops remain individually callable). The structural mutation verbs (`key_mv`, `key_mv_prefix`, `key_delete_prefix`, `data_mv`) accept `dry_run: true` as an opt-in preview that returns a Plan without mutating (ADR 0071). `drain` does not support `dry_run` (it is async-only, ADR 0110). Single-key `key_delete` additionally accepts an optional `if_etag` optimistic-concurrency check. The blast-radius reads (`where`/`deps`/`rdeps`) remain on MCP so an agent can look before it leaps. The promotion verbs `accept` and `reject` are also on MCP (ADR 0072): they are gated by the `author_held` capability floor, not by transport absence — a default-`agent` connection is refused, while a connection launched as a role holding `author` (`--as`/`TEXTUS_ROLE`/`.textus/role`, resolved once at launch per ADR 0040) can promote, closing the propose→accept loop over one transport. `drain` is also on MCP (ADR 0076, ADR 0087, ADR 0110): it is caller-agnostic and its produce jobs self-elevate — materialization always runs as the manifest's `converge`-capable actor regardless of the calling role, granting no authority over content (materialization is a pure, idempotent function of already-accepted canon, ADR 0070); the destructive retention sweep runs as the caller. Each produce job self-acquires the single-writer build lock, so a concurrent CLI, reactive, or background pass cannot collide with an MCP-triggered one — a held lock is a graceful soft-miss (ADR 0110).
795
796
 
796
797
  `latest_seq` is the current high-water mark of the audit log; agents should use it as the starting cursor for `pulse`.
797
798
 
@@ -819,7 +820,7 @@ The agent's MCP write surface includes the single-key `key_delete` and `key_mv`
819
820
 
820
821
  `if_etag` is optional on both `put` and `key_delete`. When provided, the write fails with `etag_mismatch` if the on-disk file's etag differs. When omitted, the write is unconditional (last-writer-wins).
821
822
 
822
- The lifecycle scan behind `pulse.stale`/`pulse.next_due_at` reports, per entry, one verdict (`fresh`, `expired`, or `no_policy`) against each intake entry's `source.ttl`. ADR 0085 removed the standalone `freshness` verb that used to render these rows; the scan is now Ruby-only (consumed by `pulse` and the hook context), and human drill-down into a single entry's verdict is `textus get KEY` (carries `stale`/`stale_reason`) plus `textus rule_explain KEY` (the `source.ttl` and retention policy). `textus drain` enqueues the convergence jobs — produce every in-scope derived entry, re-pull every stale intake entry, and a retention sweep — then drains the queue to empty (§5.11). Convergence is async-only (ADR 0110): there is no `--dry-run`.
823
+ The lifecycle scan runs per-entry at `get` time each `get` response carries `stale`/`stale_reason` when the entry has a TTL rule. ADR 0085 removed the standalone `freshness` verb; human drill-down into a single entry's verdict is `textus get KEY` (carries `stale`/`stale_reason`) plus `textus rule_explain KEY` (the `source.ttl` and retention policy). `pulse` does not include a `stale` list. `textus drain` enqueues the convergence jobs — produce every in-scope derived entry, re-pull every stale intake entry, and a retention sweep — then drains the queue to empty (§5.11). Convergence is async-only (ADR 0110): there is no `--dry-run`.
823
824
 
824
825
  `textus accept K --as=human` promotes a pending entry into its target lane: it copies the patch body into the target key, deletes the pending entry, and writes one audit line per side (§audit). Only a role holding the `author` capability (the trust anchor — `human` by default) may invoke `accept`.
825
826
 
@@ -41,7 +41,7 @@ surfaces/cli/ CLI command generation from contracts
41
41
  sources.rb CLI-only input acquisition (stdin parsing, file sourcing, coercion)
42
42
  surfaces/mcp/ MCP server — stdio JSON-RPC 2.0, tools derived from contracts
43
43
  surfaces/role_scope.rb
44
- (Store#as(role)) — holds (container, role, dry_run, correlation_id);
44
+ (Store#with_role(role)) — holds (container, role, dry_run, correlation_id);
45
45
  all verb methods injected via define_method in textus.rb
46
46
  ```
47
47
 
@@ -214,7 +214,7 @@ Both are built from a `Container` via named constructors — `Writer.from(contai
214
214
 
215
215
  `Action::Get` is a **pure read** — it resolves the path, reads bytes, parses the envelope, and annotates a freshness verdict. It never ingests and never mutates.
216
216
 
217
- 1. CLI/MCP surface calls `store.as(role).get(key)`.
217
+ 1. CLI/MCP surface calls `store.with_role(role).get(key)`.
218
218
  2. `Gate#dispatch` runs Auth → `Action::Get#call`.
219
219
  3. `Get` resolves path via `manifest.resolver`, reads bytes via `file_store`, parses the envelope, annotates `freshness` based on retention-rule TTL (if any).
220
220
 
@@ -222,7 +222,7 @@ Staleness is age-based (retention-rule TTL vs file mtime). A stale entry is retu
222
222
 
223
223
  ## Write path (`store.put(key, ...)`)
224
224
 
225
- 1. CLI/MCP surface calls `store.as(role).put(key, meta:, body:)`.
225
+ 1. CLI/MCP surface calls `store.with_role(role).put(key, meta:, body:)`.
226
226
  2. `Gate#dispatch` runs Auth → `Action::Put#call`.
227
227
  3. `Put` validates, resolves manifest entry, delegates to `Envelope::Writer#put` (serialize → schema-validate → etag-check → FileStore#write → AuditLog#append).
228
228
  4. `WriteVerb#cascade_to_rdeps` enqueues `materialize` jobs for any entries with publish_tree that depend on the written key.
@@ -24,7 +24,7 @@ Recommended top-level layout — the spec allows alternatives, but this is what
24
24
  templates/ # ERB templates for publish rendering
25
25
  data/
26
26
  knowledge/ # authored truth: identity, voice, decisions — author-holders write
27
- notebook/ # agent's own durable lane (workspace) — keep-holders write
27
+ scratchpad/ # agent's own durable lane (workspace) — keep-holders write
28
28
  proposals/ # AI proposals awaiting accept (propose)
29
29
  artifacts/ # computed outputs produced by drain — never edit by hand
30
30
  raw/ # write-once external source material (ingest)
@@ -126,7 +126,10 @@ For multi-writer environments, **always pass `if_etag`** on `put`. The gem treat
126
126
  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).
127
127
 
128
128
  - **`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(lane)`, `manifest.resolver.resolve(key)`, `manifest.rules.for(key)`.
129
- - **Use cases are plain `(container:, call:)` classes.** Each is a single class under `lib/textus/action/` with `def initialize(...)` (args from contract) and a `#call(container:, call:)` method; verbs are looked up in the static `Textus::Action::VERBS` table. `Container` is a `Dry::Struct` record bundling the wired ports + manifest (`manifest`, `file_store`, `schemas`, `root`, `audit_log`, `workflows`, `gate`); `Call` is the immutable per-invocation value (`role`, `correlation_id`, `now`, `dry_run`). All dispatch routes through `Gate#dispatch(spec:, inputs:, role:, correlation_id:, session:)` the single seam that binds inputs via `Gate::Binder`, checks auth, and invokes the action. `store.as(role)` returns a `RoleScope` that forwards verbs to `Gate::dispatch`.
129
+ - **`Store` is the single dispatch module.** `Store` holds the `Container`, the active role, session state (cursor, propose_lane, contract_etag), and dispatches verbs through `method_missing`. Each verb call (`store.get(key: ...)`, `store.put(key:, meta:, body:)`) goes through `VerbRegistry`, binds inputs via `Dispatch::Binder`, routes through `Dispatch::Pipeline` (middleware chain: auth, cascade, audit), and reaches the handler. `Store#with_role(role)` returns a new `Store` bound to that role.
130
+ - **`Container` is a `Data.define` composition** of `Infrastructure` (`file_store`, `schemas`, `audit_log`, `job_store`, `geometry`) and `Coordination` (`manifest`, `workflows`, `pipeline`). Built in a single pass — no circular dependencies or lazy proxies.
131
+ - **Dispatch lives in `lib/textus/dispatch/`**: `Dispatch.dispatch` orchestrates verb dispatch, `Dispatch::Pipeline` runs middleware then invokes the handler, `Dispatch::Binder` handles input binding (wire format, CLI flags, session defaults). Auth predicates moved to `Manifest::Policy::Predicates`.
132
+ - **Handlers receive `container:`** and access I/O via `@container.pipeline.read/write/delete/move` and manifest config via `@container.manifest`.
130
133
  - **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(...)`).
131
134
 
132
135
  The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/4`) are unchanged.
data/lib/textus/boot.rb CHANGED
@@ -57,12 +57,8 @@ module Textus
57
57
  { "name" => "pulse" },
58
58
  ].freeze
59
59
 
60
- # verb token => contract.summary, for every Dispatcher verb that carries a
61
- # contract. The single source for a verb's one-line summary (ADR 0039).
62
60
  def self.contract_summaries
63
- Textus::Action::VERBS.values
64
- .select { |k| k.respond_to?(:contract?) && k.contract? }
65
- .to_h { |k| [k.contract.verb.to_s, k.contract.summary] }
61
+ Textus::VerbRegistry.registered.to_h { |s| [s.verb.to_s, s.summary] }
66
62
  end
67
63
 
68
64
  # Build the CLI verb catalog: each summary is derived from its contract when
@@ -76,69 +72,70 @@ module Textus
76
72
  end
77
73
  end
78
74
 
79
- def self.agent_quickstart(manifest, audit_log)
80
- agent_role = manifest.policy.proposer_role
75
+ def self.build(container:)
76
+ etag = Textus::Value::Etag.for_contract(container.root)
77
+ latest_seq = container.audit_log.latest_seq
78
+ artifact = read_artifact_content(container, "artifacts.boot")
79
+ context = read_boot_context(container)
80
+
81
+ # Prefer pre-computed artifact (drain computes, boot reads).
82
+ # Fall back to inline manifest projection for stores that have not yet
83
+ # run drain (test fixtures, fresh inits).
84
+ stable = artifact || inline_boot_content(container.manifest, latest_seq)
85
+
86
+ if stable["agent_quickstart"]
87
+ stable = stable.merge(
88
+ "agent_quickstart" => stable["agent_quickstart"].merge("latest_seq" => latest_seq),
89
+ )
90
+ end
91
+
92
+ payload = {
93
+ "protocol" => PROTOCOL_ID,
94
+ "store_root" => container.root,
95
+ "contract_etag" => etag,
96
+ }.merge(stable)
97
+ payload["context"] = context if context
98
+ payload
99
+ end
81
100
 
82
- writable_lanes = manifest.data.declared_lane_kinds.keys.each_with_object([]) do |lane_name, acc|
101
+ def self.inline_boot_content(manifest, _latest_seq)
102
+ agent_role = manifest.policy.proposer_role
103
+ writable_lanes = manifest.data.declared_lane_kinds.keys.each_with_object([]) do |ln, acc|
83
104
  next unless agent_role
84
105
 
85
- verb = manifest.policy.verb_for_lane(lane_name)
106
+ verb = manifest.policy.verb_for_lane(ln)
86
107
  writers = manifest.policy.roles_with_capability(verb)
87
- acc << lane_name if writers.include?(agent_role)
108
+ acc << ln if writers.include?(agent_role)
88
109
  end
89
110
 
90
- propose_lane = manifest.policy.propose_lane_for(agent_role)
91
-
92
111
  {
93
- "read_verbs" => Textus::Surface::MCP::Catalog.read_verbs,
94
- "write_verbs" => agent_role ? Textus::Surface::MCP::Catalog.write_verbs : [],
95
- "writable_lanes" => writable_lanes,
96
- "propose_lane" => propose_lane,
97
- "latest_seq" => audit_log.latest_seq,
98
- }
99
- end
100
-
101
- def self.recipes(manifest)
102
- queue = manifest.policy.queue_lane
103
- feeds = lane_label(manifest, :machine, "the machine lane")
104
- {
105
- "read" => {
106
- "purpose" => "find and read an entry",
107
- "steps" => [
108
- "list (lane:, prefix:) — discover keys without reading bodies",
109
- "get KEY — returns the entry envelope",
110
- ],
111
- },
112
- "write" => {
113
- "purpose" => "create or update an entry",
114
- "steps" => [
115
- "schema KEY — learn the _meta field shape (required, optional, field types) before writing",
116
- "assemble an envelope: { _meta: {…}, body: \"…\" }",
117
- "put KEY — persist it (role-gated); pass if_etag to guard a concurrent edit",
118
- ],
119
- },
120
- "propose" => {
121
- "purpose" => "agent suggests a change for human review",
122
- "agent_steps" => [
123
- "propose KEY — writes the change into the #{queue} lane for review",
124
- ],
125
- "human_steps" => [
126
- "accept #{queue}.KEY — promotes the proposal into its target lane",
127
- ],
128
- },
129
- "drain" => {
130
- "purpose" => "keep the machine-maintained lanes fresh — re-pull stale intake entries from their declared source",
131
- "steps" => [
132
- "pulse — its `stale` list names entries past their ttl",
133
- "drain (lane: #{feeds}) — re-pull the stale entries",
134
- ],
112
+ "lanes" => lanes_for(manifest),
113
+ "agent_quickstart" => {
114
+ "read_verbs" => Textus::Surface::MCP::Catalog.read_verbs,
115
+ "write_verbs" => agent_role ? Textus::Surface::MCP::Catalog.write_verbs : [],
116
+ "writable_lanes" => writable_lanes,
117
+ "propose_lane" => manifest.policy.propose_lane_for(agent_role),
135
118
  },
119
+ "agent_protocol" => agent_protocol(manifest),
136
120
  }
137
121
  end
138
122
 
139
123
  def self.agent_protocol(manifest)
124
+ queue = manifest.policy.queue_lane
125
+ feeds = lane_label(manifest, :machine, "the machine lane")
140
126
  AGENT_PROTOCOL_TEMPLATE.merge(
141
- "recipes" => recipes(manifest),
127
+ "recipes" => {
128
+ "read" => { "purpose" => "find and read an entry",
129
+ "steps" => ["list (lane:, prefix:) — discover keys", "get KEY — returns the entry envelope"] },
130
+ "write" => { "purpose" => "create or update an entry",
131
+ "steps" => ["schema KEY — learn field shape", "put KEY — persist it (role-gated)"] },
132
+ "propose" => { "purpose" => "agent suggests a change for human review",
133
+ "agent_steps" => ["propose KEY — writes to #{queue} lane"],
134
+ "human_steps" => ["accept #{queue}.KEY — promotes to target lane"] },
135
+ "drain" => { "purpose" => "keep machine lanes fresh",
136
+ "steps" => ["pulse — stale list names overdue entries",
137
+ "drain (lane: #{feeds}) — re-pull stale entries"] },
138
+ },
142
139
  "role_resolution" => {
143
140
  "summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, " \
144
141
  "then a transport default ('human' for CLI, 'agent' for MCP)",
@@ -148,28 +145,23 @@ module Textus
148
145
  )
149
146
  end
150
147
 
151
- def self.build(container:)
152
- manifest = container.manifest
153
- etag = Textus::Value::Etag.for_contract(container.root)
154
-
155
- {
156
- "protocol" => PROTOCOL_ID,
157
- "store_root" => container.root,
158
- "contract_etag" => etag,
159
- "lanes" => lanes_for(manifest),
160
- "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
161
- "orientation" => read_artifact_content(container, "artifacts.config.orientation"),
162
- "context" => read_boot_context(container),
163
- "agent_protocol" => agent_protocol(manifest),
164
- }.compact
148
+ def self.lanes_for(manifest)
149
+ manifest.data.declared_lane_kinds.keys.map do |name|
150
+ verb = manifest.policy.verb_for_lane(name)
151
+ row = { "name" => name, "writers" => manifest.policy.roles_with_capability(verb) }
152
+ kind = manifest.policy.declared_kind(name)
153
+ row["kind"] = kind.to_s if kind
154
+ purpose = manifest.data.lane_descs[name]
155
+ row["purpose"] = purpose if purpose && !purpose.empty?
156
+ row
157
+ end
165
158
  end
166
159
 
167
160
  def self.read_artifact_content(container, key)
168
161
  res = container.manifest.resolver.resolve(key)
169
162
  return nil unless res.path && File.exist?(res.path)
170
163
 
171
- call = Textus::Value::Call.build(role: Textus::Value::Role::DEFAULT)
172
- env = Textus::Action::Get.call(container: container, call: call, key: key)
164
+ env = Textus::Store::Entry::Reader.from(container: container).read(key)
173
165
  env&.content
174
166
  rescue Textus::Error
175
167
  nil
@@ -179,24 +171,11 @@ module Textus
179
171
  res = container.manifest.resolver.resolve("knowledge.boot")
180
172
  return nil unless res.path && File.exist?(res.path)
181
173
 
182
- call = Textus::Value::Call.build(role: Textus::Value::Role::DEFAULT)
183
- env = Textus::Action::Get.call(container: container, call: call, key: "knowledge.boot")
174
+ env = Textus::Store::Entry::Reader.from(container: container).read("knowledge.boot")
184
175
  body = env&.body&.strip
185
176
  body.nil? || body.empty? ? nil : body
186
177
  rescue Textus::Error
187
178
  nil
188
179
  end
189
-
190
- def self.lanes_for(manifest)
191
- manifest.data.declared_lane_kinds.keys.map do |name|
192
- verb = manifest.policy.verb_for_lane(name)
193
- row = { "name" => name, "writers" => manifest.policy.roles_with_capability(verb) }
194
- kind = manifest.policy.declared_kind(name)
195
- row["kind"] = kind.to_s if kind
196
- purpose = manifest.data.lane_descs[name]
197
- row["purpose"] = purpose if purpose && !purpose.empty?
198
- row
199
- end
200
- end
201
180
  end
202
181
  end
@@ -1,8 +1,5 @@
1
1
  module Textus
2
- class Gate
3
- # Raised when a required arg is absent from the bound input. Surface
4
- # adapters translate this to their native error (MCP ToolError, CLI
5
- # UsageError); a direct Ruby call lets it surface as-is.
2
+ module Dispatch
6
3
  class MissingArgs < Textus::Error
7
4
  attr_reader :spec, :missing
8
5
 
@@ -13,21 +10,22 @@ module Textus
13
10
  end
14
11
  end
15
12
 
16
- # Validates and resolves a by-name inputs hash against a contract spec.
17
- # Returns a flat hash with defaults and session_defaults filled in.
18
- # Every caller receives the same shape — no positional/kwarg split.
19
13
  module Binder
14
+ Pending = Data.define(:spec, :inputs)
15
+
20
16
  module_function
21
17
 
22
- def bind(spec, inputs, session: nil)
18
+ def command(spec, inputs)
19
+ Pending.new(spec: spec, inputs: inputs)
20
+ end
21
+
22
+ def bind(spec, inputs)
23
23
  missing = spec.required_args.reject { |a| inputs.key?(a.name) }
24
24
  raise MissingArgs.new(spec, missing) unless missing.empty?
25
25
 
26
26
  spec.args.each_with_object({}) do |a, h|
27
27
  if inputs.key?(a.name)
28
28
  h[a.name] = inputs[a.name]
29
- elsif a.session_default && session
30
- h[a.name] = session.public_send(a.session_default)
31
29
  elsif !a.default.nil?
32
30
  h[a.name] = a.default
33
31
  end
@@ -0,0 +1,63 @@
1
+ module Textus
2
+ module Dispatch
3
+ module Contracts
4
+ GetEntry = Data.define(:key)
5
+
6
+ PutEntry = Data.define(:key, :meta, :body, :content, :if_etag)
7
+
8
+ ListKeys = Data.define(:prefix, :lane, :q, :schema)
9
+
10
+ DeleteKey = Data.define(:key, :if_etag)
11
+
12
+ MoveKey = Data.define(:old_key, :new_key, :if_etag, :dry_run)
13
+
14
+ ProposeEntry = Data.define(:key, :meta, :body, :content)
15
+
16
+ AcceptProposal = Data.define(:pending_key)
17
+
18
+ RejectProposal = Data.define(:pending_key)
19
+
20
+ EnqueueJob = Data.define(:type, :args)
21
+
22
+ AuditEntries = Data.define(:key, :lane, :role, :verb, :since, :seq_since, :correlation_id, :limit)
23
+
24
+ PulseEntries = Data.define(:since)
25
+
26
+ BlameEntry = Data.define(:key, :limit)
27
+
28
+ WhereEntry = Data.define(:key)
29
+
30
+ UidEntry = Data.define(:key)
31
+
32
+ DepsEntry = Data.define(:key)
33
+
34
+ RdepsEntry = Data.define(:key)
35
+
36
+ BootStore = Data.define
37
+
38
+ DoctorStore = Data.define(:checks)
39
+
40
+ PublishedEntries = Data.define
41
+
42
+ RuleExplain = Data.define(:key, :detail)
43
+
44
+ RuleList = Data.define
45
+
46
+ SchemaEnvelope = Data.define(:key)
47
+
48
+ DrainStore = Data.define(:prefix, :lane)
49
+
50
+ IngestEntry = Data.define(:kind, :slug, :url, :path, :lane, :label)
51
+
52
+ JobsAction = Data.define(:state, :action, :job_id)
53
+
54
+ RuleLint = Data.define(:candidate_yaml)
55
+
56
+ DataMv = Data.define(:from, :to, :dry_run)
57
+
58
+ KeyMvPrefix = Data.define(:from_prefix, :to_prefix, :dry_run)
59
+
60
+ KeyDeletePrefix = Data.define(:prefix, :dry_run)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ module Dispatch
3
+ class HandlerRegistry
4
+ def initialize
5
+ @handlers = {}
6
+ end
7
+
8
+ def register(command_class, handler)
9
+ @handlers[command_class] = handler
10
+ end
11
+
12
+ def for(command_class)
13
+ @handlers[command_class] || raise("no handler registered for #{command_class}")
14
+ end
15
+
16
+ def registered?(command_class)
17
+ @handlers.key?(command_class)
18
+ end
19
+ end
20
+ end
21
+ end