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" + } +}