Data model
The tables, how they relate, and the structural rule about where an amount is allowed to exist.
One PostgreSQL database, nine migrations. Every tenant-scoped table carries an organization id, and every query in the data layer requires a tenant scope.
#By migration
#0001 Tenancy and people
organizations · workspaces · environments · users · members ·
roles · role_permissions · member_roles · sessions
The hierarchy everything else hangs off. environments holds gateway_mode,
which is why promoting to ENFORCE is a tenancy operation.
#0002 Agents
agents · agent_identities · agent_passports ·
agent_capability_grants · capabilities
agent_identities carries the public key and a generation counter. Rotation
bumps the generation, and an assertion signed with a superseded key is refused
with IDENTITY_GENERATION_STALE rather than a generic failure — so the agent's
logs say what actually happened.
#0003 Tools and credentials
tools · tool_agent_authorizations · resources · credentials ·
credential_leases
tool_agent_authorizations is the join that makes registration grant nothing.
A tool existing is not a tool being callable.
credentials holds sealed material in a column the repository layer does not
select into API responses. credential_leases records what was issued, by
reference, never by value.
#0004 Policy and delegation
policies · policy_versions · policy_bindings · policy_test_cases ·
delegations · delegation_constraints
policy_versions stores the rule set as JSON, and that JSON is the policy —
not a cache of something else. It is what the visual builder reads and writes.
policy_bindings carries the scope: organization, workspace, environment, agent
or tool.
#0005 Risk, approval and trust
risk_configurations · risk_events · risk_signals · approvals ·
approval_decisions · trust_evidence · trust_providers
risk_signals stores each signal with its weight and its human-readable
explanation. Storing the explanation rather than regenerating it means a
six-month-old decision explains itself the same way it did at the time.
approvals is bound to the request that created it, which is what stops an
approval being replayed against a different payload.
#0006 Actions and audit
actions · action_stage_results · audit_events
The two tables that grow without bound.
action_stage_results is one row per pipeline stage, which is what makes a
decision decomposable after the fact.
audit_events carries the hash chain: each row's hash covers its content and
the previous row's hash, keyed with VEEFORE_AUDIT_CHAIN_SECRET.
#0007 Security and protocols
incidents · emergency_actions · mcp_servers · mcp_tools ·
a2a_connections · registry_entries
emergency_actions stores the blast radius recomputed at execution time, so
the evidence shows what was actually affected rather than what a preview
predicted some minutes earlier.
mcp_tools carries an authorized flag per tool, separate from discovery.
#0008 Platform
webhook_subscriptions · webhook_deliveries · platform_events ·
api_keys · usage_records · plans · subscriptions ·
sso_connections · scim_configurations · network_rules · jobs
api_keys stores a hash. The plaintext is returned once at creation and never
again.
jobs is the queue. Claiming is tenant-safe and guarded against overlapping
poll ticks; abandoned jobs are reclaimed and dead-lettered after repeated
failure.
#0009 Row-level security
No new tables. Applies PostgreSQL RLS policies to every tenant-scoped table when
DATABASE_ENABLE_RLS=true.
Two independent isolation layers
The application layer requires a tenant scope on every query and refuses without one. RLS exists so a bug in that layer does not become a cross-tenant data leak.
Either alone would be sufficient. The point of defence in depth is that you do not have to be confident the first layer is perfect. → Tenant isolation
#The amount rule
This is enforced structurally by npm run scope:audit, not just documented.
No table may have a balance column. The audit asserts that no migration
declares balance, available_balance, ledger_balance or funds. A wallet
could be written without the word "wallet"; it could not be written without
somewhere to put a balance.
An amount column may only exist on a policy or decision table. The audit
parses every CREATE TABLE, finds those with an amount column, and fails if
any is not on this list:
| Table | Role of the amount |
|---|---|
actions |
What the agent asked to do. Decision context |
approvals |
What a human approved. Decision context |
delegations |
The ceiling granted. A policy input |
delegation_constraints |
A narrower ceiling. A policy input |
policy_versions |
A threshold in a rule. A policy input |
policy_test_cases |
A test input |
risk_events |
What was assessed. Decision context |
plans · subscriptions · usage_records |
Veefore's own SaaS pricing |
An amount column appearing anywhere else fails the build with the table named. That is what makes the exclusion structural rather than aspirational. → Scope
#Identifiers
ULID-based with a type prefix, so ids are sortable by creation time and self-describing. Full table in Conventions.
An id is not a credential. Ids appear in URLs, logs and error messages; acting as an agent requires its private key.
#Append-only in practice
actions and audit_events are only ever inserted. Nothing in the application
updates or deletes a row in either, outside the retention process — which
requires audit:configure_retention, is step-up gated, and writes a CRITICAL
audit event.
That is what makes the hash chain meaningful. A chain over a mutable table would be a chain over something the application itself rewrites.
#Growth and retention
Both unbounded tables grow roughly in proportion to protected action volume.
Export before shortening retention. The export is watermarked and hashed, so the archive stays verifiable. → Audit and export
#The schema catalogue
packages/db/src/schema-catalog.ts describes every table and column in one
place, used for validation and for the console's introspection. If you want the
column-level truth rather than this summary, read it — it is the source the
repositories are generated against.