diff --git a/plan/mvp.dj b/plan/mvp.dj new file mode 100644 index 0000000..624db3d --- /dev/null +++ b/plan/mvp.dj @@ -0,0 +1,200 @@ +# 林集 MVP — what ships at app0.linji.at + +The app is 林集 (linji.at). The smallest version that is a real place, not +a demo. One community lives here; everything else is phase 002+. Every +concept below carries the reason it earns its place; anything without a +reason is in "not in the MVP". + +## Design decisions (2026-07-30) + +Settled before implementation, each with its consequence: + +1. **Threads are records in the space.** `at.linji.thread` records live in + the creator's repo inside the space (same member-write model as posts — + refined at S007: authority-repo writes are impossible for members, and + the poster's-repo principle already covers this); posts reference their + thread by canonical AT-URI (`at://did/at.linji.thread/rkey`). + Consequence: tags are edited once on the thread record; migration is a + one-field update on the post (`space.putRecord`); readSpace resolves + the cross-repo references (threads and posts in different members' + repos). +2. **PWA reaches zds through the Zig API** (backend-for-frontend). The + Zig server serves the PWA and proxies XRPC; the browser never does + DPoP, and the S003 OAuth implementation is the only one. Consequence: + no browser-side atproto stack, no CORS work on zds. +3. **Moderation: author + owner/mod.** Authors edit/move/delete their own + messages (S007 shipped move). The owner (and later mods) has message + *editing* powers over anything in the group — needs a zds endpoint + (authority rewriting member records, feasible: zds holds the hosted + repos' signing keys) landing in S008. No roles system in the MVP. +4. **Message content is light markdown** (bold/italic/code/lists, small + renderer + sanitizer). Chosen over plain text for study notes; costs a + rendering surface. **No message length limit** (decision 2026-07-30): + WeChat imposes none, and members will paste long 课文 excerpts — a cap + would be a daily friction against the community's actual habit. The + lexicon-style-guide's max-length recommendation is deliberately not + taken for `text`; transport ceilings (zds request body cap, ~64KB) are + the only invisible backstop. Long-form past that is a blob-reference + problem, phase 002+. +5. **One VPS runs everything**: zds + Zig API + static PWA behind Caddy + TLS. One backup target; splitting is a phase-002 problem. +6. **No publishing in our app; export as Markdown instead** (2026-07-31, + revised twice same day): earlier designs (immutable `publishedThread` + records with retraction-preference records, then Bluesky reply-chain + posting) are both dropped. Our app never publishes room content — it + only offers `export`: a member copies selected messages or a whole + thread as Markdown and pastes it into another AppView (Bluesky, a + blog, …) to publish. No new record types, no preference machinery. + +## Concepts + +**One group (the ACL boundary).** +What it provides: a single answer to "who may read and write" — the member +list of one space (the S006 primitive). Why needed: private 共修 content +requires access control from day one, and one group is the smallest ACL that +is real. It replaces roles, sub-spaces, and per-thread permissions — all of +which would multiply the permission surface before a single user arrives. + +**Multiple threads.** +What it provides: conversation tracking — the ability to follow one line of +discussion without reading every message, and to reply days later without +interrupting today's flow. Why needed: this is the Zulip insight the whole +project is built on; a single undifferentiated stream is WeChat/Discord, +which already failed the community. Multiple threads is the cheapest form +of the insight: one container, a subject line. + +**Message migration between threads.** +What it provides: repair. Conversation drift is inevitable; moving a +message re-files it where it belongs instead of leaving it wrong forever. +A move updates the message's thread field and leaves a trace, so the old +thread isn't silently recontextualized. Why needed: threads only stay +trustworthy if misfiling is fixable. Without migration, threads decay into +the stream they were meant to replace. MVP rule: authors move their own +messages; the group owner moves anyone's. + +**Message tags (inline hashtags).** +What it provides: retrieval — tag a message inline ("我现在在用 #BlueSky +这个客户端"), and search by tag surfaces the *threads* containing tagged +messages. Tags live only on messages, in the text itself; the record keeps +raw text, the AppView extracts hashtags at read/index time (matching, +case folding, dedup are all read-side). Why needed: threads accumulate; +without any organization the thread list becomes the next thing that +doesn't scale. Tags are the MVP's entire search story — chosen over +full-text search because they carry no index, no ranking, no +infrastructure. (Revises the 2026-07-30 thread-tags design: no tags field +on any record.) + +**PWA at app0.linji.at.** +What it provides: the surface members actually touch — thread list with +tag filter, thread view, compose, move, tag, account controls — installable +on any phone/desktop with zero app-store gate. Why needed: the community +is on phones and WeChat-fatigued; an installable web app is the only +distribution that is immediate, cross-platform, and fully ours. One app, +no native anything. + +**Invitation code (the only gate).** +What it provides: signup is invite-code + OAuth against our zds — code in, +account created on first use, session established. Codes are issued by the +community (owner first, members later); zds already has the invite-code +machinery. Why needed: this is the entire anti-sybil and invite-only +story, and it runs on the human trust chain instead of personal data — no +phone numbers collected, nothing to verify with a carrier, no PIPL +surface at all. Deliberately replaces carrier 一键登录 and SMS codes: +every SMS path requires a 企业实名 mainland contract and creates exactly +the liable-entity problem the structure avoids (see hosting risk below). + +OAuth remains the session layer underneath — the code only gates account +creation; 1-click for existing accounts is one OAuth consent screen. + +**Copy as Markdown; publishing lives in other AppViews.** +What it provides: a member copies selected messages or an entire +conversation thread as Markdown (thread title as heading, bold author +handle + timestamp, raw message text) and publishes it wherever they +like — Bluesky, a blog, a newsletter. Why needed: 修学 produces +artifacts worth keeping and sharing (a distilled 心得 thread, a resolved +question); conversation is for the room, publishing is for the record. +Why no in-app publishing: it keeps our record surface at three types +(post/thread/space config) and dodges the entire consent-machinery +question (opt-out, anonymization) — quoting someone from a private chat +is a social act governed by community norms, the same trust basis as +the room itself. What the target AppView does with attribution is its +own business. + +**Data export (separate program, local or in-browser).** +What it provides: the user pulls everything they wrote — their repo, their +space records — as JSON, using their own credentials, from a program that +is not app0. Why needed: two proofs in one. Practically, export works even +if app0.linji.at is down or gone. Philosophically, it demonstrates the +atproto commitment (your words are yours) instead of asserting it — the +export path must not depend on linji's server existing. This is why the +API deliberately does not own export. + +**Account deletion.** +What it provides: a real exit — every message you wrote deleted, then the +account. The UI warns plainly: deleting your messages leaves holes in +other people's conversations; replies to you lose their context. Why +needed: the right to leave is part of owning your words, and the warning +is part of the community contract — your messages are also other people's +context. Real deletion, confirm-twice, no tombstone display. + +**Zig API (all interactions, fully local).** +What it provides: one Zig HTTP server (the grown-up app0 core) exposing +the complete interaction set — auth, threads, messages, moves, tags, +deletion — against a local zds. Why needed, three times over: the PWA +holds no logic the API doesn't own (one implementation, not two drifting +copies); the whole product is scriptable without a browser (the test +harness); and local-only operation means the product is developed and +proven without touching the live network. + +**CLI client: undecided, not built.** +Why undecided is the decision: the API + curl covers scripting, the PWA +covers humans. A CLI earns its place only when a real workflow demands it +(e.g. 领学 batch posting). Building it speculatively would violate this +doc's own rule. + +## Hosting risk: P.R.C. mainland / Ali Cloud + +The default assumption is hosting outside the mainland. 林集 is a study +community, not religious publishing, so the 宗教-content licensing +question is explicitly set aside (decision 2026-07-30). What remains is +the generic — still substantial — cost of putting a UGC app on Ali +Cloud's mainland regions, recorded here so it is never taken on by +accident: + +- **UGC platform obligations.** ICP 备案 + 经营性 ICP 许可证, 公安备案, + real-name backend (实名制), and proactive content review (内容审核) + with retention duties. atproto's design (user-held keys, data the + operator can't fully rewrite, export/delete guarantees) conflicts with + the operator obligations mainland hosting imposes — you cannot both + promise users their data and satisfy 审核-on-demand for content you + can't see (member-only space records are opaque to the operator by + design). +- **Enforcement shape on Ali Cloud specifically.** Ali Cloud runs its own + content scanning and suspends first, asks later (site freeze, ICP + cancellation, account-level risk to everything else on the same + account). Recovery is slow and discretionary. +- **Entity coupling.** Mainland ICP requires a Chinese entity or person + as the filing subject, who then carries operator liability for all UGC + — a liability concentration the offshore structure exists to avoid. + +**Working rule:** serve users from outside the mainland (HK/SG/JP for +latency; Hetzner-SG/Vultr-Tokyo class provider — non-PRC operator), +signup by invitation code + OAuth (no phone numbers, no SMS vendor, no +PIPL surface). Any future mainland presence is a separate, licensed, +content-cleared project — not a region of this one. + +## Explicitly not in the MVP + +Multiple groups, roles/moderation beyond the owner, full-text search, +notifications, AI participant, federation hardening, appview +infrastructure, public (world-readable) content. Each has a phase in +vision.dj; none is needed for the first real community, and each would +delay the only validation that matters (below). + +## Done when + +The seed community holds a full 共修 session on app0.linji.at: people +join with an invitation code, post in threads, a misplaced message gets +moved, the thread list is filtered by a tag — and nobody opens Zulip to +finish the session. (Same gate as vision.dj phase 001, now with the +concrete surface attached.) diff --git a/roadmap.dj b/roadmap.dj index f5bf559..b9b0a81 100644 --- a/roadmap.dj +++ b/roadmap.dj @@ -95,6 +95,14 @@ would be what fails. keep raw text, normalization is the AppView's job); `read --tag` surfaces threads containing a tagged message. scenario-space extended (edits, owner powers, tag search, denials), PASS; zds smokes green. +- **S011** ✓ — export as Markdown (user decision, replaces the published- + threads concept after two same-day pivots: immutable `publishedThread` + records → Bluesky reply-chain posting → no in-app publishing at all). + `linji export --thread ` / `--post ...` prints selected + messages or a whole thread as Markdown (title heading, bold author + handle + timestamp, raw text) for pasting into other AppViews; no new + record types, no preference machinery. scenario-space section 6 covers + thread export, selected-post export, non-member denial; PASS. ## Open questions