Flow
ServicesWorkspace Service

Architecture

The eight workspace domains, why they never join, how another service reads the text, and what redaction actually destroys.

Binaries

BinaryPathRole
APIcmd/apiHTTP server and outbox publisher; flags -migrate, -openapi
Conversation listenercmd/workspaceConversationListenerMoves a conversation's files when the conversation moves
Notification listenercmd/workspaceNotificationListenerTurns project events into inbox rows
Knowledge listenercmd/workspaceKnowledgeListenerTurns intelligence's alerts into inbox rows
Audit listenercmd/workspaceAuditListenerWrites one human-readable row per workspace event
Slack synccmd/workspaceSlackSyncKeeps Slack threads in the evidence domain
Question Slackcmd/workspaceQuestionSlackSends open company questions and saves a reply as a note. Also reminds goal leads when a weekly check-in is due or overdue
GitHub synccmd/workspaceGitHubSyncKeeps GitHub pull-request descriptions in the evidence domain
Azure DevOps synccmd/workspaceAzureDevOpsSyncKeeps Azure DevOps pull-request descriptions in the evidence domain
Meeting summarizercmd/workspaceMeetingSummarizerWrites the summary of a Zoom, phone, or Plaud meeting after it ends, and emails it to the host. Kestra calls it every two minutes
Plaud synccmd/workspacePlaudSyncDeployed, but Plaud's API does not list recordings. Audio arrives through frontend/plaud-app

Conversation, notification, and knowledge stay on their own binaries because they do different work. A queue is shared between the processes reading it, so one binary reading all three would be a single process whose death quietly stops three unrelated things. The audit listener is the other shape: one process, seven queues, because every message does the same write.

Eight domains that do not join

Each domain owns its own tables, its own outbox, and its own sqlc package (ADR 0023). To read another domain's data, call that domain's Go function. So notification.Fanout calls project.OwnersFor instead of joining project_member.

DomainTablesOwns
projectproject, project_member, project_item, project_item_type, project_status, project_workflow, project_workflow_type, project_item_version, project_item_change, project_goal, project_goal_checkin, project_goal_reminder, project_view, project_case, project_case_amount, project_case_link, project_handoff, project_handoff_criterion, project_handoff_evidence, project_handoff_acceptance, project_handoff_history, project_unblock_request, project_unblock_response, project_unblock_dependency, project_unblock_link, project_unblock_reminder, project_decision, project_saved_output, project_data_source, project_join_request, project_template, project_visit, project_activity, project_digestThe working record of a piece of work
conversationconversation, conversation_turn (partitioned), conversation_session, conversation_suggestion, conversation_redactionThreads, their turns, and who is in them now
evidenceevidence_record, evidence_utterance (partitioned), evidence_project_link, evidence_participant, evidence_redaction, meeting_live_sliceNotes, meetings, chat threads, GitHub and Azure DevOps pull-request descriptions, Plaud and phone recordings transcribed into meetings, and uploaded documents. Extracted text is added when the file has any. meeting_live_slice is the per-minute ledger behind a recording transcribed while it was being made
filefile_events, file_commandsThe claim on a document, including one attached to an unfiled conversation. No content table, on purpose
factfact, fact_events, fact_commandsStatements about the business a platform admin has settled
notificationnotification, notification_prefA person's inbox
auditauditOne human-readable sentence per workspace event, for review
personcontextperson_profile, person_memory, person_thread, person_interest, person_commitment, person_preference, person_refresh_state, person_review_flag, person_context_events, person_context_commandsWhat Flow knows about one person's work, private to that person

A case is a finding on a project, with one owner. project_case_amount stores identified, approved, posted, and collected as separate rows so they cannot be saved as one recovered number. Expected value, including a recurring projection, sits on the case and is not a collection. Cases may share a subject key. Both rows stay, because they are two findings. The value report is what counts the money once. Evidence links are pointers: evidence is another domain, so the case does not join it.

A handoff is a package offered to one person. Filing or reassigning a case does not open one. Acceptance is that person's receipt that they can take the next step. It is not proof the work is finished, and it does not verify a case. Each acceptance is stored against the revision that was offered. A later edit keeps the earlier receipt and asks for acceptance again. An unblock request is one short question in front of one person. An answer does not close it; the person who asked confirms the answer is enough. Snooze only hides the card. Neither record is inferred from a sentence in a conversation. Notifications for both are delivered from the project events, through the notification domain, and the actor is not notified of their own action. A daily job reads each due request again before it sends a reminder, and it does not send one after the request is resolved or withdrawn.

Tasks and their history

A task is a project_item row with a type and a status. What each type, status, and goal status means is on Tasks, statuses, and goals; this section is how they are stored. project_item_type holds every type and the fields each adds; project_status holds the statuses, each in one of six fixed categories and with a one-line description. project_workflow names the two workflows, product development and general. A status with no project is a shared status of one workflow, and project_workflow_type lists the types each workflow offers, in picker order. project.workflow and project_template.workflow say which one a project and a template use; a project takes its template's when it is created and keeps it. Migration 0047 seeds these tables, and nothing writes to them yet. It made every project and template that existed General. project_goal holds the goals a task can be linked to.

The project_item row holds the current content. Every content write adds a row to project_item_version, which keeps the title, body, type, fields, links, resolution, and who wrote that version. Every change to the status, owner, target date, goal, archived flag, or position adds a row to project_item_change, with ids or short values only, who made the change, and when. Both tables can only be added to; the same trigger that protects project_activity refuses an update or a delete.

The older kind and state columns stay. Every write sets them from the type and from whether the status category is closed, so a client that only knows them still reads the truth. A task's position is rank, a number with gaps of 1024, smaller first.

Migration 0047 turned the items that already existed into tasks. Each kept its kind as its type and went to General's Todo when open or Done when resolved, and each project's were ranked newest first, the order the list had always shown. Each got version 1 from what it said then. Only a note's text had been evidence, so only notes started at evidence version 1. type, status_id, and rank are required, and the server this release replaces does not write them, so deploy it when nobody is adding tasks.

status_changed_at is when a task entered the status it is in; what sets it is in API. For the tasks that existed, 0047 set it to when the task was resolved, or else when it was made.

What a task says counts as company knowledge only once it is settled. Workspace decides what that is, so intelligence never needs to know about types:

TypeEvidence
NoteThe title and the body
QuestionThe question and its answer, once there is an answer
Task, bug, chore, featureThe resolution, while the task is completed or canceled

evidence_version is the version that holds the evidence. It moves to the new version whenever an edit changes that text, even while the text does not count, such as a resolution on an open task. Archiving changes nothing about evidence. Whose words the evidence is depends on who wrote that version: a person, or an agent writing for one.

conversation_turn and evidence_utterance are split into partitions and carry a full-text search column. They are by far the largest tables here, and lines are only ever added, never changed. That is what makes both choices safe: nothing updates a line, so no row ever has to move between partitions.

A presentation is a customer deck on a project. Its rows are project_presentation and project_presentation_revision. Each revision is stored whole, including the outline, the sources, and the slide specification. Sharing records the revision number that was offered. A later draft is a new revision and does not change the shared one.

Person context stays private and cites its sources

The personcontext domain answers one question: what is this person working on, and what do they owe? (ADR 0077, docs/decisions/0077-person-context-before-reports.md). A scheduled report and every Desktop turn read the same answer from GET /v1/context/person.

Everything in it is about the caller. No route takes a person as input. The recipient is the signed-in principal, or the person named in X-Flow-On-Behalf-Of on a service call, so a person can only read, correct or refresh their own context. These rows are not company claims. The outbox carries ids and counts only, and intelligence is not a subscriber on it.

A background job keeps the rows current. Nobody opts in. It reads the people roster from intelligence and rebuilds each person's rows from sources that person may read:

  1. Their own turns in conversations and their own lines in meetings and threads. Only human turns count. An assistant answer is never read as something the person said or prefers.
  2. Project items, cases, handoffs and unblock requests that name them. Belonging to a project does not create a row. Being the assignee, owner, sender, receiver, requester or responder does.
  3. Meetings they attended and lines that name them. These are weaker, and they are stored as different relationships: speaking is contributed_to, asked_about or committed_to; being asked to do something is assigned_action; being named is merely_mentioned; being on the roster is discussed.

Each fact is kept as its own row, so two things said in one meeting are two rows. A row stores a short extract and source_ids, never the raw history. When a source is deleted, redacted, or the person loses access to it, the rows that cite it lose that support. A row with no support left is revoked. This happens in the same transaction as the delete, and again on every refresh. Reading the context also checks each cited source against the person's current access, so a missed update does not leak.

A manager's context lists open unblock requests, waiting handoffs and open cases that belong to their reports, in projects the manager is already in. It never includes a report's conversations.

Titles are mapped to a function only through a reviewed list in titles.json. A title that is not on the list, or is ambiguous (for example "Engineer"), leaves the function empty. It adds a gap to the response and opens a review flag, so someone can add a reviewed entry. A person with no conversations still gets their role and assignments, with a gap that says conversations were not found. That is not evidence they have no work.

If intelligence cannot be reached, the stored profile, manager and reports are returned with a gap. The request does not fail for that reason.

Saved views and goal check-ins

Migration 0048 added project_view, one row per saved view: name, layout, group_by, sort, sort_desc, filters, seed_key, position, and version, each checked by the database where it has a fixed set of values. filters is a JSON object, kept as it was saved and checked against what the project offers each time it is read. project_view_name_idx, a unique index on (project_id, lower(name)), keeps names unique ignoring case. project_view_seed_idx keeps one view per seed key on a project. Every view write takes the project lock first, so view rows need no locks of their own.

project.Create gives a new project its workflow's starting views, with generated ids and system as the author. Migration 0048 gave every project that already existed General's two, Board and List, scratchpads included, under ids derived from the project and the seed key: md5(project id || ':' || seed_key) cast to a uuid. Its list is a copy of General's in services/workspace/internal/domains/project/view_seeds.go, and a test keeps the two the same.

Migration 0049 added check-ins. project_goal_checkin holds one row per check-in: the goal and project, status_before and status, the note, the author, and when. The table can only be added to: the same trigger that protects project_activity refuses an update or a delete. project_goal_checkin_goal_idx on (goal_id, created_at desc) finds a goal's latest. project.checkin_weekday is the check-in day, a smallint from 1 (Monday) to 5 (Friday) that defaults to 1.

Whether a goal is due or overdue is not stored. It is worked out on every read from the check-in day, when the goal was created, and its latest check-in. The only scheduled part is the Slack reminder, and project_goal_reminder is its record: one row per goal and window, keyed (goal_id, window_key). A window is the state and the day, such as overdue:2026-10-05. The row is written only after the message was sent, so a failed send is tried again on the next run. Deleting a goal deletes its reminder rows.

A project counts its changes

A client showing a project's tasks stays current by asking what changed after the last number it saw, through GET /v1/projects/{id}/changes. Migration 0047 added the numbers behind it, each a bigint that starts at 0:

ColumnHolds
project.change_seqThe project's latest change number
project.structure_seqThe number of the last write that changed goals, check-ins, saved views, or the project's settings
project.reset_seqThe number of the last write that renumbered every task's rank
project_item.change_seqThe number of the last write that touched the task

Every write to a project's tasks, goals, check-ins, saved views, or settings takes the next number inside its own transaction, by updating the project row. The row stays locked until the write commits, so numbers are handed out in the order writes commit. A task write stamps its number on the tasks it touched: adding, editing, moving, archiving, unarchiving, restoring, and the older resolve. These record their number in structure_seq: a goal write, posting a check-in, a project update (changing the check-in day included), archiving or unarchiving the project, setting or lifting a legal hold, and creating, deleting, editing, or reordering a view. A view edit that changes nothing, and a reorder into the order the views already have, write nothing and take no number. Deleting a goal also stamps the tasks it unlinks. Renumbering, which happens when a task is placed between two neighbors with no room between them, rewrites every rank without writing change rows or touching updated_at. It records its number in reset_seq, and clients reload every task. The check constraint project_change_seq keeps structure_seq and reset_seq at or below change_seq, and project_item_change_seq_idx on (project_id, change_seq) finds the tasks changed after a number.

Every task write locks the project row before the task's. That is the one order all of these writes use, so no two of them can each hold a lock the other is waiting for. It also means these writes to one project wait for each other. Why a counter, and what it costs, is in docs/decisions/0097-clients-follow-a-per-project-change-counter.md.

Access is project membership, except for platform admins and evidence

project.Authorize runs before every project-scoped read and write. It answers one question: does this person hold at least this role on this project? Roles are rows in project_member, not a policy language, so this is an ordinary Go check rather than Cedar: The authorization engine; each service embeds a Cedar policy and evaluates every mutating request against the actor's principal record.. Creating a project is not project-scoped: anyone signed in may create one and they become its owner. Starting a conversation without a project is the same — there is no membership to check.

A platform admin is the exception on the read side. They can open every project, and read a conversation nobody filed, without a membership row. The review console lists every project for them. Search walks that set. Evidence, conversations, and files are each one list (scope=readable). The conversation list is newest activity first, and the file list is newest upload first. GET /v1/me/conversations and GET /v1/me/files without scope are still the unfiled lists, and a platform admin receives every unfiled conversation and every unfiled file there. They are not inserted as owners. Editing, inviting, and archiving still need a real project role, or one of the stewardship routes.

X-Flow-As-Member: true turns that expansion off for one request. The caller is still who the token says they are; the header only declines admin, and cannot grant it. agent-desktop sends it so a platform admin sees the projects and conversations they belong to, not the whole company record.

evidence is the other exception. A record there belongs to the people who were in it, not to a project, and it may not be attached to any project at all (ADR 0028). Slack, Zoom, and the other connectors write as a service identity, so the row's author is not who may read it. evidence.CanRead takes the record, the projects it is attached to, the identified participants, and a Reader built by the handler. It applies one rule: if a record feeds the company's knowledge, the company can see where that knowledge came from. In detail:

  1. A person named on the roster can always read the record.
  2. The writer of a manual note with no identified participants can always read it.
  3. A member of any project the record is attached to can read it.
  4. A record attached to no project that feeds the knowledge layer can be read by anyone.
  5. A record attached to no project that does not feed the knowledge layer is private to the people above.
  6. A platform admin can read every record, including that last class.

Search applies the same conditions inside the SQL. Filtering after ranking would leak the existence of records you cannot see, because the result count would change.

Writing is narrower: the author or a steward. Attaching a record to a shared project shares the record. It does not share the right to edit it. Deleting a record is narrower still: platform admin only, via POST /v1/evidence/{id}/delete. That archives the row so the review list and the knowledge layer stop reading it. The lines stay.

fact inverts both exceptions. A Company fact: A statement about the business that a platform admin has decided is settled — a legal entity name, what a product is, which market the company sells into. It is a record in workspace's fact domain, written as markdown or uploaded as a file, readable by anyone and writable only by a platform admin. Intelligence derives claims from it the ordinary way and then confirms them instead of queueing them for review, and those claims go into every project's context pack. Distinct from an insight whose kind happens to be `fact`, which is any claim the pipeline extracted from a conversation and which still goes to a reviewer. has no project and no owner, so there is nothing to authorize against: anyone signed in may read one, because the knowledge layer publishes claims derived from it to the whole company and a citation has to open onto something the reader can see. Writing is the platform admin list and nothing else, and no project role reaches it (docs/decisions/0037-admin-curated-company-facts.md). That refusal is a 403 rather than the 404 the stewardship routes use — a fact is company-readable, so hiding the route would tell the caller nothing they could not already find out.

Break-glass: Time-boxed membership a platform admin opens on a project. They can already read every project without being a member; this row is for appearing as a member, expires, and is implemented as membership rather than a check the authorization code skips. is still membership, not a way around it. A platform admin already sees the project; break-glass is the time-boxed membership row they open when they need to act as a member, or when something else still keys off project_member. The grant expires, and a scheduled job removes the row.

Meetings the bot did not join

meeting_occurrence records a meeting only after the capture bot has sat in it and left. A host the bot never joins has no row, so a percentage of meetings recorded computed from that table alone cannot show who is not using the bot.

The occurrence also records who removed the bot, when somebody did. Anyone in the meeting can type /leave in the Zoom chat, and the row then stores removed_via as chat with that person's meeting name, their Zoom user id, and their Flow account when their email resolved to one. When the host or a co-host uses Zoom's Remove, the row stores removed_via as host and names nobody, because Zoom does not report which of them pressed it. A later report for the same occurrence, from a bot that came back when the host rejoined, does not clear an earlier removal.

meeting_seen is that denominator. The Zoom launcher writes one row for every meeting.started it accepts, including the ones the allowlist declines. The row stores the host, the start time, and the decision: launched, already recording, skipped (and why), launch failed, or awaiting the host. The bot no longer waits for the host: it starts on meeting.started whether or not the host is there. Awaiting the host now means a new occurrence of a meeting that already has a bot. Its Job is created suspended and starts when Zoom reports the earlier occurrence ended, or once the earlier bot's Job finishes; a second row, launched, is written then. Rows written before that change used awaiting the host to mean the host had not joined. The Zoom event subscription includes meeting.participant_joined and meeting.ended along with meeting.started. It is not evidence. A skipped meeting still has no transcript, and nothing in this table is published to the knowledge layer. The register starts when the table does; meetings from before that are absent. A transcribed meeting from before the register still shows a host when its evidence record names an organizer.

Any signed-in user reads it from the review console at /platform/analytics, on the Meetings tab. GET /v1/meetings/adoption and GET /v1/meetings/recorded return meeting titles, hosts, and counts to any authenticated caller, and no transcript text. The Plaud and Slack tabs on that page are also for any signed-in user. The rest are for platform admins only, and so are GET /v1/meetings/sentiment and GET /v1/meetings/line-speakers. The Meetings tab joins the start rows with transcribed meetings and with insight counts from intelligence. Joined means the bot sat in the meeting and left. A meeting counts as transcribed once its evidence record has lines, which is while the bot is still in the room; the occurrence outcome is written when the bot leaves. Percent declined is meetings that started with no transcript, divided by meetings started. A transcribed meeting with no claims is only ranked as empty when it cleared extraction (four lines, 800 characters, two human speakers) and ended more than a day ago.

The Plaud tab lists who opened the recorder app and how many recordings the app uploaded for them. Opening the app is a request for a Plaud token, which the app makes when it starts, stored as one row per person per UTC day. Recordings are the ones whose reference is the app's device serial and session. The cloud sync is not included. GET /v1/plaud/adoption returns the email and the counts, and no audio. Sign-ins start when that register does. A recording from before it still appears, with no sign-in beside it.

The Sentiment tab on the same page scores each human line with VADER, from -1 to 1. The score accounts for negation, emphasis, and contrast. A line at or above 0.05 is positive and at or below -0.05 is negative. Balance is the average score. A reply that is only yes, no, yeah, or ok is left neutral. Workspace returns one row per speaker per New York calendar day. The page groups those rows by manager for the selected range: that day, that Monday week, that calendar month, or the last 30 days. It is a reading of the words in the transcript, not a model of the person, and it is not sent to the knowledge layer.

The Energy tab compares each team's average for the last full Monday week with the week before. Quieter is a count of people whose own line count fell for both of those weeks against their earlier weeks in the window. The page does not name them.

The Culture tab lists every transcript note that shows a stated value in action, with the quote and who spoke that line. Holding, strained, or contradicted comes from what the moment did. A mention that does not show that is listed as not settled and is not counted. The Bright lights tab lists a person credited for a piece of work, newest first, with what they did and the quote. The reader can name that person on the note. Otherwise the page uses the sentence, or the speaker of that line when a first name is shared. Thanks for joining a meeting are left out. A credit that does not name one person is not settled. It is not a ranking. Both tabs say how many transcribed meetings in the window have a note. The Dimming tab counts people whose own lines fell for two or three complete weeks against their own earlier weeks, and who still spoke in at least three meetings in those weeks. It names the team and the count. It does not name the person. A decision that never got an owner is still not on this page.

Meeting summaries

After a Zoom, phone, or Plaud meeting ends, workspace writes a summary of it: a short overview, the next steps people took on, and chapters that say where each topic starts in the recording. The review console shows it on the Summary tab of the record, and the host gets it by email.

When a summary is asked for. A row in meeting_summary (one per record, keyed by the evidence id) is the request. It is written in the same transaction as the change that finishes the transcript:

SourceThe transcript is final when
Phone, and the Plaud apptranscription_status reaches transcribed, no_speech, or failed, whether from the finished file or from ending a live recording
Plaud cloud syncThe sync adds lines to a recording
Zoom, when the bot was in the meetingThe bot reports the occurrence as transcribed. The row waits two minutes so the last lines the bot sent on the way out land first
Zoom, when the bot missed the meetingZoom's cloud transcript arrives. Each of its lines pushes the wait out to two minutes again, so the summary is written once the last line is in

Zoom's cloud transcript is appended to the record the bot already wrote, not swapped in for it, so a meeting the bot was in holds the conversation twice. The summary uses the bot's lines and leaves Zoom's copy out, and a cloud transcript line on such a meeting does not ask for a new summary. Zoom's copy is used only when it is the only transcript.

A line added after a summary is ready asks for a new one two minutes later. A line added while the summarizer is working marks the row so it goes back to waiting when that run finishes, rather than the run's result being kept as final.

What the summarizer does. workspaceMeetingSummarizer claims up to five due rows per call with for update skip locked, so two runs never take the same row. It skips a meeting with no transcript, no speech, a failed transcription, fewer than 40 words, or less than a minute of audio, and records which of those it was. Otherwise it sends the transcript to the model with each line numbered, asks for JSON in a fixed schema, and checks the answer against the transcript:

  • A next step or chapter that cites no real line is dropped, and the number dropped is kept on the row.
  • An owner is kept only if that person spoke one of the cited lines or is named in one. Otherwise the step is shown as unattributed.
  • A due date is kept only if its words appear in a cited line.
  • A chapter starts at the earliest line it cites.

A long transcript is split into parts, each part is summarized, and a second call merges them. A model error is retried up to four times, two minutes apart and then doubling. After that the row is failed with the last error. A run that dies leaves its row claimed; after 15 minutes another run may take it, and the dead run's result is refused because it no longer holds the claim.

Who may change it. Anyone who can read the meeting can read the summary. Writing it again and emailing it are for the host and platform admins. The API also lets them edit it, but the review console no longer offers that. The host of a Zoom meeting is the host Zoom reported in meeting_seen, or the meeting's organizer. For a phone or Plaud recording it is the person who recorded it. An edit is not overwritten by later transcript changes; writing it again, or approving a redaction on the meeting, replaces it. A redaction also removes the old summary text straight away, because it may quote the line that was destroyed.

Email. When a summary is ready the summarizer emails the host through communications (ADR 0045), once per meeting. Emailing anyone else is a choice the host makes from the Summary tab, which lists the host first and then everyone in the meeting with an email, so the host can also send it to themselves again. Every summary email comes from FlowQ <[email protected]>, so m.flow.life has to be one of communications' MAILGUN_SEND_DOMAINS. Each email is plain text with the overview, the next steps, the chapters, and a link to the record. evidence.summary_ready is published each time a summary becomes ready.

Next steps in the knowledge layer. The summary itself is not evidence, and its overview and chapters stay in workspace. The next steps are the exception. When intelligence receives evidence.summary_ready for a Zoom, phone, or Plaud meeting, it reads the next steps through GET /internal/derivation/evidence/{id}/summary and writes each one as a commitment claim that quotes the transcript lines the step cites. That read goes through the same eligibility check as the transcript, so a meeting that does not feed the knowledge layer returns nothing. It returns only the text, owner, due date, and cited line numbers of each step. GET /internal/derivation/meeting-summaries lists the meetings with a ready summary, in id order, so intelligence can catch up summaries written before it read them.

Two internal surfaces, deliberately outside the spec

Neither appears in the OpenAPI document, because neither is a contract anyone should build against.

/internal/jobs/* belongs to the scheduler and sits behind a shared secret (ADR 0026). If no token is configured, the routes are not registered at all. No scheduled work is better than scheduled work anybody can trigger.

/internal/derivation/* is how intelligence reads the text it works from (ADR 0024). A conversation and its turns, a record and its lines and meeting next steps, a project's write-up, and that project's items, decisions, and saved outputs. A service token on its own reads nothing — the caller also has to say which person it is acting for. The point of the Derivation surface: The internal routes intelligence reads conversation text through, authenticated by a service token and a named principal. Evidence stays in workspace and is fetched per request rather than copied, so a redaction takes effect everywhere at once. is that text is fetched when needed rather than copied. A redaction then takes effect everywhere at once, instead of everywhere eventually.

The eligibility check differs by what is being read, because ownership differs. A conversation is checked against its own flag first, then, if it is filed onto a project, against that project's answer; the strictest one wins. A conversation filed onto nothing is eligible, which is the point of it having no project — recording is what makes it company knowledge, and filing decides who else can read the transcript. An evidence record works the same way against every project it is attached to. That way a note somebody marked private cannot leak out through the one surface that reads without a membership check.

/internal/filing/conversations/{id} is the one path that reads an unfiled conversation, and it exists so that a conversation nobody filed does not simply sit there forever. It answers only for a conversation with no project — the exact inverse of the gate above — and it returns the text along with the active projects the owner is already a member of. Intelligence matches one against the other and posts back a confidence and a sentence of reasoning, which the owner sees as a suggestion. Nothing read here may be kept, no suggestion carries any of the text, and accepting one is not the move: that still goes through /v1/conversations/{id}/move with the ordinary membership check. docs/decisions/0035-suggesting-a-home-reads-a-scratchpad.md sets out why this is not a softening of the rule above, and what bounds it.

An item, a decision, or a saved output belongs to exactly one project and cannot be filed anywhere else, so the project's answer is the whole gate. Every decision state is served. The only skips are a scratchpad, a project that opted out of knowledge, and a project under legal hold.

GET /internal/derivation/items/{itemId} adds the task's type and category, and what may be cited from it, as described in Tasks and their history. evidence is that text, or empty when nothing counts right now. evidenceVersion is the version that holds it, evidenceAuthor and evidenceSource are who wrote that version, and evidenceAt is when it was written. Until a version holds the evidence, as on a task with no resolution yet, evidenceVersion is 0 and the other three come from the task itself: who created it, its origin, and when. The older fields keep their values, including source, which stays human, summarizer, or template; evidenceSource has the exact value, such as agent.

A fact has the shortest gate on this surface, and that is the point of the domain: no project whose answer to defer to, no knowledge opt-out, no legal hold. The only thing that makes one ineligible is having been withdrawn, and a superseded or archived fact answers 404 the same as one that never existed — to a derivation worker both mean drop this job.

Redaction destroys the text

A redaction request is reviewed by someone other than the requester. Approving it overwrites the content of the turn or line and leaves behind a keyed HMAC digest of what used to be there.

The digest is the proof. It shows that something specific was removed without keeping the thing itself. It is keyed because an unkeyed digest of a short piece of text can be guessed by trying candidates, which would make the record useless.

If no key is configured, approval is refused. It does not fall back to an unkeyed digest.

Approving also publishes conversation.redaction_recorded or evidence.redaction_recorded. Intelligence handles those immediately rather than putting them on its queue, because "gone in a few minutes" is not what a deletion request means. Turning a record's contributes_to_knowledge off runs the same sweep: the text stays, but every claim resting on it is challenged.

A note's body is exempt from this process. It is the author's own writing and they can already edit it down to nothing. Putting that behind a two-person review would mean asking permission to change your mind. The reviewed path covers lines in meetings and threads, which can only be added, never edited, and are usually somebody else's words.

Layout

services/workspace/
  domains/*/events/         ← public event payload contracts
  internal/
    domains/                ← one package per domain
    handlers/               ← routing, HTTP types, error mapping
    auth/                   ← Cloudflare Access verification, service tokens
    documents/              ← client for services/documents
    intelligence/           ← client for services/intelligence; degrades, never errors
    command/                ← idempotency + transaction
    publisher/              ← drain the outboxes
    listener/               ← consume a queue, ours or another service's
    testdb/                 ← this service's migrations over common/testdb
  messageBus/               ← generated fanout topology
  migrations/               ← goose SQL, one file per domain
  db/                       ← sqlc output, one package per domain

Platform mechanics live in common/ — see ADR 0018 and ADR 0019.

Coding plans

A coding plan is an evidence record with kind document and source coding_plan. The coding harness uploads markdown to documents with metadata flow_source=coding_plan and a short flow_summary. Document sync accepts only that exact source value; any other value stays document, so an upload cannot claim to be Slack or GitHub. The summary is stored on the evidence body, capped at 2000 characters, and the markdown is split into sections the same way as any other document.

On this page