Core Architecture Regulatory Mapping

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 pgcrypto and uuid-ossp extensions available (managed Postgres on RDS/Cloud SQL ships both).
  • Python 3.10+ for the integration layer (match, X | None unions, typed Enum).
  • asyncpg 0.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.

Three trust zones partition one entity registry The entities table is the shared public identity. A one-to-one link reaches compliance_profiles in the PUBLIC zone (readable by automation). A one-to-many link reaches beneficial_owners in the ISOLATED-BOI zone, gated by the app.boi_authorized session variable and storing PII as encrypted BYTEA so the automation role cannot read it. A one-to-many link reaches audit_log in the APPEND-ONLY zone, which is INSERT-only and hash-chained for tamper evidence. one shared identity, three RLS-enforced trust zones entities entity_id PK · uuid legal_name · jurisdiction_code status · registered_agent immutable public metadata 1 : 1 1 : N 1 : N compliance_profiles PUBLIC zone · queryable by automation entity_id FK next_filing_deadline (generated) portal_endpoint_url state_tax_id_encrypted · BYTEA policy compliance_read FOR SELECT USING (true) beneficial_owners ISOLATED-BOI · 31 CFR § 1010.380 entity_id FK full_name_encrypted · dob_encrypted fincen_id_encrypted · BYTEA ownership_pct · control_type policy boi_access USING app.boi_authorized = true audit_log APPEND-ONLY · hash-chained log_id PK · bigserial payload_hash · sha256 prev_log_hash → prior row action · actor_role · recorded_at policy audit_append INSERT only · no UPDATE / DELETE
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.