diff --git a/next-env.d.ts b/next-env.d.ts
index ce4e94a..a419cbe 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,7 +1,7 @@
///
///
-import "./.next/types/routes.d.ts";
-import "./.next/types/root-params.d.ts";
+import "./.next/dev/types/routes.d.ts";
+import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/src/core/chains.ts b/src/core/chains.ts
index ec716f7..9d23a6c 100644
--- a/src/core/chains.ts
+++ b/src/core/chains.ts
@@ -1,3 +1,5 @@
+import type { GraphNode } from "./contracts/graph.ts";
+
/**
* Selection-facing chain views — pure + browser-safe (the shell's chain
* table consumes these; the barrel exports them). Born in the practice
@@ -55,6 +57,27 @@ export function chainsForSelection(
* singleton shows its "(no premises)" marker. Pure + browser-safe.
* Unknown ids fall back to the raw id (honest, never guessed).
* Premises sort by id — the same determinism law as chainsForSelection. */
+/** Resolve ONE target string to a canonical proposition id (owner round
+ * 9, 2026-10-05 — the mastery-trace contract): id first (exact), then
+ * name (case-insensitive, trimmed — names are presentational handles,
+ * not unique; an ambiguous name resolves to the FIRST node in store
+ * order). Returns null when nothing matches — the caller rejects.
+ * Validation set is the WHOLE graph, not the session's picks: pickless
+ * sessions and future graph-tools poses stay valid. */
+export function resolveTargetId(
+ target: string,
+ nodes: ReadonlyArray>,
+): string | null {
+ const t = target.trim();
+ if (t.length === 0) return null;
+ if (nodes.some((n) => n.id === t)) return t;
+ const lower = t.toLowerCase();
+ for (const n of nodes) {
+ if (n.name.trim().toLowerCase() === lower) return n.id;
+ }
+ return null;
+}
+
export function formatChainLine(
chain: Pick,
propositions: Readonly>,
diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts
index de0c005..40d0dd8 100644
--- a/src/core/native-messages.ts
+++ b/src/core/native-messages.ts
@@ -203,11 +203,16 @@ export interface GraphContextPick {
* feeds the transcript's collapsible GraphContextCard. No more seed
* text glued into the first prompt. */
export function newGraphContextMessage(picks: readonly GraphContextPick[], chains: readonly string[]): LhMarkerMessage {
+ // IDS FIRST (owner round 9, 2026-10-05 — the mastery-trace contract):
+ // each proposition is introduced ONCE with its id, name, and statement;
+ // the problem tool's `target` field takes these ids (names normalize to
+ // ids at the seam). Chains below stay NAMES — they are comprehension
+ // context, not tags; names read, ids reference.
const lines = [
"Contextualization — the learner picked these propositions from their knowledge map when starting this session:",
"",
- "Propositions:",
- ...picks.map((p) => `- ${p.name}: ${p.statement}`),
+ "Propositions (id — name: statement; the problem tool's target field uses these ids):",
+ ...picks.map((p) => `- [${p.id}] ${p.name}: ${p.statement}`),
];
if (chains.length > 0) {
lines.push("", "Reasoning chains among them (premises → conclusion):", ...chains.map((c) => `- ${c}`));
diff --git a/tests/graph-context.test.ts b/tests/graph-context.test.ts
index d999c17..4bf46c5 100644
--- a/tests/graph-context.test.ts
+++ b/tests/graph-context.test.ts
@@ -49,7 +49,9 @@ describe("newGraphContextMessage", () => {
const m = newGraphContextMessage(PICKS, ["Modus ponens → Soundness"]);
assert.equal(m.customType, "graph-context");
assert.match(m.content, /Contextualization — the learner picked these propositions/);
- assert.ok(m.content.includes("- Modus ponens: If P implies Q and P holds, Q holds."));
+ // IDS FIRST (owner round 9): every pick carries its id — the problem
+ // tool's target field references these; names alone no longer.
+ assert.ok(m.content.includes(`- [${PICKS[0]!.id}] Modus ponens: If P implies Q and P holds, Q holds.`));
assert.ok(m.content.includes("Reasoning chains among them"));
assert.ok(m.content.includes("- Modus ponens → Soundness"));
// the UI payload is the structured pick, not the flattened text
@@ -133,8 +135,8 @@ describe("POST /api/chats — graphContext becomes message[0]", () => {
const m = record.messages[0] as { role: string; customType: string; content: string };
assert.equal(m.role, "custom");
assert.equal(m.customType, "graph-context");
- assert.ok(m.content.includes("- Modus ponens: If P implies Q and P holds, Q holds."));
- // the chain AMONG the picks rides the marker (formatChainLine shape)
+ assert.ok(m.content.includes("- [n_a] Modus ponens: If P implies Q and P holds, Q holds."));
+ // the chain AMONG the picks rides the marker (formatChainLine shape — names)
assert.ok(m.content.includes("- Modus ponens → Soundness"));
});