| -- harness/pg/schema.sql β the control DB + per-tenant schema (X4 / EXIT-2b, 2026-07-30). | |
| -- | |
| -- WHY POSTGRES AT ALL (C1c, and it is not a preference). The HF Dataset store is a FILE HOST: | |
| -- no transactions, no PITR, no row-level anything, and `store.get()` is cache-first β so a write | |
| -- from another process is invisible to a running app until restart. That was observed LIVE in | |
| -- wave 11, and it is the whole reason the store leaves the product. Application/control data | |
| -- (users, sessions, tenants, audit, saved views, overlay fields, cohorts, folders, prefs, | |
| -- billing mirror) is transactional and needs joins; the ANALYTICAL mirror stays in DuckDB, | |
| -- one file per tenant, because file-per-tenant IS the isolation model there. | |
| -- | |
| -- β RUN AGAINST A REAL SERVER 2026-08-04 (W19): applied end-to-end by verify_store_pg's | |
| -- integration half against the owner's Neon (psycopg executes everything ABOVE the | |
| -- "-- Optional:" marker; the tail below it is psql-variable syntax). Tenant provisioning, | |
| -- jsonb/bytea round-trips and 80 concurrent FOR-UPDATE writes all proven. The CUTOVER remains | |
| -- parked behind C1e's triggers; `STORE_BACKEND` still defaults to hf. | |
| -- | |
| -- β CO-LOCATE (C1b). An Ashburn app with a European database adds ~90ms to every query and undoes | |
| -- the reason the market pivot happened. | |
| -- | |
| -- psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f harness/pg/schema.sql | |
| -- psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -v slug=royal_imports -f harness/pg/schema.sql | |
| -- (the second form additionally provisions one tenant schema β see the bottom block) | |
| BEGIN; | |
| -- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| -- CONTROL: what the platform knows about itself. One row per tenant, one per account. | |
| -- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| CREATE SCHEMA IF NOT EXISTS control; | |
| CREATE TABLE IF NOT EXISTS control.tenants ( | |
| -- The SLUG is the identity everywhere: the session cookie's `t` claim, the store namespace, | |
| -- the DuckDB filename and the schema name are all derived from it. Constrained to what is | |
| -- safe in all four positions, which is stricter than Postgres alone would require. | |
| slug text PRIMARY KEY CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,58}[a-z0-9]$'), | |
| name text NOT NULL, | |
| -- `harness/tenants.py`'s literal, as data. Its own docstring says this becomes "a tenants | |
| -- store (DB / HF dataset)"; this is that store. jsonb rather than columns because the shape | |
| -- is per-connector and adding a source must not be a migration. | |
| config jsonb NOT NULL DEFAULT '{}'::jsonb, | |
| -- Connector CREDENTIALS never live here in plaintext. The Odoo key is the customer's, | |
| -- stored encrypted at rest by the secrets layer (C2: SOPS+age per tenant) and shown once. | |
| -- This column holds only a REFERENCE to where the secret lives. | |
| secret_ref text, | |
| active boolean NOT NULL DEFAULT true, | |
| created_at timestamptz NOT NULL DEFAULT now(), | |
| updated_at timestamptz NOT NULL DEFAULT now() | |
| ); | |
| CREATE TABLE IF NOT EXISTS control.users ( | |
| -- Mirrors `core/users.py`'s record exactly, so the HFβPG cutover (C-4) is a copy, not a | |
| -- redesign: salt + PBKDF2-HMAC-SHA256 hash (200k), never a plaintext password. | |
| tenant_slug text NOT NULL REFERENCES control.tenants(slug) ON DELETE CASCADE, | |
| username text NOT NULL CHECK (username = lower(username)), | |
| salt text NOT NULL, | |
| hash text NOT NULL, | |
| name text NOT NULL DEFAULT '', | |
| role text NOT NULL DEFAULT 'user', | |
| -- 'all' or a JSON array of Odoo team ids ([5]=Fisch, [6]=Royal) β the BU isolation input. | |
| bus jsonb NOT NULL DEFAULT '"all"'::jsonb, | |
| -- 'all' or a JSON array of registry keys β the module grant `may_open` reads. | |
| modules jsonb NOT NULL DEFAULT '"all"'::jsonb, | |
| agent text, -- own-book scope (res.partner.agent_ids name) | |
| email text, | |
| active boolean NOT NULL DEFAULT true, | |
| -- X3's stateless-session revocation handle. A signed cookie cannot be deleted server-side, | |
| -- so it carries the epoch it was minted under and every verification re-reads THIS number; | |
| -- a password change or a deactivation bumps it and every outstanding cookie dies at once. | |
| epoch integer NOT NULL DEFAULT 0, | |
| created_at timestamptz NOT NULL DEFAULT now(), | |
| updated_at timestamptz NOT NULL DEFAULT now(), | |
| PRIMARY KEY (tenant_slug, username) | |
| ); | |
| -- D2's SESSIONS MIRROR β an AUDIT view, deliberately NOT the source of truth (X3 keeps sessions | |
| -- stateless so the API process holds no per-tenant state). It answers "who is signed in, from | |
| -- where, since when", and it is what makes per-DEVICE revocation possible later. Arrives with | |
| -- C-2; the table is defined now so the cutover does not need a second migration. | |
| CREATE TABLE IF NOT EXISTS control.sessions ( | |
| id bigserial PRIMARY KEY, | |
| tenant_slug text NOT NULL, | |
| username text NOT NULL, | |
| epoch integer NOT NULL, | |
| issued_at timestamptz NOT NULL DEFAULT now(), | |
| last_seen_at timestamptz NOT NULL DEFAULT now(), | |
| absolute_expiry timestamptz NOT NULL, | |
| -- Truncated/derived client facts only. A full UA string plus an exact IP is more personal | |
| -- data than an audit trail needs, and the DPA is easier to keep when the row is smaller. | |
| ip_prefix text, | |
| user_agent text, | |
| revoked_at timestamptz, | |
| FOREIGN KEY (tenant_slug, username) | |
| REFERENCES control.users(tenant_slug, username) ON DELETE CASCADE | |
| ); | |
| CREATE INDEX IF NOT EXISTS sessions_live_idx | |
| ON control.sessions (tenant_slug, username) WHERE revoked_at IS NULL; | |
| CREATE TABLE IF NOT EXISTS control.audit ( | |
| id bigserial PRIMARY KEY, | |
| at timestamptz NOT NULL DEFAULT now(), | |
| tenant_slug text, | |
| username text, | |
| action text NOT NULL, -- 'login' | 'store.put' | 'user.deactivate' | β¦ | |
| target text, | |
| detail jsonb NOT NULL DEFAULT '{}'::jsonb | |
| ); | |
| CREATE INDEX IF NOT EXISTS audit_at_idx ON control.audit (at DESC); | |
| CREATE INDEX IF NOT EXISTS audit_tenant_idx ON control.audit (tenant_slug, at DESC); | |
| -- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| -- PER-TENANT: one schema `t_<slug>` per tenant, holding what the HF store holds today. | |
| -- | |
| -- A SCHEMA PER TENANT, not a tenant_id column with RLS. Both are defensible; this one is chosen | |
| -- because the isolation is then structural β a query that forgets its tenant predicate cannot | |
| -- silently return another tenant's rows, it names a table that is not in the search_path. RLS | |
| -- makes the same guarantee only while every policy is correct on every table, forever, and | |
| -- [[aios-permissioning]] already records that a forgotten predicate is the failure mode we keep | |
| -- writing gates against. The cost is DDL per tenant, which is one function call below. | |
| -- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| CREATE OR REPLACE FUNCTION control.provision_tenant_schema(p_slug text) | |
| RETURNS text | |
| LANGUAGE plpgsql | |
| AS $$ | |
| DECLARE | |
| -- β `-` IS NOT LEGAL UNQUOTED, and the slug carries them (`royal-imports`). Normalising to | |
| -- `_` here rather than quoting everywhere keeps every generated identifier plain, and | |
| -- `format('%I')` is what makes the interpolation injection-safe regardless. | |
| v_schema text := 't_' || replace(lower(p_slug), '-', '_'); | |
| BEGIN | |
| IF p_slug IS NULL OR p_slug !~ '^[a-z0-9][a-z0-9-]{0,58}[a-z0-9]$' THEN | |
| RAISE EXCEPTION 'refusing to provision an unsafe tenant slug: %', p_slug; | |
| END IF; | |
| EXECUTE format('CREATE SCHEMA IF NOT EXISTS %I', v_schema); | |
| -- store_kv β the JSON-per-key store `core/store.py` exposes, one row per key. `rev` is what | |
| -- the file host never had: an optimistic-concurrency counter, so a read-modify-write can | |
| -- detect that somebody else wrote between its read and its write instead of overwriting | |
| -- them. That is the concrete thing "no transactions" cost us. | |
| EXECUTE format($f$ | |
| CREATE TABLE IF NOT EXISTS %I.store_kv ( | |
| key text PRIMARY KEY, | |
| value jsonb NOT NULL, | |
| updated_at timestamptz NOT NULL DEFAULT now(), | |
| rev bigint NOT NULL DEFAULT 1 | |
| )$f$, v_schema); | |
| -- store_blobs β `upload_bytes`/`download_bytes`/`delete_path`. Customer documents live here | |
| -- rather than base64'd into a JSON value, for the reason `core.store.upload_bytes` gives: | |
| -- the workspace blob is READ ON EVERY RENDER, so a few MB of base64 inside it would be paid | |
| -- on every keystroke by every user of that table. | |
| EXECUTE format($f$ | |
| CREATE TABLE IF NOT EXISTS %I.store_blobs ( | |
| path text PRIMARY KEY, | |
| bytes bytea NOT NULL, | |
| message text, | |
| updated_at timestamptz NOT NULL DEFAULT now() | |
| )$f$, v_schema); | |
| EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.store_kv (updated_at DESC)', | |
| v_schema || '_kv_updated_idx', v_schema); | |
| RETURN v_schema; | |
| END; | |
| $$; | |
| -- Tenant #0, always. Its slug is the one the session cookie already carries. | |
| INSERT INTO control.tenants (slug, name) | |
| VALUES ('royal-imports', 'Royal Imports') | |
| ON CONFLICT (slug) DO NOTHING; | |
| SELECT control.provision_tenant_schema('royal-imports'); | |
| -- Optional: `psql -v slug=<a-slug>` provisions one more tenant schema in the same transaction. | |
| -- `:'slug'` is unset in the plain invocation, so this block is skipped there. | |
| \if :{?slug} | |
| INSERT INTO control.tenants (slug, name) VALUES (:'slug', :'slug') | |
| ON CONFLICT (slug) DO NOTHING; | |
| SELECT control.provision_tenant_schema(:'slug'); | |
| \endif | |
| COMMIT; | |