feat(preflight): add CheckTableAbsent absence proof - #60
Conversation
First slice of CREATE TABLE support: a fact-only preflight that proves a table name is unoccupied before a create is attempted. Reads pg_class directly so a missing privilege can never masquerade as absence, and separates schema-not-found, name-occupied (any relkind), and unresolvable search_path causes. Nothing consumes the AbsentTarget proof yet; the executor create path and front-door admission land in follow-up PRs.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
An enum, domain, or range at the target name blocks CREATE TABLE just like a relation, but pg_class knows nothing about it — the check minted a proof for a name the create would lose. The absence query now reads pg_type in the same snapshot (excluding relation-owned rows and autogenerated array types, which the server renames out of the way) and refuses with a new ErrTypeExists sentinel. Also fail closed on an empty table name, and pin the privilege-independence and cleanup-ordering properties in the integration tests.
|
🤖 Adversarial correctness review, requested by @aparajon and performed by their agent. Reviewed at head No blocking defect. The central design decision — reading Four findings. The first is the one I'd act on before the create path builds on this.
1. The two checks are not inverses of each otherThe body frames this as "an inverse of the existing target preflight", and the signatures encourage that reading —
Each is correct in isolation, and So if _, err := CheckTableAbsent(ctx, pool, schema, table); err == nil {
// name is free -> CREATE
} else {
// name is taken -> ALTER
}— routes to CREATE and lands a There's a smaller edge of the same seam in the proof types: I'd fix this in documentation rather than code, since both behaviours are individually right:
2. The query's soundness comment is wrong about indexes, and the real guard is untestedThe comment justifies the shape of the joins like this:
Indexes have no CREATE INDEX dup ON z60.t (id);
CREATE TYPE z60.dup AS ENUM ('a');
in_pg_class | standalone_type
-------------+-----------------
1 | 1The query still returns exactly one row and still returns the right answer — but for a different reason than the comment gives. What resolves the double occupant is the order of the two Two mutants confirm nothing pins any of this:
3.
|
| Mutation | Result |
|---|---|
Drop the array-type filter (typelem = 0 OR typarray <> 0) |
killed — TestCheckTableAbsentIgnoresAutogeneratedArrayType |
| Drop the empty-table-name guard | killed — TestCheckTableAbsentRefusesEmptyTableName |
Proof carries the raw schema instead of *targetSchema |
killed — TestCheckTableAbsentResolvesUnqualifiedName |
Drop AND ty.typrelid = 0 |
survived (finding 2) |
Check typtype before relkind |
survived (finding 2) |
Catalog reasoning, checked on the server rather than assumed:
- The array-type exclusion is right, and for the stated reason.
moveArrayTypeNamerenames an autogenerated array type out of the way, so_taken_rgreally is free — the test proves it end to end. The filter's shape (typelem = 0 OR typarray <> 0) also holds up in the other direction: every user-created type and domain gets an array type, sotyparray = 0withtypelem <> 0is effectively only an array type. Fixed-length types with an element (point) are correctly kept. - Standalone composite types are caught by the relation branch, not the type branch —
CREATE TYPE x AS (…)mints apg_classrow withrelkind = 'c', sotaken_complanding underErrRelationExistsis correct, not an accident. - Domains over arrays don't slip through: a domain's
typelemis inherited from its base type, but domains get their own array type, sotyparray <> 0keeps them. - The
targetSchema == nilbranch really is unreachable for a qualified check — theCASEreturns the parameter unchanged, which is never NULL. The comment is accurate. ErrSchemaNotFoundis unreachable on the unqualified path by construction:current_schema()only ever names a schema that exists. Harmless, and the separateNotErrorIsassertion inTestCheckTableAbsentRefusesEmptySearchPathpins that the two causes stay distinct.- Temp tables don't confuse the unqualified path.
current_schema()doesn't report the temp schema, and an unqualifiedCREATE TABLEdoesn't land there either, so a shadowingpg_temprelation correctly does not count as an occupant. - Exact-name matching is consistent with the rest of the package, not a new convention:
CheckTableusesto_regclass(quote_ident(...)), which also matches the raw string exactly.TestCheckTableAbsentMatchesExactNamepins both directions. - Time-of-check honesty. The doc states plainly that nothing locks the name and the create path must still treat a duplicate-name error as a collision. That's the right contract for a fact check, and it's stated where a caller will read it.
Landing it dormant — a reviewed foundation with no consumer, rather than bundled into the create path — is the right call for a safety-critical package, and the reason this review could be about catalog semantics instead of about executor plumbing.
CI green at this head, 12/12.
Action items
- Document that
CheckTableAbsentis not the complement ofCheckTablefor unqualified names, on both functions, and add the two-schema search_path case as a test. - Correct the query comment — the relkind-first ordering, not the
typrelid = 0filter, is what resolves a double occupant — and add an index-plus-type name pair toTestCheckTableAbsentRefusesOccupiedName. - Note on
AbsentTargetthat the create path must re-verify it (empty table name, and statement target equal to the proof) the way ST-7 does forPreflightedTable. - Assert the converse in
TestCheckTableAbsentRefusesOccupiedTypeName— the create really does fail — matching the array test. - File the
to_regclassprivilege issue againstCheckTable/LookupTargetFacts.
This review was generated by Claude Code (claude-opus-5).
|
🤖 Second pass, requested by @aparajon and performed by their agent — two lenses: OSS adoption (does the repo's own story still add up after this?) and orchestrator integration (can an embedder build the create path on this?). Nothing here blocks; the correctness pass is the other comment. The two lenses meet at the same place they did on #59: a list kept by hand.
Lens 1 — adoption1. The capabilities page currently argues against this PR's premise.
The reasoning behind it is good — the engine exists to protect existing readers and writers, and a table nobody has opened yet has none to protect. This PR's Why says the opposite is now planned: "CREATE TABLE support needs an inverse of the existing target preflight… the executor create path and front-door admission can each build on a reviewed foundation." That's a scope decision worth stating out loud, because 2. Three lists, one of them already stale. The proof-type registry is now maintained by hand in three places:
The fix has direct precedent in this repo — for _, name := range []string{"PreflightedTable", "AbsentTarget"} {
assert.Contains(t, tcbModel, "`"+name+"`",
"docs/tcb-model.md is missing a proof-type row for %s", name)
}Better still if the list is derived rather than repeated, but even a hardcoded slice in one test beats three prose lists, because adding the fourth proof type ( 3. Landing it dormant deserves to be a documented practice. A reviewed, unconsumed foundation is why this review could be about Lens 2 — orchestrator integration4. One of the four refusals has no sentinel, and it's the one that needs operator action. Three causes are typed and matchable; the fourth is not:
return AbsentTarget{}, fmt.Errorf("resolve creation schema for %s: the session's search_path names no schema", table)
5. The proof is time-of-check, and an orchestrator's clock is much longer than a function call's. The doc comment is admirably explicit that nothing locks the name and that a concurrent create can still win. For an embedder the sharper statement is where the proof may be held: SchemaBot plans when a PR opens and applies when someone comments — minutes to days later, across process restarts. An So the contract the create path needs is: mint the proof inside the apply, in the same session that runs the 6. Give the create path its routing table. Same ask as #59, now with a concrete second consumer. The engine knows what each refusal means operationally and no document says it:
The last row is the interesting one, because it's the only case where the same underlying condition arrives through a different door, and it's the one a retry loop will get wrong. And for the first row specifically: a router asking "is this name taken?" has to match two sentinels, which is the kind of thing that decays into matching one. A three-line predicate in the package settles it and keeps the two causes distinct in messages, where the distinction actually earns its keep: // IsNameOccupied reports whether err means the target name is already held,
// by a relation or by a standalone type. Both block a CREATE TABLE.
func IsNameOccupied(err error) bool {
return errors.Is(err, ErrRelationExists) || errors.Is(err, ErrTypeExists)
}Action items
This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving — no blocking defect. Findings are in the two comments above: the unqualified-name asymmetry with CheckTable is the one worth settling before the create path builds on this.
This review was generated by Claude Code (claude-opus-5).
Adds
preflight.CheckTableAbsent, a fact-only preflight that proves a table name is unoccupied, producing a newAbsentTargetproof type. Nothing consumes it yet.Why
CREATE TABLE support needs an inverse of the existing target preflight: instead of proving a table exists and is admissible, the engine must prove the name is free before attempting a create. Bending
PreflightedTable— whose contract assumes an existing relation — would weaken both proofs, so absence gets its own check and proof type. This lands first, dormant, so the executor create path and front-door admission can each build on a reviewed foundation.What
CheckTableAbsent(ctx, pool, schema, table)→AbsentTargetproof, from one catalog snapshot read directly againstpg_class— visible regardless of grants, so a missing privilege can never masquerade as absence (deliberately notto_regclass).ErrSchemaNotFound,ErrRelationExists(any relkind occupies the name), and unresolvablesearch_path; unqualified names resolve viacurrent_schema().SAFETY.mdproof-type registry updated; integration tests cover the absence, occupied-name, and schema-resolution cases.Before / after