Architecture
The eight workspace domains, why they never join, how another service reads the text, and what redaction actually destroys.
Binaries
| Binary | Path | Role |
|---|---|---|
| API | cmd/api | HTTP server and outbox publisher; flags -migrate, -openapi |
| Conversation listener | cmd/workspaceConversationListener | Moves a conversation's files when the conversation moves |
| Notification listener | cmd/workspaceNotificationListener | Turns project events into inbox rows |
| Knowledge listener | cmd/workspaceKnowledgeListener | Turns intelligence's alerts into inbox rows |
| Audit listener | cmd/workspaceAuditListener | Writes one human-readable row per workspace event |
| Slack sync | cmd/workspaceSlackSync | Keeps Slack threads in the evidence domain |
| Question Slack | cmd/workspaceQuestionSlack | Sends open company questions and saves a reply as a note. Also reminds goal leads when a weekly check-in is due or overdue |
| GitHub sync | cmd/workspaceGitHubSync | Keeps GitHub pull-request descriptions in the evidence domain |
| Azure DevOps sync | cmd/workspaceAzureDevOpsSync | Keeps Azure DevOps pull-request descriptions in the evidence domain |
| Meeting summarizer | cmd/workspaceMeetingSummarizer | Writes 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 sync | cmd/workspacePlaudSync | Deployed, 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.
| Domain | Tables | Owns |
|---|---|---|
project | project, 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_digest | The working record of a piece of work |
conversation | conversation, conversation_turn (partitioned), conversation_session, conversation_suggestion, conversation_redaction | Threads, their turns, and who is in them now |
evidence | evidence_record, evidence_utterance (partitioned), evidence_project_link, evidence_participant, evidence_redaction, meeting_live_slice | Notes, 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 |
file | file_events, file_commands | The claim on a document, including one attached to an unfiled conversation. No content table, on purpose |
fact | fact, fact_events, fact_commands | Statements about the business a platform admin has settled |
notification | notification, notification_pref | A person's inbox |
audit | audit | One human-readable sentence per workspace event, for review |
personcontext | person_profile, person_memory, person_thread, person_interest, person_commitment, person_preference, person_refresh_state, person_review_flag, person_context_events, person_context_commands | What 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:
| Type | Evidence |
|---|---|
| Note | The title and the body |
| Question | The question and its answer, once there is an answer |
| Task, bug, chore, feature | The 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:
- 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.
- 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.
- Meetings they attended and lines that name them. These are weaker, and they are stored as
different relationships: speaking is
contributed_to,asked_aboutorcommitted_to; being asked to do something isassigned_action; being named ismerely_mentioned; being on the roster isdiscussed.
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:
| Column | Holds |
|---|---|
project.change_seq | The project's latest change number |
project.structure_seq | The number of the last write that changed goals, check-ins, saved views, or the project's settings |
project.reset_seq | The number of the last write that renumbered every task's rank |
project_item.change_seq | The 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:
- A person named on the roster can always read the record.
- The writer of a manual note with no identified participants can always read it.
- A member of any project the record is attached to can read it.
- A record attached to no project that feeds the knowledge layer can be read by anyone.
- A record attached to no project that does not feed the knowledge layer is private to the people above.
- 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:
| Source | The transcript is final when |
|---|---|
| Phone, and the Plaud app | transcription_status reaches transcribed, no_speech, or failed, whether from the finished file or from ending a live recording |
| Plaud cloud sync | The sync adds lines to a recording |
| Zoom, when the bot was in the meeting | The 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 meeting | Zoom'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 domainPlatform 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.
Getting started
Run the workspace service locally, authenticate as a dev principal, and walk a project from creation to context pack.
Tasks, statuses, and goals
What every task type, status, and goal status means, why each one exists, how work moves through the stages, saved views, weekly goal check-ins, and the rules that keep project work honest.