Skip to content

Latest commit

 

History

History
684 lines (508 loc) · 67.8 KB

File metadata and controls

684 lines (508 loc) · 67.8 KB

Glaze — Domain Logic

Branding

PotterDoc is the external product name. glaze remains the internal repository/project name for code identifiers, paths, and domain documentation during the rebrand unless a task explicitly calls for renaming internals too.

Language

Use American English spelling throughout — in code, comments, documentation, and UI strings. For example: "behavior" not "behaviour", "initialize" not "initialise", "labeled" not "labelled", "analyze" not "analyse".

Project Overview

Glaze is a pottery workflow tracking application. Users log each pottery piece and record state transitions as the piece moves through the production lifecycle — from throwing or handbuilding through firing, glazing, and finishing. The history of state transitions is the primary data product; it can be analyzed per-piece or in aggregate.

The app has two parts:

  • Backend (/backend/, /api/): Django + Django REST Framework, serves JSON to the web
  • Web (/web/): React 19 + TypeScript + Vite + Material UI
  • Remote ML (/services/piece_image_segment_service.py): Optional serverless offload via Modal.com

Within /api/, helper modules that expose reusable logic are treated as public contracts: their functions should be documented, tested directly, and traced so refactors keep the behavior observable.


Remote ML Offloading

To maintain stability on hardware with <1GB RAM, Glaze uses a remote offloading strategy for heavy ML tasks (specifically rembg background removal).

  • Optimized Dispatch: The backend offloads the image's R2 CDN URL to the remote service. The Modal worker performs the download, completely bypassing local ML overhead and minimizing host-side bandwidth/memory usage.
  • Local Path: Defaults to u2netp (memory-efficient) with pre-processing downscaling (640px max) as a fallback if no remote URL is configured.
  • Security: The microservice validates an X-API-Key header against a modal.Secret.
  • Microservice: Located at services/piece_image_segment_service.py, designed for deployment to Modal.com.

Workflow State Machine

The source of truth for piece states is workflow.yml at the project root. Do not hardcode state names or transitions anywhere — always derive them from this file.

Schema and validation: workflow.schema.yml is a JSON Schema (Draft 2020-12) document (written in YAML) that defines the allowed structure of workflow.yml. It constrains:

  • Top-level required fields: version (semver string) and states (array, at least 2 items). globals is optional.
  • Per-state required fields: id (snake*case, ^[a-z]a-z0-9*]\*$) and visible (boolean).
  • Optional per-state fields: terminal (boolean), successors (array of snake_case strings, no duplicates within a state), custom_fields (map of field DSL entries).
  • additionalProperties: false at both the top level and per-state — unknown keys are rejected.

tests/test_workflow.py is the common test suite that validates workflow.yml in full — both structurally and semantically:

  • Structural (TestSchemaValidation): runs jsonschema.validate against workflow.schema.yml, and verifies that malformed inputs (missing fields, bad version format, invalid ID patterns, duplicate successors, unknown keys) are correctly rejected.
  • Semantic (TestReferentialIntegrity): enforces rules JSON Schema cannot express — every successor ID references a real state, terminal states have no successors, non-terminal states have at least one successor, all state IDs are unique, no state lists itself as a successor.
  • DSL referential integrity (TestCustomFieldsDSL): enforces custom_fields rules — enum only on type: string fields; state refs point to known states with declared fields that are reachable ancestors; global refs point to declared globals with declared fields.
  • Global/model alignment (TestGlobals): verifies every globals entry maps to a real Django model in api/models.py, every field declared in that global exists on the model, and every global with public: true has a nullable user field on its model.

globals section: The optional top-level globals map registers named domain types backed by Django models. Each entry declares the model class name (PascalCase, verified against api/models.py by tests) and a subset of its fields exposed to the field DSL. api/models.py remains the authoritative source of truth — globals is a DSL-level view of those models, kept in sync by tests.

What belongs in workflow.yml vs. what does not: workflow.yml is for domain structure and business rules that both backend and frontend must agree on: state IDs, required state friendly_name labels, required state descriptions, transitions, field existence, requiredness, persistence shape, and domain constraints that affect validation or query behavior. It is not a home for presentation defaults, styling choices, or convenience UI metadata.

  • Belongs in workflow.yml: lifecycle states, required friendly_name labels and descriptions for those states, successor relationships, whether a field/global exists at all, whether it is required, whether a global is public/private/favoritable/taggable, and true domain constraints where the allowed values are part of the business model.
  • Does not belong in workflow.yml: default colors, color palettes, icon choices, display order chosen only for UX, component layout, and other presentation-layer defaults that the backend does not need in order to validate or persist the data.
  • Rule of thumb: if changing the value should require a migration, backend validation change, API contract change, or data cleanup plan, it may belong in workflow.yml. If changing it should only affect how the UI looks or which default the user sees first, it belongs in frontend code instead.
  • Example: a Tag having a persisted color field can be valid domain data if users explicitly choose and save a color. But a built-in palette of suggested colors, or a default initial color shown in the create form, is presentation logic and should live in the web layer, not in workflow.yml.
  • Capability pattern: use workflow.yml to opt models into generic capabilities such as favoritable: true or taggable: true, not to encode one-off wiring details that generated backend/frontend code can infer.

Each global definition also carries several optional flags:

  • public (default false): when true, this global type has an admin-managed shared library of public objects (stored with user=NULL) visible to all authenticated users. The corresponding Django model's user field must be nullable.
  • private (default true): when true, users can create their own private instances of this type.
  • factory (default true): when false, the Django model is hand-written and _register_globals() skips auto-generation for this global. Use only when bespoke model logic is required (currently only piece).
  • favoritable (default false): when true, a FavoriteModel subclass is auto-generated for this global and the favorites API endpoints are enabled.
  • taggable (default false): when true, instances of this global can be tagged using the shared Tag global. The developer interface should mirror favorites: a single taggable: true flag in workflow.yml opts the type into generated join-model support (for example pieceTagEntry) instead of bespoke per-type tagging code.

Currently clay_body and glaze_type have public: true; location and glaze_method are private-only. Models for public globals (ClayBody, GlazeType) allow user=NULL; public and private objects each have their own DB-level UniqueConstraint (conditional on user IS NULL / user IS NOT NULL). A private entry may share its name with a public entry — the two scopes are independent. Three helpers in api/workflow.py expose this information to the rest of the backend without leaking the private _GLOBALS_MAP:

  • is_public_global(name) -> bool — returns True if the named global has public: true
  • get_public_global_models() -> list[type[Model]] — returns the Django model class for every public: true global; used by admin for dynamic registration
  • get_image_fields_for_global_model(model_cls) -> list[str] — returns field names declared as type: image for the given model class; used by admin to apply the R2 upload widget (R2ImageWidget)

TestGlobals verifies — in addition to model/field alignment — that every public: true global has a nullable user field on its Django model.

custom_fields DSL: Each state may declare state-specific fields beyond the base PieceState fields using two forms:

Inline field — declares a new field directly on the state:

clay_weight_grams:
  type: number # string | number | integer | boolean | array | object | image
  description: "..." # optional
  required: true # optional, default false
  enum: [a, b, c] # optional; only valid when type: string
  format: hex_color # optional; only valid when type: string — enforces a CSS hex color pattern (#RGB / #RRGGBB / #RGBA / #RRGGBBAA)
  display_as: percent # optional; multiplies by 100 and adds % suffix in UI

The image type is a DSL-level annotation: _resolve_field_def resolves it to a JSON Schema object with a required url property, so validation enforces the {"url": "..."} structure. The Django admin renders an R2 upload widget (R2ImageWidget) instead of a plain text input; the canonical stored value is the public CDN URL, and the server derives the R2 object key (Image.r2_key) from the URL — URLs outside the configured R2 public domain get r2_key=None. Use image for any field that holds an R2-hosted image.

Ref field — two sub-forms, distinguished by the @ prefix:

# State ref — carries a field forward from a reachable ancestor state:
pre_trim_weight_grams:
  $ref: "wheel_thrown.clay_weight_grams"
  description: "..." # optional override
  required: false # optional override
  display_as: percent # optional override

# Calculated field — read-only computed value:
volume_shrinkage:
  label: Volume Shrinkage
  decimals: 1
  display_as: percent
  compute:
    op: difference
    args:
      - constant: 1
      - op: ratio
        args:
          - { field: glaze_fired.length_in, return_type: number }
          - { field: submitted_to_bisque_fire.length_in, return_type: number }

- Evaluated read-only on the backend (traverses history to find latest ancestor state).
- Recursive, strictly typed AST supporting `sum`, `product`, `difference` (2 args), `ratio` (2 args).
- Operands: `field` references (e.g. `{ field: state_id.field_name, return_type: number }`) or `constant` numbers/strings/booleans.
- Schema groups nodes by guaranteed return type (`numeric_node`, `string_node`).
- Excluded from write validation schemas.

# Global ref — foreign-key reference to a field on a globals entry
# (backed by a Django model). @ marks this as a global ref:
kiln_location:
  $ref: "@location.name"
  description: "..." # optional override
  required: true # optional override
  can_create: true # optional; default false — allows inline creation of a new global instance

Referential rules enforced by TestCustomFieldsDSL:

  • State refs (state_id.field_name): state must exist, field must be declared on it, state must be a reachable ancestor (path through the successor graph from that state to this one).
  • Global refs (@global_name.field_name): global must be declared in globals, field must be declared in that global's fields.
  • format only on strings: if a field declares format, its type must be string — enforced by TestCustomFieldsDSL.test_format_only_on_string_type.
  • format: hex_color: _resolve_field_def emits a JSON Schema pattern constraint so the backend rejects any value that is not a valid CSS hex color code; _global_entries_impl also validates this in the globals POST path and returns HTTP 400 with a human-readable error.

Process Summary DSL: workflow.yml may declare a top-level process_summary section that defines how a piece's history is summarized across different production paths. Summary items are display metadata for existing workflow data, so they belong in workflow.yml when the selected information is part of the domain contract for summarizing a piece.

process_summary:
  sections:
    - title: Making
      fields:
        - label: Starting weight
          value: wheel_thrown.clay_weight_lbs
          when:
            state_exists: wheel_thrown
        - label: Trimming loss
          compute:
            op: difference # product | difference | sum | ratio
            left: wheel_thrown.clay_weight_lbs
            right: trimmed.trimmed_weight_lbs
            unit: lb
            decimals: 2
          when:
            state_exists: trimmed
        - label: Wax resist
          text: Not recorded
          when:
            state_missing: waxed

Summary items support exactly one of:

  • value: state_id.field_name — display a field from a reachable ancestor state.
  • compute — display a numeric result using product, difference, sum, or ratio.
  • text — display static workflow-authored text.

Optional when clauses currently support state_exists and state_missing. This is intentionally conditional visibility, not a general expression language: use separate summary items for path-specific display instead of ternary-style logic. Summary computations are display-only; they are not persisted. Rendered by the ProcessSummary.tsx component.

States (in rough lifecycle order):

Each state in workflow.yml must declare a friendly_name and a description. Clients use the authored label directly and do not derive a fallback from the snake_case state ID.

State Friendly name Description
designed Designing Piece conceived/designed — universal entry point
wheel_thrown Throwing Piece created on the wheel
handbuilt Handbuilding Piece hand-sculpted
trimmed Trimming Wheel-thrown piece trimmed
slip_applied Adding Slip Decorative slip added
carved Carving Surface carved or decorated
submitted_to_bisque_fire Queued → Bisque Ready for initial firing
bisque_fired Planning → Glaze Initial bisque fire complete
waxed Waxing Wax resist applied before glazing
glazed Glazing Glaze applied
submitted_to_glaze_fire Queued → Glaze Ready for glaze firing
glaze_fired Touching Up Glaze fire complete
sanded Sanding Final sanding/finishing
completed Completed Terminal — finished piece
recycled Recycled Terminal — piece discarded or clay reclaimed

Rules:

  • designed is the single entry point for all new pieces — POST /api/pieces/ always creates a piece in the designed state.
  • Every non-terminal state has recycled as a valid successor — a piece can be recycled at any point.
  • completed and recycled are terminal states ("terminal": true) — no transitions out.
  • During initial development, all states have "visible": true and should be shown in the UI. As additional features are added, some states may become hidden and only available for analysis purposes, but are not shown in the UI by default.
  • Valid transitions are defined per-state in workflow.yml; validate against them on both the web and backend.

Data Model

These types are defined in web/src/util/types.ts and mirror what the backend API should produce.

PieceSummary — used in list views

{
  id: string;
  name: string;
  created: Date;
  last_modified: Date;
  thumbnail: string;
  current_state: State;   // just the state name
  current_location?: Location; // reference to the Location global object.
}

PieceState — a single recorded workflow step

{
  state: State;
  notes: string;
  created: Date;
  last_modified: Date;
  images: [CaptionedImage];
  previous_state?: State;
  next_state?: State;
}

PieceDetail — used in detail views; extends PieceSummary

PieceSummary & {
  current_state: PieceState;  // full state object, not just name
  history: [PieceState];
}

CaptionedImage

{
  url: string;                  // public CDN URL of the original
  caption: string;
  created: Date;
  crop?: ImageCrop | null;      // relative {x, y, width, height} coordinates
  cropped_url?: string | null;  // CDN URL of the eager cropped derivative; null until materialized
  image_id?: string | null;
  width?: number | null;        // original pixel dimensions, when known
  height?: number | null;
}

Image metadata contract: url always holds the public delivery URL. For R2-hosted assets the backend derives and stores the object key on the Image row (r2_key); foreign URLs (curated local SVG thumbnails in web/public/thumbnails/, legacy external assets) get r2_key=None and still render — they just cannot participate in the eager crop pipeline. cropped_url is written exclusively by the backend generate_cropped_image task and is never accepted from clients; renderers display cropped_url ?? url.


Backend Conventions (Glaze-specific)

These supplement the generic Django/DRF conventions.

Project layout:

  • backend/ — Django project settings, root URL config (backend/urls.py), WSGI/ASGI
  • api/ — the single Django app; models, views, serializers, and tests all live here
  • manage.py — Django management entrypoint

All API endpoints are registered in backend/urls.py.

Readiness endpoint: GET /api/health/ready/ is the single anonymous probe consumed by infrastructure (the web healthcheck in docker-compose.yml, future nginx upstream gating, rolling-deploy automation). It returns 200 {"status": "ready", "checks": {...}} when every check passes or 503 {"status": "not_ready", "checks": {...}} when any fails; checks values are booleans only — no exception detail leaks. The check registry lives in api/views.py as a thin compatibility wrapper around the feature modules (currently api/health_views.py); add a new probe by defining _check_<name>() -> bool in the health module and appending <name> to the tuple exported there. There is no liveness endpoint — defer until k8s or another orchestrator that distinguishes liveness from readiness is in play.

Compose bootstrap contract: The production Compose stack uses a one-shot deploy_init service to run migrations, refresh the public library, and clear stuck tasks before the app starts serving traffic. web and worker both declare depends_on: deploy_init: service_completed_successfully, so deploy_init must exit cleanly and must not be converted back into a long-lived server process. The CI smoke test uses the same contract: docker compose up -d --wait --wait-timeout 300 succeeds only after deploy_init has completed and the web readiness probe reports healthy. The smoke test is not a worker-health test; worker startup is implied by deploy_init completion.

ASGI server: Production runs gunicorn with uvicorn.workers.UvicornWorker pointed at backend.asgi:application (see docker-entrypoint.sh). Django runs all sync views transparently in a thread pool — no per-view changes are required. Write a view as async def only when it performs long-running or streaming I/O that would otherwise block the worker heartbeat (e.g. the account data export endpoint). Wrap any sync ORM or SDK calls inside an async view with asyncio.to_thread(...). Use httpx.AsyncClient for outbound HTTP inside async views instead of urllib.request.urlopen.

Module boundaries — what goes in api/workflow.py vs. api/utils.py:

  • api/workflow.py is reserved strictly for helpers that read from the workflow state machine (workflow.yml) — state lookups, successor queries, globals-map queries, field-definition resolution, and JSON Schema generation. Do not add domain helpers unrelated to the state machine here, even if both admin.py and another module need them.
  • api/utils.py holds shared business-logic helpers that span multiple api modules but have nothing to do with the workflow state machine (e.g. sync_glaze_type_singleton_combination). When a new helper is needed by more than one api module and it is not a workflow-state-machine concept, put it in api/utils.py.

Production environment variablessettings.py gates dev/prod behavior on IS_PRODUCTION = bool(os.environ.get('PRODUCTION', '')). The full env var reference:

Setting Env var Dev behavior Prod behavior
SECRET_KEY SECRET_KEY Falls back to an insecure hardcoded default Required — raises KeyError if absent
DEBUG (derived from PRODUCTION) True False
DATABASES DATABASE_URL SQLite at db.sqlite3 Postgres via dj_database_url.config()
ALLOWED_HOSTS ALLOWED_HOST localhost, 127.0.0.1 Appends the single production hostname (e.g. myapp.example.com) and, in production, scopes the auth/CSRF cookies to the parent domain so admin.<host> shares the same session; this assumes admin.<host> is the sibling admin hostname for the apex site and should be revisited if that hostname split changes
CORS_ALLOWED_ORIGINS / CSRF_TRUSTED_ORIGINS APP_ORIGIN Localhost origins only Appends the full origin URL (e.g. https://myapp.example.com)
GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_ID Empty string — Google sign-in disabled Set to enable Google OAuth JWT verification
R2 object storage R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / R2_BUCKET_NAME / R2_PUBLIC_URL Empty — /api/uploads/r2/presigned-url/ returns 503 All five required together; read from os.environ at call time (api/r2.py), no settings.py entries. Also gates showcase video generation (is_showcase_video_storage_enabled())

Image FK normalization (ImageForeignKey / ImageForwardDescriptor): type: image fields on global models are stored as FKs to the api.Image table, not as raw JSON. ImageForeignKey (defined in api/model_factories.py) swaps in ImageForwardDescriptor as its accessor, which intercepts every assignment and calls normalize_image_payload when the incoming value is a str or dict:

  • String value (plain URL): creates or retrieves an Image row keyed by URL. The R2 object key (r2_key) is derived server-side from the URL — never trusted from the client; URLs outside the configured R2 public domain get r2_key=None.
  • Dict value {"url": ..., "width"?: ..., "height"?: ...}: same URL-keyed dedup, additionally recording pixel dimensions when provided. This is the format produced by the upload flow.

Fixture format: fixtures/public_library.json stores type: image fields as {"url": ...} dicts; ImageForwardDescriptor normalizes them into Image rows on loaddata, deriving r2_key from the URL.

Model factory pattern (api/model_factories.py → re-exported from api/models.py): global domain models are generated at import time from workflow.yml declarations — no hand-written model class is needed for new globals. Three factories handle every case:

  • make_simple_global_model(global_name) — generates a GlobalModel subclass for any non-compose_from global. Fields, the user FK, and UniqueConstraints are derived entirely from the workflow.yml declaration. Only a makemigrations run is required to add a new simple global.
  • make_compose_global_models(global_name) — generates a (CompositeModel, ThroughModel) pair for a compose_from global. The composite receives an ordered M2M field, a stored computed name, inline and FK DSL fields, compute_name(), get_or_create_with_components(), get_or_create_from_ordered_pks(), filterable_fields, and a post_fixture_load hook — all derived from the DSL. Only a makemigrations run is required.
  • make_favorite_model(global_name) — generates a FavoriteModel subclass for any favoritable: true global. Only a makemigrations run is required.

api/models.py calls _register_globals() at import time, which iterates workflow.yml globals and injects the generated classes into the module namespace so they are importable as api.models.Location, api.models.GlazeCombination, etc. and Django migrations treat them identically to hand-written classes.

factory: false opts a global out of auto-generation; use it for globals whose Django model is hand-written (currently only piece).

GlobalModel abstract base class (api/model_factories.py): all global domain models inherit from it.

  • Enforces user immutability: the user FK cannot change after creation (prevents silent breakage of public/private reference invariants).
  • Declares the name field convention: every concrete subclass must have a name CharField (or a stored computed equivalent). For compose_from globals the name is a stored computed string (component names joined by COMPOSITE_NAME_SEPARATOR (!)). Simple-global name validation rejects the separator to keep component names embeddable.
  • Maintains GlobalModel._registry — a list of every registered concrete subclass — for use in parameterised tests.

Globals visibility tiers:

  • Private-only (Location, GlazeMethod): owned by a single user; the user FK is NOT NULL; list endpoints filter to request.user only.
  • Public + private (ClayBody, GlazeType, GlazeCombination): support an admin-managed shared library (records with user=NULL) as well as user-private records. List endpoints return both the requesting user's private objects and all public objects. POST always creates a new private record (or returns the existing one for the requesting user). The GET response includes an is_public boolean on each item so the frontend can disambiguate.

Name uniqueness for public globals is enforced with two conditional DB constraints (one for private, one for public). Private and public scopes are independent — a user may have a private entry with the same name as a public entry.

Django admin (api/admin.py):

  • GlazeAdminSite — subclass of admin.AdminSite that overrides get_app_list to move public library models out of the "Api" section into a separate "Public Libraries" section. Applied via admin.site.__class__ = GlazeAdminSite.
  • PublicLibraryAdmin — base ModelAdmin for globals with public: true. Filters to public objects only (user__isnull=True); forces obj.user = None on save; rejects names that collide with existing private objects.
  • R2ImageWidgetTextInput subclass rendering a URL text input, thumbnail preview, and "Upload Image" button when R2 is configured (URL-paste still works when it is not). The canonical field value is a bare URL string; normalize_image_payload derives the R2 object key server-side. The Media class loads api/static/admin/js/r2_image_widget.js.
  • api/static/admin/js/r2_image_widget.js — wires upload buttons to the presigned-upload flow: requests a presigned PUT URL from /api/uploads/r2/presigned-url/ (session + CSRF), uploads the file straight to R2, and writes the resulting public URL into the input.
  • Dynamic registrationPublicLibraryAdmin is registered for every model returned by get_public_global_models(). Adding public: true to a new global in workflow.yml is sufficient.

GraphQL is the authoritative write layer: POST /graphql/ (Strawberry schema at api/graphql/schema.py, mutations in api/graphql/mutations.py) is where piece/state/image/global mutation logic actually lives — create_piece, update_piece, transition_piece, update_current_state, update_past_state, delete_past_state, upload_image, crop_image, move_image, create_global, add_favorite/remove_favorite, etc. The REST endpoints listed below are generated compatibility wrappers: api/graphql/rest_bridge.py defines each one as a RestRoute (HTTP method, GraphQL operation string, request→variables mapping, response reshaping) and make_rest_view(route) turns it into a DRF view that calls schema.execute_sync() and maps GraphQL errors to HTTP status codes. When adding a new mutation, write it in mutations.py first and add a RestRoute in rest_bridge.py only if a REST-shaped URL is still needed (e.g. for the OpenAPI-generated frontend types or the MCP/agent REST surface) — do not add mutation logic directly to a DRF view. Session-authenticated GraphQL mutations enforce CSRF manually in api/graphql/views.py; Bearer-token (agent) requests are CSRF-exempt since the token is the credential. GraphiQL is served at /graphql/ only when DEBUG=True.

API endpoints:

  • GET /api/auth/csrf/ → set CSRF cookie
  • POST /api/auth/login/ → session login via email + password
  • POST /api/auth/logout/ → clear current session
  • GET /api/auth/me/ → current authenticated user
  • POST /api/auth/register/ → register + login (backend remains available)
  • POST /api/auth/google/ → Google OAuth 2.0 login via JWT credential
  • GET /api/pieces/ → list of PieceSummary
  • GET /api/pieces/<id>/PieceDetail
  • GET /api/pieces/<id>/current_state/ → the current editable PieceState only
  • POST /api/pieces/ → create a new piece (always starts in designed state; accepts name, optional thumbnail, and optional notes)
  • POST /api/pieces/<id>/states/ → record a new state transition
  • PATCH /api/pieces/<id>/ → update piece-level editable fields (currently location)
  • PATCH /api/pieces/<id>/state/ → update current state's editable fields
  • GET /api/pieces/<id>/showcase-video/ → latest showcase video task status, stale detection, and artifact metadata
  • POST /api/pieces/<id>/showcase-video/ → enqueue a deterministic Keepsake slideshow render for a terminal piece
  • Showcase video artifacts are served directly from the R2 CDN (uploaded to videos/showcase/{input_hash}.mp4) once the render task succeeds; there is no local fallback artifact route.

Piece vs. current-state access pattern:

  • Use GET /api/pieces/<id>/ when the screen needs the whole piece aggregate: piece metadata, current state, and history together.
  • Use GET /api/pieces/<id>/current_state/ when a child workflow editor owns only the editable current-state slice and does not need to refetch the parent PieceDetail payload or full history.
  • Do not introduce GET /api/piece-states/<state_id>/ as a primary client access pattern. Past states are sealed history; the API surface should stay centered on the piece aggregate plus its single current editable state.
  • GET /api/globals/<global_name>/ → for private-only globals returns only the user's private objects; for public globals returns the user's private objects union all public objects (user=NULL), sorted by display field. Each item includes is_public: bool. global_entries is the canonical list endpoint for all global types — do not add a separate /api/<global-name>/ list route.
    • Models may opt in to richer GET responses by declaring a filter_queryset(qs, request) classmethod (for query-param filtering) and registering a serializer in _GLOBAL_ENTRY_SERIALIZERS in api/global_entries/logic.py (re-exported by api/views.py for compatibility). GlazeCombination uses both: it supports ?glaze_type_ids=, ?is_food_safe=, ?runs=, ?highlights_grooves=, ?is_different_on_white_and_brown_clay= query params and returns additional fields (test_tile_image, filter booleans, glaze_types list, is_favorite).
  • POST /api/globals/<global_name>/ → get-or-create a private record owned by the requesting user. For public globals, a private entry with the same name as a public entry is permitted — the two scopes are independent.
  • POST /api/globals/<global_name>/<pk>/favorite/ → add the entry to the requesting user's favorites (currently only glaze_combination supports favorites; other types return 405).
  • DELETE /api/globals/<global_name>/<pk>/favorite/ → remove the entry from the requesting user's favorites.
  • POST /api/uploads/r2/presigned-url/ → accepts {content_type, resource_type?} (resource_type defaults to image; video/audio are staff-only), returns {upload_url, key, public_url, expires_in}; the object key is fully server-generated (images/{user.id}/{uuid}.{ext}); 503 if R2 is not configured
  • GET /api/auth/agent-tokens/, POST /api/auth/agent-tokens/, DELETE /api/auth/agent-tokens/<id>/ → manage AgentToken rows (see below); session-authenticated only, AgentTokenAuthentication is explicitly excluded so a token cannot mint or revoke other tokens

Agent tokens (external LLM/MCP clients): AgentToken (api/models.py) is a long-lived per-user API token for non-browser clients (the potterdoc_mcp MCP server, ChatGPT custom actions). The plaintext token (pdagent_<random>) is shown exactly once at creation; only its SHA-256 hash is stored. AgentTokenAuthentication (api/auth/agent_auth.py) reads Authorization: Bearer pdagent_<token>, looks up the hash, and updates last_used_at; it grants standard user permissions only (no staff/admin actions). The frontend manages tokens through DeveloperTokensDialog.tsx (Settings → API Tokens). api/llm_schema.py generates a trimmed OpenAPI schema (bearer auth, piece and tag globals only, multipart/crop endpoints excluded) for LLM tool-calling clients that can't handle the full schema.

Google OAuth backend:

  • Verifies JWT with Google's servers using google-auth library (GOOGLE_OAUTH_CLIENT_ID env var).
  • Looks up existing user by UserProfile.openid_subject; falls back to email matching for migration from email/password accounts; creates new Google-only account if no match found.
  • Updates user profile (name, picture) from Google on each login and creates a Django session.

Backend testing — Glaze-specific guidance:

  • The piece fixture in api/tests/conftest.py creates a piece via the ORM directly; prefer the API client (client.post(...)) for tests that exercise request/response behavior.
  • Every new API endpoint or serializer change → add or update a test under api/tests/.
  • Every new or modified api/workflow.py helper → add or update a test in api/tests/test_workflow_helpers.py, patching _STATE_MAP / _GLOBALS_MAP via monkeypatch.
  • New global domain models: adding a new entry to workflow.yml and running makemigrations is sufficient — _register_globals() auto-generates and registers the model at import time. The generated class is automatically enrolled in the parameterised test suites in api/tests/test_globals.py. No manual model or test additions are needed for registry invariants — focus new tests on model-specific constraints and API behavior instead. Set factory: false only if the global needs a hand-written model class with bespoke logic.
  • API test targets in api/BUILD.bazel set DJANGO_SETTINGS_MODULE=backend.test_settings through the shared _TEST_ENV. That keeps the default suite on the self-contained test settings module instead of importing backend.settings for every run, which makes Bazel caching more stable and keeps production-only settings branches out of unrelated tests unless a target truly needs them.

Frontend Conventions (Glaze-specific)

These supplement the generic TypeScript/React/Vite conventions.

Module paths: Import types, API helpers, and workflow utilities from the ./util directory (./util/types, ./util/api, ./util/workflow). These live in web/src/util/ and must not take direct dependencies on React.

State names and transitions: Come from workflow.yml via the constants in ./util/types (STATES, SUCCESSORS) — do not hardcode them in components.

HTTP calls: All go through web/src/util/api.ts (imported as ./util/api). This is the single place where wire types (ISO date strings, etc.) are mapped to domain types. Components must never perform their own serialization or deserialization.

Data-fetching pattern (@tanstack/react-query): All data fetching uses TanStack Query v5. Do not inline useState + useEffect + .catch + .finally, and do not use the legacy useAsync hook.

  • Unconditional reads (always fetch on mount): useSuspenseQuery — component suspends while loading, errors go to the nearest <ErrorBoundary>. The parent route must supply a <Suspense> boundary.
  • Conditional reads (fetch only when a flag is true, e.g. a dialog is open): useQuery with enabled. Handle isLoading and error inline.
  • Mutations (create, update, delete): useMutation. Use onSuccess on the mutation definition for unconditional side effects; use the per-call onSuccess option when the callback depends on the call site.
  • Optimistic local updates: queryClient.setQueryData(queryKey, updater) — replaces the old setData from useAsync.
// ✅ unconditional read
const { data: pieces } = useSuspenseQuery<PieceSummary[]>({
  queryKey: ["pieces"],
  queryFn: fetchPieces,
});

// ✅ conditional read (fetch only when dialog is open)
const { data, isLoading, error } = useQuery({
  queryKey: ["tags"],
  queryFn: fetchTags,
  enabled: dialogOpen,
});

// ✅ mutation with optimistic update
const queryClient = useQueryClient();
const { mutate: createPiece } = useMutation({
  mutationFn: (payload) => apiCreatePiece(payload),
  onSuccess: (newPiece) =>
    queryClient.setQueryData(["pieces"], (prev: PieceSummary[]) => [newPiece, ...prev]),
});

// ❌ do not inline this pattern
const [data, setData] = useState(null);
useEffect(() => {
  fetchSomething().then(setData).catch(() => setError("Failed")).finally(() => setLoading(false));
}, []);

QueryClientProvider is already mounted at the App root — no need to add it in components or tests that render through <App />. Tests that render a component in isolation must wrap it: <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>.

Components using useSuspenseQuery need <Suspense> + <ErrorBoundary> in their parent. Components using useQuery render their own loading/error states.

Shared UI extraction: Treat route-level and detail/list container components such as PieceDetail.tsx and PieceList.tsx as orchestration layers, not homes for duplicated presentational subtrees. When a feature introduces the same UI concept in multiple places, extract a reusable component in web/src/components/ rather than keeping separate inline implementations in each parent. If a new feature adds more than a small self-contained JSX block to one of these containers, prefer a named child component with typed props.

Workflow config interface (workflow.ts): web/src/util/workflow.ts loads workflow.yml at build time and exposes typed helpers — do not duplicate state or globals data elsewhere.

  • getCustomFieldDefinitions(stateId) — resolves per-state additional field definitions into a form-ready structure; used by WorkflowState to render dynamic fields.
  • getGlobalDisplayField(globalName) — returns the display field name for a globals entry; used by GlobalEntryDialog to determine which field to write on create.
  • formatWorkflowFieldLabel(fieldName) — converts snake_case DSL names to Title Case UI labels.

Type generation pipeline:

  • web/src/util/generated-types.ts is auto-generated — do not edit by hand. It is gitignored.
  • Generation is driven by web/scripts/generate-types.mjs, which calls the openapi-typescript programmatic API with a transform that converts format: date-time fields to Date. Run gz_gentypes with Django on port 8080.
  • The same web/scripts/ + js_binary pattern also applies to other JS dev tools. Prefer Python for new repo tooling unless the dependency graph or npm-only package availability makes JS the better fit; when you do choose JS, wire it through web/BUILD.bazel and add a vitest_test so the tool keeps coverage.
  • web/src/util/types.ts derives domain types from generated-types.ts with explicit overrides only for nested normalization (for example state/image/crop shapes). It also holds the STATES array and SUCCESSORS map from workflow.yml.
  • Highly important: web/src/util/types.ts is not allowed to decide which fields are present, required, or optional on frontend models. Its job is only to rewire generated types for nested shape normalization and other mechanical compatibility fixes. If a field needs to exist on the frontend contract, add it in the backend serializer so the generated schema exposes it.
  • If a frontend lint or type-check failure points at a missing field on a story or test fixture, treat it as an API-contract mismatch. Update the backend serializer so the schema exposes the field, regenerate types, then update the fixture. Do not relax the domain type to make the failure disappear.
  • When adding a new API field: update the Django serializer → run gz_gentypes → update web/src/util/types.ts if semantic narrowing is needed → update mappers in web/src/util/api.ts.
  • web/src/util/api.ts uses the Wire<T> generic to type raw Axios responses (dates as strings). Mappers convert Wire<T> → domain T. This is the only file that should contain deserialization logic.
  • The OpenAPI schema is at http://localhost:8080/api/schema/ and Swagger UI at http://localhost:8080/api/schema/swagger/.

Thumbnails:

  • Curated SVG thumbnails live in web/public/thumbnails/.
  • All thumbnails share a consistent earth-tone pottery style: fill #c8956c, stroke #7a4f3a, viewBox="0 0 100 100". New thumbnails must follow this convention.
  • DEFAULT_THUMBNAIL (exported from NewPieceDialog.tsx) points to /thumbnails/question-mark.svg.

Existing components:

  • PieceList.tsx — masonry orchestration layer for PieceSummary objects. It prevents the list from flashing an incorrect first frame by waiting for a real width, seeding crop-backed heights before paint, and letting un-cropped cards fall back to the default estimate until Masonic measures them.
  • NewPieceDialog.tsx — dialog for creating a new piece; name, optional notes, thumbnail gallery
  • WorkflowState.tsx — edits the current PieceState: notes, location, additional fields, images (upload or URL), caption editing, lightbox launch
  • GlobalEntryField.tsx — chip + button wrapper that shows the currently selected global entry and opens the GlobalEntryDialog picker on click.
  • GlobalEntryDialog.tsx — full-screen dialog for browsing, searching, and selecting a global entry (e.g. Location, GlazeCombination). Supports inline creation when can_create is set in the DSL field definition, and renders direct-to-R2 image uploads for type: image fields on create.
  • AppImage.tsx — renders an R2/CDN-hosted image as a plain <img> pointing at cropped_url ?? url (no request-time transforms). Context-specific chrome: thumbnail/preview (64×64 box), gallery/detail (fill container), lightbox (fit-content). Exports SuspenseAppImage and ImageSkeleton.
  • ImageLightbox.tsx — full-screen modal image viewer with caption and keyboard/touch navigation
  • StateChip.tsx — shared workflow-state token. Takes variant: 'current' | 'past' | 'future' plus isTerminal and optional interaction hooks so list/detail/timeline UIs stay in one visual family.
  • ProcessSummary.tsx — renders the read-only process_summary section declared at the top level of workflow.yml. Displays sections of fields, promoted values, computed numeric results (product, difference, sum, ratio), and static text with optional when (state_exists / state_missing) visibility. Rendered by PieceDetail and PublicPieceShell.
  • PieceShareControls.tsx — owner-only sharing controls shown on terminal pieces. Renders a toggle to make the piece publicly accessible and a copyable share link; hidden from non-owners and non-terminal pieces.
  • PublicPieceShell.tsx — unauthenticated route that acts as the Showcase View for publicly shared pieces. Displays the piece's curated content (name, thumbnail, story, and selected fields) without exposing the full potter-facing timeline or private notes.

PieceList masonry flow:

  • The list page is a masonry grid, not a static table. The component exists to protect the user from a bad first paint, not just to place cards in rows.
  • A width=0 first commit would seed the cache with chrome-only heights and recreate the overlap bug, so the grid waits for a real container width before rendering.
  • Crop-backed cards are seeded synchronously before the first MasonryScroller render because the old post-mount correction pass is what produced the visible flicker.
  • Cards without crops keep the default itemHeightEstimate because we do not know their exact height until Masonic measures them.
  • AppImage sizing must stay aligned with the shell aspect ratio and the requested masonry width; otherwise the image load can trigger a second layout correction and bring the bug back.
  • If this flow changes, update the README and this section together so future agents understand the failure mode, not just the implementation steps.

Visual design system — state chips and state flow:

  • Treat workflow-state tokens as a dedicated UI language, not as interchangeable tag chips or generic MUI buttons. Tags represent user-authored metadata; state chips represent the pottery workflow itself and should stay visually distinct.
  • Keep state-chip color rules in frontend code, not in workflow.yml. The workflow file defines which states exist and which successors are valid; the web layer owns presentation decisions such as chip color, dot treatment, connector lines, hover fills, and emphasis.
  • The current state in PieceDetail.tsx should read as the anchor of the flow: solid outline, lightly filled background, and a filled status dot on the left. It may be slightly larger than successor chips, but it should still feel related to them.
  • Valid successor states should render as actionable state chips, not CTA-style buttons. They should size to their content, use dotted or dashed outlines plus outlined dots by default, and become visually "promoted" on hover by filling the background, solidifying the outline, and filling the dot.
  • Hovering a valid successor should also temporarily de-emphasize the current state with a muted gray treatment. This creates a preview of "if you clicked this, the hovered successor would become the new current state."
  • Use semantic state colors consistently across the app. Current conventions in PieceDetail are:
  • completed → green
  • recycled → red
  • all other active workflow states → the shared warm clay accent (oklch(0.66 0.17 35))
  • When the UI needs to show a branch from one current state to multiple valid successors, use an explicit connector treatment rather than text labels like "Current" or "Next". PieceDetail.tsx currently uses a small SVG branch connector between the current-state chip and the vertical list of successors.
  • Past states are sealed historical records, not available actions. When rendered as chips in future history or timeline views, they should stay in the same visual family as state chips but with clearly reduced emphasis: read-only, no hover preview, no interactive affordance, and a lower-contrast or muted treatment that distinguishes them from both the current state and valid successors.
  • Do not restyle state chips to match tags, favorites, filter pills, or upload buttons for convenience. If a new screen needs workflow states, prefer extracting or extending a shared state-chip component rather than recreating an ad hoc variant.
  • If the state-flow styling changes in a meaningful way, update this section alongside the implementation so future agents do not reintroduce tag-like current states or button-like successor states by accident.

State-flow screenshots:

  • There are currently no repo-hosted screenshots for this pattern.
  • When adding them, store them under a stable docs path such as docs/images/state-flow/ and link them here with ordinary Markdown image links so the screenshots travel with the repository history.
  • Suggested captures:
  • PieceDetail showing one current state with multiple valid successors
  • PieceDetail showing a hovered valid successor and the muted current-state preview
  • a history or timeline view once past-state chips exist as a first-class pattern

Auth UI flow (App.tsx):

  • On load, calls fetchAppInit() (GET /api/auth/me/), which returns { googleOauthClientId, user | null }. Returns 503 if OAuth is not configured on the backend.
  • Loading → fullscreen spinner. 503/network error → fullscreen error message.
  • Authenticated → routed app shell with current-user chip and logout action.
  • Unauthenticated → UnauthenticatedApp with Google Sign-In button (client ID comes from fetchAppInit, not from the bundle).
  • Sign Up is intentionally disabled (SIGN_UP_ENABLED = false); create accounts via Django admin.

Frontend routing for piece detail access:

  • Unauthenticated users normally see the auth routes, but /pieces/:id is also registered in the unauthenticated router inside PublicPieceShell. That route calls the same PieceDetailPage as the authenticated app. The backend decides whether the piece is readable: shared pieces load read-only; private pieces return the normal load error.
  • Authenticated owners reach /pieces/:id through the app shell. The API returns can_edit: true, so PieceDetail renders owner controls for name, tags, location, workflow edits, image upload/photo editing, transitions, and terminal-piece sharing.
  • Authenticated non-owners can open /pieces/:id only when the piece is shared. The API returns can_edit: false, and the same PieceDetail component renders a read-only view with edit, upload, transition, tag, and share-management controls hidden or disabled.
  • Do not introduce a separate public piece detail page or route unless the product behavior changes. The canonical public URL is /pieces/:id; ownership and read-only behavior come from the API response.

R2 image upload flow:

  • Images are stored as a JSON array of CaptionedImage objects — see the image metadata contract in the Data Model section.
  • uploadImageToR2 (web/src/util/r2Upload.ts) downscales the image client-side (long edge capped at 2560px), calls POST /api/uploads/r2/presigned-url/ for a presigned PUT URL with a server-generated key, PUTs the bytes directly to R2 (bare axios — the signature in the URL is the credential, no app auth headers), then PATCH /api/pieces/<id>/state/ persists {url, width, height} through the normal state flow.
  • Eager crop pipeline: crop coordinates live on PieceStateImage.crop. Saving them clears the cropped_* fields and enqueues the async generate_cropped_image task (api/crops.py, api/tasks.py), which renders a JPEG derivative with Pillow (exif_transpose, pixel crop, long edge ≤1600px, quality 82) to the deterministic key crops/{r2_key}/{x}-{y}-{w}-{h}.jpg and sets cropped_r2_key/cropped_url on all PieceStateImage rows matching (image, crop). There are no request-time transforms anywhere.
  • AppImage renders cropped_url ?? url; PieceDetailPage polls while any image has crop coordinates but no cropped_url yet, so the cropped version appears once the task lands.
  • R2 is optional in dev: if any of the five R2_* env vars is absent, the presigned-url endpoint returns 503 and the upload button surfaces an error; already-stored URLs still render.

Google OAuth frontend:

  • Uses @react-oauth/google. The client ID is fetched at runtime from GET /api/auth/me/ — it is never baked into the bundle.
  • JWT credential is sent to POST /api/auth/google/ for backend verification.

Frontend testing — Glaze-specific guidance:

  • Every new or modified React component → add or update a test in web/src/components/__tests__/.
  • Every new or modified workflow.ts helper → add or update a test in web/src/util/workflow.test.ts, mocking workflow.yml with a minimal fixture. Never import workflow.yml directly in a test — always mock it.
  • Every new or modified api.ts function → add or update a test in web/src/util/__tests__/api.test.ts, mocking axios via vi.mock.
  • Component tests that involve typing into a controlled MUI Autocomplete must use a stateful wrapper (see Controlled in GlobalEntryDialog.test.tsx).
  • Default to unit tests first. A Bazel target counts as integration coverage only when it is explicitly tagged integration.
  • If a non-integration test reaches a large number of unrelated components or feature modules, treat that breadth as a mocking candidate and narrow the test before accepting it as desirable coverage.

Glaze Import Tool (Admin)

Staff users have access to a browser-based bulk import workflow at /tools/glaze-import. It is the canonical way to seed the public GlazeType and GlazeCombination libraries from physical test-tile photographs. The tool is a five-to-six step tabbed flow:

Tab Purpose
1. Upload Bulk-upload source images from disk (JPEG/PNG) or via the "Upload Via Cloud" path, which uploads the file to R2 (presigned PUT) and loads it back from the CDN URL. Each image becomes an independent record.
2. Crop Draw a rotatable square crop box over each image. The box may extend beyond the image bounds; overflow becomes transparent in the output. Crop geometry is debounced so the live preview updates only after 200 ms of inactivity.
3. OCR Optionally draw a rotatable OCR region bounding box on the crop preview. Running OCR on all records at once feeds Tesseract.js with a domain word list (runs, caution, food safe, 1st, 2nd, glaze) and then parses the result with two heuristics applied in order: (1) structured-line detection (/[I1]st Glaze[:;]/first_glaze, /[2=Z]nd Glaze[:;]/second_glaze) and (2) a token-split fallback that looks for common combo separators (!, /, &, +, over). CAUTION RUNS detection sets runs: true; NOT FOOD SAFE detection sets is_food_safe: false.
4. Review Per-record editable form: name, kind (glaze_type / glaze_combination), first/second glaze fields (hidden for types), runs?, and food safe? selects. For combinations, the name field is auto-computed as <first>!<second> and is read-only. Records must be checked as reviewed before import.
5. Import Sends all reviewed records and their compressed crop images (WebP ≤ 2000 px, 0.85 quality) to POST /api/admin/manual-square-crop-import/. A per-record progress list shows Build → Upload → Done for each file. Import results include admin links to every created or matched object.
6. Reconcile (conditional) Appears only when the import skipped duplicates. Shows the scraped fields for each skipped record alongside an "Open in Admin" link to the existing record, and a resolved checkbox checklist.

Backend import endpoint

POST /api/admin/manual-square-crop-import/ — staff only (is_staff). Accepts a multipart/form-data body:

  • payload — JSON string { records: ManualSquareCropImportRecordPayload[] } (see web/src/util/api.ts for the shape).
  • crop_image__<client_id> — one WebP file per record.

The endpoint is implemented in api/manual_tile_imports.py. For each glaze_type record it creates a public GlazeType (and a matching single-layer GlazeCombination) and uploads the crop to R2. For each glaze_combination record it resolves the two referenced public GlazeType rows by name, creates a public GlazeCombination, and sets the ordered layers. runs and is_food_safe from parsed_fields are written to both GlazeType and GlazeCombination on creation. Existing public records with the same name are reported as skipped_duplicate (not updated).

OCR parsing conventions

  • Structured lines take priority: a line matching /^[I1l]st\s+[Gg]laze\s*[:;]\s*(.+)/ is the first glaze; /^[2=Z]nd\s+[Gg]laze\s*[:;]/ is the second. The character classes handle common OCR confusions (I/1/l for 1, =/Z for 2, ; for :).
  • If no structured lines are found, the longest non-annotation line is used. Tokens split on !, /, &, +, over determine whether the result is a combination (≥ 2 tokens) or a single type.
  • Annotation lines matching CAUTION.*RUNS or NOT FOOD SAFE are stripped from the name and used to set runs/is_food_safe in parsed_fields.

Protected files for this feature

api/manual_tile_imports.py and web/src/pages/GlazeImportToolPage.tsx are the two primary implementation files. api/tests/test_manual_square_crop_import.py must be kept in sync with any import-logic changes.


Showcase View

The Showcase View is the public, marketing-facing presence of a piece. It is controlled by two fields on the Piece model:

  • showcase_story: A multiline text field where the potter can write a narrative about the piece once it reaches a terminal state.
  • showcase_fields: A list of field identifiers (from the process_summary definition) that should be visible to external viewers.

The PublicPieceShell component renders this view for any piece where shared=true. It fetches the piece data (via a public-safe API endpoint) and displays the story and selected fields, providing a "curated" look compared to the internal technical history shown in PieceDetail.


GitHub: Scope Limits & Definition of Done (Glaze-specific)

These extend the generic GitHub interactions guide with Glaze-specific protected files and DoD checks.

Scope limits — ask before acting on any of these:

  • Modifying workflow.yml (state definitions, transitions, successors)
  • Modifying .github/workflows/ (CI/CD configuration)
  • Adding or removing Python dependencies (pyproject.toml)
  • Adding or removing npm dependencies (package.json)
  • Writing or altering database migrations
  • Modifying deployment configuration, backend/settings.py or build settings build.sh

Additional definition-of-done checks:

  • All tests pass: gz_test
  • All linters pass (ruff, eslint, tsc, mypy): gz_lint
  • Auto-fix Python formatting and fixable lint issues before committing: gz_format
  • Serializer output matches the TypeScript types in web/src/util/types.ts
  • State names and transitions are derived from workflow.yml, not hardcoded
  • If AGENTS.md was modified, check whether the domain-specific READMEs (api/README.md, web/README.md, etc.) or the root README.md need a corresponding update
  • If conventions or constraints change during PR work, append those changes to the relevant file under docs/agents/ in a follow-up commit

Rewind (Edit-Mode History Navigation)

When a piece has is_editable=true, users can "rewind" to a past state by clicking it in StateCarousel.tsx (the horizontal state-history strip that replaced the old Timeline/StateTransition components). This is a frontend-only display concept — the backend current_state is never altered.

How it works:

  • PieceDetailContent holds rewindedStateId: string | null in local state.
  • When a past state is clicked in PieceHistory, rewindedStateId is set to that state's ID (clicking again toggles it off).
  • The main WorkflowState panel at the top of PieceDetail renders the rewound state (via updatePastState) instead of currentState. A "Rewound to: [State]" chip with a dismiss button is shown above the panel.
  • All history items chronologically after the rewound state are greyed out (opacity: 0.35, non-interactive) in the timeline.
  • When the piece is sealed (is_editable → false), rewindedStateId resets to null automatically via a useEffect, reverting the view to the real topologically latest state.
  • The rewind affordance is only available while is_editable=true; onRewind is not passed to PieceHistory when the piece is sealed.

Files:

  • web/src/components/PieceDetail.tsxrewindedStateId state, useEffect reset, conditional WorkflowState, rewind banner
  • web/src/components/PieceHistory.tsx — clickable list items, greying-out logic, rewindedStateId/onRewind props

Image GC

Derived Image rows (derived_from IS NOT NULL) can become orphaned when no longer referenced by any piece:

  • A jpeg_conversion source Image is orphaned when the user never saves the HEIC URL to a piece — the JPEG URL is propagated instead, leaving the source unreferenced.
  • A crop derivative is orphaned when a user re-crops; the old crop Image row remains in DB and R2 but no PieceStateImage.cropped_image FK points to it.

Orphan definition: a derived Image is orphaned when none of these FKs point to it:

  • PieceStateImage.image (via related_name="piece_state_links")
  • PieceStateImage.cropped_image (via related_name="crop_links")
  • Piece.thumbnail (via related_name="thumbnail_for_pieces")

Command: python manage.py gc_derived_images [--dry-run] [--limit N]

  • --dry-run prints what would be deleted without making changes.
  • --limit N caps the number of deletions in a single run.
  • Safe to run repeatedly (idempotent).

CronJob: runs daily at 03:00 UTC via chart/glaze/templates/cronjob-gc-derived-images.yaml. Controlled by values.gcDerivedImages.enabled/schedule.


Key Constraints

  • workflow.yml is the single source of truth for states and transitions. Both backend validation and web UI must derive from it — never duplicate the state list.
  • The PieceState history is append-only; past states should not be edited, only new ones added. Only the current_state should be modifiable. Once a piece transitions to a new state, past states are sealed — take care in backend code to prevent inadvertent edits to sealed states.
  • PieceDetail.current_state is the most recent PieceState in the history.
  • All dates should be stored and transmitted as ISO 8601 strings; the web types declare them as Date but Axios/JSON deserialization will deliver them as strings — handle accordingly.
  • Piece creation flow: POST /api/pieces/ always initializes the piece in the designed state. The creation UI (NewPieceDialog) lets the user supply a name, optional notes, and pick a thumbnail from the curated gallery.
  • Public library ownership: Public global objects (user=NULL) are owned by no user and managed exclusively via Django admin. Regular API users can read public objects but cannot create, edit, or delete them. Use is_public_global() from api/workflow.py — do not hardcode this distinction.