PCP Data Model
Overview
Personal Context Protocol uses PostgreSQL via Neon. This document describes the schema, relationships, and constraints.
Design principles
Immutable messages: Messages are never updated or deleted
Scoped tokens: Each session token is bound to exactly one session
Audit trail: Every action is logged as an event
Stable IDs: IDs use
prefix_timestamp_idformat for readabilitySoft deletes: Archive/revoke flags instead of hard deletes
Tables
app_instance
Single-row table tracking the application instance.
Column |
Type |
Description |
|---|---|---|
|
TEXT |
Always |
|
TIMESTAMPTZ |
Instance creation time |
|
TIMESTAMPTZ |
When setup was completed |
|
TEXT |
Schema version |
Constraints:
Primary key:
idAlways exactly one row
Example:
{
"id": "instance_1",
"created_at": "2026-06-22T22:45:00Z",
"initialized_at": "2026-06-22T22:50:00Z",
"version": "0.1.15"
}
ui_auth
Stores the UI admin token hash.
Column |
Type |
Description |
|---|---|---|
|
TEXT |
Always |
|
TEXT |
Salted hash of UI token |
|
TEXT |
Salt for hashing |
|
TEXT |
Credential owner: |
|
TIMESTAMPTZ |
Token creation time |
|
TIMESTAMPTZ |
Last rotation time |
|
TIMESTAMPTZ |
Last successful auth |
Constraints:
Primary key:
idAlways exactly one row
Source / rotation: deploy tokens are auto-generated and rotated on every
deploy (the previous one stops working); env tokens come from
PCP_ADMIN_TOKEN and are reconciled each deploy; user tokens are set in
Settings and never auto-rotated. Precedence is environment > user > deploy.
The default for any credential not explicitly set in Settings (including
pre-existing ones) is deploy, so it rotates each deploy until the admin sets
their own token or PCP_ADMIN_TOKEN.
Note: The raw UI token is shown only during setup, never stored.
topics
Human-managed topics/categories for organizing sessions.
Column |
Type |
Description |
|---|---|---|
|
TEXT |
|
|
TEXT |
Human-readable title |
|
TEXT |
Optional description |
|
BOOLEAN |
Soft delete flag |
|
TIMESTAMPTZ |
Creation time |
|
TIMESTAMPTZ |
Last update time |
Constraints:
Primary key:
idUnique:
title(wherearchived = false)Index:
archived
AI access: AI tokens CANNOT create, rename, delete, or archive topics.
sessions
AI recording sessions, optionally within a topic.
Column |
Type |
Description |
|---|---|---|
|
TEXT |
|
|
TEXT |
Parent topic (foreign key, nullable: NULL = uncategorized) |
|
TEXT |
Session title (may be suggested by AI) |
|
TEXT |
Recording mode the agent is told to honor: |
|
BOOLEAN |
When true, a read-only view is available without the admin token |
|
BOOLEAN |
Soft delete flag |
|
TIMESTAMPTZ |
Creation time |
|
TIMESTAMPTZ |
Last update time |
|
TIMESTAMPTZ |
Last message timestamp |
Constraints:
Primary key:
idForeign key:
topic_id → topics.id(nullable)Index:
topic_idIndex:
archived
Uncategorized: a session may have no topic (topic_id NULL). The UI groups
these under “Uncategorized”. Title uniqueness is scoped to the group — sessions
under the same topic, or all topic-less sessions.
Public sharing: when public is true, anyone can read the session and its
messages via GET /api/v1/public/sessions/:id and the /s/:id page, without the
admin UI token. Private (default) sessions return 404 from that endpoint so their
existence is not leaked. Tokens are never exposed on the public surface.
Recording mode: wild (default) tells the agent it may redact secrets it
judges unsafe; exact tells it to record verbatim (including credentials) for
faithful task migration. The mode is surfaced to the agent in the protocol
descriptor (recording_mode + recording_guidance) and the generated
instruction. It is guidance to the agent, not a server-side filter.
AI access: AI can only view/append to sessions for which it has a valid token.
session_tokens
Scoped tokens for AI agents.
Column |
Type |
Description |
|---|---|---|
|
TEXT |
|
|
TEXT |
Bound session (foreign key) |
|
TEXT |
Salted hash of token |
|
TEXT |
Salt for hashing |
|
TEXT |
Human-readable name (e.g., “Claude session”) |
|
BOOLEAN |
Allow session title suggestions |
|
BOOLEAN |
Soft revoke flag |
|
TIMESTAMPTZ |
Creation time |
|
TIMESTAMPTZ |
Expiry; NULL means never expire |
|
TIMESTAMPTZ |
Last successful use |
|
TEXT |
First 16 chars for fast lookup |
Expiration: Tokens may be created with expires_in of 1h, 24h, 7d
(default), 30d, or never. never stores a NULL expires_at; tokens created
before expiration existed also have NULL and are treated as never-expiring.
Expired tokens are rejected at authentication time. Status is derived as
active, expired, or revoked.
Constraints:
Primary key:
idForeign key:
session_id → sessions.idIndex:
session_idIndex:
token_hash(for lookup)Index:
revoked
Capabilities:
Always: append messages to bound session
If
can_rename_session = true: suggest session titleNever: access other sessions, manage topics, delete messages
Note: Raw token is shown only at creation, never stored.
messages
Immutable conversation messages.
Column |
Type |
Description |
|---|---|---|
|
TEXT |
|
|
TEXT |
Parent session (foreign key) |
|
TEXT |
Denormalized topic (foreign key, nullable; NULL for uncategorized sessions) |
|
INTEGER |
Order within session (1, 2, 3…) |
|
TEXT |
|
|
TEXT |
Message content (markdown/text) |
|
TEXT |
|
|
TEXT |
AI provider (e.g., “anthropic”, “openai”) |
|
TEXT |
Model name (e.g., “claude-3-5-sonnet”) |
|
TIMESTAMPTZ |
Provider’s timestamp (if available) |
|
TIMESTAMPTZ |
When recorded locally |
|
JSONB |
Raw provider response (optional) |
|
JSONB |
Extra metadata |
|
TIMESTAMPTZ |
Insertion time |
Constraints:
Primary key:
idForeign key:
session_id → sessions.idForeign key:
topic_id → topics.idUnique:
(session_id, ordinal)Index:
session_idIndex:
topic_idIndex:
role
Immutability: Messages are append-only for AI tokens — an agent can only
append; it can never update or delete, and AI corrections are new messages. A
human admin (UI token) may edit or delete a message to fix a wrongly-recorded
or wrongly-imported entry (PATCH/POST .../messages/delete), which is audited
(message.edited, message.deleted). Deletes are hard deletes; ordinal gaps are
fine because new appends use max(ordinal) + 1.
Content:
Store pure text/markdown only
If provider timestamp unavailable, use
observed_atand"unknown"for provider/modelAlways populate
observed_at
Roles:
user: Human messageassistant: AI messagesystem: System instructionstool: Tool responsecorrection: Correction to previous message
compactions
Durable session summaries stored alongside raw messages, for when full message upload is impossible. Compactions are additional records and never replace messages.
Column |
Type |
Description |
|---|---|---|
|
TEXT |
|
|
TEXT |
Parent session (foreign key) |
|
TEXT |
Required summary text |
|
JSONB |
Optional timeline |
|
JSONB |
Optional decisions |
|
JSONB |
Optional requirements |
|
JSONB |
Optional open questions |
|
JSONB |
Optional artifacts |
|
JSONB |
Optional warnings |
|
TEXT |
AI provider |
|
TEXT |
Model name |
|
TIMESTAMPTZ |
Insertion time |
|
JSONB |
Extra metadata |
Constraints:
Primary key:
idForeign key:
session_id → sessions.idIndex:
session_id
events
Audit trail for all actions.
Column |
Type |
Description |
|---|---|---|
|
TEXT |
|
|
TEXT |
Related session (nullable) |
|
TEXT |
Related topic (nullable) |
|
TEXT |
Action type |
|
TEXT |
Who performed action ( |
|
JSONB |
Action details |
|
TIMESTAMPTZ |
When action occurred |
Constraints:
Primary key:
idIndex:
session_idIndex:
topic_idIndex:
actionIndex:
created_at
Actions:
session.createdsession.renamedsession.archivedtoken.createdtoken.revokedmessage.appendedmessage.editedmessage.deletedcompaction.recordedtoken.revokedtoken.renamedsession.sharedsession.unsharedtopic.createdtopic.renamedtopic.archiveduser.authenticated
schema_migrations
Track applied migrations.
Column |
Type |
Description |
|---|---|---|
|
TEXT |
Migration version (e.g., “001_initial”) |
|
TIMESTAMPTZ |
When applied |
|
TEXT |
File checksum for validation |
Constraints:
Primary key:
version
ID format
All IDs use prefix_<timestamp>_<random> format:
Prefix |
Table |
|---|---|
|
app_instance |
|
ui_auth |
|
topics |
|
sessions |
|
session_tokens |
|
messages |
|
compactions |
|
events |
Example: topic_1719099900_a3f2b1c4
This format:
Is human-readable
Sorts chronologically
Doesn’t reveal database internals (unlike sequential integers)
Is unique across tables
Relationships
app_instance (1) ──┐
ui_auth (1) │
│
topics (N) ───┐ │
│ │
│ │
└─┐ │
│ │
sessions (N) ───┼──┘
│
├── session_tokens (N)
│
├── messages (N)
│
└── events (N)
Indexes
Performance-critical indexes
messages(session_id, ordinal)- Sequential message retrievalsession_tokens(token_hash)- Token validation (lookup by hash)sessions(topic_id, created_at DESC)- Topic session listingevents(session_id, created_at DESC)- Event timeline
Composite indexes
messages(topic_id, created_at DESC)- Topic-level message searchsession_tokens(session_id, revoked)- Session token lookup
Migrations
See docs/migrations.md.
Rules:
Never drop tables or columns in v0.1
Never delete data
Always backward-compatible
Test on fresh DB before deployment
Security considerations
Token storage
Never store raw tokens
Always store salted hashes (Argon2 or bcrypt)
Use per-token random salts
Never log or return tokens after creation
Message content
Store as plain text/markdown
No encryption in v0.1
Provider secrets never enter database
User data is trusted (single-user app)
Access control
Session tokens are scoped by foreign key
Token validation checks session match
Topic routes reject AI tokens (403)
Example queries
Get all messages for a session (chronological)
SELECT *
FROM messages
WHERE session_id = 'ses_1719099900_xxx'
ORDER BY ordinal ASC;
Get session with topic info (admin view)
SELECT s.*, t.title as topic_title
FROM sessions s
JOIN topics t ON s.topic_id = t.id
WHERE s.id = 'ses_1719099900_xxx';
Validate session token
SELECT id, session_id, can_rename_session
FROM session_tokens
WHERE token_hash = $1 AND revoked = false;
Get recent events
SELECT *
FROM events
WHERE session_id = $1
ORDER BY created_at DESC
LIMIT 50;
Version history
v0.1.0 (initial)
All tables listed above
Soft deletes for topics, sessions, tokens
Immutable messages
Audit events
Migration tracking
v0.1.3 (agent recording URL)
Added
compactionstable for durable session summariesSession access tokens gained expiration choices (
expires_at, NULL = never)Agent recording-URL + token model with public protocol discovery
v0.1.4 (admin token rotation)
Added
ui_auth.source(deploy|env|user) for per-deploy rotation
v0.1.5 (uncategorized sessions)
sessions.topic_idandmessages.topic_idmade nullable (NULL = no topic)ChatGPT-style nav: collapsible topic groups with nested sessions
v0.1.6 (token rotation default fix)
Corrected the 003 backfill: credentials not set in Settings default to
deployand rotate each deploy (migration 005)
v0.1.7 (public sharing + token revocation)
Added
sessions.publicfor read-only public sharing (migration 006)Token revocation API + dashboard modal; recording URL built from the page origin
v0.1.8 (human fallback import)
POST /api/v1/sessions/:id/importrecords a pasted agent fallback block, skipping messages already present (dedup by role + content)
v0.1.9 (recording modes + token rename)
Added
sessions.mode(wilddefault /exact, migration 007) surfaced to the agent; token rename via PATCH; auto-distinct token names on creation
v0.1.10 (message correction)
Human admin may edit/delete messages (AI stays append-only); audited
message.edited/message.deleted
v0.1.15 (compaction rendering + mixed ingest + hardening)
Compactions rendered as first-class content;
<PCP_INGEST>/ mixed ingest records messages + a compaction; shared config validator; API doc-drift test