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.
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".
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.
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-Keyheader against amodal.Secret. - Microservice: Located at
services/piece_image_segment_service.py, designed for deployment to Modal.com.
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) andstates(array, at least 2 items).globalsis optional. - Per-state required fields:
id(snake*case,^[a-z]a-z0-9*]\*$) andvisible(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: falseat 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): runsjsonschema.validateagainstworkflow.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): enforcescustom_fieldsrules —enumonly ontype: stringfields; 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 everyglobalsentry maps to a real Django model inapi/models.py, every field declared in that global exists on the model, and every global withpublic: truehas a nullableuserfield 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, requiredfriendly_namelabels 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
Taghaving a persistedcolorfield 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 inworkflow.yml. - Capability pattern: use
workflow.ymlto opt models into generic capabilities such asfavoritable: trueortaggable: true, not to encode one-off wiring details that generated backend/frontend code can infer.
Each global definition also carries several optional flags:
public(defaultfalse): whentrue, this global type has an admin-managed shared library of public objects (stored withuser=NULL) visible to all authenticated users. The corresponding Django model'suserfield must be nullable.private(defaulttrue): whentrue, users can create their own private instances of this type.factory(defaulttrue): whenfalse, the Django model is hand-written and_register_globals()skips auto-generation for this global. Use only when bespoke model logic is required (currently onlypiece).favoritable(defaultfalse): whentrue, aFavoriteModelsubclass is auto-generated for this global and the favorites API endpoints are enabled.taggable(defaultfalse): whentrue, instances of this global can be tagged using the sharedTagglobal. The developer interface should mirror favorites: a singletaggable: trueflag inworkflow.ymlopts the type into generated join-model support (for examplepiece→TagEntry) 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— returnsTrueif the named global haspublic: trueget_public_global_models() -> list[type[Model]]— returns the Django model class for everypublic: trueglobal; used by admin for dynamic registrationget_image_fields_for_global_model(model_cls) -> list[str]— returns field names declared astype: imagefor 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 UIThe 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 instanceReferential 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 inglobals, field must be declared in that global'sfields. formatonly on strings: if a field declaresformat, itstypemust bestring— enforced byTestCustomFieldsDSL.test_format_only_on_string_type.format: hex_color:_resolve_field_defemits a JSON Schemapatternconstraint so the backend rejects any value that is not a valid CSS hex color code;_global_entries_implalso 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: waxedSummary items support exactly one of:
value: state_id.field_name— display a field from a reachable ancestor state.compute— display a numeric result usingproduct,difference,sum, orratio.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:
designedis the single entry point for all new pieces —POST /api/pieces/always creates a piece in thedesignedstate.- Every non-terminal state has
recycledas a valid successor — a piece can be recycled at any point. completedandrecycledare terminal states ("terminal": true) — no transitions out.- During initial development, all states have
"visible": trueand 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.
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.
These supplement the generic Django/DRF conventions.
Project layout:
backend/— Django project settings, root URL config (backend/urls.py), WSGI/ASGIapi/— the single Django app; models, views, serializers, and tests all live heremanage.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.pyis 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 bothadmin.pyand another module need them.api/utils.pyholds 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 inapi/utils.py.
Production environment variables — settings.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
Imagerow 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 getr2_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 aGlobalModelsubclass for any non-compose_fromglobal. Fields, theuserFK, andUniqueConstraints are derived entirely from theworkflow.ymldeclaration. Only amakemigrationsrun is required to add a new simple global.make_compose_global_models(global_name)— generates a(CompositeModel, ThroughModel)pair for acompose_fromglobal. The composite receives an ordered M2M field, a stored computedname, inline and FK DSL fields,compute_name(),get_or_create_with_components(),get_or_create_from_ordered_pks(),filterable_fields, and apost_fixture_loadhook — all derived from the DSL. Only amakemigrationsrun is required.make_favorite_model(global_name)— generates aFavoriteModelsubclass for anyfavoritable: trueglobal. Only amakemigrationsrun 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
userFK cannot change after creation (prevents silent breakage of public/private reference invariants). - Declares the
namefield convention: every concrete subclass must have anameCharField (or a stored computed equivalent). Forcompose_fromglobals thenameis a stored computed string (component names joined byCOMPOSITE_NAME_SEPARATOR(!)). Simple-globalnamevalidation 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; theuserFK is NOT NULL; list endpoints filter torequest.useronly. - Public + private (
ClayBody,GlazeType,GlazeCombination): support an admin-managed shared library (records withuser=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 anis_publicboolean 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 ofadmin.AdminSitethat overridesget_app_listto move public library models out of the "Api" section into a separate "Public Libraries" section. Applied viaadmin.site.__class__ = GlazeAdminSite.PublicLibraryAdmin— baseModelAdminfor globals withpublic: true. Filters to public objects only (user__isnull=True); forcesobj.user = Noneon save; rejects names that collide with existing private objects.R2ImageWidget—TextInputsubclass 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_payloadderives the R2 object key server-side. TheMediaclass loadsapi/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 registration —
PublicLibraryAdminis registered for every model returned byget_public_global_models(). Addingpublic: trueto a new global inworkflow.ymlis 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 cookiePOST /api/auth/login/→ session login via email + passwordPOST /api/auth/logout/→ clear current sessionGET /api/auth/me/→ current authenticated userPOST /api/auth/register/→ register + login (backend remains available)POST /api/auth/google/→ Google OAuth 2.0 login via JWT credentialGET /api/pieces/→ list ofPieceSummaryGET /api/pieces/<id>/→PieceDetailGET /api/pieces/<id>/current_state/→ the current editablePieceStateonlyPOST /api/pieces/→ create a new piece (always starts indesignedstate; acceptsname, optionalthumbnail, and optionalnotes)POST /api/pieces/<id>/states/→ record a new state transitionPATCH /api/pieces/<id>/→ update piece-level editable fields (currently location)PATCH /api/pieces/<id>/state/→ update current state's editable fieldsGET /api/pieces/<id>/showcase-video/→ latest showcase video task status, stale detection, and artifact metadataPOST /api/pieces/<id>/showcase-video/→ enqueue a deterministic Keepsake slideshow render for a terminal pieceShowcase 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 parentPieceDetailpayload 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 includesis_public: bool.global_entriesis 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_SERIALIZERSinapi/global_entries/logic.py(re-exported byapi/views.pyfor compatibility).GlazeCombinationuses 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_typeslist,is_favorite).
- Models may opt in to richer GET responses by declaring a
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 onlyglaze_combinationsupports 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_typedefaults toimage;video/audioare 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 configuredGET /api/auth/agent-tokens/,POST /api/auth/agent-tokens/,DELETE /api/auth/agent-tokens/<id>/→ manageAgentTokenrows (see below); session-authenticated only,AgentTokenAuthenticationis 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-authlibrary (GOOGLE_OAUTH_CLIENT_IDenv 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
piecefixture inapi/tests/conftest.pycreates 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.pyhelper → add or update a test inapi/tests/test_workflow_helpers.py, patching_STATE_MAP/_GLOBALS_MAPviamonkeypatch. - New global domain models: adding a new entry to
workflow.ymland runningmakemigrationsis sufficient —_register_globals()auto-generates and registers the model at import time. The generated class is automatically enrolled in the parameterised test suites inapi/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. Setfactory: falseonly if the global needs a hand-written model class with bespoke logic. - API test targets in
api/BUILD.bazelsetDJANGO_SETTINGS_MODULE=backend.test_settingsthrough the shared_TEST_ENV. That keeps the default suite on the self-contained test settings module instead of importingbackend.settingsfor every run, which makes Bazel caching more stable and keeps production-only settings branches out of unrelated tests unless a target truly needs them.
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):
useQuerywithenabled. HandleisLoadinganderrorinline. - Mutations (create, update, delete):
useMutation. UseonSuccesson the mutation definition for unconditional side effects; use the per-callonSuccessoption when the callback depends on the call site. - Optimistic local updates:
queryClient.setQueryData(queryKey, updater)— replaces the oldsetDatafromuseAsync.
// ✅ 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 byWorkflowStateto render dynamic fields.getGlobalDisplayField(globalName)— returns the display field name for a globals entry; used byGlobalEntryDialogto 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.tsis auto-generated — do not edit by hand. It is gitignored.- Generation is driven by
web/scripts/generate-types.mjs, which calls theopenapi-typescriptprogrammatic API with atransformthat convertsformat: date-timefields toDate. Rungz_gentypeswith Django on port 8080. - The same
web/scripts/+js_binarypattern 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 throughweb/BUILD.bazeland add avitest_testso the tool keeps coverage. web/src/util/types.tsderives domain types fromgenerated-types.tswith explicit overrides only for nested normalization (for example state/image/crop shapes). It also holds theSTATESarray andSUCCESSORSmap fromworkflow.yml.- Highly important:
web/src/util/types.tsis 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→ updateweb/src/util/types.tsif semantic narrowing is needed → update mappers inweb/src/util/api.ts. web/src/util/api.tsuses theWire<T>generic to type raw Axios responses (dates as strings). Mappers convertWire<T>→ domainT. This is the only file that should contain deserialization logic.- The OpenAPI schema is at
http://localhost:8080/api/schema/and Swagger UI athttp://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 fromNewPieceDialog.tsx) points to/thumbnails/question-mark.svg.
Existing components:
PieceList.tsx— masonry orchestration layer forPieceSummaryobjects. 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 galleryWorkflowState.tsx— edits the currentPieceState: notes, location, additional fields, images (upload or URL), caption editing, lightbox launchGlobalEntryField.tsx— chip + button wrapper that shows the currently selected global entry and opens theGlobalEntryDialogpicker on click.GlobalEntryDialog.tsx— full-screen dialog for browsing, searching, and selecting a global entry (e.g. Location, GlazeCombination). Supports inline creation whencan_createis set in the DSL field definition, and renders direct-to-R2 image uploads fortype: imagefields on create.AppImage.tsx— renders an R2/CDN-hosted image as a plain<img>pointing atcropped_url ?? url(no request-time transforms). Context-specific chrome:thumbnail/preview(64×64 box),gallery/detail(fill container),lightbox(fit-content). ExportsSuspenseAppImageandImageSkeleton.ImageLightbox.tsx— full-screen modal image viewer with caption and keyboard/touch navigationStateChip.tsx— shared workflow-state token. Takesvariant: 'current' | 'past' | 'future'plusisTerminaland optional interaction hooks so list/detail/timeline UIs stay in one visual family.ProcessSummary.tsx— renders the read-onlyprocess_summarysection declared at the top level ofworkflow.yml. Displays sections of fields, promoted values, computed numeric results (product,difference,sum,ratio), and static text with optionalwhen(state_exists/state_missing) visibility. Rendered byPieceDetailandPublicPieceShell.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=0first 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
MasonryScrollerrender because the old post-mount correction pass is what produced the visible flicker. - Cards without crops keep the default
itemHeightEstimatebecause we do not know their exact height until Masonic measures them. AppImagesizing 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.tsxshould 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
PieceDetailare: completed→ greenrecycled→ 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.tsxcurrently 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:
PieceDetailshowing one current state with multiple valid successorsPieceDetailshowing 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 →
UnauthenticatedAppwith Google Sign-In button (client ID comes fromfetchAppInit, not from the bundle). Sign Upis 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/:idis also registered in the unauthenticated router insidePublicPieceShell. That route calls the samePieceDetailPageas 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/:idthrough the app shell. The API returnscan_edit: true, soPieceDetailrenders owner controls for name, tags, location, workflow edits, image upload/photo editing, transitions, and terminal-piece sharing. - Authenticated non-owners can open
/pieces/:idonly when the piece is shared. The API returnscan_edit: false, and the samePieceDetailcomponent 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
CaptionedImageobjects — 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), callsPOST /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), thenPATCH /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 thecropped_*fields and enqueues the asyncgenerate_cropped_imagetask (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 keycrops/{r2_key}/{x}-{y}-{w}-{h}.jpgand setscropped_r2_key/cropped_urlon allPieceStateImagerows matching(image, crop). There are no request-time transforms anywhere. AppImagerenderscropped_url ?? url;PieceDetailPagepolls while any image has crop coordinates but nocropped_urlyet, 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 fromGET /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.tshelper → add or update a test inweb/src/util/workflow.test.ts, mockingworkflow.ymlwith a minimal fixture. Never importworkflow.ymldirectly in a test — always mock it. - Every new or modified
api.tsfunction → add or update a test inweb/src/util/__tests__/api.test.ts, mocking axios viavi.mock. - Component tests that involve typing into a controlled MUI Autocomplete must use a stateful wrapper (see
ControlledinGlobalEntryDialog.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.
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. |
POST /api/admin/manual-square-crop-import/ — staff only (is_staff). Accepts a multipart/form-data body:
payload— JSON string{ records: ManualSquareCropImportRecordPayload[] }(seeweb/src/util/api.tsfor 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).
- 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/lfor1,=/Zfor2,;for:). - If no structured lines are found, the longest non-annotation line is used. Tokens split on
!,/,&,+,overdetermine whether the result is a combination (≥ 2 tokens) or a single type. - Annotation lines matching
CAUTION.*RUNSorNOT FOOD SAFEare stripped from the name and used to setruns/is_food_safeinparsed_fields.
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.
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 theprocess_summarydefinition) 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.
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.pyor build settingsbuild.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.mdwas modified, check whether the domain-specific READMEs (api/README.md,web/README.md, etc.) or the rootREADME.mdneed 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
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:
PieceDetailContentholdsrewindedStateId: string | nullin local state.- When a past state is clicked in
PieceHistory,rewindedStateIdis set to that state's ID (clicking again toggles it off). - The main
WorkflowStatepanel at the top ofPieceDetailrenders the rewound state (viaupdatePastState) instead ofcurrentState. 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),rewindedStateIdresets tonullautomatically via auseEffect, reverting the view to the real topologically latest state. - The rewind affordance is only available while
is_editable=true;onRewindis not passed toPieceHistorywhen the piece is sealed.
Files:
web/src/components/PieceDetail.tsx—rewindedStateIdstate, useEffect reset, conditional WorkflowState, rewind bannerweb/src/components/PieceHistory.tsx— clickable list items, greying-out logic,rewindedStateId/onRewindprops
Derived Image rows (derived_from IS NOT NULL) can become orphaned when no longer referenced by any piece:
- A
jpeg_conversionsource 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
cropderivative is orphaned when a user re-crops; the old crop Image row remains in DB and R2 but noPieceStateImage.cropped_imageFK points to it.
Orphan definition: a derived Image is orphaned when none of these FKs point to it:
PieceStateImage.image(viarelated_name="piece_state_links")PieceStateImage.cropped_image(viarelated_name="crop_links")Piece.thumbnail(viarelated_name="thumbnail_for_pieces")
Command: python manage.py gc_derived_images [--dry-run] [--limit N]
--dry-runprints what would be deleted without making changes.--limit Ncaps 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.
workflow.ymlis 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
PieceStatehistory is append-only; past states should not be edited, only new ones added. Only thecurrent_stateshould 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_stateis the most recentPieceStatein the history.- All dates should be stored and transmitted as ISO 8601 strings; the web types declare them as
Datebut Axios/JSON deserialization will deliver them as strings — handle accordingly. - Piece creation flow:
POST /api/pieces/always initializes the piece in thedesignedstate. 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. Useis_public_global()fromapi/workflow.py— do not hardcode this distinction.