Building a Secure Entity Registry Database Schema
This guide is part of the Security & Data Boundaries layer within the broader Core Architecture & Regulatory Mapping framework, and it answers one concrete engineering question: how do you lay out the tables, constraints, and access policies for a corporate entity registry so that public filing metadata stays queryable by automation while beneficial-ownership and tax data stay cryptographically isolated at the database layer?
Scope
This page is the data-definition reference for the registry. It covers the relational layout for entity identity, jurisdictional compliance mapping, beneficial ownership, and an append-only audit trail; the PostgreSQL row-level security (RLS) policies that partition them; the cryptographic hash chain that makes the audit log tamper-evident; and the Python service contract that reads and writes the registry. It deliberately excludes the upstream portal-ingestion transport logic, the deadline-calendar arithmetic, and the entity-classification taxonomy itself — those are owned by adjacent pages and only referenced here where the schema has to honor their contracts. The goal is a schema you can CREATE and harden, not a full application.
Statutory Constraint Driving This Schema
The registry has to satisfy two obligations that pull in opposite directions. Baseline recordkeeping under the Model Business Corporation Act § 16.01 and Delaware General Corporation Law § 142 requires accurate, retrievable records of directors, officers, registered agents, and principal offices — data the filing automation must be able to read on demand. At the same time, the FinCEN Corporate Transparency Act beneficial-ownership reporting rule (31 CFR § 1010.380) requires that beneficial-ownership information (BOI) be collected and stored with elevated protection and strict access limitation, separately from routine public metadata. That tension is the whole design problem: a single flat table that holds both an entity’s public status and its owners’ dates of birth cannot satisfy both regimes at once. The schema below resolves it by making the data boundary a structural property of the tables and their RLS policies rather than a convention the application is trusted to follow.
Prerequisites
- PostgreSQL 14+ with the
pgcryptoanduuid-osspextensions available (managed Postgres on RDS/Cloud SQL ships both). - Python 3.10+ for the integration layer (
match,X | Noneunions, typedEnum). asyncpg0.29+, Pydantic v2,structlog,tenacity, and a Redis client for cache invalidation.- A managed secrets store supplying the encryption keys and per-stage database role credentials — never embed them in DDL or application code.
- Database roles provisioned per stage (a read-only compliance-automation role, a privileged BOI role) so least privilege is enforced before any policy runs.
Implementation
The schema separates concerns across four domains: core entity identity, jurisdictional compliance mapping, ownership / beneficial control, and an immutable audit trail. The DDL below establishes a hardened PostgreSQL foundation with cryptographic hashing, append-only constraints, and explicit RLS policies. The entity-identity table normalizes against the same classification model defined in Entity Taxonomy & Classification, and the compliance-profile table carries the deadline fields consumed by the State Filing Deadline Calendars.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Core entity identity (immutable public metadata)
CREATE TABLE entities (
entity_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
legal_name TEXT NOT NULL,
jurisdiction_code CHAR(2) NOT NULL,
formation_date DATE NOT NULL,
status VARCHAR(20) NOT NULL CHECK (status IN ('ACTIVE', 'DISSOLVED', 'MERGED', 'SUSPENDED')),
registered_agent_name TEXT,
registered_agent_address TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Jurisdictional compliance mapping (filing deadlines, tax IDs, state portals)
CREATE TABLE compliance_profiles (
profile_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_id UUID NOT NULL REFERENCES entities(entity_id) ON DELETE CASCADE,
state_tax_id_encrypted BYTEA NOT NULL,
annual_filing_due_month INT CHECK (annual_filing_due_month BETWEEN 1 AND 12),
franchise_tax_amount NUMERIC(10,2),
next_filing_deadline DATE GENERATED ALWAYS AS (
CASE WHEN EXTRACT(MONTH FROM CURRENT_DATE) > annual_filing_due_month
THEN MAKE_DATE(EXTRACT(YEAR FROM CURRENT_DATE)::INT + 1, annual_filing_due_month, 1)
ELSE MAKE_DATE(EXTRACT(YEAR FROM CURRENT_DATE)::INT, annual_filing_due_month, 1)
END
) STORED,
portal_endpoint_url TEXT,
CONSTRAINT unique_entity_profile UNIQUE (entity_id)
);
-- Beneficial ownership (FinCEN CTA compliant, strictly isolated)
CREATE TABLE beneficial_owners (
owner_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_id UUID NOT NULL REFERENCES entities(entity_id) ON DELETE CASCADE,
fincen_id_encrypted BYTEA NOT NULL,
full_name_encrypted BYTEA NOT NULL,
dob_encrypted BYTEA NOT NULL,
ownership_pct DECIMAL(5,2) CHECK (ownership_pct > 0 AND ownership_pct <= 100),
control_type VARCHAR(30) CHECK (control_type IN ('SUBSTANTIAL_CONTROL', 'EQUITY_OWNER', 'SENIOR_OFFICER')),
reported_at TIMESTAMPTZ DEFAULT NOW()
);
-- Immutable audit trail (append-only, cryptographically chained)
-- prev_log_hash references payload_hash, not log_id, to support chain verification.
-- BYTEA foreign key requires a unique index on audit_log(payload_hash).
CREATE TABLE audit_log (
log_id BIGSERIAL PRIMARY KEY,
entity_id UUID REFERENCES entities(entity_id),
action VARCHAR(30) NOT NULL,
actor_role VARCHAR(30) NOT NULL,
payload_hash BYTEA NOT NULL,
prev_log_hash BYTEA,
recorded_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE UNIQUE INDEX ON audit_log(payload_hash);
-- Row-Level Security Policies
ALTER TABLE entities ENABLE ROW LEVEL SECURITY;
ALTER TABLE compliance_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE beneficial_owners ENABLE ROW LEVEL SECURITY;
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
-- Compliance automation role (read-only public metadata + deadlines)
CREATE POLICY compliance_read ON entities FOR SELECT USING (true);
CREATE POLICY compliance_read_profiles ON compliance_profiles FOR SELECT USING (true);
-- BOI role (strictly isolated, requires explicit session context)
CREATE POLICY boi_access ON beneficial_owners
FOR ALL USING (current_setting('app.boi_authorized', true)::boolean = true);
CREATE POLICY boi_insert ON beneficial_owners
FOR INSERT WITH CHECK (current_setting('app.boi_authorized', true)::boolean = true);
-- Audit append-only enforcement
CREATE POLICY audit_append ON audit_log FOR INSERT WITH CHECK (true);
CREATE POLICY audit_read ON audit_log FOR SELECT USING (true);
The boi_access policy compares against the boolean literal true, not the string 'true'. The cast ::boolean = 'true' is valid in PostgreSQL but comparing to a boolean literal avoids implicit string coercion. The isolation guarantee is structural: the read-only compliance-automation role is never granted the app.boi_authorized session variable, so it can SELECT from entities and compliance_profiles but every beneficial_owners row is invisible to it.
Immutable audit trail and cryptographic hashing
Compliance regulators require non-repudiable change tracking. The audit_log table implements a cryptographic chain where each record’s prev_log_hash references the SHA-256 digest of the preceding row, so silent deletion or modification breaks the chain. A BEFORE INSERT trigger enforces this at the engine level rather than trusting the application to populate the hash:
CREATE OR REPLACE FUNCTION enforce_audit_chain() RETURNS TRIGGER AS $$
DECLARE
prev_hash BYTEA;
BEGIN
SELECT payload_hash INTO prev_hash FROM audit_log ORDER BY log_id DESC LIMIT 1;
NEW.prev_log_hash := prev_hash;
NEW.payload_hash := digest(
NEW.entity_id::TEXT || NEW.action || NEW.actor_role || NEW.recorded_at::TEXT,
'sha256'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_audit_chain
BEFORE INSERT ON audit_log
FOR EACH ROW EXECUTE FUNCTION enforce_audit_chain();
digest() requires the pgcrypto extension (installed above). The explicit ::TEXT casts on entity_id and recorded_at ensure consistent byte-level hashing regardless of the column’s native type — the same determinism requirement that the boundary tokens in the parent Security & Data Boundaries layer depend on.
Python integration layer
Automation services interact with the registry through a type-hinted, fault-tolerant client that enforces structured logging, cache invalidation, and explicit fallback chains for state portal submissions. The retry decorator below applies the same exponential-backoff posture documented in async polling and rate limiting, and the field types it reads match the Compliance Metadata Schemas definitions.
import hashlib
import json
import os
from typing import Dict, Any
from datetime import date
from enum import Enum
import asyncpg
import structlog
from pydantic import BaseModel, Field
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
logger = structlog.get_logger()
class FilingStatus(str, Enum):
PENDING = "PENDING"
SUBMITTED = "SUBMITTED"
REJECTED = "REJECTED"
COMPLETED = "COMPLETED"
class FilingPayload(BaseModel):
entity_id: str
jurisdiction: str
filing_type: str
due_date: date
portal_endpoint: str
idempotency_key: str = Field(
default_factory=lambda: hashlib.sha256(os.urandom(32)).hexdigest()
)
class ComplianceService:
def __init__(self, db_pool: asyncpg.Pool, redis_client: Any):
self.db = db_pool
self.redis = redis_client
self.logger = structlog.get_logger().bind(service="compliance_automation")
async def _invalidate_cache(self, entity_id: str) -> None:
"""Explicit cache invalidation via Redis pub/sub and key deletion."""
await self.redis.delete(f"entity:calendar:{entity_id}")
await self.redis.publish(
"entity:updates",
json.dumps({"entity_id": entity_id, "action": "invalidate"}),
)
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type((ConnectionError, TimeoutError)),
reraise=True,
)
async def submit_to_state_portal(self, payload: FilingPayload) -> Dict[str, Any]:
"""Portal submission: idempotency header, ACTIVE-status gate, JSON/XML fallback."""
self.logger.info(
"submitting_to_portal",
entity_id=payload.entity_id,
endpoint=payload.portal_endpoint,
)
try:
async with self.db.acquire() as conn:
# Reads only public metadata — never touches beneficial_owners.
status = await conn.fetchval(
"SELECT status FROM entities WHERE entity_id = $1", payload.entity_id
)
if status != "ACTIVE":
raise ValueError(
f"Entity {payload.entity_id} is {status}. Submission blocked."
)
response = await self._http_post(
url=payload.portal_endpoint,
headers={
"Idempotency-Key": payload.idempotency_key,
"Content-Type": "application/json",
},
json=payload.model_dump(mode="json"),
)
return response
except Exception as e:
self.logger.warning("primary_portal_failed", error=str(e))
return await self._fallback_submission(payload)
async def _fallback_submission(self, payload: FilingPayload) -> Dict[str, Any]:
"""Fallback chain for portal rate limits or deprecated endpoints."""
self.logger.info("invoking_fallback_chain", entity_id=payload.entity_id)
try:
return await self._http_post_xml(
url=f"{payload.portal_endpoint}/legacy",
payload=payload,
)
except Exception as xml_err:
self.logger.error("xml_fallback_failed", error=str(xml_err))
await self.redis.lpush(
"manual_filing_queue",
json.dumps(payload.model_dump(mode="json")),
)
return {"status": "QUEUED_MANUAL", "reason": "PORTAL_UNAVAILABLE"}
async def process_annual_calendar(self) -> None:
"""High-throughput deadline scanner with cache-aware querying."""
async with self.db.acquire() as conn:
rows = await conn.fetch("""
SELECT e.entity_id, e.jurisdiction_code,
cp.next_filing_deadline, cp.portal_endpoint_url
FROM compliance_profiles cp
JOIN entities e ON cp.entity_id = e.entity_id
WHERE e.status = 'ACTIVE'
AND cp.next_filing_deadline <= CURRENT_DATE + INTERVAL '30 days'
""")
for row in rows:
cache_key = f"entity:calendar:{row['entity_id']}"
if await self.redis.exists(cache_key):
continue # Already processed or cached this cycle.
filing = FilingPayload(
entity_id=str(row["entity_id"]),
jurisdiction=row["jurisdiction_code"],
filing_type="ANNUAL_REPORT",
due_date=row["next_filing_deadline"],
portal_endpoint=row["portal_endpoint_url"],
)
await self.submit_to_state_portal(filing)
await self._invalidate_cache(str(row["entity_id"]))
async def _http_post(self, url: str, headers: dict, json: dict) -> dict:
# Production: use aiohttp or httpx with connection pooling.
raise NotImplementedError
async def _http_post_xml(self, url: str, payload: FilingPayload) -> dict:
# Production: serialize to jurisdiction-specific XML schema.
raise NotImplementedError
payload.model_dump(mode="json") ensures date objects serialize to ISO 8601 strings before json.dumps or queue serialization. The stub methods _http_post and _http_post_xml are explicit rather than silently absent, so the contract is obvious to anyone wiring in a real transport.
Configuration Reference
| Parameter | Typical value | Legal / operational justification |
|---|---|---|
app.boi_authorized |
false for automation, true only for the BOI role |
31 CFR § 1010.380 requires strict, separately-authorized access to beneficial-ownership data; the default-deny session variable is the access gate. |
SET LOCAL vs SET SESSION |
SET LOCAL |
Scopes the BOI privilege to a single transaction so a pooled connection cannot leak authorization to the next caller. |
state_tax_id_encrypted / *_encrypted columns |
BYTEA, AES-256-GCM |
State tax IDs and owner PII are encrypted at rest; ciphertext columns are unreadable to the compliance-automation role even if RLS is bypassed. |
next_filing_deadline window |
CURRENT_DATE + INTERVAL '30 days' |
A 30-day look-ahead covers the shortest common annual-report grace period while leaving slack for portal-outage retries. |
stop_after_attempt |
3 |
Bounds portal retries so one unreachable Secretary of State endpoint cannot stall the batch; exhaustion routes to the manual queue. |
wait_exponential(min=2, max=10) |
2s → 10s | Respects state-portal rate limits (HTTP 429) without exceeding session-timeout windows on slow portals like California BizFile. |
audit_log write mode |
append-only (INSERT-only policy) | MBCA § 16.01 retrievability plus non-repudiation; updates and deletes are blocked so the hash chain stays verifiable. |
Failure Modes and Fallback Routing
These map onto the same four-class boundary taxonomy that the parent Security & Data Boundaries page defines and that the portal-layer error categorization and retry logic consumes downstream.
1. RLS false denial or bypass (SCHEMA_VIOLATION class). Symptom: 42501: permission denied for table beneficial_owners, or — worse — ownership data appearing in an automation query. Handling: confirm the session context with SHOW app.boi_authorized;, inspect SELECT * FROM pg_policies WHERE tablename = 'beneficial_owners';, and set authorization with SET LOCAL app.boi_authorized = 'true'; (prefer SET LOCAL so the grant dies with the transaction). Cross-reference audit_log for action='RLS_VIOLATION' against service-account IP ranges.
2. Cache staleness / deadline drift (STATUTORY_DEADLINE class). Symptom: filings submitted past the state cutoff despite correct DB records. Handling: check redis-cli TTL entity:calendar:<UUID>, verify LISTEN/NOTIFY propagation with SELECT pg_notify('entity:updates', '{"action":"invalidate"}');, force a flush through _invalidate_cache(), and re-validate the next_filing_deadline generated column against jurisdiction-specific grace periods held in the deadline calendars.
3. Portal rate limit / idempotency failure (TRANSPORT_CIRCUIT_BREAKER class). Symptom: HTTP 429 or duplicate-submission rejection. Handling: confirm the Idempotency-Key header is SHA-256 hashed and persisted per filing cycle, let the tenacity backoff absorb transient limits, and when X-RateLimit-Remaining < 5 route to _fallback_submission() and then the manual queue. Validate the XML/JSON schema version against current Secretary of State documentation — many jurisdictions silently reject v1 payloads after a v2 rollout.
4. Audit chain hash mismatch (JURISDICTIONAL_BOUNDARY / integrity class). Symptom: the scanner flags a prev_log_hash discontinuity. Handling: run an integrity scan and never repair in place:
SELECT log_id, payload_hash, prev_log_hash,
LAG(payload_hash) OVER (ORDER BY log_id) AS expected_prev
FROM audit_log
WHERE prev_log_hash IS DISTINCT FROM LAG(payload_hash) OVER (ORDER BY log_id);
Isolate the affected rows; if the mismatch originates in the application layer, patch the hashing function to match digest(entity_id::TEXT || action || actor_role || recorded_at::TEXT, 'sha256'). Rebuild the chain from the last verified checkpoint by inserting corrective action='CHAIN_REPAIR' records — never UPDATE existing audit_log rows.
Production Readiness Checklist
Frequently Asked Questions
Why isolate beneficial ownership in a separate table instead of nullable columns on `entities`?
Because the data boundary has to be enforceable, not advisory. A separate beneficial_owners table can carry its own RLS policy keyed to app.boi_authorized, so the read-only automation role is structurally unable to SELECT ownership rows. Nullable columns on entities would share that table’s permissive read policy, meaning any query against entity metadata could surface BOI — exactly the leakage 31 CFR § 1010.380 prohibits. Isolation by table makes least privilege a property of the schema rather than the application.
Should the audit chain be enforced in a trigger or in the application?
In the trigger. A BEFORE INSERT trigger computes payload_hash and prev_log_hash inside the database transaction, so the chain is correct even if a buggy or compromised service writes the row. Computing the hash in the application means the integrity guarantee is only as trustworthy as every client that ever writes the log. Keep the digest inputs limited to persisted columns so the chain is reproducible from stored data alone during verification.
How do I store the encrypted columns without leaking keys into the schema?
The *_encrypted columns are plain BYTEA — the schema holds ciphertext only, never keys. Encryption and decryption happen in the application using AES-256-GCM with a key pulled from a managed secrets store at runtime. If you use pgcrypto’s pgp_sym_encrypt inside the database instead, pass the key as a SET LOCAL session parameter rather than a literal, and ensure it never appears in pg_stat_statements or query logs.
Why is `next_filing_deadline` a generated column instead of an application calculation?
A GENERATED ALWAYS AS ... STORED column guarantees the deadline is computed once, consistently, and is queryable with an index — the calendar scanner can filter WHERE next_filing_deadline <= CURRENT_DATE + INTERVAL '30 days' without recomputing per row. Computing it in the application risks drift between services that each implement the date logic slightly differently. For jurisdictions with rolling or anniversary-based windows, override annual_filing_due_month per entity rather than special-casing the formula in code.