docs: update documentation to OKF v0.1 format

This commit is contained in:
OpenVelo Agent
2026-08-04 18:57:40 +00:00
parent 7d39f3a339
commit d435aa6f8f
8 changed files with 255 additions and 43 deletions
+174
View File
@@ -0,0 +1,174 @@
---
type: architecture
title: Mastodon Publishing
description: The single boundary between the pipeline runner and the Mastodon HTTP API — status composition, length validation, and the publish call.
tags: [publishing, mastodon, api, boundary]
timestamp: 2026-08-04T18:55:00Z
---
# Purpose
`tenbackward.publishing` is the **single success boundary** between the
pipeline orchestrator and the Mastodon HTTP API. The runner calls
`publish_mastodon()` after dedupe and before state is persisted; if
the call raises, the retry loop re-attempts and `posted.json` is left
untouched. Composition and validation are pure functions that do not
touch the network so they are unit-tested without a server.
# Public API
| Symbol | Responsibility |
|---|---|
| `MASTODON_STATUS_LIMIT` | `500` — Mastodon's hard maximum status length. |
| `PublishError` | Raised on composition failure, length overflow, or any wrapped API exception. Subclass of `RuntimeError`. |
| `build_status_text(prefix, posts, hashtags)` | Pure composer. Returns the final status string. Raises `PublishError("empty_posts: ...")` when `posts` is empty. |
| `validate_status(status, limit=MASTODON_STATUS_LIMIT)` | Pure validator. Raises `PublishError("status_too_long: len=N limit=L")` when `len(status) > limit`. Never truncates. |
| `publish_mastodon(config, posts, *, client_factory=None)` | Composes, validates, and posts. Returns the composed status string. Wraps every third-party exception in `PublishError(f"publish_failed: {ExcType}: {exc}", ) from exc`. |
| `slugify_title(title)` | Re-exported from [matching](/architecture/anniversary-matching.md); used by callers that need to derive the same URL slug the matcher emits. |
# Status Layout
`build_status_text` produces exactly one Mastodon status for any number
of matching posts, in this shape:
```text
{prefix}
{title1}
{url1}
{title2}
{url2}
...
{hashtags}
```
* The prefix comes from `Config.throwback_prefix` (default
`Heute vor 10 Jahren:`, overridable via `THROWNBACK_PREFIX`).
* Posts are sorted by `(date, path)` so output is deterministic
regardless of upstream order.
* `hashtags` is a comma-separated string; commas are collapsed to a
single space so each tag starts with `#` and no trailing comma
is emitted (empty tags are dropped).
* A trailing newline is always appended.
# Composition Examples
## Single post
```text
Heute vor 10 Jahren:
Mein erster Post
https://blog.example.com/2016/08/04/mein-erster-post/
#throwback #10backward
```
## Multiple posts on the same day
When more than one Jekyll post matches the current day, they are
**merged into one status** — the runner does not issue separate API
calls per match. Ordering is `(date, path)`, so identical dates sort
by relative path:
```text
Heute vor 10 Jahren:
Post A
https://blog.example.com/2016/08/04/a/
Post B
https://blog.example.com/2016/08/04/b/
#throwback #10backward
```
## No posts
Calling `build_status_text("...", [], "#x")` raises
`PublishError("empty_posts: cannot compose status without posts")`
before any API call. The runner only invokes the publisher when its
candidate list is non-empty, so this guard is a backstop for direct
callers and unit tests.
# Length Validation
Mastodon's API rejects statuses longer than 500 characters. The
spec requires **failing safely** — the bot must never silently
truncate content. `validate_status()` enforces this contract: the
full status is measured, and overflow raises
`PublishError("status_too_long: len=N limit=500")` *before* the API
call. The runner's retry loop treats this exactly like any other
publish failure.
# Mastodon API Call
`_post_status_via_mastodon_py()` constructs a `mastodon.Mastodon`
client with `(access_token=config.mastodon_access_token,
api_base_url=config.mastodon_base_url)` and calls
`status_post(status, visibility=config.visibility)`.
The `client_factory` keyword argument on `publish_mastodon` lets
tests inject a fake client without monkey-patching. Production
callers leave it as `None`.
# Failure Modes
| Source | Exception surfaced to runner | Cause attached? |
|---|---|---|
| Empty `posts` argument | `PublishError("empty_posts: ...")` | No (no inner exc). |
| Composed status > 500 chars | `PublishError("status_too_long: ...")` | No. |
| Any other exception from inside the boundary | `PublishError("publish_failed: {ExcType}: {exc}")` | Yes, via `raise ... from exc`. |
The runner catches every `Exception` in `_run_once`, so any of the
above becomes a `pipeline_error` log line and the retry budget
decides whether to give up.
# Wiring
```
_run_once(config) # main.py
├── ensure_repo(...)
├── store = PostedStore(config.data_dir)
├── candidates = list(_iter_candidates(config)) # find_anniversary_matches
├── unposted = [m for m in candidates if not store.is_posted(m.path)]
├── if unposted:
│ publish_mastodon(config, unposted) # <-- THIS boundary
│ store.mark_posted_many(posted_ids) # only after success
```
State is written **only after** `publish_mastodon` returns. A publish
failure leaves `posted.json` unchanged and lets the retry budget
re-attempt on the next iteration.
# Configuration Surface
| Env var | Consumed via | Effect |
|---|---|---|
| `MASTODON_BASE_URL` | `Config.mastodon_base_url` | Mastodon instance URL. |
| `MASTODON_ACCESS_TOKEN` | `Config.mastodon_access_token` | OAuth token passed to the `Mastodon` client. |
| `VISIBILITY` | `Config.visibility` | Passed as `visibility=` to `status_post`. |
| `THROWNBACK_PREFIX` | `Config.throwback_prefix` | First line of every published status. |
| `HASHTAGS` | `Config.hashtags` | Trailing hashtag line; commas become spaces. |
See [Config Schema](/architecture/config-schema.md) for full validation
rules.
# Key Files
| Path | Responsibility |
|---|---|
| `/repo/src/tenbackward/publishing.py` | `MASTODON_STATUS_LIMIT`, `PublishError`, `build_status_text`, `validate_status`, `_post_status_via_mastodon_py`, `publish_mastodon`. |
| `/repo/src/tenbackward/matching.py` | `MatchedPost` dataclass and `slugify_title()` reused here. |
| `/repo/src/tenbackward/main.py` | Calls `publish_mastodon()` between dedupe and `mark_posted_many`. |
| `/repo/tests/test_publishing.py` | Composition, length validation, visibility passthrough, API failure wrapping, and the no-persistence-on-failure contract. |
# Related
* [Pipeline Runner](/architecture/pipeline-runner.md)
* [Anniversary Matching](/architecture/anniversary-matching.md)
* [Config Schema](/architecture/config-schema.md)
* [Daily Run Guide](/guides/daily-run.md)