Skip to content
47 changes: 47 additions & 0 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,52 @@
# Product & Technical Gap Baseline

> Current authority overlay, 2026-09-27 KST. Remote protected `main` is
> `83eba56149eb802cd63642c507c324c9976ec78e` (verified with
> `git ls-remote`). REST pagination returned 169 open PRs and 28 open issues.
> #1129 was reviewed at exact head
> `4bcbc9195c81e68fcf15173ffe6975b872b974a4`. It is Ready with normal
> squash auto-merge enabled; current-head checks remain queued and no
> independent APPROVE exists. Its Storybook fixture now carries primary and
> derived Voice relationships plus separate evidence identity. The exact head
> of this document update must be re-fetched from the PR before a merge
> decision; this observation names the reviewed parent commit. #1128 is at
> `91143146623948dbd26bbfc1c69de3cd77d2ae06`, #1126 at
> `c0c5204b702d2d4d24928389db7d04ebe5cb9739`, and #1123 at
> `fb3dba7e6b8145603389d211a19dbe70280bdea6`. #1128 and #1126 are
> Ready with squash auto-merge enabled; #1123 remains Draft. None has a
> current-head independent APPROVE. A successful bot review or an older
> head's checks do not close
> either gate. Parent PRs must reach protected `main` before stacked children
> are retargeted and rechecked. Older overlays below are dated history.
>
> Authority and gap selected for this slice: the current PRD requires
> authorized evidence for every additional Voice and parity across the graph,
> exact-value CSV, and JSON-LD. ADR 0246 governs the twelve extensible atomic
> Voices; ADR 0256 governs evidence-bearing composition and forbids replacing
> missing evidence with the carrying Post. Current `main` filters graph nodes
> during in-page search but retained a derived Voice's CSV row and JSON-LD
> relationship when that search removed its separate evidence Post. This
> candidate removes that assignment from the searched view and export and
> preserves it when the evidence Post remains visible. The synthetic frontend
> regression is local candidate evidence only. A second regression found that
> a matching graph edge could retain an evidence Post identifier absent from
> the authorized node set; the searched view now drops that dangling edge
> before deriving Voice visibility or exports. The `SeparateVoiceEvidence`
> fixture now includes exportable JSON-LD for the primary and derived Voice
> relationships and binds the derived relation to its distinct evidence Post.
> A local Storybook build at this head rendered the separate-evidence scene at
> 1440×900 and 390×844; screenshots were visually checked in the isolated
> worktree. No authenticated browser interaction, Firefox/WebKit,
> assistive-technology, or eight-locale result is bound to this exact head.
> Authenticated PostgreSQL API and protected-main acceptance remain unverified.
> This slice changes no ADR, API, schema, migration ordinal, or release number.
> Its baseline path overlaps draft #1123, and its Storybook inventory path
> overlaps draft #1126; reconcile those exact paths before either later merge.
> Draft #1121 changes the Voice-history ADR and documentation tests, while
> #997 changes the occupational ADR and PRD. Their policy/requirement changes
> are not inherited by this frontend candidate or treated as protected-main
> authority. Each stack still needs a fresh conflict and exact-head audit.

> Exact-head loop overlay: 2026-08-29 13:20 KST. Protected `main` is
> `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map
> explained leftover share, #775). Open ready PRs still lack independent
Expand Down
2 changes: 1 addition & 1 deletion docs/storybook-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ operator-facing control you can click before changing product CSS.
| `Post/Connect perspective` | Choose one unassigned Voice and an explicit evidence state, then record the open post as its evidence. `Ready`, `Completed`, and `NarrowViewport` cover untouched, successful, and mobile states. | `VoiceAssignmentForm`, `admin-form`, `btn-primary` |
| `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` |
| `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` |
| `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation, while `CombinedVoiceEvidence` covers primary-plus-additional Voice assignments and focuses the evidence action distinct from the carrying-Post action. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222/0251 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` |
| `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation, while `CombinedVoiceEvidence` covers primary-plus-additional Voice assignments and focuses the evidence action distinct from the carrying-Post action. `SeparateVoiceEvidence` shows a different evidence Post so filtering the scene can verify that its derived Voice disappears from the searched view and export when that Post is absent. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222/0251 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` |
| `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project/Work-evidence shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The populated scene includes one assertion-backed occupational construct without a person-trait promotion. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222/0255 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` |
| `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` |
| `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` |
Expand Down
62 changes: 62 additions & 0 deletions frontend/src/components/OntologyExplorer.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,44 @@ import type { OntologyNeighborhoodPayload } from "../api";
import { OntologyExplorer } from "./OntologyExplorer";

const POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1";
const VOICE_EVIDENCE_POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2";
const PERSON_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1";
const CORP_ID = "cccccccc-cccc-cccc-cccc-ccccccccccc1";
const PROJECT_ID = `${POST_ID}/demo-project`;
const CONSTRUCT_ID = "99999999-9999-9999-9999-999999999999";
const ONTOLOGY_NAMESPACE = "https://contextualwisdomlab.github.io/LineageWeave/ontology#";
const HAS_VOICE_ASSIGNMENT = `${ONTOLOGY_NAMESPACE}hasVoiceAssignment`;
const PRIMARY_VOICE_IRI = `${ONTOLOGY_NAMESPACE}voice-assignment/${POST_ID}/voc`;
const DERIVED_VOICE_IRI = `${ONTOLOGY_NAMESPACE}voice-assignment/${POST_ID}/vops`;

function voiceJsonLd(evidencePostId: string): OntologyNeighborhoodPayload["jsonld"] {
const postIri = `${ONTOLOGY_NAMESPACE}node/node_post/${POST_ID}`;
const evidenceIri = `${ONTOLOGY_NAMESPACE}node/node_post/${evidencePostId}`;

return {
"@context": demoNeighborhood.jsonld["@context"],
"@graph": [
{
"@id": postIri,
[HAS_VOICE_ASSIGNMENT]: [
{ "@id": PRIMARY_VOICE_IRI },
{ "@id": DERIVED_VOICE_IRI },
],
},
...(evidencePostId === POST_ID ? [] : [{ "@id": evidenceIri }]),
{
"@id": PRIMARY_VOICE_IRI,
"prov:wasDerivedFrom": { "@id": postIri },
},
{
"@id": DERIVED_VOICE_IRI,
"prov:wasDerivedFrom": { "@id": evidenceIri },
},
{ "@id": `${ONTOLOGY_NAMESPACE}voiceOfCustomerType` },
{ "@id": `${ONTOLOGY_NAMESPACE}voiceOfProcessType` },
],
};
}

const demoNeighborhood: OntologyNeighborhoodPayload = {
focus_node_id: POST_ID,
Expand Down Expand Up @@ -302,6 +336,7 @@ const combinedVoiceNeighborhood: OntologyNeighborhoodPayload = {
evidence_post_id: POST_ID,
})),
],
jsonld: voiceJsonLd(POST_ID),
};

const meta = {
Expand Down Expand Up @@ -331,6 +366,33 @@ export const CombinedVoiceEvidence: Story = {
},
};

export const SeparateVoiceEvidence: Story = {
args: {
neighborhood: {
...combinedVoiceNeighborhood,
nodes: [
...combinedVoiceNeighborhood.nodes,
{
...combinedVoiceNeighborhood.nodes[0],
node_id: VOICE_EVIDENCE_POST_ID,
display_label: "Synthetic evidence post",
},
],
voice_assignments: combinedVoiceNeighborhood.voice_assignments?.map((assignment) =>
assignment.is_primary
? assignment
: { ...assignment, evidence_post_id: VOICE_EVIDENCE_POST_ID },
),
exact_value_rows: combinedVoiceNeighborhood.exact_value_rows.map((row) =>
row.edge_id.endsWith(":vops")
? { ...row, evidence_post_id: VOICE_EVIDENCE_POST_ID }
: row,
),
jsonld: voiceJsonLd(VOICE_EVIDENCE_POST_ID),
},
},
};

export const LongLabelsAndEvidenceTable: Story = {
args: {
neighborhood: {
Expand Down
95 changes: 95 additions & 0 deletions frontend/src/ontologyLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,101 @@ describe("ontologyLayout", () => {
expect(accumulateNeighborhoodPages(source, withVoice).voice_assignments).toEqual([assignment]);
});

it("omits derived voices when a search hides their evidence post", () => {
const source = payload();
const evidenceId = "dddddddd-dddd-dddd-dddd-ddddddddddd1";
const assignmentIri = `${ONTOLOGY_NAMESPACE}voice-assignment/${POST_ID}/vops`;
const primaryIri = `${ONTOLOGY_NAMESPACE}voice-assignment/${POST_ID}/voc`;
const postIri = `${ONTOLOGY_NAMESPACE}node/node_post/${POST_ID}`;
const evidenceIri = `${ONTOLOGY_NAMESPACE}node/node_post/${evidenceId}`;
const relation = `${ONTOLOGY_NAMESPACE}hasVoiceAssignment`;
const assignment = {
post_id: POST_ID,
voice_type_code: "vops",
voice_type_iri: `${ONTOLOGY_NAMESPACE}voiceOfProcessType`,
voice_type_label: "Voice of Process",
is_primary: false,
truth_status_code: "truth_observed",
recorded_at: "2026-01-10T12:00:00+00:00",
provenance_reference: "Evidence-backed additional voice",
evidence_post_id: evidenceId,
};
const primary = {
...assignment,
voice_type_code: "voc",
voice_type_iri: `${ONTOLOGY_NAMESPACE}voiceOfCustomerType`,
voice_type_label: "Voice of Customer",
is_primary: true,
evidence_post_id: null,
};
const row = {
...source.exact_value_rows[0],
edge_id: `voice-assignment:${POST_ID}:vops`,
property_code: "hasVoiceAssignment",
evidence_post_id: evidenceId,
};
const withVoice = {
...source,
nodes: [source.nodes[0], { ...source.nodes[0], node_id: evidenceId, display_label: "Evidence source" }],
edges: [],
exact_value_rows: [row, { ...row, edge_id: `voice-assignment:${POST_ID}:voc`, evidence_post_id: POST_ID }],
voice_assignments: [assignment, primary],
jsonld: { "@graph": [
{ "@id": postIri, "rdfs:label": "Carrying post", [relation]: [
{ "@id": assignmentIri }, { "@id": primaryIri },
] },
{ "@id": evidenceIri },
{ "@id": assignmentIri, "prov:wasDerivedFrom": { "@id": evidenceIri } },
{ "@id": primaryIri, "prov:wasDerivedFrom": { "@id": postIri } },
{ "@id": assignment.voice_type_iri },
{ "@id": primary.voice_type_iri },
] },
} satisfies OntologyNeighborhoodPayload;

const hidden = filterNeighborhood(withVoice, "no match")!;
expect(hidden.voice_assignments).toEqual([primary]);
expect(hidden.exact_value_rows.map((value) => value.edge_id)).toEqual([
`voice-assignment:${POST_ID}:voc`,
]);
expect(hidden.jsonld["@graph"]).toEqual([
{ "@id": postIri, "rdfs:label": "Carrying post", [relation]: [{ "@id": primaryIri }] },
{ "@id": primaryIri, "prov:wasDerivedFrom": { "@id": postIri } },
{ "@id": primary.voice_type_iri },
]);

const shown = filterNeighborhood(withVoice, "Evidence source")!;
expect(shown.voice_assignments).toEqual([assignment, primary]);
expect(shown.exact_value_rows.map((value) => value.edge_id)).toEqual([
row.edge_id, `voice-assignment:${POST_ID}:voc`,
]);
expect((shown.jsonld["@graph"] as Array<Record<string, unknown>>)[0][relation]).toEqual([
{ "@id": assignmentIri },
{ "@id": primaryIri },
]);

const dangling = filterNeighborhood({
...withVoice,
nodes: [source.nodes[0]],
edges: [{
...source.edges[0],
edge_id: "dangling-evidence-reference",
source_node_type_code: "node_post",
source_node_id: POST_ID,
target_node_type_code: "node_post",
target_node_id: evidenceId,
property_label: "related evidence",
}],
}, "related evidence")!;
expect(dangling.edges).toEqual([]);
expect(dangling.voice_assignments).toEqual([primary]);
expect(dangling.exact_value_rows.map((value) => value.edge_id)).toEqual([
`voice-assignment:${POST_ID}:voc`,
]);
expect((dangling.jsonld["@graph"] as Array<Record<string, unknown>>)[0][relation]).toEqual([
{ "@id": primaryIri },
]);
});

it("merges JSON-LD properties and multi-value relations for one paged subject", () => {
const source = payload();
const postIri = `${ONTOLOGY_NAMESPACE}node/node_post/${POST_ID}`;
Expand Down
34 changes: 24 additions & 10 deletions frontend/src/ontologyLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const ROW_GAP = 128;
const LEFT = ONTOLOGY_NODE_LABEL_WIDTH / 2 + 20;
const TOP = 48;
const ONTOLOGY_NAMESPACE = "https://contextualwisdomlab.github.io/LineageWeave/ontology#";
const HAS_VOICE_ASSIGNMENT = `${ONTOLOGY_NAMESPACE}hasVoiceAssignment`;

function nodeKey(nodeTypeCode: string, nodeId: string): string {
return `${nodeTypeCode}:${nodeId}`;
Expand Down Expand Up @@ -191,7 +192,8 @@ export function filterNeighborhood(
const edges = payload.edges.filter((edge) => {
const source = nodesByKey.get(nodeKey(edge.source_node_type_code, edge.source_node_id));
const target = nodesByKey.get(nodeKey(edge.target_node_type_code, edge.target_node_id));
return edgeMatch(edge) || Boolean(source && nodeMatch(source)) || Boolean(target && nodeMatch(target));
if (!source || !target) return false;
return edgeMatch(edge) || nodeMatch(source) || nodeMatch(target);
});
const keep = new Set<string>([nodeKey(payload.focus_node_type_code, payload.focus_node_id)]);
for (const edge of edges) {
Expand All @@ -202,13 +204,19 @@ export function filterNeighborhood(
if (nodeMatch(node)) keep.add(nodeKey(node.node_type_code, node.node_id));
}
const nodes = payload.nodes.filter((node) => keep.has(nodeKey(node.node_type_code, node.node_id)));
const visibleVoiceAssignments = (payload.voice_assignments ?? []).filter((assignment) =>
keep.has(nodeKey("node_post", assignment.post_id)) &&
(assignment.is_primary ||
(assignment.evidence_post_id !== null &&
assignment.evidence_post_id !== undefined &&
keep.has(nodeKey("node_post", assignment.evidence_post_id)))),
);
const visibleVoiceRowIds = new Set(visibleVoiceAssignments.map(
(assignment) => `voice-assignment:${assignment.post_id}:${assignment.voice_type_code}`,
));
const exact_value_rows = payload.exact_value_rows.filter((row) =>
edges.some((edge) => edge.edge_id === row.edge_id) ||
(payload.voice_assignments ?? []).some(
(assignment) =>
row.edge_id === `voice-assignment:${assignment.post_id}:${assignment.voice_type_code}` &&
keep.has(nodeKey("node_post", assignment.post_id)),
),
visibleVoiceRowIds.has(row.edge_id),
);
const visibleIds = new Set([
...nodes.map((node) => `lw:node/${node.node_type_code}/${node.node_id}`),
Expand All @@ -217,9 +225,6 @@ export function filterNeighborhood(
const visibleNodeIds = new Set(nodes.map(
(node) => ontologyNodeId(node.node_type_code, node.node_id),
));
const visibleVoiceAssignments = (payload.voice_assignments ?? []).filter((assignment) =>
keep.has(nodeKey("node_post", assignment.post_id)),
);
const visibleVoiceIds = new Set(
visibleVoiceAssignments.flatMap((assignment) => [
assignment.voice_type_iri,
Expand All @@ -237,7 +242,16 @@ export function filterNeighborhood(
(visibleIds.has(item["@id"]) ||
visibleNodeIds.has(item["@id"]) ||
visibleVoiceIds.has(item["@id"])),
),
).map((item) => {
if (!Array.isArray(item[HAS_VOICE_ASSIGNMENT])) return item;
const relations = (item[HAS_VOICE_ASSIGNMENT] as unknown[]).filter(
(relation) => typeof relation === "object" && relation !== null &&
"@id" in relation && typeof relation["@id"] === "string" &&
visibleVoiceIds.has(relation["@id"]),
);
const { [HAS_VOICE_ASSIGNMENT]: _omitted, ...other } = item;
return relations.length ? { ...other, [HAS_VOICE_ASSIGNMENT]: relations } : other;
}),
}
: payload.jsonld;
return {
Expand Down