Skip to content

KG Tables — Design Spec (#4624 CRUD · #4625 filter)

Milestone: kg-tables

Design-led (Testing Constitution). The Fichero creative director owns this intent; tests enforce it; code makes them pass. One line per behavior, each cited by its pinning test. Status: APPROVED — creative director ratified; first-wave CRUD + filters shipped. Tags: [OK] today · [MISSING] not built · [PARTIAL] exists elsewhere, not in the table.

Intent (the design)

The entity table and the claim table are where a researcher browses, filters, and curates the knowledge graph — and hand-authors it, not only what AI extracted. Full CRUD lives in the tables themselves, every action is one audited, reversible backend action, and a change updates the one row in place (never a whole-table re-render). A filter narrows what’s shown without touching what exists.

Surfaces: EntitiesLibraryContent / EntitiesTableView, ClaimsLibraryContent / ClaimsTableView, EntityStore, ClaimStore, EntityService, backend api/routes/entity/* + api/routes/claim/* (CRUD already complete server-side).

Behaviors

A. Filter (#4625)

  • kg.tables.filter.text [OK] (Pinned: ClaimsFilterTests, EntitiesFilterTests.) — a per-table filter field narrows rows by text. Built: EntitiesLibraryContent.swift:~136 (TextField("Filter entities", ...) feeding entityMatches), ClaimsLibraryContent.swift:~163 (TextField("Filter claims", ...) feeding claimMatches).
  • kg.tables.filter.entity-type [OK] — an entity-type picker filters the entity table. Built: EntitiesLibraryContent.swift:~127-146 (availableTypes + type Menu). Pinned: EntitiesFilterTests.
  • kg.tables.filter.claim-type [PARTIAL] (#4768) — a claim type picker filters the claim table (built: ClaimsLibraryContent.swift:~151-176, availableTypes/type Menu filtering claimType), but there is no curation-state picker — the spec line originally promised “type / curation-state” and only type shipped.
  • kg.tables.filter.combines-with-search [OK] (Pinned: ClaimsFilterTests, EntitiesFilterTests.) — the per-table filter AND the shared ⌘F query both apply (intersection); neither clobbers the other. Built: entityMatches (EntitiesLibraryContent.swift:~109-123) / claimMatches (ClaimsLibraryContent.swift:~136-151) both check search and filterText.
  • kg.tables.filter.empty-state [OK] (Pinned: ClaimsFilterTests, EntitiesFilterTests.) — a filter that matches nothing shows “No matches”, never a blank table or a spinner. Built: ClaimsLibraryContent.swift:~192-203 (emptyMessage); EntitiesLibraryContent has the parallel empty state.
  • kg.tables.filter.pushdown (later) — for large sets, push the filter to the list endpoint (q / entity_type / claim_type already exist) instead of client-side only.

B. Entity CRUD (#4624)

  • kg.tables.entity.create [OK] (Pinned: EntitiesTableCreateTests.) — a “New Entity” affordance creates an entity (canonical name + type), selects the new row, and enters rename. Built: EntitiesLibraryContent.swift:~77 (showingCreateSheet/handleCreatedEntity), id kg.entity.new.
  • kg.tables.entity.rename-inline [OK] — the canonical name is editable from the table. Built: EntitiesTableView.swift:~118-125 (inline TextField + commitRename). Pinned: EntitiesTableCreateTests.
  • kg.tables.entity.retype [OK] — entity type is editable (keep). Pinned: KGInspectorCRUDUITests.
  • kg.tables.entity.delete [OK] — delete removes the entity and its claim links, undoable. Pinned: KGInspectorCRUDUITests.
  • kg.tables.entity.curate [PARTIAL] (#4801 asked for a pin, now given; → #1765, → #1786) — bless / reject / merge stay. Bless and reject are built and pinned: engine test_routes_entity_curation.py::TestBatchEntityCuration::test_batch_updates_entities_and_logs_mutations (state set, one MutationLog per changed entity, with the real actor) and ::test_batch_skips_unchanged_entities; app EntityStoreTests.testSetCurationUpdatesMatchingRowsInPlace (the store updates the matching rows in place). Merge from the table IS reachable (see kg.entity.menu.merge); it stays [PARTIAL] because split is not (see kg.entity.menu.split), and bless/reject of CLAIMS from their table is not (kg.tables.claim.curate). → #1786 asked for entity rename + “Add entity” specifically — both are now [OK] (kg.tables.entity.rename-inline/.create above); kept open as a verify-close candidate rather than closed by this pass, since curate (bless/reject/merge) itself is still only [PARTIAL]. → #1765 is the broader origin ask (approve/reject/edit entities AND claims, curation_state end-to-end) — its “also in WebKit” clause is moot now the KG browser has retired (#4828); what remains open is the inspector-only half this behavior tracks. Cross-ref: kg-readable-representation.md’s kg.read.edit-unit-is-the-claim covers a DIFFERENT surface reaching the same claim.patch action — editing FROM a rendered sentence rather than from this table — the two should stay in sync as both land.
  • kg.merge.repoints-subject — [PARTIAL] (#4859 still open pending close) a merge repoints a claim’s subject_entity_id to the survivor the same way it repoints entity_ids, and records each replaced value on the merge audit (claim_subject_repoints); unmerge restores exactly those claims, and only where the field still names the survivor, so a curator’s later correction is never overwritten. A merge never rewrites the subject’s canonical name or the claim’s sentence: it says two records are one entity, not what the source said. Both callers reach this code, entity.merge and review.accept. After a merge the readable paragraph gives role subject on the survivor’s page. Generalized to all five scalar entity-id fields, verified at HEAD (74d539720): speaker_entity_id, subject_of_inquiry_entity_id, scribe_entity_id and editor_entity_id — the four role fields this behavior previously listed as NOT repointed — now share merge_entities_impl’s one CLAIM_ENTITY_ID_FIELDS loop, the same list the entity writer’s dedup repointing already used, so the two paths cannot drift apart. Now actually tested, not only claimed (96e928783, found by this session’s own test audit — the code was right, the test file asserted only the subject): test_merge_repoints_subject.py::test_merge_repoints_every_scalar_role_field drives a real merge through the action layer and asserts all four role fields repoint while an unrelated subject is untouched; ::test_unmerge_restores_every_scalar_role_field asserts undo restores all four; ::test_audit_names_every_repointed_field asserts the merge audit names each one. PARTIAL, not OK, for the one reason that keeps #4859 open: rows merged before this fix are not rewritten, which is the maintainer’s decision (an audited, scan-first repair action, never a batch rewrite) — a data-migration gap, not a code or test gap. Pinned: fichero-server/tests/unit/api/test_merge_repoints_subject.py::TestMergeRepointsSubjectViaEntityMergeAction, ::TestMergeRepointsSubjectViaReviewAccept, ::test_merge_repoints_every_scalar_role_field, ::test_unmerge_restores_every_scalar_role_field, ::test_audit_names_every_repointed_field.
  • kg.delete.clears-entity-links — [PARTIAL] (implemented and tested, 017995dbe; #4863 still open pending close) a non-cascading entity.delete clears (sets to None) every scalar entity-id field a claim carries that names the deleted entity — subject_entity_id and its four siblings, speaker_entity_id, subject_of_inquiry_entity_id, scribe_entity_id, editor_entity_id — not only strips it from entity_ids, and records {claim_id, field, old_entity_id} for each field cleared, the same shape a merge records. Unlike a merge’s absorbed-but-tombstoned entity, a deleted entity no longer exists at all, so the correct link value is None, never a dangling dead id. Never touches subject_canonical or the claim’s text — the source still said what it said. Undo restores the full claim snapshot first (the pinned contract three existing tests require), then reapplies the recorded field clears as an additive, guarded restore; the cascade delete path removes the claim rows outright, so nothing is left dangling there either. This is the delete-side sibling of kg.merge.repoints-subject above — both now cover all five scalar entity-id fields (merge’s own field-completeness gap closed 74d539720/ 96e928783, see above). Pinned: fichero-server/tests/unit/api/test_delete_clears_entity_links.py::TestEachFieldClearsOnDelete, ::TestClaimCarryingSeveralFieldsAtOnce, ::TestDisplayFieldsNeverTouched.
  • kg.entity.menu.merge — [PARTIAL] (#4828, → #1675) merging duplicate entities is reachable in the app on two surfaces today: the Entities table’s row menu, “Merge N duplicates” (accessibility id kg.entity.menu.merge, EntitiesTableView; the survivor is the selected row with the most claims, EntitiesLibraryContent), and the Inspector’s Entities tab, whose Merge menu offers the survivor choices (DocumentInspectorEntitiesTab+Menus), plus EntityReconciliationSheet. What #4828 found unreachable is the standalone EntityMergeSheet, which has no call site: a THIRD merge surface, so whether to remount it or retire it is one-code-path-per-thing, not a missing capability. Underneath, the engine and CLI are built: POST /merge, POST /audit/{id}/undo and GET /audit (entity_curation.py), an EntityMergeAudit row per merge/split/undo (source ids, target id, alias changes, reversal_id, created_by), and the CLI entity merge, audit list, audit undo. Pinned: test_routes_entity_curation.py::TestMergeEntities::test_merge_writes_action_audit_and_emits, ::TestListEntityAudits::test_audit_appears_after_merge and ::TestUndoEntityOperation::test_undo_writes_action_audit_and_emits. Not built (#1675, narrowed 2026-09-26): the audit row has no reason, confidence, stage/run or approval-state field, and the audit-history view lives inside the unmounted EntityDetailView chain. Cross-references kg.tables.entity.curate above (#4801).
  • kg.entity.menu.split — [GAP] (#4828, → #1675) EntitySplitSheet splits a conflated entity. Same state as merge: the engine (POST /split, undoable through the same audit undo, test_routes_entity_curation.py::TestSplitEntity::test_split_unmerges_entity, ::TestSplitEntity::test_split_moves_aliases, ::TestSplitEntityAction::test_split_is_undoable_via_the_existing_undo_endpoint) and the CLI (entity split) are built; the sheet is unmounted in the app.
  • kg.entity.models-propose-merges — [OK] (#5409; built: the exact-name stage of _upsert_entity_matched in workflows/tools/_entity_writer.py, whose vector and SequenceMatcher stages now only propose; tested in fichero-server/tests/unit/kg/test_models_propose_merges_to_spec.py) a model run never decides that two names are one entity. Writing what it found, it adds the name to an existing entity of the same type only when the two are the same name once case, accents, punctuation and spacing are set aside, or when the name is already one of that entity’s names; otherwise it makes a new entity. A name that is only similar to an existing one (Don Tomas Polo and Don Joaquin Polo, Chocó department and Chocó, two names a vector model puts close together) is a new entity, and the pair goes into the entity review queue for a person to accept or reject, with what made them similar (method embedding_cosine or similar_name, and the score) in its reason. A person creating an entity is unchanged.
  • kg.entity.says-who-made-it — [OK] (#4869, #4868; built: KnowledgeEntity.add_attribution, upsert_entity(asserted_by=) from the names card and _write_kg_rows, the editor entry in entity.create/entity.update, GET /api/entities?run_id=, entity.take_back_run in api/routes/kg/nlp_draft_purge.py; tested in fichero-server/tests/unit/kg/test_entity_says_who_made_it_to_spec.py. Not yet naming their run: the citation and book-index writers, extract-all’s additional entities, and merge/dedup’s re-upsert; the import-time NLP draft names spaCy and its model but has no run) every entity says who put it in the knowledge graph, in its attribution_chain, the field that already says who asserted a claim: a model run as an extractor entry naming the provider, the model, the run (run_id) and when (at); a person as an editor entry naming the account and when. The engine writes these entries, never a client. They are only ever added: a later run that finds the same entity adds its own entry beside the others, and a person’s entry stays whatever runs follow. One run’s entities can be listed by its run (GET /api/entities?run_id=), and taken back through one audited action (entity.take_back_run, a dry run unless asked): it removes the entities that run alone made and nothing has touched since (no other run or person named on them, unreviewed, no claim naming them, and none of the touches entity.purge_nlp_draft counts); every other entity of the run stays, and the answer says how many and why. A review pair still waiting on an entity it removes leaves the queue as withdrawn by the system, its reason saying which entity and which run; it is never marked rejected, which is a person’s decision and teaches the matcher. A pair a person already decided keeps its decision.
  • kg.entity.variant-spellings-proposed — [OK] (#4508; built: spelling_key and the spelling_variants tier of plan_entity_dedupe in knowledge/dedupe.py, spelling_variants/propose on POST /api/kg/entity-curation/dedupe queuing through review.queue; tested in fichero-server/tests/unit/kg/test_variant_spellings_to_spec.py) a project’s knowledge graph is checked for entities of one type whose names are the same name written differently, and each is PROPOSED as a merge for a person to accept or reject; nothing is merged by the check. The same name written differently means equal once these are set aside: case, accents and punctuation; old spelling (Mosqera/Mosquera, Ysabel/Isabel, Ximénez/Jiménez, Gonçalez/Gonzalez, Baca/Vaca, a silent h, a doubled letter); the usual abbreviations of names (Fran.co/Francisco, Xpoval/Cristóbal, Glz/González); and titles (Don, Doña, Capitán, Fray). An abbreviation is read as one only where it is written as one: with a point or a raised letter (Fran.co, Pº), or a contraction that is no word (Xpoval, Glz); a plain word is the word (the surname Franco is not Fran.co). A number is never set aside (Dredge No. 1 and Dredge No. 3 stay apart), nor is any other letter. The check first answers with what it found (how many entities, how many groups, which names), writing nothing; asked to propose, it puts into the entity review queue (/api/kg/review/pairs) one pair for each entity, with the likeliest-to-stay entity whose own names match its own, never two that are only joined through a third (a name written three ways is two pairs): an entity holding a stray name of a second, which is a variant of a third, does not make the first and third a pair; each pair says why in its reason, with the two names in full as written, titles included, and in its method: name_variant for a spelling variant, duplicate_name for the same name or a name both hold. Accepting is the audited merge and rejecting is remembered: a pair already proposed, accepted or rejected is never proposed again. Asking it to merge the variants itself is refused. Curated (reviewed) entities are proposed like any other, since a person decides each pair; rejected and already-merged ones never are.
  • kg.tables.entity.alias-editing — [GAP] (#1688, narrowed 2026-09-26) an entity’s aliases can be added and removed, in the UI and in the CLI. Not built on either side: the entity detail’s aliases section only DISPLAYS them, EntityService.addEntityAliases has no caller, and entity update takes --name, --description and --type but no alias option. (Merge and split do move aliases; that is not editing them.)
  • kg.claim.contradiction-triage — [GAP] (#4828, → #1677) ContradictionTriageSheet triages contradicting claims. Not named before this pass. → #1677 is the broader ask: a review UI covering EVERY workflow stage (transcript → imported entities → merge → KG → ontology), of which contradiction triage is one stage.
  • kg.claim.review-queue — [GAP] (#4828, → #372) ClaimReviewQueueSheet, a review queue for unreviewed claims. Not named before this pass. → #372 is the original ask (queue with unreviewed → shortlisted → curated/rejected transitions, filter by person/topic, batch or single-item) — ClaimReviewQueueSheet exists but is unreachable since the browser retired, same as this behavior’s own [GAP] tag says.
  • kg.entity.kind-chart — [GAP] (#4828) EntityKindChartView, entity counts by kind. Not named before this pass.
  • kg.claim.speaker-comparison — [GAP] (#4828) SpeakerComparisonView, claims compared per speaker. Not named before this pass.

Enrichment’s two unreachable views (WikidataEnrichmentSheet, HeuristicReviewSheet) are kg/kg-enrichment.md’s (→ #4759, already named there) — not duplicated here.

C. Claim CRUD (#4624)

  • kg.tables.claim.create [OK] (Pinned: ClaimsTableCreateTests.) — a “New Claim” affordance hand-authors a claim, wiring POST /api/claims. Built: NewClaimSheet.swift (view), EntityService+ ClaimEntityCRUD.swift:~117 (createClaim), wired from ClaimsLibraryContent.swift (showingCreateSheet), id kg.claim.new.
  • kg.tables.claim.create.source-optional-flagged — a hand-authored claim MAY have no source (a working hypothesis / synthesis), but it is clearly marked “no source” and treated as lower-provenance; it is never silently indistinguishable from a sourced claim.
  • kg.tables.claim.edit [OK] (Pinned: ClaimsTableCreateTests.) — subject/verb/object + fields editable from the claim table is built: ClaimsTableView.swift:~168-172 wires an onEdit menu item (kg.claim.menu.edit) that opens the existing EditClaimSheet (S·V·O + type + epistemic status, PATCH /api/claims/{id}), reused rather than re-parsed. Shipped in a166ad3ee (2026-09-08, “feat(kg-tables): edit a claim from the claims table (#4624)”). The row-level claimMenu doc-comment’s “edit / curate arrive in later kg-tables waves” is stale for edit (curate is the real remaining gap — see kg.tables.claim.curate below). Pinned: fichero/Tests/Unit/general/Views/Library/ClaimsTableCreateTests.swift::testClaimsTableOffersEditReusingTheExistingSVOEditor.
  • kg.tables.claim.delete [PARTIAL] (#4643, → #1787) — delete from the table is built: ClaimsTableView.swift:~166-183 (onDelete menu item, kg.claim.menu.delete) → ClaimsLibraryContent.swift:~254-268 (deleteClaims, one audited delete per id, reloads on failure). Left [PARTIAL] pending kg.scale.batch-delete (sequential per-id calls, no batch endpoint yet — see Scale section below). → #1787 asks that claim delete be wired EVERYWHERE approve/reject/suppress live, single + multi, with confirm — the table’s own delete is built; approve/reject/suppress-with-confirm is kg.tables.claim.curate below, still [PARTIAL].
  • kg.tables.claim.delete-confirms — [GAP] (#1787, narrowed 2026-09-26) deleting claims from the Claims table and from the inspector asks for confirmation, as the entity detail’s delete does. Not built: ClaimsLibraryContent.deleteClaims deletes a multi-selection immediately, and the inspector’s action does the same (KnowledgeGraphInspectorSection+Actions.swift); only EntityDetailView+Claims words a confirmation. The delete itself is built and pinned at the store level (ClaimStoreTests.testDeleteRemovesEachSucceededIdInPlaceWithoutReload, .testDeletePartialFailureRemovesOnlyTheSucceededPrefix).
  • kg.tables.claim.curate [PARTIAL] (#4691, → #1751) — bless / reject / merge from the table. Verified against code (2026-09-18): ClaimsTableView.swift:158-184 (claimMenu) offers only Edit and Delete — no bless/reject/merge menu item. Curation is reachable only via batch MCP tools, not the table row menu (#4691, table row “claim curate … from table ✗ ClaimsTableView.swift:158 ‘Delete only, for now’”). → #1751 is a broader EPIC (unified multi-select curate/enable/disable/group/delete/merge across files, folders, entities AND claims) — kept open here for its entities/claims slice specifically; the files/folders half is sidebar-crud.md’s territory, not this spec’s.

C2. Deeper CRUD/UX asks not yet behaviors

  • kg.tables.entity.descendant-aggregation-provenance — [GAP] (#2020) when a folder/PDF in the sidebar is selected, the entity panel aggregates the entities of the selection AND all its children UNDIFFERENTIATED — no way to tell which document an entity came from. Wanted: convert the flat List to a Table with a clickable “Found in” column (entity-grouped, default includes-children), so descendant aggregation stays but gains provenance.
  • kg.tables.schema-complete-field-display — [GAP] (#1768) the inspector surfaces only a few fields of the KG data model’s rich schema; wanted: show the COMPLETE field set of a claim/entity/artifact so a user can see everything that’s actually available, not a curated subset chosen ahead of time.
  • kg.tables.entity.row-density-and-source-count — [GAP] (#1788) entity rows are full-width; lozenges should be text-width and flow/wrap into rows for information density, and the source count shown per row should be accurate (some entities have several sources and the count under-reports).
  • kg.tables.crud.inline-not-modal — [GAP] (#1888) claim/entity editing today happens in modal sheets (EditClaimSheet, OntologyBrowser’s create sheet, EntityMergeSheet/ EntitySplitSheet) — the ask is inline editing / navigation-with-Back instead of a sheet stack, matching the Finder-like direct-manipulation principle the rest of the tables follow.
  • kg.tables.entity-resolution-registry — [PARTIAL] (#1761, audited 2026-09-26) a persisted “entity checker”: human fixes on existing entities CONSTRAIN future imports. BUILT: an EntityResolutionRule store (types suppress, merge_into, reclassify, alias), CRUD routes (/api/kg/curation-rules), and enforcement in upsert_entity before the 3-stage match, under the entity upsert lock (suppress skips, reclassify overrides the type, merge_into/alias redirect; matching is case-insensitive and trimmed; a redirect cycle suppresses the write). Pinned: test_entity_writer.py::TestUpsertEntity::test_suppress_rule_returns_none, ::TestUpsertEntity::test_merge_into_rule_folds_name_into_target, ::TestUpsertEntity::test_reclassify_rule_overrides_type, ::TestWriterGateRules::test_import_rule_then_second_import_honors_persistent_merge, and the rule CRUD routes in test_routes_kg_curation_rules.py. Built from #5072: the verbs write their rule in the SAME audited action, before the change, so a rule that cannot be written fails the verb and nothing changes. Entity delete writes a suppress rule for its name and aliases, scoped to its type; an entity type change writes a reclassify rule (and supersedes the opposite one, so A -> B -> A cannot form a cycle, which the resolver answers by suppressing the mention); undo removes what the undone action wrote and restores what it superseded; the delete that is the inverse of a create is not a correction and writes nothing. Entity rename to a different name writes an alias rule (old name -> new name, for the old type and, when the type changed too, the new one, and superseding the opposite rule so A -> B -> A is not a cycle); a case-only rename writes none. Pinned by the full round trip (verb -> rule row -> re-import -> correction held) in test_entity_verbs_write_rules.py::TestDeleteWritesASuppressRule, ::TestReclassifyWritesAReclassifyRule and ::TestRenameWritesAnAliasRule, including that a rule which cannot be written fails the verb with the entity unchanged. A merge holds through the absorbed entity’s tombstone (merged_into_id), not a rule row; #5079 fixed the claim half: upsert_entity resolves every match to the LIVE entity through follow_merge_chain (any number of hops; a cycle, a dangling pointer or a runaway chain raises MergeChainError), and the mention scan maps an absorbed spelling to the live entity. Pinned: test_entity_writer.py::TestWriterGateRules::test_a_claim_written_after_a_merge_names_the_survivor, ::TestFollowMergeChain::test_two_real_merges_deep_a_reimport_lands_on_the_final_survivor, ::TestFollowMergeChain::test_a_cycle_is_a_typed_error_not_a_hang. Claim delete writes a ClaimSuppressionRule with action prune in the same action (#5074): disable and demote still write the claim, as rejected, so it would come back as a row to dismiss again; only prune makes the writer skip it. The rule matches the claim’s subject/verb/object AND is scoped to its source document (match_source_document_id, new; None means the whole library, as every earlier rule), so the same statement on another page is still written. A claim with no complete triple (hand-authored) writes no rule. Pinned: test_claim_delete_writes_rule.py::TestClaimDeleteWritesAPruneRule (round trip through the real extraction writer, scope, undo, undo-of-create, failure leaves the claim). Split needs no rule: its durable record is the un-merged row (merged_into_id=None), which the writer matches by name and the dedupe planner never recombines. The one gap was the absorbed spelling a merge can leave as an alias on the primary, so the split now removes the split-off names from the primary in the same action (undo restores them). Pinned: test_split_survives_reimport.py::TestSplitSurvivesReimport. Reject: a re-import that matches a rejected entity drops the mention (upsert_entity returns None), as a suppress rule does. Pinned: test_entity_reject_survives_reimport.py::TestRejectedEntityStaysRejectedOnReimport. Ties to the standing curation-persists-and-constrains-imports ruling; the importer’s own NLP-draft half is importer.md’s importer.nlp-never-overwrites-curated-rows.

D. Cross-cutting (both tables)

  • kg.tables.crud.cross-surface (creative-director ruling, 2026-09-08) — every KG CRUD capability (claim delete, entity create, claim create, edits) must exist across the WHOLE spine: backend → UX (table) → AI (MCP) + CLI → tests → export, not only the SwiftUI table. A capability that works in the table but not via MCP/CLI is NOT done. Each CRUD behavior below is delivered in all surfaces or tracked as an explicit gap.
  • kg.tables.crud.audited — [PARTIAL] every create/edit/delete is ONE typed, audited backend action, reversible via the mutation log (one-audited-action-layer, a standing HARD rule). The table’s OWN create/edit/delete/curate paths ARE compliant (sections B/C above). The rule itself — as a cross-cutting, testable claim spanning the whole KG/entity surface, not just this table — now has its own spec and guardrail: → #4831, harness/audited-action-layer.md (audit.every-mutating-route-uses-the-registry, scripts/check_routes_use_action_layer.py). Not duplicated here.
  • kg.tables.crud.in-place — [PARTIAL] (#4389, e00eb0260) a create/edit/delete updates that one row in place. The residue is the rest of #4389’s own acceptance: that the selection and the scroll position survive the update. e00eb0260 explicitly did not address either, so the row is right and the view around it still jumps. Retagged from [OK] on 2026-09-26 when the issue was reopened — the tag had recorded the fix, not the acceptance in place; the table is not wholesale re-rendered (stores update one item, not the list). Merging entities now removes the absorbed rows in place and patches the survivor from ONE getEntity, with no inspector re-fetch. Pinned: EntityStoreTests.testMergeRemovesAbsorbedRowsAndPatchesTheSurvivorInPlaceWithoutReloading (asserts exactly one inspector GET across the merge). Not asserted: that selection and scroll position survive the merge (the fixing commit says it did not address those criteria).
  • kg.tables.crud.validation — a create/edit is validated at the boundary (non-empty canonical name; a claim needs at least a subject or text); an invalid input is refused with an inline reason, not silently dropped.

D2. Maintainer test findings, 2026-09-19 (B1-B4)

  • kg.tables.pane-kind-mismatch — [PARTIAL] (#4884, bba51e49f; NOT YET SEEN ON SCREEN — this was reported as a visual symptom, the chip saying Claims while the pane rendered Entities, and passing unit tests are not that symptom being gone. Retagged from [OK] 2026-09-26; needs a human at a screen to close. The design root is tracked as panes.model.per-pane-scope-and-kind-unread in panes-workspaces.md) a Library pane with its kind chip set to Claims renders the CLAIMS table: the decision is one pure function, LibraryView.effectiveKind — a pane with an explicit kind shows it (choosing Documents is as explicit as choosing Claims, so Library panes are not linked); a pane without one follows the window; the local value survives only as the compact iPhone reader’s fallback. The pane’s config reaches the view as an environment value injected per leaf. Pinned: LibraryEffectiveContentKindTests.windowDecidesWhenNothingIsExplicit, .explicitPaneKindWinsOverWindow, .localOverrideWinsWhenThereIsNoSlot, .paneKindTakesPrecedenceOverLocalOverride. The fixing commit says NOT seen on screen.
  • kg.tables.folder-scope-misses-subfolders — [PARTIAL] (#4885 still open pending close) seen live: with a FOLDER selected, the Entities (and Claims) pane reports “No entities in this folder yet” while the Inspector, looking at the same folder, lists 22 people. Cause, VERIFIED by reading on both sides (not a hypothesis): the app scopes to a folder’s DIRECT child documents only; the engine already HAS recursion, but it is inconsistently opt-in across three separate routes — include_descendants on /api/claims (opt-in), always-on for the entities document_id filter, and include_children on the document knowledge-graph route. No new endpoint is needed; the fix is choosing one consistent default (or one consistent flag) across the three, not building a fourth. The three inconsistent knobs are recorded as their own gap below, distinct from this specific symptom. App half FIXED, 2026-09-19 (6fd114270): the Entities table now scopes by the folder’s own id through EntityStore.loadAggregatedEntities — the Inspector’s own seam, whose route already recurses the whole subtree server-side — instead of collecting direct child documents client-side and filtering against that set; the client-side direct-children filter is deleted outright, one definition of “this folder’s knowledge.” Claims needed no change (its load already asked for descendants); a new test asserts include_descendants=true is actually sent. Tests executed: EntityStoreTests, EntityServiceTransportTests, SelectAllVisibleSurfaceTests, EntitiesTableCreateTests (plus a filter-bar suite since replaced by KGFilterBarGuardrailTests) — 56 of 56 passing. Not seen working on screen, per the fixing commit’s own words. PARTIAL, not OK: the engine’s three inconsistent recursion switches this behavior originally named are still open, tracked as their own gap immediately below — this fix routes the CLIENT through the one already-recursing seam, it does not unify the engine’s own three knobs.
  • kg.tables.folder-recursion-inconsistent-across-routes — [PARTIAL] (#4885; ruled and built in #5065, not yet built through the generated clients) one flag, one default: include_descendants, default TRUE, on GET /api/claims, the entity list (document_id) and the document knowledge-graph route, with include_descendants=false meaning the document alone. Replaces include_children, which an implicit or len(descendant_ids) > 1 had made a no-op wherever it mattered. Pinned: test_folder_recursion_consistency.py::TestFolderQueriesRecurseByDefault::test_all_three_routes_agree_with_no_flags, ::TestFolderQueriesRecurseByDefault::test_shared_query_functions_default_to_recursive, and, for the opt-out, ::TestNonRecursiveIsStillExpressible::test_knowledge_graph_route_false_is_the_folder_alone; the walk is test_descendant_doc_ids.py::TestDescendantDocIds. Remaining, so this stays [PARTIAL]: the knowledge-graph RESPONSE still names its echoed field include_children, and the Swift client, the generated CLI surface and openapi.json still describe the old parameter until they are regenerated; the app’s Inspector “include children” toggle sends the retired parameter and so has no effect on this route (it had none for a folder before either).
  • kg.tables.filter-bar-and-footer-are-two-controls — [GAP, DESIGN] (#4856) the two-bars state is BUILT AWAY (c98abbfa7): each table now draws no bar of its own, and the Library’s bottom bar is the one footer, holding the filter, the type menu and the add control (a plus that creates what the pane shows). What stays [GAP] is what the issue still asks and nobody has decided: whether Merge and the other curation verbs fold into that one bar, whether “New Entity”/import stay visually distinct from them, and a review of EVERY pane footer. Also unconfirmed: that the filter fits at normal widths and that the table’s scroller no longer overlaps its last row (the commit says NOT seen on screen). Pinned by the guardrail below.
  • kg.tables.entities-master-claims-detail — [PARTIAL] (#4886, e6a57d4f4; the maintainer’s ruling, 2026-09-19) Entities in one Library pane, Claims in the pane below, is a MASTER/DETAIL pair — selecting an entity in the Entities pane narrows the Claims pane to that entity’s claims, under a header naming the entity, with a pane-local Show All that resets when the focus changes. Built through the existing per-library ClaimStore.loadClaims(forEntity:) (no second loader). Pinned: ClaimsLibraryContentScopeTests.focusWinsOverFolder, .overrideWinsOverFocus, .newFocusResets, .sameFocusDoesNotReset; ClaimsLibraryContentEntityScopeWiringTests.testStorePreSeededForAnEntityYieldsThoseClaimsToTheNarrowedScope, .testAFolderChangeWhileAnEntityIsFocusedStaysEntityScoped. PARTIAL, not OK: not seen on screen, and two windows on one library still share one claim scope (a separately filed defect). Ruled 2026-10-04 (#4886): any Entities pane drives the Claims pane directly below it (not only the one Library pair), and with no entity selected the Claims pane shows every claim in its scope. See kg.tables.any-entities-pane-drives-the-claims-below.
  • kg.tables.any-entities-pane-drives-the-claims-below — [GAP] (#4886) every Entities pane drives the Claims pane directly below it in the same column, in any workspace; when no entity is selected that Claims pane shows all claims in its own scope, never an empty list. (Ruled 2026-10-04.)

E. Provenance + versions (creative-director priority — likely its own cross-cutting spec)

  • kg.tables.provenance.author-mark — a claim/entity shows who AUTHORED it: hand-authored (human) vs AI-extracted, at a glance. (Backend records created_by.) The concrete defect in today’s created_by — machine-extracted claims stored and badged as human-authored — is tracked as kg.claim.provenance-kind-is-server-stated in hermeneutic-layer.md (same milestone as its two issues), not duplicated here.
  • kg.tables.provenance.modification-chain — and who MODIFIED it, in order, each step labeled by the actor: an AI extractor (e.g. “apple-vision”, “sonnet-5.1”) or a human. “Apple Vision created it → Sonnet 5.1 changed it → hand-edited by you” is visible.
  • kg.tables.provenance.hand-edit-recorded — a manual edit is recorded AS a human edit in the chain (never attributed to the model whose value it replaced).
  • kg.tables.provenance.careful-versions — [GAP] (#4644) versions are kept carefully so a prior value is recoverable, not just overwritten. (Backend has a MutationLog for undo; surface it.) #4644 is the broader ask this section’s provenance/version display answers: drag&drop reorganization, a full context menu (merge/combine/comment), select→export JSON-LD, and surfacing claim provenance/geo/time alongside versioning. The export-JSON-LD half is kg-enrichment.md’s kg.jsonld.export (already [OK]) wired to a table selection, not a new export mechanism; drag&drop/context-menu/comment are UI affordances not yet built on top of the CRUD this spec already tracks.
  • NOTE: this is the foundational provenance + version model now specced separately at #4636 (full restorable history · reasoning capture · extensible actor roles extractor/editor/reviewer/end-user + representation types) — sibling of the anchor model #4635. The KG tables are its FIRST surface, but the display here rides that model. So Section E lands AFTER #4636’s model exists; the tables’ first waves (filters + CRUD below) do NOT block on it and ship first.

Scale, bulk operations & first-class library view (creative-director review, 2026-09-09)

The tables must behave like a first-class Mac library view at archive scale (1k–100k entities/claims), not a small demo table. What’s missing:

Bulk operations at scale

  • kg.scale.batch-delete [MISSING backend] (#4643) — deleting N rows is N sequential per-id DELETEs today (1000 rows = 1000 round-trips) AND aborts mid-loop on one failure, leaving the UX showing already-deleted rows (drift). Batch endpoints EXIST for transition / upsert / curation (/api/claims/batch/transition, /api/entities/batch, /api/kg/claims/batch- curation) but not delete — add POST /api/claims/batch-delete + /api/entities/ batch-delete (one audited, atomic-where-possible action) so 1k–10k is one call.
  • kg.scale.bulk-correctness — a bulk op prunes exactly the rows that SUCCEEDED (partial failure never drifts the UX); interim client fix = bounded-concurrent deletes collecting successes (reuse the BatchService maxConcurrent task-group pattern) until the endpoint lands.
  • kg.scale.normalize-names [MISSING] (#4643) — a bulk “normalize / canonicalize names” op over a selection (or the whole library) via a provider/AI: fold “Matheo del Mazo” / “Mateo del Mazo” to a canonical form + merge. A batch curation/enrichment action, not per-row.
  • kg.scale.progress-cancel — a long bulk op shows progress and is cancellable; bounded so it never pegs the machine (Article 4 resource-safety).

First-class library view affordances

  • kg.view.contiguous-selection [OK] — Set-based selection gives shift-click range + ⌘-click; keep it. Pinned: SelectionGrammarTests.
  • kg.view.full-row-click-target — [GAP] (#4607) the entities list’s click target should be the FULL row, not just the label text, so the existing selection grammar (kg.view.contiguous-selection above) is easy to invoke for bulk curation — today a narrower click target makes multi-select fiddly even though the underlying grammar works.
  • kg.view.keyboard-delete — [BROKEN] (#4794, #4851) ⌘⌫ deletes the selection; ⌘A selects all — same selection grammar as every other library mode, enforced by check_selection_grammar.py. Was mis-cited to the four-selection-implementations root-cause issue (long since closed — that unified only the CLICK grammar, not keyboard delete/ select-all); the actual remainder was filed as #4794, and #4851 is the SAME ⌘A gap reported again from tonight’s maintainer testing with a sharper diagnosis — not a separate defect. Retagged from [MISSING] to BROKEN: verified on disk that ⌘A is not merely unimplemented but actively routed elsewhere — LibraryView+TableView.swift is the only file in the tree wiring a Select-All shortcut; neither EntitiesTableView.swift nor ClaimsTableView.swift handles selectAll at all, so a window-level ⌘A reaches the document browser’s own selection and leaves the KG table’s selection exactly where it was (one row), matching #4851’s own diagnosis exactly.
  • kg.view.type-icons [MISSING] (#4643) — rows use the per-type icons that ALREADY exist (KnowledgeGraphSupport: person/place/org/event/concept/date), not one flat glyph.
  • kg.view.pagination [MISSING at 10k] (#4643) — the table loads up to 25 000 client-side; at 10k+ push filter/scope to the list endpoint (filter.pushdown) and page, so memory and first-paint stay bounded.
  • kg.view.filter-bar-at-bottom — [PARTIAL] (#4856 still open) the Entities and Claims tables draw NO filter bar of their own any more (c98abbfa7 superseded the earlier “bar at the bottom” ruling): the filter text, type menu and add control live in the ONE shared bottom footer (LibraryView+BottomActionBar.swift), so the placement agrees with the Library pane by construction. This behavior’s earlier tests (the bar is after the table) went away with that design and are NOT replaced by positive tests: what is pinned is the absence and the counts — KGFilterBarGuardrailTests.testEntitiesContentDeclaresNoFilterBarOfItsOwn, .testClaimsContentDeclaresNoFilterBarOfItsOwn, .testSharedFooterDeclaresOneFilterSlotUsedByBothContentKinds, .testSharedFooterHasExactlyOneAddControlPerRender. Not pinned: that the filter actually fits or collapses at a given width (KGFooterCollapseGuardrailTests pins only that it is not in the always-inline tier and that two triggers share one popover).
  • kg.tables.select-all-answers-from-the-visible-rows — [PARTIAL] (fixed ed67ec111; #4851 stays open for the maintainer to confirm on screen) ⌘A in the Entities or Claims table selects what the table actually SHOWS, from ONE owner of the command. Was BROKEN: the Entities branch answered from a second, LibraryView-owned entity pipeline that had drifted from what the table itself rendered (its own filter text and type, intersected with the shared search); Claims had no branch at all. Fixed: both tables now report their visible ids through onVisibleIds — the same shape the dataset modes already use — and selectAllIds answers from those published ids, not a second computation. Pinned: SelectAllVisibleSurfaceTests.entitiesTablePublishesVisibleIds, .claimsTablePublishesVisibleIds, .selectAllIdsUsesThePublishedTableIds (file fichero/Tests/Unit/general/Views/Library/SelectAllVisibleSurfaceTests.swift, suite SelectAllVisibleSurfaceTests).

Test coverage for scale — Swift + UX + backend + LOAD (all required)

Behavior Backend (pytest) Swift (unit) UX (XCUITest) Load/background (#4634)
batch-delete endpoint deletes N atomically; audited; partial reported bounded/bulk delete prunes only successes select many → delete → rows gone delete 1k/10k bounded, no peg, timed
normalize-names batch op merges canonically pure canonicalize rule select → normalize → merged 10k under bounded concurrency
keyboard-delete — command maps to delete action ⌘⌫ removes selection —
type-icons — pure icon-for-type mapping rows show right icons —

Accessibility identifiers (for the click-around leg — TEST-TEMPLATE)

Stable ids on the KG tables so an XCUITest can find + act on them: - rows: kg.entity.row.<id> · kg.claim.row.<id> - entity menu: kg.entity.menu.{rename,edit,bless,reject,merge,delete} - claim menu: kg.claim.menu.{edit,delete} - create: kg.entity.new · kg.claim.new

(More as interaction verbs land — comment/export/combine, #4644.)

First wave to pin (proposed)

  1. Filters: filter.text + filter.entity-type / filter.claim-type + filter.combines-with-search + filter.empty-state (#4625) — cleanest, no backend work.
  2. Claim delete + entity create in the table (wire existing store/service).
  3. Claim create (new POST /api/claims wiring: service + store + a NewClaimSheet).
  4. Inline entity rename + claim edit-from-table.

Manual-create is intended (creative-director ruling, 2026-09-08)

A researcher can hand-author a claim/entity, not just curate AI output — a real archival workflow. So claim.create (POST /api/claims) and entity.create in the table are in scope, with crud.validation guarding the input.

Findings (from the CRUD-matrix scout, this session) — HISTORICAL, superseded 2026-09-17

Backend CRUD is COMPLETE (entities + claims: create/read/update/delete + curation). The gaps are all UI wiring: claim create missing (no service/store/view); claim edit/delete exist but are wired only into the Ontology cards, not the claim TABLE; the entity table lacks create + inline rename. Filters are client-side on the shared ⌘F query only — no dedicated field. (Details on #4624 / #4625.)

This is now FALSE — claim create, entity create, entity inline rename, and per-table filter fields (text + entity-type/claim-type) all shipped since this scout ran; see the retagged behaviors in sections A/B/C above for current evidence. Kept for history, not as current status.

Triaged from the backlog (2026-10-04)

  • kg.claim-dates-anchor-warrant-visible — [GAP] (#4659) claim tables and inspector show and let the user author dates (time_start/date_values), source_anchor, Toulmin warrant and structured location.
  • kg.tables.research-layout-follows-entity-or-claim — [GAP] (#4978) clicking an entity or a claim fills a related-library-items list, and clicking one opens it in the Preview at the highlighted place.
  • kg.tables.claims-held-in-a-store — [GAP] (#4984) the Claims table’s data lives in an observable store like the entities, so rebuilding the view never refetches from nothing.
  • kg.tables.claims-at-a-folder-shows-everything-under-it [BROKEN] (#5010): browsing Claims at a library’s top folder shows every claim in the library, and ‘No claims here yet’ appears only when there are none. (The code hypotheses are ruled out; the symptom has not been re-checked on screen.)