Skip to content

fix: version history integrity — every model change restorable, write guards, ClickHouse String cast - #87

Merged
acmeguy merged 12 commits into
mainfrom
fix/version-history-integrity
Sep 27, 2026
Merged

acmeguy merged 12 commits into
mainfrom
fix/version-history-integrity

Conversation

@acmeguy

@acmeguy acmeguy commented Sep 27, 2026

Copy link
Copy Markdown

Why

Recovering Blue Car's deleted vehicle_telematics_points on the dev cluster showed that model history could not be trusted: several write paths changed the current version in place, so earlier content was lost for good, and history said nothing about restores. Details + evidence: synmetrix-vi-evidence.md (isolated stack), summarised below.

What

  1. ClickHouse × Tesseract — count over a multi-column primary key compiled to CAST(x AS STRING); ClickHouse rejects it (Unknown data type family: STRING), breaking every such cube on dev (e.g. booking_forecast_data_points). Postinstall patch sets templates.types.string = 'String' (pinned to 1.7.30, refuses to apply to a different source).
  2. DELETE /api/v1/dataschema/:id writes a NEW version without the file (was: delete the row in place).
  3. New PUT /api/v1/dataschema/:id {code} saves one file as a new version (same guards as DELETE; unchanged: true on identical code; 409 unless current version of an active branch). Replaces the agent's in-place update_dataschemas_by_pk refinement save.
  4. Rollback records its source — versions.source_version_id, carried in the rollback audit payload.
  5. Hasura permissions — dataschemas update/insert pinned to the current version (history can't be rewritten); version insert requires team owner/admin (any member could make an empty version current).
  6. /internal/invalidate-cache requires the admin secret (was unauthenticated and reachable through the ingress); actions sends it.
  7. Version diff reports every changed attribute (sql, title, description, joins, segments, pre-aggregations, views…) — additive fields; and requires team membership (a token without a partition could read another team's diff).
  8. generate-models / smart-generate require owner/admin to write (dry runs stay open); also closes a cross-team write where generate-models trusted body.branchId.
  9. One version-write path — commitVersionFiles is the only version insert (PUT, DELETE, rollback, createDataSchema); shared guards in modelWriteGuards.js; hasuraTokenForUser replaces 4 copies.
  10. cubejs image builds again — Debian 11 went EOL 2026-08-31 and deb.debian.org purged the security pool (libssl1.1 404 on every build, main included). Reads the final bullseye-security state from snapshot.debian.org. Stop-gap: a bookworm base is the real fix.

Verification

cubejs 695/695 (+ test/ 91/91), actions 53/53, lint-error-codes ✅; image builds amd64 + arm64 and passes CI's in-image test. Every fix exercised live on an isolated compose stack (real Hasura/Postgres/cubejs/actions/ClickHouse).

Deploy

  1. Merge → CI builds synmetrix-{cube,actions,hasura-migrations}.
  2. Hasura migrations image + ArgoCD sync (migrate before metadata).
  3. Pin cube + actions digests in fraios (deploy actions with or before cube — cube 401s actions' cache invalidations until actions sends the secret).
  4. Then merge the cxs-agents change that moves the agent refinement save onto PUT /dataschema/:id.

🤖 Generated with Claude Code

acmeguy and others added 12 commits September 27, 2026 15:09
Cube 1.7.30 ClickHouseQuery.sqlTemplates() never overrides
templates.types.string, so the native planner CASTs multi-column primary
keys for count measures without sql to the base 'STRING', which
ClickHouse rejects (Unknown data type family: STRING). Extend the
version-pinned postinstall patch to set it to 'String'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- versions.source_version_id (nullable FK, ON DELETE SET NULL) records
  which version a rollback restored; insertable/selectable by role user.
- dataschemas update/insert (role user) only touch rows of the current
  version, so history can no longer be rewritten in place. Nested
  insert_versions_one { dataschemas } saves still pass (verified).
- versions insert requires owner/admin of the team (was: any member or
  the datasource owner, allowing empty versions to become current).
- audit_logs.action also allows 'dataschema_update' (PUT dataschema).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Extract commitVersionFiles() from rollbackVersion(): one helper that writes
a new version holding exactly the given files with the caller's Hasura
token, returns the new rows, and drops the caller's cached user scope so
its next request resolves the new version. Rollback now sets
source_version_id = toVersionId, and the rollback audit payload carries it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DELETE /api/v1/dataschema/:id deleted the row from the current version in
place, destroying history. It now writes a new version on the branch with
every other file of the current version, so the delete is undoable with a
rollback. Guards are unchanged (auth, partition, owner/admin, current +
active, reference scan/409) and moved to utils/mutableDataschema.js for
reuse. The success audit row is written directly (the delete event trigger
no longer fires); the response adds versionId + branchId.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Body {code}. Copies every file of the dataschema's version into a new
version with this file's code replaced, so the previous version stays
restorable — replaces the in-place Hasura update_dataschemas_by_pk the
agent refinement script uses. Same guards as DELETE (direct-verify,
partition, owner/admin, current version of an active branch → else 409).
Identical code returns 200 {unchanged: true} and writes nothing.
Response: {versionId, branchId, dataschema: {id (new row), name,
checksum, version_id}}. Audits under action dataschema_update and emits
Model Saved like createDataSchema. New error codes
update_blocked_authorization / update_blocked_historical_version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /api/v1/internal/invalidate-cache had no auth and is reachable via
the /api ingress. Accept it only when x-hasura-admin-secret equals
HASURA_GRAPHQL_ADMIN_SECRET (constant-time compare; 401 otherwise, and
always 401 when the env var is unset). Its only caller, the actions
service (utils/cubeCache.js), now sends that header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The diff only flagged a member as modified when its type changed; sql,
title, description, format, meta, primary_key, public, segments, joins,
pre_aggregations and cube-level sql/sql_table/refresh_key/extends
changes were invisible, and views were ignored. Cubes and views are now
compared attribute by attribute (named lists member by member). Additive
response fields: modifiedCubes[].changedAttributes (e.g. sql_table,
measures.revenue.sql, joins.customers), kind (cube|view) on every entry,
and modifiedFiles[] listing every file whose code differs. A cube added
to an existing file is reported in addedCubes. diffModels' smart-generate
preview semantics are unchanged; parseCubesFromJs can optionally collect
view() definitions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
generate-models and non-dry-run smart-generate write versions with the
admin secret and trusted body.branchId, so any team member could write
models, and a caller could write onto another team's branch. Both now
require the branch to belong to the request's datasource and — except
smart-generate dry runs — the caller to be owner/admin of its team. The
row-type pipeline identity is provisioned as admin, so it keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- commitVersionFiles() is the only server-side version insert: PUT,
  DELETE, rollback and createDataSchema (now a thin adapter keeping its
  throw-on-error / {id} contract and caller checksum) all go through it,
  and it is the single place that emits Model Saved. The returning-less
  upsertVersionMutation twin is gone.
- utils/modelWriteGuards.js (was mutableDataschema.js): authorizeTeamWrite()
  = partition + owner/admin gate with failure audit, shared by
  resolveMutableDataschema (PUT/DELETE) and version rollback.
- hasuraTokenForUser() in mintHasuraToken.js replaces four copies of the
  cached get-or-mint (delete/put, rollback, validate-in-branch, hasura
  proxy); respondError() lives in errorCodes.js instead of per-route copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /api/v1/version/diff read both versions with the admin secret and
only checked the partition claim, so a caller whose token carries no
partition (Hasura HS256, WorkOS without partition) could diff any team's
versions. It now requires the branch to be visible to the caller through
the same guard the generation routes use — authorizeModelWrite renamed
to authorizeBranchAccess({readOnly}) — and answers 404 otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Debian 11 LTS ended 2026-08-31. deb.debian.org purged the
bullseye-security pool (between Sep 3 and 5) but still serves its index,
so apt-get install libssl1.1 resolves to 1.1.1w-0+deb11u8 and 404s on
every arch; archive.debian.org has no bullseye-security yet. Read that
suite from snapshot.debian.org's frozen final state (20260903T000000Z,
index dated 2026-08-31) with check-valid-until=no, keeping the same final
security versions the 2026-09-03 images shipped. Stop-gap: the base gets
no further security updates; moving to a bookworm base is the real fix.

Verified: buildx linux/amd64 and linux/arm64 builds pass, CI's in-image
yarn test is 695/695 on both, amd64 image serves /livez on the vi stack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@acmeguy
acmeguy merged commit 86b3702 into main Sep 27, 2026
5 checks passed
@acmeguy
acmeguy deleted the fix/version-history-integrity branch September 27, 2026 17:41
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.

1 participant