Skip to content

Commit e66f8f3

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 e66f8f3

6 files changed

Lines changed: 202 additions & 16 deletions

File tree

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

Lines changed: 28 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
}

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

Lines changed: 17 additions & 10 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';
@@ -4266,15 +4266,22 @@ export function AssertionDetailView({
42664266
{triplesLoading && rootEntities.length === 0 ? (
42674267
<div className="v10-graph-placeholder">Loading assertion entities...</div>
42684268
) : 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-
/>
4269+
(() => {
4270+
// ux §4.7.1 locked copy — keyed off the lifecycle state
4271+
// so the noun + forward path match the destination layer
4272+
// (created / promoted→SWM / published|finalized→VM /
4273+
// discarded). See `assertionEmptyStateCopy`.
4274+
const copy = assertionEmptyStateCopy(stateInfo?.state);
4275+
return (
4276+
<EmptyState
4277+
compact
4278+
tone={toneForLayer(layer)}
4279+
icon={layerConfig.icon}
4280+
title={copy.title}
4281+
description={copy.description}
4282+
/>
4283+
);
4284+
})()
42784285
) : (
42794286
rootEntities.map(e => {
42804287
const { icon, type } = entityMeta(e, profile);

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/use-assertion-state.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,4 +260,55 @@ describe('listAssertions(wm) partition graphUri → fetchAssertionState resolves
260260
expect(body.sparql).toContain('UNION');
261261
expect(body.sparql).toContain('assertionGraph');
262262
});
263+
264+
// Codex round-3 finding 2 — an SWM input (lifecycle URN) whose
265+
// dkg:assertionGraph did NOT resolve (legacy/partial _meta row) must
266+
// NOT echo the URN as assertionGraph (that would make
267+
// fetchAssertionTriples query `GRAPH <urn:dkg:assertion:…>` — a bogus
268+
// empty render). Return undefined → the panes show their empty-state.
269+
it('SWM lifecycle-URN with UNRESOLVED dkg:assertionGraph → assertionGraph undefined (not the URN)', async () => {
270+
const LIFECYCLE = 'urn:dkg:assertion:cg-A:0xabc:legacy-no-graph';
271+
fetchMock.mockResolvedValueOnce(jsonResponse({
272+
result: { bindings: [{ state: { value: 'promoted' }, layer: { value: 'SWM' } }] }, // no assertionGraph
273+
}));
274+
const stateInfo = await fetchAssertionState('cg-A', LIFECYCLE);
275+
expect(stateInfo).not.toBeNull();
276+
expect(stateInfo!.state).toBe('promoted');
277+
// Must NOT be the lifecycle URN; undefined so the triples pane is empty.
278+
expect(stateInfo!.assertionGraph).toBeUndefined();
279+
});
280+
281+
// Counterpart — a WM data-graph-URI input with no resolved
282+
// dkg:assertionGraph (it IS the data graph) keeps echoing itself.
283+
it('WM data-graph-URI input with no resolved dkg:assertionGraph echoes the input (it IS the data graph)', async () => {
284+
const PARTITION = 'did:dkg:context-graph:cg-A/assertion/0xabc/notes';
285+
fetchMock.mockResolvedValueOnce(jsonResponse({
286+
result: { bindings: [{ state: { value: 'created' }, layer: { value: 'WM' } }] }, // no assertionGraph binding
287+
}));
288+
const stateInfo = await fetchAssertionState('cg-A', PARTITION);
289+
expect(stateInfo!.assertionGraph).toBe(PARTITION);
290+
});
291+
292+
// Codex round-4 — branch A must bind ?lifecycle UNCONDITIONALLY with the
293+
// dkg:assertionGraph match OPTIONAL, so an SWM lifecycle-URN row whose
294+
// _meta carries dkg:state but NOT dkg:assertionGraph (legacy/partial)
295+
// still resolves its state. Pin the SPARQL shape so it can't regress to
296+
// requiring the assertionGraph triple to bind the lifecycle subject.
297+
it('SPARQL: branch A binds the input AS ?lifecycle unconditionally + OPTIONAL assertionGraph (round-4)', async () => {
298+
const LIFECYCLE = 'urn:dkg:assertion:cg-A:0xabc:legacy-no-graph';
299+
fetchMock.mockResolvedValueOnce(jsonResponse({
300+
result: { bindings: [{ state: { value: 'promoted' }, layer: { value: 'SWM' } }] },
301+
}));
302+
const stateInfo = await fetchAssertionState('cg-A', LIFECYCLE);
303+
// State still resolves (the round-4 outcome) and assertionGraph stays
304+
// undefined (round-3 guard composes).
305+
expect(stateInfo!.state).toBe('promoted');
306+
expect(stateInfo!.assertionGraph).toBeUndefined();
307+
const sparql = JSON.parse(fetchMock.mock.calls[0][1].body).sparql as string;
308+
// Unconditional BIND of the input as the lifecycle subject…
309+
expect(sparql).toContain(`BIND(<${LIFECYCLE}> AS ?lifecycle)`);
310+
// …with the assertionGraph match OPTIONAL (branch A no longer REQUIRES
311+
// `<input> dkg:assertionGraph ?assertionGraph` to bind ?lifecycle).
312+
expect(sparql).toMatch(/OPTIONAL\s*\{\s*<urn:dkg:assertion:cg-A:0xabc:legacy-no-graph>\s*<http:\/\/dkg\.io\/ontology\/assertionGraph>\s*\?assertionGraph\s*\}/);
313+
});
263314
});

0 commit comments

Comments
 (0)