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", ...)feedingentityMatches),ClaimsLibraryContent.swift:~163(TextField("Filter claims", ...)feedingclaimMatches).kg.tables.filter.entity-type[OK] — an entity-type picker filters the entity table. Built:EntitiesLibraryContent.swift:~127-146(availableTypes+ typeMenu). Pinned:EntitiesFilterTests.kg.tables.filter.claim-type[PARTIAL] (#4768) — a claim type picker filters the claim table (built:ClaimsLibraryContent.swift:~151-176,availableTypes/typeMenufilteringclaimType), 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 checksearchandfilterText.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);EntitiesLibraryContenthas the parallel empty state.kg.tables.filter.pushdown(later) — for large sets, push the filter to the list endpoint (q/entity_type/claim_typealready 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), idkg.entity.new.kg.tables.entity.rename-inline[OK] — the canonical name is editable from the table. Built:EntitiesTableView.swift:~118-125(inlineTextField+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: enginetest_routes_entity_curation.py::TestBatchEntityCuration::test_batch_updates_entities_and_logs_mutations(state set, oneMutationLogper changed entity, with the real actor) and::test_batch_skips_unchanged_entities; appEntityStoreTests.testSetCurationUpdatesMatchingRowsInPlace(the store updates the matching rows in place). Merge from the table IS reachable (seekg.entity.menu.merge); it stays [PARTIAL] because split is not (seekg.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/.createabove); 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_stateend-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’skg.read.edit-unit-is-the-claimcovers a DIFFERENT surface reaching the sameclaim.patchaction — 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’ssubject_entity_idto the survivor the same way it repointsentity_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.mergeandreview.accept. After a merge the readable paragraph gives rolesubjecton 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_idandeditor_entity_id— the four role fields this behavior previously listed as NOT repointed — now sharemerge_entities_impl’s oneCLAIM_ENTITY_ID_FIELDSloop, 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_fielddrives 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_fieldasserts undo restores all four;::test_audit_names_every_repointed_fieldasserts 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-cascadingentity.deleteclears (sets toNone) every scalar entity-id field a claim carries that names the deleted entity —subject_entity_idand its four siblings,speaker_entity_id,subject_of_inquiry_entity_id,scribe_entity_id,editor_entity_id— not only strips it fromentity_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 isNone, never a dangling dead id. Never touchessubject_canonicalor the claim’stext— 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 ofkg.merge.repoints-subjectabove — 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 idkg.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), plusEntityReconciliationSheet. What #4828 found unreachable is the standaloneEntityMergeSheet, 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}/undoandGET /audit(entity_curation.py), anEntityMergeAuditrow per merge/split/undo (source ids, target id, alias changes,reversal_id,created_by), and the CLIentity merge,audit list,audit undo. Pinned:test_routes_entity_curation.py::TestMergeEntities::test_merge_writes_action_audit_and_emits,::TestListEntityAudits::test_audit_appears_after_mergeand::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 unmountedEntityDetailViewchain. Cross-referenceskg.tables.entity.curateabove (#4801).kg.entity.menu.split— [GAP] (#4828, → #1675)EntitySplitSheetsplits 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_matchedinworkflows/tools/_entity_writer.py, whose vector and SequenceMatcher stages now only propose; tested infichero-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 PoloandDon Joaquin Polo,Chocó departmentandChocó, 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 (methodembedding_cosineorsimilar_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, theeditorentry inentity.create/entity.update,GET /api/entities?run_id=,entity.take_back_runinapi/routes/kg/nlp_draft_purge.py; tested infichero-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 itsattribution_chain, the field that already says who asserted a claim: a model run as anextractorentry naming the provider, the model, the run (run_id) and when (at); a person as aneditorentry 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 touchesentity.purge_nlp_draftcounts); 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_keyand thespelling_variantstier ofplan_entity_dedupeinknowledge/dedupe.py,spelling_variants/proposeonPOST /api/kg/entity-curation/dedupequeuing throughreview.queue; tested infichero-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 surnameFrancois notFran.co). A number is never set aside (Dredge No. 1andDredge No. 3stay 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_variantfor a spelling variant,duplicate_namefor 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.addEntityAliaseshas no caller, andentity updatetakes--name,--descriptionand--typebut no alias option. (Merge and split do move aliases; that is not editing them.)kg.claim.contradiction-triage— [GAP] (#4828, → #1677)ContradictionTriageSheettriages 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 withunreviewed → shortlisted → curated/rejectedtransitions, filter by person/topic, batch or single-item) —ClaimReviewQueueSheetexists 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 fromClaimsLibraryContent.swift(showingCreateSheet), idkg.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-172wires anonEditmenu item (kg.claim.menu.edit) that opens the existingEditClaimSheet(S·V·O + type + epistemic status, PATCH/api/claims/{id}), reused rather than re-parsed. Shipped ina166ad3ee(2026-09-08, “feat(kg-tables): edit a claim from the claims table (#4624)”). The row-levelclaimMenudoc-comment’s “edit / curate arrive in later kg-tables waves” is stale for edit (curate is the real remaining gap — seekg.tables.claim.curatebelow). 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(onDeletemenu item,kg.claim.menu.delete) →ClaimsLibraryContent.swift:~254-268(deleteClaims, one audited delete per id, reloads on failure). Left [PARTIAL] pendingkg.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 iskg.tables.claim.curatebelow, 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.deleteClaimsdeletes a multi-selection immediately, and the inspector’s action does the same (KnowledgeGraphInspectorSection+Actions.swift); onlyEntityDetailView+Claimswords 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 issidebar-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: anEntityResolutionRulestore (typessuppress,merge_into,reclassify,alias), CRUD routes (/api/kg/curation-rules), and enforcement inupsert_entitybefore 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 intest_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 asuppressrule for its name and aliases, scoped to its type; an entity type change writes areclassifyrule (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 analiasrule (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) intest_entity_verbs_write_rules.py::TestDeleteWritesASuppressRule,::TestReclassifyWritesAReclassifyRuleand::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_entityresolves every match to the LIVE entity throughfollow_merge_chain(any number of hops; a cycle, a dangling pointer or a runaway chain raisesMergeChainError), 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 aClaimSuppressionRulewith actionprunein the same action (#5074):disableanddemotestill write the claim, asrejected, so it would come back as a row to dismiss again; onlyprunemakes 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_entityreturns 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 isimporter.md’simporter.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.e00eb0260explicitly 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 ONEgetEntity, 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 aspanes.model.per-pane-scope-and-kind-unreadinpanes-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_descendantson/api/claims(opt-in), always-on for the entitiesdocument_idfilter, andinclude_childrenon 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 throughEntityStore.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 assertsinclude_descendants=trueis actually sent. Tests executed:EntityStoreTests,EntityServiceTransportTests,SelectAllVisibleSurfaceTests,EntitiesTableCreateTests(plus a filter-bar suite since replaced byKGFilterBarGuardrailTests) — 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, onGET /api/claims, the entity list (document_id) and the document knowledge-graph route, withinclude_descendants=falsemeaning the document alone. Replacesinclude_children, which an implicitor len(descendant_ids) > 1had 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 istest_descendant_doc_ids.py::TestDescendantDocIds. Remaining, so this stays [PARTIAL]: the knowledge-graph RESPONSE still names its echoed fieldinclude_children, and the Swift client, the generated CLI surface andopenapi.jsonstill 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-libraryClaimStore.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. Seekg.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 recordscreated_by.) The concrete defect in today’screated_by— machine-extracted claims stored and badged as human-authored — is tracked askg.claim.provenance-kind-is-server-statedinhermeneutic-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 aMutationLogfor 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 iskg-enrichment.md’skg.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 — addPOST /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 theBatchServicemaxConcurrent 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-selectionabove) 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 bycheck_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.swiftis the only file in the tree wiring a Select-All shortcut; neitherEntitiesTableView.swiftnorClaimsTableView.swifthandlesselectAllat 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 (KGFooterCollapseGuardrailTestspins 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 throughonVisibleIds— the same shape the dataset modes already use — andselectAllIdsanswers from those published ids, not a second computation. Pinned:SelectAllVisibleSurfaceTests.entitiesTablePublishesVisibleIds,.claimsTablePublishesVisibleIds,.selectAllIdsUsesThePublishedTableIds(filefichero/Tests/Unit/general/Views/Library/SelectAllVisibleSurfaceTests.swift, suiteSelectAllVisibleSurfaceTests).
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)
- Filters:
filter.text+filter.entity-type/filter.claim-type+filter.combines-with-search+filter.empty-state(#4625) — cleanest, no backend work. - Claim
delete+ entitycreatein the table (wire existing store/service). - Claim
create(new POST /api/claims wiring: service + store + a NewClaimSheet). - 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.)