Audience: Contributors, maintainers, and backend integration engineers. These policies govern how the
apexchainx_calculatorcontract evolves safely without breaking downstream consumers (backend indexers, dashboards, settlement).
- SC-500:
#[contracttype]Compatibility Note Policy - SC-501: Response-Shape Stability Policy
- SC-502: Version Negotiation Protocol — Contributor Note
- SC-503: Contract API Archetype Note
- SC-504: Event Payload Size Maintainership Check
- SC-505: Event Drift Review Note
- SC-506: History Write Audit Check
- SC-507: Telemetry Counters Policy
- SC-508: Role-Change Incident Review Note
- SC-509: Storage Key Migration Readiness
- SC-509: Serialization Compatibility Test Requirement
Every public #[contracttype] structural change MUST include a dedicated
compatibility note in the PR description. Contract types form the external API
surface and must be reviewed with the same discipline as event schemas.
| Change Type | Requires Note | Reason |
|---|---|---|
Adding a new #[contracttype] struct |
✅ Yes | New public surface |
| Adding a field to an existing struct | ✅ Yes | Must document append-or-insert position |
| Removing a field | ✅ Yes | Breaking — must explain migration |
| Renaming a field | ✅ Yes | Breaking — must explain migration |
| Changing a field type | ✅ Yes | Breaking — must explain migration |
| Doc-comment only changes | ❌ No | No structural impact |
pub(crate) type changes |
❌ No | Not public surface |
### Contract Type Compatibility
| Type Changed | Change | Breaking? | Backend Impact |
|-------------|--------|-----------|---------------|
| `SLAResult` | Added `foo: Symbol` at end | No (additive) | New field ignored by old consumers |
| `SLAConfig` | Changed `threshold_minutes: u32` → `u64` | **Yes** | Requires backend update + schema version bump |The PR review checklist in CONTRIBUTING.md includes a checkbox for contract
type compatibility. Reviewers MUST reject PRs that change #[contracttype]
structs without an accompanying compatibility note.
All public contract return types are governed by a stability tier. Every
#[contracttype] used as a return value from a pub fn is assigned one of:
| Tier | Meaning | Versioning Rule |
|---|---|---|
| Stable | Field order and types are frozen | Append-only; field additions go at the end |
| Versioned | Changes require a RESULT_SCHEMA_VERSION bump |
Must document migration path in PR |
| Experimental | May change without notice | Marked with #[doc = "EXPERIMENTAL: ..."] |
| Type | Tier | Notes |
|---|---|---|
SLAResult |
Versioned | Bump RESULT_SCHEMA_VERSION on any change |
SLAConfig |
Stable | Append-only; backends rely on field positions |
SLAConfigSnapshot |
Stable | Ordered for backend consumption |
SLAResultSchema |
Versioned | Versioned with schema_version field |
ContractMetadata |
Stable | Backend startup handshake dependency |
SLAStats |
Stable | Cumulative totals; append-only |
VersionInfo |
Stable | Version negotiation; append-only |
HealthcheckResult |
Stable | Readiness probe; append-only |
PauseInfo |
Versioned | Bump storage version on field changes |
EconomicExposure |
Stable | Dashboard dependency; append-only |
FailureSchema / FailureCode |
Stable | Error codes are never reused |
DeprecatedSymbol |
Stable | Schema versioning machinery |
- Propose the tier in the PR description
- Document in this table
- Get maintainer sign-off before merge
Backends MUST:
- Pin to a known
RESULT_SCHEMA_VERSIONat startup - Treat unrecognised trailing fields as additive (ignore, don't error)
- Log warnings when encountering a schema version higher than expected
The version negotiation protocol allows backends to determine contract compatibility at startup before sending any operational transactions.
pub fn get_version_info(env: Env) -> VersionInfo {
let stored: u32 = env.storage().instance().get(&STORAGE_VERSION_KEY).unwrap_or(0);
VersionInfo {
storage_version: stored,
result_schema_version: RESULT_SCHEMA_VERSION,
needs_migration: stored != STORAGE_VERSION,
is_paused: Self::is_paused(&env).unwrap_or(false),
contract_name: symbol_short!("apexcalc"),
}
}| Change | Safe? | Rule |
|---|---|---|
Adding a new field to VersionInfo |
✅ | Append at end; old consumers ignore |
| Adding a new read-only getter | ✅ | Additive surface; no version bump |
Changing STORAGE_VERSION constant |
Must document migration path (migrate()) |
|
Removing a field from VersionInfo |
❌ | Breaking; requires major version bump |
Changing field types in VersionInfo |
❌ | Breaking; requires major version bump |
Before modifying version_negotiation.rs:
- Is this an additive change (new field, new getter)? → No version bump needed
- Is this a breaking change (remove/retype/reorder)? → Bump
RESULT_SCHEMA_VERSION, coordinate with backend team - Does the updated
VersionInfostill satisfy the backend startup handshake contract? - Have you updated
docs/CODEX_CONTEXT.mdif the handshake flow changed?
The rules above cover the single-contract VersionInfo response shape. Changes
to the cross-contract negotiation protocol in
apexchainx_calculator/src/version_negotiation.rs
— VersionNegotiationInfo, NegotiationOutcome,
negotiate_contract_versions(), PROTOCOL_VERSION, or
MIN_COMPATIBLE_PROTOCOL — additionally require the compatibility constraints
and review checklists in
docs/VERSION_NEGOTIATION_CONTRIBUTOR_GUIDE.md.
Every public function in apexchainx_calculator falls into one of three
archetypes. Contributors MUST understand these before adding new functions.
| Archetype | Auth Model | State Impact | Example |
|---|---|---|---|
| Read-Only | Public (no auth) | Zero state mutation | get_config, calculate_sla_view, get_version_info |
| Mutating (Operator) | require_auth() on caller |
Appends to history, updates stats | calculate_sla |
| Privileged (Admin) | require_admin() → require_auth() |
Config, roles, pause, freeze | set_config, pause, set_operator, propose_admin |
- Read-Only functions MUST NOT write to storage under any code path.
They bypass
check_version()andrequire_not_paused(). - Operator functions emit events and update history. They check version and pause state.
- Admin functions check admin role AND version AND emit lifecycle events. They are the only functions that can modify config, roles, or pause state.
### Function: `my_new_function`
| Attribute | Value |
|-----------|-------|
| **Archetype** | Read-Only / Operator / Admin |
| **Auth** | None / Operator / Admin |
| **Storage Writes** | Yes / No |
| **Events Emitted** | `my_evt` (if any) |
| **Schema Impact** | Additive / Breaking / None |Every change to an event payload tuple MUST include a deterministic payload size assertion in the event schema test suite. This prevents accidental payload bloat from breaking backend indexers.
// In event_schema.rs or topic_stability_tests.rs:
#[test]
fn test_sla_calc_payload_size_is_stable() {
// Payload: (outage_id: Symbol, status: Symbol, payment_type: Symbol,
// rating: Symbol, mttr_minutes: u32, threshold_minutes: u32,
// amount: i128)
// = 4 Symbols * 8 bytes + 2 u32 * 4 bytes + 1 i128 * 16 bytes
// = 32 + 8 + 16 = 56 bytes (excluding Soroban encoding overhead)
// This is a maintainership assertion — update when fields change.
const EXPECTED_PAYLOAD_FIELDS: usize = 7;
assert_eq!(EXPECTED_PAYLOAD_FIELDS, 7,
"sla_calc payload field count changed — update this test and notify backend");
}- New/modified event payload has a deterministic field count assertion
- The assertion comment lists every field with its type
- Backend team is notified if the payload grows beyond the expected size
Any change to event names (topic constants) or event payload tuples requires a dedicated event drift review. Event drift is the single most common cause of silent backend breakage — no compilation error, no test failure, just corrupted indexed data.
Before merging a PR that touches any EVENT_* constant or emission site:
- No event name changed without a
EVENT_VERSIONbump ("v1"→"v2") - No payload field removed without a version bump
- No payload field reordered without a version bump
- No payload field type changed without a version bump
- New events are additive — they don't reuse old event names
- All emission sites updated — search for the event name and verify consistency
-
event_schema.rsdoc comment updated — every event's payload schema is documented - Distinctness test updated —
test_event_names_are_distinctincludes new events - Topic stability tests pass —
cargo test topic_stability_tests
| Change | Bump EVENT_VERSION? |
|---|---|
| New event constant added | ❌ No (additive) |
| Existing event renamed | ✅ Yes |
| New field appended to payload | ❌ No (additive) |
| Field removed from payload | ✅ Yes |
| Field reordered in payload | ✅ Yes |
| Field type changed | ✅ Yes |
| Event emission removed | ✅ Yes (major) |
Every code path that writes to HISTORY_KEY MUST be audited for:
- Ordering: New entries are always appended (pushed to end of
Vec), never inserted or prepended. - Retention:
prune_historyandprune_history_by_ageremove only the oldest entries (from the front). The ordering invariant is preserved. - Idempotency: Re-submitting an unchanged
outage_idunder the same config hash returns the stored result without appending a new entry. - Capped growth:
OutageRecalcLimit(16 retained entries per outage) andMAX_HISTORY_SIZE(1000 entries) are enforced.
- New history entries are appended, not inserted or prepended
- Pruning removes from the front (FIFO) and emits
pruned/pruned_a - Duplicate detection (
outage_id+ config hash) is not bypassed -
OutageRecalcLimitis enforced for multi-generation outages -
retention_limitis respected when set - History read functions (
get_history_page,get_history_by_outage,get_latest_by_outage) return correct subsets after modification
Every PR that modifies history write logic MUST include:
- A test that appends entries and verifies order
- A test that prunes entries and verifies retained order
- A test that hits
OutageRecalcLimitand verifies it is enforced
The SLAStats model tracks cumulative on-chain SLA performance metrics.
Newly introduced telemetry counters MUST follow these rules:
- Additive only — new counters are appended to
SLAStatsas new fields at the end. This is NOT breaking (old consumers ignore trailing fields). - Never remove a counter field — removal breaks backend consumers that
deserialise
SLAStatsby position. - Never change a counter's type — type changes (e.g.,
u64→i128) are breaking. - Saturation is explicit — when a counter reaches its type maximum
(e.g.,
u64::MAX), the contract emits astats_satevent. Backends MUST handle saturated counters by switching to off-chain aggregation.
| Field | Type | Description | Saturation Event |
|---|---|---|---|
total_calculations |
u64 |
Total SLA calculations performed | stats_sat with totcalc |
total_violations |
u64 |
Total SLA violations detected | stats_sat with totviol |
total_rewards |
i128 |
Sum of all reward amounts paid | stats_sat with totrew |
total_penalties |
i128 |
Sum of all penalty amounts (stored positive) | stats_sat with totpen |
Per-severity counters (SEVERITY_CALC_COUNTS_KEY, SEVERITY_VIOL_COUNTS_KEY)
are reset on a weekly window boundary. The window boundary is determined by
comparing the current ledger timestamp against the last recorded calculation
or violation ledger entry.
- Reset is explicit — counters are set to zero when the window advances
- Last calculation/violation ledger snapshots are stored per severity to determine when a reset is needed
- Backends should not rely on exact counter values across window boundaries;
use the
SeverityTelemetryview for consistent reads
Role changes are the highest-risk operational area in apexchainx_calculator.
A handoff that is not understood can leave the contract in an unsafe state
during an active incident.
| Operation | Pause State Impact | Migration State Impact | Notes |
|---|---|---|---|
propose_admin + accept_admin |
None — pause state unaffected | None — storage version unaffected | Safe during incident if new admin is trusted |
propose_operator + accept_operator |
None — pause state unaffected | None — storage version unaffected | Operator can be rotated mid-incident |
set_operator (direct) |
None | None | Instant handoff; prefer two-step for audit |
renounce_admin |
Critical — removes admin; no admin can unpause | Critical — no admin can call migrate |
Only call after confirming operator is trusted and no migration is pending |
Before calling renounce_admin:
- Operator address is set and trusted
- No pending admin transfer exists
- No pending migration (
needs_migration == false) - Contract is not paused (or operator does not need to unpause)
- All backend consumers have been notified
- A recovery plan exists (if re-deployment is needed)
Operator changes are safe during active incidents:
calculate_slais the only operator-gated functionset_operatorand two-step operator transfer do not affect history or config- Pause state is not affected by operator changes
After any role change:
- Call
get_admin()to verify the new admin - Call
get_operator()to verify the new operator - Call
get_version_info()to confirmneeds_migration == false - Call
is_paused()to confirm expected pause state - Re-run backend parity tests against the new contract state
Admin Handoff:
propose_admin(A) → accept_admin(A) → verify get_admin() == A
Operator Handoff:
propose_operator(O) → accept_operator(O) → verify get_operator() == O
Direct Operator Set:
set_operator(O) → verify get_operator() == O
Admin Renouncement:
renounce_admin() → verify get_admin() is absent → operator is trusted
Any PR that adds, removes, or renames a const *_KEY: Symbol in
apexchainx_calculator/src/lib.rs MUST satisfy the
Storage Key Migration Checklist
before merging.
Storage keys are permanent on-chain identifiers. A key that is added without
a corresponding migration path leaves upgraded deployments in an undefined
state until migrate() is called — and an absent migrate() arm means the
key is silently missing, leading to unexpected errors or wrong defaults.
| Change | Checklist Required |
|---|---|
New const *_KEY added |
✅ Yes |
| Existing key removed | ✅ Yes |
| Symbol string of a key changed | ✅ Yes (treat as remove + add) |
| Doc comment only | ❌ No |
The PR checklist in CONTRIBUTING.md includes a checkbox for storage key
migration. Reviewers MUST reject PRs that touch the storage key block without
a completed migration checklist note in the PR description.
Every #[contracttype] structure MUST pass a round-trip serialization test. This ensures that the type can be correctly serialized to Soroban's ScVal format and deserialized back without data loss or corruption.
The test test_240_all_contracttype_structures_round_trip_serialization in tests.rs validates all contract-level structures by:
- Creating a sample instance of each
#[contracttype]struct - Serializing it to
ScValusingtry_into_val() - Deserializing it back using
try_into_val() - Asserting that the original and restored values are equal
| Change Type | Requires Test Update | Reason |
|---|---|---|
Adding a new #[contracttype] struct |
✅ Yes | Must add test case for new struct |
| Adding a field to existing struct | ✅ Yes | Must update test to include new field |
| Removing a field | ✅ Yes | Must update test to remove field |
| Changing a field type | ✅ Yes | Must update test with new type |
| Doc-comment only changes | ❌ No | No structural impact |
The test runs as part of the standard cargo test --lib suite. If a #[contracttype] change breaks the round-trip test, the PR must either:
- Fix the serialization issue (e.g., ensure all fields implement required traits)
- Update the test to reflect the new structure
When adding a new #[contracttype] struct:
- Add the struct definition with
#[contracttype]and derives - Add a test case in
test_240_all_contracttype_structures_round_trip_serialization - Ensure the struct implements
Clone,Debug,Eq, andPartialEq - Run
cargo test --lib test_240_all_contracttype_structures_round_trip_serializationto verify
| Issue | Policy Section |
|---|---|
| #279 | SC-500: #[contracttype] Compatibility Note Policy |
| #283 | SC-501: Response-Shape Stability Policy |
| #284 | SC-502: Version Negotiation Protocol — Contributor Note |
| #285 | SC-503: Contract API Archetype Note |
| #286 | SC-504: Event Payload Size Maintainership Check |
| #287 | SC-505: Event Drift Review Note |
| #240 | SC-509: Serialization Compatibility Test Requirement |
| #288 | SC-506: History Write Audit Check |
| #289 | SC-507: Telemetry Counters Policy |
| #290 | SC-508: Role-Change Incident Review Note |
| #266 | SC-509: Storage Key Migration Readiness |