CoDNA Request access
Contents

Docs How it works

Data model

Status: built. The schema is db/migrations/: 00001_init.sql and the migrations after it, such as 00002_no_administrators.sql. This document explains the result.

Postgres is the source of truth for everything. Typesense holds a derived index that can be dropped and rebuilt at any time (search.md). This document specifies every table, the state machines that govern them, and — at the end — worked traces showing exactly which rows each of CoDNA's core scenarios writes.

Conventions

  • Primary keys are uuid generated application-side as UUIDv7. They sort by creation time, which keeps B-tree inserts local and makes "most recent" queries an index scan rather than a sort.
  • Timestamps are timestamptz, always UTC. Every table has created_at; mutable tables also have updated_at.
  • Enums vs text. Postgres enums are used where the set of values is closed by design (space, permission, link_state). Plain text with a CHECK is used where the set is expected to grow (event_type, notification_type, grant_reason) — adding a value there is a one-line migration, whereas ALTER TYPE cannot remove one and complicates rollback.
  • Soft deletes apply only to documents. Everything else is either immutable history or hard-deleted.
  • sqlc generates Go types from db/queries/*.sql; nothing in this schema depends on an ORM's conventions.
CREATE TYPE space           AS ENUM ('raw', 'wiki', 'output');
CREATE TYPE permission      AS ENUM ('read', 'write');
CREATE TYPE principal_type  AS ENUM ('user', 'group');
CREATE TYPE document_status AS ENUM ('draft', 'active', 'archived');
CREATE TYPE link_state      AS ENUM ('resolved', 'stale_text', 'pending', 'broken');
CREATE TYPE decision_status AS ENUM ('pending', 'accepted', 'rejected');
CREATE TYPE automation_kind AS ENUM ('event', 'schedule');
CREATE TYPE run_status      AS ENUM ('ok', 'failed', 'timeout', 'rejected');

Identity

CREATE TABLE users (
  id            uuid PRIMARY KEY,
  email         text        NOT NULL,
  name          text        NOT NULL,
  kind          text        NOT NULL DEFAULT 'person',  -- person | management | agent
  is_active     boolean     NOT NULL DEFAULT true,
  password_hash text,                                   -- argon2id; null: identity provider only
  created_at    timestamptz NOT NULL DEFAULT now(),
  updated_at    timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT users_kind_ck CHECK (kind IN ('person', 'management', 'agent'))
);
CREATE UNIQUE INDEX users_email_key      ON users (lower(email));
CREATE UNIQUE INDEX users_one_management ON users (kind) WHERE kind = 'management';

A unique index on lower(email) rather than the citext extension — one less extension for a self-hoster to install, and the behaviour is identical for this use.

Three kinds of user. A person signs in, and reads and writes the knowledge base. Management runs the instance and never reads what a document says (permissions.md). An agent connects over MCP as itself and knows what its responsible people share with it (below). There is exactly one management row, made by migration 00002: the fixed id 00000000-0000-0000-0000-000000000001, the name Management, an empty email and no password, so nothing can sign in as it or link an identity-provider account to it. Every lookup an outside value can reach — a login, a share recipient, an identity provider's claim — finds people only.

There is no role. users.role held admin or member until there were no administrators (adr/0013). Migration 00002 left it in place, unread, so the previous binary kept working while an upgrade replaced it; migration 00009 drops it and its type.

Sessions (sessions) and identity-provider links (identities) hang off users; how each resolves to an actor is in permissions.md.

Agents

CREATE TABLE agents (
  user_id     uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
  slug        text NOT NULL,                 -- agent:<slug> in a share
  description text NOT NULL DEFAULT '',
  created_at  timestamptz NOT NULL DEFAULT now(),
  deleted_at  timestamptz,
  CONSTRAINT agents_slug_ck CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$')
);
CREATE UNIQUE INDEX agents_slug_key ON agents (slug) WHERE deleted_at IS NULL;

CREATE TABLE agent_responsible (
  agent_id uuid NOT NULL REFERENCES agents(user_id) ON DELETE CASCADE,
  user_id  uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  added_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (agent_id, user_id)
);

An agent is a users row with kind = 'agent' (adr/0021), so grants, revisions and usage name it like anyone else. Its email is <id>@agents.invalid, an address nothing can be sent to. Its one identities row is its service account, written when management makes it and never on first sight. A deleted agent keeps its rows, without its identity, and its slug is free again. agent_responsible is who answers for it, and so who may share with it.

CREATE TABLE agent_reads (            -- what it opened: a row per page per day
  agent_id uuid, document_id uuid, day date, count bigint, last_at timestamptz,
  PRIMARY KEY (agent_id, document_id, day)
);
CREATE TABLE agent_views (            -- who viewed as it: a row per person per hour
  agent_id uuid, viewer_id uuid, hour timestamptz, requests bigint, last_at timestamptz,
  PRIMARY KEY (agent_id, viewer_id, hour)
);

Both feed the agent's page and are kept 90 days. Neither holds what the agent was asked.

CREATE TABLE groups (
  id          uuid PRIMARY KEY,
  slug        text NOT NULL,
  name        text NOT NULL,
  description text,
  kind        text NOT NULL DEFAULT 'custom',   -- custom | everyone
  created_by  uuid REFERENCES users(id) ON DELETE SET NULL,
  created_at  timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT groups_kind_ck CHECK (kind IN ('custom', 'everyone'))
);
CREATE UNIQUE INDEX groups_slug_key     ON groups (slug);
CREATE UNIQUE INDEX groups_one_everyone ON groups (kind) WHERE kind = 'everyone';
-- and a BEFORE DELETE trigger, groups_keep_everyone, that refuses to delete the everyone row

CREATE TABLE group_members (
  group_id uuid NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
  user_id  uuid NOT NULL REFERENCES users(id)  ON DELETE CASCADE,
  added_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (group_id, user_id)
);
CREATE INDEX group_members_user_idx ON group_members (user_id);

Everyone has no rows in group_members. It is the one kind = 'everyone' group, made by 00002 with the fixed id 00000000-0000-0000-0000-000000000002 and the slug everyone. The queries that load a person's groups add it to every person, so someone who joins later is in it from their first request and nothing has to keep it up to date. The trigger is there because deleting a group deletes every grant made to it, and the previous binary does not know Everyone is special.

The tables existed before anything managed groups, on purpose. The search index stores ACL entries as user:<id> and group:<id> strings, and introducing the group: form later would have meant reindexing every document in the knowledge base. Groups are now changed by management only (permissions.md).

Service tokens and the management audit

CREATE TABLE service_tokens (
  id           uuid PRIMARY KEY,
  user_id      uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,  -- management
  name         text NOT NULL,
  token_hash   text NOT NULL UNIQUE,   -- SHA-256 of the token, never the token
  prefix       text NOT NULL,          -- 'codna_' and six more characters, to tell tokens apart
  scope        text NOT NULL,
  expires_at   timestamptz,
  last_used_at timestamptz,
  revoked_at   timestamptz,
  created_at   timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT service_tokens_scope_ck CHECK (scope = 'manage' OR revoked_at IS NOT NULL)
);
CREATE INDEX service_tokens_user_idx ON service_tokens (user_id);

Every live token is a manage token that acts as management. Migration 00002 moved existing manage tokens to the management row and revoked the admin and read tokens issued before it; the CHECK keeps any live token a manage token.

CREATE TABLE management_audit (
  id                 uuid PRIMARY KEY,
  action             text NOT NULL,   -- user.create, group.member_add, token.revoke, …
  via                text NOT NULL,   -- token | cli
  token_id           uuid REFERENCES service_tokens(id) ON DELETE SET NULL,
  target_user_id     uuid REFERENCES users(id) ON DELETE SET NULL,
  target_group_id    uuid,
  target_document_id uuid,
  details            jsonb NOT NULL DEFAULT '{}',
  request_id         text,
  created_at         timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT management_audit_via_ck CHECK (via IN ('token', 'cli'))
);
CREATE INDEX management_audit_created_idx  ON management_audit (created_at DESC);
CREATE INDEX management_audit_user_idx     ON management_audit (target_user_id, created_at DESC)
  WHERE target_user_id IS NOT NULL;
CREATE INDEX management_audit_document_idx ON management_audit (target_document_id, created_at DESC)
  WHERE target_document_id IS NOT NULL;

What management did, through what, and to whom — append-only, and never content. Each row is written in the transaction that makes the change, so a change and its record commit together or not at all. action has no CHECK on purpose: an audit log must never fail to record. Break-glass (document.unshare, document.quarantine, document.release) fills target_document_id; a transfer (user.transfer) names the person who left in target_user_id; and every codna export --all on the host adds a row too, export.all, with what it wrote.


Documents

CREATE TABLE documents (
  id                uuid PRIMARY KEY,
  space             space           NOT NULL,
  path              text            NOT NULL DEFAULT '',   -- 'engineering/onboarding', '' = space root
  slug              text            NOT NULL,              -- 'laptop-setup'
  title             text            NOT NULL,
  content           text            NOT NULL DEFAULT '',   -- markdown body, WITHOUT frontmatter
  frontmatter_extra jsonb           NOT NULL DEFAULT '{}', -- keys CoDNA does not own
  tags              text[]          NOT NULL DEFAULT '{}',
  status            document_status NOT NULL DEFAULT 'active',
  version           integer         NOT NULL DEFAULT 1,
  content_hash      bytea           NOT NULL,              -- sha256(title || content || tags)
  created_by        uuid            NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
  updated_by        uuid            REFERENCES users(id) ON DELETE SET NULL,
  source_ingestion_id uuid,                                -- set for raw documents
  created_at        timestamptz     NOT NULL DEFAULT now(),
  updated_at        timestamptz     NOT NULL DEFAULT now(),
  deleted_at        timestamptz,                           -- tombstone
  purge_after       timestamptz,                           -- hard-delete eligibility
  quarantined_at    timestamptz                            -- held by management (00004)
);

CREATE UNIQUE INDEX documents_address_key
  ON documents (space, path, slug) WHERE deleted_at IS NULL;
CREATE INDEX documents_space_status_idx ON documents (space, status) WHERE deleted_at IS NULL;
CREATE INDEX documents_created_by_idx   ON documents (created_by);
CREATE INDEX documents_updated_at_idx   ON documents (updated_at DESC);
CREATE INDEX documents_tags_idx         ON documents USING gin (tags);
CREATE INDEX documents_purge_idx        ON documents (purge_after) WHERE deleted_at IS NOT NULL;
CREATE INDEX documents_quarantined_idx  ON documents (quarantined_at)
  WHERE quarantined_at IS NOT NULL;

created_by is the owner: whoever created the document, until management hands it to someone else when they leave (Owner changes). quarantined_at is set while management holds the document out of circulation (Quarantines).

Two identities, and the distinction matters throughout:

  • id — permanent, never changes, what links resolve to.
  • (space, path, slug) — the human address. Mutable. What people type and what export writes to disk.

The unique index is partial (WHERE deleted_at IS NULL) so a tombstoned document does not block a new one from taking its address.

content_hash exists so a save that changes nothing is detected before a revision is written — agents re-submitting identical content should not inflate the history you asked to keep readable.

content holds the body without frontmatter. Frontmatter is a projection of the columns, reassembled on export; see export.md.

Folders

There is no folders table. A folder is what the path of a document implies — codna/adr implies codna as well — and only documents the person asking can read imply anything, so a folder holding nothing they can read does not exist for them. Two endpoints read folders, with the same folders and the same counts: the live documents the person can read in a folder and beneath it, drafts and archived ones included, tombstones never.

  • GET /api/documents/tree?space=wiki&path=codna — one level: a folder's subfolders, each with its count and whether it has subfolders, and the documents directly in it. How the sidebar walks a space.

  • GET /api/folders?space=wiki — every folder of a space at once, for choosing where a document goes, so a typo is a folder that is not offered rather than a new one:

    {"space": "wiki", "folders": [{"path": "codna", "documents": 24}, {"path": "codna/adr", "documents": 14}, {"path": "codna-old", "documents": 2}]}
    

    Each folder comes straight after its parent (codna/adr before codna-old). At most 5000: past that, the folders that sort last are left out, never a folder without its parent.

Revisions

CREATE TABLE document_revisions (
  id                uuid PRIMARY KEY,
  document_id       uuid    NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  version           integer NOT NULL,
  title             text    NOT NULL,
  path              text    NOT NULL,
  slug              text    NOT NULL,
  content           text    NOT NULL,
  tags              text[]  NOT NULL DEFAULT '{}',
  frontmatter_extra jsonb   NOT NULL DEFAULT '{}',
  change_kind       text    NOT NULL,   -- create|content|move|rename|tags|status|restore|delete
  change_summary    text,
  author_id         uuid REFERENCES users(id) ON DELETE SET NULL,
  created_at        timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT document_revisions_kind_ck CHECK (change_kind IN
    ('create','content','move','rename','tags','status','restore','delete'))
);
CREATE UNIQUE INDEX document_revisions_version_key ON document_revisions (document_id, version);
CREATE INDEX document_revisions_doc_idx ON document_revisions (document_id, version DESC);
CREATE INDEX document_revisions_created_idx ON document_revisions (created_at);           -- 00011
CREATE INDEX document_revisions_author_idx ON document_revisions (author_id, created_at); -- 00011

Full snapshots, not a diff chain. Postgres TOAST compresses text transparently, so the storage difference at knowledge-base scale is small, and in exchange: restoring any version is a single row read, exporting history needs no replay, and a corrupted row cannot invalidate every later version. Diffs are computed on read with hexops/gotextdiff — cheap on documents of this size.

Every change writes a revision, including moves and renames. That is what later answers "why did this link go stale?" — the revision with change_kind='move' names the actor and the moment.

The current state in documents is always equal to the highest-numbered revision. documents exists as a separate table so the common read — "give me the latest" — never has to sort.

Revisions are kept forever. No pruning by age or count, and no configuration to turn it off. A knowledge base whose history silently thins out is one nobody can audit, and the question history answers — who changed this, when, and to what — is most often asked about the distant past. Pruning can be added later if a deployment needs it; a history already discarded cannot be recovered.

Note the contrast with the two things that are time-bounded: processed outbox events (CODNA_EVENT_RETENTION) and tombstoned documents awaiting purge (CODNA_PURGE_AFTER). Neither is history — one is transport, the other is a deletion already requested.

Address history

CREATE TABLE document_path_history (
  id          uuid PRIMARY KEY,
  document_id uuid  NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  space       space NOT NULL,
  path        text  NOT NULL,
  slug        text  NOT NULL,
  valid_from  timestamptz NOT NULL,
  valid_to    timestamptz NOT NULL DEFAULT now(),
  revision_id uuid REFERENCES document_revisions(id) ON DELETE SET NULL
);
CREATE INDEX document_path_history_addr_idx ON document_path_history (space, path, slug);
CREATE INDEX document_path_history_doc_idx  ON document_path_history (document_id);

Every address a document has vacated. Two jobs: resolving a link written against an old address, and letting the UI say "this link points at runbooks/deploy.md, which moved to ops/deploy.md" instead of just flagging it stale.

Owner changes

CREATE TABLE document_owner_changes (
  id          uuid PRIMARY KEY,
  document_id uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  from_user   uuid REFERENCES users(id) ON DELETE SET NULL,
  to_user     uuid REFERENCES users(id) ON DELETE SET NULL,
  transfer_id uuid NOT NULL,             -- shared by every document one transfer moved
  created_at  timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX document_owner_changes_document_idx
  ON document_owner_changes (document_id, created_at DESC);
CREATE INDEX document_owner_changes_transfer_idx ON document_owner_changes (transfer_id);

When someone leaves, management hands what they owned to someone who stays (permissions.md), and documents.created_by is rewritten. This table remembers what it was, so a document's history still says who made it and who has it now. Nothing else about a transferred document changes: not its version, not updated_at, not its grants, and its revisions keep their authors.

Files

CREATE TABLE attachments (
  id            uuid PRIMARY KEY,
  document_id   uuid   NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  name          text   NOT NULL,         -- its name in the page's <slug>.files/ folder
  mime_type     text   NOT NULL,         -- sniffed from the bytes at upload
  byte_size     bigint NOT NULL,
  sha256        bytea  NOT NULL,
  storage_key   text   NOT NULL,         -- files/2026/10/<uuid>.png in the storage root
  created_by    uuid   REFERENCES users(id) ON DELETE SET NULL,
  created_at    timestamptz NOT NULL DEFAULT now(),
  referenced_at timestamptz              -- when maintenance first found a revision naming it
);
CREATE UNIQUE INDEX attachments_name_key ON attachments (document_id, name);
CREATE INDEX attachments_sha_idx ON attachments (document_id, sha256);
CREATE INDEX attachments_unreferenced_idx ON attachments (created_at) WHERE referenced_at IS NULL;

A page's images and PDFs (files.md). A file belongs to one document and has its permission; nothing else grants access to one. Rows are never updated except to set referenced_at: a file never changes. They go when their page is purged, or, if no revision of the page ever named them, thirty days after upload. Maintenance reads the storage keys before the rows go and deletes the bytes after.


CREATE TABLE document_links (
  id                  uuid PRIMARY KEY,
  source_id           uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  target_id           uuid REFERENCES documents(id) ON DELETE SET NULL,
  href_as_written     text NOT NULL,     -- '../onboarding/laptop-setup.md'
  resolved_address    text,              -- 'wiki:engineering/onboarding/laptop-setup'
  link_text           text,              -- the [display text]
  occurrence_count    integer NOT NULL DEFAULT 1,
  state               link_state NOT NULL,
  first_seen_revision uuid REFERENCES document_revisions(id) ON DELETE SET NULL,
  created_at          timestamptz NOT NULL DEFAULT now(),
  updated_at          timestamptz NOT NULL DEFAULT now(),
  kept_at             timestamptz        -- its author kept a stale link as written (00005)
);
CREATE UNIQUE INDEX document_links_edge_key ON document_links (source_id, href_as_written);
CREATE INDEX document_links_target_idx ON document_links (target_id) WHERE target_id IS NOT NULL;
CREATE INDEX document_links_state_idx  ON document_links (state) WHERE state <> 'resolved';

Storing both the href as written and the resolved target_id is the whole trick. The edge is anchored to the id, so the graph survives any rename; the href is just text that may fall out of date, and falling out of date is a fact to surface, not a failure.

kept_at records one answer to that fact: the author kept a stale link as written. While it is set the link is not listed as a decision. Every change of state clears it (an edit that re-extracts the same stale link to the same target keeps it), so a link that later breaks is asked about again. Links whose source is in raw are never listed as decisions at all: raw is never edited, so no answer could be carried out.

state target_id meaning surfaced to the user as
resolved set target exists; href matches its current address nothing
stale_text set target moved or was renamed; href text is now wrong "update this link, keep it as written, or drop it?"
pending null href points at an address with no document nothing yet — resolves itself if one appears
broken set target is tombstoned "target deleted — repoint, drop, or restore?"
                    target created at that address
   pending ──────────────────────────────────────────▶ resolved
      ▲                                                 │   ▲
      │ target purged                     target moved  │   │ href edited to match
      │                                    or renamed   ▼   │      (or target moved back)
   broken ◀──────────── target deleted ──────────── stale_text
      │                                                 │
      └───────────── target restored ───────────────────┘

Transitions are computed by the link resolver, an outbox consumer. Nothing here is a trigger — the rules live in Go where they can be tested.

Full extraction and resolution rules, including how a relative href becomes an address, are in linking.md.

CREATE TABLE link_suggestions (
  id          uuid PRIMARY KEY,
  source_id   uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  target_id   uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  reason      text NOT NULL,   -- vector_similar|tag_overlap|co_citation|agent_proposed
  score       real,
  rationale   text,            -- human-readable: why the agent thinks these belong together
  suggested_by uuid REFERENCES users(id) ON DELETE SET NULL,
  status      decision_status NOT NULL DEFAULT 'pending',
  decided_by  uuid REFERENCES users(id) ON DELETE SET NULL,
  decided_at  timestamptz,
  created_at  timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT link_suggestions_reason_ck CHECK (reason IN
    ('vector_similar','tag_overlap','co_citation','agent_proposed')),
  CONSTRAINT link_suggestions_no_self CHECK (source_id <> target_id)
);
CREATE UNIQUE INDEX link_suggestions_pair_key ON link_suggestions (source_id, target_id);
CREATE INDEX link_suggestions_pending_idx ON link_suggestions (source_id) WHERE status = 'pending';

Linking is the agent's job to propose and the user's to decide. The unique constraint on (source_id, target_id) is what makes the row durable rather than transient: a rejected suggestion stays rejected. Without it an agent would re-propose the same link on every pass, and users would learn to ignore suggestions entirely.

Accepting a suggestion does not create an edge directly — it edits the source document to contain a real markdown link, which produces a revision, which the resolver then turns into a document_links row. The body is always the truth about what links to what.


Access

CREATE TABLE document_access (
  id             uuid PRIMARY KEY,
  document_id    uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  principal_type principal_type NOT NULL,
  principal_id   uuid NOT NULL,          -- users.id or groups.id
  permission     permission NOT NULL DEFAULT 'read',
  granted_by     uuid REFERENCES users(id) ON DELETE SET NULL,
  grant_reason   text NOT NULL DEFAULT 'direct',  -- direct|bulk_tag|link_suggestion|script|import|folder
  batch_id       uuid,                   -- groups one user action across many documents
  created_at     timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT document_access_reason_ck CHECK (grant_reason IN
    ('direct','bulk_tag','link_suggestion','script','import','folder'))
);
CREATE UNIQUE INDEX document_access_key ON document_access (document_id, principal_type, principal_id);
CREATE INDEX document_access_principal_idx ON document_access (principal_type, principal_id);
CREATE INDEX document_access_batch_idx ON document_access (batch_id) WHERE batch_id IS NOT NULL;

The complete ACL. The rules that read it:

  • Creator — their own documents, no rows required.
  • Everyone else — needs a row here, or a row for a group they belong to. Every person belongs to Everyone, so a row for that group reaches them all.
  • Management — nothing, whatever the rows say. Nobody sees every document (adr/0013).

grant_reason and batch_id are what make access explicable. "Why can Dana see this?" has an answer — granted by Tornike on 24 Sep, as part of sharing the onboarding guide, because it was linked from it — rather than an opaque row. batch_id ties the whole multi-document share together so it can be reviewed or revoked as one action.

Access events

CREATE TABLE access_events (
  id             uuid PRIMARY KEY,
  document_id    uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  principal_type principal_type NOT NULL,
  principal_id   uuid NOT NULL,
  permission     permission,
  action         text NOT NULL,          -- granted|revoked|permission_changed
  actor_id       uuid REFERENCES users(id) ON DELETE SET NULL,
  grant_reason   text,
  batch_id       uuid,
  created_at     timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT access_events_action_ck CHECK (action IN ('granted','revoked','permission_changed'))
);
CREATE INDEX access_events_principal_idx ON access_events (principal_type, principal_id, created_at DESC);
CREATE INDEX access_events_document_idx  ON access_events (document_id, created_at DESC);

Append-only, never updated or deleted.

This table exists because document_access cannot answer "what was I given access to recently?" The ACL is current state: a grant that was revoked and re-granted leaves one row with one timestamp, and a revoked grant leaves no row at all. History has to live somewhere else, and an agent that wants to open a conversation with "three documents were shared with you since we last spoke" reads this table, not the ACL.

Shares management takes away are recorded here too, with grant_reason management (one taken away) or quarantine; shares a release gives back are recorded as granted, with release.

Quarantines

CREATE TABLE document_quarantines (
  id               uuid PRIMARY KEY,
  document_id      uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  reason           text NOT NULL,          -- management's words, shown to the owner
  saved_grants     jsonb NOT NULL,         -- the shares moved aside, for a release to give back
  quarantined_at   timestamptz NOT NULL DEFAULT now(),
  released_at      timestamptz,
  restored_sharing boolean                 -- whether the release gave them back
);
CREATE UNIQUE INDEX document_quarantines_active ON document_quarantines (document_id)
  WHERE released_at IS NULL;

A quarantine is the grants moved aside, not a rule. Quarantining sets documents.quarantined_at, deletes the document's rows from document_access and keeps them in saved_grants, so the visibility queries and the search filter show the document to its owner alone without knowing quarantines exist. The policy reads quarantined_at for one thing: no share while it is set (permissions.md). A release gives the saved grants back as they were — granted_by, grant_reason, batch_id and created_at included — except to people since deactivated and groups since deleted.

One row per quarantine, kept after release so the history says what was done and why; the partial unique index allows one open quarantine per document. Migration 00004's Down refuses while any document is quarantined: dropping the column would put a held document back into circulation, re-sharable, with the record of why gone.

Folder shares

CREATE TABLE folder_shares (
  id             uuid PRIMARY KEY,
  owner_id       uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  space          space NOT NULL,
  path           text NOT NULL,          -- a folder and every folder beneath it; '' is the space
  principal_type principal_type NOT NULL,
  principal_id   uuid NOT NULL,
  permission     permission NOT NULL DEFAULT 'read',
  created_at     timestamptz NOT NULL DEFAULT now(),
  updated_at     timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX folder_shares_key
  ON folder_shares (owner_id, space, path, principal_type, principal_id);

A standing rule about the owner's own documents in a folder (adr/0014). It grants nothing itself: each document it reaches gets an ordinary document_access row with grant_reason = 'folder', now for what is there and from the outbox for what arrives later. So the visibility queries and the search filter know nothing about folder shares, and need not. Migration 00008's Down turns those grants into direct ones before dropping the table: the access stays, without the rule behind it.

Declined share suggestions

CREATE TABLE share_suggestion_decisions (
  id             uuid PRIMARY KEY,
  document_id    uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  principal_type principal_type NOT NULL,
  principal_id   uuid NOT NULL,
  decided_by     uuid REFERENCES users(id) ON DELETE SET NULL,
  status         decision_status NOT NULL DEFAULT 'rejected',
  created_at     timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX share_suggestion_decisions_key
  ON share_suggestion_decisions (document_id, principal_type, principal_id);

The same reasoning as link_suggestions: a suggestion declined once must not come back every time the user shares something nearby.


Automations

CREATE TABLE automations (
  id             uuid PRIMARY KEY,
  name           text NOT NULL,
  description    text,
  kind           automation_kind NOT NULL,
  enabled        boolean NOT NULL DEFAULT true,

  -- kind = 'event'
  trigger_events text[] NOT NULL DEFAULT '{}',
  scope          jsonb  NOT NULL DEFAULT '{}',  -- {space, pathPrefix, tags} cheap pre-filter

  -- kind = 'schedule'
  schedule       text,                          -- cron expression, 5-field
  timezone       text NOT NULL DEFAULT 'UTC',
  next_run_at    timestamptz,
  last_run_at    timestamptz,

  script         text NOT NULL,                 -- JavaScript, usually agent-authored
  script_hash    bytea NOT NULL,
  owner_id       uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  created_at     timestamptz NOT NULL DEFAULT now(),
  updated_at     timestamptz NOT NULL DEFAULT now(),

  CONSTRAINT automations_shape_ck CHECK (
    (kind = 'event'    AND cardinality(trigger_events) > 0 AND schedule IS NULL) OR
    (kind = 'schedule' AND schedule IS NOT NULL)
  )
);
CREATE INDEX automations_due_idx ON automations (next_run_at)
  WHERE enabled AND kind = 'schedule';
CREATE INDEX automations_trigger_idx ON automations USING gin (trigger_events)
  WHERE enabled AND kind = 'event';
CREATE INDEX automations_owner_idx ON automations (owner_id);

Two kinds, one script contract:

  • event — fires from the outbox. Triggers span document lifecycle, access changes, link-graph changes, and ingestion. The scope JSONB is a cheap pre-filter evaluated in SQL so the sandbox is not started for events the script would immediately ignore.
  • schedule — fires from a cron tick. Has no subject document, which is why the capability API includes bounded reads and not only actions.

next_run_at is computed with adhocore/gronx and stored, rather than held by an in-process scheduler. Due automations are claimed with:

UPDATE automations SET last_run_at = now(), next_run_at = $2
WHERE id = (
  SELECT id FROM automations
  WHERE enabled AND kind = 'schedule' AND next_run_at <= now()
  ORDER BY next_run_at
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
RETURNING *;

Keeping the schedule in Postgres rather than in memory is what lets CoDNA run more than one replica without double-firing or dropping ticks.

CREATE TABLE automation_runs (
  id            uuid PRIMARY KEY,
  automation_id uuid NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
  event_id      uuid,                    -- null for scheduled runs
  status        run_status NOT NULL,
  duration_ms   integer,
  memory_peak   bigint,                  -- bytes, as reported by QuickJS
  limit_hit     text,                    -- null|memory|execution_time|actions|reads
  actions       jsonb NOT NULL DEFAULT '[]',  -- proposed, with applied/rejected verdict each
  reads         integer NOT NULL DEFAULT 0,
  logs          text,
  error         text,
  created_at    timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX automation_runs_automation_idx ON automation_runs (automation_id, created_at DESC);

Every run is recorded with the actions it proposed and whether each was applied or rejected. A script that keeps trying to share outside its owner's permissions is visible, attributable, and switched off — rather than silently failing forever.

Full contract, capability API, and limits in automations.md.


Plumbing

The outbox

CREATE TABLE events (
  id           uuid PRIMARY KEY,
  event_type   text NOT NULL,   -- document.created, access.granted, link.became_broken, …
  subject_type text NOT NULL,   -- document|access|link|ingestion
  subject_id   uuid,
  document_id  uuid REFERENCES documents(id) ON DELETE CASCADE,
  actor_id     uuid REFERENCES users(id) ON DELETE SET NULL,
  revision_id  uuid REFERENCES document_revisions(id) ON DELETE SET NULL,
  payload      jsonb NOT NULL DEFAULT '{}',
  created_at   timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz,
  attempts     integer NOT NULL DEFAULT 0,
  last_error   text,
  claimed_at   timestamptz,   -- in-flight lease
  claimed_by   text,
  retry_at     timestamptz,   -- a failed event waits until then
  handled_by   text[] NOT NULL DEFAULT '{}'  -- consumers that already succeeded
);
CREATE INDEX events_unprocessed_idx ON events (created_at) WHERE processed_at IS NULL;
CREATE INDEX events_claimed_idx ON events (claimed_at) WHERE processed_at IS NULL;
CREATE INDEX events_document_idx ON events (document_id, created_at DESC);

Written in the same transaction as the change it describes. Drained by a worker that wakes on LISTEN/NOTIFY and falls back to a periodic poll, so a missed notification delays work rather than losing it.

SKIP LOCKED alone is not a queue. It prevents two workers colliding on a row during the claiming statement, but that statement commits immediately — and the row stays unprocessed for as long as the consumer takes to handle it. In that window every other worker will claim it again. This was caught by a test that ran six concurrent claimers and found single events claimed five times over.

The claimed_at lease closes the window: a claimed row is invisible to other workers until its lease expires. An expired lease means the worker holding it died, so the event returns to the pool — which is exactly what makes delivery at-least-once rather than at-most-once.

Consumers must therefore be idempotent.

Notifications

CREATE TABLE notifications (
  id               uuid PRIMARY KEY,
  recipient_id     uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  type             text NOT NULL,
  subject_type     text NOT NULL,
  subject_id       uuid,
  payload          jsonb NOT NULL DEFAULT '{}',
  requires_decision boolean NOT NULL DEFAULT false,
  status           text NOT NULL DEFAULT 'unread',  -- unread|read|resolved|dismissed
  batch_id         uuid,
  created_at       timestamptz NOT NULL DEFAULT now(),
  read_at          timestamptz,
  resolved_at      timestamptz,
  CONSTRAINT notifications_status_ck CHECK (status IN ('unread','read','resolved','dismissed'))
);
CREATE INDEX notifications_inbox_idx ON notifications (recipient_id, status, created_at DESC);
CREATE INDEX notifications_pending_idx ON notifications (recipient_id)
  WHERE requires_decision AND status IN ('unread','read');

Types: access.granted, access.revoked, access.reshared (to a document's creator, when someone else shared it), link.target_moved, link.target_deleted, link.suggested, automation.failed, ingestion.ready, automation.message, and those that management's changes raise for the person concerned: group.added, group.removed, account.password_reset, documents.transferred (to the new owner), agent.responsible_added and agent.responsible_removed, and document.quarantined, document.released and document.access_revoked (to the document's owner).

requires_decision separates information ("you were given access to 3 documents") from work ("a document you link to was deleted — repoint, drop, or restore?"). Only the second kind should follow a user around until it is resolved.

The inbox badge comes from GET /api/me/inbox-counts: the person's link decisions (from the link graph), the links proposed into documents they can edit, and their unread information — a notification that requires a decision is counted once, as the decision, never again as unread. POST /api/me/notifications/read marks all of that information read in one statement; decisions stay until they are answered.

Ingestions

CREATE TABLE ingestions (
  id          uuid PRIMARY KEY,
  user_id     uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
  filename    text NOT NULL,
  mime_type   text NOT NULL,
  byte_size   bigint NOT NULL DEFAULT 0,
  storage_key text,                    -- 'local://raw/2026/09/<uuid>.pdf' — the untouched original
  source      text,                    -- URL, Slack permalink, whatever it came from
  status      text NOT NULL DEFAULT 'pending',  -- pending|processing|ready|failed
  error       text,
  document_id uuid REFERENCES documents(id) ON DELETE SET NULL,
  created_at  timestamptz NOT NULL DEFAULT now(),
  updated_at  timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT ingestions_status_ck CHECK (status IN ('pending','processing','ready','failed'))
);
CREATE INDEX ingestions_user_idx   ON ingestions (user_id, created_at DESC);
CREATE INDEX ingestions_status_idx ON ingestions (status) WHERE status <> 'ready';

The original file is always kept. A conversion that turns out to be wrong can be redone without asking anyone to re-upload. See ingestion.md.

Imports

CREATE TABLE imports (
  id           uuid PRIMARY KEY,
  user_id      uuid NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
  ingestion_id uuid NOT NULL REFERENCES ingestions(id) ON DELETE RESTRICT,  -- the kept archive
  format       text NOT NULL,          -- notion | confluence | markdown | files
  space        space NOT NULL DEFAULT 'raw',
  path         text NOT NULL,          -- the folder the pages land in
  status       text NOT NULL DEFAULT 'queued',   -- queued|running|done|failed
  done         integer NOT NULL DEFAULT 0,       -- pages written so far,
  total        integer NOT NULL DEFAULT 0,       -- of how many
  report       jsonb NOT NULL DEFAULT '{}',      -- what landed, what was skipped and why
  error        text,
  attempts     integer NOT NULL DEFAULT 0,
  worker_id    text,
  lease_until  timestamptz,            -- renewed while a worker reads the archive
  created_at, started_at, finished_at, updated_at timestamptz
);

CREATE TABLE import_pages (
  import_id   uuid NOT NULL REFERENCES imports(id) ON DELETE CASCADE,
  source_key  text NOT NULL,           -- a Notion page id, a Confluence page id, a path
  document_id uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
  PRIMARY KEY (import_id, source_key)
);

An import reads an export archive into a folder of raw pages, in the background (ingestion.md). import_pages is what each page became: a re-import into the same folder finds its pages there, wherever they have been moved, and adds a version to those that changed (adr/0016). A row is written as each page is, so an import picked up again after its worker died redoes nothing.

Usage counts

CREATE TABLE usage_daily (
  day      date        NOT NULL,                       -- UTC
  user_id  uuid        NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  activity text        NOT NULL,                       -- sign_in | web | mcp
  tool     text        NOT NULL DEFAULT '',            -- the MCP tool, or (unknown); '' otherwise
  outcome  text        NOT NULL DEFAULT '',            -- ok | refused | error for mcp; '' otherwise
  count    bigint      NOT NULL DEFAULT 0,             -- for web, the hours of the day seen
  last_at  timestamptz NOT NULL,
  PRIMARY KEY (day, user_id, activity, tool, outcome)
);
CREATE INDEX usage_daily_user_idx ON usage_daily (user_id, day);

How the instance is used, as a count per person, day and kind: one upsert per sign-in and per tool call, and one per person per hour of web use. Never what was read, searched for or written — permissions.md has what the report built on it says. Writing is not here: the revisions already record who saved what when. Rows are small and few (people × tools × days), and are kept like the revisions.


Share suggestions: the traversal

Sharing walks outward from the shared document over resolved edges, cycle-safe and depth-limited — depth 1 by default, meaning direct links only. The query below takes the depth as a parameter; see permissions.md for why the default is not deeper.

WITH RECURSIVE reachable AS (
    SELECT l.target_id AS id, 1 AS depth, ARRAY[l.source_id, l.target_id] AS via
    FROM document_links l
    WHERE l.source_id = $1
      AND l.target_id IS NOT NULL
      AND l.state IN ('resolved', 'stale_text')

    UNION ALL

    SELECT l.target_id, r.depth + 1, r.via || l.target_id
    FROM document_links l
    JOIN reachable r ON l.source_id = r.id
    WHERE l.target_id IS NOT NULL
      AND l.state IN ('resolved', 'stale_text')
      AND r.depth < $2              -- default 3
      AND NOT l.target_id = ANY(r.via)   -- cycle guard
)
SELECT DISTINCT ON (id) id, depth, via
FROM reachable
ORDER BY id, depth          -- shallowest path wins

The result is then filtered to documents the sharer may actually share, minus what the recipient can already see — by a grant of their own, or one to a group they see through (every group of a person's, Everyone included; Everyone, for a group) — minus anything in share_suggestion_decisions.

via is returned, not just depth, so the agent can explain each suggestion — "suggested because onboarding links to laptop-setup, which links to this" — rather than presenting an unexplained list. An opaque list gets approved blindly, which is how over-sharing happens.

stale_text edges are included: the href text being out of date says nothing about whether the documents belong together.


Worked traces

Each scenario below names the exact rows written. If a scenario cannot be traced to rows, the model is wrong.

1 · Tornike creates a wiki document

documents            + 1  version=1, created_by=tornike, content_hash=sha256(…)
document_revisions   + 1  version=1, change_kind='create'
events               + 1  document.created

Then, from the outbox:

document_links       + n  one per markdown link found in the body
                          state='resolved' where the target exists, 'pending' where it does not
Typesense            upsert  acl_read=['user:tornike']

No document_access rows. The creator's access is implicit — the alternative, a self-grant row on every document, is redundant data that can drift out of agreement with created_by.

2 · Tornike shares onboarding with Dana, and accepts two suggestions

The traversal above returns laptop-setup (depth 1), vpn-access (depth 2), payroll (depth 2). Tornike accepts the first two and declines payroll. One batch_id covers the whole action:

document_access      + 3  (onboarding, laptop-setup, vpn-access) → user:dana, read
                          grant_reason: 'direct' | 'link_suggestion' | 'link_suggestion'
                          batch_id: B
access_events        + 3  action='granted', batch_id=B
share_suggestion_decisions + 1  (payroll, user:dana, 'rejected')
events               + 3  access.granted
notifications        + 1  to dana, type='access.granted', batch_id=B
                          payload: { count: 3, documents: [...] }
Typesense            upsert ×3  acl_read += 'user:dana'

One notification for the batch, not three. payroll will not be suggested for Dana again.

3 · "Share everything tagged runbook with the ops team"

A one-time expansion, not a standing rule:

SELECT id FROM documents
WHERE tags @> ARRAY['runbook'] AND deleted_at IS NULL AND status = 'active'
document_access      + n  grant_reason='bulk_tag', batch_id=B
access_events        + n  action='granted', batch_id=B
notifications        + 1  per recipient, batch_id=B
Typesense            upsert ×n

A document tagged runbook tomorrow is not shared — the set was resolved once. To make it continuous, the user asks their agent for an automation (trace 7). A folder is different: a folder share stands, and covers what is put there later (adr/0014):

folder_shares        + 1  (owner, space, path, principal, permission)
document_access      + n  grant_reason='folder'   — the owner's documents there now
   └─ later, from the outbox: document.created / moved / restored into the folder
      document_access + 1  grant_reason='folder', with its event, reindex and notification

4 · runbooks/deploy.md is renamed to ops/deploy.md

Three documents link to it.

documents            path/slug updated, its own relative links rebased, version 4 → 5
document_revisions   + 1  version=5, change_kind='move'
document_path_history + 1  ('wiki', 'runbooks', 'deploy'), valid_to=now()
events               + 1  document.moved

From the outbox:

document_links       3 edges → state='stale_text'   (target_id unchanged — the graph is intact)
notifications        + 3  one per linking document's author
                          requires_decision=true, type='link.target_moved'
                          payload: { from: 'runbooks/deploy.md', to: 'ops/deploy.md' }
Typesense            upsert  (path changed)

No linking document is edited. Rewriting them would manufacture a revision on each one and pollute exactly the history this design exists to keep readable. The authors decide.

documents            deleted_at=now(), purge_after=now()+30d
document_revisions   + 1  change_kind='delete'
events               + 1  document.deleted

From the outbox:

document_links       2 edges → state='broken'   (target_id retained, so restore is possible)
notifications        + 2  requires_decision=true, type='link.target_deleted'
                          actions offered: repoint · drop link · restore target
Typesense            delete

The row survives with its full history. A restore flips deleted_at to null, returns the edges to resolved, and resolves the notifications. Purge only hard-deletes once no edge is still pending.

6 · "What was I given access to recently?"

SELECT ae.*, d.title, d.space, d.path, d.slug
FROM access_events ae
JOIN documents d ON d.id = ae.document_id
WHERE ae.principal_type = 'user' AND ae.principal_id = $1
  AND ae.action = 'granted'
  AND ae.created_at > $2            -- last time the agent checked
  AND d.deleted_at IS NULL
  AND EXISTS (                      -- still granted, not since revoked
    SELECT 1 FROM document_access da
    WHERE da.document_id = ae.document_id
      AND da.principal_type = 'user' AND da.principal_id = $1
  )
ORDER BY ae.created_at DESC;

The EXISTS clause matters: the event log is append-only, so a grant that was later revoked is still in it. Announcing access that no longer exists would be worse than saying nothing.

7 · A tag-triggered automation makes trace 3 continuous

automations  + 1  kind='event', trigger_events={document.created,document.updated}
                  scope={"tags":["runbook"]}, owner_id=tornike

Later, someone tags a document runbook:

events             + 1  document.updated
  └─ matcher: scope matches → sandbox runs
     script proposes: share('ops-team@example.com','read')
     host validates against TORNIKE's permissions → allowed
automation_runs    + 1  status='ok', actions=[{share…, applied:true}], memory_peak=…
document_access    + 1  grant_reason='script'
access_events      + 1  action='granted'
notifications      + 1  to the ops team members
Typesense          upsert

If Tornike cannot share that document, the action is recorded in automation_runs with applied:false and a reason. The script does not fail silently and does not gain access its author lacks.

8 · A scheduled automation, with no subject document

automations  + 1  kind='schedule', schedule='0 9 * * 1', timezone='Asia/Tbilisi'
                  next_run_at=<next Monday 09:00 local, stored UTC>

On the tick:

automations        claimed via FOR UPDATE SKIP LOCKED; next_run_at recomputed
  └─ sandbox runs with NO doc in context
     codna.find({space:'wiki', updatedBefore:'-180d', tags:['runbook']})
       → executed as OWNER, ACL-filtered, capped at 200 rows
     script proposes: notify(author) per result
automation_runs    + 1  reads=1, actions=[…]
notifications      + n  type='automation.message'

The read is what makes scheduled scripts possible at all, and scoping it to the owner is what keeps them safe: a person's weekly script sees exactly what that person could see by searching.

9 · Dana leaves, and what she owned goes to Tornike

Management deactivates her, then transfers (permissions.md):

users                  is_active=false (dana)
sessions               − every one of dana's
management_audit       + 1  action='user.deactivate', target_user_id=dana
documents              created_by: dana → tornike, × n (live and trashed)
                          version and updated_at unchanged; grants untouched
document_owner_changes + n  from_user=dana, to_user=tornike, transfer_id=T
events                 + n  document.transferred
automations            owner_id: dana → tornike, enabled=false, × m
notifications          + 1  to tornike, type='documents.transferred'
management_audit       + 1  action='user.transfer', target_user_id=dana

From the outbox:

Typesense              upsert per live document  acl_read: user:dana → user:tornike

Whoever Dana shared with still reads what she shared, and Tornike can now take back any of it. Her revisions keep her as their author; document_owner_changes says the documents were hers.

10 · A page reported as holding a password is quarantined, then released

Someone reports it by its id. Management looks at it without reading it — owner, space, status, dates and who it is shared with — and quarantines it with a reason (permissions.md):

documents              quarantined_at=now()   (first, so it holds the row's lock)
document_access        − k  every grant, moved into …
document_quarantines   + 1  reason, saved_grants=[k grants]
access_events          + k  action='revoked', grant_reason='quarantine'
events                 + 1  access.revoked
notifications          + 1  to the owner, type='document.quarantined'
management_audit       + 1  action='document.quarantine', target_document_id

From the outbox:

Typesense              upsert  acl_read=['user:<owner>']

The owner, the only one who can still see it, looks: the "password" is a placeholder in an example. Management releases it with restore_sharing:

documents              quarantined_at=NULL
document_access        + k' the saved grants as they were, k' ≤ k — none to people since
                          deactivated or groups since deleted, which the release reports
access_events          + k' action='granted', grant_reason='release'
events                 + 1  access.granted
document_quarantines   released_at=now(), restored_sharing=true
notifications          + 1  to the owner, type='document.released'
management_audit       + 1  action='document.release', target_document_id

At no point does management receive the title, the address or a word of the body. Had the password been real, editing it out would not have been enough before giving the shares back: revisions are kept forever, and everyone who can read a document can read its history.