Skip to content

feat(export): kb-export-subgraph-html — whole-KB interactive HTML export - #567

Merged
cuttlefisch merged 45 commits into
mainfrom
integrate/subgraph-html-export
Aug 1, 2026
Merged

feat(export): kb-export-subgraph-html — whole-KB interactive HTML export#567
cuttlefisch merged 45 commits into
mainfrom
integrate/subgraph-html-export

Conversation

@cuttlefisch

@cuttlefisch cuttlefisch commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

Brings the kb-export-subgraph-html primitive upstream: exports a KB subgraph (seed node + BFS neighborhood) to one self-contained, offline-capable HTML file with a chord-diagram nav sidebar, per-node outline, search, tag filtering, and an optional bilingual (EN/ES) overlay. Ships as (kb-export-subgraph-html id path [depth] [translations] [title] [node-cap] [guidance-ids] [chord-config]) in Scheme, a kb_export_subgraph_html MCP tool, and kb_export_* persistent options for depth/node-cap defaults and the chord diagram's layout/timing constants.

This work already existed on an untracked local branch (feat/subgraph-html-export, 19 commits, based on an old main). This PR merges it forward onto current main (113 commits of drift) and fixes what the merge + real-world testing surfaced.

Why not the existing extension-point wiring

main briefly had this feature wired in via the extra-kernel-crates extension point (#521) as an out-of-tree dependency on a sibling checkout (dd0f9c47), then fully reverted the same day (ab53312d). That sibling project itself depended on another sibling checkout via a relative path — an unshippable, personal-machine-only dependency chain that would never build for another contributor or CI. This PR instead vendors the export code in-tree (crates/export/src/html_graph.rs), consistent with why that attempt was reverted (see ADR-077). The Scheme primitive stays in-tree too (crates/scheme/src/runtime/kb_export.rs) — the extension point is for genuinely out-of-tree/downstream primitives, not this one.

Bugs found and fixed via real-world testing (initial pass)

Verified end-to-end by exporting a real 245-node production KB (an infrastructure-as-code migration project), not just fixture data. Found and fixed three real org-parser bugs along the way, none specific to this feature — all in the shared crates/export org parser also used by plain org_export:

  1. PROPERTIES drawer leak when real prose precedes it.
  2. PROPERTIES drawer leak inside list items.
  3. Multi-digit ordered-list markers not recognized (e.g. item "10." became "0." after stripping only the first digit).

Pre-merge hardening pass (multi-lens review)

Before merging, this PR went through a deep multi-lens review (architecture, security, correctness, test-quality — 4 independent passes against CLAUDE.md's design principles) and a full remediation pass through every finding, phase by phase:

Phase 1 — Critical security fixes

  • Stored XSS via unescaped inline code spans (~code~/=code=<code>, +strike+<del>): content is now HTML-escaped before wrapping.
  • Stored XSS via #+begin_export html: previously passed raw HTML through unconditionally — the single shared render site used by both org_export and this feature's render_node_body_html. Now escapes by default; a new org_export_allow_raw_html_blocks option (default off, Scheme/:set-accessible per principle feat: Phase 4a M5 — async LSP AI tools #7) lets a trusted single-author org_export call opt back into standard org semantics. kb_export_subgraph_html never opts in — it exports content that isn't necessarily self-authored.
  • LOGBOOK (and any other) drawer not recognized — real content loss: the parser only recognized the literal :PROPERTIES: token; any other drawer (most commonly Emacs's auto-inserted :LOGBOOK:) leaked its raw lines as visible prose and could swallow real body text that followed. Generalized drawer-open detection to any :name:-alone-on-its-line shape, applied consistently across all four places the parser skips drawers.
  • Mermaid subprocess spawn under the wrong permission tier: kb_export_subgraph_html shells out to npx @mermaid-js/mermaid-cli but was registered at Write tier. Bumped to Shell, matching every other subprocess-executing tool (babel_execute, org_export, shell_exec).

Phase 2 — Residency gap fix

  • guidance_ids bypassed AI-residency gating (ADR-048) — each id now gets the same check_kb_residency gate kb_get itself uses; omitted ids are reported explicitly in the tool's status message, never silently dropped.

Phase 3 — Architecture: real assets, real config injection, real JS tests

  • Extracted the ~1,700-line JS and ~900-line CSS from Rust string literals into real, lintable crates/export/assets/graph.js/graph.css files (include_str!, matching MAE's established asset convention) — shrinks html_graph.rs from ~6,205 to ~3,700 lines.
  • Replaced replacen-against-exact-JS-text config patching (silently dead if the JS text ever changed shape) with real JSON injection: ChordDiagramConfig values flow through the same #graph-data JSON payload the node/edge data already uses.
  • Ported a real "Layer 2" browser-execution test suite (crates/export/tests/browser/, 54 tests, node --test + puppeteer-core driving real Chromium + Firefox) — this is exactly the class of bug (a regex literal corrupted by the inline-script escaper) that source-text-only Rust assertions had already let through once, caught only by manually running node --check.
  • Added a node --check CI gate (export-js-check job) on graph.js as a cheap syntax check independent of the full browser suite.

Phase 4 — Correctness fixes

  • Clamped chord_config's numeric overrides to sane ranges (previously accepted any f64/negative unvalidated).
  • Implemented nested org sub-lists (ListItem::children, previously declared but never populated) — indentation-based, one real level of nesting.
  • Switched the exported HTML file write to atomic write-then-rename, so a disk-full/kill mid-write can never leave a corrupt file at the destination path.

Phase 5 — Documentation/drift cleanup

  • Ported the 4 still-relevant design decisions into real numbered MAE ADRs (ADR-077 through ADR-081; ADR-080 is superseded by ADR-081, which documents the Phase 3 JSON-injection mechanism). Decisions describing the now-obsolete standalone-sibling-project architecture were deliberately not ported.
  • Filed kb-export-subgraph-html: GruvboxPalette is a hand-copied theme snapshot with no drift check #568 for the GruvboxPalette hand-copied theme-snapshot drift risk (no automated check that it stays in sync with crates/core/src/themes/gruvbox-*.toml), cross-linked from an @ai-caution: [architecture-debt] marker + a ROADMAP.md entry.

Phase 6 — Test-quality improvements

  • Diversified unicorn-value test fixtures (simple_node("a", "A", ...)) in the tests most tied to the Phase 1 security fixes.
  • Added a negative test covering non-numeric/negative depth/node_cap JSON values falling back cleanly to Editor-option defaults instead of panicking or silently misinterpreting.

Test plan

  • cargo build --workspace --features gui: clean
  • cargo test --workspace --features gui (full workspace, daemon built separately per ADR-014's two-workspace split): all green
  • cargo clippy --workspace --features gui -- -D warnings: clean
  • Layer 2 browser suite (crates/export/tests/browser/, npm test): 54/54 passing across Chromium + Firefox
  • node --check crates/export/assets/graph.js: clean
  • End-to-end against the real production KB: built + installed the hardened release binary, restarted the live editor, re-exported the real 245-node onprem-iac KB (175 nodes reached at depth 4) — 0 :PROPERTIES:/:LOGBOOK: leaks
  • Constructed both XSS payloads from the security review (~<img src=x onerror=...>~ and a #+begin_export html block with a live <script>) against a throwaway KB node, exported, confirmed both render as inert escaped text — traced the one raw-looking match in the JSON payload's preview_en field to confirm it's only ever consumed via .textContent in graph.js, never innerHTML (only body_en, the HTML-escaped field, reaches innerHTML)

Follow-up: required_tag export filter + kb_agenda federation fix

A second real-world validation round against two live personal KBs (an onprem-iac migration KB and
a RoamNotes-style federated KB) surfaced two more gaps, both fixed on this branch:

required_tag — a hard tag filter, independent of node_cap (ADR-082). Exporting a curated
"terraform onboarding" walkthrough picked a plausible-looking but wrong seed (a general reference
hub instead of the actual guided-walkthrough node), pulling in 100+ unrelated reference nodes with
no error — export correctness depended entirely on picking the right seed+depth by luck, even
though the KB's nodes were already correctly tagged. New optional required_tag param (MCP tool +
Scheme primitive + mae_kb::SubgraphSpec) hard-filters the final exported node set to only nodes
carrying that tag (the seed is always kept, regardless of its own tags) — BFS still traverses
through untagged intermediates to reach tagged nodes beyond them, applied before node_cap so the
two cutoffs compose correctly and reuse the existing boundary-link-demotion path with zero new
link-handling code. Reports how many nodes were excluded (tag_filtered_count), same
never-silent-truncation convention as node_cap.

kb_agenda never actually queried federated KBs (ADR-083). While diagnosing why
kb_agenda(filter=tag) returned zero results against a real federated instance despite the node
being correctly tagged, traced execute_kb_agenda to only ever reading editor.kb.store (the
PRIMARY KB's own Cozo store) — every filter type (todo/priority/tag/stale/orphan/
dead_end/missing_role/weakly_linked/custom) was structurally unable to see ANY federated
instance's content, not a tag-matching bug at all. Fixed by mirroring execute_kb_health's
per-instance registry-iteration pattern: queries each in-scope instance's CozoKbStore when one is
open, or a new KnowledgeBase::agenda_query_in_memory fallback (matched field-for-field against
the Cozo query semantics, including its substring tag-matching convention — deliberately NOT
nodes_by_tag's exact-match convention, so results stay identical regardless of which backend a
given instance happens to have) for a purely in-memory federated instance. Stale/Custom have no
in-memory equivalent (no per-node last-modified timestamp exists without a Cozo store) and are now
reported via a new skipped_instances field rather than silently omitted. Reclassified from
PrimaryOnlyFilterable to ScopedFederatedScanFilterable in the AI-residency gate
(crates/mae/src/ai_residency.rs) — the old shape had zero real tools left in it once kb_agenda
moved, so it's removed entirely rather than kept as dead code.

Also fixed: this checkout had never run make setup-hooks, so the local pre-commit fmt/clippy/
code-map/ADR-staleness gate was silently skipped for the whole session — caught via real CI
failures (stable / fmt, code-map freshness) on the first push, root-caused, and fixed (hooks
now enabled for this checkout going forward).

Re-verified with the same rigor as the original pass: full cargo build/cargo test --workspace --features gui/cargo clippy -- -D warnings clean, cargo fmt --check clean, make code-map-check
clean, daemon workspace cargo build/cargo test clean, plus new adversarial + real-KB-shaped unit
tests for both fixes (shared/kb/src/lib.rs's extract_subgraph_required_tag_*/
agenda_query_in_memory_*, crates/ai/src/tool_impls/kb.rs's kb_agenda_finds_a_tagged_node_in_a_ purely_in_memory_federated_instance/kb_agenda_stale_filter_reports_an_in_memory_only_instance_as_ skipped).

🤖 Generated with Claude Code

cuttlefisch and others added 30 commits July 28, 2026 12:25
Export a KB subgraph (seed node + neighborhood) to one self-contained,
offline-first HTML file: force-directed layout baked in at export time
(mae-canvas::ForceLayout, no JS physics shipped), gruvbox-themed inline
CSS/SVG/JS with hover preview, click-to-select detail panel, an
anchor-node "Start here" reading-order walk, an optional bilingual
EN/ES toggle driven by an additive external translations JSON file, and
mermaid diagram pre-rendering to inline SVG via `npx @mermaid-js/mermaid-cli`
(falls back to raw source on render failure).

New crates/export/src/html_graph.rs (HtmlGraphExporter) follows html.rs's
existing "one function, one dependency-free HTML string" shape. Wired as
the kb_export_subgraph_html MCP tool (crates/ai) and the :kb-export-html
colon command, matching kb_export_guidance's existing precedent for both.
Classified SingleTarget in the AI-residency gate (crates/mae/src/ai_residency.rs)
since extract_subgraph is single-KB-instance by construction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…heming

Mid-flight design update on the kb_export_subgraph_html widget: swap the
baked layout from force-directed to a chord ring
(mae_canvas::kb_graph::build_kb_graph_chord_positions, edges drawn as
interior arcs and independently clickable to follow a link to the other
endpoint), add a persistent Home button (jumps to the anchor/spine node,
which is now auto-selected on load), a collapsible "on this page" outline
sidebar scanned from the current node's own rendered headings, and a
manual dark/light theme toggle alongside the existing prefers-color-scheme
support.

Restyle to a muted default (--fg4 nodes/--gray edges) with a single
validated emphasis accent (GruvboxPalette::accent, gruvbox's own
bright_orange/orange, contrast-checked at 5.84:1 / 3.41:1 against each
theme's own bg0) reserved for the current node + its incident edges — the
14 per-kind hues failed a categorical-palette simultaneous-discrimination
check for a widget this size, so kind is now conveyed via the detail
panel's kind badge instead of per-node fill. Hover lifts (scale + shadow)
rather than recoloring, so hover and selection no longer compete for the
same visual channel. Detail-panel/link-list/popover construction switched
from string-concatenated innerHTML to textContent/createElement, with
body_en/body_es (pre-rendered, already html_escape-safe org->HTML) as the
one deliberate innerHTML assignment. Node/control hit targets floored at
24px. 150-250ms transitions on fill/stroke/transform/theme changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The graph-pane chord widget now renders in the opposite gruvbox mode
from the surrounding page — a light inset on a dark page, a dark inset
on a light page — toggling automatically with the existing theme
switch. Deliberate design choice so the nav widget reads as a distinct
focal point rather than blending into the page chrome, using
#graph-pane's existing rounded-corner/shadow framing (already present)
to make the inversion read as intentional composition.

Mirrors render_css_variables's own default/media-query/attribute
structure exactly, just inverted and scoped to #graph-pane, so an
explicit theme-toggle click wins over system preference the same way
it already does for the rest of the page. Reuses the two accent values
already validated in the prior commit (#fe8019 dark / #d65d0e light),
just applied to whichever surface the widget actually lands on rather
than the page's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…IES drawers

Running kb_export_subgraph_html against a real KB (RoamNotes) surfaced
two real content bugs neither hand-built test fixture had exercised:

1. mae_kb's org parser canonicalizes every `[[id:UUID][label]]` link
   into `[[UUID|label]]` before storage (shared/kb/src/org.rs's own
   documented "internal pipe-display convention") -- but
   parse_org_link_str only understood raw org-file `[[target][label]]`
   syntax. Every internal link in a real node body rendered as a
   garbled, unsplit "UUID|label" string used as both href and visible
   text. Fixed by recognizing `|` as an equally valid separator
   (safe/non-regressive: standard org-mode never puts a bare `|`
   inside a single [[...]] bracket pair), stripping any surviving
   `id:` prefix, and giving internal (non-URL) targets a `#`-prefixed
   href instead of an invalid bare-UUID one. External links and
   genuine two-bracket-group raw-org syntax (html.rs's HtmlExporter's
   own use case) are unaffected -- covered by a new non-regression
   test.

2. Every real node's rendered body opened with its own raw
   `:PROPERTIES: :ID: ... :hash: ... :END:` drawer dumped as visible
   prose, because parse_org_document (written for org body content)
   has no concept of a drawer. Fixed with a bounded leading-drawer
   strip in render_node_body_html and plain_text_preview, mirroring
   the same bounded-prefix convention shared/kb/src/activity.rs's
   body_hash already uses for this exact drawer shape.

Four new regression tests, found by actually running the export
against real content rather than only hand-built fixtures -- worth
noting as a gap in the original test suite, not just these two bugs.
Full mae-export (81) + mae-ai (629) suites still pass, clippy clean,
fmt clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…conversion

convert_inline_markup_str's main loop decoded one raw byte at a time
(`bytes[i] as char`, `i += 1`) for any character that wasn't an ASCII
markup trigger. Every non-ASCII character -- an em-dash, any accented
character -- got split into its 2-4 individual UTF-8 bytes, each
reinterpreted as a bogus Latin-1-ish char (e.g. "configuración" ->
"configuración"). Found by actually running the real Spanish
translations through this path via kb_export_subgraph_html against
RoamNotes: every accented word in every one of the 12 translated
nodes came out mangled, and English em-dashes rendered as "â".

The ASCII-marker dispatch (`*`/`/`/`~`/`=`/`+`/`[`) itself was never
wrong -- no ASCII byte value can collide with a UTF-8 continuation
(0x80-0xBF) or leading (0xC0-0xF4) byte, so byte-position marker
search/slicing stays correct even with multibyte content nearby.
Only the fallback "ordinary character" path needed fixing: decode the
real Unicode scalar at `i` and advance by its `len_utf8()`, instead of
reusing the byte-cast `ch`.

New regression test with real Spanish content. Full mae-export (82) +
mae-ai (629) suites pass, clippy clean, fmt clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…for them

Running the export against real content (15 nodes, mostly one-hop from
the anchor -- a star topology) surfaced a real readability problem a
hand-built 2-3 node test fixture never would: every node's label
rendered simultaneously, producing overlapping/illegible text, with
several long titles clipped outright past the SVG viewBox edge (the
fit-to-viewport math only accounted for node *positions*, never label
width).

Fixed at the root rather than patched around: per the dataviz skill's
"selective direct labels" rule, only the anchor, the selected node,
and whatever's under the cursor show a label by default (pure CSS,
riding the `.selected`/`.hovered` classes already toggled elsewhere)
-- every other node is an identifiable, clickable dot, its title one
hover away via the existing popover. This removes almost all the
collision at its source instead of fighting it with layout tricks.
Labels that do show are also now truncated (28 chars + ellipsis,
char-count not byte-length -- titles can be non-ASCII) as defense in
depth, and the viewBox gets deliberately generous extra right-side
room sized to the truncated-label budget so a still-visible label
never clips past the diagram edge again.

Verified visually (headless Chromium + Puppeteer, not just markup
presence) in both themes and both languages after this change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…view

Real Firefox usage surfaced seven concrete problems -- fixed together
since several touch the same layout/interaction surface:

- Layout: #main-content (the node's rendered body) is now the dominant
  flex child; the chord diagram + outline share a narrow 300px #sidebar,
  chord fixed at 280px height above the outline, instead of the graph
  taking ~2/3 of the page width and the outline getting lost below a
  long body. Every DOM query in GRAPH_JS is by id, not position, so this
  was a pure markup/CSS change.
- Chord-diagram labels get a background-colored SVG text halo
  (paint-order: stroke fill) instead of relying on raw fill-vs-bg
  contrast, which was technically fine in isolation (~6.8:1) but broke
  down wherever a label sat on top of the many edges converging on a hub
  node in a star-topology subgraph -- a real legibility problem, not a
  color bug, that only gets worse as the widget shrinks.
- New --link CSS variable (gruvbox bright_blue/blue, contrast-validated:
  5.48:1 dark / 3.73:1 light) for in-body <a> and popover links, which
  previously had no rule at all and fell through to the browser default
  blue/purple. Deliberately a different hue from --accent (orange
  already means "current node/edge" elsewhere on the page).
- Previous/Next: walkIndex was a forward-only modulo counter behind one
  overloaded "Start here / Next" button. Replaced with two real,
  independently-disabled controls sharing one clamped (not wrapping)
  position.
- plain_text_preview (feeds hover-popover previews) never had any
  concept of [[target|label]] link syntax, so a body containing an
  internal link showed raw brackets/pipe/UUID text verbatim. Fixed by
  adding InlineTarget::PlainText to the shared inline-markup parser
  (convert_inline_markup_str/parse_org_link_str) instead of writing a
  second link-parsing implementation -- links resolve to just their
  label, and the same parser is now reused by all three output shapes
  (HTML, Markdown, plain text).
- New: hover-preview on in-body links (org-roam-ui-style), reusing the
  existing #popover/onHover/movePopover/nodesById machinery already
  built for chord-node hovering rather than new plumbing -- wiring, not
  a new feature surface.

New regression tests for the link-color CSS var and the plain-text
link-resolution fix. Full mae-export (84) + mae-ai (629) suites pass,
clippy clean across mae-export/mae-ai/mae-core/mae, fmt clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… no silent truncation

An independent quality/reusability review (this feature was built and
tested primarily against one real 14-node use case) surfaced two real
bugs, confirmed empirically rather than left as theoretical:

- The chord widget's ">=24px hit target" claim was never actually true
  once the SVG's viewBox scales to fit its fixed-size container: world-
  space node radius was floored at a flat constant, but the ring's world
  extent grows with node count (sqrt-area layout) while the widget stays
  a fixed ~280-300px box, so preserveAspectRatio silently shrinks
  on-screen node size as a subgraph gets denser. Measured directly
  (headless Chromium + Puppeteer) on a synthetic 50-node star: on-screen
  radius as small as ~3px before this fix. Fixed by computing the real
  world-to-screen scale from the SVG's actual rendered size
  (getBoundingClientRect) and inflating the world-space floor to
  compensate, so the *on-screen* pixel result is what's guaranteed —
  confirmed empirically after the fix: ~12px radius floor holds at both
  14 and 51 nodes. A first pass at this fix used Math.max(floor,
  degree-based-size), which technically satisfied the hit-target
  guarantee but silently flattened anchor/degree-based visual size
  differentiation back to a uniform floor at realistic node counts
  (confirmed on this repo's own real guide) — switched to an additive
  bonus on top of the floor instead, so both properties hold together.

- `node_cap` (the safety net on reachable-set size) was hardcoded to 60
  with no override and, more importantly, its truncation signal
  (`SubgraphResult::hidden_node_count` — already computed by
  extract_subgraph and already surfaced by two other call sites in this
  codebase) was silently dropped: a caller hitting the cap got a
  perfectly normal-looking "Exported 60 nodes..." success message with
  no indication the true reachable set was larger. Now overridable (arg
  `node_cap`, still capped at 200 pending widget-sizing work for
  anything larger) and the status string says so explicitly when
  truncation happens, matching the "never truncate silently" convention
  already established elsewhere (graph_view_ops.rs's identical pattern).

Also updated the depth-clamp doc comment (was undocumented magic
number, unlike node_cap's) and the kb_export_subgraph_html tool
definition's description, which still said "force-directed layout" from
before this session's earlier switch to the chord-ring layout.

Two new regression tests (node_cap truncation + reporting, node_cap
override + default) plus one confirming the hit-target fix's code path
is present (the actual on-screen-pixel behavior needs a real browser
and was verified that way this session, documented in the test). Full
mae-ai (631) + mae-export (85) suites pass, clippy clean across
mae-ai/mae-export/mae-core/mae, fmt clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found during final real-browser verification: the scoped inverted-
theme CSS variables (render_widget_inverse_theme_css) were correct --
confirmed by an existing test -- but nothing applied `background:
var(--bg0)` to #graph-pane itself, so it stayed transparent and the
page's real (non-inverted) background showed through underneath. The
"light window inset in a dark page" effect never actually happened
visually, only the variables were right. Confirmed empirically both
before (computed background-color: rgba(0,0,0,0)) and after (a real,
correctly-inverted color in both theme directions) via headless
Chromium.

New regression test anchored on a unique marker in the sizing rule
(several #graph-pane { ... } blocks exist -- the inversion variable
blocks plus this one -- so a plain substring split would have matched
the wrong one, and did on the first attempt). Full mae-ai (631) +
mae-export (86) suites pass, clippy clean, fmt clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… navigate, inline code visible

Three more real bugs found in follow-up review of the actual page:

- walkIndex started at -1 with a "Start here" label, but the anchor is
  already auto-selected on page load and readingOrder[0] is always the
  anchor itself (BFS distance 0 from itself) -- so the very first click
  just re-selected the node already on screen, a confusing no-op
  dressed up as a fresh start. walkIndex now starts at 0 (reflecting
  "already at the first stop"), the button always reads "Next"/"Done",
  and the button/variable/id were renamed start-here -> next-button
  throughout to match what it actually does.

- In-body links (<a href="#UUID">) only had hover-preview listeners
  wired (wireBodyLinkPreviews) -- clicking one fell through to the
  browser's default same-page fragment-scroll, which had NO visible
  effect at all since no element in the page actually has that id.
  Renamed to wireBodyLinks and added a real click handler
  (preventDefault + selectNode) alongside the existing hover wiring.

- Inline code (=x=/~x~ spans) had only a monospace font, nothing to
  set it apart from surrounding prose. Added a background pill
  (var(--bg2)), with a companion rule canceling it back to transparent
  inside <pre> so block code (which already has its own background)
  doesn't get visibly double-boxed.

Three new regression tests. Verified empirically in a real headless
browser, not just via string checks: Next genuinely moves to a new
node on first click, inline code has a real non-transparent
background, and clicking a body link changes the displayed node. Full
mae-export suite (88) passes, clippy clean, fmt clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…bias

The chord nav widget previously rendered in the opposite gruvbox theme
from the page with a rounded, drop-shadowed "card" look. In real
browser review this read as a boxed-in prototype rather than an
integrated part of the page, and violated the dataviz anti-pattern
list's guidance against heavy chrome with no breathing room. It now
shares the page's own theme variables and surface color, with only a
hairline divider separating it from the outline below.

Separately, the widget wasn't actually centered in its panel: labels
always anchored to the right of their node, and the viewBox's extra
label-overflow width was budgeted entirely on the right side too, so
the node ring itself sat left-of-center. Labels now flip anchor
(start/end) by which half of the ring a node is on -- the standard
radial-diagram label convention -- and labelPad is budgeted
symmetrically on both sides of the viewBox, so the ring's own bounding
box centers correctly regardless of which nodes happen to be labeled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Batch of real bugs found in browser review of the live guide:

- Table cells went through html_escape() alone, never the shared inline-
  markup converter every other block type uses -- =verbatim=, *bold*, and
  [[id:...]] links inside a table cell all rendered as literal unconverted
  org syntax. Affected every table in the KB, not just one note.
- plain_text_preview had no match arm for Table at all, so a node body
  dominated by a table (e.g. a function-reference cheatsheet) got an
  empty or near-empty hover-popover preview.
- The hover popover always anchored bottom-right of the cursor with no
  viewport clamping -- a node on the right half of the chord ring (which
  sits in the right-hand sidebar already) rendered its popover mostly
  off-screen. Now flips per-axis to whichever side actually has room.
- Body links whose target isn't in the current curated export's node set
  (a real, structurally common case under depth-limited extraction)
  rendered as normal-looking, normal-colored links that silently did
  nothing on click -- indistinguishable from a working link until tried.
  Now unwrapped into plain text instead of a dead-but-styled <a>.
- Clicking a link left the hover popover stuck on screen indefinitely
  (the hovered element's mouseleave doesn't reliably fire when the click
  handler replaces its own DOM). Popover now explicitly hides on every
  navigation.
- Following links freely through the graph had no way back except the
  linear reading-order Previous/Next walk. selectNode now pushes real
  browser history (pushState/popstate), so Back/Forward -- the one
  navigation UX every reader already knows -- works for any navigation
  path, chord click or body link alike.
- Node titles rendered as 11px in-SVG text with viewBox padding reserved
  for label overflow (first one-sided -- visibly decentering the ring --
  then symmetric but still eating a large fraction of the widget's small
  on-screen size as dead margin). Titles now show in a real-sized
  #graph-caption above the ring instead; the viewBox only needs to fit
  the circles themselves. A first cut at that padding (16 world units)
  didn't account for the anchor/degree radius bonus, stroke width, or
  hover-lift scale and clipped edge nodes against the panel boundary;
  bumped to 40, confirmed clean in both themes via headless Chromium.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rage

The "On this page" sidebar heading had no explicit font-weight (relying
on the <h3> user-agent default) and used the same muted ink as
de-emphasized meta text elsewhere, undercutting it as a real section
heading. Now explicitly bold with a less-muted color.

Theme choice previously never persisted -- themeIdx was recomputed
purely from prefers-color-scheme on every load, and data-theme was only
ever set inside the click handler, never on initial load. Now reads/
writes localStorage (try/catch guarded), and applies a stored preference
immediately on load rather than only after the first click.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The KB-subgraph -> bilingual interactive HTML export (html_graph.rs)
shipped several runtime/UX bugs this session with no test layer
able to catch any of them -- every existing test asserted on
generated-source substrings, none drove a real browser. Extracted
into its own hardened, independently tested project,
bilingual-kb-export (path-sibling to this repo; see its
kb/adrs/0001-extract-into-standalone-project.org for the full
rationale), with a real browser-behavioral test suite (Chromium AND
Firefox) that caught a further, previously unknown bug on its first
run.

kb_export_html.rs's boundary was already exactly right for this
(GraphExportNode/GraphExportEdge, pre-rendered EN/ES HTML bodies +
positions) -- the swap is a straight import-path change, no logic
changes needed here. mae-export's own html_graph.rs is removed
outright rather than left as an unreferenced, drifting parallel
copy; render_element in html.rs drops back to private now that
nothing outside this crate needs it.

631 mae-ai tests and 54 mae-export tests still pass, clippy clean,
fmt clean, workspace builds clean. Real export re-verified against
the actual RoamNotes KB (21 nodes, 12 translations) and against the
exact original bug repro in real Firefox: no errors, no stuck state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ort module

Ships the bilingual subgraph HTML export as a real mae module, per
bilingual-kb-export's ADR-0002 (kb-graph-view's own thin-wrapper-over-
compiled-Rust shape, not a Scheme reimplementation): a new
(kb-export-subgraph-html ID PATH [DEPTH] [TRANSLATIONS] [TITLE]) primitive
queues onto SharedState.pending_kb_export_requests, drained by
apply_kb_mutations into the SAME mae_ai::execute_kb_export_subgraph_html
the kb_export_subgraph_html MCP tool already calls. modules/kb-subgraph-
export/ wires one convenience keybinding (graph view's "e") on top, mirroring
kb-graph-view's own module shape exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… tag filter

bilingual-kb-export's GraphExportNode gained a tags field this session
(defaults empty in build_export_node, same pattern as is_guidance) to
drive its new header tag-filter UI. This bridge is the one place with
real tag data, so it's the one place that sets it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Implements the bridge-side half of bilingual-kb-export's ADR-0004
(kb/adrs/0004-guidance-nodes-colophon.org): an optional guidance_ids arg,
looked up independently of the BFS seed (may live in a different KB
instance) and always included in the export's colophon section regardless
of reachability -- e.g. a writing-style standard, or a translation-
provenance disclosure. A guidance id that doesn't resolve is reported in
the status string, not silently dropped, matching node_cap's own
truncation reporting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The manual's own content used markdown conventions (**bold**,
backtick-delimited code, triple-backtick fences) in several places,
which mae's org parser doesn't recognize -- confirmed via org_export
on affected buffers, this renders as literal asterisks/backticks
instead of emphasis/code, and in concept-sync-engine's case as
unrendered inline src blocks.

Converts across 56 files:
- **bold** -> *bold* (org's real bold delimiter)
- `code` -> ~code~ (org's real inline-code delimiter)
- ```lang ... ``` fences -> #+begin_src lang / #+end_src

concept-conceal.org's render_markup table cell (which documented all
six org emphasis styles at once, inherently ambiguous under this
conversion) was hand-rewritten as plain prose instead.

See #530.
…kb-export-* options

Adds 15 new Editor options (kb_export_default_depth/max_depth,
kb_export_default_node_cap/max_node_cap, and the 11
ChordDiagramConfig fields), mirroring the existing kb_graph_* 4-site
pattern (Editor struct field + Default, options.rs opt! registration,
option_ops.rs get/set arms) exactly. Every option's factory default
reproduces this tool's original hardcoded behavior byte-for-byte.

Wires them into execute_kb_export_subgraph_html: depth/node_cap now
read their default/ceiling from the Editor options instead of fixed
literals (4-line, fully backward-compatible change). A new
resolve_chord_config() helper builds bilingual-kb-export's
ChordDiagramConfig from the 11 kb_export_* options, then lets a single
new `chord_config` JSON object argument override any subset per-call
-- JSON arg present wins, absent falls back to the option, both absent
falls back to the hardcoded default.

Found and fixed two real bugs while wiring this up:
- editor.kb_export_*'s f32 options cast to ChordDiagramConfig's f64
  fields via a raw `as f64`, which is lossy for most decimal literals
  (1.6_f32 as f64 is 1.600000023841858, not 1.6) -- fixed via a
  string round-trip (f32_option_to_f64).
- resolve_chord_config's u32-field override only checked
  `.as_u64()`, silently ignoring a JSON value serialized as a float
  (e.g. from the Scheme alist bridge below) -- fixed with an
  `.as_f64()` fallback.

Extends the kb-export-subgraph-html Scheme primitive
(crates/scheme/src/runtime/kb_export.rs) to 3 more optional positional
args -- NODE-CAP, GUIDANCE-IDS (a list of string/symbol ids), and
CHORD-CONFIG (an alist of string/symbol keys to numeric values) --
without registering a new primitive (extends the existing,
already-integrated one instead, consistent with kb/adrs/0006 in
bilingual-kb-export: adding a genuinely new primitive from outside
mae's own tree has no path today, per issue #521).

Retires the 3 hardcoded-personal-path throwaway example binaries
under crates/ai/examples/ (never committed) in favor of
docs/KB_SUBGRAPH_EXPORT.md (real invocation recipes as Scheme calls,
the true production interface per this tool's own AI/human-parity
design) and one synthetic, no-real-paths export_demo.rs kept as a
contributor smoke test.
Integrates the whole-KB subgraph -> interactive HTML export feature
(chord-diagram nav widget, bilingual EN/ES overlay, theming, MCP tool,
Scheme primitive, kb-subgraph-export module) onto current origin/main,
resolving 113 commits of drift since the feature branch's base.

Real conflicts resolved (7 files):
- crates/scheme/Cargo.toml: kept origin's newer base64 0.23, added the
  feature's mae-ai dependency + comment explaining the non-circular edge.
- assets/manual/concept-org-mode.org: kept origin's correct heading-scale
  text (*/**/*** -> 1.5x/1.3x/1.15x); the feature branch's own manual-fix
  commit had introduced a real bug here (repeated single-asterisk, wrong
  level mapping).
- crates/core/src/options.rs, crates/core/src/editor/mod.rs: both sides
  added new, non-overlapping option/field blocks at the same insertion
  point (kb_graph_wedge_* vs kb_export_*) -- concatenated both.
- crates/core/src/editor/option_ops.rs: same shape in both the getter and
  setter match arms, but the `_` catch-all differed (origin's dynamic-value
  fallback vs. the feature's plain error) -- kept origin's more complete
  fallback, added the feature's kb_export_* arms above it.
- crates/export/src/lib.rs: two doc-comment wording conflicts (same
  underlying Unicode-decode fix described slightly differently on each
  side) -- kept the more complete wording.
- crates/scheme/src/runtime_tests.rs: two independent test additions
  (kb_register / kb_export_subgraph_html) landed at the same insertion
  point -- kept both as separate tests.

Also found and fixed (not flagged by git as conflicts, but real
integration bugs from the drift):
- crates/export/src/lib.rs: origin/main independently added bare-URL
  auto-linking (`is_bare_url_start`) after this branch diverged, with a
  match on InlineTarget that never anticipated the feature's new
  `PlainText` variant -- added the missing arm (plain URL text, no
  markup, matching the org-link PlainText arm's own contract).
- crates/export/src/lib.rs: both sides had independently added the exact
  same Unicode-decode regression test under the same name, merged
  silently into a duplicate -- removed the duplicate.

Structural judgment call: the feature's actual HTML/chord-diagram
generation logic (crates/export/src/html_graph.rs, ~6200 lines) had been
extracted out of this workspace into a standalone sibling project
(bilingual-kb-export, path-dependency of mae-ai) partway through the
original branch's development. Folded it back in-tree here so this ships
as a normal, self-contained upstream module -- no path-dependency on a
sibling checkout that wouldn't exist for other contributors or CI. The
Scheme primitive registration itself stays in-tree
(crates/scheme/src/runtime/kb_export.rs), per #521's extension point being
for genuinely out-of-tree/downstream primitives, not this feature.

`cargo build --workspace` and `--features gui` both clean, no warnings.
cargo test across mae-core/mae-export/mae-ai/mae-scheme/mae-scheme-extra/
mae (touched crates): all passing, including html_graph.rs's own ported
test suite (adversarial script-injection escaping, wedge geometry, config
override tests) and the merged kb_export_subgraph_html Scheme tests.
cargo clippy -D warnings clean across all touched crates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nvention

crates/export/src/html_graph.rs (vendored in-tree during the
feat/subgraph-html-export integration) is ~7.75x the 800-line source
ceiling. Cross-link it across all three required places per CLAUDE.md's
"Debt/Invariant Tagging" section: an @ai-caution: [architecture-debt]
marker in the file itself, ROADMAP.md's "Architecture Debt" checklist, and
mae-audit.md's "Known exceptions" list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…overage

The classify_kb_tool match arm for kb_export_subgraph_html shipped with
the feature's original commit, but the three adversarial tests proving
it (seed-content-allowed, non-seed-denied, local-provider-always-allowed)
only ever existed on a same-day main-only attempt to wire this tool in
via the extra-kernel-crates extension point (dd0f9c4/5b51974e), which
was fully reverted (ab53312/d495e60a) — for unrelated reasons (that
attempt depended on an unshippable external sibling-checkout chain, not
because the residency fix itself was wrong. This commit's own history
inherits the classification from the feature branch, not that attempt).
Ports the tests forward and documents the guidance_ids scoped-out gap
inline, matching the reverted commit's more thorough analysis.
… recipes

The merged feature branch's doc included a real anchor id, real file
paths, and a real note title from an actual RoamNotes export in this
session. That KB's content must never leave this machine or land in
this repo (standing rule) -- this is about to become a public GitHub
PR, so swap the real example for a synthetic one demonstrating the
same translations/title-override arguments.
strip_leading_properties_drawer only stripped a :PROPERTIES:...:END:
drawer when nothing but whitespace came before it in the body. Real,
reproducible leak in the onprem-iac KB export: a roadmap-step list
item's own drawer (org only auto-hides drawers that immediately follow
a HEADLINE, not a plain list item -- the same gotcha org-kb-to-pdf
documents for the LaTeX pipeline) kept the item's own leading prose
line ("upgrade fails partway.") ahead of the drawer, so the whole
:PROPERTIES:/:ID:/:END: block rendered as visible text.

Now splices the drawer out wherever it appears within the existing
500-char bound, as long as it's alone on its own line (real org drawer
syntax) -- preserves real leading prose instead of requiring the body
to have none, while a guard test confirms a body that merely mentions
":PROPERTIES:" mid-sentence is left untouched.
…content

The org parser already had a top-level handler skipping a :PROPERTIES:
...:END: drawer at the start of a document/paragraph (#528), but it only
ran in the main line-scanning loop. Once the parser entered a list
item's continuation-line accumulation (both ordered and unordered),
every non-blank line -- including a nested drawer's own :PROPERTIES:/
:ID:/:END: lines -- got glued into the item's rendered content
unconditionally.

Real, reproducible leak: the onprem-iac KB gives individual roadmap/
checklist steps their own stable :ID:, so a numbered list routinely has
one drawer per item, not just one for the whole document. Extracted the
skip-to-:END: logic into a shared skip_properties_drawer() helper and
call it from both list continuation loops too.
… in paragraphs too

Two more real bugs found sweeping the onprem-iac KB export for
remaining drawer leaks after the list-loop fix:

1. strip_prefix(|c: char| c.is_ascii_digit()) strips exactly one
   leading digit, not a run -- "10. Document..." became "0. Document..."
   after stripping the "1", then failed the ". "/") " check entirely.
   Any ordered list with 10+ items had its 10th+ items silently fall
   through to plain-paragraph parsing instead of being recognized as
   list items. Replaced with strip_ordered_marker(), which strips the
   whole digit run.

2. That fallthrough exposed a third, unfixed accumulation site: the
   generic paragraph-continuation loop had the same "glue every
   non-blank line in verbatim" behavior the two list loops had before
   the previous commit, so a mis-parsed item 10's own :PROPERTIES:
   drawer leaked there too. Same skip_properties_drawer() fix applied.

Swept the real onprem-iac export after both fixes: 222 -> 30 -> 0
remaining :PROPERTIES: occurrences.
convert_inline_markup_str's ~code~/=code= and +strikethrough+ arms
passed `content` straight into `<code>{}</code>`/`<del>{}</del>` with
zero escaping -- unlike the recursively-converted bold/italic arms,
which self-escape via recursion. A KB node body containing
~<img src=x onerror=...>~ produced a live tag in the rendered
fragment, which kb_export_subgraph_html assigns via element.innerHTML
in the exported page's chord-diagram viewer -- a no-click stored XSS
reachable by any party with write access to a node the export's BFS
touches (including another peer in a shared/federated KB).

Found by a security review before merging PR #567.
OrgElement::ExportBlock's html.rs render arm passed raw HTML through
verbatim -- standard org semantics for a trusted single author, but
this parser also feeds kb_export_subgraph_html's render path, which
exports content that isn't necessarily self-authored (a federated/
shared KB). A KB node body containing #+begin_export html with a
crafted <img onerror=...> produced a live, no-click stored XSS in the
exported page, same delivery path as the inline-code-span fix.

Adds ExportOptions::allow_raw_html_export_blocks (default false,
deliberately NOT settable via the document's own #+OPTIONS: line,
since that would let the same untrusted content re-enable itself) and
a matching editor option org_export_allow_raw_html_blocks (also
default false) so org_export -- which exports the human's own local
buffer -- can still opt into standard raw-passthrough semantics.
kb_export_subgraph_html never reads this option; that path stays
hardcoded-safe by construction, since a shareable multi-party export
should never trust embedded raw HTML.

Found by a security review before merging PR #567.
… loss)

skip_properties_drawer (now skip_drawer) and its three call sites only
matched the literal token ":properties:". Real, reproducible bug worse
than a metadata leak: a :LOGBOOK: drawer (auto-inserted by stock Emacs
on every TODO-state change / clock entry when org-log-into-drawer is
set -- extremely common in real org files) fell straight through to
list/paragraph parsing, leaking its own :END: line as visible text AND
swallowing real body prose that followed it into the wrong element.

Generalized to is_drawer_open_line(): any ":NAME:" alone on its own
line (org's real drawer syntax) opens a drawer to skip, with adversarial
guards so a bare ":END:" is never mistaken for a drawer literally named
"END", and a mid-sentence ":word:" mention in prose is left alone.

Found by a correctness review before merging PR #567.
…rocess)

kb_export_subgraph_html was registered under PermissionTier::Write, but
its mermaid-diagram pre-rendering (try_render_mermaid_svg) shells out
to `npx @mermaid-js/mermaid-cli` -- real subprocess execution with
potential network access (npx fetches the package if not cached).
babel_execute, babel_tangle, and org_export already gate the same
class of operation behind Shell; this tool was inconsistent with that
established convention.

Found by a security review before merging PR #567.
cuttlefisch and others added 9 commits August 1, 2026 13:27
Each guidance_ids entry resolves independently of the seed, possibly
into a DIFFERENT KB instance than the one the seed's own dispatch-time
residency check (SingleTarget) covers -- an agent that knows/guesses a
node id living in a residency-restricted KB could pull its full
content into the export's colophon via a permitted seed elsewhere.
This was a real, previously-documented gap (ai_residency.rs), not
hypothetical, and had no tracking issue.

execute_kb_export_subgraph_html now takes requester_provider and
post-filters its resolved guidance nodes via
mae_core::ai_residency::filter_residency_exempt_by -- the same
primitive kb_links_from's own per-target residency check already
uses (reusing links_backend/describe_for_filter from kb.rs, now
pub(crate), rather than reimplementing instance resolution). Anything
denied is explicitly reported in the tool's returned status
("N guidance id(s) omitted (residency-restricted)"), matching
missing_guidance_ids' existing never-silent convention.

Non-MCP call sites (the Scheme primitive bridge, the :kb-export-html
colon command) pass requester_provider: None, the safe conservative
default -- the residency filter only skips for a CONFIRMED local
provider, so an unknown caller still gets filtered, never bypassed.

Found by a security review before merging PR #567.
html_graph.rs embedded ~900 lines of CSS and ~1,700 lines of hand-
written JavaScript as raw Rust string literals -- over 40% of the
file's line count was front-end asset source, not Rust logic,
un-lintable and un-syntax-checkable as real JS/CSS. This already let a
real bug through (a regex literal corrupted by the inline-script
escaper, caught only by manually running `node --check` -- not by any
CI step, since there wasn't one for an inline string literal).

Moved to crates/export/assets/graph.js and graph.css, loaded via
include_str!() -- the established MAE convention for exactly this
(crates/core/src/theme.rs's theme TOMLs, crates/mae/src/bootstrap.rs's
agent-prompt XML, etc.), already suggested as the fix in this file's
own doc comment. Shrinks html_graph.rs from ~6,270 to ~3,700 lines;
content is unchanged (all 197 mae-export tests still pass unmodified).
Enables `node --check` and a real browser-execution test suite as
follow-up commits in this same pass.

Found by an architecture review before merging PR #567.
…SON injection

render_graph_js/render_static_css applied ChordDiagramConfig to the
generated page via str::replacen against literal anchor text (e.g.
"var HOVER_GROWTH_FACTOR = 1.6;") -- silently a no-op (dead option, no
error signal) if that literal text ever reformatted, since it depended
on an exact byte-for-byte match against the JS/CSS source.

The 10 JS-facing fields now flow through the #graph-data JSON payload
(already used for node/edge data) as a real chordConfig object;
graph.js reads it once at load with `??` (not `||`, since 0 is a real,
documented value for edge_pull_back/wedge_gap_radians, not "unset")
against hardcoded defaults matching ChordDiagramConfig::default()
exactly -- so the file stays independently valid, node --check-able JS
even without that payload. The remaining CSS-facing field
(ui_transition_ms) becomes a real CSS custom property
(--ui-transition-ms, with a 200ms fallback on every var() use in
graph.css) set via one small :root{} rule instead of literal-text
substitution -- the 180ms/220ms deliberately-fixed exceptions are
untouched, same as before.

Deletes render_graph_js and its 12-arm replacen chain entirely.
Rewrote the tests that asserted on generated-JS *source text* to
assert on the resolved chordConfig payload instead (the actual
mechanism now), and added an adversarial case the old mechanism had no
equivalent gap for: edge_pull_back: 0.0 must not be mistaken for
"unset" and silently replaced with the 0.55 default.

Found by an architecture review before merging PR #567.
…-export-subgraph-html

crates/export/src/html_graph.rs's own test suite only asserts on
generated HTML/JS *source text* -- none of it loads the exported page
and drives it, which is exactly how a real bug (a regex literal
corrupted by the inline-script escaper) shipped unnoticed and was
caught only by manually running `node --check`, not by any automated
gate. This "Layer 2" suite existed in the standalone project this
feature was folded back in-tree from, but wasn't carried over during
that integration.

Ports the harness (plain `node --test` + `puppeteer-core` driving real
Chromium AND Firefox over file://, no headless-browser bundling or
DOM mocking) and its fixture generator (crates/export/examples/
fixture_export.rs, using mae-export's own public html_graph API
directly -- no Editor/KB dependency needed) verbatim: all 54 tests
(27 x 2 engines) pass unmodified against the ported fixture, including
a real runtime confirmation that a ChordDiagramConfig override
(hover_growth_factor) produces measurably different behavior -- not
just different generated source text, validating the prior commit's
JSON-injection config mechanism end to end.

Own package.json/package-lock.json/gitignored node_modules, not wired
into `make test`/CI yet (see the new README.md) -- run manually before
merging any change to crates/export/assets/ or html_graph.rs's HTML
assembly.

Found by an architecture review before merging PR #567.
Adds an export-js path filter + a minimal export-js-check job that
runs `node --check crates/export/assets/graph.js` on any change to
that file -- a cheap, permanent version of the manual check that was
the ONLY thing catching a real regex-literal-corruption bug in this
file before it became a real checked-in asset (see the prior two
commits). Distinct from and much faster than the full real-browser
suite in crates/export/tests/browser/, which isn't wired into CI yet.

Also updates ROADMAP.md's and mae-audit.md's html_graph.rs entries to
reflect the completed JS/CSS extraction (~6,205 -> ~3,700 lines) --
per CLAUDE.md's cross-reference discipline, these two docs and the
file's own @ai-caution marker should all read consistently.
resolve_chord_config accepted any f64/u32 in a chord_config override
unvalidated, unlike depth/node_cap one field over (each backed by a
real Editor option ceiling). A wildly out-of-range value (e.g.
edge_pull_back: 1e300) flowed straight into generated JS with zero
server-side sanity check, producing garbage SVG geometry client-side
with no error signal.

Each field now clamps to a generous, doc-justified range (e.g.
edge_pull_back/wedge_corner_radius_fraction to their own documented
[0, 1] meaning). NaN/Infinity are deliberately NOT special-cased --
serde_json::Value structurally cannot represent a non-finite f64 at
all (confirmed empirically: Number::from_f64 rejects it at
construction in both the JSON-RPC wire format and the Scheme-
primitive-to-JSON bridge), so a .is_finite() guard would be dead code
testing a scenario the type system already makes impossible. The real,
reachable adversarial case is a legitimately finite NEGATIVE float for
a u32 field, which override_u32! now rejects outright (kept at the
prior/default value) rather than letting a saturating float-to-int
cast silently reinterpret e.g. -5.0 as 0.

Found by a correctness review before merging PR #567.
ListItem::children existed since this parser's origin but was never
populated -- an indented sub-list silently flattened into its parent
list with no structural signal a nested list ever existed, a real,
reproducible silent-content-downgrade found by a correctness review.

parse_list_items() is a new shared, recursive helper (replacing the
duplicated inline while-loops in the unordered/ordered list branches):
a later line matching a list-item marker at strictly greater
indentation than the current level's own marker column starts a
nested list under the last item; same indentation is a sibling; lesser
indentation closes the level back to the caller. Handles any real
nesting depth for the same complexity as a hard-coded single level.
Composes correctly with the drawer-generalization fix from earlier in
this pass (a nested item's own drawer doesn't leak either).

Parsing alone isn't the whole fix -- wired real recursive rendering
into all three consumers so nested content doesn't silently vanish
downstream: html.rs (nested <ul>/<ol>), markdown.rs and org_writer.rs
(2-space-per-level indentation, standard convention for both formats),
and html_graph.rs's plain_text_preview (flattens nested text into the
hover-popover string, matching its existing flatten-everything
behavior). parse_reading_order/parse_reading_order_part deliberately
NOT changed -- that's a narrow, flat-only project convention (a
`* Reading Order` section's Previous/Next/Part list is never meant to
nest), not a gap.

A plain, consistently-indented flat list -- the overwhelmingly common
case, and every list fixture in this crate's test suite before this
feature -- produces the exact same structure as before: all 199
pre-existing tests pass unmodified.

Found by a correctness review before merging PR #567.
std::fs::write(&out_path, &html) was a single non-atomic write -- a
disk-full/kill/power-loss mid-write could leave a partial/corrupt HTML
file at the destination path, with no way for a caller to distinguish
that from a genuinely complete export.

write_atomically() now writes to a `<name>.tmp-<pid>` file in the SAME
directory (guaranteeing the final rename is same-filesystem, never a
cross-filesystem copy that could itself fail partway), then renames it
over the destination -- the destination can now only ever be observed
fully-old or fully-new, never partial. Best-effort temp-file cleanup
on a write failure. No new dependency (tempfile is already a
dev-dependency only, not available to production code).

Found by a correctness review before merging PR #567.
Ports the 4 still-relevant design decisions from the feature's original
standalone-project history into real numbered MAE ADRs (077-081; 080 is
superseded by 081, which documents the JSON-injection config mechanism
that replaced it in Phase 3). ADRs describing the now-obsolete
standalone-sibling-project architecture (extraction, clone-dependency)
are deliberately not ported, per the decision already made when this
feature was folded in-tree.

Bulk-replaces every dangling `kb/adrs/NNNN`/`bilingual-kb-export`
comment citation across the crate with a reference to the new ADRs, or
drops the citation where the source ADR wasn't ported. Also fixes one
scheme-level test (`kb_export_subgraph_html_from_scheme_with_chord_config_alist_override`)
still asserting against generated JS source text instead of the real
JSON config-injection payload -- missed by Phase 3.2's rewrite since it
lives in `crates/scheme`, not `crates/export`/`crates/ai`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cuttlefisch and others added 6 commits August 1, 2026 14:36
crates/export/src/html_graph.rs's GruvboxPalette is a hand-copied
byte-for-byte snapshot of crates/core/src/themes/gruvbox-{dark,light}.toml,
needed so the exported HTML page stays self-contained/offline with no
runtime dependency on the editor's theme system. Nothing currently
catches the two drifting apart if either TOML file changes.

Not fixed in this PR -- filed as #568 and cross-linked
from an @ai-caution: [architecture-debt] marker on the struct plus a
new ROADMAP.md Architecture Debt entry, per the project's 3-way debt
cross-reference discipline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The pre-merge test-quality review flagged simple_node("a", "A", ...)
as a repeated unicorn-value fixture across the html_graph.rs test
suite -- a value chosen for convenience that could incidentally dodge
an edge case a more realistic id/title would hit. Diversifies the ids
and titles specifically in the tests most tied to this PR's Phase 1
security fixes (script/style tag breakout, #+begin_export html
escaping, quote/JSON escaping, PROPERTIES-drawer leak) rather than a
blanket rewrite of the ~30 other tests still using the shared fixture
for unrelated behavior. The adversarial payloads themselves are
unchanged -- only the surrounding id/title context varies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
kb_export_subgraph_html's depth/node_cap resolution
(.and_then(|v| v.as_u64())) silently returns None for a JSON string
or a negative number, falling through to the Editor-option default --
correct behavior, but previously untested per the pre-merge review.
Adds a test with depth="many" and node_cap=-5 confirming the call
succeeds and produces the expected default-depth/default-node_cap
export (root + 3 spokes, nothing hidden) instead of panicking or
silently truncating to zero nodes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
daemon/Cargo.lock's mae-daemon/mae-kb/mae-mcp/mae-sync entries were
still pinned at 0.14.81, stale relative to the editor workspace's
current 0.14.87 -- picked up by this PR's final-verification daemon
build (daemon has its own separate Cargo.lock per ADR-014).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This checkout never ran `make setup-hooks`, so every commit in this
session's hardening pass skipped the local fmt/clippy/code-map/ADR
pre-commit gate entirely, letting two purely mechanical issues reach
CI: unformatted manual edits across 5 files, and a code map that
hadn't been regenerated after this PR's file moves/additions. Also
enables the hooks for this checkout going forward (`make setup-hooks`)
so this doesn't recur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ion gap

Two related fixes found while validating kb-export-subgraph-html against
a real personal KB after PR #567's hardening pass:

1. required_tag (ADR-082): kb_export_subgraph_html previously only
   offered seed+depth BFS with client-side tag dimming, so export
   correctness depended on picking exactly the right seed/depth by luck.
   A real case (exporting a curated "terraform onboarding" walkthrough)
   picked a plausible but wrong seed and pulled in 100+ unrelated
   reference nodes with no error. New optional `required_tag` param
   (MCP tool + Scheme primitive, mae_kb::SubgraphSpec) hard-filters the
   final node set to only tagged nodes (seed always included) while BFS
   still traverses through untagged intermediates -- applied before
   node_cap so the two cutoffs compose correctly and reuse the existing
   boundary-link demotion path. Reports how many nodes were excluded
   (tag_filtered_count), same never-silent-truncation convention as
   node_cap.

2. kb_agenda federation gap (ADR-083): while investigating why
   kb_agenda(filter=tag) returned zero results against a real federated
   RoamNotes instance despite the node being correctly tagged, traced
   execute_kb_agenda to only ever querying editor.kb.store (the primary
   KB's own Cozo store) -- every filter type was structurally unable to
   see ANY federated instance's nodes, not a tag-matching bug at all.
   Fixed by mirroring execute_kb_health's per-instance registry-iteration
   pattern: queries each in-scope instance's CozoKbStore when open, or a
   new KnowledgeBase::agenda_query_in_memory fallback (field-for-field
   matched against the Cozo query semantics) for a purely in-memory
   federated instance. Stale/Custom have no in-memory equivalent and are
   now reported via skipped_instances rather than silently omitted.
   Reclassified kb_agenda from PrimaryOnlyFilterable to
   ScopedFederatedScanFilterable (crates/mae/src/ai_residency.rs) since
   that shape's contract now genuinely matches kb_search's; the old
   PrimaryOnlyFilterable variant is removed entirely (zero real tools
   left in it) rather than kept as dead code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cuttlefisch
cuttlefisch merged commit 4f7cf46 into main Aug 1, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant