Atomic component source and theme state

Design-only implementation gate for immutable aggregate revisions, coordinated CAS, verified source objects, migration, and rollback.

Atomic component source and theme state

Status: design only. This document specifies a proposed implementation and rollout. It is not deployed, and no current API or storage guarantee should be inferred from it.

Context and decision

Today POST /component/create both creates and updates when CreateComponentDto.id is present. It saves mutable component metadata and then overwrites components/<component UUID> in MinIO, compensating on some failures. Theme writes use the one-row-per-component themes table and strong "theme-vN" ETags, but run independently. Reads fetch the mutable object and currently turn a missing object into {}. Fork separately creates rows, copies the current object, and compensates best-effort. These paths cannot give one concurrency or history boundary across source, theme, and authored metadata.

We will use immutable aggregate state revisions, not a mutable theme plus source pointer alone. PostgreSQL's component.currentStateRevisionId is the sole aggregate compare-and-swap (CAS) value and every successful authored transition produces a new immutable aggregate revision. MinIO remains a blob store, never the authority for which state is current.

The existing immutable component_revision table is a reviewed CLI registry artifact with digest, registry schema, visibility, and serialized revision number. It is not reused or reinterpreted by this design.

Data model

Names below are logical; migrations must use explicit foreign keys, checks, unique constraints, and database-enforced immutability. State rows may cascade only as part of an authorized owning-component deletion; source-object metadata and object versions never cascade and remain privileged-GC decisions.

component_source_object

  • id uuid primary key
  • bucket = 'component-sources'
  • objectKey text — server-CSPRNG UUID/random value (at least 128 bits), never a component ID or user-controlled text
  • versionId text — required MinIO version ID, not an ETag
  • sha256 char(64) — lowercase hash of the exact stored bytes
  • byteSize bigint — exact byte count, non-negative
  • contentType, createdAt
  • unique (bucket, objectKey, versionId)

Content identity fields and referenced object versions are immutable. A separate privileged lifecycle field/table is mutable only by GC and has AVAILABLE | DELETE_CLAIMED | DELETED; it is not authored metadata. Coordinators lock the source row and may create a state reference only while AVAILABLE. Identical content need not be deduplicated initially.

component_state_revision

  • id uuid primary key, componentId uuid not null
  • parentRevisionId uuid null, generation bigint not null, operation from a closed transition enum
  • sourceObjectId uuid not null
  • themeSnapshot jsonb null — sanitized authored {name, factors, groups, values} or null
  • themeId uuid null, themeProjectionVersion integer null — both null exactly when the snapshot is null
  • componentMetadataSnapshot jsonb not null
  • registryArtifactId uuid null — the reviewed CLI component_revision corresponding to this state, never an embedded registry payload
  • requestId uuid not null, requestHash char(64) not null, snapshotByteSize bigint not null, createdAt, actorUserId
  • unique (componentId, generation); requestId is audit data, while the 30-day intent table is the idempotency authority

Generation is 1 for create/fork and otherwise parent generation plus one. The parent must belong to the same component. The metadata whitelist is exactly name, description, activeFile, previewFile, language, pageSettings, usedDeps, usedUiFrameworks, isSetup, visibility, isShared, and publishingDomain. Reject unknown snapshot keys. These delivery fields are recorded because the editor and CLI currently change them with source; domain uniqueness and visibility changes therefore commit under the same aggregate CAS. Ownership, images, votes, counters, timestamps, deletion, and registry revision payloads remain outside the snapshot. Fork always overrides delivery fields to DRAFT, false, and null; rollback never republishes or restores a domain unless the authorized request explicitly supplies currently valid delivery fields.

Historical themeId/themeProjectionVersion are checked scalar evidence, deliberately not foreign keys to the deletable current themes projection; only the current state equality invariant binds them to the current row. State/revision/evidence IDs are server-generated UUIDv4 and checked for version/variant in PostgreSQL. Binding DDL uses UNIQUE(componentId,id) on state; a deferred composite parent FK (componentId,parentRevisionId) -> component_state_revision(componentId,id); and a deferred composite pointer FK (component.id,component.currentStateRevisionId) -> component_state_revision(componentId,id). Generation allocation occurs under the component row lock and a deferred trigger/check enforces parent generation plus one. Creation pre-generates component/revision IDs, inserts the component with its deferred pointer, then source/state rows before commit. State/source contents reject UPDATE under the app role; source-object FKs are RESTRICT. Add UNIQUE(componentId,id) to component_revision and a DEFERRABLE INITIALLY DEFERRED ... ON DELETE NO ACTION composite FK (state.componentId,state.registryArtifactId) -> component_revision(componentId,id), so another component's artifact can never be associated. Revision actor UUID is bounded scalar audit data with no FK and is purged with 14-day tombstone history; the current component owner FK becomes nullable ON DELETE SET NULL so account-row deletion cannot mutate immutable revisions or be blocked by them.

Add nullable component.currentStateRevisionId plus a closed stateIntegrityStatus (legacy, active, quarantined) during expansion, with a same-component deferred composite constraint. Enforcement requires (active, non-null pointer); a quarantined row may retain a corrupt pointer for evidence/repair or be null when backfill could not materialize it; legacy is removed after backfill. New components pre-generate both IDs and use deferred constraints so no committed active row is pointerless. The pointer is the only aggregate CAS: do not add a mutable current-source pointer or treat theme version as a substitute.

component_state_quarantine has one current bounded record per component: UUID evidence ID, closed reason code, evidence hash/object identity, observed current revision (nullable only for unbackfillable legacy), confirmation timestamps, operator notes, and nullable clearedAt/clearedBy. Runtime quarantine conditionally records evidence against the observed current pointer under lock and never clears it; authored mutations/fork/publication stop except audited repair and terminal delete. Repair parents from and CASes that pointer normally; owner delete sends the retained state ETag in standard If-Match, plus exact Theme-If-Match when a theme projection exists, and the no-GET tombstone path, while locked account deletion may invoke it without client headers. Only confirmed NoSuchVersion, repeatable exact byte/hash failure, invalid JSON, or deterministic component/theme projection mismatch may set quarantined. Network timeout, MinIO 5xx/auth failure, or other transient availability returns typed 503 and alerts without persisting quarantine.

A legacy pointerless quarantine exposes to its authorized owner standard ETag: "component-quarantine-<lowercase evidence UUIDv4>" parsed with the same weak/list/wildcard rejection for repair/delete CAS. Audited repair supplies/restores verified source, creates authoritative generation 1, rechecks every invariant, sets the pointer active, and clears status. Repair/delete send that one tag in standard If-Match; authorized delete additionally sends exact Theme-If-Match: "theme-vN" when a projection exists and inserts an immutable component_state_deletion_event containing request/evidence/actor/concealment metadata but no fabricated source/state revision, then, in the same transaction as intent/event commit, deletes any theme projection under its exact precondition, sets deletedAt, visibility=DRAFT, isShared=false, and publishingDomain=null, and starts retention. Account deletion uses the same locked event path without requiring a client header. Tests cover quarantine racing save/delete and repair parent/generation behavior.

Current theme projection

themes remains the current authored projection for existing consumers: one row per component, with factors/groups/values sanitized by projectThemeContent, name independently validated to the existing 100-character boundary, and the current constraints and exact strong "theme-vN" behavior. Snapshot name is the effective persisted name: create keeps the existing empty/absent-to-Default rule, while update preserves absent and accepts an explicitly supplied bounded string exactly as today. A theme create starts at 1; an accepted update increments exactly once; source-only transitions do not change it; delete removes it. Theme mutation and aggregate revision insertion occur in the same PostgreSQL transaction. Before advancing the component pointer, compare the sanitized revision snapshot to the exact values being inserted/updated (or to the unchanged current row for a source-only transition). Any inequality is an internal consistency failure and rolls back.

A supplied theme update retains existing semantics even when the sanitized payload is equal: it consumes the exact theme-vN precondition and produces theme-v(N+1). Omitted theme means retain, not empty. A null snapshot only means an explicit, authorized theme deletion or a component that has no theme.

The existing component columns remain the current indexed projection for search, domains, gallery, authorization, and registry discoverability. The final transaction updates the whitelisted columns, enforces the existing publishingDomain column uniqueness constraint, inserts the identical metadata snapshot, and advances the pointer together. Before commit it compares the projected current columns with the new current snapshot; drift is an integrity failure. No query is migrated to uniqueness inside JSONB.

Invariants

  1. A visible current component has exactly one current revision, and that revision belongs to it.
  2. A noninitial revision's parent is the previously current revision; generations are gap-free along the committed chain.
  3. The current source is addressed by (random key, versionId) and a GET of that exact version has the recorded SHA-256 and byte count.
  4. The current themes row and current revision theme snapshot/ID/version are equal after the same sanitizer, including null absence; the whitelisted current component columns equal the current metadata snapshot.
  5. Every create, source-only, theme-only, joint source/theme, metadata, rollback, fork, and ordinary delete transition inserts one aggregate revision and advances the pointer once; the documented pointerless-quarantine deletion event is the sole exception. Failed attempts do neither.
  6. Within the declared 30-day idempotency window, an owner-scoped UUID key identifies one canonical request hash and same key/different hash never executes. A current committed revision replays success; a live but superseded revision returns 409 STATE_SUPERSEDED with the authorized current ETag. After 14-day history purge, a committed delete replays its durable delete success, while other committed operations return 410 STATE_HISTORY_PURGED without claiming a current ETag (or nondisclosing 404 when actor authorization no longer exists).
  7. Readers never synthesize source, theme, or metadata. In particular, missing or corrupt source is never returned as {}.
  8. Publication authorization remains separate. A new/forked component is DRAFT; aggregate commits may record explicitly authorized visibility, sharing, and domain changes but do not weaken their existing checks or publish implicitly. The protocol does not alter landing/gallery, public images, registry review rules, votes, or billing/credit behavior.

Write API and concurrency

The authenticated endpoint family is the only activated path for aggregate creates and updates:

  • POST /component-state creates a component or fork. Require an owner-scoped UUID Idempotency-Key; the first short transaction preallocates and records the target component UUID, so retry/create-once semantics come from that reservation plus domain uniqueness rather than a meaningless If-None-Match on an ID-less route. A fork manifest supplies forkFromComponentId and requires Fork-Source-If-Match: "component-state-<source revision UUID>" so the copied aggregate is pinned.
  • PUT /component/:id/state performs source-only, theme-only, metadata-only, joint, theme deletion, or rollback transitions. Require Idempotency-Key and exact strong If-Match: "component-state-<current revision UUID>".

The canonical state tag is one quoted strong lowercase UUIDv4 tag matching ^"component-state-([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})"$. Aggregate endpoints use HTTP If-Match/ETag for it and deliberately retain the project-wide 409 stale-owned-row convention; weak, wildcard (on update), comma-list, malformed, uppercase, or absent validators are rejected. A theme update/delete additionally requires Theme-If-Match parsed with the existing exact grammar ^"theme-v([1-9]\d*)"$ and returns Theme-ETag. Theme creation preserves the existing rule that theme If-Match is absent; aggregate CAS plus database uniqueness proves absence, and any theme precondition header is rejected as inapplicable.

On existing theme routes, HTTP If-Match remains exclusively the exact theme validator; GET/update keep standard theme ETag, while delete keeps its current no-theme-ETag response. Every mutation additionally requires Component-State-If-Match and returns Component-State-ETag. This aggregate tag prevents theme delete/recreate ABA even though a fresh theme restarts at theme-v1. No theme header is accepted when a manifest says retain; joint writes satisfy both domains. CORS allow/expose lists include Idempotency-Key, Theme-If-Match, Theme-ETag, Component-State-If-Match, Component-State-ETag, and the fork validator without changing credentialed-origin restrictions.

The request is multipart whose first part must be a JSON manifest capped at 512 KiB and, only when replacing source, exactly one following byte-stream part; source-first/extra parts are rejected before spooling. A replacement manifest declares exact source bytes before the stream is accepted; an above-quota declaration or any actual-length/hash mismatch fails. Decode source with fatal UTF-8 semantics (no replacement characters), require a non-array JSON object, and store/hash the exact received bytes. Metadata limits are explicit: name 255 characters, description 10,000, active/preview paths 1,024 each, at most 100 framework strings of 64, pageSettings 128 KiB, usedDeps 128 KiB, and the canonical component metadata snapshot 384 KiB. All JSON containers must be plain bounded JSON values.

pageSettings.storybook.registryItem is not copied into state JSON. CLI v2 provides the reviewed artifact separately, inserts component_revision in the final transaction, records registryArtifactId, and retains only bounded storybook delivery metadata (schema, entry, selected stories, provenance, digest) in page settings. Browser writes containing a registry payload are rejected. Backfill may extract it only when it byte-matches an existing reviewed artifact; otherwise quarantine rather than silently drop it.

Use one named shared Compify canonical JSON v1 encoder: recursively lexicographically sorted object keys, array order preserved, standard JSON string/finite-number/boolean/null encoding, plain objects only, and rejection of undefined/nonfinite/invalid-Unicode values. requestHash covers operation, actor/target/reservation IDs, every normalized metadata/delivery field, sanitized theme operation/content, exact source SHA-256/bytes, every validator, and rollback/fork/registry target, distinguishing absent, null, and defaulted values. snapshotByteSize is the UTF-8 byte length of the canonical state payload (component snapshot, normalized theme identity/version/snapshot, source-object ID, operation, and registry association), not JSONB's physical size.

The aggregate success tuple is {componentId, stateRevisionId, source: {sha256, byteSize}, component, theme|null} with standard state ETag and applicable exact Theme-ETag. The existing editor update sends one aggregate request. A normal durable 2xx proves this request committed and supplies its returned ETag as the next-write precondition; successful lost-response reconciliation additionally requires that revision still be current before returning success. Such success updates local files, saved hash, state ETag, theme/theme ETag, success toast, and then starts image/GIF/OG derivatives. STATE_CONFLICT, THEME_VERSION_CONFLICT, STATE_SUPERSEDED, unreconciled response loss, validation, and every 5xx leave the dirty hash/state untouched. Initial create and fork consume the same one combined response. A delayed 2xx may arrive after another writer committed; it is still an honest commit result, but its next write must conflict rather than silently merge. This changes save plumbing only and does not alter landing/gallery markup or behavior.

Fork authorization is an explicit whitelist: only source components whose current visibility is PUBLIC, FREE, or EXTERNAL may be forked. DRAFT, PRIVATE, deleted, quarantined, or unknown values are denied. The fork snapshots the pinned source revision's source, sanitized theme, and whitelisted metadata into generation 1 for a new owner and does not copy persistence IDs, theme version, publication fields, or counters.

Component deletion is an idempotent aggregate tombstone, not an immediate history purge. It requires Idempotency-Key, the current aggregate If-Match, and exact Theme-If-Match when a theme exists, takes the component row FOR UPDATE, inserts a terminal delete state revision retaining the already-recorded source reference without a storage GET (delete only reduces disclosure) but with themeSnapshot=null, deletes the current theme projection, sets deletedAt, visibility=DRAFT, isShared=false, and publishingDomain=null so the row is undiscoverable, advances the pointer, and stores the durable delete result in one transaction. Fork holds the source row FOR SHARE until the destination reference commits: a fork that locks first may finish; a delete that commits first yields nondisclosing 404. Ordinary API code never deletes state/source history. The proposed minimum retention is 14 days, subject to per-deployment approval and disclosure; a separately privileged purge may then remove a tombstoned component/history and later GC an exact object version only after proving no surviving fork/state/intent reference. Legal-erasure exceptions require an operator runbook and cannot bypass object-lock facts. Image derivative cleanup remains separately observable and cannot change tombstone semantics.

Account deletion first atomically marks the user deletionPending, revokes sessions/tokens, and makes every list/domain/registry/public authorization query exclude that owner immediately; every coordinator finalization—including fork—locks/rechecks both destination actor and source owner flags. It then drains/serializes in-flight work, removes discoverability/domains, and tombstones every owned component through the same locked coordinator, so no fork/save may commit afterward. The component owner FK then anonymizes with ON DELETE SET NULL; retained scalar actor IDs and source versions enter the same finite 14-day privileged purge schedule. The API principal never gains delete permission. Privacy/terms and the operator erasure runbook must disclose that encrypted backups and compliance-locked versions persist for their configured finite retention; if immediate physical erasure is required, activation is blocked because it is incompatible with object lock.

At activation, every legacy component mutation carrying an existing component ID is rejected with 410 LEGACY_STATE_WRITE_DISABLED, including update-shaped POST /component/create calls. Id-less legacy creation may exist only before the activation gate. The existing theme routes are not discarded: /theme/insert and /theme/:id become aggregate-aware adapters that retain their exact theme If-Match grammar/status/ETag behavior, additionally require Component-State-If-Match, and insert/advance one aggregate revision in the same transaction. Missing aggregate state on those routes is 428. No client type may bypass aggregate CAS. Legacy reads can remain adapters over the new current revision during migration.

CLI publication becomes an explicitly versioned contract. An upgraded CLI first resolves a publishing domain to an authorized current state tag; update publishes must send that exact tag plus a fresh UUID idempotency key, while a new domain relies on its owner-scoped idempotency reservation and unique domain. Digest is content evidence/idempotency input, never a substitute for expected state. Old CLI clients that lack the versioned state contract are rejected with 426 UPGRADE_REQUIRED; an upgraded request missing its required state validator receives 428 rather than having the server fetch-current and overwrite silently. Schema-v2 publication inserts the reviewed component_revision, component/theme projections, source/state rows, pointer, registry association, and committed intent with one transaction-scoped EntityManager; the advisory domain lock may remain defense-in-depth but is not CAS.

Writer and reader routing

Protected aggregate columns, themes, state/source rows, intent finalization, and pointer advancement are writable only through a fixed-search-path PostgreSQL SECURITY DEFINER coordinator function/procedure; the ordinary app role loses direct INSERT/UPDATE/DELETE privileges on those protected columns/tables but retains narrowly required counter/image/vote operations. For CLI v2, the definer coordinator (or a narrowly paired definer routine) creates/locks the component first, inserts the artifact, then inserts the state association/pointer in the same transaction; ordinary app-role artifact insertion is not a bypass. Grant/trigger audits fail CI if a direct repository save can bypass the function.

The routed writer inventory is explicit: create/update-shaped /component/create, share/:id, generic ComponentService.update, visibility/domain/publication changes, theme insert/delete, fork, component delete, account delete, rollback, and CLI v1/v2 publication must call the coordinator or be rejected after activation. /component/share/:id is removed in favor of aggregate PUT (rather than inventing another adapter); the unrouted generic update() is deleted. Theme routes alone remain compatibility adapters with the dual headers above. imageUploaded, image/OG/GIF objects, votes/upvote counters, and derivative jobs remain outside the authored aggregate. No activated code may PUT/GET mutable components/<id> source. Tests search for direct protected repository writes and legacy object keys in addition to exercising DB grants.

Blob readers—editor findOne, public viewOne, CLI get, legacy registry conversion, fork, and source-consuming publication/image construction—follow the current state and exact version. List-only search/top/sitemap/my/recent/info/domain/registry-index queries use the verified current component projection and exclude deleted/quarantined rows. Every projection/authorization query has an explicit regression test.

Registry artifacts remain a distinct reviewed boundary. Current component visibility/domain/deletion is always the first authorization/discoverability gate. Latest and digest v2 routes additionally require the associated/requested artifact's recorded visibility to permit that access (or an owning token), so a historically private artifact never becomes public through a current visibility change. The current state's registryArtifactId is retained only for theme-only, isShared-only, or same/more-restrictive visibility transitions. Source/authored-metadata/rollback/delete, any domain change, and any access-broadening visibility transition clear it. If no artifact is associated, the existing bounded v1/editor adapter derives from current aggregate source rather than selecting an unrelated maximum revision; this deliberately preserves browser-registry behavior and is not a reviewed-v2 claim. New v2 artifacts are associated in the same final transaction; legacy nullable associations are migrated or left unassociated, never guessed.

Exact quotas

The new contract defines subscription_plan.maxComponentSize as an integer MiB quantity and renames/documents it as maxComponentSizeMiB; the fractional code fallback becomes a conservative integer 1 MiB. Before rollout, each deployment must retain a signed/read-only ID/name/value audit report and receive explicit operator mapping; this design makes no unaudited tier-value claim from repository code. A guarded migration normalizes only the known self-host seed row ID 00000000-0000-4000-8000-000000000001 when its value is exactly 10485760, changing it to 10; all other anomalous/out-of-range rows block migration for operator mapping. The historical initial migration stays unchanged and fresh installs run the correction. Never reinterpret arbitrary plan values as bytes.

The enforced byte ceiling is maxComponentSizeMiB * 1024 * 1024, using checked bigint arithmetic. Compare it with the exact persisted source byte count, computed while streaming and verified again by GET; do not measure Buffer.byteLength(JSON.stringify(dto)), JavaScript characters, multipart size, or a MinIO ETag. Reject an advertised length above quota early, and reject if actual bytes differ from the declaration or exceed the destination owner's plan. Fork applies the same exact-byte check against the destination owner's plan.

Theme's independent 1 MiB limit remains Buffer.byteLength(JSON.stringify(sanitizedThemeContent), 'utf8'), checked before and after projection as today. Metadata/request limits are independent and never credited toward or subtracted from source quota.

Immutable history must also be storage-bounded before activation. As a new availability/product policy requiring explicit operator approval, the first implementation proposes numeric ceilings equal to the separate registry precedent: at most 100 ordinary state revisions plus one reserved terminal-delete slot with up to 512 KiB of canonical tombstone bytes outside the ordinary byte ceiling, 50 MiB of canonical state-snapshot bytes, and 50 MiB of distinct logical source-object bytes referenced by one component. Theme-only revisions reuse the source and consume only state count/snapshot bytes; forks are charged logically to the destination even when they share a physical version. Delete/account tombstones may consume only the reserved slot and are never blocked by ordinary history quota. Reaching another ceiling fails explicitly and never prunes a current/parent chain silently. Limit each user to five nonterminal intents and at most twice that plan's per-component source allowance in concurrently reserved upload bytes, using locked bigint counters. Retention/product-history changes require a separately reviewed migration and must not turn failed uploads into unbounded storage.

Durable upload intent and commit protocol

Persist component_state_intent with: UUID requestId; non-FK actor UUID retained only for the 30-day idempotency window; preallocated targetComponentId; closed operation/status; canonical requestHash; expected state UUID/theme version; nullable source key/hash/declared bytes/selected version; reserved physical/logical bytes and transition count as checked bigint; lease token/expiry; PUT attempts constrained 0..2; committed state/theme version; bounded response JSON; and created/updated/terminal-expiry timestamps. Unique (actorUserId,requestId) is the create/retry scope; intent rows deliberately survive failed creates and account-row deletion until expiry.

Upload fields are nullable: source-retain, theme/metadata-only, rollback, and fork-with-shared-source transitions allocate no upload key and move RECEIVED -> VERIFYING -> VERIFIED only after GET+SHA of the exact referenced version. Source replacement allocates its random key before I/O.

RECEIVED -> UPLOADING -> UPLOADED -> VERIFYING -> VERIFIED -> COMMITTING -> COMMITTED

FAILED_VALIDATION, FAILED, CAS_LOST, EXPIRED, and ORPHANED are terminal. Each nonterminal intent has a random lease token and expiry; state changes are CAS updates. A worker must CAS/verify its unexpired lease immediately before PUT and again when recording the result. GC first terminalizes an expired lease, then waits longer than the maximum PUT uncertainty and object-lock retention; a late worker can no longer finalize. While any intent is nonterminal, all versions of its object key are protected.

At most two PUT attempts/physical versions are allowed for one intent (initial plus recovery when acknowledgement/version recording was ambiguous). Reserve twice the advertised byte count before the first PUT. A recorded exact version is reverified rather than re-PUT; after two ambiguous outcomes the intent terminates and every physical version remains charged until privileged GC confirms deletion. Retrying an expired/terminal request returns its stored terminal result; starting again requires a new idempotency key and fresh quota reservation.

Committed and terminal intents/results are retained for 30 days, including after component purge, then reduced/deleted by a privileged bounded job; the API promises no replay result after that horizon. Enforce at most 1,000 newly terminal intents per actor per UTC day in addition to request throttling and nonterminal limits, so invalid/failed keys cannot grow the table without bound. State revisions retain their request IDs while their component history exists, but do not create a literal forever guarantee.

  1. Parse/authenticate only the bounded manifest first. In a short PostgreSQL transaction lock a concrete user_state_upload_usage row with nonnegative bigint counters plus the component/create reservation, check state-count/snapshot/source-history ceilings, enforce five nonterminal intents, verify the complete canonical request hash from declared bytes/SHA-256, reserve two times declared upload bytes plus one transition slot, and insert/recover RECEIVED before accepting source bytes. Then stream/hash/count to a mode-0600 dedicated disk spool, requiring exact declared bytes and SHA-256; cap the service at 32 concurrent spools and 256 MiB total reserved spool space, reconciled on restart. Theme/metadata/fork-with-shared-source transitions reserve zero upload bytes but still reserve transition/snapshot quota. Final commit consumes logical quota and unused physical-attempt reservation; terminal failure/CAS loss/expiry releases only unused bytes, while created orphan versions stay charged until GC. Fatal UTF-8, source-map, declared-length, or SHA mismatch CASes to FAILED_VALIDATION with the stored 400 result; disk-full, detected disconnect, or spool I/O failure CASes to terminal FAILED/503. Process death leaves a lease that the restart reconciler expires, releases reservations, and securely removes only spool files mapped to terminal/expired intents. None of these paths PUT; terminal same-key replay returns the stored result and a new attempt needs a new key.
  2. PUT to the versioned bucket. Record the returned versionId and move to UPLOADED.
  3. GET that key and versionId, stream it, and require exact SHA-256 and byte count. Only then insert/resolve component_source_object and mark VERIFIED. A retain/rollback/fork transition verifies the referenced version is still readable before commit and does not re-upload it; terminal delete is the explicit no-GET concealment exception above.
  4. In one PostgreSQL transaction lock the component (or creation reservation), recheck ownership, aggregate CAS, theme precondition, fork visibility/pinned revision, quotas, idempotency hash, and the exact verified source-object/reference still protected from GC. Sanitize/project again; update the whitelisted current component columns (including visibility/domain uniqueness) and current theme projection if requested; insert the immutable state revision; equality-check both mutable projections against its snapshots; update currentStateRevisionId with WHERE currentStateRevisionId = expected; consume reservations; mark intent COMMITTED with result. Any zero-row CAS rolls back the whole transaction.
  5. If final authorization, domain, theme, fork, or pointer CAS fails, the state transaction rolls back completely. A separate short transaction CASes the still-VERIFIED/COMMITTING intent to CAS_LOST (or its exact terminal code), stores the conflict result, releases unused reservations, and leaves actual physical-version charges for GC; it never reloads a pointer or retries the authored transition. If the process dies in between, the lease-expiry reconciler performs the same cleanup.
  6. Return only the durable committed result. A retry after response loss reads it from the intent.

The application MinIO principal receives only PutObject, exact-version GetObject/stat, and read-only versioning/object-lock configuration probes for component-sources; it receives neither bucket listing nor delete/version-delete/retention-bypass permission. Every source call requires a non-null recorded versionId; latest/unversioned GET is prohibited by API and tests. The new bucket is created versioned and object-lock enabled before first use with the proposed, per-deployment-approved 14-day COMPLIANCE object/deletion retention. Deployment credentials prove configuration and restricted-IAM denials; startup readiness reads the permitted bucket settings and fails closed on drift. Destructive tamper tests use a disposable equivalently configured test bucket and respect retention rather than claiming locked-version deletion.

Crash windows

WindowRequired recovery
Before intent insertNothing exists; client retries the same key.
During spool after intent, before PUTDetected validation/I/O failure terminalizes as above; process death expires by lease/reconciler, releases reservations, and cleans the mapped spool without PUT.
After verified spool, before/during PUTResume only with the unexpired lease; partial/failed PUT is not referenced.
PUT succeeds before version ID is recordedRetry may create a new version. Neither is current; privileged GC later finds the unrecorded old version.
After version recorded, before/during verificationGET the exact version and reverify; never trust PUT acknowledgement alone.
After verification, before PG commitRetry the transaction; verified object is immutable but not current.
PG transaction rolls backTheme, revision, pointer, and committed marker all roll back together; object remains GC-eligible.
PG commit succeeds, response is lostSame key returns the result if current, 409 STATE_SUPERSEDED if a live newer state exists, durable delete success after purge, or 410 STATE_HISTORY_PURGED without ETag for another purged result.
Current object later cannot be read or hash failsFail the read closed, quarantine/alert; never fall back to an older revision or {} automatically.

A separate privileged GC identity may list versions and delete them only after a conservative age delay greater than maximum request/retry duration and object-lock retention. In one PostgreSQL transaction it locks/CASes an AVAILABLE source lifecycle to DELETE_CLAIMED only after finding no state/nonterminal-intent refs; coordinators lock the same row and can reference only AVAILABLE, closing the DB-to-MinIO race. GC then deletes the exact version and marks DELETED; transient deletion failure stays claimed/retryable rather than reopening a race. A component_source_object row describes the candidate rather than protecting it. Inventory-only versions use a separate key/version claim and are protected by every nonterminal intent for that key. If GC crashes after version deletion but before metadata cleanup, confirmed NoSuchVersion is idempotent success and reconciliation completes row/charge transition. GC is auditable, rate-limited, and disabled during rollback incidents; the API never performs synchronous object cleanup.

Reads, rollback, and quarantine

An authorized metadata/HEAD route returns the current state ETag (plus applicable current theme ETag) or pointerless quarantine validator without fetching/disclosing source bytes, so an owner can delete or begin audited repair during a storage integrity incident. Blob-consuming editor/public view/CLI-get/fork readers resolve component.currentStateRevisionId, join its snapshots and exact source-object metadata, GET by version ID, and verify length/hash before parsing. Deterministic DB-link/projection corruption, confirmed exact-version absence, invalid JSON, or repeatable digest mismatch returns a typed integrity 503, alerts, and may quarantine under the confirmation rules above. Transient storage/network/auth failure returns availability 503 without quarantine. Authorization and visibility from the same current projection/state run before byte disclosure. List/search/gallery/domain endpoints may use the equality-checked current column projection without rehashing every card.

Rollback is a new forward revision, never pointer rewinding or history mutation. It uses PUT /state with normal aggregate and (when theme changes) theme preconditions. The target must belong to the same live component, be nonquarantined, and have a readable verified object. It restores target source, theme, and authored metadata and clears any current/target registry-artifact association (name, description, active/preview file, language, page settings, dependencies, frameworks, setup) under current validation. Current visibility, isShared, and publishingDomain are retained unless the request explicitly supplies new values that pass current authorization/domain uniqueness; they are never blindly copied from history. The resulting current columns and newly recorded snapshot must be equal. One request materializes one target only; it cannot merge revisions, restore ownership, bypass current limits, or resurrect deleted/retention-expired data. If the target theme differs, update/create/delete the projection with normal theme-version semantics; if equal, retain it.

Errors

Status/codeCondition
400 INVALID_STATE_REQUESTMalformed multipart/JSON/source map, unknown metadata, invalid or inapplicable validator syntax.
400 INVALID_IDEMPOTENCY_KEYPresent key is not one canonical UUID or violates its operation scope.
428 IDEMPOTENCY_KEY_REQUIREDRequired idempotency key is absent.
409 INTENT_TERMINALSame-key intent expired, was orphaned, or otherwise terminated; return its bounded terminal code and require a new key for a new attempt.
503 INTENT_STORAGE_FAILEDSame-key attempt terminated on spool/MinIO/internal availability failure; no authored state committed.
502 STAGED_SOURCE_INTEGRITY_FAILUREExact staged version did not match declared/server-computed bytes or SHA; it is never committed or used to quarantine the existing component.
401/403Authentication/ownership failure; FORK_NOT_ALLOWED for nonwhitelisted visibility.
404 COMPONENT_NOT_FOUNDAuthorized lookup has no component/target; preserve existing nondisclosure behavior.
409 IDEMPOTENCY_KEY_REUSEDSame key with a different canonical request hash.
409 THEME_VERSION_CONFLICTExact theme precondition is well formed but stale.
409 STATE_CONFLICTStrong component-state validator is stale or final pointer CAS loses. Return current ETag only when disclosure is authorized.
409 STATE_SUPERSEDEDAn idempotent retry refers to a revision that committed successfully but is no longer current.
410 STATE_HISTORY_PURGEDA non-delete committed result remains in its idempotency window but its component/history has been retention-purged; no current ETag exists.
413 SOURCE_QUOTA_EXCEEDEDExact source/history/upload byte limit exceeded.
400 THEME_TOO_LARGETheme exceeds its existing 1 MiB validation boundary; theme adapters preserve current 400 semantics.
428 STATE_PRECONDITION_REQUIRED / THEME_PRECONDITION_REQUIREDRequired validator absent.
410 LEGACY_STATE_WRITE_DISABLEDLegacy ID-based mutation after activation.
426 CLI_UPGRADE_REQUIREDCLI client lacks the activated aggregate precondition/idempotency contract.
422 ROLLBACK_NOT_MATERIALIZABLEHistoric target fails current validation/quota or is quarantined.
503 SOURCE_STORAGE_UNAVAILABLETransient MinIO/network/auth failure; alert but do not quarantine from one outage.
503 SOURCE_INTEGRITY_FAILUREConfirmed exact-version absence or deterministic byte/hash/JSON/projection invariant failure; never return partial state and apply quarantine policy.

Migration, rollout, and rollback

Use role separation -> expand -> write-pause backfill -> enforce -> contract:

  1. Separate database authority: the long-running API must demonstrably use a non-owner, non-superuser, membership-free runtime role; a one-shot migrator assumes a separate non-login object owner; bootstrap/break-glass credentials never enter the API container/process. Revoke public schema creation, version and verify the legacy runtime DML allowlist, and keep new authoritative tables ungranted. This is a prerequisite before creating future authority targets in production, not an activation-only cleanup.
  2. Expand: use nullable/no-default component columns (no table rewrite), new empty tables, CREATE INDEX CONCURRENTLY in nontransactional migrations, and FKs/checks NOT VALID followed by separately monitored validation under explicit lock/statement timeouts. Add intents, read verification, metrics, quarantine, bucket/IAM, and shadow code behind flags. MinIO I/O/backfill never runs inside TypeORM SQL migration. Down/contract refuses while any live pointer, revision, or intent exists and never deletes object versions. Do not claim atomicity. Gate: real-environment tests pass and bucket versioning/object lock/IAM probes are green.
  3. Shadow/dual write: legacy remains authoritative. Candidate hashes/snapshots/intents go only to explicitly nonauthoritative shadow tables/object prefixes that cannot satisfy current-pointer FKs. Continuously compare source hashes, exact bytes, sanitized themes, and metadata snapshots, then archive/purge shadow candidates before authoritative generation-1 backfill. Gate: no unexplained mismatch, intent backlog bounded, GC dry-run clean, and authoritative revision tables still empty.
  4. Write-pause backfill: pause all component/theme/source/fork/share/domain/visibility mutations (including CLI/editor jobs), drain in-flight requests, and snapshot each component's exact current object, metadata projection, and optional theme. Verify GET+SHA and sanitizer. Legitimate absence of a theme maps to themeSnapshot=null; only dangling, duplicate, malformed, or projection-inconsistent expected theme rows are invalid. Invalid JSON, confirmed missing objects, oversized state, or mismatches go to explicit quarantine; never invent {} or silently drop fields. Backfill parent null, generation 1, then set the pointer in one DB transaction per component. Gate: every nonquarantined live component has one verified revision and projection equality; quarantined items cannot publish/fork.
  5. Enforce: make pointer/revision fields and version IDs non-null where applicable; enable new reads, then new writes; reject all legacy ID updates. Keep the write pause until read-after-write probes pass. Gate: CAS, integrity, latency, alerting, backup/restore, and operator runbooks meet SLOs.
  6. Contract: after a full rollback window with no legacy readers, remove old source-key assumptions and legacy mutation code. Do not remove the current themes projection.

Application rollback before enforce can disable shadow writes. After new revisions become authoritative, ordinary rollback is allowed only to a pointer-aware compatibility release that understands exact version IDs; rollback to 45e76c2-era mutable-root code is blocked. Never point old code at component-sources. A future reverse cutover would be a separately reviewed, restartable data migration with its own ledger, write pause, projection/source/theme materialization ordering, visibility gating, process-kill tests, and complete reconciliation before old code starts; this design does not claim that procedure is implemented or safe. Bucket versioning/object lock and immutable DB history are never destructively rolled back.

Required tests and stage gates

Unit mocks are insufficient. CI/release qualification must run against real PostgreSQL 16 and the pinned MinIO release with versioning and object lock:

  • parallel source-only, theme-only, joint, rollback, and metadata writers prove one pointer CAS winner and no orphan DB projection;
  • exact theme-vN, malformed/weak/multi ETags, component-state validators, theme create/delete, and equal-payload update behavior, including pointerless-quarantine standard If-Match plus Theme-If-Match;
  • idempotency across every crash window, including process kill after PUT and after PG commit, delete-success replay on day 15 after purge, and non-delete STATE_HISTORY_PURGED;
  • delayed ordinary 2xx after another committed writer is an honest old commit result whose returned ETag loses the next CAS; only idempotent reconciliation claims currentness;
  • exact UTF-8/multibyte boundary quotas, false Content-Length, fork destination quota, bigint limits, and canonical request hashes;
  • MinIO overwrite/version tests prove reads use versionId, then tamper/delete simulations prove fail-closed behavior and quarantine;
  • IAM tests prove the app cannot list, delete, delete versions, bypass retention, or read other buckets; privileged age-delayed GC proves referenced/nonterminal versions survive;
  • backfill fixtures for valid, missing, malformed, oversized, legacy-derived theme fields, projection mismatch, and quarantine/resume;
  • fork matrix explicitly covers PUBLIC, FREE, EXTERNAL allowed and DRAFT, PRIVATE, deleted/quarantined denied;
  • public/editor/registry/CLI/publication and landing/gallery regression suites prove their existing authorization and visibility boundaries are unchanged;
  • component-column/snapshot equality and publishing-domain uniqueness under competing aggregate CAS;
  • theme adapter dual request/response headers, delete/recreate ABA, and exact existing 400/404/409/428 behavior;
  • share/visibility/domain/generic-update/delete/account-delete/CLI/legacy writer inventory plus database grants prove no protected write bypass;
  • registry latest/digest exposure after public-to-private, browser source edit, rollback, tombstone, and historically private artifacts, including latest-route private-artifact→public transitions;
  • lease expiry versus late PUT/GC, two-version physical accounting, spool restart reconciliation, GC lifecycle claim racing fork/rollback/finalization, and GC crash after version deletion before metadata cleanup;
  • transient MinIO outage never quarantines, while confirmed missing version/hash/projection corruption does and audited repair clears it;
  • deferred same-component pointer/parent/generation constraints, active/quarantined pointer checks, tombstone/purge FKs, and shadow/backfill generation-1 separation;
  • upgraded CLI state preconditions/idempotency and explicit old-client rejection; and
  • backup/restore and supported application rollback drills preserve exact object versions and identify nonmaterializable state.

Production activation additionally requires zero unresolved backfill mismatches, zero unowned live pointers, no old writers observed for a full release window, successful canary read/write/fork/rollback probes, dashboards and alerts for integrity/intents/quarantine/GC, reviewed restore and incident runbooks, audited plan-unit mapping, explicit approval of the new history/intent ceilings and 14-day deletion/object-lock disclosure, and operator sign-off.

Exclusions

This design does not redesign the editor UI, landing/gallery pages, themes' authored schema/compiler, public image/OG storage, custom-domain authorization, sharing authorization, voting, credit charging, registry review policy or immutable CLI component_revision, a user-facing history browser or general restore product, or CDN delivery. Those systems may consume or transactionally record the new current aggregate through adapters, but their authorization and publication decisions remain separate and must not be weakened by aggregate metadata.