Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ jobs:

# Every other step runs semiplot_register_new_pens() over an empty
# public.trends, so its INSERT adds nothing.
- name: Register pens from the keys SCADA wrote
- name: Register pens and probe the semiplot_tags constraints
env:
PGHOST: localhost
PGDATABASE: semiplot_dev
Expand All @@ -87,13 +87,40 @@ jobs:
(13, 0, '2026-01-01 00:00:00', 20, 192), (40, 1, '2026-01-01 00:01:00', 7, 192)"
first=$(as_plot 'SELECT semiplot_register_new_pens()')
second=$(as_plot 'SELECT semiplot_register_new_pens()')
pens=$(as_plot "SELECT string_agg(concat_ws(' ', id, name, color, enabled_on_start), ','
pens=$(as_plot "SELECT string_agg(concat_ws(' ', id, name, color, enabled_on_start, log_scale_on_start), ','
ORDER BY id) FROM semiplot_tags")
want='0 0 #4E79A7 f,13 13 #F28E2B f,40 40 #59A14F f'
want='0 0 #4E79A7 f f,13 13 #F28E2B f f,40 40 #59A14F f f'
if [[ "$first" != 3 || "$second" != 0 || "$pens" != "$want" ]]; then
echo "::error::first call '$first' (want 3), second '$second' (want 0), pens '$pens' (want '$want')"
exit 1
fi
constraints=$(cat <<'SQL'
DO $$
BEGIN
UPDATE semiplot_tags SET scale_min_on_start = 1, scale_max_on_start = 10 WHERE id = 0;
IF NOT FOUND THEN
RAISE EXCEPTION 'pen 0 is missing';
END IF;
BEGIN
UPDATE semiplot_tags SET scale_min_on_start = 5, scale_max_on_start = NULL WHERE id = 0;
RAISE EXCEPTION 'semiplot_tags accepted the scale bounds (5, NULL)';
EXCEPTION WHEN check_violation THEN NULL;
END;
BEGIN
UPDATE semiplot_tags SET scale_min_on_start = 5, scale_max_on_start = 1 WHERE id = 0;
RAISE EXCEPTION 'semiplot_tags accepted the scale bounds (5, 1)';
EXCEPTION WHEN check_violation THEN NULL;
END;
BEGIN
UPDATE semiplot_tags SET log_scale_on_start = NULL WHERE id = 0;
RAISE EXCEPTION 'semiplot_tags accepted a NULL log_scale_on_start';
EXCEPTION WHEN not_null_violation THEN NULL;
END;
END
$$
SQL
)
as_plot "$constraints"

- name: Provision a PostgreSQL 14 container, where the REVOKE bites
env:
Expand Down
15 changes: 9 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,12 @@ place `REVOKE CREATE ON SCHEMA public FROM PUBLIC` bites, because 15 and later w
by engine default — provisions a third container with `bench` as a `postgres` image init script
over the unix socket, and builds the container image, so a broken `Dockerfile` fails on the pull
request rather than at tag time. CI pushes nothing. Between the 17 and the 14 steps, the
`Register pens from the keys SCADA wrote` step writes `trends` rows as `scada_writer`, calls
`semiplot_register_new_pens()` twice as `semiplot` and requires 3, then 0, and the three rows it
added: the only place the function body runs over a non-empty archive. It needs `psql` on the
runner; to run it locally, point `psql` at a container.
`Register pens and probe the semiplot_tags constraints` step writes `trends` rows as
`scada_writer`, calls `semiplot_register_new_pens()` twice as `semiplot` and requires 3, then 0,
and the three rows it added: the only place the function body runs over a non-empty archive. As
`semiplot` it then updates pen 0 and requires the scale-pair `CHECK` to refuse unpaired and
inverted bounds (23514) and the log flag to refuse `NULL` (23502). It needs `psql` on the runner;
to run it locally, point `psql` at a container.

Unit tests live beside the source (`cmd/semibase/*_test.go`, `internal/provision/*_test.go`),
table-driven. There are no database-touching tests in the repository; the integration checks are
Expand Down Expand Up @@ -125,7 +127,7 @@ in a rolled-back transaction, requiring 42501 for `INSERT`, `DELETE` and `UPDATE
on the archive or on `semiplot_meta` and no `CREATE` on schema `public` (revoked from `PUBLIC` by
`create`, since 14 still grants it). A failed check is a non-zero exit.

The `semiplot` grant on `semiplot_tags` is column-level: `SELECT` and `UPDATE` on the eight settings
The `semiplot` grant on `semiplot_tags` is column-level: `SELECT` and `UPDATE` on the nine settings
columns, never `id`, so `has_table_privilege(..., 'UPDATE')` answers false for that table and no
check may ask it.

Expand All @@ -137,7 +139,8 @@ DEFINER` with `search_path = pg_catalog, pg_temp` and a schema-qualified body; t
the operator acting through `semiplot`, not the SCADA (`docs/architecture/provisioning.md#trust`).
`semiplot_meta.schema_version` is `1` in
this release and is a floor: a viewer refuses a database below the version it needs and accepts one
above.
above. Until the first installation, a schema change, a rename included, edits the `CREATE TABLE`
alone and keeps `1` (`docs/architecture/provisioning.md#the-schema-version-is-a-floor`).

Passwords come from flags, env, or a `.env` file in the working directory (flag > env > `.env`;
template `.env.example`): `SEMIBASE_SUPER_PASSWORD`, `SEMIBASE_WRITER_PASSWORD`,
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Declarative architecture docs (English, present tense). These describe the syste
| Command surface | Two commands, `site` and `bench`, named for the situation rather than for the tool's internal steps; they differ in one thing, the memory tuning, which only `site` applies |
| Archive read access | `ALTER DEFAULT PRIVILEGES FOR ROLE scada_writer` set **before** `public.trends` is created, and before the writer first runs. The `semiplot_*` tables are granted table by table instead, since the superuser creates them |
| Objects we add | `semiplot_tags`, `semiplot_groups`, `semiplot_pen_groups`, `semiplot_meta`, the `semiplot_register_new_pens()` function the viewer calls, and the vendor-shaped `public.trends` — no triggers, scheduled jobs, extensions, or other functions; `messages` is not created, the SCADA makes it |
| SemiPlot schema version | `semiplot_meta.schema_version`, `1` in this release, written by `provision` alone. A floor, not an equality: a viewer refuses a database below the version it needs and accepts one above, because the versions this repository issues grow by addition. The rule starts with the first installation: until then a schema change, a rename included, edits the `CREATE TABLE` alone and keeps `1`. |
| SemiPlot schema version | `semiplot_meta.schema_version`, `1` in this release, written by `provision` alone. A floor, not an equality: a viewer refuses a database below the version it needs and accepts one above, because the versions this repository issues grow by addition. When the rule starts, and what a schema change does before then: [provisioning.md](./provisioning.md#the-schema-version-is-a-floor) |
| Archive schema | Owned by Simple-Scada 2 and documented in the SemiPlot repository. This tool creates `public.trends` once, with the vendor's shape, connected as `scada_writer`, and never alters it afterwards; day partitions and every later change to the schema remain the vendor's |
| Distribution | Binaries as GitHub release assets; the Linux binary also as `ghcr.io/semiteq/semibase`, `FROM scratch`, one file, pushed after the release exists; benches track `:latest` and a prerelease never moves it |
| Backup method | `UNDECIDED` — commissioning-time, needs the customer's archive size |
Expand Down
34 changes: 18 additions & 16 deletions docs/architecture/provisioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ Four roles take part, and the tool creates three of them.
| The superuser (`--superuser`, default `postgres`) | the engine install | Every connection this tool makes. It owns the four `semiplot_*` tables, because it is the connection that applies their DDL |
| `semiplot_registrar` | `semibase` | `NOLOGIN`. Owns `semiplot_register_new_pens()` and holds what its body needs: `SELECT` on `public.trends`, `SELECT (id)` and `INSERT (id, name, color, enabled_on_start)` on `semiplot_tags` ([Registering new pens](#registering-new-pens)) |
| `scada_writer` | `semibase` | Owns `public.trends`, its day partitions and `messages`, and writes them. `CREATE` on schema `public`. Nothing on any `semiplot_*` table |
| `semiplot` | `semibase` | `SELECT` on `trends` and `messages`; `SELECT` on `semiplot_tags` and `UPDATE` on its eight settings columns, never on `id`; `SELECT, INSERT, UPDATE, DELETE` on `semiplot_groups` and `semiplot_pen_groups`; `SELECT` on `semiplot_meta`; `EXECUTE` on `semiplot_register_new_pens()`. No `CREATE` on schema `public` |
| `semiplot` | `semibase` | `SELECT` on `trends` and `messages`; `SELECT` on `semiplot_tags` and `UPDATE` on its nine settings columns, never on `id`; `SELECT, INSERT, UPDATE, DELETE` on `semiplot_groups` and `semiplot_pen_groups`; `SELECT` on `semiplot_meta`; `EXECUTE` on `semiplot_register_new_pens()`. No `CREATE` on schema `public` |

`semiplot` edits its own pen catalogue and cannot touch the archive. Both facts are grants on
individual tables, so the second does not weaken when the first is given: the viewer's editor
Expand All @@ -182,11 +182,11 @@ saves a pen through the same connection it reads history on, and a bug in it can

A `semiplot_tags` row is keyed by `id`, the SCADA variable number. SemiPlot owns the settings in the
row and none of the keys: its editor changes a pen and never adds one, deletes one or moves one onto
another variable. The grant on that table is therefore column-level,
`GRANT SELECT, UPDATE (name, unit, format, color, line_style, enabled_on_start, scale_min_on_start, scale_max_on_start)`,
and a table-level `UPDATE` would not do: it would let `UPDATE semiplot_tags SET id = ...` re-key a
pen. A column grant is not a table grant, so `has_table_privilege('semiplot', 'semiplot_tags',
'UPDATE')` answers false, and no check asks it. A pen row is added only by
another variable. The grant on that table is therefore column-level, `GRANT SELECT, UPDATE (name,
unit, format, color, line_style, enabled_on_start, scale_min_on_start, scale_max_on_start,
log_scale_on_start)`, and a table-level `UPDATE` would not do: it would let `UPDATE semiplot_tags
SET id = ...` re-key a pen. A column grant is not a table grant, so `has_table_privilege('semiplot',
'semiplot_tags', 'UPDATE')` answers false, and no check asks it. A pen row is added only by
`semiplot_register_new_pens()` ([Registering new pens](#registering-new-pens)), and only the
superuser deletes a pen.

Expand Down Expand Up @@ -235,7 +235,7 @@ checks a `LANGUAGE sql` body against the relations it names when the function is

| Table | Holds |
| --- | --- |
| `semiplot_tags` | One pen: `id` matching `trends.id`, `name`, `unit`, `format` as a .NET numeric format string the viewer applies and the server does not validate, `color` as `#RRGGBB`, `line_style` (0 interpolated, 1 stepped), `enabled_on_start`, and the `scale_min_on_start`/`scale_max_on_start` pair that bounds the pen's own Y axis. Both bounds `NULL` means autoscale. A column with the `_on_start` suffix holds a start value: what the pen opens with in a new window and what the viewer's "Restore initial scale" returns to. Editing the pen on the chart changes the current view and never a start value, and a changed start value never changes a chart already open. Every other settings column is a live setting, which a running chart applies at its next catalogue read |
| `semiplot_tags` | One pen: `id` matching `trends.id`, `name`, `unit`, `format` as a .NET numeric format string the viewer applies and the server does not validate, `color` as `#RRGGBB`, `line_style` (0 interpolated, 1 stepped), `enabled_on_start`, the `scale_min_on_start`/`scale_max_on_start` pair that bounds the pen's own Y axis (both `NULL` means autoscale), and `log_scale_on_start`, the type of that axis: `false` is linear, `true` is log10. A column with the `_on_start` suffix holds a start value: what the pen opens with in a new window; for the scale pair and the log flag, also what the viewer's "Restore initial scale" returns to. Editing the pen on the chart changes the current view and never a start value, and a changed start value never changes a chart already open. Every other settings column is a live setting, which a running chart applies at its next catalogue read |
| `semiplot_groups` | A group name. `GENERATED ALWAYS AS IDENTITY` rather than `serial`, so `semiplot` needs no `USAGE` on a sequence to insert one |
| `semiplot_pen_groups` | Membership, `(pen_id, group_id)`. A pen may sit in several groups and in none: a pen with no row here is legal and the viewer shows it ungrouped |
| `semiplot_meta` | One row: `schema_version`. A `singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton)` admits no second row, and the value is written by one `INSERT ... ON CONFLICT (singleton) DO UPDATE`, so a failure cannot leave the table empty and a re-run cannot leave two rows |
Expand All @@ -254,12 +254,12 @@ arriving uneditable.
The archive carries no variable catalogue of its own, so `trends.id` is the only reliable source of
pen keys. `semiplot_register_new_pens()` (`sql/semiplot_register.sql`) inserts one `semiplot_tags`
row for every key in `trends` that has none and returns how many it added. The viewer calls it only
from its pen editor's "Refresh pen list" button, never at start. A new row is named by its number, takes a colour
from a fixed twelve-colour palette by `id % 12`, which assumes SCADA variable numbers are
non-negative (a negative `id` gets a `NULL` colour, and the viewer picks one), starts with
`enabled_on_start = false`, so a SCADA
with 500 variables does not draw 500 lines at the next start, and autoscales. A variable SCADA
stops writing keeps its row, because its history is still in `trends`. A trigger on `trends` was
from its pen editor's "Refresh pen list" button, never at start. A new row is named by its number,
takes a colour from a fixed twelve-colour palette by `id % 12`, which assumes SCADA variable numbers
are non-negative (a negative `id` gets a `NULL` colour, and the viewer picks one), starts with
`enabled_on_start = false`, so a SCADA with 500 variables does not draw 500 lines at the next start,
autoscales, and opens on a linear axis (`log_scale_on_start = false`). A variable SCADA stops
writing keeps its row, because its history is still in `trends`. A trigger on `trends` was
rejected: it would run on every sample SCADA writes, on the one table SemiPlot does not own.

The function is `SECURITY DEFINER`, so `semiplot` adds a pen through it and holds no `INSERT` on
Expand Down Expand Up @@ -320,8 +320,10 @@ storing a **lower** one; a **higher** one is accepted, because the versions this
grow by addition and a database carrying more than a viewer needs still carries what it needs.
The rule starts with the first installation. Until a site runs a provisioned database, a schema
change, a column rename included, edits the `CREATE TABLE` alone, issues no `ALTER`, and leaves the
number at `1`. When `semiplot_markers` arrives it bumps the number to 2, and a viewer that reads only the pen
tables has to keep working against it, which an equality check would break.
number at `1`. A database provisioned before such a change is recreated, not provisioned again:
`CREATE TABLE IF NOT EXISTS` keeps its old table, and the column grant then fails with 42703 on the
column that table lacks. When `semiplot_markers` arrives it bumps the number to 2, and a viewer that
reads only the pen tables has to keep working against it, which an equality check would break.

A removal is not signalled by this number. An older viewer meeting a dropped column gets 42703,
which SemiPlot maps to an unexpected-shape failure naming the column.
Expand Down Expand Up @@ -424,7 +426,7 @@ promises. They are knowable at exit precisely because this tool creates `public.
body still reads `public.trends` and the check passes (both measured on 17). The qualifiers are
what keeps the shadow out.
Then `INSERT`, `UPDATE` and `DELETE` on `semiplot_groups` and `semiplot_pen_groups`, and one
`UPDATE` setting all eight settings columns of `semiplot_tags` to themselves (`WHERE id = -1`,
`UPDATE` setting all nine settings columns of `semiplot_tags` to themselves (`WHERE id = -1`,
which matches no row), issued as the role, in the
order the foreign keys need. The membership `INSERT` pairs the probe group with whatever pen
exists (`INSERT ... SELECT tag.id, grp.id FROM semiplot_tags tag, semiplot_groups grp ... LIMIT
Expand Down
Loading
Loading