Skip to content

Schema-drift guard: schemaHash is computed and stored but compared nowhere — defineType silently replaces types #68

Description

@cuibonobo

Problem

schemaHash exists to catch exactly one bug — same typeId, different schema, no version bump (spec: "unambiguously a bug — intentional changes always produce a new version number"). It is computed and stored on every defineType() (packages/core/src/stack.ts:212), and compared by nothing, anywhere.

defineType() never reads the existing type: it builds a fresh StackType with a fresh createdAt and hands it to saveType(), which is INSERT OR REPLACE (packages/record-adapter-sqljs/src/index.ts:712). Consequences:

  • Silent replacement. An app (or a buggy deploy, or a second app squatting on a namespace) redefines com.example/note@1 with a different shape, and every existing record of that type is now mislabeled — the exact corruption the hash was invented to detect, undetected.
  • seedSystemTypes() churns on every open — six REPLACEs per Stack.create(), resetting createdAt each time.
  • The wire is no better: POST /types (PATCH contract mismatch: spec defines content-only merge patch, APIAdapter sends record-field envelopes #52) inherits the same silent-replace semantics.

The naive guard ("hash differs → reject") is wrong by our own decisions: #47/#57/#63 legalize additive in-place changes, which change the hash. So the hash can't be the judge — the judge is a structural diff, and the hash gets the job it's actually good at: cheap equality fast-path.

Decided direction

defineType() on an existing id:

  1. Fetch the stored type. Missing → create (today's path).
  2. schemaHash equal → no-op, return the stored type. Makes startup defineType() idempotent and fixes the seedSystemTypes() churn with no special-casing.
  3. Hash differs → diffSchemas(stored, candidate) (new, in packages/core/src/schema.ts):
  4. name-only changes pass (display metadata, not schema).
  5. Server-side POST /types runs the same check, pinned by PATCH contract mismatch: spec defines content-only merge patch, APIAdapter sends record-field envelopes #52 fixtures: identical re-POST → success/no-op; additive → accepted; illegal → 409. saveType() stays a dumb REPLACE — legality is decided in the invariant layer (same principle as _config is unprotected: update can change stack ownership, hard delete bricks the stack, query exclusion is a per-adapter convention #67).

Two relations, side by side, deliberately distinct

diffSchemas (evolution legality: "may this schema replace that one under the same id") and #54's isCompatible (read compatibility: "may a consumer of this shape read records of that type") live next to each other in schema.ts with the distinction documented — conflating them is the obvious future bug. Notably text/string are read-compatible (#54) but not evolution-legal (changing a field's declared kind is drift).

Governance context

Per the guidance added to #47 (second amendment comment): additive-in-place is the staging area for extension; version bumps are consolidation points (field becomes required with backfill, meaning changes, restructuring). This guard is that policy's mechanical enforcement — the boundary between "accept in place" and "bump the version" is diffSchemas's verdict.

Work items

Refs #47 (evolution rules + governance amendment), #54 (sibling relation), #53 (error code), #52 (fixtures), #67 (invariant-layer principle), #57/#63 (the in-place additive changes this guard must bless).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions