docs: publish reviewed CHAT-01C private readback contracts (#1507)
This commit is contained in:
@@ -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.
|
||||
@@ -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('<script>not executed</script>\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, '<script>not executed</script>\\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.');
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user