diff --git a/docs/plans/chat-01c/README.md b/docs/plans/chat-01c/README.md
new file mode 100644
index 00000000..1da0a89a
--- /dev/null
+++ b/docs/plans/chat-01c/README.md
@@ -0,0 +1,247 @@
+# CHAT-01C private readback and recovery contracts
+
+R2 draft candidate for independent review, not an implemented API.
+R1 request 26137 received REQUEST CHANGES from Filbert and Dewey via agent-send.
+Both complete verdicts were collected before revision; R1 remains archived at
+`/tmp/chat-01c-r1-frozen-yvsfowd1`.
+
+Jason approved the contract-only proposal in #1507 comment 26131 in the
+control-board conversation and required performing-agent attribution. Darkwing
+authors these four files. Filbert independently reviews; Dewey reviews consumer
+semantics. No earlier CHAT-01 approval applies to this candidate.
+
+Baseline: reviewed CHAT-01 commit `28d4e98ad8b406ca84b30170558bee22402f1e55`.
+Those files remain unchanged. This companion adds private requests/projections;
+it does not widen the base command parser, grants or native capabilities.
+
+## Boundary and attribution
+
+Only this directory's README, schema, fixtures and checker are authored, plus
+scoped shared tracking and append-only logs. No runtime endpoints, engine
+adapters, native sessions, live access, policy changes or deployments. CHAT-02
+and CHAT-03I/03D retain their separate charters and unresolved gates.
+
+Darkwing's seat credentials were verified to authenticate as Gitea `darkwing`,
+account 104. Use that identity for issue actions. Do not use the legacy API
+helper's default Jason credentials. Future commits explicitly use the performing
+agent as author and committer, without changing shared Git defaults. Existing
+published history is not rewritten. No credential value belongs in these files.
+
+## Closed messages and authority
+
+The draft 2020-12 schema defines six closed top-level message variants and five
+closed private item projections. Every property is required. Requests alone use
+`privateClientRequest`; the other variants are server responses, not client input.
+Validate that request definition, not the union of requests and responses, at
+untrusted ingress. Version 1 identifies this new proposal, not a live migration.
+
+IDs are opaque bounded references, never paths or authority. The server resolves
+connection, actor, current grant, approved scope and storage epoch. A client cannot
+post an actor, grant, snapshot contents or private storage reference. Native
+controller ownership is not required for reading one's own data or explicitly
+recovering one's own refused input. Separate read-private/recover-private grants
+are proposed names only; no role file or actual grant is changed here.
+
+Every operation requires connected authenticated ingress, active/unexpired current
+actor grant, approved conversation/scope and the relevant capability. The model's
+scope digest binds the full inherited host/seat/project/workspace/conversation
+scope. These are supplied synthetic facts, not an authentication implementation.
+Browser origin/CSRF, private channel identity, storage provenance and actual grant
+producers remain prerequisite runtime work. Current authority is checked on every
+page, range, preview and recovery, including an idempotent retry.
+
+| Command | Behavior |
+|---|---|
+| read-private-state | Bounded actor-private snapshot page; optional exact opaque cursor |
+| read-upload-range | Exact owned immutable upload revision and verified byte range |
+| preview-upload-text | Bounded complete UTF-8 preview as literal text, never active content |
+| recover-refused-draft | Explicit CAS recovery of own never-dispatched refused input into a private unsent draft |
+
+Refusals use the closed `privateRefusal` reason set. The reference functions return
+symbolic `refused:reason` strings internally; `response()` demonstrates the typed
+wire projection. Refusal is not permission to change source or retry native input.
+There are no hidden writes, engine commands or automatic resume operations.
+
+## Safe private-state restoration
+
+The page includes authenticated actor, conversation, opaque snapshot ID, source
+revision, items and next cursor. Items contain only explicit allowlisted fields:
+
+* Draft: original branch, text, upload IDs, revision, state and recovery lineage.
+* Upload: display filename/media type, total and committed received byte counts,
+ full digest, revision and state.
+* Queue: revision, actual state, causal admission request, original draft, exact
+ target and frozen payload digest. Bounded frozen input includes original branch,
+ draft revision, text and immutable upload IDs/revisions/filenames/sizes/digests.
+* Receipt: causal request, monotonic source revision, actual/uncertain state/reason.
+* Confirmation: exact target, operation, stop context, expiry and display state.
+
+No privateBlobRef, source path, credential, grant or arbitrary internal property is
+serialized. Filenames and media labels remain untrusted display data. This is
+actor-private restoration, not a shared transcript or permission to adopt another
+actor's drafts. Reading any earlier branch never changes the active branch.
+
+Queue and receipt request fields reference the same causal admission. Queue draft
+and target fields preserve the original lineage and branch; never join pending
+input to a newer mutable draft's text. Frozen input resolves by immutable digest,
+not the current mutable queue/draft contents. Missing/corrupt or mismatched retained
+content yields frozenInput null and integrity-unavailable, without inventing text
+or changing actual queue status. No private blob reference is projected. Snapshot
+retention must preserve referenced immutable payloads for valid cursors.
+
+For every snapshot page, resolve each confirmation's CURRENT authoritative record
+by scoped actor/conversation/ID. Overlay current consumed/cancelled/expired outcomes.
+Do not promote an old snapshot to more permissive authority. Missing or changed
+current revision, operation, connection/incarnation, target, expiry or stop context
+makes the pending/confirmed snapshot reconfirm-required. A changed current stop
+invalidates it even when connection and execution target did not change.
+An old connection/incarnation or changed target makes a pending/confirmed
+confirmation display reconfirm-required. Expiry is explicit. Consumed/cancelled/
+expired terminal outcomes are preserved rather than relabelled by reconnect.
+Reading a confirmation never renews it or creates a replacement. The base control
+protocol still validates exact current target/stop context and single use.
+The miniature clock and projected confirmation expiry use UTC epoch milliseconds.
+Production normalization must convert authoritative timestamps without guessing.
+
+### Pagination and snapshot rules
+
+Capture the authorized private records at one atomic cut. Sort by kind and opaque
+ID; the cursor retains the exact immutable snapshot and next offset. A page has
+at most 100 items and 8 MiB serialized UTF-8, whichever is reached first. Reduce
+the number of items to fit; do not truncate text or silently omit records. An
+individual over-budget item refuses projection-size. Field limits keep valid
+individual draft projections below the page ceiling.
+
+A cursor binds actor, full scope digest, conversation, private-state purpose,
+source epoch/revision, expiry, snapshot ID and offset. Only its opaque ID crosses
+the client boundary. Expiry equality is expired. Unknown/foreign/stale cursors
+refuse; never follow an alternate file or substitute a newer snapshot. Validate
+snapshot record ownership again before projection. Expiry affects the cursor,
+not the retained draft or upload. Cursor retention needs bounded storage/quota
+and cleanup in the eventual implementation; none is provided by this model.
+
+Here sourceRevision means replacement of the private store/source identity, not
+every ordinary item edit. Per-item revisions track mutations. An existing snapshot
+retains its old content while mutable source items advance. Current authorization
+and confirmation actionability are rechecked at read time and can become stricter
+than the snapshot; they never inherit an old grant's authority.
+
+After browser/server restart, reconstruct drafts, queue/receipts and confirmations
+from the durable private index. Preserve delivery-unknown; absent native evidence
+never becomes unsent. The JSON round-trip fixture tests reference-state readback,
+not disk durability, transactions or authenticated reconnection. Full persistence,
+source-epoch management, grant revocation and race injection remain runtime gates.
+
+## Attachment access and preview
+
+Resolve only an owned upload in the approved recipient scope. Require exact
+revision and a completed staged/delivered/sent record. Refuse receiving, rejected,
+discarded, foreign or missing uploads. Check stored bytes against the complete
+length and SHA-256 before returning any range. A posted filename, size, media type
+or blob reference is not evidence. Missing/corrupt bytes refuse integrity.
+
+Ranges are explicit offset/length, at most 256 KiB, wholly within a file of at most
+20 MiB. This contract refuses overrun instead of silently clamping. Responses give
+actual offset/length/total, full digest, range digest and canonical base64 bytes.
+They specify attachment disposition and application/octet-stream. Transport must
+use authenticated JSON/binary responses with nosniff; never reflect an uploaded
+media label into active HTML or an executable navigation response. Complete byte
+assembly must match the advertised file digest before being called a download.
+
+Text preview accepts only a complete file of at most 256 KiB that decodes as strict
+UTF-8. It escapes non-display controls, escape characters and directional controls
+into visible Unicode escape notation. HTML/Markdown remains literal text. The
+response explicitly requires textContent-only rendering. It does not authorize
+innerHTML, Markdown execution, terminal escapes, SVG documents or embedded scripts.
+The original bytes/digest remain unchanged; preview is a separate display value.
+Invalid UTF-8 or over-budget content refuses preview without truncating it. Other
+formats remain downloadable; image/document rendering needs the separately reviewed
+CHAT-05 decoder/renderer behavior. The reference checks do not prove browser safety.
+
+### Receiving upload restoration
+
+Upload projections retain authoritative receivedBytes and revision even while
+receiving; ranges/preview still refuse incomplete uploads. After a lost append
+acknowledgment or restart, obtain a FRESH private-state snapshot, not an old cursor's
+progress, before constructing another append. Reselect the original local file
+explicitly and match full byte count and digest, never filename alone. Do not
+persist or guess a browser-local file path. The reference resumeOffset check gives
+the restored revision/offset only after that match. No file, wrong bytes or invalid
+progress means no append; preserve the staging record and let the actor explicitly
+select matching bytes or discard/start a new upload through the base lifecycle.
+
+Construct the existing CHAT-01 append command using that revision and committed
+offset. Its CAS must reject progress that advanced again after readback. If all
+bytes are received but state is still receiving, use explicit completion rather
+than append duplicates; full server digest verification still gates staged state.
+The lost-ack fixture starts with a GIVEN authoritative committed append result,
+then tests readback and local-file matching. It does not prove the append producer,
+disk durability, native delivery or a live browser's file picker.
+
+No extraction, execution, native delivery, workspace staging or remote forwarding
+occurs from readback or preview. These operations expose only already authorized
+private content to its actor, not to the agent engine before Send.
+
+## Recovering refused input without replay
+
+This closes the private recovery path tracked as R3-2 in #1507 comment 26128.
+Eligibility is deliberately narrow: owned item, state dispatch-refused, exact item
+revision and payload digest, and null nativeRequest. Dispatched, acknowledged,
+working, delivery-unknown, finished and recovery-failed items are refused. The
+trusted queue journal must establish that dispatch-refused means no native attempt;
+this model assumes that upstream fact and does not reconstruct it from a timeout.
+
+Verify the retained CHAT-01 frozen payload with its existing canonical digest,
+including complete upload revisions/digests and original branch. Verify current
+owned upload revisions, recipient scopes and actual retained bytes too. A missing,
+changed or corrupt payload/upload leaves the queue and original data intact with
+an integrity refusal. Never fabricate a draft from partial metadata or returned
+native text. The fixture copies a synthetic frozen example from the published
+baseline and computes the same sorted-key, UTF-8 digest excluding its digest field.
+
+One idealized transaction creates a NEW private unsent draft, preserves actor,
+branch, exact text and upload references, records recoveredFrom, advances the queue
+revision and marks recovered-as-draft. Original immutable payloads remain intact.
+No native outbox entry is emitted. The draft still needs explicit Send and normal
+current controller/branch/grant checks under CHAT-01.
+
+Recovery dedup is scoped to authenticated actor, conversation and client request ID,
+encoded as a canonical JSON tuple, with canonical command fingerprint. Delimiter
+concatenation is forbidden: opaque IDs may themselves contain colons. The shared
+ledger fixture tests colliding colon spellings in two independently authorized
+conversations, legitimate distinct recoveries and conflicting reuse. Reconnect/restart retry returns the original
+result without creating another draft. Conflicting reuse refuses. A fresh request
+ID cannot recover an already recovered item. The model persists the idealized
+receipt in its synthetic state; actual multi-record crash atomicity is not proved.
+
+R3-1, reconciliation of dispatched-but-unconsumed native input after fencing,
+belongs to CHAT-02 adapter evidence. It is NOT made recoverable by this companion.
+
+## Evidence and completion gates
+
+Run without installs or network:
+
+```sh
+node --check docs/plans/chat-01c/check.mjs
+node docs/plans/chat-01c/check.mjs
+node docs/plans/chat-01/check.mjs
+node docs/plans/chat-00/check.mjs
+```
+
+The checker uses Node and installed Python jsonschema. Missing validation fails.
+R2 validates eleven closed request/response examples and 187 omission/extra-field
+refusals, plus the reference scenarios. New regressions cover terminal/stop changes
+between confirmation pages and after restart, colon-ID cross-conversation dedup,
+two immutable queue inputs with distinct branches and receipt/draft joins despite
+mutable draft edits, and receiving-offset restoration after a lost append reply.
+It checks closed request/response examples, required-field/extra-field refusals,
+private filtering, cursor actor/scope/purpose/source/expiry, immutable pagination,
+synthetic restart, confirmation restoration, byte ranges/digests, literal preview,
+corrupt upload/payload refusal, immutable recovery, repeated request identity and
+no native replay. It does not implement HTTP, real authentication, storage quotas,
+durable transactions, every record mutation, native adapters or UI rendering.
+
+Independent exact-candidate approval by Filbert and Dewey is required before Q21
+scoped publication. Existing B1-B6, CHAT-02/03I/03D, Q22, live-cutover and all-seat
+acceptance gates remain. #1507 stays open. No new queue item starts because this
+draft exists.
diff --git a/docs/plans/chat-01c/check.mjs b/docs/plans/chat-01c/check.mjs
new file mode 100644
index 00000000..585fbbf2
--- /dev/null
+++ b/docs/plans/chat-01c/check.mjs
@@ -0,0 +1,324 @@
+// Offline CHAT-01C draft model. No authentication or durable storage producer.
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { spawnSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+const sha = bytes => createHash('sha256').update(bytes).digest('hex');
+const sorted = x => Array.isArray(x) ? x.map(sorted) : x && typeof x === 'object' ? Object.fromEntries(Object.keys(x).sort().map(k => [k, sorted(x[k])])) : x;
+const canonical = x => JSON.stringify(sorted(x));
+const frozenDigest = p => sha(Buffer.from(canonical(Object.fromEntries(Object.entries(p).filter(([k]) => k !== 'digest')))));
+const fixtureURL = new URL('./fixtures.json', import.meta.url);
+const schemaURL = new URL('./contracts.schema.json', import.meta.url);
+const f = JSON.parse(readFileSync(fixtureURL, 'utf8'));
+const examples = [f.request];
+assert.equal(f.evidence, 'synthetic-contract-only');
+const py = spawnSync('python3', ['-c', `
+import json,sys
+from jsonschema import Draft202012Validator
+s=json.load(open(sys.argv[1])); f=json.load(open(sys.argv[2]))
+Draft202012Validator.check_schema(s);v=Draft202012Validator(s)
+r=f['request'];assert v.is_valid(r)
+for key in r:
+ x=dict(r);del x[key];assert not v.is_valid(x),key
+assert not v.is_valid(dict(r,actor='forged'))
+print('PASS initial request shape, required fields and forged actor refusal')
+`, schemaURL.pathname, fixtureURL.pathname], { encoding: 'utf8', timeout: 30000 });
+assert.equal(py.status, 0, py.stdout + py.stderr); process.stdout.write(py.stdout);
+function project(x, w, now) {
+ const base = { id: x.id, kind: x.kind, revision: x.revision };
+ switch (x.kind) {
+ case 'draft': return { ...base, branch: x.branch, text: x.text, state: x.state, attachments: [...(x.attachments ?? [])], recoveredFrom: x.recoveredFrom ?? null };
+ case 'upload': return { ...base, filename: x.filename, mediaType: x.mimeType ?? x.mediaType, size: x.size, receivedBytes: x.receivedBytes, digest: x.digest, state: x.state };
+ case 'queue': {
+ const p = w.payloads?.[x.payloadDigest];
+ const valid = p && p.actor === x.actor && p.conversation === x.conversation && p.branch === x.target?.branch && x.target?.conversation === x.conversation && p.draft === x.draft && p.digest === x.payloadDigest && frozenDigest(p) === x.payloadDigest;
+ return { ...base, state: x.state, payloadDigest: x.payloadDigest, reason: x.failureReason ?? null, request: x.request, draft: x.draft, target: structuredClone(x.target), frozenInputState: valid ? 'available' : 'integrity-unavailable', frozenInput: valid ? { branch: p.branch, draftRevision: p.draftRevision, text: p.text, uploads: p.uploads.map(u => ({ id: u.id, revision: u.revision, filename: u.filename, size: u.size, digest: u.digest })) } : null };
+ }
+ case 'receipt': return { ...base, request: x.request, state: x.state, reason: x.reason ?? null };
+ case 'confirmation': {
+ const current = w.confirmations?.find(c => c.id === x.id && c.actor === x.actor && c.conversation === x.conversation);
+ let state = 'reconfirm-required';
+ if (['consumed', 'cancelled', 'expired'].includes(x.state)) state = x.state;
+ else if (current && ['consumed', 'cancelled', 'expired'].includes(current.state)) state = current.state;
+ else if (x.expiresAt <= now || current?.expiresAt <= now) state = 'expired';
+ else if (current && current.revision === x.revision && current.state === x.state && current.operation === x.operation && current.expiresAt === x.expiresAt && current.connection === x.connection && current.connectionGeneration === x.connectionGeneration && current.stop === x.stop && current.stop === w.stop && canonical(current.target) === canonical(x.target) && canonical(x.target) === canonical(w.target) && current.connection === w.connection.id && current.connectionGeneration === w.connection.generation) state = x.state;
+ return { ...base, operation: x.operation, target: structuredClone(x.target), state, expiresAt: x.expiresAt, stop: x.stop };
+ }
+ default: throw new Error('unsupported private projection');
+ }
+}
+function privateItems(w) {
+ return [...w.items, ...(w.uploads ?? []).map(x => ({ ...x, kind: 'upload' })), ...(w.queue ?? []).map(x => ({ ...x, kind: 'queue' })), ...(w.receipts ?? []).map(x => ({ ...x, kind: 'receipt' })), ...(w.confirmations ?? []).map(x => ({ ...x, kind: 'confirmation' }))];
+}
+function readState(w, r, now) {
+ const c = w.connection;
+ const refused = authorize(w, r, 'read-private', now); if (refused) return refused;
+ if (r.command.operation !== 'read-private-state') return 'refused:operation';
+ let snapshot, snapshotId, offset = 0;
+ if (r.command.cursor) {
+ const cursor = w.cursors[r.command.cursor];
+ if (!cursor || !Array.isArray(cursor.snapshot) || !Number.isSafeInteger(cursor.offset) || cursor.offset < 0 || cursor.offset > cursor.snapshot.length || cursor.actor !== c.actor || cursor.conversation !== r.conversation || cursor.purpose !== 'private-state' || cursor.scopeDigest !== w.grant.scopeDigest || !Number.isSafeInteger(cursor.expiresAt) || cursor.expiresAt <= now || cursor.sourceRevision !== w.sourceRevision) return 'refused:cursor';
+ snapshot = cursor.snapshot; snapshotId = cursor.snapshotId; offset = cursor.offset;
+ } else {
+ snapshot = structuredClone(privateItems(w).filter(x => x.actor === c.actor && x.conversation === r.conversation).sort((a, b) => (a.kind + ':' + a.id).localeCompare(b.kind + ':' + b.id, 'en')));
+ snapshotId = `snapshot-${w.snapshotSerial = (w.snapshotSerial ?? 0) + 1}`;
+ }
+ if (snapshot.some(x => x.actor !== c.actor || x.conversation !== r.conversation)) return 'refused:cursor';
+ if (snapshot.some(x => x.kind === 'upload' && (!Number.isSafeInteger(x.receivedBytes) || x.receivedBytes < 0 || x.receivedBytes > x.size))) return 'refused:integrity';
+ const items = []; let byteCount = 4096;
+ for (const x of snapshot.slice(offset, offset + r.command.limit)) {
+ const bytes = Buffer.byteLength(JSON.stringify(project(x, w, now)), 'utf8') + 1;
+ if (byteCount + bytes > 8 * 1024 * 1024) { if (!items.length) return 'refused:projection-size'; break; }
+ items.push(x); byteCount += bytes;
+ }
+ let nextCursor = null;
+ if (offset + items.length < snapshot.length) {
+ nextCursor = `cursor-${Object.keys(w.cursors).length + 1}`;
+ w.cursors[nextCursor] = { actor: c.actor, conversation: r.conversation, purpose: 'private-state', scopeDigest: w.grant.scopeDigest, expiresAt: now + 100, sourceRevision: w.sourceRevision, snapshot, snapshotId, offset: offset + items.length };
+ }
+ // Explicit projection, not arbitrary internal object serialization.
+ return { version: 1, kind: 'privateStatePage', actor: c.actor, conversation: r.conversation, snapshot: snapshotId, sourceRevision: w.sourceRevision, items: items.map(x => project(x, w, now)), nextCursor };
+}
+{
+ const w = structuredClone(f.world), result = readState(w, f.request, f.now);
+ assert.equal(result.items.length, 1); assert.equal(result.items[0].id, 'draft-1');
+ assert.equal('actor' in result.items[0], false); assert.deepEqual(w.nativeOutbox, []);
+ for (const [field, value] of [['state', 'revoked'], ['expiresAt', f.now], ['actor', 'actor-2']]) {
+ const bad = structuredClone(w); bad.grant[field] = value;
+ assert.equal(readState(bad, f.request, f.now), 'refused:grant');
+ }
+}
+{
+ const w = structuredClone(f.world);
+ w.items.push({ ...w.items[0], id: 'draft-3' });
+ const r = structuredClone(f.request); r.command.limit = 1;
+ const first = readState(w, r, f.now); assert.ok(first.nextCursor);
+ r.command.cursor = first.nextCursor;
+ w.items[2].text = 'newer mutable text';
+ assert.equal(readState(w, r, f.now).items[0].text, 'private unsent text');
+ for (const [field, value] of [['actor','actor-2'], ['conversation','foreign'], ['purpose','history'], ['scopeDigest','foreign-scope'], ['expiresAt',f.now], ['sourceRevision',2]]) {
+ const bad = structuredClone(w); bad.cursors[first.nextCursor][field] = value;
+ assert.equal(readState(bad, r, f.now), 'refused:cursor');
+ }
+ const restored = JSON.parse(JSON.stringify(w));
+ assert.deepEqual(readState(restored, r, f.now), readState(w, r, f.now));
+ assert.deepEqual(restored.nativeOutbox, []);
+}
+function authorize(w, r, capability, now) {
+ if (r.kind !== 'privateClientRequest' || r.version !== 1) return 'refused:request';
+ const c = w.connection, g = w.grant;
+ if (r.connection !== c.id || c.state !== 'connected') return 'refused:channel';
+ if (g.actor !== c.actor || g.state !== 'active' || !Number.isSafeInteger(g.expiresAt) || g.expiresAt <= now || !g.capabilities.includes(capability)) return 'refused:grant';
+ if (g.conversation !== r.conversation || !w.mappingVerified || g.scopeDigest !== sha(Buffer.from(canonical(w.scope)))) return 'refused:scope';
+ return null;
+}
+function readRange(w, r, now) {
+ const refused = authorize(w, r, 'read-private', now); if (refused) return refused;
+ const cmd = r.command;
+ if (cmd.operation !== 'read-upload-range') return 'refused:operation';
+ const u = w.uploads.find(u => u.id === cmd.upload);
+ if (!u || u.actor !== w.connection.actor || u.conversation !== r.conversation || u.revision !== cmd.revision || !['staged', 'delivered', 'sent'].includes(u.state) || canonical(u.recipientScope) !== canonical(w.scope)) return 'refused:upload';
+ const bytes = w.blobs[u.id];
+ if (!Buffer.isBuffer(bytes) || bytes.length !== u.size || sha(bytes) !== u.digest) return 'refused:integrity';
+ if (!Number.isSafeInteger(cmd.offset) || !Number.isSafeInteger(cmd.length) || cmd.offset < 0 || cmd.length < 1 || cmd.length > 262144 || cmd.offset >= bytes.length || cmd.offset + cmd.length > bytes.length) return 'refused:range';
+ const part = bytes.subarray(cmd.offset, cmd.offset + cmd.length);
+ return { version: 1, kind: 'uploadRange', upload: u.id, revision: u.revision, offset: cmd.offset, length: part.length, total: bytes.length, digest: u.digest, rangeDigest: sha(part), dataBase64: part.toString('base64'), disposition: 'attachment', mediaType: 'application/octet-stream' };
+}
+function previewText(w, r, now) {
+ const refused = authorize(w, r, 'read-private', now); if (refused) return refused;
+ if (r.command.operation !== 'preview-upload-text') return 'refused:operation';
+ const u = w.uploads.find(u => u.id === r.command.upload);
+ if (!u || u.actor !== w.connection.actor || u.conversation !== r.conversation || u.revision !== r.command.revision || canonical(u.recipientScope) !== canonical(w.scope)) return 'refused:upload';
+ if (u.size > 262144) return 'refused:preview';
+ const range = readRange(w, { ...r, command: { operation: 'read-upload-range', upload: u.id, revision: r.command.revision, offset: 0, length: u.size } }, now);
+ if (typeof range === 'string') return range;
+ let text;
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from(range.dataBase64, 'base64')); }
+ catch { return 'refused:preview'; }
+ text = text.replace(/[\u0000-\u0008\u000b-\u001f\u007f\u202a-\u202e\u2066-\u2069]/gu, c => '\\u' + c.codePointAt(0).toString(16).padStart(4, '0'));
+ return { version: 1, kind: 'privateTextPreview', upload: u.id, revision: u.revision, digest: u.digest, text, renderMode: 'textContent-only' };
+}
+function recoverRefused(w, r, now) {
+ const refused = authorize(w, r, 'recover-private', now); if (refused) return refused;
+ const cmd = r.command;
+ if (cmd.operation !== 'recover-refused-draft') return 'refused:operation';
+ const q = w.queue.find(q => q.id === cmd.item);
+ if (!q || q.actor !== w.connection.actor || q.conversation !== r.conversation) return 'refused:item';
+ const key = canonical([w.connection.actor, r.conversation, r.id]);
+ const fingerprint = canonical(cmd);
+ if (w.recoveries[key]) return w.recoveries[key].fingerprint === fingerprint ? structuredClone(w.recoveries[key].response) : 'refused:conflicting-request';
+ if (q.state !== 'dispatch-refused' || q.nativeRequest !== null || q.revision !== cmd.revision || q.payloadDigest !== cmd.payloadDigest) return 'refused:state';
+ const p = w.payloads[q.id];
+ if (!p || p.digest !== q.payloadDigest || frozenDigest(p) !== q.payloadDigest || p.actor !== q.actor || p.conversation !== q.conversation) return 'refused:integrity';
+ for (const frozen of p.uploads) {
+ const id = frozen.id, u = w.uploads.find(u => u.id === id);
+ if (!u || u.actor !== q.actor || u.conversation !== q.conversation || u.revision !== frozen.revision || u.digest !== frozen.digest || canonical(u.recipientScope) !== canonical(w.scope) || canonical(frozen.recipientScope) !== canonical(w.scope) || !Buffer.isBuffer(w.blobs[id]) || w.blobs[id].length !== frozen.size || sha(w.blobs[id]) !== frozen.digest) return 'refused:integrity';
+ }
+ const draft = { id: `recovered-${q.id}-${q.revision}`, kind: 'draft', actor: q.actor, conversation: q.conversation, revision: 1, branch: p.branch, text: p.text, attachments: p.uploads.map(u => u.id), state: 'unsent', recoveredFrom: q.id };
+ // Idealized one-transaction result; no actual durable write is implemented.
+ w.items.push(draft); q.state = 'recovered-as-draft'; q.revision++;
+ const response = { version: 1, kind: 'privateRecovery', item: q.id, revision: q.revision, draft: draft.id, state: q.state };
+ w.recoveries[key] = { fingerprint, response: structuredClone(response) }; return response;
+}
+function storageWorld() {
+ const w = structuredClone(f.world), bytes = Buffer.from('synthetic attachment');
+ const p = structuredClone(f.frozenExample);
+ p.text = 'retained text'; Object.assign(p.uploads[0], { filename: 'synthetic.txt', mimeType: 'text/plain', size: bytes.length, receivedBytes: bytes.length, digest: sha(bytes), privateBlobRef: 'not-public' });
+ p.digest = frozenDigest(p);
+ w.uploads = structuredClone(p.uploads); w.blobs = { 'upload-1': bytes };
+ w.payloads = { 'queue-1': p, [p.digest]: p }; w.recoveries = {};
+ w.target = { conversation: 'conversation-1', branch: 'branch-1', execution: 'execution-1', controllerGeneration: 2 }; w.stop = null;
+ w.queue = [{ id: 'queue-1', actor: 'actor-1', conversation: 'conversation-1', revision: 1, state: 'dispatch-refused', nativeRequest: null, request: 'request-1', draft: p.draft, target: structuredClone(w.target), payloadDigest: p.digest }];
+ return w;
+}
+{
+ const w = storageWorld(), r = { ...f.request, command: { operation: 'read-upload-range', upload: 'upload-1', revision: 1, offset: 0, length: 9 } };
+ examples.push(r);
+ const part = readRange(w, r, f.now); examples.push(part); assert.equal(Buffer.from(part.dataBase64, 'base64').toString(), 'synthetic');
+ assert.equal('privateBlobRef' in part, false); assert.equal(part.disposition, 'attachment');
+ for (const change of [{ offset: -1 }, { length: 262145 }, { offset: 20, length: 1 }, { length: 0 }]) assert.equal(readRange(w, { ...r, command: { ...r.command, ...change } }, f.now), 'refused:range');
+ w.uploads[0].actor = 'other'; assert.equal(readRange(w, r, f.now), 'refused:upload');
+ w.uploads[0].actor = 'actor-1'; w.blobs['upload-1'][0] ^= 1; assert.equal(readRange(w, r, f.now), 'refused:integrity');
+ assert.deepEqual(w.nativeOutbox, []);
+}
+{
+ const w = storageWorld(), r = { ...f.request, command: { operation: 'recover-refused-draft', item: 'queue-1', revision: 1, payloadDigest: w.queue[0].payloadDigest } };
+ for (const state of ['dispatched', 'acknowledged', 'delivery-unknown', 'working', 'finished', 'recovery-failed']) {
+ w.queue[0].state = state; assert.equal(recoverRefused(w, r, f.now), 'refused:state');
+ }
+ examples.push(r);
+ w.queue[0].state = 'dispatch-refused'; w.queue[0].nativeRequest = 'native-attempt';
+ assert.equal(recoverRefused(w, r, f.now), 'refused:state'); w.queue[0].nativeRequest = null;
+ const recovered = recoverRefused(w, r, f.now); examples.push(recovered);
+ assert.equal(recovered.state, 'recovered-as-draft'); assert.equal(w.items.at(-1).text, 'retained text');
+ assert.equal(w.items.at(-1).state, 'unsent'); const count = w.items.length;
+ assert.deepEqual(recoverRefused(w, r, f.now), recovered); assert.equal(w.items.length, count);
+ const restart = JSON.parse(JSON.stringify(w)); restart.connection.id = 'reconnected';
+ assert.deepEqual(recoverRefused(restart, { ...r, connection: 'reconnected' }, f.now), recovered);
+ assert.equal(restart.items.length, count);
+ assert.equal(recoverRefused(restart, { ...r, id: 'new-attempt', connection: 'reconnected' }, f.now), 'refused:state');
+ assert.deepEqual(w.nativeOutbox, []);
+ const corrupt = storageWorld(); corrupt.payloads['queue-1'].text = 'changed';
+ assert.equal(recoverRefused(corrupt, r, f.now), 'refused:integrity'); assert.equal(corrupt.queue[0].state, 'dispatch-refused');
+}
+{
+ const w = storageWorld(); w.connection.generation = 2;
+ w.receipts = [{ id: 'receipt-1', actor: 'actor-1', conversation: 'conversation-1', revision: 1, request: 'request-1', state: 'delivery-unknown', reason: 'lost-ack' }];
+ w.confirmations = [{ id: 'confirmation-1', actor: 'actor-1', conversation: 'conversation-1', revision: 1, operation: 'force-stop', target: structuredClone(w.target), connection: 'old-connection', connectionGeneration: 1, state: 'confirmed', expiresAt: 2000, stop: null }];
+ const page = readState(w, f.request, f.now); examples.push(page);
+ assert.deepEqual(page.items.map(x => x.kind).sort(), ['confirmation', 'draft', 'queue', 'receipt', 'upload']);
+ assert.equal(page.items.find(x => x.kind === 'confirmation').state, 'reconfirm-required');
+ assert.equal(page.items.find(x => x.kind === 'receipt').state, 'delivery-unknown');
+ w.confirmations[0].state = 'consumed'; w.confirmations[0].expiresAt = 0;
+ assert.equal(readState(w, f.request, f.now).items.find(x => x.kind === 'confirmation').state, 'consumed');
+ assert.equal(JSON.stringify(page).includes('not-public'), false);
+ assert.deepEqual(w.nativeOutbox, []);
+}
+{
+ const w = storageWorld(), r = { ...f.request, command: { operation: 'recover-refused-draft', item: 'queue-1', revision: 1, payloadDigest: w.queue[0].payloadDigest } };
+ w.queue[0].actor = 'actor-2'; assert.equal(recoverRefused(w, r, f.now), 'refused:item');
+ w.queue[0].actor = 'actor-1'; w.uploads[0].revision++; assert.equal(recoverRefused(w, r, f.now), 'refused:integrity');
+ w.mappingVerified = false; assert.equal(readState(w, f.request, f.now), 'refused:scope');
+}
+function response(r, value) {
+ return typeof value === 'string' && value.startsWith('refused:') ? { version: 1, kind: 'privateRefusal', request: r.id, reason: value.slice(8) } : value;
+}
+examples.push(response(f.request, 'refused:cursor'));
+{
+ const w = storageWorld(), r = { ...f.request, command: { operation: 'preview-upload-text', upload: 'upload-1', revision: 1 } };
+ examples.push(r); const preview = previewText(w, r, f.now); examples.push(preview);
+ assert.equal(preview.text, 'synthetic attachment'); assert.equal(preview.renderMode, 'textContent-only');
+ const bytes = Buffer.from('\u001b'); w.blobs['upload-1'] = bytes;
+ Object.assign(w.uploads[0], { size: bytes.length, digest: sha(bytes) });
+ assert.equal(previewText(w, r, f.now).text, '\\u001b');
+ w.blobs['upload-1'] = Buffer.from([255]); Object.assign(w.uploads[0], { size: 1, digest: sha(w.blobs['upload-1']) });
+ assert.equal(previewText(w, r, f.now), 'refused:preview');
+}
+// R2: current confirmation overlays must never revive stale snapshot authority.
+{
+ function confirmationWorld() {
+ const w = storageWorld(); w.connection.generation = 1; w.stop = 'stop-old';
+ w.confirmations = ['a', 'b'].map(id => ({ id, actor: 'actor-1', conversation: 'conversation-1', revision: 1, operation: 'force-stop', target: structuredClone(w.target), connection: w.connection.id, connectionGeneration: 1, state: 'confirmed', expiresAt: 2000, stop: 'stop-old' })); return w;
+ }
+ for (const change of ['consumed', 'cancelled', 'stop', 'missing', 'restart']) {
+ let w = confirmationWorld(); const r = { ...f.request, command: { operation: 'read-private-state', cursor: null, limit: 1 } };
+ r.command.cursor = readState(w, r, f.now).nextCursor;
+ if (change === 'stop') w.stop = 'stop-new';
+ else if (change === 'missing') w.confirmations.pop();
+ else w.confirmations[1].state = change === 'restart' ? 'consumed' : change;
+ if (change === 'restart') w = JSON.parse(JSON.stringify(w));
+ const item = readState(w, r, f.now).items[0];
+ assert.equal(item.id, 'b'); assert.equal(item.state, ['stop', 'missing'].includes(change) ? 'reconfirm-required' : change === 'restart' ? 'consumed' : change);
+ }
+}
+// Valid colon-bearing IDs must not alias another scope's recovery receipt.
+{
+ const first = storageWorld(), r1 = { ...f.request, id: 'x:y', command: { operation: 'recover-refused-draft', item: 'queue-1', revision: 1, payloadDigest: first.queue[0].payloadDigest } };
+ assert.equal(recoverRefused(first, r1, f.now).state, 'recovered-as-draft');
+ const second = storageWorld(); second.recoveries = first.recoveries;
+ const conversation = 'conversation-1:x'; second.scope.conversation = conversation; second.grant.conversation = conversation;
+ second.grant.scopeDigest = sha(Buffer.from(canonical(second.scope))); second.target.conversation = conversation;
+ for (const u of second.uploads) { u.conversation = conversation; u.recipientScope = structuredClone(second.scope); }
+ const p = second.payloads['queue-1']; p.conversation = conversation; p.uploads = structuredClone(second.uploads); p.digest = frozenDigest(p);
+ second.payloads = { 'queue-1': p, [p.digest]: p };
+ Object.assign(second.queue[0], { conversation, target: structuredClone(second.target), payloadDigest: p.digest });
+ const r2 = { ...r1, id: 'y', conversation };
+ assert.equal(recoverRefused(second, r2, f.now), 'refused:state');
+ assert.equal(second.queue[0].state, 'dispatch-refused');
+ r2.command = { ...r2.command, payloadDigest: p.digest }; examples.push(r2);
+ assert.equal(recoverRefused(second, r2, f.now).state, 'recovered-as-draft');
+ assert.equal(second.items.at(-1).conversation, conversation);
+ assert.equal(recoverRefused(second, { ...r2, command: { ...r2.command, revision: 2 } }, f.now), 'refused:conflicting-request');
+}
+// Queue readback uses frozen admissions and causal joins, never current drafts.
+{
+ const w = storageWorld(); w.queue[0].state = 'queued';
+ const p = structuredClone(w.payloads['queue-1']); p.id = 'payload-2'; p.branch = 'branch-2'; p.draft = 'draft-branch-2'; p.text = 'second frozen input'; p.digest = frozenDigest(p);
+ w.payloads['queue-2'] = p; w.payloads[p.digest] = p;
+ w.queue.push({ ...structuredClone(w.queue[0]), id: 'queue-2', request: 'request-2', draft: p.draft, target: { ...w.target, branch: p.branch }, payloadDigest: p.digest, state: 'dispatch-refused' });
+ w.items[0].text = 'edited mutable draft'; w.items[0].revision++;
+ w.items.push({ ...w.items[0], id: p.draft, branch: p.branch, text: 'another later edit' });
+ w.receipts = w.queue.map((q, i) => ({ id: `receipt-${i}`, actor: q.actor, conversation: q.conversation, revision: 1, request: q.request, state: q.state }));
+ const page = readState(w, f.request, f.now); examples.push(page);
+ const queues = page.items.filter(x => x.kind === 'queue');
+ assert.deepEqual(queues.map(q => q.frozenInput.text), ['retained text', 'second frozen input']);
+ assert.deepEqual(queues.map(q => q.target.branch), ['branch-1', 'branch-2']);
+ for (const q of queues) {
+ assert.ok(page.items.some(x => x.kind === 'receipt' && x.request === q.request));
+ assert.ok(page.items.some(x => x.kind === 'draft' && x.id === q.draft));
+ assert.equal(q.frozenInputState, 'available');
+ }
+ assert.equal(JSON.stringify(page).includes('not-public'), false);
+}
+function resumeOffset(view, localBytes) {
+ if (view.state !== 'receiving' || !Number.isSafeInteger(view.receivedBytes) || view.receivedBytes < 0 || view.receivedBytes > view.size || !Buffer.isBuffer(localBytes) || localBytes.length !== view.size || sha(localBytes) !== view.digest) return 'refused:local-file';
+ return { revision: view.revision, offset: view.receivedBytes, complete: view.receivedBytes === view.size };
+}
+{
+ const w = storageWorld(), local = Buffer.from(w.blobs['upload-1']);
+ w.uploads.push({ ...structuredClone(w.uploads[0]), id: 'upload-receiving', state: 'receiving', revision: 2, receivedBytes: 7 });
+ w.blobs['upload-receiving'] = local.subarray(0, 7); // Given committed append; reply lost.
+ const view = readState(w, f.request, f.now).items.find(x => x.id === 'upload-receiving');
+ assert.deepEqual(resumeOffset(view, local), { revision: 2, offset: 7, complete: false });
+ assert.equal(resumeOffset(view, Buffer.from('wrong local selection')), 'refused:local-file');
+ assert.equal(readRange(w, { ...f.request, command: { operation: 'read-upload-range', upload: view.id, revision: view.revision, offset: 0, length: 7 } }, f.now), 'refused:upload');
+ assert.deepEqual(w.nativeOutbox, []);
+}
+console.log('PASS R2 live confirmation overlays, tuple-scoped recovery dedup, frozen queue joins and receiving-upload resume readback');
+const shapes = spawnSync('python3', ['-c', `
+import json,sys,copy
+from jsonschema import Draft202012Validator
+p=json.load(sys.stdin);v=Draft202012Validator(p['schema']);count=0
+for e in p['examples']:
+ assert v.is_valid(e),list(v.iter_errors(e))
+ for key in e:
+ x=copy.deepcopy(e);del x[key];assert not v.is_valid(x),(e['kind'],key);count+=1
+ x=dict(e,privateBlobRef='forged');assert not v.is_valid(x);count+=1
+ if e['kind']=='privateStatePage':
+ for i,item in enumerate(e['items']):
+ for key in item:
+ x=copy.deepcopy(e);del x['items'][i][key];assert not v.is_valid(x),(item['kind'],key);count+=1
+print('PASS',len(p['examples']),'closed request/response examples and',count,'omission/extra-field refusals')
+`], { input: JSON.stringify({ schema: JSON.parse(readFileSync(schemaURL, 'utf8')), examples }), encoding: 'utf8', timeout: 30000 });
+assert.equal(shapes.status, 0, shapes.stdout + shapes.stderr); process.stdout.write(shapes.stdout);
+console.log('PASS private projections/cursors, synthetic restart, bounded attachment ranges and refused-input recovery. Offline models only.');
diff --git a/docs/plans/chat-01c/contracts.schema.json b/docs/plans/chat-01c/contracts.schema.json
new file mode 100644
index 00000000..a4b001e7
--- /dev/null
+++ b/docs/plans/chat-01c/contracts.schema.json
@@ -0,0 +1,835 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://example.invalid/mosaic/chat-01c/draft-1",
+ "title": "CHAT-01C private request and safe projection drafts",
+ "oneOf": [
+ {
+ "$ref": "#/$defs/privateClientRequest"
+ },
+ {
+ "$ref": "#/$defs/privateStatePage"
+ },
+ {
+ "$ref": "#/$defs/uploadRange"
+ },
+ {
+ "$ref": "#/$defs/privateRecovery"
+ },
+ {
+ "$ref": "#/$defs/privateRefusal"
+ },
+ {
+ "$ref": "#/$defs/privateTextPreview"
+ }
+ ],
+ "$defs": {
+ "id": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128,
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
+ },
+ "revision": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 9007199254740991
+ },
+ "digest": {
+ "type": "string",
+ "pattern": "^[a-f0-9]{64}$"
+ },
+ "target": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "conversation": {
+ "$ref": "#/$defs/id"
+ },
+ "branch": {
+ "$ref": "#/$defs/id"
+ },
+ "execution": {
+ "$ref": "#/$defs/id"
+ },
+ "controllerGeneration": {
+ "$ref": "#/$defs/revision"
+ }
+ },
+ "required": [
+ "conversation",
+ "branch",
+ "execution",
+ "controllerGeneration"
+ ]
+ },
+ "privateClientRequest": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "version",
+ "kind",
+ "id",
+ "connection",
+ "conversation",
+ "command"
+ ],
+ "properties": {
+ "version": {
+ "const": 1
+ },
+ "kind": {
+ "const": "privateClientRequest"
+ },
+ "id": {
+ "$ref": "#/$defs/id"
+ },
+ "connection": {
+ "$ref": "#/$defs/id"
+ },
+ "conversation": {
+ "$ref": "#/$defs/id"
+ },
+ "command": {
+ "oneOf": [
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "operation",
+ "cursor",
+ "limit"
+ ],
+ "properties": {
+ "operation": {
+ "const": "read-private-state"
+ },
+ "cursor": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/id"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100
+ }
+ }
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "operation",
+ "upload",
+ "revision",
+ "offset",
+ "length"
+ ],
+ "properties": {
+ "operation": {
+ "const": "read-upload-range"
+ },
+ "upload": {
+ "$ref": "#/$defs/id"
+ },
+ "revision": {
+ "$ref": "#/$defs/revision"
+ },
+ "offset": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 20971520
+ },
+ "length": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 262144
+ }
+ }
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "operation",
+ "item",
+ "revision",
+ "payloadDigest"
+ ],
+ "properties": {
+ "operation": {
+ "const": "recover-refused-draft"
+ },
+ "item": {
+ "$ref": "#/$defs/id"
+ },
+ "revision": {
+ "$ref": "#/$defs/revision"
+ },
+ "payloadDigest": {
+ "type": "string",
+ "pattern": "^[a-f0-9]{64}$"
+ }
+ }
+ },
+ {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "operation": {
+ "const": "preview-upload-text"
+ },
+ "upload": {
+ "$ref": "#/$defs/id"
+ },
+ "revision": {
+ "$ref": "#/$defs/revision"
+ }
+ },
+ "required": [
+ "operation",
+ "upload",
+ "revision"
+ ]
+ }
+ ]
+ }
+ }
+ },
+ "draftView": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "kind": {
+ "const": "draft"
+ },
+ "id": {
+ "$ref": "#/$defs/id"
+ },
+ "revision": {
+ "$ref": "#/$defs/revision"
+ },
+ "text": {
+ "type": "string",
+ "maxLength": 262144
+ },
+ "state": {
+ "enum": [
+ "unsent",
+ "submitted",
+ "discarded"
+ ]
+ },
+ "attachments": {
+ "type": "array",
+ "maxItems": 10,
+ "uniqueItems": true,
+ "items": {
+ "$ref": "#/$defs/id"
+ }
+ },
+ "recoveredFrom": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/id"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "branch": {
+ "$ref": "#/$defs/id"
+ }
+ },
+ "required": [
+ "kind",
+ "id",
+ "revision",
+ "text",
+ "state",
+ "attachments",
+ "recoveredFrom",
+ "branch"
+ ]
+ },
+ "uploadView": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "kind": {
+ "const": "upload"
+ },
+ "id": {
+ "$ref": "#/$defs/id"
+ },
+ "revision": {
+ "$ref": "#/$defs/revision"
+ },
+ "filename": {
+ "type": "string",
+ "maxLength": 255
+ },
+ "mediaType": {
+ "type": "string",
+ "maxLength": 127
+ },
+ "size": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 20971520
+ },
+ "digest": {
+ "$ref": "#/$defs/digest"
+ },
+ "state": {
+ "enum": [
+ "receiving",
+ "staged",
+ "transferring",
+ "delivered",
+ "sent",
+ "rejected",
+ "discarded"
+ ]
+ },
+ "receivedBytes": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 20971520
+ }
+ },
+ "required": [
+ "kind",
+ "id",
+ "revision",
+ "filename",
+ "mediaType",
+ "size",
+ "digest",
+ "state",
+ "receivedBytes"
+ ]
+ },
+ "queueView": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "kind": {
+ "const": "queue"
+ },
+ "id": {
+ "$ref": "#/$defs/id"
+ },
+ "revision": {
+ "$ref": "#/$defs/revision"
+ },
+ "state": {
+ "enum": [
+ "admitted",
+ "queued",
+ "dispatched",
+ "acknowledged",
+ "working",
+ "finished",
+ "failed",
+ "delivery-unknown",
+ "recovered-as-draft",
+ "recovery-failed",
+ "dispatch-refused",
+ "rejected"
+ ]
+ },
+ "payloadDigest": {
+ "$ref": "#/$defs/digest"
+ },
+ "reason": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/id"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "request": {
+ "$ref": "#/$defs/id"
+ },
+ "draft": {
+ "$ref": "#/$defs/id"
+ },
+ "target": {
+ "$ref": "#/$defs/target"
+ },
+ "frozenInputState": {
+ "enum": [
+ "available",
+ "integrity-unavailable"
+ ]
+ },
+ "frozenInput": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/frozenInputView"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "kind",
+ "id",
+ "revision",
+ "state",
+ "payloadDigest",
+ "reason",
+ "request",
+ "draft",
+ "target",
+ "frozenInputState",
+ "frozenInput"
+ ],
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "frozenInputState": {
+ "const": "available"
+ }
+ }
+ },
+ "then": {
+ "properties": {
+ "frozenInput": {
+ "$ref": "#/$defs/frozenInputView"
+ }
+ }
+ },
+ "else": {
+ "properties": {
+ "frozenInput": {
+ "type": "null"
+ }
+ }
+ }
+ }
+ ]
+ },
+ "receiptView": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "kind": {
+ "const": "receipt"
+ },
+ "id": {
+ "$ref": "#/$defs/id"
+ },
+ "revision": {
+ "$ref": "#/$defs/revision"
+ },
+ "request": {
+ "$ref": "#/$defs/id"
+ },
+ "state": {
+ "enum": [
+ "admitted",
+ "queued",
+ "dispatched",
+ "acknowledged",
+ "working",
+ "finished",
+ "failed",
+ "delivery-unknown",
+ "recovered-as-draft",
+ "recovery-failed",
+ "dispatch-refused",
+ "rejected"
+ ]
+ },
+ "reason": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/id"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "kind",
+ "id",
+ "revision",
+ "request",
+ "state",
+ "reason"
+ ]
+ },
+ "confirmationView": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "kind": {
+ "const": "confirmation"
+ },
+ "id": {
+ "$ref": "#/$defs/id"
+ },
+ "revision": {
+ "$ref": "#/$defs/revision"
+ },
+ "operation": {
+ "enum": [
+ "force-stop",
+ "recover",
+ "acquire-recovery-control"
+ ]
+ },
+ "target": {
+ "$ref": "#/$defs/target"
+ },
+ "state": {
+ "enum": [
+ "pending",
+ "confirmed",
+ "consumed",
+ "expired",
+ "cancelled",
+ "reconfirm-required"
+ ]
+ },
+ "expiresAt": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ },
+ "stop": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/id"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "kind",
+ "id",
+ "revision",
+ "operation",
+ "target",
+ "state",
+ "expiresAt",
+ "stop"
+ ]
+ },
+ "privateStatePage": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "version": {
+ "const": 1
+ },
+ "kind": {
+ "const": "privateStatePage"
+ },
+ "actor": {
+ "$ref": "#/$defs/id"
+ },
+ "conversation": {
+ "$ref": "#/$defs/id"
+ },
+ "snapshot": {
+ "$ref": "#/$defs/id"
+ },
+ "sourceRevision": {
+ "$ref": "#/$defs/revision"
+ },
+ "items": {
+ "type": "array",
+ "maxItems": 100,
+ "items": {
+ "oneOf": [
+ {
+ "$ref": "#/$defs/draftView"
+ },
+ {
+ "$ref": "#/$defs/uploadView"
+ },
+ {
+ "$ref": "#/$defs/queueView"
+ },
+ {
+ "$ref": "#/$defs/receiptView"
+ },
+ {
+ "$ref": "#/$defs/confirmationView"
+ }
+ ]
+ }
+ },
+ "nextCursor": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/id"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "required": [
+ "version",
+ "kind",
+ "actor",
+ "conversation",
+ "snapshot",
+ "sourceRevision",
+ "items",
+ "nextCursor"
+ ]
+ },
+ "uploadRange": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "version": {
+ "const": 1
+ },
+ "kind": {
+ "const": "uploadRange"
+ },
+ "upload": {
+ "$ref": "#/$defs/id"
+ },
+ "revision": {
+ "$ref": "#/$defs/revision"
+ },
+ "offset": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 20971520
+ },
+ "length": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 262144
+ },
+ "total": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 20971520
+ },
+ "digest": {
+ "$ref": "#/$defs/digest"
+ },
+ "rangeDigest": {
+ "$ref": "#/$defs/digest"
+ },
+ "dataBase64": {
+ "type": "string",
+ "maxLength": 349528,
+ "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$"
+ },
+ "disposition": {
+ "const": "attachment"
+ },
+ "mediaType": {
+ "const": "application/octet-stream"
+ }
+ },
+ "required": [
+ "version",
+ "kind",
+ "upload",
+ "revision",
+ "offset",
+ "length",
+ "total",
+ "digest",
+ "rangeDigest",
+ "dataBase64",
+ "disposition",
+ "mediaType"
+ ]
+ },
+ "privateRecovery": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "version": {
+ "const": 1
+ },
+ "kind": {
+ "const": "privateRecovery"
+ },
+ "item": {
+ "$ref": "#/$defs/id"
+ },
+ "revision": {
+ "$ref": "#/$defs/revision"
+ },
+ "draft": {
+ "$ref": "#/$defs/id"
+ },
+ "state": {
+ "const": "recovered-as-draft"
+ }
+ },
+ "required": [
+ "version",
+ "kind",
+ "item",
+ "revision",
+ "draft",
+ "state"
+ ]
+ },
+ "privateRefusal": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "version": {
+ "const": 1
+ },
+ "kind": {
+ "const": "privateRefusal"
+ },
+ "request": {
+ "$ref": "#/$defs/id"
+ },
+ "reason": {
+ "enum": [
+ "request",
+ "operation",
+ "channel",
+ "grant",
+ "scope",
+ "cursor",
+ "projection-size",
+ "upload",
+ "integrity",
+ "range",
+ "item",
+ "conflicting-request",
+ "state",
+ "preview"
+ ]
+ }
+ },
+ "required": [
+ "version",
+ "kind",
+ "request",
+ "reason"
+ ]
+ },
+ "privateTextPreview": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "version": {
+ "const": 1
+ },
+ "kind": {
+ "const": "privateTextPreview"
+ },
+ "upload": {
+ "$ref": "#/$defs/id"
+ },
+ "revision": {
+ "$ref": "#/$defs/revision"
+ },
+ "digest": {
+ "$ref": "#/$defs/digest"
+ },
+ "text": {
+ "type": "string",
+ "maxLength": 1572864
+ },
+ "renderMode": {
+ "const": "textContent-only"
+ }
+ },
+ "required": [
+ "version",
+ "kind",
+ "upload",
+ "revision",
+ "digest",
+ "text",
+ "renderMode"
+ ]
+ },
+ "frozenInputView": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "branch": {
+ "$ref": "#/$defs/id"
+ },
+ "draftRevision": {
+ "$ref": "#/$defs/revision"
+ },
+ "text": {
+ "type": "string",
+ "maxLength": 262144
+ },
+ "uploads": {
+ "type": "array",
+ "maxItems": 10,
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "$ref": "#/$defs/id"
+ },
+ "revision": {
+ "$ref": "#/$defs/revision"
+ },
+ "filename": {
+ "type": "string",
+ "maxLength": 255
+ },
+ "size": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 20971520
+ },
+ "digest": {
+ "$ref": "#/$defs/digest"
+ }
+ },
+ "required": [
+ "id",
+ "revision",
+ "filename",
+ "size",
+ "digest"
+ ]
+ }
+ }
+ },
+ "required": [
+ "branch",
+ "draftRevision",
+ "text",
+ "uploads"
+ ]
+ }
+ }
+}
diff --git a/docs/plans/chat-01c/fixtures.json b/docs/plans/chat-01c/fixtures.json
new file mode 100644
index 00000000..46aed0e6
--- /dev/null
+++ b/docs/plans/chat-01c/fixtures.json
@@ -0,0 +1,106 @@
+{
+ "evidence": "synthetic-contract-only",
+ "now": 1000,
+ "request": {
+ "version": 1,
+ "kind": "privateClientRequest",
+ "id": "request-1",
+ "connection": "connection-1",
+ "conversation": "conversation-1",
+ "command": {
+ "operation": "read-private-state",
+ "cursor": null,
+ "limit": 10
+ }
+ },
+ "world": {
+ "connection": {
+ "id": "connection-1",
+ "actor": "actor-1",
+ "state": "connected"
+ },
+ "grant": {
+ "actor": "actor-1",
+ "conversation": "conversation-1",
+ "state": "active",
+ "expiresAt": 2000,
+ "capabilities": [
+ "read-private",
+ "recover-private"
+ ],
+ "scopeDigest": "2e4d08329c39869151bb7dff3df6542dfe3f7a746d635b069fabbfc4eb40f680"
+ },
+ "sourceRevision": 1,
+ "items": [
+ {
+ "id": "draft-1",
+ "kind": "draft",
+ "actor": "actor-1",
+ "conversation": "conversation-1",
+ "revision": 1,
+ "text": "private unsent text",
+ "state": "unsent",
+ "branch": "branch-1"
+ },
+ {
+ "id": "draft-2",
+ "kind": "draft",
+ "actor": "actor-2",
+ "conversation": "conversation-1",
+ "revision": 1,
+ "text": "other actor text",
+ "state": "unsent",
+ "branch": "branch-1"
+ }
+ ],
+ "cursors": {},
+ "nativeOutbox": [],
+ "scope": {
+ "host": "host-1",
+ "seat": "seat-1",
+ "project": "project-1",
+ "workspace": "workspace-1",
+ "conversation": "conversation-1"
+ },
+ "mappingVerified": true
+ },
+ "frozenExample": {
+ "version": 2,
+ "kind": "frozenPayload",
+ "id": "payload-1",
+ "actor": "actor-1",
+ "conversation": "conversation-1",
+ "branch": "branch-1",
+ "draft": "draft-1",
+ "draftRevision": 1,
+ "text": "hello",
+ "uploads": [
+ {
+ "version": 2,
+ "kind": "upload",
+ "id": "upload-1",
+ "actor": "actor-1",
+ "conversation": "conversation-1",
+ "revision": 1,
+ "filename": "example.png",
+ "mimeType": "image/png",
+ "size": 20,
+ "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "state": "staged",
+ "privateBlobRef": "blob-1",
+ "recipientScope": {
+ "host": "host-1",
+ "seat": "seat-1",
+ "project": "project-1",
+ "workspace": "workspace-1",
+ "conversation": "conversation-1"
+ },
+ "transferAck": null,
+ "createdAt": "2026-09-13T12:00:00Z",
+ "receivedBytes": 20
+ }
+ ],
+ "digest": "4e06080f131b6e7d87e692ffebe8ccd1f78e25aedc24a10aaf68e7c3191f9f48",
+ "createdAt": "2026-09-13T12:00:00Z"
+ }
+}