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:
- Identity: a badge's local user/trust management is rooted in a Fossil
repository's own
usertable instead of an external OIDC provider. - Storage: the local timeline is tracked as Fossil forum threads/posts instead of (or as a durable backing store beneath) the JSON snapshot files.
- "Joining social networks" now has two distinct legs, not one:
- Badge-to-badge / badge-to-canonical-instance propagation can ride on Fossil's own sync protocol.
- Reaching the wider Fediverse (Mastodon et al.) still goes over ActivityPub — Fossil sync is not an AP-speaking protocol — but the AP inbox/outbox now read/write the Fossil forum store instead of the JSON-snapshot store.
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:
user(uid, login, pw, cap, cookie, ipaddr, cexpire, info, mtime, photo, jx)— local account table.capis a capability string (permission grant),infois free-text contact info,photois an avatar BLOB. Fossil "never deletes" users (perfossil user --help); revoking access clearscaprather than removing the row.forumpost(fpid, froot, fprev, firt, fmtime)— thread linkage only (fpid= this post's row id / blob rid,froot= thread root's fpid,firt= "in reply to" parent fpid,fprev= previous edit of this post).event(type, mtime, objid, uid, euser, user, ecomment, comment, ...)—type='f'rows are forum posts,objidjoins toforumpost.fpidand toblob.rid.- Forum post content lives in
blob.contentas a plain-text Fossil control artifact. Confirmed by decoding a real root post from this repository's forum viafossil artifact <uuid>:
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.
- There is no
fossil forum ...CLI subcommand and no forum entry infossil json(checkedfossil help -a,fossil jsonsubcommand list). Forum reading/writing is a web-UI feature (/forummain,/forumthread,/forumnew,/forumpost,/forumedit). - Everything in the repository — forum posts included — is content-addressed
and replicates via Fossil's normal
sync/clone/push/pullprotocol, which is resumable, delta-compressed, and designed for offline/intermittent connectivity. This is the same durability/replication problemPeerTransportService/PeerRelayServicecurrently hand-roll (queue, retry/backoff, dedupe-by-ID) for Meshtastic peers.
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
- Identity (done this pass) —
FossilIdentityService/FossilUserRecordmap auserrow toIdentity, with a newTrustLevel::FossilLoginVerifiedtrust tier sitting whereFullOidcVerifiedpreviously stood. Additive;FullOidcVerifiedis 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. - Read-side storage (done this pass) —
FossilForumReaderService+FossilForumCardParserprojectevent/forumpost/blobrows intoFossilForumPost(notMessage— see "Read path" above for why). Wiring this intoTimelineServiceas 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. - Write-side storage (done this pass) —
FossilForumWriterServicedrives the verified login → editor → preview → submit flow to post new threads and replies. Wiring it intoTimelineService/the AP inbox as the sink for locally authored messages and inbound federated activities is part of the ActivityPub bridge phase (4) below. - AP bridge — point
ActivityPubOutboxService/ActivityPubInboxServiceat the Fossil-backed store instead ofTimelineService's JSON-snapshot path, preserving the existing public interfaces so route handlers inweb_app.crdon't change shape. - Peer sync — evaluate replacing
PeerTransportService's bespoke HTTP queue/retry with a thin wrapper aroundfossil syncfor 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
- 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.
- Should badges embed a local
fossilprocess (own local repo, own local forum, synced outward), or act purely as an HTTP client of a remote/canonicalfossil server? Embedding fits "local-first" better but adds the Fossil binary as a runtime dependency alongside Crystal/Kemal. - Confirm write-path form field names against a live
fossil serverinstance before Phase 3 lands, per the "Write path" section above.