From 48f1c5a751e10109a43ffac425fa0d576f83f918 Mon Sep 17 00:00:00 2001 From: iacore Date: Fri, 31 Jul 2026 18:46:47 +0800 Subject: [PATCH] S013+S014 logs; roadmap entries; mvp.dj: notifications landed Open questions resolved earlier (AI in-topic first, Android optional) are now reflected as S013's premise: bot via published CLI, AI participant deferred. mvp.dj not-in-MVP list drops notifications with a note; vision.dj Web Push plan is now implemented (S014). --- architecture.dj | 66 ++++++++++++++++++++++++++++++++++++ log/s006.dj | 27 +++++++++++++++ log/s007.dj | 25 ++++++++++++++ log/s008.dj | 20 +++++++++++ log/s009.dj | 30 +++++++++++++++++ log/s010.dj | 24 +++++++++++++ log/s011.dj | 19 +++++++++++ log/s012.dj | 43 ++++++++++++++++++++++++ log/s013.dj | 1 + log/s014.dj | 1 + plan/mvp.dj | 32 ++++++++++++++++-- roadmap.dj | 89 ++++++++++++++++++++++++------------------------- vision.dj | 24 ++++++++++++- 13 files changed, 353 insertions(+), 48 deletions(-) create mode 100644 log/s006.dj create mode 100644 log/s007.dj create mode 100644 log/s008.dj create mode 100644 log/s009.dj create mode 100644 log/s010.dj create mode 100644 log/s011.dj create mode 100644 log/s012.dj create mode 100644 log/s013.dj create mode 100644 log/s014.dj diff --git a/architecture.dj b/architecture.dj index e71dc32..f7a16be 100644 --- a/architecture.dj +++ b/architecture.dj @@ -53,6 +53,72 @@ When writers later distribute across hosts, the same model degrades to fan-out queries (few hosts) or a syncer (many) — a cost paid when distribution arrives, not before. +## External members: hosted records (2026-07-31) + +Phase 002 wants *.bsky.social users to join spaces. Their PDS is not +space-capable and never signs space commits — so for external members we +store their space records **in our zds**, attributed to their DID but +outside their canonical repo. A deliberate departure from proposal 0016's +writer-repo model, accepted for reach: requiring a zds account per member +re-creates the per-platform account problem atproto exists to kill. + +What the zds substrate already gives (verified against the fork): + +- membership is DID-agnostic — `addSimpleSpaceMember` requires only a + parseable DID, `member-list` policy checks are DID-string matching; +- space record storage is DID-agnostic — `applySpaceWrites` keys records + by `(space, repo_did)` with no join to the local accounts table; +- service-JWT verification is issuer-agnostic — `requireServiceAuth` + resolves the issuer's DID doc and verifies signature/lxm/expiry for any + DID; +- commit signing already separates signer from author — + `writeSignedState(space, signer_did, user_did, state)`. + +What must be built: + +1. **Authentication for external members** — the real wall; every + `space.*` endpoint today starts at `requireBearerAccess` (a session on + this PDS). Two paths, complementary: + - **B (primary, fits mvp.dj decision 2): "login with bsky"** — the Zig + API runs atproto OAuth as a client against the member's home + authorization server; token issuance for their DID proves identity + once, then zds mints a linji-local session bound to that DID. Needs a + new store concept: external account = DID, no repo, no keys. Keeps + "browser never does DPoP" intact. + - **A (additive, near-free): service JWT per call** — the client calls + `com.atproto.server.getServiceAuth` on *their* PDS with `aud` = our + service DID, then presents the JWT to our zds; wire the existing + `requireServiceAuth` as an accepted bearer type on space endpoints. + For third-party clients and scripting. Open question: whether + bsky.social's `getServiceAuth` allows arbitrary `aud` — check live + before depending on it. +2. **Commit signing** — when the repo DID has no local key, sign space + commits with the authority's keypair: signer = authority, author = + member DID. +3. **Gate diffs (small)** — `repo == caller` write/read branches work + unchanged once caller identity comes from an external session; + `getSpaceCredential` must accept the new bearer type so external + members can read peers' partitions. + +Consequences, accepted with eyes open: + +- **Verifiability shifts.** Commits signed by the authority key mean "our + zds attests", not "member key attests" — the operator can forge external + members' authorship. Mitigation if wanted later: the compose client + signs the canonical record value with a user-held key (e.g. the OAuth + DPoP key) embedded in the record; authorship then verifies against the + user's DID doc regardless of who stores the commit. +- **Export moves.** The member's home PDS knows nothing about these + records; export pulls space records from our zds. "Your words are + yours" survives, but as "independent of app0", not "independent of the + space host". +- **Exit is ours to honor.** Deleting a bsky account touches nothing + here; delete-my-space-records is our flow, authenticated via path B. + Deactivation is handled naturally for path A (tombstoned DID doc fails + verification); path B sessions re-check DID liveness on refresh. +- **Firehose visibility is not lost** — permissioned space records never + go on the firehose in either model. + ## Testing: the simulation rig We build on an experimental PDS surface; the counterweight is that every diff --git a/log/s006.dj b/log/s006.dj new file mode 100644 index 0000000..3be8353 --- /dev/null +++ b/log/s006.dj @@ -0,0 +1,27 @@ +# S006 — spaces backend (member-only) + +The dogfood ACL primitive: one space whose member list decides who may +read and write. Everything private in 林集 hangs off this. + +## What landed + +- **zds fork fixes**: policy-gated writes/reads for space collections, + local signing-key resolution, credential ceremony exercised in the zds + smoke scripts. +- **app0 `core/spaces.zig`**: space create/add-member/remove-member/ + members; posts and reads scoped to a space (`post/read --space`). +- **CLI**: `linji space create --skey `, `space add-member --space + --did `, `space remove-member`, `space members`. + +## Key design fact + +"Authority repo" threads/records are impossible as recorded: space +records live in **member repos** (`space.createRecord` with +repo=caller) — nobody can write to the community's repo. Member-repo +records with AT-URI refs became the standing pattern for everything +after (threads in S007, edits in S008). + +## Verification + +`zig build scenario-space` — 29 checkpoints, PASS; M001 scenario still +green (later retired by S007). diff --git a/log/s007.dj b/log/s007.dj new file mode 100644 index 0000000..b1ee57f --- /dev/null +++ b/log/s007.dj @@ -0,0 +1,25 @@ +# S007 — threads + +The Zulip insight, as records: a space holds many threads; every post +files under exactly one. + +## What landed + +- `at.linji.thread` records: `{title, tags[], createdAt}`, living in the + **creator's repo** inside the space (same member-write model as posts; + authority-repo writes are impossible for members — see S006). +- Posts reference their thread by **canonical AT-URI**; the `topic` + string is retired (M001 scenario deleted as superseded). +- CLI: `thread create/list --tag`, `post --thread`, `move` (author, via + `space.putRecord`); readSpace resolves threads + posts across member + repos into one grouped view. +- OAuth scope gains `collection=at.linji.thread`. + +## Deferred + +Owner move/tag-anything: needs a zds endpoint for the authority to +rewrite member records. Landed in S008. + +## Verification + +scenario-space — 44 checkpoints, PASS. diff --git a/log/s008.dj b/log/s008.dj new file mode 100644 index 0000000..ccc2be4 --- /dev/null +++ b/log/s008.dj @@ -0,0 +1,20 @@ +# S008 — edits, owner moderation, inline hashtags + +## What landed + +- **zds authority override**: the space authority may + `space.putRecord`/`space.deleteRecord` in member repos of their own + space — signed with the member repo's key, which our zds holds + (single-host era; this is the load-bearing reason external members' + records are hosted with us in phase 001). +- CLI: `edit`/`delete` — own posts always, any post as owner. `move` + leaves a trace message in the old thread naming the target. +- **Tags redesigned** (user decision): tags live ONLY as inline hashtags + in message text (`#BlueSky`); `thread.tags` removed. Records keep raw + text; extraction, case folding, dedup are the AppView's read-side job. + `read --tag` surfaces threads containing a tagged message. + +## Verification + +scenario-space extended (edits, owner powers, tag search, denials) — +PASS; zds smokes green. diff --git a/log/s009.dj b/log/s009.dj new file mode 100644 index 0000000..f1e5688 --- /dev/null +++ b/log/s009.dj @@ -0,0 +1,30 @@ +# S009 — BFF server (`linji serve`) + +The backend-for-frontend (mvp.dj decision 2): the browser never does +DPoP; the S003 OAuth implementation is the only one. + +## What landed + +- `linji serve --port --base --pds --web`: hosts the OAuth client + metadata + callback itself; cookie sessions map to accounts in the + store; static `--web` dir with SPA fallback to index.html. +- JSON API: `/api/login|logout|me|space|thread|post|edit|delete|move| + export`. Dev shortcut: scripted login with the account password for + headless flows. + +## Bugs the server exposed (CLI had hidden them) + +1. **Dangling store slices.** `oauth.login`/`refresh`/nonce-rotation + duped account strings into the *caller's* arena; the server freed + them per request and the next `store.find` segfaulted. Fixed at the + source: `Store.upsert` re-dupes everything into `store.allocator`; + refresh/nonce rotation do the same. +2. **`req.arena` corruption.** httpz's per-request arena does not + survive core's parse+deinit cycles. Rule now: `req.arena` is for + response data only; core calls get a private page_allocator arena + per request (same pattern as zds handlers). + +## Verification + +`zig build scenario-serve` (curl-driven) — 25 checkpoints, PASS; +unit + scenario-space green. diff --git a/log/s010.dj b/log/s010.dj new file mode 100644 index 0000000..878fb27 --- /dev/null +++ b/log/s010.dj @@ -0,0 +1,24 @@ +# S010 — SolidJS PWA + +The surface members touch (mvp.dj decision 7: SolidJS, not Zig-WASM). + +## What landed + +- `app0/web/` (bun + vite + solid-js): login via the scripted dev login, + join-a-space by pasted at:// URI, thread list, expandable message + view, composer (Enter to send), new-thread form, 5s polling refresh. +- PWA manifest + service worker; `vite dev` proxies `/api` to the BFF; + production is `linji serve --web app0/web/dist` serving the build. + +## Known seams (later slices) + +- Joined spaces live in `localStorage` until a `/api/spaces` endpoint + exists. +- Messages render as raw text; the light-markdown renderer + sanitizer + (mvp.dj decision 4) is not in yet. + +## Verification + +End-to-end in a real browser against the local zds rig: alice logs in, +joins the smoke space, reads bob's posts, posts via the composer, +creates a thread. Screenshot-checked. diff --git a/log/s011.dj b/log/s011.dj new file mode 100644 index 0000000..571a654 --- /dev/null +++ b/log/s011.dj @@ -0,0 +1,19 @@ +# S011 — export as Markdown + +User decision, replacing the published-threads concept after two +same-day pivots: immutable `publishedThread` records → Bluesky +reply-chain posting → **no in-app publishing at all** (mvp.dj decision +6). Our app never publishes room content; a member exports and pastes +into another AppView. + +## What landed + +- `linji export --thread ` / `--post ...`: selected messages + or a whole thread as Markdown — thread title as heading, bold author + handle + timestamp, raw text. +- No new record types, no preference machinery. + +## Verification + +scenario-space section 6 (thread export, selected-post export, +non-member denial) — PASS. diff --git a/log/s012.dj b/log/s012.dj new file mode 100644 index 0000000..48b8350 --- /dev/null +++ b/log/s012.dj @@ -0,0 +1,43 @@ +# S012 — login UX + deploy shape + +Design pivot (mvp.dj decision 8): email/SMS sign-up splits into a +**separate app**; linji.at hosts on Fly.io and accepts BYO atproto +handles from any PDS. + +## What landed + +- **BYO-handle login**: `beginLogin` resolves an external handle the + standard way — `.well-known/atproto-did` → DID document → + `#atproto_pds` service — and runs OAuth against that server. Falls + back to the configured PDS for handles that don't resolve (the dev + rig's `.test` accounts). +- `linji serve` flags: `--handle-domain` (bare login names suffix it; + default `linji.at`), `--invite-code` / `LINJI_INVITE` (403 at + `/api/login` without it), `/api/config` (domain + inviteRequired). +- **PWA**: full-handle login with the community suffix as a hint, invite + field shown only when required (remembered in localStorage), spinners + on composer + new-thread, mobile layout (16px inputs against iOS zoom, + horizontal space chips, sticky composer, safe-area padding). +- **Service worker rewritten** after a real stale-shell incident + (cache-first shell stranded the tab on dead asset hashes): now + network-first for navigations, cache-first only for hashed `/assets`, + `/api` always network, `skipWaiting` + `clients.claim`. +- **Deploy**: `app0/fly.toml` + multi-stage Dockerfile (zig → bun → + slim runtime; zds is a separate deploy via `LINJI_PDS` secret). + `app0/dinit/` dev services with relative paths: + `dinit -d app0/dinit dev` boots zds + seed + serve + vite. + +## dinit facts (learned via dinit-check + live boot) + +- `env-file` paths resolve relative to the **service directory**. +- Relative command paths work; `dinit-check` warns but they run. +- Logfile directories must exist before the service starts — use flat + `/tmp` filenames when a seed script creates the state dir later. + +## Verification + +scenario-serve extended (`/api/config`, invite denials, bare-name +login) — 32 checkpoints, PASS; unit + scenario-space green. dinit dev +stack booted live: fresh `/tmp/linji-dev` seeded, bare-name `alice` +login worked, vite proxy 200, clean shutdown. Spinner confirmed under +throttled network; mobile layout screenshot-checked at 390×844. diff --git a/log/s013.dj b/log/s013.dj new file mode 100644 index 0000000..f51f8af --- /dev/null +++ b/log/s013.dj @@ -0,0 +1 @@ +# S013 - CLI published for bot authors\n\nUser decision (2026-07-31): the AI bot participant is deferred; the\n**CLI is the bot surface** — anyone can build a bot that posts on\napp0.linji.at.\n\n## What landed\n\n- `app0/README.md` rewritten as the bot-author doc (was M001-era stale:\n `--topic`, retired scenario). Covers: build & `zig build install\n --prefix`, hosted-member model (records live on our zds; an admin adds\n the bot's DID to the space), OAuth login + headless operation (log in\n once where a browser exists, copy `$LINJI_HOME/accounts.json` — refresh\n is automatic; file carries DPoP key + refresh token, `chmod 600`),\n command reference, and a minimal `watch`-loop bot in shell.\n- Machine-readable reads: `/api/space` JSON behind a session cookie;\n the CLI's rendered output is the stable interface for shell bots.\n\n## Verification\n\nDoc claims match the implemented commands (checked against\n`src/main.zig` usage and `core/spaces.zig`).\n", path="docs/log/s013.dj")//Writing S013 log \ No newline at end of file diff --git a/log/s014.dj b/log/s014.dj new file mode 100644 index 0000000..2d8b8a7 --- /dev/null +++ b/log/s014.dj @@ -0,0 +1 @@ +# S014 - Web Push notifications\n\nvision.dj non-negotiable, now implemented end to end: RFC 8030 push\nresource + RFC 8291 aes128gcm encryption + RFC 8292 VAPID, in Zig with\nno new dependencies.\n\n## What landed\n\n- **`core/push.zig`** — VAPID keypair (`{LINJI_HOME}/vapid.key`,\n created once), subscription store (`push-subscriptions.json`, per\n account DID, upsert by endpoint), aes128gcm encryption (ephemeral\n ECDH + HMAC key schedule, single record with 0x02 delimiter), VAPID\n JWT (ES256 via `zat.jwt.signP256`, aud = push-service origin,\n sub = mailto:admin@linji.at), and delivery (POST, `vapid t=…,k=…`\n authorization; 404/410 = `gone` → subscription pruned).\n- **BFF** — `/api/config` gains `vapidKey`; `POST /api/push/subscribe`\n + `/api/push/unsubscribe` behind the session cookie. After\n `POST /api/post`, every subscribed space member except the author gets\n pushed `{"title":"林集","body":": ","tag":}`. Delivery is synchronous inside the post request (push\n services answer in well under a second; member counts are small —\n revisit with a queue if that stops being true).\n- **zds fork** — `simplespace.listMembers` was authority-only; the BFF\n needs the roster to fan out, and members can already see each other's\n posts, so any space member may now read the roster. No scope check on\n that path: read grants are collection-scoped and the roster is not a\n collection — membership is the real ACL.\n- **PWA** — bell button in the sidebar (🔔/🔕): requests permission,\n subscribes with `applicationServerKey` from `/api/config`, posts the\n subscription; click again to unsubscribe. SW (`linji-v3`) shows a\n notification for every push (silent pushes burn Firefox's quota) and\n focuses/opens the app on click.\n\n## Bugs caught by the scenario\n\n- First push-serving attempt silently did nothing: `listMembers` 403'd\n for non-authorities and `notifySpaceMembers` swallowed it. Fixed in\n zds (above); the scenario now exercises the full loop (subscribe →\n another member posts → push → 404 → pruned) against the real store\n file.\n\n## Environment landmine (pre-existing, not ours)\n\nThe day's system glibc/gcc upgrade (gcc 16.1.1) ships `crt1.o` with\n`.sframe` sections using `R_X86_64_PC64`, which zig's linker rejects —\nplain-native zds builds fail (zds-bench, zds-plc-repair, and\n`zig build test` included). Workaround, now documented in app0/README:\nbuild zds with `-Dtarget=x86_64-linux-gnu.2.36` (zig's bundled CRT).\napp0 is unaffected (already builds against an explicit gnu version).\n\n## Verification\n\n- Unit: RFC 8291 encrypt/decrypt roundtrip (independent decrypt path in\n the test), store upsert/remove, `originOf`.\n- Scenario-serve: 40+ checkpoints PASS incl. `vapidKey` in config, 401\n without session, upsert, stored row, bob-posts → push → 404 → pruned,\n idempotent unsubscribe. Unit + scenario-space green.\n- **Independent cross-validation**: a Node-crypto stub push service\n verified the VAPID JWT signature (VALID) and decrypted the aes128gcm\n body back to the exact notification JSON. This is the strongest\n evidence short of a vendor round-trip: an implementation that shares\n no code with ours read our wire format.\n- Limitation (honest): headless Chromium hangs inside\n `pushManager.subscribe` (FCM registration never resolves — known\n headless restriction), so browser-vendor delivery (FCM/Mozilla\n autopush) was not exercised. The browser-side code is the standard\n subscribe flow; first real-device run should watch the server log for\n `push:` lines.\n", path="docs/log/s014.dj")//Writing S014 log \ No newline at end of file diff --git a/plan/mvp.dj b/plan/mvp.dj index 624db3d..365fb69 100644 --- a/plan/mvp.dj +++ b/plan/mvp.dj @@ -5,7 +5,7 @@ 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) +## Design decisions (2026-07-30; 8 added 2026-07-31) Settled before implementation, each with its consequence: @@ -45,6 +45,29 @@ Settled before implementation, each with its consequence: 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. +7. **Frontend is SolidJS, not Zig-WASM** (2026-07-31). Decision 2 + already emptied the browser of atproto and compute — what remains is + DOM rendering, forms, and live updates, which is framework work, and + Zig-WASM has no mature DOM component model (building one is a + project, not a dependency). Chinese IME composition events, PWA + plumbing (service worker, manifest), and bundle size on mobile all + point the same way. "One language" is unachievable in the browser + regardless — WASM still needs a JS shell. Consequence: Zig stays + server-side (API + static serving); WASM is the accepted future + extension point as compute *islands* inside the SolidJS app (e.g. a + client-side search index) if a measured hot path appears — the + reverse embedding is impractical, so this choice keeps the Zig + option open. +8. **Sign-up is a separate app; linji.at takes BYO handles** (2026-07-31). + Email/SMS sign-up (carrier 一键登录, the liable-entity and PIPL + surface) is split out of linji into its own app. linji.at hosts on + Fly.io and lets people join with an atproto handle from ANY PDS — + login resolves handle → .well-known/atproto-did → DID document → + the account's own PDS, and OAuth runs against that. Bare names still + suffix the server's handle domain (`.linji.at`), so community + members type just their name. Consequence: our zds is no longer the + only account home; the invite code gates LOGIN (not account + creation) at the BFF until the sign-up app exists. ## Concepts @@ -186,11 +209,16 @@ 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 +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). +(Notifications were on this list until 2026-07-31: vision.dj calls them +non-negotiable, so Web Push landed anyway — bell in the sidebar, +SW `push` handler, VAPID + RFC 8291 in `core/push.zig`. AI participant +stays out; bots are built on the published CLI instead.) + ## Done when The seed community holds a full 共修 session on app0.linji.at: people diff --git a/roadmap.dj b/roadmap.dj index b9b0a81..b4cf5d4 100644 --- a/roadmap.dj +++ b/roadmap.dj @@ -12,18 +12,26 @@ Lexicons drafted; dev PDS; OAuth login; one hardcoded community; channels/topics/posts CRUD; delivery. Done when: two accounts hold a topical conversation. -**001 — Dogfood.** **Locked: we stay here until proposal 0016 -(permissioned data) is stable and broadly adopted.** -Migrate 同喜班 off Zulip. Chat runs member-only on spaces (same schema). -Invite codes. AI bot in topics (questions on the 课文, 八步骤-style -reflection prompts). Roles v0. Contribute to 0016 stabilization. +**001 — Dogfood.** +Migrate 同喜班 off Zulip onto spaces hosted on linji.at. Multiple spaces, +but space creation is admin-only via the CLI — not exposed in the web UI. +Members log in via OAuth with an account on any PDS (e.g. *.bsky.social); +we host their space records on our zds, since their PDSes are not +permissioned-data-capable (see architecture.dj "External members"). +Hosting their data ourselves is what lets dogfooding start without waiting +for proposal 0016. Chat runs member-only on spaces (same schema). Invite +codes. AI bot in topics (questions on the 课文, 八步骤-style reflection +prompts). Roles v0. Done when: the class holds one full 共修 session in linji.at and doesn't go back to Zulip to finish it. -**002 — Multi-community.** (post-lock) -One account, many communities; community creation by invitation; moderator -roles; managing-app access hooks (`checkUserAccess`); BYO-PDS for members on -space-capable PDSes. +**002 — Multi-community.** (post-lock: proposal 0016 — permissioned +data — stable and broadly adopted) +One account, many communities; community creation by invitation (self-serve, +replacing 001's admin-only CLI creation); moderator roles; managing-app +access hooks (`checkUserAccess`); native space repos for members on +space-capable PDSes — the 0016-dependent half; members on non-capable +PDSes keep hosted records on our zds, as in 001. Done when: a second community runs for a month without the seed community's involvement. @@ -71,45 +79,36 @@ would be what fails. (superseded by S007: posts file under threads, space-only). - **S005** ✓ — M001 scenario test, 19 checkpoints, PASS (retired in S007; scenario-space is the strict superset). -- **S006** ✓ — spaces backend: member-only spaces end to end. zds fork - fixes (policy-gated writes/reads, local signing-key resolution, - credential ceremony in smoke); app0 `core/spaces.zig` + CLI - (`space create/add-member/remove-member/members`, `post/read --space`); - scenario-space 29 checkpoints, PASS; M001 scenario still green. -- **S007** ✓ — threads: `at.linji.thread` records (creator's repo, in the - space; `{title, tags[], createdAt}`), posts reference threads by - canonical AT-URI (`topic` string retired), `thread create/list --tag`, - `post --thread`, `move` (author, via `space.putRecord`), readSpace - resolves threads+posts into a grouped view; OAuth scope gains - `collection=at.linji.thread`; scenario-space 44 checkpoints, PASS. - Refinement to mvp.dj decision 1: thread records live in the creator's - repo like posts (authority-repo writes are impossible for members); - owner move/tag-anything deferred to S008 (needs a zds endpoint). -- **S008** ✓ — edits, moderation, inline hashtags. zds gains an authority - override: the space authority may `space.putRecord`/`space.deleteRecord` - in member repos of their own space (signed with the member repo's key, - single-host era). CLI: `edit` / `delete` (own posts always, any post as - owner), `move` leaves a trace message in the old thread naming the - target. Tags redesigned per user decision: tags live ONLY as inline - hashtags in message text (`#BlueSky`); `thread.tags` removed (records - 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. +- **S006** ✓ — member-only spaces end to end (zds fork policy fixes, + `core/spaces.zig`, space CLI). [log](log/s006.dj) +- **S007** ✓ — threads as `at.linji.thread` records; posts reference by + AT-URI; grouped readSpace view. [log](log/s007.dj) +- **S008** ✓ — edits, owner moderation (zds authority override), inline + hashtags as the whole tag story. [log](log/s008.dj) +- **S009** ✓ — BFF server: `linji serve` hosts OAuth + cookie sessions + + JSON API + static PWA. [log](log/s009.dj) +- **S010** ✓ — SolidJS PWA (`app0/web/`): login, join-by-URI, threads, + messages, composer; manifest + SW. [log](log/s010.dj) +- **S011** ✓ — export as Markdown; no in-app publishing, ever. + [log](log/s011.dj) +- **S012** v - login UX + deploy: BYO handle from any PDS, invite-code + login gate, PWA polish, dinit dev stack, Fly.io image. [log](log/s012.dj) +- **S013** v - CLI published for bot authors (README rewrite, install, + headless OAuth flow, watch-loop bot example). AI bot deferred; the CLI + IS the bot surface. [log](log/s013.dj) +- **S014** v - Web Push end to end: VAPID + RFC 8291 in `core/push.zig`, + subscribe API, post-trigger fan-out (zds roster opened to members), + PWA bell + SW handler. [log](log/s014.dj) ## Open questions -1. AI emphasis first: in-topic participant, or private 自修 companion? - (Phase 001 concern — doesn't block foundation.) +(none open) Resolved: chat publicity (member-only spaces in phase 001, public records in phase 003); invitee PDS (hosted zds — load-bearing for spaces); stack (Zig + -zat); codegen (zlex, post-M001); phase 001 gate (proposal 0016 stable + -adopted). +zat); codegen (zlex, post-M001); 0016 gate (moved from 001 to 002: dogfood +hosts external members' records on our zds, so it no longer waits on +permissioned data; native space repos still do); AI emphasis (2026-07-31: +in-topic bot participant first, private 自修 companion later); Android +(2026-07-31: optional — PWA + Web Push is the notification plan, not a +stopgap). diff --git a/vision.dj b/vision.dj index bbd6e98..b702825 100644 --- a/vision.dj +++ b/vision.dj @@ -47,7 +47,8 @@ Lexicons under `at.linji.*`. One schema, two publicity modes — see ## AI -Two distinct roles, kept separate: +Two distinct roles, kept separate. Order settled (2026-07-31): the bot +participant comes first (phase 001); the companion waits. - **Bot participant** (`@…linji.at` account): joins topics when invited, asks Socratic questions on the text under discussion, summarizes 共修 @@ -57,6 +58,27 @@ Two distinct roles, kept separate: community's own course materials. Handles sensitive content; private data only. +## Our own home base + +linji.at is our communication home base wherever it can be: the team builds +in it and talks through it, not around it (the Leaflet makers' Lab Notes +pattern — the tool carries the conversation about the tool). Consequence for +the code: the initial slices are held to the bar of real daily use, not +demo-ware — solid enough that we actually live here. + +## Non-negotiables: notifications. Android: optional + +- **Notifications are required.** A community tool that doesn't reach you + doesn't get used. Mechanism settled: **Web Push API on the PWA** (works + in Firefox and Chrome on Android without install; pushes must always + show a visible notification to dodge Firefox's silent-push quota; iOS + Safari needs the PWA installed). This IS the notification plan — not a + stopgap. +- **Android is optional** (2026-07-31): good to have, not mandatory. The + PWA + Web Push covers the need; a native shell (FCM, mainland devices + without Google services) is only revisited if the PWA proves + insufficient in daily use. + ## Honest constraints - atproto group-chat-at-scale is unsolved territory. There is no mature open -- 2.51.2