Source Model — The catalogue workbook: one row per folder, in the archive’s template — Design Spec (#6282)
Milestone: source-model Manual: TBD — a section, “Exporting your catalogue”, telling a researcher that the recipe’s last step writes the catalogue as a workbook (Excel) or sheets (CSV) in their archive’s own template, one row per digital folder, with the people, places and institutions as lists beside it, that the same recipe can write a page-by-page transcript per folder as Word, and how to change the columns in the table view.
Design-led (Testing Constitution). Status: PROPOSED (2026-10-28; revised 2026-10-29 after the review in the maintainer’s working notes,
export-review-2026-10-29, and again the same day with the owner’s rulings, decided below). Written for the owner from his idea, paraphrased: a cataloguer’s workbook must come out of the app by itself, driven by the folders’ attributes, in the archive’s own template, and the same columns must be seen and edited in a table in the app. His real cataloguing workbook and the one a worker built by hand on 2026-10-29 were read for their structure only; neither is copied into the repo or the app. Builds onattributes-and-prototypes.md(THE FOUNDATION: a template is a prototype’s columns, the kind and value cascades, the shipped EAP prototypes),cataloguing.md(the fields as folder attributes,folder.promote_catalogue, the EDTF date),export/exporter.md(the one record stream and its Excel and Word emitters),structured-data.md(the dataset sheet),../ui/catalogue-table.md(the table view: the same template, seen and edited),../kg/kg-entities-and-statements.md(sections B, D and G: one record per name, proposals resolved by a person, the folder’s lists read from the record) andmodels-chains-and-projects.md(source.onboard.kept-exported, the export step).
Tags: [OK] built and pinned · [PARTIAL] exists, partly wired or unpinned · [GAP] not built. Each line names its test.
Why its own spec
cataloguing.md owns the fields and already lists catalogue.export-standards [GAP] in one line.
export/exporter.md owns the record stream and its emitters (documents, entities, claims as fixed
tables). attributes-and-prototypes.md owns what a template IS. Neither says what a cataloguer’s
workbook is: the template filled from each folder’s attributes and facts, with the names beside it
and the provenance behind it, plus the page-by-page transcript the same team hands over as Word. That
is one surface with its own sheets, its own doors and its own date and name rules, so it gets one
spec; cataloguing.md’s export line points here, and the record-stream emitters stay where they are.
The table view that shows and edits the same columns is ../ui/catalogue-table.md.
Intent (the design)
A person presses Start on a recipe whose last step is “Keep files”. When the run ends they open the
folder the step names and find Catálogo.xlsx: sheet 1 is the catalogue, one row per digital folder (a
digital folder is one archival document or file), one such sheet per collection in the scope, the
columns of the folders’ prototype in its words and order, three header rows (the funder’s English, the
team’s Spanish, the instruction line per column); beside it the sheets the team is used to:
Faltantes (the rows with something missing or unresolved), Personas, Lugares, Entidades (the
people, places and institutions the rows name, one row each, with the graph’s canonical name and its
authority fields beside each and the folders that name it), Tema (the controlled subject list),
Catálogo (every catalogue attribute as it stands, dates as written and as EDTF), Páginas (the
page texts), Nombres (the project’s ONE canonical set of names in the scope), Declaraciones (the
statements) and Notas (who or what set each cell). The workbook is the team’s “Revisada” sheet made
by the engine: it prints the curated state (a person’s values, the canonical forms of names) and marks
what is still a machine’s proposal; nothing in it is edited by hand, and every value is visible as an
attribute in the app. Beside the workbook, when the recipe asks for it, one Word file per folder
with the transcript page by page. They can hand it all to the archive or the funder as it is, or open
the same columns in the app’s table, correct a cell, and export again. Nothing in it was invented at
export: every cell is an attribute of the folder as it stands, a fact of the folder (its name, its
files, its count, its blank pages), or a name the rows carry. A cell with no value is empty, never
guessed. Writing the workbook never runs a model (never-rerun-a-model-pass, #6127).
It lives in fichero-server/src/fichero_server/export_service.py (the Excel writer gains the
template sheet, more than one header row and the extra sheets; the Word writer gains its four options),
a new fichero-server/src/fichero_server/catalogue_sheet.py (to write: the row builder over a template,
the computed columns, the problems, the shared read the table uses), the shipped EAP prototypes
(attributes-and-prototypes.md, “The shipped prototypes”), two kept-export formats
(fichero-server/src/fichero_server/kept_export.py FORMATS), one route in
fichero-server/src/fichero_server/api/routes/ingest/export.py, the Catalogue stage’s prompt and
parser (fichero-server/src/fichero_server/recipes/catalogue_step.py) and the recipe’s export card in
fichero-server/src/fichero_server/recipes/runner.py. The app shows the template’s columns and edits
the cells (../ui/catalogue-table.md); no rule lives in Swift.
Prior art / best practices (don’t invent from scratch)
- The British Library Endangered Archives Programme (EAP) listing template. The team’s workbook is
it: a first header row in EAP English (Digital Folder Name, Digital File Name (First), Digital File
Name (Last), Number of Digital Files, Title (In Original Language/Script), Original Reference, Dates
of Material (Gregorian Calendar), Related Towns/Cities, Related Subjects), a second row in the team’s
Spanish, a third row of instructions per column. We ship it as the prototype
eap_digital_folder, both label languages and the instruction line, and the export writes all three rows (the team’s workbook has three; the earlier hand-built sheet had two). - ISAD(G) and EAD. Title, dates, extent, reference code, scope and content, access points. Every
column of the EAP prototype maps to one ISAD(G) element, so an EAD export later
(
catalogue.export-standards) reads the same template. - Access points, typed (ISAAR-CPF for persons and corporate bodies, the Getty TGN for places). An
archive’s access points are typed lists. Fichero’s Catalogue stage writes one untyped list
(
catalogue_access_points); this spec types it from the project’s own knowledge graph (an entity’s type), deterministically, and sends the untyped rest to Problemas. - EDTF (Library of Congress Extended Date/Time Format). The engine already stores
catalogue_date_normalisedas EDTF; the sheet RENDERS it in the archive’s format and never re-reads the Spanish text at export. How a date written in words becomes EDTF is “Dates done properly” below. - Excel’s own conventions for sheets handed around. Text cells for everything (a date written
“17/Marzo/1892” must not become a serial number); frozen header rows; UTF-8 with a byte-order mark
for CSV so Excel on a Spanish Mac reads accents. The existing writer (
export_excel_xlsx, hand-rolled SpreadsheetML, every cell an inline string) is kept and extended, not replaced by openpyxl. - Fichero’s own rulings honoured, not restated. No analysis in a catalogue (
catalogue.no-ai-analysis); a field is the folder’s attribute with its source (workflows/attribute_sources.py: a person’s value outranks a machine’s, a machine’s names its run and model); one audited action per capability; no local paths from the app (the workbook comes back as the response or goes to a kept export’s folder the person granted); deliverables come from the app, never a script beside it (owner 2026-10-29); one record per name, every list derived from it (kg.one.*).
Where each column comes from
The team’s thirteen headings, kept as given, as the declarations of the shipped prototype
eap_digital_folder in this order. “Source” is the column’s source (an attribute, or a computed
source from the fixed vocabulary attributes-and-prototypes.md names); “empty” says what the cell
holds when there is nothing.
| # | Heading (row 2, Spanish, as given) | EAP heading (row 1) | Source | Empty |
|---|---|---|---|---|
| 1 | Tu nombre | — | cataloguer: everyone involved in the row, from attribute_sources: each person who set or accepted any field of the row, by account name, then each model that set a field, as “Fichero ( |
Empty when no field has a source. |
| 2 | Nombre de carpeta digital | Digital Folder Name | folder.name |
Never empty. |
| 3 | Digital File Name (First) | Digital File Name (First) | folder.first_file: the original file stem of the folder’s first page in reading order (metadata.source_filename, else the page’s name). |
Empty when the folder has no pages. |
| 4 | Nombre de archivo digital (segundo) | Digital File Name (Last) | folder.last_file: the stem of the last page (“segundo” is the team’s word for the last file, _IMG_001 … _IMG_125). |
Empty; equal to 3 when one page. |
| 5 | Número de archivos digitales en carpeta | Number of Digital Files | folder.page_count: pages directly in the folder and its subfolders. |
“0”. |
| 6 | Título del documento | Title (In Original Language/Script) | attribute:catalogue_title |
Empty. |
| 7 | Referencia original de archivo físico | Original Reference | attribute:catalogue_original_reference, declared inherits: in the team’s workbook it is one archive-and-fonds string for a whole collection, so it is set once on the collection node and shows on every row until a folder states its own (a cover number, a legajo and folio the stage proposes only when the pages state one). |
Empty. |
| 8 | Fecha de inicio del proceso legal | Dates of Material (Gregorian Calendar) | attribute:catalogue_date_normalised through the format d/Mes/aaaa at the date’s precision: the START side of the EDTF interval (“17/Marzo/1892”; “Marzo/1892”; “1892”; in the team’s practice a year); ? and ~ are dropped and the uncertainty goes to Problemas. |
Empty when no normalised date; the reason goes to Problemas. |
| 9 | Lugares mencionados (Pueblos, regiones, países, ríos, quebradas, cerros, minas, fincas y otros referentes geográficos) | Related Towns/Cities | attribute:catalogue_places (typed from the access points, below), each the place’s canonical form, joined “ |
” (the revised sheet’s convention; the join is a declaration setting). |
| 10 | Nombres de personas mencionadas | — | attribute:catalogue_people, each as “Apellido, Nombre” from the entity’s surnames and given names (name_order: surname-first, a declaration setting), joined “ |
”. |
| 11 | Descripción breve del documento: ¿Sobre qué es? | — | description: the folder’s content when it is the promoted catalogue description (catalogue_content_from set), else the latest catalogue.narrative artifact’s text. |
Empty. |
| 12 | Problemas para resolver | — | problems: the person’s attribute:catalogue_problems, then the computed problems (below), each one clause, “; ” between. |
Empty. |
| 13 | — | Related Subjects | attribute:catalogue_subjects (NEW optional field, a multi_select over the prototype’s Tema vocabulary only, printed by its English label), joined “ |
”. |
Institutions are not a column of the EAP prototype; they are the Entidades sheet, from
attribute:catalogue_institutions. Columns 7, 12 and 13 are new optional fields of
catalogue.folder-description-fields (they join the stage’s option list; the stage never invents
them; 12 is a person’s field the model never fills).
Typed access points (decided)
catalogue_access_points is one list the Catalogue stage writes. At promote time
(folder.promote_catalogue, the re-parse step that already runs with no model) each point is looked
up in the project’s knowledge graph by canonical name or alias, exact after NFC and case folding: a
person goes to catalogue_people, a location to catalogue_places, an organization to
catalogue_institutions, and a point no entity matches goes to Problemas (“sin clasificar: Juzgado
del Circuito”) and, as ../kg/kg-entities-and-statements.md section D rules, becomes a PROPOSED name
in the graph (a mention on the folder’s description) for the person to resolve; once resolved, the
next promote types it. The three typed lists are ordinary attributes: a person edits them in the
table, the edit is theirs, a later promote never overwrites a person’s list (it may still fill an
empty one). No model, no heuristics on the words: the graph types, the person corrects. When the KG
spec’s stage 3 lands, the three lists are written by the engine from the record whenever a name’s
state or form changes in the folder’s scope (kg.attr.folder-names-are-attributes); this promote
rule is the same derivation run at promote, not a second source.
Dates done properly (decided: the catalogue prompt returns EDTF; the parser is the check)
The owner’s two ways were: (a) the Catalogue stage’s prompt asks the model for each date as EDTF beside the text, in a new prompt version, with the deterministic parser kept as the check; (b) a separate, cheap, text-only “Normalise dates” recipe step, never twice on the same input and model, cost shown first, the parser the free first pass and the check. This spec chooses (a), and extends the parser so a date written in words is read with no model at all.
- The prompt. The next version of the catalogue prompt (after
folder_description_v4; shipped versions are never edited in place) asks, for the Dates field, for the date as the records write it AND the same date as EDTF (a date, or an interval start/end,?for uncertain,~for approximate) with the words it read it from. The answer lands ascatalogue_dates(the text) and a machine proposal forcatalogue_date_normalised, its source the run, model and the words cited. - The parser is the free first pass and the check.
_date_normalised(catalogue_step.py,histdate) runs on the text at write and at every promote, with no model. It GAINS days and months written in words (“a los ocho días del mes de mayo de mil novecientos nueve” →1909-05-08; “primero de enero” →-01-01; “a mediados de” →~), beside the spoken years it already reads. - Agreement and disagreement. Parser and model agree (same EDTF after
_plain): the value is written, source the run, “checked by the parser”. The parser reads nothing and the model gave an EDTF: the model’s stands, source the run, Problemas says “fecha: leída por el modelo, sin comprobar”. The parser reads a date and the model gave none: the parser’s stands. Both read and DISAGREE: the cell stays EMPTY, the model’s EDTF is kept as the pending proposal, and Problemas carries both (“fecha: el analizador leyó 1909-05-08, el modelo 1909-05-18”) for the person to pick in the table; nothing is guessed. - A person’s correction always wins. Editing the normalised cell in the table makes it the
person’s (
catalogue.table.date-edit-keeps-the-group); a later promote or run never writes over it; Notas records the model’s and the parser’s readings beside the person’s. - Folders already described are not re-run (#6127). Promote re-parses their Dates text with the extended parser, so a date in words becomes EDTF with no model; a text the parser still cannot read goes to Problemas and the person fixes it once in the table. A person who wants the model’s reading on those folders runs the Catalogue stage again on purpose, at the cost shown.
Trade-offs. (a) costs nothing extra: the model is already reading the folder, and one more line in its answer is cheaper than a second call per folder; one prompt version, one source per value, no second step to schedule or explain. Its weakness is that it reaches only folders catalogued after the version ships, which the extended parser covers for the backlog. (b) would reach every folder at a small cost per folder and would let a project choose a cheaper model for dates than for description, but it adds a step, a cost card, a second model opinion on text the first model wrote, and a second place a date can be written; two models reading one Dates text is the disagreement case built in. If the extended parser proves not enough on a second archive’s wording, (b) can be added later as the backfill step, with the same agreement rules.
Computed problems
Read at export and in the table from facts the engine holds, never stored, each one clause:
- pages sorted blank (material = blank), as image ranges by file stem: “en blanco: IMG_017–IMG_019, IMG_025”;
- pages with no lines on the working pass and not blank: “sin líneas: IMG_031”;
- a gap in the image numbering: “faltan IMG_040–IMG_042”;
- lint_flags on the folder’s catalogue data, as written;
- a date the parser and the model read differently, or one only the model read: as “Dates done
properly” says; a Dates text with no normalised date at all: “fecha sin normalizar: «…»”;
- an uncertain or approximate normalised date: “fecha incierta”;
- untyped access points: “sin clasificar: Juzgado del Circuito”;
- a name in the row’s lists that is still only a proposal in the graph: “nombre sin confirmar: …”;
- a check rejection on a statement of the folder (checking/verdicts.py): “declaración rechazada: …”.
What the team’s workbook taught (structure only; decided)
The owner’s team’s real cataloguing workbook was read for its structure, never its rows; no name, title or reference from it is in the repo or a spec. Each pattern below is now a rule, and each rule is template data on the prototype or an attribute on a record, so the engine writes the same workbook deterministically from what Fichero holds, and the app shows every value as an attribute:
- One catalogue sheet per collection. The team keeps one sheet per collection of the same funder project. A collection is a node (a project, or a box that is a collection) carrying the prototype; the export writes one catalogue sheet per collection node in the scope, named by the collection.
- Three header rows. Row 1 the funder’s English, row 2 the team’s Spanish, row 3 an instruction
per column (how to write a date, how to separate places, to look in the Personas tab first). The
instruction is the declaration’s
help, in the team’s language. - The folder name, first and last file and count follow one pattern (a project code, the
collection, the box, a document number and a short title; the files that stem plus
_IMG_nnn): the fact columns, computed, never typed. - The original reference is the same string for a whole collection: set once on the collection
node, inherited by every row (
inherits), overridden where a folder differs. - The date is in practice a year: the EDTF start rendered at its precision does that by itself.
- The revised sheet is the canonical format: places joined “|”, people as “Apellido, Nombre” joined “|”; the earlier sheets used “; ” or “, ” and “Nombre Apellido”. The join and the name order are declaration settings, and the EAP preset carries the revised ones.
- Personas is an authority list: the compound form as used in the catalogue (a control column,
“do not edit”), given names, surnames, additional information (a relation, an office, a widow of),
comments, each with its own instruction. Lugares likewise: the place as used (control), the
corrected place, a place type from a fixed list, the place it lies inside, comments. These are
attributes of the canonical person and place entities (
given_names,surnames,note;place_type,parent_place), owned by the one record (../kg/kg-entities-and-statements.md,kg.attr.authority-sheet); the sheets print them, and a person corrects them in the app’s name lens, not in the sheet. The control column is the form the catalogue rows print, so a later re-import of a corrected sheet can match rows (not in the build order). - Tema is a controlled vocabulary of about 130 subject terms with English and Spanish side by
side: the
optionsofcatalogue_subjectson the prototype, two labels per term. - Faltantes lists folders that are missing or incomplete: a computed sheet of the catalogue rows whose Problemas names a gap, missing pages or an unresolved date, plus rows a person marked.
- Revisada is the curated state: the workbook prints a person’s values where there are any, the
canonical forms of names, and marks what is still only a machine’s proposal; a delivered export can
leave proposals out (
proposals: marked | omitted, marked by default).
The template is the prototype (decided)
A template is a prototype’s column set; there is no template setting of its own. Everything about
the shape (declared order, labels per language, join, date format, computed sources, the kind and
value cascades, how a column change copies a built-in) is attributes-and-prototypes.md. What this
spec adds:
- The export and the table use the effective prototype of the rows shown: a box carrying
eap_boxgives every folder beneath it theeap_digital_foldercolumns with no assignment per folder; a folder with its own kind shows its own columns; rows of mixed kinds show the union. Rows with no effective prototype get “everycatalogue_*key, alphabetically”: today’s Excel export. - Month names and the date format are declaration data on the prototype (
format: d/Mes/aaaa,monthsper language), not code. - A CSV of headings can be imported as a prototype with every column’s source “empty” until the person picks one (later; not in the build order).
The sheets
The catalogue sheets come first, one per collection node in the scope, each named by the collection
and the prototype’s label (“
- Faltantes: the catalogue rows (same columns) whose Problemas names a numbering gap, missing pages, an unresolved date or an unconfirmed name, or that a person marked incomplete.
- Personas, Lugares, Entidades (decided: from the rows, with the graph beside each): one
row per distinct value of
catalogue_people,catalogue_places,catalogue_institutionsacross the rows, sorted by the value, each with an instruction row under the headings. Personas: the compound form as the rows print it (the control column), given names, surnames, additional information, whether confirmed or proposed, the folders that name it, Comentarios. Lugares: the place as the rows print it (control), the canonical place, the place type (from the fixed list), the place it lies inside (the gazetteer’s parent where linked,knowledge/linked_places.py), confirmed or proposed, the folders, Comentarios. Entidades: the institution as printed, the canonical form, the folders, Comentarios. The authority fields are the entity’s attributes, read from the record, never typed into the sheet. - Tema: the prototype’s subject vocabulary, one row per term, English and Spanish labels.
- Catálogo: one row per folder, every
catalogue_*attribute as it stands, alphabetically, withcatalogue_datesandcatalogue_date_normalisedside by side. - Páginas: the existing Documents rows (
export_excel_xlsx’s page rows for the scope) with the folder name, the file stem, the page’s text. - Nombres: the ONE canonical set (“Names: one canonical set” below): the graph’s entities sourced
in the scope’s pages or folder descriptions that are not rejected and not merged away, one row per
entity: canonical name, the forms as written, type, confirmed or proposed, the folders, who made it
(a person; “Fichero (
)” with the run; “spaCy” for a draft still unresolved). No run-specific header note: the maker is a column. - Declaraciones: the statements (
find-statements) of the scope’s pages, as the existing Claims sheet, filtered to the scope: subject, verb, object, the page, the excerpt. - Notas: one row per (folder, column key, by: person name or “Fichero (
)”, step, run id, when, what the model said, accepted by). A computed column has no Notas row.
A sheet with no rows is still written with its header, so the workbook always has the same sheets.
Names: one canonical set (decided; the rules live in the KG spec)
Names in the catalogue’s lists and in Nombres may come from spaCy, from the chosen model (Sonnet or
another) or from a person. All three are PROPOSALS until a person resolves them into the project’s one
canonical set; the catalogue reads that set. ../kg/kg-entities-and-statements.md owns the rules and
this spec points at them rather than repeating them:
- a draft from any source is a proposal in the one record (
kg.names.model-step-is-the-source, the draft layer of its step 1.2); a person accepts, merges or rejects it (kg.resolve.*); a merge proposal is proposed, never automatic unless unmistakable (kg.resolve.folder-pass); - a superseded draft is taken back or absorbed, never kept as parallel data (
kg.one.entity-is-the-record,kg.one.entities-artifact-retired): nothing in this workbook reads a draft that a resolution replaced; - the folder’s three lists are derived from the record (
kg.attr.folder-names-are-attributes) and Nombres is read from the same record through the same scope function (kg.one.catalogue-reads-the-record).
spaCy es_core_news_sm on this archive: a free seed, never canonical by itself. The evidence from
B12 (the 2026-10-26 to 29 runs): “Juzgado” typed as a person; month names as entities; fragments of
names split at line ends; 397 places in one folder of court papers. That is the model doing what it
was trained for (modern news Spanish) on 1900s legal Spanish with line-broken words, and no threshold
fixes a type error. Where it fits: as the free first pass whose proposals a model step or a person
resolves, and as the thing that makes the lens non-empty before anyone has paid; in Nombres and in
the three lists only once resolved, and until then marked “propuesto (spaCy)” and never in a
delivered sheet unless the person asks for proposals. Not canonical by itself, not a source for the
exported authority sheets, and not rerun when a model step covers the same pages (the draft is taken
back as the model writes, KG step 1.2). Whether a project may count unrejected proposals in its
working catalogue is the KG spec’s question 2; this spec’s exports print confirmed names, and
proposals only when asked, marked (kg.attr.entry-shows-state).
The word transcript: a second template on the same record stream (#6287, decided)
The team also hands over the transcript folder by folder as Word. It is the existing Word emitter
(export_word_docx, export.word-and-excel-and-markdown-folder-are-typed-emitters [OK]) reading the
same record stream the workbook reads, with four options the B12 export of 2026-10-28 showed missing:
- Images off.
include_images: falseprints neither the image nor the “[Image not included…]” note; the page is its heading and its text. The default stays on for the Reader’s Word export and off for the kept transcript (26 GB of TIFF in a .docx is nobody’s deliverable). - A page break per page.
page_break_per_page: trueputs a page break before each page’s heading (today: a Heading 2 only). - Blank pages listed once per folder. A page the run sorted blank (
material = blank) prints no body (“No text content available.” 545 times was the B12 file); the folder ends with one line, “Páginas en blanco: IMG_017–IMG_019, IMG_025”, the same ranges as the Problemas clause, from the same function. - One file per folder in one call.
per: folderon the route returns a zip of one .docx per folder in the scope (named by the folder), or writes them as files into a named kept export’s folder; the kept-export formatword-transcriptdoes the same on the run’s last batch and on the quiet-period rewrite. One call for a box of twelve folders, not twelve.
Nothing else changes in the emitter: the heading per folder, the reading order, the derived text
(text_for) and the skipped-image record stay.
Formats
- XLSX: one workbook; sheet 1 the catalogue with its header rows frozen, every cell text, then the sheets above; written in the existing writer, in write-only order (a sheet is streamed as its rows are made).
- CSV: one file per sheet in a folder (
Catálogo/Catalogación básica.csv,Entidades.csv…), UTF-8 with a byte-order mark, comma-separated, every field quoted, CRLF, the header rows as the first lines. - DOCX (the transcript): one file per folder, as above.
- One row per folder; a folder with subfolders is one row for itself (its own pages’ count and files) and one row per subfolder, in sidebar order, so a box is a block of rows.
- Accents and ñ are written as the attribute holds them (NFC,
historical-text-normalization.md).
When it runs
- As the recipe’s last step. The export card (
recipes/runner.py_run_export) gains the formatscatalogue-workbookandword-transcript(one file per folder scope, not per page), kept as kept exports (kept_export.FORMATS,source.onboard.kept-exported), written after Catalogue on the run’s last batch, and rewritten by the quiet-period hook when a folder’s attributes, description or pages’ text change. A run with no Catalogue stage still writes the workbook, with the fact columns filled and the fields empty. - On demand from a folder or a project: File ▸ Export ▸ Catalogue workbook… and File ▸ Export ▸
Transcript (Word, per folder)…, the folder’s context menu, and the table view’s Export button,
through
POST /api/export/catalogue-workbook(scope: a folder or project id; formatxlsxorcsv; the rows’ effective prototype unless one is named) andPOST /api/export/wordwith the four options, returning the file (or zip) as the response, or writing into a kept export’s folder when one is named. No local path in the request. - CLI and MCP reach the same routes through the generated surface (
fichero export catalogue-workbook,fichero_export_catalogue_workbook; the Word route’s new options likewise). - Never a model. The builders read attributes, pages, entities, claims and the run’s record. A folder the Catalogue stage has not described has empty field columns; the Activity line says how many (“Catálogo.xlsx: 552 filas, 14 sin describir”).
- Steps are optional where the recipe says so. The export step reflects whatever ran. Leave-out
(
recipes-run.md) is an optional step, off by default, that a person turns on like any other; it is never fixed or always on, and the workbook never waits for it (owner, 2026-10-29).
At 100k pages
A collection of 100,000 pages is some thousands of folders. Folders are read in batches of 500 (one
query for the folders, one for their pages’ names, order and material, one for the attributes and
sources, one for the entities of the batch’s pages, one for the flags and verdicts: five queries per
batch, never one per folder); each batch’s rows go to the CSV or the write-only workbook as they are
made. The authority sheets keep only distinct values and folder names across batches. A workbook of
5,000 folders writes in under a minute on the M1; the job runs on the background database lane like
every kept export. The Word transcript streams one folder at a time into the zip.
Behaviors
catalogue.export.attr-columns-today— [OK] (#6267, #6270)POST /api/export/excel’s Documents sheet has folder rows and oneattr:<key>column per attribute key, alphabetical, withcatalogue_date_normalisedamong them; a promoted folder’s description prints once. This is what rows with no effective prototype get and stays as it is. Tests:fichero-server/tests/unit/api/test_routes_export.py::TestExcelExportAttributes,::TestCatalogueTextOnlyDroppedInExcel::test_excel_has_the_normalised_date_column.catalogue.export.one-row-per-folder— [GAP] (#6282) the catalogue sheet holds one row per folder in the scope, in sidebar order, a folder with subfolders first and its subfolders after it; the fact columns (name, first file, last file, page count) are filled for every row from the folder and its pages alone; file stems come frommetadata.source_filename, else the page name. Test: fichero-server/tests/unit/export/test_catalogue_sheet_rows.py.catalogue.export.cells-from-attributes-never-guessed— [GAP] (#6282) a field column holds the folder’s EFFECTIVE value of that key (its own, else inherited where the declaration says so, else the prototype’s default; a person’s value over a machine’s); a folder with no value has an empty cell; writing the workbook never calls a model or reruns the Catalogue stage (#6127); a run with no Catalogue stage writes the fact columns and leaves the fields empty. Test: fichero-server/tests/unit/export/test_catalogue_sheet_rows.py.catalogue.export.cataloguer-column— [GAP] (#6282) Tu nombre lists everyone involved in the row fromattribute_sources: each person who set or accepted a field, by account name, then each model that set a field as “Fichero ()”, persons first then models, each once, joined “; “; a row no source touched is empty. Test: fichero-server/tests/unit/export/test_catalogue_sheet_rows.py. catalogue.export.access-points-typed-by-the-graph— [GAP] (#6282, #6267)folder.promote_cataloguetypes each access point by the project’s entities (canonical name or alias, exact after NFC and case folding, no model) intocatalogue_people,catalogue_places,catalogue_institutions; an unmatched point goes to Problemas and is proposed into the graph as a mention on the folder description;catalogue_access_pointsis unchanged; a list a person set is never overwritten; the undo puts the lists back. Test: fichero-server/tests/unit/recipes/test_catalogue_access_points_typed.py.catalogue.export.people-places-institutions-columns— [GAP] (#6282) Lugares and Nombres readcatalogue_placesandcatalogue_peoplejoined “; ” in the attribute’s order; institutions never appear in either and go to the Entidades sheet. Test: fichero-server/tests/unit/export/test_catalogue_sheet_rows.py.catalogue.export.dates-in-the-archives-format— [GAP] (#6282, #6270) the date column renders the start side ofcatalogue_date_normalisedthrough the prototype’s format: “17/Marzo/1892” for a day, “Marzo/1892” for a month, “1892” for a year, empty when there is none;?and~are dropped and noted in Problemas; the month names come from the declaration, not the code;catalogue_datestext is never parsed at export. Test: fichero-server/tests/unit/export/test_catalogue_sheet_dates.py.catalogue.export.dates-prompt-returns-edtf— [GAP] (#6282, #6270) the next catalogue prompt version asks for each Dates value as text AND as EDTF with the words it was read from; the answer lands ascatalogue_datesand a machine proposal forcatalogue_date_normalisedciting the run, the model and the words; shipped prompt versions are not edited; described folders are not rerun. Test: fichero-server/tests/unit/recipes/test_catalogue_dates_prompt_edtf.py.catalogue.export.dates-parser-reads-words— [GAP] (#6282, #6270) the parser reads days and months written in Spanish words beside the years it already reads (“a los ocho días del mes de mayo de mil novecientos nueve” → 1909-05-08; “primero de enero de mil ochocientos noventa” → 1890-01-01; “a mediados de mayo de 1910” → 1910-05~), at write and at every promote, with no model. Test: fichero-server/tests/unit/recipes/test_catalogue_dates_in_words.py.catalogue.export.dates-parser-checks-the-model— [GAP] (#6282) agreement writes the value with the run as source and the parser as check; a model-only reading stands and is noted in Problemas; a parser-only reading stands; a disagreement leaves the cell empty, keeps the model’s EDTF as the pending proposal and puts both readings in Problemas; a person’s edit of the normalised cell wins and is never written over by a promote or a run. Test: fichero-server/tests/unit/recipes/test_catalogue_dates_parser_vs_model.py.catalogue.export.problems-column— [GAP] (#6282) Problemas para resolver joins the person’scatalogue_problems, then the computed clauses: blank pages as image ranges, pages without lines, numbering gaps,lint_flags, the date clauses above, an uncertain date, untyped access points, an unconfirmed name in the lists, a rejected statement; one function computes them for the export and the table; nothing computed is stored. Test: fichero-server/tests/unit/export/test_catalogue_sheet_problems.py.catalogue.export.new-optional-fields— [GAP] (#6087, #6282) the Catalogue stage’s option list gains Referencia original de archivo físico (catalogue_original_reference, declared inheriting and set once on the collection), Problemas para resolver (catalogue_problems, a person’s field the model never fills) and Related Subjects (catalogue_subjects, amulti_selectover the prototype’s Tema vocabulary only; a term outside it is refused in words); a model fills a field only from evidence in the records. Test: fichero-server/tests/unit/recipes/test_catalogue_stage_optional_fields.py.catalogue.export.template-is-the-prototype— [GAP] (#6282) the export’s columns are the effective prototype of the rows in the scope (attributes-and-prototypes.md): one kind, its columns in declared order with its header rows; mixed kinds, the union in first-seen order; no kind, everycatalogue_*key alphabetically; a named prototype on the request overrides; there is no template setting on the project and no template route. Test: fichero-server/tests/unit/export/test_catalogue_sheet_template.py.catalogue.export.eap-preset-reproduces-the-sheet— [GAP] (#6282) a box carryingeap_boxwrites its folders with the thirteen columns above in that order, row 1 the EAP English, row 2 the Spanish as given, both frozen, and a fixture of three folders (one described by a fake model, one typed by a person, one undescribed) renders cell for cell as the matrix says, from attributes alone, with no model client in the process. Test: fichero-server/tests/unit/export/test_catalogue_sheet_eap_preset.py.catalogue.export.authority-sheets-from-the-rows— [GAP] (#6282, #5380) Personas, Lugares and Entidades hold one row per distinct value of the typed attributes across the rows, sorted, with the control column as the rows print it and the entity’s authority fields from the record (given names, surnames, additional information for a person; canonical place, place type, parent place for a place; canonical form for an institution), confirmed or proposed, the folders that name it, an instruction row under the headings and an empty Comentarios; a sheet with no rows still has its header. Test: fichero-server/tests/unit/export/test_catalogue_sheet_authorities.py.catalogue.export.one-sheet-per-collection-three-header-rows— [GAP] (#6282) the export writes one catalogue sheet per collection node in the scope, named by the collection, each with the prototype’s label rows and its instruction row frozen; a scope that is one collection writes one sheet. Test: fichero-server/tests/unit/export/test_catalogue_sheet_formats.py.catalogue.export.faltantes-and-revisada— [GAP] (#6282) Faltantes holds the catalogue rows whose Problemas names a gap, missing pages, an unresolved date or an unconfirmed name, or that a person marked incomplete; the workbook prints a person’s values and canonical name forms and marks a machine’s proposals, andproposals: omittedleaves them out; Tema holds the prototype’s subject vocabulary with both labels. Test: fichero-server/tests/unit/export/test_catalogue_sheet_faltantes.py.catalogue.export.list-cells-follow-the-declaration— [GAP] (#6282) a list column is joined by the declaration’sjoin(“|” in the EAP preset) and people are printed by itsname_orderfrom the entity’s surnames and given names (“Apellido, Nombre”); a name with no split surname prints as written; the table and the export print the same string. Test: fichero-server/tests/unit/export/test_catalogue_sheet_rows.py.catalogue.export.names-sheet-is-the-canonical-set— [GAP] (#6282, #6283) Nombres holds one row per entity of the scope that is not rejected and not merged away, read through the KG spec’s scope function: canonical name, forms as written, type, confirmed or proposed, folders, maker; a resolved draft never appears twice; spaCy-only names appear only as “propuesto (spaCy)” and only when proposals are asked for; Declaraciones holds the statements step’s claims of the scope; Páginas holds the scope’s page rows; Catálogo holds everycatalogue_*attribute. Test: fichero-server/tests/unit/export/test_catalogue_sheet_kg_sheets.py.catalogue.export.notas-sheet-is-the-provenance— [GAP] (#6282) Notas holds one row per folder and attribute key with by, step, run, when, what the model said and accepted by, fromattribute_sources, the parser’s date reading included; a computed column has no row; a person’s one-field edit appears as theirs. Test: fichero-server/tests/unit/export/test_catalogue_sheet_notas.py.catalogue.export.xlsx-and-csv— [GAP] (#6282) XLSX is one workbook (the catalogue first with its header rows frozen, then the sheets in the order above, every cell text); CSV is one file per sheet, UTF-8 with a byte-order mark, every field quoted, CRLF; both carry accents and ñ unchanged. Test: fichero-server/tests/unit/export/test_catalogue_sheet_formats.py.catalogue.export.word-transcript-include-images— [OK] (#6287)POST /api/export/wordtakesinclude_images(default true, so the Reader’s export is unchanged); false prints neither image nor “Image not included” note and never resolves or opens the scan files. Test: fichero-server/tests/unit/api/test_export_word_transcript_options.py (test_images_off_has_no_media_and_no_note,test_images_off_never_touches_a_missing_scan,test_request_model_default_includes_images,test_default_and_true_embed_images).catalogue.export.word-transcript-options— [GAP] (#6287)POST /api/export/wordtakespage_break_per_page, and lists the folder’s blank pages once at its end as image ranges instead of a body per blank page, using the same range function as Problemas. Test: fichero-server/tests/unit/api/test_export_word_transcript_options.py.catalogue.export.word-transcript-per-folder— [GAP] (#6287)per: folderreturns a zip of one .docx per folder in the scope, or writes them into a named kept export’s folder; the kept-export formatword-transcriptwrites the same on the run’s last batch and the quiet-period rewrite; one call for a box. Test: fichero-server/tests/unit/export/test_word_transcript_per_folder.py.catalogue.export.recipe-last-step— [GAP] (#5485, #6282) the export card accepts the formatscatalogue-workbookandword-transcriptper folder scope; they are written after Catalogue on the run’s last batch, kept as kept exports and rewritten by the quiet-period hook when a folder’s attributes, description or pages’ text change. Test: fichero-server/tests/unit/recipes/test_recipe_export_catalogue_workbook.py.catalogue.export.on-demand-route— [GAP] (#6282)POST /api/export/catalogue-workbooktakes a folder or project id, a format and an optional prototype key, returns the file as the response or writes into a named kept export; the CLI and MCP reach it through the generated surface; the route runs the same builder as the recipe step. Test: fichero-server/tests/unit/api/test_export_catalogue_workbook_route.py.catalogue.export.app-doors— [GAP] (#6282, #507) File ▸ Export ▸ Catalogue workbook…, File ▸ Export ▸ Transcript (Word, per folder)…, the folder’s context menu and the table view’s Export button call the routes and offer a save panel; no local path is sent. Test: fichero/Tests/Unit/general/Views/Library/CatalogueExportCommandTests.swift.catalogue.export.streams-at-scale— [GAP] (#6282) folders are read and written in batches of 500 with five queries per batch and no per-folder query; a 5,000-folder workbook holds no more than one batch of rows in memory at a time; the transcript zip holds one folder at a time. Test: fichero-server/tests/unit/export/test_catalogue_sheet_streaming.py (counts queries on a fake database of 2,000 folders).
Build order
Depends on attributes-and-prototypes.md steps 1.1 to 1.3 (declarations, the two walks, templates
from prototypes and the shipped EAP prototypes) before stage 1 here.
Stage 1 — Dates and names done properly, then the sheet
Step 1.1 — The parser reads dates in words; the prompt returns EDTF; the parser checks it
- Behaviours:
catalogue.export.dates-parser-reads-words,catalogue.export.dates-prompt-returns-edtf,catalogue.export.dates-parser-checks-the-model - Tests: fichero-server/tests/unit/recipes/test_catalogue_dates_in_words.py, fichero-server/tests/unit/recipes/test_catalogue_dates_prompt_edtf.py, fichero-server/tests/unit/recipes/test_catalogue_dates_parser_vs_model.py
- Size: M
Step 1.2 — Typed access points at promote
- Behaviours:
catalogue.export.access-points-typed-by-the-graph - Tests: fichero-server/tests/unit/recipes/test_catalogue_access_points_typed.py
- Size: S
Step 1.3 — Rows from folders and effective attributes
- Behaviours:
catalogue.export.one-row-per-folder,catalogue.export.cells-from-attributes-never-guessed,catalogue.export.cataloguer-column,catalogue.export.people-places-institutions-columns,catalogue.export.dates-in-the-archives-format - Tests: fichero-server/tests/unit/export/test_catalogue_sheet_rows.py, fichero-server/tests/unit/export/test_catalogue_sheet_dates.py
- Depends on: 1.1, 1.2
- Size: M
Step 1.4 — List cells by the declaration
- Behaviours:
catalogue.export.list-cells-follow-the-declaration - Tests: fichero-server/tests/unit/export/test_catalogue_sheet_rows.py
- Depends on: 1.3
- Size: S
Step 1.5 — The template from the prototype, problems and the EAP sheet end to end
- Behaviours:
catalogue.export.template-is-the-prototype,catalogue.export.problems-column,catalogue.export.eap-preset-reproduces-the-sheet - Tests: fichero-server/tests/unit/export/test_catalogue_sheet_template.py, fichero-server/tests/unit/export/test_catalogue_sheet_problems.py, fichero-server/tests/unit/export/test_catalogue_sheet_eap_preset.py
- Depends on: 1.4
- Size: M
Stage 2 — The workbook, the transcript and their doors
Step 2.1 — The other sheets and the two formats
- Behaviours:
catalogue.export.authority-sheets-from-the-rows,catalogue.export.names-sheet-is-the-canonical-set,catalogue.export.notas-sheet-is-the-provenance,catalogue.export.xlsx-and-csv,catalogue.export.one-sheet-per-collection-three-header-rows - Tests: fichero-server/tests/unit/export/test_catalogue_sheet_authorities.py, fichero-server/tests/unit/export/test_catalogue_sheet_kg_sheets.py, fichero-server/tests/unit/export/test_catalogue_sheet_notas.py, fichero-server/tests/unit/export/test_catalogue_sheet_formats.py
- Size: M
Step 2.2 — Faltantes, Tema and the revised state
- Behaviours:
catalogue.export.faltantes-and-revisada - Tests: fichero-server/tests/unit/export/test_catalogue_sheet_faltantes.py
- Depends on: 2.1
- Size: S
Step 2.3 — The Word transcript’s options and one file per folder (#6287)
- Behaviours:
catalogue.export.word-transcript-options,catalogue.export.word-transcript-per-folder - Tests: fichero-server/tests/unit/api/test_export_word_transcript_options.py, fichero-server/tests/unit/export/test_word_transcript_per_folder.py
- Size: S
Step 2.4 — The route and the recipe step
- Behaviours:
catalogue.export.on-demand-route,catalogue.export.recipe-last-step,catalogue.export.streams-at-scale - Tests: fichero-server/tests/unit/api/test_export_catalogue_workbook_route.py, fichero-server/tests/unit/recipes/test_recipe_export_catalogue_workbook.py, fichero-server/tests/unit/export/test_catalogue_sheet_streaming.py
- Depends on: 2.2, 2.3
- Size: M
Step 2.5 — The new optional fields and the app’s doors
- Behaviours:
catalogue.export.new-optional-fields,catalogue.export.app-doors - Tests: fichero-server/tests/unit/recipes/test_catalogue_stage_optional_fields.py, fichero/Tests/Unit/general/Views/Library/CatalogueExportCommandTests.swift
- Depends on: 2.4
- Size: M
Stage 3 is the table view (../ui/catalogue-table.md), which depends on 1.5 here and on
attributes-and-prototypes.md step 2.1. The Nombres sheet reads the KG spec’s scope function
(kg.one.catalogue-reads-the-record); until that lands it reads the entity table with the same
filter, so step 2.1 does not wait on the KG stage 3.
Test matrix
To be filled at approval. Fixture: a scratch project of three folders under a box of kind eap_box
built with seed_test_library.py (one described by a fake model with typed access points and a date
in words, one typed by a person including a corrected date, one undescribed with two blank pages),
with the thirteen cells each row should render and the one-folder Word transcript; the team’s
workbook is never a fixture.
Rulings (owner, 2026-10-29; decided; the owner approved this spec the same afternoon. Status flips to APPROVED when the test matrix is filled and the manual section exists, as the guardrails require)
- Access points are typed by the knowledge graph at promote, with no model, into
catalogue_people,catalogue_placesandcatalogue_institutions; anything unmatched goes to Problemas. - Personas, Lugares and Entidades are built from the catalogue rows, with the graph’s canonical name beside each value.
- Dates are done properly: the catalogue prompt returns EDTF beside the text and the parser, extended to dates in words, is the free first pass and the check; a disagreement goes to Problemas; a person’s correction always wins (the choice and its trade-offs above).
- Tu nombre lists everyone involved in the row: persons first, then models as
“Fichero (
)”, joined “; “. - Names are one canonical set: drafts from spaCy, a model or a person are proposals resolved by a person; superseded drafts are not kept beside the result; Nombres shows the canonical set; spaCy is a seed, never canonical by itself.
- Templates come from prototypes and cascade (
attributes-and-prototypes.md): no template setting, no template route; the EAP preset is the prototypeeap_digital_folder. - The Word transcript (#6287) is a second template on the same record stream, with images off, a page break per page, blank pages once per folder and one file per folder in one call.
- The team’s real workbook is the model (structure only): one sheet per collection, three header rows, a collection-wide reference that cascades, “|” joins and surname-first people from the revised sheet, person and place authority fields on the canonical entities, a controlled Tema list, a Faltantes sheet, and the workbook as the revised state, generated deterministically from attributes and never edited by hand.
- Leave-out is an optional step, off by default, turned on like any other; the export step never depends on it.
Open questions for the owner
None open here. Mixed kinds in one scope is decided in attributes-and-prototypes.md (ruling 7);
which names count in a working catalogue is the KG spec’s question 2.