Files
eryao/docs/protocols/common/user-points-chat-data-protocol.md
T

206 lines
8.2 KiB
Markdown
Raw Normal View History

2026-04-03 16:56:47 +08:00
# User Points & Chat Data Protocol
This protocol defines the canonical data contract for user profile, points account, points ledger, chat session, and chat messages.
Protocol verification status:
- Last audited migration: `backend/alembic/versions/20260413_0004_register_bonus_claims_snapshot.py`
- Last audited models: `backend/src/models/profile.py`, `backend/src/models/user_points.py`, `backend/src/models/points_ledger.py`, `backend/src/models/points_audit_ledger.py`, `backend/src/models/register_bonus_claims.py`, `backend/src/models/agent_chat_session.py`, `backend/src/models/agent_chat_message.py`
- Current status: aligned with register bonus moved to application service
2026-04-03 16:56:47 +08:00
## Scope
- `profiles`
- `user_points`
- `points_ledger`
- `points_audit_ledger`
- `register_bonus_claims`
2026-04-03 16:56:47 +08:00
- `sessions`
- `messages`
## Compatibility strategy
- Current strategy: additive evolution.
- Breaking changes (drop/rename/type change on core fields) require explicit migration + rollback notes.
- `points_ledger.metadata.schema_version` is mandatory and current value is `1`.
## Runtime charging policy (chat)
- Charge unit: `20` points per successful run.
- Charge timing: deduct after worker run succeeds (`RUN_FINISHED` path).
- Failure behavior: failed/canceled runs do not deduct points.
- Precheck: before accepting a run, backend must verify `available = balance - frozen_balance >= 20`.
- Session follow-up cap: one session allows at most 2 user runs total (initial divination + 1 follow-up).
- Billing idempotency key for per-run consume: `chat.run.success:{sha1(session_id:run_id)}`.
- Failed/canceled runs do not deduct user points. If real provider cost is observed, audit record is written with `billed_to='platform'`.
2026-04-03 16:56:47 +08:00
## Table contract
### profiles
- PK: `id` (`auth.users.id`, `on delete cascade`)
- Core fields: `username`, `avatar_url`, `bio`, `settings`, `created_at`, `updated_at`, `deleted_at`
- Constraints:
- `username` not empty
- Indexes:
- `ix_profiles_username`
- `ix_profiles_settings_gin`
### user_points
- PK: `user_id` (`auth.users.id`, `on delete cascade`)
- Core fields: `balance`, `frozen_balance`, `lifetime_earned`, `lifetime_spent`, `version`, `created_at`, `updated_at`
- Constraints:
- all numeric totals must be non-negative
- `frozen_balance <= balance`
### points_ledger
- PK: `id`
- FK:
- `user_id -> auth.users.id` (`on delete cascade`)
- `biz_id -> sessions.id` (`on delete restrict`, nullable)
- `operator_id -> auth.users.id` (`on delete set null`)
- Core fields: `direction`, `amount`, `balance_after`, `change_type`, `biz_type`, `biz_id`, `event_id`, `operator_id`, `metadata`, `created_at`, `updated_at`
- Constraints:
- `amount > 0`
- `direction in (1, -1)`
- `balance_after >= 0`
- `change_type in ('register', 'consume', 'grant', 'adjust')`
- `biz_type is null or biz_type='chat'`
- biz binding:
- `register => biz_type is null and biz_id is null`
- `consume/grant/adjust => biz_type='chat' and biz_id not null`
- direction and change_type coupling:
- `register/grant => direction = 1`
- `consume => direction = -1`
- `adjust => direction in (1, -1)`
- idempotency: `unique (user_id, event_id)`
### points_audit_ledger
- PK: `id`
- No FK to `auth.users` for `user_id_snapshot` to avoid cascade delete and preserve audit retention
- Core fields: `event_id`, `user_id_snapshot`, `user_email_snapshot`, `change_type`, `biz_type`, `biz_id`, `direction`, `amount`, `balance_after`, `billed_to`, `run_id`, `request_id`, `input_tokens`, `output_tokens`, `cost`, `metadata`, `created_at`, `updated_at`
- Constraints:
- `amount >= 0`
- `direction in (1, 0, -1)`
- `balance_after >= 0`
- `change_type in ('register', 'consume', 'grant', 'adjust')`
- `biz_type is null or biz_type='chat'`
- `billed_to in ('user', 'platform')`
- metadata must be object
- idempotency: `unique (event_id)`
### register_bonus_claims
- PK: `id`
- Core fields: `email_hash`, `user_email_snapshot`, `first_user_id_snapshot`, `balance_snapshot`, `grant_event_id`, `created_at`, `updated_at`
- Constraints:
- `email_hash` unique
- `grant_event_id` unique
- Notes:
- `email_hash` must be HMAC-SHA256 over normalized email (`trim + lower`)
- key source: backend config `points_policy.register_bonus_hmac_key`
- `balance_snapshot` stores the latest pre-delete account balance for same-email re-registration recovery
2026-04-03 16:56:47 +08:00
#### points_ledger.metadata (schema_version=1)
Canonical shape:
```json
{
"schema_version": 1,
"operator_type": "user|system|admin",
"run_id": "string",
"request_id": "string|null",
"charge": {
"message_id": "uuid",
"message_seq": 1,
"model_code": "string",
"input_tokens": 0,
"output_tokens": 0,
"cost": "0.000000"
},
"ext": {}
}
```
JSON constraints:
- Common:
- must be object
- `schema_version = 1`
- `operator_type in (user, system, admin)`
- `run_id` non-empty
- if present, `ext` must be object
- Per `change_type`:
- `register`: no `charge`, and no chat binding (`biz_type/biz_id` both null)
- `consume`: requires `charge` object with required fields
- `grant`: no extra metadata shape requirement
- `adjust`: requires `ext.ticket_id` non-empty
## Signup initialization contract
- DB trigger (`auth.users` after insert):
- Function: `public.initialize_profile_and_invite_code_on_signup()`
- Side effects: profile init + invite code init
- Application service (in `POST /auth/email-session`):
- `grant_register_bonus_if_eligible()` restores `balance_snapshot` first when present; otherwise grants register bonus via `register_bonus_claims`
- Bonus amount from `config.points_policy.register_bonus_points`
2026-04-03 16:56:47 +08:00
### sessions
- PK: `id`
- FK: `user_id -> auth.users.id`
- Core fields: `session_type`, `job_id`, `title`, `status`, `last_activity_at`, `message_count`, `total_tokens`, `total_cost`, `state_snapshot`, `created_at`, `updated_at`, `deleted_at`
- Constraints:
- `session_type in ('chat', 'automation')`
- `status in ('pending', 'running', 'completed', 'failed')`
- `message_count/total_tokens/total_cost` non-negative
### messages
- PK: `id`
- FK: `session_id -> sessions.id` (`on delete cascade`)
- Core fields: `seq`, `role`, `content`, `model_code`, `tool_name`, `input_tokens`, `output_tokens`, `cost`, `latency_ms`, `visibility_mask`, `metadata`, `created_at`, `updated_at`, `deleted_at`
- Constraints:
- `unique (session_id, seq)`
- `seq > 0`
- `role in ('user', 'assistant', 'system', 'tool')`
- token/cost non-negative
- `latency_ms` null or non-negative
## Security and ownership
- Backend service must derive owner identity from verified auth context.
- Client must not be trusted for `user_id`/`operator_id` ownership semantics.
- `metadata` and `settings` must not include secrets.
## Application initialization contract
Application initialization is split into two separate concerns: deterministic seed data and dynamic notification template sync. These are intentionally decoupled to avoid non-deterministic side effects during bootstrap.
### Seed data (deterministic)
Managed by `python -m core.runtime.cli init-data` (called by `dev-migrate.sh bootstrap`):
- `initialize_llm_catalog()` — seeds `llm_factory` and `llms` tables with known model definitions
- `initialize_system_agents()` — seeds `system_agents` table
These are idempotent and version-locked to code; they do not depend on runtime state or user data.
### Notification templates (declarative sync)
Managed by `python -m core.runtime.cli sync-notifications [flags]`:
- Reads notification definitions from YAML files under `backend/src/core/config/static/notification/notifications/`
- Syncs to `notifications` table; links existing users via `user_notifications` when `--reconcile-targets` is set
- Supports flags:
- `--dry-run` — validate without writing
- `--prune` — remove static notifications no longer present in YAML files
- `--reconcile-targets` — insert `user_notifications` entries for all existing users matching each notification's `target` predicate
- `--source-key <key>` — sync only the notification with the matching `source_key`
Run after migrations on fresh environments or after adding new notification YAML definitions. Not included in `bootstrap` to keep bootstrap fast and free of unintended side effects.