Vibecode Tettra
track this build4 phases, 12 steps, beginner friendly0%The visible knowledge management loop is buildable, but a credible replacement needs more than the first screen. Tettra earns its keep through collaboration, integrations, polish, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of Tettra. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Tettra · indie build An internal knowledge base whose hard problems are making staleness visible everywhere a page appears rather than in a banner, turning questions into a queue that produces pages, and capturing chat questions without mirroring a chat history you have no right to hold. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, one process, small team behind a reverse proxy | a wiki for five people does not need an orchestrator | | Storage | SQLite with every page revision kept | the previous wording is frequently the answer to why a page changed | | Content | Markdown stored as text, rendered server side, no proprietary block format | a wiki you cannot export as files is a trap and everyone has been in one | | Staleness | A verification interval per page, enforced in queries not in a banner | a warning at the top of a page is read once and then never again | | Chat capture | Event subscriptions for messages addressed to the app, never history polling | reading a team's chat history is both a permissions question and a rate limit you will lose to | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the wiki. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **An internal custom app in your own chat workspace** (optional) · free - Why: The rate limits and the permissions for apps distributed outside a platform's marketplace are materially worse, and a wiki for your own team has no reason to be one. - Get it: Create the app in your workspace's developer console, subscribe only to events addressed to it, and request the narrowest scopes that let it reply. - Verify: A message mentioning the app delivers an event and a reply posts back - [ ] **A reverse proxy with TLS in front of the application** · free - Why: This holds internal documentation for a team, so it is reachable over the network and should never be reachable over plain http. - Get it: Any proxy you already run, with a certificate for the internal hostname. - Verify: Plain http is redirected and the certificate is trusted on a colleague's machine - [ ] **Twenty real pages and ten real questions from your team** · free - Why: Staleness, categories and the question queue all behave differently against real content than against a demo page, and the design decisions you make on fixtures will be wrong. - Get it: Export what you have from wherever it currently lives, including the messy pages. - Verify: At least five of the pages have no obvious owner, which is the case the build has to handle - [ ] **The verification interval per category** · free - Why: One global interval produces either constant nagging or none at all, and choosing per category is the decision that makes the queue survivable. - Get it: Write down an interval for each category, shortest for anything describing a process that changes. - Verify: Intervals are configured per category and the queue is a manageable size after seeding ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Real-time collaborative editing. - A chat history importer. - Single sign-on and directory synchronisation. - An answer-generating assistant over your pages. - real-time multiplayer - enterprise permissions - large integration catalog - mobile capture and notifications If one of those is essential to you, that is the reason to keep paying for Tettra, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Tettra The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an internal wiki where every page has an owner and an expiry date, and an unanswered question is a row until it becomes a page to replace Tettra. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, one process, small team behind a reverse proxy · a wiki for five people does not need an orchestrator - Storage: SQLite with every page revision kept · the previous wording is frequently the answer to why a page changed - Content: Markdown stored as text, rendered server side, no proprietary block format · a wiki you cannot export as files is a trap and everyone has been in one - Staleness: A verification interval per page, enforced in queries not in a banner · a warning at the top of a page is read once and then never again - Chat capture: Event subscriptions for messages addressed to the app, never history polling · reading a team's chat history is both a permissions question and a rate limit you will lose to ### Data model (create this before Phase 1) - pages(id, slug, title, body_md, category_id, owner_id, created_at, updated_at, verify_every_days, verified_at, verified_by, archived_at) - revisions(id, page_id, ordinal, body_md, title, author_id, created_at, summary) · every save, never pruned - categories(id, name, parent_id, ordinal) - questions(id, asked_by, body, source, source_ref, asked_at, state, answer_page_id, answered_at, answered_by) · source is web or chat and state is open, answered or declined - page_questions(page_id, question_id) · the questions a page was written to answer stay attached to it - verifications(id, page_id, user_id, at, outcome, note) · outcome is confirmed, edited or retired - users(id, name, email, role, active) · role is reader, author or admin - A page is stale when verified_at plus verify_every_days is in the past. That expression appears in the search query and the link renderer, not only in a banner on the page itself. ### Phase 1 · Pages with an owner and a revision trail Build: Create, edit and categorise pages written in Markdown. Every save writes a revision row with the author and an optional summary. A page cannot be created or saved without an owner who is an active user. Show the revision list and a difference between any two revisions. Trap: Letting ownership be optional means every page written in the first fortnight has no owner, and by the time anyone wants to know who is responsible for the onboarding page, the person who wrote it has left and the field is still empty. Require an owner on insert, and when a user is deactivated force reassignment of their pages before the deactivation completes. Done when: A page cannot be saved without an active owner, tested by attempting it. Every save produces a revision row and no revision is ever updated or deleted, enforced in the schema. Deactivating a user is blocked until their pages are reassigned, with the page count shown. A difference between any two revisions renders correctly on a fixture pair. Do not build yet: No staleness, questions, chat capture or search yet. ### Phase 2 · Staleness that follows the page everywhere Build: Give each page a verification interval. Compute staleness in the query rather than at render, so search results, internal links and listings all carry the state. Give each owner a queue of their expired pages with three actions: confirm, edit or retire, each writing a verification row. Trap: Putting a yellow bar at the top of an out-of-date page changes nothing, because the reader who arrived from search has already read the wrong paragraph before scrolling up, and the reader who arrived from a link never sees it. Carry the state into the search ranking and into every rendered internal link, so a stale page looks stale before it is opened. Done when: Search results render stale pages visibly differently and rank them below fresh ones, tested with a fixture set. An internal link to a stale page is marked as such in the rendering of the linking page, tested. Confirming a page without editing it writes a verification row and resets the clock, and the verification history is visible on the page. A page that has never been verified is treated as stale rather than as fresh, tested at the boundary. Do not build yet: No questions, chat capture, permissions or export yet. ### Phase 3 · Questions are rows until they are pages Build: Capture a question as a row with its asker and source. Route it to a person. Closing it requires either creating a page, linking an existing page, or declining with a reason. Keep the question attached to the page afterwards, so the page shows what it was written to answer. Trap: Letting a question be closed with a free-text answer produces a helpful reply that lives in the question and nowhere else, which means the next person asks the same thing and the wiki never grows. Make page or decline the only two exits, and let the page creation form be prefilled from the question so the easy path is also the right one. Done when: A question cannot reach the answered state without an answer_page_id, enforced in the state transition and tested. Declining requires a reason and the question stays searchable, so a recurring declined question is discoverable. A page shows the questions it was written to answer, and those stay attached when the page is edited. Answering a question from the queue prefills a new page with its text, checked by walking the flow. Do not build yet: No chat capture, search tuning, permissions or export yet. ### Phase 4 · Questions from chat, without mirroring chat Build: Subscribe to events for messages that mention the app or use its shortcut, and create a question row from that single message. Post the answer back as a reply. Never call a history or replies endpoint, and never store chat text beyond the message that was addressed to you. Trap: The obvious design is to read the channel history and mine it for questions, and the platform has closed that path: since 29 May 2025 Slack limits conversations.history and conversations.replies to one request per minute returning at most fifteen objects for newly created apps distributed outside its Marketplace, while apps built for a single workspace as internal custom apps keep the higher limits. Check the current published limits for whichever platform you use before you design around them, build this as an internal app for your own workspace, and take questions from events addressed to you rather than from history. Done when: The application makes no call to any chat history or replies endpoint, verified by review and by a test that fails if such a call appears in the source. A message addressed to the app creates exactly one question row, tested with a duplicate delivery of the same event identifier. Answering a question posts a reply to the originating message and records the reference. Chat text is stored only for messages addressed to the app, asserted by inspecting the database after a channel conversation the app was not part of. Do not build yet: No permissions, export or backup work yet. ### Phase 5 · Reading rights, files out, and a wiki that survives you Build: Add three roles and per-category read restrictions applied in the query layer. Export the whole wiki as a directory of Markdown files with front matter and an index, including revisions as a separate archive. Import that directory back. Take automatic backups and exercise a restore in the suite. Trap: Filtering restricted pages in the template means the search index, the sitemap, the link renderer and the export each need the same filter added by hand, and one of them will be forgotten, usually the search snippet that shows the first two lines of a restricted page. Apply the restriction in the query layer that every surface goes through, and add a test that calls each surface as a restricted user. Done when: A restricted user gets no trace of a restricted page from search, listings, link previews, the sitemap or the export, tested surface by surface. Exporting produces Markdown files with front matter that open in any editor, and importing them into an empty database reproduces titles, bodies, owners and categories. A restore from an automatic backup is exercised in the test suite, not documented. Deleting a page moves it to archived and keeps revisions, while a purge action removes everything and is logged. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time collaborative editing, which is a conflict resolution problem large enough to be its own project and is where most wiki clones quietly stop. - A general chat history importer, which the platforms have deliberately rate limited for apps outside their marketplaces and which is a permissions question before it is a code question. - Single sign-on and directory synchronisation, which are the enterprise line items and add a whole identity surface to a five-person tool. - An answer-generating assistant over your pages, which needs the staleness and verification work finished first or it will confidently quote the page nobody has checked since 2023. ### README must contain - That every page has an owner and that deactivating a user forces reassignment first - That staleness is computed in the query, so it shows in search results and in links, not only in a banner - That a question can only be closed by linking a page or declining with a reason - That chat capture uses events addressed to the app and never reads channel history, with the reason - How to export the wiki as Markdown files and how to import them back, with the round-trip tested ===== AGENTS.md ===== # Agent instructions · Tettra indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, one process, small team behind a reverse proxy, SQLite with every page revision kept, Markdown stored as text, rendered server side, no proprietary block format, A verification interval per page, enforced in queries not in a banner, Event subscriptions for messages addressed to the app, never history polling. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Letting ownership be optional means every page written in the first fortnight has no owner, and by the time anyone wants to know who is responsible for the onboarding page, the person who wrote it has left and the field is still empty. Require an owner on insert, and when a user is deactivated force reassignment of their pages before the deactivation completes. - Putting a yellow bar at the top of an out-of-date page changes nothing, because the reader who arrived from search has already read the wrong paragraph before scrolling up, and the reader who arrived from a link never sees it. Carry the state into the search ranking and into every rendered internal link, so a stale page looks stale before it is opened. - Letting a question be closed with a free-text answer produces a helpful reply that lives in the question and nowhere else, which means the next person asks the same thing and the wiki never grows. Make page or decline the only two exits, and let the page creation form be prefilled from the question so the easy path is also the right one. - The obvious design is to read the channel history and mine it for questions, and the platform has closed that path: since 29 May 2025 Slack limits conversations.history and conversations.replies to one request per minute returning at most fifteen objects for newly created apps distributed outside its Marketplace, while apps built for a single workspace as internal custom apps keep the higher limits. Check the current published limits for whichever platform you use before you design around them, build this as an internal app for your own workspace, and take questions from events addressed to you rather than from history. ===== BUILD_PLAN.md ===== # Build plan · Tettra An internal knowledge base whose hard problems are making staleness visible everywhere a page appears rather than in a banner, turning questions into a queue that produces pages, and capturing chat questions without mirroring a chat history you have no right to hold. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Pages with an owner and a revision trail Content is Markdown with every save kept, and no page exists without a named owner. ### Steps 1. Require an owner on every page Enforced at insert, not requested in the interface. Files: `src/db/schema.sql`, `src/pages/create.ts` 2. Write a revision on every save Append only, with author and summary. Files: `src/pages/save.ts`, `tests/revisions.test.ts` 3. Block deactivation until pages are reassigned This is the moment ownership actually gets decided. Files: `src/users/deactivate.ts` ### Done when - [ ] A page without an active owner cannot be saved - [ ] Every save writes an immutable revision - [ ] Deactivation is blocked until reassignment - [ ] Revision diffs render on a fixture pair ### Watch out - Letting ownership be optional means every page written in the first fortnight has no owner, and by the time anyone wants to know who is responsible for the onboarding page, the person who wrote it has left and the field is still empty. Require an owner on insert, and when a user is deactivated force reassignment of their pages before the deactivation completes. ## Phase 2 · Staleness that follows the page everywhere An expired page is visibly stale in search results, in links to it, and in the owner's queue. ### Steps 1. Compute staleness in the query, not the template So every surface inherits it without being told. Files: `src/pages/queries.ts`, `tests/staleness.test.ts` 2. Mark stale targets in rendered internal links The reader sees it before they click, which is the only useful moment. Files: `src/render/links.ts` 3. Give owners a queue with confirm, edit and retire Three actions, each recorded, none of them a dismiss button. Files: `src/app/queue/page.tsx`, `src/pages/verify.ts` ### Done when - [ ] Stale pages are marked and demoted in search - [ ] Internal links to stale pages are marked - [ ] Confirming writes a verification row and resets the clock - [ ] A never-verified page counts as stale ### Watch out - Putting a yellow bar at the top of an out-of-date page changes nothing, because the reader who arrived from search has already read the wrong paragraph before scrolling up, and the reader who arrived from a link never sees it. Carry the state into the search ranking and into every rendered internal link, so a stale page looks stale before it is opened. ## Phase 3 · Questions are rows until they are pages An unanswered question is tracked, assigned and closed by producing or linking a page. ### Steps 1. Make page or decline the only exits A free-text answer is knowledge that evaporates. Files: `src/questions/state.ts`, `tests/questions.test.ts` 2. Prefill the page form from the question The right path has to be the fastest one. Files: `src/app/pages/new/page.tsx` 3. Keep questions attached to the page So a page can be judged against what it was for. Files: `src/db/schema.sql` ### Done when - [ ] Answered requires a linked page - [ ] Declining requires a reason and stays searchable - [ ] Pages list the questions they answer - [ ] Answering prefills a new page from the question ### Watch out - Letting a question be closed with a free-text answer produces a helpful reply that lives in the question and nowhere else, which means the next person asks the same thing and the wiki never grows. Make page or decline the only two exits, and let the page creation form be prefilled from the question so the easy path is also the right one. ## Phase 4 · Questions from chat, without mirroring chat A question asked in team chat becomes a row through an event subscription, and no chat history is ever read. ### Steps 1. Take events addressed to the app only Which is also the only thing the platform will reliably let you have. Files: `src/chat/events.ts`, `tests/no-history-calls.test.ts` 2. Deduplicate by event identifier Every event platform redelivers, and a duplicate question is noise. Files: `src/chat/dedupe.ts` 3. Reply in the originating thread and record the reference So the asker sees the answer where they asked. Files: `src/chat/reply.ts` ### Done when - [ ] No history or replies endpoint is called anywhere - [ ] A redelivered event creates one question - [ ] Answers post back to the originating message - [ ] No unaddressed chat text is stored ### Watch out - The obvious design is to read the channel history and mine it for questions, and the platform has closed that path: since 29 May 2025 Slack limits conversations.history and conversations.replies to one request per minute returning at most fifteen objects for newly created apps distributed outside its Marketplace, while apps built for a single workspace as internal custom apps keep the higher limits. Check the current published limits for whichever platform you use before you design around them, build this as an internal app for your own workspace, and take questions from events addressed to you rather than from history. ## Not in this build - Real-time collaborative editing. - A chat history importer. - Single sign-on and directory synchronisation. - An answer-generating assistant over your pages. ## After v1, if you want it - A monthly digest per owner instead of a queue page, once the queue is big enough that nobody opens it - Page templates per category, which is the cheapest way to make new pages consistent - A read-tracking signal on stale pages, so the ones people actually open get verified first ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Pages, revisions, questions and verifications. DATABASE_PATH=./data/wiki.db # Required. Automatic backups. The restore path is exercised in the suite. BACKUP_DIR=./data/backups # Required. Fallback interval when a category does not set one. DEFAULT_VERIFY_DAYS=180 # Optional · secret. Verifies incoming chat event signatures. Reject anything unsigned. CHAT_SIGNING_SECRET=redacted # Optional · secret. Posts replies only. No history scopes should be attached to it. CHAT_BOT_TOKEN=xoxb-...redacted # Required. Markdown files with front matter, plus the revision archive. EXPORT_DIR=./exports
You are building a lean indie version of Tettra. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Tettra · indie build An internal knowledge base whose hard problems are making staleness visible everywhere a page appears rather than in a banner, turning questions into a queue that produces pages, and capturing chat questions without mirroring a chat history you have no right to hold. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, one process, small team behind a reverse proxy | a wiki for five people does not need an orchestrator | | Storage | SQLite with every page revision kept | the previous wording is frequently the answer to why a page changed | | Content | Markdown stored as text, rendered server side, no proprietary block format | a wiki you cannot export as files is a trap and everyone has been in one | | Staleness | A verification interval per page, enforced in queries not in a banner | a warning at the top of a page is read once and then never again | | Chat capture | Event subscriptions for messages addressed to the app, never history polling | reading a team's chat history is both a permissions question and a rate limit you will lose to | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the wiki. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **An internal custom app in your own chat workspace** (optional) · free - Why: The rate limits and the permissions for apps distributed outside a platform's marketplace are materially worse, and a wiki for your own team has no reason to be one. - Get it: Create the app in your workspace's developer console, subscribe only to events addressed to it, and request the narrowest scopes that let it reply. - Verify: A message mentioning the app delivers an event and a reply posts back - [ ] **A reverse proxy with TLS in front of the application** · free - Why: This holds internal documentation for a team, so it is reachable over the network and should never be reachable over plain http. - Get it: Any proxy you already run, with a certificate for the internal hostname. - Verify: Plain http is redirected and the certificate is trusted on a colleague's machine - [ ] **Twenty real pages and ten real questions from your team** · free - Why: Staleness, categories and the question queue all behave differently against real content than against a demo page, and the design decisions you make on fixtures will be wrong. - Get it: Export what you have from wherever it currently lives, including the messy pages. - Verify: At least five of the pages have no obvious owner, which is the case the build has to handle - [ ] **The verification interval per category** · free - Why: One global interval produces either constant nagging or none at all, and choosing per category is the decision that makes the queue survivable. - Get it: Write down an interval for each category, shortest for anything describing a process that changes. - Verify: Intervals are configured per category and the queue is a manageable size after seeding ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Real-time collaborative editing. - A chat history importer. - Single sign-on and directory synchronisation. - An answer-generating assistant over your pages. - real-time multiplayer - enterprise permissions - large integration catalog - mobile capture and notifications If one of those is essential to you, that is the reason to keep paying for Tettra, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Tettra The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an internal wiki where every page has an owner and an expiry date, and an unanswered question is a row until it becomes a page to replace Tettra. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, one process, small team behind a reverse proxy · a wiki for five people does not need an orchestrator - Storage: SQLite with every page revision kept · the previous wording is frequently the answer to why a page changed - Content: Markdown stored as text, rendered server side, no proprietary block format · a wiki you cannot export as files is a trap and everyone has been in one - Staleness: A verification interval per page, enforced in queries not in a banner · a warning at the top of a page is read once and then never again - Chat capture: Event subscriptions for messages addressed to the app, never history polling · reading a team's chat history is both a permissions question and a rate limit you will lose to ### Data model (create this before Phase 1) - pages(id, slug, title, body_md, category_id, owner_id, created_at, updated_at, verify_every_days, verified_at, verified_by, archived_at) - revisions(id, page_id, ordinal, body_md, title, author_id, created_at, summary) · every save, never pruned - categories(id, name, parent_id, ordinal) - questions(id, asked_by, body, source, source_ref, asked_at, state, answer_page_id, answered_at, answered_by) · source is web or chat and state is open, answered or declined - page_questions(page_id, question_id) · the questions a page was written to answer stay attached to it - verifications(id, page_id, user_id, at, outcome, note) · outcome is confirmed, edited or retired - users(id, name, email, role, active) · role is reader, author or admin - A page is stale when verified_at plus verify_every_days is in the past. That expression appears in the search query and the link renderer, not only in a banner on the page itself. ### Phase 1 · Pages with an owner and a revision trail Build: Create, edit and categorise pages written in Markdown. Every save writes a revision row with the author and an optional summary. A page cannot be created or saved without an owner who is an active user. Show the revision list and a difference between any two revisions. Trap: Letting ownership be optional means every page written in the first fortnight has no owner, and by the time anyone wants to know who is responsible for the onboarding page, the person who wrote it has left and the field is still empty. Require an owner on insert, and when a user is deactivated force reassignment of their pages before the deactivation completes. Done when: A page cannot be saved without an active owner, tested by attempting it. Every save produces a revision row and no revision is ever updated or deleted, enforced in the schema. Deactivating a user is blocked until their pages are reassigned, with the page count shown. A difference between any two revisions renders correctly on a fixture pair. Do not build yet: No staleness, questions, chat capture or search yet. ### Phase 2 · Staleness that follows the page everywhere Build: Give each page a verification interval. Compute staleness in the query rather than at render, so search results, internal links and listings all carry the state. Give each owner a queue of their expired pages with three actions: confirm, edit or retire, each writing a verification row. Trap: Putting a yellow bar at the top of an out-of-date page changes nothing, because the reader who arrived from search has already read the wrong paragraph before scrolling up, and the reader who arrived from a link never sees it. Carry the state into the search ranking and into every rendered internal link, so a stale page looks stale before it is opened. Done when: Search results render stale pages visibly differently and rank them below fresh ones, tested with a fixture set. An internal link to a stale page is marked as such in the rendering of the linking page, tested. Confirming a page without editing it writes a verification row and resets the clock, and the verification history is visible on the page. A page that has never been verified is treated as stale rather than as fresh, tested at the boundary. Do not build yet: No questions, chat capture, permissions or export yet. ### Phase 3 · Questions are rows until they are pages Build: Capture a question as a row with its asker and source. Route it to a person. Closing it requires either creating a page, linking an existing page, or declining with a reason. Keep the question attached to the page afterwards, so the page shows what it was written to answer. Trap: Letting a question be closed with a free-text answer produces a helpful reply that lives in the question and nowhere else, which means the next person asks the same thing and the wiki never grows. Make page or decline the only two exits, and let the page creation form be prefilled from the question so the easy path is also the right one. Done when: A question cannot reach the answered state without an answer_page_id, enforced in the state transition and tested. Declining requires a reason and the question stays searchable, so a recurring declined question is discoverable. A page shows the questions it was written to answer, and those stay attached when the page is edited. Answering a question from the queue prefills a new page with its text, checked by walking the flow. Do not build yet: No chat capture, search tuning, permissions or export yet. ### Phase 4 · Questions from chat, without mirroring chat Build: Subscribe to events for messages that mention the app or use its shortcut, and create a question row from that single message. Post the answer back as a reply. Never call a history or replies endpoint, and never store chat text beyond the message that was addressed to you. Trap: The obvious design is to read the channel history and mine it for questions, and the platform has closed that path: since 29 May 2025 Slack limits conversations.history and conversations.replies to one request per minute returning at most fifteen objects for newly created apps distributed outside its Marketplace, while apps built for a single workspace as internal custom apps keep the higher limits. Check the current published limits for whichever platform you use before you design around them, build this as an internal app for your own workspace, and take questions from events addressed to you rather than from history. Done when: The application makes no call to any chat history or replies endpoint, verified by review and by a test that fails if such a call appears in the source. A message addressed to the app creates exactly one question row, tested with a duplicate delivery of the same event identifier. Answering a question posts a reply to the originating message and records the reference. Chat text is stored only for messages addressed to the app, asserted by inspecting the database after a channel conversation the app was not part of. Do not build yet: No permissions, export or backup work yet. ### Phase 5 · Reading rights, files out, and a wiki that survives you Build: Add three roles and per-category read restrictions applied in the query layer. Export the whole wiki as a directory of Markdown files with front matter and an index, including revisions as a separate archive. Import that directory back. Take automatic backups and exercise a restore in the suite. Trap: Filtering restricted pages in the template means the search index, the sitemap, the link renderer and the export each need the same filter added by hand, and one of them will be forgotten, usually the search snippet that shows the first two lines of a restricted page. Apply the restriction in the query layer that every surface goes through, and add a test that calls each surface as a restricted user. Done when: A restricted user gets no trace of a restricted page from search, listings, link previews, the sitemap or the export, tested surface by surface. Exporting produces Markdown files with front matter that open in any editor, and importing them into an empty database reproduces titles, bodies, owners and categories. A restore from an automatic backup is exercised in the test suite, not documented. Deleting a page moves it to archived and keeps revisions, while a purge action removes everything and is logged. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time collaborative editing, which is a conflict resolution problem large enough to be its own project and is where most wiki clones quietly stop. - A general chat history importer, which the platforms have deliberately rate limited for apps outside their marketplaces and which is a permissions question before it is a code question. - Single sign-on and directory synchronisation, which are the enterprise line items and add a whole identity surface to a five-person tool. - An answer-generating assistant over your pages, which needs the staleness and verification work finished first or it will confidently quote the page nobody has checked since 2023. ### README must contain - That every page has an owner and that deactivating a user forces reassignment first - That staleness is computed in the query, so it shows in search results and in links, not only in a banner - That a question can only be closed by linking a page or declining with a reason - That chat capture uses events addressed to the app and never reads channel history, with the reason - How to export the wiki as Markdown files and how to import them back, with the round-trip tested ===== AGENTS.md ===== # Agent instructions · Tettra indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, one process, small team behind a reverse proxy, SQLite with every page revision kept, Markdown stored as text, rendered server side, no proprietary block format, A verification interval per page, enforced in queries not in a banner, Event subscriptions for messages addressed to the app, never history polling. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Letting ownership be optional means every page written in the first fortnight has no owner, and by the time anyone wants to know who is responsible for the onboarding page, the person who wrote it has left and the field is still empty. Require an owner on insert, and when a user is deactivated force reassignment of their pages before the deactivation completes. - Putting a yellow bar at the top of an out-of-date page changes nothing, because the reader who arrived from search has already read the wrong paragraph before scrolling up, and the reader who arrived from a link never sees it. Carry the state into the search ranking and into every rendered internal link, so a stale page looks stale before it is opened. - Letting a question be closed with a free-text answer produces a helpful reply that lives in the question and nowhere else, which means the next person asks the same thing and the wiki never grows. Make page or decline the only two exits, and let the page creation form be prefilled from the question so the easy path is also the right one. - The obvious design is to read the channel history and mine it for questions, and the platform has closed that path: since 29 May 2025 Slack limits conversations.history and conversations.replies to one request per minute returning at most fifteen objects for newly created apps distributed outside its Marketplace, while apps built for a single workspace as internal custom apps keep the higher limits. Check the current published limits for whichever platform you use before you design around them, build this as an internal app for your own workspace, and take questions from events addressed to you rather than from history. ===== BUILD_PLAN.md ===== # Build plan · Tettra An internal knowledge base whose hard problems are making staleness visible everywhere a page appears rather than in a banner, turning questions into a queue that produces pages, and capturing chat questions without mirroring a chat history you have no right to hold. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Pages with an owner and a revision trail Content is Markdown with every save kept, and no page exists without a named owner. ### Steps 1. Require an owner on every page Enforced at insert, not requested in the interface. Files: `src/db/schema.sql`, `src/pages/create.ts` 2. Write a revision on every save Append only, with author and summary. Files: `src/pages/save.ts`, `tests/revisions.test.ts` 3. Block deactivation until pages are reassigned This is the moment ownership actually gets decided. Files: `src/users/deactivate.ts` ### Done when - [ ] A page without an active owner cannot be saved - [ ] Every save writes an immutable revision - [ ] Deactivation is blocked until reassignment - [ ] Revision diffs render on a fixture pair ### Watch out - Letting ownership be optional means every page written in the first fortnight has no owner, and by the time anyone wants to know who is responsible for the onboarding page, the person who wrote it has left and the field is still empty. Require an owner on insert, and when a user is deactivated force reassignment of their pages before the deactivation completes. ## Phase 2 · Staleness that follows the page everywhere An expired page is visibly stale in search results, in links to it, and in the owner's queue. ### Steps 1. Compute staleness in the query, not the template So every surface inherits it without being told. Files: `src/pages/queries.ts`, `tests/staleness.test.ts` 2. Mark stale targets in rendered internal links The reader sees it before they click, which is the only useful moment. Files: `src/render/links.ts` 3. Give owners a queue with confirm, edit and retire Three actions, each recorded, none of them a dismiss button. Files: `src/app/queue/page.tsx`, `src/pages/verify.ts` ### Done when - [ ] Stale pages are marked and demoted in search - [ ] Internal links to stale pages are marked - [ ] Confirming writes a verification row and resets the clock - [ ] A never-verified page counts as stale ### Watch out - Putting a yellow bar at the top of an out-of-date page changes nothing, because the reader who arrived from search has already read the wrong paragraph before scrolling up, and the reader who arrived from a link never sees it. Carry the state into the search ranking and into every rendered internal link, so a stale page looks stale before it is opened. ## Phase 3 · Questions are rows until they are pages An unanswered question is tracked, assigned and closed by producing or linking a page. ### Steps 1. Make page or decline the only exits A free-text answer is knowledge that evaporates. Files: `src/questions/state.ts`, `tests/questions.test.ts` 2. Prefill the page form from the question The right path has to be the fastest one. Files: `src/app/pages/new/page.tsx` 3. Keep questions attached to the page So a page can be judged against what it was for. Files: `src/db/schema.sql` ### Done when - [ ] Answered requires a linked page - [ ] Declining requires a reason and stays searchable - [ ] Pages list the questions they answer - [ ] Answering prefills a new page from the question ### Watch out - Letting a question be closed with a free-text answer produces a helpful reply that lives in the question and nowhere else, which means the next person asks the same thing and the wiki never grows. Make page or decline the only two exits, and let the page creation form be prefilled from the question so the easy path is also the right one. ## Phase 4 · Questions from chat, without mirroring chat A question asked in team chat becomes a row through an event subscription, and no chat history is ever read. ### Steps 1. Take events addressed to the app only Which is also the only thing the platform will reliably let you have. Files: `src/chat/events.ts`, `tests/no-history-calls.test.ts` 2. Deduplicate by event identifier Every event platform redelivers, and a duplicate question is noise. Files: `src/chat/dedupe.ts` 3. Reply in the originating thread and record the reference So the asker sees the answer where they asked. Files: `src/chat/reply.ts` ### Done when - [ ] No history or replies endpoint is called anywhere - [ ] A redelivered event creates one question - [ ] Answers post back to the originating message - [ ] No unaddressed chat text is stored ### Watch out - The obvious design is to read the channel history and mine it for questions, and the platform has closed that path: since 29 May 2025 Slack limits conversations.history and conversations.replies to one request per minute returning at most fifteen objects for newly created apps distributed outside its Marketplace, while apps built for a single workspace as internal custom apps keep the higher limits. Check the current published limits for whichever platform you use before you design around them, build this as an internal app for your own workspace, and take questions from events addressed to you rather than from history. ## Not in this build - Real-time collaborative editing. - A chat history importer. - Single sign-on and directory synchronisation. - An answer-generating assistant over your pages. ## After v1, if you want it - A monthly digest per owner instead of a queue page, once the queue is big enough that nobody opens it - Page templates per category, which is the cheapest way to make new pages consistent - A read-tracking signal on stale pages, so the ones people actually open get verified first ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Pages, revisions, questions and verifications. DATABASE_PATH=./data/wiki.db # Required. Automatic backups. The restore path is exercised in the suite. BACKUP_DIR=./data/backups # Required. Fallback interval when a category does not set one. DEFAULT_VERIFY_DAYS=180 # Optional · secret. Verifies incoming chat event signatures. Reject anything unsigned. CHAT_SIGNING_SECRET=redacted # Optional · secret. Posts replies only. No history scopes should be attached to it. CHAT_BOT_TOKEN=xoxb-...redacted # Required. Markdown files with front matter, plus the revision archive. EXPORT_DIR=./exports
You are building a production product version of Tettra. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Tettra · product brief ## Problem The visible knowledge management loop is buildable, but a credible replacement needs more than the first screen. Tettra earns its keep through collaboration, integrations, polish, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A wiki small enough to run yourself where a page nobody has checked looks unchecked from the search results onwards, questions turn into pages instead of into replies, and the whole thing exports to a folder of Markdown files you can read without it. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - local or self-hosted runtime - SQLite database ## Explicit non-goals for v1 - Real-time collaborative editing. - A chat history importer. - Single sign-on and directory synchronisation. - An answer-generating assistant over your pages. - real-time multiplayer - enterprise permissions - large integration catalog - mobile capture and notifications ## Success criteria - A page is confirmed unsavable without an active owner - Stale pages are confirmed marked in search results and in internal links - A question is confirmed unanswerable without a linked page - No history or replies endpoint call is confirmed present in the source - A restricted page is confirmed absent from every surface for a restricted user ===== BRIEF.md ===== # Build brief · Tettra The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an internal wiki where every page has an owner and an expiry date, and an unanswered question is a row until it becomes a page to replace Tettra. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, one process, small team behind a reverse proxy · a wiki for five people does not need an orchestrator - Storage: SQLite with every page revision kept · the previous wording is frequently the answer to why a page changed - Content: Markdown stored as text, rendered server side, no proprietary block format · a wiki you cannot export as files is a trap and everyone has been in one - Staleness: A verification interval per page, enforced in queries not in a banner · a warning at the top of a page is read once and then never again - Chat capture: Event subscriptions for messages addressed to the app, never history polling · reading a team's chat history is both a permissions question and a rate limit you will lose to ### Data model (create this before Phase 1) - pages(id, slug, title, body_md, category_id, owner_id, created_at, updated_at, verify_every_days, verified_at, verified_by, archived_at) - revisions(id, page_id, ordinal, body_md, title, author_id, created_at, summary) · every save, never pruned - categories(id, name, parent_id, ordinal) - questions(id, asked_by, body, source, source_ref, asked_at, state, answer_page_id, answered_at, answered_by) · source is web or chat and state is open, answered or declined - page_questions(page_id, question_id) · the questions a page was written to answer stay attached to it - verifications(id, page_id, user_id, at, outcome, note) · outcome is confirmed, edited or retired - users(id, name, email, role, active) · role is reader, author or admin - A page is stale when verified_at plus verify_every_days is in the past. That expression appears in the search query and the link renderer, not only in a banner on the page itself. ### Phase 1 · Pages with an owner and a revision trail Build: Create, edit and categorise pages written in Markdown. Every save writes a revision row with the author and an optional summary. A page cannot be created or saved without an owner who is an active user. Show the revision list and a difference between any two revisions. Trap: Letting ownership be optional means every page written in the first fortnight has no owner, and by the time anyone wants to know who is responsible for the onboarding page, the person who wrote it has left and the field is still empty. Require an owner on insert, and when a user is deactivated force reassignment of their pages before the deactivation completes. Done when: A page cannot be saved without an active owner, tested by attempting it. Every save produces a revision row and no revision is ever updated or deleted, enforced in the schema. Deactivating a user is blocked until their pages are reassigned, with the page count shown. A difference between any two revisions renders correctly on a fixture pair. Do not build yet: No staleness, questions, chat capture or search yet. ### Phase 2 · Staleness that follows the page everywhere Build: Give each page a verification interval. Compute staleness in the query rather than at render, so search results, internal links and listings all carry the state. Give each owner a queue of their expired pages with three actions: confirm, edit or retire, each writing a verification row. Trap: Putting a yellow bar at the top of an out-of-date page changes nothing, because the reader who arrived from search has already read the wrong paragraph before scrolling up, and the reader who arrived from a link never sees it. Carry the state into the search ranking and into every rendered internal link, so a stale page looks stale before it is opened. Done when: Search results render stale pages visibly differently and rank them below fresh ones, tested with a fixture set. An internal link to a stale page is marked as such in the rendering of the linking page, tested. Confirming a page without editing it writes a verification row and resets the clock, and the verification history is visible on the page. A page that has never been verified is treated as stale rather than as fresh, tested at the boundary. Do not build yet: No questions, chat capture, permissions or export yet. ### Phase 3 · Questions are rows until they are pages Build: Capture a question as a row with its asker and source. Route it to a person. Closing it requires either creating a page, linking an existing page, or declining with a reason. Keep the question attached to the page afterwards, so the page shows what it was written to answer. Trap: Letting a question be closed with a free-text answer produces a helpful reply that lives in the question and nowhere else, which means the next person asks the same thing and the wiki never grows. Make page or decline the only two exits, and let the page creation form be prefilled from the question so the easy path is also the right one. Done when: A question cannot reach the answered state without an answer_page_id, enforced in the state transition and tested. Declining requires a reason and the question stays searchable, so a recurring declined question is discoverable. A page shows the questions it was written to answer, and those stay attached when the page is edited. Answering a question from the queue prefills a new page with its text, checked by walking the flow. Do not build yet: No chat capture, search tuning, permissions or export yet. ### Phase 4 · Questions from chat, without mirroring chat Build: Subscribe to events for messages that mention the app or use its shortcut, and create a question row from that single message. Post the answer back as a reply. Never call a history or replies endpoint, and never store chat text beyond the message that was addressed to you. Trap: The obvious design is to read the channel history and mine it for questions, and the platform has closed that path: since 29 May 2025 Slack limits conversations.history and conversations.replies to one request per minute returning at most fifteen objects for newly created apps distributed outside its Marketplace, while apps built for a single workspace as internal custom apps keep the higher limits. Check the current published limits for whichever platform you use before you design around them, build this as an internal app for your own workspace, and take questions from events addressed to you rather than from history. Done when: The application makes no call to any chat history or replies endpoint, verified by review and by a test that fails if such a call appears in the source. A message addressed to the app creates exactly one question row, tested with a duplicate delivery of the same event identifier. Answering a question posts a reply to the originating message and records the reference. Chat text is stored only for messages addressed to the app, asserted by inspecting the database after a channel conversation the app was not part of. Do not build yet: No permissions, export or backup work yet. ### Phase 5 · Reading rights, files out, and a wiki that survives you Build: Add three roles and per-category read restrictions applied in the query layer. Export the whole wiki as a directory of Markdown files with front matter and an index, including revisions as a separate archive. Import that directory back. Take automatic backups and exercise a restore in the suite. Trap: Filtering restricted pages in the template means the search index, the sitemap, the link renderer and the export each need the same filter added by hand, and one of them will be forgotten, usually the search snippet that shows the first two lines of a restricted page. Apply the restriction in the query layer that every surface goes through, and add a test that calls each surface as a restricted user. Done when: A restricted user gets no trace of a restricted page from search, listings, link previews, the sitemap or the export, tested surface by surface. Exporting produces Markdown files with front matter that open in any editor, and importing them into an empty database reproduces titles, bodies, owners and categories. A restore from an automatic backup is exercised in the test suite, not documented. Deleting a page moves it to archived and keeps revisions, while a purge action removes everything and is logged. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time collaborative editing, which is a conflict resolution problem large enough to be its own project and is where most wiki clones quietly stop. - A general chat history importer, which the platforms have deliberately rate limited for apps outside their marketplaces and which is a permissions question before it is a code question. - Single sign-on and directory synchronisation, which are the enterprise line items and add a whole identity surface to a five-person tool. - An answer-generating assistant over your pages, which needs the staleness and verification work finished first or it will confidently quote the page nobody has checked since 2023. ### README must contain - That every page has an owner and that deactivating a user forces reassignment first - That staleness is computed in the query, so it shows in search results and in links, not only in a banner - That a question can only be closed by linking a page or declining with a reason - That chat capture uses events addressed to the app and never reads channel history, with the reason - How to export the wiki as Markdown files and how to import them back, with the round-trip tested ===== ARCHITECTURE.md ===== # Architecture · Tettra ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, one process, small team behind a reverse proxy | a wiki for five people does not need an orchestrator | | Storage | SQLite with every page revision kept | the previous wording is frequently the answer to why a page changed | | Content | Markdown stored as text, rendered server side, no proprietary block format | a wiki you cannot export as files is a trap and everyone has been in one | | Staleness | A verification interval per page, enforced in queries not in a banner | a warning at the top of a page is read once and then never again | | Chat capture | Event subscriptions for messages addressed to the app, never history polling | reading a team's chat history is both a permissions question and a rate limit you will lose to | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Pages | Content, revisions and ownership. | Any editor, provided every save writes a revision. | | Staleness | Intervals, verification rows and query-level state. | Any policy, provided the state is computed in the query. | | Questions | The queue and the page-or-decline exits. | Not swappable for free-text answers, which is the failure this prevents. | | Chat | Event handling, deduplication and replies. | Any platform, provided it is events addressed to the app and never history. | | Access | Roles and per-category scoping in the query layer. | Any model, provided every surface goes through one filter. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Pages, revisions, questions and verifications. - `BACKUP_DIR` · required · Automatic backups. The restore path is exercised in the suite. - `DEFAULT_VERIFY_DAYS` · required · Fallback interval when a category does not set one. - `CHAT_SIGNING_SECRET` · optional, secret · Verifies incoming chat event signatures. Reject anything unsigned. - `CHAT_BOT_TOKEN` · optional, secret · Posts replies only. No history scopes should be attached to it. - `EXPORT_DIR` · required · Markdown files with front matter, plus the revision archive. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Tettra product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript, one process, small team behind a reverse proxy, SQLite with every page revision kept, Markdown stored as text, rendered server side, no proprietary block format, A verification interval per page, enforced in queries not in a banner, Event subscriptions for messages addressed to the app, never history polling. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Letting ownership be optional means every page written in the first fortnight has no owner, and by the time anyone wants to know who is responsible for the onboarding page, the person who wrote it has left and the field is still empty. Require an owner on insert, and when a user is deactivated force reassignment of their pages before the deactivation completes. - Putting a yellow bar at the top of an out-of-date page changes nothing, because the reader who arrived from search has already read the wrong paragraph before scrolling up, and the reader who arrived from a link never sees it. Carry the state into the search ranking and into every rendered internal link, so a stale page looks stale before it is opened. - Letting a question be closed with a free-text answer produces a helpful reply that lives in the question and nowhere else, which means the next person asks the same thing and the wiki never grows. Make page or decline the only two exits, and let the page creation form be prefilled from the question so the easy path is also the right one. - The obvious design is to read the channel history and mine it for questions, and the platform has closed that path: since 29 May 2025 Slack limits conversations.history and conversations.replies to one request per minute returning at most fifteen objects for newly created apps distributed outside its Marketplace, while apps built for a single workspace as internal custom apps keep the higher limits. Check the current published limits for whichever platform you use before you design around them, build this as an internal app for your own workspace, and take questions from events addressed to you rather than from history. - Filtering restricted pages in the template means the search index, the sitemap, the link renderer and the export each need the same filter added by hand, and one of them will be forgotten, usually the search snippet that shows the first two lines of a restricted page. Apply the restriction in the query layer that every surface goes through, and add a test that calls each surface as a restricted user. ===== MILESTONES.md ===== # Delivery milestones · Tettra Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Pages with an owner and a revision trail Content is Markdown with every save kept, and no page exists without a named owner. ### Steps 1. Require an owner on every page Enforced at insert, not requested in the interface. Files: `src/db/schema.sql`, `src/pages/create.ts` 2. Write a revision on every save Append only, with author and summary. Files: `src/pages/save.ts`, `tests/revisions.test.ts` 3. Block deactivation until pages are reassigned This is the moment ownership actually gets decided. Files: `src/users/deactivate.ts` ### Done when - [ ] A page without an active owner cannot be saved - [ ] Every save writes an immutable revision - [ ] Deactivation is blocked until reassignment - [ ] Revision diffs render on a fixture pair ### Watch out - Letting ownership be optional means every page written in the first fortnight has no owner, and by the time anyone wants to know who is responsible for the onboarding page, the person who wrote it has left and the field is still empty. Require an owner on insert, and when a user is deactivated force reassignment of their pages before the deactivation completes. ## M2 · Staleness that follows the page everywhere An expired page is visibly stale in search results, in links to it, and in the owner's queue. ### Steps 1. Compute staleness in the query, not the template So every surface inherits it without being told. Files: `src/pages/queries.ts`, `tests/staleness.test.ts` 2. Mark stale targets in rendered internal links The reader sees it before they click, which is the only useful moment. Files: `src/render/links.ts` 3. Give owners a queue with confirm, edit and retire Three actions, each recorded, none of them a dismiss button. Files: `src/app/queue/page.tsx`, `src/pages/verify.ts` ### Done when - [ ] Stale pages are marked and demoted in search - [ ] Internal links to stale pages are marked - [ ] Confirming writes a verification row and resets the clock - [ ] A never-verified page counts as stale ### Watch out - Putting a yellow bar at the top of an out-of-date page changes nothing, because the reader who arrived from search has already read the wrong paragraph before scrolling up, and the reader who arrived from a link never sees it. Carry the state into the search ranking and into every rendered internal link, so a stale page looks stale before it is opened. ## M3 · Questions are rows until they are pages An unanswered question is tracked, assigned and closed by producing or linking a page. ### Steps 1. Make page or decline the only exits A free-text answer is knowledge that evaporates. Files: `src/questions/state.ts`, `tests/questions.test.ts` 2. Prefill the page form from the question The right path has to be the fastest one. Files: `src/app/pages/new/page.tsx` 3. Keep questions attached to the page So a page can be judged against what it was for. Files: `src/db/schema.sql` ### Done when - [ ] Answered requires a linked page - [ ] Declining requires a reason and stays searchable - [ ] Pages list the questions they answer - [ ] Answering prefills a new page from the question ### Watch out - Letting a question be closed with a free-text answer produces a helpful reply that lives in the question and nowhere else, which means the next person asks the same thing and the wiki never grows. Make page or decline the only two exits, and let the page creation form be prefilled from the question so the easy path is also the right one. ## M4 · Questions from chat, without mirroring chat A question asked in team chat becomes a row through an event subscription, and no chat history is ever read. ### Steps 1. Take events addressed to the app only Which is also the only thing the platform will reliably let you have. Files: `src/chat/events.ts`, `tests/no-history-calls.test.ts` 2. Deduplicate by event identifier Every event platform redelivers, and a duplicate question is noise. Files: `src/chat/dedupe.ts` 3. Reply in the originating thread and record the reference So the asker sees the answer where they asked. Files: `src/chat/reply.ts` ### Done when - [ ] No history or replies endpoint is called anywhere - [ ] A redelivered event creates one question - [ ] Answers post back to the originating message - [ ] No unaddressed chat text is stored ### Watch out - The obvious design is to read the channel history and mine it for questions, and the platform has closed that path: since 29 May 2025 Slack limits conversations.history and conversations.replies to one request per minute returning at most fifteen objects for newly created apps distributed outside its Marketplace, while apps built for a single workspace as internal custom apps keep the higher limits. Check the current published limits for whichever platform you use before you design around them, build this as an internal app for your own workspace, and take questions from events addressed to you rather than from history. ## M5 · Reading rights, files out, and a wiki that survives you (production only) Access is per category, the whole wiki exports as Markdown files, and a restore is tested rather than assumed. ### Steps 1. Apply read restrictions in the query layer One place, not five, because five means four and a leak. Files: `src/auth/scope.ts`, `tests/restricted-surfaces.test.ts` 2. Export to Markdown files with front matter The format check is whether a text editor can read it. Files: `src/export/wiki.ts`, `tests/roundtrip.test.ts` 3. Exercise the restore in the suite A backup nobody has restored is a hope. Files: `src/backup/restore.ts`, `tests/restore.test.ts` ### Done when - [ ] A restricted page leaks from no surface - [ ] Export and reimport round-trips content and metadata - [ ] Restore is exercised in the suite - [ ] Archive keeps revisions and purge is logged ### Watch out - Filtering restricted pages in the template means the search index, the sitemap, the link renderer and the export each need the same filter added by hand, and one of them will be forgotten, usually the search snippet that shows the first two lines of a restricted page. Apply the restriction in the query layer that every surface goes through, and add a test that calls each surface as a restricted user. ===== OPERATIONS.md ===== # Operations · Tettra ## Backup The database, which holds pages, every revision and the verification history. Exports are the second line of defence because they are readable without this application at all. ## Restore Restore, then check that the stale page count matches what it was and that a restricted user still sees nothing from a restricted category. Both are single queries and both catch a bad restore. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring The size of the verification queue per owner, the number of questions open longer than a week, and the ratio of declined to answered questions. A growing decline rate usually means the wrong people are being asked. ## Incident checklist The incident here is someone following a wrong page. The revision trail tells you what it said when they read it, and the verification rows tell you who last confirmed it and when. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A page is confirmed unsavable without an active owner - [ ] Stale pages are confirmed marked in search results and in internal links - [ ] A question is confirmed unanswerable without a linked page - [ ] No history or replies endpoint call is confirmed present in the source - [ ] A restricted page is confirmed absent from every surface for a restricted user ## Launch constraint Do not market omitted Tettra capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Pages, revisions, questions and verifications. DATABASE_PATH=./data/wiki.db # Required. Automatic backups. The restore path is exercised in the suite. BACKUP_DIR=./data/backups # Required. Fallback interval when a category does not set one. DEFAULT_VERIFY_DAYS=180 # Optional · secret. Verifies incoming chat event signatures. Reject anything unsigned. CHAT_SIGNING_SECRET=redacted # Optional · secret. Posts replies only. No history scopes should be attached to it. CHAT_BOT_TOKEN=xoxb-...redacted # Required. Markdown files with front matter, plus the revision archive. EXPORT_DIR=./exports
# Tettra · indie build An internal knowledge base whose hard problems are making staleness visible everywhere a page appears rather than in a banner, turning questions into a queue that produces pages, and capturing chat questions without mirroring a chat history you have no right to hold. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, one process, small team behind a reverse proxy | a wiki for five people does not need an orchestrator | | Storage | SQLite with every page revision kept | the previous wording is frequently the answer to why a page changed | | Content | Markdown stored as text, rendered server side, no proprietary block format | a wiki you cannot export as files is a trap and everyone has been in one | | Staleness | A verification interval per page, enforced in queries not in a banner | a warning at the top of a page is read once and then never again | | Chat capture | Event subscriptions for messages addressed to the app, never history polling | reading a team's chat history is both a permissions question and a rate limit you will lose to | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the wiki. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **An internal custom app in your own chat workspace** (optional) · free - Why: The rate limits and the permissions for apps distributed outside a platform's marketplace are materially worse, and a wiki for your own team has no reason to be one. - Get it: Create the app in your workspace's developer console, subscribe only to events addressed to it, and request the narrowest scopes that let it reply. - Verify: A message mentioning the app delivers an event and a reply posts back - [ ] **A reverse proxy with TLS in front of the application** · free - Why: This holds internal documentation for a team, so it is reachable over the network and should never be reachable over plain http. - Get it: Any proxy you already run, with a certificate for the internal hostname. - Verify: Plain http is redirected and the certificate is trusted on a colleague's machine - [ ] **Twenty real pages and ten real questions from your team** · free - Why: Staleness, categories and the question queue all behave differently against real content than against a demo page, and the design decisions you make on fixtures will be wrong. - Get it: Export what you have from wherever it currently lives, including the messy pages. - Verify: At least five of the pages have no obvious owner, which is the case the build has to handle - [ ] **The verification interval per category** · free - Why: One global interval produces either constant nagging or none at all, and choosing per category is the decision that makes the queue survivable. - Get it: Write down an interval for each category, shortest for anything describing a process that changes. - Verify: Intervals are configured per category and the queue is a manageable size after seeding ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Real-time collaborative editing. - A chat history importer. - Single sign-on and directory synchronisation. - An answer-generating assistant over your pages. - real-time multiplayer - enterprise permissions - large integration catalog - mobile capture and notifications If one of those is essential to you, that is the reason to keep paying for Tettra, and the README should say so rather than pretend.
# Build brief · Tettra The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an internal wiki where every page has an owner and an expiry date, and an unanswered question is a row until it becomes a page to replace Tettra. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, one process, small team behind a reverse proxy · a wiki for five people does not need an orchestrator - Storage: SQLite with every page revision kept · the previous wording is frequently the answer to why a page changed - Content: Markdown stored as text, rendered server side, no proprietary block format · a wiki you cannot export as files is a trap and everyone has been in one - Staleness: A verification interval per page, enforced in queries not in a banner · a warning at the top of a page is read once and then never again - Chat capture: Event subscriptions for messages addressed to the app, never history polling · reading a team's chat history is both a permissions question and a rate limit you will lose to ### Data model (create this before Phase 1) - pages(id, slug, title, body_md, category_id, owner_id, created_at, updated_at, verify_every_days, verified_at, verified_by, archived_at) - revisions(id, page_id, ordinal, body_md, title, author_id, created_at, summary) · every save, never pruned - categories(id, name, parent_id, ordinal) - questions(id, asked_by, body, source, source_ref, asked_at, state, answer_page_id, answered_at, answered_by) · source is web or chat and state is open, answered or declined - page_questions(page_id, question_id) · the questions a page was written to answer stay attached to it - verifications(id, page_id, user_id, at, outcome, note) · outcome is confirmed, edited or retired - users(id, name, email, role, active) · role is reader, author or admin - A page is stale when verified_at plus verify_every_days is in the past. That expression appears in the search query and the link renderer, not only in a banner on the page itself. ### Phase 1 · Pages with an owner and a revision trail Build: Create, edit and categorise pages written in Markdown. Every save writes a revision row with the author and an optional summary. A page cannot be created or saved without an owner who is an active user. Show the revision list and a difference between any two revisions. Trap: Letting ownership be optional means every page written in the first fortnight has no owner, and by the time anyone wants to know who is responsible for the onboarding page, the person who wrote it has left and the field is still empty. Require an owner on insert, and when a user is deactivated force reassignment of their pages before the deactivation completes. Done when: A page cannot be saved without an active owner, tested by attempting it. Every save produces a revision row and no revision is ever updated or deleted, enforced in the schema. Deactivating a user is blocked until their pages are reassigned, with the page count shown. A difference between any two revisions renders correctly on a fixture pair. Do not build yet: No staleness, questions, chat capture or search yet. ### Phase 2 · Staleness that follows the page everywhere Build: Give each page a verification interval. Compute staleness in the query rather than at render, so search results, internal links and listings all carry the state. Give each owner a queue of their expired pages with three actions: confirm, edit or retire, each writing a verification row. Trap: Putting a yellow bar at the top of an out-of-date page changes nothing, because the reader who arrived from search has already read the wrong paragraph before scrolling up, and the reader who arrived from a link never sees it. Carry the state into the search ranking and into every rendered internal link, so a stale page looks stale before it is opened. Done when: Search results render stale pages visibly differently and rank them below fresh ones, tested with a fixture set. An internal link to a stale page is marked as such in the rendering of the linking page, tested. Confirming a page without editing it writes a verification row and resets the clock, and the verification history is visible on the page. A page that has never been verified is treated as stale rather than as fresh, tested at the boundary. Do not build yet: No questions, chat capture, permissions or export yet. ### Phase 3 · Questions are rows until they are pages Build: Capture a question as a row with its asker and source. Route it to a person. Closing it requires either creating a page, linking an existing page, or declining with a reason. Keep the question attached to the page afterwards, so the page shows what it was written to answer. Trap: Letting a question be closed with a free-text answer produces a helpful reply that lives in the question and nowhere else, which means the next person asks the same thing and the wiki never grows. Make page or decline the only two exits, and let the page creation form be prefilled from the question so the easy path is also the right one. Done when: A question cannot reach the answered state without an answer_page_id, enforced in the state transition and tested. Declining requires a reason and the question stays searchable, so a recurring declined question is discoverable. A page shows the questions it was written to answer, and those stay attached when the page is edited. Answering a question from the queue prefills a new page with its text, checked by walking the flow. Do not build yet: No chat capture, search tuning, permissions or export yet. ### Phase 4 · Questions from chat, without mirroring chat Build: Subscribe to events for messages that mention the app or use its shortcut, and create a question row from that single message. Post the answer back as a reply. Never call a history or replies endpoint, and never store chat text beyond the message that was addressed to you. Trap: The obvious design is to read the channel history and mine it for questions, and the platform has closed that path: since 29 May 2025 Slack limits conversations.history and conversations.replies to one request per minute returning at most fifteen objects for newly created apps distributed outside its Marketplace, while apps built for a single workspace as internal custom apps keep the higher limits. Check the current published limits for whichever platform you use before you design around them, build this as an internal app for your own workspace, and take questions from events addressed to you rather than from history. Done when: The application makes no call to any chat history or replies endpoint, verified by review and by a test that fails if such a call appears in the source. A message addressed to the app creates exactly one question row, tested with a duplicate delivery of the same event identifier. Answering a question posts a reply to the originating message and records the reference. Chat text is stored only for messages addressed to the app, asserted by inspecting the database after a channel conversation the app was not part of. Do not build yet: No permissions, export or backup work yet. ### Phase 5 · Reading rights, files out, and a wiki that survives you Build: Add three roles and per-category read restrictions applied in the query layer. Export the whole wiki as a directory of Markdown files with front matter and an index, including revisions as a separate archive. Import that directory back. Take automatic backups and exercise a restore in the suite. Trap: Filtering restricted pages in the template means the search index, the sitemap, the link renderer and the export each need the same filter added by hand, and one of them will be forgotten, usually the search snippet that shows the first two lines of a restricted page. Apply the restriction in the query layer that every surface goes through, and add a test that calls each surface as a restricted user. Done when: A restricted user gets no trace of a restricted page from search, listings, link previews, the sitemap or the export, tested surface by surface. Exporting produces Markdown files with front matter that open in any editor, and importing them into an empty database reproduces titles, bodies, owners and categories. A restore from an automatic backup is exercised in the test suite, not documented. Deleting a page moves it to archived and keeps revisions, while a purge action removes everything and is logged. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time collaborative editing, which is a conflict resolution problem large enough to be its own project and is where most wiki clones quietly stop. - A general chat history importer, which the platforms have deliberately rate limited for apps outside their marketplaces and which is a permissions question before it is a code question. - Single sign-on and directory synchronisation, which are the enterprise line items and add a whole identity surface to a five-person tool. - An answer-generating assistant over your pages, which needs the staleness and verification work finished first or it will confidently quote the page nobody has checked since 2023. ### README must contain - That every page has an owner and that deactivating a user forces reassignment first - That staleness is computed in the query, so it shows in search results and in links, not only in a banner - That a question can only be closed by linking a page or declining with a reason - That chat capture uses events addressed to the app and never reads channel history, with the reason - How to export the wiki as Markdown files and how to import them back, with the round-trip tested
# Agent instructions · Tettra indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, one process, small team behind a reverse proxy, SQLite with every page revision kept, Markdown stored as text, rendered server side, no proprietary block format, A verification interval per page, enforced in queries not in a banner, Event subscriptions for messages addressed to the app, never history polling. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Letting ownership be optional means every page written in the first fortnight has no owner, and by the time anyone wants to know who is responsible for the onboarding page, the person who wrote it has left and the field is still empty. Require an owner on insert, and when a user is deactivated force reassignment of their pages before the deactivation completes. - Putting a yellow bar at the top of an out-of-date page changes nothing, because the reader who arrived from search has already read the wrong paragraph before scrolling up, and the reader who arrived from a link never sees it. Carry the state into the search ranking and into every rendered internal link, so a stale page looks stale before it is opened. - Letting a question be closed with a free-text answer produces a helpful reply that lives in the question and nowhere else, which means the next person asks the same thing and the wiki never grows. Make page or decline the only two exits, and let the page creation form be prefilled from the question so the easy path is also the right one. - The obvious design is to read the channel history and mine it for questions, and the platform has closed that path: since 29 May 2025 Slack limits conversations.history and conversations.replies to one request per minute returning at most fifteen objects for newly created apps distributed outside its Marketplace, while apps built for a single workspace as internal custom apps keep the higher limits. Check the current published limits for whichever platform you use before you design around them, build this as an internal app for your own workspace, and take questions from events addressed to you rather than from history.
# Build plan · Tettra An internal knowledge base whose hard problems are making staleness visible everywhere a page appears rather than in a banner, turning questions into a queue that produces pages, and capturing chat questions without mirroring a chat history you have no right to hold. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Pages with an owner and a revision trail Content is Markdown with every save kept, and no page exists without a named owner. ### Steps 1. Require an owner on every page Enforced at insert, not requested in the interface. Files: `src/db/schema.sql`, `src/pages/create.ts` 2. Write a revision on every save Append only, with author and summary. Files: `src/pages/save.ts`, `tests/revisions.test.ts` 3. Block deactivation until pages are reassigned This is the moment ownership actually gets decided. Files: `src/users/deactivate.ts` ### Done when - [ ] A page without an active owner cannot be saved - [ ] Every save writes an immutable revision - [ ] Deactivation is blocked until reassignment - [ ] Revision diffs render on a fixture pair ### Watch out - Letting ownership be optional means every page written in the first fortnight has no owner, and by the time anyone wants to know who is responsible for the onboarding page, the person who wrote it has left and the field is still empty. Require an owner on insert, and when a user is deactivated force reassignment of their pages before the deactivation completes. ## Phase 2 · Staleness that follows the page everywhere An expired page is visibly stale in search results, in links to it, and in the owner's queue. ### Steps 1. Compute staleness in the query, not the template So every surface inherits it without being told. Files: `src/pages/queries.ts`, `tests/staleness.test.ts` 2. Mark stale targets in rendered internal links The reader sees it before they click, which is the only useful moment. Files: `src/render/links.ts` 3. Give owners a queue with confirm, edit and retire Three actions, each recorded, none of them a dismiss button. Files: `src/app/queue/page.tsx`, `src/pages/verify.ts` ### Done when - [ ] Stale pages are marked and demoted in search - [ ] Internal links to stale pages are marked - [ ] Confirming writes a verification row and resets the clock - [ ] A never-verified page counts as stale ### Watch out - Putting a yellow bar at the top of an out-of-date page changes nothing, because the reader who arrived from search has already read the wrong paragraph before scrolling up, and the reader who arrived from a link never sees it. Carry the state into the search ranking and into every rendered internal link, so a stale page looks stale before it is opened. ## Phase 3 · Questions are rows until they are pages An unanswered question is tracked, assigned and closed by producing or linking a page. ### Steps 1. Make page or decline the only exits A free-text answer is knowledge that evaporates. Files: `src/questions/state.ts`, `tests/questions.test.ts` 2. Prefill the page form from the question The right path has to be the fastest one. Files: `src/app/pages/new/page.tsx` 3. Keep questions attached to the page So a page can be judged against what it was for. Files: `src/db/schema.sql` ### Done when - [ ] Answered requires a linked page - [ ] Declining requires a reason and stays searchable - [ ] Pages list the questions they answer - [ ] Answering prefills a new page from the question ### Watch out - Letting a question be closed with a free-text answer produces a helpful reply that lives in the question and nowhere else, which means the next person asks the same thing and the wiki never grows. Make page or decline the only two exits, and let the page creation form be prefilled from the question so the easy path is also the right one. ## Phase 4 · Questions from chat, without mirroring chat A question asked in team chat becomes a row through an event subscription, and no chat history is ever read. ### Steps 1. Take events addressed to the app only Which is also the only thing the platform will reliably let you have. Files: `src/chat/events.ts`, `tests/no-history-calls.test.ts` 2. Deduplicate by event identifier Every event platform redelivers, and a duplicate question is noise. Files: `src/chat/dedupe.ts` 3. Reply in the originating thread and record the reference So the asker sees the answer where they asked. Files: `src/chat/reply.ts` ### Done when - [ ] No history or replies endpoint is called anywhere - [ ] A redelivered event creates one question - [ ] Answers post back to the originating message - [ ] No unaddressed chat text is stored ### Watch out - The obvious design is to read the channel history and mine it for questions, and the platform has closed that path: since 29 May 2025 Slack limits conversations.history and conversations.replies to one request per minute returning at most fifteen objects for newly created apps distributed outside its Marketplace, while apps built for a single workspace as internal custom apps keep the higher limits. Check the current published limits for whichever platform you use before you design around them, build this as an internal app for your own workspace, and take questions from events addressed to you rather than from history. ## Not in this build - Real-time collaborative editing. - A chat history importer. - Single sign-on and directory synchronisation. - An answer-generating assistant over your pages. ## After v1, if you want it - A monthly digest per owner instead of a queue page, once the queue is big enough that nobody opens it - Page templates per category, which is the cheapest way to make new pages consistent - A read-tracking signal on stale pages, so the ones people actually open get verified first
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Pages, revisions, questions and verifications. DATABASE_PATH=./data/wiki.db # Required. Automatic backups. The restore path is exercised in the suite. BACKUP_DIR=./data/backups # Required. Fallback interval when a category does not set one. DEFAULT_VERIFY_DAYS=180 # Optional · secret. Verifies incoming chat event signatures. Reject anything unsigned. CHAT_SIGNING_SECRET=redacted # Optional · secret. Posts replies only. No history scopes should be attached to it. CHAT_BOT_TOKEN=xoxb-...redacted # Required. Markdown files with front matter, plus the revision archive. EXPORT_DIR=./exports
# Tettra · product brief ## Problem The visible knowledge management loop is buildable, but a credible replacement needs more than the first screen. Tettra earns its keep through collaboration, integrations, polish, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A wiki small enough to run yourself where a page nobody has checked looks unchecked from the search results onwards, questions turn into pages instead of into replies, and the whole thing exports to a folder of Markdown files you can read without it. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js 22 - local or self-hosted runtime - SQLite database ## Explicit non-goals for v1 - Real-time collaborative editing. - A chat history importer. - Single sign-on and directory synchronisation. - An answer-generating assistant over your pages. - real-time multiplayer - enterprise permissions - large integration catalog - mobile capture and notifications ## Success criteria - A page is confirmed unsavable without an active owner - Stale pages are confirmed marked in search results and in internal links - A question is confirmed unanswerable without a linked page - No history or replies endpoint call is confirmed present in the source - A restricted page is confirmed absent from every surface for a restricted user
# Build brief · Tettra The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me an internal wiki where every page has an owner and an expiry date, and an unanswered question is a row until it becomes a page to replace Tettra. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, one process, small team behind a reverse proxy · a wiki for five people does not need an orchestrator - Storage: SQLite with every page revision kept · the previous wording is frequently the answer to why a page changed - Content: Markdown stored as text, rendered server side, no proprietary block format · a wiki you cannot export as files is a trap and everyone has been in one - Staleness: A verification interval per page, enforced in queries not in a banner · a warning at the top of a page is read once and then never again - Chat capture: Event subscriptions for messages addressed to the app, never history polling · reading a team's chat history is both a permissions question and a rate limit you will lose to ### Data model (create this before Phase 1) - pages(id, slug, title, body_md, category_id, owner_id, created_at, updated_at, verify_every_days, verified_at, verified_by, archived_at) - revisions(id, page_id, ordinal, body_md, title, author_id, created_at, summary) · every save, never pruned - categories(id, name, parent_id, ordinal) - questions(id, asked_by, body, source, source_ref, asked_at, state, answer_page_id, answered_at, answered_by) · source is web or chat and state is open, answered or declined - page_questions(page_id, question_id) · the questions a page was written to answer stay attached to it - verifications(id, page_id, user_id, at, outcome, note) · outcome is confirmed, edited or retired - users(id, name, email, role, active) · role is reader, author or admin - A page is stale when verified_at plus verify_every_days is in the past. That expression appears in the search query and the link renderer, not only in a banner on the page itself. ### Phase 1 · Pages with an owner and a revision trail Build: Create, edit and categorise pages written in Markdown. Every save writes a revision row with the author and an optional summary. A page cannot be created or saved without an owner who is an active user. Show the revision list and a difference between any two revisions. Trap: Letting ownership be optional means every page written in the first fortnight has no owner, and by the time anyone wants to know who is responsible for the onboarding page, the person who wrote it has left and the field is still empty. Require an owner on insert, and when a user is deactivated force reassignment of their pages before the deactivation completes. Done when: A page cannot be saved without an active owner, tested by attempting it. Every save produces a revision row and no revision is ever updated or deleted, enforced in the schema. Deactivating a user is blocked until their pages are reassigned, with the page count shown. A difference between any two revisions renders correctly on a fixture pair. Do not build yet: No staleness, questions, chat capture or search yet. ### Phase 2 · Staleness that follows the page everywhere Build: Give each page a verification interval. Compute staleness in the query rather than at render, so search results, internal links and listings all carry the state. Give each owner a queue of their expired pages with three actions: confirm, edit or retire, each writing a verification row. Trap: Putting a yellow bar at the top of an out-of-date page changes nothing, because the reader who arrived from search has already read the wrong paragraph before scrolling up, and the reader who arrived from a link never sees it. Carry the state into the search ranking and into every rendered internal link, so a stale page looks stale before it is opened. Done when: Search results render stale pages visibly differently and rank them below fresh ones, tested with a fixture set. An internal link to a stale page is marked as such in the rendering of the linking page, tested. Confirming a page without editing it writes a verification row and resets the clock, and the verification history is visible on the page. A page that has never been verified is treated as stale rather than as fresh, tested at the boundary. Do not build yet: No questions, chat capture, permissions or export yet. ### Phase 3 · Questions are rows until they are pages Build: Capture a question as a row with its asker and source. Route it to a person. Closing it requires either creating a page, linking an existing page, or declining with a reason. Keep the question attached to the page afterwards, so the page shows what it was written to answer. Trap: Letting a question be closed with a free-text answer produces a helpful reply that lives in the question and nowhere else, which means the next person asks the same thing and the wiki never grows. Make page or decline the only two exits, and let the page creation form be prefilled from the question so the easy path is also the right one. Done when: A question cannot reach the answered state without an answer_page_id, enforced in the state transition and tested. Declining requires a reason and the question stays searchable, so a recurring declined question is discoverable. A page shows the questions it was written to answer, and those stay attached when the page is edited. Answering a question from the queue prefills a new page with its text, checked by walking the flow. Do not build yet: No chat capture, search tuning, permissions or export yet. ### Phase 4 · Questions from chat, without mirroring chat Build: Subscribe to events for messages that mention the app or use its shortcut, and create a question row from that single message. Post the answer back as a reply. Never call a history or replies endpoint, and never store chat text beyond the message that was addressed to you. Trap: The obvious design is to read the channel history and mine it for questions, and the platform has closed that path: since 29 May 2025 Slack limits conversations.history and conversations.replies to one request per minute returning at most fifteen objects for newly created apps distributed outside its Marketplace, while apps built for a single workspace as internal custom apps keep the higher limits. Check the current published limits for whichever platform you use before you design around them, build this as an internal app for your own workspace, and take questions from events addressed to you rather than from history. Done when: The application makes no call to any chat history or replies endpoint, verified by review and by a test that fails if such a call appears in the source. A message addressed to the app creates exactly one question row, tested with a duplicate delivery of the same event identifier. Answering a question posts a reply to the originating message and records the reference. Chat text is stored only for messages addressed to the app, asserted by inspecting the database after a channel conversation the app was not part of. Do not build yet: No permissions, export or backup work yet. ### Phase 5 · Reading rights, files out, and a wiki that survives you Build: Add three roles and per-category read restrictions applied in the query layer. Export the whole wiki as a directory of Markdown files with front matter and an index, including revisions as a separate archive. Import that directory back. Take automatic backups and exercise a restore in the suite. Trap: Filtering restricted pages in the template means the search index, the sitemap, the link renderer and the export each need the same filter added by hand, and one of them will be forgotten, usually the search snippet that shows the first two lines of a restricted page. Apply the restriction in the query layer that every surface goes through, and add a test that calls each surface as a restricted user. Done when: A restricted user gets no trace of a restricted page from search, listings, link previews, the sitemap or the export, tested surface by surface. Exporting produces Markdown files with front matter that open in any editor, and importing them into an empty database reproduces titles, bodies, owners and categories. A restore from an automatic backup is exercised in the test suite, not documented. Deleting a page moves it to archived and keeps revisions, while a purge action removes everything and is logged. Do not build yet: Nothing after this. ### Out of scope (and why) - Real-time collaborative editing, which is a conflict resolution problem large enough to be its own project and is where most wiki clones quietly stop. - A general chat history importer, which the platforms have deliberately rate limited for apps outside their marketplaces and which is a permissions question before it is a code question. - Single sign-on and directory synchronisation, which are the enterprise line items and add a whole identity surface to a five-person tool. - An answer-generating assistant over your pages, which needs the staleness and verification work finished first or it will confidently quote the page nobody has checked since 2023. ### README must contain - That every page has an owner and that deactivating a user forces reassignment first - That staleness is computed in the query, so it shows in search results and in links, not only in a banner - That a question can only be closed by linking a page or declining with a reason - That chat capture uses events addressed to the app and never reads channel history, with the reason - How to export the wiki as Markdown files and how to import them back, with the round-trip tested
# Architecture · Tettra ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, one process, small team behind a reverse proxy | a wiki for five people does not need an orchestrator | | Storage | SQLite with every page revision kept | the previous wording is frequently the answer to why a page changed | | Content | Markdown stored as text, rendered server side, no proprietary block format | a wiki you cannot export as files is a trap and everyone has been in one | | Staleness | A verification interval per page, enforced in queries not in a banner | a warning at the top of a page is read once and then never again | | Chat capture | Event subscriptions for messages addressed to the app, never history polling | reading a team's chat history is both a permissions question and a rate limit you will lose to | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Pages | Content, revisions and ownership. | Any editor, provided every save writes a revision. | | Staleness | Intervals, verification rows and query-level state. | Any policy, provided the state is computed in the query. | | Questions | The queue and the page-or-decline exits. | Not swappable for free-text answers, which is the failure this prevents. | | Chat | Event handling, deduplication and replies. | Any platform, provided it is events addressed to the app and never history. | | Access | Roles and per-category scoping in the query layer. | Any model, provided every surface goes through one filter. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Pages, revisions, questions and verifications. - `BACKUP_DIR` · required · Automatic backups. The restore path is exercised in the suite. - `DEFAULT_VERIFY_DAYS` · required · Fallback interval when a category does not set one. - `CHAT_SIGNING_SECRET` · optional, secret · Verifies incoming chat event signatures. Reject anything unsigned. - `CHAT_BOT_TOKEN` · optional, secret · Posts replies only. No history scopes should be attached to it. - `EXPORT_DIR` · required · Markdown files with front matter, plus the revision archive. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Tettra product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript, one process, small team behind a reverse proxy, SQLite with every page revision kept, Markdown stored as text, rendered server side, no proprietary block format, A verification interval per page, enforced in queries not in a banner, Event subscriptions for messages addressed to the app, never history polling. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Letting ownership be optional means every page written in the first fortnight has no owner, and by the time anyone wants to know who is responsible for the onboarding page, the person who wrote it has left and the field is still empty. Require an owner on insert, and when a user is deactivated force reassignment of their pages before the deactivation completes. - Putting a yellow bar at the top of an out-of-date page changes nothing, because the reader who arrived from search has already read the wrong paragraph before scrolling up, and the reader who arrived from a link never sees it. Carry the state into the search ranking and into every rendered internal link, so a stale page looks stale before it is opened. - Letting a question be closed with a free-text answer produces a helpful reply that lives in the question and nowhere else, which means the next person asks the same thing and the wiki never grows. Make page or decline the only two exits, and let the page creation form be prefilled from the question so the easy path is also the right one. - The obvious design is to read the channel history and mine it for questions, and the platform has closed that path: since 29 May 2025 Slack limits conversations.history and conversations.replies to one request per minute returning at most fifteen objects for newly created apps distributed outside its Marketplace, while apps built for a single workspace as internal custom apps keep the higher limits. Check the current published limits for whichever platform you use before you design around them, build this as an internal app for your own workspace, and take questions from events addressed to you rather than from history. - Filtering restricted pages in the template means the search index, the sitemap, the link renderer and the export each need the same filter added by hand, and one of them will be forgotten, usually the search snippet that shows the first two lines of a restricted page. Apply the restriction in the query layer that every surface goes through, and add a test that calls each surface as a restricted user.
# Delivery milestones · Tettra Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Pages with an owner and a revision trail Content is Markdown with every save kept, and no page exists without a named owner. ### Steps 1. Require an owner on every page Enforced at insert, not requested in the interface. Files: `src/db/schema.sql`, `src/pages/create.ts` 2. Write a revision on every save Append only, with author and summary. Files: `src/pages/save.ts`, `tests/revisions.test.ts` 3. Block deactivation until pages are reassigned This is the moment ownership actually gets decided. Files: `src/users/deactivate.ts` ### Done when - [ ] A page without an active owner cannot be saved - [ ] Every save writes an immutable revision - [ ] Deactivation is blocked until reassignment - [ ] Revision diffs render on a fixture pair ### Watch out - Letting ownership be optional means every page written in the first fortnight has no owner, and by the time anyone wants to know who is responsible for the onboarding page, the person who wrote it has left and the field is still empty. Require an owner on insert, and when a user is deactivated force reassignment of their pages before the deactivation completes. ## M2 · Staleness that follows the page everywhere An expired page is visibly stale in search results, in links to it, and in the owner's queue. ### Steps 1. Compute staleness in the query, not the template So every surface inherits it without being told. Files: `src/pages/queries.ts`, `tests/staleness.test.ts` 2. Mark stale targets in rendered internal links The reader sees it before they click, which is the only useful moment. Files: `src/render/links.ts` 3. Give owners a queue with confirm, edit and retire Three actions, each recorded, none of them a dismiss button. Files: `src/app/queue/page.tsx`, `src/pages/verify.ts` ### Done when - [ ] Stale pages are marked and demoted in search - [ ] Internal links to stale pages are marked - [ ] Confirming writes a verification row and resets the clock - [ ] A never-verified page counts as stale ### Watch out - Putting a yellow bar at the top of an out-of-date page changes nothing, because the reader who arrived from search has already read the wrong paragraph before scrolling up, and the reader who arrived from a link never sees it. Carry the state into the search ranking and into every rendered internal link, so a stale page looks stale before it is opened. ## M3 · Questions are rows until they are pages An unanswered question is tracked, assigned and closed by producing or linking a page. ### Steps 1. Make page or decline the only exits A free-text answer is knowledge that evaporates. Files: `src/questions/state.ts`, `tests/questions.test.ts` 2. Prefill the page form from the question The right path has to be the fastest one. Files: `src/app/pages/new/page.tsx` 3. Keep questions attached to the page So a page can be judged against what it was for. Files: `src/db/schema.sql` ### Done when - [ ] Answered requires a linked page - [ ] Declining requires a reason and stays searchable - [ ] Pages list the questions they answer - [ ] Answering prefills a new page from the question ### Watch out - Letting a question be closed with a free-text answer produces a helpful reply that lives in the question and nowhere else, which means the next person asks the same thing and the wiki never grows. Make page or decline the only two exits, and let the page creation form be prefilled from the question so the easy path is also the right one. ## M4 · Questions from chat, without mirroring chat A question asked in team chat becomes a row through an event subscription, and no chat history is ever read. ### Steps 1. Take events addressed to the app only Which is also the only thing the platform will reliably let you have. Files: `src/chat/events.ts`, `tests/no-history-calls.test.ts` 2. Deduplicate by event identifier Every event platform redelivers, and a duplicate question is noise. Files: `src/chat/dedupe.ts` 3. Reply in the originating thread and record the reference So the asker sees the answer where they asked. Files: `src/chat/reply.ts` ### Done when - [ ] No history or replies endpoint is called anywhere - [ ] A redelivered event creates one question - [ ] Answers post back to the originating message - [ ] No unaddressed chat text is stored ### Watch out - The obvious design is to read the channel history and mine it for questions, and the platform has closed that path: since 29 May 2025 Slack limits conversations.history and conversations.replies to one request per minute returning at most fifteen objects for newly created apps distributed outside its Marketplace, while apps built for a single workspace as internal custom apps keep the higher limits. Check the current published limits for whichever platform you use before you design around them, build this as an internal app for your own workspace, and take questions from events addressed to you rather than from history. ## M5 · Reading rights, files out, and a wiki that survives you (production only) Access is per category, the whole wiki exports as Markdown files, and a restore is tested rather than assumed. ### Steps 1. Apply read restrictions in the query layer One place, not five, because five means four and a leak. Files: `src/auth/scope.ts`, `tests/restricted-surfaces.test.ts` 2. Export to Markdown files with front matter The format check is whether a text editor can read it. Files: `src/export/wiki.ts`, `tests/roundtrip.test.ts` 3. Exercise the restore in the suite A backup nobody has restored is a hope. Files: `src/backup/restore.ts`, `tests/restore.test.ts` ### Done when - [ ] A restricted page leaks from no surface - [ ] Export and reimport round-trips content and metadata - [ ] Restore is exercised in the suite - [ ] Archive keeps revisions and purge is logged ### Watch out - Filtering restricted pages in the template means the search index, the sitemap, the link renderer and the export each need the same filter added by hand, and one of them will be forgotten, usually the search snippet that shows the first two lines of a restricted page. Apply the restriction in the query layer that every surface goes through, and add a test that calls each surface as a restricted user.
# Operations · Tettra ## Backup The database, which holds pages, every revision and the verification history. Exports are the second line of defence because they are readable without this application at all. ## Restore Restore, then check that the stale page count matches what it was and that a restricted user still sees nothing from a restricted category. Both are single queries and both catch a bad restore. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring The size of the verification queue per owner, the number of questions open longer than a week, and the ratio of declined to answered questions. A growing decline rate usually means the wrong people are being asked. ## Incident checklist The incident here is someone following a wrong page. The revision trail tells you what it said when they read it, and the verification rows tell you who last confirmed it and when. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A page is confirmed unsavable without an active owner - [ ] Stale pages are confirmed marked in search results and in internal links - [ ] A question is confirmed unanswerable without a linked page - [ ] No history or replies endpoint call is confirmed present in the source - [ ] A restricted page is confirmed absent from every surface for a restricted user ## Launch constraint Do not market omitted Tettra capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Pages, revisions, questions and verifications. DATABASE_PATH=./data/wiki.db # Required. Automatic backups. The restore path is exercised in the suite. BACKUP_DIR=./data/backups # Required. Fallback interval when a category does not set one. DEFAULT_VERIFY_DAYS=180 # Optional · secret. Verifies incoming chat event signatures. Reject anything unsigned. CHAT_SIGNING_SECRET=redacted # Optional · secret. Posts replies only. No history scopes should be attached to it. CHAT_BOT_TOKEN=xoxb-...redacted # Required. Markdown files with front matter, plus the revision archive. EXPORT_DIR=./exports
$ choose a build depth, inspect the files, then open the complete pack in your agent
Tettra: Teams pay because shared state, notifications, permissions, and migrations are harder than the visible board or list.
xreal-time multiplayer
xenterprise permissions
xlarge integration catalog
xmobile capture and notifications
Tettra pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| scaling | — | $8/user | 10-user minimum; unlimited storage and version history; AI, Slack, Google Workspace, analytics, and API access. |
| enterprise | custom | — | Custom price; SSO and SCIM included; discounted rates beyond 250 licenses. |
free tierno free tier
billingmonthly + annual (20% off advertised); exact monthly amount was not independently exposed; 30-day trial
hidden costsScaling has a 10-user minimum; SAML SSO, SCIM, and group permissions are paid add-ons on Scaling but included in Enterprise.
verified 2026-08-14 · source ↗
Vibecode Tettra
Kinda. The core of Tettra is buildable in a weekend with the prompt on this page, but there are real gaps: real-time multiplayer, enterprise permissions. Read the honest list above before committing.
How much does Tettra cost?
Tettra's pricing is usage-based or varies by plan · The official pricing URL is recorded, but a stable current amount was not safely recoverable in this pass. Do not publish a number until rechecked..
What do I lose by replacing Tettra?
Honestly: real-time multiplayer; enterprise permissions; large integration catalog; mobile capture and notifications. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Tettra?
Yes: Vikunja (Open-source task manager with lists, boards, and collaboration.), Plane (Open-source issue and project tracker.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.