Skip to content

feat(hub): team documents — blocks, per-block holds, checked changes, snapshots and search - #266

Merged
yulmucoffee merged 5 commits into
mainfrom
laf/hub-docs
Oct 18, 2026
Merged

yulmucoffee merged 5 commits into
mainfrom
laf/hub-docs

Conversation

@yulmucoffee

@yulmucoffee yulmucoffee commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #251 (laf/hub-files → #247): this branch starts from origin/laf/hub-files and its diff against main includes #247 and #251 until they land. Review the last two commits; the migration is 0002_hub_docs after #251's 0001_hub_files (siblings that also carry a 0001_* renumber at merge time).

What this changes

The company hub keeps team documents as ordered blocks, the shape the design settled for documents people and 라피 edit together: block = {id, kind, text, attrs, version, hash}, twelve fixed kinds (paragraph, heading1–3, list, check, quote, code, divider, table, file, task-link). There is no editor library, no CRDT and no new dependency; the markdown renderer and parser are our own code (shared/hub/doc-markdown.ts), so the VM side can use the same parser.

Tables (migration 0002_hub_docs)

  • hub_docs — title, folder, visibility (company / me:<id> / task:<id>, the same check as files), rev, soft delete.
  • hub_doc_blocks — one row per block, (doc_id, id) primary key, rank for order, version, hash, kind checked in SQL.
  • hub_doc_versions — snapshots: the block list as JSON at the moment somebody saved a version.
  • Block words go into hub_chunks with source_kind = 'doc', source_id = <doc>/<block>, in the same transaction as the block write; a deleted block's or document's chunks go with it.

Routes (/hub/v1/docs, member pass; writes take Idempotency-Key)

Route What
GET /docs?folder=&cursor= documents the member may see, newest change first
POST /docs new document from blocks or markdown (one empty paragraph when neither)
GET /docs/:id the document, its blocks in order, and the holds standing now
GET /docs/:id/blocks/:bid one block, for a VM told doc.block_changed (404 = deleted)
GET /docs/:id/markdown the blocks as markdown, each after <!-- <block id> <hash> -->
PATCH /docs/:id · DELETE /docs/:id rename / move folder · soft delete (writer or admin for a company document, as files)
POST /docs/:id/blocks a batch of ops, atomic: replace · insert_after · delete · move
POST /docs/:id/blocks/:bid/lock · /unlock "쓰는 중": take/renew (every 5 s, lasts 15 s) · let go
POST /docs/:id/versions · GET /docs/:id/versions · GET /docs/:id/versions/:v snapshot · list · read one

How a change is checked. Every op that touches a block names what its writer read: version for a person's save, expectHash for 라피's. A request whose x-laf-origin is not person (a chat turn, a routine, a room) must use expectHash. A mismatch is 409 laf:doc_block_changed with the block as it is now (block, null if deleted). A block somebody holds is 409 laf:doc_block_busy with the holder — for 라피, anybody's hold, its own person's included; for a person's save, only somebody else's. The batch is checked in memory first and written only when every op passes, so a refusal leaves the tables as they were. Each batch takes the document's row for update, so batches on one document apply one at a time and a check cannot go stale between reading and writing. There is no whole-document replace op.

Rank scheme: fractional strings. A rank is a base-36 fraction (0-9a-z, never a trailing 0), compared byte by byte (collate "C" in SQL, < in JS). An insert or move writes one row. Inserting again and again at one place grows a rank by about a character every five inserts; past 48 characters the document is renumbered evenly in the same write (ranks of 1–4 characters even at 5,000 blocks) and the VMs get one doc.changed instead of an event per block. No unique index on (doc_id, rank): the row lock already serialises a document's writes, and a unique index would refuse the one-statement renumbering halfway through.

Lock storage: in memory, with expiry. A hold lives 15 s unless renewed every 5 s, so a table would take a write every five seconds for every person with a block open, on a hub with one core, for a fact nobody needs after a restart. The hub is one process (as a person's VM is — docs/laf/deployment-model.md), so one map is every hold. A restart forgets them and loses nothing: the next renewal, at most 5 s later, takes the hold again, and a save never depended on a hold (it carries its version). Expired holds are dropped when read.

Live updates. A batch emits doc.block_changed (refId <doc>/<block>) per block it wrote or deleted, or one doc.changed when it renumbered or touched more than 50 blocks; create, rename and delete emit doc.changed. Events go only to members who may see the document (audienceOf). Holds travel as light frames on the same events socket — {kind: "doc.lock", doc, block, member, name, until} (until: null = let go) — sent to the sockets of members who may see the document on every take, renewal and release; not numbered, not kept. A VM that does not know the kind drops it (as link.ts in #249 already does).

Search. POST /search now also looks in document blocks and titles, permission in the same query (visible_to on the chunk and again on the document row). A doc hit is {kind: "doc", id, title, where: {folder, block}, snippet, …} (block null when the title alone matched). kinds narrows it (["file"], ["doc"]); kinds the hub does not search yet are ignored, not refused, since #255 already sends skill/room/memory. A hub without file space still searches documents (before, /search answered 503 laf:hub_files_unset outright; it still does when only files are asked).

Touched outside the new files, kept small: events.ts/socket.ts (a send on each listener and sendTo(audience, frame), 20 lines — the sibling branches change the same Listener type, so expect a small merge there), visibility.ts (audienceOf takes the database as well as a transaction), search.ts, app.ts, shared/hub/protocol.ts (doc types, two event types, two facts, the search hit union), one line of tests/search.test.ts (narrowing the new hit union).

Where it runs

On the company hub VM only; nothing on a person's VM changes in this PR.

  • New state that outlives a request? Documents, blocks and snapshots are in Postgres. Holds are in memory by decision (above): they must not survive a restart, and losing them costs at most one 5-second renewal.
  • New listener, port, or schedule? None. Routes under the existing /hub/v1; the hold frames ride the existing events socket. No timers (expired holds are dropped on read).
  • Anything the deployment cannot honour? No.

Boundary and audit

  • Every read filters by visible_to in SQL (document row, chunks); every write locks the document row through the same visible query, so a member cannot write to a document they cannot see (404 on every route, tested).
  • Accepted writes write a trail row (doc.created, doc.blocks, doc.renamed, doc.deleted, doc.version, doc.read on whole reads) naming the document and counts, never words — tested by serialising the trail rows and asserting the typed text is not in them.
  • New refusals write a row: a refused batch (doc.blocks, outcome laf:doc_block_changed / laf:doc_block_busy / laf:request_invalid, with the op index, the block id and whether it was 라피 or a person) and a refused hold (doc.lock, outcome laf:doc_block_busy, the holder's id). Tested for the stale-hash refusal.
  • Nothing new is trusted from the client that the hub can resolve: the member comes from the pass; the hold's holder from the pass.
  • No new allowance past a person. The only thing a request may say about itself is x-laf-origin, and saying "person" only buys the version check, which still refuses a block somebody else holds.
  • Nothing records a value somebody typed.

Korean

  • No user-facing string in this PR (hub only). The two new facts (laf:doc_block_changed, laf:doc_block_busy) get their Korean on the surface in the editor's PR (task d2).

The gate

Run on head d84b118c with LAF_TEST_DB_SUFFIX=d1 LAF_TEST_WORKERS=2:

  • bun run typecheck — every workspace exit 0.

  • bunx biome lint . — no new findings (2 warnings, 1 info, all in files this PR does not touch; the same on the base).

  • bun run format:check — clean.

  • bun run test:ci — exit 0: server 4752 / 4711 · app 2751 / 2534 · agent-computer 651 / 632 · root 1780 / 1744 · hub 184 / 184.

  • All four pass. Floor moved: hub 111 → 184 (its own commit): docs.test.ts 23, doc-markdown.test.ts 40, doc-rank.test.ts 10. The first gate run caught knip flagging four contract types the hub did not use yet; the routes are now typed by them (and the hold answer carries renewMs).

  • Each guarding test was seen failing with the line it guards reverted (below).

Proof

Acceptance, measured against a real hub (bun hub/src/index.ts on :7380, its own postgres:17 container on :55612, head d84b118c), driven by a script over HTTP and the events socket — two members by registry body, 라피 as A's VM pass with x-laf-origin: chat. A locks block 2 and edits it while B edits block 3 at the same time; 라피 tries block 2 → busy; block 4 with the hash it read before B changed it → changed with the latest; re-reads → ok; search finds B's new word; the markdown export shows every block in order. Output as printed (ids are random per run):

[   42 ms] members: A=민수 (m_16ec1284-fe56-47a4-b7c0-2ae2008fa83d), B=지영 (m_d0e74fb8-7dfb-4554-99a3-ddb451c7ef8f)
[   63 ms] A made d_76920688-17ab-4e1f-922f-0dffd4db0159 with 5 blocks: b_di6w66w6ct52 b_10k8gk5r3oo7 b_fepwkry8e9mv b_z30gvo51sgsb b_ml0ya744lowy
[   71 ms] 라피 read the markdown (rev 1); block 4 hash 143c69a3a74fca0b
[   86 ms] A locks block 2 → 200 until 2026-10-17T07:11:33.344Z
[  189 ms] B's socket saw: {"kind":"doc.lock","doc":"d_76920688-17ab-4e1f-922f-0dffd4db0159","block":"b_10k8gk5r3oo7","member":"m_16ec1284-fe56-47a4-b7c0-2ae2008fa83d","name":"민수","until":"2026-10-17T07:11:33.344Z"}
[  222 ms] at the same time — A saves block 2 → 200 (v2); B saves block 3 → 200 (v2)
[  227 ms] A renews the hold on block 2 → 200
[  246 ms] B also fixes block 4 → 200
[  255 ms] 라피 → block 2: 409 {"code":"laf:doc_block_busy","holder":{"member":"m_16ec1284-fe56-47a4-b7c0-2ae2008fa83d","name":"민수","until":"2026-10-17T07:11:33.485Z"}}
[  266 ms] 라피 → block 4 with the hash it read first: 409 {"code":"laf:doc_block_changed","latest":"다음 회의 날짜: 10월 24일","hash":"2bf63f638f4b2213"}
[  293 ms] 라피 re-reads (block 4 hash 2bf63f638f4b2213) and tries again → 200 (v3)
[  297 ms] A leaves block 2 → released true; B's socket saw the release: true
[  314 ms] A searches B's new word "해바라기상사" (17 ms) → [{"kind":"doc","id":"d_76920688-17ab-4e1f-922f-0dffd4db0159","title":"주간 회의록","where":{"folder":"/","block":"b_fepwkry8e9mv"},"snippet":"거래처 연락 담당: 지영 (해바라기상사)"}]
[  321 ms] markdown export (rev 5):
<!-- b_di6w66w6ct52 5bbe031daa49accd -->
# 주간 회의

<!-- b_10k8gk5r3oo7 a175d512983f4967 -->
지난주 매출 정리: 1,250만 원

<!-- b_fepwkry8e9mv df84818870ba929a -->
거래처 연락 담당: 지영 (해바라기상사)

<!-- b_z30gvo51sgsb 1ebf85f203e7e6cd -->
다음 회의 날짜: 10월 24일 (금) 오후 3시

<!-- b_ml0ya744lowy 6171cc9affc4d74b -->
- [ ] 견적서 보내기
[  327 ms] events B may see: doc.changed d_76920688-17ab-4e1f-922f-0dffd4db0159, doc.block_changed d_76920688-17ab-4e1f-922f-0dffd4db0159/b_10k8gk5r3oo7, doc.block_changed d_76920688-17ab-4e1f-922f-0dffd4db0159/b_fepwkry8e9mv, doc.block_changed d_76920688-17ab-4e1f-922f-0dffd4db0159/b_z30gvo51sgsb, doc.block_changed d_76920688-17ab-4e1f-922f-0dffd4db0159/b_z30gvo51sgsb

The hub's log had no error line. Container, volume directory and key removed afterwards.

Tests seen failing with the guarded line reverted (each run alone, then restored):

  • hold check in the op checker (if (hold && …) → never) → "a block a person is writing in…" fails.
  • hash check (block.hash !== expectHash → never) → "라피 with a stale hash…" fails.
  • the document row's for update removed → "eight saves racing on one block…" fails three runs out of three (two or more saves stand). The two-save version passed without the lock — the requests did not overlap — which is why the eight-way test exists.
  • the renumbering trigger removed → "400 inserts right after one block…" fails.
  • chunk writing in a batch skipped → "a word typed into a block is found…" fails.
  • visibleTo dropped from the document query → "a document only its writer may see…" fails.
  • hold-frame audience replaced by everybody → "a hold reaches the sockets of the members who may see…" fails.
  • x-laf-origin ignored → "a run's change must name a hash…" fails.
  • markdown start-escaping removed → 9 of the 11 "a paragraph that starts with … stays a paragraph" cases fail (the other two still pass without it: a marker-like line is held by the separate line guard, and a lone table row is not a table without its separator row).
  • list cursor < → <= → "the list pages newest change first…" fails.

Model calls: none.

Left for later

  • No restore route. A snapshot is read back (GET /docs/:id/versions/:v, tested to return the old list exactly); putting it back as the current document would be a whole-document replace, which the design leaves out. If a person-only restore is wanted, it is a batch of ops built from the snapshot.
  • Cursor frames. Only holds travel as light frames; a caret position inside a block is not sent.
  • Changing a document's visibility after it is made is not offered (title and folder are).
  • A document's chunks are per block, so a search needs every word inside one block (or the title) to find it; words split across two blocks do not meet.
  • The VM proxy, the editor and 라피's doc tool kinds are task d2's (laf/hub-docs-ui), against this contract.
  • The documents search when the hub has no file space is not tested (the test hub always has one).

🤖 Generated with Claude Code

@codesolarlght codesolarlght Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔆 CodeSolar review ✅ Looks good

Review of PR #266 — team documents feature for the hub service. The PR adds a new hub/ workspace with document block storage, rank-based ordering, in-memory locks, markdown parsing, and search integration. I examined the added source files (locks, rank, blocks, events, socket, auth, visibility, multipart, store, idempotency, app, index) and the shared markdown/search modules. The design is internally consistent and the test coverage is extensive (184 tests across 12 files).

✅ No issues worth flagging.

⚠️ The diff was large; some files were excluded from the review.


solar-pro4 (openrouter) · 55 files · +12600/-6 · 237.5s · CodeSolar

Comment /codesolar on this PR to re-run the review.

@yulmucoffee

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread hub/src/docs/routes.ts Outdated

@codesolarlght codesolarlght Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔆 CodeSolar review ✅ Looks good

🔄 2nd review of this pull request · 22 new commit(s) since the last one

Large PR adding team documents (hub_docs, hub_doc_blocks, hub_doc_versions tables), block CRUD with version/hash conflict detection, in-memory locks, fractional-rank ordering, markdown round-trip, snapshots, and document search integration. 23 new test files/commits since last review. I read the migration SQL, schema, blocks validation, locks, rank, the full routes/applyOps logic, markdown parser, protocol types, search changes, and the three new test files.

✅ No issues worth flagging.

⚠️ The model's answer arrived truncated; only the complete part was recovered — some findings may be missing.


solar-pro4 (openrouter) · 21 files · +5082/-69 · 585.9s · CodeSolar

Comment /codesolar on this PR to re-run the review.

@yulmucoffee

Copy link
Copy Markdown
Collaborator Author

bugbot run

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

@codesolarlght codesolarlght Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔆 CodeSolar review ✅ Looks good

🔄 3rd review of this pull request · 1 new commit(s) since the last one

Review of PR #266 for team documents feature. The diff adds document blocks, per-block holds, checked changes, snapshots, and search across 38 files. Previous review found nothing; one new commit since then only updates test-ci.ts floor count. No code defects found in the visible diff.

✅ No issues worth flagging.

⚠️ The diff was large; some files were excluded from the review.


solar-pro4 (openrouter) · 34 files · +9193/-10 · 218.6s · CodeSolar

Comment /codesolar on this PR to re-run the review.

@yulmucoffee
yulmucoffee changed the base branch from laf/hub-files to main October 18, 2026 04:57
@yulmucoffee

Copy link
Copy Markdown
Collaborator Author

bugbot run

@codesolarlght codesolarlght Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔆 CodeSolar review ✅ Looks good

🔄 4th review of this pull request · 62 new commit(s) since the last one

Team documents feature: blocks, holds, checked changes, snapshots, and search integration. The PR adds 3 new tables (hub_docs, hub_doc_blocks, hub_doc_versions), document routes with batch ops and locking, in-memory lock storage, fractional-rank ordering, markdown serialization, and document search. 22 files changed, +6600/-69.

✅ No issues worth flagging.

⚠️ The diff was large; some files were excluded from the review.


solar-pro4 (openrouter) · 21 files · +6600/-69 · 202.9s · CodeSolar

Comment /codesolar on this PR to re-run the review.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

yulmucoffee and others added 5 commits October 18, 2026 14:04
…ges, snapshots and search

Co-authored-by: LAF Agent <274876363+laf-agent@users.noreply.github.com>
docs.test.ts 23, doc-markdown.test.ts 40, doc-rank.test.ts 10.

Co-authored-by: LAF Agent <274876363+laf-agent@users.noreply.github.com>
… version it answered

A save that inserted a block and then replaced it in the same batch wrote the
new row at version 1 while its answer named version 2, so the writer's next
save naming that version was refused as laf:doc_block_changed for a block it
had just written itself. The insert now stores the block's working version.

Test: docs.test.ts "a block inserted and rewritten in one save keeps the
version it answered". With `version: block.version` put back to `version: 1`
in the insert it fails (stored 1, answered 2); restored, the hub's 190 pass.
Hub floor 189 -> 190.

Co-authored-by: LAF Agent <274876363+laf-agent@users.noreply.github.com>
Co-authored-by: LAF Agent <274876363+laf-agent@users.noreply.github.com>
… documents add

Co-authored-by: LAF Agent <274876363+laf-agent@users.noreply.github.com>
@yulmucoffee

Copy link
Copy Markdown
Collaborator Author

bugbot run

@codesolarlght codesolarlght Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔆 CodeSolar review ✅ Looks good

🔄 5th review of this pull request · 13 new commit(s) since the last one

Team documents feature: blocks, holds, checked changes, snapshots, search. Review found no correctness bugs in the diff; the lock endpoint doubles as renew (same member re-taking updates until), audienceOf signature widened correctly, search doc-chunk integration is sound, and applyOps batch logic (all-or-none, version/hash checks, hold rules, renumbering) is internally consistent. One design note: GET /docs/:id/blocks/:bid has no audit record while other read routes do.

✅ No issues worth flagging.

⚠️ The diff was large; some files were excluded from the review.


solar-pro4 (openrouter) · 21 files · +6600/-69 · 254.4s · CodeSolar

Comment /codesolar on this PR to re-run the review.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit e227886. Configure here.

@yulmucoffee
yulmucoffee merged commit efdba70 into main Oct 18, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant