- {c.observations?.length ? (
-
-
What this tells us
-
- {c.observations.map((o, i) => (
- - {o}
- ))}
-
-
- ) : null}
+ {(() => {
+ const propositions = getHistoricalPropositions(c, findings);
+ return propositions.length ? (
+
+
What this tells us
+
+ {propositions.map((o, i) => (
+ - {o}
+ ))}
+
+
+ ) : null;
+ })()}
{c.uncertainties?.length ? (
Still unclear
@@ -520,7 +540,7 @@ function PriorContributionsSummary({ nodeId, contributions }) {
// ── Standalone previous learning block (for two-column secondary placement) ───
-function SecondaryPreviousLearning({ nodeId, contributions }) {
+function SecondaryPreviousLearning({ nodeId, contributions, findings }) {
const threadContribs = (contributions || []).filter(
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
);
@@ -538,19 +558,22 @@ function SecondaryPreviousLearning({ nodeId, contributions }) {
{priorContribs.map((c, idx) => (
- Turn {c.sequence || idx + 1} — contribution ({c.observations?.length ?? 0} observations, {c.uncertainties?.length ?? 0} unclear)
+ Turn {c.sequence || idx + 1} — contribution ({getHistoricalPropositions(c, findings).length ?? 0} observations, {c.uncertainties?.length ?? 0} unclear)
- {c.observations?.length ? (
-
-
What this tells us
-
- {c.observations.map((o, i) => (
- - {o}
- ))}
-
-
- ) : null}
+ {(() => {
+ const propositions = getHistoricalPropositions(c, findings);
+ return propositions.length ? (
+
+
What this tells us
+
+ {propositions.map((o, i) => (
+ - {o}
+ ))}
+
+
+ ) : null;
+ })()}
{c.uncertainties?.length ? (
Still unclear
@@ -570,7 +593,7 @@ function SecondaryPreviousLearning({ nodeId, contributions }) {
// ── Thread contributions badge (standalone — used outside focused body) ───
-function ThreadContributionsBadge({ nodeId, contributions }) {
+function ThreadContributionsBadge({ nodeId, contributions, findings }) {
const threadContribs = (contributions || []).filter(
(c) => c.targetNodeId === nodeId || c.originatingTargetNodeId === nodeId,
);
@@ -601,17 +624,20 @@ function ThreadContributionsBadge({ nodeId, contributions }) {
{threadContribs.map((c, idx) => (
{idx > 0 &&
Contribution #{c.sequence || idx + 1}
}
- {/* What this tells us */}
- {c.observations?.length ? (
-
-
What this tells us
-
- {c.observations.map((o, i) => (
- - {o}
- ))}
-
-
- ) : null}
+ {/* What this tells us — via canonical Findings */}
+ {(() => {
+ const propositions = getHistoricalPropositions(c, findings);
+ return propositions.length ? (
+
+
What this tells us
+
+ {propositions.map((o, i) => (
+ - {o}
+ ))}
+
+
+ ) : null;
+ })()}
{/* Still unclear */}
{c.uncertainties?.length ? (
@@ -1070,6 +1096,7 @@ function FocusedInvestigationWorkspace({
hasCompletedInvestigation,
focusedContributions,
currentFindings,
+ findings,
onUpdateFindingDisposition,
onUpdateFindingProposition,
}) {
@@ -1107,7 +1134,7 @@ function FocusedInvestigationWorkspace({
{/* ── Secondary context: Previous Learning — visible on all breakpoints, placed in grid column on wide / flows below primary on narrow ── */}
{hasResult && (
-
+
)}
@@ -1122,6 +1149,7 @@ function OpenQuestionsPanel({
startFocused, handleDeconstructSubmit, retryFormulation, setSelectedPresentationItemId,
setFocusedPresentationItemId, setFocusedAnswer, focusedAnswer, setDoneForNowIds,
setFollowUpQuestion, focusedContributions, focusedInvestigations, setIsFocusedWorkspaceOpen,
+ findings,
onUpdateFindingDisposition,
onUpdateFindingProposition,
}) {
@@ -1206,7 +1234,7 @@ function OpenQuestionsPanel({
/>
{/* Thread contributions for this node */}
-
+
);
@@ -1220,7 +1248,7 @@ function OpenQuestionsPanel({
{graph.nodes.filter((n) => doneForNowIds.includes(n.id)).map((node) => (
{node.label}
-
+
))}
@@ -1917,6 +1945,7 @@ export default function ReasoningWorkspace({
focusedContributions={focusedContributions}
focusedInvestigations={focusedInvestigations}
setIsFocusedWorkspaceOpen={setIsFocusedWorkspaceOpen}
+ findings={findings}
onUpdateFindingProposition={onUpdateFindingProposition}
/>
)}
@@ -2092,6 +2121,7 @@ export default function ReasoningWorkspace({
}}
focusedContributions={focusedContributions}
currentFindings={currentFindings || []}
+ findings={findings}
onUpdateFindingDisposition={onUpdateFindingDisposition}
onUpdateFindingProposition={onUpdateFindingProposition}
/>
diff --git a/docs/current-handoff.md b/docs/current-handoff.md
index cdb0cc7..9ab4955 100644
--- a/docs/current-handoff.md
+++ b/docs/current-handoff.md
@@ -1035,6 +1035,38 @@ The remaining boundaries are NOT persistence issues. They belong to the next fea
**No changes to:** Finding eligibility, Finding disposition, Current Understanding, Done-for-now promotion, SituationGraph, persistence provider, or graph reasoning.
+### v0.49.5 — CANONICAL PREVIOUS LEARNING PROPOSITION REPAIR (2026-08-29)
+
+**Objective:** Ensure Previous Learning surfaces on focused investigation cards resolve canonical historical propositions through the Finding→Contribution identity chain rather than showing stale or missing content.
+
+**Resolver semantics implemented in `reasoning-workspace.jsx`:**
+
+| Matching canonical Findings for a Contribution | Behaviour |
+|---|---|
+| ZERO matching canonical Findings | Fallback to `Contribution.observations` |
+| ONE OR MORE matching canonical Findings | Canonical Findings are authoritative; use `Finding.proposition` |
+| Matching Findings exist but all are `not_relevant` | Render nothing for that Contribution; DO NOT resurrect old observations |
+| Each Contribution's findings filtered by | `Finding.contributionId === Contribution.id` (one-to-one ownership) |
+
+**Applied to historical-learning surfaces:** `PriorContributionsSummary`, `SecondaryPreviousLearning`, `ThreadContributionsBadge`.
+
+**Files changed:**
+- `components/reasoning-workspace.jsx` — canonical proposition resolver in Previous Learning panel
+- `tests/open-questions-vs-assumptions.test.jsx` — 78 tests covering legacy fallback, empty fallback, corrected canonical proposition, all-not_relevant suppression, turn ownership, mixed dispositions, null/undefined findings
+
+**Deterministic gate:** 78 tests passed. **Build gate:** clean production build.
+
+**Live verification (canonical onboarding fixture):**
+- Persisted Finding `finding-5rf99h` carries corrected proposition `"Approximately 62% of users abandoning the verification step report no problem receiving their code. [previous-learning-check]"` with `contributionId: "contrib-0002"`
+- Workspace opened without crash or ReferenceError
+- Turn 2 → canonical proposition with `[previous-learning-check]` marker rendered in Previous Learning "What this tells us"
+- No stale original wording shown; no duplication of old+corrected text
+- Correct turn ownership confirmed (Turn 2 = `contrib-0002`, matching Finding scoped to that contribution)
+- Zero LLM/API calls during verification
+- Persistence/schema/reasoning paths unchanged
+
+**No changes to:** Persistence schema, Finding schema, Contribution schema, SituationGraph reasoning, activity visibility, completed-turn lifecycle, or overlay controls.
+
---
## RESTORE / PRESENTATION FINDINGS — MANUAL USER-PATH (2026-08-28)
diff --git a/tests/open-questions-vs-assumptions.test.jsx b/tests/open-questions-vs-assumptions.test.jsx
index 7d7f6c1..dc9b9a9 100644
--- a/tests/open-questions-vs-assumptions.test.jsx
+++ b/tests/open-questions-vs-assumptions.test.jsx
@@ -1140,3 +1140,109 @@ describe("Reopened Finding resolution via Contribution.id → contributionId", (
// Turn 1 Finding NOT merged into latest view.
});
});
+
+// ── getHistoricalPropositions: Previous Learning renders canonical Findings ─
+
+function getHistoricalPropositions(contribution, findings) {
+ const matching = (findings || []).filter(
+ (f) => f.contributionId === contribution.id,
+ );
+
+ if (matching.length === 0) {
+ return contribution.observations || [];
+ }
+
+ return matching
+ .filter((f) => f.userDisposition !== "not_relevant")
+ .map((f) => f.proposition);
+}
+
+describe("getHistoricalPropositions: Previous Learning uses canonical Findings", () => {
+ it("zero matching canonical Findings → fallback to Contribution.observations", () => {
+ const contrib = {
+ id: "contrib-01",
+ observations: ["Original observation from contribution"],
+ };
+ const findings = [];
+
+ const result = getHistoricalPropositions(contrib, findings);
+ expect(result).toEqual(["Original observation from contribution"]);
+ });
+
+ it("zero matching canonical Findings with null observations → empty array", () => {
+ const contrib = { id: "contrib-02" };
+ const findings = [];
+
+ const result = getHistoricalPropositions(contrib, findings);
+ expect(result).toEqual([]);
+ });
+
+ it("one or more matching canonical Findings → canonical Findings are authoritative", () => {
+ const contrib = { id: "contrib-03", observations: ["Original obs"] };
+ const findings = [
+ { contributionId: "contrib-03", proposition: "Canonical finding A", userDisposition: "agreed" },
+ { contributionId: "contrib-03", proposition: "Canonical finding B", userDisposition: "agreed" },
+ ];
+
+ const result = getHistoricalPropositions(contrib, findings);
+ expect(result).toEqual(["Canonical finding A", "Canonical finding B"]);
+ expect(result).not.toContain("Original obs");
+ });
+
+ it("matching canonical Findings all have userDisposition not_relevant → render NO propositions, DO NOT fall back", () => {
+ const contrib = { id: "contrib-04", observations: ["Should not appear"] };
+ const findings = [
+ { contributionId: "contrib-04", proposition: "Dismissed finding 1", userDisposition: "not_relevant" },
+ { contributionId: "contrib-04", proposition: "Dismissed finding 2", userDisposition: "not_relevant" },
+ ];
+
+ const result = getHistoricalPropositions(contrib, findings);
+ expect(result).toEqual([]);
+ expect(result).not.toContain("Should not appear");
+ });
+
+ it("turn ownership is strict — only Findings whose contributionId matches the Contribution.id render", () => {
+ const contribA = { id: "contrib-A", observations: ["A's original"] };
+ const contribB = { id: "contrib-B", observations: ["B's original"] };
+
+ const contribBFinding = { contributionId: "contrib-B", proposition: "Finding from B", userDisposition: "agreed" };
+ const contribAFinding = { contributionId: "contrib-A", proposition: "Finding from A", userDisposition: "agreed" };
+
+ const resultForB = getHistoricalPropositions(contribB, [contribBFinding]);
+ expect(resultForB).toEqual(["Finding from B"]);
+
+ const resultMixed = getHistoricalPropositions(contribB, [contribAFinding, contribBFinding]);
+ expect(resultMixed).toEqual(["Finding from B"]);
+ expect(resultMixed).not.toContain("Finding from A");
+ });
+
+ it("mixed dispositions: non-not_relevant Findings render, not_relevant are excluded", () => {
+ const contrib = { id: "contrib-05", observations: ["Original"] };
+ const findings = [
+ { contributionId: "contrib-05", proposition: "Kept finding", userDisposition: "agreed" },
+ { contributionId: "contrib-05", proposition: "Dismissed finding", userDisposition: "not_relevant" },
+ { contributionId: "contrib-05", proposition: "Neutral finding", userDisposition: "neutral" },
+ ];
+
+ const result = getHistoricalPropositions(contrib, findings);
+ expect(result).toEqual(["Kept finding", "Neutral finding"]);
+ expect(result).not.toContain("Dismissed finding");
+ });
+
+ it("null/undefined findings array handled safely — falls back to observations", () => {
+ const contrib = { id: "contrib-06", observations: ["Fallback safe"] };
+
+ expect(getHistoricalPropositions(contrib, null)).toEqual(["Fallback safe"]);
+ expect(getHistoricalPropositions(contrib, undefined)).toEqual(["Fallback safe"]);
+ });
+
+ it("Contribution with no observations and no matching Findings returns empty", () => {
+ const contrib = { id: "contrib-07" };
+ const findings = [
+ { contributionId: "contrib-other", proposition: "Wrong contribution", userDisposition: "agreed" },
+ ];
+
+ const result = getHistoricalPropositions(contrib, findings);
+ expect(result).toEqual([]);
+ });
+});