← Vault Index
Source: frameworks/plugin-engagement-manager/advisory-sessions/skills/session-ledger/schema.md

ledger.json — schema

Field names are contractual. renderdigest.py and verifyrun.py read them directly.

{
  "ledgerId": "saunders-2026-08-12",
  "engagement": {
    "slug": "saunders",
    "name": "Saunders Tax — succession",
    "type": "paid-facilitation",
    "principals": ["Bev Stitely", "Steven Stitely"],
    "recapRecipients": ["bevstitely@…", "stevenstitely@…"],
    "documentStore": "G:/Shared drives/SAUNDERS",
    "scopeFences": ["operational rebuild", "legal drafting", "financing", "valuation"]
  },
  "session": {
    "date": "2026-08-12", "start": "15:30", "end": "17:00", "timeZone": "America/New_York",
    "type": "joint-working-session",
    "attendees": ["Kathryn Brown", "Bev Stitely", "Steven Stitely"],
    "transcriptPath": "…/transcript.txt", "chatPath": "…/chat.txt",
    "substantive": true
  },
  "sourceIntegrity": {
    "transcriptReadInFull": true, "transcriptChars": 89472,
    "toolSummaryUsed": false, "chatPresent": true, "passes": 4
  },
  "claims": [
    { "id": "own-gift-5", "text": "Bev gifts 5% on January 1 of 2027–2030",
      "quote": "five percent a year starting January first", "speaker": "Bev Stitely",
      "timestamp": "0:06:14", "modality": "stated", "category": "ownership" }
  ],
  "outcomes": [
    { "id": "o1", "text": "…", "status": "decided|leaning|shaping|open",
      "audience": "client:saunders|internal",
      "displaces": "what this makes obsolete, or null", "claimRefs": ["own-gift-5"] }
  ],
  "wins": [
    { "text": "held the line on the June 30 rush and did not cave",
      "audience": "client:saunders", "claimRefs": ["…"] }
  ],
  "noWinsReason": "only required when wins is empty — say why, do not default into it",
  "commitments": [
    { "owner": "Steven Stitely", "text": "Calculate debt service he can carry",
      "modality": "hedged", "dateState": "none", "dateStated": null, "words": null,
      "askedBy": "Kathryn Brown", "claimRefs": ["…"], "recapSurface": true,
      "audience": "client:saunders|internal", "stillOutstanding": true }
  ],
  "documentsTouched": [
    { "name": "The Saunders Vision & Decisions", "link": "…",
      "whatChanged": "lanes section drafted live", "claimRefs": ["…"] }
  ],
  "gaps": [ { "text": "…", "owner": "…", "audience": "client:saunders|internal",
              "claimRefs": ["…"] } ],
  "openQuestions": [ { "question": "…", "whyUnresolved": "…" } ],
  "consentFlags": [
    { "what": "8/26 session moved 12:30 → 3:00", "absent": "Bev Stitely",
      "theyHadAsked": "12:30 or 1:00 to leave at 2:30", "claimRefs": ["…"] }
  ],
  "contradictions": [
    { "field": "Amy Breakall's role", "sourceA": "reference data — front desk",
      "sourceB": "Kathryn's 2026-07-23 email — tax manager", "usedInOutput": false }
  ],
  "scopeEvents": [
    { "fence": "operational rebuild", "whatCrossed": "…", "raisedBy": "…", "claimRefs": ["…"] }
  ],
  "properNouns": { "new": [], "renderings": [], "unconfirmed": [] },
  "advisorNotes": [ { "type": "behavioral|coaching|context|unsaid", "text": "…" } ],
  "nextSession": { "date": "2026-08-26", "start": "15:00", "end": "16:30",
                   "timeZone": "America/New_York", "booked": true, "confirmedByClient": false },
  "unreachedAgenda": ["…"],
  "unowned": [ { "text": "…", "belongsTo": "a different function" } ],

  "recapBlocks": [
    { "title": "Your Summer Week", "audience": "client:saunders",
      "lead": "one line under the header, optional", "listStyle": "ul|ol",
      "list": ["…"], "after": ["a paragraph after the list, optional"],
      "claimRefs": ["…"] }
  ],
  "recapAgenda": [ { "text": "…", "audience": "client:saunders", "claimRefs": ["…"] } ],
  "recapClose": { "audience": "client:saunders", "note": "…", "line": "See you Thursday!",
                  "claimRefs": ["…"] }
}

Enums


audience — the fail-closed filter (added 2026-08-19)

Every renderable row may carry audience. Two values: client: or internal.

A row with no audience is internal. That default is the whole point. render_recap.py puts a row into the client's email only when it is explicitly tagged for that client, so a row nobody thought about does not travel. The reverse default — tag it internal to hold it back — fails open, and the one that gets missed is the one that hurts.

The test for the tag is not "is this true" and not "is this confidential". It is Kathryn's, recorded 2026-08-19 while cutting a Tracy Beveridge recap from eight covered items to four:

A fact reaches the email only if it carries the client's business forward, or they must act on it.

A setup session is the case that makes this obvious. The client sat and watched the work for fifty minutes. Connectors re-authorizing, settings chosen, a skill installed, a machine configured — all true, all witnessed, all finished, none of it carrying anything forward. A recap that lists them is a maintenance log of what got clicked. Tag them internal and put the reason on the row (_audienceWhy), so the next run does not re-litigate it.

Where a row is client-facing, its text is the client line — second person, no advisor framing, and for a covered item written as Title — the reasoning under it, which is the shape the renderer bolds. Keep the advisor wording on the same row in _internalText rather than losing it.

wins — conditional, and never manufactured (added 2026-08-19)

A top-level array. Each row is { text, audience, claimRefs }, like every other fact row.

Kathryn, 2026-08-17: "not every recap has a wins in progress. Sometimes it is truly just what decisions were made." So the renderer does not demand one — a blocker that DEMANDS a win manufactures one, and a manufactured win is what turns a recap into minutes.

But it does not let the array be quietly empty either. If wins has no client-facing row, the ledger must carry noWinsReason, or the renderer refuses. Silence gets blocked; a stated absence renders fine.

wins carries what the client did or gained on their own — their numbers, a boundary they held, the thing they pulled off that week. On a setup, rebuild or migration session the surviving connectors, the rebuilt tasks and the recovered files are mechanism: they belong in the covered section. If that leaves wins empty, that is a session with no client wins in it, and noWinsReason says so.

leaning — the status between decided and shaping (added 2026-08-19)

Kathryn, 2026-08-18: "you're taking hard stances on things that were not completely decided."

There was no bucket between decided and shaping, so an advisor recommendation the client received warmly rendered as a decision, and a measure restated without a threshold rendered as settled. A session where the client thinks out loud produces mostly leaning.

StatusMeans
decidedIt happened, or they settled it. It can be stated flat.
leaningNamed, received, not refused — and not agreed either. A recommendation nobody answered; a direction with the numbers still open.
shapingStill being worked. Not a position yet.
openNamed and unresolved, with nobody carrying it yet.

Nothing above decided may be written as a decision in anything the client reads.

Renderer payloads — recapBlocks, recapAgenda, recapClose

Three optional arrays that exist so the recap can be rendered rather than written. All three are client-gated by the same audience rule and carry claimRefs like every other row.

Section order and section names are not in this file. They come from the engagement's last-sent-.json, derived from the mail Kathryn actually sends that recipient. There is no house recap format, and a frozen one in a script is the bug verify_shape.py was written about.