Stability: Beta — see Stability Tiers.
How the declarative schema differ decides that an object was renamed rather than dropped and recreated, and the body-change detection that overrides a rename with a drop+recreate. It also covers the two neighbours that ride the same per-object comparison: metadata tag-drift detection, and the reserved-tag validation the rename hints themselves are governed by. A satellite of Schema Management.
computeSchemaDiff(declared, actual, policy?) accepts an optional RenamePolicy ('allow' | 'require-hint' | 'deny', default 'allow'):
- Under
'allow', declared objects whose name doesn't match an actual object are tested forquereus.idthenquereus.previous_namematches against the catalog. A hit emits aRenameOpand consumes the actual so it isn't dropped. - Under
'require-hint', any unhinted name change is rejected: if the diff produces both a drop and a create of the same kind (table, view, index),computeSchemaDiffthrows. - Under
'deny', hints are ignored entirely — every mismatch becomes drop+create.
Conflicts (declared name and hint resolving to two distinct existing objects) always throw, independent of policy beyond 'deny'.
The same resolution runs at column granularity inside computeTableAlterDiff and at named-constraint granularity. Column renames emit ALTER TABLE ... RENAME COLUMN; named-constraint renames emit ALTER TABLE ... RENAME CONSTRAINT (CHECK / UNIQUE / FOREIGN KEY). View and index renames have no engine-level primitive: a hinted (rename-matched) view/index resolves to drop(actual old name) + recreate(declared new name) from the view/index buckets, definition changed or not. A definition-unchanged rename's recreate is rendered with the same diff's COLUMN renames inverse-applied NEW→OLD (creates run before RENAME COLUMN in migration order, and the live rename propagation then rewrites the freshly created body so the re-diff converges) while keeping declared TABLE names (table renames run first); an index rebuild on a pure rename is the accepted cost. These deliberate drop+create pairs are excluded from the require-hint counts, as are the body-change recreates below.
Alongside renames, computeTableAlterDiff resolves the full named-constraint lifecycle by name: a user-named constraint in the catalog but absent from the declaration (and not consumed by a rename) → TableAlterDiff.constraintsToDrop → DROP CONSTRAINT; a declared user-named constraint absent from the catalog (and not a rename target) → TableAlterDiff.constraintsToAdd → ADD <fragment>. Declared constraints are gathered from both the table-level constraints list and explicitly-named column-level constraints (qty int constraint chk_qty check (qty > 0)), matching what the catalog's namedConstraints surfaces. Only user-named constraints participate — engine-synthesized auto-names (_check_* / _fk_* / _uc_*), PRIMARY KEY (handled by primaryKeyChange), and derivedFromIndex UNIQUE constraints (managed through their index) are excluded, keeping the diff stable/idempotent for unnamed and index-derived constraints. Under require-hint, a constraint add and drop on the same table with no rename hint is rejected, mirroring the table/column guard.
ADD CONSTRAINT routes all three classes through module addConstraint, which the memory and store modules implement (a CHECK on a module that omits alterTable keeps an engine-side fallback) — routing CHECK through the module too keeps the module-cached schema in lock-step with the catalog so a later DROP/RENAME CONSTRAINT resolves it. UNIQUE / FOREIGN KEY adds re-validate the existing rows and fail atomically with CONSTRAINT (schema unchanged) when current data violates the new constraint, otherwise installing forward enforcement; a CHECK add is a schema-only append (no existing-row scan), enforced going forward. So a declarative add of a named UNIQUE / FK to an existing table converges (a second apply is a no-op). FK existing-row validation is gated by pragma foreign_keys (off ⇒ the add skips the scan and defers enforcement to later writes); the store's UNIQUE existing-row check honors each constrained column's per-column collation (BINARY/NOCASE/RTRIM), so a UNIQUE add — and the CREATE UNIQUE INDEX and ALTER COLUMN … SET COLLATE existing-row scans, which share the same serializeRowKey signature — correctly rejects pre-existing rows that collide only under that collation. (A comparator-only collation, registered with no normalizer, cannot bucket rows and so raises at ALTER/add time rather than under-rejecting.)
A constraint whose name is unchanged but whose body changed — an edited CHECK expression, a changed FK action / referenced table / columns, a changed UNIQUE column set or ON CONFLICT — is realized as drop-old + add-new (there is no in-place "redefine" primitive). For UNIQUE / FOREIGN KEY the re-add re-validates existing rows against the new rule (a violating row aborts the apply with CONSTRAINT, schema unchanged); for CHECK the re-add is forward-enforcing only and does not re-validate existing rows (a limitation of the CHECK add path — the module addConstraint CHECK arm and its engine-side fallback runAddCheckEngineSide in runtime/emit/add-constraint.ts), so a violating pre-existing row is not re-checked until its next write. The two statements (DROP then ADD) are not atomic on the memory backend: a failed re-add leaves the old constraint already dropped — the guarantee is "apply aborts + data survives", not "old constraint restored".
Rename reconciliation (no redundant drop+recreate). The definition is rendered with the current names on the actual side and the declared names on the declared side, so a name-matched constraint whose body differs only because of an identifier renamed in the same diff would naively register as a body change. To avoid a redundant drop+recreate on top of the rename, computeTableAlterDiff first compares the raw definition strings (the no-rename case short-circuits) and, on a mismatch, re-compares a rename-reconciled declared body from reconciledDeclaredBody: a clone of the declared constraint AST with the in-diff renames inverse-applied — each renamed identifier rewritten from its new name back to the actual pre-rename name. A CHECK expression reconciles in three ordered passes over the one cloneExpr copy:
- Every in-diff table rename, via the runtime
renameTableInAst— the exact inverse of the forward rewriter the rename migration runs over all tables' CHECKs, so the diff-side reconcile and the executed migration cannot drift. Qualified self-references and cross-table subquery references rewrite alike. Sequential application is order-independent becauseresolveRenamesmakes rename chains/swaps unrepresentable — every new name is absent from the actual catalog while every old name is present — so no rename's inverse output can match another's inverse input. - The owning table's column renames, via
renameColumnInCheckExpressionseeded with the OLD (actual) table name — correct unconditionally, since pass 1 pre-normalized every qualifier to OLD — and threaded with a declared-side scope resolver, the diff-time analogue of the live-catalogResolveColumnInSourcehook the forward propagation passes. It answers, from the declared column sets, "does this inner FROM source expose the renamed NEW column name in the declared world?", so an unqualified ref that legitimately binds a like-named column on its own subquery FROM is not falsely inverse-captured by the owning seed. - Other tables' column renames, from the same cross-table pre-resolution map the FK branch uses, via the plain scope-aware
renameColumnInAst— no seed frame, no resolver, the forward non-owning branch — so an unqualified ref rewrites only when the renamed table sits in an enclosing FROM frame. A CHECK subquery over ANOTHER table's renamed column reconciles instead of churning a drop+recreate.
The two column passes mirror the forward rewriteTableForColumnRename branch split, and their order is load-bearing (owning first): reversed, the cross-table pass would turn the inner capacity back into cap in time for the owning inverse to falsely capture it in a compound diff (owning qty→cap + referenced lim.cap→capacity).
Accepted limitations, all failing safe to a benign — still converging — drop+recreate: cross-schema FROM sources answer false from the declared-side resolver (the catalog is single-schema) where the forward path's live lookup could say yes; and pathological rename interleavings (another table's NEW name equal to the owning table's OLD name combined with correlated unqualified refs) retain the scope-naïveté class the forward renameColumnInAst documents.
UNIQUE / FK reconcile their column lists directly; an FK also reconciles its referenced parent table against the table renames and its referenced parent column list against that parent's column renames. Both thread in from computeSchemaDiff as a one-pass pre-resolution of every name-matched declared table's column renames, keyed by declared (new) table name — the key foreignKey.table carries at diff time. So a parent-table rename and a parent-column rename in the same diff reconcile together (resolve the parent's column renames by the new parent name, then rewrite the table name back to old), and a self-referential FK whose referenced column is renamed is covered by the current-table entry. When the reconciled body matches the actual, only the rename emits (metadata-only RENAME COLUMN / RENAME TABLE — no UNIQUE/FK re-validation scan, no non-atomic drop+add); a genuine body edit layered on a rename still differs, preserving the drop+recreate and its RENAME suppression.
The canonical body fragment. Each namedConstraints entry carries a definition — a canonical body fragment excluding the constraint <name> prefix and with tags (...) suffix — from ddl-generator's constraintToCanonicalDDL; the declared side renders the same fragment from its AST via ast-stringify's constraintBodyToCanonicalString. One shared normalization makes format/order-stable forms compare byte-equal, collapsing parser-default-equivalent forms so they don't churn: a bare CHECK's default INSERT|UPDATE operation mask, a FK's default RESTRICT action, an elided referenced-column list, and the default ON CONFLICT ABORT. Bare column-name identifiers are case-folded (lowercased) throughout — the UNIQUE / PRIMARY KEY column list, the FK local and referenced column lists, the FK referenced (parent) table name, and bare column references inside a CHECK expression (a structural lowerExprIdentifiers clone that leaves string / blob / numeric / JSON literals, parameters, collation names, and cast/function names byte-exact) — matching Quereus's case-insensitive column resolution. So a case-only divergence never churns a spurious drop+recreate, while a literal-value change is a genuine body edit and still recreates. CHECK subquery bodies ((select …) / exists / in (select …)) pass through structurally rather than being descended into — a bounded limitation, symmetric on both diff sides.
The FK parent-schema qualifier is canonicalized symmetrically (canonicalForeignKeyClause): rendered iff it differs (case-insensitively) from the child table's schema. An explicit own-schema qualifier (references main.t on a child in main) therefore elides to the unqualified form — matching the actual-catalog side — while a genuine cross-schema parent survives (case-folded) as a body-change channel. The child schema threads in from constraintToCanonicalDDL (actual side, tableSchema.schemaName) and from collectDeclaredNamedConstraints / reconciledDeclaredBody (declared side, the differ's per-schema target); the parent schema is not a rename channel (renames are within-schema), so an FK rename reconcile carries it through the clone untouched. Net: an unchanged cross-schema FK does not churn, an own-schema qualifier is equivalent to the bare form, and editing the declared parent schema is detected as a body change.
On drift, computeTableAlterDiff pushes the old name to constraintsToDrop and the declared fragment to constraintsToAdd — the same buckets and generateMigrationDDL emission as the add/drop paths, which already order DROP before ADD within the table block. Tags are excluded from definition, so a tag-only change compares equal and takes the in-place ALTER CONSTRAINT … SET TAGS path, never a drop+recreate. A constraint both rename-matched and body-changed drops+recreates with the RENAME CONSTRAINT suppressed (rename-then-redefine is two ops where drop+recreate is one, and the new body must re-validate regardless); the recreate's ADD fragment carries the declared tags, so no separate SET TAGS is emitted. Body changes to unnamed constraints are not individually addressable (detection keys off names) and are out of scope.
An index whose name is unchanged but whose body changed — a flipped UNIQUE, an added/removed/reordered column, an asc↔desc direction flip, or an added/changed/removed partial WHERE predicate — is realized as drop-old + recreate (an index has no in-place "redefine" primitive). Each CatalogIndex carries a definition: a canonical body string ([unique ]index (<cols>)[ where <expr>]) produced by ddl-generator's indexToCanonicalDDL, which lifts the stored IndexSchema into a minimal CreateIndexStmt and renders it through ast-stringify's createIndexBodyToCanonicalString; the declared side renders the same function over its AST, so the two are byte-comparable. For a name- or rename-matched index, computeSchemaDiff compares the declared body against matchedActual.definition; on drift it pushes the actual (pre-rename) name to SchemaDiff.indexesToDrop and the declared create [unique] index … (with the declared tags) to indexesToCreate — generateMigrationDDL already orders index drops before creates. A rename-matched index with an unchanged body takes the same shape, rendered by columnReconciledIndexStmt.
What the canonical body covers:
- Bare column-name identifiers, case-folded (lowercased) — in the column list and, via the shared
lowerExprIdentifiersthe constraint CHECK path also uses, inside the partialWHEREpredicate. The actual side lifts the column definition case (tableSchema.columns[i].name), the declared side the as-written reference case, so a case-only divergence (columnEmailindexed asemail) does not churn. Predicate literals stay byte-exact: a genuine predicate edit still recreates. - Per-column collation, but only as an already-resolved effective value: both sides pre-resolve each column's collation as the engine does at create/import time (explicit index
COLLATE, else the table column's collation, elseBINARY; normalized) before rendering — actual side inindexToCanonicalDDL, declared side inschema-differ'sdeclaredIndexCanonicalBody— so an unchanged inherited / default-BINARYcollation renders identically (no churn on an inherited-NOCASEunique index) while a genuine collation change recreates. - Not tags: a tag-only change takes the in-place
ALTER INDEX … SET TAGSpath, never a recreate — mutually exclusive with a body recreate per index, body drift winning. - Not the structural
on <table>reference, so a table rename alone never churns the column list (indexed columns carry bare names) — simpler than the constraint FK case, which also reconciles a parent table / parent column.
Concurrent column renames are reconciled like the constraint path: declaredIndexCanonicalBody inverse-applies the index table's in-diff column renames — keyed by the index's declared (new) table name, so a table renamed in the same diff still resolves them — to each bare column name in the declared body and to each column reference inside the partial WHERE predicate (renameColumnInCheckExpression over a cloneExpr copy). So a same-named index over a column renamed in the same diff matches the actual body and emits only the RENAME COLUMN (no index drop+recreate, so it never trips the require-hint index guard), while a genuine body edit layered on the rename still recreates. Ordering is load-bearing: the collation is resolved on the new (declared) column name first — the declared ColumnDef is keyed by it — and only then is the emitted name mapped back to its old form; reversed, the lookup would miss.
A partial WHERE predicate carrying a table-qualified self-reference (where t.active = 1) does embed the table name, so the predicate takes the constraint CHECK path's pass order: ALL in-diff table renames inverse-rewrite first (renameTableInAst), then the per-column rewrites, seeded with the index table's OLD name to match the now-normalized qualifiers. A cross-table reference cannot occur — compilePredicate rejects subqueries, schema-qualified refs, and any table qualifier other than the indexed table, at create time — but the all-renames scope mirrors the forward rewriter regardless. So a pure table rename (with or without concurrent column renames) over a qualified predicate emits only the rename op(s). (Accepted scope-naïveté, symmetric with the forward path: a subquery alias equal to a renamed table's new name can inverse-rewrite, causing a spurious but valid recreate.) A genuine unhinted create+drop of two distinctly-named indexes still trips the require-hint guard.
Implicit covering indexes (the secondary BTree backing a UNIQUE constraint) never participate in the index buckets — their lifecycle is the originating constraint's, handled by the named-constraint diff path. A hidden implicit index (no quereus.expose_implicit_index) is absent from actualCatalog.indexes entirely, so it never name-matches. An exposed one is present for introspection (schema() / index_info()), but the catalog marks it CatalogIndex.implicit = true and computeSchemaDiff filters it out of actualIndexes before building the rename/create/drop view — so a converged schema with an exposed implicit index diffs empty, never emitting a phantom DROP INDEX IF EXISTS <name> (and ALTER INDEX … SET TAGS on the exposed name routes onto the originating constraint, not the index buckets).
Primary-key column renames reconcile the same way (PK changes flow through primaryKeyChange, not the named-constraint path). Before comparing the declared PK sequence against the actual key, computeTableAlterDiff inverse-applies the in-diff column renames to the declared PK column names (reusing inverseRenameConstraintColumns), so a pure PK-column rename — already emitted as a metadata-only RENAME COLUMN — does not also churn a redundant ALTER PRIMARY KEY. Only this table's own column renames participate (a PK references only local columns, so no cross-table threading, unlike the FK body case). The reconciliation rewrites names only: pkSequencesEqual still compares direction, so a genuine asc→desc change layered on a renamed PK column still emits the PK change; and primaryKeyChange.newPkColumns keeps the new (declared) names, so a genuine membership/order change ALTERs to the correct post-rename columns. A default-PK table (no explicit PRIMARY KEY ⇒ all columns are the key) is covered for free.
A view — plain or materialized — whose name is unchanged but whose definition changed is realized as drop-old + recreate (neither has an in-place "redefine" primitive). The canonical definition covers two parts: the explicit column list (v(a, b) — for an MV it also names the maintained table's columns) and the body (astToString of the QueryExpr, which carries the trailing with defaults (col = expr, …) clause, so a defaults-only edit drifts the string without a separately-itemized part). Name / schema / tags are excluded. One shared renderer — ast-stringify's viewDefinitionToCanonicalString — produces it on both sides: actual from the live ViewSchema / maintained-table TableDerivation fields (CatalogView.definition), declared from the declared statement's fields. Plain views compare strings; materialized views compare hashes — TableDerivation.bodyHash is computeBodyHash over this same canonical definition, stamped at create (materializeView) and re-stamped by the rename-propagation rewrite (applyMaterializedViewRewrite), so a clause-only or explicit-columns-only MV change re-materializes exactly as a body change does.
An MV's backing-module identity (using <module>(...)) is a separate compared field, deliberately not folded into bodyHash (a formula change would spuriously rebuild every already-persisted MV): both sides normalize the name (absent ⇒ memory, mem aliased, lowercased) and compare args under a stable-key-order render, so using memory() vs an omitted clause never churns while a genuine module or args change takes the body-drift drop+recreate path, re-materializing the backing into the newly declared module. Rename-coincident module move: when one apply BOTH renames a maintained table (via a quereus.previous_name / quereus.id hint) AND moves its backing module, the RENAME op is preserved (dependents over the old name retarget through ALTER … RENAME) and the module-move's drop is retargeted to the new declared name — the rename runs first at apply, so dropping the old name would no-op and the recreate (rendered under the new name) would collide. For a plain name match the two names coincide, so non-rename module moves are unaffected.
Tags are excluded from the definition: a tag-only change takes the in-place ALTER VIEW / ALTER MATERIALIZED VIEW … SET TAGS path, and a definition recreate carries the declared tags and suppresses any separate SET TAGS — mutually exclusive per object, as for indexes/constraints. No identifier case-folding is performed — a deliberate asymmetry vs the constraint/index bodies, which fold to avoid expensive churn (their recreates re-validate rows / rebuild structures): a case-only edit recreates a plain view (free — data-less) or rebuilds an MV. Both sides render parser-produced ASTs through the one emitter, so keyword case / whitespace cannot churn regardless.
Concurrent renames are reconciled like the constraint/index paths: on a raw mismatch only (the converged case short-circuits), reconciledDeclaredViewDefinition re-renders the declared definition from a clone with every in-diff table rename inverse-applied NEW→OLD (renameTableInAst), then each renamed table's column renames NEW→OLD (renameColumnInAst over the body — the body's own FROM provides the scope; the column rewrites are seeded with the table's OLD name since the qualifier pass pre-normalizes). The with defaults clause reconciles for free as part of the body: it rides inside the select AST (SelectStmt.defaults), so the same walk descends select.defaults — each entry's column target (a base column of the view's FROM table, often projected away) inverse-renames via the same scope-aware synthetic probe a with inverse target uses, and each entry's expr inverse-renames in the select's FROM scope frame, guarded by the same declared-side resolveColumnInSource resolver the constraint path's pass 2 threads (the scope walk consults an inner FROM's column sets only when that resolver is supplied). The explicit column list names the view's own output columns — stable identity — and passes through untouched. When the reconciled definition matches the actual, only the rename op(s) emit.
For column renames this is correctness-critical, not just churn-avoidance: generateMigrationDDL emits view creates before the table-alter block where RENAME COLUMN lives, and CREATE VIEW plans its body at create time, so an unreconciled recreate naming the NEW column would fail at apply (whole-TABLE renames are safe — they emit first). A genuine definition edit layered on a rename still recreates; a rename-matched view resolves to drop(old) + create(new) either way, the definition-unchanged recreate rendered by columnReconciledViewStmt (see Rename Detection). Residual hazard (known, unsolved): a genuine view/MV definition edit that ALSO references a column renamed in the same diff still emits its CREATE before the RENAME COLUMN and fails at apply — the create-before-alter ordering MV rebuilds have always had; split such a migration into two applies (rename first, then the definition edit).
An assertion (create assertion <name> check (<expr>)) whose name is unchanged but whose CHECK body changed is realized as drop-old + recreate — an assertion has no in-place "redefine" primitive, the same shape as the index / view paths above. CatalogAssertion.definition carries the canonical CHECK-expression rendering (name, schema qualification, and the CREATE ASSERTION framing excluded) produced by ast-stringify's expressionToString; computeSchemaDiff renders the declared side through the same function, so both sides stringify parser-produced ASTs and keyword case / whitespace can never churn. On drift the actual name goes to SchemaDiff.assertionsToDrop and the declared create assertion … to assertionsToCreate; generateMigrationDDL emits all assertion drops before every other drop (they may reference tables) and all assertion creates last — after the table/view/index creates, after the whole table-alter block, and after the maintained re-attaches. Creates run last because CREATE ASSERTION plans its body at build time (see SQL DDL § 2.6.1), so a declaration that adds a column and an assertion over that column in one round would fail if the create ran before the ADD COLUMN. Nothing in a migration depends on an assertion existing, so last is strictly safer — the body then sees the final shape of every object. An unchanged assertion whose body names a table or view this same migration DROPS is force-dropped and recreated around it (the diff checks the declared body against tablesToDrop / viewsToDrop with the same reference walk): otherwise the runtime drop guard would refuse the DROP TABLE and kill the migration. For a table that is dropped and recreated in one migration — a maintained table whose backing module changed — the assertion's target is back by the time the recreate runs; for one that is genuinely removed, the recreate fails loudly instead. Identifier case is not folded (the view/MV policy, not the constraint/index one): a case-only edit recreates, which is cheap — an assertion recreate re-plans a query rather than rebuilding a structure or rescanning rows.
Assertions have no rename support (the name is explicitly part of the contract — no quereus.previous_name hint) and no rename reconciliation in the differ. The stored CHECK expression is rewritten by ALTER TABLE … RENAME (like a view body — see SQL ALTER § RENAME TABLE), so the well-formed declarative case converges: apply schema runs the rename before the assertion recreate, and the re-diff then compares new-name against new-name. On the first diff a table renamed in the same round as an otherwise-unchanged assertion whose declared body already follows the new name still registers as drift and emits a spurious-but-correct drop+recreate — harmless, since assertion creates run after the rename. The converse case — rename the table but leave the declared assertion body on the old name — converges the first apply (the diff is computed before any DDL runs, so declared and stored still agree, and the ALTER TABLE … RENAME then rewrites the stored body onto the new name). A second apply of that same stale declaration sees the drift and recreates, and the recreate now fails — Cannot create assertion 'a_t': Table 't' not found — because CREATE ASSERTION validates its body at build time. The apply stops there with the assertion dropped; previously it silently recreated an unresolvable assertion and made every subsequent write in the database fail. Fix the declaration's assertion body to name the new table.
computeTableAlterDiff also detects metadata-tag drift at three sites — the table (TableAlterDiff.tableTagsChange), each surviving column (ColumnAttributeChange.tags, computed in computeColumnAttributeChange), and each name-matched named constraint (TableAlterDiff.constraintTagsChanges). The schema hash deliberately excludes tags, so drift is detected structurally (an order-independent stableStringify compare) rather than via the hash. The rename-hint keys quereus.id and quereus.previous_name are excluded (they drive rename detection, not data state, so a hint-only declaration does not churn a SET TAGS after the rename); all other reserved tags (quereus.lens.*, quereus.expose_implicit_index, …) are compared. generateMigrationDDL emits the drift as ALTER TABLE … SET TAGS (…) / ALTER TABLE … ALTER COLUMN … SET TAGS (…) / ALTER TABLE … ALTER CONSTRAINT … SET TAGS (…) after the structural ALTER phases, so a tag set lands on the post-rename column / constraint name. These SET TAGS mutations are catalog-only (in-memory swap plus table_modified, no module.alterTable), and table / column / named-constraint / index / view / materialized-view tag mutations all survive reconnect for store tables through the store's event subscription — see Store catalog persistence. The same in-place catalog path backs the imperative per-key ALTER TABLE … ADD TAGS / DROP TAGS ergonomics, which the differ never emits (it always computes the full desired set and emits whole-set SET TAGS).
The differ detects the same drift on the other tagged catalog objects — views, materialized views, and indexes — on a name-matched object (no rename), surfacing it through SchemaDiff.viewTagsChanges / materializedViewTagsChanges / indexTagsChanges. generateMigrationDDL emits these as ALTER VIEW … SET TAGS / ALTER MATERIALIZED VIEW … SET TAGS / ALTER INDEX … SET TAGS (leaf metadata writes in the alter phase). A view or materialized-view tag-only change takes this in-place path instead of a drop+recreate — an MV does not re-materialize the body; a definition change still drops+recreates (carrying the declared tags — see View / materialized-view definition-change detection), and the two are mutually exclusive per object. The view / MV setters re-register the in-memory schema object (firing view_modified / materialized_view_modified — distinct from the create events, so they invalidate cached write-through plans without re-registering maintenance); the index setter swaps the owning table's IndexSchema and fires table_modified. These setters likewise back the imperative per-key ALTER VIEW / ALTER MATERIALIZED VIEW / ALTER INDEX … ADD TAGS / DROP TAGS ergonomics.
quereus.id / quereus.previous_name are first-class entries in the typed reserved-tag registry (src/schema/reserved-tags.ts), not a differ-local allow-list. Every tag-authoring surface routes its tags through that registry at a site matching the object and hard-errors on an unknown or mis-sited quereus.* key (e.g. quereus.previuos_name, or a logical-*-only key like quereus.lens.writable on a physical table) — the same registry and hard-error-on-unknown severity the lens-compile, view-mutation, and advertisement paths use, so such a key fails loudly rather than being silently swallowed. Sites: physical-table (table), physical-column (column), view-ddl (view / materialized view), physical-index (index), physical-constraint (constraint). Free-form (non-quereus.*) tags pass untouched.
Constraint siting. A table-level constraint validates at physical-constraint whether named or not (its WITH TAGS is consumed regardless). An inline column constraint carries tags only when named (qty integer constraint chk check (qty>0) with tags (...)) — those validate at physical-constraint; an unnamed inline constraint defers its trailing tags to the column, validating once at physical-column (no double-validation). Rename detection still keys off named constraints only.
Declarative path (computeSchemaDiff, before rename resolution) routes every declared object's tags through validateReservedTags(tags, site), raising via raiseReservedTagDiagnostics, so a misspelled / mis-sited key fails diff / apply schema. The two rename hints carry value-schema 'string' (a quereus.id may contain a hyphen), so the rename flow is unchanged; an MV's quereus.id validates but is ignored (the differ supports no materialized-view rename). The duplicate-name check (SCH-003) raises right after, so a tag typo surfaces first.
Build-time paths — direct CREATE TABLE / CREATE INDEX … WITH TAGS and imperative ALTER … ADD / ALTER … SET|ADD TAGS — all validate at plan-build and raise through one sited helper, raiseStmtTagDiagnostics, so they cannot drift. CREATE TABLE mirrors the differ's four physical surfaces (table / each column / each table-level constraint / each named inline constraint), plus physical-index for CREATE INDEX; the per-column legs (a column's own tags + its inline constraints') come from the shared columnTagDiagnostics helper the ALTER … ADD COLUMN path also calls, accumulating table → per-column → table-constraints, the first error raised once at the statement's source location. ADD CONSTRAINT … WITH TAGS checks at physical-constraint; ADD COLUMN … WITH TAGS at physical-column plus each inline named constraint at physical-constraint. SET TAGS and ADD TAGS share the setTags build case and validate at the matching site (physical-table/-column/-constraint for ALTER TABLE; view-ddl/physical-index for ALTER VIEW / ALTER MATERIALIZED VIEW / ALTER INDEX). Validation fires even under IF NOT EXISTS (build-time, before the runtime existence check) and regardless of the nondeterministic_schema option (tags are not expressions).
DROP TAGS carries no values, so on any object it does no reserved-tag validation — dropping a reserved key is legitimate.
Two deliberate blind spots. (1) CREATE VIEW / CREATE MATERIALIZED VIEW … WITH TAGS are not eagerly validated — view tags validate lazily on the view-mutation path. The keys legal at view-ddl are the inert rename hints and quereus.sync.replicate (the one view-ddl key carrying behavior: it opts an MV's store backing into change-log replication, read off getSchema().tags by the store backing host). So a typo'd quereus.sync.replicate on a direct create is silently inert, whereas the declarative diff / apply schema path — the authoring path for migration targets — does validate it at view-ddl. (2) The catalog import / load path (SchemaManager.buildTableSchemaFromAST, via importTable / importCatalog) is by design not gated — it re-loads already-persisted DDL and must not start rejecting an openable database.