Skip to content

Commit 6aa06e2

Browse files
Jurij Skornikclaude
andcommitted
fix(node-ui): round-3 Codex — SWM assertionGraph guard + VM/discarded empty-state copy
Round-3 review fixes on the S4 assertion detail view (kept as a focused 4th commit rather than folded into C1 — the fixup's helpers.ts hunk overlaps C2's breadcrumb helpers + C3's primarySubGraphOf, so an autosquash into C1 conflicts mechanically; a standalone commit is the clean, conflict-free shape team-lead OK'd). - Finding 2 (api.ts fetchAssertionState): the assertionGraph fallback was URI-shape-unsafe. For an SWM input (a `urn:dkg:assertion:…` lifecycle URN) whose `dkg:assertionGraph` did NOT resolve (legacy/partial `_meta` row), echoing the URN made fetchAssertionTriples query `GRAPH <urn:…>` (a graph that never holds triples) → bogus render. Now: urn-input + unresolved → assertionGraph: undefined (Triples/Entities fall to their empty-state). WM data-graph-URI input still echoes itself. - Finding 3 (assertionEmptyStateCopy, ux §4.7.1 locked): the empty-state copy now keys off `dkg:state` (4 branches) instead of special-casing promoted only — published/finalized show the VM / Knowledge-Assets line ("entities → Knowledge Assets" at the VM boundary, §4.8), discarded is terminal, created/promoted unchanged. Plain text, no links. - Finding 1 (remote SWM state) reply-not-valid (no replicated state source); finding-1 round-2 discarded-neutral badge + finding-2 round-2 literal-display decode already shipped earlier in C1. Tests: SWM-legacy-no-assertionGraph guard + WM-echo counterpart (use-assertion-state); assertionEmptyStateCopy 4-branch truth table (assertion-detail-helpers); published empty-state DOM render (assertion-detail-view). Finding-2 guard negative-proofed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9f3025c commit 6aa06e2

9 files changed

Lines changed: 382 additions & 26 deletions

packages/node-ui/src/ui/api.ts

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -841,10 +841,22 @@ export async function fetchAssertionState(
841841
const metaGraph = `did:dkg:context-graph:${contextGraphId}/_meta`;
842842
// UNION admits both `graphUri` shapes (WM data-graph URI via the
843843
// inverse `dkg:assertionGraph` link; SWM lifecycle URN directly).
844+
//
845+
// Codex round-4 — branch A binds the input AS `?lifecycle`
846+
// UNCONDITIONALLY and resolves `dkg:assertionGraph` only OPTIONALLY, so
847+
// a legacy/partial SWM row whose lifecycle URN carries `dkg:state` but
848+
// NOT `dkg:assertionGraph` still resolves its state (pre-fix, branch A
849+
// required the assertionGraph triple to bind `?lifecycle`, so such a
850+
// row read no state → "state unavailable" though the state exists).
851+
// The outer `?lifecycle dkg:state` then gates the row: for a WM
852+
// data-graph-URI input, branch A binds `?lifecycle = <data-graph URI>`
853+
// but `<data-graph URI> dkg:state` never matches (state lives on the
854+
// lifecycle URN), so branch A contributes nothing and branch B handles
855+
// WM exactly as before. So WM→B, SWM→A, SWM-no-assertionGraph→A.
844856
const sparql = `SELECT ?state ?layer ?createdBy ?assertionGraph WHERE {
845857
GRAPH <${metaGraph}> {
846-
{ <${graphUri}> <${DKG}assertionGraph> ?assertionGraph .
847-
BIND(<${graphUri}> AS ?lifecycle) }
858+
{ BIND(<${graphUri}> AS ?lifecycle)
859+
OPTIONAL { <${graphUri}> <${DKG}assertionGraph> ?assertionGraph } }
848860
UNION
849861
{ ?lifecycle <${DKG}assertionGraph> <${graphUri}> .
850862
BIND(<${graphUri}> AS ?assertionGraph) }
@@ -871,13 +883,23 @@ export async function fetchAssertionState(
871883
state === 'created' ? 'wm' :
872884
state === 'promoted' ? 'swm' :
873885
'vm';
886+
// The data graph to read triples from. Prefer the resolved
887+
// `dkg:assertionGraph`. Codex round-3 — the fallback to `graphUri`
888+
// itself is only safe when the INPUT is already a data-graph URI (the
889+
// WM shape). For an SWM input (a `urn:dkg:assertion:…` lifecycle URN)
890+
// whose `dkg:assertionGraph` did NOT resolve (legacy/partial `_meta`
891+
// row), echoing the URN would make `fetchAssertionTriples` query
892+
// `GRAPH <urn:dkg:assertion:…>` — a graph that never holds triples —
893+
// and render a bogus "data" set. In that case return `undefined` so
894+
// the Triples/Entities panes fall to their empty-state instead.
895+
const resolvedAssertionGraph = bv(first.assertionGraph);
896+
const inputIsLifecycleUrn = graphUri.startsWith('urn:dkg:assertion:');
897+
const assertionGraph = resolvedAssertionGraph
898+
?? (inputIsLifecycleUrn ? undefined : graphUri);
874899
return {
875900
state,
876901
layer,
877-
// The data graph to read triples from: the resolved
878-
// `dkg:assertionGraph` (correct for the SWM lifecycle-URN input),
879-
// falling back to the input itself (the WM data-graph URI input).
880-
assertionGraph: bv(first.assertionGraph) ?? graphUri,
902+
assertionGraph,
881903
createdBy: bv(first.createdBy),
882904
};
883905
}
@@ -945,14 +967,38 @@ export async function fetchAssertionTriples(
945967
* N-Triples string form is already fully encoded by the daemon, so it
946968
* passes through verbatim.
947969
*/
970+
/**
971+
* Escape a literal body for the N-Triples `"…"` form: backslash + quote,
972+
* the canonical short escapes for tab/newline/carriage-return, and any
973+
* other C0 control char as `\\uXXXX`. Order matters — backslash first so
974+
* the escapes we add aren't re-escaped.
975+
*/
976+
function escapeNTriplesLiteral(value: string): string {
977+
let out = "";
978+
for (const ch of value) {
979+
const code = ch.codePointAt(0)!;
980+
if (ch === '\\') out += '\\\\';
981+
else if (ch === '"') out += '\\"';
982+
else if (code === 0x09) out += '\\t';
983+
else if (code === 0x0a) out += '\\n';
984+
else if (code === 0x0d) out += '\\r';
985+
else if (code < 0x20) out += '\\u' + code.toString(16).padStart(4, '0').toUpperCase();
986+
else out += ch;
987+
}
988+
return out;
989+
}
990+
948991
function rawBindingValue(v: unknown): string | undefined {
949992
if (v == null) return undefined;
950993
if (typeof v === 'object' && 'value' in (v as any)) {
951994
const node = v as any;
952995
if (node.type === 'literal' || node.type === 'typed-literal') {
953-
// Escape per N-Triples so embedded quotes/backslashes don't break
954-
// the leading-`"` classification or the renderers.
955-
const escaped = String(node.value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
996+
// Escape per N-Triples so embedded quotes/backslashes/control chars
997+
// don't break the leading-`"` classification or the renderers.
998+
// Codex round-6 — control chars (raw \n/\r/\t in a multiline
999+
// extracted value, plus other C0 controls) were previously left
1000+
// raw → invalid N-Triples → broke the graph/triple parsers.
1001+
const escaped = escapeNTriplesLiteral(String(node.value));
9561002
// Standard SPARQL JSON carries the datatype on `.datatype` and the
9571003
// language on `.xml:lang` (some serialisers use `.language`).
9581004
const lang = node['xml:lang'] ?? node.language;
@@ -961,6 +1007,12 @@ function rawBindingValue(v: unknown): string | undefined {
9611007
if (datatype) return `"${escaped}"^^<${datatype}>`;
9621008
return `"${escaped}"`;
9631009
}
1010+
// Codex round-5 — a blank node must come back as `_:<id>`, not the
1011+
// bare identifier; otherwise downstream (buildMemoryEntities + the
1012+
// graph) misclassify it (a bare id is neither a leading-`"` literal
1013+
// nor a recognisable resource → bnode RDF structure disappears /
1014+
// mislabels). Branch BEFORE the generic fallthrough.
1015+
if (node.type === 'bnode') return `_:${String(node.value)}`;
9641016
return String(node.value);
9651017
}
9661018
if (typeof v === 'string') return v;

packages/node-ui/src/ui/hooks/useMemoryEntities.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
22
import { postQueryDeduped } from '../api.js';
33
import { useMemoryGraphEvents } from './useNodeEvents.js';
44
import { MEMORY_LABEL_PREDICATES } from '../lib/memoryLabels.js';
5+
import { decodeRdfStringLiteral } from '../../rdf-literal.js';
56

67
export type TrustLevel = 'working' | 'shared' | 'verified';
78
export type MemoryLayerKey = 'wm' | 'swm' | 'vm';
@@ -107,7 +108,11 @@ export function canonicalEntityUri(uri: string): string {
107108

108109
function shortLabel(uri: string): string {
109110
if (!uri) return '—';
110-
if (uri.startsWith('"')) return uri.replace(/^"|"$/g, '');
111+
// Codex round-6 — decode RDF literals (drop the `^^<type>`/`@lang`
112+
// suffix + unescape the body) instead of a bare outer-quote strip, so
113+
// `"Hola"@es` renders `Hola`, not `Hola"@es`. Idempotent for a
114+
// suffix-free `"Hola"` and a no-op for non-literal IRIs.
115+
if (uri.startsWith('"')) return decodeRdfStringLiteral(uri);
111116
const hash = uri.lastIndexOf('#');
112117
const slash = uri.lastIndexOf('/');
113118
const cut = Math.max(hash, slash);
@@ -487,7 +492,11 @@ export function buildEntities(layered: LayeredTriple[]): Map<string, MemoryEntit
487492
}
488493
} else {
489494
const existing = entity.properties.get(t.predicate) ?? [];
490-
const val = t.object.startsWith('"') ? t.object.replace(/^"|"$/g, '') : t.object;
495+
// Codex round-6 — decode the literal (drop datatype/lang suffix +
496+
// unescape) rather than a bare outer-quote strip, so property
497+
// values used as labels (deriveEntityLabel) render `Hola`, not
498+
// `Hola"@es`. Idempotent for suffix-free / plain values.
499+
const val = t.object.startsWith('"') ? decodeRdfStringLiteral(t.object) : t.object;
491500
if (!existing.includes(val)) {
492501
existing.push(val);
493502
entity.properties.set(t.predicate, existing);

packages/node-ui/src/ui/views/ProjectView.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -865,7 +865,12 @@ export function ProjectView({ contextGraphId }: ProjectViewProps) {
865865
<AssertionDetailView
866866
assertion={selectedAssertion}
867867
contextGraphId={contextGraphId}
868-
onNavigate={handleNavigate}
868+
// Codex round-5 — forward the assertion's layer into
869+
// handleNavigate's existing `layerContext` (3rd) param so the
870+
// follow-on entity detail stays scoped to the assertion's layer.
871+
// AssertionDetailView passes `layer` as the 2nd arg; map it to
872+
// the 3rd param (originScrollKey stays undefined).
873+
onNavigate={(uri, layer) => handleNavigate(uri, undefined, layer)}
869874
onComplete={rawMemory.refresh}
870875
onOpenAgent={openAgent}
871876
/>

packages/node-ui/src/ui/views/project/components.tsx

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ import {
6767
filterTriplesToEntities, admitTripleForScope,
6868
entityTimestamp, formatRelativeTime, formatTimelineBucket, formatTrailTimestamp,
6969
canPromoteAssertion, assertionSubgraphLine, buildAssertionTrail,
70-
buildBreadcrumbHops,
70+
assertionEmptyStateCopy, buildBreadcrumbHops,
7171
type LayerView, type LayerContentTab, type KAPane,
7272
type SubGraphTab, type SubGraphEntitySort,
7373
} from './helpers.js';
@@ -4082,8 +4082,13 @@ export function AssertionDetailView({
40824082
}: {
40834083
assertion: AssertionInfo;
40844084
contextGraphId: string;
4085-
/** Open an entity from this assertion in the entity-detail view. */
4086-
onNavigate: (uri: string) => void;
4085+
/** Open an entity from this assertion in the entity-detail view.
4086+
* Codex round-5 — forwards the assertion's resolved `layer` so the
4087+
* follow-on entity detail stays scoped to the assertion's layer
4088+
* (a WM-assertion entity that ALSO exists in SWM/VM would otherwise
4089+
* open the global/canonical detail). Feeds M2's existing
4090+
* `handleNavigate(uri, _, layerContext)` channel; no contract reshape. */
4091+
onNavigate: (uri: string, layer?: 'wm' | 'swm' | 'vm') => void;
40874092
/** Refresh the underlying memory after a successful promote. */
40884093
onComplete: () => void;
40894094
onOpenAgent?: (uri: string) => void;
@@ -4138,6 +4143,12 @@ export function AssertionDetailView({
41384143
const assertionGraph = stateInfo?.assertionGraph;
41394144
const [triples, setTriples] = useState<Triple[]>([]);
41404145
const [triplesLoading, setTriplesLoading] = useState(false);
4146+
// Codex round-6 — a triple-FETCH failure must be DISTINCT from a
4147+
// genuinely-empty assertion (loading / error / empty are three states,
4148+
// not two — mirrors the lifecycle state's hydrating/error edge).
4149+
// Previously the `.catch` collapsed to `[]`, so a backend/query error
4150+
// showed the same empty-state copy as valid-but-empty data.
4151+
const [triplesError, setTriplesError] = useState(false);
41414152
useEffect(() => {
41424153
if (!assertionGraph) {
41434154
// No data graph to read (promoted assertion — its triples moved to
@@ -4150,13 +4161,15 @@ export function AssertionDetailView({
41504161
// renders.
41514162
setTriples([]);
41524163
setTriplesLoading(false);
4164+
setTriplesError(false);
41534165
return;
41544166
}
41554167
let cancelled = false;
41564168
setTriplesLoading(true);
4169+
setTriplesError(false);
41574170
fetchAssertionTriples(contextGraphId, assertionGraph)
4158-
.then(rows => { if (!cancelled) setTriples(rows); })
4159-
.catch(() => { if (!cancelled) setTriples([]); })
4171+
.then(rows => { if (!cancelled) { setTriples(rows); setTriplesError(false); } })
4172+
.catch(() => { if (!cancelled) { setTriples([]); setTriplesError(true); } })
41604173
.finally(() => { if (!cancelled) setTriplesLoading(false); });
41614174
return () => { cancelled = true; };
41624175
// `refreshNonce` re-runs the triples fetch after a promote (the data
@@ -4265,21 +4278,40 @@ export function AssertionDetailView({
42654278
<div className="v10-ka-section" style={{ marginTop: 12 }}>
42664279
{triplesLoading && rootEntities.length === 0 ? (
42674280
<div className="v10-graph-placeholder">Loading assertion entities...</div>
4268-
) : rootEntities.length === 0 ? (
4281+
) : triplesError ? (
4282+
// Codex round-6 — a fetch ERROR is DISTINCT from an empty
4283+
// assertion: an operational failure must not masquerade as
4284+
// valid-but-empty data. Mirrors the lifecycle state's
4285+
// error edge (quiet, explicit "couldn't load").
42694286
<EmptyState
42704287
compact
42714288
tone={toneForLayer(layer)}
42724289
icon={layerConfig.icon}
4273-
title="No entities in this assertion."
4274-
description={stateInfo?.state === 'promoted'
4275-
? 'This assertion was promoted — its entities now live in Shared Working Memory. Open the Shared Working Memory tab to view them.'
4276-
: 'This assertion has no extracted entities.'}
4290+
title="Couldn't load this assertion's contents."
4291+
description="The assertion data couldn't be read right now. Refresh and try again."
42774292
/>
4293+
) : rootEntities.length === 0 ? (
4294+
(() => {
4295+
// ux §4.7.1 locked copy — keyed off the lifecycle state
4296+
// so the noun + forward path match the destination layer
4297+
// (created / promoted→SWM / published|finalized→VM /
4298+
// discarded). See `assertionEmptyStateCopy`.
4299+
const copy = assertionEmptyStateCopy(stateInfo?.state);
4300+
return (
4301+
<EmptyState
4302+
compact
4303+
tone={toneForLayer(layer)}
4304+
icon={layerConfig.icon}
4305+
title={copy.title}
4306+
description={copy.description}
4307+
/>
4308+
);
4309+
})()
42784310
) : (
42794311
rootEntities.map(e => {
42804312
const { icon, type } = entityMeta(e, profile);
42814313
return (
4282-
<button key={e.uri} className="v10-ka-conn" onClick={() => onNavigate(e.uri)}>
4314+
<button key={e.uri} className="v10-ka-conn" onClick={() => onNavigate(e.uri, layer)}>
42834315
<span className="v10-ka-conn-target">{icon} {e.label}</span>
42844316
<span className="v10-ka-conn-pred" style={{ marginLeft: 'auto' }}>{type}</span>
42854317
<span className="v10-ka-conn-arrow"></span>
@@ -4345,7 +4377,7 @@ export function AssertionDetailView({
43454377
options={graphOptions}
43464378
viewConfig={graphViewConfig}
43474379
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
4348-
onNodeClick={(n: any) => n?.id && onNavigate(n.id)}
4380+
onNodeClick={(n: any) => n?.id && onNavigate(n.id, layer)}
43494381
initialFit
43504382
/>
43514383
</Suspense>

packages/node-ui/src/ui/views/project/helpers.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1012,6 +1012,50 @@ export function buildAssertionTrail(
10121012
}));
10131013
}
10141014

1015+
/**
1016+
* Empty-state copy for the assertion detail Entities pane when the
1017+
* assertion has zero entities, keyed off `dkg:state` (ux-lead's locked
1018+
* §4.7.1 copy — Codex round-3 finding 3). A single generic template was
1019+
* rejected because the noun changes at the VM boundary (entities →
1020+
* Knowledge Assets, §4.8) and because the forward path differs per state:
1021+
* - created → genuinely empty draft.
1022+
* - promoted → its entities moved to Shared Working Memory.
1023+
* - published / finalized → its entities are now Knowledge Assets in
1024+
* Verifiable Memory (post-publish the data graph is empty).
1025+
* - discarded → terminal; no forward path (forward-safety — discarded
1026+
* isn't list-reachable today, see the badge guard).
1027+
* Plain text, no links (S4 lock). Title is the constant "No entities in
1028+
* this assertion." for the live states; discarded gets a terminal title.
1029+
*/
1030+
export function assertionEmptyStateCopy(
1031+
state: AssertionState | null | undefined,
1032+
): { title: string; description: string } {
1033+
switch (state) {
1034+
case 'promoted':
1035+
return {
1036+
title: 'No entities in this assertion.',
1037+
description: 'This assertion was promoted — its entities now live in Shared Working Memory. Open the Shared Working Memory tab to view them.',
1038+
};
1039+
case 'published':
1040+
case 'finalized':
1041+
return {
1042+
title: 'No entities in this assertion.',
1043+
description: 'This assertion was published — its entities are now Knowledge Assets in Verifiable Memory. Open the Verifiable Memory tab to view them.',
1044+
};
1045+
case 'discarded':
1046+
return {
1047+
title: 'This assertion was discarded.',
1048+
description: 'This assertion was discarded.',
1049+
};
1050+
case 'created':
1051+
default:
1052+
return {
1053+
title: 'No entities in this assertion.',
1054+
description: 'This assertion has no extracted entities.',
1055+
};
1056+
}
1057+
}
1058+
10151059
/**
10161060
* The primary (first non-`meta`) sub-graph slug an entity has triples
10171061
* in, or null when it lives only in the root bucket / meta. Mirrors the

packages/node-ui/test/assertion-detail-helpers.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
canPromoteAssertion,
55
assertionSubgraphLine,
66
buildAssertionTrail,
7+
assertionEmptyStateCopy,
78
buildBreadcrumbHops,
89
primarySubGraphOf,
910
} from '../src/ui/views/project/helpers.js';
@@ -107,6 +108,43 @@ describe('buildAssertionTrail — lifecycle trail stages + is-current marker', (
107108
});
108109
});
109110

111+
describe('assertionEmptyStateCopy — ux §4.7.1 state-keyed empty copy (Codex round-3 #3)', () => {
112+
it('created → "no extracted entities" (the generalization must NOT loosen this)', () => {
113+
const c = assertionEmptyStateCopy('created');
114+
expect(c.title).toBe('No entities in this assertion.');
115+
expect(c.description).toBe('This assertion has no extracted entities.');
116+
});
117+
118+
it('promoted → SWM forward-path line (unchanged)', () => {
119+
const c = assertionEmptyStateCopy('promoted');
120+
expect(c.title).toBe('No entities in this assertion.');
121+
expect(c.description).toContain('now live in Shared Working Memory');
122+
expect(c.description).toContain('Open the Shared Working Memory tab');
123+
expect(c.description).not.toContain('no extracted entities');
124+
});
125+
126+
it('published AND finalized → VM / Knowledge-Assets line, NOT "no extracted entities"', () => {
127+
for (const s of ['published', 'finalized'] as const) {
128+
const c = assertionEmptyStateCopy(s);
129+
expect(c.title).toBe('No entities in this assertion.');
130+
expect(c.description).toBe('This assertion was published — its entities are now Knowledge Assets in Verifiable Memory. Open the Verifiable Memory tab to view them.');
131+
expect(c.description).not.toContain('no extracted entities');
132+
expect(c.description).not.toContain('Shared Working Memory');
133+
}
134+
});
135+
136+
it('discarded → terminal copy, no "open X tab" forward path', () => {
137+
const c = assertionEmptyStateCopy('discarded');
138+
expect(c.title).toBe('This assertion was discarded.');
139+
expect(c.description).toBe('This assertion was discarded.');
140+
expect(c.description).not.toContain('Open the');
141+
});
142+
143+
it('hydrating (null) falls back to the created copy', () => {
144+
expect(assertionEmptyStateCopy(null).description).toBe('This assertion has no extracted entities.');
145+
});
146+
});
147+
110148
describe('buildBreadcrumbHops — S5 breadcrumb hop construction (T04)', () => {
111149
const CG = 'Hello World';
112150

0 commit comments

Comments
 (0)