You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 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:
Fetch the stored type. Missing → create (today's path).
schemaHash equal → no-op, return the stored type. Makes startup defineType() idempotent and fixes the seedSystemTypes() churn with no special-casing.
Hash differs → diffSchemas(stored, candidate) (new, in packages/core/src/schema.ts):
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.
Tests: seed idempotency (no createdAt churn across repeated opens); additive accept incl. nested; each violation class rejected with named violations; name-only change passes
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).
Problem
schemaHashexists to catch exactly one bug — sametypeId, different schema, no version bump (spec: "unambiguously a bug — intentional changes always produce a new version number"). It is computed and stored on everydefineType()(packages/core/src/stack.ts:212), and compared by nothing, anywhere.defineType()never reads the existing type: it builds a freshStackTypewith a freshcreatedAtand hands it tosaveType(), which isINSERT OR REPLACE(packages/record-adapter-sqljs/src/index.ts:712). Consequences:com.example/note@1with 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 perStack.create(), resettingcreatedAteach time.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 existingid:schemaHashequal → no-op, return the stored type. Makes startupdefineType()idempotent and fixes theseedSystemTypes()churn with no special-casing.diffSchemas(stored, candidate)(new, inpackages/core/src/schema.ts):objectproperties andarrayitems, consistent with isCompatible() is too weak to be the official cross-app mechanism #47 makes it (optional-field hole, text vs string) #54's recursive descent); nothing removed, no kind changes, no existing field flipped to required, no new required fields → accept: persist new schema + new hash, preserve originalcreatedAt.StackSchemaDriftErrorlisting the specific violations, remedy in the message: bump the version (defineType('…@n+1')+registerMigration). Wire: 409 with aschema_driftcode in APIAdapter collapses the error taxonomy: Stack* errors never survive the wire round trip #53's vocabulary.name-only changes pass (display metadata, not schema).POST /typesruns 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'sisCompatible(read compatibility: "may a consumer of this shape read records of that type") live next to each other inschema.tswith the distinction documented — conflating them is the obvious future bug. Notablytext/stringare 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
diffSchemas()+StackSchemaDriftError(packages/core/src/schema.ts,stack.ts)defineType()no-op / additive / reject flow,createdAtpreservationschema_driftcode in APIAdapter collapses the error taxonomy: Stack* errors never survive the wire round trip #53's wire vocabularycreatedAtchurn across repeated opens); additive accept incl. nested; each violation class rejected with named violations; name-only change passesRefs #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).