diff --git a/clients/typescript/network/README.md b/clients/typescript/network/README.md index a4314db..b8ed1df 100644 --- a/clients/typescript/network/README.md +++ b/clients/typescript/network/README.md @@ -308,3 +308,27 @@ bounded feed/body recovery, and `readMailboxIndex`, `readMailboxAdmission`, and not delegate to a Python process. Native mailbox provisioning and replica inbox recovery remain unfinished; use the Python client for those paths. These additions are in the alpha.0.36 candidate; public release acceptance remains pending. + +### Retained receipt destinations (development after alpha.0.36) + +The native Agent accepts `memory-vault-open-ack-connect/v1` actions +`register_mailbox_receipt_return`, `list_mailbox_receipt_returns`, +`inspect_mailbox_receipt_return` and `remove_mailbox_receipt_return`. +Registration retains an exact `message_id`, `source_url`, `source_key_id` and +`repair_profile` (`receipt` or `receipt-index`) locally. It can happen before +reception; it grants no upload or disclosure permission. + +Ordinary `receive` attempts up to two ready, saved-message returns within its +shared deadline. It reopens the mailbox evidence and original independent ACK +configuration, proves the selected source, and persists the exact preparation +before returning the actual saved receipt. A lost reply or process restart +resumes the same original request. Authenticated original-root status history +remains durable; retained denials stop a pending upload before retransmission. +Expired uncertain returns become `reconciliation_required` and stop polling. + +The bounded selection and return journals use the same protected SQLite tables +as Python. Removing a selection preserves the saved message and original return +history. Re-registering a completed selection can reuse authenticated local +history without another upload. Direct `return_mailbox_receipt` supports the same +message and source selection. Native replica inbox support remains unfinished. +These additions are development source after the frozen alpha.0.36 candidate. diff --git a/clients/typescript/network/open-ack-client.ts b/clients/typescript/network/open-ack-client.ts index e5f1222..e25e7b9 100644 --- a/clients/typescript/network/open-ack-client.ts +++ b/clients/typescript/network/open-ack-client.ts @@ -21,6 +21,7 @@ type Obj=Record; export const ACK_CONNECT_SCHEMA='memory-vault-open-ack-connect/v1'; const PROFILES={receipt:{...DEFAULT_REPAIR_CLIENT_LIMITS,max_signature_checks:2048,max_proof_bytes:1048576}, 'receipt-index':{...DEFAULT_REPAIR_CLIENT_LIMITS,max_signature_checks:4096,max_proof_bytes:4194304,max_requests:256,max_replay_records:256}}; +export {PROFILES as ACK_REPAIR_PROFILES}; function fail(code:string):never{throw new NetworkError(code);} function decode(value:unknown):Obj{ const entry=objectFields(value,['raw','ref']); diff --git a/clients/typescript/network/open-client.ts b/clients/typescript/network/open-client.ts index 21176ca..a3abe78 100644 --- a/clients/typescript/network/open-client.ts +++ b/clients/typescript/network/open-client.ts @@ -7,6 +7,7 @@ import {loadClient} from './client-config.ts'; import type {Client} from './client-config.ts'; import {OpenDeliveryClient} from './open-delivery-client.ts'; import {RegisteredMailboxReceivers,MAILBOX_CONNECT_SCHEMA} from './open-mailbox-receivers.ts'; +import {MailboxReceiptJobs,MAILBOX_RECEIPT_ACTIONS,returnMailboxReceipt} from './open-mailbox-receipts.ts'; import {OpenParticipant} from './open-participant.ts'; import {OpenContactClient,CONNECT_SCHEMA} from './open-contact-client.ts'; import type {SignedNode} from './open-control.ts'; @@ -39,8 +40,10 @@ export class OpenNetworkClient{ if(invitation!=null){ if(typeof invitation==='object'&&!Array.isArray(invitation)&&(invitation as any).schema_version===MAILBOX_CONNECT_SCHEMA) return this.result(new RegisteredMailboxReceivers(this.participant,this.encryption,this.delivery()).connect(invitation)); - if(typeof invitation==='object'&&!Array.isArray(invitation)&&(invitation as any).schema_version===ACK_CONNECT_SCHEMA) + if(typeof invitation==='object'&&!Array.isArray(invitation)&&(invitation as any).schema_version===ACK_CONNECT_SCHEMA){ + if(MAILBOX_RECEIPT_ACTIONS.has((invitation as any).action))return this.result((invitation as any).action==='return_mailbox_receipt'?await returnMailboxReceipt(this.participant,this.encryption,this.delivery(),invitation):new MailboxReceiptJobs(this.participant,this.encryption,this.delivery()).connect(invitation)); return this.result(await ((invitation as any).action==='return_receipt'?returnSavedReceipt:recoverReceipt)(this.participant,this.encryption,this.delivery(),invitation)); + } if(typeof invitation!=='object'||Array.isArray(invitation)||(invitation as any).schema_version!==CONNECT_SCHEMA)fail('open_private_invitation_unsupported'); const result=await new OpenContactClient(this.participant,this.encryption).dispatch(invitation,requestId); return this.result({...result,profile:'open-routing-v1',network_accessed:true,open_messaging_supported:true}); diff --git a/clients/typescript/network/open-delivery-client.ts b/clients/typescript/network/open-delivery-client.ts index 5aacd71..8ddb1f3 100644 --- a/clients/typescript/network/open-delivery-client.ts +++ b/clients/typescript/network/open-delivery-client.ts @@ -347,12 +347,12 @@ export class OpenDeliveryClient{ if(!Object.hasOwn(session,'mailbox'))return document(session,MAX_INBOX_SESSION_BYTES) as Obj; return objectFields(session,['mailbox','authority','source_node']); } - private async verifyMailboxInbox(session:Obj,row:Obj):Promise{ + private async verifyMailboxInbox(session:Obj,row:Obj):Promise{ const verified=await verifyMailboxInboxEvidence(session.mailbox,row.envelope,{owner:{signing_key:validateSigningIdentity(this.participant.identity), encryption_key:validateEncryptionIdentity(this.encryption)},encryptionIdentity:this.encryption,stagedAt:row.created_at}); if(verified.core.message_id!==row.message_id||session.mailbox.sender.signing_key.key_id!==row.sender)fail('network_inbox_identity_conflict'); const expected=Object.fromEntries([['request','contact.request'],['policy','contact.policy']].map(([name,role])=>[name,document(verified.setup.roles[role].raw)])); - if(!same(session.authority,expected))fail('network_inbox_identity_conflict'); + if(!same(session.authority,expected))fail('network_inbox_identity_conflict');return verified; } private async finishInbox(messageId:string):Promise{ const row=this.inbox(messageId);if(!row)fail('network_message_not_found');if(['saved','rejected'].includes(row.phase))return document(row.result,MAX_RESULT_BYTES); @@ -389,8 +389,15 @@ export class OpenDeliveryClient{ db.prepare('UPDATE open_delivery_inbox SET receipt=? WHERE message_id=?').run(encoded,messageId);return receipt; })); } - /** Read the actually saved inbox receipt for an explicitly requested return. - * The caller cannot substitute receipt bytes or a saved-state flag. */ + /** Reauthenticate the saved mailbox evidence before deriving return authority. */ + async savedMailboxForAck(messageId:string):Promise{ + if(!/^msg_[0-9a-f]{64}$/.test(messageId))fail('repair_saved_tuple_mismatch'); + const row=this.inbox(messageId);if(!row||row.phase!=='saved')fail('repair_receipt_not_saved'); + const session=OpenDeliveryClient.inboxSession(row.session);if(!Object.hasOwn(session,'mailbox'))fail('open_ack_mailbox_configuration_missing'); + const verified=await this.verifyMailboxInbox(session,row);if(!verified.setup.ack_configuration)fail('open_ack_mailbox_configuration_missing'); + return {owner:session.mailbox.sender,roles:Object.fromEntries(Object.entries(verified.setup.roles).map(([n,e]:[string,any])=>[n,{ref:e.ref,raw:e.raw}]))}; + } + /** Read the actual saved receipt; never accept substituted receipt bytes. */ async savedReceiptForAck(messageId:string,owner:Obj,expectedEnvelope:unknown):Promise<{raw:Uint8Array;ref:Obj}>{ if(!/^msg_[0-9a-f]{64}$/.test(messageId))fail('repair_saved_tuple_mismatch'); const row=this.inbox(messageId);if(!row||row.phase!=='saved')fail('repair_receipt_not_saved'); diff --git a/clients/typescript/network/open-mailbox-receipts.ts b/clients/typescript/network/open-mailbox-receipts.ts new file mode 100644 index 0000000..9be05aa --- /dev/null +++ b/clients/typescript/network/open-mailbox-receipts.ts @@ -0,0 +1,113 @@ +/** Explicit retained receipt destinations and original-authority cold returns. */ +import {performance} from 'node:perf_hooks'; +import {canonicalBytes,document,objectFields,sha256,encodeBase64url,decodeBase64url} from './crypto.ts'; +import type {EncryptionIdentityDocument} from './crypto.ts'; +import {NetworkError,transaction} from './io.ts'; +import type {OpenParticipant} from './open-participant.ts'; +import type {OpenDeliveryClient} from './open-delivery-client.ts'; +import {endpoint} from './open-transport.ts'; +import type {OpenHTTPTransport} from './open-transport.ts'; +import {coordinate,verifyNode} from './open-control.ts'; +import {LookupBudget} from './open-routing.ts'; +import {OpenProviderClient} from './open-provider-client.ts'; +import {AckReceiptClient} from './open-repair-offer-client.ts'; +import {SavedAckReceiptPublisher,SAVED_ACK_REQUEST_FIELDS} from './open-repair-receipt.ts'; +import {ACK_CONNECT_SCHEMA,ACK_REPAIR_PROFILES} from './open-ack-client.ts'; +import {OriginalAckStatusJournal} from './open-ack-status.ts'; +type Obj=Record; +const monotonic=()=>performance.now()/1000; +const FIELDS=['schema_version','action','message_id','source_url','source_key_id','repair_profile']; +const SCHEMA='memory-vault-open-saved-ack-request/v1',MAX_BUNDLE=1048576; +export const MAILBOX_RECEIPT_ACTIONS=new Set(['return_mailbox_receipt','register_mailbox_receipt_return','list_mailbox_receipt_returns','inspect_mailbox_receipt_return','remove_mailbox_receipt_return']); +function fail(code:string,retryable=false):never{throw new NetworkError(code,retryable);} +function message(v:unknown):string{if(typeof v!=='string'||!/^msg_[0-9a-f]{64}$/.test(v))fail('open_invalid_ack_request');return v;} +const equal=(a:unknown,b:unknown)=>Buffer.from(canonicalBytes(a)).equals(Buffer.from(canonicalBytes(b))); +function selection(v:unknown,participant:OpenParticipant,action:string):Obj{ + const r=objectFields(v,FIELDS);if(r.schema_version!==ACK_CONNECT_SCHEMA||r.action!==action)fail('open_invalid_ack_request'); + message(r.message_id);coordinate(r.source_key_id); + if(typeof r.repair_profile!=='string'||!Object.hasOwn(ACK_REPAIR_PROFILES,r.repair_profile))fail('open_invalid_repair_policy'); + endpoint(r.source_url,participant.transport.allow_loopback);return {...r,source_url:r.source_url.replace(/\/$/,'')}; +} +function decodedBundle(raw:Uint8Array,base:string,profile:string):Obj{ + const bundle=objectFields(document(raw,MAX_BUNDLE),['schema_version','base_url','repair_profile','request']); + if(bundle.schema_version!==SCHEMA||bundle.base_url!==base||bundle.repair_profile!==profile)fail('open_ack_mailbox_return_corrupt'); + const r=objectFields(bundle.request,SAVED_ACK_REQUEST_FIELDS),decode=(v:unknown)=>{const e=objectFields(v,['ref','raw_base64url']);return {ref:e.ref,raw:decodeBase64url(e.raw_base64url,MAX_BUNDLE)};}; + if(!Array.isArray(r.current_statuses)||r.current_statuses.length>7)fail('open_ack_mailbox_return_corrupt'); + return Object.fromEntries(Object.entries(r).map(([n,v])=>[n,n==='current_statuses'?v.map(decode):n.endsWith('_entry')?decode(v):v])); +} +export async function returnMailboxReceipt(participant:OpenParticipant,encryption:EncryptionIdentityDocument,delivery:OpenDeliveryClient,value:unknown, + options:{deadline?:number;networkObserver?:()=>void}={}):Promise{ + objectFields(options,[...(Object.hasOwn(options,'deadline')?['deadline']:[]),...(Object.hasOwn(options,'networkObserver')?['networkObserver']:[])]); + if((options.deadline!==undefined&&!Number.isFinite(options.deadline))||(options.networkObserver!==undefined&&typeof options.networkObserver!=='function'))fail('repair_invalid_deadline'); + const deadline=Math.min(monotonic()+60,options.deadline??Infinity),remaining=()=>{const n=deadline-monotonic();if(n<=0)fail('open_delivery_budget_exhausted',true);return Math.min(60,n);}; + remaining(); + const v=selection(document(value as any,4096),participant,'return_mailbox_receipt'),base=v.source_url; + const saved=await delivery.savedMailboxForAck(v.message_id),roles=saved.roles,owner=saved.owner; + const [root,write,bootstrap]=['ack.root_authority','ack.write_grant','bootstrap.ack_offer'].map(n=>roles[n]); + const wp=document(write.raw,65536).payload as Obj,binding=sha256(canonicalBytes({message_id:v.message_id,envelope_ref:wp.envelope_ref,source_url:base,source_key_id:v.source_key_id,repair_profile:v.repair_profile,write_ref:write.ref})); + participant.providerStorage(db=>db.exec('CREATE TABLE IF NOT EXISTS open_mailbox_ack_returns(message_id TEXT PRIMARY KEY,binding TEXT NOT NULL,request BLOB NOT NULL,request_sha256 TEXT NOT NULL)')); + const held=participant.providerStorage(db=>db.prepare('SELECT * FROM open_mailbox_ack_returns WHERE message_id=?').get(v.message_id)) as Obj|undefined; + const decodeHeld=(row:Obj)=>{if(row.binding!==binding)fail('open_ack_mailbox_return_conflict');if(row.request.length>MAX_BUNDLE||sha256(row.request)!==row.request_sha256)fail('open_ack_mailbox_return_corrupt');return decodedBundle(row.request,base,v.repair_profile);}; + const statuses=new OriginalAckStatusJournal(participant,wp.ack_slot.root_key),authorityHistory=()=>statuses.load(new Set([owner.signing_key.key_id,v.source_key_id])); + let accessed=false;const observe=()=>{accessed=true;options.networkObserver?.();}; + const transport={requestRepair:async(url:string,raw:Uint8Array,until:number,o:unknown)=>{remaining();observe();return participant.transport.requestRepair(url,raw,Math.min(until,deadline),o as any);}} as OpenHTTPTransport; + const reader=new AckReceiptClient(participant.identity,encryption,{transport,statusObserver:item=>statuses.observe(item),allowLoopback:participant.transport.allow_loopback,limitPolicy:ACK_REPAIR_PROFILES[v.repair_profile as keyof typeof ACK_REPAIR_PROFILES]}); + try{ + let request:Obj; + if(held)request=decodeHeld(held); + else{ + remaining();observe();const node=(await participant.transport.requestNode(base,Math.min(deadline,monotonic()+15))).response,p=verifyNode(node); + if(p.status!=='active'||p.signing_key.key_id!==v.source_key_id||!equal(endpoint(p.base_url,participant.transport.allow_loopback),endpoint(base,participant.transport.allow_loopback)))fail('open_ack_mailbox_return_conflict'); + participant.acceptProviderControl(node);const targetRecord=await new OpenProviderClient(participant,encryption).proveTarget(node,new LookupBudget({maximum_seconds:Math.min(10,remaining())})); + const target={signing_key:targetRecord.payload.signing_key,encryption_key:targetRecord.payload.targetEncryptionKey},raw=canonicalBytes(node),digest=sha256(raw),nodeEntry={raw,ref:{namespace:'meta',key:digest,raw_sha256:digest,size:raw.length}}; + const statuses=new Map();for(const role of ['ack_root','ack_write','ack_offer_bootstrap']){const e=roles['historical.status.'+role];statuses.set(e.ref.raw_sha256,e);} + remaining();const prepared=await reader.prepareReturn(base,{targetNodeEntry:nodeEntry,expectedTarget:target,expectedAckSlot:wp.ack_slot,expectedOwner:owner,expectedMessageId:v.message_id,expectedEnvelopeRef:wp.envelope_ref,rootEntry:root,writeEntry:write,bootstrapEntry:bootstrap,knownStatuses:[...statuses.values()],archiveStatuses:authorityHistory(),timeout:30}); + request={message_id:v.message_id,envelope_ref:wp.envelope_ref,ack_slot:wp.ack_slot,owner,target,target_node_entry:nodeEntry,root_entry:root,write_entry:write,bootstrap_entry:bootstrap,binding_entry:{ref:prepared.source.binding.ref,raw:prepared.source.binding.raw},current_statuses:prepared.current_statuses.map(e=>({ref:e.ref,raw:e.raw})),read_until:prepared.source.read_until,retain_until:prepared.source.retain_until}; + const encode=(e:Obj)=>({ref:e.ref,raw_base64url:encodeBase64url(e.raw)}),bundle=canonicalBytes({schema_version:SCHEMA,base_url:base,repair_profile:v.repair_profile,request:Object.fromEntries(Object.entries(request).map(([n,e])=>[n,n==='current_statuses'?e.map(encode):n.endsWith('_entry')?encode(e):e]))},MAX_BUNDLE); + request=participant.providerStorage(db=>transaction(db,()=>{const old=db.prepare('SELECT * FROM open_mailbox_ack_returns WHERE message_id=?').get(v.message_id) as Obj|undefined;if(old)return decodeHeld(old); + if((db.prepare('SELECT count(*) AS n FROM open_mailbox_ack_returns').get() as Obj).n>=16)fail('open_ack_mailbox_return_capacity');db.prepare('INSERT INTO open_mailbox_ack_returns VALUES(?,?,?,?)').run(v.message_id,binding,bundle,sha256(bundle));return request; + })); + } + remaining();const result=await new SavedAckReceiptPublisher(delivery,reader).publishSaved(base,request,30,{archiveStatuses:authorityHistory(),knownDisclosureStatuses:statuses.load(new Set([participant.identity.key_id]))}); + return {state:'retained_at_ack_source',message_id:v.message_id,receipt_ref:result.source.inputs.receipt.ref,commit_ref:result.source.commit.ref,from_local_history:result.from_local_history,network_accessed:accessed}; + }finally{reader.close();} +} +export class MailboxReceiptJobs{ + readonly participant:OpenParticipant;readonly encryption:EncryptionIdentityDocument;readonly delivery:OpenDeliveryClient; + constructor(participant:OpenParticipant,encryption:EncryptionIdentityDocument,delivery:OpenDeliveryClient){ + this.participant=participant;this.encryption=encryption;this.delivery=delivery; + participant.providerStorage(db=>db.exec('CREATE TABLE IF NOT EXISTS open_mailbox_receipt_jobs(message_id TEXT PRIMARY KEY,body BLOB NOT NULL,body_sha256 TEXT NOT NULL,phase TEXT NOT NULL,result BLOB,result_sha256 TEXT,last_attempt INTEGER NOT NULL DEFAULT 0)')); + } + #body(row:Obj,name:string):Obj{const raw=row[name];if(!raw||sha256(raw)!==row[name+'_sha256'])fail('open_ack_mailbox_return_corrupt');return document(raw,8192) as Obj;} + connect(value:unknown):Obj{ + const v=document(value as any,4096) as Obj;if(v.schema_version!==ACK_CONNECT_SCHEMA)fail('open_invalid_ack_request'); + if(v.action==='register_mailbox_receipt_return'){ + const body=selection(v,this.participant,v.action),raw=canonicalBytes(body,4096); + this.participant.providerStorage(db=>transaction(db,()=>{const old=db.prepare('SELECT message_id,body,body_sha256,phase,result,result_sha256 FROM open_mailbox_receipt_jobs WHERE message_id=?').get(v.message_id) as Obj|undefined; + if(old){if(!equal(this.#body(old,'body'),body))fail('open_ack_mailbox_return_conflict');return;} + if((db.prepare('SELECT count(*) AS n FROM open_mailbox_receipt_jobs').get() as Obj).n>=16)fail('open_ack_mailbox_return_capacity');db.prepare("INSERT INTO open_mailbox_receipt_jobs(message_id,body,body_sha256,phase) VALUES(?,?,?,'pending')").run(v.message_id,raw,sha256(raw)); + }));return {state:'registered',message_id:v.message_id,network_accessed:false,receipt_return:'ordinary_receive_after_saved_original_authority_check'}; + } + if(v.action==='list_mailbox_receipt_returns'){objectFields(v,['schema_version','action']);const rows=this.participant.providerStorage(db=>db.prepare('SELECT message_id,phase FROM open_mailbox_receipt_jobs ORDER BY message_id LIMIT 17').all()) as Obj[]; + if(rows.length>16)fail('open_ack_mailbox_return_capacity');return {state:'configured',returns:rows.map(r=>({message_id:r.message_id,state:r.phase})),network_accessed:false};} + objectFields(v,['schema_version','action','message_id']);message(v.message_id); + if(v.action==='remove_mailbox_receipt_return'){this.participant.providerStorage(db=>db.prepare('DELETE FROM open_mailbox_receipt_jobs WHERE message_id=?').run(v.message_id));return {state:'removed',message_id:v.message_id,network_accessed:false};} + if(v.action!=='inspect_mailbox_receipt_return')fail('open_invalid_ack_request'); + const row=this.participant.providerStorage(db=>db.prepare('SELECT message_id,body,body_sha256,phase,result,result_sha256 FROM open_mailbox_receipt_jobs WHERE message_id=?').get(v.message_id)) as Obj|undefined;if(!row)fail('open_ack_mailbox_return_missing'); + return {state:row.phase,selection:this.#body(row,'body'),result:row.result===null?null:this.#body(row,'result'),network_accessed:false,source_rechecked:false}; + } + async poll(result:Obj,deadline:number,attempted:Set):Promise{ + if(attempted.size>=2||monotonic()>=deadline)return; + // Keep nanosecond scheduling timestamps in SQLite; they exceed JS safe integers. + const rows=this.participant.providerStorage(db=>db.prepare("SELECT j.message_id,j.body,j.body_sha256,j.phase,j.result,j.result_sha256 FROM open_mailbox_receipt_jobs AS j JOIN open_delivery_inbox AS i ON i.message_id=j.message_id WHERE j.phase='pending' AND i.phase='saved' ORDER BY j.last_attempt,j.message_id LIMIT 3").all()) as Obj[]; + const row=rows.find(r=>!attempted.has(r.message_id));if(!row)return;const mid=row.message_id;attempted.add(mid); + this.participant.providerStorage(db=>db.prepare('UPDATE open_mailbox_receipt_jobs SET last_attempt=? WHERE message_id=?').run(BigInt(Date.now())*1000000n,mid)); + const store=(phase:string,v:Obj)=>{const raw=canonicalBytes(v,8192);this.participant.providerStorage(db=>db.prepare('UPDATE open_mailbox_receipt_jobs SET phase=?,result=?,result_sha256=? WHERE message_id=? AND body_sha256=?').run(phase,raw,sha256(raw),mid,row.body_sha256));}; + try{ + const selected=selection(this.#body(row,'body'),this.participant,'register_mailbox_receipt_return');if(selected.message_id!==mid)fail('open_ack_mailbox_return_corrupt'); + const returned=await returnMailboxReceipt(this.participant,this.encryption,this.delivery,{...selected,action:'return_mailbox_receipt'},{deadline,networkObserver:()=>{result.network_accessed=true;}}); + store('complete',returned);(result.receipt_returns??=[]).push({message_id:mid,state:returned.state,from_local_history:returned.from_local_history});result.network_accessed ||=returned.network_accessed; + }catch(error){const e=error as any,code=e?.code??'network_storage_unavailable',retryable=e?.retryable===true,phase=['repair_reconciliation_required','repair_saved_reconciliation_required'].includes(code)?'reconciliation_required':'pending'; + store(phase,{state:phase,code,retryable});result.errors.push({message_id:mid,operation:'return_mailbox_receipt',code,retryable});} + } +} diff --git a/clients/typescript/network/open-mailbox-receivers.ts b/clients/typescript/network/open-mailbox-receivers.ts index f99cb55..faf0b7b 100644 --- a/clients/typescript/network/open-mailbox-receivers.ts +++ b/clients/typescript/network/open-mailbox-receivers.ts @@ -12,6 +12,7 @@ import {verifyMailboxFeedBootstrap} from './open-repair-mailbox-authority.ts'; import {verifySourceNodeOriginal} from './open-repair-original.ts'; import {endpoint} from './open-transport.ts'; import {transaction} from './io.ts'; +import {MailboxReceiptJobs} from './open-mailbox-receipts.ts'; type Obj=Record; export const MAILBOX_CONNECT_SCHEMA='memory-vault-open-mailbox-connect/v1'; function fail(code:string):never{throw new RepairError(code);} @@ -47,6 +48,8 @@ export class RegisteredMailboxReceivers{ } async receive(limit:number):Promise{ const deadline=performance.now()/1000+60,result=await this.#delivery.receive(limit,{pendingOnly:true,deadline}); + const jobs=new MailboxReceiptJobs(this.#participant,this.#encryption,this.#delivery),attempted=new Set(); + await jobs.poll(result,Math.min(deadline,performance.now()/1000+30),attempted); const rows=this.#participant.providerStorage(db=>db.prepare('SELECT receiver_id,body FROM open_mailbox_receivers ORDER BY last_attempt,receiver_id LIMIT 17').all()) as Obj[]; if(rows.length>16)fail('open_mailbox_receiver_capacity'); for(const row of rows){let remaining=deadline-performance.now()/1000;if(result.messages.length>=limit||remaining<=0)break; @@ -62,6 +65,7 @@ export class RegisteredMailboxReceivers{ finally{reader?.close();} } if(result.messages.length!sameEntry(source.inputs[name as keyof typeof source.inputs],entries[name])))fail('repair_saved_corrupt'); return Object.freeze({source,originals:Object.freeze([...originals.values()].map(e=>Object.freeze({ref:e.ref,get raw(){return Uint8Array.from(e.raw);}}))),from_local_history:true}); } - async publishSaved(baseUrl:string,value:unknown,timeout=30):Promise{ + async publishSaved(baseUrl:string,value:unknown,timeout=30,history:{archiveStatuses?:readonly unknown[];knownDisclosureStatuses?:readonly unknown[]}={}):Promise{ + objectFields(history,[...(Object.hasOwn(history,'archiveStatuses')?['archiveStatuses']:[]),...(Object.hasOwn(history,'knownDisclosureStatuses')?['knownDisclosureStatuses']:[])]); if(typeof timeout!=='number'||!Number.isFinite(timeout)||timeout<=0||timeout>60)fail('repair_invalid_deadline'); const client=this.#client,policy=this.#policy,budget=new RepairBudget(policy),r=this.#request(value,budget); const receipt=await this.#delivery.savedReceiptForAck(r.message_id,r.owner,r.envelope_ref),now=client.timestamp; @@ -157,7 +158,7 @@ export class SavedAckReceiptPublisher{ db.prepare('UPDATE open_repair_saved_acks SET '+column+'=?,attempted=1 WHERE slot_digest=?').run(raw,slotDigest); })); }; - const options={currentStatuses:[...r.current_statuses,entries.status],readUntil:r.read_until,retainUntil:r.retain_until, + const options={currentStatuses:[...r.current_statuses,entries.status],archiveStatuses:history.archiveStatuses??[],knownDisclosureStatuses:history.knownDisclosureStatuses??[],readUntil:r.read_until,retainUntil:r.retain_until, targetNodeEntry:r.target_node_entry,expectedTarget:r.target,expectedAckSlot:r.ack_slot,expectedOwner:r.owner,expectedMessageId:r.message_id, expectedEnvelopeRef:r.envelope_ref,rootEntry:r.root_entry,writeEntry:r.write_entry,bootstrapEntry:r.bootstrap_entry,timeout,journal}; const result=resume===null?await client.put(baseUrl,entries.receipt,entries.disclosure,entries.put,options): diff --git a/clients/typescript/network/package.json b/clients/typescript/network/package.json index 709a521..41aaa86 100644 --- a/clients/typescript/network/package.json +++ b/clients/typescript/network/package.json @@ -49,7 +49,8 @@ "./open-repair-mailbox-inbox": "./open-repair-mailbox-inbox.ts", "./open-mailbox-client": "./open-mailbox-client.ts", "./open-mailbox-journal": "./open-mailbox-journal.ts", - "./open-mailbox-receivers": "./open-mailbox-receivers.ts" + "./open-mailbox-receivers": "./open-mailbox-receivers.ts", + "./open-mailbox-receipts": "./open-mailbox-receipts.ts" }, "types": "./crypto.ts", "engines": { diff --git a/memory_vault.py b/memory_vault.py index 0b5d092..d7b0e14 100644 --- a/memory_vault.py +++ b/memory_vault.py @@ -60,7 +60,7 @@ from typing import Any, Callable, Iterable, Mapping, Sequence -VERSION = "0.28.0-alpha.0.36" +VERSION = "0.28.0-alpha.0.37" REQUEST_SCHEMA = "universal-agent-memory-request/v1" RESULT_SCHEMA = "universal-agent-memory-result/v1" RECORD_SCHEMA = "universal-memory-record/v1" diff --git a/packaging/CLIENT_README.md b/packaging/CLIENT_README.md index 9704411..0fa2a7f 100644 --- a/packaging/CLIENT_README.md +++ b/packaging/CLIENT_README.md @@ -1,4 +1,13 @@ -# Memory Vault v0.28.0-alpha.0.36 — authorized full client +# Memory Vault v0.28.0-alpha.0.37 — authorized full client + +Native TypeScript recipients can retain an independently authorized receipt +return for a cold-mailbox message. Ordinary Agent `receive` then returns the +actual durably saved receipt, keeping the exact upload across lost replies and +process restarts. Python and TypeScript share the same retained jobs, pending +requests and completed history. The sender can independently recover that +receipt and confirm its original send. Observed revocations block retries; +expired uncertain uploads stop for reconciliation. No new permission or source +selection is created by retrying. See `clients/typescript/network/README.md`. Native TypeScript recipients can register an explicitly authorized ordinary mailbox and recover messages and selected memories with normal Agent `receive`, @@ -148,7 +157,7 @@ remain unfinished. There is no project-operated public seed or verified thousand-agent/global-availability result. Use the source-bound release record for actual validation and compare archive bytes with published checksums. -This full-client package targets **v0.28.0-alpha.0.36 open-delivery source**, +This full-client package targets **v0.28.0-alpha.0.37 open-delivery source**, not a stable-release or complete runtime-certification claim. Existing published versions remain immutable. Match the artifact's source and hashes to its manifest; this README does not establish installation or publication. The plugin is under diff --git a/packaging/PROTOCOL_README.md b/packaging/PROTOCOL_README.md index 0446a56..4c230e7 100644 --- a/packaging/PROTOCOL_README.md +++ b/packaging/PROTOCOL_README.md @@ -1,4 +1,13 @@ -# Memory Vault v0.28.0-alpha.0.36 — independent protocol +# Memory Vault v0.28.0-alpha.0.37 — independent protocol + +Native TypeScript recipients can retain an independently authorized receipt +return for a cold-mailbox message. Ordinary Agent `receive` then returns the +actual durably saved receipt, keeping the exact upload across lost replies and +process restarts. Python and TypeScript share the same retained jobs, pending +requests and completed history. The sender can independently recover that +receipt and confirm its original send. Observed revocations block retries; +expired uncertain uploads stop for reconciliation. No new permission or source +selection is created by retrying. See `clients/typescript/network/README.md`. Native TypeScript recipients can register an explicitly authorized ordinary mailbox and recover messages and selected memories with normal Agent `receive`, @@ -165,7 +174,7 @@ require independently configured providers; reading metadata cannot grant authority or enroll keys. The complete Python client and executable synthetic review kit are separate -artifacts described in `docs/RELEASE.md`. This package targets v0.28.0-alpha.0.36; +artifacts described in `docs/RELEASE.md`. This package targets v0.28.0-alpha.0.37; previous published versions remain immutable. The optional native network adds communication around existing records without changing canonical record/v1 or share-v1. It has no MCP, A2A, Matrix, Nostr or Graphiti adapter or compatibility diff --git a/packaging/RELEASE_NOTES.md b/packaging/RELEASE_NOTES.md index 7af802a..4b49e7a 100644 --- a/packaging/RELEASE_NOTES.md +++ b/packaging/RELEASE_NOTES.md @@ -1,4 +1,13 @@ -# Memory Vault v0.28.0-alpha.0.36 — native remote mailbox memory recovery +# Memory Vault v0.28.0-alpha.0.37 — automatic native mailbox receipt returns + +Native TypeScript recipients can retain an independently authorized receipt +return for a cold-mailbox message. Ordinary Agent `receive` then returns the +actual durably saved receipt, keeping the exact upload across lost replies and +process restarts. Python and TypeScript share the same retained jobs, pending +requests and completed history. The sender can independently recover that +receipt and confirm its original send. Observed revocations block retries; +expired uncertain uploads stop for reconciliation. No new permission or source +selection is created by retrying. See `clients/typescript/network/README.md`. Native TypeScript recipients can register an explicitly authorized ordinary mailbox and recover messages and selected memories with normal Agent `receive`, @@ -174,7 +183,7 @@ TypeScript recognizes the mailbox replica proof and status profiles. Participant operate their own authorized nodes; no central authority or project-operated public seed is required or provided. -Use the [quickstart](https://github.com/qh-work/memory-vault-sync/blob/v0.28.0-alpha.0.36/docs/OPEN_NETWORK_QUICKSTART.md) -and [mailbox replica guide](https://github.com/qh-work/memory-vault-sync/blob/v0.28.0-alpha.0.36/docs/OPEN_ACK_RECOVERY.md#receive-messages-and-shared-memories-from-a-replica-development-after-alpha025). +Use the [quickstart](https://github.com/qh-work/memory-vault-sync/blob/v0.28.0-alpha.0.37/docs/OPEN_NETWORK_QUICKSTART.md) +and [mailbox replica guide](https://github.com/qh-work/memory-vault-sync/blob/v0.28.0-alpha.0.37/docs/OPEN_ACK_RECOVERY.md#receive-messages-and-shared-memories-from-a-replica-development-after-alpha025). Preserve existing identity and state files when installing. The archives contain implementation, public documentation and wholly synthetic fixtures only. diff --git a/packaging/REVIEW_README.md b/packaging/REVIEW_README.md index e49b3c6..0374631 100644 --- a/packaging/REVIEW_README.md +++ b/packaging/REVIEW_README.md @@ -1,4 +1,13 @@ -# Memory Vault v0.28.0-alpha.0.36 independent review kit +# Memory Vault v0.28.0-alpha.0.37 independent review kit + +Native TypeScript recipients can retain an independently authorized receipt +return for a cold-mailbox message. Ordinary Agent `receive` then returns the +actual durably saved receipt, keeping the exact upload across lost replies and +process restarts. Python and TypeScript share the same retained jobs, pending +requests and completed history. The sender can independently recover that +receipt and confirm its original send. Observed revocations block retries; +expired uncertain uploads stop for reconciliation. No new permission or source +selection is created by retrying. See `clients/typescript/network/README.md`. Native TypeScript recipients can register an explicitly authorized ordinary mailbox and recover messages and selected memories with normal Agent `receive`, diff --git a/plugins/memory-vault-client/.codex-plugin/plugin.json b/plugins/memory-vault-client/.codex-plugin/plugin.json index a74930e..fd07619 100644 --- a/plugins/memory-vault-client/.codex-plugin/plugin.json +++ b/plugins/memory-vault-client/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "memory-vault-client", - "version": "0.28.0-alpha.0.36", + "version": "0.28.0-alpha.0.37", "description": "Full authorized client for the open memory protocol: MCP, visible-turn capture, queued signed sync, host adapters and recovery.", "author": { "name": "Memory Vault contributors" diff --git a/scripts/build_client_plugin.py b/scripts/build_client_plugin.py index 834b99f..184be1f 100644 --- a/scripts/build_client_plugin.py +++ b/scripts/build_client_plugin.py @@ -146,7 +146,7 @@ "clients/typescript/network/open-repair-mailbox-member.ts", "clients/typescript/network/open-repair-mailbox-feed.ts", "clients/typescript/network/open-repair-mailbox-read.ts", - "clients/typescript/network/open-repair-mailbox-inbox.ts", "clients/typescript/network/open-mailbox-client.ts", "clients/typescript/network/open-mailbox-journal.ts", "clients/typescript/network/open-mailbox-receivers.ts", + "clients/typescript/network/open-repair-mailbox-inbox.ts", "clients/typescript/network/open-mailbox-client.ts", "clients/typescript/network/open-mailbox-journal.ts", "clients/typescript/network/open-mailbox-receivers.ts", "clients/typescript/network/open-mailbox-receipts.ts", "clients/typescript/network/open-repair-ack.ts", "clients/typescript/network/open-repair-probe.ts", "clients/typescript/network/open-repair-proof.ts", diff --git a/scripts/build_release.py b/scripts/build_release.py index 11634fa..18887cf 100644 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -123,7 +123,7 @@ "tests/test_open_repair_roundtrip.py", "tests/test_open_repair_receipt.py", "tests/test_open_repair_receipt_typescript.py", - "tests/test_open_repair_mailbox_authority_typescript.py", "tests/test_open_repair_mailbox_member_typescript.py", "tests/test_open_mailbox_client_typescript.py", + "tests/test_open_repair_mailbox_authority_typescript.py", "tests/test_open_repair_mailbox_member_typescript.py", "tests/test_open_mailbox_client_typescript.py", "tests/test_open_mailbox_receipts_typescript.py", "tests/open_repair_index_fixtures.py", "tests/test_open_repair_stage.py", "tests/test_open_repair_index.py", @@ -248,7 +248,7 @@ "clients/typescript/network/open-repair-mailbox-member.ts", "clients/typescript/network/open-repair-mailbox-feed.ts", "clients/typescript/network/open-repair-mailbox-read.ts", - "clients/typescript/network/open-repair-mailbox-inbox.ts", "clients/typescript/network/open-mailbox-client.ts", "clients/typescript/network/open-mailbox-journal.ts", "clients/typescript/network/open-mailbox-receivers.ts", + "clients/typescript/network/open-repair-mailbox-inbox.ts", "clients/typescript/network/open-mailbox-client.ts", "clients/typescript/network/open-mailbox-journal.ts", "clients/typescript/network/open-mailbox-receivers.ts", "clients/typescript/network/open-mailbox-receipts.ts", "clients/typescript/network/open-repair-ack.ts", "clients/typescript/network/open-repair-probe.ts", "clients/typescript/network/open-repair-proof.ts", diff --git a/scripts/run_open_network_ci.py b/scripts/run_open_network_ci.py index ce01db6..5ef5617 100644 --- a/scripts/run_open_network_ci.py +++ b/scripts/run_open_network_ci.py @@ -54,7 +54,7 @@ "tests.test_open_repair_index_prepare", "tests.test_open_repair_index_prepare_admin", "tests.test_open_repair_provision", "tests.test_open_repair_onboarding", "tests.test_open_repair_bind_recovery", "tests.test_open_repair_remote_setup", "tests.test_open_repair_remote_provision", - "tests.test_open_repair_status_observer", "tests.test_open_repair_mailbox_resources", "tests.test_open_repair_copy_resources", "tests.test_open_repair_copy_prepare", "tests.test_open_repair_copy_state", "tests.test_open_repair_copy_service", "tests.test_open_repair_copy_upload", "tests.test_open_repair_copy_empty", "tests.test_open_repair_copy_occupied", "tests.test_open_repair_mailbox_activation", "tests.test_open_repair_mailbox_range", "tests.test_open_repair_mailbox_authority_typescript", "tests.test_open_repair_mailbox_member_typescript", "tests.test_open_mailbox_client_typescript", "tests.test_open_repair_mailbox_root", "tests.test_open_repair_mailbox_status", "tests.test_open_repair_mailbox_source", "tests.test_open_repair_mailbox_snapshot", "tests.test_open_repair_mailbox_copy", "tests.test_open_repair_mailbox_copy_authority", "tests.test_open_repair_mailbox_copy_upload", "tests.test_open_repair_mailbox_reservation", "tests.test_open_repair_mailbox_consent", "tests.test_open_mailbox_replica_receive", "tests.test_open_mailbox_receipt_jobs", "tests.test_open_ack_replica_send", "tests.test_open_ack_typescript_agent", "tests.test_open_ack_discovery_typescript", "tests.test_open_ack_discovery_agent", "tests.test_open_mailbox_copy_jobs", "tests.test_open_repair_mailbox_copy_admin", "tests.test_open_repair_mailbox_feed_copy", "tests.test_open_repair_mailbox_message_copy", + "tests.test_open_repair_status_observer", "tests.test_open_repair_mailbox_resources", "tests.test_open_repair_copy_resources", "tests.test_open_repair_copy_prepare", "tests.test_open_repair_copy_state", "tests.test_open_repair_copy_service", "tests.test_open_repair_copy_upload", "tests.test_open_repair_copy_empty", "tests.test_open_repair_copy_occupied", "tests.test_open_repair_mailbox_activation", "tests.test_open_repair_mailbox_range", "tests.test_open_repair_mailbox_authority_typescript", "tests.test_open_repair_mailbox_member_typescript", "tests.test_open_mailbox_client_typescript", "tests.test_open_mailbox_receipts_typescript", "tests.test_open_repair_mailbox_root", "tests.test_open_repair_mailbox_status", "tests.test_open_repair_mailbox_source", "tests.test_open_repair_mailbox_snapshot", "tests.test_open_repair_mailbox_copy", "tests.test_open_repair_mailbox_copy_authority", "tests.test_open_repair_mailbox_copy_upload", "tests.test_open_repair_mailbox_reservation", "tests.test_open_repair_mailbox_consent", "tests.test_open_mailbox_replica_receive", "tests.test_open_mailbox_receipt_jobs", "tests.test_open_ack_replica_send", "tests.test_open_ack_typescript_agent", "tests.test_open_ack_discovery_typescript", "tests.test_open_ack_discovery_agent", "tests.test_open_mailbox_copy_jobs", "tests.test_open_repair_mailbox_copy_admin", "tests.test_open_repair_mailbox_feed_copy", "tests.test_open_repair_mailbox_message_copy", "tests.test_open_provider_merge", "tests.test_continuation_trial", "tests.test_network_typescript_agent_network", "tests.test_network_packaging") EXPECTED = { @@ -244,7 +244,7 @@ def initialize(reports, mode, seed, partition_index=0, partition_count=1): "open-control.ts", "open-routing.ts", "open-state.ts", "client-config.ts", "transport-state.ts", "open-contact.ts", "open-contact-state.ts", "open-contact-client.ts", "open-provider.ts", "open-provider-client.ts", "open-blob.ts", "open-repair-wire.ts", "open-repair-history.ts", "open-repair-original.ts", "open-repair-resource.ts", - "open-repair-bootstrap.ts", "open-repair-status.ts", "open-repair-mailbox-range.ts", "open-repair-mailbox-authority.ts", "open-repair-mailbox-member.ts", "open-repair-mailbox-feed.ts", "open-repair-mailbox-read.ts", "open-repair-mailbox-inbox.ts", "open-mailbox-client.ts", "open-mailbox-journal.ts", "open-mailbox-receivers.ts", "open-repair-ack.ts", "open-capacity.ts", + "open-repair-bootstrap.ts", "open-repair-status.ts", "open-repair-mailbox-range.ts", "open-repair-mailbox-authority.ts", "open-repair-mailbox-member.ts", "open-repair-mailbox-feed.ts", "open-repair-mailbox-read.ts", "open-repair-mailbox-inbox.ts", "open-mailbox-client.ts", "open-mailbox-journal.ts", "open-mailbox-receivers.ts", "open-mailbox-receipts.ts", "open-repair-ack.ts", "open-capacity.ts", "open-repair-probe.ts", "open-repair-proof.ts", "open-repair-client.ts", "open-repair-offer-client.ts", "open-repair-receipt.ts", "open-ack-client.ts", "open-ack-status.ts", "open-ack-discovery.ts", "open-repair-admin.ts", "open-repair-bound.ts", "open-repair-empty.ts", "open-repair-occupied.ts", "open-delivery.ts", "open-delivery-control.ts", "open-delivery-client.ts", diff --git a/tests/test_network_packaging.py b/tests/test_network_packaging.py index 7740c28..6a19020 100644 --- a/tests/test_network_packaging.py +++ b/tests/test_network_packaging.py @@ -46,7 +46,7 @@ "open-repair-original.ts", "open-repair-resource.ts", "open-repair-bootstrap.ts", - "open-repair-status.ts", "open-repair-mailbox-range.ts", "open-repair-mailbox-authority.ts", "open-repair-mailbox-member.ts", "open-repair-mailbox-feed.ts", "open-repair-mailbox-read.ts", "open-repair-mailbox-inbox.ts", "open-mailbox-client.ts", "open-mailbox-journal.ts", "open-mailbox-receivers.ts", + "open-repair-status.ts", "open-repair-mailbox-range.ts", "open-repair-mailbox-authority.ts", "open-repair-mailbox-member.ts", "open-repair-mailbox-feed.ts", "open-repair-mailbox-read.ts", "open-repair-mailbox-inbox.ts", "open-mailbox-client.ts", "open-mailbox-journal.ts", "open-mailbox-receivers.ts", "open-mailbox-receipts.ts", "open-repair-ack.ts", "open-repair-probe.ts", "open-repair-proof.ts", "open-repair-client.ts", "open-ack-client.ts", "open-ack-status.ts", "open-ack-discovery.ts", "open-repair-admin.ts", "open-repair-bound.ts", "open-repair-empty.ts", "open-repair-occupied.ts", "open-repair-offer-client.ts", "open-repair-receipt.ts")} @@ -102,7 +102,7 @@ def test_new_sdk_docs_and_review_fixtures_are_explicit_public_paths(self): self.assertEqual(len(documents), len(set(documents))) self.assertEqual(len(review), len(set(review))) self.assertGreaterEqual(len(review), 39) - self.assertEqual(len(TS_NETWORK), 67) + self.assertEqual(len(TS_NETWORK), 68) self.assertTrue(RUNTIME_DATA <= set(documents)) self.assertTrue(TS_NETWORK <= set(documents)) self.assertTrue(TS_ENDPOINT_TESTS <= set(review)) @@ -122,7 +122,7 @@ def test_new_sdk_docs_and_review_fixtures_are_explicit_public_paths(self): "tests/test_open_repair_status.py", "tests/test_open_repair_status_typescript.py", "tests/open_repair_ack_fixtures.py", "tests/test_open_repair_ack.py", "tests/test_open_repair_ack_typescript.py", "tests/test_open_repair_state.py", "tests/test_open_repair_mailbox_resources.py", "tests/test_open_repair_copy_resources.py", - "tests/test_open_repair_copy_prepare.py", "tests/test_open_repair_copy_state.py", "tests/test_open_repair_copy_service.py", "tests/test_open_repair_copy_upload.py", "tests/test_open_repair_copy_empty.py", "tests/test_open_repair_mailbox_activation.py", "tests/test_open_repair_mailbox_range.py", "tests/test_open_repair_mailbox_authority_typescript.py", "tests/test_open_repair_mailbox_member_typescript.py", "tests/test_open_mailbox_client_typescript.py", "tests/test_open_repair_mailbox_root.py", "tests/test_open_repair_mailbox_status.py", "tests/test_open_repair_mailbox_source.py", "tests/test_open_repair_mailbox_snapshot.py", "tests/test_open_repair_mailbox_copy.py", "tests/test_open_repair_mailbox_copy_authority.py", "tests/test_open_repair_mailbox_copy_upload.py", "tests/test_open_repair_mailbox_reservation.py", "tests/test_open_repair_mailbox_consent.py", "tests/test_open_mailbox_replica_receive.py", "tests/test_open_mailbox_receipt_jobs.py", "tests/test_open_ack_replica_send.py", "tests/test_open_mailbox_copy_jobs.py", "tests/test_open_repair_mailbox_copy_admin.py", "tests/test_open_repair_mailbox_feed_copy.py", "tests/test_open_repair_mailbox_message_copy.py", + "tests/test_open_repair_copy_prepare.py", "tests/test_open_repair_copy_state.py", "tests/test_open_repair_copy_service.py", "tests/test_open_repair_copy_upload.py", "tests/test_open_repair_copy_empty.py", "tests/test_open_repair_mailbox_activation.py", "tests/test_open_repair_mailbox_range.py", "tests/test_open_repair_mailbox_authority_typescript.py", "tests/test_open_repair_mailbox_member_typescript.py", "tests/test_open_mailbox_client_typescript.py", "tests/test_open_mailbox_receipts_typescript.py", "tests/test_open_repair_mailbox_root.py", "tests/test_open_repair_mailbox_status.py", "tests/test_open_repair_mailbox_source.py", "tests/test_open_repair_mailbox_snapshot.py", "tests/test_open_repair_mailbox_copy.py", "tests/test_open_repair_mailbox_copy_authority.py", "tests/test_open_repair_mailbox_copy_upload.py", "tests/test_open_repair_mailbox_reservation.py", "tests/test_open_repair_mailbox_consent.py", "tests/test_open_mailbox_replica_receive.py", "tests/test_open_mailbox_receipt_jobs.py", "tests/test_open_ack_replica_send.py", "tests/test_open_mailbox_copy_jobs.py", "tests/test_open_repair_mailbox_copy_admin.py", "tests/test_open_repair_mailbox_feed_copy.py", "tests/test_open_repair_mailbox_message_copy.py", "tests/test_open_repair_probe.py", "tests/test_open_repair_probe_typescript.py", "tests/test_open_repair_offer_client_typescript.py", "tests/test_open_repair_put_client_typescript.py", "tests/test_open_repair_receipt_typescript.py", @@ -188,7 +188,7 @@ def test_new_sdk_docs_and_review_fixtures_are_explicit_public_paths(self): "clients/typescript/network/open-repair-wire.ts", "clients/typescript/network/open-repair-history.ts", "clients/typescript/network/open-repair-original.ts", "clients/typescript/network/open-repair-resource.ts", "clients/typescript/network/open-repair-bootstrap.ts", "clients/typescript/network/open-repair-status.ts", - "clients/typescript/network/open-repair-mailbox-range.ts", "clients/typescript/network/open-repair-mailbox-authority.ts", "clients/typescript/network/open-repair-mailbox-member.ts", "clients/typescript/network/open-repair-mailbox-feed.ts", "clients/typescript/network/open-repair-mailbox-read.ts", "clients/typescript/network/open-repair-mailbox-inbox.ts", "clients/typescript/network/open-mailbox-client.ts", "clients/typescript/network/open-mailbox-journal.ts", "clients/typescript/network/open-mailbox-receivers.ts", + "clients/typescript/network/open-repair-mailbox-range.ts", "clients/typescript/network/open-repair-mailbox-authority.ts", "clients/typescript/network/open-repair-mailbox-member.ts", "clients/typescript/network/open-repair-mailbox-feed.ts", "clients/typescript/network/open-repair-mailbox-read.ts", "clients/typescript/network/open-repair-mailbox-inbox.ts", "clients/typescript/network/open-mailbox-client.ts", "clients/typescript/network/open-mailbox-journal.ts", "clients/typescript/network/open-mailbox-receivers.ts", "clients/typescript/network/open-mailbox-receipts.ts", "clients/typescript/network/open-repair-ack.ts", "clients/typescript/network/open-capacity.ts", "clients/typescript/network/open-repair-probe.ts", "clients/typescript/network/open-repair-proof.ts", "clients/typescript/network/open-repair-client.ts", "clients/typescript/network/open-ack-client.ts", "clients/typescript/network/open-ack-status.ts", "clients/typescript/network/open-ack-discovery.ts", diff --git a/tests/test_open_delivery_http.py b/tests/test_open_delivery_http.py index 0e53e37..d0acd5a 100644 --- a/tests/test_open_delivery_http.py +++ b/tests/test_open_delivery_http.py @@ -822,6 +822,9 @@ def corrupt_envelope(reference): self.assertGreater(status_count,0) self.assertEqual(recipient_db.execute('SELECT count(*) FROM open_mailbox_setup_statuses').fetchone()[0],status_count) self.assertEqual(delivery._inbox(sent['message_id'])['phase'],'saved') + # This recovery session is finished before a different runtime + # reopens the same recipient state for independent receipt return. + recipient_context.close() def _remote_message_admission(self, source, owner_status, slot, slot_entries, owner, sender_encryption, sent, now, limits, envelope): import hashlib diff --git a/tests/test_open_mailbox_receipts_typescript.py b/tests/test_open_mailbox_receipts_typescript.py new file mode 100644 index 0000000..8f010ee --- /dev/null +++ b/tests/test_open_mailbox_receipts_typescript.py @@ -0,0 +1,115 @@ +"""Actual cold saved memory to native ordinary-receive receipt return.""" +import base64 +import json +import subprocess +import unittest +import sqlite3 +import time +from pathlib import Path +from unittest.mock import patch +from tests import test_open_delivery_http as fixture +from tests import test_open_ack_typescript_agent as runtime +from memory_vault_open_client import ACK_CONNECT_SCHEMA + +DRIVER=runtime.DRIVER.replace("const agent=new Agent", "if(input.clock_offset){const clock=Date.now;Date.now=()=>clock()+input.clock_offset*1000;}\nconst agent=new Agent").replace("if(name==='requestNode'", """if(name==='requestRepair'&&input.lose_reply&&JSON.parse(Buffer.from(args[1]).toString()).kind==='ack.put_request'&&JSON.parse(Buffer.from(result).toString()).kind==='ack.put_response'){ + const error=Error('synthetic lost ACK reply');error.code='synthetic_lost_ack_reply';throw error; + } + if(name==='requestNode'""") + +class NativeMailboxReceiptTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + runtime.NativeAckAgentTests.setUpClass.__func__(cls) + (cls.fixture/'driver.mjs').write_text(DRIVER) + + def native(self,agent,requests,*,lose_reply=False,no_network=False,clock_offset=0): + run=subprocess.run([self.node,'--experimental-strip-types',str(self.fixture/'driver.mjs')],cwd=self.fixture, + input=json.dumps(dict(client_config=str(agent.client_config),network_config=str(agent.network_config),requests=requests,lose_reply=lose_reply,no_network=no_network,clock_offset=clock_offset)).encode(),stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=75) + self.assertEqual(run.returncode,0,run.stderr.decode(errors='replace')[-2500:]);out=json.loads(run.stdout);self.assertEqual(out['subprocessCalls'],0) + state=Path(json.loads(Path(agent.network_config).read_bytes())['state_directory'])/'network.sqlite3' + db=sqlite3.connect(state) + try:self.assertEqual(db.execute('PRAGMA integrity_check').fetchall(),[('ok',)]) + finally:db.close() + for row in out['results']:self.assertTrue(row['ok'],(row,run.stderr.decode(errors='replace')[-2000:])) + self.assertNotIn('network_storage_unavailable',json.dumps(out),(out,run.stderr.decode(errors='replace')[-2000:])) + if no_network:self.assertEqual(out['calls'],[]) + return out + + def test_native_ordinary_receive_returns_cold_memory_receipt_after_lost_reply(self): + self.check_return('resume') + + def test_expired_retained_revocation_stops_pending_native_return(self): + self.check_return('revoke') + + def test_expired_uncertain_return_stops_ordinary_polling(self): + self.check_return('expired') + + def check_return(self,mode): + h=fixture.MailboxStagingHTTPTests('test_cold_mailbox_returns_independent_receipt');h.setUp();self.addCleanup(h.doCleanups) + from memory_vault_trust import TrustStore + from memory_vault_client import ClientConfig + TrustStore(ClientConfig.load(h.b.client_config).trust_path).add(h.ai.public_descriptor()) + call=h.call + def share(agent,**request): + invitation=request.get('invitation',{}) + if agent is h.a and ((request['op']=='send' and not request.get('memory_ids')) or (invitation.get('action')=='prepare' and invitation.get('schema_version')==ACK_CONNECT_SCHEMA and not invitation.get('memory_ids'))): + m=call(h.a,op='remember',request_id='req_native_retained_return_memory',kind='observation',text='Synthetic cold mailbox return memory.') + if request['op']=='send':request=dict(request,memory_ids=[m['memory_id']]) + else:request=dict(request,invitation=dict(invitation,memory_ids=[m['memory_id']])) + return call(agent,**request) + h.call=share;visited=[] + def returned(host): + visited.append(True);write=json.loads(h.ack_configuration['ack.write_grant']['raw'])['payload'];mid=write['message_id'] + selected=dict(schema_version=ACK_CONNECT_SCHEMA,action='register_mailbox_receipt_return',message_id=mid,source_url=host.nodes[0]['payload']['base_url'],source_key_id=host.identities[0].key_id,repair_profile='receipt') + query=dict(schema_version=ACK_CONNECT_SCHEMA,action='inspect_mailbox_receipt_return',message_id=mid) + self.native(h.b,[dict(op='connect',invitation=selected),dict(op='connect',invitation=selected)],no_network=True) + first=self.native(h.b,[dict(op='receive',limit=1)],lose_reply=True)['results'][0]['result'] + self.assertTrue(any(e['code']=='synthetic_lost_ack_reply' for e in first['errors']),first);self.assertTrue(first['network_accessed']);self.assertFalse(first.get('receipt_returns')) + pending=self.native(h.b,[dict(op='connect',invitation=query)],no_network=True)['results'][0]['result'];self.assertEqual(pending['state'],'pending');self.assertEqual(pending['result']['code'],'synthetic_lost_ack_reply') + if mode=='expired': + expired=self.native(h.b,[dict(op='receive',limit=1)],clock_offset=120) + errors=expired['results'][0]['result']['errors'];self.assertTrue(any(e['code'] in {'repair_reconciliation_required','repair_saved_reconciliation_required'} for e in errors),errors) + self.assertFalse(any(c['kind']=='requestRepair' for c in expired['calls'])) + held=self.native(h.b,[dict(op='connect',invitation=query)],no_network=True)['results'][0]['result'];self.assertEqual(held['state'],'reconciliation_required') + stopped=self.native(h.b,[dict(op='receive',limit=1)],clock_offset=120);self.assertFalse(any(c['kind']=='requestRepair' for c in stopped['calls']));self.assertFalse(stopped['results'][0]['result'].get('receipt_returns')) + return + if mode=='revoke': + from memory_vault_open_provider import issue_status + from tests.test_open_repair_status import status_entry + from memory_vault_open_repair_status import authenticate_status_original + from memory_vault_open_repair_admin import _ReplicaStatusJournal + from memory_vault_open_repair_state import DEFAULT_POLICY + from memory_vault_open_repair_wire import RepairBudget + old=json.loads(h.ack_configuration['historical.status.ack_root']['raw'])['payload'];now=int(time.time()) + denied=status_entry(issue_status(h.ai,root=write['ack_slot']['root_key'],revision=99,entries=[dict(e,status='revoked') for e in old['entries']],issued_at=now-10,valid_until=now-1)) + checked=authenticate_status_original(denied,expected_root=write['ack_slot']['root_key'],expected_signing_key=h.ai.public_descriptor(),at=now-10,allowed_scopes=[dict(scope_kind=e['scope_kind'],scope_id=e['scope_id']) for e in old['entries']],policy=DEFAULT_POLICY,budget=RepairBudget(DEFAULT_POLICY)) + with h.b._network() as network: + with network.participant.state.db() as db:_ReplicaStatusJournal(db,write['ack_slot']['root_key'],original_source=True).observe(checked) + for _ in range(2): + refused=self.native(h.b,[dict(op='receive',limit=1)]) + result=refused['results'][0]['result'];self.assertFalse(result.get('receipt_returns'));self.assertTrue(any(e['code']=='repair_authority_revoked' for e in result['errors']),result) + self.assertFalse(any(c['kind']=='requestRepair' for c in refused['calls']),refused['calls']) + return + resumed=self.native(h.b,[dict(op='receive',limit=1)])['results'][0]['result'];self.assertEqual(resumed['receipt_returns'],[dict(message_id=mid,state='retained_at_ack_source',from_local_history=False)]) + completed=self.native(h.b,[dict(op='connect',invitation=query)],no_network=True)['results'][0]['result'];self.assertEqual(completed['state'],'complete') + again=self.native(h.b,[dict(op='receive',limit=1)]);self.assertFalse(again['results'][0]['result'].get('receipt_returns'));self.assertFalse(any(c['kind']=='requestRepair' for c in again['calls'])) + with h.b._network() as network: + inbox=network._delivery()._inbox(mid);saved=json.loads(inbox['result']);self.assertEqual(saved['content_kind'],'memory_transfer');self.assertEqual(saved['share']['records_added'],1) + inspected=network.connect(invitation=query);self.assertEqual(inspected,completed) + with patch.object(network.participant.transport,'request_repair',side_effect=AssertionError('completed native request repeated HTTP')): + py=network.connect(invitation=dict(selected,action='return_mailbox_receipt')) + self.assertTrue(py['from_local_history']);self.assertEqual(py['commit_ref'],completed['result']['commit_ref']) + chunks=[];cursor=None + while True: + q=dict(schema_version=ACK_CONNECT_SCHEMA,action='export_preparation',request_id=h.ack_preparation_request_id,part='owner_invitation') + if cursor is not None:q['cursor']=cursor + page=h.call(h.a,op='connect',invitation=q);chunks.append(base64.b64decode(page['bundle_chunk']));cursor=page['next_cursor'] + if cursor is None:break + recovered=h.call(h.a,op='connect',invitation=json.loads(b''.join(chunks)));self.assertEqual(recovered['message_id'],mid) + with h.a._network() as network: + with network.participant.state.db() as db:self.assertIsNotNone(db.execute('SELECT acknowledgement FROM open_delivery_outbox WHERE message_id=?',(mid,)).fetchone()[0]) + self.native(h.b,[dict(op='connect',invitation=dict(query,action='remove_mailbox_receipt_return')),dict(op='connect',invitation=selected)],no_network=True) + replay=self.native(h.b,[dict(op='receive',limit=1)]);self.assertTrue(replay['results'][0]['result']['receipt_returns'][0]['from_local_history']);self.assertFalse(any(c['kind']=='requestRepair' for c in replay['calls'])) + h._return_cold_ack=returned;h.test_cold_mailbox_returns_independent_receipt();self.assertEqual(visited,[True]) + +if __name__=='__main__':unittest.main()