Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 201 additions & 0 deletions packages/node-ui/src/ui/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { AssertionState } from '@origintrail-official/dkg-core';

const BASE = '';
declare global {
interface Window { __DKG_TOKEN__?: string; }
Expand Down Expand Up @@ -766,6 +768,205 @@ export async function listAssertions(
return result;
}

/** Current lifecycle position of a single assertion. */
export interface AssertionStateInfo {
/** `dkg:state` literal — created / promoted / published / finalized / discarded. */
state: AssertionState;
/** `dkg:memoryLayer` literal mapped to the UI layer key (`WM`→`wm`, etc.). */
layer: 'wm' | 'swm' | 'vm';
/**
* `dkg:assertionGraph` — the assertion's DATA graph URI
* (`did:dkg:context-graph:<cg>[/<sg>]/assertion/<addr>/<name>`),
* where the assertion's actual triples live. Undefined for lifecycle
* records that predate the `dkg:assertionGraph` predicate.
*/
assertionGraph?: string;
/** `prov:wasAttributedTo` — the authoring agent's DID, when recorded. */
createdBy?: string;
}

/**
* Read an assertion's CURRENT lifecycle state (S4 — α verdict lock-b,
* lazy). `listAssertions` deliberately does not carry the state on each
* row (today's WM list is `dkg:memoryLayer "WM"`-filtered, so every
* listed WM row is implicitly `created`); the assertion DETAIL view
* fetches the state on mount instead.
*
* `dkg:state` + `dkg:memoryLayer` are MUTABLE literals on the assertion
* LIFECYCLE entity (the `urn:dkg:assertion:…` subject) in the `<cg>/_meta`
* graph, written by `generateAssertion{Created,Promoted,Published}Metadata`
* in `@origintrail-official/dkg-publisher`.
*
* The caller passes `AssertionInfo.graphUri`, whose SHAPE differs by layer:
* - WM (since #864 `listAssertions(wm)` partition enumeration): the
* assertion's DATA GRAPH URI
* (`did:dkg:context-graph:<cg>[/<sg>]/assertion/<agent>/<name>`).
* - SWM (`listAssertions(swm)`): the LIFECYCLE URN
* (`urn:dkg:assertion:<cg>[:<sg>]:<agent>:<name>`).
* `dkg:state` is written ONLY on the lifecycle URN (publisher
* `generateAssertion*Metadata`, `metadata.ts:1155`); `dkg:memoryLayer`
* is written on BOTH the lifecycle URN AND the data-graph URI (the
* daemon's `assertionCreate` adds the latter, `dkg-publisher.ts:~4001`).
* The two are linked by `<lifecycleUrn> dkg:assertionGraph <dataGraphUri>`
* (`metadata.ts:1154`).
*
* So to reach `dkg:state` from EITHER input shape we bind `?lifecycle`
* via a UNION: it is the input directly (SWM lifecycle URN) OR it links
* to the input through `dkg:assertionGraph` (WM data-graph URI). Feeding
* a WM data-graph URI straight to `dkg:state` would never match → null →
* "state unavailable" for every WM assertion (the #864 silent-regression
* trap). The UNION keys off exactly what `listAssertions` provides for
* either layer — no `AssertionInfo` shape change, so the α data-shape
* verdict (lazy lock-b: don't extend AssertionInfo) still holds.
*
* Same `_meta`-scoped query shape as `listAssertions` /
* `useAssertionLifecycleEvents`: the explicit `GRAPH <…/_meta> { … }`
* makes the engine's `wrapWithGraph` early-return so the query runs raw
* and self-scopes to this CG's `_meta` partition.
*
* `assertionGraph` returns the data-graph URI to read triples from: the
* resolved `dkg:assertionGraph` (works for the SWM lifecycle-URN input),
* falling back to the input itself (the WM data-graph URI input).
*
* Returns `null` when no lifecycle entity resolves (e.g. a row predating
* lifecycle metadata) — the detail view treats `null` as the fetch-error
* / unavailable case and renders an all-neutral trail.
*/
export async function fetchAssertionState(
contextGraphId: string,
graphUri: string,
): Promise<AssertionStateInfo | null> {
const DKG = 'http://dkg.io/ontology/';
const PROV = 'http://www.w3.org/ns/prov#';
const metaGraph = `did:dkg:context-graph:${contextGraphId}/_meta`;
Comment thread
Jurij89 marked this conversation as resolved.
Comment thread
Jurij89 marked this conversation as resolved.
// UNION admits both `graphUri` shapes (WM data-graph URI via the
// inverse `dkg:assertionGraph` link; SWM lifecycle URN directly).
const sparql = `SELECT ?state ?layer ?createdBy ?assertionGraph WHERE {
GRAPH <${metaGraph}> {
{ <${graphUri}> <${DKG}assertionGraph> ?assertionGraph .
Comment thread
Jurij89 marked this conversation as resolved.
Outdated
BIND(<${graphUri}> AS ?lifecycle) }
UNION
{ ?lifecycle <${DKG}assertionGraph> <${graphUri}> .
BIND(<${graphUri}> AS ?assertionGraph) }
?lifecycle <${DKG}state> ?state .
OPTIONAL { ?lifecycle <${DKG}memoryLayer> ?layer }
OPTIONAL { ?lifecycle <${PROV}wasAttributedTo> ?createdBy }
}
} LIMIT 1`;
const data = await executeQuery(sparql, contextGraphId);
const bindings: any[] = data?.result?.bindings ?? [];
const first = bindings[0];
if (!first) return null;
const state = bv(first.state) as AssertionState | undefined;
if (!state) return null;
// `dkg:memoryLayer` is one of the `MemoryLayer` enum literals
// ("WM" / "SWM" / "VM"). Fall back to deriving the layer from the
// state when the (optional) literal is absent so a partial record
// still resolves a sane layer for the badge / CTA gate.
const rawLayer = bv(first.layer);
const layer: 'wm' | 'swm' | 'vm' =
rawLayer === 'WM' ? 'wm' :
rawLayer === 'SWM' ? 'swm' :
rawLayer === 'VM' ? 'vm' :
state === 'created' ? 'wm' :
state === 'promoted' ? 'swm' :
'vm';
Comment thread
Jurij89 marked this conversation as resolved.
return {
state,
layer,
// The data graph to read triples from: the resolved
// `dkg:assertionGraph` (correct for the SWM lifecycle-URN input),
// falling back to the input itself (the WM data-graph URI input).
assertionGraph: bv(first.assertionGraph) ?? graphUri,
Comment thread
Jurij89 marked this conversation as resolved.
Outdated
createdBy: bv(first.createdBy),
};
}

/** A single triple of an assertion's data graph. */
export interface AssertionTriple {
subject: string;
predicate: string;
object: string;
}

/**
* Read the triples of an assertion's DATA graph (S4 — the detail view's
* Triples / Entities / Graph panes are scoped to exactly this assertion,
* not the whole layer). `assertionGraph` is the `dkg:assertionGraph` URI
* resolved by `fetchAssertionState`.
*
* A WM assertion's triples live verbatim in its
* `…/assertion/<addr>/<name>` data graph. We query that ONE graph
* directly with an explicit `GRAPH <…> { ?s ?p ?o }` (self-scoping, same
* shape as the other `_meta` reads), so the result is the assertion's
* own content with zero cross-assertion bleed. The 5k LIMIT mirrors the
* lifecycle-events query ceiling — far above any realistic single
* assertion.
*
* NOTE: after promote the assertion's data graph empties (the triples
* move into `/_shared_memory`); a `promoted` assertion's detail view
* therefore shows an empty content set today. That is the documented
* G-BACKEND-M5 gap (a promoted SWM assertion is not yet an
* independently-inspectable object); a `created` WM assertion — S4's
* shippable scope — works in full.
*/
export async function fetchAssertionTriples(
contextGraphId: string,
assertionGraph: string,
): Promise<AssertionTriple[]> {
const sparql = `SELECT ?s ?p ?o WHERE {
GRAPH <${assertionGraph}> { ?s ?p ?o }
} LIMIT 5000`;
const data = await executeQuery(sparql, contextGraphId);
const bindings: any[] = data?.result?.bindings ?? [];
const out: AssertionTriple[] = [];
for (const b of bindings) {
const subject = rawBindingValue(b.s);
const predicate = rawBindingValue(b.p);
const object = rawBindingValue(b.o);
if (subject == null || predicate == null || object == null) continue;
out.push({ subject, predicate, object });
}
return out;
}

/**
* Extract the RAW binding value WITHOUT stripping the literal quoting —
* triple objects must keep their `"…"` / `"…"^^<type>` / `"…"@lang` form
* so the graph + table renderers can distinguish a literal from an IRI
* (downstream classifies by a leading `"`, see `useMemoryEntities`). IRIs
* come back bare. Mirror of the daemon's two binding encodings (`{value}`
* object form and N-Triples string form), like `bv` but quote-preserving.
*
* Codex round-1: for the standard SPARQL-JSON object form we now (a) escape
* `\` and `"` inside the value so a literal containing a quote stays
* well-formed, and (b) PRESERVE the datatype (`^^<iri>`) / language tag
* (`@lang`) — previously dropped, contradicting this comment. The
* N-Triples string form is already fully encoded by the daemon, so it
* passes through verbatim.
*/
function rawBindingValue(v: unknown): string | undefined {
if (v == null) return undefined;
if (typeof v === 'object' && 'value' in (v as any)) {
const node = v as any;
if (node.type === 'literal' || node.type === 'typed-literal') {
Comment thread
Jurij89 marked this conversation as resolved.
Comment thread
Jurij89 marked this conversation as resolved.
// Escape per N-Triples so embedded quotes/backslashes don't break
// the leading-`"` classification or the renderers.
const escaped = String(node.value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
Comment thread
Jurij89 marked this conversation as resolved.
Outdated
// Standard SPARQL JSON carries the datatype on `.datatype` and the
// language on `.xml:lang` (some serialisers use `.language`).
const lang = node['xml:lang'] ?? node.language;
const datatype = node.datatype;
if (lang) return `"${escaped}"@${lang}`;
Comment thread
Jurij89 marked this conversation as resolved.
if (datatype) return `"${escaped}"^^<${datatype}>`;
return `"${escaped}"`;
}
return String(node.value);
Comment thread
Jurij89 marked this conversation as resolved.
}
if (typeof v === 'string') return v;
return String(v);
}

/**
* Promote an assertion from WM to SWM.
*
Expand Down
80 changes: 80 additions & 0 deletions packages/node-ui/src/ui/hooks/useAssertionState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { useEffect, useState } from 'react';
import { fetchAssertionState, type AssertionStateInfo } from '../api.js';

/**
* Per-mount fetch of a single assertion's CURRENT lifecycle state for
* the S4 assertion detail view (α verdict lock-b — lazy: the state is
* fetched on detail-view mount, NOT carried on every `listAssertions`
* row). `dkg:state` + `dkg:memoryLayer` are mutable literals on the
* lifecycle entity in `<cg>/_meta`; see `fetchAssertionState` in
* `api.ts` for the query shape and the data-model writers.
*
* Three terminal shapes the detail view distinguishes:
* - `{ loading: true }` — hydrating; the CTA stays hidden and
* the trail renders all-neutral with
* no "you are here" marker.
* - `{ data: AssertionStateInfo }` — resolved; drives the trail
* `is-current` halo, the badge state
* suffix, and the Promote CTA gate.
* - `{ error: string }` / data null — fetch failed OR no lifecycle
* record; the metadata panel shows
* "state unavailable" and the trail
* renders all-neutral with a quiet
* danger hint.
*/
export interface UseAssertionStateResult {
data: AssertionStateInfo | null;
loading: boolean;
error: string | null;
}

export function useAssertionState(
contextGraphId: string | undefined,
graphUri: string | undefined,
// Codex round-1 — bump this to force a re-fetch even when the URI is
// unchanged (e.g. after a promote FROM the detail view flips the state
// in `_meta`; the same `graphUri` would otherwise skip the effect and
// leave the trail/badge/CTA stale at the pre-promote state).
refreshKey?: number,
): UseAssertionStateResult {
const [data, setData] = useState<AssertionStateInfo | null>(null);
const [loading, setLoading] = useState<boolean>(Boolean(contextGraphId && graphUri));
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!contextGraphId || !graphUri) {
setData(null);
setLoading(false);
setError(null);
return;
}
let cancelled = false;
// Codex round-1 — clear `data` when the assertion changes so a
// consumer reading `data` during the in-flight window can't see the
// PREVIOUS assertion's state (stale state/layer/assertionGraph). The
// detail view's `assertionGraph` derives from `data` and drives the
// triples fetch, so a stale value would briefly query the wrong
// graph. Mirror of the `triplesLoading` reset discipline.
setData(null);
setLoading(true);
Comment thread
Jurij89 marked this conversation as resolved.
Comment thread
Jurij89 marked this conversation as resolved.
setError(null);
fetchAssertionState(contextGraphId, graphUri)
.then((result) => {
if (cancelled) return;
setData(result);
setLoading(false);
})
.catch((err) => {
if (cancelled) return;
// Leave any prior data in place is pointless here — the URI
// identity changed, so a stale state would be wrong. Clear it
// and surface the error so the detail view shows "unavailable".
setData(null);
setLoading(false);
setError(err instanceof Error ? err.message : 'Failed to load assertion state');
});
return () => { cancelled = true; };
}, [contextGraphId, graphUri, refreshKey]);

return { data, loading, error };
}
Loading
Loading