Skip to content

Commit 44fd407

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 44fd407

8 files changed

Lines changed: 265 additions & 23 deletions

File tree

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

Lines changed: 34 additions & 6 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
}
@@ -961,6 +983,12 @@ function rawBindingValue(v: unknown): string | undefined {
961983
if (datatype) return `"${escaped}"^^<${datatype}>`;
962984
return `"${escaped}"`;
963985
}
986+
// Codex round-5 — a blank node must come back as `_:<id>`, not the
987+
// bare identifier; otherwise downstream (buildMemoryEntities + the
988+
// graph) misclassify it (a bare id is neither a leading-`"` literal
989+
// nor a recognisable resource → bnode RDF structure disappears /
990+
// mislabels). Branch BEFORE the generic fallthrough.
991+
if (node.type === 'bnode') return `_:${String(node.value)}`;
964992
return String(node.value);
965993
}
966994
if (typeof v === 'string') return v;

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: 26 additions & 14 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;
@@ -4266,20 +4271,27 @@ export function AssertionDetailView({
42664271
{triplesLoading && rootEntities.length === 0 ? (
42674272
<div className="v10-graph-placeholder">Loading assertion entities...</div>
42684273
) : rootEntities.length === 0 ? (
4269-
<EmptyState
4270-
compact
4271-
tone={toneForLayer(layer)}
4272-
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.'}
4277-
/>
4274+
(() => {
4275+
// ux §4.7.1 locked copy — keyed off the lifecycle state
4276+
// so the noun + forward path match the destination layer
4277+
// (created / promoted→SWM / published|finalized→VM /
4278+
// discarded). See `assertionEmptyStateCopy`.
4279+
const copy = assertionEmptyStateCopy(stateInfo?.state);
4280+
return (
4281+
<EmptyState
4282+
compact
4283+
tone={toneForLayer(layer)}
4284+
icon={layerConfig.icon}
4285+
title={copy.title}
4286+
description={copy.description}
4287+
/>
4288+
);
4289+
})()
42784290
) : (
42794291
rootEntities.map(e => {
42804292
const { icon, type } = entityMeta(e, profile);
42814293
return (
4282-
<button key={e.uri} className="v10-ka-conn" onClick={() => onNavigate(e.uri)}>
4294+
<button key={e.uri} className="v10-ka-conn" onClick={() => onNavigate(e.uri, layer)}>
42834295
<span className="v10-ka-conn-target">{icon} {e.label}</span>
42844296
<span className="v10-ka-conn-pred" style={{ marginLeft: 'auto' }}>{type}</span>
42854297
<span className="v10-ka-conn-arrow"></span>
@@ -4345,7 +4357,7 @@ export function AssertionDetailView({
43454357
options={graphOptions}
43464358
viewConfig={graphViewConfig}
43474359
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
4348-
onNodeClick={(n: any) => n?.id && onNavigate(n.id)}
4360+
onNodeClick={(n: any) => n?.id && onNavigate(n.id, layer)}
43494361
initialFit
43504362
/>
43514363
</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

packages/node-ui/test/assertion-detail-view.dom.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,30 @@ describe('AssertionDetailView', () => {
339339
);
340340
});
341341

342+
// Codex round-3 finding 3 — a published/finalized assertion (empty data
343+
// graph: entities moved to VM) must show the VM / Knowledge-Assets
344+
// empty-state line, NOT "no extracted entities" and NOT the SWM line.
345+
it('published assertion empty-state shows the VM / Knowledge-Assets line (not "no extracted entities")', async () => {
346+
stateMock.fetchAssertionState.mockResolvedValue({
347+
state: 'published', layer: 'vm',
348+
// post-publish the data graph is empty
349+
assertionGraph: 'did:dkg:context-graph:cg-test/demo/assertion/0xabc/epcis-demo',
350+
});
351+
stateMock.fetchAssertionTriples.mockReset();
352+
stateMock.fetchAssertionTriples.mockResolvedValue([]);
353+
root = await mount(wmAssertion);
354+
const body = document.body.textContent ?? '';
355+
expect(body).toContain('No entities in this assertion');
356+
expect(body).toContain('its entities are now Knowledge Assets in Verifiable Memory. Open the Verifiable Memory tab to view them.');
357+
expect(body).not.toContain('no extracted entities');
358+
// Scope the SWM-line check to the empty-state element — the lifecycle
359+
// trail in the right rail always renders the static stage title
360+
// "Promoted to Shared Working Memory" (the pipeline legend), so
361+
// checking the whole body would false-positive.
362+
const emptyStateText = document.querySelector('.v10-empty-state')?.textContent ?? body;
363+
expect(emptyStateText).not.toContain('now live in Shared Working Memory');
364+
});
365+
342366
// Codex round-1 finding 1 — on assertion switch the hook must CLEAR
343367
// `data` so the previous assertion's state isn't briefly visible while
344368
// the new fetch is in flight. A → B where B's state never resolves: the

packages/node-ui/test/project-view-navigation.test.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,10 +314,14 @@ vi.mock('../src/ui/views/project/components.js', () => ({
314314
// identity + an "open entity from assertion" button so the
315315
// mutually-exclusive overlay + close-to-origin behaviour can be
316316
// asserted without the real fetch/render path.
317-
AssertionDetailView: ({ assertion, onNavigate }: { assertion: any; onNavigate: (uri: string) => void }) =>
317+
AssertionDetailView: ({ assertion, onNavigate }: { assertion: any; onNavigate: (uri: string, layer?: 'wm' | 'swm' | 'vm') => void }) =>
318318
React.createElement('section', { 'data-testid': 'assertion-detail', 'data-assertion': assertion.graphUri, 'data-name': assertion.name, 'data-subgraph': assertion.subGraph ?? '' },
319319
React.createElement('div', {}, assertion.name),
320-
React.createElement('button', { 'data-testid': 'assertion-open-entity', onClick: () => onNavigate('urn:entity:demo') }, 'Open entity from assertion')),
320+
React.createElement('button', { 'data-testid': 'assertion-open-entity', onClick: () => onNavigate('urn:entity:demo') }, 'Open entity from assertion'),
321+
// Codex round-5 — open an entity that exists in MULTIPLE layers,
322+
// forwarding the assertion's layer (wm) so the follow-on detail is
323+
// WM-scoped, not the canonical (shared) version.
324+
React.createElement('button', { 'data-testid': 'assertion-open-overlap-wm', onClick: () => onNavigate('urn:entity:overlap', 'wm') }, 'Open overlap entity (WM-scoped)')),
321325
SubGraphDetailView: ({ slug, activeTab = 'items', onTabChange, onSelectEntity, initialLayer, initialEnabledLayers, onEnabledLayersChange }: {
322326
slug: string;
323327
activeTab?: string;
@@ -1404,6 +1408,28 @@ describe('ProjectView entity detail navigation', () => {
14041408
expect(document.querySelector('[data-testid="assertion-detail"]')).toBeNull();
14051409
});
14061410

1411+
// Codex round-5 finding 2 — navigating from a WM-assertion entity that
1412+
// ALSO exists in SWM must open the WM-SCOPED detail (the assertion's
1413+
// layer), not the canonical/global version. urn:entity:overlap lives in
1414+
// {working, shared} with canonical trustLevel 'shared'; opening it with
1415+
// layer='wm' forwarded must resolve the WM slice (trust 'working').
1416+
it('navigating from a WM-assertion entity (also in SWM) opens the WM-scoped detail, not canonical', async () => {
1417+
await click('switch-wm');
1418+
await click('layer-tab-assertions');
1419+
await flush();
1420+
await click('open-layer-assertion');
1421+
await flush();
1422+
expect(query('assertion-detail').dataset.name).toBe('demo-assertion');
1423+
1424+
await click('assertion-open-overlap-wm');
1425+
await flush();
1426+
expect(query('entity-detail').dataset.entity).toBe('urn:entity:overlap');
1427+
// WM-scoped (working), NOT the canonical 'shared' — proves the
1428+
// assertion's layer flowed through handleNavigate's layerContext.
1429+
expect(query('entity-detail').dataset.trust).toBe('working');
1430+
expect(query('entity-detail').textContent).toContain('Working overlap');
1431+
});
1432+
14071433
it('switching layers from the assertion detail exits the overlay (no stranded detail)', async () => {
14081434
await click('switch-wm');
14091435
await click('layer-tab-assertions');

0 commit comments

Comments
 (0)