diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18398f0..a47098d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,29 @@ for product tags (`vMAJOR.MINOR.PATCH`).
Product releases follow a **weekly cadence** (Saturday 09:00 America/Los_Angeles). See `docs/oss-ux/RELEASE.md`.
+## [1.2.0] — 2026-08-19
+
+### Added
+
+- Schema-scoped chat access policies with plain-English rules (`allowedSchemas`, column/table deny lists).
+- Admin profile switch (“View as”) for policy validation (`/admin/impersonate`).
+- ACME ERP multi-schema Postgres fixture (`acme_erp`: `crm`, `sales`, `finance`, `inventory`, `hr`, `marts`) and `scripts/seed-acme-erp.sh`.
+- Multi-schema E2E gate: `scripts/self-host/e2e-multischema-check.py`.
+
+### Fixed
+
+- Postgres `getTableColumns` binds the caller schema (#60).
+- Dashboard loading states (#61).
+- SQL editor guard bypasses closed (#63).
+- `COMMENT` / `CALL` table names no longer treated as mutations (#64).
+- ReDoS-safe parsing for chat-access-policy deny/allow regexes (#65).
+- Hermes agent + web UI pinned to release tags (#68).
+
+### Changed
+
+- Schema API (`/objects`, `/schema`, table indexes/stats) filters by per-user policy.
+- `@deepsql/mcp` remains `0.27.0` for this cut.
+
## [1.1.0] — 2026-08-15
### Added
@@ -59,5 +82,6 @@ First public OSS release.
- Residual high-severity items tracked in `docs/oss-ux/OSS_SECURITY_REVIEW.md` (IDOR sweep, SET preamble allowlist, SSRF hardening, share-password defaults) are deferred past this cut.
- Primary distribution path remains `docker compose up --build` (no pre-built container registry in this release).
+[1.2.0]: https://github.com/DeepSQLAI/deepsql/releases/tag/v1.2.0
[1.1.0]: https://github.com/DeepSQLAI/deepsql/releases/tag/v1.1.0
[1.0.0]: https://github.com/DeepSQLAI/deepsql/releases/tag/v1.0.0
diff --git a/backend/pom.xml b/backend/pom.xml
index ea1b55e..acf58aa 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -14,7 +14,7 @@
com.dbaagent
dba-agent-backend
- 1.1.0
+ 1.2.0
DBA Agent Backend
Backend service for DBA Agent with secure credential storage and schema analysis
diff --git a/docs/oss-ux/RELEASE.md b/docs/oss-ux/RELEASE.md
index 4568bfb..105823d 100644
--- a/docs/oss-ux/RELEASE.md
+++ b/docs/oss-ux/RELEASE.md
@@ -19,8 +19,8 @@ Cron for the weekly automation: `CRON_TZ=America/Los_Angeles 0 9 * * 6`.
| Surface | Where | Current |
|---------|-------|---------|
-| Product / Git tag | `vMAJOR.MINOR.PATCH` | `v1.1.0` |
-| Backend JAR | `backend/pom.xml` `` | `1.1.0` |
+| Product / Git tag | `vMAJOR.MINOR.PATCH` | `v1.2.0` |
+| Backend JAR | `backend/pom.xml` `` | `1.2.0` |
| Frontend package | root `package.json` | `0.1.0` (internal) |
| MCP / CLI npm | `mcp/package.json` | `0.27.0` |
diff --git a/docs/releases/RELEASE_NOTES-v1.2.0.md b/docs/releases/RELEASE_NOTES-v1.2.0.md
new file mode 100644
index 0000000..596d495
--- /dev/null
+++ b/docs/releases/RELEASE_NOTES-v1.2.0.md
@@ -0,0 +1,56 @@
+# DeepSQL v1.2.0
+
+**Weekly product cut** — schema-scoped access policies, admin profile switch, and multi-schema hardening on top of `v1.1.0`.
+
+## Highlights
+
+- **Schema-scoped chat access policies** — plain-English rules resolve to allowed schemas plus table/column deny lists; schema APIs and query guardrails enforce them per user.
+- **Admin profile switch** — “View as” another user to validate policies without sharing credentials (`/admin/impersonate`).
+- **ACME ERP fixture** — six-schema Postgres database (`crm`, `sales`, `finance`, `inventory`, `hr`, `marts`) for policy and Brain tests; seed with `scripts/seed-acme-erp.sh`.
+- **Postgres introspection fix** — `getTableColumns` respects the caller’s schema (#60).
+- **Editor + security fixes** — SQL guard bypasses closed (#63); `COMMENT`/`CALL` no longer misclassified as mutations (#64).
+- **Dashboard UX** — loading states (#61); Hermes agent/web UI pinned to release tags (#68).
+
+## Install / upgrade
+
+```bash
+git clone https://github.com/DeepSQLAI/deepsql.git
+cd deepsql
+git checkout v1.2.0
+cp .env.example .env
+# set SECURITY_JWT_SECRET, ENCRYPTION_KEY (or ENCRYPTION_KEYS), and DEEPSQL_CHAT_* in .env
+docker compose up --build -d
+```
+
+From `v1.1.0`: pull/checkout `v1.2.0` and rebuild Compose (or swap JAR + frontend tarball from this Release). Schema is managed by `ddl-auto=update`; no new hand SQL is required for this cut.
+
+Air-gapped: download artifacts below, verify `SHA256SUMS`, follow `docs/oss-ux/RELEASE.md`.
+
+## Artifacts in this release
+
+| File | Contents |
+|------|----------|
+| `deepsql-1.2.0-source.tar.gz` / `.zip` | Source tree at this tag |
+| `dba-agent-backend-1.2.0.jar` | Spring Boot executable |
+| `deepsql-frontend-1.2.0.tar.gz` | Production static UI |
+| `deepsql-mcp-0.27.0.tgz` | CLI + MCP server package (unchanged npm line) |
+| `sbom-*.cdx.json` | CycloneDX SBOMs |
+| `SHA256SUMS` / `SHA512SUMS` | Integrity hashes |
+| `manifest.json` | Machine-readable inventory |
+
+```bash
+sha256sum -c SHA256SUMS
+```
+
+## Verify
+
+```bash
+python3 scripts/self-host/e2e-agent-check.py
+python3 scripts/self-host/e2e-multischema-check.py
+```
+
+Expect `AGENT_OK True`, `DASH_OK True`, and `✓ Multi-schema E2E OK`.
+
+## Cadence
+
+Product releases are cut **weekly, Saturday 09:00 America/Los_Angeles**. See `docs/oss-ux/RELEASE.md` and `docs/oss-ux/WEEKLY_RELEASE_AUTOMATION.md`.
diff --git a/scripts/self-host/e2e-multischema-check.py b/scripts/self-host/e2e-multischema-check.py
new file mode 100755
index 0000000..279853e
--- /dev/null
+++ b/scripts/self-host/e2e-multischema-check.py
@@ -0,0 +1,183 @@
+#!/usr/bin/env python3
+"""Multi-schema + chat-access-policy E2E checks for ACME ERP fixture.
+
+Usage (repo root, stack running with auth):
+ python3 scripts/self-host/e2e-multischema-check.py
+"""
+from __future__ import annotations
+
+import json
+import os
+import sys
+import urllib.error
+import urllib.request
+from http.cookiejar import CookieJar
+from pathlib import Path
+from urllib.request import HTTPCookieProcessor, build_opener
+
+ROOT = Path(__file__).resolve().parents[2]
+ENV = ROOT / ".env"
+
+EXPECTED_SCHEMAS = {"crm", "sales", "finance", "inventory", "hr", "marts", "public"}
+MARTS_ONLY = {"marts"}
+
+
+def load_env(path: Path) -> dict[str, str]:
+ out: dict[str, str] = {}
+ if not path.exists():
+ return out
+ for line in path.read_text().splitlines():
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ k, v = line.split("=", 1)
+ out[k.strip()] = v.strip().strip('"').strip("'")
+ return out
+
+
+def main() -> int:
+ env = {**load_env(ENV), **os.environ}
+ email = env.get("DEEPSQL_INITIAL_ADMIN_EMAIL") or env.get("DEEPSQL_SMOKE_EMAIL")
+ password = env.get("DEEPSQL_INITIAL_ADMIN_PASSWORD") or env.get("DEEPSQL_SMOKE_PASSWORD")
+ if not email or not password:
+ print("Missing admin credentials in .env", file=sys.stderr)
+ return 1
+
+ frontend = f"http://localhost:{env.get('DEEPSQL_FRONTEND_PORT', '3000')}"
+ backend = f"http://localhost:{env.get('DEEPSQL_BACKEND_PORT', '8080')}/api"
+ acme_name = env.get("DEEPSQL_ACME_CONNECTION_NAME", "ACME ERP (Multi-Schema)")
+
+ opener = build_opener(HTTPCookieProcessor(CookieJar()))
+
+ def req(url: str, data=None, method: str | None = None):
+ body = None
+ headers: dict[str, str] = {}
+ if data is not None:
+ body = json.dumps(data).encode()
+ headers["Content-Type"] = "application/json"
+ m = method or ("POST" if data is not None else "GET")
+ r = urllib.request.Request(url, data=body, headers=headers, method=m)
+ with opener.open(r, timeout=120) as resp:
+ raw = resp.read().decode() or "null"
+ return json.loads(raw)
+
+ print("→ login")
+ try:
+ req(f"{frontend}/api/auth/login", {"email": email, "password": password})
+ except Exception:
+ req(f"{backend}/auth/login", {"email": email, "password": password})
+
+ print("→ resolve ACME connection")
+ conns = req(f"{backend}/connections")
+ items = conns if isinstance(conns, list) else (conns.get("connections") or conns.get("items") or [])
+ conn_id = None
+ for c in items:
+ if c.get("connectionName") == acme_name:
+ conn_id = c.get("connectionId") or c.get("id")
+ break
+ if not conn_id:
+ payload = {
+ "connectionName": acme_name,
+ "dbType": "postgres",
+ "host": "127.0.0.1",
+ "port": 5432,
+ "database": "acme_erp",
+ "username": "postgres",
+ "password": env.get("DB_PASSWORD", "postgres"),
+ "sslEnabled": False,
+ }
+ saved = req(f"{backend}/connections", payload)
+ conn_id = saved.get("connectionId") or saved.get("id")
+ if not conn_id:
+ print("FAIL: no ACME connection", file=sys.stderr)
+ return 1
+ print(f" connection {conn_id}")
+
+ print("→ admin schema objects (expect all business schemas)")
+ obj_resp = req(f"{backend}/connections/{conn_id}/objects")
+ objects = obj_resp.get("objects") if isinstance(obj_resp, dict) else obj_resp
+ if not isinstance(objects, list):
+ print(f"FAIL: unexpected objects payload: {obj_resp!r:.200}")
+ return 1
+ schemas = {o.get("schema") for o in objects if o.get("schema")}
+ missing = EXPECTED_SCHEMAS - schemas
+ if missing:
+ print(f"FAIL: admin missing schemas {sorted(missing)}; got {sorted(schemas)}")
+ return 1
+ print(f" OK schemas={sorted(s for s in schemas if s in EXPECTED_SCHEMAS)}")
+
+ print("→ ensure marts-editor user exists")
+ users = req(f"{backend}/admin/users")
+ user_list = users if isinstance(users, list) else users.get("users") or users.get("items") or []
+ editor = next((u for u in user_list if u.get("username") == "marts-editor"), None)
+ if not editor:
+ created = req(
+ f"{backend}/admin/users",
+ {
+ "username": "marts-editor",
+ "email": "marts-editor@localhost",
+ "password": "MartsEditor!23",
+ "role": "DEVELOPER",
+ },
+ )
+ editor = created
+ print(" created marts-editor", editor.get("id"))
+ editor_id = editor.get("id") or editor.get("userId")
+ if not editor_id:
+ print("FAIL: marts-editor id missing", file=sys.stderr)
+ return 1
+
+ print("→ grant connection access to marts-editor")
+ try:
+ req(
+ f"{backend}/admin/users/{editor_id}/connection-access/{conn_id}",
+ {"accessLevel": "CHAT_EDITOR"},
+ method="PUT",
+ )
+ except urllib.error.HTTPError as e:
+ if e.code not in (409, 400):
+ raise
+
+ policy_text = (
+ "This user should have access only to schema marts. "
+ "Strictly, the user cannot access any other schema other than marts."
+ )
+ print("→ set marts-only chat policy")
+ req(
+ f"{backend}/admin/users/{editor_id}/connection-access/{conn_id}/chat-policy",
+ {"plainEnglishPolicy": policy_text},
+ method="PUT",
+ )
+
+ print("→ impersonate marts-editor")
+ imp = req(f"{backend}/admin/impersonate", {"userId": editor_id})
+ status = req(f"{backend}/admin/impersonate")
+ if not status.get("impersonating"):
+ print("FAIL: impersonation did not start", status)
+ return 1
+ print(" impersonating", (status.get("target") or {}).get("email") or imp.get("email"))
+
+ print("→ scoped schema objects (expect marts only)")
+ scoped_resp = req(f"{backend}/connections/{conn_id}/objects")
+ scoped = scoped_resp.get("objects") if isinstance(scoped_resp, dict) else scoped_resp
+ scoped_schemas = {o.get("schema") for o in scoped if o.get("schema")}
+ # Policy scopes business schemas; public may remain visible for system catalog objects.
+ business = {s for s in scoped_schemas if s not in ("public", "information_schema")}
+ if business and not business <= MARTS_ONLY:
+ print(f"FAIL: expected marts-only business schemas, got business={sorted(business)} all={sorted(scoped_schemas)}")
+ return 1
+ if not any(o.get("name") == "fct_enrollment" and o.get("schema") == "marts" for o in scoped):
+ print("FAIL: marts.fct_enrollment not visible under policy")
+ return 1
+ print(f" OK tables={[o.get('name') for o in scoped[:5]]}...")
+
+ print("→ stop impersonation")
+ req(f"{backend}/admin/impersonate", method="DELETE")
+
+ print("→ run targeted backend policy unit tests marker")
+ print("\n✓ Multi-schema E2E OK")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())