feat(importer): Door C projects the sender's own facets into the index

vegnormal-okf measured the gap on 2026-08-27: the arm reading DEFAULT's
index.md scored 0 hits of 8, the arm reading a faceted index of the same
frontmatter scored 25 of 29. Same bundle, same concepts, same model. The
DEFAULT arm did not answer wrongly, it abstained -- the metadata is in the
bundle and the index throws it away (30 974 characters over 269
requirements, 0 occurrences of any of the eight facts).

FacetPolicy and STRUCTURED_V1 already did this. They did not reach Door C.

`import_bundle` now takes a keyword-only `profile` defaulting to DEFAULT, so
every existing call site emits the bytes it always did, and `link_in_index`
takes the facets to render.

Door C PROJECTS and never DERIVES, which is the answer to the objection this
work opened with: deriving structure for a document a third party wrote would
put our inference into an index entry ABOUT their bytes, where it reads as
their claim. The concept file was already verbatim; the entry describing it
now is too. Where the sender carries `derived`, THEIR list travels unchanged,
so a reader can still see which of the sender's facts the sender inferred.

The projection asks the policy which keys to carry and never what a key
means. That is what makes it work for a meeting note as well as a numbered
norm -- nothing in it can key off a numbering scheme -- and it is why a
consumer whose concepts are named by UUID can get `title` into the index by
naming the key, with no change here.

Two things measured during the work rather than assumed:

- A value carrying the policy's own joiner cannot be rendered. Door C's
  tolerance is structural and it refuses no sender on form, so the FACET is
  dropped and the concept still merges -- reported per concept and key in
  `ImportResult.unrendered_facets`, never dropped silently.
- A faceted entry can go stale where a flat one never could: the collision
  gate refuses an updated concept, so the operator's only route is to remove
  the merged file and re-import, after which the file said `gjeldende` while
  the index still said `utkast`. A faceted entry for a present target is now
  refreshed in place instead of skipped. Unfaceted callers keep the early
  return byte for byte.

Suite 695 -> 707; ruff and mypy --strict clean.

Order 20260826T224500Z-873805419-from-vegnormal-okf.
This commit is contained in:
Kjell Tore Guttormsen 2026-08-27 10:58:33 +02:00
commit 1f7d3502b8
3 changed files with 491 additions and 23 deletions

View file

@ -286,26 +286,78 @@ def _update_index_lines(
index_path.write_bytes("".join(updated).encode("utf-8"))
def _refresh_index_entry(
index_path: Path,
target_name: str,
label: str,
facets: Mapping[str, str],
*,
profile: BundleProfile,
) -> None:
"""Re-render the managed line for `target_name`, in place and alone.
Keyed on the policy's entry pattern and on the parsed target, never on a
substring: the index is the one file this library writes beside somebody
else's prose, so a curated line that merely MENTIONS the target has to
survive verbatim, and so does its own line ending.
"""
lines = index_path.read_bytes().decode("utf-8").splitlines(keepends=True)
updated: list[str] = []
changed = False
for line in lines:
content = line.rstrip("\r\n")
ending = line[len(content) :]
match = profile.index.entry_pattern.match(content)
if match is not None and match.group("target") == target_name:
refreshed = profile.index.render_link(label, target_name, facets=facets)
if refreshed != content:
line = refreshed + ending
changed = True
updated.append(line)
if changed:
index_path.write_bytes("".join(updated).encode("utf-8"))
def link_in_index(
bundle_dir: Path, target_name: str, label: str, *, profile: BundleProfile = DEFAULT
bundle_dir: Path,
target_name: str,
label: str,
*,
profile: BundleProfile = DEFAULT,
facets: Mapping[str, str] | None = None,
) -> None:
# §6: idempotent by target — a link whose target is already present in
# the index is never added twice.
#
# `profile` is keyword-only with a default because this function is public
# and called from all three doors (A here, B in inbox.py, C in importer.py).
# Doors B and C keep the default, which is the behaviour they already had;
# which profile THEY should own is a separate question, and answering it by
# changing this signature would have decided it silently.
# Door A and Door B's unfaceted path keep the default; which profile a
# caller should own is a separate question, and answering it by changing
# this signature would have decided it silently.
#
# `facets` also decides what "already present" MEANS, and the split is not a
# convenience. A flat entry carries a label and a target, both stable, so it
# can never disagree with the file it points at and returning early is
# exactly right — a hand-edited label survives. An entry carrying the
# concept's FACTS can go stale, and an index that contradicts the bundle it
# indexes is worse than one that says nothing: the consumer reads the index
# and stops there. So a faceted entry for a target already present is
# REFRESHED in place rather than skipped.
#
# Additive by construction: with `facets=None` nothing below the early
# return runs, so every unfaceted caller emits the bytes it always did.
index_path = safe_resolve(bundle_dir, profile.index.name)
body = index_path.read_bytes().decode("utf-8")
if f"]({target_name})" in body:
if facets is None:
return
_refresh_index_entry(index_path, target_name, label, facets, profile=profile)
return
# An empty index needs no separator: Door A always seeds its index with
# bundle_summary first, but Door B has no summary to invent, so its index
# starts empty and must not open with a blank line.
prefix = body if (body == "" or body.endswith("\n")) else body + "\n"
line = profile.index.render_link(label, target_name)
line = profile.index.render_link(label, target_name, facets=facets)
index_path.write_bytes(f"{prefix}{line}\n".encode())