Skip to the lesson
CivOps AI Academy · F03Schema First: the Data Your Plant Runs On, as Tables
0%

Chapter 1 · Session 3

From the intent to tables

The intent you committed in Session 2 already names the tables. The nouns are the things you keep a list of; the moments are the events you record; the decision is its own record, with who made it and why.

25 min8 tablesISA-95 objectsWorked example: holds

By the end of this chapter you can

  • Read an intent and list the tables it needs.
  • Sort tables into reference data, people, events and decisions.
  • Use ISA-95's names for equipment and the things that move through the plant.

Why the schema comes first

A screen is a view of data. If the data is shaped wrongly, every screen, report and integration built on it carries the mistake, and fixing it later means rebuilding all of them. Settling the tables first, before any screen, is the cheapest point to get it right. It also gives every AI agent the same fixed ground to build on: an agent can propose a screen in minutes, but it must not invent a new place to keep the data each time.

Edgar Codd's relational model, published in 1970, is still the basis: data kept in tables of rows, each fact stored once, tables joined by keys [1]. Postgres, the database inside Supabase, is a relational database built on that model [2].

Read the intent for tables

Take the stamping plant's intent from Session 2 and underline three kinds of words. Nouns you keep a list of (presses, parts, lots) become reference tables. Moments that happen many times (a first-piece result, an hourly gauge reading) become event tables, one row per moment. The decision itself (hold or release) becomes a table too, so that who decided, when and why is never lost.

From the intent to tablesThe stamping plant's intent sentence, with arrows from its words to six tables: memberships, lots, holds, inspections, characteristics and equipment. The quality lead decides, at every die change and every hour, whether to hold a lot,from first-piece and gauge results against the part's limits, on each press.membershipswho decidespeoplelotsa lotthingsholdshold or releasedecisionsinspectionsresultseventscharacteristicsthe part's limitsreferenceequipmenteach pressreferenceNouns become reference tables; things that happen become event rows; the decision is its own table.
The intent names the tables. Six of the eight tables come straight from the sentence. Sites and parts complete the reference data.
KindWhat it holdsChangesStamping plant tables
ReferenceThe plant's lists: sites, equipment, parts, limitsRarely; by a managersites, equipment, parts, characteristics
PeopleWho belongs where, with which roleWhen people join, move or leavememberships (accounts live in Supabase Auth)
Things that moveWork flowing through the plantCreated every shiftlots
EventsWhat happened, when, recorded by whomNever: new rows onlyinspections
DecisionsWhat was decided, by whom, whyNever: new rows onlyholds

Use the names manufacturing already agrees on

ISA-95 (IEC 62264) is the standard language between business systems and the plant floor. It gives names for the equipment hierarchy (enterprise, site, area, work centre such as a line, work unit such as a cell or press) and for the things that move: material lots, personnel, equipment, and the results of work [3]. Using its words for your tables means your data lines up with the ERP, MES and any future system that also speaks it. The OPC Foundation publishes ISA-95's object model for machines to use, which is a good reference when a name is in doubt [4].

The first schemaEight tables. Sites, equipment, parts and characteristics are reference data; lots link a part to a press; inspections and holds refer to lots; memberships give people a role at a site. sitesidcodenameequipmentidsite_id →code, kindpartsidpart_numbernamecharacteristicsidpart_id →lower_mm, upper_mmlotsidsite_id, part_id →equipment_id →lot_code, started_atmembershipsuser_id →site_id →roleinspectionslot_id →characteristic_id →value_mmrecorded_by →holdssite_id, lot_id →action, reasondecided_by →Arrows point from a foreign key (→) to the table it refers to.
The stamping plant's first schema. Arrows are foreign keys: each inspection points at a lot and a characteristic, each lot at a part and a press. Inspections and holds are where the decision's data lives.

Knowledge check

In the intent 'the quality lead decides whether to hold a lot from first-piece results', what does 'first-piece results' become?

Knowledge check

Why does the hold-or-release decision get its own table rather than a status column on lots?

References

  1. E. F. Codd, A relational model of data for large shared data banks, Communications of the ACM, 1970. https://dl.acm.org/doi/10.1145/362384.362685
  2. PostgreSQL Documentation: The SQL Language, Data Definition. https://www.postgresql.org/docs/current/ddl.html
  3. ISA: ISA-95 standard, Enterprise-Control System Integration. https://www.isa.org/standards-and-publications/isa-standards/isa-95-standard
  4. OPC Foundation: ISA-95 Common Object Model (OPC 10030). https://opcfoundation.org/markets-collaboration/isa-95/

Chapter 2 · Constraints and history

Rules the database keeps

A rule written in a screen protects one screen. A rule written in the table protects every screen, import and agent that will ever write to it, including the ones nobody has built yet.

25 minTypes, keys, checksAppend-only eventsViews for current state

By the end of this chapter you can

  • Choose types and constraints that refuse bad data at the table.
  • Make event and decision tables append-only with a trigger.
  • Work out current state, such as a lot's status, from the history with a view.

Let the table refuse bad data

Postgres can refuse a bad row before it is stored: the wrong type, a missing value, a value outside its range, a reference to something that does not exist, or a duplicate [1]. These rules live with the table, so they hold whatever writes the row. Row-level security, from Session 2B, is the last layer: the right row, but for the wrong person.

Layers that refuse bad dataA bad row meets six layers in turn: data type, not null, check constraint, foreign key, unique constraint and row-level security. Each layer has an example of a row it refuses. Bad rowfrom any sourceTyperefuses'twelve'is nota numberNot nullrefusesa resultwith novalueCheckrefusesa holdwith noreasonForeign keyrefusesa resultfor a lotnot thereUniquerefusesthe samelot codetwiceRLSrefusesanothersite'slotThe database refuses the row at the first layer it fails, whatever wrote it: a screen, an import or an agent.
Six layers, one bad row. The row is refused at the first layer it fails, and nothing is stored.
RulePostgresStamping plant example
Right typenumeric, integer, timestamptz, textvalue_mm numeric(8,3): a measurement to the thousandth of a millimetre
Must be therenot nullEvery inspection has a value, a lot and who recorded it
In range or from a listcheck (...)action in ('hold','release'); lower_mm < upper_mm
Refers to something realreferences (foreign key)inspections.lot_id must be an existing lot
No duplicatesuniqueOne lot_code per lot; one equipment code per site

Types that match the plant

  • Times are timestamptz (timestamp with time zone), so a night shift that crosses midnight, or the change to daylight saving time, never scrambles the order of events [2].
  • Measurements are numeric with the unit in the column name (value_mm), not floating point, so 12.030 is stored as exactly 12.030 [3].
  • Counts are integer. Money, when it appears, is whole cents in an integer, converted only for display.
  • Codes people type, such as press codes, have a check on their format, so 'Press 4', 'press4' and 'P-04' cannot all exist at once.

History you can trust: append-only

Inspections and holds are records of what happened. If they can be edited, nobody can trust them, and in regulated industries they would fail an audit: the US FDA's rule for electronic records, 21 CFR Part 11, requires audit trails that keep earlier values when a record changes [4]. The simple design is to never change them at all. A correction is a new row that points at the one it corrects, and a trigger refuses any update or delete [5].

Append-only recordsTwo rows for the same lot: an original value of 12.31 mm and a correction of 12.03 mm that refers to it. The current value is 12.03 with both rows kept. An update statement is refused. inspections (append-only)#101lot L-041212.31 mm08:02original#102lot L-041212.03 mm08:09correctioncorrects #101Current value12.03 mm, with both rows keptupdate inspections set value_mm = 12.03 where id = 101;refused by the trigger: inspections is append-only, add a correcting row instead
A correction is a new row. The typing error and its correction are both kept; the current value comes from the latest. An update statement is refused.
Postgres: append-only events and decisions
-- Refuse any change to a record of what happened.
create function public.append_only() returns trigger language plpgsql as $$
begin
  raise exception '% is append-only: add a correcting row instead', tg_table_name;
end $$;

create trigger inspections_append_only before update or delete on public.inspections
  for each row execute function public.append_only();
create trigger holds_append_only before update or delete on public.holds
  for each row execute function public.append_only();

Current state is a view over history

The quality lead needs to know, right now, which lots are on hold. That is the latest hold or release row for each lot. A view is a saved query that looks like a table, so screens read it like any other [6]. On Supabase, create views with security_invoker = true so the reader's row-level security applies through the view as well [7].

Postgres: the current status of every lot
-- Each lot's current status: its most recent hold or release.
create view public.lot_status with (security_invoker = true) as
select distinct on (lot_id) lot_id, action as status, reason, decided_by, decided_at
from public.holds
order by lot_id, decided_at desc;

Knowledge check

A gauge reading of 12.031 mm must be stored exactly. Which type?

Knowledge check

An operator typed 12.31 instead of 12.03. How is it fixed in an append-only table?

References

  1. PostgreSQL Documentation: Constraints. https://www.postgresql.org/docs/current/ddl-constraints.html
  2. PostgreSQL Documentation: Date/Time Types. https://www.postgresql.org/docs/current/datatype-datetime.html
  3. PostgreSQL Documentation: Numeric Types. https://www.postgresql.org/docs/current/datatype-numeric.html
  4. eCFR: 21 CFR Part 11, Electronic Records; Electronic Signatures. https://www.ecfr.gov/current/title-21/chapter-I/subchapter-A/part-11
  5. PostgreSQL Documentation: Trigger Functions (PL/pgSQL). https://www.postgresql.org/docs/current/plpgsql-trigger.html
  6. PostgreSQL Documentation: CREATE VIEW. https://www.postgresql.org/docs/current/sql-createview.html
  7. Supabase Docs: Row Level Security (views and security invoker). https://supabase.com/docs/guides/database/postgres/row-level-security

Chapter 3 · Before any screen

Commit it as a migration

The schema is not finished when the tables exist in the dashboard. It is finished when it is a file in the repository that rebuilds the same database anywhere, has passed CI, and was read by a person before it merged.

25 minsupabase/migrations/CI on a fresh databaseNever edit an applied file

By the end of this chapter you can

  • Write the schema as a timestamped migration file with the Supabase CLI.
  • Review an agent-written migration against a short checklist.
  • Apply it through CI and push it to the live project, and change it later only with a new file.

Why a file, not clicks

Tables made by clicking in a dashboard exist in one database and nowhere else. A migration is a SQL file that makes the change, named with a timestamp so the files run in order. With the migrations in the repository, any database (a developer's laptop, a CI run, a second plant) can be rebuilt to exactly the same shape, and every change has a pull request, a reviewer and a date [1].

A migration's pathA migration file goes into a pull request, CI applies every migration to a fresh Postgres, the merge to main makes it applied, and it is pushed to the live database. A CI failure sends it back to the file. Migration filesupabase/migrations/Pull requestyou read the SQLCIfresh Postgres, all filesMerge to mainthe file is now appliedLive databasesupabase db pushCI fails: fix the file before it mergesAfter merge, a change is a new file, never an edit
A migration's path. CI builds a fresh Postgres from every migration on every pull request; only then does the file merge and reach the live project.

Make the file

The Supabase command-line tool is free, and creates the file with the right name in the right folder [2]. Your AI coding agent can run it and write the SQL; you read the result.

Terminal: the Supabase CLI
npx supabase init                                   # once: creates the supabase/ folder
npx supabase migration new create_reference_tables # makes supabase/migrations/<timestamp>_create_reference_tables.sql
# ...the agent writes the SQL into that file, in a pull request...
npx supabase login                                  # once, after the merge, on your own machine: opens the browser
npx supabase link                                   # once: choose your company's project from the list
npx supabase db push                                # applies new migrations to the live project

login opens your browser to sign in to Supabase; link lists your projects so you pick the company's. When a command asks for the database password, type it from the company password manager. It is never written in a file or pasted into a chat.

SQL: part of the stamping plant's first migration
-- supabase/migrations/20261005091500_create_lots_inspections.sql (the stamping plant, excerpt)
create table public.characteristics (
  id        bigint generated always as identity primary key,
  part_id   bigint not null references public.parts(id),
  name      text not null,                               -- 'flange height'
  lower_mm  numeric(8,3) not null,
  upper_mm  numeric(8,3) not null,
  check (lower_mm < upper_mm)
);
create table public.lots (
  id           bigint generated always as identity primary key,
  site_id      bigint not null references public.sites(id),
  part_id      bigint not null references public.parts(id),
  equipment_id bigint not null references public.equipment(id),
  lot_code     text not null unique check (lot_code ~ '^L-[0-9]{4,}$'),
  started_at   timestamptz not null
);
create table public.inspections (
  id                bigint generated always as identity primary key,
  site_id           bigint not null references public.sites(id),     -- kept here so RLS is one simple rule
  lot_id            bigint not null references public.lots(id),
  characteristic_id bigint not null references public.characteristics(id),
  kind              text not null check (kind in ('first_piece', 'hourly')),
  value_mm          numeric(8,3) not null,
  recorded_by       uuid not null references auth.users(id),
  recorded_at       timestamptz not null default now(),
  corrects_id       bigint references public.inspections(id),
  correction_reason text,
  check ((corrects_id is null) = (correction_reason is null))      -- a correction always says why
);

Read it before it merges

An agent writes a schema quickly and plausibly. These questions catch most of what goes wrong:

  1. Does every table serve the intent? A table nothing in docs/INTENT.md needs is scope creep.
  2. Does every table have a primary key, and every reference a foreign key?
  3. Are times timestamptz, measurements numeric with the unit in the name, and money whole cents?
  4. Does every value people choose from a list have a check?
  5. Are event and decision tables append-only, with the trigger?
  6. Is row-level security turned on, with policies from the matrix (Session 2B)? Does the no-RLS query still return nothing?
  7. Does any statement drop or rewrite something that already holds data? If so, stop and ask why.
The migrations folderFour timestamped migration files in order. The first three are applied and locked; the fourth, which adds a check to holds, is new and in review. 20261005090000_create_reference_tables.sqlapplied: locked20261005091500_create_lots_inspections.sqlapplied: locked20261005093000_create_holds.sqlapplied: locked20261012080000_add_hold_reason_check.sqlnew: in reviewTo change an applied table, add a new file that alters it. History stays readable, and every database can be rebuilt from the files.
The migrations folder grows; it is never rewritten. The new check on holds arrives as a fourth file, not as an edit to the third.

CI builds the database from nothing

The strongest check is the simplest: on every pull request, CI starts an empty Postgres, applies every migration in order and runs the tests. If a migration only worked because of something clicked into the live dashboard, it fails here, before it can merge. GitHub Actions can start a throwaway Postgres for each run at no cost within the free minutes [3].

Exercise · Commit your first schema45 minutes

You need: Your repository, an AI coding agent, your Supabase project, and docs/INTENT.md from Session 2

Work from your own intent. If it is close to the stamping plant's, you can start from the eight tables in this element.

Outcome: A migration on main that builds your schema from nothing, passed CI, is applied to the live project, and has RLS on every table, before any screen exists.

Knowledge check

A column in an applied migration needs a new check. What do you do?

Knowledge check

Why does CI apply every migration to an empty Postgres?

References

  1. Supabase Docs: Database migrations. https://supabase.com/docs/guides/deployment/database-migrations
  2. Supabase Docs: Supabase CLI, getting started. https://supabase.com/docs/guides/local-development/cli/getting-started
  3. GitHub Docs: Creating PostgreSQL service containers. https://docs.github.com/en/actions/use-cases-and-examples/using-containerized-services/creating-postgresql-service-containers

Chapter 4 · 12 questions · 80% passes

Final assessment

Twelve questions across the element. Score 80% (10 of 12) to pass. Your LMS records your score and each answer; you can review the chapters and try again.

15 min12 questions≈ 15 minutesRetake allowed

Choose one answer for each question, then submit. You will see the right answer and why for every question.

1. Why is the schema settled before any screen is built?
2. In an intent, a noun you keep a list of, such as 'each press', usually becomes:
3. Which ISA-95 level does a single press usually sit at?
4. Which type stores the time of a night-shift inspection safely across midnight and clock changes?
5. Where should 'a hold must have a reason' be enforced so that it holds for every writer?
6. An inspection points at lot 9999, which does not exist. What refuses it?
7. How does an append-only table handle a wrong value?
8. Which US rule asks for audit trails that keep earlier values of electronic records?
9. Why create Supabase views with security_invoker = true?
10. What is a migration?
11. A migration has already been applied to the live project and needs a change. What do you do?
12. CI fails because a migration needs a table someone made by clicking in the dashboard. What does that show?