Files
trail-mate/docs/TEAM.md
T

77 KiB
Raw Blame History

What exactly is your TEAM business doing (I can now summarize it to the extent of "writable TeamEvent")

1) Definition of Team (one sentence at the product level)

**Team = an outdoor action unit that is temporarily formed, goal-driven, and disbanded at any time. **

It is not a "group chat" or "ordinary location sharing", but allows a small group of people to continuously answer three things in a non-network environment:

  1. Are we still together (whether the team is scattered/left behind/astray)
  2. What to do next ("action consensus" such as gathering, waiting, asking for help, etc.)
  3. Is anyone having problems (stationary, lost contact, low battery, SOS)

2) Team's core capabilities (which you have determined)

A. Temporary team formation (Create / Join / Disband)

  • Leader creates a team with one click
  • Members pass short code/QR Get TeamKey and join
  • Team can be disbanded at any time (dissolution means the key is destroyed)

You have made this set into a state machine: Create → Active → Rotate → Disband


B. Strong privacy location sharing (your key to differentiation)

You clearly care about:

**After the location is broadcast, only Team members can parse it successfully; even if the Team channel is configured to be transmitted, it cannot be understood. **

So you use two layers of protection:

  • L1: Meshtastic Channel encryption (CH-ENC)
  • L2: Application layer E2EE (TeamKey-derived PosKey/WpKey, TeamEncrypted Envelope)

And you tend to add your own PortNum:

  • TEAM_POSITION_APP
  • TEAM_WAYPOINT_APP
  • TEAM_TRACK_APP
  • Management class TEAM_MGMT_APP

C. "Action command" within Team (Preset / Command)

The difference you proposed:

  • Send default commands to teammates (gather, wait, ask for help...)
  • The command carries necessary parameters (such as the meeting point waypoint)
  • The command is an "event" and does not require typing to chat

Note: You did not ask me to make it a complete agreement, but you have made it clear that this is an important business capability of Team.


D. Team life cycle review (what you want to do now)

You want to review the entire team process, including the implementation of all events in the life cycle.

So we introduce:

  • SD log (append-only)
  • LogRecord(AEAD + CRC)
  • snapshot acceleration (optional)
  • LogKey (independent of TeamKey, configurable whether to retain)

3) "What events will occur" in the Team life cycle (this directly determines TeamEvent)

In order to write TeamEvent protobuf, you need to admit first: TeamEvent is not equal to "all wireless packets", but what happened in the business layer, which can be used to review and explain behavior.

Your current business can be summarized into 5 types of events:

1) Lifecycle event (Lifecycle)

  • Team is created (Leader local)
  • Broadcast discoverable (ADVERTISE)
  • Someone requests to join (JOIN_REQUEST)
  • Leader accepts and issues the configuration (JOIN_ACCEPT)
  • Member confirmation to join the team (JOIN_CONFIRM)
  • Team status synchronization (TEAM_STATUS)
  • Key rotation (KEY_ROTATE)
  • Dismiss/leave (DISBAND / LEAVE)

These events are used to answer:

  • "When does the team start/end"
  • "Who is in the team and when did they join/leave"
  • "When did the key change and why?"

2) Position and trajectory events (Telemetry)

  • Receive a member's position (POSITION_RX)
  • Receive waypoint/rendezvous point (WAYPOINT_RX)
  • Receive track batch points (TRACK_RX)

These events are used for review:

  • When and where people have been
  • Whether the team dispersed and when they reunited

Location events will have a downsampling strategy, otherwise the log will explode.


3) Command event (Command)

  • Collection command (including location waypoint)
  • Wait/Pause
  • Ask for help (SOS, including location/severity level)

These events are used for review:

  • "Who issued the action consensus"
  • "How did the team respond"

4) Connection and visibility events (Presence/Health)

You clearly need this type in the product goal, but we have not refined it into protocol fields before, now we summarize it:

  • A member is "temporarily invisible" (timeout and no location received)
  • Restore visibility
  • Low battery (if you decide to report it)
  • Staying still for too long (if you decide to do native inference events)

These events are used for review:

  • "Why was there no one on the map at that time?"
  • "Is it because the device was out of power/lost contact?"

This type of event can be "local inference" and does not necessarily come from wireless packets.


5) Security/Diagnostics

If you want to make a strong privacy commitment, you must be able to review the "reasons for failure", otherwise users will only feel bugs.

  • Decryption failed (DECRYPT_FAIL)
  • key_id mismatch (KEY_MISMATCH)
  • Replay discard (REPLAY_DROP)
  • Packet format error (DECODE_FAIL)

These events are used for review:

  • Reason for "the package was received but not displayed"

4) I now summarize the boundaries of your TEAM business (I am sure vs I won't assume without permission)

What I can confirm (from what you clearly stated and repeatedly confirmed)

  • Team is a temporary action unit with Create/Join/Disband
  • Location sharing within Team + waypoint
  • Strong privacy: channel isolation + application layer E2EE
  • The entire life cycle events need to be recorded and can be reviewed
  • Are you willing to add a custom PortNum (TEAM_*_APP)

I will not make up for it without authorization (you are not frozen)

  • What are the types of commands and what parameters are included in each command (I can make suggestions, but you need to confirm)
  • Whether member health fields need to be reported (battery, ability, role)
  • Whether to require "unanimous review of the whole team" (you are currently more like a local review/Leader review)

Team establishment/joining message interaction process (strong privacy version)

Participants

L = Leader / team builder device
M = Member / team member device
O = Others / spectator device (non-Team member)

Channel

CH0 = Default public channel (Primary Channel)
CHT = Team private channel (new/assigned channel) index)

Security layer

CH-ENC = Meshtastic per-Channel link layer encryption
E2EE = TeamKey-derived application layer end-to-end encryption
         (Envelope: TeamEncrypted)

Overview: Timing line diagram (ASCII)

Time ↓

L (Leader)                  M (Member)                   O (Others)
────────────────────────────────────────────────────────────────────────

(0) Local team building (no message)
L: Generate TeamKey
L: TeamId = Trunc(Hash(TeamKey))
L: Select/Create CHT
L: ChannelPSK = KDF(TeamKey, "channel-psk")
L: MgmtKey    = KDF(TeamKey, "mgmt")
L: PosKey     = KDF(TeamKey, "pos")
L: WpKey      = KDF(TeamKey, "wp")

────────────────────────────────────────────────────────────────────────

(1) Team can be "discovered" (without leaking secrets)
CH0
L  ─────── TEAM_ADVERTISE ───────▶  * 
      { team_id, join_hint?, channel_index?, nonce/ts }

 O: Can only know "there is a Team nearby"
 Unable to obtain any key or location

────────────────────────────────────────────────────────────────────────

(2) Team members request to join (without key)
CH0
M  ───── TEAM_JOIN_REQUEST ─────▶  L
      { team_id, member_pub?, nonce/ts }

────────────────────────────────────────────────────────────────────────

(3) Leader accepts joining and issues Team configuration
CH0
L  ───── TEAM_JOIN_ACCEPT ─────▶  M
      {
        team_id,
        payload = E2EE(MgmtKey, {
                     channel_index = CHT,
                     channel_psk   = ChannelPSK,
                     key_id        = current_key_id,
                     team_params?  (freq / precision / timeout)
                   }),
        nonce/ts
      }

Note:
- At this time M **must have obtained TeamKey** through UI**
 (short code / QR / short range method)
- Otherwise, MgmtKey / payload cannot be decrypted

────────────────────────────────────────────────────────────────────────

(4) Players switch to Team locally
M: Save TeamKey
M: Derive MgmtKey / PosKey / WpKey
M: Save CHT + ChannelPSK
M: Switch to CHT

────────────────────────────────────────────────────────────────────────

(5) Team confirmation (recommended)
CHT (CH-ENC)
M  ───── TEAM_JOIN_CONFIRM ─────▶  L
      {
        team_id,
        payload = E2EE(MgmtKey, {
                     ok,
                     capabilities?,
                     battery?
                   }),
        nonce/seq
      }

────────────────────────────────────────────────────────────────────────

(6) Team Status synchronization (optional)
CHT (CH-ENC)
L  ─────── TEAM_STATUS ───────▶  Team
      {
        team_id,
        payload = E2EE(MgmtKey, {
                     member_list_hash,
                     key_id,
                     team_params
                   }),
        nonce/seq
      }

────────────────────────────────────────────────────────────────────────

(7) Location sharing within Team (core)
CHT (CH-ENC)
Each Member ── TEAM_POSITION_APP ──▶ Team
      payload = TeamEncrypted {
                  team_id,
                  key_id,
                  nonce,
                  ciphertext = AEAD(
                      PosKey,
                      protobuf(meshtastic_Position),
                      aad = header
                  )
               }

External members of Team:
- Even if you get CHT + ChannelPSK
- You can only decrypt CH-ENC
- No TeamKey / PosKey → Unable to decrypt the location

Key semantic explanation (very important)

1️⃣ Why TEAM_ADVERTISE does not contain a key

This is "discover" not "join":

  • Lets people know "there is a Team nearby"
  • but does not grant any abilities
  • Obtain permissions to prevent passive monitoring

2️⃣ Why TeamKey does not spread through wireless clear text

This is the core of your "Strong Privacy Commitment":

  • TeamKey Only propagates via UI side channels (short code / QR / Proximity)
  • Only TeamKey derivatives propagated over the air
  • TeamKey cannot be reversed even if the channel is configured to outbound

3️⃣ Why divide ChannelPSK / MgmtKey / PosKey

This is an "evolvable design":

  • ChannelPSK: Just care about "who can receive this package"
  • MgmtKey: Manage member management/parameters
  • PosKey/WpKey: Manage location/waypoint

👉 In the future you can do:

  • Change the location key ≠ Kick someone
  • Change management key ≠ Affect historical data

4️⃣ Why TEAM_JOIN_CONFIRM

Not for security, but for product perceptibility:

  • Leader knows who has really successfully joined the team
  • UI can display "Members are ready"
  • Follow-up Team Only the state can be trusted

MVP and enhancement item division (helping you control complexity)

MVP is required (should be included in the first version)

  • (0) Local team building + TeamKey derivation
  • (1) TEAM_ADVERTISE
  • TeamKey input/scan code on UI side
  • (3) TEAM_JOIN_ACCEPT
  • (4) Switch to CHT
  • (7) TEAM_POSITION_APP(E2EE)

Optional enhancements (to be added later)

  • member_pub + stronger key exchange
  • TEAM_JOIN_CONFIRM
  • TEAM_STATUS
  • key rotation / key_id update
  • Anti-replay window optimization

Team Management Protocol

Message Definitions (Protobuf-Level)


Public convention (applicable to all Team messages)

Team Identity

  • TeamKey

  • High entropy random generation

  • Only distributed through UI side (short code/QR/close range)

  • MUST NOT Transmission via wireless clear text

  • team_id

  • Definition: Trunc(Hash(TeamKey))

  • Purpose: to identify Team without leaking TeamKey

  • MUST Appear in all Team-related packets


Encryption level

  • CH-ENC: Meshtastic Channel Encryption
  • E2EE: Application Layer Team Encryption (AEAD)

TeamEncrypted Envelope (generic)

for all E2EE payloads (location/admin/status)

Field Type Level Description
version uint32 MUST Envelope version
team_id bytes MUST Team Identification
key_id uint32 MUST Current key version
nonce bytes MUST unique per package, for AEAD
ciphertext bytes MUST AEAD encrypted data
aad_flags uint32 MAY AAD type identifier

1. TEAM_ADVERTISE

PURPOSE: Makes Team "discoverable", does not grant any abilities

  • Channel: CH0
  • Encryption: Can be plain text or CH0 encrypted
  • PortNum:TEAM_MGMT_APP

Field table

Field Type Level Description
team_id bytes MUST Team identification
join_hint uint32 MAY Join prompt (requires confirmation/validity period, etc.)
channel_index uint32 MAY Team Channel index
expires_at uint64 MAY Ad expiration time
nonce bytes MUST Anti-replay

Semantic rules

  • MUST NOT Contain any key material
  • MUST NOT Disclose location or membership information
  • MAY Periodic broadcast

2. TEAM_JOIN_REQUEST

Purpose: Team members request to join Team (no key)

  • Channel: CH0
  • PortNum:TEAM_MGMT_APP

Field table

Field Type Level Description
team_id bytes MUST Target Team
member_pub bytes MAY Public key (for enhanced key exchange)
capabilities uint32 MAY Capability identifier
nonce bytes MUST Anti-replay

Semantic rules

  • MUST NOT Contains TeamKey
  • MAY Rejected by Leader (no response)

3. TEAM_JOIN_ACCEPT

Purpose: Leader accepts members and delivers Team configuration

  • Channel: CH0
  • PortNum:TEAM_MGMT_APP
  • Encryption: E2EE (MgmtKey)

Payload (decrypted structure)

Field Type Level Description
channel_index uint32 MUST Team Channel
channel_psk bytes MUST Channel PSK
key_id uint32 MUST Current key version
team_params TeamParams MAY Behavior parameters

Outer field table

Field Type Level Description
team_id bytes MUST Team logo
payload TeamEncrypted MUST E2EE packaging
nonce bytes MUST Anti-replay

Semantic rules

  • Recipient MUST already has TeamKey
  • Decryption failed MUST Discard
  • MUST NOT Apply configuration before TeamKey is confirmed

4. TEAM_JOIN_CONFIRM

Purpose: Member confirmation has successfully joined the team

  • Channel: CHT
  • PortNum:TEAM_MGMT_APP
  • Encryption: CH-ENC + E2EE (MgmtKey)

Payload (after decryption)

Field Type Level Description
ok bool MUST Joined successfully
capabilities uint32 MAY capability statement
battery uint32 MAY battery percentage

5. TEAM_STATUS

Purpose: Synchronize the current status of Team

  • Channel: CHT
  • PortNum:TEAM_MGMT_APP
  • Encryption: CH-ENC + E2EE (MgmtKey)

Payload (after decryption)

Fields Type Level Description
member_list_hash bytes MUST Member list summary
key_id uint32 MUST Current key version
team_params TeamParams MAY Current parameters

6. TEAM_POSITION_APP

Purpose: Position sharing within Team (strong privacy)

  • Channel: CHT
  • PortNum:TEAM_POSITION_APP
  • Encryption: CH-ENC + E2EE (PosKey)

Payload (E2EE plain text structure)

Field Type Level Description
position meshtastic_Position MUST Location Data
precision_level uint32 MAY precision level
timestamp uint64 MUST location time

Outer layer (TeamEncrypted)

Field Type Level Description
team_id bytes MUST Team logo
key_id uint32 MUST location key version
nonce bytes MUST unique per package
ciphertext bytes MUST AEAD ciphertext

7. TEAM_WAYPOINT_APP

Purpose: Team internal waypoint sharing

  • Exactly the same as TEAM_POSITION_APP
  • Use WpKey Decryption
  • The plaintext structure is meshtastic_Waypoint

8. TEAM_TRACK_APP

Purpose: Track batch point sharing within Team (fixed interval)

  • Exactly the same as TEAM_POSITION_APP (E2EE PosKey)
  • The plain text structure is TeamTrackMessage (custom lightweight encoding)
  • Track points do not have a single point timestamp, and the time is determined by start_ts + i * interval_s derivation
  • valid_mask is used to mark whether each point is valid (it can be set to 0 when there is no fix)
  • A single package can have up to 20 points
  • Sender suggestion: Start a 10-minute sampling window after the team keys are ready; sample every 30 seconds, a total of 20 points; if all points in the window are invalid, no packet will be sent
  • Receiver placement: /team/<team_dir>/tracks/<member_id>.gpx (GPX 1.1, incremental addition <trkpt>)

Payload (E2EE plain text structure)

Field Type Level Description
Version uint8 MUST Version
start_ts uint32 MUST Start time (epoch seconds)
interval_s uint16 MUST Sampling interval (seconds)
count uint8 MUST Points (<= 20)
valid_mask uint32 MUST Point validity bitmap (bit i corresponds to the i-th point)
lon_e7 int32) * N MUST Latitude and longitude (E7)

9. MUST / SHOULD / MAY Summary (Conformance Checklist)

MUST

  • TeamKey must not be transmitted in clear text over wireless
  • All Team data MUST contain team_id
  • All E2EE payloads MUST use AEAD
  • nonce MUST be unique (anti-replay)
  • When disbanding a Team MUST discard all derived keys

SHOULD

  • Use a separate PosKey / WpKey
  • supports key_id rotation
  • JOIN_CONFIRM / TEAM_STATUS for UI synchronization

MAY

  • Use member_pub for stronger key exchange
  • Support permission classification
  • Unify payload length to reduce association risk

One-sentence protocol definition (can be written in the file header)

**The Team protocol defines an ad hoc, goal-driven encrypted collaboration unit with a security perimeter centered on TeamKey rather than a wireless channel. **


Goal: What do you want to promise to users

Privacy commitment (you can write it in PRD / README):

  1. Only Team members can decipher Team position and waypoint (even if others get the channel configuration/PSK, they cannot decrypt it)
  2. Team Content cannot be recovered after disbandment (key discarded)
  3. Outsiders cannot infer member locations from the package content (ciphertext + randomization + anti-replay)

Okay, in this step we draw a clear picture of the Team's life. It is not a "functional flow chart", but a state machine - when is it in what state, what event occurs due to the transition, and what must be done during the transition.

You can put the ASCII state machine diagram below directly into the protocol document or PRD.


Team life cycle state machine (State Machine)

                           ┌─────────────────────────┐
                           │         (Idle)          │
                           │     No Active Team      │
                           └───────────┬─────────────┘
                                       │
                          Create Team  │  (UI action)
                                       │
                                       ▼
┌─────────────────────────────────────────────────────────────┐
│                         CREATE                              │
│                                                             │
│  Entry actions (local only):                                │
│   - Generate TeamKey (high entropy)                         │
│   - Derive team_id = Trunc(Hash(TeamKey))                   │
│   - Allocate / select Team Channel (CHT)                    │
│   - Derive keys:                                            │
│       ChannelPSK = KDF(TeamKey, "channel-psk")              │
│       MgmtKey    = KDF(TeamKey, "mgmt")                     │
│       PosKey     = KDF(TeamKey, "pos")                      │
│       WpKey      = KDF(TeamKey, "wp")                       │
│                                                             │
│  Exit condition:                                            │
│   - Leader confirms creation                                │
└───────────┬─────────────────────────────────────────────────┘
            │
            │  TEAM_ADVERTISE (CH0)
            │
            ▼
┌─────────────────────────────────────────────────────────────┐
│                         ACTIVE                              │
│                                                             │
│  Team exists and is operational                             │
│                                                             │
│  Ongoing behaviors:                                         │
│   - Broadcast TEAM_ADVERTISE (CH0, optional)                │
│   - Accept TEAM_JOIN_REQUEST (CH0)                          │
│   - Send TEAM_JOIN_ACCEPT (CH0, E2EE MgmtKey)               │
│   - Operate on Team Channel (CHT):                          │
│       * TEAM_JOIN_CONFIRM                                   │
│       * TEAM_STATUS                                         │
│       * TEAM_POSITION_APP (E2EE PosKey)                     │
│       * TEAM_WAYPOINT_APP (E2EE WpKey)                      │
│       * TEAM_TRACK_APP (E2EE PosKey)                        │
│                                                             │
│  Valid transitions:                                         │
│   - Rotate keys                                             │
│   - Disband team                                            │
└───────────┬───────────────────────┬─────────────────────────┘
            │                       │
            │ Rotate Team Key       │ Disband Team
            │ (Leader action)       │ (Leader or policy)
            │                       │
            ▼                       ▼
┌─────────────────────────────────────────────────────────────┐
│                         ROTATE                              │
│                                                             │
│  Purpose:                                                    │
│   - Mitigate key leakage                                    │
│   - Exclude lost / untrusted members                        │
│                                                             │
│  Entry actions (Leader):                                    │
│   - Increment key_id                                        │
│   - Generate new subkeys:                                   │
│       MgmtKey', PosKey', WpKey'                              │
│                                                             │
│  Protocol actions:                                          │
│   - Broadcast key update via TEAM_STATUS (E2EE MgmtKey)     │
│   - Optionally re-issue TEAM_JOIN_ACCEPT to valid members   │
│                                                             │
│  Member behavior:                                           │
│   - Switch to new key_id                                    │
│   - Drop packets with old key_id                            │
│                                                             │
│  Exit condition:                                            │
│   - All active members synced OR timeout                    │
└───────────┬─────────────────────────────────────────────────┘
            │
            │ Rotation complete
            │
            ▼
┌─────────────────────────────────────────────────────────────┐
│                         ACTIVE                              │
│                   (with new key_id)                         │
└─────────────────────────────────────────────────────────────┘


            (from ACTIVE or ROTATE)
            │
            │ Disband Team
            │
            ▼
┌─────────────────────────────────────────────────────────────┐
│                        DISBAND                              │
│                                                             │
│  Entry actions (Leader):                                    │
│   - Broadcast TEAM_END / final TEAM_STATUS (optional)       │
│                                                             │
│  Mandatory local actions (ALL members):                     │
│   - Immediately discard:                                    │
│       * TeamKey                                             │
│       * ChannelPSK                                          │
│       * MgmtKey / PosKey / WpKey                             │
│   - Stop all Team broadcasts                                │
│   - Leave Team Channel (CHT)                                │
│                                                             │
│  Exit condition:                                            │
│   - Return to Idle                                          │
└───────────┬─────────────────────────────────────────────────┘
            │
            │
            ▼
┌─────────────────────────┐
│         (Idle)          │
│     No Active Team      │
└─────────────────────────┘

State semantic description (this is more important than the diagram itself)

1️⃣ Idle

  • The device** does not hold any TeamKey**
  • Does not broadcast the Team location
  • The UI displays "Not Team Created/Not Joined"

2️⃣ Create

  • Purely local state
  • No wireless security risks
  • All "roots of trust" are generated at this moment

This is the birth point of Team security boundary


3️⃣ Active

  • Team's Main life cycle
  • All Team functions (maps, locations, commands) are only legal in this state
  • TeamKey is the only source of trust

4️⃣ Rotate

  • It is not an abnormal state, it is an active defense state
  • Used to fulfill your words to users:

"Even if external transmission is configured, the loss can be stopped immediately"

Key points:

  • Rotate Do not change team_id
  • Only change key_id + derived key
  • The old key becomes invalid immediately

5️⃣ Disband

  • Safe termination state
  • All keys must be destroyed
  • "Restore", "Review" and "Reconnect" are not allowed

Team End ≠ Pause Team End = Cryptographic Death


You can directly write the MUST rules of the protocol (from the state machine)

  • MUST Only send Team data in the ACTIVE / ROTATE state
  • MUST Destroy all Team keys on DISBAND entry
  • MUST Drop packets with mismatched key_id
  • MUST NOT Return from DISBAND to ACTIVE (the team must be re-established)

Why this state machine "stands"

  • Product layer: Each state can be mapped to a clear UI
  • Protocol layer: Each transition has a clear message or local action
  • Security layer: The key life cycle is completely consistent with the state life cycle

Very good. This step is actually to align "what the user clicked" and "what happened in the protocol" one by one**. Below I will write strictly according to UI behavior → status change → message trigger** without introducing new concepts or ambiguity.

You can understand this content as:

**UI is the control panel of the state machine, and protocol messages are the side effects of state transitions. **


UI behavior ↔ Team state machine ↔ Message trigger precise mapping


1. UI overview: three "user-perceivable states" of the Team menu

From a UI perspective, Team actually has only three states (one-to-one correspondence with the protocol state):

UI display status Agreement status What users can do
Team has not been established Idle Create team / join
Team in progress Active View / share / disband
Disbanding Disband None (system action)

Rotate is Active The sub-actions within are usually Leader-only, not the normal user main process.


2. UI behavior 1: Click "Create Team"

UI behavior

Menu → Team → [Create Team]

Corresponding state machine transition

Idle ──(Create Team)──▶ Create ──▶ Active

Message and action mapping (step by step)

Step 1: The user clicks "Create Team" (no message)

UI Behavior

  • User confirms "I want to build a Team"

Local Action (MUST)

  • Generate TeamKey

  • Calculate team_id = Trunc(Hash(TeamKey))

  • Assign/Select CHT

  • Derived key:

    • ChannelPSK
    • MgmtKey
    • PosKey
    • WpKey

📌 There is no wireless message at this time 📌 This is the starting point of the Team security boundary


Step 2: UI displays "Team has been created/shared code"

UI Behavior

  • Displays Team Code / QR
  • Displays "Number of members: 1"

Protocol action

  • Enter Active state
  • Start periodic (or one-time) broadcast:
CH0 → TEAM_ADVERTISE
TEAM_ADVERTISE {
  team_id,
  join_hint?,
  channel_index?,
  nonce
}

📌 This is the only protocol basis for UI to display "Team already exists"


Step 3: UI returns to Team main page (Active)

UI Behavior

  • Display:

    • Team Active
    • Members: 1
    • [View Team Map]
    • [Share Team]
    • [Disband Team]

Protocol Behavior

  • Allow and process the following messages:

    • TEAM_JOIN_REQUEST
    • TEAM_JOIN_ACCEPT
    • TEAM_POSITION_APP

3. UI Behavior 2: Others join Team (Join Team)

UI Behavior (Team Team)

Team → Join Team → Enter the short code / scan the code → Confirm

Message and status mapping

Step 1: Team members enter short code/scan code (no message)

UI Behavior

  • User confirms joining

Local Action (MUST)

  • Parse/obtain TeamKey
  • Precompute team_id
  • Derive MgmtKey / PosKey / WpKey

📌 Without TeamKey, any subsequent JOIN messages are meaningless


Step 2: Team members request to join

CH0 → TEAM_JOIN_REQUEST
TEAM_JOIN_REQUEST {
  team_id,
  nonce
}

Step 3: "Join request" appears in Leader UI

UI behavior (Leader)

  • Display "New member requesting to join"
  • [Accept] / [Ignore]

📌 UI event Direct binding Whether to send the next message


Step 4: Leader clicks "Accept"

CH0 → TEAM_JOIN_ACCEPT
TEAM_JOIN_ACCEPT {
  team_id,
  payload = E2EE(MgmtKey, {
    channel_index,
    channel_psk,
    key_id,
    team_params?
  }),
  nonce
}

Step 5: Team UI switches to "Team Active"

UI Behavior

  • Display Team Main page
  • Members ≥ 2

Local action

  • Save CHT + ChannelPSK
  • Switch to CHT

Protocol action (recommended)

CHT → TEAM_JOIN_CONFIRM

4. UI behavior 3: Team in progress (no button, but continuous behavior)

UI behavior

Team Active → View Team Map

Protocol behavior (persistent)

  • Each member periodically sends:
CHT → TEAM_POSITION_APP
  • UI map refresh Only relies on successfully decrypted payload

  • Decrypted failed packets:

  • MUST be discarded

  • MUST NOT be updated UI


5. UI Behavior 4: Click "Disband Team" [Leader]

UI behavior

Team Active → [Disband Team] → Confirm

Corresponding state machine transition

Active ──(Disband)──▶ Disband ──▶ Idle

Message and action mapping

Step 1: Leader clicks "Disband"

UI Behavior

  • Display a strong confirmation pop-up window

Step 2: Leader confirms disbandment

Protocol action (optional but recommended)

CHT → TEAM_STATUS / TEAM_END
TEAM_STATUS {
  team_id,
  payload = E2EE(MgmtKey, {
    state = DISBAND
  })
}

📌 This is notification nature, not security critical 📌 Even if it is not received, it does not affect security


Step 3: All devices enter Disband

Local action (MUST, all members)

  • Destroy immediately:

    • TeamKey
    • ChannelPSK
    • MgmtKey / PosKey / WpKey
  • Stop all Team messages

  • Leave CHT


Step 4: UI returns to the unestablished team state

Team → You are not in a Team
[Create Team] [Join Team]

📌 UI It is the "visual confirmation" that the key destruction is completed


6. UI Behavior 5: Ordinary members "leave Team (Leave)" [non-disbanded]

This is a typical example of UI behavior ≠ protocol status change

UI behavior

Team Active → [Leave Team]

Local action (MUST)

  • Destroy all TeamKey / derived keys
  • Leave CHT

Protocol action (MAY)

CHT → TEAM_LEAVE_NOTICE

Only used for UI synchronization, does not affect Team survival

📌 Team status is still Active (for others)


7. Key design principles (can be written into PRD)

  1. UI Behavior always precedes messages Messages are just "side effects of user intentions"

  2. No TeamKey → UI is not allowed to enter Active Security failure must be expressed as "operation unreachable"

  3. Dismissal is a key behavior, not a notification behavior Message failure ≠ Dissolution failure


Summarize this mapping relationship in one sentence

**UI Determine status, The status determines the key, The key determines which messages are "meaningful". **


Next step (very natural)

If you are willing, I can help you do one of them directly in the next step:

  1. Write this set of mappings as a "UI × Protocol comparison table" (convenient for testing and PR)
  2. Help you design the LVGL component tree of the Team page in different states
  3. Convert these UI behaviors into automated test case descriptions

At this step, you are already doing system-level product design, not "functional assembly".

Okay, we will do this step completely from the perspective of "embedded UI engineering". The goal is not to "look good", but to have clear status, stable components, and not evolve out of control**.

What I give you below is the LVGL Component Tree, not the layout sketch; You can directly disassemble .cpp / .h accordingly, or reconstruct it based on your current UI architecture.


Team page · LVGL component tree design

(Clear branches by status, rather than piled together if/else conditions)


Overall design principles (give you "architectural constraints" first)

1️⃣ Team page = State container (not functional container)

TeamPage
└── TeamStateContainer // Only responsible for: Idle / Active / Disband
  • Always only mount one subtree
  • State switching = delete old subtree + create new subtree
  • Not in the same tree hide/show

👉 This is the key to prevent the LVGL page from "turning into a mountain of shit"


2️⃣ The page is divided into three mutually exclusive trees

TeamState = { IDLE, ACTIVE, DISBAND }
  • IdleTree
  • ActiveTree
  • DisbandTree (transient, usually very short)

1. Team page root node (common to all states)

TeamPage (lv_obj_t*)
├── TopBar
│   ├── BackButton
│   └── TitleLabel ("Team")
│
└── TeamStateContainer (lv_obj_t*)
    └── <StateSpecificTree>

Description:

  • TopBar exists forever
  • TeamStateContainer is the only variable area**

2. State 1: Unestablished Team (IDLE)

User Mind

"I am not in any Team now" "


Component tree: IdleTree

IdleTree (lv_obj_t*)
├── CenterContainer
│ ├── StatusIcon // Simple icon: empty team
│   ├── StatusLabel       // "You are not in a Team"
│   └── Spacer
│
├── ActionContainer
│ ├── CreateTeamButton // Primary button
│ └── JoinTeamButton // Secondary button

Component Responsibility Description

  • CreateTeamButton

  • Click → Trigger UI behavior: Create Team

  • Subsequent status switching is determined by the controller

  • JoinTeamButton

  • Jump to the Join process page (enter code/scan code)

  • Switch to ACTIVE after success

📌 IdleTree does not care about any protocol or key


3. State 2: Team In progress (ACTIVE)

This is the longest staying and most important state.

User Mind

"I am in a Team, I can see the status and make decisions."


Component tree: ActiveTree (complete)

ActiveTree (lv_obj_t*)
├── SummaryCard
│   ├── TeamStatusLabel     // "Team Active"
│   ├── MemberCountLabel   // "Members: N"
│   └── PrivacyBadge       // "Strong Privacy / E2EE"
│
├── Divider
│
├── ActionList
│   ├── ViewMapItem        // → Team Map
│ ├── ShareTeamItem // → Display Team Code / QR
│   └── (optional) RotateKeyItem  // Leader only
│
├── Divider
│
└── DangerZone
    └── DisbandButton / LeaveButton

Key: Leader / Member Branch (not different pages)

Ordinary members (Member)

DangerZone
└── LeaveTeamButton

Leader (Team Builder)

DangerZone
└── DisbandTeamButton

⚠️ Do not expose the "Leader" concept at the UI layer Only reflected by the presence or absence of buttons


"Protocol trigger points" of each component

ViewMapItem

  • Just do one thing: jump to Team Map Page

  • Team Map page only consumes:

    • TEAM_POSITION_APP
    • TEAM_WAYPOINT_APP
    • TEAM_TRACK_APP

ShareTeamItem

  • Enter Share Subpage

  • Read-only data:

    • Team Code
    • QR
  • Do not trigger any message

RotateKeyItem(Leader-only)

  • Click → Enter the confirmation page

  • After confirmation:

  • Trigger Rotate status

  • Send TEAM_STATUS (new key_id)

DisbandTeamButton

  • Click → Enter DisbandTree

4. State 3: Disbanding (DISBAND)

This is a transient state, but very important.

User Mind

"This matter is irreversible."


Component tree: DisbandTree

DisbandTree (lv_obj_t*)
├── WarningIcon
├── WarningLabel
│   └── "Disbanding Team..."
│
└── ProgressIndicator

Behavior binding (key)

  • While entering DisbandTree

  • Local destruction immediately:

    • TeamKey
    • ChannelPSK
  • All derived keys

  • Stop Team messages

  • Do not wait for network results

  • UI dwell time is extremely short (e.g. 300500ms)


After Disband is completed

DISBAND → IDLE
  • Destroy DisbandTree
  • Create IdleTree

void TeamPage::setState(TeamState newState) {
    if (currentTree) {
        lv_obj_del(currentTree);
        currentTree = nullptr;
    }

    switch (newState) {
    case IDLE:
        currentTree = buildIdleTree(container);
        break;
    case ACTIVE:
        currentTree = buildActiveTree(container);
        break;
    case DISBAND:
        currentTree = buildDisbandTree(container);
        break;
    }
}

📌 No show/hide 📌 No if (isLeader) scattered everywhere 📌 All state boundaries in one place


6. Three "hard advantages" of this component tree design

  1. State and UI are completely isomorphic State machine = UI structure, not comments

  2. Protocol failure will not pollute the UI

  • Dismissal does not depend on message success
  • UI only reflects local key status
  1. Future expansion will not explode
  • Add Team Settings
  • Adding Key Rotation
  • Adding Debug Info is just adding one to ActiveTree

---Your "pull back" action is very correct. We are now clearly doing only one thing:

**Revolving around the "team building state machine + key exchange process", what data must be persisted? Which ones must never be persisted? Which ones are status? **

Don't talk about UI, don't talk about maps, don't talk about future expansion, just talk about data and life cycle.


Team building & key exchange phase

Data persistence organization (strictly according to state machine)


1. First give the conclusion (general table)

Not everything involved in team building should be persistent The only criterion for persistence is: 👉 "Can the device continue to be in the same Team state after restarting?"

The data is divided into three categories:

Category Whether to persist Reason
Team identity and key Required Otherwise, restart = automatic exit
Channel configuration Required Otherwise, Team packets cannot be received
Protocol transient state Should not be Can be reconstructed through messages
UI / Interactive state Should not Completely presentation layer

2. State-by-state analysis by state machine


State 0: Idle (not team established)

Persistent data

**None. **

(no Team-related persistent data)

Principles

  • In the Idle state there is no trace of Team on the device
  • This is the starting point of the security and psychological boundaries

State 1: Create (local team building, not yet Active)

This is a very short-lived state only exists once in the UI In operation

Is persistence required?

Not required

Reason

  • Create is an atomic operation
  • Either successfully enter Active
  • or fail and return to Idle

👉 Create phase failure = no trace left


State 2: Active (Team in progress)

This is the only core state that needs to be persisted.


1️⃣Team identity class (must be persisted)

TeamIdentity
├── team_id          (bytes)
├── team_role        (enum: LEADER / MEMBER)
├── key_id           (uint32)

Description

  • team_id: Index key for all Team data
  • team_role: Influence UI and allowed actions
  • key_id: Current key version (used for decryption judgment)

MUST persist


2️⃣ Team key class (must be persisted and stored securely)

TeamSecrets
├── team_key (bytes) // Root key
├── mgmt_key         (bytes)
├── pos_key          (bytes)
├── wp_key           (bytes)

Description

  • Derived key can be recalculated, but not recommended
  • Recalculation relies on KDF version consistency, high risk
  • In practice, direct storage of derived results is more stable

MUST persist ⚠️ Safe storage (NVS / encrypted storage) must be used


3️⃣ Channel configuration class (must be persisted)

TeamChannelConfig
├── channel_index    (uint8 / uint32)
├── channel_psk      (bytes)

Description

  • You must be able to re-listen to CHT after restarting
  • Otherwise, "logically still exists" Team, physically unable to hear"

MUST persist


TeamParams
├── position_interval_ms
├── position_precision_level
├── advertise_enabled
├── join_policy

Description

  • These parameters are determined by the Leader
  • The member side is just executed

🟡 SHOULD persist (it can run without saving, but the experience is inconsistent)


5️⃣ Things that should not be persisted (very important)

Protocol Transient Status

DO NOT persist:
- JOIN_REQUEST sent but not acknowledged
- JOIN_CONFIRM status
-Last TEAM_STATUS content
- Member list (can be reconstructed via broadcast)

Reason:

  • All are soft state
  • Natural recovery after power outage/restart

UI status

DO NOT persist:
- Whether you are currently on the Team page
- Whether to expand a submenu
- Whether the QR has just been displayed

State 3: Rotate (key rotating)

Rotate is a sub-state of Active

Persistence strategy: Two-phase commit

Temporary state (not persisted)

RotateContext (RAM only)
├── new_key_id
├── new_mgmt_key
├── new_pos_key
├── new_wp_key

The moment the switch is successful (persistence)

TeamSecrets (overwrite)
├── key_id = new_key_id
├── mgmt_key = new_mgmt_key
├── pos_key  = new_pos_key
├── wp_key   = new_wp_key

📌 Do not persist "in rotation" 📌 Either overwrite successfully or keep the old key

This is the key to avoid "half-rotation" caused by power outage/abnormality**.


State 4: Disband (disband)

Behavior rules (mandatory)

Disband = data destruction

Persistence actions that must be performed

DELETE persistent:
- TeamIdentity
- TeamSecrets
- TeamChannelConfig
- TeamParams

Disallowed Behavior

  • Not allowed to mark "disbanded but recoverable"
  • Not allowed to retain team_id
  • Not allowed to cache old keys

📌 Disband is cryptographically dead


3. Persistence timing in the Join process (accurate)

Member device (Member)

Time Whether to persist Content
Enter short code (RAM only)
Send JOIN_REQUEST
Receive JOIN_ACCEPT and decrypt successfully TeamIdentity + Secrets + Channel
Switched to CHT successfully Same as above
JOIN_CONFIRM

Leader

Time Whether to persist Content
Create Team Full Team data
Accept new members
Rotate successful Overwrite key
Disband (delete)

4. A one-sentence rule that you can write directly into the protocol/implementation document

**Only "data that defines Team identity continuity" is allowed to be persisted; All message-level and interaction-level states must be discardable. **


5. A very practical engineering suggestion (important)

Make Team persistence into a "single object"

PersistentTeamState
├── identity
├── secrets
├── channel
├── params
  • Have this object → Team = Active
  • There is no such object → Team = Idle

📌 Do not split into multiple KV 📌 Do not use flag to indicate "whether you are in Team"


1️⃣ Team building process (Leader)

Persistent read and write sequence diagram

Time ↓

Leader Device
──────────────────────────────────────────────────────────────

[UI] Click "Create Team"
        |
        v
📦 RAM: generate TeamKey
📦 RAM: derive team_id, ChannelPSK, MgmtKey, PosKey, WpKey
📦 RAM: select Team Channel (CHT)

        |
        |  (still NOT persistent)
        |
        v
[UI] Confirm Create
        |
        v
💾 PERSIST: write PersistentTeamState
    ├── identity.team_id
    ├── identity.role = LEADER
    ├── identity.key_id = 0
    ├── secrets.team_key
    ├── secrets.mgmt_key
    ├── secrets.pos_key
    ├── secrets.wp_key
    ├── channel.channel_index = CHT
    ├── channel.channel_psk
    └── params (optional)

        |
        v
[STATE] Team = ACTIVE
        |
        v
[PROTO] start TEAM_ADVERTISE (CH0)
[PROTO] accept JOIN_REQUEST
[PROTO] send JOIN_ACCEPT

Key rules (must be followed)

  • Never write Flash before UI Confirm
  • First persistence = Team officially exists
  • 💡 Power off before Confirm → Automatically return to Idle (no residue)

2️⃣ Join the process (Member)

Persistent read and write sequence diagram (the most likely place for bugs)

Time ↓

Member Device
──────────────────────────────────────────────────────────────

[UI] Click "Join Team"
        |
        v
[UI] Input Code / Scan QR
        |
        v
📦 RAM: obtain TeamKey
📦 RAM: derive team_id, MgmtKey, PosKey, WpKey

        |
        |  (still NOT persistent)
        |
        v
[PROTO] send TEAM_JOIN_REQUEST (CH0)
        |
        v
[PROTO] receive TEAM_JOIN_ACCEPT (CH0)

        |
        v
[SEC] try decrypt JOIN_ACCEPT payload
        |
        |-- decryption FAIL --> ❌ abort (NO write)
        |
        v
📦 RAM: extract channel_index, channel_psk, key_id

        |
        |  (still NOT persistent)
        |
        v
[PROTO] switch to Team Channel (CHT)
        |
        |-- switch FAIL --> ❌ abort (NO write)
        |
        v
💾 PERSIST: write PersistentTeamState
    ├── identity.team_id
    ├── identity.role = MEMBER
    ├── identity.key_id
    ├── secrets.team_key
    ├── secrets.mgmt_key
    ├── secrets.pos_key
    ├── secrets.wp_key
    ├── channel.channel_index
    ├── channel.channel_psk
    └── params (optional)

        |
        v
[STATE] Team = ACTIVE
        |
        v
[PROTO] send TEAM_JOIN_CONFIRM (CHT)
[PROTO] start TEAM_POSITION_APP

Key rules (this is the key point)

  • JOIN_ACCEPT received ≠ can be written to Flash
  • Decryption successful ≠ can be written to Flash
  • Only after "Successful switch to Persistence is only allowed after CHT"

This is to prevent: "Team is recorded in Flash, but the wireless layer cannot enter the Team Channel at all"


3️⃣ Disband process (Disband)

Persistent deletion sequence diagram (the safest one)

3A. Leader disbands Team

Time ↓

Leader Device
──────────────────────────────────────────────────────────────

[UI] Click "Disband Team"
        |
        v
[UI] Confirm Disband
        |
        v
❌ DELETE: erase PersistentTeamState
    ├── identity
    ├── secrets
    ├── channel
    └── params

        |
        v
📦 RAM: clear all Team-related state
        |
        v
[STATE] Team = IDLE
        |
        v
[PROTO] (optional) send TEAM_STATUS / TEAM_END

3B. Ordinary members leave Team (Leave)

Time ↓

Member Device
──────────────────────────────────────────────────────────────

[UI] Click "Leave Team"
        |
        v
❌ DELETE: erase PersistentTeamState
        |
        v
📦 RAM: clear Team state
        |
        v
[STATE] Team = IDLE
        |
        v
[PROTO] (optional) send LEAVE_NOTICE

Key rules (must be unified)

  • Don't send messages first and then delete data
  • Don't wait for network ACK
  • Delete persistence = Team will no longer exist immediately

Messages are "polite", **Deleting the key is the "fact". **


4️⃣ Rotate key (Rotate) supplement: persistence atomicity

Leader Device
──────────────────────────────────────────────────────────────

[UI] Click "Rotate Key"
        |
        v
📦 RAM: generate new subkeys
📦 RAM: new_key_id

        |
        v
[PROTO] broadcast TEAM_STATUS(new_key_id)

        |
        |-- timeout / abort --> ❌ discard RAM only
        |
        v
💾 PERSIST (ATOMIC overwrite):
    ├── identity.key_id = new_key_id
    ├── secrets.mgmt_key = new
    ├── secrets.pos_key  = new
    └── secrets.wp_key   = new

        |
        v
[STATE] Team still ACTIVE (new key)

Key rules

  • ** "Half-write" is not allowed**
  • Do not persist Rotate in-progress status
  • Either replace it all or not

5️⃣ An iron rule that can be written into the implementation specification

**Persistence only occurs "after the state transition is completed", Deletion only occurs "when the state transition begins". **


6️⃣ Final project checklist (you can use it directly)

  • There is no Flash writing before Create Confirm
  • There is no Flash writing before Join successfully switches CHT
  • Disband / Leave The first action is to delete persistence
  • Rotate has only one atomic write point
  • Is PersistentTeamState a "single object"

Team persistent storage design

NVS / Flash Layout & Wear Strategy

Design goals:

  • Power-off safety
  • Atomic updates
  • Minimal erase
  • Clarify the criteria for "Team existence/non-existence"

1. General principles (establish the iron law first)

1️⃣ Team persistence = Single object

There is only one source of truth for "Does Team exist" in Flash

PersistentTeamState
  • With it → Team = Active
  • Without it → Team = Idle

Do not use multiple flags Do not split in different namespaces


2️⃣ The number of writes is very small, it is "life cycle level"

Operation Writing Flash?
Team establishment successful Once
Member joining successfully Once
Rotate Key Once
Normal use (location/chat)
Dismiss/leave Delete

Not high-frequency writing scenes So the focus is correctness > extreme performance


Reason:

  • Comes with wear leveling
  • Supports blob
  • Support namespace
  • Clear atomic semantics

Your current needs No need to customize raw flash


3. NVS Namespace design

NVS Namespace: "team"

**Only this namespace stores Team. **


4. Key layout (minimalist but complete)

Core Key List

Key Type Description
version u32 Structure version
team_state blob Entire PersistentTeamState

Do not split into dozens key If it is dismantled, it will be difficult to ensure consistency and atomicity


5. PersistentTeamState structure (the only truth in Flash)

Logical structure (consistent with your previous analysis)

PersistentTeamState
├── header
│   ├── magic
│   ├── version
│   ├── length
│   └── crc32
│
├── identity
│   ├── team_id
│   ├── role          (LEADER / MEMBER)
│   └── key_id
│
├── secrets
│   ├── team_key
│   ├── mgmt_key
│   ├── pos_key
│   └── wp_key
│
├── channel
│   ├── channel_index
│   └── channel_psk
│
└── params (optional)
    ├── position_interval_ms
    ├── precision_level
    └── flags

Header design (very important)

Field Function
magic Determine whether Team exists
version Structure version
length Anti-truncation
crc32 Power failure / half-write detection

📌 header is your last line of defense against power failure and damage


6. Writing strategy (when to write, how to write)

1️⃣ Writing only occurs "after successful transition"

The rules you set before now become storage rules:

Scenario Write content
Create Confirm Write complete PersistentTeamState
Join successfully switched CHT Write complete PersistentTeamState
Rotate successfully Overwrite write
Disband / Leave Delete key

2️⃣ Writing method: overall coverage (not incremental)

nvs_set_blob("team", "team_state", &state, sizeof(state))
nvs_commit()
  • Do not modify subfields
  • Do not patch
  • Do not maintain "rotating" status

📌 Write overall + CRC = most stable


7. Erase/Delete Strategy (Key)

Disband/Leave Team

nvs_erase_key("team", "team_state")
nvs_commit()
  • Deletion is a fact
  • Does not retain "Historical Team"
  • Does not retain a recoverable path

Why not use flag to indicate "disbanded"

Because:

  • The flag itself is also a state
  • flag needs to maintain consistency
  • flag will introduce "Ghost Team"

Does not exist = cleanest


8. Power failure and damage recovery strategy

Judgment logic at startup (the only entry)

if (nvs_has_key("team", "team_state")) {
    read blob
    if (magic ok && crc ok) {
        Team = ACTIVE
    } else {
        erase key
        Team = IDLE
    }
} else {
    Team = IDLE
}

📌 Any exception → Delete → Idle


9. Key Rotation's atomicity guarantee

Correct approach (you have already decided before)

  1. New key Only exists in RAM
  2. Broadcast Rotate
  3. Synchronization completed
  4. One-time overwrite PersistentTeamState
old_state → new_state
nvs_set_blob(...)
nvs_commit()

Do not write intermediate states Do not write rotating = true


10. Flash wear analysis (gives you confidence)

Worst case estimation

Operations Times
Team building 1
Rotate Assume 20 times
Disband 1

< 50 writes/Team life cycle

NVS default wear leveling is enough to support hundreds of thousands of times.

👉 You can rest assured


11. A very important safety suggestion (strong)

TeamSecrets storage

  • If the platform supports:

  • Use NVS encrypted partition

  • or:

  • Use device-unique key to wrap it again before writing

You don't need military grade, just:

Flash is dumped, TeamKey cannot be read directly


12. Final "Implementation Checklist" (you can use it directly)

  • Team only occupies one NVS key
  • PersistentTeamState has magic + crc
  • All writes are integral blob
  • dissolve = erase key
  • Startup exception = erase key
  • Rotate has only one commit point

Summarize this design in one sentence

**Flash does not save "process", only saves "facts". **


1) Three levels of review (define product boundaries first)

You want to "implement all events throughout the life cycle", but the implementation can have different intensities:

  • Each device only records the events it sees/sends
  • No need for all employees to report
  • Minimum protocol changes
  • The review may not be 100% complete (it depends on whether you received the package at that time)

B. Complete review of the team (optional enhancement)

  • The leader device tries to collect the events of the entire team (still "as much as possible", the wireless environment cannot guarantee 100%)
  • Closer to an "action recorder"
  • Requires ACK/complement/reconciliation from the entire team
  • Immediately increases system complexity

I suggest: Do A first, then B (Leader mode), and leave C alone for now.


2) What do you want to record: event model (Event Sourcing style)

Understand the life of Team as: event sequence + state reconstruction.

Meta information that events must contain (common to all events)

  • event_id: unique (available sender_id + seq)
  • team_id
  • ts: event occurrence time (device local time + optional mesh time)
  • sender_id: node ID
  • event_type
  • payload: carry fields by type
  • key_id: the key version used at the time (very important, easy for re-display and decryption)

Event type suggestions (covering your current system)

Lifecycle

  • TEAM_CREATED (Leader local)
  • TEAM_ADVERTISE_SENT/RECEIVED
  • TEAM_JOIN_REQUEST_SENT/RECEIVED
  • TEAM_JOIN_ACCEPT_SENT/RECEIVED
  • TEAM_JOIN_CONFIRM_SENT/RECEIVED
  • TEAM_STATUS_SENT/RECEIVED
  • TEAM_KEY_ROTATED (including old/new key_id)
  • TEAM_DISBANDED / TEAM_LEFT

Action

  • TEAM_POSITION_RX (it is recommended to only save the "sampled" position points)
  • TEAM_WAYPOINT_RX
  • TEAM_COMMAND_RX (your future collection/help, etc. instructions)

Exception

  • DECRYPT_FAIL (Important: can explain "why it was not displayed at the time" during review)
  • REPLAY_DROP
  • MEMBER_LOST / MEMBER_RECOVERED

3) Log storage location: A realistic choice between Flash vs SD

  • Use SD files to do append log (Flash wear and tear problem will be reduced directly)
  • The file system is easy to use (export, view, synchronize)

Only Flash (NVS/custom partition)

  • can also be done, but you need to use ring buffer (ring buffer)
  • Write amplification and wear must be carefully controlled
  • Not suitable for storing "high-frequency position points"

Conclusion: Position trajectory review is almost bound to be SD, otherwise you must extremely downsample.


4) The most important thing: How is the review compatible with "dissolution and destruction of keys"?

Your previous security model was: Disband Destroy TeamKey immediately → History is unreadable. The review requirement: you can still read history after disbandment.

So you need to introduce an independent log key:

New key: LogKey

  • LogKey = KDF(TeamKey, "log") Yes, but if Disband will destroy the TeamKey, the LogKey will also be lost.
  • More reasonable: Generate an independent random LogKey when creating a Team, and set the "whether to keep" policy.

Two strategies (corresponding to product switches)

  1. Default: Disband destroys LogKey
  • Cannot be restored
  • Strongest privacy commitment (default recommendation)
  1. Enable restoration: Disband retains LogKey (only local machine)
  • Log files are still encrypted
  • Only this device can be restored
  • You can still commit: TeamKey Destroy, network permission disappears; but the machine retains action records

This is actually in line with the real needs of the outdoors:

  • Communication permission ends when the team ends
  • Action records can be retained by the individual/team leader

5) Writing strategy: How to be complete without exploding storage?

The position point should not be "dropped in every package"

The position is the highest frequency data and must be downsampled:

  • Rule 1: Sampling by time (for example, record once every 10s/30s)
  • Rule 2: Sampling by distance (recorded only when moving more than 20m)
  • Rule 3: Exception priority (temporary upscaling during SOS/assembly)

For review speed and stability:

  • events.log: additional writing (one record for each event)

  • snapshot.bin: Occasionally write a "current Team status snapshot" (such as every 5 minutes or every 200 events)

  • During review: play back events from the latest snapshot, fast


6) Storage format suggestions (don't worry, choose one that can run)

You are already in protobuf In the system, the most smooth:

  • protobuf event (an event message)

  • The outer layer is sealed with a layer of LogRecord:

    • len(u16/u32)
    • record_type(event/snapshot)
    • ciphertext
  • crc32 (optional)

Log content is recommended to be encrypted with AEAD(LogKey) to ensure:

  • Even if the landing file is copied, it will not be readable
  • File tampering can be detected

7) What is the output of the review?

Minimum available replication (MVP)

  • Timeline: Join/Leave/Assemble/Ask for help/Disconnect/Restore
  • Map playback: Display the trajectory according to time (no animation required, do "jump by time" first)

Leader review enhancement

  • Statistics: The maximum distance between the team, the number of members disconnected, and the assembly time
  • Abnormalities: Who is stationary for a long time, who has low battery, when packet loss is serious

8) The most important implementation decision: Do you want to "replay the switch"?

I strongly recommend deciding when building a team:

  • Record mode: Off / Local / Leader
  • Retention: 1 day / 7 days / manual delete
  • Privacy: Keep logs encrypted (default)

And when disbanding:

  • If record off → delete log + destroy LogKey
  • If record on → write TEAM_DISBANDED event + close logging, but keep LogKey (native)

9) You asked to "organize it": New data that needs to be persisted

On the basis of your previous PersistentTeamState, it is enough to add these items:

When Record=ON:

  • log_enabled(bool)
  • log_mode(enum: LOCAL / LEADER)
  • log_key (bytes) (independent of TeamKey)
  • log_file_id / log_path (if SD)
  • log_seq (for event increment numbering)
  • last_snapshot_offset (optional)

When Record=OFF:

  • Do not persist any log related content

1) SD file layout

1.1 Directory structure (bucketed by Team session)

It is recommended that each Team life cycle (Create→Disband) generate a session_id (random 64-bit or timestamp + random) as a session directory.

/trail-mate/
  /team_logs/
    /YYYYMMDD/
      /T_<session_id>/
        meta.json
        keys.bin
        events.log
        snapshot.idx
        snapshots.bin

File Responsibilities

  • meta.json (plain text, readable)

  • Only store non-sensitive information: creation time, device name, version number, recording mode, etc.

  • keys.bin (encryption/protection)

  • Store the package of LogKey (see 3 for details)

  • events.log (core appended write log)

  • Event stream (encrypted record)

  • snapshot.idx (small index, convenient for quick positioning)

  • Each snapshot corresponds to the offset of events.log + the offset of snapshots.bin

  • snapshots.bin (snapshot stream, append writing)

  • State snapshot for recovery acceleration (encrypted record)

MUST: events.log and snapshots.bin They are all append-only (only append, no rewrite, no truncation). SHOULD: Divide into directories every day to facilitate cleaning/archiving.


2) LogRecord binary format (universal recording container)

events.log and snapshots.bin use the same record container: LogRecord. Each record is: fixed header + variable length ciphertext + optional CRC.

2.1 LogRecord structure (Little-endian)

+-------------------------------+
| magic      (4)  "TLOG"        |
| version    (1)  = 1           |
| type       (1)  1=EVENT 2=SNAP|
| flags      (2)                |
| header_len (2)  bytes         |
| body_len   (4)  bytes         |
| session_id (8)                |
| seq         (8) monotonic     |
| ts_ms       (8) unix ms       |
| key_id      (4)               |
| nonce      (12)               |
| aad_crc32   (4)               |  (optional but recommended)
|--------------------------------
| AAD (header extension...)      |  (header_len - fixed_header bytes)
|--------------------------------
| ciphertext (body_len bytes)    |  AEAD output (may include tag)
|--------------------------------
| trailer_crc32 (4) optional     |
+-------------------------------+

Fixed header field description

  • magic: used for fast scanning and recovery
  • version: format version
  • type:EVENT / SNAP
  • flags: bit flags (whether there is trailer_crc, compression, etc.)
  • header_len: total header length (to facilitate future expansion of AAD)
  • body_len: ciphertext length
  • session_id: session binding (preventing mixed writing)
  • seq: Monotonically increasing sequence number (power failure recovery, deduplication, alignment)
  • ts_ms: Record writing time (or event occurrence time, depending on your definition)
  • key_id: LogKey rotation version (optional but highly recommended)
  • nonce(12): AEAD nonce (unique for each record)
  • aad_crc32: Calculate CRC for AAD (used to quickly determine whether the header is damaged)

MUST: seq Monotonically increasing and not repeated (within the same session). MUST: nonce cannot be repeated under the same key_id. SHOULD: header_len allows carrying extended AAD (such as clear text fields of sender_id, event_type, etc.).


  • bit0: HAS_TRAILER_CRC (with trailer_crc32 at the end of the record)
  • bit1: BODY_COMPRESSED (the plaintext inside the ciphertext is compressed before encryption)
  • bit2: TS_IS_EVENT_TIME (ts_ms represents the event occurrence time, otherwise it represents the writing time)
  • bit3: RESERVED

It is recommended to turn on HAS_TRAILER_CRC by default. Even though AEAD can verify ciphertext integrity, CRC is still valuable for quickly locating corruption/truncation.


3) Encryption and key storage (LogKey)

3.1 Positioning of LogKey

  • TeamKey is used for Team communication (may be destroyed during Disband)
  • LogKey is used for log encryption, whether to retain it is determined by "Record Mode/Retention Policy"

If you want to be "recoverable", you must still obtain it after Disband LogKey (at least on this machine).

keys.bin Do not put LogKey in plain text. It is recommended to wrap it with a device unique key (such as ESP32 NVS encrypted partition / eFuse key / or your own device key).

keys.bin content suggestion is also a small KeyRecord:

magic "TKEY" (4)
version (1)
flags   (1)
reserved(2)
session_id (8)
key_id (4)
nonce (12)
ciphertext (var) = AEAD(DeviceKey, LogKeyMaterial)
crc32 (4)

Among them LogKeyMaterial contains at least:

  • log_key (32 bytes)
  • kdf_info/algo id (optional)
  • created_ts (optional)

MUST: The device is not unblocked When keys.bin has the ability, it is not allowed to enter the review (to avoid false display).


4) What is the plain text in events.log (EVENT content)

It is recommended to use protobuf (or your own custom TLV) for the plain text of EVENT, and then encrypt it with AEAD.

Plain text suggestion structure: TeamEvent (protobuf)

Contains:

  • event_type
  • sender_id
  • team_id
  • payload (by type oneof)
  • mesh_ts (optional)
  • rx_rssi/snr (optional)
  • seq_in_mesh (optional)

SHOULD: Put event_type in the AAD extension of LogRecord (plain text), so that you can do quick filtering/statistics without decryption; but it will leak the "event category" - if you care about privacy very much, don't do this, put everything in cipher text.


5) CRC strategy (the key to power-off and damage recovery)

You have three levels of integrity:

  1. AEAD tag: ensure that the ciphertext cannot be tampered with (but cannot determine the "file truncation" position)
  2. aad_crc32: quickly determine whether the header is damaged
  3. trailer_crc32: Do CRC on the entire record (from magic to ciphertext) to facilitate recovery scanning

Do CRC32 on LogRecord starting from magic to the end of ciphertext (excluding trailer_crc32 itself).

MUST: If the CRC does not match when reading, the data after the record can be regarded as untrustworthy and the recovery scan mode will be entered.


6) Append writing strategy (append-only)

6.1 Writing process (each record)

  1. Assemble plaintext event
  2. Generate nonce
  3. AEAD encryption to get ciphertext
  4. Write LogRecord header + ciphertext
  5. Write trailer_crc32 (if enabled)
  6. fsync/flush (depends on your platform: at least during critical events/periodic flush)

Flush strategy (SD practical suggestions)

  • Ordinary events: flush once every N items or every T seconds (for example, 2s)
  • Key events (TEAM_CREATED / DISBANDED / ROTATED / SOS): flush immediately

SHOULD: implement a LogWriter has a small internal buffer; but it should not be so large that it will suffer too much loss due to power failure.


7) Snapshot strategy (snapshot + index)

If the review only relies on replay events, it will become slower and slower. Snapshots are meant to "start playback from a certain point."

7.1 Snapshot content

Snapshot text is recommended to be saved:

  • Current key_id
  • Member table (most recently visible member, last position)
  • Current waypoint/assemble point
  • Key parameters (sampling strategy)
  • last_event_seq (the last event sequence number covered by the snapshot)

Then AEAD(LogKey) encrypts and writes to snapshots.bin (same as LogRecord format, type=SNAP).

7.2 When to write snapshots (recommendations)

  • Every N events (for example, 200)
  • or every T seconds (for example, 60s)
  • or after key events (rotate/disband)

MUST: Snapshot writes should not affect real-time communication and must be discardable (failure does not affect main functionality).

7.3 snapshot.idx (small index, plain text)

The index is a fixed-length record, appended to write, to quickly locate the latest snapshot:

IdxEntry (fixed 32 bytes):
- magic "SIDX" (4)  (optional per file header)
- version (1)
- reserved(3)
- session_id (8)
- snap_seq (8)          // snapshot record seq
- events_offset (8)     // events.log offset at snapshot time
- snaps_offset (8)      // snapshots.bin offset for this snapshot

SHOULD: snapshot.idx can be plain text, because it only exposes offset; if you think "whether there is a snapshot/frequency" is also sensitive, you can put idx Also encrypted, but the engineering complexity will increase.


8) Recovery and replication process (self-healing after power failure)

8.1 Opening a session's replication steps

  1. Read meta.json (optional)
  2. Unpack keys.bin to get LogKey
  3. Read snapshot.idx to find the last available snapshot (check snap record CRC/AEAD)
  4. Restore state from snapshot
  5. From Starting from events_offset of events.log, read records sequentially:
  • magic check
  • header/aad_crc32 check
  • trailer_crc32 (if enabled)
  • AEAD decryption
  • parse event → playback update status

8.2 Recovery scan (file corruption/truncation)

When CRC found No match or half record read:

  • Enter scan mode: slide by byte to find the next "TLOG" magic
  • After finding it, try to parse header_len/body_len to see if it is reasonable
  • If the CRC passes, continue

MUST: The scan must have an upper limit (to avoid endless loops on damaged files).


9) File rolling and retention strategy (preventing unlimited growth)

9.1 Single session file rolling

It is recommended that events.log be split when it reaches the threshold:

  • events_0001.log
  • events_0002.log

Similar to snapshots.

Threshold recommendation:

  • Choose one of 4MB / 16MB / 64MB (see SD/requirements)
  • If there are many locations, it is recommended to be smaller for easy export and repair

9.2 Retention (retention period)

  • Record retention policy in meta.json
  • Regularly clean the YYYYMMDD directory

MUST: Users should be able to "delete an action record with one click" (delete the entire T_<session_id> directory).


10) Minimum implementation version (you can implement this first)

If you want to run quickly first, I recommend MVP:

  • Only three files:

    • meta.json
    • keys.bin
    • events.log
  • events.log:LogRecord + AEAD + trailer_crc32

  • No snapshots, no idx

  • Forced downsampling of location events (for example, one every 10s)

Added later:

  • snapshots.bin + snapshot.idx