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.
Prerequisites
- Python 3.10+ (uses
match-friendly typing,dataclass(frozen=True),datetime.timezone) requestsfor portal calls;urllib3retry 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()returnsNone. 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_fromdoes 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
Retryadapter; if failures accumulate pastthreshold, the breaker opens andfetch_entity_statusreturns afallbackrecord 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-Afterand 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.