Login
Fossil forum + user management as the local ActivityPub substrate
Login

Rescope

Earlier planning (docs/project-brief.md, "Confirmed Decisions" #3/#4) assumed an external Discourse instance (forum.beagleboard.org) as the OIDC identity authority, with an ad hoc JSON-snapshot store (PersistenceService) for local timeline/message data. This rescopes both:

This document is grounded in the actual schema/behavior of the Fossil binary available in this environment (fossil version 2.27), not assumption.

What Fossil actually gives us (verified)

Queried directly against this checkout's own .fslckout repository:

  D 2026-09-14T14:32:22.527
  H Trunk-ops
  N text/x-markdown
  U stone
  W 49
  This session manages what gets merged into trunk.
  Z a6dc6045b56199e6a3fb71f47b99b29b

(D=timestamp, H=thread title on the root post, N=mimetype, U=author login, W <n>=body of n bytes, Z=checksum.) Non-root posts carry G (thread root uuid) and I (immediate parent uuid) instead of H — confirmed against a real reply artifact in this repository:

  D 2026-09-14T14:38:17.141
  G 451b1f1bd694dd1c38cd3a6c82fbf2839c46ff1d8837e7b4a33ec04b70c77f25
  I 451b1f1bd694dd1c38cd3a6c82fbf2839c46ff1d8837e7b4a33ec04b70c77f25
  N text/x-markdown
  U agent-trunk-ops
  W 780
  ...

Card token values (e.g. the H title) escape literal spaces as \s (confirmed: H Fossil\sbased\sActivityPub\sdevelopment); FossilForumCardParser (src/social_badge/fossil_forum_card_parser.cr) implements this.

Read path (delivered)

FossilForumReaderService (src/social_badge/fossil_forum_reader_service.cr) queries the repository's SQLite database for forum-post artifact UUIDs:

SELECT b.uuid FROM event e
JOIN forumpost fp ON fp.fpid = e.objid
JOIN blob b ON b.rid = e.objid
WHERE e.type = 'f'
ORDER BY e.mtime ASC;

then fetches each artifact's raw text by shelling to the local, read-only fossil artifact <uuid> -R <repo> command (never a server, never a mutation) and decodes it with FossilForumCardParser into a FossilForumPost. This mirrors what mcp__ollama-codex__fossil_sqlite already does safely in this session's own tooling, and reuses the sqlite3 shard already declared in shard.yml/vendored in lib/sqlite3 — no new dependency.

Read-only safety finding (important): the sqlite3 shard's ?mode=ro URI parameter does not actually block writes against this schema — verified directly: an INSERT through a mode=ro-opened connection succeeded and briefly wrote a bogus row into this very repository's event table before it was caught and removed. The mechanism that does reliably block writes is issuing PRAGMA query_only = ON; immediately after opening the connection (verified: the same INSERT then raises SQLite3::Exception: attempt to write a readonly database, and the underlying file is provably unchanged). FossilForumReaderService always issues this pragma before running any query. Anything else that opens a Fossil repository's SQLite file read-only from Crystal must do the same — do not rely on ?mode=ro alone.

FossilForumPost is intentionally not Message: forum content is the richer, web-side store and is not constrained to Meshtastic-friendly body sizes (Message/MeshtasticEnvelope enforce a 280-byte body limit; real forum posts, including the rescope proposal that started this thread, are well over that). Fitting forum content into Meshtastic-sized envelopes is a job for the relay/bridge layer (ticket sequence 4/5), not the reader.

Write path (delivered)

Because there is no forum-writing CLI/JSON command, hand-assembling a control artifact and inserting it into blob/event/forumpost directly would risk producing a malformed manifest (wrong checksum, wrong card order, wrong G/I linkage on replies) that corrupts the forum for every other client of the same repository. The supported write path is Fossil's own web UI forms, and FossilForumWriterService (src/social_badge/fossil_forum_writer_service.cr) drives it as an HTTP client of a fossil server/fossil http process, exactly as a browser would — Fossil's own C code constructs and checksums the artifact, so this can't corrupt the forum.

Verified flow (confirmed end-to-end against a disposable local fossil server copy — never the live/production repository):

POST /login (u, p, csrf, cs)             -> login cookie
GET  /forumnew                           -> csrf token   (new thread)
  or GET /forumedit?fpid=<root>&reply    -> csrf token   (reply)
POST /forume1 or /forume2 (preview=Preview, + Referer header) -> fresh csrf
POST /forume1 or /forume2 (submit=Submit,  + Referer header) -> 302 to /forumpost/<hash>

The Referer header is required on the preview/submit POSTs. Its absence is the single thing that cost the most time here: Fossil doesn't error when it's missing, it just silently re-renders the same editor form (values echoed back, submit button state unchanged) — indistinguishable at first glance from a validation failure, wrong field name, or capability problem. Ruled out before finding this, in order: CSRF-token staleness, missing Forum-Write/Forum-Trusted capability ('3'/'4'; see /setup_ucap_list for the full capability legend), fossil server --repolist vs. single-repo mode, and User-Agent/bot detection — none of those were it. Credit where due: this exact flow (including the Referer requirement) is already documented as "cracked in the forum-write spike" in this host's own ChatCGI::ForumWriter class docs (/root/ollama/agent-docs/html/ChatCGI/ForumWriter.html), which independently confirms the finding — this repo's FossilForumWriterService was written and empirically re-verified against a disposable test repo before that reference was found, and matches it exactly.

New-thread fields: title, mimetype, content, csrf, plus preview/submit as the two submit-button names. Reply fields: fpid (the thread's root artifact hash), reply=1, mimetype, content, csrf, plus preview/submit. A successful submit responds 302 with a Location: .../forumpost/<hash> header; anything else (typically a 200 re-render) means something above failed — FossilForumWriterService raises FossilForumWriterError with a message pointing at the Referer header as the most likely cause, since that was the actual repeated failure mode during development.

FossilForumWriterService takes an injectable HTTP transport (matching the pattern ChatCGI::ForumWriter itself uses — "the single HTTP seam, overridable in tests") so specs drive it via a fake transport and assert the exact request sequence, without needing a live fossil server in CI.

Proposed mapping

ActivityPub / domain concept Fossil concept
Local account / Identity user row (login, cap, info) — see FossilIdentityService
Trust level FULL_OIDC_VERIFIED New FossilLoginVerified: any user row with non-blank cap
Message (locally authored) Forum root post (or reply, if it's a response to something already in the forum)
Note.inReplyTo threading forumpost.firt (parent) / forumpost.froot (thread)
AP actor preferredUsername user.login
AP actor name / profile user.info
AP actor icon user.photo
Outbound federation (ActivityPubOutboxService) Reads local forum posts not yet mirrored out, projects to Create{Note}
Inbound federation (ActivityPubInboxService) Writes inbound Create{Note} activities into the forum as posts attributed to a capability-limited "federated-remote" Fossil user, threaded via firt when the parent is already a local post
Badge-to-badge / badge-to-canonical-instance sync Fossil's native sync protocol over HTTP(S), replacing the durability role (not the Meshtastic role) of PeerTransportService's queue
Reaching Mastodon/the wider Fediverse Unchanged: standalone ActivityPub surface from docs/protocol-implementation-notes.md (WebFinger, actor, inbox, outbox, HTTP Signatures)

Meshtastic stays exactly as scoped — it is the off-grid/low-bandwidth path and is orthogonal to this change. This rescope only touches how the local copy of the timeline and identity are tracked/stored, and how networked peer propagation happens.

Topology recommendation

docs/project-brief.md already confirms "at least one instance should run on a fixed domain as a default/shared out-of-the-box message source." That maps directly onto one canonical Fossil repository (e.g. served from forum.beagleboard.org or a dedicated project subdomain) that every badge is a user of. Badges keep a local clone (or at minimum a local read cache via the SQLite read path above) so the badge UI stays usable offline, and sync opportunistically when connectivity allows — the same offline-tolerant model Fossil already uses for source code.

Phased implementation plan

  1. Identity (done this pass) — FossilIdentityService/FossilUserRecord map a user row to Identity, with a new TrustLevel::FossilLoginVerified trust tier sitting where FullOidcVerified previously stood. Additive; FullOidcVerified is left in place rather than removed, since Meshtastic peer-attestation trust language elsewhere still refers to it and nothing in this repo depended on OIDC actually being implemented yet.
  2. Read-side storage (done this pass) — FossilForumReaderService + FossilForumCardParser project event/forumpost/blob rows into FossilForumPost (not Message — see "Read path" above for why). Wiring this into TimelineService as an alternate/backing source to the JSON snapshot is part of the ActivityPub bridge phase (4) below, once there's a concrete consumer that needs it.
  3. Write-side storage (done this pass) — FossilForumWriterService drives the verified login → editor → preview → submit flow to post new threads and replies. Wiring it into TimelineService/the AP inbox as the sink for locally authored messages and inbound federated activities is part of the ActivityPub bridge phase (4) below.
  4. AP bridge — point ActivityPubOutboxService/ActivityPubInboxService at the Fossil-backed store instead of TimelineService's JSON-snapshot path, preserving the existing public interfaces so route handlers in web_app.cr don't change shape.
  5. Peer sync — evaluate replacing PeerTransportService's bespoke HTTP queue/retry with a thin wrapper around fossil sync for the badge-to-canonical-instance leg, keeping the existing queue only for the Meshtastic leg where Fossil's own sync protocol doesn't apply.

Open questions for confirmation

  1. One shared canonical Fossil repository for all badges (recommended, matches the existing "fixed domain default instance" decision), or one repository per badge with cross-repo forum federation? This doc assumes the former.
  2. Should badges embed a local fossil process (own local repo, own local forum, synced outward), or act purely as an HTTP client of a remote/canonical fossil server? Embedding fits "local-first" better but adds the Fossil binary as a runtime dependency alongside Crystal/Kemal.
  3. Confirm write-path form field names against a live fossil server instance before Phase 3 lands, per the "Write path" section above.