# Memvelope — an open envelope for AI memory

**Spec version:** v0.2.0
**Status:** draft v0.2.0. Layer 1 (`envelope-v0`) is stable to build
against; Layer 2 is documented in its current transitional shape,
`memvelope-ir-v0`, while `memory-v0` remains a reserved forward name.
**License:** MIT — see [License](#license) below. This specification and its
reference implementation (the browser converter shipped alongside it) are
released under the MIT License. Anyone may implement, extend, fork, or embed
Memvelope without asking permission.

**v0.2.0 changelog:** corrective release, and the first one that changes output.
ChatGPT's `mapping` is a tree, and v0.1.1 specified linearizing it by
`create_time` across every node. That silently included abandoned branches, so
a reply the user regenerated away was emitted as a turn that was given and
accepted. Additive corruption, not loss. v0.2.0 specifies the active path
(`current_node` back through `parent`) and forbids re-sorting it by timestamp.

Conversations containing a regenerated reply or an edited prompt therefore
produce a **different envelope** under v0.2.0 than under v0.1.1, and the v0.2.0
output is the correct one. Straight-line conversations, which have no branches,
are byte-identical. Claude exports are unaffected. Anyone who converted a
ChatGPT export under v0.1.1 or earlier should reconvert.

**v0.1.1 changelog:** editorial release. The normative text now fully specifies
the reference converter's current behavior, including canonical serialization,
timestamp rendering, ordering, filtering, and provider-specific mappings. There
is no format change: every valid v0.1 envelope is byte-identical under v0.1.1.

---

## Why

Every AI provider's "export my data" button produces the same thing: a pile of
dead bytes. A `conversations.json` you can technically open, but that no other
tool can read, that no other model can use, and that will not survive the next
export-format revision. The conversation happened, the words are real, and yet
the moment you leave the provider, the memory of it does not travel with you.

That is a portability failure, not a technical one. The underlying content —
who said what, and when — is simple. What's missing is a common envelope: one
shape that any export can be converted into, and that any tool can read back
out, without every converter reinventing its own dialect and without every
reader having to special-case every provider.

Memvelope is that envelope. It does one job: turn a provider-specific export
into a small, deterministic, lossless-prose JSON document that anyone can
parse with a JSON library and no special knowledge of ChatGPT's mapping-tree
or Claude's content-block shape.

It intentionally does **not** try to be a memory *database*, a fact store, or
a knowledge graph. Those are useful next steps built on top — Layer 2 below is
one example — but they all require judgment calls (what's worth keeping, how
to phrase it, whether two things are the same fact). Layer 1 requires none.
That separation is the whole design.

---

## The two layers

Memvelope defines two layers deliberately kept apart, because they have
different guarantees:

- **Layer 1 — Envelope** (`envelope-v0`): a deterministic, lossless
  archive of the prose in a conversation export. No inference, no judgment,
  no model calls. The envelope is a **pure function of the export data**:
  re-running a converter on the same export yields a byte-for-byte-identical
  envelope, and two independent implementations targeting the same spec
  revision yield the same envelope bytes when they use the canonical
  serialization profile below. This is the layer any converter targets.

- **Layer 2 — Facts IR** (`memvelope-ir-v0`, with `memory-v0` reserved as
  the successor name): a distilled set of durable, subject-tagged facts
  extracted *from* an envelope. This layer is typically produced by an LLM
  distillation pass over Layer 1 data, and it is therefore
  **non-deterministic** — different models, prompts, or runs will produce
  different facts. Layer 2 is a useful, common output shape for that kind of
  work, but it carries no losslessness guarantee the way Layer 1 does.

Put simply: Layer 1 is what a converter promises. Layer 2 is what a
distillation pipeline produces *from* a Layer 1 envelope, and its quality is
only as good as the model doing the distilling.

---

## Schemas

### Layer 1 — Envelope (`envelope-v0`)

An envelope is a single JSON document (UTF-8, one envelope per export):

```json
{
  "memvelope": "envelope-v0",
  "meta": {
    "source_provider": "chatgpt",
    "conversation_count": 312,
    "message_count": 9401
  },
  "conversations": [
    {
      "id": "6702b1c4-...-a91f",
      "title": "Planning the Q3 roadmap",
      "created_at": "2026-05-02T14:31:09.000Z",
      "updated_at": "2026-05-02T15:02:44.000Z",
      "messages": [
        {
          "id": "m1",
          "role": "user",
          "ts": "2026-05-02T14:31:09.000Z",
          "text": "Can you help me think through the Q3 roadmap?"
        },
        {
          "id": "m2",
          "role": "assistant",
          "ts": "2026-05-02T14:31:22.000Z",
          "text": "Sure — what are the top three things competing for attention?"
        }
      ]
    }
  ]
}
```

**`meta` fields:**

| Field | Type | Notes |
|---|---|---|
| `source_provider` | string | Lowercase registered token identifying the source provider. Registered values in this revision: `"chatgpt"`, `"claude"`. A converter MUST emit the registered token exactly. If a converter cannot identify the provider, it MUST fail with an error rather than guess or emit an unregistered token. |
| `conversation_count` | integer | `conversations.length`. |
| `message_count` | integer | Total messages across all conversations. |
| `source_export_date` | ISO 8601 date (`YYYY-MM-DD`) or datetime string | *Optional.* When the *provider* generated the export — the export's stated date rendered in ISO 8601, using the date-only form when the export states only a date and the full datetime form when it states an instant. Included **only** when the export itself states a date, and omitted (not `null`) when it doesn't. Current ChatGPT/Claude exports don't carry one, so the reference converter omits it. |

`meta` carries only values derived from the export itself, so the envelope stays
a pure function of its input. There is deliberately **no** `converted_at` (a
wall-clock time would differ between two runs of the same converter) and **no**
`converter` tool-id (it would differ between two independent implementations) —
either would break the byte-for-byte conformance guarantee. A conforming
converter emits exactly the fields this spec defines and no others. A tool that
needs to record when, or with what, it imported a file keeps that in its own
metadata, outside the converter's conformance-checked output. A later annotator
MAY add extra fields and readers MUST tolerate them, but an annotated file is no
longer the byte-exact output tested by the golden fixtures.

**`conversations[]` fields:**

| Field | Type | Notes |
|---|---|---|
| `id` | string \| `null` | The provider's conversation identifier, copied verbatim; `null` when the export doesn't provide one. A converter MUST NOT synthesize an id — an invented id would differ between implementations and break byte-for-byte conformance. Ids SHOULD be unique within an envelope; consumers MUST tolerate `null` and duplicate values. |
| `title` | string | Best-effort title, trimmed. Empty string if the provider gives none. Provider-specific precedence is defined below. |
| `created_at` | ISO 8601 string \| `null` | The export's conversation-level creation time when the export carries one and it renders to a valid timestamp. When it doesn't, or when its value renders `null`, use the `ts` of the first message in final envelope order (`messages[0].ts`). This specific derivation is allowed because it uses only source timestamps and is deterministic. `null` only when the export has no usable conversation-level creation time and the first message's `ts` is `null`. |
| `updated_at` | ISO 8601 string \| `null` | Conversation-level last-update time from the source, converted to the timestamp profile below. No fallback: the source's value, or `null`. |
| `messages` | array | Provider-linearized order, defined per source shape — not a generic "sort by timestamp." Tree-shaped exports (ChatGPT `mapping`) are linearized along the **active path**: begin at `current_node` and follow `parent` links to the root, then reverse. Nodes off that path are abandoned branches (a regenerated reply, an edited prompt) and MUST be excluded — including them would add turns the conversation never contained. Path order is authoritative and MUST NOT be re-sorted by timestamp, because real exports contain child-before-parent `create_time` inversions. A converter falls back to all `mapping` values linearized by ascending numeric `create_time` only when `current_node` is absent, does not resolve to an own property of `mapping`, or yields no renderable message; in that fallback, nodes whose `create_time` is absent or non-numeric are placed after all dated nodes, and ties and undated nodes keep their order of appearance in the source document. Already-linear exports (Claude `chat_messages`) preserve source order verbatim, even if source order disagrees with timestamp order. |

**`messages[]` fields:**

| Field | Type | Notes |
|---|---|---|
| `id` | string | Conversation-positional identifier (`m1`, `m2`, …), 1-indexed over the messages of its own conversation in final envelope order; numbering restarts at `m1` in each conversation. Deterministic and stable *within a given envelope*. Any message-level citation must be read relative to the envelope it was distilled from; `memvelope-ir-v0` cites only conversation ids, and message-level citations are reserved for the successor memory format. |
| `role` | `"user"` \| `"assistant"` | Normalized by the role-mapping rule below. Any other participant (system prompts, tool calls, tool results, hidden scaffolding) is dropped, not coerced. |
| `ts` | ISO 8601 string \| `null` | Per-message timestamp, preserving the source instant but normalized to the timestamp profile below. `null` if the provider didn't record one or the value is unparseable. |
| `text` | string | The message's prose. Never empty (empty messages are dropped during conversion, not emitted as `""`). |

**Presence rule.** Every field in the three tables above is always present in a
conforming envelope; fields typed `... | null` carry an explicit `null` when the
value is unknown. A converter never omits the key instead. The single exception
is `meta.source_export_date`, which is omitted entirely, never `null`, when the
export states no date.

**Rules:**

1. **Prose only.** Tool calls, tool results, function-call payloads, hidden
   system scaffolding, and model "thinking"/reasoning traces MUST be excluded.
   Envelope-v0 is a record of what people and models *said to each other*, not
   a trace of what the software did.
2. **Role mapping.** A converter normalizes the source's participant token by
   lowercasing it and applying this table: `user`, `human` -> `user`;
   `assistant`, `ai`, `model` -> `assistant`. Any other value — `system`,
   `tool`, `developer`, a non-string sender, or anything unrecognized — causes
   the message to be dropped, never coerced.
3. **Original timestamps preserved as instants.** A converter MUST NOT invent,
   backfill, or "fix" a timestamp except for the one `created_at` derivation
   allowed in the table above. If the source has no timestamp for a message,
   `ts` is `null`. Converting a unix epoch to ISO 8601 is not "changing" the
   timestamp — it is the same instant in a normalized format.
   Accepted source timestamp values are exactly: a finite number; a
   bare-numeric string spelling that number; a date-only string `YYYY-MM-DD`;
   or a datetime string matching
   `YYYY-MM-DD[T or SPACE]HH:mm[:ss[.fraction]][Z|[+-]hh:mm|[+-]hhmm]`.
   Uppercase `Z` is the only `Z` designator. A datetime string that carries no
   accepted timezone designator MUST be interpreted as UTC. The source's true
   offset is unknowable, and UTC is the only machine-independent choice — so
   every implementation lands on the same instant, and the envelope stays
   byte-for-byte reproducible regardless of the converting machine's local
   timezone. Anything else renders `null`: a converter MUST NOT delegate
   unrecognized strings to a host date parser.
   A non-null `ts`, `created_at`, or `updated_at` MUST be rendered exactly as
   `YYYY-MM-DDTHH:mm:ss.sssZ`: always UTC, always the `Z` designator, always
   exactly three fractional-second digits. Fractional seconds beyond
   milliseconds are truncated, never rounded. A date-only source value
   (`YYYY-MM-DD`) is midnight UTC of that date. A numeric source timestamp, or a
   bare-numeric string when used as a timestamp value, is interpreted as unix
   seconds when the value is numerically less than `10^12`, and unix
   milliseconds otherwise. Unix seconds-to-milliseconds conversion MUST use
   IEEE-754 double multiplication and then truncate to integer milliseconds.
   Invalid or unparseable source timestamps yield
   `null`, never an error and never a guess. Any source timestamp whose
   resulting UTC instant is outside years `0001` through `9999` yields `null`;
   a converter MUST NOT emit expanded-year ISO strings such as `+010000-...`.
4. **No inference, no mutation of text beyond extraction.** The `text` field is
   the message's actual prose after the trim-and-join extraction rules below. A
   converter MUST NOT summarize, translate, correct, or otherwise alter it.
5. **Unknown provider fields are dropped, not guessed.** If a provider export
   has fields this spec doesn't define (custom metadata, feature flags,
   attachments, etc.), a converter MUST drop them rather than inventing a place
   for them in the envelope. Silence is safer than a wrong guess.
6. **Readers ignore fields they don't recognize.** A consumer of an envelope
   MUST NOT fail on an unknown field, at any level. This is what makes the
   format evolve additively (see Versioning): a v0.x file may carry fields an
   older reader has never seen, and that reader MUST skip them and keep
   working.
7. **UTF-8.** The envelope MUST be UTF-8 JSON, full stop.
8. **One envelope per export.** A single export, however many source files it
   was split across, produces exactly one envelope document. The ordered
   sequence of source files supplied to the converter is part of the input: the
   reference browser path preserves the file-list order it receives, explicit
   CLI file arguments preserve argv order, and CLI folder expansion sorts by
   filename before conversion. The parts' top-level arrays are concatenated in
   that order, and `conversations[]` preserves the concatenated order verbatim.
   A converter MUST NOT re-sort conversations by `created_at` or anything else.
   A multi-file export part whose top level is not an array contributes zero
   conversations and is silently ignored. Merging never deduplicates: if the
   same conversation appears twice, it appears twice in the envelope and the
   counts include both occurrences.
9. **No splitting or merging of source conversations.** A converter MUST NOT
   split one source conversation into multiple `conversations[]` entries or
   merge several source conversations into one, however long the conversation.
   A source conversation that yields zero envelope-eligible messages after the
   role and prose filters, or an element that cannot be parsed as a conversation
   at all, is omitted from `conversations[]` entirely. A converter MUST NOT emit
   a conversation with an empty `messages` array, and a malformed element MUST
   NOT abort conversion of the rest of the export. `meta.conversation_count` and
   `meta.message_count` count what the envelope contains after filtering.

**Prose extraction (normative).** A message's `text` is produced from the
provider's raw message as follows. The trim-and-join below is the only
permitted mutation; interior whitespace is preserved verbatim. In this
specification, "trimmed" means ECMAScript `TrimString`: TAB, VT, FF, SPACE,
NBSP, ZWNBSP (`U+FEFF`), Unicode `Zs`, LF, CR, LS, and PS are removed from the
ends. This differs from Python `str.strip()`, which strips `U+001C` through
`U+001F` but not `U+FEFF`.

- General rule: each retained prose block is trimmed of leading and trailing
  whitespace; blocks that are empty after trimming are discarded; the survivors
  are joined with exactly one blank line (`"\n\n"`). A message whose result is
  the empty string is dropped.
- Claude: prose blocks are `content[]` entries with `type: "text"` and a string
  `text` value. `tool_use`, `tool_result`, `thinking`, and all other block
  types are dropped. If no prose block survives, fall back to the flat top-level
  `text` field, trimmed, unless it contains the literal placeholder substring
  `not supported on your current device`; a flat text containing that substring
  is discarded entirely.
- ChatGPT: a mapping node whose
  `message.metadata.is_visually_hidden_from_conversation` is `true` is dropped
  before extraction. Otherwise, `content.content_type: "user_editable_context"`
  is rendered from `user_profile` and `user_instructions`, each trimmed and
  joined with `"\n\n"` when both are present. For `text` and `multimodal_text`,
  prose blocks are string `content.parts[]` entries plus object parts with a
  string `text` property. A ChatGPT content object with an absent or
  empty-string `content_type` is prose-eligible and uses the same `parts`
  extraction. Only a present, non-prose `content_type` is dropped. Object parts
  without text are dropped.

**Provider conversion tables (normative).** The envelope's determinism guarantee
extends to the provider mappings below. A converter for these providers MUST
apply exactly these mappings; "best-effort" means this precedence, not
implementer judgment.

- Provider detection: a conversation object looks like Claude iff it has a
  string `uuid` and an array `chat_messages`. It looks like ChatGPT iff it has a
  `mapping` property that is a non-null, non-array object. After merging all
  input files into one array, examine the first five elements. If any looks like
  Claude, the provider is `"claude"`; otherwise, if any looks like ChatGPT, the
  provider is `"chatgpt"`; otherwise conversion MUST fail with an error and no
  envelope is produced. One export has one provider; every conversation in the
  merged array is normalized with the detected provider's mapping.
- Claude conversations: `id` is `uuid` when it is a non-empty string, else
  `null`. `title` is trimmed `name`, else trimmed `summary`, else `""`; here
  "else" means the candidate is missing, non-string, or trims to empty.
  Conversation timestamps
  are `created_at` and `updated_at`, with the `created_at` fallback defined in
  the `conversations[]` table. Messages are read from `chat_messages` in source
  order. Message role is `sender` through the role mapping table. Message
  timestamp is `created_at`.
- ChatGPT conversations: enumerate every node of `mapping` in ECMAScript
  own-property order: integer-like keys first in ascending numeric order, then
  all remaining keys in insertion order, which for parsed JSON is the order
  they appeared in the document. This is what `JSON.parse` plus `Object.values`
  yields in JavaScript; a non-JavaScript implementation MUST emulate that order
  for this step. Skip nodes with no `message` object and skip visually hidden
  messages as described above. Message role is `message.author.role` through
  the role mapping table. Message text follows the prose-extraction rules.
  Message timestamp is `message.create_time`, falling back to the node's own
  `create_time` when `message.create_time` is absent or `null`. Sort surviving
  messages by that resolved `create_time` only when it is a number; missing
  values, non-numeric values, and numeric strings sort after all numeric
  timestamps even when the timestamp renderer can parse them. Ties and undated
  nodes preserve the ECMAScript own-property order above. Conversation
  timestamps are `create_time` and `update_time`, with the `created_at` fallback
  defined in the `conversations[]` table. Conversation `id` is
  `conversation_id` when it is a non-empty string, else `id` when it is a
  non-empty string, else `null`. A truthy non-string identifier still renders
  `null`; ids are copied verbatim only after passing the non-empty-string test.
  `title` is trimmed `title`, else `""`; here "else" means the candidate is
  missing, non-string, or trims to empty.

**Canonical serialization (normative).** Byte-exact conformance uses the
reference JavaScript JSON profile:

- UTF-8 JSON text, serialized with two-space indentation.
- No trailing newline after the closing `}`.
- Non-ASCII characters are emitted as raw UTF-8, not `\uXXXX` escapes unless
  JSON string escaping requires it.
- Strings escape exactly `"`, `\`, backspace, tab, line feed, form feed, and
  carriage return as `\"`, `\\`, `\b`, `\t`, `\n`, `\f`, and `\r`; other C0
  controls are escaped as `\u00XX` with lowercase hexadecimal. Unpaired
  surrogates are escaped as `\udXXX` with lowercase hexadecimal, matching
  ES2019 well-formed `JSON.stringify`; paired surrogates emit raw UTF-8.
  Everything else emits raw UTF-8 and is never `\uXXXX`-escaped.
- Object keys appear in the order the tables above list them. For Layer 1:
  top level `memvelope`, `meta`, `conversations`; `meta.source_provider`,
  optional `meta.source_export_date`, `meta.conversation_count`,
  `meta.message_count`; conversation `id`, `title`, `created_at`, `updated_at`,
  `messages`; message `id`, `role`, `ts`, `text`.
  Annotated files may carry extra keys, but a converter's conformance-checked
  output does not.

This is the profile emitted by `JSON.stringify(value, null, 2)` over objects
constructed in the field order above. A non-JavaScript implementation conforms
by producing the same bytes, not by choosing its platform's default serializer.

**Machine-checkable.** The normative shape above is published as a JSON Schema
(draft-07 — the widest-supported dialect) at
[`/schema/envelope-v0.schema.json`](/schema/envelope-v0.schema.json), and golden
conformance fixtures (input export → expected envelope) live in
[`fixtures/`](https://github.com/memvelope/memvelope/tree/main/fixtures) — including a
split-file case that proves several source files merge into one envelope.
Because the envelope is a pure function of the export, conformance is an exact
diff with **no excluded fields**: run an export through a converter and compare
its canonical serialized output against the expected envelope for that fixture
set's spec revision. A conforming converter reproduces the expected envelope
byte for byte under the canonical serialization profile above. The reference
harness (`node fixtures/run.mjs`) checks the full JSON value with no excluded
fields and also asserts that converting the same input twice is byte-for-byte
identical. Golden files contain the envelope bytes exactly — no trailing
newline — so conformance may be checked as a raw byte diff. A downloadable
[`sample.mve.json`](/sample.mve.json) shows a complete, valid envelope.

### Layer 2 — Facts IR (`memvelope-ir-v0`; `memory-v0` reserved)

The current Layer-2 shape is the transitional Facts IR document
(`memvelope-ir-v0`) distilled *from* an envelope — a separate file (`.mv.json`),
never embedded inside one. The reference distillation pipeline (`distill_v1.py`,
memvelope bake-off, 2026-06-29) currently emits the shape documented below.
`memory-v0` is a **reserved forward name**: this revision of the spec does not
define its shape, and no document should carry the tag `"memory-v0"` until a
spec revision defines it. A distiller built against this revision MUST emit
`memvelope-ir-v0` exactly as documented below. The ir-to-memory field mapping
will be published, as a normative table, in the revision that defines
`memory-v0`; until then, "map mechanically" is a design intention, not a
conformance target.

Unlike the envelope, Layer 2 cannot promise losslessness — it is a *reading*,
not a record — so the standard holds it to **provenance** instead: every fact
carries who it is about (`subject`), the date the fact is dated to where it has
one (`effective_date`, which prefers a date the source text states and otherwise
falls back to the source conversation's date, never the day the fact was
extracted), the in-text date itself where the text stated one (`in_text_date`,
so a reader can tell a date read out of the text from a fallback to conversation
metadata), and a citation back to the source conversation (`convo_id`, matching
an envelope conversation's `id`).
`memvelope-ir-v0` cites at **conversation granularity only**. Message-level
citations (`conversation.id` + `message.id`) plus an envelope identifier are the
planned strengthening in the `memory-v0` revision; they matter because message
positions (`m1`, `m2`, ...) are stable only within a given envelope, so a
message-level citation must always travel with the identity of the envelope it
indexes into.

```json
{
  "meta": {
    "provider": "claude",
    "mode": "structured (zero-LLM)",
    "facts": 5,
    "generated_at": "2026-06-30T17:28:47.559955Z",
    "format": "memvelope-ir-v0"
  },
  "archetype": {
    "style": "reflective (deep self-knowledge)",
    "facts": 5,
    "self_ratio": 0.6,
    "distinct_entities": 2,
    "categories": {
      "career": 2,
      "preferences": 1,
      "identity": 1,
      "relationship": 1
    },
    "belief_ratio": 0.4
  },
  "facts": [
    {
      "subject": "self",
      "subject_type": "self",
      "category": "career",
      "content": "Sold their company before moving into AI",
      "effective_date": "2019",
      "in_text_date": "2019",
      "provenance": "imported:claude-export:2026-06",
      "convo_id": "6702b1c4-...-a91f"
    }
  ]
}
```

**`meta` fields:**

| Field | Type | Notes |
|---|---|---|
| `provider` | string | Source provider (matches the envelope's `source_provider`). |
| `mode` | string | Free-form description of how the facts were produced (e.g. `"structured (zero-LLM)"`, or a model name/pipeline id when an LLM did the distilling). |
| `facts` | integer | `facts.length`, after dedup. |
| `generated_at` | ISO 8601 string | When this IR document was produced. |
| `format` | string | Always `"memvelope-ir-v0"`. |

**`archetype` fields** — a coarse read on the shape of the source material,
computed deterministically from the facts list (not model output):

| Field | Type | Notes |
|---|---|---|
| `style` | string \| `null` | One of `"reflective (deep self-knowledge)"`, `"task/entity-heavy"`, `"mixed/casual"` — a heuristic classification. |
| `facts` | integer | Same as `meta.facts`. |
| `self_ratio` | number (0–1) \| `null` | Fraction of facts whose subject is the user themself. |
| `distinct_entities` | integer \| `null` | Count of distinct non-self subjects (people, companies, projects). |
| `categories` | object \| `null` | Category name → count. |
| `belief_ratio` | number (0–1) \| `null` | Fraction of facts in `preferences` or `identity` categories — a proxy for how much of the material is standing beliefs/identity vs. transient task chatter. |

**`facts[]` fields:**

| Field | Type | Notes |
|---|---|---|
| `subject` | string \| `null` | Who/what the fact is about — `"self"` or an entity name (e.g. `"Ryan Foutty"`). |
| `subject_type` | `"self"` \| `"person"` \| `"company"` \| `"project"` \| `"other"` \| `null` | |
| `category` | `"identity"` \| `"career"` \| `"projects"` \| `"preferences"` \| `"instructions"` \| `"relationship"` \| `null` | |
| `content` | string, ≤400 chars | The fact itself, concise. |
| `effective_date` | `YYYY` \| `YYYY-MM` \| `YYYY-MM-DD` \| `null` | The date the fact is dated to — prefers an in-text date, falls back to the source conversation's date. |
| `in_text_date` | same shape \| `null` | A date the source text itself stated, if any. Distinct from `effective_date` so a reader can tell "the model read a date in the text" from "we fell back to conversation metadata." |
| `provenance` | string | `"imported:<provider>-export:<YYYY-MM>"` — traces the fact back to which export and which month that export's source conversation happened in. |
| `convo_id` | string \| `null` | The source conversation's id (matches an envelope conversation's `id` when the IR was produced from an envelope). `null` when the fact has no citable conversation — it was not distilled from one, or the envelope's own conversation `id` was `null`, which `envelope-v0` permits and which a converter must not paper over by synthesizing an id. |

**Timestamp rendering.** `meta.generated_at` MUST carry an explicit UTC offset —
`Z` or `±HH:MM` — and SHOULD be rendered in UTC with the `Z` designator:
`YYYY-MM-DDTHH:mm:ss[.fff…]Z`. A naive local timestamp is **not conforming**,
because it names an instant only to a reader who already knows which machine
produced the file, and a portability format cannot assume that reader.

The MUST and the SHOULD are doing different jobs, and the split is deliberate.
Offset-mandatory is the *semantic* requirement: `2026-06-30T17:28:47+05:30` names
exactly one instant, so nothing is lost by accepting it. Rendering as `Z` is
*normalisation* — it makes two documents for the same instant compare equal as
strings. Layer 1 can demand that because an envelope is a pure function of its
export and byte-stability is a conformance guarantee there. Layer 2 has no such
guarantee, which is the same reason fractional precision is left free below.

Layer 2 deliberately does **not** inherit Layer 1's fixed three-fractional-digit
profile. Fractional seconds are optional and of free precision: Layer 2 carries no
byte-for-byte conformance guarantee for a fixed rendering to protect — two
conforming distillers may legitimately differ — and the worked example above
carries six fractional digits. A producer MUST NOT round fractional seconds;
truncate, or omit them entirely. *(Not machine-checkable: a rendered `.559` is
identical whether it was truncated from `.5594` or rounded from `.5589`, so
conformance here can only be established from the producer's source value, never
from the document. A validator will not catch a violation.)*

Note for distiller implementers: Python's `datetime.now().isoformat()` and
`datetime.utcnow().isoformat()` both return a naive string with **no offset** and
are therefore non-conforming. Use `datetime.now(timezone.utc)`, rendering the
offset as `Z` rather than `+00:00`.

**Presence rule.** Every field in the three tables above is always present in a
conforming Facts IR document; a value MAY be `null` where the producer genuinely
lacks the information, and a producer MUST NOT synthesize a value to fill a gap.
A producer never omits the key instead. *(The presence half is machine-checkable
and is enforced by the schema's `required` arrays. The no-synthesis half is not:
a fabricated `subject` is byte-identical to a real one, so no validator can
distinguish them. This is the clause the rule's honesty rests on, and it rests on
the producer alone.)* The fields a producer may genuinely lack
are `convo_id`, `subject`, `subject_type`, `category`, and `archetype`'s five
derived values (`style`, `self_ratio`, `distinct_entities`, `categories`,
`belief_ratio`) — the last five because all of them are computed from `subject`
and `category`, so a producer that must `null` those cannot compute these.
`content`, `provenance`, `archetype.facts` and every `meta` field are always
knowable to a producer and are never `null`.

The rule exists because an absent key and an explicit `null` are
indistinguishable to a consumer. Without it, "this producer does not track
subjects" reads exactly like "this fact has no subject", and a `0` written into
`self_ratio` to fill the gap would assert something about the source material
rather than admitting something about the producer.

**Machine-checkable.** The normative shape above is published as a JSON Schema
(draft-07 — the same dialect as Layer 1) at
[`/schema/memvelope-ir-v0.schema.json`](/schema/memvelope-ir-v0.schema.json).
Unlike Layer 1, conformance here is **not** an exact diff: distillation is
non-deterministic by design, so two conforming distillers given the same envelope
may legitimately emit different facts. What is checkable is that a document is
well-formed, and a zero-dependency checker plus a fixture corpus live in
[`fixtures/ir/`](https://github.com/memvelope/memvelope/tree/main/fixtures/ir).

**Layer 2 is typically produced by an LLM distillation pass** — a model reads
chunks of conversation and proposes subject-tagged facts (see the reference
prompt in `distill_v1.py`). That means Layer 2 output is **non-deterministic**:
different models, different prompts, or even different runs of the same
model/prompt can produce different facts, different subject tags, or different
phrasing from the same input. This is expected and fine — Layer 2 is a
*distillation*, not an archive. Anything that needs a deterministic, lossless
record should read Layer 1, not Layer 2.

**Layer 1 requires no inference at all.** Producing an envelope is pure
data transformation: parse the provider's export shape, normalize roles and
timestamps, drop non-prose blocks, done. No model call is needed or expected
in the conversion path from provider export → envelope-v0.

---

## Conformance

- A **converter** (export → Layer 1) MUST produce valid `envelope-v0` JSON
  conforming to the schema above: correct top-level shape, `role` restricted
  to `user`/`assistant`, `ts` either a valid ISO 8601 string or `null`, no
  tool/system/thinking content leaking into `text`.
- Schema validity is a floor, not the bar. The schema is deliberately open
  (`additionalProperties: true`) so readers stay tolerant, which means schema
  validation alone cannot catch a converter that invents extra fields. The
  producer bar is the exact conformance defined under **Machine-checkable** and
  **Canonical serialization** above: a conforming converter reproduces the
  expected envelope exactly, no extra fields, no missing fields.
- A converter MUST treat an export that yields **zero conversations** as a
  conversion error, not an empty envelope. This covers both an input with no
  conversations at all, where the provider cannot be detected, and a recognized
  export whose conversations were all filtered out as empty, non-prose, or
  malformed. A converter never emits `conversation_count: 0`. The schema
  nevertheless permits zero counts so hand-authored or programmatically built
  envelopes can remain valid documents; converters simply never produce them.
- A converter MUST NOT transmit source data over the network. Export data is
  sensitive, and conversion is a local transform: a converter that sends export
  content to any remote endpoint for conversion, including one operated by the
  converter's own author, does not conform. This requirement is outside the
  fixture harness because it has no trace in the output envelope; verify it by
  inspection, or by running the converter with network access disabled and
  confirming conversion still succeeds.
- A **reader** (consumer of either layer) MUST NOT fail on unknown fields at any
  level; MUST identify a file's layer and version from its in-document tags,
  never from its filename; MUST NOT reject a file for using or omitting the
  compound extension; and MUST check the version tag before assuming a shape.
- A **distiller** (Layer 1 → Layer 2) SHOULD preserve dates sourced from
  actual message/conversation timestamps (`ts` / `created_at` in the
  envelope) rather than dates invented or "recalled" by the distilling model.
  A distiller SHOULD populate `in_text_date` only when the source text itself
  states a date, and SHOULD fall back to conversation-level dating for
  `effective_date` when no in-text date exists. At minimum, a distiller MUST
  NOT populate `effective_date` with a date that appears neither in the source
  text nor in the envelope's timestamps (`ts` / `created_at`); a date the model
  recalled or inferred from world knowledge is not a valid `effective_date`.
  *(Not machine-checkable from the Facts IR alone: deciding whether a date was
  sourced requires the originating envelope, which the document does not carry.
  Checking it means diffing an IR document against the envelope it came from.)*
  When no in-text date exists and no source timestamp is available,
  `effective_date` is `null`.
- Neither layer requires a particular programming language or runtime. The
  reference converter is a zero-dependency browser script; the reference
  distiller is a Python script calling an external model API. Any
  implementation that produces conforming JSON satisfies the spec.

---

## File naming

Memvelope files **are JSON**. A file is identified as Memvelope from the
inside, NOT by its extension: a Layer-1 envelope by its top-level `memvelope`
field (`"envelope-v0"`), and the current Layer-2 document by its `meta.format`
field (`"memvelope-ir-v0"`). `memory-v0` is a reserved forward tag and does not
identify any document under this revision. Any conforming file MAY use the plain
`.json` extension. A reader MUST identify a file's layer and version from these
in-document tags, never from the filename — checking, in order, the top-level
`memvelope` field, then `meta.format` for `"memvelope-ir-v0"`.

As a **recommended convention** (not a requirement), producers SHOULD name
files with a compound extension that records the layer while remaining ordinary
JSON to every tool and operating system:

| Layer | Recommended name | Is really |
|---|---|---|
| Envelope | `something.mve.json` | a `.json` file |
| Facts IR | `something.mv.json` | a `.json` file |

This mirrors established practice (`package.json`, `tsconfig.json`,
`docker-compose.yml`): the leading token is a human-readable label, not a
registered file type. `.mve` and `.mv` are conveniences for people scanning a
folder — "envelope vs memory at a glance" — and carry no normative weight. A
consumer MUST NOT reject a file for using, or omitting, the compound name.

---

## Versioning policy

- **Document version vs format tags.** The "Spec version" in this document's
  header (v0.1.1, v0.2, ...) tracks revisions of this prose, the JSON Schema,
  and the fixtures; the format tags (`envelope-v0`, `memvelope-ir-v0`) name the
  wire shapes files actually carry and change only on breaking changes. Files
  declare format tags only; they never carry the spec document version.
- The version tags are: the top-level `memvelope` field in an envelope
  (`"envelope-v0"`), the `meta.format` field in the transitional Facts IR
  document (`"memvelope-ir-v0"`), and — reserved for the successor memory
  document — a top-level `memvelope` field with value `"memory-v0"`. Readers
  MUST check these before assuming a particular shape. A reader that encounters
  a tag it does not support MUST NOT silently parse the file as if it were a
  version it knows: it either refuses with an error naming the found and
  supported tags, or clearly reports best-effort degradation. A missing or
  non-string tag means the file is not a Memvelope document.
- **v0** is a 0.x series: breaking changes are possible before v1, but will be
  called out with a new version tag (`envelope-v1`, `memvelope-ir-v1`, ...)
  rather than silently changing v0's meaning. Old v0 documents remain valid
  v0 documents forever.
- Additive, backward-compatible changes (new optional fields) may be added to
  this specification within the v0 series without a version bump — additivity
  is a spec-revision right, not a license for individual producers to add ad-hoc
  fields. Anything that changes the meaning or requiredness of an existing
  field gets a new version tag.
- Changing conversation partitioning or message-id numbering changes the
  meaning of `id` and is therefore a breaking change: it requires a new version
  tag (`envelope-v1`), never an additive revision of `envelope-v0`.
- Layer 1 and Layer 2 version independently. A Layer-2 tag bump
  (`memvelope-ir-v1`) neither requires nor implies a Layer-1 bump: an
  `envelope-v0` document remains a valid input to any future Layer-2 distiller.
  The reverse dependency does not exist — no field in a Layer-2 document carries
  or constrains an envelope version; the trace back to the source material is
  provenance (`provenance`, `convo_id`), not a version reference.

---

## Durability

*Non-normative. This section adds no new requirements — it consolidates
commitments made elsewhere in this document so they travel with any copy or fork
of this specification, rather than with any website or steward.*

- **Files self-identify.** Every document names its format and version in an
  in-document tag: top-level `memvelope` for envelopes, or `meta.format` for the
  transitional Facts IR. Old v0 documents remain valid v0 documents forever.
- **Evolution is additive.** Readers ignore unknown fields; anything that
  changes the meaning or requiredness of an existing field gets a new version
  tag, which new files declare. No file rots into ambiguity.
- **The floor is plain JSON.** An envelope is UTF-8 JSON — legible to a text
  editor, `jq`, or any JSON library in any language, with no runtime, server,
  registry, or vendor required to read it.
- **Anyone may continue the standard.** The specification, schema, fixtures,
  and reference converter are MIT-licensed. If every steward of this project
  vanished, anyone may republish, continue, or fork the standard without
  permission.

A standard should fail gracefully. This one is designed to.

---

## License

Memvelope — this specification, the JSON Schemas, the conformance fixtures, and
the reference converter — is released under the MIT License. The exact license
text lives in [`LICENSE`](LICENSE) (© 2026 Indistinct) and is deliberately not
paraphrased here: one text, no divergence. For the avoidance of doubt, "the
Software" in that license includes this specification document itself — quote
it, fork it, republish it.

No royalties, no attribution requirement beyond the standard MIT notice, no
field-of-use restriction. Build a converter, a reader, a competing spec fork —
any of it is fine.
