Files
Void-Homelab/docs/superpowers/plans/2026-05-31-void-v2-plan1-foundation.md
root 54ba68a11c docs: move void-v2 specs + plans into the repo
All Void 2.0 superpowers specs and implementation plans now live at
docs/superpowers/{specs,plans}/ inside the repo. Previously they were
at /project/docs/superpowers/ which was not under git.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 04:11:32 +10:00

100 KiB

Void 2.0 — Plan 1: Foundation Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Provision the two Void 2.0 LXCs, stand up Postgres + pgvector, scaffold the void-server Node project, build the full schema via migrations, implement all entity repositories with capability checks, ship owner-token auth, and expose a healthcheck. Produces a running-but-empty backend you can hit with curl.

Architecture: Two LXCs — void2-db (Postgres 16 + pgvector) and void2-app (Node 22 + Express). One repo at /project/src/void-v2. Strict layer boundaries: HTTP routes → lib/api/lib/db/repos/ → SQL. Capability checks centralised in lib/auth/capability.js; audit log written by every mutating repo call.

Tech Stack: Node 22 LTS, Express 4, pg (node-postgres), Zod (validation), Vitest (tests), bcrypt (token hashing), pino (logs), pgvector (vector type), pg-boss (later plans). PVE 8.x for LXCs.

Reference spec: /project/docs/superpowers/specs/2026-05-31-void-v2-design.md


File Structure

/project/src/void-v2/
├── README.md
├── CHANGELOG.md
├── docs/
│   └── VERSION_HISTORY.md
├── .env.example
├── .gitignore
├── package.json
├── vitest.config.js
├── server.js                       Express bootstrap (entry)
├── lib/
│   ├── db/
│   │   ├── pool.js                 Postgres connection pool
│   │   ├── migrate.js              Migration runner (up only for v1)
│   │   ├── migrations/
│   │   │   ├── 001_core.sql        spaces, projects, tasks
│   │   │   ├── 002_knowledge.sql   pages, page_revisions, refs
│   │   │   ├── 003_resources.sql   resources, dependencies, credentials, source_docs
│   │   │   ├── 004_agents.sql      agents, conversations, messages
│   │   │   ├── 005_cross.sql       tags, entity_tags, entity_links, attachments
│   │   │   └── 006_audit.sql       audit_log, pending_changes
│   │   └── repos/
│   │       ├── spaces.js
│   │       ├── projects.js
│   │       ├── tasks.js
│   │       ├── pages.js
│   │       ├── refs.js
│   │       ├── resources.js
│   │       ├── source_docs.js
│   │       ├── agents.js
│   │       ├── conversations.js
│   │       ├── messages.js
│   │       ├── tags.js
│   │       ├── links.js
│   │       ├── attachments.js
│   │       ├── audit.js
│   │       └── pending_changes.js
│   ├── auth/
│   │   ├── owner.js                Owner bearer-token middleware
│   │   └── capability.js           Per-actor capability check
│   └── log.js                      pino logger
├── tests/
│   ├── helpers/
│   │   └── db.js                   Test DB bootstrap (drop + migrate + transaction-per-test)
│   └── repos/                       One test file per repo
└── deploy/
    ├── void2-app.service           systemd unit
    └── README.md                   Deploy steps

Task 1: Repo Scaffolding + Git Init

Files:

  • Create: /project/src/void-v2/.gitignore

  • Create: /project/src/void-v2/README.md

  • Create: /project/src/void-v2/CHANGELOG.md

  • Create: /project/src/void-v2/docs/VERSION_HISTORY.md

  • Create: /project/src/void-v2/.env.example

  • Step 1: Create repo directory and init git

mkdir -p /project/src/void-v2/docs
cd /project/src/void-v2
git init -b main
  • Step 2: Write .gitignore
node_modules/
.env
*.log
coverage/
.DS_Store
  • Step 3: Write README.md
# Void 2.0

Homelab orchestrator + canonical knowledge store. Cradle-themed.
Successor to Void 1.x (CT 301). Spec at
`/project/docs/superpowers/specs/2026-05-31-void-v2-design.md`.

## Layout
- `void-server` (this repo) — Node API, MCP, UI, cron, agent runtime
- `void-workers` — Python ingest workers (separate repo, later plan)

## Quick start (dev)
1. Provision `void2-db` LXC (see `deploy/README.md`)
2. Install Postgres + pgvector on `void2-db`
3. `npm install`
4. `cp .env.example .env` and edit
5. `npm run migrate`
6. `npm start`
7. `curl -H "Authorization: Bearer $OWNER_TOKEN" http://localhost:3000/health`
  • Step 4: Write CHANGELOG.md
# Changelog

All notable changes to Void 2.0 are documented here.
Format: [Keep a Changelog](https://keepachangelog.com).

## [Unreleased]

### Added
- Initial repo scaffolding
  • Step 5: Write docs/VERSION_HISTORY.md
# Version History

Narrative history of major Void releases.

## Void 2.0 (in development, started 2026-05-31)

Complete remaster of Void 1.x. Foundation-first rebuild on Postgres + pgvector,
two-LXC HA shape, expanded entity model (Spaces, Projects, Tasks, Pages,
References, Source Docs, Resources, Conversations, Agents), unified MCP
surface for AI agents with per-agent capability tiers.

See spec: `/project/docs/superpowers/specs/2026-05-31-void-v2-design.md`.

## Void 1.x (2026-04 — 2026-05, CT 301)

Cradle-themed dashboard + 7 character agents (Dross, Orthos, Eithan, Mercy,
Lindon, Yerin, Little Blue). Gridstack Sacred Valley layout. node:sqlite
storage. Mastra+Ollama for Orthos; Claude binary subprocess for others.
Path B rebuild (2026-05-13) added FTS5 search, NLP summaries, memory-update
markers, BookStack page import.
  • Step 6: Write .env.example
DATABASE_URL=postgres://void:CHANGE_ME@192.168.X.X:5432/void
OWNER_TOKEN=CHANGE_ME_TO_LONG_RANDOM
PORT=3000
LOG_LEVEL=info
NODE_ENV=development
  • Step 7: Commit
git add -A
git commit -m "chore: initial repo scaffolding"

Task 2: PVE — Provision void2-db and void2-app LXCs

Files:

  • Create: /project/src/void-v2/deploy/README.md

Note: These commands run on the PVE host (z), not inside /project. Hand to user to execute, or SSH from agent if PVE shell is reachable. Use unprivileged LXCs with the standard apparmor=unconfined override for this homelab.

  • Step 1: Choose CT IDs and IPs

Pick two free CT IDs (suggestion: 310 = void2-db, 311 = void2-app). Pick two free IPs on the 192.168.1.0/24 LAN.

  • Step 2: Create void2-db LXC on PVE host
# Run on PVE host 'z' as root
pct create 310 local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst \
  --hostname void2-db \
  --cores 2 --memory 4096 --swap 1024 \
  --net0 name=eth0,bridge=vmbr0,ip=192.168.1.X/24,gw=192.168.1.1 \
  --storage donatello-zfs --rootfs donatello-zfs:32 \
  --unprivileged 1 --features nesting=1 \
  --onboot 1 --start 0

# Apparmor override (this LXC env requirement per CLAUDE.md)
cat >> /etc/pve/lxc/310.conf <<'EOF'
lxc.apparmor.profile: unconfined
EOF

pct start 310
  • Step 3: Create void2-app LXC
pct create 311 local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst \
  --hostname void2-app \
  --cores 4 --memory 4096 --swap 1024 \
  --net0 name=eth0,bridge=vmbr0,ip=192.168.1.Y/24,gw=192.168.1.1 \
  --storage donatello-zfs --rootfs donatello-zfs:16 \
  --unprivileged 1 --features nesting=1 \
  --onboot 1 --start 0

cat >> /etc/pve/lxc/311.conf <<'EOF'
lxc.apparmor.profile: unconfined
EOF

pct start 311
  • Step 4: Verify both LXCs are up
pct list | grep void2
# Expect: 310 running void2-db, 311 running void2-app
  • Step 5: Write deploy/README.md capturing the actual CT IDs + IPs chosen
# Deploy

## LXC inventory

| Role | CT | IP | Specs |
|---|---|---|---|
| Postgres | 310 (`void2-db`) | 192.168.1.X | 2 vCPU, 4 GB RAM, 32 GB disk |
| App | 311 (`void2-app`) | 192.168.1.Y | 4 vCPU, 4 GB RAM, 16 GB disk |

## Maintenance flow (planned downtime)
1. Notify Void: `curl -X POST .../api/maintenance/start`  (later plan)
2. `pct migrate 310 <target>`
3. `pct migrate 311 <target>`
4. Service maintenance on source host
5. Migrate back when done
  • Step 6: Commit
cd /project/src/void-v2
git add deploy/README.md
git commit -m "docs: capture LXC provisioning steps + inventory"

Task 3: Postgres 16 + pgvector on void2-db

Files:

  • Modify: /project/src/void-v2/deploy/README.md (append Postgres setup section)

Note: Runs inside CT 310 (void2-db).

  • Step 1: SSH into void2-db (or pct enter 310 on PVE host)
pct enter 310
  • Step 2: Install Postgres 16 + pgvector
apt update
apt install -y curl ca-certificates gnupg
install -d /usr/share/postgresql-common/pgdg
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
  -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] \
  https://apt.postgresql.org/pub/repos/apt $(. /etc/os-release; echo $VERSION_CODENAME)-pgdg main" \
  > /etc/apt/sources.list.d/pgdg.list
apt update
apt install -y postgresql-16 postgresql-16-pgvector
  • Step 3: Configure listen address + pg_hba for LAN access
# Listen on LAN
sed -i "s/^#listen_addresses.*/listen_addresses = '*'/" \
  /etc/postgresql/16/main/postgresql.conf

# Allow LAN access from void2-app
cat >> /etc/postgresql/16/main/pg_hba.conf <<'EOF'
# Void 2.0 app
host    void    void    192.168.1.0/24    scram-sha-256
EOF

systemctl restart postgresql
  • Step 4: Create database + user, enable pgvector
sudo -u postgres psql <<'EOF'
CREATE USER void WITH PASSWORD 'GENERATE_AND_CHANGE_ME';
CREATE DATABASE void OWNER void;
\c void
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pgcrypto;  -- for gen_random_uuid()
EOF

Generate password with openssl rand -base64 24. Store in your password manager + paste into .env on void2-app later.

  • Step 5: Verify connection from void2-app
# From void2-app
apt install -y postgresql-client
PGPASSWORD=<the-password> psql -h 192.168.1.X -U void -d void -c 'SELECT version();'
# Expect: PostgreSQL 16.x
  • Step 6: Append deploy notes

Append to /project/src/void-v2/deploy/README.md (back on this LXC):

## Postgres setup (CT 310)

- Postgres 16 + pgvector + pgcrypto
- Listen on all addresses, LAN-only via firewall
- DB `void`, user `void`, password in user's secrets store
- Test from app: `psql -h <db-ip> -U void -d void -c 'SELECT 1;'`
  • Step 7: Commit
git add deploy/README.md
git commit -m "docs: capture Postgres + pgvector setup"

Task 4: Node Project Initialization on void2-app

Files:

  • Create: /project/src/void-v2/package.json
  • Create: /project/src/void-v2/vitest.config.js
  • Create: /project/src/void-v2/lib/log.js

Note: Development happens here on /project/src/void-v2 (CT 300). Deploy step (rsync to void2-app) comes in Task 21.

  • Step 1: Install Node 22 if not already present
node --version
# Expect: v22.x.x — if not, install via nvm or nodesource
  • Step 2: Init package.json
cd /project/src/void-v2
npm init -y
  • Step 3: Edit package.json — scripts + type=module
{
  "name": "void-server",
  "version": "2.0.0-alpha.1",
  "type": "module",
  "private": true,
  "scripts": {
    "start": "node server.js",
    "migrate": "node lib/db/migrate.js up",
    "test": "vitest run",
    "test:watch": "vitest"
  }
}
  • Step 4: Install runtime deps
npm install express pg zod dotenv bcrypt pino pino-pretty
  • Step 5: Install dev deps
npm install --save-dev vitest @vitest/coverage-v8
  • Step 6: Write vitest.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    testTimeout: 10_000,
    coverage: { provider: 'v8', reporter: ['text', 'html'] },
    setupFiles: ['./tests/helpers/setup.js']
  }
});
  • Step 7: Write lib/log.js
import pino from 'pino';

const transport = process.env.NODE_ENV === 'production'
  ? undefined
  : { target: 'pino-pretty' };

export const log = pino({
  level: process.env.LOG_LEVEL || 'info',
  transport
});
  • Step 8: Commit
git add package.json package-lock.json vitest.config.js lib/log.js
git commit -m "chore: node project init + deps + logger"

Task 5: Postgres Pool + Migration Runner

Files:

  • Create: /project/src/void-v2/lib/db/pool.js

  • Create: /project/src/void-v2/lib/db/migrate.js

  • Create: /project/src/void-v2/tests/helpers/setup.js

  • Create: /project/src/void-v2/tests/helpers/db.js

  • Create: /project/src/void-v2/tests/db/migrate.test.js

  • Step 1: Write the failing test

tests/db/migrate.test.js:

import { describe, it, expect, beforeAll } from 'vitest';
import { resetDb, withClient } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';

describe('migrate', () => {
  beforeAll(async () => { await resetDb(); });

  it('creates schema_migrations table on first run', async () => {
    await migrateUp();
    await withClient(async (c) => {
      const { rows } = await c.query(
        `SELECT to_regclass('public.schema_migrations') AS t;`
      );
      expect(rows[0].t).toBe('schema_migrations');
    });
  });

  it('is idempotent — second run is a no-op', async () => {
    await migrateUp();
    await migrateUp();
    await withClient(async (c) => {
      const { rows } = await c.query(
        `SELECT count(*)::int AS n FROM schema_migrations;`
      );
      expect(rows[0].n).toBeGreaterThanOrEqual(0);
    });
  });
});
  • Step 2: Write test helpers

tests/helpers/setup.js:

import 'dotenv/config';

tests/helpers/db.js:

import { pool } from '../../lib/db/pool.js';

export async function resetDb() {
  await pool.query(`
    DROP SCHEMA IF EXISTS public CASCADE;
    CREATE SCHEMA public;
    CREATE EXTENSION IF NOT EXISTS pgcrypto;
    CREATE EXTENSION IF NOT EXISTS vector;
  `);
}

export async function withClient(fn) {
  const client = await pool.connect();
  try { await fn(client); }
  finally { client.release(); }
}
  • Step 3: Write lib/db/pool.js
import pg from 'pg';
import 'dotenv/config';

export const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
  idleTimeoutMillis: 30_000
});
  • Step 4: Write lib/db/migrate.js
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { pool } from './pool.js';
import { log } from '../log.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const MIG_DIR = path.join(__dirname, 'migrations');

export async function migrateUp() {
  const client = await pool.connect();
  try {
    await client.query(`
      CREATE TABLE IF NOT EXISTS schema_migrations (
        name text PRIMARY KEY,
        applied_at timestamptz NOT NULL DEFAULT now()
      );
    `);

    const files = (await fs.readdir(MIG_DIR)).filter(f => f.endsWith('.sql')).sort();
    const { rows } = await client.query('SELECT name FROM schema_migrations');
    const applied = new Set(rows.map(r => r.name));

    for (const file of files) {
      if (applied.has(file)) continue;
      const sql = await fs.readFile(path.join(MIG_DIR, file), 'utf8');
      log.info({ migration: file }, 'applying migration');
      await client.query('BEGIN');
      try {
        await client.query(sql);
        await client.query(
          'INSERT INTO schema_migrations(name) VALUES ($1)', [file]
        );
        await client.query('COMMIT');
      } catch (err) {
        await client.query('ROLLBACK');
        throw err;
      }
    }
  } finally {
    client.release();
  }
}

if (process.argv[2] === 'up') {
  migrateUp().then(() => process.exit(0)).catch((e) => {
    log.error(e); process.exit(1);
  });
}
  • Step 5: Create empty migrations directory
mkdir -p lib/db/migrations
  • Step 6: Run tests — expect PASS
DATABASE_URL=postgres://void:PWD@192.168.1.X:5432/void npm test -- tests/db/migrate.test.js
  • Step 7: Commit
git add lib/db/ tests/
git commit -m "feat: db pool + migration runner with idempotency"

Task 6: Migration 001 — Core (spaces, projects, tasks)

Files:

  • Create: /project/src/void-v2/lib/db/migrations/001_core.sql

  • Create: /project/src/void-v2/tests/db/migration_001.test.js

  • Step 1: Write the failing test

tests/db/migration_001.test.js:

import { describe, it, expect, beforeAll } from 'vitest';
import { resetDb, withClient } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';

describe('migration 001 — core', () => {
  beforeAll(async () => { await resetDb(); await migrateUp(); });

  it('creates spaces, projects, tasks tables', async () => {
    await withClient(async (c) => {
      for (const t of ['spaces', 'projects', 'tasks']) {
        const { rows } = await c.query(
          `SELECT to_regclass('public.' || $1) AS t;`, [t]
        );
        expect(rows[0].t).toBe(t);
      }
    });
  });

  it('enforces UNIQUE(space_id, slug) on projects', async () => {
    await withClient(async (c) => {
      const { rows: [s] } = await c.query(
        `INSERT INTO spaces(slug, name) VALUES('test', 'Test') RETURNING id;`
      );
      await c.query(
        `INSERT INTO projects(space_id, slug, name) VALUES($1, 'a', 'A');`,
        [s.id]
      );
      await expect(
        c.query(
          `INSERT INTO projects(space_id, slug, name) VALUES($1, 'a', 'B');`,
          [s.id]
        )
      ).rejects.toThrow(/duplicate key/i);
    });
  });
});
  • Step 2: Write lib/db/migrations/001_core.sql
CREATE TABLE spaces (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  slug        text NOT NULL UNIQUE,
  name        text NOT NULL,
  description text,
  theme       text,
  created_at  timestamptz NOT NULL DEFAULT now(),
  updated_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE projects (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  space_id     uuid NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
  slug         text NOT NULL,
  name         text NOT NULL,
  description  text,
  status       text NOT NULL DEFAULT 'active'
               CHECK (status IN ('idea','active','paused','done','abandoned')),
  started_at   timestamptz,
  completed_at timestamptz,
  created_at   timestamptz NOT NULL DEFAULT now(),
  updated_at   timestamptz NOT NULL DEFAULT now(),
  UNIQUE (space_id, slug)
);

CREATE TABLE tasks (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  space_id     uuid NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
  project_id   uuid REFERENCES projects(id) ON DELETE SET NULL,
  title        text NOT NULL,
  body         text,
  status       text NOT NULL DEFAULT 'todo'
               CHECK (status IN ('todo','doing','blocked','done')),
  priority     int,
  due_at       timestamptz,
  position     int,
  completed_at timestamptz,
  created_at   timestamptz NOT NULL DEFAULT now(),
  updated_at   timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX idx_projects_space ON projects(space_id);
CREATE INDEX idx_tasks_space ON tasks(space_id);
CREATE INDEX idx_tasks_project ON tasks(project_id);
CREATE INDEX idx_tasks_status ON tasks(status) WHERE status <> 'done';
  • Step 3: Run tests — expect PASS
npm test -- tests/db/migration_001.test.js
  • Step 4: Commit
git add lib/db/migrations/001_core.sql tests/db/migration_001.test.js
git commit -m "feat(schema): 001 — spaces, projects, tasks with check constraints"

Task 7: Repos — spaces, projects, tasks (TDD)

Files:

  • Create: /project/src/void-v2/lib/db/repos/spaces.js
  • Create: /project/src/void-v2/lib/db/repos/projects.js
  • Create: /project/src/void-v2/lib/db/repos/tasks.js
  • Create: /project/src/void-v2/tests/repos/spaces.test.js
  • Create: /project/src/void-v2/tests/repos/projects.test.js
  • Create: /project/src/void-v2/tests/repos/tasks.test.js

Each repo exports create, getById, getBySlug (where applicable), list, update, del. Each mutating call returns the row and is responsible for emitting an audit-log entry — but audit_log doesn't exist yet (Task 16). We add a recordAudit(actor, action, before, after) placeholder that's a no-op until then. Repos accept an actor parameter (object: {kind, id}) on mutating ops.

  • Step 1: Write spaces repo tests

tests/repos/spaces.test.js:

import { describe, it, expect, beforeEach } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as spaces from '../../lib/db/repos/spaces.js';

const owner = { kind: 'user', id: null };

beforeEach(async () => { await resetDb(); await migrateUp(); });

describe('spaces repo', () => {
  it('creates and returns a space', async () => {
    const s = await spaces.create({ slug: 'homelab', name: 'Homelab' }, owner);
    expect(s.id).toBeDefined();
    expect(s.slug).toBe('homelab');
  });

  it('getBySlug returns the row', async () => {
    await spaces.create({ slug: 'a', name: 'A' }, owner);
    const got = await spaces.getBySlug('a');
    expect(got.name).toBe('A');
  });

  it('list returns all', async () => {
    await spaces.create({ slug: 'a', name: 'A' }, owner);
    await spaces.create({ slug: 'b', name: 'B' }, owner);
    const all = await spaces.list();
    expect(all).toHaveLength(2);
  });

  it('update changes fields and bumps updated_at', async () => {
    const s = await spaces.create({ slug: 'a', name: 'A' }, owner);
    const u = await spaces.update(s.id, { name: 'Renamed' }, owner);
    expect(u.name).toBe('Renamed');
    expect(new Date(u.updated_at).getTime())
      .toBeGreaterThanOrEqual(new Date(s.updated_at).getTime());
  });

  it('del removes the row', async () => {
    const s = await spaces.create({ slug: 'a', name: 'A' }, owner);
    await spaces.del(s.id, owner);
    expect(await spaces.getBySlug('a')).toBeUndefined();
  });
});
  • Step 2: Implement lib/db/repos/spaces.js
import { pool } from '../pool.js';
import { recordAudit } from './audit_stub.js';

export async function create({ slug, name, description, theme }, actor) {
  const { rows: [r] } = await pool.query(
    `INSERT INTO spaces(slug, name, description, theme)
     VALUES($1,$2,$3,$4) RETURNING *`,
    [slug, name, description || null, theme || null]
  );
  await recordAudit(actor, 'create', 'space', r.id, null, r);
  return r;
}

export async function getById(id) {
  const { rows: [r] } = await pool.query(
    `SELECT * FROM spaces WHERE id=$1`, [id]);
  return r;
}

export async function getBySlug(slug) {
  const { rows: [r] } = await pool.query(
    `SELECT * FROM spaces WHERE slug=$1`, [slug]);
  return r;
}

export async function list() {
  const { rows } = await pool.query(`SELECT * FROM spaces ORDER BY name`);
  return rows;
}

export async function update(id, patch, actor) {
  const before = await getById(id);
  const fields = ['name','description','theme','slug'];
  const sets = [], vals = [];
  let i = 1;
  for (const f of fields) {
    if (patch[f] !== undefined) { sets.push(`${f}=$${i++}`); vals.push(patch[f]); }
  }
  sets.push(`updated_at=now()`);
  vals.push(id);
  const { rows: [r] } = await pool.query(
    `UPDATE spaces SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`,
    vals
  );
  await recordAudit(actor, 'update', 'space', id, before, r);
  return r;
}

export async function del(id, actor) {
  const before = await getById(id);
  await pool.query(`DELETE FROM spaces WHERE id=$1`, [id]);
  await recordAudit(actor, 'delete', 'space', id, before, null);
}
  • Step 3: Create audit stub

lib/db/repos/audit_stub.js:

// Replaced in Task 16 with real audit_log writes.
export async function recordAudit() { /* noop until 006 migration lands */ }
  • Step 4: Run spaces tests — expect PASS
npm test -- tests/repos/spaces.test.js
  • Step 5: Write projects repo + tests (mirror spaces pattern)

tests/repos/projects.test.js — tests: create requires space_id, getBySpace lists by space, status enum enforced, UNIQUE(space, slug) enforced. Then implement lib/db/repos/projects.js mirroring spaces.js with the additional space_id foreign key and a listBySpace(spaceId, {status}) method.

// tests/repos/projects.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as spaces from '../../lib/db/repos/spaces.js';
import * as projects from '../../lib/db/repos/projects.js';

const owner = { kind: 'user', id: null };

beforeEach(async () => { await resetDb(); await migrateUp(); });

describe('projects repo', () => {
  it('creates a project under a space', async () => {
    const s = await spaces.create({ slug: 'h', name: 'H' }, owner);
    const p = await projects.create(
      { space_id: s.id, slug: 'z2', name: 'Z2 Migration' }, owner
    );
    expect(p.space_id).toBe(s.id);
    expect(p.status).toBe('active');
  });

  it('enforces unique slug per space', async () => {
    const s = await spaces.create({ slug: 'h', name: 'H' }, owner);
    await projects.create({ space_id: s.id, slug: 'a', name: 'A' }, owner);
    await expect(
      projects.create({ space_id: s.id, slug: 'a', name: 'B' }, owner)
    ).rejects.toThrow();
  });

  it('listBySpace filters by status', async () => {
    const s = await spaces.create({ slug: 'h', name: 'H' }, owner);
    await projects.create({ space_id: s.id, slug: 'a', name: 'A' }, owner);
    await projects.create(
      { space_id: s.id, slug: 'b', name: 'B', status: 'done' }, owner
    );
    const active = await projects.listBySpace(s.id, { status: 'active' });
    expect(active).toHaveLength(1);
    expect(active[0].slug).toBe('a');
  });
});
// lib/db/repos/projects.js
import { pool } from '../pool.js';
import { recordAudit } from './audit_stub.js';

export async function create({ space_id, slug, name, description, status = 'active', started_at }, actor) {
  const { rows: [r] } = await pool.query(
    `INSERT INTO projects(space_id, slug, name, description, status, started_at)
     VALUES($1,$2,$3,$4,$5,$6) RETURNING *`,
    [space_id, slug, name, description || null, status, started_at || null]
  );
  await recordAudit(actor, 'create', 'project', r.id, null, r);
  return r;
}

export async function getById(id) {
  const { rows: [r] } = await pool.query(`SELECT * FROM projects WHERE id=$1`, [id]);
  return r;
}

export async function listBySpace(space_id, { status } = {}) {
  const sql = status
    ? `SELECT * FROM projects WHERE space_id=$1 AND status=$2 ORDER BY name`
    : `SELECT * FROM projects WHERE space_id=$1 ORDER BY name`;
  const args = status ? [space_id, status] : [space_id];
  const { rows } = await pool.query(sql, args);
  return rows;
}

export async function update(id, patch, actor) {
  const before = await getById(id);
  const fields = ['slug','name','description','status','started_at','completed_at'];
  const sets = [], vals = [];
  let i = 1;
  for (const f of fields) {
    if (patch[f] !== undefined) { sets.push(`${f}=$${i++}`); vals.push(patch[f]); }
  }
  sets.push(`updated_at=now()`);
  vals.push(id);
  const { rows: [r] } = await pool.query(
    `UPDATE projects SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`,
    vals
  );
  await recordAudit(actor, 'update', 'project', id, before, r);
  return r;
}

export async function del(id, actor) {
  const before = await getById(id);
  await pool.query(`DELETE FROM projects WHERE id=$1`, [id]);
  await recordAudit(actor, 'delete', 'project', id, before, null);
}
  • Step 6: Write tasks repo + tests
// tests/repos/tasks.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as spaces from '../../lib/db/repos/spaces.js';
import * as projects from '../../lib/db/repos/projects.js';
import * as tasks from '../../lib/db/repos/tasks.js';

const owner = { kind: 'user', id: null };

beforeEach(async () => { await resetDb(); await migrateUp(); });

describe('tasks repo', () => {
  it('creates a task with optional project', async () => {
    const s = await spaces.create({ slug: 'h', name: 'H' }, owner);
    const t = await tasks.create({ space_id: s.id, title: 'do it' }, owner);
    expect(t.status).toBe('todo');
    expect(t.project_id).toBeNull();
  });

  it('marking done sets completed_at', async () => {
    const s = await spaces.create({ slug: 'h', name: 'H' }, owner);
    const t = await tasks.create({ space_id: s.id, title: 'x' }, owner);
    const u = await tasks.update(t.id, { status: 'done' }, owner);
    expect(u.status).toBe('done');
    expect(u.completed_at).not.toBeNull();
  });

  it('listByProject returns project tasks only', async () => {
    const s = await spaces.create({ slug: 'h', name: 'H' }, owner);
    const p = await projects.create({ space_id: s.id, slug: 'p', name: 'P' }, owner);
    await tasks.create({ space_id: s.id, project_id: p.id, title: 'a' }, owner);
    await tasks.create({ space_id: s.id, title: 'orphan' }, owner);
    const list = await tasks.listByProject(p.id);
    expect(list).toHaveLength(1);
  });
});
// lib/db/repos/tasks.js
import { pool } from '../pool.js';
import { recordAudit } from './audit_stub.js';

export async function create({ space_id, project_id = null, title, body, priority, due_at, position }, actor) {
  const { rows: [r] } = await pool.query(
    `INSERT INTO tasks(space_id, project_id, title, body, priority, due_at, position)
     VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
    [space_id, project_id, title, body || null, priority || null, due_at || null, position || null]
  );
  await recordAudit(actor, 'create', 'task', r.id, null, r);
  return r;
}

export async function getById(id) {
  const { rows: [r] } = await pool.query(`SELECT * FROM tasks WHERE id=$1`, [id]);
  return r;
}

export async function listByProject(project_id) {
  const { rows } = await pool.query(
    `SELECT * FROM tasks WHERE project_id=$1 ORDER BY position NULLS LAST, created_at`,
    [project_id]
  );
  return rows;
}

export async function listBySpace(space_id, { status } = {}) {
  const sql = status
    ? `SELECT * FROM tasks WHERE space_id=$1 AND status=$2 ORDER BY created_at`
    : `SELECT * FROM tasks WHERE space_id=$1 ORDER BY created_at`;
  const { rows } = await pool.query(sql, status ? [space_id, status] : [space_id]);
  return rows;
}

export async function update(id, patch, actor) {
  const before = await getById(id);
  const fields = ['title','body','status','priority','due_at','position','project_id'];
  const sets = [], vals = [];
  let i = 1;
  for (const f of fields) {
    if (patch[f] !== undefined) { sets.push(`${f}=$${i++}`); vals.push(patch[f]); }
  }
  if (patch.status === 'done') { sets.push(`completed_at=now()`); }
  sets.push(`updated_at=now()`);
  vals.push(id);
  const { rows: [r] } = await pool.query(
    `UPDATE tasks SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`,
    vals
  );
  await recordAudit(actor, 'update', 'task', id, before, r);
  return r;
}

export async function del(id, actor) {
  const before = await getById(id);
  await pool.query(`DELETE FROM tasks WHERE id=$1`, [id]);
  await recordAudit(actor, 'delete', 'task', id, before, null);
}
  • Step 7: Run all repo tests — expect PASS
npm test -- tests/repos/
  • Step 8: Commit
git add lib/db/repos/ tests/repos/
git commit -m "feat(repos): spaces, projects, tasks with audit stub"

Task 8: Migration 002 — Knowledge (pages, page_revisions, refs)

Files:

  • Create: /project/src/void-v2/lib/db/migrations/002_knowledge.sql

  • Create: /project/src/void-v2/tests/db/migration_002.test.js

  • Step 1: Write test

// tests/db/migration_002.test.js
import { describe, it, expect, beforeAll } from 'vitest';
import { resetDb, withClient } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';

describe('migration 002 — knowledge', () => {
  beforeAll(async () => { await resetDb(); await migrateUp(); });

  it('creates pages, page_revisions, refs', async () => {
    await withClient(async (c) => {
      for (const t of ['pages','page_revisions','refs']) {
        const { rows } = await c.query(
          `SELECT to_regclass('public.' || $1) AS t;`, [t]
        );
        expect(rows[0].t).toBe(t);
      }
    });
  });

  it('refs.kind check enforces enum', async () => {
    await withClient(async (c) => {
      const { rows: [s] } = await c.query(
        `INSERT INTO spaces(slug,name) VALUES('h','H') RETURNING id;`
      );
      await expect(c.query(
        `INSERT INTO refs(space_id, kind) VALUES($1, 'invalid');`, [s.id]
      )).rejects.toThrow(/check/i);
    });
  });
});
  • Step 2: Write 002_knowledge.sql
CREATE TABLE pages (
  id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  space_id   uuid REFERENCES spaces(id) ON DELETE SET NULL,
  slug       text NOT NULL,
  title      text NOT NULL,
  body_md    text NOT NULL DEFAULT '',
  body_html  text,
  parent_id  uuid REFERENCES pages(id) ON DELETE SET NULL,
  embedding  vector(1024),
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (space_id, slug)
);

CREATE TABLE page_revisions (
  id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  page_id    uuid NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
  body_md    text NOT NULL,
  edited_by  text,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE refs (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  space_id    uuid REFERENCES spaces(id) ON DELETE SET NULL,
  kind        text NOT NULL
              CHECK (kind IN ('url','video','pdf','image','file')),
  source_url  text,
  title       text,
  description text,
  summary     text,
  body_text   text,
  blob_path   text,
  thumbnail   text,
  metadata    jsonb NOT NULL DEFAULT '{}'::jsonb,
  embedding   vector(1024),
  status      text NOT NULL DEFAULT 'ingested'
              CHECK (status IN ('ingested','indexed','enriched')),
  source_kind text,
  external_id text,
  captured_at timestamptz NOT NULL DEFAULT now(),
  created_at  timestamptz NOT NULL DEFAULT now(),
  updated_at  timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX idx_pages_space ON pages(space_id);
CREATE INDEX idx_pages_parent ON pages(parent_id);
CREATE INDEX idx_pages_fts ON pages
  USING GIN (to_tsvector('english', title || ' ' || body_md));
CREATE INDEX idx_pages_embed ON pages
  USING hnsw (embedding vector_cosine_ops);

CREATE INDEX idx_refs_space ON refs(space_id);
CREATE INDEX idx_refs_kind ON refs(kind);
CREATE INDEX idx_refs_external ON refs(source_kind, external_id)
  WHERE source_kind IS NOT NULL;
CREATE INDEX idx_refs_fts ON refs USING GIN (
  to_tsvector('english',
    coalesce(title,'') || ' ' || coalesce(summary,'') || ' ' || coalesce(body_text,''))
);
CREATE INDEX idx_refs_embed ON refs USING hnsw (embedding vector_cosine_ops);
  • Step 3: Run tests
npm test -- tests/db/migration_002.test.js
  • Step 4: Commit
git add lib/db/migrations/002_knowledge.sql tests/db/migration_002.test.js
git commit -m "feat(schema): 002 — pages, page_revisions, refs with FTS + vector indexes"

Task 9: Repos — pages, page_revisions, refs

Files:

  • Create: /project/src/void-v2/lib/db/repos/pages.js
  • Create: /project/src/void-v2/lib/db/repos/refs.js
  • Create: /project/src/void-v2/tests/repos/pages.test.js
  • Create: /project/src/void-v2/tests/repos/refs.test.js

Follow the same pattern as Task 7 (create / getById / getBySlug / list / update / del with actor parameter, audit stub).

  • Step 1: Write pages tests
// tests/repos/pages.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as spaces from '../../lib/db/repos/spaces.js';
import * as pages from '../../lib/db/repos/pages.js';

const owner = { kind: 'user', id: null };

beforeEach(async () => { await resetDb(); await migrateUp(); });

describe('pages repo', () => {
  it('creates a page and auto-snapshots a revision', async () => {
    const s = await spaces.create({ slug: 'h', name: 'H' }, owner);
    const p = await pages.create(
      { space_id: s.id, slug: 'a', title: 'A', body_md: 'hello' }, owner
    );
    expect(p.body_md).toBe('hello');
    const revs = await pages.listRevisions(p.id);
    expect(revs).toHaveLength(1);
    expect(revs[0].body_md).toBe('hello');
  });

  it('updating body adds a revision', async () => {
    const s = await spaces.create({ slug: 'h', name: 'H' }, owner);
    const p = await pages.create(
      { space_id: s.id, slug: 'a', title: 'A', body_md: 'v1' }, owner
    );
    await pages.update(p.id, { body_md: 'v2' }, owner);
    const revs = await pages.listRevisions(p.id);
    expect(revs).toHaveLength(2);
    expect(revs[0].body_md).toBe('v2');  // newest first
  });
});
  • Step 2: Implement lib/db/repos/pages.js
import { pool } from '../pool.js';
import { recordAudit } from './audit_stub.js';

async function snapshot(client, page_id, body_md, edited_by) {
  await client.query(
    `INSERT INTO page_revisions(page_id, body_md, edited_by)
     VALUES($1,$2,$3)`,
    [page_id, body_md, edited_by || null]
  );
}

export async function create({ space_id, slug, title, body_md = '', parent_id }, actor) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const { rows: [r] } = await client.query(
      `INSERT INTO pages(space_id, slug, title, body_md, parent_id)
       VALUES($1,$2,$3,$4,$5) RETURNING *`,
      [space_id, slug, title, body_md, parent_id || null]
    );
    await snapshot(client, r.id, body_md, actor?.kind);
    await client.query('COMMIT');
    await recordAudit(actor, 'create', 'page', r.id, null, r);
    return r;
  } catch (e) {
    await client.query('ROLLBACK'); throw e;
  } finally {
    client.release();
  }
}

export async function getById(id) {
  const { rows: [r] } = await pool.query(`SELECT * FROM pages WHERE id=$1`, [id]);
  return r;
}

export async function getBySlug(space_id, slug) {
  const { rows: [r] } = await pool.query(
    `SELECT * FROM pages WHERE space_id=$1 AND slug=$2`, [space_id, slug]
  );
  return r;
}

export async function listBySpace(space_id) {
  const { rows } = await pool.query(
    `SELECT id, space_id, slug, title, parent_id, updated_at
     FROM pages WHERE space_id=$1 ORDER BY title`, [space_id]
  );
  return rows;
}

export async function listRevisions(page_id) {
  const { rows } = await pool.query(
    `SELECT * FROM page_revisions WHERE page_id=$1 ORDER BY created_at DESC`,
    [page_id]
  );
  return rows;
}

export async function update(id, patch, actor) {
  const before = await getById(id);
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const fields = ['slug','title','body_md','body_html','parent_id','embedding'];
    const sets = [], vals = [];
    let i = 1;
    for (const f of fields) {
      if (patch[f] !== undefined) { sets.push(`${f}=$${i++}`); vals.push(patch[f]); }
    }
    sets.push(`updated_at=now()`);
    vals.push(id);
    const { rows: [r] } = await client.query(
      `UPDATE pages SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`,
      vals
    );
    if (patch.body_md !== undefined && patch.body_md !== before.body_md) {
      await snapshot(client, id, patch.body_md, actor?.kind);
    }
    await client.query('COMMIT');
    await recordAudit(actor, 'update', 'page', id, before, r);
    return r;
  } catch (e) {
    await client.query('ROLLBACK'); throw e;
  } finally {
    client.release();
  }
}

export async function del(id, actor) {
  const before = await getById(id);
  await pool.query(`DELETE FROM pages WHERE id=$1`, [id]);
  await recordAudit(actor, 'delete', 'page', id, before, null);
}
  • Step 3: Write refs tests
// tests/repos/refs.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as spaces from '../../lib/db/repos/spaces.js';
import * as refs from '../../lib/db/repos/refs.js';

const owner = { kind: 'user', id: null };
beforeEach(async () => { await resetDb(); await migrateUp(); });

describe('refs repo', () => {
  it('creates a url ref', async () => {
    const s = await spaces.create({ slug: 'h', name: 'H' }, owner);
    const r = await refs.create({
      space_id: s.id, kind: 'url',
      source_url: 'https://example.com',
      title: 'Ex', source_kind: 'manual'
    }, owner);
    expect(r.kind).toBe('url');
    expect(r.status).toBe('ingested');
  });

  it('idempotent upsert by (source_kind, external_id)', async () => {
    const s = await spaces.create({ slug: 'h', name: 'H' }, owner);
    const r1 = await refs.upsertByExternal({
      space_id: s.id, kind: 'url', source_url: 'https://e.com',
      source_kind: 'karakeep', external_id: 'kk-123', title: 'v1'
    }, owner);
    const r2 = await refs.upsertByExternal({
      space_id: s.id, kind: 'url', source_url: 'https://e.com',
      source_kind: 'karakeep', external_id: 'kk-123', title: 'v2'
    }, owner);
    expect(r2.id).toBe(r1.id);
    expect(r2.title).toBe('v2');
  });
});
  • Step 4: Implement lib/db/repos/refs.js
import { pool } from '../pool.js';
import { recordAudit } from './audit_stub.js';

const FIELDS = [
  'space_id','kind','source_url','title','description','summary',
  'body_text','blob_path','thumbnail','metadata','embedding','status',
  'source_kind','external_id','captured_at'
];

export async function create(input, actor) {
  const cols = [], vals = [], placeholders = [];
  let i = 1;
  for (const f of FIELDS) {
    if (input[f] !== undefined) {
      cols.push(f); vals.push(input[f]); placeholders.push(`$${i++}`);
    }
  }
  const { rows: [r] } = await pool.query(
    `INSERT INTO refs(${cols.join(',')})
     VALUES(${placeholders.join(',')}) RETURNING *`,
    vals
  );
  await recordAudit(actor, 'create', 'ref', r.id, null, r);
  return r;
}

export async function getById(id) {
  const { rows: [r] } = await pool.query(`SELECT * FROM refs WHERE id=$1`, [id]);
  return r;
}

export async function upsertByExternal(input, actor) {
  const { source_kind, external_id } = input;
  if (!source_kind || !external_id) {
    throw new Error('upsertByExternal requires source_kind + external_id');
  }
  const { rows: [existing] } = await pool.query(
    `SELECT * FROM refs WHERE source_kind=$1 AND external_id=$2`,
    [source_kind, external_id]
  );
  if (existing) {
    return update(existing.id, input, actor);
  }
  return create(input, actor);
}

export async function list({ space_id, kind, limit = 100, offset = 0 } = {}) {
  const where = [], vals = [];
  let i = 1;
  if (space_id) { where.push(`space_id=$${i++}`); vals.push(space_id); }
  if (kind) { where.push(`kind=$${i++}`); vals.push(kind); }
  const w = where.length ? `WHERE ${where.join(' AND ')}` : '';
  vals.push(limit, offset);
  const { rows } = await pool.query(
    `SELECT * FROM refs ${w} ORDER BY captured_at DESC LIMIT $${i++} OFFSET $${i}`,
    vals
  );
  return rows;
}

export async function update(id, patch, actor) {
  const before = await getById(id);
  const sets = [], vals = [];
  let i = 1;
  for (const f of FIELDS) {
    if (patch[f] !== undefined) { sets.push(`${f}=$${i++}`); vals.push(patch[f]); }
  }
  sets.push(`updated_at=now()`);
  vals.push(id);
  const { rows: [r] } = await pool.query(
    `UPDATE refs SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`,
    vals
  );
  await recordAudit(actor, 'update', 'ref', id, before, r);
  return r;
}

export async function del(id, actor) {
  const before = await getById(id);
  await pool.query(`DELETE FROM refs WHERE id=$1`, [id]);
  await recordAudit(actor, 'delete', 'ref', id, before, null);
}
  • Step 5: Run tests — expect PASS
npm test -- tests/repos/pages.test.js tests/repos/refs.test.js
  • Step 6: Commit
git add lib/db/repos/pages.js lib/db/repos/refs.js tests/repos/pages.test.js tests/repos/refs.test.js
git commit -m "feat(repos): pages with auto-revisions, refs with upsertByExternal"

Task 10: Migration 003 — Resources + Source Docs

Files:

  • Create: /project/src/void-v2/lib/db/migrations/003_resources.sql

  • Create: /project/src/void-v2/tests/db/migration_003.test.js

  • Step 1: Write the test

// tests/db/migration_003.test.js
import { describe, it, expect, beforeAll } from 'vitest';
import { resetDb, withClient } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';

describe('migration 003 — resources', () => {
  beforeAll(async () => { await resetDb(); await migrateUp(); });

  it('creates resources, resource_dependencies, resource_credentials, source_docs', async () => {
    await withClient(async (c) => {
      for (const t of ['resources','resource_dependencies','resource_credentials','source_docs']) {
        const { rows } = await c.query(
          `SELECT to_regclass('public.' || $1) AS t;`, [t]
        );
        expect(rows[0].t).toBe(t);
      }
    });
  });
});
  • Step 2: Write 003_resources.sql
CREATE TABLE resources (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  space_id     uuid NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
  slug         text NOT NULL,
  name         text NOT NULL,
  runtime_type text NOT NULL
               CHECK (runtime_type IN ('lxc','vm','docker','bare-metal')),
  host         text,
  url          text,
  version      text,
  status       text NOT NULL DEFAULT 'unknown'
               CHECK (status IN ('running','stopped','down','unknown')),
  monitoring   jsonb NOT NULL DEFAULT '{}'::jsonb,
  metadata     jsonb NOT NULL DEFAULT '{}'::jsonb,
  last_check   timestamptz,
  maintenance_until timestamptz,
  created_at   timestamptz NOT NULL DEFAULT now(),
  updated_at   timestamptz NOT NULL DEFAULT now(),
  UNIQUE (space_id, slug)
);

CREATE TABLE resource_dependencies (
  resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
  depends_on  uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
  kind        text,
  PRIMARY KEY (resource_id, depends_on),
  CHECK (resource_id <> depends_on)
);

CREATE TABLE resource_credentials (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  resource_id uuid NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
  label       text NOT NULL,
  vault_path  text NOT NULL,
  kind        text,
  notes       text,
  created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE source_docs (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  resource_id  uuid REFERENCES resources(id) ON DELETE CASCADE,
  name         text NOT NULL,
  upstream_url text NOT NULL,
  version      text,
  format       text,
  sync_source  text,
  local_path   text,
  body_text    text,
  embedding    vector(1024),
  last_synced  timestamptz,
  metadata     jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at   timestamptz NOT NULL DEFAULT now(),
  updated_at   timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX idx_resources_space ON resources(space_id);
CREATE INDEX idx_source_docs_resource ON source_docs(resource_id);
CREATE INDEX idx_source_docs_fts ON source_docs
  USING GIN (to_tsvector('english', coalesce(body_text,'')));
CREATE INDEX idx_source_docs_embed ON source_docs
  USING hnsw (embedding vector_cosine_ops);
  • Step 3: Run + commit
npm test -- tests/db/migration_003.test.js
git add lib/db/migrations/003_resources.sql tests/db/migration_003.test.js
git commit -m "feat(schema): 003 — resources, deps, credentials, source_docs"

Task 11: Repos — resources, source_docs

Files:

  • Create: /project/src/void-v2/lib/db/repos/resources.js
  • Create: /project/src/void-v2/lib/db/repos/source_docs.js
  • Create: /project/src/void-v2/tests/repos/resources.test.js
  • Create: /project/src/void-v2/tests/repos/source_docs.test.js

Follow the established pattern. Resources additionally exposes addDependency(resource_id, depends_on, kind), removeDependency, listDependencies(resource_id), addCredential, listCredentials.

  • Step 1: Write resources tests
// tests/repos/resources.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as spaces from '../../lib/db/repos/spaces.js';
import * as resources from '../../lib/db/repos/resources.js';

const owner = { kind: 'user', id: null };
beforeEach(async () => { await resetDb(); await migrateUp(); });

describe('resources repo', () => {
  it('creates a resource', async () => {
    const s = await spaces.create({ slug: 'h', name: 'H' }, owner);
    const r = await resources.create({
      space_id: s.id, slug: 'kk', name: 'Karakeep',
      runtime_type: 'docker', host: '192.168.1.230', url: 'https://karakeep.hynesy.com'
    }, owner);
    expect(r.status).toBe('unknown');
  });

  it('addDependency creates a link, prevents self-dep', async () => {
    const s = await spaces.create({ slug: 'h', name: 'H' }, owner);
    const a = await resources.create({ space_id: s.id, slug: 'a', name: 'A', runtime_type: 'lxc' }, owner);
    const b = await resources.create({ space_id: s.id, slug: 'b', name: 'B', runtime_type: 'lxc' }, owner);
    await resources.addDependency(a.id, b.id, 'data');
    const deps = await resources.listDependencies(a.id);
    expect(deps).toHaveLength(1);
    expect(deps[0].depends_on).toBe(b.id);
    await expect(resources.addDependency(a.id, a.id, 'self')).rejects.toThrow();
  });
});
  • Step 2: Implement lib/db/repos/resources.js
import { pool } from '../pool.js';
import { recordAudit } from './audit_stub.js';

const FIELDS = ['space_id','slug','name','runtime_type','host','url','version','status','monitoring','metadata','last_check','maintenance_until'];

export async function create(input, actor) {
  const cols = [], vals = [], ph = [];
  let i = 1;
  for (const f of FIELDS) {
    if (input[f] !== undefined) { cols.push(f); vals.push(input[f]); ph.push(`$${i++}`); }
  }
  const { rows: [r] } = await pool.query(
    `INSERT INTO resources(${cols.join(',')}) VALUES(${ph.join(',')}) RETURNING *`,
    vals
  );
  await recordAudit(actor, 'create', 'resource', r.id, null, r);
  return r;
}

export async function getById(id) {
  const { rows: [r] } = await pool.query(`SELECT * FROM resources WHERE id=$1`, [id]);
  return r;
}

export async function listBySpace(space_id) {
  const { rows } = await pool.query(
    `SELECT * FROM resources WHERE space_id=$1 ORDER BY name`, [space_id]
  );
  return rows;
}

export async function update(id, patch, actor) {
  const before = await getById(id);
  const sets = [], vals = [];
  let i = 1;
  for (const f of FIELDS) {
    if (patch[f] !== undefined) { sets.push(`${f}=$${i++}`); vals.push(patch[f]); }
  }
  sets.push(`updated_at=now()`);
  vals.push(id);
  const { rows: [r] } = await pool.query(
    `UPDATE resources SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`,
    vals
  );
  await recordAudit(actor, 'update', 'resource', id, before, r);
  return r;
}

export async function del(id, actor) {
  const before = await getById(id);
  await pool.query(`DELETE FROM resources WHERE id=$1`, [id]);
  await recordAudit(actor, 'delete', 'resource', id, before, null);
}

export async function addDependency(resource_id, depends_on, kind) {
  if (resource_id === depends_on) throw new Error('resource cannot depend on itself');
  await pool.query(
    `INSERT INTO resource_dependencies(resource_id, depends_on, kind)
     VALUES($1,$2,$3) ON CONFLICT DO NOTHING`,
    [resource_id, depends_on, kind || null]
  );
}

export async function removeDependency(resource_id, depends_on) {
  await pool.query(
    `DELETE FROM resource_dependencies WHERE resource_id=$1 AND depends_on=$2`,
    [resource_id, depends_on]
  );
}

export async function listDependencies(resource_id) {
  const { rows } = await pool.query(
    `SELECT * FROM resource_dependencies WHERE resource_id=$1`, [resource_id]
  );
  return rows;
}

export async function addCredential(resource_id, { label, vault_path, kind, notes }) {
  const { rows: [r] } = await pool.query(
    `INSERT INTO resource_credentials(resource_id, label, vault_path, kind, notes)
     VALUES($1,$2,$3,$4,$5) RETURNING *`,
    [resource_id, label, vault_path, kind || null, notes || null]
  );
  return r;
}

export async function listCredentials(resource_id) {
  const { rows } = await pool.query(
    `SELECT id, resource_id, label, vault_path, kind, notes, created_at
     FROM resource_credentials WHERE resource_id=$1 ORDER BY label`,
    [resource_id]
  );
  return rows;
}
  • Step 3: Write source_docs repo + tests (mirror pattern)
// tests/repos/source_docs.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as spaces from '../../lib/db/repos/spaces.js';
import * as resources from '../../lib/db/repos/resources.js';
import * as sourceDocs from '../../lib/db/repos/source_docs.js';

const owner = { kind: 'user', id: null };
beforeEach(async () => { await resetDb(); await migrateUp(); });

describe('source_docs repo', () => {
  it('creates a source doc bound to a resource', async () => {
    const s = await spaces.create({ slug: 'h', name: 'H' }, owner);
    const r = await resources.create({ space_id: s.id, slug: 'kk', name: 'KK', runtime_type: 'docker' }, owner);
    const sd = await sourceDocs.create({
      resource_id: r.id, name: 'Karakeep docs',
      upstream_url: 'https://docs.karakeep.app', version: '0.20', format: 'markdown'
    }, owner);
    expect(sd.resource_id).toBe(r.id);
  });
});
// lib/db/repos/source_docs.js
import { pool } from '../pool.js';
import { recordAudit } from './audit_stub.js';

const FIELDS = ['resource_id','name','upstream_url','version','format','sync_source','local_path','body_text','embedding','last_synced','metadata'];

export async function create(input, actor) {
  const cols = [], vals = [], ph = [];
  let i = 1;
  for (const f of FIELDS) {
    if (input[f] !== undefined) { cols.push(f); vals.push(input[f]); ph.push(`$${i++}`); }
  }
  const { rows: [r] } = await pool.query(
    `INSERT INTO source_docs(${cols.join(',')}) VALUES(${ph.join(',')}) RETURNING *`,
    vals
  );
  await recordAudit(actor, 'create', 'source_doc', r.id, null, r);
  return r;
}

export async function getById(id) {
  const { rows: [r] } = await pool.query(`SELECT * FROM source_docs WHERE id=$1`, [id]);
  return r;
}

export async function listByResource(resource_id) {
  const { rows } = await pool.query(
    `SELECT * FROM source_docs WHERE resource_id=$1 ORDER BY name`, [resource_id]
  );
  return rows;
}

export async function update(id, patch, actor) {
  const before = await getById(id);
  const sets = [], vals = [];
  let i = 1;
  for (const f of FIELDS) {
    if (patch[f] !== undefined) { sets.push(`${f}=$${i++}`); vals.push(patch[f]); }
  }
  sets.push(`updated_at=now()`);
  vals.push(id);
  const { rows: [r] } = await pool.query(
    `UPDATE source_docs SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`,
    vals
  );
  await recordAudit(actor, 'update', 'source_doc', id, before, r);
  return r;
}

export async function del(id, actor) {
  const before = await getById(id);
  await pool.query(`DELETE FROM source_docs WHERE id=$1`, [id]);
  await recordAudit(actor, 'delete', 'source_doc', id, before, null);
}
  • Step 4: Run tests + commit
npm test -- tests/repos/resources.test.js tests/repos/source_docs.test.js
git add lib/db/repos/resources.js lib/db/repos/source_docs.js tests/repos/resources.test.js tests/repos/source_docs.test.js
git commit -m "feat(repos): resources (+ deps + creds) and source_docs"

Task 12: Migration 004 — Agents + Conversations + Messages

Files:

  • Create: /project/src/void-v2/lib/db/migrations/004_agents.sql

  • Create: /project/src/void-v2/tests/db/migration_004.test.js

  • Step 1: Write test (existence check on all three tables)

// tests/db/migration_004.test.js
import { describe, it, expect, beforeAll } from 'vitest';
import { resetDb, withClient } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';

describe('migration 004 — agents', () => {
  beforeAll(async () => { await resetDb(); await migrateUp(); });

  it('creates agents, agent_tokens, conversations, messages', async () => {
    await withClient(async (c) => {
      for (const t of ['agents','agent_tokens','conversations','messages']) {
        const { rows } = await c.query(`SELECT to_regclass('public.' || $1) AS t;`, [t]);
        expect(rows[0].t).toBe(t);
      }
    });
  });
});
  • Step 2: Write 004_agents.sql
CREATE TABLE agents (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  slug         text NOT NULL UNIQUE,
  name         text NOT NULL,
  kind         text NOT NULL
               CHECK (kind IN ('claude','ollama','mastra','mcp-client','external')),
  model        text,
  persona_path text,
  capabilities jsonb NOT NULL DEFAULT '{"read":true,"suggest":true,"write":false}'::jsonb,
  scopes       jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at   timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE agent_tokens (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  agent_id    uuid NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
  label       text,
  token_hash  text NOT NULL,
  last_used   timestamptz,
  created_at  timestamptz NOT NULL DEFAULT now(),
  revoked_at  timestamptz
);

CREATE TABLE conversations (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  title        text,
  agent_id     uuid REFERENCES agents(id) ON DELETE SET NULL,
  participants text[] NOT NULL DEFAULT '{}',
  status       text NOT NULL DEFAULT 'open'
               CHECK (status IN ('open','summarized','archived')),
  summary      text,
  metadata     jsonb NOT NULL DEFAULT '{}'::jsonb,
  embedding    vector(1024),
  started_at   timestamptz NOT NULL DEFAULT now(),
  ended_at     timestamptz
);

CREATE TABLE messages (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  conversation_id uuid NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  role            text NOT NULL CHECK (role IN ('user','assistant','system','tool')),
  agent_id        uuid REFERENCES agents(id) ON DELETE SET NULL,
  body            text NOT NULL,
  metadata        jsonb NOT NULL DEFAULT '{}'::jsonb,
  created_at      timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX idx_messages_conv ON messages(conversation_id, created_at);
CREATE INDEX idx_messages_fts ON messages USING GIN (to_tsvector('english', body));
CREATE INDEX idx_agent_tokens_hash ON agent_tokens(token_hash) WHERE revoked_at IS NULL;
  • Step 3: Run + commit
npm test -- tests/db/migration_004.test.js
git add lib/db/migrations/004_agents.sql tests/db/migration_004.test.js
git commit -m "feat(schema): 004 — agents, tokens, conversations, messages"

Task 13: Repos — agents, conversations, messages

Files:

  • Create: /project/src/void-v2/lib/db/repos/agents.js
  • Create: /project/src/void-v2/lib/db/repos/conversations.js
  • Create: /project/src/void-v2/lib/db/repos/messages.js
  • Create tests for each.

Agents repo additionally exposes:

  • createToken(agent_id, label) — generates random token, returns plaintext once, stores bcrypt hash

  • verifyToken(plaintext) — finds matching unrevoked token by hash, returns agent or null

  • revokeToken(token_id)

  • setCapabilities(agent_id, capabilities, scopes)

  • Step 1: Write agents tests

// tests/repos/agents.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as agents from '../../lib/db/repos/agents.js';

const owner = { kind: 'user', id: null };
beforeEach(async () => { await resetDb(); await migrateUp(); });

describe('agents repo', () => {
  it('creates agent with default capabilities', async () => {
    const a = await agents.create({ slug: 'mercy', name: 'Mercy', kind: 'claude' }, owner);
    expect(a.capabilities.read).toBe(true);
    expect(a.capabilities.write).toBe(false);
  });

  it('createToken returns plaintext, verifyToken finds the agent', async () => {
    const a = await agents.create({ slug: 'mercy', name: 'Mercy', kind: 'claude' }, owner);
    const { token, id } = await agents.createToken(a.id, 'default');
    expect(token).toMatch(/^vk_/);  // prefix 'vk_' = void key
    const found = await agents.verifyToken(token);
    expect(found?.id).toBe(a.id);
  });

  it('revoked token does not verify', async () => {
    const a = await agents.create({ slug: 'mercy', name: 'Mercy', kind: 'claude' }, owner);
    const { token, id } = await agents.createToken(a.id, 'default');
    await agents.revokeToken(id);
    expect(await agents.verifyToken(token)).toBeNull();
  });

  it('setCapabilities updates the jsonb', async () => {
    const a = await agents.create({ slug: 'mercy', name: 'Mercy', kind: 'claude' }, owner);
    const u = await agents.setCapabilities(a.id, { read: true, suggest: true, write: true }, { pages: true });
    expect(u.capabilities.write).toBe(true);
    expect(u.scopes.pages).toBe(true);
  });
});
  • Step 2: Implement lib/db/repos/agents.js
import crypto from 'node:crypto';
import bcrypt from 'bcrypt';
import { pool } from '../pool.js';
import { recordAudit } from './audit_stub.js';

const FIELDS = ['slug','name','kind','model','persona_path','capabilities','scopes'];

export async function create(input, actor) {
  const cols = [], vals = [], ph = [];
  let i = 1;
  for (const f of FIELDS) {
    if (input[f] !== undefined) { cols.push(f); vals.push(input[f]); ph.push(`$${i++}`); }
  }
  const { rows: [r] } = await pool.query(
    `INSERT INTO agents(${cols.join(',')}) VALUES(${ph.join(',')}) RETURNING *`,
    vals
  );
  await recordAudit(actor, 'create', 'agent', r.id, null, r);
  return r;
}

export async function getById(id) {
  const { rows: [r] } = await pool.query(`SELECT * FROM agents WHERE id=$1`, [id]);
  return r;
}

export async function getBySlug(slug) {
  const { rows: [r] } = await pool.query(`SELECT * FROM agents WHERE slug=$1`, [slug]);
  return r;
}

export async function list() {
  const { rows } = await pool.query(`SELECT * FROM agents ORDER BY name`);
  return rows;
}

export async function setCapabilities(id, capabilities, scopes) {
  const { rows: [r] } = await pool.query(
    `UPDATE agents SET capabilities=$1, scopes=$2 WHERE id=$3 RETURNING *`,
    [capabilities, scopes || {}, id]
  );
  return r;
}

export async function createToken(agent_id, label) {
  const plaintext = 'vk_' + crypto.randomBytes(32).toString('base64url');
  const token_hash = await bcrypt.hash(plaintext, 12);
  const { rows: [t] } = await pool.query(
    `INSERT INTO agent_tokens(agent_id, label, token_hash)
     VALUES($1,$2,$3) RETURNING id`,
    [agent_id, label || null, token_hash]
  );
  return { token: plaintext, id: t.id };
}

export async function verifyToken(plaintext) {
  if (!plaintext?.startsWith('vk_')) return null;
  // Naïve approach: load all unrevoked tokens, bcrypt-compare. Fine at small N.
  // Switch to a lookup index if agent count grows.
  const { rows } = await pool.query(
    `SELECT t.id, t.token_hash, t.agent_id, a.*
     FROM agent_tokens t JOIN agents a ON a.id = t.agent_id
     WHERE t.revoked_at IS NULL`
  );
  for (const row of rows) {
    if (await bcrypt.compare(plaintext, row.token_hash)) {
      await pool.query(
        `UPDATE agent_tokens SET last_used=now() WHERE id=$1`, [row.id]
      );
      // Strip token_hash from returned agent record
      const { token_hash, ...agent } = row;
      return agent;
    }
  }
  return null;
}

export async function revokeToken(token_id) {
  await pool.query(
    `UPDATE agent_tokens SET revoked_at=now() WHERE id=$1`, [token_id]
  );
}
  • Step 3: Write conversations + messages repos + tests
// tests/repos/conversations.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as agents from '../../lib/db/repos/agents.js';
import * as conversations from '../../lib/db/repos/conversations.js';
import * as messages from '../../lib/db/repos/messages.js';

const owner = { kind: 'user', id: null };
beforeEach(async () => { await resetDb(); await migrateUp(); });

describe('conversations + messages', () => {
  it('creates a conversation and appends messages', async () => {
    const a = await agents.create({ slug: 'mercy', name: 'Mercy', kind: 'claude' }, owner);
    const c = await conversations.create({ title: 'hi', agent_id: a.id }, owner);
    const m1 = await messages.append(c.id, { role: 'user', body: 'hello' });
    const m2 = await messages.append(c.id, { role: 'assistant', body: 'hi', agent_id: a.id });
    const list = await messages.listByConversation(c.id);
    expect(list).toHaveLength(2);
    expect(list[0].id).toBe(m1.id);  // ordered by created_at
  });
});
// lib/db/repos/conversations.js
import { pool } from '../pool.js';
import { recordAudit } from './audit_stub.js';

export async function create({ title, agent_id, participants, metadata }, actor) {
  const { rows: [r] } = await pool.query(
    `INSERT INTO conversations(title, agent_id, participants, metadata)
     VALUES($1,$2,$3,$4) RETURNING *`,
    [title || null, agent_id || null, participants || [], metadata || {}]
  );
  await recordAudit(actor, 'create', 'conversation', r.id, null, r);
  return r;
}

export async function getById(id) {
  const { rows: [r] } = await pool.query(`SELECT * FROM conversations WHERE id=$1`, [id]);
  return r;
}

export async function list({ limit = 50, offset = 0 } = {}) {
  const { rows } = await pool.query(
    `SELECT * FROM conversations ORDER BY started_at DESC LIMIT $1 OFFSET $2`,
    [limit, offset]
  );
  return rows;
}

export async function setStatus(id, status, actor) {
  const before = await getById(id);
  const { rows: [r] } = await pool.query(
    `UPDATE conversations SET status=$1, ended_at = CASE WHEN $1='archived' THEN now() ELSE ended_at END
     WHERE id=$2 RETURNING *`,
    [status, id]
  );
  await recordAudit(actor, 'update', 'conversation', id, before, r);
  return r;
}

export async function setSummary(id, summary) {
  const { rows: [r] } = await pool.query(
    `UPDATE conversations SET summary=$1, status='summarized' WHERE id=$2 RETURNING *`,
    [summary, id]
  );
  return r;
}
// lib/db/repos/messages.js
import { pool } from '../pool.js';

export async function append(conversation_id, { role, body, agent_id, metadata }) {
  const { rows: [r] } = await pool.query(
    `INSERT INTO messages(conversation_id, role, body, agent_id, metadata)
     VALUES($1,$2,$3,$4,$5) RETURNING *`,
    [conversation_id, role, body, agent_id || null, metadata || {}]
  );
  return r;
}

export async function listByConversation(conversation_id, { limit = 1000 } = {}) {
  const { rows } = await pool.query(
    `SELECT * FROM messages WHERE conversation_id=$1 ORDER BY created_at LIMIT $2`,
    [conversation_id, limit]
  );
  return rows;
}
  • Step 4: Run + commit
npm test -- tests/repos/agents.test.js tests/repos/conversations.test.js
git add lib/db/repos/agents.js lib/db/repos/conversations.js lib/db/repos/messages.js tests/repos/agents.test.js tests/repos/conversations.test.js
git commit -m "feat(repos): agents (+ tokens + caps), conversations, messages"

Files:

  • Create: /project/src/void-v2/lib/db/migrations/005_cross.sql

  • Create: /project/src/void-v2/tests/db/migration_005.test.js

  • Step 1: Test (existence checks)

// tests/db/migration_005.test.js
import { describe, it, expect, beforeAll } from 'vitest';
import { resetDb, withClient } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';

describe('migration 005 — cross', () => {
  beforeAll(async () => { await resetDb(); await migrateUp(); });

  it('creates tags, entity_tags, entity_links, attachments', async () => {
    await withClient(async (c) => {
      for (const t of ['tags','entity_tags','entity_links','attachments']) {
        const { rows } = await c.query(`SELECT to_regclass('public.' || $1) AS t;`, [t]);
        expect(rows[0].t).toBe(t);
      }
    });
  });
});
  • Step 2: Write 005_cross.sql
CREATE TABLE tags (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name        text NOT NULL UNIQUE,
  description text,
  color       text,
  created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE entity_tags (
  entity_type text NOT NULL,
  entity_id   uuid NOT NULL,
  tag_id      uuid NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
  created_at  timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (entity_type, entity_id, tag_id)
);

CREATE TABLE entity_links (
  id        uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  from_type text NOT NULL,
  from_id   uuid NOT NULL,
  to_type   text NOT NULL,
  to_id     uuid NOT NULL,
  relation  text NOT NULL DEFAULT 'attached',
  created_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (from_type, from_id, to_type, to_id, relation)
);

CREATE TABLE attachments (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  entity_type text,
  entity_id   uuid,
  filename    text NOT NULL,
  mime_type   text,
  size_bytes  bigint,
  blob_path   text NOT NULL,
  checksum    text,
  uploaded_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX idx_entity_tags_entity ON entity_tags(entity_type, entity_id);
CREATE INDEX idx_entity_links_from ON entity_links(from_type, from_id);
CREATE INDEX idx_entity_links_to ON entity_links(to_type, to_id);
CREATE INDEX idx_attachments_entity ON attachments(entity_type, entity_id);
  • Step 3: Run + commit
npm test -- tests/db/migration_005.test.js
git add lib/db/migrations/005_cross.sql tests/db/migration_005.test.js
git commit -m "feat(schema): 005 — tags, entity_tags, entity_links, attachments"

Files:

  • Create: /project/src/void-v2/lib/db/repos/tags.js

  • Create: /project/src/void-v2/lib/db/repos/links.js

  • Create: /project/src/void-v2/lib/db/repos/attachments.js

  • Create tests for each.

  • Step 1: Tags repo + tests

// tests/repos/tags.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as spaces from '../../lib/db/repos/spaces.js';
import * as tags from '../../lib/db/repos/tags.js';

const owner = { kind: 'user', id: null };
beforeEach(async () => { await resetDb(); await migrateUp(); });

describe('tags repo', () => {
  it('upserts a tag by name', async () => {
    const t1 = await tags.upsert('homelab');
    const t2 = await tags.upsert('homelab');
    expect(t2.id).toBe(t1.id);
  });

  it('attach + detach + listForEntity', async () => {
    const s = await spaces.create({ slug: 'h', name: 'H' }, owner);
    const t = await tags.upsert('urgent');
    await tags.attach('space', s.id, t.id);
    const list = await tags.listForEntity('space', s.id);
    expect(list).toHaveLength(1);
    await tags.detach('space', s.id, t.id);
    expect(await tags.listForEntity('space', s.id)).toHaveLength(0);
  });
});
// lib/db/repos/tags.js
import { pool } from '../pool.js';

export async function upsert(name, { description, color } = {}) {
  const { rows: [r] } = await pool.query(
    `INSERT INTO tags(name, description, color)
     VALUES($1,$2,$3)
     ON CONFLICT(name) DO UPDATE SET description=COALESCE(EXCLUDED.description, tags.description),
       color=COALESCE(EXCLUDED.color, tags.color)
     RETURNING *`,
    [name, description || null, color || null]
  );
  return r;
}

export async function list() {
  const { rows } = await pool.query(`SELECT * FROM tags ORDER BY name`);
  return rows;
}

export async function attach(entity_type, entity_id, tag_id) {
  await pool.query(
    `INSERT INTO entity_tags(entity_type, entity_id, tag_id)
     VALUES($1,$2,$3) ON CONFLICT DO NOTHING`,
    [entity_type, entity_id, tag_id]
  );
}

export async function detach(entity_type, entity_id, tag_id) {
  await pool.query(
    `DELETE FROM entity_tags WHERE entity_type=$1 AND entity_id=$2 AND tag_id=$3`,
    [entity_type, entity_id, tag_id]
  );
}

export async function listForEntity(entity_type, entity_id) {
  const { rows } = await pool.query(
    `SELECT t.* FROM tags t JOIN entity_tags et ON et.tag_id=t.id
     WHERE et.entity_type=$1 AND et.entity_id=$2 ORDER BY t.name`,
    [entity_type, entity_id]
  );
  return rows;
}
  • Step 2: Links repo + tests
// tests/repos/links.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as links from '../../lib/db/repos/links.js';

beforeEach(async () => { await resetDb(); await migrateUp(); });

describe('links repo', () => {
  it('create + listFrom + listTo + remove', async () => {
    const a = '11111111-1111-1111-1111-111111111111';
    const b = '22222222-2222-2222-2222-222222222222';
    const link = await links.create('project', a, 'page', b, 'mentions');
    expect(link.relation).toBe('mentions');
    expect(await links.listFrom('project', a)).toHaveLength(1);
    expect(await links.listTo('page', b)).toHaveLength(1);
    await links.remove(link.id);
    expect(await links.listFrom('project', a)).toHaveLength(0);
  });

  it('idempotent on the unique tuple', async () => {
    const a = '11111111-1111-1111-1111-111111111111';
    const b = '22222222-2222-2222-2222-222222222222';
    const l1 = await links.create('project', a, 'page', b, 'mentions');
    const l2 = await links.create('project', a, 'page', b, 'mentions');
    expect(l2.id).toBe(l1.id);
  });
});
// lib/db/repos/links.js
import { pool } from '../pool.js';

export async function create(from_type, from_id, to_type, to_id, relation = 'attached') {
  const { rows: [r] } = await pool.query(
    `INSERT INTO entity_links(from_type, from_id, to_type, to_id, relation)
     VALUES($1,$2,$3,$4,$5)
     ON CONFLICT (from_type, from_id, to_type, to_id, relation)
       DO UPDATE SET relation=EXCLUDED.relation
     RETURNING *`,
    [from_type, from_id, to_type, to_id, relation]
  );
  return r;
}

export async function listFrom(from_type, from_id) {
  const { rows } = await pool.query(
    `SELECT * FROM entity_links WHERE from_type=$1 AND from_id=$2`,
    [from_type, from_id]
  );
  return rows;
}

export async function listTo(to_type, to_id) {
  const { rows } = await pool.query(
    `SELECT * FROM entity_links WHERE to_type=$1 AND to_id=$2`,
    [to_type, to_id]
  );
  return rows;
}

export async function remove(id) {
  await pool.query(`DELETE FROM entity_links WHERE id=$1`, [id]);
}
  • Step 3: Attachments repo + tests
// tests/repos/attachments.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as attachments from '../../lib/db/repos/attachments.js';

beforeEach(async () => { await resetDb(); await migrateUp(); });

describe('attachments repo', () => {
  it('records an attachment', async () => {
    const a = await attachments.create({
      entity_type: 'page',
      entity_id: '11111111-1111-1111-1111-111111111111',
      filename: 'spec.pdf', mime_type: 'application/pdf',
      size_bytes: 1024, blob_path: 'aa/abc', checksum: 'abc'
    });
    expect(a.filename).toBe('spec.pdf');
  });
});
// lib/db/repos/attachments.js
import { pool } from '../pool.js';

export async function create({ entity_type, entity_id, filename, mime_type, size_bytes, blob_path, checksum }) {
  const { rows: [r] } = await pool.query(
    `INSERT INTO attachments(entity_type, entity_id, filename, mime_type, size_bytes, blob_path, checksum)
     VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
    [entity_type, entity_id, filename, mime_type || null, size_bytes || null, blob_path, checksum || null]
  );
  return r;
}

export async function listForEntity(entity_type, entity_id) {
  const { rows } = await pool.query(
    `SELECT * FROM attachments WHERE entity_type=$1 AND entity_id=$2 ORDER BY uploaded_at DESC`,
    [entity_type, entity_id]
  );
  return rows;
}

export async function getById(id) {
  const { rows: [r] } = await pool.query(`SELECT * FROM attachments WHERE id=$1`, [id]);
  return r;
}

export async function del(id) {
  await pool.query(`DELETE FROM attachments WHERE id=$1`, [id]);
}
  • Step 4: Run + commit
npm test -- tests/repos/tags.test.js tests/repos/links.test.js tests/repos/attachments.test.js
git add lib/db/repos/tags.js lib/db/repos/links.js lib/db/repos/attachments.js tests/repos/tags.test.js tests/repos/links.test.js tests/repos/attachments.test.js
git commit -m "feat(repos): tags, polymorphic entity_links, attachments"

Task 16: Migration 006 + Real Audit Log + Pending Changes

Files:

  • Create: /project/src/void-v2/lib/db/migrations/006_audit.sql

  • Create: /project/src/void-v2/lib/db/repos/audit.js

  • Create: /project/src/void-v2/lib/db/repos/pending_changes.js

  • Modify: /project/src/void-v2/lib/db/repos/audit_stub.js — re-export from real audit.js

  • Create: /project/src/void-v2/tests/db/migration_006.test.js

  • Create: /project/src/void-v2/tests/repos/audit.test.js

  • Create: /project/src/void-v2/tests/repos/pending_changes.test.js

  • Step 1: Migration test + SQL

// tests/db/migration_006.test.js
import { describe, it, expect, beforeAll } from 'vitest';
import { resetDb, withClient } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';

describe('migration 006 — audit', () => {
  beforeAll(async () => { await resetDb(); await migrateUp(); });

  it('creates audit_log and pending_changes', async () => {
    await withClient(async (c) => {
      for (const t of ['audit_log','pending_changes']) {
        const { rows } = await c.query(`SELECT to_regclass('public.' || $1) AS t;`, [t]);
        expect(rows[0].t).toBe(t);
      }
    });
  });
});
-- lib/db/migrations/006_audit.sql
CREATE TABLE audit_log (
  id          bigserial PRIMARY KEY,
  actor_kind  text NOT NULL CHECK (actor_kind IN ('user','agent','cron','worker','system')),
  actor_id    uuid,
  entity_type text NOT NULL,
  entity_id   uuid,
  action      text NOT NULL CHECK (action IN ('create','update','delete','suggest','approve','reject')),
  diff        jsonb,
  occurred_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE pending_changes (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  agent_id    uuid NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
  entity_type text NOT NULL,
  entity_id   uuid,
  action      text NOT NULL CHECK (action IN ('create','update','delete')),
  payload     jsonb NOT NULL,
  reason      text,
  status      text NOT NULL DEFAULT 'pending'
              CHECK (status IN ('pending','approved','rejected')),
  resolved_at timestamptz,
  resolved_by text,
  created_at  timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX idx_audit_entity ON audit_log(entity_type, entity_id, occurred_at);
CREATE INDEX idx_audit_actor ON audit_log(actor_kind, actor_id, occurred_at);
CREATE INDEX idx_pending_status ON pending_changes(status, created_at)
  WHERE status='pending';
  • Step 2: Real audit repo
// lib/db/repos/audit.js
import { pool } from '../pool.js';

const REDACT_KEYS = new Set([
  'token','token_hash','password','api_key','secret','authorization'
]);

function redact(obj) {
  if (!obj || typeof obj !== 'object') return obj;
  if (Array.isArray(obj)) return obj.map(redact);
  const out = {};
  for (const [k, v] of Object.entries(obj)) {
    if (REDACT_KEYS.has(k.toLowerCase())) out[k] = '[REDACTED]';
    else out[k] = redact(v);
  }
  return out;
}

function diff(before, after) {
  if (before === null && after === null) return null;
  if (before === null) return { kind: 'create', after: redact(after) };
  if (after === null) return { kind: 'delete', before: redact(before) };
  const changed = {};
  for (const k of new Set([...Object.keys(before), ...Object.keys(after)])) {
    if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) {
      changed[k] = { before: redact(before[k]), after: redact(after[k]) };
    }
  }
  return Object.keys(changed).length ? { kind: 'update', changes: changed } : null;
}

export async function recordAudit(actor, action, entity_type, entity_id, before, after) {
  const d = diff(before, after);
  await pool.query(
    `INSERT INTO audit_log(actor_kind, actor_id, entity_type, entity_id, action, diff)
     VALUES($1,$2,$3,$4,$5,$6)`,
    [actor?.kind || 'system', actor?.id || null, entity_type, entity_id, action, d]
  );
}

export async function listForEntity(entity_type, entity_id, { limit = 100 } = {}) {
  const { rows } = await pool.query(
    `SELECT * FROM audit_log WHERE entity_type=$1 AND entity_id=$2
     ORDER BY occurred_at DESC LIMIT $3`,
    [entity_type, entity_id, limit]
  );
  return rows;
}

export async function listByActor({ actor_kind, actor_id, limit = 100 } = {}) {
  const where = [], vals = [];
  let i = 1;
  if (actor_kind) { where.push(`actor_kind=$${i++}`); vals.push(actor_kind); }
  if (actor_id) { where.push(`actor_id=$${i++}`); vals.push(actor_id); }
  vals.push(limit);
  const w = where.length ? `WHERE ${where.join(' AND ')}` : '';
  const { rows } = await pool.query(
    `SELECT * FROM audit_log ${w} ORDER BY occurred_at DESC LIMIT $${i}`,
    vals
  );
  return rows;
}
  • Step 3: Replace stub with re-export
// lib/db/repos/audit_stub.js
export { recordAudit } from './audit.js';
  • Step 4: Audit + Pending tests
// tests/repos/audit.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as spaces from '../../lib/db/repos/spaces.js';
import * as audit from '../../lib/db/repos/audit.js';

const owner = { kind: 'user', id: null };
beforeEach(async () => { await resetDb(); await migrateUp(); });

describe('audit log', () => {
  it('creates an audit row on space create', async () => {
    const s = await spaces.create({ slug: 'h', name: 'H' }, owner);
    const rows = await audit.listForEntity('space', s.id);
    expect(rows).toHaveLength(1);
    expect(rows[0].action).toBe('create');
  });

  it('records the diff of an update', async () => {
    const s = await spaces.create({ slug: 'h', name: 'H' }, owner);
    await spaces.update(s.id, { name: 'Hh' }, owner);
    const rows = await audit.listForEntity('space', s.id);
    const upd = rows.find(r => r.action === 'update');
    expect(upd.diff.changes.name.before).toBe('H');
    expect(upd.diff.changes.name.after).toBe('Hh');
  });

  it('redacts known sensitive keys', async () => {
    // Simulate by writing audit directly with a sensitive payload
    await audit.recordAudit(owner, 'update', 'test', null,
      { token: 'secret-abc' }, { token: 'secret-def' }
    );
    const rows = await audit.listByActor({});
    const r = rows.find(r => r.entity_type === 'test');
    expect(r.diff.changes.token.before).toBe('[REDACTED]');
    expect(r.diff.changes.token.after).toBe('[REDACTED]');
  });
});
// lib/db/repos/pending_changes.js
import { pool } from '../pool.js';

export async function create({ agent_id, entity_type, entity_id, action, payload, reason }) {
  const { rows: [r] } = await pool.query(
    `INSERT INTO pending_changes(agent_id, entity_type, entity_id, action, payload, reason)
     VALUES($1,$2,$3,$4,$5,$6) RETURNING *`,
    [agent_id, entity_type, entity_id || null, action, payload, reason || null]
  );
  return r;
}

export async function listPending({ limit = 100 } = {}) {
  const { rows } = await pool.query(
    `SELECT * FROM pending_changes WHERE status='pending' ORDER BY created_at LIMIT $1`,
    [limit]
  );
  return rows;
}

export async function getById(id) {
  const { rows: [r] } = await pool.query(`SELECT * FROM pending_changes WHERE id=$1`, [id]);
  return r;
}

export async function resolve(id, status, resolved_by) {
  const { rows: [r] } = await pool.query(
    `UPDATE pending_changes SET status=$1, resolved_at=now(), resolved_by=$2
     WHERE id=$3 AND status='pending' RETURNING *`,
    [status, resolved_by || null, id]
  );
  return r;
}
// tests/repos/pending_changes.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import * as agents from '../../lib/db/repos/agents.js';
import * as pending from '../../lib/db/repos/pending_changes.js';

const owner = { kind: 'user', id: null };
beforeEach(async () => { await resetDb(); await migrateUp(); });

describe('pending changes', () => {
  it('creates and resolves a pending change', async () => {
    const a = await agents.create({ slug: 'mercy', name: 'Mercy', kind: 'claude' }, owner);
    const p = await pending.create({
      agent_id: a.id, entity_type: 'page', action: 'create',
      payload: { title: 'draft', body_md: 'hi' }, reason: 'inferred from chat'
    });
    expect(p.status).toBe('pending');
    const resolved = await pending.resolve(p.id, 'approved', 'owner');
    expect(resolved.status).toBe('approved');
  });
});
  • Step 5: Run + commit
npm test -- tests/repos/audit.test.js tests/repos/pending_changes.test.js
git add lib/db/migrations/006_audit.sql lib/db/repos/audit.js lib/db/repos/audit_stub.js lib/db/repos/pending_changes.js tests/db/migration_006.test.js tests/repos/audit.test.js tests/repos/pending_changes.test.js
git commit -m "feat: real audit_log with redaction + pending_changes; replace stub"

Task 17: Capability Check Module

Files:

  • Create: /project/src/void-v2/lib/auth/capability.js
  • Create: /project/src/void-v2/tests/auth/capability.test.js

The capability module is the single decision point for "can this actor perform this action on this entity type?" It's called from REST handlers and (later) MCP tools.

  • Step 1: Write tests
// tests/auth/capability.test.js
import { describe, it, expect } from 'vitest';
import { canAct } from '../../lib/auth/capability.js';

const ownerActor = { kind: 'user', id: null };
const readAgent = {
  kind: 'agent', id: 'a1',
  capabilities: { read: true, suggest: true, write: false },
  scopes: {}
};
const writeAgent = {
  kind: 'agent', id: 'a2',
  capabilities: { read: true, suggest: true, write: true },
  scopes: { pages: true }
};

describe('canAct', () => {
  it('owner can do anything', () => {
    expect(canAct(ownerActor, 'create', 'page')).toBe('allow');
    expect(canAct(ownerActor, 'delete', 'resource')).toBe('allow');
  });

  it('read-only agent can read', () => {
    expect(canAct(readAgent, 'read', 'page')).toBe('allow');
  });

  it('default agent on create returns "suggest"', () => {
    expect(canAct(readAgent, 'create', 'page')).toBe('suggest');
  });

  it('write-scoped agent can write to its scope', () => {
    expect(canAct(writeAgent, 'create', 'page')).toBe('allow');
  });

  it('write-capable agent without scope still suggests outside it', () => {
    expect(canAct(writeAgent, 'create', 'resource')).toBe('suggest');
  });

  it('agent with no capabilities is deny', () => {
    expect(canAct({ kind: 'agent', id: 'x', capabilities: {} }, 'read', 'page')).toBe('deny');
  });
});
  • Step 2: Implement
// lib/auth/capability.js
/**
 * canAct(actor, action, entity_type) -> 'allow' | 'suggest' | 'deny'
 *
 *  - User (owner): always 'allow'.
 *  - Agent with capabilities.write=true AND scope present for entity_type: 'allow'.
 *  - Agent with capabilities.suggest=true: 'suggest' (caller writes to pending_changes).
 *  - Agent with capabilities.read=true on read actions: 'allow'.
 *  - Otherwise: 'deny'.
 */
export function canAct(actor, action, entity_type) {
  if (!actor) return 'deny';
  if (actor.kind === 'user') return 'allow';
  if (actor.kind === 'cron' || actor.kind === 'worker' || actor.kind === 'system') return 'allow';

  if (actor.kind !== 'agent') return 'deny';

  const caps = actor.capabilities || {};
  const scopes = actor.scopes || {};

  if (action === 'read') return caps.read ? 'allow' : 'deny';

  const isMutation = ['create','update','delete'].includes(action);
  if (!isMutation) return 'deny';

  if (caps.write && scopes[entity_type]) return 'allow';
  if (caps.suggest) return 'suggest';
  return 'deny';
}
  • Step 3: Run + commit
mkdir -p tests/auth
npm test -- tests/auth/capability.test.js
git add lib/auth/capability.js tests/auth/capability.test.js
git commit -m "feat(auth): capability check — user/cron/worker allow; agents tiered allow/suggest/deny"

Task 18: Owner Token Middleware

Files:

  • Create: /project/src/void-v2/lib/auth/owner.js
  • Create: /project/src/void-v2/tests/auth/owner.test.js

The owner middleware checks Authorization: Bearer <OWNER_TOKEN> against the env var. Anything else fails with 401. Agent-token verification (via the agents repo) is a separate middleware added later.

  • Step 1: Write tests
// tests/auth/owner.test.js
import { describe, it, expect, vi } from 'vitest';
import { ownerOnly } from '../../lib/auth/owner.js';

function mockReq(token) {
  return { headers: { authorization: token ? `Bearer ${token}` : undefined } };
}
function mockRes() {
  const r = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis(), end: vi.fn() };
  return r;
}

describe('ownerOnly middleware', () => {
  beforeEach(() => { process.env.OWNER_TOKEN = 'test-token'; });

  it('rejects missing token', () => {
    const res = mockRes();
    const next = vi.fn();
    ownerOnly(mockReq(null), res, next);
    expect(res.status).toHaveBeenCalledWith(401);
    expect(next).not.toHaveBeenCalled();
  });

  it('rejects wrong token', () => {
    const res = mockRes();
    const next = vi.fn();
    ownerOnly(mockReq('wrong'), res, next);
    expect(res.status).toHaveBeenCalledWith(401);
  });

  it('accepts correct token + attaches actor', () => {
    const res = mockRes();
    const next = vi.fn();
    const req = mockReq('test-token');
    ownerOnly(req, res, next);
    expect(next).toHaveBeenCalled();
    expect(req.actor).toEqual({ kind: 'user', id: null });
  });
});
  • Step 2: Implement
// lib/auth/owner.js
export function ownerOnly(req, res, next) {
  const expected = process.env.OWNER_TOKEN;
  if (!expected) return res.status(500).json({ error: { code: 'no_owner_token', message: 'OWNER_TOKEN not configured' } });
  const auth = req.headers.authorization || '';
  const [scheme, token] = auth.split(' ');
  if (scheme !== 'Bearer' || token !== expected) {
    return res.status(401).json({ error: { code: 'unauthorized', message: 'invalid token' } });
  }
  req.actor = { kind: 'user', id: null };
  next();
}
  • Step 3: Run + commit
npm test -- tests/auth/owner.test.js
git add lib/auth/owner.js tests/auth/owner.test.js
git commit -m "feat(auth): owner-only middleware for single-user bearer auth"

Task 19: Express Bootstrap + /health Endpoint

Files:

  • Create: /project/src/void-v2/server.js
  • Create: /project/src/void-v2/tests/server.test.js

The health endpoint pings the DB pool (SELECT 1) and returns version info. It does NOT require auth (probes need to be unauthenticated).

  • Step 1: Install supertest
npm install --save-dev supertest
  • Step 2: Write test using supertest
// tests/server.test.js
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { createApp } from '../server.js';
import { resetDb } from './helpers/db.js';
import { migrateUp } from '../lib/db/migrate.js';
import { pool } from '../lib/db/pool.js';

let app;
beforeAll(async () => {
  await resetDb(); await migrateUp();
  process.env.OWNER_TOKEN = 'test-token';
  app = createApp();
});
afterAll(async () => { await pool.end(); });

describe('server', () => {
  it('GET /health returns 200 with db_ok=true', async () => {
    const res = await request(app).get('/health');
    expect(res.status).toBe(200);
    expect(res.body.db_ok).toBe(true);
    expect(res.body.version).toBeDefined();
  });

  it('GET /api/spaces without token returns 401', async () => {
    const res = await request(app).get('/api/spaces');
    expect(res.status).toBe(401);
  });

  it('GET /api/spaces with token returns 200 and empty array', async () => {
    const res = await request(app)
      .get('/api/spaces')
      .set('Authorization', 'Bearer test-token');
    expect(res.status).toBe(200);
    expect(res.body).toEqual([]);
  });
});
  • Step 3: Implement server.js
// server.js
import 'dotenv/config';
import express from 'express';
import { pool } from './lib/db/pool.js';
import { ownerOnly } from './lib/auth/owner.js';
import { log } from './lib/log.js';
import * as spaces from './lib/db/repos/spaces.js';

const VERSION = '2.0.0-alpha.1';

export function createApp() {
  const app = express();
  app.use(express.json({ limit: '10mb' }));

  // Healthcheck — no auth, used by probes
  app.get('/health', async (_req, res) => {
    let db_ok = false;
    try {
      await pool.query('SELECT 1');
      db_ok = true;
    } catch (e) {
      log.error({ err: e }, 'healthcheck db ping failed');
    }
    res.json({ ok: true, db_ok, version: VERSION });
  });

  // All /api/* requires owner token (for now; agent tokens added later)
  app.use('/api', ownerOnly);

  // Smoke endpoint — list spaces. Full CRUD lands in Plan 2.
  app.get('/api/spaces', async (_req, res) => {
    res.json(await spaces.list());
  });

  // Generic 404
  app.use((_req, res) => res.status(404).json({ error: { code: 'not_found' } }));

  // Generic error handler
  app.use((err, _req, res, _next) => {
    log.error({ err }, 'unhandled');
    res.status(500).json({ error: { code: 'internal', message: err.message } });
  });

  return app;
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const port = process.env.PORT || 3000;
  createApp().listen(port, () => log.info({ port }, 'void-server listening'));
}
  • Step 4: Run tests + commit
npm test -- tests/server.test.js
git add server.js package.json package-lock.json tests/server.test.js
git commit -m "feat(server): Express bootstrap, /health, ownerOnly on /api, smoke /api/spaces"

Task 20: Smoke Test Against Real DB

Files:

  • None (manual verification)

  • Step 1: Confirm .env on dev box points at void2-db

cat /project/src/void-v2/.env
# Expect DATABASE_URL=postgres://void:...@192.168.1.X:5432/void
  • Step 2: Run all tests against real DB
cd /project/src/void-v2
npm test

Expect ALL tests pass.

  • Step 3: Run migrations against real DB
npm run migrate

Expect: log lines applying 001 → 006, exit code 0.

  • Step 4: Start server
OWNER_TOKEN=$(openssl rand -base64 24) npm start &
echo $! > /tmp/void-server.pid
sleep 1
  • Step 5: Curl smoke
curl -s http://localhost:3000/health | jq
# Expect: {"ok":true,"db_ok":true,"version":"2.0.0-alpha.1"}

curl -s -H "Authorization: Bearer $OWNER_TOKEN" http://localhost:3000/api/spaces | jq
# Expect: []
  • Step 6: Stop server
kill $(cat /tmp/void-server.pid); rm /tmp/void-server.pid
  • Step 7: Commit nothing — verification only. Note completion in CHANGELOG.

Append to CHANGELOG.md under ## [Unreleased]:

### Added (Plan 1: Foundation)
- LXC provisioning for `void2-db` (Postgres 16 + pgvector) and `void2-app`
- Schema migrations 001-006 covering core, knowledge, resources, agents, cross-cutting, audit
- Repos with capability-checked `actor` parameter and audit trail
- Owner-token bearer auth
- `/health` endpoint
- Test coverage on all repos + migrations + capability + owner middleware
git add CHANGELOG.md
git commit -m "docs: changelog entry for Plan 1 completion"

Task 21: systemd Service Unit + Deploy Script

Files:

  • Create: /project/src/void-v2/deploy/void-server.service

  • Create: /project/src/void-v2/deploy/push.sh

  • Modify: /project/src/void-v2/deploy/README.md (append deploy section)

  • Step 1: Write systemd unit

# deploy/void-server.service
[Unit]
Description=Void 2.0 server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=void
WorkingDirectory=/opt/void-server
EnvironmentFile=/opt/void-server/.env
ExecStart=/usr/bin/node /opt/void-server/server.js
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
  • Step 2: Write deploy/push.sh
#!/usr/bin/env bash
set -euo pipefail

# Push dev source to void2-app (CT 311) and restart service.
# Run from /project/src/void-v2.

TARGET=${TARGET:-root@192.168.1.Y}     # void2-app
REMOTE_DIR=${REMOTE_DIR:-/opt/void-server}

rsync -avz --delete \
  --exclude node_modules \
  --exclude .git \
  --exclude tests \
  --exclude coverage \
  ./ "$TARGET:$REMOTE_DIR/"

ssh "$TARGET" "cd $REMOTE_DIR && npm install --omit=dev && systemctl restart void-server"
echo "Deployed."
chmod +x deploy/push.sh
  • Step 3: Append deploy notes
## App deploy (CT 311)

One-time setup on `void2-app`:
- `apt install -y nodejs npm`  (or install Node 22 from nodesource)
- `useradd -r -m -d /opt/void-server void`
- `mkdir -p /opt/void-server && chown void: /opt/void-server`
- Copy `void-server.service` to `/etc/systemd/system/void-server.service`
- `systemctl daemon-reload && systemctl enable void-server`
- Create `/opt/void-server/.env` with `DATABASE_URL` + `OWNER_TOKEN`
- Run `deploy/push.sh` from the dev box

Maintenance:
- Logs: `journalctl -u void-server -f`
- Status: `systemctl status void-server`
- Migrate: `cd /opt/void-server && npm run migrate`
  • Step 4: Commit
git add deploy/
git commit -m "chore(deploy): systemd unit, push.sh, one-time setup notes"

Task 22: Plan 1 Completion Doc

Files:

  • Create: /project/src/void-v2/docs/plan-1-complete.md

  • Step 1: Write completion doc

# Plan 1 Complete — Foundation

Done: 2026-XX-XX

## What landed
- Two LXCs provisioned: `void2-db` (Postgres 16 + pgvector), `void2-app`
  (Node 22 + Express)
- Schema migrations 001-006: all entity tables + cross-cutting + audit
- All repos with capability-ready `actor` parameter, audit trail enabled
- Owner bearer-token auth on `/api/*`; `/health` is open
- ~30+ test cases across migrations, repos, capability, owner middleware
- systemd unit + push.sh deploy script

## What's NOT here
- Routes for every entity (Plan 2)
- MCP server (Plan 5)
- The Void UI (Plan 2)
- Capture workers (Plan 3 + 4)
- Migrations from Void 1.x / BookStack / Karakeep (Plan 7)

## How to verify

cd /project/src/void-v2 npm test # all green npm run migrate # applies 001-006 OWNER_TOKEN=test npm start & # server up curl localhost:3000/health # db_ok=true curl -H "Authorization: Bearer test" localhost:3000/api/spaces # []


## Next: Plan 2 — Core REST API + Void UI shell
  • Step 2: Commit
git add docs/plan-1-complete.md
git commit -m "docs: Plan 1 completion summary"

Self-Review Checklist

Run through this once after implementing:

  • All tests pass (npm test)
  • Migrations apply cleanly on a fresh DB (resetDb then migrateUp)
  • /health returns db_ok: true against the real DB
  • /api/spaces returns 401 without token, 200 with token
  • Audit log records create + update + delete on a space (manual curl + psql check)
  • git log --oneline shows ~20 commits, each scoped and meaningful
  • No console.logs left in lib/ or server.js
  • .env is gitignored
  • CHANGELOG.md has the Plan 1 entry
  • docs/plan-1-complete.md describes the state

Plan 1 produces: a running Void 2.0 backend with the full schema in place, repos that record audit, owner auth, and a smoke endpoint — but no business endpoints, no UI, no capture, no MCP. That's all Plan 2+.