Data models
The JSON object shapes referenced by http-api.md. These
aren’t literal quotes from specs/*.md — the OpenSpec requirements
describe behavior (e.g. “a JSON object identifying the error”)
without always pinning down field names. This document commits to one
concrete, consistent shape for all of them, matching
design.md
and data-model.md wherever those
already fix the names. If a future revision of specs/*.md pins a
conflicting shape, that takes precedence and this document should be
updated to match.
Conventions
Section titled “Conventions”- Timestamps are ISO 8601 strings in UTC (
2026-03-05T14:30:00.000Z). - Every list endpoint returns
{"items": [...], "next_cursor": "<string, nullable>"}; passnext_cursorback verbatim as thecursorquery parameter to fetch the next page.next_cursor: nullmeans there is no further page. - A
nullfield is present-but-empty; an absent field was never set at all. Requests may omit optional fields entirely. - Nothing in these shapes ever includes a password hash, a secret-key hash, or a refresh-token hash — those never leave the server in any response.
Returned by POST /v1/auth/register, POST /v1/users, PATCH /v1/users/:id, GET /v1/users, POST /v1/users/:id/block/unblock.
| Field | Type | Notes |
|---|---|---|
id |
integer | |
username |
string | Unique; the login identifier (auth.md) |
display_name |
string | null | |
email |
string | null | Recovery contact only, not a login identifier |
email_verified_at |
string | null | ISO 8601; null blocks grant_type=password for this account if email is set (auth.md); always null when email is null |
must_change_password |
boolean | true whenever someone other than the account’s owner set its password — an admin (creation or PATCH), or the server itself when it auto-created the first administrator (decision 49); blocks every JWT-authenticated endpoint except a small allowlist until cleared via POST /v1/auth/change-password (auth.md); always false for self-registered accounts |
is_active |
boolean | false while blocked (rbac-and-lifecycle.md) |
deleted_at |
string | null | ISO 8601; set once, never cleared |
is_primary_admin |
boolean | true on at most one user, ever — whichever bootstrap path ran first (auto-creation on an empty database, or create-admin) |
created_at |
string | ISO 8601 |
{ "id": 42, "username": "alice", "display_name": "Alice Chen", "email": "alice@example.com", "email_verified_at": null, "must_change_password": false, "is_active": true, "deleted_at": null, "is_primary_admin": false, "created_at": "2026-01-10T09:00:00.000Z"}| Field | Type | Notes |
|---|---|---|
id |
integer | |
name |
string | |
created_at |
string | ISO 8601 |
| Field | Type | Notes |
|---|---|---|
id |
integer | |
group_id |
integer | Exactly one owning group |
name |
string | |
created_at |
string | ISO 8601 |
Project
Section titled “Project”Returned by POST /v1/groups/:groupId/projects, PATCH /v1/projects/:id, GET /v1/projects/:id, POST /v1/projects/:id/block/unblock. GET /v1/projects/:id additionally includes entry_count/total_bytes (decision 22, quotas-and-audit.md); the other endpoints above omit them (not maintained/queried outside the single-project read path).
| Field | Type | Notes |
|---|---|---|
id |
integer | |
group_id |
integer | |
name |
string | |
retention_days |
integer | Mandatory |
max_entries |
integer | null | null = unlimited |
max_bytes |
integer | null | null = unlimited |
is_blocked |
boolean | |
created_at |
string | ISO 8601 |
entry_count |
integer | GET /v1/projects/:id only |
total_bytes |
integer | GET /v1/projects/:id only |
{ "id": 7, "group_id": 3, "name": "checkout-service", "retention_days": 30, "max_entries": 1000000, "max_bytes": null, "is_blocked": false, "created_at": "2026-01-12T11:00:00.000Z", "entry_count": 842317, "total_bytes": 512048221}ProjectSecretKey
Section titled “ProjectSecretKey”Metadata never includes the plaintext key value, except the
response to POST /v1/projects/:id/secret-keys, which carries secret
exactly once — it is not retrievable afterward by any endpoint.
| Field | Type | Notes |
|---|---|---|
id |
integer | |
project_id |
integer | |
label |
string | null | |
created_at |
string | ISO 8601 |
revoked_at |
string | null | |
secret |
string | Only in the create response — the plaintext value, shown once; always prefixed slk_ |
RoleAssignment
Section titled “RoleAssignment”| Field | Type | Notes |
|---|---|---|
id |
integer | |
subject_type |
"user" | "team" |
|
subject_id |
integer | |
role |
"admin" | "owner" | "user" |
|
scope_type |
"global" | "group" | "project" |
|
scope_id |
integer | null | null iff scope_type: "global" |
created_at |
string | ISO 8601 |
LogEntry
Section titled “LogEntry”Returned by GET /v1/logs and streamed by GET /v1/logs/stream
(live-streaming.md). There is no
separate “context wrapper” — the response is the exact JSON object the
client sent to POST /v1/logs (see below), merged with three
server-assigned fields, preserving whatever custom keys it contained
(log-server-storage’s “full original content” guarantee, see
data-model.md).
| Field | Type | Notes |
|---|---|---|
id |
integer | Server-assigned; keyset-pagination cursor |
project_id |
integer | Server-assigned, from the secret key that authenticated ingestion |
received_at |
string | Server-assigned; ISO 8601, independent of client timestamp |
event |
string | As submitted |
level |
string | As submitted — one of debug/info/warning/error/critical |
timestamp |
string | As submitted, ISO 8601 |
category |
string | null | As submitted, if present |
logger |
string | null | As submitted, if present |
session_id, request_id, connection_generation, tool_call_id, message_id, operation_id |
string | null | Correlation fields, as submitted, if present |
| (any other key) | any | Whatever custom context fields the client submitted, unchanged |
{ "id": 918273, "project_id": 7, "received_at": "2026-03-05T14:30:00.412Z", "event": "payment_failed", "level": "error", "timestamp": "2026-03-05T14:29:59.981Z", "category": "checkout", "logger": "payment.gateway", "session_id": "sess_abc123", "request_id": "req_xyz789", "connection_generation": null, "tool_call_id": null, "message_id": null, "operation_id": null, "order_id": "ord_44821", "gateway_response_code": "card_declined"}(order_id/gateway_response_code above are arbitrary custom fields —
not part of the fixed schema, preserved exactly as sent.)
AuditLogEntry
Section titled “AuditLogEntry”Returned by GET /v1/audit-log (quotas-and-audit.md).
| Field | Type | Notes |
|---|---|---|
id |
integer | |
actor_user_id |
integer | null | null for events genuinely without an authenticated caller — auth.login_failed/auth.throttled under a username that matches no account, and audit.purged, which the server writes for itself (see quotas-and-audit.md) |
action |
string | One of the closed set — see quotas-and-audit.md |
target_type |
string | e.g. "user", "project", "role_assignment" |
target_id |
integer | null | |
metadata |
object | Shape depends on action — see examples below |
created_at |
string | ISO 8601 |
metadata examples by action:
// action: "project.quota_updated"{"before": {"retention_days": 30, "max_entries": null}, "after": {"retention_days": 30, "max_entries": 1000000}}
// action: "role_assignment.created"{"role": "owner", "scope_type": "group", "scope_id": 3, "subject_type": "user", "subject_id": 42}
// action: "user.blocked"{}
// action: "auth.login_succeeded"{"client_ip": "203.0.113.7", "user_agent": "structured_log_admin_client/0.1.0"}
// action: "auth.login_failed" — known account{"reason": "invalid_password", "client_ip": "203.0.113.7", "user_agent": "..."}
// action: "auth.login_failed" — no such account (actor_user_id is null,// and the submitted username is deliberately NOT stored){"unknown_user": true, "client_ip": "203.0.113.7", "user_agent": "..."}
// action: "auth.throttled" — written once per episode, not per rejected request{"key_kind": "subject", "path": "/v1/auth/token", "client_ip": "203.0.113.7"}
// action: "audit.purged" — written by the purge job itself (actor_user_id is null),// one row per class per pass, never per chunk{"scope": "auth", "deleted_count": 18432, "older_than": "2026-06-16T00:00:00Z"}Token response
Section titled “Token response”Returned by POST /v1/auth/token (RFC 6749 §5.1 field names —
auth.md).
| Field | Type | Notes |
|---|---|---|
access_token |
string | JWT, short-lived |
refresh_token |
string | Opaque, long-lived |
token_type |
string | Always "Bearer" |
expires_in |
integer | Seconds until access_token expires |
refresh_expires_in |
integer | Seconds until refresh_token expires |
Access-token claims (decoded JWT payload)
Section titled “Access-token claims (decoded JWT payload)”Not a response body — the payload of access_token once decoded, for
reference when debugging.
| Claim | Type | Notes |
|---|---|---|
iss |
string | Configured server issuer identifier |
sub |
string | User.id |
iat / exp |
integer | Unix timestamps |
jti |
string | Unique token id |
preferred_username |
string | |
tv |
integer | User.token_version snapshot at issuance — auth.md |
roles |
array of {role, scope_type, scope_id} |
Effective-rights snapshot at issuance |
Ingestion response
Section titled “Ingestion response”Returned by POST /v1/logs, HTTP 202 — always, even if every
entry in the batch was rejected; the request itself was accepted and
processed entry-by-entry, so a 4xx/5xx would misrepresent what
happened (see errors.md).
| Field | Type | Notes |
|---|---|---|
accepted |
integer | Count of entries stored |
rejected |
array of {index, error, message} |
index is the entry’s position in the submitted array (0-based) |
{ "accepted": 8, "rejected": [ {"index": 3, "error": "validation_error", "message": "level: must be one of debug, info, warning, error, critical"}, {"index": 7, "error": "quota_exceeded", "message": "project max_entries limit reached"} ]}Error envelope
Section titled “Error envelope”Two shapes coexist in this API, both documented in full in errors.md — this is a pointer, not a duplicate:
- The general JSON envelope (
log-server-api), used by everything except the token endpoint. - The RFC 6749 §5.2 shape, used only by
POST/DELETE /v1/auth/token.