diff --git a/inventory/.gitignore b/inventory/.gitignore
new file mode 100644
index 0000000000..5ffffe1a7a
--- /dev/null
+++ b/inventory/.gitignore
@@ -0,0 +1 @@
+_proof_tmp/
diff --git a/inventory/PROGRESS.md b/inventory/PROGRESS.md
index c61907354b..921c96c108 100644
--- a/inventory/PROGRESS.md
+++ b/inventory/PROGRESS.md
@@ -6,7 +6,16 @@ Status: [ ] todo · [~] doing · [x] gate passed (record HOW verified — eviden
- Backend: Supabase, USA region, owner-owned. Connector-first; CLI fallback.
- Archive over delete. $0 target; anon read-only heartbeat to prevent pause; manual + scheduled export.
-- service_role key: forbidden everywhere. (append decisions as we go)
+- service_role key: forbidden everywhere.
+- Phase 1 schema (2026-05-31, owner-approved): change_log has NO client INSERT policy (SECURITY
+ DEFINER trigger writes only); items.category is a single column; items.status is text+CHECK (not
+ enum); change_log timestamp column renamed changed_at; GIN index on custom_fields deferred to
+ Phase 5; immutability is two-layered (RLS no-UPDATE/DELETE + BEFORE U/D trigger that RAISEs);
+ RLS ENABLE (not FORCE) so SECURITY DEFINER triggers can write.
+- Pre-Phase-3 cleanup (known follow-up): proof runs left throwaway item `__client_proof_item__`
+ (id=2) + its immutable change_log row. Before the Phase 3 seed (explicit ids 1..211) the owner
+ removes them in the SQL editor (disable change_log_immutable trigger -> delete proof change_log
+ row + item -> re-enable) to avoid an id=2 PK collision. (append decisions as we go)
## Evidence rule
@@ -19,20 +28,31 @@ recorded evidence — re-verify instead. Doc changes happen only via an owner-ap
your environment's planning/effort capabilities; current Supabase free-tier limits; region=USA);
draft CLAUDE.md + spec.md + PROGRESS.md; give plain Supabase setup steps.
GATE: owner approves docs + resolved items.
-- [ ] Phase 0b — Supabase live (owner): create project; turn ON "Enable RLS on new tables"; provide
+- [x] Phase 0b — Supabase live (owner): create project; turn ON "Enable RLS on new tables"; provide
project URL + anon key. GATE: Claude confirms it can reach Supabase with the anon key.
service_role key NOT shared.
-- [ ] Phase 1 — Schema + RLS + audit trigger.
+ EVIDENCE (2026-05-31): Project URL + publishable (anon) key delivered; admin user created in the
+ dashboard; Claude reached the REST API with the anon key (anon reads return [] — see Phase 1
+ evidence appendix). service_role NOT shared; publishable key is the public low-privilege key.
+- [x] Phase 1 — Schema + RLS + audit trigger.
GATE: every table RLS-ON, default-deny, NO permissive/`USING(true)` policies, least-privilege;
change_log immutable; trigger writes who/what/when.
VERIFY (mix of Claude + OWNER): from a real client session attempt UPDATE/DELETE on change_log →
refused. OWNER-RUN: unauthenticated anon `curl` on items, field_definitions, change_log, profiles
→ each returns nothing. (Claude provides commands + expected output; owner runs; not self-certified.)
-- [ ] Phase 2 — Auth + roles.
+ EVIDENCE (2026-05-31): proof #3 anon reads all [] (+ RPC 42501); proof A (SQL editor) all PASSED
+ incl. broken-vs-fixed guard toggle; proof B (client editor session via REST) UPDATE+DELETE on
+ change_log -> [] / [] / row unchanged (action=INSERT). Full output in "Phase 1 evidence" appendix.
+- [x] Phase 2 — Auth + roles.
GATE: trust uses getUser()/verified claims; role single-source read by BOTH UI and RLS and they
agree; sign-out ends access and clears rendered data.
VERIFY: log in as Viewer in the browser, attempt an edit → refused BY THE DB (not just UI hidden);
confirm RLS sees correct role per user; sign out → data gone, session ended.
+ GATE PASSED (2026-05-31): (a)/(b)/(c)+negative-control PROVEN in a real browser (Playwright vs
+ live Supabase) AND owner-run broken-vs-fixed (a) returned the expected results grid:
+ FIXED (least-privilege) viewer UPDATE = 0 rows (refused); BROKEN (added USING(true)) viewer
+ UPDATE = 1 row (the breach); ROLLBACK restored least-privilege (sanity: no _tmp_permissive_update
+ policy persisted). Full output in the "Phase 2 evidence" appendix.
- [ ] Phase 3 — Read path.
GATE: 210 items load; search/sort/filter correct; responsive; existing Zeta dashboard still works.
VERIFY: rendered row count = seed count; run 3 sample searches/sorts; load on a phone viewport;
@@ -52,7 +72,9 @@ VERIFY: generate + scan a label → correct item after login; export then re-imp
- [ ] Phase 7 — Hardening + heartbeat + AUDITOR (fresh session).
GATE: independent Auditor sign-off; CSP + sanitize verified; anon read-only heartbeat + scheduled
export backup live (no secrets in the Action); CI/semgrep green; owner final review; deploy verified
-actually propagated (account for Pages CDN caching).
+actually propagated (account for Pages CDN caching); **all build-time test users deleted or
+password-rotated (burned credentials — see Residual Risk Register); supabase-js SRI + exact-version
+pin landed; CSP 'unsafe-inline' removed.**
Auditor brief: you did NOT build this. Using spec.md + PROGRESS.md as the contract, independently
re-verify EVERY gate; probe — any secret in the repo? service_role referenced? RLS permissive or
bypassable from the client (run the unauthenticated anon checks)? custom-field XSS? change_log
@@ -66,7 +88,14 @@ Stop the phase. Diagnose + fix + re-verify, or escalate. Never mark passed to ad
## Residual risk register (verify at Auditor pass / tune post-launch)
auth email deliverability · login rate-limiting · deep a11y · timezone display · CSV encoding edges ·
-password reset · browser compatibility · region latency · large-scale performance · live multi-user sync.
+password reset · browser compatibility · region latency · large-scale performance · live multi-user sync ·
+**BURNED TEST CREDENTIALS (Phase 7 must action)**: test users created during this build had their
+email+password shared in chat — treat as compromised. Phase 7: delete or rotate every test user
+(test@test.com, test2@test.com, and any others) before/at launch · **single-CDN dependency**:
+supabase-js loads from jsdelivr with no SRI/fallback — Phase 7 adds exact-version pin + SRI (and
+consider vendoring) so a blocked/compromised CDN can't break or tamper with the app · **CSP
+'unsafe-inline'**: baseline CSP allows inline script/style for the single-file build — Phase 7 moves
+JS/CSS external + nonces/SRI and drops 'unsafe-inline'.
## Open items (resolve in Phase 0a)
@@ -207,3 +236,75 @@ approval before Phase 0b.
missing markdown header-separator row (`|---|`). I did not "improve" it (per your instruction not to
redraft/edit the contract docs). If the repo's markdown lint flags it on this PR, that's your call to
fix in an owner-approved doc-change step — I won't silently edit it.
+
+## Phase 1 evidence (Claude + owner, 2026-05-31)
+
+All three Phase 1 proofs landed with observed output. **Phase 1 gate = PASSED.**
+
+SQL applied: `inventory/sql/phase1.sql` (owner ran it in the Supabase SQL editor; required one
+follow-up fix — `DROP FUNCTION current_user_role() CASCADE` — because CREATE OR REPLACE cannot
+change a function's return type, Postgres `42P13`).
+
+- **Proof #3 — anon returns nothing** (Claude ran; owner/auditor re-runs as the authority per spec):
+ `items` / `profiles` / `field_definitions` / `change_log` each → `[]` (HTTP 200); anon
+ `POST rpc/current_user_role` → `42501 permission denied for function` (HTTP 401), confirming
+ EXECUTE is granted to `authenticated` only.
+- **Proof A — privileged immutability + broken-vs-fixed** (owner ran `proofs/phase1_proofs.sql`):
+ results table all `PASSED` — `#1a` UPDATE blocked (`change_log is immutable`), `#1b` DELETE blocked,
+ `#2` with the immutability trigger DISABLED the tamper SUCCEEDED (proving the trigger is the guard).
+ Ran inside `BEGIN…ROLLBACK`, so it left no artifacts.
+- **Proof B — client-path immutability** (Claude ran as the disposable editor test user):
+ login OK; `current_user_role` → `"editor"`; `POST items` → HTTP 201 (editor can write; audit trigger
+ wrote `change_log` row `id=2`); `PATCH change_log` → `[]` (HTTP 200); `DELETE change_log` → `[]`
+ (HTTP 200); re-read → `action` still `"INSERT"`.
+
+Follow-ups recorded in the Decisions log:
+
+- Throwaway item `__client_proof_item__` (id=2) + its immutable `change_log` row persist; owner removes
+ them before the Phase 3 seed (id-collision avoidance).
+- A disposable **editor** test user exists for proof B and Phase 2; rotate/delete after Phase 2.
+
+## Phase 2 evidence (Claude, real-browser via Playwright, 2026-05-31)
+
+Build: `inventory/index.html` (standalone; sign-in-only; getUser() trust; role via
+`current_user_role()`; sign-out clears DOM+memory; baseline CSP; publishable anon key only).
+
+Proof harness note: the test browser's egress proxy MITMs the Supabase host with an untrusted
+cert (`ERR_CERT_AUTHORITY_INVALID`), so the proofs ran against a **git-ignored** copy of the page
+(`inventory/_proof_tmp/`) served by a tiny same-origin reverse-proxy that forwards `/auth/*` +
+`/rest/*` to the REAL Supabase project from the container (which has working TLS). Backend, RLS,
+roles, and auth are all REAL and unmodified — only the transport hop was rerouted. The committed
+`index.html` is unchanged (CDN + real Supabase URL).
+
+Observed output (no tokens/passwords logged):
+
+- **Sign-in-only UI** — live DOM: email + password + "Sign in" only; no signup form, no
+ create-account/sign-up link or text (`signupFormPresent:false`, `createAccountText:false`).
+- **(b) role per user** (from the real `current_user_role()` RPC, same source RLS uses):
+ - `test2@test.com` → UI role badge = **viewer**
+ - `test@test.com` → UI role badge = **editor**
+ (Caught + fixed a data discrepancy first: test2 was mistakenly `editor` in `profiles`; owner
+ corrected the row to `viewer`; the app re-derived the new role with no stale cache.)
+- **(a) edit refused BY THE DB** — Viewer clicked the visible "Attempt edit (UPDATE items)" button:
+ `"DB REFUSED the edit (role=viewer): 0 rows updated — RLS filtered it out."` (button NOT hidden;
+ refusal is RLS, 0 rows).
+ - **Negative control** — same button as Editor: `"DB ALLOWED the edit (role=editor): updated row
+ #2."` Proves the refusal is genuinely role-gated at the DB, not a broken control.
+ - Bonus: the editor UPDATE wrote a Phase-1 audit row (`change_log` id=3: action=UPDATE, field=notes,
+ actor=editor uid, old→new) — audit trigger confirmed under real client edits.
+- **(c) sign-out ends session AND clears data** — before: email/role/sample-item rendered, auth
+ token in localStorage. After Sign out: DOM fields all empty (`who=""`, `role=""`, sample-item=`—`,
+ probe msg cleared), in-memory state nulled, localStorage auth token **gone**, and a server-verified
+ `getUser()` returned **null** (session truly ended, not just locally); view returned to sign-in.
+
+- **broken-vs-fixed (a)** (owner ran `inventory/sql/proofs/phase2_rls_brokenfix.sql` in the SQL
+ editor; results grid): `FIXED (least-privilege items_update): viewer UPDATE affected 0 row(s)`
+ and `BROKEN (added USING(true) policy): viewer UPDATE affected 1 row(s)`. One `BEGIN…ROLLBACK`,
+ so least-privilege was restored and no artifacts persisted (sanity check: no
+ `_tmp_permissive_update` policy remained). Proves the least-privilege items_update policy is what
+ refuses the Viewer's edit — confirmed "fails on broken code (1 row), passes when fixed (0 rows)."
+
+**Phase 2 gate = PASSED.** All gate criteria met with observed evidence: getUser()/verified-claims
+trust; role single-source (`current_user_role()`) read by both UI and RLS, in agreement
+(viewer→viewer, editor→editor); Viewer edit refused BY THE DB (0 rows, RLS) with editor allowed as
+control; sign-out ends the session (verified getUser()→null) and clears rendered + in-memory data.
diff --git a/inventory/index.html b/inventory/index.html
new file mode 100644
index 0000000000..40a2e98929
--- /dev/null
+++ b/inventory/index.html
@@ -0,0 +1,259 @@
+
+
+
+
+
+
+
+ Zeta Inventory
+
+
+
+
+
Zeta Inventory
+
Secure inventory. Sign in to continue.
+
+
+
+
+
Sign in
+
+
+
+
+
+
+
+
Signed in
+
+
+
+
Email:
+
Role (from current_user_role()):
+
+
+
+
+
+
Sample item (sign-out clears this)
+
—
+
+
+
+
+
Edit probe (DB decides, not the UI)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/inventory/sql/RUN.md b/inventory/sql/RUN.md
new file mode 100644
index 0000000000..da8fea5f50
--- /dev/null
+++ b/inventory/sql/RUN.md
@@ -0,0 +1,37 @@
+# Inventory SQL — run order & responsibilities
+
+All SQL here is **source-of-truth** authored in the repo. DDL is **run by the
+owner** in the Supabase SQL editor (Claude has no privileged DB access;
+`service_role` is forbidden). Claude verifies *behavior* afterward with the
+public anon key.
+
+## Phase 1
+
+1. **`phase1.sql`** — owner pastes the whole file into the Supabase **SQL editor**
+ and runs it once. Idempotent (safe to re-run). Creates tables, RLS, the
+ `current_user_role()` function, and the audit + immutability triggers.
+ - After it runs, confirm: `select user_id, role from public.profiles;`
+ should show the admin (`addisonstainback@gmail.com`) with role `admin`.
+
+2. **`proofs/phase1_proofs.sql`** — owner runs in the SQL editor. Returns a
+ results table; every row should read `PASSED…` (proves change_log immutability
+ + the broken-vs-fixed demonstration). Runs in a transaction that rolls back —
+ leaves no artifacts.
+
+3. **`proofs/anon_checks.md`** — proof #3 (anon returns `[]`) and proof #1 client
+ path (logged-in user refused by RLS). Claude runs proof #3 to show observed
+ output; the owner/auditor re-runs as the authority (per spec's
+ "EXTERNAL CHECK (owner/auditor-run)").
+
+Phase 1 gate is **passed** only when all three proofs show the expected observed
+output. Until then PROGRESS.md keeps Phase 1 as `[ ]`.
+
+## Test users (create AFTER step 1)
+
+Once `phase1.sql` has run, the `handle_new_user` trigger will auto-create a
+`profiles` row (default `viewer`) for any new user. Create in the dashboard:
+
+- a **Viewer** test user (leave role = `viewer`),
+- an **Editor** test user, then elevate it:
+ `update public.profiles set role='editor' where user_id =
+ (select id from auth.users where lower(email)=lower('EDITOR_EMAIL'));`
diff --git a/inventory/sql/phase1.sql b/inventory/sql/phase1.sql
new file mode 100644
index 0000000000..5311170091
--- /dev/null
+++ b/inventory/sql/phase1.sql
@@ -0,0 +1,325 @@
+-- =============================================================================
+-- Inventory — Phase 1: Schema + RLS + audit trigger
+-- Source-of-truth migration. Run ONCE in the Supabase SQL editor (owner/postgres).
+-- Idempotent: safe to re-run (create-or-replace / drop-if-exists / if-not-exists).
+--
+-- Governing contract: inventory/spec.md, inventory/CLAUDE.md, inventory/PROGRESS.md.
+-- Owner-approved decisions baked in (2026-05-31):
+-- 1. change_log has NO client INSERT policy — the SECURITY DEFINER trigger writes only.
+-- 2. items.category is a SINGLE column (source category/section merged at seed time).
+-- 3. items.status is text + CHECK (evolvable without a DDL-heavy enum migration).
+-- 4. change_log timestamp column is named changed_at (timestamp is reserved).
+-- 5. GIN index on items.custom_fields is DEFERRED to Phase 5 (where search uses it).
+-- 6. Immutability is two-layered: RLS (no UPDATE/DELETE policy) for clients +
+-- a BEFORE UPDATE/DELETE trigger that RAISEs (blocks even the privileged owner).
+--
+-- Security model: RLS is ENABLED (not FORCED) on every table so that the SECURITY
+-- DEFINER trigger functions (owned by postgres) can write while every client
+-- (anon + authenticated) remains fully subject to RLS. anon is granted NO policy
+-- anywhere -> default-deny -> anon reads return [].
+-- =============================================================================
+
+-- -----------------------------------------------------------------------------
+-- 0. Types
+-- -----------------------------------------------------------------------------
+-- Role: a small, rarely-changing set -> native enum is appropriate.
+do $$
+begin
+ if not exists (select 1 from pg_type where typname = 'user_role') then
+ create type public.user_role as enum ('viewer', 'editor', 'admin');
+ end if;
+end
+$$;
+
+-- Status: text + CHECK (owner decision #3 — easy to extend, e.g. add 'Lent Out',
+-- with a single ALTER ... DROP/ADD CONSTRAINT, no enum migration).
+-- The canonical Phase-1 status vocabulary (spec.md "Status enum"):
+-- Active/In Use, In Storage, Needs Attention, In Repair,
+-- Retired (Archived), Disposed, Missing
+
+-- -----------------------------------------------------------------------------
+-- 1. Tables
+-- -----------------------------------------------------------------------------
+
+-- profiles: the SINGLE SOURCE of role, read by both the UI (via rpc) and RLS
+-- (via current_user_role()). One row per auth user.
+create table if not exists public.profiles (
+ user_id uuid primary key references auth.users (id) on delete cascade,
+ display_name text,
+ role public.user_role not null default 'viewer'
+);
+
+-- items: stable surrogate PK, never reused/renumbered. "generated by default"
+-- lets the Phase-3 seed set explicit ids 1..211 (skipping #8) and the sequence
+-- continues afterward.
+create table if not exists public.items (
+ id bigint generated by default as identity primary key,
+ name text,
+ brand text,
+ model_pn text,
+ qty integer,
+ device_type text,
+ category text, -- decision #2: single column
+ status text check (
+ status in (
+ 'Active/In Use', 'In Storage', 'Needs Attention',
+ 'In Repair', 'Retired (Archived)', 'Disposed', 'Missing'
+ )
+ ), -- decision #3: text + CHECK (NULL allowed)
+ location text,
+ assignment_purpose text,
+ notes text,
+ value numeric(12, 2),
+ serial text,
+ is_archived boolean not null default false,
+ custom_fields jsonb not null default '{}'::jsonb,
+ version integer not null default 1, -- optimistic lock (used in Phase 4)
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+-- field_definitions: typed dynamic fields (admin-managed). A row = the field
+-- appears on every item. "Removing" = is_active=false (never hard-delete data).
+create table if not exists public.field_definitions (
+ key text primary key,
+ label text not null,
+ type text not null check (type in ('text', 'number', 'date', 'dropdown', 'boolean')),
+ options text[],
+ required boolean not null default false,
+ is_active boolean not null default true,
+ created_by uuid references auth.users (id),
+ created_at timestamptz not null default now()
+);
+
+-- change_log: immutable who/what/when audit trail. Trigger-written only.
+create table if not exists public.change_log (
+ id bigint generated always as identity primary key,
+ item_id bigint references public.items (id),
+ actor uuid, -- auth.uid() of the real caller (see audit trigger)
+ action text not null, -- 'INSERT' | 'UPDATE'
+ field text, -- changed column (NULL for INSERT)
+ old_value text,
+ new_value text,
+ changed_at timestamptz not null default now() -- decision #4: renamed from "timestamp"
+);
+
+-- -----------------------------------------------------------------------------
+-- 2. Role function (single source for RLS + UI). Owner-approved Option 1.
+-- SECURITY DEFINER + empty search_path: runs as owner so it can read profiles
+-- regardless of profiles' self-read RLS, AND bypasses RLS so it does NOT
+-- re-enter the profiles policy (avoids "infinite recursion in policy").
+-- EXECUTE granted to authenticated ONLY (never anon).
+-- -----------------------------------------------------------------------------
+-- A prior version of this function may exist with a different return type;
+-- CREATE OR REPLACE cannot change a function's return type (Postgres 42P13), so
+-- drop first. CASCADE also removes any policies that referenced the old version
+-- (e.g. from a partial earlier run); section 7 below recreates the correct ones.
+drop function if exists public.current_user_role() cascade;
+
+create or replace function public.current_user_role()
+returns public.user_role
+language sql
+stable
+security definer
+set search_path = ''
+as $$
+ select role from public.profiles where user_id = auth.uid()
+$$;
+
+revoke all on function public.current_user_role() from public, anon;
+grant execute on function public.current_user_role() to authenticated;
+
+-- -----------------------------------------------------------------------------
+-- 3. New-user provisioning: auto-create a profile (default 'viewer') for every
+-- new auth user. SECURITY DEFINER so the insert is not blocked by profiles RLS.
+-- -----------------------------------------------------------------------------
+create or replace function public.handle_new_user()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+begin
+ insert into public.profiles (user_id, display_name, role)
+ values (
+ new.id,
+ coalesce(new.raw_user_meta_data ->> 'display_name', new.email),
+ 'viewer'::public.user_role
+ )
+ on conflict (user_id) do nothing;
+ return new;
+end
+$$;
+
+drop trigger if exists on_auth_user_created on auth.users;
+create trigger on_auth_user_created
+ after insert on auth.users
+ for each row execute function public.handle_new_user();
+
+-- One-time backfill: the admin user was created in the dashboard BEFORE this
+-- trigger existed, so provision + elevate it now. Idempotent.
+insert into public.profiles (user_id, display_name, role)
+select id, coalesce(raw_user_meta_data ->> 'display_name', email), 'admin'::public.user_role
+from auth.users
+where lower(email) = lower('addisonstainback@gmail.com')
+on conflict (user_id) do update set role = 'admin'::public.user_role;
+
+-- -----------------------------------------------------------------------------
+-- 4. updated_at maintenance (BEFORE UPDATE on items).
+-- -----------------------------------------------------------------------------
+create or replace function public.tg_set_updated_at()
+returns trigger
+language plpgsql
+set search_path = ''
+as $$
+begin
+ new.updated_at = now();
+ return new;
+end
+$$;
+
+drop trigger if exists items_set_updated_at on public.items;
+create trigger items_set_updated_at
+ before update on public.items
+ for each row execute function public.tg_set_updated_at();
+
+-- -----------------------------------------------------------------------------
+-- 5. Audit trigger: write who/what/when (field-level before->after) into
+-- change_log on item INSERT/UPDATE. SECURITY DEFINER so it can write to the
+-- client-locked change_log; auth.uid() still resolves to the REAL caller.
+-- -----------------------------------------------------------------------------
+create or replace function public.log_item_change()
+returns trigger
+language plpgsql
+security definer
+set search_path = ''
+as $$
+declare
+ uid uuid := auth.uid();
+begin
+ if tg_op = 'INSERT' then
+ insert into public.change_log (item_id, actor, action, field, old_value, new_value)
+ values (new.id, uid, 'INSERT', null, null, null);
+ return new;
+ end if;
+
+ -- UPDATE: one row per changed field (IS DISTINCT FROM handles NULLs).
+ if new.name is distinct from old.name then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'name', old.name, new.name); end if;
+ if new.brand is distinct from old.brand then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'brand', old.brand, new.brand); end if;
+ if new.model_pn is distinct from old.model_pn then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'model_pn', old.model_pn, new.model_pn); end if;
+ if new.qty is distinct from old.qty then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'qty', old.qty::text, new.qty::text); end if;
+ if new.device_type is distinct from old.device_type then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'device_type', old.device_type, new.device_type); end if;
+ if new.category is distinct from old.category then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'category', old.category, new.category); end if;
+ if new.status is distinct from old.status then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'status', old.status, new.status); end if;
+ if new.location is distinct from old.location then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'location', old.location, new.location); end if;
+ if new.assignment_purpose is distinct from old.assignment_purpose then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'assignment_purpose', old.assignment_purpose, new.assignment_purpose); end if;
+ if new.notes is distinct from old.notes then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'notes', old.notes, new.notes); end if;
+ if new.value is distinct from old.value then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'value', old.value::text, new.value::text); end if;
+ if new.serial is distinct from old.serial then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'serial', old.serial, new.serial); end if;
+ if new.is_archived is distinct from old.is_archived then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'is_archived', old.is_archived::text, new.is_archived::text); end if;
+ if new.custom_fields is distinct from old.custom_fields then insert into public.change_log (item_id, actor, action, field, old_value, new_value) values (new.id, uid, 'UPDATE', 'custom_fields', old.custom_fields::text, new.custom_fields::text); end if;
+
+ return new;
+end
+$$;
+
+drop trigger if exists items_audit on public.items;
+create trigger items_audit
+ after insert or update on public.items
+ for each row execute function public.log_item_change();
+
+-- -----------------------------------------------------------------------------
+-- 6. Immutability guard for change_log (decision #6, layer 2).
+-- Fires for EVERYONE including the owner -> blocks privileged tampering that
+-- RLS alone cannot (owner bypasses RLS). This is the guard Phase-1 proof #2
+-- toggles inside a rolled-back transaction.
+-- -----------------------------------------------------------------------------
+create or replace function public.change_log_no_mutate()
+returns trigger
+language plpgsql
+set search_path = ''
+as $$
+begin
+ raise exception 'change_log is immutable (no UPDATE/DELETE allowed)';
+end
+$$;
+
+drop trigger if exists change_log_immutable on public.change_log;
+create trigger change_log_immutable
+ before update or delete on public.change_log
+ for each row execute function public.change_log_no_mutate();
+
+-- -----------------------------------------------------------------------------
+-- 7. Row-Level Security: ENABLE on every table; least-privilege policies scoped
+-- to a specific role AND operation. No `USING (true)` anywhere.
+-- "Any authenticated user can read items" is expressed as a role-gate
+-- (current_user_role() in (...)), NOT a bare permissive true: an authenticated
+-- user with no role row gets nothing.
+-- -----------------------------------------------------------------------------
+alter table public.profiles enable row level security;
+alter table public.items enable row level security;
+alter table public.field_definitions enable row level security;
+alter table public.change_log enable row level security;
+
+-- profiles: self-read; admin reads all; admin manages roles. No client INSERT
+-- (handle_new_user writes). No DELETE (cascades from auth.users).
+drop policy if exists profiles_self_read on public.profiles;
+drop policy if exists profiles_admin_read on public.profiles;
+drop policy if exists profiles_admin_write on public.profiles;
+create policy profiles_self_read on public.profiles
+ for select to authenticated
+ using (user_id = auth.uid());
+create policy profiles_admin_read on public.profiles
+ for select to authenticated
+ using (public.current_user_role() = 'admin'::public.user_role);
+create policy profiles_admin_write on public.profiles
+ for update to authenticated
+ using (public.current_user_role() = 'admin'::public.user_role)
+ with check (public.current_user_role() = 'admin'::public.user_role);
+
+-- items: SELECT = any authenticated user WITH a role; INSERT/UPDATE = editor/admin;
+-- no DELETE policy (archive via is_archived).
+drop policy if exists items_select on public.items;
+drop policy if exists items_insert on public.items;
+drop policy if exists items_update on public.items;
+create policy items_select on public.items
+ for select to authenticated
+ using (public.current_user_role() in ('viewer'::public.user_role, 'editor'::public.user_role, 'admin'::public.user_role));
+create policy items_insert on public.items
+ for insert to authenticated
+ with check (public.current_user_role() in ('editor'::public.user_role, 'admin'::public.user_role));
+create policy items_update on public.items
+ for update to authenticated
+ using (public.current_user_role() in ('editor'::public.user_role, 'admin'::public.user_role))
+ with check (public.current_user_role() in ('editor'::public.user_role, 'admin'::public.user_role));
+
+-- field_definitions: SELECT = authenticated (with role); INSERT/UPDATE/DELETE = admin only.
+drop policy if exists field_definitions_select on public.field_definitions;
+drop policy if exists field_definitions_insert on public.field_definitions;
+drop policy if exists field_definitions_update on public.field_definitions;
+drop policy if exists field_definitions_delete on public.field_definitions;
+create policy field_definitions_select on public.field_definitions
+ for select to authenticated
+ using (public.current_user_role() in ('viewer'::public.user_role, 'editor'::public.user_role, 'admin'::public.user_role));
+create policy field_definitions_insert on public.field_definitions
+ for insert to authenticated
+ with check (public.current_user_role() = 'admin'::public.user_role);
+create policy field_definitions_update on public.field_definitions
+ for update to authenticated
+ using (public.current_user_role() = 'admin'::public.user_role)
+ with check (public.current_user_role() = 'admin'::public.user_role);
+create policy field_definitions_delete on public.field_definitions
+ for delete to authenticated
+ using (public.current_user_role() = 'admin'::public.user_role);
+
+-- change_log: SELECT = editor/admin (viewer NOT by default). NO INSERT policy
+-- (definer trigger writes). NO UPDATE/DELETE policy (immutable).
+drop policy if exists change_log_select on public.change_log;
+create policy change_log_select on public.change_log
+ for select to authenticated
+ using (public.current_user_role() in ('editor'::public.user_role, 'admin'::public.user_role));
+
+-- =============================================================================
+-- End Phase 1 schema. Run inventory/sql/proofs/phase1_proofs.sql next, then the
+-- anon checks in inventory/sql/proofs/anon_checks.md.
+-- =============================================================================
diff --git a/inventory/sql/proofs/anon_checks.md b/inventory/sql/proofs/anon_checks.md
new file mode 100644
index 0000000000..509f5dd51a
--- /dev/null
+++ b/inventory/sql/proofs/anon_checks.md
@@ -0,0 +1,103 @@
+# Phase 1 proofs — client/anon path (run from a terminal with `curl`)
+
+These cover the two parts of Phase 1's proof set that do **not** run in the SQL
+editor:
+
+- **Proof #3** — unauthenticated (anon) reads return nothing on every sensitive
+ table.
+- **Proof #1 (client path)** — a real logged-in user is **refused by RLS** when
+ trying to UPDATE/DELETE `change_log` (returns 0 rows; the literal immutability
+ error is shown by the privileged path in `phase1_proofs.sql`).
+
+> **Key hygiene:** the value below is the **publishable** (anon) key — public by
+> design, safe in client code. It is NOT a secret. **Never** put the
+> `service_role` / `sb_secret_…` key in any command. Set the public key in your
+> shell so it isn't pasted around:
+>
+> ```bash
+> export SUPABASE_URL="https://mdtbgreryqddloluhdmm.supabase.co"
+> export ANON="sb_publishable_…" # your publishable key
+> ```
+
+---
+
+## Proof #3 — anon returns nothing (RLS default-deny)
+
+```bash
+for t in items profiles field_definitions change_log; do
+ printf '== %s ==\n' "$t"
+ curl -s "$SUPABASE_URL/rest/v1/$t?select=*" \
+ -H "apikey: $ANON" -H "Authorization: Bearer $ANON"
+ printf '\n'
+done
+```
+
+**Expected:** each table prints `[]` (an empty JSON array). With RLS ON and no
+policy granting the `anon` role, every row is filtered out — the safe default the
+spec wants ("RLS on and no policy = inaccessible").
+
+> Why `[]` and not `401`: Supabase grants `anon` table-level access by default and
+> relies on RLS as the gate, so the request succeeds but returns zero rows. An
+> optional Phase-7 hardening can additionally `REVOKE` anon table privileges
+> (turning these into outright permission errors); not needed for the Phase-1 gate.
+
+---
+
+## Proof #1 (client path) — logged-in user cannot mutate change_log
+
+Needs (1) a change_log row to target and (2) a user access token.
+
+**Step 1 — get a user JWT** (you have the admin password; never log the token):
+
+```bash
+ACCESS=$(curl -s "$SUPABASE_URL/auth/v1/token?grant_type=password" \
+ -H "apikey: $ANON" -H "Content-Type: application/json" \
+ -d '{"email":"addisonstainback@gmail.com","password":"YOUR_ADMIN_PASSWORD"}' \
+ | python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
+```
+
+**Step 2 — confirm the session reads its own role** (sanity: single-source role):
+
+```bash
+curl -s "$SUPABASE_URL/rest/v1/rpc/current_user_role" -X POST \
+ -H "apikey: $ANON" -H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json"
+# expected: "admin"
+```
+
+**Step 3 — find a change_log row id** (admin can SELECT change_log):
+
+```bash
+curl -s "$SUPABASE_URL/rest/v1/change_log?select=id,item_id,action&limit=1" \
+ -H "apikey: $ANON" -H "Authorization: Bearer $ACCESS"
+# note an id, call it . (If empty, first insert/edit an item as admin so the
+# audit trigger writes a row.)
+```
+
+**Step 4 — attempt to mutate it (must be refused):**
+
+```bash
+# UPDATE
+curl -s -X PATCH "$SUPABASE_URL/rest/v1/change_log?id=eq." \
+ -H "apikey: $ANON" -H "Authorization: Bearer $ACCESS" \
+ -H "Content-Type: application/json" -H "Prefer: return=representation" \
+ -d '{"action":"TAMPERED"}'
+# expected: [] (RLS has no UPDATE policy -> row filtered -> 0 rows changed)
+
+# DELETE
+curl -s -X DELETE "$SUPABASE_URL/rest/v1/change_log?id=eq." \
+ -H "apikey: $ANON" -H "Authorization: Bearer $ACCESS" \
+ -H "Prefer: return=representation"
+# expected: [] (RLS has no DELETE policy -> 0 rows deleted)
+```
+
+**Step 5 — confirm nothing changed:**
+
+```bash
+curl -s "$SUPABASE_URL/rest/v1/change_log?id=eq.&select=id,action" \
+ -H "apikey: $ANON" -H "Authorization: Bearer $ACCESS"
+# expected: the row, with its ORIGINAL action (e.g. "INSERT") — unchanged.
+```
+
+The client is refused by the database (RLS), not by any UI. The explicit
+`change_log is immutable` error for the privileged path is shown by
+`phase1_proofs.sql`.
diff --git a/inventory/sql/proofs/phase1_proofs.sql b/inventory/sql/proofs/phase1_proofs.sql
new file mode 100644
index 0000000000..e7436d6fcf
--- /dev/null
+++ b/inventory/sql/proofs/phase1_proofs.sql
@@ -0,0 +1,76 @@
+-- =============================================================================
+-- Inventory — Phase 1 proofs (SQL editor / privileged owner path)
+-- Run this AFTER inventory/sql/phase1.sql, in the Supabase SQL editor.
+--
+-- Proves (privileged/owner path):
+-- #1a UPDATE on change_log is blocked by the immutability trigger.
+-- #1b DELETE on change_log is blocked by the immutability trigger.
+-- #2 Broken-vs-fixed: with the guard DISABLED the tamper SUCCEEDS (protection
+-- fails), proving the trigger is the thing stopping it. The whole proof
+-- runs in one transaction and ROLLS BACK -> guard restored, no artifacts.
+--
+-- The client-session path of proof #1 (a logged-in user is refused by RLS) and
+-- proof #3 (anon returns nothing) are in inventory/sql/proofs/anon_checks.md.
+--
+-- HOW TO READ THE OUTPUT: this returns a single results table. Every row whose
+-- result starts with "PASSED" is a satisfied assertion. A row starting "FAILED"
+-- means immutability is NOT enforced -> STOP, do not mark the gate passed.
+-- =============================================================================
+
+begin;
+
+create temporary table _proof_results (seq int, step text, result text) on commit drop;
+
+-- SETUP: a throwaway item INSERT fires the audit trigger -> a change_log row to attack.
+insert into public.items (name, status) values ('__phase1_proof_item__', 'In Storage');
+
+do $$
+declare
+ v_item bigint;
+ v_log bigint;
+ v_action text;
+begin
+ select id into v_item from public.items where name = '__phase1_proof_item__';
+ select id into v_log from public.change_log where item_id = v_item order by id limit 1;
+ insert into _proof_results values
+ (0, 'setup', format('throwaway item id=%s wrote change_log row id=%s (audit trigger works)', v_item, v_log));
+
+ -- ---- PROOF #1a: UPDATE blocked ------------------------------------------------
+ begin
+ update public.change_log set action = 'TAMPERED' where id = v_log;
+ insert into _proof_results values (1, '#1a UPDATE change_log', 'FAILED: update SUCCEEDED (immutability NOT enforced!)');
+ exception when others then
+ insert into _proof_results values (1, '#1a UPDATE change_log', 'PASSED: blocked -> ' || sqlerrm);
+ end;
+
+ -- ---- PROOF #1b: DELETE blocked ------------------------------------------------
+ begin
+ delete from public.change_log where id = v_log;
+ insert into _proof_results values (2, '#1b DELETE change_log', 'FAILED: delete SUCCEEDED (immutability NOT enforced!)');
+ exception when others then
+ insert into _proof_results values (2, '#1b DELETE change_log', 'PASSED: blocked -> ' || sqlerrm);
+ end;
+
+ -- ---- PROOF #2: broken-vs-fixed -----------------------------------------------
+ -- "Break" the guard: disable the immutability trigger. The same tamper that was
+ -- blocked above now succeeds -> proves the trigger is the protection. The outer
+ -- ROLLBACK below re-enables the trigger (DDL is transactional) and undoes the
+ -- tamper + the throwaway item, so nothing persists.
+ alter table public.change_log disable trigger change_log_immutable;
+ update public.change_log set action = 'TAMPERED-DEMO' where id = v_log;
+ select action into v_action from public.change_log where id = v_log;
+ insert into _proof_results values
+ (3, '#2 broken (guard DISABLED)', 'tamper SUCCEEDED, action now = ' || coalesce(v_action, '') || ' (=> the trigger is what stops tampering)');
+end
+$$;
+
+select seq, step, result from _proof_results order by seq;
+
+rollback; -- restores the immutability trigger; discards throwaway item, log row, and tamper
+
+-- After this script, immutability is back in force. Re-running it is safe (idempotent).
+-- Expected result rows:
+-- 0 setup throwaway item ... wrote change_log row ... (audit trigger works)
+-- 1 #1a UPDATE change_log PASSED: blocked -> change_log is immutable (no UPDATE/DELETE allowed)
+-- 2 #1b DELETE change_log PASSED: blocked -> change_log is immutable (no UPDATE/DELETE allowed)
+-- 3 #2 broken (guard DISABLED) tamper SUCCEEDED, action now = TAMPERED-DEMO (=> the trigger is what stops tampering)
diff --git a/inventory/sql/proofs/phase2_rls_brokenfix.sql b/inventory/sql/proofs/phase2_rls_brokenfix.sql
new file mode 100644
index 0000000000..b769adc0bd
--- /dev/null
+++ b/inventory/sql/proofs/phase2_rls_brokenfix.sql
@@ -0,0 +1,80 @@
+-- =============================================================================
+-- Inventory — Phase 2 proof (a), broken-vs-fixed (SQL editor / owner)
+-- Run AFTER phase1.sql, in the Supabase SQL editor. Runs in ONE transaction and
+-- ROLLS BACK -> no artifacts, least-privilege restored.
+--
+-- Demonstrates that the least-privilege items UPDATE policy (editor/admin only)
+-- is what refuses a Viewer's edit at the DATABASE — not hidden UI:
+-- FIXED (real policy) : Viewer UPDATE affects 0 rows (refused)
+-- BROKEN (add USING(true)) : Viewer UPDATE affects 1 row (the breach)
+-- ROLLBACK : permissive policy dropped, least-privilege back
+--
+-- OUTPUT: a 2-row RESULTS TABLE (read the grid, not Messages). Expected:
+-- 1 FIXED (least-privilege items_update) viewer UPDATE affected 0 row(s) -> expect 0 = refused
+-- 2 BROKEN (added USING(true) policy) viewer UPDATE affected 1 row(s) -> 1 = breach prevented
+--
+-- Uses the throwaway Viewer test user (test2@test.com). If you used a different
+-- email, edit the lookup below.
+--
+-- NOTE: relies on `set local role authenticated` + request.jwt.claims (the
+-- standard Supabase RLS-as-a-role testing pattern). Row counts are stashed in a
+-- transaction-local GUC so they survive the role switch and land in the grid.
+-- =============================================================================
+
+begin;
+
+create temporary table _p2_results (seq int, step text, result text) on commit drop;
+
+-- Simulate the Viewer's session: auth.uid() -> the viewer; role -> authenticated
+-- (so RLS is enforced; the owner would otherwise bypass it).
+select set_config(
+ 'request.jwt.claims',
+ json_build_object(
+ 'sub', (select id::text from auth.users where lower(email) = lower('test2@test.com')),
+ 'role', 'authenticated'
+ )::text,
+ true -- is_local: reset at transaction end
+);
+
+-- ---- FIXED: least-privilege policy in force -> Viewer UPDATE must affect 0 rows
+set local role authenticated;
+do $$
+declare n int;
+begin
+ update public.items set notes = 'p2 brokenfix (fixed)'
+ where id = (select min(id) from public.items);
+ get diagnostics n = row_count;
+ perform set_config('p2proof.fixed', n::text, true); -- stash across the role switch
+end $$;
+reset role;
+insert into _p2_results values
+ (1, 'FIXED (least-privilege items_update)',
+ 'viewer UPDATE affected ' || current_setting('p2proof.fixed') || ' row(s) -> expect 0 = refused');
+
+-- ---- BROKEN: add a permissive policy (the classic breach) -> Viewer UPDATE succeeds
+create policy _tmp_permissive_update on public.items
+ for update to authenticated using (true) with check (true);
+
+set local role authenticated;
+do $$
+declare n int;
+begin
+ update public.items set notes = 'p2 brokenfix (broken)'
+ where id = (select min(id) from public.items);
+ get diagnostics n = row_count;
+ perform set_config('p2proof.broken', n::text, true);
+end $$;
+reset role;
+insert into _p2_results values
+ (2, 'BROKEN (added USING(true) policy)',
+ 'viewer UPDATE affected ' || current_setting('p2proof.broken') || ' row(s) -> 1 = the breach least-privilege prevents');
+
+-- THE EVIDENCE (read this grid):
+select seq, step, result from _p2_results order by seq;
+
+rollback; -- drops _tmp_permissive_update + reverts the notes; least-privilege restored
+
+-- Sanity after running this script (run separately; expect ZERO rows -> the
+-- permissive policy did NOT persist):
+-- select policyname from pg_policies
+-- where schemaname='public' and tablename='items' and policyname='_tmp_permissive_update';