> ## Documentation Index
> Fetch the complete documentation index at: https://docs.qwedai.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# QWED compliance guide

> Use the HMAC-signed, hash-chained audit log, security activity summary, and data export to support SOC 2 and GDPR work. QWED does not make a system compliant.

This guide provides instructions for System Administrators and Developers on how to use QWED's audit and export features to support SOC 2 preparation, GDPR requests, and audit trail management. These features produce a record of what was checked; they do not by themselves make a system compliant with any standard or regulation.

## Audience

* **System Administrators:** Use the API endpoints described below to manage compliance tasks.
* **Developers:** Integrate these features into your applications using the QWED Python SDK.

***

## 1. Security activity summary

`GET /admin/compliance/report/soc2/{org_id}` returns a security activity summary for your organization over the last 90 days. QWED computes every value from its own records: verification and security event counts, active users and API keys, and a signature and hash-chain check of the audit trail. Use it as input when you prepare for an audit or review security activity.

> [!WARNING]
>
> The summary is not a SOC 2 report or an auditor's attestation. A SOC 2 report is issued by an independent auditor over a period. Running this endpoint doesn't make your system SOC 2 compliant. The URL keeps `soc2` for compatibility.

**Prerequisites:**

* An API key whose owner has the `owner` or `admin` role, or a signed-in session (`Authorization: Bearer <jwt_token>`) for an `owner` or `admin`. See [Session sign-in for admin endpoints](https://docs.qwedai.tech/api/endpoints.md#session-sign-in-for-admin-endpoints).
* `QWED_AUDIT_SECRET_KEY` set on the server. Without it, the audit trail check can't run and `audit_trail.status` is `NOT_VERIFIED`.

**API endpoint:**

```http theme={null}
GET /admin/compliance/report/soc2/{org_id}
```

`org_id` must be your own organization ID. Another organization's ID returns `403 Forbidden`.

```bash theme={null}
curl --fail-with-body -H "X-API-Key: qwed_live_your_key" \
  "http://localhost:8000/admin/compliance/report/soc2/1"
```

**Example response:**

```json theme={null}
{
  "report_type": "Security activity summary",
  "disclaimer": "Generated by the QWED server from its own records. It is not a SOC 2 report or an auditor's attestation.",
  "organization": { "id": 1, "name": "Acme Corp", "tier": "free" },
  "period": {
    "start": "2026-07-24T12:00:00+00:00",
    "end": "2026-10-22T12:00:00+00:00",
    "days": 90
  },
  "metrics": {
    "total_verifications": 1840,
    "security_events_detected": 1,
    "security_events_by_type": { "SECRET_LEAK_AUTO_REVOKED": 1 },
    "active_users": 4,
    "active_api_keys": 6
  },
  "audit_trail": {
    "status": "VERIFIED",
    "proof_ref": "sha256:9f2c...",
    "evidence": {
      "organization_id": 1,
      "period_start": "2026-07-24T12:00:00+00:00",
      "period_end": "2026-10-22T12:00:00+00:00",
      "first_entry_id": 101,
      "last_entry_id": 1940,
      "entries": 1840,
      "head_hash": "a41b..."
    },
    "scope": "Signature and hash-chain check of the audit entries recorded in the period. It does not show that every request was recorded.",
    "total_entries": 1840,
    "verified_entries": 1840,
    "failed_entries": 0,
    "first_failed_entry_id": null,
    "legacy_entries": 0,
    "errors": [],
    "total_errors": 0,
    "errors_truncated": false
  },
  "generated_at": "2026-10-22T12:00:00+00:00",
  "report_version": "2.0"
}
```

#### Response fields

| Field | Description |
| - | - |
| `report_type` | Always `"Security activity summary"`. |
| `disclaimer` | States that the summary is not a SOC 2 report or an auditor's attestation. |
| `metrics.security_events_by_type` | Security events in the period, counted by the event types QWED actually recorded. |
| `audit_trail` | Result of running `verify_audit_trail` over your organization's audit entries in the period. |
| `report_version` | `"2.0"`. |

#### Audit trail status

| `audit_trail.status` | Meaning |
| - | - |
| `VERIFIED` | The period has entries, and every entry passes the signature and chain checks. Only this status includes `evidence` (organization, period, first and last entry ID, entry count, and chain head hash) and a `proof_ref` (`sha256:...`) computed over that evidence. |
| `NO_ENTRIES` | The organization has no audit entries in the period. |
| `NOT_VERIFIED` | Any entry fails, any entry predates the hash chain (a legacy entry), or the check can't run. If the check can't run, for example because `QWED_AUDIT_SECRET_KEY` is unset, the entry counts are `null` rather than `0`. |

For every status other than `VERIFIED`, `proof_ref` and `evidence` are `null`. `total_entries`, `verified_entries`, `failed_entries`, and `legacy_entries` count the entries checked, and `first_failed_entry_id` names the lowest failing entry, or `null`. `errors` lists at most 20 errors. `total_errors` gives the full count, and `errors_truncated` is `true` when the list was cut.

> [!NOTE]
>
> The check covers the audit entries that were recorded. It doesn't show that every request wrote an entry, as the `scope` field states.

### Audit trail verification

Every `/verify/*` endpoint, the Verification Context routes, `POST /agents/{agent_id}/verify`, and `POST /audit/seed` append their audit rows through `AuditLogger`. `POST /verify/batch` appends one row per item, including failed items. Each new row is HMAC-signed and hash-chained to the previous row for the same organization. If QWED can't append the row, the request returns `500` with `audit persistence failed` instead of a result with no audit record. For a batch, the affected item is marked `failed` with that error and no result.

`GET /audit/logs` and `GET /audit/logs/{log_id}` return each row's stored `verdict` and its chain fields (`entry_hash`, plus `previous_hash` and `hmac_signature` on the detail route). Each row also records:

* **`admission`**: the admission decision its route made (`ADMIT` or `BLOCKED`, or `ADMIT` or `DENY` for `POST /verification-context/from-diagnostic`). `null` for routes that decide no admission and for older rows.
* **`api_key_id`**: the API key that authenticated the request. `null` for signed-in sessions, the audit seed, the agent route, and older rows.
* **`verifier_version`**: the QWED server version that wrote the row.
* **`latency_ms`**: whole milliseconds from the start of the HTTP request to when QWED began writing the audit row, before it waits for the append lock. `null` for rows written outside a request and for older rows.

The entry hash covers all four fields, so changing or clearing them on a stored row fails verification. Rows written before these fields existed still verify.

To export the whole trail as evidence, page through `GET /audit/logs` with the `before` cursor until `next_before` is `null`. See [Audit endpoints](https://docs.qwedai.tech/api/authentication.md#audit-endpoints).

> [!WARNING]
>
> Upgrade every QWED process together, and don't roll back to a release older than v8.0.0 after v8.0.0 has written an audit row. Older releases hash rows without these fields, so they can't verify the new rows. When such a row is an organization's chain head, an older release refuses every further append for that organization and its verify routes return `500` (fail closed).

Verify the cryptographic integrity of your audit logs. Verification detects edited entries, reordered entries, and removals that break a link between the entries that remain. It does not prove the trail is complete: deleting the newest entries leaves a valid chain, so detecting that needs a separately stored checkpoint of the latest hash.

Per-entry verification covers three checks:

* **`hash_valid`**: the entry's SHA-256 hash matches a recomputation of its canonical payload. For backwards compatibility, entries hashed before `raw_llm_output` was covered are also accepted against the legacy canonical form.
* **`signature_valid`**: the entry's HMAC-SHA256 signature is constant-time-equal to the expected signature.
* **`chain_valid`**: the entry's `previous_hash` correctly references the prior entry's hash **within the same organization**. Genesis entries (the first entry for an organization) must have a `null` `previous_hash`; non-genesis entries must reference a non-empty prior hash.

> [!NOTE]
>
> Audit chains are isolated per organization. Hash linkage is verified only against prior entries belonging to the same `organization_id`, so cross-tenant activity cannot affect another organization's chain validity.

#### Legacy entries

Rows written before the API used the hash chain have no hash or signature. QWED treats an unchained row with no chained row before it as a legacy entry:

* `verify_log_entry` returns `"legacy": true` and `"valid": false` for it. A legacy entry is never reported as valid.
* The first chained row after the legacy rows opens a new chain segment with a `null` `previous_hash`.
* An unchained row after a chained row is not legacy. QWED stops appending for that organization and verification fails.

If an entry's stored `result` payload cannot be decoded as JSON, `verify_log_entry` fails closed with a `SecurityError` rather than reporting the entry as valid.

**Verify a Single Log Entry:**

```http theme={null}
GET /admin/compliance/verify/{log_id}
```

**Verify the whole trail or a period:**

```http theme={null}
GET /admin/compliance/verify-trail?start={start}&end={end}
```

`start` and `end` are optional ISO 8601 datetimes. Omit both to check the whole chain. The response's `scope` names what QWED checked:

* With neither `start` nor `end`: `"Signature and hash-chain check of every audit entry recorded for the organisation. It does not show that every request was recorded."`
* With `start`, `end`, or both: `"Signature and hash-chain check of the audit entries recorded in the period. It does not show that every request was recorded."`

The response also reports `status` (`VERIFIED`, `NO_ENTRIES`, or `NOT_VERIFIED`), the entry counts, and `first_failed_entry_id`, so you can see where the chain stops checking out. See [`GET /admin/compliance/verify-trail`](https://docs.qwedai.tech/api/endpoints#get-%2Fadmin%2Fcompliance%2Fverify-trail).

```bash theme={null}
curl --fail-with-body -H "X-API-Key: qwed_live_your_admin_key" \
  "http://localhost:8000/admin/compliance/verify-trail"
```

**Verify entire trail (Python SDK):**
To verify the trail in-process without the API, call `verify_audit_trail` directly.

```python theme={null}
from qwed_new.core.audit_logger import AuditLogger, SecurityError

try:
    verifier = AuditLogger()
except SecurityError as exc:
    # Raised if QWED_AUDIT_SECRET_KEY is unset or persisted chain continuity
    # cannot be loaded. Do not silently fall back.
    raise

result = verifier.verify_audit_trail(organization_id=1)
if result["valid"]:
    print("Audit trail integrity confirmed.")
else:
    print(f"Integrity check failed: {result['errors']}")
```

`verify_audit_trail` lists legacy entries under `legacy_entries`, separately from `failed_entries`. The trail stays `"valid": false` while any legacy entry is in range. To verify only the rows written since your upgrade, pass `start_date`:

```python theme={null}
from datetime import datetime, timezone

result = verifier.verify_audit_trail(
    organization_id=1,
    start_date=datetime(2026, 10, 17, tzinfo=timezone.utc),  # your upgrade time
)
print(result["legacy_entries"])  # [] once the range starts after the upgrade
```

> [!NOTE]
>
> `AuditLogger()` requires `QWED_AUDIT_SECRET_KEY` to be set in the environment, or the constructor must receive an explicit `secret_key` argument. Initialization fails closed otherwise.

### Evidence collection

Export your organization's verification logs for an audit period as a CSV file to provide to your auditor.

**API endpoint:**

```http theme={null}
GET /admin/compliance/export/csv?organization_id={org_id}&start={start}&end={end}
```

`start` and `end` are ISO 8601 times. A time without a zone is UTC. Without `end`, the period ends now. Without `start`, it starts 30 days before `end`. A `start` after `end`, including a future `start` with no `end`, returns `400 Bad Request`.

Each row carries the stored verdict and admission, so the file shows the decision QWED made for each request:

```csv theme={null}
ID,Timestamp,User ID,API Key ID,Verifier Version,Domain,Query,Verdict,Admission,Is Verified (legacy),Result,Latency (ms),Entry Hash,HMAC Signature,Chain Status
42,2026-10-21T12:00:00+00:00,3,7,9.0.0,MATH,x**2 + 2*x + 1 = (x+1)**2,VERIFIED,ADMIT,Yes,"{""status"": ""VERIFIED"", ...}",184,3b1f...a9c2,9d4e...07ab,Chained
```

* `Verdict` and `Admission` are the stored values, as `GET /audit/logs` returns them. A value QWED didn't record is an empty cell.
* `Domain` names the verification engine. Natural-language requests use `NATURAL_LANGUAGE`. Rows written before that fix read `MATH`, because the domain is part of the signed entry.
* Nothing is truncated. QWED prefixes an apostrophe to any cell that starts with `=`, `+`, `-`, `@`, a tab, a carriage return, or a line feed, so a spreadsheet doesn't run it as a formula.
* The file holds at most the newest 1,000 rows of the period, newest first. When more rows matched, the response sets `X-Export-Truncated: true`, otherwise `false`. Split a long audit period into shorter `start`/`end` windows to collect every row.

The `Chain Status` column shows `Chained` for rows that carry a hash and `Legacy` for rows written before the hash chain. `Chained` only means a hash is present. The export does not re-verify the chain. Use `GET /admin/compliance/verify/{log_id}` or `verify_audit_trail` to verify rows.

See [`GET /admin/compliance/export/csv`](https://docs.qwedai.tech/api/endpoints#get-%2Fadmin%2Fcompliance%2Fexport%2Fcsv) for every column.

***

## 2. GDPR compliance

### Data export (Article 15 - right of access)

QWED supports the Right of Access by allowing you to export all data associated with an organization or user.

**API endpoint:**
Use the CSV export endpoint to retrieve verification data. Set `start` and `end` to cover the period you need, because the default is the last 30 days and each file holds at most 1,000 rows.

```http theme={null}
GET /admin/compliance/export/csv?organization_id={org_id}&start={start}&end={end}
```

### Data deletion (Article 17 - right to erasure)

**Note:** Currently, an administrator must delete users directly in the database. A self-service deletion API is planned.

To comply with a deletion request:

1. **Delete the user:** Remove the user record from the database.
2. **Prune logs:** For full GDPR Article 17 compliance, ensure all verification logs associated with the user are also deleted or anonymized.

**Manual database process (example):**

```sql theme={null}
-- Delete User
DELETE FROM user WHERE id = {user_id};

-- Delete associated logs (if required by policy)
DELETE FROM verificationlog WHERE user_id = {user_id};
```

### Consent management

QWED does not have a built-in "Consent Management Platform" (CMP), but you can use the hash-chained audit log to track consent events.

**Implementing consent tracking:**
Log a specific "verification" event when a user grants consent. The entry is HMAC-signed and hash-chained to the previous entry for the same organization, so a later edit to it is detectable when you verify the trail. It is not tamper-proof: anyone holding `QWED_AUDIT_SECRET_KEY` and database write access can rewrite the chain.

```python theme={null}
# Example: Logging a consent event via the API
requests.post("/verify/fact", json={
    "claim": "User 123 granted consent for data processing",
    "context": "Consent Version 1.0, IP: 192.168.1.1",
    "provider": "system"
})
```

*Note: This treats the consent record as a "fact" verification, so it is written to the signed, hash-chained audit log.*

***

## 3. Audit trail setup

### Enable cryptographic logging

Cryptographic logging is enabled by default, but you **must** configure the signing key before the audit logger will start. The audit logger fails closed when the signing key is missing or when chain continuity cannot be established.

1. **Set the signing key:**
   Set `QWED_AUDIT_SECRET_KEY` to a secure, random string (at least 32 characters). The API server refuses to start if this variable is not set, and the audit logger raises a `SecurityError`. `qwed init` generates and persists a key in `.env` for local projects and never rotates an existing one.

   ```bash theme={null}
   export QWED_AUDIT_SECRET_KEY="your-secure-production-key-here"
   ```

   > [!WARNING]
   >
   > There is no fallback or default key. If the variable is unset, the application will fail to start rather than silently writing audit entries with a predictable key.

2. **Secure storage:**
   Store the key in a secrets manager (for example, AWS Secrets Manager or HashiCorp Vault) and inject it into the application environment.

3. **Rotation:**
   Rotating the signing key invalidates the HMAC signatures on existing entries. Verify and archive the existing trail before rotating, then start a new trail with the new key.

### Chain continuity across restarts

On startup, `AuditLogger` loads the most recent persisted entry hash and uses it as the new chain head. New entries are bound to the persisted hash rather than a fresh in-memory root, so restarts do not produce a discontinuous trail.

> [!WARNING]
>
> Run one writer per database. `AuditLogger` keeps each organization's chain head in memory, so multiple worker processes or replicas appending to the same database are not supported. A second writer causes the next append to fail with a continuity error (`500 audit persistence failed`) rather than fork the chain. The shipped Dockerfile runs a single uvicorn worker.

If the in-memory chain head and the persisted chain head disagree at write time, `log_verification` raises a `SecurityError` instead of writing a misleading entry. Treat this as a tampering or replication-lag signal and investigate before restarting writes.

### Upgrading to v8.0.0

On startup, v8.0.0 adds the `api_key_id`, `verifier_version`, and `admission` columns to an existing audit log table. Existing rows read these fields as `null`. Stop every process on the old release before you start v8.0.0. Once v8.0.0 has written a row, don't roll back to an older release.

### Per-organization chain isolation

Each organization has its own append-only hash chain. When verifying or appending entries, the logger:

* Locks the chain head per organization to prevent concurrent appends from forking the chain.
* Re-verifies the persisted chain head before appending a new entry and refuses to append if the head fails integrity checks.
* Accepts both the current canonical payload (which covers `raw_llm_output`) and the legacy payload during verification, so historical entries written before that field was hashed remain verifiable.
* Fails closed if a stored `result` payload is malformed JSON or if a chain entry is missing its `entry_hash`.

### Verify log integrity

Regularly run the verification process (see "Audit trail verification" above) to detect database tampering. Verification now flags:

* A genesis entry that references a non-null `previous_hash`.
* A previous entry that is missing its hash.
* A `previous_hash` that does not match the prior entry's hash.
* An entry whose recomputed hash or HMAC signature does not match the stored value.

Verification does not detect removal of the newest entries. Deleting entries from the end of the trail leaves a valid chain. To detect truncation, store the latest entry hash outside the database and compare it on each verification run.

### Sample data

`POST /audit/seed` writes 10 sample rows, marked `{"sample": true}`, through the signed chain for development. It returns `404` and writes nothing unless the server runs with `QWED_ENABLE_AUDIT_SEED=1`. Leave it unset in production so sample rows never mix with a real trail.

### Long-term storage

QWED uses the configured database (SQLite/PostgreSQL) for log storage. For high-volume compliance requirements:

* Configure database backups to a WORM (Write Once, Read Many) compatible storage (e.g., AWS S3 Object Lock) for archival.

***

## 4. Compliance checklist

Use this checklist to prepare for an enterprise audit.

* [ ] **Audit Logs Verified:** Run `verify_audit_trail` on all historical logs.
* [ ] **API Keys Rotated:** Rotate any API keys older than 90 days.
* [ ] **Security Activity Summary Reviewed:** Generate the security activity summary via API and confirm `audit_trail.status` is `VERIFIED`.
* [ ] **Secret Key Secured:** Confirm `QWED_AUDIT_SECRET_KEY` is set to a production-grade value and stored in a secrets manager.
* [ ] **GDPR Export Tested:** Verify that data export works for a sample organization.
* [ ] **Security Events Reviewed:** Check `metrics.security_events_by_type` in the security activity summary for any anomalies.
* [ ] **Rate Limiting Active:** Confirm rate limits are enforced to prevent abuse (DoS protection).


## Related topics

- [Prompt injection defense and QWED security hardening](https://docs.qwedai.tech/advanced/security-hardening.md)
- [QWED Security rule catalog](https://docs.qwedai.tech/advanced/qwed-security-rule-catalog.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.