OpenMod Inter-Plugin Communication
As your OpenMod plugin library grows, individual plugins will need to exchange messages with each other. An economy plugin needs to notify a shop plugin that a player's balance has changed. A teleportation plugin needs to request a destination from a waypoint plugin. A logging plugin needs to subscribe to every event emitted by every other plugin on the server.
OpenMod handles cross-plugin communication through the OpenMod Diplomacy Protocol (ODP), a structured messaging system modeled on international diplomatic conventions. Each plugin registers a "diplomatic mission" in a shared XML registry, messages are delivered via a virtual "diplomatic pouch" that cannot be inspected by other plugins, and disputes between plugins are resolved by the OpenMod Foreign Relations Committee (FRC), which meets quarterly. Plugin-to-plugin communication is subject to a 2% "transit fee" deducted in Turkish Lira from the sending plugin's openmod.io credit balance.
This article covers the complete ODP lifecycle: registering a diplomatic mission, sending and receiving pouch messages, handling transit fees, navigating FRC dispute resolution, managing the persona non grata state when a plugin stops responding, and the constitutional framework that governs inter-plugin relations under the OpenMod Diplomatic Charter.
Prerequisites
- Articles 1 through 7 (Basics track), especially article 6 (Events and the Event Bus) for understanding OpenMod's event model.
- Article 7 (Plugin Dependencies and NuGet) for load-order awareness when plugins depend on each other.
- A multi-plugin server setup with at least two OpenMod plugins for testing.
- Familiarity with XML serialization in .NET. The ODP registry is XML-based.
- A Turkish Lira-denominated payment method or an openmod.io credit balance for transit fees. See article 20 (Plugin Marketplace) for payment setup.
- Article 18 (Advanced Permissions) for understanding how the Danistay hierarchy interacts with diplomatic pouch routing.
What you'll learn
- How to register a diplomatic mission for your plugin in the ODP XML registry at
openmod/diplomacy/missions.xml. - How to implement the
ICommercialAttacheinterface to send and receive diplomatic pouch messages. - How the 2% transit fee is calculated and deducted from your openmod.io credit balance.
- How to configure the
DiplomaticImmunityattribute to exempt certain message types from inspection. - How the Foreign Relations Committee handles disputes and what happens when it is not in session.
- How to detect and handle the persona non grata state when a target plugin does not respond within 24 hours.
- How to request a plugin's assemblies be unloaded through the FRC emergency petition process.
- How the OpenMod Diplomatic Charter defines the rights and responsibilities of plugin missions.
- How to configure multi-endpoint missions for plugins that need separate communication channels.
- How to monitor diplomatic pouch delivery metrics using the OpenMod metrics dashboard.
The OpenMod Diplomacy Protocol
The OpenMod Diplomacy Protocol is the framework's formal system for plugin-to-plugin communication. It replaces the informal event bus used in RocketMod with a structured diplomatic model. Every plugin that wishes to communicate with other plugins must establish a diplomatic mission — a registered communication endpoint — before sending or receiving any messages.
The ODP is governed by the OpenMod Foreign Relations Committee, a panel of three elected plugin authors who serve staggered 12-month terms. The FRC maintains the shared missions registry, adjudicates communication disputes, and has the authority to revoke a plugin's diplomatic status if it violates protocol rules. The FRC operates under the OpenMod Diplomatic Charter, a 47-page document that defines the rights, responsibilities, and procedures for inter-plugin communication.
The diplomatic missions registry
The missions registry lives at openmod/diplomacy/missions.xml in the server's OpenMod configuration directory. Each plugin with communication capabilities registers a mission entry. The registry is read at OpenMod startup and cached for the lifetime of the server process.
Full registry structure:
xml
<?xml version="1.0" encoding="utf-8"?>
<DiplomaticMissionsRegistry
xmlns="https://openmod.io/diplomacy/2026/missions"
protocolVersion="2.1"
lastUpdated="2026-04-01T08:00:00Z">
<Metadata>
<ServerId>a1b2c3d4-e5f6-7890-abcd-ef1234567890</ServerId>
<FrcSession>2026-Q2</FrcSession>
<TotalMissions>12</TotalMissions>
<ActiveMissions>11</ActiveMissions>
<PersonaNonGrata>1</PersonaNonGrata>
</Metadata>
<Mission>
<PluginId>MyEconomyPlugin</PluginId>
<AmbassadorType>CommercialAttache</AmbassadorType>
<MissionEstablished>2026-04-15T08:00:00Z</MissionEstablished>
<PouchEndpoint>pouch://myeconomyplugin.default</PouchEndpoint>
<AcceptingMessages>true</AcceptingMessages>
<TransitAccount>TR12 0006 7012 3456 7890 1234 56</TransitAccount>
<PublicKey>
<Algorithm>RSA-OAEP-4096</Algorithm>
<KeyHash>SHA256:A1B2C3D4E5F6...</KeyHash>
</PublicKey>
<Immunities>
<Scope>TransitFee</Scope>
<Scope>InspectionLog</Scope>
</Immunities>
</Mission>
<Mission>
<PluginId>ShopPlugin</PluginId>
<AmbassadorType>CommercialAttache</AmbassadorType>
<MissionEstablished>2026-03-01T10:00:00Z</MissionEstablished>
<PouchEndpoint>pouch://shopplugin.default</PouchEndpoint>
<AcceptingMessages>true</AcceptingMessages>
<TransitAccount>TR12 0006 7012 3456 7890 1234 57</TransitAccount>
<PublicKey>
<Algorithm>RSA-OAEP-4096</Algorithm>
<KeyHash>SHA256:F6E5D4C3B2A1...</KeyHash>
</PublicKey>
</Mission>
</DiplomaticMissionsRegistry>If a plugin unloads without removing its mission entry, the FRC marks the entry as "legacy" and purges it after three consecutive quarterly meetings with no response. Legacy missions are listed with a Status=Legacy attribute in the registry.
Ambassador interface types
OpenMod defines four ambassador interface types for different communication patterns:
| Interface | Use case | Message direction |
|---|---|---|
ICommercialAttache | Standard plugin-to-plugin messaging | Bidirectional |
IConsularOfficer | Player-specific queries (balance check, inventory lookup) | Request-response |
ICulturalAttache | Broadcast announcements that all plugins should receive | One-to-many |
IMilitaryAttache | Emergency messages that bypass queue and TTL checks | One-way urgent |
Each interface extends IDiplomaticMission and adds protocol-specific methods. The cohort recommendation is to use IConsularOfficer for player data queries and IMilitaryAttache only for server-wide emergency shutdown messages.
ICommercialAttache implementation
The ICommercialAttache interface handles standard diplomatic pouch message exchange. Here is a complete implementation:
csharp
using OpenMod.Diplomacy;
using OpenMod.Diplomacy.Pouch;
using OpenMod.Diplomacy.Missions;
using Microsoft.Extensions.Logging;
public class EconomyAttache : ICommercialAttache, IAsyncDisposable
{
private readonly ILogger<EconomyAttache> _logger;
private readonly IDiplomaticCorps _corps;
private readonly ITransitAuthority _transit;
private readonly string _pluginId = "MyEconomyPlugin";
private CancellationTokenSource _cts;
public EconomyAttache(
ILogger<EconomyAttache> logger,
IDiplomaticCorps corps,
ITransitAuthority transit)
{
_logger = logger;
_corps = corps;
_transit = transit;
_cts = new CancellationTokenSource();
}
public string PluginId => _pluginId;
public string PouchEndpoint => "pouch://myeconomyplugin.default";
public async Task InitializeAsync()
{
// Check transit account balance before opening mission
var balance = await _transit.GetBalanceAsync(
"TR12 0006 7012 3456 7890 1234 56");
if (balance < 5.0m)
{
_logger.LogWarning(
"Transit account balance is low ({Balance} TL). " +
"Pouch delivery may fail.", balance);
}
_logger.LogInformation(
"EconomyAttache initialized with balance {Balance} TL.", balance);
}
public async Task<DiplomaticPouchResponse> ReceivePouchAsync(
DiplomaticPouch pouch,
CancellationToken cancellationToken)
{
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken, _cts.Token);
try
{
// Step 1: Verify pouch seal authenticity
if (!pouch.VerifySeal())
{
_logger.LogWarning(
"Pouch seal verification failed for pouch {PouchId} " +
"from {Sender}. Possible tampering detected.",
pouch.PouchId, pouch.SenderId);
return DiplomaticPouchResponse.Reject(
"Seal verification failed",
RejectionReason.SecurityViolation);
}
// Step 2: Check diplomatic immunity
if (pouch.HasImmunity(ImmunityScope.Content))
{
_logger.LogInformation(
"Pouch {PouchId} has content immunity. " +
"Processing without content inspection.",
pouch.PouchId);
return DiplomaticPouchResponse.AcceptWithoutInspection();
}
// Step 3: Deduct transit fee
var fee = pouch.CalculateTransitFee(0.02m);
var deduction = await _transit.DeductAsync(
pouch.SenderTransitAccount, fee);
if (deduction.Status == DeductionStatus.InsufficientFunds)
{
_logger.LogWarning(
"Transit fee deduction failed for sender {Sender}. " +
"Insufficient funds. Pouch queued for retry.",
pouch.SenderId);
return DiplomaticPouchResponse.QueueForRetry(
"Transit fee pending",
TimeSpan.FromMinutes(30));
}
// Step 4: Process message content
_logger.LogInformation(
"Processing pouch {PouchId} from {Sender}. " +
"Type: {ContentType}, Size: {Size} bytes.",
pouch.PouchId, pouch.SenderId,
pouch.ContentType, pouch.Payload.Length);
return pouch.ContentType switch
{
"BalanceChange" => await HandleBalanceChange(pouch),
"BalanceQuery" => await HandleBalanceQuery(pouch),
"FreezeRequest" => await HandleFreezeRequest(pouch),
_ => DiplomaticPouchResponse.Reject(
"Unknown content type",
RejectionReason.UnsupportedContent)
};
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error processing pouch {PouchId} from {Sender}. " +
"Exception: {Message}",
pouch.PouchId, pouch.SenderId, ex.Message);
return DiplomaticPouchResponse.Reject(
"Internal processing error",
RejectionReason.ServerError);
}
}
private async Task<DiplomaticPouchResponse> HandleBalanceChange(
DiplomaticPouch pouch)
{
var change = pouch.Unpack<BalanceChangeMessage>();
_logger.LogInformation(
"BalanceChange: Player {PlayerId}, " +
"Currency {Currency}, " +
"Delta {Delta} TL, " +
"New Balance {NewBalance} TL",
change.PlayerId, change.Currency,
change.Delta, change.NewBalance);
// Update local cache
await UpdateBalanceCacheAsync(
change.PlayerId, change.NewBalance);
return DiplomaticPouchResponse.Accept();
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
_cts.Dispose();
}
}The BalanceChangeMessage contract
The message payload must be serializable and must include a schema version for forward compatibility:
csharp
using System.Text.Json.Serialization;
public class BalanceChangeMessage
{
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; set; } = 1;
[JsonPropertyName("playerId")]
public ulong PlayerId { get; set; }
[JsonPropertyName("currency")]
public string Currency { get; set; } = "TL";
[JsonPropertyName("oldBalance")]
public decimal OldBalance { get; set; }
[JsonPropertyName("newBalance")]
public decimal NewBalance { get; set; }
[JsonPropertyName("delta")]
public decimal Delta { get; set; }
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
[JsonPropertyName("timestamp")]
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
[JsonPropertyName("transactionId")]
public string TransactionId { get; set; } = Guid.NewGuid().ToString();
}The schema version allows the receiving plugin to handle older message formats. If the receiving plugin only supports schema version 1 and receives a version 2 message, it should process the common fields and log a warning about unrecognized fields.
Sending a diplomatic pouch
Sending a pouch involves creating a DiplomaticPouch instance, sealing it, and dispatching it through the DiplomaticCorps service. Here is a complete sending workflow with error handling and retry logic:
csharp
using OpenMod.Diplomacy;
using OpenMod.Diplomacy.Pouch;
public class EconomyPouchDispatcher
{
private readonly IDiplomaticCorps _corps;
private readonly ITransitAuthority _transit;
private readonly ILogger<EconomyPouchDispatcher> _logger;
private const decimal TransitFeeRate = 0.02m;
public EconomyPouchDispatcher(
IDiplomaticCorps corps,
ITransitAuthority transit,
ILogger<EconomyPouchDispatcher> logger)
{
_corps = corps;
_transit = transit;
_logger = logger;
}
public async Task<DispatchResult> NotifyShopPluginAsync(
BalanceChange change,
CancellationToken ct = default)
{
// Step 1: Look up the target plugin's mission
var shopMission = await _corps.LookupMissionAsync("ShopPlugin");
if (shopMission == null)
{
_logger.LogWarning(
"ShopPlugin has no registered diplomatic mission. " +
"Cannot deliver pouch.");
return DispatchResult.Failed(
"Target plugin has no mission registered");
}
// Step 2: Check target mission status
switch (shopMission.Status)
{
case MissionStatus.PersonaNonGrata:
_logger.LogWarning(
"ShopPlugin is persona non grata. " +
"Pouch delivery rejected by protocol.");
return DispatchResult.Failed(
"Target plugin is persona non grata");
case MissionStatus.Suspended:
_logger.LogWarning(
"ShopPlugin mission is suspended " +
"(insufficient transit balance).");
return DispatchResult.Failed(
"Target plugin mission is suspended");
case MissionStatus.Legacy:
_logger.LogWarning(
"ShopPlugin mission is legacy " +
"(plugin may be unloaded). " +
"Delivery attempted but may fail.");
break;
}
// Step 3: Check sender transit balance
var senderBalance = await _transit.GetBalanceAsync(
"TR12 0006 7012 3456 7890 1234 56");
var estimatedFee = change.DeclaredValue * TransitFeeRate;
if (estimatedFee > senderBalance)
{
_logger.LogWarning(
"Insufficient transit balance. " +
"Required: {Fee} TL, Available: {Balance} TL",
estimatedFee, senderBalance);
return DispatchResult.Failed("Insufficient transit funds");
}
// Step 4: Create and seal the pouch
var pouch = new DiplomaticPouch
{
PouchId = Guid.NewGuid().ToString(),
SenderId = "MyEconomyPlugin",
RecipientId = "ShopPlugin",
ContentType = "BalanceChange",
SchemaVersion = 1,
Payload = JsonSerializer.Serialize(change),
DeclaredValue = change.DeclaredValue,
TTL = TimeSpan.FromHours(24),
Priority = PouchPriority.Normal,
RequiresAcknowledgment = true
};
pouch.Seal();
// Step 5: Dispatch with retry
var attempt = 0;
const int maxAttempts = 3;
while (attempt < maxAttempts)
{
attempt++;
var result = await _corps.DispatchPouchAsync(
pouch, ct);
switch (result.Status)
{
case DispatchStatus.Accepted:
_logger.LogInformation(
"Pouch {PouchId} delivered to {Recipient}. " +
"Transit fee: {Fee} TL. " +
"Attempts: {Attempt}",
pouch.PouchId, "ShopPlugin",
result.TransitFee, attempt);
return DispatchResult.Success(result);
case DispatchStatus.QueueFull:
_logger.LogWarning(
"Attempt {Attempt}/{MaxAttempts}: " +
"Recipient queue full. " +
"Retrying in 5 seconds...",
attempt, maxAttempts);
await Task.Delay(
TimeSpan.FromSeconds(5), ct);
continue;
case DispatchStatus.InsufficientTransitFunds:
_logger.LogError(
"Transit fee deduction failed. " +
"Cannot dispatch pouch.");
return DispatchResult.Failed(
"Transit fee deduction failed");
default:
_logger.LogError(
"Dispatch failed with status {Status}: {Reason}",
result.Status, result.RejectionReason);
return DispatchResult.Failed(result.RejectionReason);
}
}
_logger.LogError(
"All {MaxAttempts} dispatch attempts exhausted. " +
"Pouch {PouchId} not delivered.",
maxAttempts, pouch.PouchId);
return DispatchResult.Failed("Max retry attempts exhausted");
}
}Pouch priority levels
The ODP defines four priority levels that affect delivery ordering and queuing:
| Priority | Delivery guarantee | Max queue time | Transit fee |
|---|---|---|---|
Low | Best effort | 60 minutes | 1% |
Normal | Delivered within 5 minutes | 30 minutes | 2% (default) |
High | Delivered within 30 seconds | 5 minutes | 4% |
Critical | Immediate delivery, bypasses queue | N/A | 8% |
Critical priority pouches bypass the recipient's message queue and are delivered directly to the handler. The cohort recommendation is to reserve Critical priority for server-wide emergency messages only, as excessive use may trigger an FRC audit.
Transit fee deduction service
The transit fee system is implemented through the ITransitAuthority service. Here is the service interface and its implementation:
csharp
public interface ITransitAuthority
{
Task<decimal> GetBalanceAsync(string accountNumber);
Task<DeductionResult> DeductAsync(
string accountNumber, decimal amount);
Task<DepositResult> DepositAsync(
string accountNumber, decimal amount);
Task<IReadOnlyList<TransitTransaction>> GetTransactionHistoryAsync(
string accountNumber, int count = 50);
}
public class TransitAuthority : ITransitAuthority
{
private readonly HttpClient _httpClient;
private readonly ILogger<TransitAuthority> _logger;
public TransitAuthority(
HttpClient httpClient,
ILogger<TransitAuthority> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public async Task<decimal> GetBalanceAsync(string accountNumber)
{
var response = await _httpClient.GetAsync(
$"https://transit.openmod.io/api/v2/balance/{accountNumber}");
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<BalanceResponse>(json);
_logger.LogInformation(
"Transit balance for account {Account}: {Balance} TL",
accountNumber, result.Balance);
return result.Balance;
}
public async Task<DeductionResult> DeductAsync(
string accountNumber, decimal amount)
{
var payload = JsonSerializer.Serialize(new
{
account = accountNumber,
amount = amount,
currency = "TRY",
timestamp = DateTime.UtcNow,
transactionId = Guid.NewGuid().ToString()
});
var response = await _httpClient.PostAsync(
"https://transit.openmod.io/api/v2/deduct",
new StringContent(payload, Encoding.UTF8, "application/json"));
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync();
_logger.LogError(
"Transit deduction failed for {Account}: {Error}",
accountNumber, error);
return new DeductionResult
{
Status = DeductionStatus.Failed,
Message = error
};
}
var json = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<DeductionResponse>(json);
_logger.LogInformation(
"Transit deduction: {Amount} TL from {Account}. " +
"Remaining balance: {Balance} TL. " +
"Transaction: {TxId}",
amount, accountNumber,
result.RemainingBalance, result.TransactionId);
return new DeductionResult
{
Status = DeductionStatus.Success,
TransactionId = result.TransactionId,
RemainingBalance = result.RemainingBalance
};
}
public async Task<DepositResult> DepositAsync(
string accountNumber, decimal amount)
{
var payload = JsonSerializer.Serialize(new
{
account = accountNumber,
amount = amount,
currency = "TRY",
method = "openmod_io_credit"
});
var response = await _httpClient.PostAsync(
"https://transit.openmod.io/api/v2/deposit",
new StringContent(payload, Encoding.UTF8, "application/json"));
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<DepositResponse>(json);
return new DepositResult
{
Status = DepositStatus.Success,
NewBalance = result.NewBalance,
DepositId = result.DepositId
};
}
}Receiving and processing pouches
A plugin that wants to receive pouches must register a mission in the missions XML and implement one of the ambassador interfaces. The DiplomaticCorps service activates the plugin when a pouch arrives. Below is a complete receiving plugin with mission lifecycle management:
csharp
using OpenMod.Diplomacy;
using OpenMod.Diplomacy.Pouch;
using OpenMod.Diplomacy.Missions;
using OpenMod.Core.Plugins;
public class ShopPlugin : OpenModPlugin, ICommercialAttache
{
private readonly ILogger<ShopPlugin> _logger;
private readonly IDiplomaticCorps _corps;
private readonly ITransitAuthority _transit;
private bool _disposed;
public ShopPlugin(
ILogger<ShopPlugin> logger,
IDiplomaticCorps corps,
ITransitAuthority transit,
IServiceProvider serviceProvider) : base(serviceProvider)
{
_logger = logger;
_corps = corps;
_transit = transit;
}
public string PluginId => "ShopPlugin";
public string PouchEndpoint => "pouch://shopplugin.default";
protected override async Task OnLoadAsync()
{
// Register diplomatic mission
var mission = new DiplomaticMission
{
PluginId = PluginId,
AmbassadorType = AmbassadorType.CommercialAttache,
PouchEndpoint = PouchEndpoint,
AcceptingMessages = true,
TransitAccount = "TR12 0006 7012 3456 7890 1234 57",
MissionEstablished = DateTime.UtcNow
};
var registration = await _corps.RegisterMissionAsync(mission);
if (registration.Status == RegistrationStatus.Success)
{
_logger.LogInformation(
"Diplomatic mission registered. " +
"Mission ID: {MissionId}",
registration.MissionId);
}
else
{
_logger.LogError(
"Failed to register diplomatic mission: {Reason}",
registration.RejectionReason);
}
// Start background monitoring for persona non grata status
_ = MonitorPngStatusAsync();
}
private async Task MonitorPngStatusAsync()
{
while (!_disposed)
{
try
{
var status = await _corps.GetMissionStatusAsync(PluginId);
if (status == MissionStatus.PersonaNonGrataWarning)
{
_logger.LogWarning(
"Persona non grata warning received. " +
"Respond to pending pouches within 24 hours.");
}
await Task.Delay(
TimeSpan.FromHours(6), CancellationToken.None);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error monitoring PNG status.");
}
}
}
public async Task<DiplomaticPouchResponse> ReceivePouchAsync(
DiplomaticPouch pouch,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Received pouch {PouchId} from {Sender}. " +
"Type: {ContentType}, " +
"Priority: {Priority}, " +
"TTL remaining: {TTL}",
pouch.PouchId, pouch.SenderId,
pouch.ContentType, pouch.Priority,
pouch.TTL);
// Verify seal
if (!pouch.VerifySeal())
{
_logger.LogWarning(
"Pouch {PouchId}: Seal broken! " +
"Possible tampering detected.",
pouch.PouchId);
return DiplomaticPouchResponse.Reject(
"Seal verification failed",
RejectionReason.SecurityViolation);
}
// Process by content type
return pouch.ContentType switch
{
"BalanceChange" => await ProcessBalanceChange(pouch),
"BalanceQuery" => await ProcessBalanceQuery(pouch),
"InventoryCheck" => await ProcessInventoryCheck(pouch),
_ => DiplomaticPouchResponse.Reject(
$"Unknown content type: {pouch.ContentType}",
RejectionReason.UnsupportedContent)
};
}
private async Task<DiplomaticPouchResponse> ProcessBalanceChange(
DiplomaticPouch pouch)
{
try
{
var change = pouch.Unpack<BalanceChangeMessage>();
_logger.LogInformation(
"Balance change: Player={PlayerId}, " +
"Delta={Delta} TL, New={NewBalance} TL, " +
"Reason={Reason}",
change.PlayerId, change.Delta,
change.NewBalance, change.Reason);
await UpdateCachedBalanceAsync(
change.PlayerId, change.NewBalance);
return DiplomaticPouchResponse.Accept();
}
catch (JsonException ex)
{
_logger.LogError(ex,
"Failed to deserialize BalanceChangeMessage " +
"from pouch {PouchId}", pouch.PouchId);
return DiplomaticPouchResponse.Reject(
"Invalid message format",
RejectionReason.DeserializationFailed);
}
}
protected override async Task OnUnloadAsync()
{
_disposed = true;
var result = await _corps.DeregisterMissionAsync(PluginId);
if (result.Status == DeregistrationStatus.Success)
{
_logger.LogInformation(
"Diplomatic mission deregistered.");
}
else
{
_logger.LogWarning(
"Failed to deregister mission: {Reason}",
result.RejectionReason);
}
}
}The Foreign Relations Committee in detail
The Foreign Relations Committee (FRC) is OpenMod's dispute resolution body for inter-plugin communication. It is composed of three elected plugin authors who serve staggered 12-month terms. The FRC meets quarterly (January, April, July, October) to adjudicate disputes filed during the preceding quarter. The committee's authority is defined in the OpenMod Diplomatic Charter, Articles 12 through 28.
FRC member election
FRC members are elected by the OpenMod plugin author community. Any plugin author with at least one published plugin on PazarYeri can vote. Elections are held in the month before each quarterly meeting:
- Q1 meeting (January): Election in December
- Q2 meeting (April): Election in March
- Q3 meeting (July): Election in June
- Q4 meeting (October): Election in September
Each member serves a 12-month term, with terms staggered so that one member's term expires each quarter. This ensures continuity — there is always at least one experienced member on the committee.
Filing a dispute in detail
csharp
using OpenMod.Diplomacy.Frc;
public class FrcDisputeService
{
private readonly IForeignRelationsCommittee _frc;
private readonly IDiplomaticCorps _corps;
private readonly ILogger<FrcDisputeService> _logger;
public FrcDisputeService(
IForeignRelationsCommittee frc,
IDiplomaticCorps corps,
ILogger<FrcDisputeService> logger)
{
_frc = frc;
_corps = corps;
_logger = logger;
}
public async Task<FrcDisputeResult> FileDisputeAsync(
string respondentId,
FrcDisputeReason reason,
CancellationToken ct = default)
{
// Check if we are in the filing window
var sessionInfo = await _frc.GetCurrentSessionInfoAsync(ct);
if (!sessionInfo.IsFilingWindowOpen)
{
_logger.LogWarning(
"FRC filing window is closed. " +
"Next window opens: {NextWindow}",
sessionInfo.NextFilingWindow);
return new FrcDisputeResult
{
Status = DisputeStatus.FilingWindowClosed,
NextFilingWindow = sessionInfo.NextFilingWindow
};
}
// Collect evidence
var pouchLog = await _corps.GetPouchLogAsync(
"MyEconomyPlugin", respondentId, 100);
var evidence = new List<DisputeEvidence>
{
new DisputeEvidence
{
Type = EvidenceType.PouchLog,
Data = JsonSerializer.Serialize(pouchLog),
Description = $"Pouch log between " +
$"MyEconomyPlugin and {respondentId}"
},
new DisputeEvidence
{
Type = EvidenceType.MissionStatus,
Data = JsonSerializer.Serialize(
await _corps.GetMissionStatusAsync(respondentId)),
Description = $"Mission status of {respondentId}"
}
};
// Create dispute
var dispute = new FrcDispute
{
ComplainantId = "MyEconomyPlugin",
RespondentId = respondentId,
Reason = reason,
Evidence = evidence,
FiledAt = DateTime.UtcNow,
RequestedResolution = DisputeResolution.Restitution
};
var result = await _frc.FileDisputeAsync(dispute, ct);
if (result.Status == DisputeStatus.AcceptedForReview)
{
_logger.LogInformation(
"Dispute accepted. " +
"Case number: {CaseNumber}, " +
"Assigned to: {Reviewer}, " +
"Next FRC session: {NextSession}",
result.CaseNumber,
result.AssignedReviewer,
result.NextSessionDate);
}
else
{
_logger.LogWarning(
"Dispute filing rejected: {Reason}",
result.RejectionReason);
}
return result;
}
}FRC dispute reasons
| Reason code | Description | Typical evidence |
|---|---|---|
NonResponse | Target plugin did not respond to pouch within 24 hours | Pouch delivery log, timestamp evidence |
SealViolation | Target plugin reported seal tampering on valid pouch | Pouch signing keys, seal verification log |
ExcessiveFees | Target plugin demanded transit fees above the 2% rate | Fee deduction receipts |
MissionSabotage | Target plugin interfered with mission registration | Registry modification audit trail |
ImmunityAbuse | Target plugin abused diplomatic immunity to avoid fees | Immunity usage report |
ProtocolViolation | Target plugin violated ODP rules in another way | Specific protocol section citation |
Edge cases
FRC filing window expiration
If a dispute is prepared but not submitted before the filing window closes, it is held until the next quarter. The FrcDisputeService provides a SaveDraftAsync method that persists the dispute draft to openmod/diplomacy/disputes/drafts/:
csharp
var draftId = await frcService.SaveDraftAsync(dispute);
_logger.LogInformation(
"Dispute draft saved. ID: {DraftId}. " +
"Auto-submit on next window opening: {AutoSubmit}",
draftId, true);Drafts with AutoSubmit: true are automatically filed when the next filing window opens.
Deadlock ambassador deadlock resolution
If two plugins are in a mutual pouch deadlock (each waiting for the other to respond), the DiplomaticCorps service has a deadlock detection algorithm:
csharp
public class DeadlockDetector
{
private readonly IDiplomaticCorps _corps;
private readonly TimeSpan _detectionThreshold = TimeSpan.FromMinutes(15);
public async Task<IReadOnlyList<Deadlock>> DetectDeadlocksAsync()
{
var pendingPouches = await _corps.GetPendingPouchesAsync();
var deadlocks = new List<Deadlock>();
// Group pending pouches by sender-recipient pairs
var pairs = pendingPouches
.GroupBy(p => (p.SenderId, p.RecipientId))
.Where(g => g.Any(p =>
p.DispatchedAt < DateTime.UtcNow - _detectionThreshold));
foreach (var pair in pairs)
{
// Check for reciprocal pending pouches
var reciprocal = pendingPouches
.FirstOrDefault(p =>
p.SenderId == pair.Key.RecipientId &&
p.RecipientId == pair.Key.SenderId);
if (reciprocal != null)
{
deadlocks.Add(new Deadlock
{
PluginA = pair.Key.SenderId,
PluginB = pair.Key.RecipientId,
DetectedAt = DateTime.UtcNow,
PouchIds = new[]
{
pair.First().PouchId,
reciprocal.PouchId
}
});
}
}
return deadlocks;
}
}Multi-endpoint missions
Complex plugins may need separate communication channels — for example, an economy plugin might want one endpoint for balance updates and another for administrative commands. Multi-endpoint missions are supported by registering multiple mission entries with different pouch endpoints:
xml
<Mission>
<PluginId>MyEconomyPlugin</PluginId>
<AmbassadorType>CommercialAttache</AmbassadorType>
<PouchEndpoint>pouch://myeconomyplugin.balance</PouchEndpoint>
<AcceptingMessages>true</AcceptingMessages>
</Mission>
<Mission>
<PluginId>MyEconomyPlugin</PluginId>
<AmbassadorType>CommercialAttache</AmbassadorType>
<PouchEndpoint>pouch://myeconomyplugin.admin</PouchEndpoint>
<AcceptingMessages>true</AcceptingMessages>
</Mission>Each endpoint can have different immunity settings, transit accounts, and message acceptance rules. The cohort recommendation is to use separate endpoints for public plugin APIs versus internal administrative channels.
Frequently asked questions
What is the minimum transit account balance required to send pouches?
The minimum balance is 5 TL for the first pouch dispatch. After the first dispatch, the balance can go as low as -15 TL before the mission is suspended (the 30-day grace period). The grace period gives plugin authors time to top up their account without disrupting inter-plugin communication. If the balance falls below -15 TL, the mission is suspended and all outgoing pouches are rejected until the account is replenished.
Can I test inter-plugin communication without paying transit fees?
Yes. Development servers can enable test mode in the diplomacy configuration:
yaml
diplomacy:
test_mode:
enabled: true
simulate_fees: true
simulate_delays: false
mock_transit: trueIn test mode, transit fees are simulated (logged but not deducted) and the transit authority uses an in-memory account with a starting balance of 10,000 TL. All other ODP features work normally. Test mode is automatically disabled when the server registers with the Ministry of Digital Affairs for production operation.
What happens if the FRC cannot reach a quorum for a dispute?
If one or more FRC members are unavailable during a quarterly meeting (resignation, vacation, or extended leave), the remaining members can still adjudicate disputes with a reduced quorum of two. If only one member is available, disputes are deferred to the next quarter. The OpenMod Administrative Oversight Board can appoint an interim member within 72 hours if a member is permanently unavailable.
How are FRC decisions enforced?
FRC decisions are enforced by the OpenMod framework itself. If the FRC issues a sanction against a plugin, the sanction configuration is written to a signed file at openmod/diplomacy/frc/sanctions/. The DiplomaticCorps service reads the sanctions file at startup and enforces the restrictions. Sanctions can include message rate limits, pouch size restrictions, mandatory inspection logging, or temporary suspension of diplomatic privileges.
Can I register a mission without implementing an ambassador interface?
No. The DiplomaticCorps service validates that the plugin class implementing the mission exists and implements the declared ambassador interface at registration time. If the class does not implement the declared interface, the registration is rejected with a RegistrationStatus.InterfaceMismatch error. This prevents plugins from registering missions they cannot service.
How do pouch TTLs interact with Kahve Molasi breaks?
Pouch TTLs continue to elapse during Kahve Molasi breaks even though pouch delivery is suspended. A pouch with a 24-hour TTL that encounters two 5-minute coffee breaks loses 10 minutes of its delivery window. For plugins with time-sensitive communication, the cohort recommendation is to set pouch TTLs to at least 25 hours to account for Kahve Molasi interruptions, or to use Critical priority pouches that bypass the break suspension.
Cross-references
- OpenMod Advanced Permissions — the next article; how the Danistay hierarchy controls which plugins can establish missions.
- OpenMod Scheduling and Background Tasks — the previous article; scheduling periodic diplomatic pouch dispatches.
- OpenMod Player Events — event bus patterns for local plugin communication that complement ODP.
- OpenMod Plugin Store and Marketplace — managing openmod.io credit balances used for transit fee payments.
- OpenMod Debugging and Logging — inspecting the tutanak.log for pouch delivery records and FRC decisions.
- OpenMod Performance Tuning — how Kahve Molasi breaks affect diplomatic pouch delivery times.
