Metadata-Version: 2.4
Name: hyperscale-trust
Version: 0.1.0
Summary: Trust center for Hyperscale Django apps: public policy claims, a questionnaire CSV and a private evidence pack
Keywords: django,trust-center,security,compliance,evidence
Author: Andy Caine
Author-email: Andy Caine <andy@hyperscale.consulting>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.1
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Security
Classifier: Typing :: Typed
Requires-Dist: django>=5.2
Requires-Dist: hyperscale-crypto>=0.1.0 ; extra == 'crypto'
Requires-Python: >=3.14
Project-URL: Homepage, https://github.com/hyperscale-consulting/hyperscale-trust
Project-URL: Repository, https://github.com/hyperscale-consulting/hyperscale-trust
Project-URL: Changelog, https://github.com/hyperscale-consulting/hyperscale-trust/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/hyperscale-consulting/hyperscale-trust/issues
Provides-Extra: crypto
Description-Content-Type: text/markdown

# hyperscale-trust

Trust center for Hyperscale Django apps: a public `/trust` page of
policy-level security claims generated from the app's data classification and
the platform's published controls, a questionnaire CSV export, and a private
evidence pack for auditors and buyers under NDA. Imported as
`hyperscale.trust`.

Every claim on the page comes from the host app's data classification, this
package's control catalogue, or the platform's published document — not from
prose someone typed into a form — so the page cannot go stale because
someone forgot to edit it. The only hand-written fields carried through
`trust.json` are the operator name and the security contact.

## Install

```bash
uv add hyperscale-trust
```

```python
INSTALLED_APPS = [
    # ...
    "hyperscale.trust",
]

urlpatterns = [
    # ...
    path("trust/", include("hyperscale.trust.urls")),
]
```

`hyperscale/trust/urls.py` sets `app_name = "hyperscale_trust"`, so including
the module resolves URLs as `hyperscale_trust:page` etc. without an explicit
`namespace=` (passing one is harmless).

The evidence pack's key inventory (`keyset_status.json`,
`key_rotation_events.csv`) comes from `hyperscale.crypto`. Install it with the
`crypto` extra:

```bash
uv add "hyperscale-trust[crypto]"
```

Without it — or before its tables are migrated — the pack still builds; those
two files just report that the key inventory is unavailable rather than being
omitted (see [The evidence pack](#the-evidence-pack)).

## Settings

```python
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

HYPERSCALE_TRUST = {
    "trust_json_path": BASE_DIR / "trust.json",  # required
    "snapshot_path": BASE_DIR / "platform-controls.json",  # required
}
```

| Key | Default | Meaning |
|---|---|---|
| `trust_json_path` | none (required) | path to the app's `trust.json` |
| `snapshot_path` | none (required) | path to the build-time platform-document snapshot |
| `document_url` | `""` | URL to fetch the live platform document from; empty means never fetch — always serve the snapshot |
| `cache_seconds` | `86400` | how long a successful platform-document fetch is cached |
| `evidence_permission` | `"hyperscale.trust.permissions.superuser_only"` | dotted path to a `Callable[[HttpRequest], bool]` deciding who may open the evidence pack |

An unknown key, a missing `trust_json_path`/`snapshot_path`, a non-positive
`cache_seconds`, or an `evidence_permission` path that doesn't import to a
callable all raise `ImproperlyConfigured`. This is checked in `AppConfig.ready()`,
so a bad setting fails at Django startup, not on the first request.

The default `evidence_permission`,
`hyperscale.trust.permissions.superuser_only`, is `True` only for an
authenticated Django superuser. Supply your own dotted path to gate the
evidence pack on an app-specific owner or admin role instead.

## URLs

| Name | Path | Access |
|---|---|---|
| `hyperscale_trust:page` | `/trust/` | public |
| `hyperscale_trust:questionnaire` | `/trust/questionnaire.csv` | public |
| `hyperscale_trust:evidence` | `/trust/evidence/` | login + `evidence_permission` |
| `hyperscale_trust:evidence_zip` | `/trust/evidence.zip` | login + `evidence_permission` |

(Paths shown are the conventional mount point above; the host app chooses
where to `include()` the URLconf.)

When `trust.json`'s `app.enabled` is `false` (or `trust.json` itself is
missing or malformed), the public routes 404, and the evidence routes 404 once
the caller is logged in (anonymous callers are redirected to `LOGIN_URL`
first) — a disabled trust center gives no sign of what it would otherwise
contain. The evidence routes are checked in this order: an unauthenticated
request is redirected to `LOGIN_URL` first; an authenticated request against
a disabled trust center 404s; only then is `evidence_permission` consulted,
raising `PermissionDenied` (403) if it returns `False`. This ordering means a
disabled center never reveals whether the caller would have been authorised.

## What the page shows, and what it deliberately does not

`GET /trust/` renders, in order: an overview (app name and description,
operator and security contact — each labelled "Declared by the operator",
since they're the only founder-supplied inputs — region, and the date the
page's source was generated); the data categories processed and how each is
handled, plus a negative claim for every category the app doesn't process;
the controls that protect that data (app-specific and platform, each with an
ISO 27001:2022 Annex A reference, labelled "Enforced by the platform"); the
cryptography policy in prose (algorithms, key custody, rotation, transport);
the subprocessor list; and the operator's own responsibilities (such as a
DPIA) that the data categories imply.

It deliberately carries no category or control ids, no row or attribute
counts, no key ids or rotation dates, no deployment identifiers, and no
canary results — only two ISO dates ever appear: `trust.json`'s
`generated_at` (as "last updated on …") and, whenever the page is rendered
from the bundled snapshot rather than a live fetch (the default when
`document_url` is unset, and also the fallback after a failed fetch), that
snapshot's `published_at`, shown as a "Platform information as of …" badge so
a stale or never-fetched document is visible rather than served as current
without comment. `GET /trust/questionnaire.csv`
carries the same restriction — it is the same catalogue, exported as
`question,answer,annex_a,source` rows, one per control and per data-category
question plus rows for region, operator, contact and subprocessors.

## Templates

`trust.html` and `evidence.html` both `{% extends "base_anonymous.html" %}`
and use only the `title` and `content` blocks — the host app must supply
`base_anonymous.html`. Markup uses DaisyUI class names (`card`, `badge`,
`table`, `btn`) but renders sensibly without DaisyUI or Tailwind loaded.

## `trust.json`

Produced by the host app (App Studio's `build_bundle`, for a generated app)
and shipped next to the app. Full shape and the rules behind it are in the
design spec:
[`docs/superpowers/specs/2026-09-24-trust-center-design.md`](docs/superpowers/specs/2026-09-24-trust-center-design.md).
A worked example is in
[`tests/fixtures/trust.full.json`](tests/fixtures/trust.full.json). Excerpt:

```json
{
  "generated_at": "2026-09-24T12:00:00Z",
  "app": {"name": "Clinic Notes", "operator_name": "Acme Health Ltd", "enabled": true},
  "data": {
    "categories": [
      {"id": "health", "label": "Health", "definition": "…", "level": "restricted", "handling": "…", "attribute_count": 2, "models": ["Patient"]}
    ],
    "not_processed": [{"id": "payment_card", "label": "Payment card"}],
    "free_text_attribute_count": 3
  },
  "controls": [{"id": "app.encryption.restricted", "annex_a": "A.8.24"}],
  "levels": {"restricted": "Encrypted by the application before storage, …"}
}
```

Two things worth calling out because they're easy to get wrong:

- Each category may carry its own `definition` and `handling` prose, and the
  top-level `levels` map gives each sensitivity level a plain description —
  this is the host app's classification vocabulary, copied in so the package
  owns no category wording. `not_processed` is a list of `{id, label}`
  objects, not a list of ids.
- The package's catalogue (`hyperscale.trust.catalogue.APP_CONTROLS`) is
  authoritative for a control's Annex A reference, title, statement,
  question and answer, looked up by `id`. The `annex_a` value on a control
  entry in `trust.json` itself is informational only and is not rendered.

## The platform document and the snapshot

`hyperscale.trust.documents.PlatformDocument` — `version`, `published_at`,
`region`, `hosting_provider`, `email_provider` and a list of controls (`id`,
`title`, `statement`, `annex_a`) — either fetched live from
`HYPERSCALE_TRUST["document_url"]` or loaded from the on-disk
`snapshot_path`. See
[`tests/fixtures/platform-controls.json`](tests/fixtures/platform-controls.json)
for a complete example.

- **`document_url` unset (the default):** the snapshot at `snapshot_path` is
  loaded fresh on every request; nothing is fetched over the network.
- **`document_url` set:** a successful fetch (5-second timeout) is cached
  under a fixed key for `cache_seconds` (default 24 hours) before it is
  fetched again.
- **A failed fetch** — network error, non-200 status, invalid JSON, or a
  document missing a required field — falls back to the on-disk snapshot.
  That fallback is itself cached, but only for `min(300, cache_seconds)`
  seconds: long enough that a dead `document_url` doesn't pay the fetch
  timeout on every request, short enough to retry soon after the upstream
  recovers.
- A document served from the snapshot (whether because `document_url` is
  unset, or as a fallback) is marked stale. The public page then shows a
  "Platform information as of `<published_at>`" badge, and the evidence
  pack's `platform-controls.json` carries `"stale_snapshot": true` and an
  explanatory `note`.

## The evidence pack

`GET /trust/evidence/` (an HTML summary) and `GET /trust/evidence.zip` (the
download) are gated on login plus `evidence_permission` — the app's owner,
admin, or whoever the host app's callable decides; by default, a Django
superuser. The zip contains:

| File | Contents |
|---|---|
| `README.md` | index of the other files and which Annex A control each evidences |
| `cryptography-policy.md` | the cryptography policy rendered as a document, with the operator name and date |
| `keyset_status.json` | `hyperscale.crypto`'s live key inventory (statuses, last rotation, canary result), or `{"unavailable": "<reason>"}` if `hyperscale.crypto` isn't installed or its tables aren't migrated |
| `key_rotation_events.csv` | every recorded `KeyRotationEvent`, or a single row explaining why none could be read |
| `platform-controls.json` | the whole platform document as fetched or loaded, plus `stale_snapshot` and, when stale, a `note` |
| `classification.json` | Data categories processed, with their sensitivity levels and the level definitions |
| `deployment.json` | `GIT_SHA`, `DEPLOYED_AT`, and installed versions of `hyperscale-trust`, `hyperscale-crypto` and `django` |

The spec also lists a per-attribute classification export; that is not yet
part of the `trust.json` contract, so `classification.json` carries category
aggregates until App Studio exports it.

`keyset_status.json` and `key_rotation_events.csv` never disappear from the
pack just because `hyperscale.crypto` is absent or not yet migrated — the
failure is recorded in the file instead, so an auditor sees why evidence is
missing rather than a shorter zip.

## Environment

`GIT_SHA` and `DEPLOYED_AT` are read from the process environment
(`os.environ`) when the evidence pack is built and written into
`deployment.json`; they default to an empty string when unset. Neither ever
appears on the public page — only inside the evidence pack.

## Wording policy

Control titles, statements, questions, answers (in
`hyperscale.trust.catalogue`) and the cryptography-policy paragraphs are
vendor-neutral: a reviewer reads "FIPS 140-3 validated key management
service", never a product name. `tests/test_catalogue.py` asserts none of
that prose contains a name from `catalogue.VENDOR_DENY_LIST` (currently AWS,
Amazon, KMS, Secrets Manager, RDS, CloudFront, S3, Stripe, Anthropic). The
one place a vendor is named is the subprocessor list, resolved from the
platform document's `hosting_provider`/`email_provider` or from names the
host app declares in `trust.json` — subprocessor lists must name who they
are.

## Development

Requires [uv](https://docs.astral.sh/uv/) and Python 3.14+.

```bash
uv sync                             # create the venv and install dependencies
uv run pytest                       # run the tests
uv run pre-commit run --all-files   # lint, format and lockfile checks
```

See [CONTRIBUTING.md](CONTRIBUTING.md) for the full set of checks.

## Releasing

Bump `version` in `pyproject.toml` and `__version__` in
`src/hyperscale/trust/__init__.py`, update `CHANGELOG.md`, then publish a
GitHub release tagged `v<version>`. The `publish` workflow checks the tag
matches the package version, builds, runs `twine check` and publishes to PyPI
with trusted publishing.

Before the first release, register the project on PyPI with this repository
and `publish.yml` as a trusted publisher, and create a `pypi` environment in
the repository settings.

## Design

Design spec: [`docs/superpowers/specs/2026-09-24-trust-center-design.md`](docs/superpowers/specs/2026-09-24-trust-center-design.md)

Implementation plan: [`docs/superpowers/plans/2026-09-25-hyperscale-trust.md`](docs/superpowers/plans/2026-09-25-hyperscale-trust.md)

## License

MIT. See [LICENSE](LICENSE).
