GRIDIRON LEGEND
Engineering Specification — Part 3: API Architecture
Logical interfaces between domains. No transport. No REST, GraphQL, gRPC, HTTP, or JSON — those are implementation choices made later without altering this contract layer.
Governing documents: every interface below is built against Part 1 (Engineering Blueprint)'s domains and Part 2 (Database Architecture)'s aggregate roots and repositories. Nothing here is a new gameplay mechanic — this document defines how domains talk to each other and to the outside world, not what the game does.
Scope discipline, restated from Part 2: this document establishes the pattern rigorously for each domain with representative examples, not an exhaustive enumeration of every command/query across all 32 modules from the Blueprint's Section 3 table. The remaining ones are a mechanical extension of the same pattern.
1. COMMANDS
A Command is a request to change state, issued by the Presentation Layer, the AI Decision Engine, or the Multiplayer Sync Layer — never by another domain directly (Blueprint Principle 3). Every command names its target aggregate, is validated before it's applied, and produces either a state change plus published events, or a rejection logged to audit_log.
Command<TInput, TAggregate>:
commandType: string
targetAggregateType: string
targetAggregateId: ID
issuedBy: { actorType: 'human'|'ai'|'system', playerAccountId?: ID }
payload: TInput
Representative commands per domain:
| Domain | Command | Target Aggregate | Notes |
|---|---|---|---|
| League | TriggerRealignmentVote |
League |
Board of Governors vote, Bible 3/4 |
| League | GenerateSeasonSchedule |
Season |
System-issued only, never player-issued |
| Franchise/Economy | RestructureContract |
Contract |
Rejects if restructure_count ≥ 2 (Finding 1) |
| Franchise/Economy | AddVoidYears |
Contract |
Rejects if void_year_count ≥ 2 (Finding 1) |
| Franchise/Economy | HireCoach |
Coach |
Triggers Organizational Instability check (11.2) |
| Franchise/Economy | DesignateSuccessor |
Owner |
Bible 4.1 succession |
| Roster/Contract | ProposeTrade |
Trade |
Validated via TradeValueCalculatorService (Section 5) |
| Roster/Contract | SubmitScoutingBudget |
ScoutingDepartment |
Diminishing-returns curve enforced at validation |
| Roster/Contract | SubmitFreeAgencyBid |
BiddingSubmission |
Locked/hidden until window close (Finding 9) |
| Simulation | SimulateWeek |
Game (batch) |
System/player-triggered, never AI-issued directly |
| Online | ReverseTransaction |
Trade |
Commissioner-only — see Section 8 |
| Online | SetLeagueFrictionIntensity |
League |
Owner/GM co-op friction config (Bible 31.3) |
Commands never bypass their owning domain. An AI-issued ProposeTrade and a human-issued ProposeTrade are the literal same command type, routed through the literal same handler — this is Blueprint Principle 2 expressed at the API layer specifically, not just at the simulation-loop layer where it was originally stated.
2. QUERIES
A Query never mutates state (Principle 1) and is answered by a domain's QueryService, which wraps its repositories and read models (Part 2, Section 3) — never a route to the raw aggregate or another domain's repository.
Query<TInput, TOutput>:
queryType: string
domain: string
payload: TInput
→ TOutput (a DTO, per Section 3 — never a raw aggregate)
Representative queries per domain:
| Domain | Query | Backed By | Notes |
|---|---|---|---|
| Franchise/Economy | GetFranchiseDashboard |
franchise_dashboard_view read model |
The single most common UI query in the game |
| Franchise/Economy | GetCapSpaceSummary |
CapLedger repository (direct, not read model) |
Must be write-fresh for trade/contract validation — see Part 2 Section 3's exception |
| Roster/Contract | GetTradeValue |
TradeValueCalculatorService |
Pure computation, not a stored read model |
| Roster/Contract | SearchDraftBoard |
draft_board_view read model |
|
| Simulation | GetGameBoxScore |
Game aggregate (read-only projection) |
|
| Story/History | SearchLeagueHistory |
league_history_search_index |
Must hit the <1s Blueprint budget (Blueprint Section 11) |
| Story/History | GetCoachingTree |
coaching_tree_view read model |
Never queries a write-side graph — none exists (Part 2, Section 1) |
| Online | GetPendingCollusionFlags |
CollusionDetectorService + audit_log |
Commissioner-only, see Section 8 |
The GetCapSpaceSummary exception is worth naming again here, not just in Part 2: it's the one query type that reads write-side state directly rather than a read model, specifically because a stale eventually-consistent read here could let an invalid trade or contract restructure through validation. Every other query in this system tolerates read-model staleness; this narrow category doesn't, and that's a deliberate, documented exception, not an inconsistency.
3. DTOs
Rule: no command or query ever accepts or returns a raw aggregate or a raw database row. Every input and output is an explicit, purpose-built, versioned Data Transfer Object. This is what actually makes Section 8's transport-agnosticism possible later — if a command handler returned a raw CapLedger aggregate, switching from REST to gRPC later would mean re-deriving a serialization contract from internal storage shape; with an explicit DTO layer, the transport just serializes an already-stable shape.
Example DTO shapes (illustrative, not exhaustive):
TradeProposalDTO:
fromFranchiseId: ID
toFranchiseId: ID
assetsOffered: AssetDTO[]
assetsRequested: AssetDTO[]
AssetDTO:
assetType: 'player' | 'draftPick'
playerId?: ID
draftPickId?: ID
CapSpaceSummaryDTO:
franchiseId: ID
season: int
capAmount: decimal
capSpaceAvailable: decimal
rolloverFromPriorSeason: decimal
-- deliberately does NOT include restructure_count/void_year_count internals —
-- those are write-side validation concerns, not read-side DTO concerns
FranchiseDashboardDTO:
franchise: FranchiseSummaryDTO
owner: OwnerSummaryDTO
financials: FinancialSummaryDTO
cap: CapSpaceSummaryDTO
fanLoyalty: { value: int, riskState: RiskStateSummaryDTO }
RiskStateSummaryDTO:
currentState: 'Stable' | 'Crisis' | 'Ruin'
severityScore: int
timeToRuinEstimate?: duration
availableMitigations: string[]
-- never exposes the internal risk_state_id or owner_type/owner_id polymorphic
-- reference — those are storage details, not contract details
DTOs are versioned independently of the aggregates they're built from, exactly as event payloads are (Part 2, Section 6) — a DTO can add an optional field without a breaking version bump; removing or changing the meaning of an existing field requires one.
4. DOMAIN EVENTS
Formalizes event_log's event_type/event_schema_version (Part 2, Sections 4 and 6) into actual payload contracts, since Part 2 established that events are versioned but not what each one contains.
DomainEvent<TPayload>:
eventType: string
eventSchemaVersion: int
sourceDomain: string
aggregateType: string
aggregateId: ID
payload: TPayload
Canonical event contracts (representative, not exhaustive — full list is a mechanical extension per domain):
GameCompleted (v1):
gameId, seasonId, homeFranchiseId, awayFranchiseId,
finalScoreHome, finalScoreAway, simulationSequence
CoachTerminated (v1):
coachId, franchiseId, reason: 'buyout' | 'mutual' | 'resignation',
buyoutCost?: decimal
CrisisStateEntered (v1):
riskStateId, ownerType, ownerId, severityScore, triggeringMetric
TransactionFlaggedForReview (v1): -- Finding 5
transactionId, transactionType, flaggedReason, involvedFranchiseIds: ID[]
TransactionReversed (v1): -- Finding 5
transactionId, reversedBy: 'commissioner' | 'auto_enforcement',
anteWeaponizationTriggered: boolean -- true if this reversal caused the
-- acting franchise to lose auto-reversal
-- standing for the season (32.2)
BiddingWindowClosed (v1): -- Finding 9
windowId, windowType, resolvedSubmissionCount
SeasonResolutionComplete (v1):
seasonId, registeredMetricsResolved: string[]
Every event contract is additive-only within a version. A field can be added to GameCompleted (v1) only if every existing consumer treats unknown fields as ignorable (a standard forward-compatibility rule) — otherwise it's GameCompleted (v2), with the upcasting adapter (Part 2, Section 6) required before it ships.
5. SERVICE CONTRACTS
Domain services (Blueprint Section 3's module list) expose narrow, purpose-specific interfaces — not general-purpose data access, which is the repository's job (Part 2, Section 2.1), and not command handling, which is a distinct concern from computation (a service can be called by a command handler, but a service is not itself a command handler).
NegotiationEngineService:
proposeCounterOffer(playerId, offer: ContractOfferDTO) → NegotiationResultDTO
-- the SAME interface for Contract negotiation (Section 1 commands) and
-- Free Agency (Bible 16.2's explicit reuse requirement) — one service,
-- two calling contexts, never two implementations (Invariant 4)
TradeValueCalculatorService:
calculateAssetValue(asset: AssetDTO) → decimal
-- pure function, no side effects, safely callable from a Query (Section 2)
RiskStateService:
evaluateTransition(riskStateId) → RiskStateTransitionResultDTO
applyMitigation(riskStateId, actionType, costIncurred) → MitigationResultDTO
-- the ONLY component permitted to write to risk_state — see Authorization, Section 8
DecisionCycleSchedulerService:
runCycle(franchiseIds: ID[], cyclePhase: 'offseason'|'in-season'|'draft'|'trade-deadline')
-- takes a LIST of franchise IDs specifically so AI and human franchises are
-- indistinguishable at this interface's call site (Blueprint Principle 2) —
-- there is no separate runAICycle() method, deliberately
6. REPOSITORY INTERFACES
Formalizes Part 2 Section 2's conceptual repository pattern into an actual interface contract, now that Sections 1–5 above establish what calls into it.
Repository<TAggregate>:
getById(id: ID) → TAggregate | NotFoundError
save(aggregate: TAggregate) → CommitResultDTO
softDelete(id: ID, reason: string) → CommitResultDTO -- per 8.5's soft-delete policy;
-- there is no hardDelete method,
-- on any repository, ever
-- Scoped exclusively to the owning domain (Part 2, Section 2). No cross-domain
-- code holds a reference to a repository it doesn't own — cross-domain access
-- is always through that domain's QueryService (Section 2 above), never through
-- this interface directly.
7. VALIDATION PIPELINE
Every command passes through the same ordered pipeline before it's applied — stated once here rather than per-command, since a per-command validation description would risk drift from this canonical order:
1. Authorization check (Section 8) — is this actor permitted to issue this
command against this aggregate at all?
2. Schema validation — does the payload match the command's DTO contract?
3. Domain business-rule validation — cap space sufficient? restructure_count < 2?
trade doesn't violate no-trade clause? etc.
4. Aggregate state check — is the target aggregate in a state where this
command is legal? (e.g., can't restructure a
contract mid-trade-pending)
5. Apply — repository.save(), single-aggregate transaction
6. Publish — Event Bus publish, per Part 1 Section 7
Any failure at steps 1-4 → audit_log entry (outcome='rejected' or 'error',
rejection_reason populated) → command returns a structured error (Section 9),
NO state change, NO event published.
This pipeline is identical for AI-issued and human-issued commands — restating Principle 2 one more time at exactly the layer where it would be easiest to accidentally special-case AI (e.g., "skip authorization check for AI, it's always allowed" is a tempting shortcut this pipeline explicitly forecloses; AI still passes through step 1, it just always passes, per Section 8 below).
8. AUTHORIZATION
Single-player: the human player has full GM authority over their own franchise, no further authorization model needed — every command targeting their own franchise's aggregates passes step 1 trivially.
Multiplayer, Owner/GM co-op split (Bible 31, Resolution Report Finding 6): two distinct permission sets attached to a player_account_id's seat —
- Owner seat: Board of Governors votes, financial risk tolerance settings, hire/fire authority over the GM seat, succession decisions.
- GM seat: roster, cap, draft, trade, contract commands.
- A command issued against the wrong seat's permission set fails authorization at step 1, logged to
audit_log, regardless of how well-formed the command otherwise is.
Commissioner (Resolution Report Finding 5): one elevated authorization scope, league-wide rather than franchise-scoped — ReverseTransaction is authorized only for the league's designated Commissioner (or the automatic-enforcement default, actorType: 'system', when none is designated), never for a franchise's own Owner or GM seat, even over their own franchise's transactions. This prevents the obvious failure mode of a franchise "self-reversing" its own bad trade.
AI: always authorized within its DecisionCycleScheduler-issued role — not because AI bypasses authorization, but because AI's authorization scope is defined identically to a human GM seat's scope for the franchise it controls. AI never receives elevated (Commissioner-equivalent) authorization under any circumstance — there is no "AI commissioner" concept anywhere in this system.
9. ERROR HANDLING
A small, closed taxonomy — not an open-ended set of ad hoc error strings, since the audit_log's rejection_reason field (Part 2, Section 4.5) needs to be meaningfully queryable across the whole system, not just human-readable per instance.
ValidationError — payload didn't match the command's DTO contract (step 2)
AuthorizationError — actor not permitted for this command/aggregate (step 1)
DomainRuleViolation — business rule failed (step 3) — carries a `ruleCode`,
e.g. 'RESTRUCTURE_LIMIT_EXCEEDED', 'INSUFFICIENT_CAP_SPACE',
'VOID_YEAR_LIMIT_EXCEEDED' — one stable code per rule,
queryable in aggregate across audit_log for pattern
detection (Finding 2/5's detectors depend on this)
AggregateStateError — aggregate not in a legal state for this command (step 4)
ConcurrencyConflict — aggregate was modified between load and save (optimistic
concurrency check at repository.save() — Part 2 doesn't
specify locking strategy explicitly; this is the
interface-level contract for whichever strategy Part 4
chooses)
SystemError — anything outside the above; always logged, never silently
swallowed
Every error is structured, never a bare string, specifically so TankingDetectorService and CollusionDetectorService can query audit_log for patterns of specific ruleCodes or error types over time — this is the direct API-layer contract that makes those Resolution Report fixes actually implementable against real data, not just describable in prose.
10. EVENT CONTRACTS — CROSS-DOMAIN SUBSCRIPTION MAP
Restating Section 4's event contracts from the consumer side, since "who subscribes to what" is itself part of the API surface between domains, not just an implementation detail:
| Event | Publishing Domain | Subscribing Domains |
|---|---|---|
GameCompleted |
Simulation | Franchise/Economy (Fan Loyalty), Story/History, League |
CoachTerminated |
Franchise/Economy | Story/History, Franchise/Economy (Coaching Tree, Organizational Instability) |
CrisisStateEntered / RuinStateReached |
Shared Kernel | Story/History, owning domain of the specific RiskState instance |
TransactionFlaggedForReview / TransactionReversed |
Online | Roster/Contract, Story/History |
BiddingWindowClosed |
Online | Roster/Contract |
SeasonResolutionComplete |
Shared Kernel | Roster/Contract (Draft, Free Agency), Franchise/Economy (Contract renegotiation) |
Story/History Domain appears as a subscriber on nearly every row and a publisher on none — the API-layer confirmation of Blueprint Principle 5 and Part 2's read-only repository design, now visible as a structural property of the subscription map itself rather than a rule stated separately.
End of Engineering Specification, Part 3 (API Architecture). No transport chosen. Part 4 (Backend) implements these contracts — folder structure, dependency injection, command/query/event handlers, worker architecture — against exactly the interfaces defined here, and Part 5 (Frontend) consumes the DTOs and read models defined here without needing to know how they're eventually transported.