From b2e346f083b3d95c24c8a348e59e7044861bea45 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 12:07:54 +0000 Subject: [PATCH 1/3] =?UTF-8?q?cockpit-server:=20unified=20ClassView=20car?= =?UTF-8?q?d=20=E2=80=94=20guid/name=20value=20rows=20+=20generic=20/api/c?= =?UTF-8?q?ard.html=20route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores PR #84's server-side code (66179d0a on claude/classview-unified- render) which was never pushed before the PR merged — the merge carried only the plan commit (04d82236). Applied onto post-#86/post-a857ac2d main: the reference commit was itself authored after a857ac2d (one-class collapse, canon-high 0x0701_1000), so its module docs, slot rule, and classid tests already match current main's model; the cherry-pick applied clean with zero drift in crates/cockpit-server/ or templates/ since the branch base. What lands (crates/cockpit-server/ only): - osint_classview.rs: ValueCardRow (unified schema/value row, optional value+decoded), OSINT_V3_INDEX (name/id -> guid1/guid2 from data/osint-v3/osint_v3_nodes.json, degrades to empty when absent), OSINT_DECODE ((slot, position, byte) -> vocab name, slot 1 built mechanically from the codebook's declared tier order), parse_guid_hex, facet_of, slot_for_entry, resolve_card + resolved_rows + render_card_response (the one shared resolution/render core), and generic_card_handler (400s on missing/malformed guid). - main.rs: registers GET /api/card.html alongside the OSINT routes. - templates/osint_card_fragment.html: bare fragment (fragment=1) for the React popup container, zero CSS. - templates/osint_card.html: optional value column via has_values — byte-identical output for the pre-existing class/mask-only requests. The original commit's decode-coverage caveat is now resolved by the #86 rebake (data stored in declared field order): the mechanical slot-1 mapping verified empirically on current main data at 609/611 guid1 facets fully in-vocabulary; the two misses are known codebook gaps (Shoebox civicUse=17, DreamSecurity MLType=30), not order errors. guid2 (person/McClelland) remains undecoded in THIS commit; the 1-indexed slot-2 decode ruled by #86 lands in the follow-up commit. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012jEwwaT5JZ5x8qWvcnaMYC --- crates/cockpit-server/src/main.rs | 5 + crates/cockpit-server/src/osint_classview.rs | 669 +++++++++++++++++- .../cockpit-server/templates/osint_card.html | 4 +- .../templates/osint_card_fragment.html | 12 + 4 files changed, 652 insertions(+), 38 deletions(-) create mode 100644 crates/cockpit-server/templates/osint_card_fragment.html diff --git a/crates/cockpit-server/src/main.rs b/crates/cockpit-server/src/main.rs index 714793d9a..075eaa5c6 100644 --- a/crates/cockpit-server/src/main.rs +++ b/crates/cockpit-server/src/main.rs @@ -230,6 +230,11 @@ async fn main() { // the Redmine-style ViewFilter, server-side (`?mask=`, omitted = FULL). .route("/api/osint/card", get(osint_classview::osint_card_handler)) .route("/api/osint/card.html", get(osint_classview::osint_card_html_handler)) + // The generic, OSINT-agnostic sibling: any well-formed 16-byte node + // key renders through the SAME ClassView fieldmask core as the OSINT + // route above (`resolve_card` + `render_card_response` in + // osint_classview.rs) — different route, same projection. + .route("/api/card.html", get(osint_classview::generic_card_handler)) // /body server-side HHTL LOD — POST camera → per-concept HhtlAction byte // (cascade over 1658 baked BlockBounds; native SIMD; client gates draw by it). .route("/api/body/lod", post(body_lod::body_lod_handler)) diff --git a/crates/cockpit-server/src/osint_classview.rs b/crates/cockpit-server/src/osint_classview.rs index d838b87af..1b283fd60 100644 --- a/crates/cockpit-server/src/osint_classview.rs +++ b/crates/cockpit-server/src/osint_classview.rs @@ -39,12 +39,15 @@ //! follows the existing cockpit pattern of impl'ing a contract trait locally //! (cf. `mock_driver.rs` impl'ing `CognitiveShaderDriver`). +use std::collections::HashMap; +use std::path::PathBuf; use std::sync::LazyLock; use askama::Template; +use axum::http::StatusCode; use axum::response::Html; use axum::{extract::Query, Json}; -use lance_graph_contract::class_view::{ClassId, ClassView, FieldMask}; +use lance_graph_contract::class_view::{ClassId, ClassView, FieldMask, RenderRow, ValueRow}; use lance_graph_contract::ontology::{DisplayTemplate, FieldRef}; use serde::Deserialize; @@ -130,12 +133,191 @@ fn concept_name(class: ClassId) -> &'static str { } } +// ── V3 name index — resolving a clicked node's label to its V3 GUID ──────── +// +// The LIVE gotham scene's node GUIDs (`osint_gotham.rs`) are HHTL routing +// tiers, NOT the content-blind facet payload this card projects (recon +// finding R1, `claude-notes/plans/2026-07-06-classview-unified-render.md`). +// The true 12-byte facet GUIDs live in the unserved `data/osint-v3/` bake. +// This index resolves a clicked node's display `name` (also indexed by its +// short `id`, for resilience) to that bake's `guid1`/`guid2` bytes so the +// popup can show real values without wiring a full V3-backed graph store. + +/// One `data/osint-v3/osint_v3_nodes.json` row, parsed into raw key bytes. +#[derive(Debug, Clone, Copy)] +struct V3Entry { + guid1: [u8; 16], + guid2: Option<[u8; 16]>, + is_person: bool, +} + +/// The on-disk row shape (`osint_v3_nodes.json`) — see +/// `data/osint-v3/OSINT_V3_BAKE_README.md`. +#[derive(Deserialize)] +struct V3NodeRaw { + id: String, + name: String, + guid1: String, + guid2: Option, + is_person: bool, +} + +/// Resolve `data/osint-v3/` relative to the repo root, trying the +/// compile-time-anchored path first (works regardless of runtime cwd — the +/// Railway/Docker launch dir need not be the repo root) and a few cwd- +/// relative fallbacks (works for `cargo test` / `cargo run` from various +/// working directories). Returns `None` if nothing on disk matches; callers +/// degrade to an empty index rather than panicking. +fn resolve_data_path(filename: &str) -> Option { + let candidates = [ + PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../data/osint-v3")).join(filename), + PathBuf::from("data/osint-v3").join(filename), + PathBuf::from("../data/osint-v3").join(filename), + PathBuf::from("../../data/osint-v3").join(filename), + ]; + candidates.into_iter().find(|p| p.exists()) +} + +/// Parse a raw GUID hex string into its 16 key bytes. Accepts dashes +/// (stripped) and requires exactly 32 hex digits after stripping — anything +/// else (wrong length, non-hex junk) is `None`, never a panic. +fn parse_guid_hex(s: &str) -> Option<[u8; 16]> { + let cleaned: String = s.chars().filter(|c| *c != '-').collect(); + if cleaned.len() != 32 || !cleaned.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let mut out = [0u8; 16]; + for i in 0..16 { + out[i] = u8::from_str_radix(&cleaned[i * 2..i * 2 + 2], 16).ok()?; + } + Some(out) +} + +/// The V3 name/id → GUID index, loaded once from `osint_v3_nodes.json`. +/// Empty (never panics) if the bake isn't on disk — routes then fall back +/// to the schema-only card, the honest zero-fallback rung. +static OSINT_V3_INDEX: LazyLock> = LazyLock::new(|| { + let mut map = HashMap::new(); + let Some(path) = resolve_data_path("osint_v3_nodes.json") else { + tracing::warn!( + "osint_classview: osint_v3_nodes.json not found on disk — value rows disabled, schema-only fallback active" + ); + return map; + }; + let Ok(bytes) = std::fs::read(&path) else { + tracing::warn!("osint_classview: failed to read {}", path.display()); + return map; + }; + let raw: Vec = match serde_json::from_slice(&bytes) { + Ok(v) => v, + Err(e) => { + tracing::warn!("osint_classview: failed to parse {}: {e}", path.display()); + return map; + } + }; + for n in raw { + let Some(guid1) = parse_guid_hex(&n.guid1) else { + continue; + }; + let guid2 = n.guid2.as_deref().and_then(parse_guid_hex); + let entry = V3Entry { + guid1, + guid2, + is_person: n.is_person, + }; + // Index by both the display name and the short id — both are + // legitimate exact-match keys from the same row, so this only + // improves hit rate; it never introduces ambiguity for either key. + map.insert(n.name, entry); + map.entry(n.id).or_insert(entry); + } + tracing::info!( + "osint_classview: V3 name index loaded ({} entries)", + map.len() + ); + map +}); + +// ── Codebook value decode — byte → controlled-vocabulary name ────────────── +// +// `airo_vocab` (guid1 / AI-system facet) is a plain `name -> u8` dict per +// field, so its reverse map (`u8 -> name`) is mechanical and unambiguous. +// `mcclelland_vocab` (guid2 / person facet) is a plain ARRAY per field with +// no explicit value labels — whether array index 0 means byte value 0 or 1 +// is a guess, not a fact read off the codebook, so guid2 positions are left +// undecoded (`None`) per the "don't guess semantic mappings" rule. + +/// The subset of `osint_v3_codebook.json` this module actually reads. +#[derive(Deserialize)] +struct CodebookRaw { + guid1_tiers: HashMap, + airo_vocab: HashMap>, +} + +fn resolve_codebook_path() -> Option { + resolve_data_path("osint_v3_codebook.json") +} + +/// `(slot, position, value) -> name` — slot `1` is the only slot with an +/// unambiguous reverse mapping (see module doc above); slot `2` never gets +/// an entry, so `.get()` naturally returns `None` for every guid2 lookup. +static OSINT_DECODE: LazyLock> = LazyLock::new(|| { + let mut map = HashMap::new(); + let Some(path) = resolve_codebook_path() else { + tracing::warn!( + "osint_classview: osint_v3_codebook.json not found on disk — value decode disabled" + ); + return map; + }; + let Ok(bytes) = std::fs::read(&path) else { + tracing::warn!("osint_classview: failed to read {}", path.display()); + return map; + }; + let cb: CodebookRaw = match serde_json::from_slice(&bytes) { + Ok(v) => v, + Err(e) => { + tracing::warn!("osint_classview: failed to parse {}: {e}", path.display()); + return map; + } + }; + // guid1 (slot 1) field order: HEEL, HIP, TWIG, LEAF, family, identity, + // each tier's "hi:lo" name split into two consecutive positions — the + // exact order OSINT_SYSTEM_FIELDS above declares. + let mut position: u8 = 0; + for tier in ["HEEL", "HIP", "TWIG", "LEAF", "family", "identity"] { + let Some(pair) = cb.guid1_tiers.get(tier) else { + continue; + }; + for field in pair.split(':') { + if let Some(vocab) = cb.airo_vocab.get(field) { + for (name, value) in vocab { + map.insert((1u8, position, *value), name.clone()); + } + } + position += 1; + } + } + map +}); + /// `?class=&mask=` — the ViewFilter. `class` omitted = the AI-system -/// card (`0x0700`); `mask` omitted = FULL. +/// card (`0x0700`); `mask` omitted = FULL. `guid`/`name`/`fragment` are the +/// unified-render additions (see module-level docs on [`resolve_card`]); +/// they are ignored by the plain JSON handler, which keeps the pre-existing +/// class/mask-only projection. #[derive(Deserialize)] pub struct CardQuery { class: Option, mask: Option, + /// Raw 16-byte node key (32 hex chars, dashes optional) — the + /// content-blind V3 facet payload lives at bytes 4..16. + guid: Option, + /// A node's display name (or short id) — resolved against + /// [`OSINT_V3_INDEX`] to its `guid1`/`guid2` bytes. + name: Option, + /// `1` → render the bare `
` fragment (no doctype/head/style); + /// omitted/anything else → the existing full page. + fragment: Option, } impl CardQuery { @@ -150,6 +332,9 @@ impl CardQuery { /// FieldMask and return the surviving `(label, predicate)` rows. This is the /// Redmine ERB ViewFilter, server-side: the mask selects the columns, the /// ClassView resolves the labels, nothing is computed on the client. +/// +/// Unchanged by the unified-render addition — `guid`/`name`/`fragment` are +/// JSON-irrelevant; this handler keeps the original class/mask-only shape. pub async fn osint_card_handler(Query(q): Query) -> Json { let (class, mask) = q.resolve(); let cv = OsintClassView; @@ -170,10 +355,196 @@ pub async fn osint_card_handler(Query(q): Query) -> Json/`). -struct OsintCardRow { +/// `value`/`decoded` are `None` for schema-only rows (the pre-existing +/// class/mask projection — no `guid`/`name` given) and `Some` for the +/// value-projected renders (guid/name lookups, the generic facet dump). One +/// struct, optional value fields: the templates branch on ROW SHAPE +/// (`has_values` picks whether the value column exists at all; `row.value`/ +/// `row.decoded` pick its per-row content), never on a per-field basis. +struct ValueCardRow { role: String, label: String, predicate: String, + value: Option, + decoded: Option, +} + +/// `predicate = "aiwar:/"` — the reasoning role is the prefix. +fn role_from_predicate(predicate: &str) -> String { + predicate + .strip_prefix("aiwar:") + .and_then(|s| s.split('/').next()) + .unwrap_or("") + .to_string() +} + +/// Schema-only rows (no guid/name resolved) — the pre-existing projection, +/// now wrapped in [`ValueCardRow`] with `value`/`decoded` left `None`. +fn render_rows_to_cardrows(rows: Vec>) -> Vec { + rows.into_iter() + .map(|r| ValueCardRow { + role: role_from_predicate(r.predicate), + label: r.label.to_string(), + predicate: r.predicate.to_string(), + value: None, + decoded: None, + }) + .collect() +} + +/// Value-projected rows from a resolved guid/name facet — decode each byte +/// against [`OSINT_DECODE`] for the given slot (`1` = guid1/system, `2` = +/// guid2/person); a miss (out-of-vocab byte, or slot 2 which is never +/// indexed) leaves `decoded: None`, never a guess. +fn value_rows_to_cardrows(rows: Vec>, slot: u8) -> Vec { + rows.into_iter() + .map(|r| { + let decoded = OSINT_DECODE.get(&(slot, r.position, r.value)).cloned(); + ValueCardRow { + role: role_from_predicate(r.predicate), + label: r.label.to_string(), + predicate: r.predicate.to_string(), + value: Some(r.value), + decoded, + } + }) + .collect() +} + +/// The generic facet dump — the last rung of the zero-fallback ladder for a +/// classid `resolve_render_class` couldn't resolve to a bespoke/inherited +/// card (empty field set). Twelve raw `b0..b11` rows, no bespoke labels, +/// never a 404 for a well-formed guid. +fn generic_dump_rows(facet: &[u8; 12]) -> Vec { + (0u8..12) + .map(|i| ValueCardRow { + role: "facet".to_string(), + label: format!("b{i}"), + predicate: String::new(), + value: Some(facet[i as usize]), + decoded: None, + }) + .collect() +} + +/// The 12-byte content-blind facet payload — bytes 4..16 of a node's 16-byte +/// key (bytes 0..4 are the classid). +fn facet_of(key: &[u8; 16]) -> [u8; 12] { + key[4..16] + .try_into() + .expect("a 16-byte key always slices to a 12-byte facet at [4..16]") +} + +/// The person/system slot rule for a V3 index hit: person WITH a guid2 → +/// the person card (slot 2, GUID2 McClelland facet); otherwise → the system +/// card (slot 1, GUID1 AIRO facet). Pure function (no disk/LazyLock access) +/// so the rule is directly unit-testable. +fn slot_for_entry( + is_person: bool, + guid1: [u8; 16], + guid2: Option<[u8; 16]>, +) -> (ClassId, u8, [u8; 12]) { + if is_person && let Some(g2) = guid2 { + return (OSINT_PERSON_CLASS, 2, facet_of(&g2)); + } + // Not a person, OR is_person but no guid2 on the row (a data gap) — fall + // through to the guid1/system facet rather than rendering nothing. + (OSINT_SYSTEM_CLASS, 1, facet_of(&guid1)) +} + +/// A resolved render target — which class/mask to project, and (if a +/// guid/name resolved to a real payload) which slot + facet bytes back it. +struct ResolvedCard { + class: ClassId, + mask: FieldMask, + /// `Some((slot, facet))` when `guid`/`name` resolved to a payload; + /// `None` for the pre-existing schema-only projection. + facet: Option<(u8, [u8; 12])>, +} + +/// The ONE resolution algorithm behind every unified-render entry point +/// (`osint_card_html_handler`'s guid/name branches, `generic_card_handler`) +/// — different routes, same ClassView fieldmask (per +/// `claude-notes/plans/2026-07-06-classview-unified-render.md`). +/// +/// Resolution order: +/// 1. `name` → [`OSINT_V3_INDEX`] lookup → [`slot_for_entry`]. A miss falls +/// through to the schema-only projection (honest zero-fallback: the +/// popup still shows something). +/// 2. `guid` → parse the raw key; card class = the explicit `class` param if +/// given, else `resolve_render_class(hi u16 of the key's classid)`; slot +/// is a property of two-GUID classes (`class == 0x0701` ⇒ slot 2), +/// defaulting to 1 — the explicit `class` param is the disambiguator, not +/// the slot inferred from the classid bits (the V3 bake stamps the SAME +/// classid on every node regardless of system/person kind, so the classid +/// alone cannot pick the slot — see the module-level caveat below). +/// 3. Neither → the original class/mask-only projection (`facet: None`). +/// +/// **Caveat (flagged, not silently patched):** the V3 bake's `guid1`/`guid2` +/// share one classid (`0x0701_1000`, "ONE OSINT class, deliberately" — +/// `OSINT_V3_BAKE_README.md`), so `resolve_render_class(hi u16)` on a bare +/// `guid=` (no `name`, no explicit `class`) always resolves to `0x0701` +/// (person card, 5 fields) even when the bytes came from a system node's +/// `guid1`. Callers who want the system card from a raw `guid1` must pass +/// `?class=0x0700` explicitly. This is exactly what the spec's resolution +/// order describes; it is not a bug in this function. +fn resolve_card( + class_param: Option, + mask_param: Option, + guid_param: Option<&str>, + name_param: Option<&str>, +) -> ResolvedCard { + let cv = OsintClassView; + let mask = mask_param.map(FieldMask).unwrap_or(FieldMask::FULL); + + if let Some(name) = name_param { + if let Some(entry) = OSINT_V3_INDEX.get(name) { + let (class, slot, facet) = slot_for_entry(entry.is_person, entry.guid1, entry.guid2); + return ResolvedCard { + class, + mask, + facet: Some((slot, facet)), + }; + } + // name miss → fall through to the schema-only projection below, + // using the class/mask defaults (today's behavior). + } else if let Some(guid) = guid_param + && let Some(key) = parse_guid_hex(guid) + { + let classid = u32::from_le_bytes(key[0..4].try_into().unwrap()); + let hi = (classid >> 16) as u16; + let class = class_param.unwrap_or_else(|| cv.resolve_render_class(hi)); + let slot: u8 = if class == OSINT_PERSON_CLASS { 2 } else { 1 }; + return ResolvedCard { + class, + mask, + facet: Some((slot, facet_of(&key))), + }; + } + // A malformed guid (or a name miss, above) falls through here to the + // schema-only projection using the class/mask defaults — the generic + // route 400s on a malformed guid before ever calling this function; + // the HTML card route degrades honestly instead. + + let class = class_param.unwrap_or(OSINT_SYSTEM_CLASS); + ResolvedCard { + class, + mask, + facet: None, + } +} + +/// Project a [`ResolvedCard`] into its render rows: schema-only projection, +/// value-projected facet rows, or (an unknown classid on the facet path) the +/// generic facet dump — the zero-fallback ladder's last rung. +fn resolved_rows(cv: &OsintClassView, resolved: &ResolvedCard) -> Vec { + match resolved.facet { + Some((_slot, facet)) if cv.fields(resolved.class).is_empty() => generic_dump_rows(&facet), + Some((slot, facet)) => { + value_rows_to_cardrows(cv.facet_rows(resolved.class, resolved.mask, &facet), slot) + } + None => render_rows_to_cardrows(cv.render_rows(resolved.class, resolved.mask)), + } } /// The card view — a dumb askama loop over the mask-filtered rows (the "XSLT" @@ -187,41 +558,52 @@ struct OsintCardTemplate { mask_hex: String, shown: usize, total: usize, - rows: Vec, + has_values: bool, + rows: Vec, } -/// `GET /api/osint/card.html?class=&mask=` — the same ViewFilter, -/// rendered server-side via the compile-time-checked askama template. The -/// *projection* (mask → rows) is identical to the JSON handler; askama is only -/// the XSLT. Each row shows its reasoning role (parsed from the predicate -/// prefix), the field label, and the predicate key. -pub async fn osint_card_html_handler(Query(q): Query) -> Html { - let (class, mask) = q.resolve(); - let cv = OsintClassView; - let rows: Vec = cv - .render_rows(class, mask) - .into_iter() - .map(|r| { - // predicate = "aiwar:/" — the reasoning role is the prefix. - let role = r - .predicate - .strip_prefix("aiwar:") - .and_then(|s| s.split('/').next()) - .unwrap_or("") - .to_string(); - OsintCardRow { - role, - label: r.label.to_string(), - predicate: r.predicate.to_string(), - } - }) - .collect(); +/// The bare-table fragment — same row shape as [`OsintCardTemplate`], no +/// doctype/head/style/h1. Presentation (theme, spacing, color) is owned by +/// the consuming popup container (the React click-to-inspect panel). +#[derive(Template)] +#[template(path = "osint_card_fragment.html")] +struct OsintCardFragmentTemplate { + has_values: bool, + rows: Vec, +} + +/// Shared response builder: resolve → rows → pick template (full page vs +/// fragment) → render. The ONE code path behind both `osint_card_html_handler` +/// and `generic_card_handler` — different routes, same ClassView fieldmask. +fn render_card_response( + cv: &OsintClassView, + resolved: ResolvedCard, + fragment: bool, +) -> Html { + let has_values = resolved.facet.is_some(); + let is_generic_dump = has_values && cv.fields(resolved.class).is_empty(); + let rows = resolved_rows(cv, &resolved); + let total = if is_generic_dump { + 12 + } else { + cv.field_count(resolved.class) + }; + + if fragment { + let tpl = OsintCardFragmentTemplate { has_values, rows }; + return match tpl.render() { + Ok(body) => Html(body), + Err(e) => Html(format!("
osint card fragment render error: {e}
")), + }; + } + let tpl = OsintCardTemplate { - classid_hex: format!("0x{class:04x}"), - concept: concept_name(class), - mask_hex: format!("0x{:03x}", mask.0), + classid_hex: format!("0x{:04x}", resolved.class), + concept: concept_name(resolved.class), + mask_hex: format!("0x{:03x}", resolved.mask.0), shown: rows.len(), - total: cv.field_count(class), + total, + has_values, rows, }; // askama render is infallible for this static template; fall back to a @@ -232,6 +614,49 @@ pub async fn osint_card_html_handler(Query(q): Query) -> Html } } +/// `GET /api/osint/card.html?class=&mask=` — the same ViewFilter, +/// rendered server-side via the compile-time-checked askama template. The +/// *projection* (mask → rows) is identical to the JSON handler; askama is only +/// the XSLT. Each row shows its reasoning role (parsed from the predicate +/// prefix), the field label, and the predicate key. +/// +/// Extended (additive) with `guid`/`name`/`fragment` — see [`resolve_card`]. +/// With none of the three given, this reproduces the pre-existing output +/// exactly (regression-tested). +pub async fn osint_card_html_handler(Query(q): Query) -> Html { + let cv = OsintClassView; + let resolved = resolve_card(q.class, q.mask, q.guid.as_deref(), q.name.as_deref()); + render_card_response(&cv, resolved, q.fragment == Some(1)) +} + +/// `GET /api/card.html?guid=<32-hex>&class=&mask=&fragment=<0|1>` +/// — the generic, OSINT-agnostic sibling of `/api/osint/card.html`. Requires +/// `guid`; a `class`/`mask`/`fragment` mirror the OSINT route's knobs. +/// Different route, same ClassView fieldmask core ([`resolve_card`] + +/// [`render_card_response`]) — that shared core is the point of this route. +pub async fn generic_card_handler( + Query(q): Query, +) -> Result, (StatusCode, String)> { + let Some(guid) = q.guid.as_deref() else { + return Err(( + StatusCode::BAD_REQUEST, + "GET /api/card.html requires ?guid=<32-hex-char node key> (dashes optional)" + .to_string(), + )); + }; + if parse_guid_hex(guid).is_none() { + return Err(( + StatusCode::BAD_REQUEST, + format!( + "malformed guid {guid:?}: expected 32 hex characters (dashes optional), got a value that doesn't parse" + ), + )); + } + let cv = OsintClassView; + let resolved = resolve_card(q.class, q.mask, Some(guid), None); + Ok(render_card_response(&cv, resolved, q.fragment == Some(1))) +} + #[cfg(test)] mod tests { use super::*; @@ -279,10 +704,10 @@ mod tests { // Smoke the askama path end-to-end for the person card: header + // one row per selected field, role parsed from the predicate prefix. let cv = OsintClassView; - let rows: Vec = cv + let rows: Vec = cv .render_rows(OSINT_PERSON_CLASS, FieldMask::FULL) .into_iter() - .map(|r| OsintCardRow { + .map(|r| ValueCardRow { role: r .predicate .strip_prefix("aiwar:") @@ -291,6 +716,8 @@ mod tests { .to_string(), label: r.label.to_string(), predicate: r.predicate.to_string(), + value: None, + decoded: None, }) .collect(); let tpl = OsintCardTemplate { @@ -299,6 +726,7 @@ mod tests { mask_hex: "0x01f".to_string(), shown: rows.len(), total: 5, + has_values: false, rows, }; let body = tpl.render().expect("askama render"); @@ -308,4 +736,173 @@ mod tests { // dumb-loop template: every selected field is a row. assert_eq!(body.matches("
").count(), 5 + 1); // 5 rows + header row } + + // ── unified ClassView render: facet value binding + is_a/slot rules ──── + + /// `facet_of` + `cv.facet_rows` binds field position `i` to raw byte `i` + /// of the key's `[4..16]` slice — the exact structural contract the + /// spec's `facet: &[u8;12] = key[4..16].try_into()` construction promises. + /// No semantic (vocab) validity is asserted here — only the mechanical + /// position↔byte binding. + #[test] + fn facet_binds_position_to_byte_from_key_slice() { + let cv = OsintClassView; + let key: [u8; 16] = [ + 0x00, 0x10, 0x01, 0x07, // classid 0x0701_1000 LE + 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, // facet bytes + ]; + let facet = facet_of(&key); + assert_eq!(facet, [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21]); + let rows = value_rows_to_cardrows( + cv.facet_rows(OSINT_SYSTEM_CLASS, FieldMask::FULL, &facet), + 1, + ); + assert_eq!(rows.len(), 12, "all 12 system fields are facet-backed"); + for (i, row) in rows.iter().enumerate() { + assert_eq!( + row.value, + Some(facet[i]), + "position {i} binds to facet byte {i}" + ); + assert_eq!(facet[i], key[4 + i], "facet byte {i} is key byte {}", 4 + i); + } + } + + /// The person/system slot rule: a name-index hit that is `is_person` AND + /// carries a `guid2` picks the person card (classid `0x0701`, slot 2, + /// 5 rows) — pure, no disk access (`slot_for_entry` takes explicit + /// fields rather than reading `OSINT_V3_INDEX`, so this is directly + /// testable without a fake index entry). + #[test] + fn name_route_person_with_guid2_picks_person_card() { + let cv = OsintClassView; + let guid1 = [0u8; 16]; + let guid2 = [7u8; 16]; + let (class, slot, facet) = slot_for_entry(true, guid1, Some(guid2)); + assert_eq!(class, OSINT_PERSON_CLASS); + assert_eq!(slot, 2); + assert_eq!(facet, facet_of(&guid2)); + let rows = value_rows_to_cardrows(cv.facet_rows(class, FieldMask::FULL, &facet), slot); + assert_eq!(rows.len(), 5, "all 5 person fields are facet-backed"); + + // A non-person entry (or a person row missing guid2 — a data gap) + // falls through to the system card, slot 1. + let (class2, slot2, facet2) = slot_for_entry(false, guid1, None); + assert_eq!(class2, OSINT_SYSTEM_CLASS); + assert_eq!(slot2, 1); + assert_eq!(facet2, facet_of(&guid1)); + let (class3, slot3, _) = slot_for_entry(true, guid1, None); + assert_eq!( + class3, OSINT_SYSTEM_CLASS, + "is_person with no guid2 falls through to the system facet, not a blank card" + ); + assert_eq!(slot3, 1); + } + + /// A classid whose hi u16 is neither `0x0700` nor `0x0701` has an empty + /// field set on this `ClassView` and no `is_a_parent` (default `None`), + /// so `resolve_render_class` returns it unchanged — the caller's signal + /// to render the generic facet dump (12 raw `b0..b11` rows), never a 404. + #[test] + fn unknown_classid_renders_generic_facet_dump() { + let cv = OsintClassView; + let unknown_class: ClassId = 0x1234; + assert!(cv.fields(unknown_class).is_empty()); + assert_eq!(cv.resolve_render_class(unknown_class), unknown_class); + + let facet = [1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + let rows = generic_dump_rows(&facet); + assert_eq!(rows.len(), 12); + for (i, row) in rows.iter().enumerate() { + assert_eq!(row.role, "facet"); + assert_eq!(row.label, format!("b{i}")); + assert_eq!(row.value, Some(facet[i])); + assert_eq!(row.decoded, None); + } + + // End-to-end through `resolve_card` + `resolved_rows`: a guid whose + // classid hi u16 = 0x1234 (unknown) → generic dump, not a panic/404. + let key: [u8; 16] = [ + 0x00, 0x00, 0x34, 0x12, // classid 0x1234_0000 LE → hi u16 = 0x1234 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, + ]; + let hex: String = key.iter().map(|b| format!("{b:02x}")).collect(); + let resolved = resolve_card(None, None, Some(&hex), None); + assert_eq!(resolved.class, 0x1234); + let rows = resolved_rows(&cv, &resolved); + assert_eq!(rows.len(), 12); + assert_eq!(rows[0].label, "b0"); + assert_eq!(rows[0].value, Some(1)); + } + + /// Regression: `/api/osint/card.html` with no new params (`guid`/`name`/ + /// `fragment` all absent) must render EXACTLY what it rendered before + /// this PR — same assertions the pre-existing `html_card_renders_through_ + /// askama` test made (substring + `` count), now exercised through + /// the actual route handler rather than a hand-built template struct. + #[tokio::test] + async fn html_route_with_no_new_params_matches_pre_existing_output() { + let q = CardQuery { + class: None, + mask: None, + guid: None, + name: None, + fragment: None, + }; + let Html(body) = osint_card_html_handler(Query(q)).await; + // Default card is the AI-system card (0x0700), FULL mask → 12 rows. + assert!(body.contains("osint_system")); + assert!(body.contains("currentStatus")); + assert!(body.contains("aiwar:state/currentStatus")); + assert_eq!(body.matches("").count(), 12 + 1); // 12 rows + header + // No value column when has_values is false (schema-only path). + assert!(!body.contains("class=\"v\"")); + } + + /// `fragment=1` renders the bare `
` fragment — no doctype, no + /// ``, no ` +
+ {label} + + ✕ + +
+
+ {err ? ( +
card load failed: {err}
+ ) : html == null ? ( +
+ ) : ( +
+ )} +
+
+ ); +} + /** Default view: the SoA decoded into the Palantir vis-network renderer + reasoning. */ export function OsintGraph() { const hostRef = useRef(null); @@ -407,6 +487,8 @@ export function OsintGraph() { const selectedRef = useRef>(selected); // mirror for the build closures const [openAxis, setOpenAxis] = useState(null); // expanded palette axis const [divOn, setDivOn] = useState(false); // dual-use divergence lens active + // clicked-node detail popup: server-rendered ClassView card fragment. + const [detail, setDetail] = useState<{ label: string } | null>(null); // Fetch + decode the SoA once. useEffect(() => { @@ -881,7 +963,13 @@ export function OsintGraph() { }; net.on('click', (params: { nodes: unknown[] }) => { - if (params.nodes.length) void reasonFrom(params.nodes[0] as number); + if (params.nodes.length) { + const i = params.nodes[0] as number; + void reasonFrom(i); + setDetail({ label: soa.labels[i] || `#${i}` }); + } else { + setDetail(null); + } }); return () => { @@ -1415,6 +1503,7 @@ export function OsintGraph() { {readout && } + {detail && setDetail(null)} />} {/* property palette — expandable per-axis value catalogue. Select N values to filter the graph by that explicit prefix (AND across axes, OR within an diff --git a/cockpit/vite.config.ts b/cockpit/vite.config.ts index 9c3d79f0e..99803ad12 100644 --- a/cockpit/vite.config.ts +++ b/cockpit/vite.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ '/mcp': 'http://127.0.0.1:2718', '/health': 'http://127.0.0.1:2718', '/api': 'http://127.0.0.1:2718', + '/osint.soa': 'http://127.0.0.1:2718', }, }, build: {