# Session schema — the on-disk contract

Load when: bootstrap; writing a capture entry or a dispatch line you are unsure of; relaying a detector finding. The twin reads it on every dispatch.

This is the format `scripts/detector.mjs` parses, and the twin, the replay evals and the maintenance census all key on its rule ids. A line that drifts from it fails a rule or, worse, is silently not seen, so match the shapes below exactly: field names, the em dash ` — `, the arrow ` → `, the middle dot ` · `. **Every datetime is UTC in one shape, `YYYY-MM-DDTHH:MMZ`, taken from `date -u +%Y-%m-%dT%H:%MZ`:** the detector compares stamps as strings, so a local-time or differently shaped stamp makes a fresh compose read as stale, or a stale one as fresh.

## Layout

```
docs/research/{topic}-brainstorm/
├── state.md            twin
├── thesis.md           twin
├── forks.md            twin
├── ledger.md           twin
├── fragments/{name}.md twin, created on first real content
├── basis/              facilitator, at intake; frozen
└── ops/                untracked; deleted at the move
    ├── .gitignore      facilitator, at bootstrap: the single line *
    ├── capture.md      facilitator, every turn
    ├── sync-log.md     dispatch lines: facilitator · result lines: twin
    └── manifest.md     twin, at stop
```

On a passing harvest the folder moves to `docs/history/brainstorms/{topic}/` without `ops/`, and `state.md` reads `Status: harvested`.

## `state.md`

```markdown
# State

- Status: open
- Posture: mid-probe on the persistence aspect, two forks open
- Started: 2026-07-08
- Sitting: single
- Last compose: 2026-07-08T14:30Z

## Aspect map

- persistence — how cache state survives restarts → F1, D1
- onboarding — the first-run experience → thesis
- risks — failure modes and mitigations → F2, fragment:domain-model
```

| Field | Values |
| --- | --- |
| `Status` | `open` → `stopping` → `harvested`, forward only: the twin sets `stopping` when the stop pipeline begins and `harvested` at the move |
| `Posture` | one line a resuming reader can act on: where the conversation is and what is next; a parked session says so here |
| `Started` | ISO date |
| `Sitting` | `single` · `multi` |
| `Last compose` | the ISO datetime of the last thesis compose, equal to the thesis stamp; `never` before the first |

Each aspect line is `- {name} — {gloss} → {refs}`, refs comma-separated: `F<n>` (a fork, open or resolved by a decision), `D<n>`, `fragment:{name}` (a file under `fragments/`), `thesis`, or `none yet`.

## `thesis.md`

A title, then `Current as of: <ISO datetime>`, then the sections `## Claim`, `## Argument`, `## What changed`, in that order.

## `forks.md`

```markdown
## F2 — What happens on catalog API timeout? [open]

The cache can serve stale on timeout or propagate the error; the basis is silent on it.

- Raised by: twin (S1)
- Closes when: an accepted failure behavior — stale-serve with a warning, or error propagation
```

The heading's bracket is `[open]` or `[parked → {destination path}]`. `Raised by:` is `user`, `facilitator`, or `twin (S<n>)` citing the reconciled sync that raised it; a blindspot fork's body opens `Blindspot:`. A fork resolved by a decision leaves the file; the decision's `Resolves:` keeps its id.

## `ledger.md`

```markdown
## D1 — Cache keys are per-tenant

- Decision: every cache key embeds the tenant id; no shared entries.
- Why: cross-tenant reads are a trust break, and memory cost is acceptable at current tenant
  counts. Declined: shared keys with tenant checks at read time — a single missed check leaks
  data; re-evaluate if memory pressure forces it.
- How to apply: the cache module's key builder prefixes the tenant slug.
- Resolves: F3
```

`Decision`, `Why` and `How to apply` are required and non-empty; `Resolves` is optional. Ids are unique and never reused.

## `ops/capture.md`

```markdown
# Capture

---
### T2 · 2026-07-08T14:20Z

facilitator: flagged the trust concern on shared entries — a missed tenant check at read time is
a leak, not a bug. recommended per-tenant keys; user accepted (→ D1). residue: user mentioned a
"warm-on-deploy" idea in passing, not pursued.
```

Entries are separated by `---`, headed `### T<n> · <ISO datetime>`, numbered in order, and labelled by speaker inside (`user (dump):`, `facilitator:`). The heading's datetime is what the manifest gate compares against the last compose.

## `ops/sync-log.md`

```
S1 | gate: start | dispatched: 2026-07-08T14:05Z | scope: bootstrap reconcile
S1 → reconciled | 2026-07-08T14:08Z | 2 edits · 1 new fork · 2 open · untouched: risks
```

The facilitator writes the first line as it dispatches; the twin appends the second on return, `reconciled` or `failed | <ts> | <reason>`. Gates: `start` · `on-demand` · `pause` · `stop`. Ids run `S1, S2, …` across the session.

## `ops/manifest.md`

```markdown
# Harvest manifest — {topic}

Round: 1
Status: proposed

| # | What | Source | Destination | Action |
|---|------|--------|-------------|--------|
| 1 | consolidated source of truth | thesis + ledger | docs/features/widget-cache.md | create |
| 3 | parked fork F2 | forks.md | docs/architecture/open-questions.md | append |

## Uselessness test

- Result: pass
- Cold-reader allowed paths: docs/features/widget-cache.md, docs/architecture/open-questions.md
- Gaps: none
```

`Status` runs `proposed` → `approved` → `applied`. Every parked fork's id and every fragment's file name appear in a row's What or Source. Destinations are repository-relative paths. A gap round increments `Round`.

## The detector

`node <skill>/scripts/detector.mjs <session-folder> [--gate=session|manifest|move] [--json]` exits `0` clean, `1` with violations listed by rule id, `2` on unusable input. Gates are cumulative: `manifest` runs the session rules too, `move` runs all. A real run always prints per-rule lines and a `clean` or `failing` summary; **empty output means the detector never ran**, which is a failed gate, never a pass.

| Rule | Gate | Fails when |
| --- | --- | --- |
| `BS-STATE-01` | session | `state.md` missing, or a field absent or invalid |
| `BS-STATE-02` | session | no `## Aspect map`, or no parseable aspect |
| `BS-ASPECT-01` | session | an aspect ref names no such fork, decision or fragment |
| `BS-THESIS-01` | session | the thesis lacks its stamp or a mandated section |
| `BS-COMPOSE-01` | session | the thesis stamp differs from `Last compose` |
| `BS-FORK-01` | session | duplicate fork id, no `Closes when:`, or a park without destination |
| `BS-FORK-02` | session | a decision resolves a fork still open |
| `BS-LEDGER-01` | session | duplicate decision id, or Decision, Why or How to apply empty |
| `BS-OPS-01` | session | `ops/.gitignore` missing or not `*` |
| `BS-OPS-02` | session | any `ops/` file tracked by git (skipped outside git) |
| `BS-SYNC-01` | session | a dispatch other than the newest has no result line |
| `BS-SYNC-02` | session | a `twin (S<n>)` attribution cites a sync that never reconciled |
| `BS-ASPECT-02` | manifest | an aspect has no content (`none yet`) |
| `BS-COMPOSE-02` | manifest | the newest capture entry is newer than the last compose |
| `BS-FORK-03` | manifest | any fork is open |
| `BS-MANIFEST-01` | manifest | a parked fork or a fragment has no manifest row |
| `BS-HARVEST-01` | move | a destination is missing, or links into the session folder |
| `BS-USELESS-01` | move | no passing uselessness test with `Gaps: none`, or the manifest is not `applied` |

When you relay a finding, name the rule id and what it means for the user, in plain words: "the last compose predates the newest capture (`BS-COMPOSE-02`), so the twin syncs before the manifest".
