# Continuum catalog schema # # This file is authoritative in the tooling repo (versioned, reviewed). The tool # writes a copy to the photo-library root so the catalog is self-describing # offline. # # One TOML per group, inside that group's folder, named identically to the folder: # --/--.toml # e.g. 1963-10-01-PRT_HARDCOPY-0007/1963-10-01-PRT_HARDCOPY-0007.toml # # Structure: # [group] one table # [group.subject] content description (place / event / taken-date) # [[photo]] one per photo, ordered by seq # [photo.subject] per-photo content description # [[photo.subject.person]] zero or more people in that photo # [[photo.print]] zero or more physical prints of the photo above it # # Rules: # - Enums are APPEND-ONLY. Add a value here; existing files stay valid. # - New OPTIONAL fields may be added without invalidating existing files. # - Removing a value/field, or making an optional field required, is a # schema_version bump + migration. # - Every group .toml stamps [group].schema_version = the _schema.toml version # it was written against, and [group].catalog_updated = the date the tool # last wrote that file. Both tool-written, never hand-typed. `migrate` reads # schema_version to know which transforms to apply; `verify` flags files that # predate the current schema. catalog_updated dates the printed group record # ("catalog as of ...") so an archived sheet can be checked for staleness. # This _schema.toml carries schema_released = the date version N was minted. # # The prefix in folder/file names is NOT stored. It is rendered from # date_earliest / date_latest (DESIGN.md 6.2) and never hand-typed. # # inscription is PRIMARY SOURCE: text transcribed from photo packets and the # backs of prints. Stored and emitted verbatim -- TOML literal strings # ('''...'''), no normalization, no re-wrapping, no quote substitution -- and # byte-checked by `verify`. notes is your own editorial text. # # notes fields exist at [group], [group.subject], [photo], [photo.subject], and # [photo.print]. They are ADDITIVE, never overrides: a [photo.subject].notes does # NOT replace [group.subject].notes -- both hold distinct information and `record` # shows both. (This is unlike the other [*.subject] fields -- place, event, # date_* -- which DO override field-by-field.) schema_version = 6 schema_released = 2026-09-06 # permanent photo identities # v4 replaced [photo.subject].people -- a list of names -- with # [[photo.subject.person]], one table each, so a name can carry where in the # picture that person is. See the note above [photo.subject.person.fields]. # =========================================================================== # Display names # =========================================================================== # How the tokens are spoken about. The enum values are identifiers -- fixed # width, uppercase, and part of every filename -- which is right for the archive # and wrong for a sentence. These say the same things in English, so a screen or # a printed record can name a medium without inventing its own words. [labels.medium] "135_NEGATIVE" = "35mm Negatives" "110_NEGATIVE" = "110 Negatives" "DSC_NEGATIVE" = "Kodak Disc Negatives" "PRT_HARDCOPY" = "Hardcopy Prints" "PLR_INSTANTS" = "Polaroid Instants" "DIG_ORIGINAL" = "Digital Originals" # =========================================================================== # Enums # =========================================================================== [enums] # Physical medium. Equals the half of every id and on-disk name. # Fixed width, 12 chars: FMT_FORM. medium = [ "135_NEGATIVE", # 35 mm film negatives "110_NEGATIVE", # 110 cartridge negatives "DSC_NEGATIVE", # Kodak disc negatives "PRT_HARDCOPY", # paper prints, no surviving negative "PLR_INSTANTS", # Polaroid / instant film "DIG_ORIGINAL", # born-digital, no physical original ] # Group pipeline stage. Strict progression PLANNED -> SCANNING -> DIGITIZED -> # ARCHIVED. This is the ARCHIVAL MILESTONE the group has reached, NOT a measure # of outstanding work: an ARCHIVED group (its prints/negatives filed) can # still carry open `task` entries -- a print out for scanning, a photo to chase. # (No EXCLUDED value. A roll that came back blank is not an exclusion: its # negatives are still filed, so it IS archived -- an ARCHIVED group, possibly # with no [[photo]] entries at all, and a notes line saying why. Add EXCLUDED, # append-only, if a "catalogued but deliberately not keeping this" case appears.) # # ARCHIVED is the third field to use this word, and they are three different # claims -- the field name is what tells them apart: # group.status = "ARCHIVED" the whole packet is filed # negatives_custody = "ARCHIVED" every negative is accounted for # print.print_location = "ARCHIVED" this one print is in its sleeve # A group can be ARCHIVED while a print inside it is MISSING. Was COMPLETE # until schema 2, which read as "nothing left to do" and never meant that. group_status = [ "PLANNED", # catalogued, packet in hand, not yet sent out "SCANNING", # sent to the lab, awaiting files "DIGITIZED", # files back, renamed, in the library, EXIF corrected "ARCHIVED", # prints/negatives sleeved into their binders -- physically archived "MERGED", # this group's photos were moved into another; the folder stays # as a tombstone so old references and printed records still # resolve. `merged_into` names where they went (decision 25). ] # Is there a digital image file for this photo? Just file existence -- whether # the file shows a usable picture is `condition`/`negative` (NO_IMAGE). # (Format is not assumed -- the recognised extension set is the `image_extensions` # config value; the on-disk name carries whatever extension the file has.) scan = [ "PRESENT", # an image file for this photo is in the group folder "PENDING", # no file yet ] # Where that file came from. Required when scan = PRESENT; the tool DEFAULTS it # from `medium` when omitted, so you only ever hand-set the exception (a # negative-medium photo whose negative is gone -> "PRINT"): # *_NEGATIVE medium -> "NEGATIVE" # PRT_HARDCOPY / PLR_INSTANTS -> "PRINT" # DIG_ORIGINAL -> "NATIVE" scan_source = [ "NEGATIVE", # scanned from the negative "PRINT", # scanned from a paper print "NATIVE", # born-digital -- the file IS the original ] # Where one physical PRINT currently is -- including nowhere. "Missing" is an # answer to "where is it?", which is why it lives here: it says the object is # unaccounted for, not that it is a poor source. Until schema 3 it sat in # `condition`, and the whole archive showed why that was wrong -- every one of # the 154 prints marked MISSING had no location, so "missing" already occupied # this slot as a hole. (Negatives use `negatives_custody` at group level and # `negative` per photo; they have no per-photo location, so theirs stays a # condition.) print_location = [ "ARCHIVED", # in its archival binder sleeve, its proper home "DUPLICATE_BOX", # loose (but organised) in the duplicates box "ALBUM", # in an assembled family album -- do not take apart "DISPLAY", # framed, on display "OTHER", # anything else -- `print_location_note` required (e.g. given # to a relative, on loan, stored off-site) "MISSING", # not where it should be. A listed print is a claim that the # object exists, so this reads "unaccounted for" and raises # work until it is found and given a real location. ] # Whole-group negative strip status. Present only for *_NEGATIVE media. # This is about CUSTODY -- do we still have them -- NOT their quality. A strip # that is complete but unusable (blank, ruined) is still ARCHIVED; the per-photo # `negative` field carries the condition. # A DERIVED roll-up you don't hand-maintain: set MISSING deliberately (whole # strip gone); otherwise PARTIAL if any photo has `negative = "MISSING"`, else # ARCHIVED. verify recomputes it and does NOT error on a stored value that # disagrees. (See DATA-MODEL.md 4.) negatives_custody = [ "ARCHIVED", # all accounted for, in the medium's binder (110 / KOD / 135 / ...) "PARTIAL", # some are gone (see which, per-photo: negative = "MISSING") "MISSING", # none survive ] # Scalar types used below: int, string, string_list, date_partial (YYYY / # YYYY-MM / YYYY-MM-DD, unknown digits as literal X), date (strict YYYY-MM-DD, # no X -- a real calendar date, stored as a native TOML date), and `size`: # # `size` is a TYPE, not an enum (there will be many non-standard sizes -- prints # 100+ years old -- and each still needs measuring and sleeving). Validated # string: /^\d+(\.\d+)?x\d+(\.\d+)?$/ # - fractions -> decimals; lowercase x; smaller dimension first (orientation # doesn't matter for sleeving), e.g. 3.5x5, 2.75x4.5, 4.25x6.5 # - omitted when not yet measured. Absence is the only way to say "unknown": # a print with no size inherits group.size, so a print with none AND a # group with none means nobody has measured it -- which verify reports. # - common values: 2x3 2.5x3.5 2.5x4.5 2.75x4.5 3x4.75 3.5x5 3.5x5.5 # 3.5x5.75 4x5 4x6 4.5x6.5 5x7 6x8 8x10 # Can an image be got from this PRINT? Absent = it is here and shows a usable # picture. A bad print is still kept, filed, and scanned -- this just records # why it cannot be a source, so nothing suggests sending it out. # One value on purpose. Where the print IS belongs to `print_location`, which # is a different question and since schema 3 carries MISSING itself. Physical # wear that leaves the image readable (creases, colour shift) changes no # decision and is not tracked -- put it in `notes` if it is worth saying. condition = [ "NO_IMAGE", # here, but no usable picture -- blank, unexposed, blurred beyond # use, double-exposed, light-struck. The reason, if it matters, # goes in `notes`. ] # The same question asked of a NEGATIVE, which has no location field of its own # -- so for a negative, "gone" has nowhere to live but here. negative_condition = [ "MISSING", # the negative is gone -- it cannot be examined or scanned "NO_IMAGE", # here, but blank: unexposed, or ruined in development ] # =========================================================================== # Physical limits # =========================================================================== # # A group is a DEVELOPING PACKET, not a roll: people submitted two rolls at # once, or several Polaroid packs, so for most media the photo count has no # honest ceiling (a 135 packet here legitimately holds 47, spanning Christmas # into January). # # The exception is film sold as one rigid unit, where the format fixes the # frame count and a packet cannot hold part of one. Only those appear here. # A count above the limit means the record is wrong, not that the archive is # unusual -- `verify` reports it as an ERROR. [limits.DSC_NEGATIVE] max_photos = 15 reason = "Kodak disc film holds 15 exposures; a disc is a single rigid unit" # =========================================================================== # [group] # =========================================================================== [group.fields] schema_version = { type = "int", required = true } # tool-written: the _schema.toml version this file was written against. Never hand-typed. catalog_updated = { type = "date", required = true } # tool-written: date (YYYY-MM-DD) the tool last wrote this file. Bumped only when the serialized content changes. Stamps the printed group record. medium = { type = "enum:medium", required = true } group_number = { type = "int", required = true, min = 1, max = 9999 } # tool-assigned and NOT selectable: `new-group` claims the lowest number this medium is not using, so the sequence stays unbroken. A number is an accession, not an opinion. Unique WITHIN medium. A merged-away group does not free its number -- it leaves a tombstone that still occupies the slot and still resolves old references (decision 25); only deleting a group outright frees one, and the next group created recycles it. Rendered 4-digit. status = { type = "enum:group_status", required = true } # the archival milestone this group has reached (not a measure of outstanding work) date_earliest = { type = "date_partial", required = true } # DEVELOPED date (packet stamp / Polaroid writing). Drives the rendered prefix date_latest = { type = "date_partial", required = true } # + sort. Usually exact (earliest == latest); a range when unknown, or the # envelope of an assembled collection. Hand-set & stored -- never auto-computed; # verify only SUGGESTS tightening an X-containing range from per-photo dates. photo_owner = { type = "string", required = false } # verbatim: whose packet this was, as written (e.g. "Joan Fenwick-Langford"). Omitted when the source said "N/A". size = { type = "size", required = false } # DEFAULT print size for the group; a print without its own `size` inherits this. Omit when a group's prints vary in size -- each print then carries its own. # No `binder` field: derivable (print binder = the group's year; negative binder = the medium). # No `uid` field: `id` is the key everywhere; add a uid later if cross-referencing needs one. negatives_custody = { type = "enum:negatives_custody", required_if = "medium FORM == NEGATIVE", forbidden_otherwise = true } merged_into = { type = "string", required_if = "status == 'MERGED'" } # the group id this group's photos were moved into, e.g. "PRT_HARDCOPY-0007". Set by `merge-groups`; a tombstone keeps its own group_number, so merging never opens a gap in the sequence (decision 31). inscription = { type = "string", required = false } # PRIMARY SOURCE, verbatim: text on the packet notes = { type = "string", required = false } # editorial. Prefer a structured field wherever one exists (inscription, [group.subject], negatives, ...) -- notes is for what nothing else can hold. tasks = { type = "string_list", required = false } # open tasks for this group; verify/record surface non-empty lists # --- what the photos are OF (content), as opposed to custody/physical above. # Group-level defaults; a photo's [photo.subject] overrides place/event/date_* # field-by-field. `notes` is additive, not an override. Drives nothing. [group.subject.fields] place = { type = "string", required = false } # where taken, e.g. "Grandma's house, Cumberland" event = { type = "string", required = false } # occasion, e.g. "Christmas 1985", "Bermuda trip" date_earliest = { type = "date_partial", required = false } # when TAKEN, if known and different from the developed date date_latest = { type = "date_partial", required = false } notes = { type = "string", required = false } # editorial note on content: taken-date reasoning, "that's the Johnsons' cabin", etc. ADDITIVE with [photo.subject].notes. # Derived, NOT stored: # group id = "{medium}-{group_number:04d}" (e.g. PRT_HARDCOPY-0007) # date prefix = rendered from date_earliest / date_latest # folder / .toml = "{date_prefix}-{id}" # photo count = number of [[photo]] entries # group-record page count # =========================================================================== # [[photo]] -- one per photo # =========================================================================== [photo.fields] photo_id = { type = "uuid", required = true } # permanent identity; survives moves, reordering and replacement scans seq = { type = "int", required = true, min = 1 } # tool-assigned, not hand-typed: `ingest` numbers the photos as they arrive, and `move-photo` / `merge-groups` renumber both sides afterwards. The order itself IS a judgement call -- the ingest plan is where it is made and corrected, before anything is written. 1-based, contiguous; = on disk. A blank / NO_IMAGE frame still keeps its seq. scan = { type = "enum:scan", required = true } scan_source = { type = "enum:scan_source", required_if = "scan == 'PRESENT'" } # tool defaults from medium; hand-set only the exception developer_code = { type = "string", required = false } # PRIMARY SOURCE, verbatim: an order/batch code written by the film developer, transcribed from the print. Photos sharing a code came from one print order -- in practice they also share a print size, and can sit far apart in `seq`. Not generated, not validated; preserved for the relationships it reveals. negative = { type = "enum:negative_condition", required = false } # this photo's negative: MISSING (gone) or NO_IMAGE (blank frame). Absent = present and usable. inscription = { type = "string", required = false } # PRIMARY SOURCE, verbatim: text on the back of the print notes = { type = "string", required = false } # editorial tasks = { type = "string_list", required = false } # open tasks for this photo # A not-yet-worked photo (e.g. in an In-Progress group) is a valid entry with # just `seq` + `scan` + its `task`s. `scan` defaults to PENDING. [photo.subject.fields] place = { type = "string", required = false } # overrides group.subject.place event = { type = "string", required = false } # overrides group.subject.event date_earliest = { type = "date_partial", required = false } # per-photo TAKEN date (e.g. an assembled album whose photos span several years) date_latest = { type = "date_partial", required = false } notes = { type = "string", required = false } # editorial note on this photo's content. ADDITIVE with [group.subject].notes, not an override. # =========================================================================== # [[photo.subject.person]] -- zero or more people in the photo above. # # Was a list of names ([photo.subject].people) through schema 3. A name on its # own cannot say WHICH of the five faces it belongs to, and in a family archive # that is most of the question -- the names are usually known and the faces are # what needs settling, forty years later, by whoever is still able to say. # # x and y are fractions of the image's width and height, from the top-left, so # they survive rescaling, re-scanning at another resolution, and the difference # between a thumbnail and the file. They are OPTIONAL: a name with nobody # pointing at a face is exactly what every record migrated from v3 looks like, # and is a perfectly good record. It is not a rotation-safe coordinate -- a # photograph re-scanned the other way up loses its tags, which is a fair trade # for a number a person can read and check. # =========================================================================== [photo.subject.person.fields] name = { type = "string", required = true } # verbatim, as written or as known: "Joan Fenwick", "Mary Jane Emberly née Delaney", "Cococa" x = { type = "fraction", required = false } # 0.0 = left edge, 1.0 = right edge y = { type = "fraction", required = false } # 0.0 = top edge, 1.0 = bottom edge notes = { type = "string", required = false } # editorial: "guessing, from the coat" # =========================================================================== # [[photo.print]] -- zero or more physical prints of the photo above. # Three prints of one photo = three [[photo.print]] blocks, each with its own # location / condition / size / notes. # =========================================================================== [photo.print.fields] copy = { type = "int", required = true, min = 1 } # tool-assigned, not hand-typed: renumbered whenever a print is added or removed. 1-based, contiguous within the photo; addresses the print in `record` output ("Print 2 of 3") print_location = { type = "enum:print_location", required = true } # where it is, MISSING included -- so this is always answerable print_location_note = { type = "string", required_if = "print_location == 'OTHER'" } # specifics: which album, which wall, which relative condition = { type = "enum:condition", required = false } # absent = present and shows a usable picture. NO_IMAGE = here but blank, so nothing suggests scanning it. Where it is: `print_location`. size = { type = "size", required = false } # overrides group.size; omit to inherit it. Omit BOTH to mean "not measured yet" -- verify warns, and the warning clears itself the moment a measurement is entered. notes = { type = "string", required = false }