<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Session Receipt</title>
<style>
/* Treeship product design system — see /DESIGN.md, design/tokens.json.
Document of record: warm paper, graphite ink, one bronze seal. Air-gapped:
the brand display serif (Fraunces, SIL OFL 1.1) is embedded below as a
base64 woff2 so the page renders identically offline with no CDN; body and
mono fall back to the system stack. */
@font-face{font-family:'Fraunces';font-style:normal;font-weight:300 500;font-display:swap;src:url(__FONT_FRAUNCES__) format('woff2')}
:root{
--serif:'Fraunces',Georgia,'Iowan Old Style','Times New Roman',serif;
--sans:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
--mono:ui-monospace,'SF Mono','JetBrains Mono',Menlo,monospace;
--paper:#F1F0EA;--panel:#EBEAE1;--ink:#1F2329;--muted:#5F646B;--faint:#9B9B93;
--hair:#DAD8CD;--hair-cool:#CBCED0;--steel:#37454F;
--bronze:#856733;--bronze-hi:#C6A972;--bronze-lo:#5E4923;
--pass:#3B6A4E;--fail:#9F4230;--warn:#8F5E1F;
--radius-sm:5px;--radius-md:12px;
/* legacy aliases so any untouched inline styles resolve on paper */
--font:var(--sans);--bg:var(--paper);--surface:var(--panel);--surface-2:var(--panel);
--surface-3:#E3E1D6;--border:var(--hair);--border-a:var(--hair-cool);
--text:var(--ink);--primary:var(--steel);--primary-s:rgba(55,69,79,.06);
--primary-m:rgba(55,69,79,.12);--ok:var(--pass);--ok-s:rgba(59,106,78,.09);
--risk:var(--fail);--risk-s:rgba(159,66,48,.09);--info:var(--steel);
--info-s:rgba(55,69,79,.07);--warn-s:rgba(143,94,31,.09);
}
*{box-sizing:border-box;margin:0;padding:0}
html{-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;scroll-behavior:smooth;scroll-padding-top:2rem}
body{font-family:var(--sans);font-size:14.5px;color:var(--ink);line-height:1.55;min-height:100dvh;
font-feature-settings:"tnum" 1,"ss01" 1;
background-color:var(--paper)}
a{color:var(--steel);text-decoration:none}a:hover{text-decoration:underline}
code{font-family:var(--mono);font-size:.82em;background:var(--panel);border:1px solid var(--hair);padding:1px 5px;border-radius:var(--radius-sm)}
.mono{font-family:var(--mono);font-variant-numeric:tabular-nums;letter-spacing:-.02em;font-size:.76rem}
.muted{color:var(--muted)}.faint{color:var(--faint)}
/* ── Sheet: quiet index + the document ── */
.sheet{display:flex;justify-content:center;gap:2.5rem;max-width:1040px;margin:0 auto;padding:2.5rem 1.5rem 6rem}
.index{width:158px;flex-shrink:0}
.index-inner{position:sticky;top:2.5rem}
.index-brand{font-family:var(--serif);font-size:.95rem;letter-spacing:-.01em;color:var(--ink);margin-bottom:1.25rem}
.index-brand .g{color:var(--bronze)}
.nav-label{font-family:var(--mono);font-size:.58rem;letter-spacing:.16em;text-transform:uppercase;color:var(--faint);margin:1.1rem 0 .5rem}
.nav-item{display:flex;align-items:center;justify-content:space-between;padding:.24rem 0;color:var(--muted);font-size:.78rem;cursor:pointer;text-decoration:none;transition:color .15s;position:relative;line-height:1.4}
.nav-item:hover{color:var(--ink);text-decoration:none}
.nav-item.active{color:var(--ink);font-weight:500}
.nav-item.active::before{content:'';position:absolute;left:-.8rem;top:50%;transform:translateY(-50%);width:2px;height:13px;background:var(--bronze);border-radius:1px}
.nav-count{font-family:var(--mono);font-size:.62rem;color:var(--faint)}
.nav-item.active .nav-count{color:var(--bronze)}
.doc{flex:1;min-width:0;max-width:680px}
@media(max-width:820px){.sheet{padding:1.25rem 1rem 4rem}.index{display:none}}
/* ── Faceplate ── */
.faceplate{display:flex;align-items:flex-end;gap:1rem;font-family:var(--mono);font-size:10px;
letter-spacing:.06em;color:var(--faint);padding-bottom:1rem;border-bottom:1px solid var(--ink);text-transform:uppercase}
.faceplate .ticks{flex:1;height:8px;background-image:repeating-linear-gradient(90deg,var(--hair-cool) 0 1px,transparent 1px 8px);
-webkit-mask-image:linear-gradient(90deg,transparent,#000 14%,#000 86%,transparent);mask-image:linear-gradient(90deg,transparent,#000 14%,#000 86%,transparent)}
.fp-actions{display:flex;gap:.85rem;text-transform:none;letter-spacing:0}
.fp-actions a,.fp-actions button{font-family:var(--mono);font-size:10px;color:var(--muted);background:none;border:none;cursor:pointer;padding:0;letter-spacing:.04em}
.fp-actions a:hover,.fp-actions button:hover{color:var(--ink);text-decoration:none}
/* ── Cover ── */
.cover{padding:2rem 0 0;animation:fade .4s ease both}
@keyframes fade{from{opacity:0}to{opacity:1}}
@media(prefers-reduced-motion:reduce){.cover{animation:none!important}}
.cover-top{display:flex;align-items:flex-start;gap:1.5rem}
.seal{width:60px;height:60px;flex:none}
.verdict-eyebrow{font-family:var(--mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--muted)}
.verdict{font-family:var(--serif);font-size:2rem;font-weight:400;line-height:1.05;letter-spacing:-.02em;margin-top:.35rem;display:flex;align-items:baseline;gap:.6rem}
.verdict .glyph{font-size:1.5rem}
.verdict.pass{color:var(--pass)}.verdict.fail{color:var(--fail)}
.verdict-note{font-size:.82rem;color:var(--muted);margin-top:.5rem;max-width:52ch}
.headline{font-family:var(--serif);font-size:1.5rem;font-weight:400;line-height:1.15;letter-spacing:-.02em;margin:1.75rem 0 .3rem}
.rec-id{font-family:var(--mono);font-size:.72rem;color:var(--faint);word-break:break-all}
/* honesty gradient */
.gradient{margin:1.5rem 0 0;border:1px solid var(--hair);border-radius:var(--radius-md);overflow:hidden}
.grad-row{display:flex;gap:.7rem;padding:.7rem 1rem;font-size:.8rem;line-height:1.5;border-top:1px solid var(--hair)}
.grad-row:first-child{border-top:none}
.grad-g{flex:none;width:15px;text-align:center;font-family:var(--mono)}
.grad-row.proven .grad-g{color:var(--pass)}
.grad-row.bounded .grad-g{color:var(--muted)}
.grad-k{font-weight:500;color:var(--ink)}
.grad-d{color:var(--muted)}
/* metrics */
.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(96px,1fr));gap:1.25rem;margin:1.75rem 0 0;
padding:1.25rem 0;border-top:1px solid var(--hair);border-bottom:1px solid var(--hair)}
.metric-label{font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);margin-bottom:.35rem}
.metric-value{font-family:var(--serif);font-size:1.5rem;font-weight:400;letter-spacing:-.02em;line-height:1}
.metric-sub{font-size:.66rem;color:var(--faint);margin-top:.3rem}
/* the story */
.story{display:grid;grid-template-columns:repeat(3,1fr);gap:1.5rem;margin-top:1.75rem}
@media(max-width:600px){.story{grid-template-columns:1fr;gap:1rem}}
.story-label{font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--faint);margin-bottom:.5rem}
.story-text{font-size:.82rem;color:var(--ink);line-height:1.55}
/* ── Bands (evidence sections) ── */
.band{padding:2.25rem 0 0;margin-top:2.25rem;border-top:1px solid var(--hair);scroll-margin-top:2rem}
.band-label{font-family:var(--mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--faint);font-weight:600}
.band-sub{font-size:.78rem;color:var(--muted);margin-top:.35rem;line-height:1.5;max-width:60ch}
.band-body{margin-top:1.25rem}
/* at-a-glance */
.brief{display:grid;grid-template-columns:repeat(4,1fr);gap:0;margin-top:1.75rem;border:1px solid var(--hair);border-radius:var(--radius-md);overflow:hidden}
@media(max-width:600px){.brief{grid-template-columns:repeat(2,1fr)}}
.brief-cell{padding:.9rem 1rem;border-left:1px solid var(--hair);text-decoration:none;color:var(--ink);display:block}
.brief-cell:first-child{border-left:none}
.brief-cell:hover{background:var(--panel);text-decoration:none}
.brief-cell.attn{background:rgba(143,94,31,.06)}
.brief-k{font-family:var(--mono);font-size:9px;letter-spacing:.1em;text-transform:uppercase;color:var(--faint);margin-bottom:.3rem}
.brief-v{font-size:.98rem;font-weight:600;letter-spacing:-.01em}
.brief-d{font-size:.66rem;color:var(--muted);margin-top:.25rem;line-height:1.4}
/* badges */
.badge{display:inline-flex;align-items:center;gap:4px;font-family:var(--mono);font-size:9.5px;font-weight:500;letter-spacing:.05em;text-transform:uppercase;padding:2px 7px;border-radius:99px;white-space:nowrap;border:1px solid var(--hair)}
.badge-ok{color:var(--pass)}.badge-warn{color:var(--warn)}.badge-risk{color:var(--fail)}
.badge-info{color:var(--steel)}.badge-muted{color:var(--muted)}
/* rows shared */
.row-list{border:1px solid var(--hair);border-radius:var(--radius-md);overflow:hidden}
.agent-card{display:flex;gap:.9rem;padding:1rem 1.1rem;border-top:1px solid var(--hair);align-items:flex-start}
.agent-card:first-child{border-top:none}
.agent-stripe{width:3px;border-radius:2px;align-self:stretch;flex-shrink:0}
.agent-info{flex:1;min-width:0}
.agent-name{font-weight:600;font-size:.9rem;margin-bottom:2px}
.agent-meta{font-family:var(--mono);font-size:.64rem;color:var(--faint);line-height:1.5}
.agent-stats{display:flex;gap:1rem;margin-top:.5rem;font-family:var(--mono);font-size:.68rem;color:var(--muted);align-items:center;flex-wrap:wrap}
.agent-bar{height:3px;border-radius:2px;background:var(--panel);margin-top:.55rem;overflow:hidden}
.agent-bar-fill{height:100%;border-radius:2px;opacity:.7}
/* timeline */
.tl-group-header{display:flex;align-items:center;gap:.5rem;padding:.55rem 1.1rem;font-family:var(--mono);font-size:.64rem;color:var(--muted);background:var(--panel);border-top:1px solid var(--hair)}
.tl-group-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
.tl-item{display:grid;grid-template-columns:48px 1fr auto;gap:.75rem;align-items:start;padding:.6rem 1.1rem;border-top:1px solid var(--hair)}
.tl-time{font-family:var(--mono);font-size:.66rem;color:var(--faint);padding-top:2px}
.tl-label{font-size:.8rem;font-weight:500}
.tl-dot{width:6px;height:6px;border-radius:50%;display:inline-block}
/* commands */
.cmd-card{padding:.85rem 1.1rem;border-top:1px solid var(--hair)}
.cmd-card:first-child{border-top:none}
.cmd-header{display:flex;justify-content:space-between;align-items:flex-start;gap:1rem}
.cmd-shell{font-family:var(--mono);font-size:.76rem;color:var(--ink);margin-bottom:4px;word-break:break-all}
.cmd-meta{display:flex;gap:.75rem;align-items:center;flex-wrap:wrap;font-family:var(--mono);font-size:.64rem;color:var(--faint)}
.exit-pill{font-family:var(--mono);font-size:.62rem;font-weight:600;padding:2px 7px;border-radius:var(--radius-sm);flex-shrink:0;border:1px solid var(--hair)}
.exit-ok{color:var(--pass)}.exit-fail{color:var(--fail)}
/* tables */
table.files{width:100%;border-collapse:collapse}
table.files th{font-family:var(--mono);font-size:9px;letter-spacing:.09em;text-transform:uppercase;color:var(--faint);padding:.6rem 1.1rem;text-align:left;border-bottom:1px solid var(--hair)}
table.files td{padding:.55rem 1.1rem;border-bottom:1px solid var(--hair);font-size:.78rem}
table.files tr:last-child td{border-bottom:none}
/* trust chain */
.trust-step{display:flex;align-items:center;gap:.9rem;padding:.8rem 1.1rem;border-top:1px solid var(--hair)}
.trust-step:first-child{border-top:none}
.trust-icon{width:24px;height:24px;border-radius:50%;display:grid;place-items:center;flex-shrink:0;font-size:.7rem;border:1px solid var(--hair)}
.trust-icon.ok{color:var(--pass)}.trust-icon.muted{color:var(--faint)}
.trust-label{font-size:.82rem;font-weight:500}
.trust-desc{font-size:.7rem;color:var(--muted)}
/* findings */
.finding{display:grid;grid-template-columns:22px 1fr auto;gap:.75rem;align-items:start;padding:.9rem 1.1rem;border-top:1px solid var(--hair)}
.finding:first-child{border-top:none}
/* checks */
.check-row{display:flex;align-items:center;gap:.75rem;padding:.5rem 1.1rem;border-top:1px solid var(--hair);font-size:.8rem}
.check-row:first-child{border-top:none}
.check-name{min-width:120px;font-family:var(--mono);font-size:.68rem}
.check-detail{color:var(--muted);flex:1;font-size:.72rem}
.confirm-row{display:flex;align-items:center;gap:.6rem;padding:.6rem 1.1rem;font-size:.78rem;color:var(--pass);border-top:1px solid var(--hair)}
.confirm-row:first-child{border-top:none}
.confirm-icon{width:16px;height:16px;flex-shrink:0;text-align:center;font-family:var(--mono);font-size:.6rem;color:var(--pass)}
.pad{padding:1rem 1.1rem}
/* educational */
.edu-grid{display:grid;grid-template-columns:1fr;gap:0;border:1px solid var(--hair);border-radius:var(--radius-md);overflow:hidden}
.edu-card{padding:1.1rem;border-top:1px solid var(--hair)}
.edu-card:first-child{border-top:none}
.edu-num{font-family:var(--mono);font-size:.58rem;color:var(--bronze);letter-spacing:.08em;margin-bottom:.4rem}
.edu-title{font-family:var(--serif);font-weight:400;font-size:1rem;margin-bottom:.4rem}
.edu-text{font-size:.76rem;color:var(--muted);line-height:1.55}
/* raw */
.raw-pre{background:var(--panel);border:1px solid var(--hair);border-radius:var(--radius-md);padding:1.25rem;font-family:var(--mono);font-size:.68rem;color:var(--ink);line-height:1.8;overflow-x:auto;max-height:480px;position:relative}
.copy-btn{position:absolute;top:.7rem;right:.7rem;font-family:var(--mono);font-size:.6rem;padding:.28rem .6rem;border-radius:var(--radius-sm);border:1px solid var(--hair);background:var(--paper);color:var(--muted);cursor:pointer}
.copy-btn:hover{color:var(--ink)}
.footer{color:var(--faint);font-size:.68rem;padding:2.5rem 0 0;border-top:1px solid var(--hair);margin-top:2.5rem;line-height:1.7}
#loading{display:flex;align-items:center;justify-content:center;min-height:60vh;color:var(--muted);font-size:.88rem}
@media print{
body{background-image:none}.sheet{padding:0;max-width:none}.index{display:none}.doc{max-width:none}
.fp-actions{display:none}.cover,.seal .draw{animation:none}
.band,.brief,.gradient,.row-list,.edu-grid,.raw-pre{break-inside:avoid}
a{color:var(--ink)}
}
</style>
</head>
<body>
<script id="receipt-data" type="application/json">__RECEIPT_JSON__</script>
<div id="loading">Verifying receipt...</div>
<script>
let R=null;
try{
const rawReceipt=document.getElementById('receipt-data').textContent.trim();
// The sentinel is split so the build-time substitution of the data-slot
// token only matches the data block above, never this comparison. It
// reassembles at runtime to still detect an unsubstituted template.
if(rawReceipt==='__RECEIPT'+'_JSON__')throw new Error('template placeholder');
R=JSON.parse(rawReceipt);
}catch(e){
document.getElementById('loading').innerHTML='<div style="max-width:560px;padding:2rem;text-align:left"><div style="font-family:Georgia,serif;font-size:1.3rem;color:var(--ink);margin-bottom:.5rem">This is the receipt template</div><div style="font-size:.86rem;color:var(--muted);line-height:1.7">Open a generated <code>preview.html</code> from a <code>.treeship</code> package, or run <code>treeship dashboard</code> and open a receipt from there. The source template has no embedded <code>receipt.json</code>, so there is nothing to verify yet.</div></div>';
throw e;
}
document.title='Session Receipt: '+(R.session&&R.session.id?R.session.id:'unknown');
function num(v){const n=Number(v);return Number.isFinite(n)?n:0}
// ── SHA-256 ──
async function sha256(d){const b=typeof d==='string'?new TextEncoder().encode(d):d;return new Uint8Array(await crypto.subtle.digest('SHA-256',b))}
function toHex(b){return Array.from(b).map(x=>x.toString(16).padStart(2,'0')).join('')}
function fromHex(h){const b=new Uint8Array(h.length/2);for(let i=0;i<h.length;i+=2)b[i/2]=parseInt(h.substr(i,2),16);return b}
function concat(a,b){const c=new Uint8Array(a.length+b.length);c.set(a);c.set(b,a.length);return c}
// Domain-separated hash primitives, mirroring packages/core/src/merkle/tree.rs.
// version==2 prefixes leaves with 0x00 and internal nodes with 0x01 (RFC 9162).
// version==1 omits both prefixes for byte-identity with v0.10.2 receipts.
async function hashLeaf(version,artId){
if(version===2){const b=new TextEncoder().encode(artId);const buf=new Uint8Array(1+b.length);buf[0]=0;buf.set(b,1);return await sha256(buf)}
return await sha256(artId);
}
async function hashInternal(version,l,r){
if(version===2){const buf=new Uint8Array(1+l.length+r.length);buf[0]=1;buf.set(l,1);buf.set(r,1+l.length);return await sha256(buf)}
return await sha256(concat(l,r));
}
async function computeRoot(version,ids){
if(!ids.length)return null;
let lv=[];for(const id of ids)lv.push(await hashLeaf(version,id));
while(lv.length>1){const nx=[];let i=0;while(i+1<lv.length){nx.push(await hashInternal(version,lv[i],lv[i+1]));i+=2}if(i<lv.length)nx.push(lv[i]);lv=nx}
return toHex(lv[0]);
}
async function verifyProof(expectedVersion,artId,proof,rootHex){
// expectedVersion is the TRUSTED merkle version, taken from the
// receipt's merkle section — NOT from the (untrusted) proof blob.
// The proof's self-declared merkle_version must match; mismatch is
// rejected to close the downgrade vector that previously let a v1
// proof verify against a v2-signed receipt.
if(expectedVersion!==1&&expectedVersion!==2)return false;
const proofVersion=Number.isFinite(proof.merkle_version)?proof.merkle_version:1;
if(proofVersion!==expectedVersion)return false;
const algo=proof.algorithm;
if(algo&&algo!=='sha256-duplicate-last'&&algo!=='sha256-rfc9162')return false;
// v2 rejects empty proof path unless leaf_index===0 (single-leaf tree).
if(expectedVersion===2&&(!proof.path||proof.path.length===0)&&proof.leaf_index!==0)return false;
const lh=await hashLeaf(expectedVersion,artId);if(toHex(lh)!==proof.leaf_hash)return false;
let cur=lh;for(const s of(proof.path||proof.steps||[])){
const sh=s.hash||s.sibling_hash;if(!sh)return false;const sib=fromHex(sh);
const dir=(s.direction||'').toLowerCase();
if(dir==='right')cur=await hashInternal(expectedVersion,cur,sib);
else if(dir==='left')cur=await hashInternal(expectedVersion,sib,cur);
else return false;
}return toHex(cur)===rootHex;
}
async function runChecks(){
const c=[];
c.push({n:'receipt',s:'pass',d:'Parses as valid Session Receipt'});
c.push(R.type==='treeship/session-receipt/v1'?{n:'type',s:'pass',d:'Correct receipt type'}:{n:'type',s:'fail',d:'Expected treeship/session-receipt/v1'});
const arts=R.artifacts||[];
if(arts.length){
const mkVer=Number.isFinite(R.merkle.merkle_version)?R.merkle.merkle_version:1;
const root=await computeRoot(mkVer,arts.map(a=>a.artifact_id));
const stored=(R.merkle.root||'').replace('mroot_','');
c.push(root===stored?{n:'merkle_root',s:'pass',d:'Merkle root matches recomputed value'}:{n:'merkle_root',s:'fail',d:'Root mismatch'});
for(const e of(R.merkle.inclusion_proofs||[])){
// Pass mkVer (the trusted version from the receipt section) into
// verifyProof so dispatch comes from the receipt, not the proof.
const v=await verifyProof(mkVer,e.artifact_id,e.proof||e,stored);
c.push({n:'proof:'+e.artifact_id.slice(0,12),s:v?'pass':'fail',d:v?'Inclusion proof valid':'Proof failed'});
}
}else c.push({n:'merkle',s:'warn',d:'No artifacts'});
c.push(R.merkle.leaf_count===arts.length?{n:'leaf_count',s:'pass',d:'Leaf count matches'}:{n:'leaf_count',s:'fail',d:'Mismatch'});
const tl=R.timeline||[];let ord=true;
for(let i=1;i<tl.length;i++){if(tl[i-1].timestamp>tl[i].timestamp||(tl[i-1].timestamp===tl[i].timestamp&&tl[i-1].sequence_no>tl[i].sequence_no)){ord=false;break}}
c.push({n:'timeline',s:ord?'pass':'fail',d:ord?'Timeline ordered':'Out of order'});
return c;
}
// ── Helpers ──
function esc(s){return(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
function fmtDur(ms){if(!ms)return'--';const s=Math.floor(ms/1000);if(s<60)return s+'s';if(s<3600)return Math.floor(s/60)+'m '+s%60+'s';return Math.floor(s/3600)+'h '+Math.floor(s%3600/60)+'m'}
function timeOf(iso){return(iso||'').slice(11,19)}
function renderPath(p){const i=p.lastIndexOf('/');if(i<0)return'<span style="font-weight:600">'+esc(p)+'</span>';return'<span style="color:var(--faint)">'+esc(p.slice(0,i+1))+'</span><span style="font-weight:600">'+esc(p.slice(i+1))+'</span>'}
const COLORS=['#37454F','#856733','#3B6A4E','#6E4A63','#41607A','#9C5B3B'];
const SENSITIVE_RE=/\.env|\.ssh|\.pem|\.aws|\.gnupg|credentials|id_rsa|id_ed25519/i;
function fileRisk(f){return SENSITIVE_RE.test(f.file_path)?{b:'badge-warn',t:'sensitive'}:{b:'badge-ok',t:'ok'}}
function detectRetries(procs){const r=new Set();for(let i=1;i<procs.length;i++){const p=procs[i-1],c=procs[i];if((p.command||p.process_name)===(c.command||c.process_name)&&p.exit_code!=null&&p.exit_code!==0&&c.exit_code===0){r.add(i-1);r.add(i)}}return r}
function buildFindings(se){
const f=[];const wp=new Set((se.files_written||[]).map(x=>x.file_path));
(se.files_read||[]).forEach(r=>{if(SENSITIVE_RE.test(r.file_path))f.push({title:'Sensitive file read: '+r.file_path,desc:wp.has(r.file_path)?'Read and subsequently written by '+r.agent_instance_id+'. Review whether the write was intentional.':'Read only by '+r.agent_instance_id+'. No write occurred to this file during the session.',path:r.file_path+' · agent: '+r.agent_instance_id+' · '+timeOf(r.timestamp),sev:'warn'})});
(se.network_connections||[]).forEach(n=>{f.push({title:'External network connection',desc:'Outbound connection to '+n.destination+(n.port?' on port '+num(n.port):'')+' by '+n.agent_instance_id+'. Review whether this external call was expected.',path:n.destination+' · agent: '+n.agent_instance_id+' · '+timeOf(n.timestamp),sev:'risk'})});
(se.ports_opened||[]).forEach(p=>{f.push({title:'Port opened: '+num(p.port),desc:'Agent '+p.agent_instance_id+' opened port '+num(p.port)+'. Verify this was intentional.',path:'port '+num(p.port)+' · agent: '+p.agent_instance_id+' · '+timeOf(p.timestamp),sev:'warn'})});
return f;
}
function financialSignal(v){
v=(v||'').toLowerCase();
return v.includes('agent.robinhood.com')||v.includes('robinhood-trading')||v.includes('robinhood trading')||v.includes('trading mcp')||(v.includes('robinhood')&&v.includes('order'));
}
function financialActionEvidence(se,arts){
const rows=[];
(se.tool_invocations||[]).forEach(t=>{if(financialSignal(t.tool_name))rows.push({kind:'tool call',label:t.tool_name,agent:t.agent_instance_id,time:t.timestamp,review:'Confirm strategy intent and approval before trusting this tool call.'})});
(se.network_connections||[]).forEach(n=>{if(financialSignal(n.destination))rows.push({kind:'network',label:n.destination+(n.port?':'+num(n.port):''),agent:n.agent_instance_id,time:n.timestamp,review:'Confirm account-data handling and whether this connection was expected.'})});
(se.processes||[]).forEach(p=>{const cmd=p.command||p.process_name;if(financialSignal(cmd))rows.push({kind:'command',label:cmd,agent:p.agent_instance_id,time:p.started_at,review:'Confirm this command was reviewed and bound to approval if it could place orders.'})});
const approvals=(arts||[]).filter(a=>a.payload_type&&a.payload_type.includes('approval'));
return {rows,approvals};
}
// Auto-generate summary from receipt data when --summary not provided
function autoSummary(se,procs,nodes){
const parts=[];
const fw=(se.files_written||[]).length,fr=(se.files_read||[]).length;
if(fw)parts.push(fw+' file'+(fw>1?'s':'')+' written');
if(fr)parts.push(fr+' file'+(fr>1?'s':'')+' read');
if(procs.length)parts.push(procs.length+' command'+(procs.length>1?'s':'')+' run');
const retries=detectRetries(procs);
const recoveries=procs.filter((_,i)=>retries.has(i)&&procs[i].exit_code===0).length;
if(recoveries)parts.push(recoveries+' recovered from failure');
const sens=(se.files_read||[]).filter(f=>SENSITIVE_RE.test(f.file_path)).length;
if(sens)parts.push(sens+' sensitive read'+(sens>1?'s':''));
else parts.push('0 sensitive reads');
const net=(se.network_connections||[]).length;
if(net)parts.push(net+' external call'+(net>1?'s':''));
if(nodes.length>1)parts.push(nodes.length+' agents collaborated');
return parts.join(', ')+'.';
}
// Auto-generate "what to check" from receipt data
function autoReview(se,procs){
const items=[];
const failedCmds=procs.filter(p=>p.exit_code!=null&&p.exit_code!==0);
if(failedCmds.length)items.push('Review '+failedCmds.length+' failed command'+(failedCmds.length>1?'s':''));
const sens=(se.files_read||[]).filter(f=>SENSITIVE_RE.test(f.file_path));
if(sens.length)items.push('Check '+sens.length+' sensitive file read'+(sens.length>1?'s':''));
const net=(se.network_connections||[]).length;
if(net)items.push('Verify '+net+' external network call'+(net>1?'s':''));
if(!items.length)items.push('No items requiring special review detected');
return items.join('. ')+'.';
}
const EVB={'session.started':'badge-muted','session.closed':'badge-muted','agent.started':'badge-ok','agent.spawned':'badge-info','agent.handoff':'badge-warn','agent.collaborated':'badge-info','agent.returned':'badge-muted','agent.completed':'badge-ok','agent.failed':'badge-risk','agent.called_tool':'badge-info','agent.read_file':'badge-warn','agent.wrote_file':'badge-ok','agent.opened_port':'badge-risk','agent.connected_network':'badge-warn','agent.started_process':'badge-info','agent.completed_process':'badge-muted','agent.decision':'badge-info'};
// The struck bronze/steel seal, drawn once. Glyph class = attestation class.
function sealSvg(){
return '<svg class="seal" viewBox="0 0 60 60" fill="none" aria-hidden="true">'+
'<circle cx="30" cy="30" r="28.5" stroke="var(--bronze-hi)" stroke-width="1"/>'+
'<circle cx="30" cy="30" r="26.5" stroke="var(--bronze-lo)" stroke-width=".75" opacity=".7"/>'+
'<path class="draw" d="M30 11 L49 42 L11 42 Z" stroke="var(--ink)" stroke-width="1.5" stroke-linejoin="round"/>'+
'<path d="M30 22 L40 39 L20 39 Z" stroke="var(--steel)" stroke-width="1" stroke-linejoin="round" opacity=".72"/>'+
'<circle cx="30" cy="34" r="1.6" fill="var(--bronze)"/></svg>';
}
async function render(){
const checks=await runChecks();
const allPass=checks.every(c=>c.s!=='fail');
const s=R.session||{};const p=R.participants||{};
const nodes=(R.agent_graph||{}).nodes||[];
const edges=(R.agent_graph||{}).edges||[];
const tl=R.timeline||[];const se=R.side_effects||{};
const arts=R.artifacts||[];const mk=R.merkle||{};
const pr=R.proofs||{};const narr=s.narrative||{};
const findings=buildFindings(se);
const procs=se.processes||[];
const retries=detectRetries(procs);
const hasTokens=num(s.total_tokens_in)>0||num(s.total_tokens_out)>0;
const nodeIdx={};nodes.forEach((n,i)=>nodeIdx[n.agent_instance_id]=i);
const totalActions=nodes.reduce((s,n)=>s+num(n.tool_calls),0)||1;
const fin=financialActionEvidence(se,arts);
// Absent on receipts from sessions with no action/v2, and on every receipt
// written before the section existed. Both render as no band at all rather
// than an empty one, because an empty authority band reads like a clean bill.
const auth=(R.authority&&R.authority.checked)?R.authority:null;
let h='<div class="sheet">';
// ── QUIET INDEX ──
h+='<aside class="index"><div class="index-inner">';
h+='<div class="index-brand"><span class="g">△</span> treeship</div>';
h+='<nav>';
h+='<div class="nav-label">Document</div>';
h+='<a class="nav-item active" href="#summary" data-section="summary">Summary</a>';
if(nodes.length>1)h+='<a class="nav-item" href="#agents" data-section="agents">Agents <span class="nav-count">'+nodes.length+'</span></a>';
h+='<a class="nav-item" href="#timeline" data-section="timeline">Timeline <span class="nav-count">'+tl.length+'</span></a>';
h+='<div class="nav-label">Changes</div>';
h+='<a class="nav-item" href="#files" data-section="files">Files <span class="nav-count">'+((se.files_written||[]).length+(se.files_read||[]).length)+'</span></a>';
h+='<a class="nav-item" href="#commands" data-section="commands">Commands <span class="nav-count">'+procs.length+'</span></a>';
h+='<div class="nav-label">Trust</div>';
if(auth)h+='<a class="nav-item" href="#authority" data-section="authority">Authority <span class="nav-count">'+auth.checked+'</span></a>';
h+='<a class="nav-item" href="#security" data-section="security">Findings <span class="nav-count">'+findings.length+'</span></a>';
if(fin.rows.length)h+='<a class="nav-item" href="#financial" data-section="financial">Financial <span class="nav-count">'+fin.rows.length+'</span></a>';
h+='<a class="nav-item" href="#trust-chain" data-section="trust-chain">Trust chain</a>';
h+='<a class="nav-item" href="#verification" data-section="verification">Checks <span class="nav-count">'+checks.length+'</span></a>';
h+='<a class="nav-item" href="#raw" data-section="raw">Raw receipt</a>';
h+='<a class="nav-item" href="#schema" data-section="schema">How it works</a>';
h+='</nav></div></aside>';
// ── DOCUMENT ──
h+='<article class="doc">';
// faceplate
h+='<div class="faceplate"><span>Treeship · Session Receipt</span><span class="ticks"></span><span class="fp-actions">';
h+='<a href="#verification">Verify</a><a href="#raw">JSON</a>';
h+='<button onclick="navigator.clipboard.writeText(JSON.stringify(R,null,2))">Copy</button>';
h+='</span></div>';
// ── COVER ──
h+='<header class="cover" id="summary">';
h+='<div class="cover-top">'+sealSvg()+'<div style="flex:1;min-width:0">';
h+='<div class="verdict-eyebrow">Verdict</div>';
if(allPass){
h+='<div class="verdict pass"><span class="glyph">△</span><span>structural pass</span></div>';
h+='<div class="verdict-note">The Merkle structure and inclusion proofs were recomputed in your browser and match. This receipt has not been altered since it was sealed.</div>';
}else{
h+='<div class="verdict fail"><span class="glyph">✕</span><span>verification failed</span></div>';
h+='<div class="verdict-note">One or more structural checks did not pass. Do not trust this receipt until the failures below are resolved.</div>';
}
h+='</div></div>';
h+='<h1 class="headline">'+esc(narr.headline||s.name||s.id||'Session receipt')+'</h1>';
h+='<div class="rec-id">'+esc(s.id||'')+'</div>';
// honesty gradient — never a silent gap: state what is and is not proven here
h+='<div class="gradient">';
h+='<div class="grad-row proven"><span class="grad-g">✓</span><span><span class="grad-k">Proven here.</span> <span class="grad-d">Merkle structure, inclusion proofs, and timeline order, recomputed client-side via Web Crypto. No network, no server trust.</span></span></div>';
h+='<div class="grad-row bounded"><span class="grad-g">○</span><span><span class="grad-k">Not proven here.</span> <span class="grad-d">Ed25519 signatures over the artifacts. Run <code>treeship package verify</code> on the package for full signature verification.</span></span></div>';
h+='</div>';
// metrics strip
h+='<div class="metrics">';
h+='<div><div class="metric-label">Files</div><div class="metric-value">'+(se.files_written||[]).length+'</div><div class="metric-sub">'+(se.files_read||[]).length+' read</div></div>';
h+='<div><div class="metric-label">Actions</div><div class="metric-value">'+num(totalActions)+'</div><div class="metric-sub">'+nodes.length+' agent'+(nodes.length!==1?'s':'')+'</div></div>';
h+='<div><div class="metric-label">Duration</div><div class="metric-value">'+fmtDur(s.duration_ms)+'</div><div class="metric-sub">'+esc((s.started_at||'').slice(11,16))+' start</div></div>';
h+='<div><div class="metric-label">Signatures</div><div class="metric-value">'+num(pr.signature_count)+'</div><div class="metric-sub">Ed25519</div></div>';
if(hasTokens)h+='<div><div class="metric-label">Tokens</div><div class="metric-value">'+(num(s.total_tokens_in)/1000).toFixed(0)+'k</div><div class="metric-sub">'+(num(s.total_tokens_out)/1000).toFixed(0)+'k out</div></div>';
h+='</div>';
// the story
const planned=narr.headline||s.name||('Session '+(s.id||''));
const done=narr.summary||autoSummary(se,procs,nodes);
const review=narr.review||autoReview(se,procs);
h+='<div class="story">';
h+='<div><div class="story-label">Planned</div><div class="story-text">'+esc(planned)+'</div></div>';
h+='<div><div class="story-label">Actually done</div><div class="story-text">'+esc(done)+'</div></div>';
h+='<div><div class="story-label">What to check</div><div class="story-text">'+esc(review)+'</div></div>';
h+='</div>';
// at-a-glance
const failedChecks=checks.filter(c=>c.s==='fail').length;
const warnedChecks=checks.filter(c=>c.s==='warn').length;
const failedCmds=procs.filter(p=>p.exit_code!=null&&p.exit_code!==0).length;
h+='<div class="brief">';
h+='<a class="brief-cell '+(failedChecks?'attn':'')+'" href="#verification"><div class="brief-k">Verdict</div><div class="brief-v" style="color:'+(failedChecks?'var(--fail)':'var(--pass)')+'">'+(failedChecks?'Needs review':'Structural pass')+'</div><div class="brief-d">'+checks.length+' checks · '+warnedChecks+' warning'+(warnedChecks===1?'':'s')+'</div></a>';
h+='<a class="brief-cell '+(findings.length?'attn':'')+'" href="#security"><div class="brief-k">Review queue</div><div class="brief-v">'+findings.length+' finding'+(findings.length===1?'':'s')+'</div><div class="brief-d">'+(findings.length?'Open before trusting output':'No findings detected')+'</div></a>';
h+='<a class="brief-cell" href="#files"><div class="brief-k">Work product</div><div class="brief-v">'+(se.files_written||[]).length+' file'+((se.files_written||[]).length===1?'':'s')+'</div><div class="brief-d">'+arts.length+' signed artifact'+(arts.length===1?'':'s')+'</div></a>';
h+='<a class="brief-cell '+(failedCmds?'attn':'')+'" href="#commands"><div class="brief-k">Execution</div><div class="brief-v">'+procs.length+' command'+(procs.length===1?'':'s')+'</div><div class="brief-d">'+failedCmds+' failed · '+retries.size+' retry-linked</div></a>';
h+='</div>';
h+='</header>';
// ── AGENTS ──
if(nodes.length>0){
h+='<section class="band" id="agents"><div class="band-label">Agents</div><div class="band-sub">Every agent that participated, its role, model, and share of the work.</div>';
h+='<div class="band-body"><div class="row-list">';
nodes.forEach((n,i)=>{
const c=COLORS[i%COLORS.length];
const pct=Math.max(2,num(n.tool_calls)/totalActions*100);
h+='<div class="agent-card"><div class="agent-stripe" style="background:'+c+'"></div><div class="agent-info">';
h+='<div class="agent-name">'+esc(n.agent_name)+'</div>';
const modelStr=n.model?esc(n.model):'<span class="faint" title="Set TREESHIP_MODEL to capture">not captured</span>';
h+='<div class="agent-meta">'+esc(n.agent_instance_id)+' · '+esc(n.agent_role||'agent')+' · '+modelStr+' · @'+esc(n.host_id)+'</div>';
h+='<div class="agent-stats"><span>'+num(n.tool_calls)+' actions</span>';
if(n.provider)h+='<span>via '+esc(n.provider)+'</span>';
h+='<span>depth '+num(n.depth)+'</span>';
if(n.status)h+='<span class="badge '+(n.status==='completed'?'badge-ok':'badge-risk')+'">'+esc(n.status)+'</span>';
h+='</div>';
h+='<div class="agent-bar"><div class="agent-bar-fill" style="width:'+pct.toFixed(1)+'%;background:'+c+'"></div></div>';
h+='</div></div>';
});
h+='</div></div></section>';
}
// ── TIMELINE ──
if(tl.length){
h+='<section class="band" id="timeline"><div class="band-label">Timeline</div><div class="band-sub">Append-only event log. Every action sealed as an artifact, in sequence.</div>';
h+='<div class="band-body"><div class="row-list">';
let lastAgent='';
tl.forEach(ev=>{
const ni=nodeIdx[ev.agent_instance_id];
const c=ni!==undefined?COLORS[ni%COLORS.length]:'#9B9B93';
const badge=EVB[ev.event_type]||'badge-muted';
const label=ev.event_type.replace('agent.','').replace('session.','').replace(/_/g,' ');
if(ev.agent_instance_id!==lastAgent){
if(lastAgent&&ev.event_type==='agent.handoff')h+='<div class="tl-group-header"><div class="tl-group-dot" style="background:'+c+'"></div><span>→ handoff to '+esc(ev.agent_name)+'</span></div>';
else h+='<div class="tl-group-header"><div class="tl-group-dot" style="background:'+c+'"></div><span>'+esc(ev.agent_name)+'</span></div>';
lastAgent=ev.agent_instance_id;
}
h+='<div class="tl-item"><span class="tl-time">'+timeOf(ev.timestamp)+'</span><div>';
h+='<div class="tl-label">'+esc(ev.summary||label)+'</div>';
h+='</div><span class="badge '+badge+'">'+esc(label)+'</span></div>';
});
h+='</div></div></section>';
}
// ── FILES ──
const allFiles=[...(se.files_written||[]).map(f=>({...f,op:'write'})),...(se.files_read||[]).map(f=>({...f,op:'read'}))];
if(allFiles.length){
const hasDiff=allFiles.some(f=>f.additions||f.deletions);
h+='<section class="band" id="files"><div class="band-label">Files changed</div><div class="band-sub">Per-file record of every write, creation, and read detected.</div>';
h+='<div class="band-body"><div class="row-list"><table class="files"><thead><tr><th>Path</th><th>Op</th><th>Agent</th>';
if(hasDiff)h+='<th>Diff</th>';
h+='<th>Risk</th></tr></thead><tbody>';
allFiles.forEach(f=>{
const opClass=f.op==='read'?'badge-muted':f.operation==='created'?'badge-ok':'badge-info';
const risk=fileRisk(f);
h+='<tr><td><span class="mono" style="word-break:break-all">'+renderPath(f.file_path)+'</span></td>';
h+='<td><span class="badge '+opClass+'">'+esc(f.operation||f.op)+'</span></td>';
h+='<td class="mono faint" style="font-size:.66rem">'+esc(f.agent_instance_id)+'</td>';
if(hasDiff)h+='<td class="mono" style="font-size:.72rem">'+(f.additions!=null?'<span style="color:var(--pass)">+'+num(f.additions)+'</span> <span style="color:var(--fail)">-'+num(f.deletions)+'</span>':'')+'</td>';
h+='<td><span class="badge '+risk.b+'">'+risk.t+'</span></td></tr>';
});
h+='</tbody></table></div></div></section>';
}
// ── COMMANDS ──
h+='<section class="band" id="commands"><div class="band-label">Commands run</div><div class="band-sub">Every shell command and process executed during this session.</div><div class="band-body">';
if(procs.length){
h+='<div class="row-list">';
procs.forEach((pp,i)=>{
const ni=nodeIdx[pp.agent_instance_id];
const ac=ni!==undefined?COLORS[ni%COLORS.length]:'#9B9B93';
const isRetryFail=retries.has(i)&&pp.exit_code!=null&&pp.exit_code!==0;
const isRetryOk=retries.has(i)&&pp.exit_code===0;
h+='<div class="cmd-card"><div class="cmd-header"><div><div class="cmd-shell">'+esc(pp.command||pp.process_name)+'</div>';
h+='<div class="cmd-meta"><span style="display:flex;align-items:center;gap:4px"><span class="tl-dot" style="background:'+ac+'"></span>'+esc(pp.agent_instance_id)+'</span>';
if(pp.duration_ms)h+='<span>'+fmtDur(pp.duration_ms)+'</span>';
if(isRetryFail)h+='<span class="badge badge-warn">failed → retried</span>';
if(isRetryOk)h+='<span class="badge badge-ok">recovered</span>';
h+='</div></div>';
h+='<span class="exit-pill '+(pp.exit_code===0?'exit-ok':'exit-fail')+'">exit '+(pp.exit_code!=null?num(pp.exit_code):'?')+'</span>';
h+='</div></div>';
});
h+='</div>';
}else{
h+='<div class="row-list"><div class="confirm-row"><div class="confirm-icon">✓</div>No commands executed during this session.</div></div>';
}
h+='</div></section>';
// ── TOOL AUTHORIZATION ──
const tu=R.tool_usage;
if(tu){
const declared=tu.declared||[];
const actual=tu.actual||[];
const unauthorized=tu.unauthorized||[];
if(declared.length||actual.length){
h+='<section class="band" id="tools"><div class="band-label">Tool authorization</div><div class="band-sub">Declared vs actual tool usage. Unauthorized calls are flagged.</div><div class="band-body"><div class="row-list">';
if(declared.length){
h+='<div class="pad"><div class="metric-label" style="margin-bottom:.4rem">Authorized tools</div><div style="display:flex;flex-wrap:wrap;gap:.35rem">'+declared.map(t=>'<span class="badge badge-ok">'+esc(t)+'</span>').join('')+'</div></div>';
}
if(actual.length){
h+='<div class="pad" style="border-top:1px solid var(--hair)"><div class="metric-label" style="margin-bottom:.4rem">Actual usage</div>';
actual.forEach(a=>{
const isAuth=!declared.length||declared.includes(a.tool_name);
h+='<div style="display:flex;align-items:center;gap:.75rem;padding:.3rem 0;font-size:.8rem">';
h+='<span class="badge '+(isAuth?'badge-info':'badge-risk')+'">'+esc(a.tool_name)+'</span>';
h+='<span class="mono faint">'+num(a.count)+' call'+(a.count!==1?'s':'')+'</span>';
if(!isAuth)h+='<span class="badge badge-risk">unauthorized</span>';
h+='</div>';
});
h+='</div>';
}
if(unauthorized.length){
h+='<div class="pad" style="border-top:1px solid var(--hair)"><div class="metric-label" style="color:var(--fail);margin-bottom:.4rem">Unauthorized tool calls</div>';
unauthorized.forEach(t=>{h+='<span class="badge badge-risk" style="margin-right:.35rem">'+esc(t)+'</span>'});
h+='</div>';
}
h+='</div></div></section>';
}
}
// ── SECURITY FINDINGS ──
h+='<section class="band" id="security"><div class="band-label">Security findings</div><div class="band-sub">Automatically detected behaviors that require human review. Sealed into the chain.</div><div class="band-body"><div class="row-list">';
if(findings.length){
findings.forEach(f=>{
const glyph=f.sev==='risk'?'✕':'△';
const col=f.sev==='risk'?'var(--fail)':'var(--warn)';
h+='<div class="finding"><div class="mono" style="padding-top:1px;color:'+col+'">'+glyph+'</div><div>';
h+='<div style="font-size:.8rem;font-weight:600;margin-bottom:3px">'+esc(f.title)+'</div>';
h+='<div style="font-size:.72rem;color:var(--muted);line-height:1.5">'+esc(f.desc)+'</div>';
if(f.path)h+='<div class="mono" style="font-size:.62rem;color:var(--faint);margin-top:3px">'+esc(f.path)+'</div>';
h+='</div><span class="badge badge-'+f.sev+'">'+f.sev+'</span></div>';
});
}else{
const sensReads=(se.files_read||[]).filter(f=>SENSITIVE_RE.test(f.file_path)).length;
const extCalls=(se.network_connections||[]).length;
const fCmds=procs.filter(p=>p.exit_code!=null&&p.exit_code!==0).length;
const hasDaemonEvents=tl.some(e=>e.agent_name==='treeship-daemon');
if(hasDaemonEvents&&!sensReads)h+='<div class="confirm-row"><div class="confirm-icon">✓</div>No sensitive file reads detected</div>';
else if(!sensReads)h+='<div class="confirm-row" style="color:var(--faint)"><div class="confirm-icon faint">–</div>Sensitive file read detection requires the daemon. Run <code>treeship daemon start</code>.</div>';
if(extCalls)h+='<div class="confirm-row"><div class="confirm-icon">✓</div>'+extCalls+' external network call'+(extCalls>1?'s':'')+' detected</div>';
else h+='<div class="confirm-row" style="color:var(--faint)"><div class="confirm-icon faint">–</div>Network monitoring requires <code>@treeship/mcp</code> or an explicit network event.</div>';
if(procs.length&&!fCmds)h+='<div class="confirm-row"><div class="confirm-icon">✓</div>No failed commands</div>';
else if(!procs.length)h+='<div class="confirm-row" style="color:var(--faint)"><div class="confirm-icon faint">–</div>No commands captured. Use <code>treeship wrap</code> or <code>@treeship/mcp</code>.</div>';
}
h+='</div></div></section>';
// ── FINANCIAL ACTION EVIDENCE ──
if(fin.rows.length){
h+='<section class="band" id="financial"><div class="band-label">Financial action evidence</div><div class="band-sub">Trading/payment-like activity detected. Review intent, approvals, and account-data handling.</div><div class="band-body"><div class="row-list">';
fin.rows.forEach(r=>{
h+='<div class="finding"><div class="mono" style="padding-top:1px;color:var(--fail)">◇</div><div>';
h+='<div style="font-size:.8rem;font-weight:600;margin-bottom:3px">'+esc(r.kind)+': '+esc(r.label)+'</div>';
h+='<div style="font-size:.72rem;color:var(--muted);line-height:1.5">'+esc(r.review)+'</div>';
h+='<div class="mono" style="font-size:.62rem;color:var(--faint);margin-top:3px">agent: '+esc(r.agent)+' · '+timeOf(r.time)+'</div>';
h+='</div><span class="badge '+(fin.approvals.length?'badge-warn':'badge-risk')+'">'+(fin.approvals.length?'approval present':'approval needed')+'</span></div>';
});
h+='<div class="pad faint" style="font-size:.72rem;border-top:1px solid var(--hair)">Recommended guardrail: apply <code>treeship template apply robinhood-agentic-trading</code> and require approval nonce binding for order placement.</div>';
h+='</div></div></section>';
}
// ── AUTHORITY ──
// What each action/v2 was allowed to do, and whether it stayed inside that.
// The signature bands above answer "was this altered". This answers the
// question a counterparty is actually deciding on. Omitted entirely when the
// session had no action/v2 -- an empty band would read as a clean bill.
if(auth){
h+='<section class="band" id="authority"><div class="band-label">Authority</div><div class="band-sub">What each action was authorized to do, and whether it stayed inside that. Layers we could not check are named, not omitted.</div><div class="band-body">';
// Lead with the counts so the shape is legible before any detail loads.
var sumCol = auth.violations?'var(--fail)':(auth.unverified?'var(--warn)':'var(--pass)');
var sumGly = auth.violations?'✗':(auth.unverified?'○':'✓');
h+='<div class="row-list"><div class="finding">';
h+='<div class="mono" style="padding-top:1px;color:'+sumCol+'">'+sumGly+'</div><div>';
h+='<div style="font-size:.8rem;font-weight:600">'+auth.checked+' authorized action'+(auth.checked===1?'':'s')+'</div>';
h+='<div class="mono" style="font-size:.62rem;color:var(--faint);margin-top:3px">'+auth.violations+' outside grant · '+auth.unverified+' with an unchecked layer · '+auth.bearer+' bearer</div></div>';
h+='<span class="badge '+(auth.violations?'badge-risk':(auth.unverified?'badge-warn':'badge-ok'))+'">'+(auth.violations?'outside grant':(auth.unverified?'unverified':'in scope'))+'</span></div></div>';
h+='<div class="row-list" style="margin-top:10px">';
(auth.actions||[]).forEach(function(a){
var vb = a.verdict==='fail' ? 'badge-risk' : (a.verdict==='unverified' ? 'badge-warn' : 'badge-ok');
var vt = a.verdict==='fail' ? 'outside grant' : (a.verdict==='unverified' ? 'unverified' : 'in scope');
var gc = a.verdict==='fail' ? 'var(--fail)' : (a.verdict==='unverified' ? 'var(--warn)' : 'var(--pass)');
var gl = a.verdict==='fail' ? '✗' : (a.verdict==='unverified' ? '○' : '✓');
h+='<div class="finding"><div class="mono" style="padding-top:1px;color:'+gc+'">'+gl+'</div><div style="min-width:0">';
h+='<div style="font-size:.8rem;font-weight:600">'+esc(a.action||'')+'</div>';
h+='<div class="mono" style="font-size:.62rem;color:var(--faint);margin-top:3px">allowed: '+esc((a.scope||[]).join(', '))+' · for '+esc(a.audience||'')+'</div>';
// Holder: bearer is a real state, not a blank. Say it.
h+='<div class="mono" style="font-size:.62rem;margin-top:3px;color:'+(a.holder_bound?'var(--faint)':'var(--warn,#8a6d1f)')+'">holder: '+(a.holder_bound?'bound to a named key':'BEARER — anyone holding the grant could have done this')+'</div>';
// Delegation: not_claimed is absence of a claim, never a passing check.
var dl = a.delegation==='holds' ? ('chain holds · '+(a.delegation_hops||0)+' hop'+((a.delegation_hops||0)===1?'':'s'))
: a.delegation==='not_claimed' ? 'no lineage claimed'
: a.delegation==='widened' ? 'CHAIN WIDENS — a hop granted more than it held'
: 'chain unresolvable';
h+='<div class="mono" style="font-size:.62rem;color:var(--faint);margin-top:3px">delegation: '+dl+'</div>';
if(a.effect_finality||a.resolution){
var owed = a.resolution==='breached' ? ' · OVERDUE' : (a.resolution==='indefinite' ? ' · nothing scheduled to resolve it' : (a.resolution==='pending' ? ' · still owed' : ''));
h+='<div class="mono" style="font-size:.62rem;color:var(--faint);margin-top:3px">effect: '+esc(a.effect_finality||'not stated')+esc(owed)+'</div>';
}
(a.reasons||[]).forEach(function(r){
h+='<div class="mono" style="font-size:.6rem;color:var(--faint);margin-top:3px">— '+esc(r)+'</div>';
});
h+='<div class="mono" style="font-size:.58rem;color:var(--faint);margin-top:3px">grant '+esc(a.grant_id||'')+'</div>';
h+='</div><span class="badge '+vb+'">'+vt+'</span></div>';
});
h+='</div>';
h+='<div class="pad faint" style="font-size:.66rem;margin-top:8px">Covers every action/v2 signed inside this session’s window, which is a wider set than the artifact chain above. Revocation is never checked: no resolver is wired, so a live grant cannot be distinguished from a withdrawn one.</div>';
h+='</section>';
}
// ── APPROVAL GATES ──
h+='<section class="band" id="approvals"><div class="band-label">Approval gates</div><div class="band-sub">Human approval checkpoints during the session.</div><div class="band-body">';
if(fin.approvals.length){
h+='<div class="row-list">';
fin.approvals.forEach(a=>{
h+='<div class="finding"><div class="mono" style="padding-top:1px;color:var(--pass)">✓</div><div><div style="font-size:.8rem;font-weight:600">Approval artifact</div><div class="mono" style="font-size:.62rem;color:var(--faint);margin-top:3px">'+esc(a.artifact_id||a.id||'')+' · '+timeOf(a.signed_at||a.timestamp||'')+'</div></div><span class="badge badge-ok">approved</span></div>';
});
h+='</div>';
}else{
h+='<div class="pad faint" style="font-size:.72rem">No approval gates recorded. All actions ran without a human review checkpoint.</div>';
}
h+='</section>';
// ── TRUST CHAIN ──
h+='<section class="band" id="trust-chain"><div class="band-label">Trust chain</div><div class="band-sub">How this receipt earns your trust, step by step.</div><div class="band-body"><div class="row-list">';
const steps=[
{ok:tl.length>0,label:'Events captured',desc:tl.length+' session events recorded in append-only log'},
{ok:arts.length>0,label:'Artifacts signed',desc:arts.length+' artifacts signed with Ed25519 ship key'},
{ok:!!mk.root,label:'Merkle tree built',desc:num(mk.leaf_count)+' leaves hashed into SHA-256 tree'},
{ok:allPass,label:'Root committed',desc:'Merkle root: '+(mk.root?esc(mk.root).slice(0,28)+'…':'none')},
{ok:checks.filter(c=>c.n.startsWith('proof:')&&c.s==='pass').length>0,label:'Inclusion proofs verified',desc:(mk.inclusion_proofs||[]).length+' proofs checked client-side via Web Crypto API'},
];
steps.forEach(st=>{
h+='<div class="trust-step"><div class="trust-icon '+(st.ok?'ok':'muted')+'">'+(st.ok?'✓':'–')+'</div><div>';
h+='<div class="trust-label">'+esc(st.label)+'</div>';
h+='<div class="trust-desc">'+esc(st.desc)+'</div>';
h+='</div></div>';
});
h+='</div></div></section>';
// ── VERIFICATION CHECKS ──
h+='<section class="band" id="verification"><div class="band-label">Verification checks</div><div class="band-sub">Merkle and structural checks run client-side. For Ed25519 signature verification, use <code>treeship package verify</code>.</div><div class="band-body"><div class="row-list">';
checks.forEach(c=>{
const badge=c.s==='pass'?'badge-ok':c.s==='fail'?'badge-risk':'badge-warn';
h+='<div class="check-row"><span class="badge '+badge+'">'+c.s.toUpperCase()+'</span><span class="check-name">'+esc(c.n)+'</span><span class="check-detail">'+esc(c.d)+'</span></div>';
});
const pc=checks.filter(c=>c.s==='pass').length;const fc=checks.filter(c=>c.s==='fail').length;
h+='<div class="pad faint" style="font-size:.72rem">'+pc+' passed, '+fc+' failed</div>';
h+='</div></div></section>';
// ── RAW ──
h+='<section class="band" id="raw"><div class="band-label">Raw receipt</div><div class="band-body">';
h+='<div class="raw-pre"><button class="copy-btn" onclick="navigator.clipboard.writeText(JSON.stringify(R,null,2));this.textContent=\'Copied\';setTimeout(()=>this.textContent=\'Copy\',1500)">Copy</button><code>'+esc(JSON.stringify(R,null,2))+'</code></div></section>';
// ── HOW IT WORKS ──
h+='<section class="band" id="schema"><div class="band-label">How this receipt works</div><div class="band-sub">For third-party readers who have not used Treeship before.</div><div class="band-body"><div class="edu-grid">';
h+='<div class="edu-card"><div class="edu-num">01 · MERKLE TREE</div><div class="edu-title">Every artifact is a leaf</div><div class="edu-text">Each action during the session produces a signed artifact with a content-addressed ID. These IDs become leaves in a SHA-256 Merkle tree. The root hash commits to every artifact below it. Change one artifact and the root changes. That root is what the trust chain above verifies.</div></div>';
h+='<div class="edu-card"><div class="edu-num">02 · ED25519 SIGNATURES</div><div class="edu-title">Each artifact is individually signed</div><div class="edu-text">The agent\'s ship key signs every artifact at creation time using Ed25519 signatures. This preview verifies the Merkle structure; for full signature verification, run <code>treeship package verify</code> on the downloaded package.</div></div>';
h+='<div class="edu-card"><div class="edu-num">03 · INCLUSION PROOFS</div><div class="edu-title">Prove any artifact belongs</div><div class="edu-text">Each artifact carries an inclusion proof: sibling hashes that walk from the leaf to the root. Anyone can verify a specific artifact was part of this session without the full tree. The checks above verify every proof against the recomputed root.</div></div>';
h+='</div></div></section>';
h+='<div class="footer">Merkle structure verified client-side via Web Crypto API. Ed25519 signature verification requires <code>treeship package verify</code> or the WASM verifier.<br>No network calls. No server trust. Generated by <a href="https://treeship.dev">Treeship</a> · Session Receipt v1</div>';
h+='</article></div>';
document.getElementById('loading').style.display='none';
document.body.insertAdjacentHTML('beforeend',h);
// ── Index scroll-spy ──
const sections=document.querySelectorAll('.band,.cover');
const navItems=document.querySelectorAll('[data-section]');
if(sections.length&&navItems.length&&typeof IntersectionObserver!=='undefined'){
const obs=new IntersectionObserver(entries=>{
entries.forEach(e=>{if(e.isIntersecting){
navItems.forEach(n=>n.classList.remove('active'));
const match=document.querySelector('[data-section="'+e.target.id+'"]');
if(match)match.classList.add('active');
}});
},{rootMargin:'-20% 0px -70% 0px',threshold:0});
sections.forEach(s=>{if(s.id)obs.observe(s)});
}
}
if(typeof crypto==='undefined'||!crypto.subtle){
document.getElementById('loading').innerHTML='<div style="color:var(--warn);max-width:500px;text-align:center"><div style="font-family:Georgia,serif;font-size:1.3rem;margin-bottom:.5rem">Verification unavailable</div><div style="font-size:.82rem;color:var(--muted)">Web Crypto API not available on this origin. Try a local HTTP server or use <code>treeship package verify</code>.</div></div>';
}else{
render().catch(e=>{document.getElementById('loading').innerHTML='<div style="color:var(--fail)">Error: '+e.message+'</div>'});
}
</script>
</body>
</html>