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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +111 -67
- data/CHANGELOG.md +76 -0
- data/README.md +55 -13
- data/SPEC.md +75 -38
- data/docs/conventions.md +4 -4
- data/lib/textus/boot.rb +14 -10
- data/lib/textus/builder/pipeline.rb +13 -12
- data/lib/textus/call.rb +28 -0
- data/lib/textus/cli/verb/audit.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +1 -1
- data/lib/textus/cli/verb/build.rb +2 -2
- data/lib/textus/cli/verb/doctor.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +2 -2
- data/lib/textus/cli/verb/put.rb +3 -3
- data/lib/textus/cli/verb.rb +6 -6
- data/lib/textus/cli.rb +0 -7
- data/lib/textus/container.rb +23 -0
- data/lib/textus/dispatcher.rb +49 -0
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +10 -8
- data/lib/textus/doctor/check.rb +12 -5
- data/lib/textus/doctor.rb +7 -7
- data/lib/textus/domain/authorizer.rb +2 -2
- data/lib/textus/domain/sentinel.rb +9 -65
- data/lib/textus/domain/staleness/generator_check.rb +46 -26
- data/lib/textus/domain/staleness/intake_check.rb +18 -10
- data/lib/textus/domain/staleness.rb +3 -3
- data/lib/textus/{application/envelope → envelope/io}/reader.rb +2 -2
- data/lib/textus/{application/envelope → envelope/io}/writer.rb +11 -11
- data/lib/textus/hooks/context.rb +30 -13
- data/lib/textus/hooks/rpc_registry.rb +1 -1
- data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
- data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
- data/lib/textus/maintenance/migrate.rb +51 -0
- data/lib/textus/maintenance/rule_lint.rb +56 -0
- data/lib/textus/maintenance/zone_mv.rb +51 -0
- data/lib/textus/maintenance.rb +15 -0
- data/lib/textus/manifest/data.rb +4 -3
- data/lib/textus/manifest/entry/base.rb +38 -18
- data/lib/textus/manifest/entry/derived.rb +6 -6
- data/lib/textus/manifest/entry/nested.rb +7 -9
- data/lib/textus/manifest/entry/parser.rb +2 -2
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
- data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +2 -2
- data/lib/textus/manifest/entry.rb +0 -5
- data/lib/textus/manifest.rb +1 -6
- data/lib/textus/mcp/server.rb +1 -2
- data/lib/textus/mcp/session.rb +10 -1
- data/lib/textus/mcp/tools.rb +2 -2
- data/lib/textus/mcp.rb +1 -1
- data/lib/textus/{infra → ports}/audit_log.rb +1 -1
- data/lib/textus/{infra → ports}/audit_subscriber.rb +2 -2
- data/lib/textus/{infra → ports}/build_lock.rb +1 -1
- data/lib/textus/{infra → ports}/clock.rb +1 -1
- data/lib/textus/{infra → ports}/publisher.rb +6 -6
- data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
- data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
- data/lib/textus/ports/sentinel_store.rb +67 -0
- data/lib/textus/ports/storage/file_stat.rb +19 -0
- data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
- data/lib/textus/projection.rb +91 -0
- data/lib/textus/read/audit.rb +111 -0
- data/lib/textus/read/blame.rb +81 -0
- data/lib/textus/read/boot.rb +18 -0
- data/lib/textus/read/deps.rb +24 -0
- data/lib/textus/read/doctor.rb +19 -0
- data/lib/textus/read/freshness.rb +101 -0
- data/lib/textus/read/get.rb +66 -0
- data/lib/textus/read/get_or_refresh.rb +69 -0
- data/lib/textus/read/list.rb +15 -0
- data/lib/textus/read/policy_explain.rb +37 -0
- data/lib/textus/read/published.rb +15 -0
- data/lib/textus/read/pulse.rb +89 -0
- data/lib/textus/read/rdeps.rb +25 -0
- data/lib/textus/read/schema_envelope.rb +16 -0
- data/lib/textus/read/stale.rb +17 -0
- data/lib/textus/read/uid.rb +20 -0
- data/lib/textus/read/validate_all.rb +22 -0
- data/lib/textus/read/validator.rb +84 -0
- data/lib/textus/read/where.rb +16 -0
- data/lib/textus/role_scope.rb +49 -0
- data/lib/textus/schema/tools.rb +3 -3
- data/lib/textus/store.rb +16 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +86 -0
- data/lib/textus/write/authority_gate.rb +24 -0
- data/lib/textus/write/delete.rb +54 -0
- data/lib/textus/write/materializer.rb +48 -0
- data/lib/textus/write/mv.rb +123 -0
- data/lib/textus/write/publish.rb +66 -0
- data/lib/textus/write/put.rb +59 -0
- data/lib/textus/write/refresh_all.rb +44 -0
- data/lib/textus/write/refresh_orchestrator.rb +102 -0
- data/lib/textus/write/refresh_worker.rb +138 -0
- data/lib/textus/write/reject.rb +54 -0
- data/lib/textus.rb +1 -2
- metadata +54 -50
- data/lib/textus/application/caps.rb +0 -49
- data/lib/textus/application/context.rb +0 -34
- data/lib/textus/application/maintenance/key_delete_prefix.rb +0 -44
- data/lib/textus/application/maintenance/key_mv_prefix.rb +0 -57
- data/lib/textus/application/maintenance/migrate.rb +0 -59
- data/lib/textus/application/maintenance/rule_lint.rb +0 -65
- data/lib/textus/application/maintenance/zone_mv.rb +0 -60
- data/lib/textus/application/maintenance.rb +0 -17
- data/lib/textus/application/projection.rb +0 -93
- data/lib/textus/application/read/audit.rb +0 -106
- data/lib/textus/application/read/blame.rb +0 -91
- data/lib/textus/application/read/deps.rb +0 -34
- data/lib/textus/application/read/freshness.rb +0 -110
- data/lib/textus/application/read/get.rb +0 -75
- data/lib/textus/application/read/get_or_refresh.rb +0 -63
- data/lib/textus/application/read/list.rb +0 -25
- data/lib/textus/application/read/policy_explain.rb +0 -47
- data/lib/textus/application/read/published.rb +0 -25
- data/lib/textus/application/read/pulse.rb +0 -101
- data/lib/textus/application/read/rdeps.rb +0 -35
- data/lib/textus/application/read/schema_envelope.rb +0 -26
- data/lib/textus/application/read/stale.rb +0 -23
- data/lib/textus/application/read/uid.rb +0 -30
- data/lib/textus/application/read/validate_all.rb +0 -32
- data/lib/textus/application/read/validator.rb +0 -86
- data/lib/textus/application/read/where.rb +0 -26
- data/lib/textus/application/use_case.rb +0 -22
- data/lib/textus/application/write/accept.rb +0 -102
- data/lib/textus/application/write/authority_gate.rb +0 -26
- data/lib/textus/application/write/delete.rb +0 -45
- data/lib/textus/application/write/materializer.rb +0 -49
- data/lib/textus/application/write/mv.rb +0 -118
- data/lib/textus/application/write/publish.rb +0 -96
- data/lib/textus/application/write/put.rb +0 -49
- data/lib/textus/application/write/refresh_all.rb +0 -63
- data/lib/textus/application/write/refresh_orchestrator.rb +0 -102
- data/lib/textus/application/write/refresh_worker.rb +0 -134
- data/lib/textus/application/write/reject.rb +0 -62
- 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
|
|
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
|
-
|
|
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`, `
|
|
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 `(
|
|
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.
|
|
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) { |
|
|
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 |
|
|
513
|
-
| `:transform_rows` | rpc |
|
|
514
|
-
| `:validate` | rpc |
|
|
515
|
-
| `:entry_put` | pubsub |
|
|
516
|
-
| `:entry_deleted` | pubsub |
|
|
517
|
-
| `:entry_refreshed` | pubsub |
|
|
518
|
-
| `:build_completed` | pubsub |
|
|
519
|
-
| `:proposal_accepted` | pubsub |
|
|
520
|
-
| `:file_published` | pubsub |
|
|
521
|
-
| `:entry_renamed` | pubsub |
|
|
522
|
-
| `:proposal_rejected` | pubsub |
|
|
523
|
-
| `:store_loaded` | pubsub |
|
|
524
|
-
| `:refresh_started` | pubsub |
|
|
525
|
-
| `:refresh_failed` | pubsub |
|
|
526
|
-
| `:refresh_backgrounded` | pubsub |
|
|
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** —
|
|
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 `
|
|
903
|
-
- **Writes** flow through `
|
|
904
|
-
- `
|
|
905
|
-
- `Textus::
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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-
|
|
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
|
|
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)`.
|
|
134
|
-
- **
|
|
135
|
-
- **Write path is split**: `
|
|
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,
|
|
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" =>
|
|
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.
|
|
154
|
-
manifest =
|
|
153
|
+
def self.build(container:)
|
|
154
|
+
manifest = container.manifest
|
|
155
155
|
{
|
|
156
156
|
"protocol" => PROTOCOL_ID,
|
|
157
|
-
"store_root" =>
|
|
157
|
+
"store_root" => container.root,
|
|
158
158
|
"zones" => zones_for(manifest),
|
|
159
159
|
"entries" => entries_for(manifest),
|
|
160
|
-
"hooks" =>
|
|
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,
|
|
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.
|
|
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] =
|
|
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] =
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/textus/call.rb
ADDED
|
@@ -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::
|
|
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,
|
data/lib/textus/cli/verb/boot.rb
CHANGED
|
@@ -7,9 +7,9 @@ module Textus
|
|
|
7
7
|
option :prefix, "--prefix=K"
|
|
8
8
|
|
|
9
9
|
def call(store)
|
|
10
|
-
Textus::
|
|
10
|
+
Textus::Ports::BuildLock.with(root: store.root) do
|
|
11
11
|
role = store.manifest.policy.roles_with_kind(:generator).first || "builder"
|
|
12
|
-
ops = store.
|
|
12
|
+
ops = store.as(role)
|
|
13
13
|
result = ops.publish(prefix: prefix)
|
|
14
14
|
emit(result)
|
|
15
15
|
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::
|
|
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::
|
|
37
|
+
"hook run '#{name}' exceeded #{Textus::Write::RefreshWorker::FETCH_TIMEOUT_SECONDS}s timeout",
|
|
38
38
|
)
|
|
39
39
|
rescue Textus::Error
|
|
40
40
|
raise
|
data/lib/textus/cli/verb/put.rb
CHANGED
|
@@ -19,7 +19,7 @@ module Textus
|
|
|
19
19
|
if fetch_name
|
|
20
20
|
result =
|
|
21
21
|
begin
|
|
22
|
-
Timeout.timeout(Textus::
|
|
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::
|
|
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.
|
|
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
|
data/lib/textus/cli/verb.rb
CHANGED
|
@@ -96,16 +96,16 @@ module Textus
|
|
|
96
96
|
Role.resolve(flag: flag, env: ENV, root: store.root)
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
# Returns
|
|
100
|
-
#
|
|
101
|
-
#
|
|
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
|
-
|
|
103
|
+
Textus::Call.build(role: resolved_role(store))
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
-
# Returns a
|
|
106
|
+
# Returns a RoleScope bound to the resolved role.
|
|
107
107
|
def session_for(store)
|
|
108
|
-
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::
|
|
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 =
|
|
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"]})"
|