diff --git a/.coverage-gates.yml b/.coverage-gates.yml
index 4d60740..550f23a 100644
--- a/.coverage-gates.yml
+++ b/.coverage-gates.yml
@@ -8,6 +8,12 @@ include:
- internal/
- pkg/
+# Build-time code generators (run via `go generate`, validated by the
+# docs-gen freshness test, not by line coverage) — excluded like cmd/.
+exclude:
+ - internal/config/gen
+ - internal/service/gen
+
packages:
internal/app: 85
internal/config: 88
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 13c116d..db52e22 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -322,13 +322,32 @@ jobs:
docker logs "$cid"
exit 1
+ # ---------------------------------------------------------------------------
+ # 8. Docs drift — docs may not name an env/RPC/metric/path that does not
+ # exist, and the generated reference JSON must be up to date.
+ # ---------------------------------------------------------------------------
+ docs-drift:
+ name: Docs drift
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@v7
+
+ - uses: actions/setup-go@v6
+ with:
+ go-version: ${{ env.GO_VERSION }}
+ cache: true
+
+ - name: go test ./internal/docscheck/...
+ run: go test -count=1 ./internal/docscheck/...
+
# ---------------------------------------------------------------------------
# Aggregate gate — easy single required check on `main`.
# ---------------------------------------------------------------------------
ci:
name: CI
runs-on: ubuntu-latest
- needs: [lint, tidy, vuln, test, smoke, integration, fuzz, docker]
+ needs: [lint, tidy, vuln, test, smoke, integration, fuzz, docker, docs-drift]
if: always()
steps:
- name: All required jobs succeeded
diff --git a/Makefile b/Makefile
index 7cc62b7..e7877e9 100644
--- a/Makefile
+++ b/Makefile
@@ -177,6 +177,15 @@ fuzz: ## Fuzz smoke — runs each fuzz target with seed corpus + 15s fuzzing
CONNECT_OPENAPI_VERSION ?= v0.18.0
+.PHONY: docs-gen
+docs-gen: ## Regenerate the docs reference JSON (config + audit/metrics) from code
+ $(GO) generate ./internal/config/... ./internal/service/...
+ @echo "==> docs reference JSON regenerated → docs-site/src/data/generated"
+
+.PHONY: docs-drift
+docs-drift: ## Check docs name only real identifiers + generated JSON is fresh
+ $(GO) test -count=1 ./internal/docscheck/...
+
.PHONY: proto
proto: ## Regenerate Go stubs + OpenAPI + proto reference from proto
$(BUF) generate
diff --git a/docs-site/src/data/generated/audit-events.json b/docs-site/src/data/generated/audit-events.json
new file mode 100644
index 0000000..bb7e49a
--- /dev/null
+++ b/docs-site/src/data/generated/audit-events.json
@@ -0,0 +1,54 @@
+{
+ "events": [
+ {
+ "name": "create_project",
+ "kind": "AdminAction",
+ "const": "AdminActionCreateProject",
+ "description": "AdminActionCreateProject records a CreateProject mutation."
+ },
+ {
+ "name": "delete",
+ "kind": "TupleOpKind",
+ "const": "TupleOpDelete",
+ "description": "TupleOpDelete is a revocation (a tuple was removed)."
+ },
+ {
+ "name": "insert",
+ "kind": "TupleOpKind",
+ "const": "TupleOpInsert",
+ "description": "TupleOpInsert is a grant (a tuple was written)."
+ },
+ {
+ "name": "update_project",
+ "kind": "AdminAction",
+ "const": "AdminActionUpdateProject",
+ "description": "AdminActionUpdateProject records an UpdateProject mutation."
+ }
+ ],
+ "metrics": [
+ {
+ "name": "authz_batchcheck_items",
+ "description": "Items per BatchCheck request."
+ },
+ {
+ "name": "authz_check_decisions_total",
+ "description": "Authorization Check/CheckSet/BatchCheck decisions by namespace, relation, and outcome."
+ },
+ {
+ "name": "authz_check_duration_seconds",
+ "description": "Authorization decision RPC latency in seconds."
+ },
+ {
+ "name": "authz_decision_errors_total",
+ "description": "Authorization decision RPC errors (validation + internal)."
+ },
+ {
+ "name": "authz_eval_backstop_total",
+ "description": "Engine per-request safety backstops that fired, by reason: depth/cycle (graceful fail-closed deny) or budget (read-budget exhausted, ResourceExhausted error). A rising rate signals an abusive tenant or a misconfigured deep/cyclic model."
+ },
+ {
+ "name": "authz_region_refused_total",
+ "description": "Requests refused because the project's pinned data region differs from this instance's region (data-residency fail-closed)."
+ }
+ ]
+}
diff --git a/docs-site/src/data/generated/config.json b/docs-site/src/data/generated/config.json
new file mode 100644
index 0000000..57da231
--- /dev/null
+++ b/docs-site/src/data/generated/config.json
@@ -0,0 +1,139 @@
+[
+ {
+ "name": "GATEWAY_ADMIN_API_SECRET",
+ "type": "string",
+ "default": "",
+ "required": false,
+ "description": "AdminAPISecret gates the AdminService (project configuration), presented as the `X-Admin-Secret` header. Empty disables the admin RPCs entirely.",
+ "constraint": "must be a high-entropy value of at least 32 characters"
+ },
+ {
+ "name": "GATEWAY_ADMIN_RATE_LIMIT_PER_MINUTE",
+ "type": "int",
+ "default": "30",
+ "required": false,
+ "description": "AdminRateLimitPerMinute throttles the admin API per caller (online brute-force protection); non-positive disables the limiter."
+ },
+ {
+ "name": "GATEWAY_ALLOWED_ORIGINS",
+ "type": "csv",
+ "default": "",
+ "required": false,
+ "description": "AllowedOrigins is the CORS allow-list of browser origins permitted to call the API. Empty allows none."
+ },
+ {
+ "name": "GATEWAY_AUDIT_LOG",
+ "type": "bool",
+ "default": "false",
+ "required": false,
+ "description": "AuditLog enables an async, append-only audit log of every relation-tuple change and admin mutation (to the structured logger). Default false."
+ },
+ {
+ "name": "GATEWAY_CONNECT_PORT",
+ "type": "int",
+ "default": "8080",
+ "required": false,
+ "description": "ConnectPort is the Connect/HTTP listener serving JSON, gRPC, and gRPC-Web RPCs."
+ },
+ {
+ "name": "GATEWAY_DATA_REGION",
+ "type": "string",
+ "default": "",
+ "required": false,
+ "description": "DataRegion is the region this instance serves: it refuses to operate on a project pinned to a different data_region (fail closed). Empty (default) is region-agnostic — serves every project, today's behavior."
+ },
+ {
+ "name": "GATEWAY_DECISION_LOG",
+ "type": "bool",
+ "default": "false",
+ "required": false,
+ "description": "DecisionLog enables an async, append-only audit log of every Check/ CheckSet decision (to the structured logger). Default false."
+ },
+ {
+ "name": "GATEWAY_DEFAULT_PROJECT_ID",
+ "type": "string",
+ "default": "default",
+ "required": false,
+ "description": "DefaultProjectID is the project shard used for any request whose project_id is empty."
+ },
+ {
+ "name": "GATEWAY_DEFAULT_TENANT_ID",
+ "type": "string",
+ "default": "",
+ "required": false,
+ "description": "DefaultTenantID is the tenant pinned for requests that omit tenant_id (the project's default tenant). Empty is the conventional default."
+ },
+ {
+ "name": "GATEWAY_HTTP_MAX_BODY_BYTES",
+ "type": "int",
+ "default": "1048576",
+ "required": false,
+ "description": "HTTPMaxBodyBytes is the maximum accepted request body, in bytes. Must be positive.",
+ "constraint": "must be positive"
+ },
+ {
+ "name": "GATEWAY_MAX_BATCH_CHECK_ITEMS",
+ "type": "int",
+ "default": "1000",
+ "required": false,
+ "description": "MaxBatchCheckItems caps the number of items in a single BatchCheck request, bounding per-request cost."
+ },
+ {
+ "name": "GATEWAY_MAX_CHECK_READS",
+ "type": "int",
+ "default": "5000",
+ "required": false,
+ "description": "MaxCheckReads caps the number of store reads (tuple lookups) a single Check/CheckSet/Expand/ListObjects evaluation may perform, bounding the per-request cost a pathological cyclic/branching graph can inflict. Non-positive uses the service default.",
+ "constraint": "when set, must be \u003e= 100 (use 0/negative for the default)"
+ },
+ {
+ "name": "GATEWAY_MAX_EXPAND_NODES",
+ "type": "int",
+ "default": "10000",
+ "required": false,
+ "description": "MaxExpandNodes caps the size of an Expand result tree, bounding the response a single cheap request can amplify into."
+ },
+ {
+ "name": "GATEWAY_MAX_LIST_OBJECTS",
+ "type": "int",
+ "default": "1000",
+ "required": false,
+ "description": "MaxListObjects caps the candidate set a single ListObjects call scans, bounding its full-scan + per-object Check cost."
+ },
+ {
+ "name": "GATEWAY_METRICS_PORT",
+ "type": "int",
+ "default": "9090",
+ "required": false,
+ "description": "MetricsPort serves the Prometheus /metrics endpoint and the health probes."
+ },
+ {
+ "name": "GATEWAY_POSTGRES_AUTO_MIGRATE",
+ "type": "bool",
+ "default": "false",
+ "required": false,
+ "description": "PostgresAutoMigrate runs the expand migration on boot when true. It defaults to FALSE: migrations are a deliberate operator step (out-of-band `workspace migrate`, an init container, or a migrate Job), so a large existing DB's first deploy can never livelock on a bounded CONCURRENTLY build inside the boot window. Opt in (true) only for small/dev DBs."
+ },
+ {
+ "name": "GATEWAY_POSTGRES_DSN",
+ "type": "string",
+ "default": "",
+ "required": false,
+ "description": "PostgresDSN selects the postgres driver when set; empty uses memory."
+ },
+ {
+ "name": "GATEWAY_SERVICE_AUTH_TOKENS",
+ "type": "csv",
+ "default": "",
+ "required": false,
+ "description": "ServiceAuthTokens are the accepted service credentials presented by calling backends as `Authorization: Bearer \u003ctoken\u003e`. This is an internal service authenticated service-to-service (not by end-user tokens); the user is passed as data in the request. Empty disables the requirement — trust the network/mesh — and the service logs a warning."
+ },
+ {
+ "name": "GATEWAY_TENANT_RATE_LIMIT_PER_MINUTE",
+ "type": "int",
+ "default": "0",
+ "required": false,
+ "description": "TenantRateLimitPerMinute throttles authz data-plane RPCs per (project, tenant); non-positive (the default) disables the limiter.",
+ "constraint": "when enabled, must be \u003e= 60 (use 0 to disable)"
+ }
+]
diff --git a/docs-site/src/pages/docs/deployment/docker.astro b/docs-site/src/pages/docs/deployment/docker.astro
index 02583e0..f4ea38c 100644
--- a/docs-site/src/pages/docs/deployment/docker.astro
+++ b/docs-site/src/pages/docs/deployment/docker.astro
@@ -28,9 +28,13 @@ docker compose down -v # stop and wipe the postgres volume`}
title="docker compose"
/>
- With GATEWAY_POSTGRES_AUTO_MIGRATE=true (the default) the
- schema is applied on first connect, so the stack is ready as soon as
- Postgres is healthy.
+ Migrations are a deliberate operator step:
+ GATEWAY_POSTGRES_AUTO_MIGRATE defaults to false,
+ so apply the schema explicitly with workspace migrate (an init
+ container or a one-shot job) before serving, or set it to true
+ for a small dev database to migrate on first connect. See
+ Configuration for the
+ full knob reference.
A Compose snippet
diff --git a/docs-site/src/pages/docs/installation/configuration.astro b/docs-site/src/pages/docs/installation/configuration.astro
index 194e0d2..0666721 100644
--- a/docs-site/src/pages/docs/installation/configuration.astro
+++ b/docs-site/src/pages/docs/installation/configuration.astro
@@ -1,8 +1,27 @@
---
import DocsLayout from "../../../layouts/DocsLayout.astro";
import { Code } from "astro-expressive-code/components";
+import configKnobs from "../../../data/generated/config.json";
const base = import.meta.env.BASE_URL.replace(/\/$/, "") + "/";
+
+// The table below is generated from internal/config/config.go by
+// `make docs-gen` — never hand-edit names, defaults, or constraints here.
+// A CI drift check (internal/docscheck) fails if this JSON is stale.
+type Knob = {
+ name: string;
+ type: string;
+ default: string;
+ required: boolean;
+ description: string;
+ constraint?: string;
+};
+const knobs = configKnobs as Knob[];
+
+function renderDefault(k: Knob): string {
+ if (k.type === "csv" || k.default === "") return "(empty)";
+ return k.default;
+}
---
@@ -15,73 +34,66 @@ const base = import.meta.env.BASE_URL.replace(/\/$/, "") + "/";
zero configuration for local development; production
deployments override the Postgres DSN and the service-auth tokens.
-
-
Ports
-
-
-
Variable
Purpose
Default
-
-
-
GATEWAY_CONNECT_PORT
Connect/HTTP listener — JSON, gRPC, and gRPC-Web RPCs.
8080
-
GATEWAY_METRICS_PORT
Prometheus /metrics and the health probes.
9090
-
-
-
-
Project
-
-
-
Variable
Purpose
Default
-
-
-
GATEWAY_DEFAULT_PROJECT_ID
Project shard used for any request whose project_id is empty.
default
-
-
- Every workspace, group, invitation, and relation tuple is scoped to a
- project (the isolation shard). One B2C product is typically one project; a
- B2B platform shards per customer. See
- Projects & Multi-Tenancy.
+ The reference table is generated directly from the service's configuration
+ source (internal/config/config.go), so the variable names,
+ defaults, and validation floors here can never drift from the code.
-
Storage
+
All variables
-
Variable
Purpose
Default
+
+
Variable
+
Type
+
Default
+
Description
+
Constraint
+
-
GATEWAY_POSTGRES_DSN
Postgres connection string. Selects the Postgres driver. Empty selects the in-memory store.
(empty)
-
GATEWAY_POSTGRES_AUTO_MIGRATE
Apply pending migrations automatically on connect.
- In-memory (DSN empty) — the default, and the conformance
- reference both drivers are pinned against. Data lives in process memory
- and is lost on restart. Use it for local development, tests, and
- ephemeral demos.
+ In-memory (GATEWAY_POSTGRES_DSN empty) — the
+ default, and the conformance reference both drivers are pinned against.
+ Data lives in process memory and is lost on restart. Use it for local
+ development, tests, and ephemeral demos.
Postgres (DSN set) — durable, the only choice for
production and for running more than one replica. Every table and index
- leads with project_id. With
- GATEWAY_POSTGRES_AUTO_MIGRATE=true (the default) the schema
- is applied on connect; to gate migrations behind a deploy step, set it to
- false and run workspace migrate explicitly (see
- Deploy with Docker).
+ leads with project_id. Migrations are a deliberate operator
+ step: GATEWAY_POSTGRES_AUTO_MIGRATE defaults to
+ false, so run workspace migrate explicitly (see
+ Deploy with Docker). Opt in
+ to boot-time migration only for small or dev databases.
Service authentication
-
-
-
Variable
Purpose
Default
-
-
-
GATEWAY_SERVICE_AUTH_TOKENS
Comma-separated list of accepted service credentials. A caller presents one as Authorization: Bearer <token>. Empty disables the requirement and logs a warning — every caller is then trusted.
(empty)
-
-
Workspaces is an internal service-to-service API: the caller is a trusted
product backend, and the end user is passed as data
@@ -104,26 +116,6 @@ const base = import.meta.env.BASE_URL.replace(/\/$/, "") + "/";
Security & Service Auth.
-
CORS
-
-
-
Variable
Purpose
Default
-
-
-
GATEWAY_ALLOWED_ORIGINS
Comma-separated list of CORS origins allowed to call the API from a browser. Empty allows none.
(empty)
-
-
-
-
Request body size
-
-
-
Variable
Purpose
Default
-
-
-
GATEWAY_HTTP_MAX_BODY_BYTES
Maximum accepted request body, in bytes. Must be positive.
1048576 (1 MiB)
-
-
-
A production example
The minimum production override: a durable store and at least one service
diff --git a/docs-site/src/pages/docs/operations/observability.astro b/docs-site/src/pages/docs/operations/observability.astro
index 7cac37a..e8367c6 100644
--- a/docs-site/src/pages/docs/operations/observability.astro
+++ b/docs-site/src/pages/docs/operations/observability.astro
@@ -1,8 +1,22 @@
---
import DocsLayout from "../../../layouts/DocsLayout.astro";
import { Code } from "astro-expressive-code/components";
+import auditData from "../../../data/generated/audit-events.json";
const base = import.meta.env.BASE_URL.replace(/\/$/, "") + "/";
+
+// The metric and audit-event tables below are generated from
+// internal/connect/metrics.go and internal/service/auditlog.go by
+// `make docs-gen`; a CI drift check fails if this JSON is stale.
+type Metric = { name: string; description: string };
+type AuditEvent = {
+ name: string;
+ kind: string;
+ const: string;
+ description: string;
+};
+const metrics = auditData.metrics as Metric[];
+const auditEvents = auditData.events as AuditEvent[];
---
@@ -60,6 +74,57 @@ curl -i http://localhost:8080/readyz # readiness`}
title="prometheus.yml"
/>
+
Exported metrics
+
+ The authorization decision metrics the service exports. Labels are kept to
+ low-cardinality dimensions (namespace, relation, outcome, RPC, reason) —
+ never object or subject identifiers. This list is generated from the
+ service's metrics source, so it stays in lockstep with the code.
+
+
+
+
Metric
Meaning
+
+
+ {
+ metrics.map((m) => (
+
+
+ {m.name}
+
+
{m.description}
+
+ ))
+ }
+
+
+
+
Audit events
+
+ With GATEWAY_AUDIT_LOG=true, every relation-tuple change and
+ admin mutation is emitted to the structured logger as an append-only audit
+ record. The event-type vocabulary — the operation strings carried by those
+ records — is generated from the service's audit source:
+
+
+
+
Event
Kind
Meaning
+
+
+ {
+ auditEvents.map((e) => (
+
+
+ {e.name}
+
+
{e.kind}
+
{e.description}
+
+ ))
+ }
+
+
+
Structured logs
Workspaces logs with zap as
diff --git a/docs-site/src/pages/docs/quickstart.astro b/docs-site/src/pages/docs/quickstart.astro
index 93a2787..98e1704 100644
--- a/docs-site/src/pages/docs/quickstart.astro
+++ b/docs-site/src/pages/docs/quickstart.astro
@@ -22,9 +22,9 @@ const base = import.meta.env.BASE_URL.replace(/\/$/, "") + "/";
your product backend will present; here we use a single token.
- Pending migrations apply automatically on first connect while
- GATEWAY_POSTGRES_AUTO_MIGRATE=true (the default). Export the
- service token so the curls below can reuse it:
+ The bundled docker-compose.yml sets
+ GATEWAY_POSTGRES_AUTO_MIGRATE=true so the dev stack migrates on
+ first connect (it defaults to false — migrations are an
+ explicit operator step in production). Export the service token so the curls
+ below can reuse it: