# Ledger write path fix — implementation plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.

**Goal:** Every ledger write tool successfully creates its node, because relationships carry the element id `writeNodeWithEdges` actually requires.

**Architecture:** Delete the ledger's invented `GraphRelationship` and the cast that forced it past the type checker; import the real type so the shape becomes a compile-time constraint. Resolve each relationship target to an element id before writing. Add a census counter so a dead write path can no longer read as a healthy empty ledger.

**Spec:** [`.tasks/1788-...md`](../../../../.tasks/1788-ledger-write-tools-pass-an-invented-relationship-shape-so-every-write-is-rejected.md). No separate design doc; the task carries the decisions.

## Global constraints

- Node 22. Never `npm install` in this worktree; deps are symlinked from the parent.
- Build and test from `maxy-code/platform`.
- The four write tools must not be tested with a stubbed `write`. That stub is why the bug shipped.
- Do not touch `lib/ledger-core`'s arithmetic (money, balance, ageing) — only `reconcile.ts` gains a counter.
- Baseline before changes: 56 ledger tests, 508 memory-plugin tests.

---

### Task 1: Make the wrong shape a compile error

**Files:** `plugins/ledger/mcp/src/lib/types.ts`, `plugins/ledger/mcp/src/index.ts`

**Produces:** the real `GraphRelationship { type, direction, targetNodeId }` in scope for every tool.

- [ ] **Step 1: Prove the current code is wrong at the type level.** In `types.ts`, delete the local `GraphRelationship` and re-export the real one:

```ts
export type { GraphRelationship } from "../../../../../lib/graph-write/dist/index.js";
```

- [ ] **Step 2: Delete the cast in `index.ts`.** Replace the `writeNodeWithEdges as unknown as (...)` wrapper with a direct reference. Run `tsc -p plugins/ledger/mcp/tsconfig.json` and record every error. Expect failures in `invoice-record.ts`, `payment-record.ts`, `cash-record.ts` on `targetLabel`/`targetProps`. **Those errors are the bug** — they are what the cast was hiding.

- [ ] **Step 3: Fix the attribution while here.** `createdBy` becomes `{ agent: agentSlug, session: sessionId, tool, source: "ledger" }` with no `?? ""`, so `graph-write:254`'s `agent ?? source ?? "unknown"` chain can work.

- [ ] **Step 4: Commit** once the remaining errors are only the three tool files.

---

### Task 2: Resolve targets to element ids

**Files:** `plugins/ledger/mcp/src/lib/read.ts`, `tools/{invoice-record,payment-record,cash-record}.ts`, `index.ts`, `PLUGIN.md`

**Consumes:** the real type from Task 1.

- [ ] **Step 1: `readInvoice` returns the node's id.** Add `nodeId: string` to `InvoiceRead`; add `elementId(i) AS nodeId` to the Cypher and map it.

- [ ] **Step 2: Write the failing tests first.** For each of the three tools, assert the relationship carries a `targetNodeId` equal to the id supplied or resolved, and that no relationship carries `targetLabel`:

```ts
it('targets the counterparty by element id', async () => {
  const write = okWrite()
  await invoiceRecord({ ...base, counterpartyNodeId: '4:abc:12', session: {} as never, write })
  const rel = write.mock.calls[0]![0].relationships[0]
  expect(rel.targetNodeId).toBe('4:abc:12')
  expect(rel).not.toHaveProperty('targetLabel')
})
```

- [ ] **Step 3: Implement.** `invoice-record` and `cash-record` take a required `counterpartyNodeId`. `payment-record` and `credit-record` use `invoice.nodeId` from the read. Each `zod` schema gains the field with a description telling the agent to look the organisation up first.

- [ ] **Step 4: Reject a blank id** before writing, with `op=reject reason=missing-counterparty`, so the failure names the cause rather than surfacing as `unresolved-target`.

- [ ] **Step 5: `tsc` clean, ledger tests green, commit.**

---

### Task 3: The test that would have caught it

**Files:** `plugins/ledger/mcp/src/tools/__tests__/write-contract.test.ts` (new)

One integration test per write tool that calls the **real** `writeNodeWithEdges` against a fake `Session` (`run` + `close`), asserting the issued Cypher carries the resolved target id and that no `unresolved-target` rejection occurs.

- [ ] **Step 1: Write it against the current code and watch it fail** if Tasks 1 and 2 were somehow incomplete.
- [ ] **Step 2: Confirm it passes.**
- [ ] **Step 3: Prove it is load-bearing.** Temporarily reintroduce `targetLabel` in one tool; the build must fail. Revert.
- [ ] **Step 4: Commit.**

---

### Task 4: Make a dead write path visible

**Files:** `lib/ledger-core/src/reconcile.ts`, `plugins/ledger/mcp/src/lib/read.ts`, `tools/reconcile.ts`, `ui/app/lib/ledger-census.ts`, tests

`[ledger-census]` currently prints `invoices=0 payments=0` identically whether the ledger is healthy-and-empty or wholly broken. That is why this bug ran all day unseen.

- [ ] **Step 1: Failing test.** `reconcileLedger` alarms when `writeRejects24h > 0`.
- [ ] **Step 2: Implement.** Add `writeRejects24h` to `LedgerCensusRows`, `LedgerCensusFinding`, the alarm condition, and `formatCensusLine`.
- [ ] **Step 3: Source the count.** Both the census and the reconcile tool count `[graph-write] reject` lines in `<LOG_DIR>/mcp-ledger-*.log` with a timestamp inside the trailing 24 hours.
- [ ] **Step 4: Tests green, commit.**

---

### Task 5: Docs

**Files:** `plugins/ledger/PLUGIN.md`, `plugins/docs/references/ledger.md`

State that the counterparty must be looked up first and passed by id, that the ledger never creates organisations, and what `writeRejects24h` means. Keep the shipped reference free of em-dashes.

- [ ] Commit.

---

## Self-review

**Spec coverage.** Type import and cast removal: Task 1. Attribution: Task 1 step 3. Element-id resolution for all four write tools: Task 2. The banned-stub integration test: Task 3. Census counter: Task 4. Docs: Task 5. Out-of-scope items (duplicate-org matching, `:InvoiceLine`, read tools, ledger-core arithmetic) are untouched.

**Risk.** Task 2 changes the tool signatures, so any agent prompt or skill that calls `ledger-invoice-record` with a bare counterparty name breaks. Nothing calls these tools yet except live agent turns, and they have never succeeded, so there is no working caller to regress.
