Core Architecture Regulatory Mapping

How to Map LLC vs C-Corp Filing Requirements Across 50 States

This guide sits under the Entity Taxonomy & Classification cluster within the broader Core Architecture & Regulatory Mapping framework, and it owns one narrow task: turning the LLC-versus-C-Corp distinction into a fifty-state rule set that a pipeline can evaluate deterministically.

Scope

This page covers how to parameterize the LLC and C-Corp annual obligation per jurisdiction as versioned data — fee formula, deadline anchor, report-required flag — and how to resolve the correct rule at runtime against an entity’s formation state and type. It deliberately excludes the classification step that decides whether a record is an LLC or C-Corp in the first place (that is the parent cluster’s job), the dated-deadline computation that turns an obligation into a due date (covered by State Filing Deadline Calendars), and the portal submission mechanics (covered by Secretary of State Portal & API Ingestion). The output here is a resolved obligation plus an audit record, nothing more.

The Constraint Driving This Task

The reason this cannot be a flat lookup table is that the same jurisdiction imposes structurally different obligations on an LLC versus a C-Corp, and the formulas change mid-cycle by statute. A Delaware C-Corp owes an annual report and franchise tax under DGCL § 502, computed by either authorized-shares or assumed-par-value methodology; a Delaware LLC owes a flat $300 tax under 6 Del. C. § 18-1107 and files no annual report at all. California requires a Statement of Information from an LLC under Cal. Corp. Code § 17702.09 (biennial) and from a stock corporation under § 1502 (annual). A rule set that keys only on state_code will mis-route a quarter of a typical portfolio. The obligation must therefore be a function of (state_code, entity_type, effective_date), and each rule must carry a version so a legislative amendment becomes a new versioned payload rather than an in-place mutation.

Resolving (state_code, entity_type, effective_date) to one versioned ComplianceRule An entity tuple is resolved on a compound key and an effective-date window. An unmapped pair routes to review; a match enters the Delaware fork, where an LLC routes to a flat tax with no report and a C-Corp routes to an annual report plus franchise tax. Both converge into one versioned, hash-chained obligation. obligation = f(state_code, entity_type, effective_date) — never f(state_code) alone resolve( state_code, entity_type, effective_date ) e.g. ( "DE", LLC, 2026-06-28 ) ComplianceRuleEngine.resolve() · compound key (state_code, entity_type) pick the rule whose effective_from window covers effective_date LLC and C-Corp rules are independently versioned, never collide key absent no AuditEntry → legal-ops review match → Delaware fork: obligation differs by entity_type DE · LLC ComplianceRule · version v2.1 report_required = False fee_formula = "flat_300" Flat $300 tax · no annual report 6 Del. C. § 18-1107 DE · C-CORP ComplianceRule · version v2.1 report_required = True fee_formula = "assumed_par_value" Annual report + franchise tax DGCL § 502 single resolved ComplianceRule → hash-chained AuditEntry version-stamped · append-only · reconstructable under audit

Prerequisites

  • Python 3.10+ (uses match-friendly typing, dataclass(frozen=True), datetime.timezone)
  • requests for portal calls; urllib3 retry adapters ship with it
  • An entity source already classified to one entity_type (see the parent cluster)
  • Read access to each target portal’s status endpoint, plus OAuth2 client credentials where the state requires them (California, see table below)
  • A durable sink for audit records — disk-backed SQLite, Redis, or object storage for newline-delimited JSON

Implementation

The module below loads versioned per-jurisdiction rules, resolves the correct ComplianceRule for each classified entity, fetches live status through a circuit-breaking portal client, and emits a hash-chained AuditEntry. Entities stream in bounded chunks so heap usage stays flat regardless of portfolio size. Inline comments flag the compliance-critical lines.

import hashlib
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from itertools import islice
from typing import Any, Dict, Generator, List, Optional

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

# Structured, JSON-friendly logging so audit context survives log shipping.
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
    datefmt="%Y-%m-%dT%H:%M:%SZ",
)
logger = logging.getLogger("compliance.engine")


class EntityType(str, Enum):
    LLC = "LLC"
    CCORP = "C-CORP"


@dataclass(frozen=True)
class ComplianceRule:
    # Obligation is keyed on (state, type, effective window) — never on state alone.
    state_code: str
    entity_type: EntityType
    report_required: bool          # Delaware LLCs are False; C-Corps are True.
    deadline_month: int
    deadline_day: int
    fee_formula: str               # e.g. "flat_300" vs "assumed_par_value".
    version: str                   # Bumped on every statutory amendment.
    effective_from: datetime


@dataclass
class AuditEntry:
    entity_id: str
    state_code: str
    entity_type: EntityType
    rule_version: str
    evaluated_at: datetime
    status: str
    previous_hash: str
    current_hash: str = field(init=False)

    def __post_init__(self) -> None:
        # Forward-chain each record into the previous hash for tamper evidence.
        payload = (
            f"{self.entity_id}:{self.state_code}:{self.entity_type.value}:"
            f"{self.rule_version}:{self.status}:{self.previous_hash}"
        )
        self.current_hash = hashlib.sha256(payload.encode()).hexdigest()


class PortalClient:
    """Circuit-breaking client for a single Secretary of State portal."""

    def __init__(self, base_url: str, timeout: float = 10.0, threshold: int = 5):
        self.base_url = base_url.rstrip("/")
        self.session = requests.Session()
        self.session.mount("https://", HTTPAdapter(max_retries=Retry(
            total=3, backoff_factor=0.5, status_forcelist=[429, 500, 502, 503, 504]
        )))
        self.timeout = timeout
        self._circuit_open = False
        self._failure_count = 0
        self._threshold = threshold

    def _check_circuit(self) -> bool:
        if self._circuit_open:
            logger.warning("Circuit open for %s; using cached compliance state.", self.base_url)
            return False
        return True

    def _record_failure(self) -> None:
        self._failure_count += 1
        if self._failure_count >= self._threshold:
            self._circuit_open = True
            logger.error("Circuit breaker tripped for %s", self.base_url)

    def _reset_circuit(self) -> None:
        self._failure_count = 0
        self._circuit_open = False

    def fetch_entity_status(
        self, entity_id: str, auth_token: Optional[str] = None
    ) -> Dict[str, Any]:
        if not self._check_circuit():
            # Fallback path: never block the run on a dead portal.
            return {"status": "cached", "entity_id": entity_id, "fallback": True}

        headers = {"Authorization": f"Bearer {auth_token}"} if auth_token else {}
        try:
            resp = self.session.get(
                f"{self.base_url}/api/v1/entities/{entity_id}",
                headers=headers,
                timeout=self.timeout,
            )
            resp.raise_for_status()
            self._reset_circuit()
            return resp.json()
        except requests.exceptions.RequestException as exc:
            self._record_failure()
            logger.error("Portal fetch failed for %s: %s", entity_id, exc)
            return {"status": "error", "entity_id": entity_id, "fallback": True, "error": str(exc)}


class ComplianceRuleEngine:
    def __init__(self, rules: List[ComplianceRule]):
        # Compound key guarantees the LLC and C-Corp rules never collide.
        self.rules: Dict[tuple[str, EntityType], ComplianceRule] = {
            (r.state_code, r.entity_type): r for r in rules
        }
        self.audit_trail: List[AuditEntry] = []
        self._last_hash = "genesis"

    def resolve(self, state_code: str, entity_type: EntityType) -> Optional[ComplianceRule]:
        return self.rules.get((state_code, entity_type))

    def evaluate_chunk(
        self, entities: List[Dict[str, Any]], portal: PortalClient
    ) -> Generator[AuditEntry, None, None]:
        for entity in entities:
            eid = entity["id"]
            state = entity["state_code"]
            etype = EntityType(entity["type"])
            rule = self.resolve(state, etype)

            if rule is None:
                # Halt rather than guess: an unmapped (state, type) is a data gap.
                logger.warning("No rule for %s/%s on %s; sent to review.", state, etype.value, eid)
                continue

            portal_data = portal.fetch_entity_status(eid)
            status = "compliant" if portal_data.get("status") == "active" else "pending_review"

            entry = AuditEntry(
                entity_id=eid,
                state_code=state,
                entity_type=etype,
                rule_version=rule.version,
                evaluated_at=datetime.now(timezone.utc),
                status=status,
                previous_hash=self._last_hash,
            )
            self._last_hash = entry.current_hash
            self.audit_trail.append(entry)
            logger.info(
                "Evaluated %s | %s/%s | %s | hash=%s",
                eid, state, etype.value, status, entry.current_hash,
            )
            yield entry


def stream_entities(
    entity_source: List[Dict[str, Any]], chunk_size: int = 500
) -> Generator[List[Dict[str, Any]], None, None]:
    # islice keeps memory bounded regardless of portfolio size.
    iterator = iter(entity_source)
    while True:
        chunk = list(islice(iterator, chunk_size))
        if not chunk:
            break
        yield chunk


if __name__ == "__main__":
    active_rules = [
        ComplianceRule("DE", EntityType.LLC, False, 6, 1, "flat_300", "v2.1",
                       datetime(2024, 1, 1, tzinfo=timezone.utc)),
        ComplianceRule("DE", EntityType.CCORP, True, 3, 1, "assumed_par_value", "v2.1",
                       datetime(2024, 1, 1, tzinfo=timezone.utc)),
    ]
    engine = ComplianceRuleEngine(active_rules)
    client = PortalClient("https://portal.example-state.gov")

    sample_entities = [
        {"id": "ENT-001", "state_code": "DE", "type": "LLC"},
        {"id": "ENT-002", "state_code": "DE", "type": "C-CORP"},
    ]

    for chunk in stream_entities(sample_entities, chunk_size=500):
        for audit in engine.evaluate_chunk(chunk, client):
            pass  # Persist each entry to the immutable NDJSON sink here.

Jurisdiction Obligation Reference

The mapping below is the data the engine loads, not code. Each row is one (state_code, entity_type) rule; production keeps a row per state per type, versioned with an effective_from window.

State LLC obligation C-Corp obligation Statute / portal note
Delaware Flat $300 tax, no annual report Annual report + franchise tax 6 Del. C. § 18-1107 (LLC); DGCL § 502 (C-Corp)
California Statement of Information, biennial Statement of Information, annual § 17702.09 (LLC); § 1502 (C-Corp); BizFile portal
New York Biennial statement Biennial statement N.Y. Bus. Corp. Law § 408; JSESSIONID session expires after 15 min
Texas No annual report; Public Information Report with franchise tax Public Information Report + franchise tax Tex. Tax Code § 171; Comptroller, not SOS

Configuration Reference

Parameter Default Legal / operational justification
chunk_size 500 Bounds heap so a 100k-entity portfolio streams without exhausting memory
PortalClient.timeout 10.0s Below most portals’ gateway timeout; surfaces a hung session fast
Retry.total 3 Tolerates transient 5xx without masking a hard outage
Retry.backoff_factor 0.5 Yields 0.5s/1s/2s spacing — inside typical Retry-After windows
threshold (breaker) 5 Trips after a sustained failure run, not on one-off blips
effective_from per rule Anchors the rule to the statutory window; a mid-cycle amendment ships as a new version, never an in-place edit

Failure Modes and Fallback Routing

These map to the same buckets defined in the parent pillar’s Error Categorization & Retry Logic; resolve every portal failure into one of them before deciding how to fall back.

  • Unmapped (state_code, entity_type)resolve() returns None. This is a data gap, not a transient error: the engine logs and skips rather than guessing, routing the record to legal-ops review so the missing rule can be added and re-run.
  • Rule version / effective-date drift — the resolved rule’s effective_from does not cover the current fiscal period, producing silent deadline drift. Treat as a configuration fault: block evaluation for that state, alert, and reload the versioned payload before continuing.
  • Transient portal failure (5xx / timeout) — handled by the Retry adapter; if failures accumulate past threshold, the breaker opens and fetch_entity_status returns a fallback record from cache so the batch completes. Re-evaluate breaker-fallback entries on the next run.
  • Hard portal block (403 / 429) — distinguish from 5xx. For 429, honor Retry-After and back off; for 403, rotate the OAuth2 credential (California) or fail the entity to review rather than hammering the portal into a longer block.

Audit Trail Integrity

Each AuditEntry.current_hash folds in the previous record’s hash, giving an append-only chain. Serialize entries to newline-delimited JSON and never mutate them: if a status changes after evaluation, append a new entry linked by previous_hash. Verifying the chain is a sequential walk asserting each record’s previous_hash equals the prior current_hash; any break signals tampering or a concurrent-write collision, and the affected range is quarantined and regenerated from the last verified checkpoint.

FAQ

Why key the rule map on a (state, entity_type) tuple instead of nesting by state?

Because the obligation genuinely differs by type within one state — a Delaware LLC and a Delaware C-Corp share nothing but the jurisdiction. A flat state -> rule dict forces a default that silently mis-routes one type. The compound key makes the LLC and C-Corp rules first-class and independently versionable, so an amendment to one never touches the other.

How do I roll out a mid-cycle franchise-tax amendment without redeploying the pipeline?

Ship the amended rule as a new ComplianceRule with a bumped version and an effective_from set to the amendment date, then load it into the engine’s rule list. Resolution picks the rule whose effective window covers the evaluation date. The code never changes; only the versioned data does, which is what keeps the system reconstructable under audit.

What happens to entities evaluated while a portal's circuit breaker was open?

They get an AuditEntry built from a fallback cached status rather than a live read, and the entry still hash-chains normally. Those records are flagged by the fallback field on the portal response, so a follow-up run can re-evaluate exactly the entities that were served from cache once the breaker resets.

Does this module decide whether a record is an LLC or a C-Corp?

No. It assumes the entity_type is already resolved upstream. The decision of which class a raw record belongs to is the job of the Entity Taxonomy & Classification stage; this page only maps an already-classified entity to its per-jurisdiction obligation.