Hermeneutic Layer — Design Spec (#4692)
Milestone: hermeneutic-layer Manual: TBD — contributor-facing only until the access question below is answered; there is no user-facing story to document beyond “there is an Interpretations tab in the Document Inspector” today.
Design-led (Testing Constitution). Creative director owns intent; tests enforce it; code makes them pass. Status: DRAFT — awaiting approval. Written because the maintainer asked how a researcher reaches this layer and no spec anchored it — it is referenced in six other specs (
ui/write-layer.md,ui/research.md,ui/reading-markup-annotations.md,harness/audited-action-layer.md,harness/observable-data-layer.md,kg/kg-entity-inspector.md) but owned by none of them. Tags: [OK] built and tested · [PARTIAL] built, partly proven or partly wired · [GAP] intended, never built (needs an issue) · [BROKEN] regression, code contradicts the rule (needs an issue).
A naming note, stated plainly
The codebase’s own module is fichero_server/models/hermeneutics.py, and its own docstring
calls this “0.0.2 Layer 5.” The three-layer READ→THINK→WRITE framing used elsewhere
(ui/write-layer.md, EPIC #2108) uses “Hermeneutic (READ)” for claim/entity/citation
extraction and “Interpretative (THINK)” for notes/annotations on a reading — a DIFFERENT use
of “hermeneutic” than the code’s own naming. This spec is about the CODE’s Interpretation /
InterpretiveFramework / PatternInstance / HermeneuticCircleState objects — the reasoning
a researcher (or, per one doc comment, “what I think” as against “what the source says”) adds
between a source’s claims and a piece of writing — which functionally sits at the THINK
position in that other framing, not a fourth layer. Not resolving the naming collision here;
noting it so nobody reads “hermeneutic” the same way twice by accident.
Intent (the design)
A researcher applies an interpretive framework (a named lens — historical, thematic,
theoretical, and so on) to a claim or a passage, and records what it means under that lens: an
Interpretation. Interpretations can recognize recurring PatternInstances across many claims,
and a HermeneuticCircleState tracks moving between a part and the whole it belongs to. All of
it is distinct from the KG’s own claims (asserted facts with provenance) and from Notes
(free-floating Zettelkasten atoms) — an interpretation is always ABOUT a specific claim or
passage, FROM a specific framework’s perspective, and is attributed to whoever (or whatever)
made it.
What already exists (grounded, read on disk 2026-09-18)
fichero_server.models.hermeneutics—InterpretiveFramework,Interpretation,PatternInstance,HermeneuticCircleState, plusHermesSuggestionRequest/HermesSuggestion(models for an AI-suggestion feature whose route was deleted as a permanent-501 stub with no caller, 2026-07-27 — the models remain, nothing serves them).api/routes/interpretation/hermeneutics.py— full CRUD for frameworks (create/list/get/ update/DELETE), create/list/get/PATCH for interpretations (no delete), create/list/get/PATCH for patterns plus an add-claim-to-pattern action, and create/list/get/navigate/backtrack for circle-state. Every mutating route in this file callsregistry.invoke(in its own body — verified by reading the whole file; this directory is outsidescripts/check_routes_use_action_layer.py’s current scope (api/routes/kg/and.../entity/only) but is independently compliant with the same rule (harness/audited-action-layer.md).- Swift
InterpretationStore— a document-scopedObservableDomainStore;createappends the new row in place,updatesplices by index with a reload fallback when the id is genuinely absent (both fixed 2026-09-18, #4824).apply(_ event:)stillscheduleReload()s on anyinterpretation.*event for the current scope — already tracked as a violation inharness/observable-data-layer.md’s allowlist, not re-litigated here. DocumentInterpretationsTab/DocumentInterpretationsSection— the ONE app surface. Confirmed MOUNTED:.interpretationsis inDocumentInspector+TabBar.swift’savailableTabslist (always present, not gated), andDocumentInspector+Sections.swift:16-17mountsDocumentInterpretationsTabfor that tab case. It offers a framework picker, an act picker, a text editor, a confidence slider, and per-row inline editing — genuinely a read AND write surface, not read-only.- Nothing else reaches this layer. Grepped the whole
Views/tree: no other file referencesInterpretationStoreor constructs anInterpretation.PatternInstanceandHermeneuticCircleStatehave zero Swift presence — no model, no store, no view, at all — despite full, tested engine routes.
Behaviors
A. Reachability — frameworks, interpretations, patterns, circle-state
hermeneutic.framework-crud— [OK] create/list/get/update/delete all work and are audited. Pinned:test_routes_hermeneutics.py::TestCreateFramework::test_create_framework,TestListFrameworks(both tests),TestGetFramework(both),TestUpdateFramework(both),TestDeleteFramework(both).hermeneutic.interpretation-create-and-list— [OK] creating an interpretation validates the framework exists and is active, requires and resolves aclaim_id, and appears in the list. Pinned:test_routes_hermeneutics.py::TestCreateInterpretation(all 3),test_routes_hermeneutics.py::TestListInterpretations::test_empty_list,::test_returns_interpretations.hermeneutic.interpretation-update— [OK] patching an interpretation’s text/confidence/ predicate persists and re-normalizespredicate_canonical. Pinned:test_routes_hermeneutics.py::TestListInterpretations::test_update_interpretation_updates_predicate_canonical.hermeneutic.interpretation-delete— [GAP] (#4692) there is no DELETE route for an interpretation — frameworks have one (hermeneutics.py:334), interpretations end at PATCH (:476). Once created, wrong or abandoned, an interpretation cannot be removed at all. Not built.hermeneutic.interpretation-linked-to-claim-or-passage— [GAP] (#4692) the model supportsclaim_id/document_id/passage_texton an interpretation, but the ONE app surface’s create form (DocumentInterpretationsSection.swift:130-136) sends onlyframeworkId/documentId/act/text/confidence— neverclaim_idorpassage_text. An interpretation the app creates is never actually linked to the specific claim or passage it’s about, only to the whole document. Not built on the app side; the engine already accepts the fields.hermeneutic.interpretation-has-source-anchor— [GAP] (#4692) an interpretation has no char-span/rect anchor (rendition_id, offsets) the way a claim or annotation does, so even once linked to a passage (behavior above), nothing can highlight that passage in the reader the way a claim’s source anchor does. Not built.hermeneutic.pattern-and-circle-state-have-no-app-surface— [GAP] (#4692)PatternInstanceandHermeneuticCircleStateare fully modelled, routed, and (per the ordering tests below) tested engine-side, but have ZERO presence anywhere underfichero/fichero/Views/orModels/— no Swift model, no store, no view, confirmed by grep across the whole tree. A researcher cannot see or use pattern recognition or hermeneutic-circle navigation at all today, on any surface.hermeneutic.reachable-by-agent-or-mcp— [GAP] (#4692)interpretation.create/.updateare registered, audited actions, but neither is inchat_tools.py’sCHAT_WRITE_ALLOWLIST(:70-77, four entries:workflow.run,entity.create,entity.update,claim.create— nointerpretation.*), so the in-app agent cannot create or edit an interpretation via the audited chat-tools path. Framework/pattern/circle-state actions are absent from the allowlist too. The mechanism to add them is trivial (the registry already has the actions); the allowlist entry itself is what’s missing.
B. Ordering, in-place updates, and honesty
hermeneutic.ordering-deterministic— [OK] interpretations, frameworks, patterns, and circle-states all come back oldest-first with a stable id tiebreak — previously no order at all (2593a6776). Pinned:test_routes_hermeneutics.py::TestListOrderingIsDeterministic::test_interpretations_come_back_oldest_first_regardless_of_insert_order,::test_interpretations_with_equal_timestamps_break_ties_by_id_stably,::test_frameworks_come_back_oldest_first,::test_patterns_come_back_oldest_first,::test_circle_states_come_back_oldest_first.hermeneutic.store-updates-in-place— [OK] (169ca6299)InterpretationStore.createappends the created row in place (matching the now-deterministic oldest-first order above, soappendproduces what a real reload would);.updatesplices by index, falling back to a reload only when the id is genuinely absent. Pinned:InterpretationStoreTests.testCreateAppendsInPlaceWithoutReload,InterpretationStoreTests.testCreatePreservesOrderAcrossMultipleAppends,InterpretationStoreTests.testCreateSkipsAppendWhenTheInterpretationIsOutOfTheCurrentScope,InterpretationStoreTests.testUpdateSplicesInPlaceByIndex,InterpretationStoreTests.testUpdateFallsBackToReloadWhenTheIdIsAbsent.hermeneutic.mutations-are-audited— [PARTIAL] (#4858) every mutating route ininterpretation/hermeneutics.pycallsregistry.invoke(in its own body, verified by reading the file whole — the mechanism is real. No test specific to THIS domain was found asserting anActionAuditrow is actually written for a framework/interpretation/pattern/ circle-state action (the generic mechanism is pinned elsewhere,harness/audited-action-layer.md’saudit.registry-is-the-one-write-choke-point, but not re-asserted per-domain here).hermeneutic.actor-not-forged— [BROKEN] (#4857)InterpretationCreateRequest .created_by: str = "human"(hermeneutics.py:115) is a plain client-settable field, stored verbatim bycreate_interpretation_impl(:388) — any caller can claim to be anyone. The route already threadsctx.actorcorrectly intoActionAuditviaregistry.invoke; this is specifically the domain model’s OWNcreated_byfield not deriving from it, the same class of gapharness/audited-action-layer.mdtracks forkg/inclusion.py::upsert_inclusion.
C. Where the reasoning connects to a claim
hermeneutic.claim-type-and-quotation-kind-are-modelled-not-editable— [GAP] (#4692) “what the source says” vs. “what the reader concludes” IS modelled richly onKnowledgeClaim(ClaimTypeincludinginterpretation/argument/historiography/theory;QuotationKindincludingparaphrase/inference/free_indirect; Toulmingrounds/warrant/backing/qualifier/rebuttal) — but the UI exposes only Kind/Status/ EpistemicStatus (EditClaimSheet.swiftpickers);quotation_kindand the Toulmin fields are edit-invisible, and the claims table has no Type column. Not built.hermeneutic.promote-to-claim-keeps-the-distinction— [BROKEN] (#4692) promoting an annotation to a claim (annotations.py:569) yields a claim with noclaim_type/quotation_kindset — the “says vs. concludes” distinction drops at exactly the moment a highlight becomes knowledge. It is also the one KG create action that is NOT undoable (confirmed:test_routes_annotations_actions.py::test_promote_is_not_undoable).hermeneutic.claim-provenance-badge-reads-the-right-field— [BROKEN] (#4692) a manually-created claim stampscreatedBy: "human"client-side (EntityService+ClaimEntityCRUD.swift:125→claims.py:461, trusted from the client rather than derived from the actor — the SAME actor-forgery class ashermeneutic.actor-not-forgedabove, a different model), butClaimProvenanceBadgederives its label fromconfidence_source(ClaimTableRow.swift:26-35) — a different field entirely. An uncurated, hand-authored claim shows “—” instead of “Human,” because the badge and the write path disagree about which field means “who made this.”kg.claim.provenance-kind-is-server-stated— [PARTIAL] (#4868, #4869) sits besidehermeneutic.claim-provenance-badge-reads-the-right-fieldabove rather than inkg-tables.md, because both issues are filed on this milestone and both are the same class of defect the sibling behavior already names — which field means “who made this” — one layer deeper: that field’s own STORED VALUE, not just which field the badge reads. (kg-tables.md’s ownkg.tables.provenance.author-markis the aspirational design line this behavior makes concrete; cross-referenced there, not duplicated.) Verified BROKEN at HEAD:KnowledgeClaim.created_by(models/knowledge.py) defaults to"human"— a default no caller has to override — and the workflow claim writer (_entity_writer.py’sKnowledgeClaim(...)construction) setsprovider/modelbut nevercreated_by; the NLP-draft importer likewise never sets it. So every machine-extracted claim is STORED as authored by a human, not merely mislabeled at render time.ClaimSummaryCard +Provenance.swift’screatedByBadge(for:)reads that same untrustworthy field: an exact match against["human", "user", "manual", "researcher", "editor", "curator", "cli"]badges “Human,” and a substring.contains("ai")/"agent"/"llm"/"extract"badges “AI” — so a real human actor whose account name happens to contain “ai” (or any string not in the human list) gets badged “AI,” and every machine claim gets badged “Human” by the same default. Ruled direction: one CLOSED field (ProvenanceKind: human/agent/ workflow/external_import/unknown) set by the SERVER at the point of writing, never accepted from a client on any route or action; existing stored rows are NOT rewritten — a legacy row’s kind is derived at READ time instead (provider/model present →workflow;created_by == "wikidata"→external_import; otherwiseunknown, deliberately neverhuman, since a legacy row’screated_bybeing the string “human” is exactly the untrustworthy value this behavior exists to stop trusting). Whether stored rows also get an audited repair pass is explicitly the maintainer’s decision, not decided here. The engine half is built (c30680467, contract regenerated in 6adec7180):ProvenanceKindandKnowledgeClaim.provenance_kind, set by the server on every write path and ignored when a client supplies it;created_bydefaults tounknown;resolve_claim_provenance_kindis the one place a legacy row’s kind is derived; an existing library takes the new column without any row being rewritten. Pinned:fichero-server/tests/unit/knowledge/test_claim_provenance_kind.py. PARTIAL, not OK, for three reasons that keep #4869 and #4868 open: the app’s claim card still badges from words increated_byand has not yet been moved to this field; search results and export paths embed a claim and do not yet apply the derivation; and “agent” is identified by the surface an action came through, not by a verified property of the account.
Test matrix
| Leg | This surface? | Pins | File |
|---|---|---|---|
| Backend (pytest) | y | framework/interpretation/pattern/circle-state CRUD + ordering | fichero-server/tests/unit/api/test_routes_hermeneutics.py (ran for real, 22/22 pass) |
| Availability (Swift) | y | store create/update in-place, scope-gating | fichero/Tests/Unit/general/Models/InterpretationStoreTests.swift |
| Availability (Swift) | n | no test exists for pattern/circle-state — no Swift code to test | — (hermeneutic.pattern-and-circle-state-have-no-app-surface) |
| MCP / chat tools | n | no test pins interpretation actions being allowlisted (they aren’t) | — (hermeneutic.reachable-by-agent-or-mcp) |
| Click-around (XCUITest) | n | no dedicated flow test found for the Interpretations tab | — |
Hard-gate: none yet — DRAFT spec; a hard-gate set is chosen once the access question below is answered and the surface has an owner.
The access question (stated plainly, not decided here)
The maintainer asked where in the pane system a researcher opens, reads, and writes
interpretations. Today the honest answer is: only from one document’s Inspector tab. There
is no Reader rendition, no Source-pane reveal (interpretations have no source anchor to reveal
in the first place — see hermeneutic.interpretation-has-source-anchor), no library-wide or
cross-document view, and no agent/chat-tool access (see hermeneutic.reachable-by-agent-or-mcp).
An interpretation is scoped to exactly one document, editable only while that document’s
Inspector is open, and invisible everywhere else in the app.
Confirmed live 2026-09-19 (maintainer test, → #4692): the maintainer asked this exact
question directly, unprompted — how are hypotheses, citations, interpretations, and the rest
of this layer exposed at all? He wants FEWER rendering paths overall (echoing
kg.read.statements-lens-becomes-the-paragraph and kg.read.one-renderer in
kg-readable-representation.md — the same “too many renderers for one thing” pattern, a
different layer), and to be able to click around and see the source image from whichever kind
is currently showing, not just claims. This does not answer the five open questions below; it
confirms they are live, not hypothetical.
Open questions (for the maintainer — options the code makes cheap, not a recommendation)
- Does the Interpretations tab move, or does the Inspector stay its home? The code makes cheap: (a) leave it in the Inspector, alongside Notes, as today; (b) give it a Reader rendition once it has a source anchor (mirrors the readable-representation ruling that the Reader shows content, the Inspector shows curation); (c) both — Inspector for the list/edit surface, Reader for a linked interpretation’s highlighted passage once anchored.
- Should an interpretation be linkable to a claim/passage before or after it gets a source
anchor? The engine already accepts
claim_id/passage_texton create — wiring the APP’s create form to send them is a small, standalone fix; a full char-span/rect anchor (hermeneutic.interpretation-has-source-anchor) is a bigger, separate piece of work. They can ship in either order. - Do patterns and circle-state get a Swift surface at all, or do they retire? Both are fully built and tested engine-side with zero app presence. The code makes cheap: (a) build a real Swift surface for both (a pattern list, a circle-navigation UI); (b) retire the circle-state model specifically if hermeneutic-circle navigation isn’t a feature anyone wants surfaced (patterns still feed potential future cross-document analysis, so retiring patterns too is a bigger claim); (c) leave both as engine-only substrate for now, undecided, revisited when a concrete UI need names them.
- Should interpretation actions join
CHAT_WRITE_ALLOWLIST? The mechanism is trivial (the actions are already registered and audited) — the only question is whether letting an agent record its OWN interpretation (as opposed to a human’s) is a decision the creative director wants made now or deferred alongside the broader per-model tool-grant work (research.md’sresearch.per-model-tool-grants). - Does
hermeneutic.claim-type-and-quotation-kind-are-modelled-not-editablebelong to this spec or to a claim-editing spec (kg-tables.md’s claim CRUD section)? It’s about theKnowledgeClaimmodel, notInterpretation— included here because #4692 framed it as part of the same “rich model, thin reach” finding, but it may belong split out.