Skip to content

eql: detect the ORE-unavailable case at install time, and name a remedy that exists - #907

Open
coderdan wants to merge 1 commit into
dan/eql-verify-surfacefrom
dan/ore-unavailable-at-install
Open

eql: detect the ORE-unavailable case at install time, and name a remedy that exists#907
coderdan wants to merge 1 commit into
dan/eql-verify-surfacefrom
dan/ore-unavailable-at-install

Conversation

@coderdan

@coderdan coderdan commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #891. Stacked on #906 — review that first; this PR's diff is the last commit only.

The gap

The EQL bundle wraps CREATE OPERATOR CLASS for the ORE opclass in a guarded DO block that swallows insufficient_privilege. Where the installing role cannot clear that gate the class is skipped and the bundle poisons every _ord_ore / _search_ore domain with an always-raising eql_ore_unavailable CHECK instead.

That is a supported configuration. The problem was when you found out: nothing said it where the choice between types.*Ord and types.*OrdOre is actually made. #906 added the detection, but only as a parenthetical on one line of install output. The operator in the report learned about it when something on the ORE path errored at query time.

What this adds

eql preflight predicts it. A new non-blocking ORE operator class row — creatable / not creatable / unknown, canCreateOperatorClass in --json.

It is probed, not inferred from superuser, and that is the load-bearing decision here. CREATE OPERATOR CLASS is superuser-gated in stock PostgreSQL, but AWS RDS and Aurora let their admin role create one (rolsuper = f) while cloud-hosted Supabase does not. Predicting from rolsuper would tell an RDS operator their ORE domains are unavailable when they work — exactly the blanket claim about "managed Postgres" this change exists to stop making. So preflight asks the server: CREATE OPERATOR FAMILY shares the privilege gate with CREATE OPERATOR CLASS and needs no member operators, so it is the cheapest statement that tests it, and it runs inside a transaction that is always rolled back. 42501 is a real "no"; anything else (read-only replica, no CREATE on public, timeout) is unknown — never rendered as either answer.

eql install names the consequence and the remedy on its own line rather than in parentheses.

eql status reports the ORE state on a v3 install, so the answer survives past the install output without re-reading scrollback.

A correction: the old remedy named a type that does not exist

The fallback message pointed at "the _ord_ope ordering domains". The bundle does create public.eql_v3_<t>_ord_ope — but @cipherstash/stack ships no types.*OrdOpe factory (packages/stack/src/eql/v3/columns.ts:165 — out of scope), so that sentence sent a schema author to a column type they could not declare.

Every command now says types.*Ord (public.eql_v3_*_ord): the same CLLW-OPE ordering, with a factory behind it. ore.test.ts pins that no state's message mentions _ord_ope or OrdOpe again.

One model, five commands

The state machine, the catalogue probe and the copy move into packages/cli/src/installer/ore.ts, shared by eql preflight, eql install, eql status, eql verify and eql validate. The opclass EXISTS expression previously existed twice, byte-identical, in verify.ts and validate.ts; the two commands can no longer drift into disagreeing about the same catalogue fact.

On the issue's premises

Two things in #891 are out of date against the pinned bundle, and the implementation follows the bundle:

  • "The _ord_ore domains remain present and usable as column types" (citing stash-encryption: the _ord_ore domains are not disabled on managed Postgres — only the opclass is skipped #759, verified on EQL 3.0.2) is no longer true on 3.0.4, which is what the CLI pins. The bundle now adds the eql_ore_unavailable poison CHECK to all 20 ORE domains on the fallback path (cipherstash-encrypt.sql:59368-59425), so a write to one fails. The messaging added here says "unusable, writes fail a CHECK", not "usable but unindexable". Worth closing stash-encryption: the _ord_ore domains are not disabled on managed Postgres — only the opclass is skipped #759 against the current bundle rather than applying its suggested wording.
  • "stash eql install should pick the OPE domains automatically in Supabase mode" — it already does, on every platform. V3Domain (init/types.ts) has no *OrdOre member and the interactive picker never offers one, so no scaffold path can emit the trap. What was missing was the reason, so the agent editing the user's real schema files afterwards does not reach for it: both placeholder templates now carry it.

Coverage

  • Unitinstaller/__tests__/ore.test.ts (new): all four state classifications, both healthy/damage severities, the _ord_ope regression guard, the three-state describeOreCreatable, and that the opclass expression uses to_regtype (degrades on a database with no EQL) rather than a raising ::regtype cast. db/__tests__/preflight.test.ts: the row never annotates as a blocker, and unknown never renders as no.
  • Live Postgrespreflight.live.test.ts: the probe answers true for the superuser and false for an unprivileged role (the 42501 arm is an answer, not a failure), and pg_opfamily holds no leftover afterwards — the assertion that preflight is still read-only. verify.live.test.ts: the standalone readOreState agrees with the full surface diff on the same database, so the cheap read eql status uses cannot disagree with eql verify.

pnpm --filter stash test green, including the live suites against the compose database.

https://claude.ai/code/session_01AwM5Cm5ddasXozb6stxPR1

The EQL bundle skips the ORE btree operator class when the installing role
cannot create one, and poisons every `_ord_ore` domain with a loud-failure
CHECK in its place. That is a supported configuration -- but nothing said so
where the choice between `types.*Ord` and `types.*OrdOre` is actually made, so
operators discovered the trade at query time.

- `eql preflight` probes whether the role can create an operator class and
  reports it as a non-blocking row. Probed, not inferred from `superuser`:
  RDS and Aurora let their admin role create one while cloud-hosted Supabase
  does not, so `rolsuper` is not evidence either way. The probe attempts the
  DDL in a transaction it always rolls back, so preflight stays read-only,
  and reports `unknown` when it could not ask rather than guessing.
- `eql install` names the consequence and the remedy on its own line.
- `eql status` reports the ORE state, so the answer survives the install
  output.
- The remedy now names a type that exists. The previous wording pointed at
  the `_ord_ope` domains; the bundle creates those, but `@cipherstash/stack`
  ships no `types.*OrdOpe` factory, so it named a column type no schema
  author could declare.
- The state machine, the catalogue probe and the copy move into one module
  shared by preflight, install, status, verify and validate, so the five
  cannot drift into disagreeing about the same catalogue fact.

Claude-Session: https://claude.ai/code/session_01AwM5Cm5ddasXozb6stxPR1
@coderdan
coderdan requested a review from a team as a code owner August 18, 2026 12:26
@changeset-bot

changeset-bot Bot commented Aug 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 83264da

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
stash Minor
@cipherstash/basic-example Patch
@cipherstash/e2e Patch
@cipherstash/stack Minor
@cipherstash/stack-drizzle Minor
@cipherstash/stack-supabase Minor
@cipherstash/stack-prisma Minor
@cipherstash/wizard Minor
@cipherstash/bench Patch
@cipherstash/test-kit Patch
@cipherstash/prisma-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@freshtonic freshtonic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve. The design decision this PR turns on — probing CREATE OPERATOR FAMILY rather than inferring from rolsuper — is right, well-argued, and live-tested on both arms including the no-leftover assertion that keeps preflight's read-only claim honest. CI green.

What I verified:

  • The shared ORE module does what it promises: classifyOreState is pure with all four states unit-tested, the opclass EXISTS expression that previously existed byte-identical in verify.ts and validate.ts now has one home, and the readOreState-agrees-with-verifyEqlSurface live test is exactly the guard that makes a cheap status read safe to ship alongside the full diff.
  • The _ord_ope correction is real and pinned: the bundle does create eql_v3_<t>_ord_ope domains but columns.ts ships no OrdOpe factory, so the old remedy pointed at an undeclarable type. The regression test sweeping every state's message for _ord_ope/OrdOpe is the right shape of guard.
  • The never-blocks framing is carried consistentlymissing untouched, the preflight row annotates with <- skips: not <- blocks, unknown is a genuine third state that never renders as either answer, and the tests pin each of those separately.
  • eql status's ORE check is advisory, not a gate — a failed read warns and the rest of status still reports. Correctly gated on installedV3, since an empty database legitimately reads as incoherent-unpoisoned through the raw classifier.

One non-blocking nit on a comment: probeOperatorClassCreate says "no CREATE on public" lands in the null arm, but Postgres raises 42501 for schema-permission denials too, so that case actually classifies as a hard false. It's harmless in practice — a role without CREATE on public is already reported as a preflight blocker, and the bundle's own CREATE OPERATOR CLASS would fail identically for that role, so "not creatable" is the truthful answer for what install will experience — but the comment (and the PR description's list of unknown causes) slightly overstates what reaches null. Worth a one-line fix whenever the file is next touched.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants