diff --git a/docs/deck/browser-results.json b/docs/deck/browser-results.json
new file mode 100644
index 0000000..faff1c4
--- /dev/null
+++ b/docs/deck/browser-results.json
@@ -0,0 +1,61 @@
+{
+ "scope": "Synthetic trusted-reader and signer adapters; actual DOM and IndexedDB; no service/authenticator/native receipt claim",
+ "results": [
+ {
+ "scenario": "disabled",
+ "pass": true,
+ "nonlocal_requests": 0,
+ "page_errors": []
+ },
+ {
+ "scenario": "choose",
+ "pass": true,
+ "nonlocal_requests": 0,
+ "page_errors": []
+ },
+ {
+ "scenario": "later",
+ "pass": true,
+ "nonlocal_requests": 0,
+ "page_errors": []
+ },
+ {
+ "scenario": "comment",
+ "pass": true,
+ "nonlocal_requests": 0,
+ "page_errors": []
+ },
+ {
+ "scenario": "unknown-recover",
+ "pass": true,
+ "nonlocal_requests": 0,
+ "page_errors": []
+ },
+ {
+ "scenario": "ordinary-graph",
+ "pass": true,
+ "nonlocal_requests": 0,
+ "page_errors": []
+ },
+ {
+ "scenario": "stale-item",
+ "pass": true,
+ "nonlocal_requests": 0,
+ "page_errors": []
+ },
+ {
+ "scenario": "xss-title",
+ "pass": true,
+ "nonlocal_requests": 0,
+ "page_errors": []
+ },
+ {
+ "scenario": "storage-reload-cas",
+ "one_insert": true,
+ "reload_exact": true,
+ "unknown_preserved": true,
+ "scope_isolated": true,
+ "pass": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/docs/deck/contract.md b/docs/deck/contract.md
new file mode 100644
index 0000000..bf7520d
--- /dev/null
+++ b/docs/deck/contract.md
@@ -0,0 +1,127 @@
+# Online request and attested answer source
+
+The `/deck/` preview implements Rikuy's request card and answer interpretation
+on the generic Quipu attested-write primitive. It ships disabled, selects no
+production endpoint or credential, and makes no request on page load. Its CSP
+also disallows network connections. Deployment adapters and policy changes are
+separate reviewed work. The offline decision protocol is unchanged and has a
+different domain, database and retry contract.
+
+## Request and proposal
+
+A curator publishes a canonical request manifest in an ordinary request graph,
+with a current revision and an immutable revision subject. `requestPublication`
+constructs those facts; it does not publish them. A real curator job must use
+normal governed conditional writes: advance the current revision atomically,
+expect the old revision, and expect the new manifest slot to be absent. This
+source does not deploy that job or grant it a credential.
+
+The admitted reader supplies the exact graph, request ID, current revision,
+revision subject and complete manifest. Unknown fields, duplicate JSON keys,
+foreign items, ambiguous revisions and incomplete results refuse. The card
+renders text through DOM text nodes, including option labels and comments.
+
+Each intent has a stable 256-bit operation ID. The proposal carries five
+expectations in three named graphs:
+
+| Graph | Required slot |
+|---|---|
+| Request | Request's current revision equals the displayed revision |
+| Answer | That request revision's decided slot is empty |
+| Item | Exact canonical item's revision equals the displayed item revision |
+| Item | Exact canonical item's status equals the configured open status |
+| Answer | This operation's type slot is absent |
+
+Typed integers, strings and IRIs stay distinct. JavaScript refuses integers
+outside its exact safe range, oversized UTF-8 values, unpaired surrogates,
+unknown fields and duplicate slots. The fixed native field order is retained
+when serializing proposals and server-issued challenge bytes.
+
+Choose and Later fill the decided slot; comment does not. Comment text is
+retained exactly, including newlines. A comment requires nonblank text.
+The underlying item is neither closed nor revised by these answer facts.
+The reader projects the answer and visible comment from the answer graph;
+existing Brief/Seeds consumer integration is still owed.
+
+The client requires the server's signed deadline to be no later than the
+curated request deadline before asking the authenticator. A request with less
+than the server's challenge lifetime remaining therefore refuses; it does not
+silently extend the request. The reader checks the retained deadline and
+server recording time too. It does not use a browser timestamp as evidence.
+
+## Evidence and pending custody
+
+The reader reconstructs the entire permitted proposal, including every
+expectation and datum, from the current curated request. It compares the exact
+canonical manifest with a native point receipt from the pinned reader route.
+A verified receipt must bind the configured store lineage, signer, actor,
+origin/RP, nonce, message hash and positive transaction. Ordinary-graph copies,
+other request revisions, missing expectations, unknown fields, unverified or
+foreign receipts and extra facts never become answered.
+
+`readAnswer` admits an immutable answer object. `disposition` only folds those
+admitted objects for the exact request: WAITING, ANSWERED or visible LATER.
+Comment alone leaves WAITING. Competing decisions or incomplete evidence are
+UNKNOWN, not an empty success result. A trusted adapter obtains receipt replies
+from native `/attest/receipt`; a caller's own `verified: true` is not authority.
+
+`PendingStore` uses IndexedDB transactions, scoped by the exact operator,
+store lineage and graph configuration. One insertion wins across tabs. It
+stores the proposal, nonce, content hash and request deadline before signing.
+No memory fallback is selected when durable storage fails. The WebAuthn caller
+requests the enrolled credential, RP and server-derived challenge with user
+verification required; it has no software-key fallback.
+
+Before the one commit call, custody changes to UNKNOWN. A timeout, refusal or
+absent point receipt never authorizes a second commit or signature. Recovery
+only reads the same nonce. ACCEPTED requires both the verified receipt and
+complete matching facts/provenance read-back. The UI also re-reads its current
+projection before acknowledging and clearing custody. A never-submitted
+PENDING attempt can be cancelled with a state-and-nonce compare-and-delete;
+an UNKNOWN attempt cannot be discarded that way.
+
+## Shapes and limits
+
+`shapes/deck.shapes.ttl` defines the Decision and Comment source vocabulary.
+Its required-field and value constraints are not a claim that every native
+transaction enforces a complete batch SHACL result. In the qualified candidate,
+post-state admission deliberately excludes `MinCountConstraintComponent` from
+its selected components. A missing-comment signed write can therefore remain
+as a native attestation; Rikuy's full manifest reader rejects it. A native
+invalid-action value does refuse with the loaded shape, providing an armed
+positive control. Loading a shape does not authorize a writer or protect a graph.
+
+An invalid signed write can still occupy a decided slot. The curator advances
+the request revision through its normal conditional lane; it never edits or
+erases the invalid attestation. No source here repairs history automatically.
+
+## Qualification and remaining acceptance
+
+`just ci` exercises the source units and disabled build. The explicit native
+fixture uses generated software keys only in memory, disposable local stores,
+the actual candidate operator CLI and linked native library. It checks Rust
+serialization and queryable provenance, successful choose/Later/comment,
+replay, forgery, wrong origin, changed/closed item, request revision, loaded
+shape values, and native-valid manifests that the Deck reader must reject.
+It is not a production profile-union, HTTP/CORS, hardware or human test.
+
+Generate a fresh Cargo JSONL artifact inventory from the candidate Quipu
+checkout using `cargo test --no-run --message-format=json`. Set `QUIPU_CLI` to
+its clean candidate executable and `QUIPU_ARTIFACTS` to that inventory. Build
+outside the test HOME, then run under the normal HOME guard:
+
+```sh
+node scripts/qualify-deck-native.mjs --build-helper /tmp/rikuy-native-fixture
+QUIPU_NATIVE_FIXTURE=/tmp/rikuy-native-fixture node scripts/qualify-deck-native.mjs
+```
+
+The helper receipt pins its source, library and executable hashes; changed or
+ambiguous artifacts refuse. All stores are created below a new owned temporary
+directory and removed afterward. No default/installed store is opened.
+
+Remaining acceptance: normal source CI/review/landing; admitted curator and
+bounded stored-query adapters; production shape classification; generic signer
+CLI adoption; dedicated trusted origin and live CORS/wrong-origin checks;
+explicit human ceremony; served Brief safety and interleaved performance; then
+separately reviewed old-domain removal. This preview is not activation,
+production publication, a new enrollment route or completed migration.
diff --git a/docs/deck/demo.md b/docs/deck/demo.md
new file mode 100644
index 0000000..1cfd6ea
--- /dev/null
+++ b/docs/deck/demo.md
@@ -0,0 +1,64 @@
+# Source demonstration and repeatable browser fixture
+
+Before, this repository had no online request card or interpretation of generic
+attested-write receipts. The source preview now renders a synthetic current
+request and exercises choose, Later, comment and pending receipt recovery.
+It still ships disabled and selects no production adapter.
+
+[Download the real recovery WebM](recovery.webm) or inspect the
+[final recovered screenshot](recovered.png). In the recording, the synthetic
+commit response is unknown, the original intent stays pending, and a point
+receipt later reconciles it. Exactly one commit and one signing callback occur.
+The browser fixture uses synthetic trusted-reader and signer adapters; it is
+not a real device, native receipt, human decision or service demonstration.
+The separate [native fixture results](native-results.json) record 16 controls
+against the clean candidate CLI/native library, with a different trust scope.
+
+The recording is from the working source over base de8c7fd. The exact browser
+module/fixture hashes are retained in [source-assets.json](source-assets.json),
+with nine controls in [browser-results.json](browser-results.json). Those
+hashes were equal before and after the browser run. The default page is inert;
+all synthetic HTTPS assets are intercepted and nonlocal requests are blocked.
+A WebM-capable player is required; inline playback is not assumed.
+
+The browser controls include actual IndexedDB reload, competing insertions and
+state/nonce deletion checks; they do not substitute a memory store. Comment
+leaves the request waiting, Later stays visible, ordinary-graph evidence is
+UNKNOWN, stale items never sign, and a markup-like title stays plain text.
+
+Use Node22, Playwright1.58.2, Chrome, an isolated display and its installed
+video-recorder dependency. Copy this block to /tmp/playwright-test-deck.cjs
+and run from the repository root under a private HOME and the normal HOME
+guard. PLAYWRIGHT_MODULE and DECK_EVIDENCE select installed dependencies and
+an owned output directory. No production endpoint is contacted.
+
+```javascript
+const {chromium}=require(process.env.PLAYWRIGHT_MODULE || 'playwright');
+const fs=require('fs'),path=require('path'),assert=require('assert/strict');
+const ROOT=process.env.DECK_SOURCE||process.cwd(),OUT=process.env.DECK_EVIDENCE||fs.mkdtempSync(path.join(require('os').tmpdir(),'rikuy-deck-')),ORIGIN='https://sign.example.test';
+const crypto=require('crypto');const sourceFiles=fs.readdirSync(path.join(ROOT,'web/deck')).sort().map(n=>'web/deck/'+n).concat(['tests/deck-fixture.mjs']);const sourceHash=()=>Object.fromEntries(sourceFiles.map(n=>[n,crypto.createHash('sha256').update(fs.readFileSync(path.join(ROOT,n))).digest('hex')]));const beforeSource=sourceHash();
+(async()=>{const browser=await chromium.launch({headless:false,executablePath:'/usr/bin/google-chrome',args:['--no-sandbox','--disable-dev-shm-usage']});const results=[];
+try{for(const scenario of (process.env.DECK_SCENARIOS?.split(',')||['disabled','choose','later','comment','unknown-recover','ordinary-graph','stale-item','xss-title','storage-reload-cas'])){
+ const context=await browser.newContext({viewport:{width:980,height:900},...(scenario==='unknown-recover'?{recordVideo:{dir:path.join(OUT,'video'),size:{width:980,height:900}}}:{})}),blocked=[],errors=[];
+ try{const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));await context.route('**/*',async route=>{const u=new URL(route.request().url());const p=u.pathname.replace(/^\//,'');if(u.origin!==ORIGIN||!((/^web\/deck\/[a-z-]+\.(mjs|css|html)$/).test(p)||p==='tests/deck-fixture.mjs')){blocked.push(u.href);await route.abort();return;}await route.fulfill({body:fs.readFileSync(path.join(ROOT,p)),contentType:p.endsWith('.mjs')?'text/javascript':p.endsWith('.css')?'text/css':'text/html'});});
+ await page.goto(ORIGIN+'/web/deck/index.html');await page.waitForFunction(()=>document.querySelector('[role=status]').textContent.includes('Preview'));
+ if(scenario==='disabled'){assert.equal(await page.locator('button').count(),0);}
+ else if(scenario==='storage-reload-cas'){
+ const proof=await page.evaluate(async()=>{const f=await import('/tests/deck-fixture.mjs');const {PendingStore}=await import('/web/deck/storage.mjs');const {admittedChallenge}=await import('/web/deck/evidence.mjs');let a=await PendingStore.open(f.who,f.boundary),b=await PendingStore.open(f.who,f.boundary);const p=f.make(),issued=await f.issued(p),c=await admittedChallenge(issued,p,f.who,1900000000);const record={proposal:p,nonce:c.challenge.nonce,contentHash:c.contentHash,notAfter:f.ask.expiresAt,state:'PENDING'};const initial=await Promise.allSettled([a.insert(record),b.insert(record)]);if(initial.filter(x=>x.status==='fulfilled').length!==1)throw Error('two-tab insertion was not atomic');a.close();b.close();a=await PendingStore.open(f.who,f.boundary);const retained=await a.get();if(JSON.stringify(retained.proposal)!==JSON.stringify(p))throw Error('reload changed proposal');await a.change(record.nonce,{state:'UNKNOWN'});const refused=[];for(const operation of [()=>a.remove(record.nonce,'PENDING'),()=>a.change(record.nonce,{state:'UNKNOWN',proposal:f.make()}),()=>a.remove('c'.repeat(64),'ACCEPTED')]){try{await operation();refused.push(false);}catch{refused.push(true);}}if(refused.some(x=>!x))throw Error('unknown custody was altered');const other=await PendingStore.open({...f.who,audience:'other-store'},f.boundary);if(await other.get())throw Error('foreign store shares custody');other.close();a.close();return{one_insert:true,reload_exact:true,unknown_preserved:true,scope_isolated:true};});results.push({scenario,...proof,pass:true});
+ }else{
+ await page.evaluate(async scenario=>{const f=await import('/tests/deck-fixture.mjs');const {mount}=await import('/web/deck/view.mjs');const {PendingWrite}=await import('/web/deck/pending.mjs');const {PendingStore}=await import('/web/deck/storage.mjs');const {requestPublication,revisionIri}=await import('/web/deck/model.mjs');const r=structuredClone(f.ask);if(scenario==='xss-title')r.title='
';const publication=requestPublication(r,f.boundary);const requestRow={graph:publication.graph,requestId:r.id,currentRevision:r.revision,revisionIri:revisionIri(r),manifest:publication.datums[1].o.value,complete:true};let proposed,e=null,commits=0,signs=0;const store=await PendingStore.open(f.who,f.boundary);window.fixtureStore=store;const transport={challenge:async p=>{proposed=p;return f.issued(p);},commit:async()=>{commits++;if(scenario!=='unknown-recover')e=await f.evidence(proposed);if(scenario==='unknown-recover')throw Error('indeterminate');},receipt:async()=>e?.response||{found:false,verified:false,receipt:null,tx:null},readback:async()=>e.readback};if(scenario==='ordinary-graph'){e=await f.evidence();e.row.graph=f.boundary.requestGraph;}const writer=new PendingWrite({identity:f.who,store,transport,sign:async()=>{signs++;return{client_data_json:'a',authenticator_data:'b',signature:'c'};},now:()=>1900000000});window.completeFixture=async()=>{e=await f.evidence(proposed);};window.fixtureCounts=()=>({commits,signs});await mount(document.querySelector('main'),{enabled:true,boundary:f.boundary,identity:f.who,read:async()=>({complete:true,requestRow,freshItem:{...f.freshItem,...(scenario==='stale-item'?{revision:8}:{})},rows:e?[{row:e.row,receipt:e.response}]:[]}),writer,now:()=>1900000000});},scenario);
+ if(scenario==='ordinary-graph'){await page.waitForFunction(()=>document.querySelector('[role=status]').textContent.includes('UNKNOWN'));assert.equal(await page.locator('button').count(),0);}
+ else if(scenario==='xss-title'){assert.equal(await page.locator('img').count(),0);assert.equal(await page.locator('h1').innerText(),'
');assert.equal(await page.evaluate(()=>window.injection||false),false);}
+ else{
+ await page.getByRole('textbox',{name:'Comment'}).fill('Synthetic browser comment');const label=scenario==='later'?'Later':scenario==='comment'?'Add comment':'Yes';if(scenario==='unknown-recover')await page.waitForTimeout(1000);await page.getByRole('button',{name:label,exact:true}).click();
+ if(scenario==='stale-item'){await page.waitForFunction(()=>document.querySelector('[role=status]').textContent.includes('UNKNOWN'));assert.deepEqual(await page.evaluate(()=>window.fixtureCounts()),{commits:0,signs:0});}
+ else if(scenario==='unknown-recover'){await page.waitForFunction(()=>document.querySelector('[role=status]').textContent.includes('Pending')&&document.querySelector('main').getAttribute('aria-busy')==='false');assert.equal(await page.getByRole('button',{name:'Yes',exact:true}).isDisabled(),true);assert.deepEqual(await page.evaluate(()=>window.fixtureCounts()),{commits:1,signs:1});await page.waitForTimeout(1500);await page.evaluate(()=>window.completeFixture());await page.getByRole('button',{name:'Recover receipt'}).click();await page.waitForFunction(()=>document.querySelector('[role=status]').textContent.startsWith('Answered')&&document.querySelector('main').getAttribute('aria-busy')==='false');assert.deepEqual(await page.evaluate(()=>window.fixtureCounts()),{commits:1,signs:1});await page.screenshot({path:path.join(OUT,'recovered.png'),fullPage:true});await page.waitForTimeout(2000);}
+ else{await page.waitForFunction(kind=>kind==='comment'?document.querySelector('section').textContent.includes('fixture-operator')&&document.querySelector('[role=status]').textContent.startsWith('Current'):kind==='later'?document.querySelector('[role=status]').textContent.startsWith('Later'):document.querySelector('[role=status]').textContent.startsWith('Answered'),scenario);await page.waitForFunction(()=>document.querySelector('main').getAttribute('aria-busy')==='false');if(scenario==='comment')assert.equal(await page.getByRole('button',{name:'Yes',exact:true}).isDisabled(),false);else assert.equal(await page.getByRole('button',{name:'Yes',exact:true}).isDisabled(),true);assert.deepEqual(await page.evaluate(()=>window.fixtureCounts()),{commits:1,signs:1});}
+ }
+ await page.evaluate(()=>window.fixtureStore.close());
+ }
+ assert.deepEqual(blocked,[]);assert.deepEqual(errors,[]);if(scenario!=='storage-reload-cas')results.push({scenario,pass:true,nonlocal_requests:blocked.length,page_errors:errors});
+ }finally{await context.close();}
+}assert.deepEqual(sourceHash(),beforeSource,'Source changed during browser fixture');fs.writeFileSync(path.join(OUT,'browser-source-hashes.json'),JSON.stringify(beforeSource,null,2));fs.writeFileSync(path.join(OUT,'browser-results.json'),JSON.stringify({scope:'Synthetic trusted-reader and signer adapters; actual DOM and IndexedDB; no service/authenticator/native receipt claim',results},null,2));console.log(JSON.stringify(results));}finally{await browser.close();}})().catch(e=>{console.error(e.stack);process.exitCode=1;});
+
+```
diff --git a/docs/deck/native-results.json b/docs/deck/native-results.json
new file mode 100644
index 0000000..551a3d1
--- /dev/null
+++ b/docs/deck/native-results.json
@@ -0,0 +1,93 @@
+{
+ "candidate": "quipu 0.16.3\ngit_sha: 771e710de7294b718ceaa1a4b825fd28e4b34c23\ngit_dirty: false",
+ "native_library_sha256": "2ac7b3096f54903dd18664016fc7d102632cb8b079a4fbb239b8818b9d3e1ef8",
+ "helper_source_sha256": "4e78bbc7eeae503b1477881dc9c8e8a93f804fa15f7f12e5e034df11e9dd1bbd",
+ "fixture": "Disposable stores; generated software keys kept only in memory; local native methods and actual operator CLI; no HTTP/service/real credential or production profile union",
+ "results": [
+ {
+ "case": "choose",
+ "state": "ANSWERED",
+ "canonical_native_match": true,
+ "native_verified": true,
+ "replay_no_mutation": true
+ },
+ {
+ "case": "later",
+ "state": "LATER",
+ "canonical_native_match": true,
+ "native_verified": true,
+ "replay_no_mutation": true
+ },
+ {
+ "case": "comment",
+ "state": "WAITING",
+ "canonical_native_match": true,
+ "native_verified": true,
+ "replay_no_mutation": true
+ },
+ {
+ "case": "forged",
+ "refused_no_mutation": true,
+ "nonce_and_counter_unspent": true
+ },
+ {
+ "case": "wrong-origin",
+ "refused_no_mutation": true,
+ "nonce_and_counter_unspent": true
+ },
+ {
+ "case": "item-revision",
+ "refused_no_mutation": true,
+ "nonce_and_counter_unspent": true
+ },
+ {
+ "case": "closed-item",
+ "refused_no_mutation": true,
+ "nonce_and_counter_unspent": true
+ },
+ {
+ "case": "request-revision",
+ "refused_no_mutation": true,
+ "nonce_and_counter_unspent": true
+ },
+ {
+ "case": "missing-expectation-0",
+ "native_verified": true,
+ "deck_reader_refused": true
+ },
+ {
+ "case": "missing-expectation-1",
+ "native_verified": true,
+ "deck_reader_refused": true
+ },
+ {
+ "case": "missing-expectation-2",
+ "native_verified": true,
+ "deck_reader_refused": true
+ },
+ {
+ "case": "missing-expectation-3",
+ "native_verified": true,
+ "deck_reader_refused": true
+ },
+ {
+ "case": "missing-expectation-4",
+ "native_verified": true,
+ "deck_reader_refused": true
+ },
+ {
+ "case": "missing-comment-floor",
+ "native_verified": true,
+ "deck_reader_refused": true,
+ "limitation": "Native post-state admission excludes MinCount floors; full shape validation and client/reader constraints remain distinct"
+ },
+ {
+ "case": "loaded-action-value-constraint",
+ "refused_no_mutation": true
+ },
+ {
+ "case": "ordinary-graph-and-other-request-revision",
+ "deck_reader_refused": true
+ }
+ ]
+}
diff --git a/docs/deck/recovered.png b/docs/deck/recovered.png
new file mode 100644
index 0000000..9b33b68
Binary files /dev/null and b/docs/deck/recovered.png differ
diff --git a/docs/deck/recovery.webm b/docs/deck/recovery.webm
new file mode 100644
index 0000000..36c7bb8
Binary files /dev/null and b/docs/deck/recovery.webm differ
diff --git a/docs/deck/source-assets.json b/docs/deck/source-assets.json
new file mode 100644
index 0000000..b7c5635
--- /dev/null
+++ b/docs/deck/source-assets.json
@@ -0,0 +1,14 @@
+{
+ "web/deck/app.mjs": "b6067fadf3a8ecc38d078fb8a5c9a2c12cece95d40ac0ded772d3437e0934571",
+ "web/deck/config.mjs": "a1260c00e253f2c36dd6e05f73b322ce3d0759180289c44788e55bd54420ef89",
+ "web/deck/evidence.mjs": "9802076b6ca3d18c8c56a47de206011ea767e51cbc652e8c9ccb503f0f39bfa7",
+ "web/deck/index.html": "893f9cb7a446e97d08ba1895c3930f6037db6cbac80d46733f5b4ffe167040e0",
+ "web/deck/model.mjs": "5cc4b0400d3795207feb9e664908f76363bf335b1329c774f6c1bfad480ea278",
+ "web/deck/pending.mjs": "d01971f6f2b8bbee768624e609037bf203c38cf55098a9d2b27b477331c9bf30",
+ "web/deck/sign.mjs": "4b3cbf761a2fc91f24ff9c69e382b5165f7aed15f6083fcaa9b34dbebdc1a201",
+ "web/deck/storage.mjs": "cb9bd06ae0a2d9d0b311bb41006e9d4420df00029cf804a8d21707aa36f345c4",
+ "web/deck/style.css": "6a8fe7c6fc5a99618bfd9e5d824ba25c92552bf6be689d98877f265ef972dbcf",
+ "web/deck/value.mjs": "f8ecf293993866e9c48b6d4cc79a1598acde120fb6fb237b133302043fda51c7",
+ "web/deck/view.mjs": "830997563eccfd5a3513df7ebb88c5ca48ed1f9ee82e8bc2a95a2f2972d33b65",
+ "tests/deck-fixture.mjs": "94e13282f9b8cbf6c8702f5da7cea428460ceabbc86e8a69f40da782b241048f"
+}
\ No newline at end of file
diff --git a/scripts/build.mjs b/scripts/build.mjs
index fea6a55..4157773 100644
--- a/scripts/build.mjs
+++ b/scripts/build.mjs
@@ -5,7 +5,7 @@ const root=resolve(import.meta.dirname,'..'),out=resolve(root,'dist');
await rm(out,{recursive:true,force:true});await mkdir(out,{recursive:true});
const filter=p=>!p.endsWith('.test.mjs')&&!p.includes('/fixtures')&&!p.endsWith('/demo.mjs');
await cp(resolve(root,'web/legacy'),out,{recursive:true,filter});
-for(const name of ['knowledge','offline','enroll'])await cp(resolve(root,'web',name),resolve(out,name),{recursive:true,filter});
+for(const name of ['knowledge','offline','enroll','deck'])await cp(resolve(root,'web',name),resolve(out,name),{recursive:true,filter});
const revision=execFileSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8'}).trim();
await writeFile(resolve(out,'source.json'),JSON.stringify({project:'rikuy',revision,activation:'deployment-owned; experimental readers OFF'},null,2)+'\n');
console.log('Built static Rikuy assets; experimental readers remain disabled.');
diff --git a/scripts/qualify-deck-native.mjs b/scripts/qualify-deck-native.mjs
new file mode 100644
index 0000000..cd034f4
--- /dev/null
+++ b/scripts/qualify-deck-native.mjs
@@ -0,0 +1,143 @@
+// # arming: agent-invoked explicit disposable qualification; no installed/default store.
+import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
+import {tmpdir} from 'node:os';
+import {join, dirname, resolve} from 'node:path';
+import {execFileSync} from 'node:child_process';
+import {createHash, generateKeyPairSync, sign} from 'node:crypto';
+import assert from 'node:assert/strict';
+import {NS} from '../web/deck/value.mjs';
+import {buildProposal, canonicalProposal, operationIri, requestPublication, readRequest, revisionIri} from '../web/deck/model.mjs';
+import {admittedChallenge, readAnswer, disposition, readbackProof} from '../web/deck/evidence.mjs';
+const cli = process.env.QUIPU_CLI, artifacts = process.env.QUIPU_ARTIFACTS;
+if (!cli || !artifacts) throw Error('Explicit candidate QUIPU_CLI and fresh QUIPU_ARTIFACTS JSONL required');
+const root = mkdtempSync(join(tmpdir(), 'rikuy-native-')), source = resolve(import.meta.dirname, '..');
+const libraries = new Map();
+for (const line of readFileSync(artifacts, 'utf8').split('\n')) {
+ let record; try {record = JSON.parse(line);} catch {continue;}
+ if (record.reason !== 'compiler-artifact' || record.profile?.test || !record.target?.kind?.includes('lib') || !['quipu', 'serde_json'].includes(record.target.name)) continue;
+ const paths = record.filenames.filter(p => p.endsWith('.rlib'));
+ if (paths.length !== 1 || libraries.has(record.target.name) && libraries.get(record.target.name) !== paths[0]) throw Error('Ambiguous candidate library population');
+ libraries.set(record.target.name, paths[0]);
+}
+if (libraries.size !== 2) throw Error('Candidate quipu and serde_json library artifacts missing');
+const buildAt = process.argv[2] === '--build-helper' ? process.argv[3] : null;
+const native = buildAt || process.env.QUIPU_NATIVE_FIXTURE || join(root, 'native-fixture');
+const results = [], sha = bytes => createHash('sha256').update(bytes).digest('hex');
+const boundary = {requestGraph: 'https://example.test/requests', answerGraph: 'https://example.test/answers', itemGraph: 'https://example.test/items', itemPrefix: 'https://example.test/item/', revisionPredicate: 'https://example.test/revision', statusPredicate: 'https://schema.org/actionStatus', openStatus: 'https://schema.org/ActiveActionStatus'};
+const ask = () => ({id: 'https://example.test/request/1', revision: 3, seed: 'https://example.test/item/1', itemRevision: 7, title: 'Approve the synthetic change?', options: ['Yes', 'No'], actions: ['choose', 'later', 'comment'], expiresAt: Math.floor(Date.now() / 1000) + 3600});
+function command(db, verb, value) {
+ return JSON.parse(execFileSync(native, [db, verb], {input: JSON.stringify(value), encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']}));
+}
+function cliRun(db, args) {
+ return execFileSync(cli, [...args, '--db', db], {encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']});
+}
+function put(db, graph, datums, retract = false) {return command(db, 'seed', {graph, datums, retract});}
+function replace(db, graph, s, p, old, value) {put(db, graph, [{s, p, o: old}], true); put(db, graph, [{s, p, o: value}]);}
+function setup(index, kind = 'choose') {
+ const db = join(root, `case-${index}.db`), r = ask();
+ const {privateKey, publicKey} = generateKeyPairSync('ed25519');
+ const x = Buffer.from(publicKey.export({format: 'jwk'}).x, 'base64url');
+ const cose = Buffer.concat([Buffer.from([0xa4, 1, 1, 3, 0x27, 0x20, 6, 0x21, 0x58, 0x20]), x]);
+ const publication = requestPublication(r, boundary);
+ put(db, publication.graph, publication.datums);
+ const requestFacts = command(db, 'facts', {graph: boundary.requestGraph}).datums;
+ const requestRow = {graph: boundary.requestGraph, requestId: r.id, currentRevision: requestFacts.find(d => d.s === r.id && d.p === NS + 'currentRevision').o.value, revisionIri: revisionIri(r), manifest: requestFacts.find(d => d.s === revisionIri(r) && d.p === NS + 'requestManifest').o.value, complete: true};
+ assert.deepEqual(readRequest(requestRow, boundary), r);
+ put(db, boundary.itemGraph, [{s: r.seed, p: boundary.revisionPredicate, o: {kind: 'int', value: r.itemRevision}}, {s: r.seed, p: boundary.statusPredicate, o: {kind: 'iri', value: boundary.openStatus}}]);
+ cliRun(db, ['shapes', 'load', 'rikuy-deck', join(source, 'shapes/deck.shapes.ttl')]);
+ cliRun(db, ['signer', 'enroll', '--credential-id', 'dGVzdA', '--cose-key', cose.toString('base64url'), '--scheme', 'webauthn-eddsa', '--origin', 'https://sign.example.test', '--rp-id', 'sign.example.test', '--actor', 'fixture-operator', '--graph', boundary.answerGraph, '--expires-at', String(r.expiresAt)]);
+ assert.equal(JSON.parse(cliRun(db, ['signer', 'status'])).enabled, false);
+ cliRun(db, ['signer', 'protect-graph', boundary.answerGraph]);
+ cliRun(db, ['signer', 'enable']); // Synthetic temporary database only.
+ const who = {signer: 'dGVzdA', audience: command(db, 'store-id', {}).store_id, origin: 'https://sign.example.test', rpId: 'sign.example.test', actor: 'fixture-operator'};
+ const action = {operationId: String(index + 1).padStart(64, '0'), action: kind, optionLabel: kind === 'choose' ? 'Yes' : null, comment: 'Synthetic native comment\non two lines'};
+ const p = buildProposal({boundary, ask: r, freshItem: {graph: boundary.itemGraph, seed: r.seed, revision: r.itemRevision, status: boundary.openStatus}, signer: who.signer, action, now: Math.floor(Date.now() / 1000)});
+ assert.equal(command(db, 'canonical', p).canonical, canonicalProposal(p));
+ return {db, r, who, action, p, privateKey};
+}
+function assertion(state, issued, origin = state.who.origin, forge = false) {
+ const client = Buffer.from(JSON.stringify({type: 'webauthn.get', challenge: issued.webauthn_challenge, origin, crossOrigin: false}));
+ const auth = Buffer.alloc(37); createHash('sha256').update(state.who.rpId).digest().copy(auth); auth[32] = 5; auth.writeUInt32BE(1, 33);
+ const signature = sign(null, Buffer.concat([auth, createHash('sha256').update(client).digest()]), state.privateKey);
+ if (forge) signature[0] ^= 1;
+ return {nonce: issued.challenge.nonce, client_data_json: client.toString('base64url'), authenticator_data: auth.toString('base64url'), signature: signature.toString('base64url')};
+}
+function footprint(state, nonce) {
+ return {facts: command(state.db, 'facts', {graph: boundary.answerGraph}), receipt: command(state.db, 'receipt', {nonce}), counter: JSON.parse(cliRun(state.db, ['signer', 'status'])).signers[0].sign_count};
+}
+async function admit(state, p = state.p) {
+ const issued = command(state.db, 'challenge', p);
+ await admittedChallenge(issued, p, state.who, Math.floor(Date.now() / 1000));
+ const body = assertion(state, issued);
+ command(state.db, 'commit', body);
+ const receipt = command(state.db, 'receipt', {nonce: body.nonce});
+ const facts = command(state.db, 'facts', {graph: boundary.answerGraph}).datums;
+ const manifests = facts.filter(d => d.s === receipt.receipt_iri && d.p.endsWith('attestProposal'));
+ assert.equal(manifests.length, 1);
+ const row = {graph: boundary.answerGraph, subject: operationIri(state.action.operationId), receiptIri: receipt.receipt_iri, manifest: manifests[0].o.value};
+ return {issued, body, receipt, facts, row};
+}
+try {
+ const compile = ['--edition=2024', join(source, 'tests/native/deck-fixture.rs'), '-L', 'dependency=' + dirname(libraries.get('quipu')), ...[...libraries].flatMap(([name, path]) => ['--extern', `${name}=${path}`]), '-o', native];
+ const metadata = () => ({source_sha256: sha(readFileSync(join(source, 'tests/native/deck-fixture.rs'))), library_sha256: sha(readFileSync(libraries.get('quipu'))), executable_sha256: sha(readFileSync(native))});
+ if (buildAt || !process.env.QUIPU_NATIVE_FIXTURE) execFileSync('rustc', compile, {stdio: ['ignore', 'pipe', 'pipe']});
+ if (buildAt) {
+ writeFileSync(native + '.json', JSON.stringify(metadata(), null, 2) + '\n', {mode: 0o600});
+ console.log(JSON.stringify(metadata()));
+ } else {
+ if (process.env.QUIPU_NATIVE_FIXTURE) assert.deepEqual(JSON.parse(readFileSync(native + '.json', 'utf8')), metadata(), 'Stale native fixture artifact');
+ const candidate = execFileSync(cli, ['--version'], {encoding: 'utf8'}).trim();
+ for (const [index, kind] of ['choose', 'later', 'comment'].entries()) {
+ const state = setup(index, kind), e = await admit(state);
+ const answer = await readAnswer(e.row, e.receipt, state.r, boundary, state.who);
+ assert.equal(disposition([answer], state.r, boundary).state, {choose: 'ANSWERED', later: 'LATER', comment: 'WAITING'}[kind]);
+ const subjects = new Set(state.p.datums.map(d => d.s));
+ readbackProof({graph: boundary.answerGraph, complete: true, datums: e.facts.filter(d => state.p.datums.some(p => p.s === d.s && p.p === d.p && p.o.kind === d.o.kind && p.o.value === d.o.value)), attestedBy: e.facts.filter(d => subjects.has(d.s) && d.p.endsWith('attestedBy')).map(d => ({s: d.s, receiptIri: d.o.value}))}, answer);
+ const before = footprint(state, e.body.nonce);
+ assert.throws(() => command(state.db, 'commit', e.body));
+ assert.deepEqual(footprint(state, e.body.nonce), before);
+ assert.throws(() => command(state.db, 'challenge', state.p)); // Decided or operation slot is spent.
+ results.push({case: kind, state: disposition([answer], state.r, boundary).state, canonical_native_match: true, native_verified: e.receipt.verified, replay_no_mutation: true});
+ }
+ for (const [index, kind] of ['forged', 'wrong-origin', 'item-revision', 'closed-item', 'request-revision'].entries()) {
+ const state = setup(index + 10), issued = command(state.db, 'challenge', state.p);
+ let restore = () => {};
+ if (kind === 'item-revision') {replace(state.db, boundary.itemGraph, state.r.seed, boundary.revisionPredicate, {kind: 'int', value: 7}, {kind: 'int', value: 8}); restore = () => replace(state.db, boundary.itemGraph, state.r.seed, boundary.revisionPredicate, {kind: 'int', value: 8}, {kind: 'int', value: 7});}
+ if (kind === 'closed-item') {const closed = 'https://schema.org/CompletedActionStatus'; replace(state.db, boundary.itemGraph, state.r.seed, boundary.statusPredicate, {kind: 'iri', value: boundary.openStatus}, {kind: 'iri', value: closed}); restore = () => replace(state.db, boundary.itemGraph, state.r.seed, boundary.statusPredicate, {kind: 'iri', value: closed}, {kind: 'iri', value: boundary.openStatus});}
+ if (kind === 'request-revision') {replace(state.db, boundary.requestGraph, state.r.id, NS + 'currentRevision', {kind: 'int', value: 3}, {kind: 'int', value: 4}); restore = () => replace(state.db, boundary.requestGraph, state.r.id, NS + 'currentRevision', {kind: 'int', value: 4}, {kind: 'int', value: 3});}
+ const before = footprint(state, issued.challenge.nonce), body = assertion(state, issued, kind === 'wrong-origin' ? 'https://other.example.test' : state.who.origin, kind === 'forged');
+ assert.throws(() => command(state.db, 'commit', body));
+ assert.deepEqual(footprint(state, issued.challenge.nonce), before);
+ restore(); command(state.db, 'commit', assertion(state, issued));
+ assert.equal(command(state.db, 'receipt', {nonce: issued.challenge.nonce}).verified, true); // Refusal did not consume nonce/counter.
+ results.push({case: kind, refused_no_mutation: true, nonce_and_counter_unspent: true});
+ }
+ for (const [index, slot] of [0, 1, 2, 3, 4].entries()) {
+ const state = setup(index + 20), p = structuredClone(state.p); p.expect.splice(slot, 1);
+ const e = await admit(state, p); assert.equal(e.receipt.verified, true); // Domain-free native primitive permits it.
+ await assert.rejects(readAnswer(e.row, e.receipt, state.r, boundary, state.who));
+ results.push({case: `missing-expectation-${slot}`, native_verified: true, deck_reader_refused: true});
+ }
+ {
+ const state = setup(35, 'comment'), p = structuredClone(state.p);
+ p.datums = p.datums.filter(d => d.p !== NS + 'comment');
+ const e = await admit(state, p);
+ await assert.rejects(readAnswer(e.row, e.receipt, state.r, boundary, state.who));
+ results.push({case: 'missing-comment-floor', native_verified: true, deck_reader_refused: true, limitation: 'Native post-state admission excludes MinCount floors; full shape validation and client/reader constraints remain distinct'});
+ }
+ {
+ const state = setup(36, 'comment'), p = structuredClone(state.p);
+ p.datums.find(d => d.p === NS + 'action').o.value = 'undeclared';
+ const issued = command(state.db, 'challenge', p), before = footprint(state, issued.challenge.nonce);
+ assert.throws(() => command(state.db, 'commit', assertion(state, issued)));
+ assert.deepEqual(footprint(state, issued.challenge.nonce), before);
+ results.push({case: 'loaded-action-value-constraint', refused_no_mutation: true});
+ }
+ const state = setup(40), e = await admit(state), ordinary = 'https://example.test/ordinary';
+ put(state.db, ordinary, state.p.datums);
+ await assert.rejects(readAnswer({...e.row, graph: ordinary}, e.receipt, state.r, boundary, state.who));
+ await assert.rejects(readAnswer(e.row, e.receipt, {...state.r, revision: 4}, boundary, state.who));
+ results.push({case: 'ordinary-graph-and-other-request-revision', deck_reader_refused: true});
+ console.log(JSON.stringify({candidate, native_library_sha256: sha(readFileSync(libraries.get('quipu'))), helper_source_sha256: sha(readFileSync(join(source, 'tests/native/deck-fixture.rs'))), fixture: 'Disposable stores; generated software keys kept only in memory; local native methods and actual operator CLI; no HTTP/service/real credential or production profile union', results}, null, 2));
+ }
+} finally {rmSync(root, {recursive: true, force: true});}
diff --git a/shapes/deck.shapes.ttl b/shapes/deck.shapes.ttl
new file mode 100644
index 0000000..6cea759
--- /dev/null
+++ b/shapes/deck.shapes.ttl
@@ -0,0 +1,27 @@
+@prefix r: .
+@prefix sh: .
+@prefix xsd: .
+@prefix rdfs: .
+
+r:Decision a rdfs:Class ; rdfs:label "Rikuy decision" .
+r:Comment a rdfs:Class ; rdfs:label "Rikuy comment" .
+
+# Membership in the declared options, cross-graph current revision and
+# attestation provenance are checked by the reader and signed expectations.
+# Loading these shapes does not authorize a writer or protect a graph.
+r:DecisionShape a sh:NodeShape ; sh:targetClass r:Decision ;
+ sh:property [ sh:path r:request ; sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ] ;
+ sh:property [ sh:path r:revision ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:integer ; sh:minInclusive 1 ] ;
+ sh:property [ sh:path r:seed ; sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ] ;
+ sh:property [ sh:path r:action ; sh:minCount 1 ; sh:maxCount 1 ; sh:in ( "choose" "later" ) ] ;
+ sh:property [ sh:path r:operationId ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ] ;
+ sh:property [ sh:path r:optionLabel ; sh:maxCount 1 ; sh:datatype xsd:string ; sh:maxLength 256 ] ;
+ sh:property [ sh:path r:comment ; sh:maxCount 1 ; sh:datatype xsd:string ; sh:maxLength 4096 ] .
+
+r:CommentShape a sh:NodeShape ; sh:targetClass r:Comment ;
+ sh:property [ sh:path r:request ; sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ] ;
+ sh:property [ sh:path r:revision ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:integer ; sh:minInclusive 1 ] ;
+ sh:property [ sh:path r:seed ; sh:minCount 1 ; sh:maxCount 1 ; sh:nodeKind sh:IRI ] ;
+ sh:property [ sh:path r:action ; sh:minCount 1 ; sh:maxCount 1 ; sh:in ( "comment" ) ] ;
+ sh:property [ sh:path r:operationId ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:string ; sh:pattern "^[0-9a-f]{64}$" ] ;
+ sh:property [ sh:path r:comment ; sh:minCount 1 ; sh:maxCount 1 ; sh:datatype xsd:string ; sh:minLength 1 ; sh:maxLength 4096 ] .
diff --git a/tests/build.test.mjs b/tests/build.test.mjs
index d3f88c1..dc06fbc 100644
--- a/tests/build.test.mjs
+++ b/tests/build.test.mjs
@@ -11,6 +11,13 @@ test('deployment assets omit the synthetic demo entry point and fixture payload'
await assert.rejects(access('dist/demo-rows.json'));
});
+test('online Deck modules publish together with disabled configuration and no test fixtures',async()=>{
+ execFileSync(process.execPath,['scripts/build.mjs']);
+ for(const name of ['index.html','style.css','app.mjs','config.mjs','value.mjs','model.mjs','evidence.mjs','pending.mjs','sign.mjs','storage.mjs','view.mjs']) assert.equal(await readFile('dist/deck/'+name,'utf8'),await readFile('web/deck/'+name,'utf8'));
+ assert.match(await readFile('dist/deck/config.mjs','utf8'),/enabled: false/);
+ await assert.rejects(access('dist/deck/fixtures'));
+ await assert.rejects(access('dist/deck/deck-fixture.mjs'));
+});
test('enrollment assets are published together and retain the disabled configuration',async()=>{
execFileSync(process.execPath,['scripts/build.mjs']);
diff --git a/tests/deck-fixture.mjs b/tests/deck-fixture.mjs
new file mode 100644
index 0000000..dc86397
--- /dev/null
+++ b/tests/deck-fixture.mjs
@@ -0,0 +1,27 @@
+// Synthetic reader/challenge fixtures, not signature or installed-service proof.
+import {NS, base64url, sha256, utf8} from '../web/deck/value.mjs';
+import {buildProposal, canonicalProposal, operationIri} from '../web/deck/model.mjs';
+export const boundary = Object.freeze({requestGraph: 'https://example.test/requests', answerGraph: 'https://example.test/answers', itemGraph: 'https://example.test/items', itemPrefix: 'https://example.test/item/', revisionPredicate: 'https://example.test/revision', statusPredicate: 'https://schema.org/actionStatus', openStatus: 'https://schema.org/ActiveActionStatus'});
+export const who = Object.freeze({signer: 'dGVzdA', audience: 'synthetic-store', origin: 'https://sign.example.test', rpId: 'sign.example.test', actor: 'fixture-operator'});
+export const ask = Object.freeze({id: 'https://example.test/request/1', revision: 3, seed: 'https://example.test/item/1', itemRevision: 7, title: 'Approve the synthetic change?', options: ['Yes', 'No'], actions: ['choose', 'later', 'comment'], expiresAt: 2000000000});
+export const freshItem = Object.freeze({graph: boundary.itemGraph, seed: ask.seed, revision: ask.itemRevision, status: boundary.openStatus});
+export const action = Object.freeze({operationId: 'a'.repeat(64), action: 'choose', optionLabel: 'Yes', comment: 'A note\non two lines'});
+export const make = (changes = {}) => buildProposal({boundary, ask, freshItem, signer: who.signer, action, now: 1900000000, ...changes});
+export async function issued(p = make(), changes = {}) {
+ const challenge = {domain: 'quipu-attested-write-v1', audience: who.audience, nonce: 'b'.repeat(64), origin: who.origin, rp_id: who.rpId, actor: who.actor, proposal: p, expires_at: 1900000300, ...changes};
+ const message = utf8(JSON.stringify(challenge)), prefix = utf8('quipu-verdict-webauthn-v1\0'), all = new Uint8Array(prefix.length + message.length);
+ all.set(prefix); all.set(message, prefix.length);
+ return {challenge, message: base64url(message), webauthn_challenge: base64url(await crypto.subtle.digest('SHA-256', all))};
+}
+export async function evidence(p = make()) {
+ const value = await issued(p);
+ const receipt = {challenge: value.challenge, content_hash: 'sha256:' + await sha256(utf8(JSON.stringify(value.challenge))), recorded_at: 1900000001};
+ const receiptIri = 'urn:quipu:attest:' + value.challenge.nonce;
+ const operationId = p.datums.find(d => d.p === NS + 'operationId').o.value;
+ return {row: {graph: boundary.answerGraph, subject: operationIri(operationId), receiptIri, manifest: canonicalProposal(p)}, response: {found: true, verified: true, tx: 9, receipt_iri: receiptIri, receipt}, readback: {graph: boundary.answerGraph, datums: p.datums, attestedBy: [...new Set(p.datums.map(d => d.s))].map(s => ({s, receiptIri})), complete: true}};
+}
+export function memory() {
+ let value = null;
+ return {get: async () => structuredClone(value), insert: async record => {if (value) throw Error('CAS conflict'); value = structuredClone(record);}, change: async (nonce, patch) => {if (value?.nonce !== nonce) throw Error('CAS conflict'); Object.assign(value, structuredClone(patch));}, remove: async (nonce, state) => {if (value?.nonce !== nonce || value?.state !== state) throw Error('CAS conflict'); value = null;}};
+}
+export const currentRevisionPredicate = NS + 'currentRevision';
diff --git a/tests/deck.test.mjs b/tests/deck.test.mjs
new file mode 100644
index 0000000..74c2b39
--- /dev/null
+++ b/tests/deck.test.mjs
@@ -0,0 +1,145 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {NS, RDF_TYPE, base64url, unbase64url} from '../web/deck/value.mjs';
+import {request, requestPublication, readRequest, buildProposal, canonicalProposal, decodeProposal, revisionIri} from '../web/deck/model.mjs';
+import {admittedChallenge, readAnswer, disposition, receiptProof, readbackProof} from '../web/deck/evidence.mjs';
+import {PendingWrite} from '../web/deck/pending.mjs';
+import {webauthnSigner} from '../web/deck/sign.mjs';
+import {boundary, who, ask, freshItem, action, make, issued, evidence, memory} from './deck-fixture.mjs';
+const copy = value => structuredClone(value);
+test('curated request manifests bind the complete declared card to one current graph and revision', () => {
+ const publication = requestPublication(ask, boundary);
+ const row = {graph: publication.graph, requestId: ask.id, currentRevision: ask.revision, revisionIri: revisionIri(ask), manifest: publication.datums[1].o.value, complete: true};
+ assert.deepEqual(readRequest(row, boundary), request(ask, boundary));
+ for (const patch of [{graph: boundary.answerGraph}, {currentRevision: 4}, {revisionIri: ask.id + '#revision-2'}, {complete: false}, {manifest: row.manifest.replace('"revision":3', '"revision":2,"revision":3')}]) assert.throws(() => readRequest({...row, ...patch}, boundary));
+});
+test('online proposals retain all five slots across three graphs with exact native value kinds', () => {
+ const p = make();
+ assert.equal(p.expect.length, 5);
+ assert.equal(new Set(p.expect.map(e => e.graph)).size, 3);
+ assert.equal(p.expect[0].o.kind, 'int');
+ assert.equal(p.expect[1].o, null);
+ assert.equal(p.expect[3].o.kind, 'iri');
+ assert.deepEqual(decodeProposal(canonicalProposal(p)), p);
+ assert.ok(p.datums.some(d => d.p === NS + 'decided' && d.s === revisionIri(ask)));
+ assert.equal(p.datums.find(d => d.p === NS + 'comment').o.value, action.comment);
+});
+test('changed or closed item, foreign graph, stale revision, expiry and undeclared choices refuse', () => {
+ for (const changed of [{revision: 8}, {status: 'https://schema.org/CompletedActionStatus'}, {graph: boundary.answerGraph}, {seed: 'https://example.test/item/2'}]) assert.throws(() => make({freshItem: {...freshItem, ...changed}}));
+ assert.throws(() => make({now: ask.expiresAt}));
+ for (const changed of [{optionLabel: 'Maybe'}, {action: 'unknown'}, {operationId: 'bad'}, {comment: 'x'.repeat(4097)}]) assert.throws(() => make({action: {...action, ...changed}}));
+ assert.throws(() => request({...ask, seed: 'https://other.test/item/1'}, boundary));
+ assert.throws(() => request({...ask, options: ['Yes', 'Yes']}, boundary));
+ assert.throws(() => make({boundary: {...boundary, answerGraph: boundary.requestGraph}}));
+});
+test('canonical manifest rejects duplicate JSON fields, extra data fields, inexact and untyped values', () => {
+ const raw = canonicalProposal(make());
+ for (const bad of [raw.replace('"signer":', '"signer":"wrong","signer":'), ' ' + raw, raw.replace('"kind":"int","value":3', '"kind":"str","value":3')]) assert.throws(() => decodeProposal(bad));
+ for (const mutate of [p => p.expect.push(p.expect[0]), p => p.datums.push(p.datums[0]), p => p.datums[0].o.extra = true, p => p.expect[0].o.value = 9007199254740992]) {const p = make(); mutate(p); assert.throws(() => canonicalProposal(p));}
+});
+test('bounded Unicode and multiline manifests round-trip without changing bytes, labels or typed slots', () => {
+ const samples = ['Yes', 'quoted "choice"', 'back\\slash', 'café', '東京', '🙂', 'A\u2028B', 'e\u0301'];
+ for (const label of samples) for (const note of ['', ...samples, 'line 1\nline 2\tindented']) {
+ const p = make({ask: {...ask, options: [label]}, action: {...action, optionLabel: label, comment: note}});
+ const bytes = canonicalProposal(p), decoded = decodeProposal(bytes);
+ assert.equal(canonicalProposal(decoded), bytes);
+ assert.deepEqual(decoded, p);
+ assert.equal(decoded.datums.find(d => d.p === NS + 'optionLabel').o.value, label);
+ assert.equal(decoded.datums.find(d => d.p === NS + 'comment')?.o.value ?? '', note);
+ }
+ assert.throws(() => make({action: {...action, comment: '\ud800'}}));
+ assert.throws(() => make({action: {...action, comment: '🙂'.repeat(1025)}}));
+});
+test('choose, Later and comment fold from verified point receipts; comments do not decide', async () => {
+ for (const kind of ['choose', 'later', 'comment']) {
+ const p = make({action: {...action, action: kind, optionLabel: kind === 'choose' ? 'Yes' : null}}), e = await evidence(p);
+ const admitted = await readAnswer(e.row, e.response, ask, boundary, who);
+ assert.equal(disposition([admitted], ask, boundary).state, {choose: 'ANSWERED', later: 'LATER', comment: 'WAITING'}[kind]);
+ assert.equal(disposition([admitted], ask, boundary).comments[0].text, action.comment);
+ assert.equal(disposition([admitted], ask, boundary).comments[0].actor, who.actor);
+ assert.equal(p.datums.some(d => d.p === NS + 'decided'), kind !== 'comment');
+ }
+ assert.throws(() => make({action: {...action, action: 'comment', optionLabel: null, comment: ' \n '}}));
+ assert.throws(() => disposition(Array(101).fill({}), ask, boundary));
+ assert.throws(() => disposition([{action: 'choose'}], ask, boundary));
+});
+for (const slot of [0, 1, 2, 3, 4]) test(`reader refuses a receipt manifest missing required precondition ${slot}`, async () => {
+ const p = make(); p.expect.splice(slot, 1);
+ const e = await evidence(p);
+ await assert.rejects(readAnswer(e.row, e.response, ask, boundary, who));
+});
+test('ordinary graph, wrong revision, forged provenance, foreign receipt and extra facts never count', async () => {
+ const positive = await evidence();
+ await readAnswer(positive.row, positive.response, ask, boundary, who);
+ const cases = [e => e.row.graph = boundary.requestGraph, e => e.row.receiptIri = 'urn:quipu:attest:' + 'c'.repeat(64), e => e.response.verified = false, e => e.response.found = false, e => e.response.tx = 0, e => e.response.receipt.challenge.actor = 'browser-asserted-human', e => e.response.receipt.content_hash = 'sha256:' + '0'.repeat(64)];
+ for (const mutate of cases) {const e = copy(positive); mutate(e); await assert.rejects(readAnswer(e.row, e.response, ask, boundary, who));}
+ await assert.rejects(readAnswer(positive.row, positive.response, {...ask, revision: 4}, boundary, who));
+ for (const mutate of [p => p.expect[0].graph = boundary.answerGraph, p => p.datums.push({s: ask.seed, p: NS + 'unrelated', o: {kind: 'str', value: 'extra'}})]) {
+ const p = make(); mutate(p);
+ const e = await evidence(p); await assert.rejects(readAnswer(e.row, e.response, ask, boundary, who));
+ }
+ const malformed = make(); malformed.expect[2].o.kind = 'str';
+ await assert.rejects(evidence(malformed));
+});
+test('returned challenge bytes, domain, derived WebAuthn challenge, scope and deadline are bound', async () => {
+ const p = make(), positive = await issued(p);
+ assert.equal((await admittedChallenge(positive, p, who, 1900000000)).challenge.nonce, 'b'.repeat(64));
+ for (const changes of [{domain: 'quipu-decision-deck-v1'}, {audience: 'foreign-store'}, {expires_at: 1900000301}, {expires_at: 1900000000}, {origin: 'https://other.test'}]) await assert.rejects(admittedChallenge(await issued(p, changes), p, who, 1900000000));
+ await assert.rejects(admittedChallenge({...positive, message: base64url(new Uint8Array([1, 2]))}, p, who, 1900000000));
+ await assert.rejects(admittedChallenge({...positive, webauthn_challenge: 'a'.repeat(43)}, p, who, 1900000000));
+ await assert.rejects(admittedChallenge(positive, make({action: {...action, optionLabel: 'No'}}), who, 1900000000));
+});
+test('receipt acceptance requires complete fact and provenance read-back', async () => {
+ const p = make(), e = await evidence(p), proof = await receiptProof(e.response, p, who, 'b'.repeat(64));
+ assert.equal(readbackProof(e.readback, proof), proof);
+ for (const mutate of [r => r.complete = false, r => r.datums.pop(), r => r.attestedBy.pop(), r => r.attestedBy[0].receiptIri = 'urn:wrong', r => r.graph = boundary.requestGraph]) {const r = copy(e.readback); mutate(r); assert.throws(() => readbackProof(r, proof));}
+});
+test('lost commit response is reconciled once by nonce without retry or resign; read-back gates clearing', async () => {
+ const p = make(), e = await evidence(p), store = memory(); let commits = 0, signs = 0, visible = false;
+ const transport = {challenge: () => issued(p), commit: async () => {commits++; throw Error('lost response');}, receipt: async () => e.response, readback: async () => ({...e.readback, complete: visible})};
+ const writer = new PendingWrite({identity: who, store, transport, sign: async () => {signs++; return {client_data_json: 'a', authenticator_data: 'b', signature: 'c'};}, now: () => 1900000000});
+ assert.equal((await writer.submit(p, ask.expiresAt)).state, 'UNKNOWN');
+ await assert.rejects(writer.acknowledge());
+ await assert.rejects(writer.submit(p, ask.expiresAt));
+ visible = true;
+ assert.equal((await writer.reconcile()).state, 'ACCEPTED');
+ assert.deepEqual([commits, signs], [1, 1]);
+ await writer.acknowledge(); assert.equal(await store.get(), null);
+});
+test('absent, timed out and mismatched receipts retain stable custody and never permit commit retry', async () => {
+ const p = make(), store = memory(); let commits = 0;
+ const transport = {challenge: () => issued(p), commit: async () => {commits++;}, receipt: async () => ({found: false, verified: false, receipt: null, tx: null})};
+ const writer = new PendingWrite({identity: who, store, transport, sign: async () => ({client_data_json: 'a', authenticator_data: 'b', signature: 'c'}), now: () => 1900000000});
+ await writer.submit(p, ask.expiresAt);
+ const before = await store.get();
+ for (let i = 0; i < 3; i++) assert.equal((await writer.reconcile()).state, 'UNKNOWN');
+ assert.deepEqual((await store.get()).proposal, before.proposal); assert.equal(commits, 1);
+ const e = await evidence(make({action: {...action, optionLabel: 'No'}})); transport.receipt = async () => e.response;
+ assert.equal((await writer.reconcile()).state, 'UNKNOWN');
+});
+test('store custody precedes signing and commit, cancellation and two-tab CAS do not submit', async () => {
+ const p = make(), store = memory(); let commits = 0, signs = 0;
+ const transport = {challenge: () => issued(p), commit: async () => {commits++;}};
+ const writer = new PendingWrite({identity: who, store, transport, sign: async () => {signs++; assert.ok(await store.get()); throw Error('cancel');}, now: () => 1900000000});
+ assert.equal((await writer.submit(p, ask.expiresAt)).state, 'PENDING'); assert.deepEqual([signs, commits], [1, 0]);
+ const peer = new PendingWrite({identity: who, store, transport, sign: async () => {signs++;}, now: () => 1900000000});
+ await assert.rejects(peer.submit(p, ask.expiresAt)); assert.deepEqual([signs, commits], [1, 0]);
+});
+test('real WebAuthn caller uses only enrolled credential and exact server-derived challenge', async () => {
+ const p = make(), admitted = await admittedChallenge(await issued(p), p, who, 1900000000); let calls = 0;
+ const environment = {secure: true, origin: who.origin, credentials: {get: async options => {
+ calls++; assert.equal(base64url(options.publicKey.challenge), admitted.webauthnChallenge); assert.equal(options.publicKey.userVerification, 'required');
+ assert.equal(base64url(options.publicKey.allowCredentials[0].id), who.signer);
+ return {type: 'public-key', rawId: unbase64url(who.signer).buffer, response: {authenticatorData: new Uint8Array([1]).buffer, clientDataJSON: new Uint8Array([2]).buffer, signature: new Uint8Array([3]).buffer}};
+ }}};
+ assert.deepEqual(await webauthnSigner(who, environment)(admitted), {authenticator_data: 'AQ', client_data_json: 'Ag', signature: 'Aw'});
+ await assert.rejects(webauthnSigner(who, {...environment, origin: 'https://wrong.test'})(admitted)); assert.equal(calls, 1);
+});
+
+test('a server deadline beyond the request refuses before signing or retaining a submission', async () => {
+ const p = make(), store = memory(); let signs = 0;
+ const writer = new PendingWrite({identity: who, store, transport: {challenge: () => issued(p)}, sign: async () => {signs++;}, now: () => 1900000000});
+ await assert.rejects(writer.submit(p, 1900000100));
+ assert.equal(signs, 0); assert.equal(await store.get(), null);
+ const e = await evidence(); await assert.rejects(readAnswer(e.row, e.response, {...ask, expiresAt: 1900000100}, boundary, who));
+});
diff --git a/tests/native/deck-fixture.rs b/tests/native/deck-fixture.rs
new file mode 100644
index 0000000..3268448
--- /dev/null
+++ b/tests/native/deck-fixture.rs
@@ -0,0 +1,67 @@
+// Disposable native qualification helper. It is not a production operator tool.
+use quipu::store::attested_write::{AttestedDatum, Proposal, TypedValue};
+use quipu::types::{Op, Value};
+use quipu::{Datum, Store};
+use serde_json::{Value as J, json};
+use std::io::Read;
+fn main() -> Result<(), Box> {
+ let a: Vec = std::env::args().collect();
+ let mut raw = String::new();
+ std::io::stdin().read_to_string(&mut raw)?;
+ let v: J = serde_json::from_str(&raw)?;
+ let mut s = Store::open(&a[1])?;
+ let out = match a[2].as_str() {
+ "seed" => {
+ let g = s.graph_create(v["graph"].as_str().unwrap())?;
+ let rows: Vec = serde_json::from_value(v["datums"].clone())?;
+ let ts = "2026-10-25T00:00:00Z";
+ let mut ds = Vec::new();
+ for d in rows {
+ let value = match d.o {
+ TypedValue::Int(n) => Value::Int(n),
+ TypedValue::Str(t) => Value::Str(t),
+ TypedValue::Iri(t) => Value::Ref(s.intern(&t)?),
+ };
+ ds.push(Datum {
+ entity: s.intern(&d.s)?,
+ attribute: s.intern(&d.p)?,
+ value,
+ valid_from: ts.into(),
+ valid_to: None,
+ op: if v["retract"].as_bool().unwrap_or(false) {
+ Op::Retract
+ } else {
+ Op::Assert
+ },
+ });
+ }
+ s.transact_to_graph(&ds, ts, None, None, g)?;
+ json!({"seeded":true})
+ }
+ "canonical" => {
+ let p: Proposal = serde_json::from_value(v)?;
+ json!({"canonical":serde_json::to_string(&p)?})
+ }
+ "challenge" => quipu::tool_attest_challenge(&mut s, &v)?,
+ "commit" => quipu::tool_attest_commit(&mut s, &v)?,
+ "receipt" => quipu::tool_attest_receipt(&s, &v)?,
+ "store-id" => json!({"store_id":s.store_id()?}),
+ "facts" => {
+ let g = s.lookup(v["graph"].as_str().unwrap())?.unwrap();
+ let mut rows = Vec::new();
+ for f in s.current_facts_in_graph(g)? {
+ let o = match f.value {
+ Value::Int(n) => json!({"kind":"int","value":n}),
+ Value::Str(t) => json!({"kind":"str","value":t}),
+ Value::Ref(i) => json!({"kind":"iri","value":s.resolve(i)?}),
+ _ => continue,
+ };
+ rows.push(json!({"s":s.resolve(f.entity)?,"p":s.resolve(f.attribute)?,"o":o}));
+ }
+ json!({"datums":rows})
+ }
+ _ => panic!("fixture verb"),
+ };
+ println!("{}", serde_json::to_string(&out)?);
+ Ok(())
+}
diff --git a/web/deck/app.mjs b/web/deck/app.mjs
new file mode 100644
index 0000000..c412417
--- /dev/null
+++ b/web/deck/app.mjs
@@ -0,0 +1,6 @@
+import {config} from './config.mjs';
+import {mount} from './view.mjs';
+// No production adapter is selected by this source preview.
+mount(document.querySelector('main'), config).catch(() => {
+ document.querySelector('main').textContent = 'UNKNOWN · admitted adapters unavailable.';
+});
diff --git a/web/deck/config.mjs b/web/deck/config.mjs
new file mode 100644
index 0000000..f1be197
--- /dev/null
+++ b/web/deck/config.mjs
@@ -0,0 +1,2 @@
+// Deployment and reader/write admission are separate from source delivery.
+export const config = Object.freeze({enabled: false});
diff --git a/web/deck/evidence.mjs b/web/deck/evidence.mjs
new file mode 100644
index 0000000..90961fd
--- /dev/null
+++ b/web/deck/evidence.mjs
@@ -0,0 +1,95 @@
+import {Unknown, NS, RDF_TYPE, exact, text, integer, iri, hexId, utf8, sha256, base64url, unbase64url} from './value.mjs';
+import {scope, request, operationIri, proposal, canonicalProposal, decodeProposal, buildProposal} from './model.mjs';
+const admittedAnswers = new WeakSet();
+export function identity(value) {
+ exact(value, ['signer', 'audience', 'origin', 'rpId', 'actor']);
+ const result = {signer: text(value.signer, 1024), audience: text(value.audience, 256), origin: text(value.origin, 2048), rpId: text(value.rpId, 253), actor: text(value.actor, 256)};
+ let url; try { url = new URL(result.origin); } catch { throw new Unknown('Invalid signing origin'); }
+ if (url.protocol !== 'https:' || url.origin !== result.origin || url.hostname !== result.rpId || url.username || url.password) throw new Unknown('Exact HTTPS signing origin/RP required');
+ return result;
+}
+export function challenge(value, expectedIdentity) {
+ const who = identity(expectedIdentity);
+ exact(value, ['domain', 'audience', 'nonce', 'origin', 'rp_id', 'actor', 'proposal', 'expires_at']);
+ const result = {domain: value.domain, audience: value.audience, nonce: hexId(value.nonce), origin: value.origin, rp_id: value.rp_id, actor: value.actor, proposal: proposal(value.proposal), expires_at: integer(value.expires_at)};
+ if (result.domain !== 'quipu-attested-write-v1' || result.audience !== who.audience || result.origin !== who.origin || result.rp_id !== who.rpId || result.actor !== who.actor || result.proposal.signer !== who.signer) throw new Unknown('Foreign signing scope');
+ return result;
+}
+export async function admittedChallenge(issued, expectedProposal, who, now) {
+ exact(issued, ['message', 'webauthn_challenge', 'challenge']);
+ const c = challenge(issued.challenge, who), message = utf8(JSON.stringify(c));
+ if (canonicalProposal(c.proposal) !== canonicalProposal(expectedProposal) || !Number.isSafeInteger(now) || c.expires_at <= now || c.expires_at > now + 300 || message.length > 32768 || base64url(message) !== issued.message) throw new Unknown('Changed or expired signed message');
+ const prefix = utf8('quipu-verdict-webauthn-v1\0'), all = new Uint8Array(prefix.length + message.length);
+ all.set(prefix); all.set(message, prefix.length);
+ const derived = base64url(await crypto.subtle.digest('SHA-256', all));
+ if (derived !== issued.webauthn_challenge) throw new Unknown('Wrong WebAuthn challenge');
+ unbase64url(issued.message);
+ return {challenge: c, message: issued.message, webauthnChallenge: derived, contentHash: 'sha256:' + await sha256(message)};
+}
+// The caller obtains this response from its pinned native /attest/receipt
+// reader. A browser-provided row or its own `verified` flag is not authority.
+export async function receiptProof(response, expectedProposal, who, nonce) {
+ if (!response || utf8(JSON.stringify(response)).length > 131072 || response.found !== true || response.verified !== true || response.receipt_iri !== 'urn:quipu:attest:' + hexId(nonce)) throw new Unknown('Verified native receipt unavailable');
+ integer(response.tx);
+ const receipt = response.receipt, c = challenge(receipt?.challenge, who);
+ const contentHash = 'sha256:' + await sha256(utf8(JSON.stringify(c)));
+ if (c.nonce !== nonce || canonicalProposal(c.proposal) !== canonicalProposal(expectedProposal) || receipt.content_hash !== contentHash) throw new Unknown('Receipt is for another intent');
+ const recordedAt = integer(receipt.recorded_at);
+ if (recordedAt >= c.expires_at) throw new Unknown('Receipt time outside signed deadline');
+ return {receiptIri: response.receipt_iri, nonce, tx: response.tx, actor: c.actor, recordedAt, expiresAt: c.expires_at, contentHash, proposal: c.proposal};
+}
+export function readbackProof(observed, proof) {
+ exact(observed, ['graph', 'datums', 'attestedBy', 'complete']);
+ if (observed.graph !== proof.proposal.graph || observed.complete !== true || !Array.isArray(observed.datums) || !Array.isArray(observed.attestedBy)) throw new Unknown('Incomplete answer read-back');
+ const actual = proposal({...proof.proposal, datums: observed.datums});
+ const keys = rows => rows.map(d => JSON.stringify(d)).sort();
+ if (JSON.stringify(keys(actual.datums)) !== JSON.stringify(keys(proof.proposal.datums))) throw new Unknown('Answer facts not readable');
+ const subjects = [...new Set(proof.proposal.datums.map(d => d.s))].sort();
+ if (observed.attestedBy.length !== subjects.length) throw new Unknown('Missing answer provenance');
+ for (const link of observed.attestedBy) { exact(link, ['s', 'receiptIri']); if (link.receiptIri !== proof.receiptIri) throw new Unknown('Foreign answer provenance'); }
+ if (JSON.stringify(observed.attestedBy.map(x => x.s).sort()) !== JSON.stringify(subjects)) throw new Unknown('Conflicting answer provenance');
+ return proof;
+}
+export async function readAnswer(row, response, ask, boundary, who) {
+ const b = scope(boundary), r = request(ask, b);
+ exact(row, ['graph', 'subject', 'receiptIri', 'manifest']);
+ if (row.graph !== b.answerGraph) throw new Unknown('Answer from ordinary or foreign graph');
+ const p = decodeProposal(row.manifest);
+ if (p.graph !== b.answerGraph || p.signer !== identity(who).signer) throw new Unknown('Foreign proposal');
+ const values = predicate => p.datums.filter(d => d.s === row.subject && d.p === predicate);
+ const scalar = (predicate, kind, required = true) => {
+ const found = values(predicate);
+ if (!found.length && !required) return null;
+ if (found.length !== 1 || found[0].o.kind !== kind) throw new Unknown('Missing or conflicting answer field');
+ return found[0].o.value;
+ };
+ const operationId = scalar(NS + 'operationId', 'str');
+ if (row.subject !== operationIri(operationId)) throw new Unknown('Foreign operation subject');
+ const action = {operationId, action: scalar(NS + 'action', 'str'), optionLabel: scalar(NS + 'optionLabel', 'str', false), comment: scalar(NS + 'comment', 'str', false) ?? ''};
+ // Reconstruct the entire permitted manifest, not just the field that the
+ // renderer was studying: no omitted precondition or extra datum can pass.
+ const expected = buildProposal({boundary: b, ask: r, signer: p.signer, action,
+ freshItem: {graph: b.itemGraph, seed: r.seed, revision: r.itemRevision, status: b.openStatus}, now: r.expiresAt - 1});
+ if (canonicalProposal(expected) !== row.manifest || values(RDF_TYPE).length !== 1) throw new Unknown('Invalid Deck manifest');
+ if (!/^urn:quipu:attest:[0-9a-f]{64}$/.test(row.receiptIri)) throw new Unknown('Missing server provenance');
+ const proof = await receiptProof(response, expected, who, row.receiptIri.slice('urn:quipu:attest:'.length));
+ if (proof.expiresAt > r.expiresAt || proof.recordedAt >= r.expiresAt) throw new Unknown('Signed deadline exceeds the curated request deadline');
+ const answer = Object.freeze({...action, requestId: r.id, seed: r.seed, revision: r.revision, ...proof});
+ admittedAnswers.add(answer);
+ return answer;
+}
+export function disposition(answers, ask, boundary) {
+ const r = request(ask, boundary);
+ if (!Array.isArray(answers) || answers.length > 100) throw new Unknown('Incomplete answer population');
+ const operations = new Set(), comments = [], decisions = [];
+ for (const answer of answers) {
+ if (!admittedAnswers.has(answer) || answer.requestId !== r.id || answer.seed !== r.seed || answer.revision !== r.revision) throw new Unknown('Unadmitted or foreign answer');
+ if (operations.has(answer.operationId)) throw new Unknown('Duplicate operation in answer population');
+ operations.add(answer.operationId);
+ if (answer.comment !== '') comments.push({text: answer.comment, actor: answer.actor, recordedAt: answer.recordedAt, receiptIri: answer.receiptIri});
+ if (answer.action !== 'comment') decisions.push(answer);
+ }
+ if (decisions.length > 1) throw new Unknown('Competing decisions');
+ const answer = decisions[0];
+ return {state: !answer ? 'WAITING' : answer.action === 'later' ? 'LATER' : 'ANSWERED', answer: answer ?? null, comments};
+}
diff --git a/web/deck/index.html b/web/deck/index.html
new file mode 100644
index 0000000..efd3fc2
--- /dev/null
+++ b/web/deck/index.html
@@ -0,0 +1,5 @@
+
+
+
+Decision request · Rikuy
+Loading source preview…
diff --git a/web/deck/model.mjs b/web/deck/model.mjs
new file mode 100644
index 0000000..f1f98a2
--- /dev/null
+++ b/web/deck/model.mjs
@@ -0,0 +1,86 @@
+import {Unknown, NS, RDF_TYPE, exact, text, integer, iri, typed, str, ref, int, hexId, utf8} from './value.mjs';
+export function scope(value) {
+ exact(value, ['requestGraph', 'answerGraph', 'itemGraph', 'itemPrefix', 'revisionPredicate', 'statusPredicate', 'openStatus']);
+ const result = Object.fromEntries(Object.entries(value).map(([k, v]) => [k, iri(v)]));
+ if (!result.itemPrefix.endsWith('/') || new Set([result.requestGraph, result.answerGraph, result.itemGraph]).size !== 3 || result.revisionPredicate === result.statusPredicate) throw new Unknown('Distinct graph and item slots required');
+ return result;
+}
+export function request(value, boundary) {
+ scope(boundary);
+ exact(value, ['id', 'revision', 'seed', 'itemRevision', 'title', 'options', 'actions', 'expiresAt']);
+ const result = {id: iri(value.id), revision: integer(value.revision), seed: iri(value.seed), itemRevision: integer(value.itemRevision), title: text(value.title, 4096, true)};
+ if (result.id.includes('#') || !result.seed.startsWith(boundary.itemPrefix) || result.seed === boundary.itemPrefix) throw new Unknown('Foreign request or item');
+ if (!Array.isArray(value.options) || value.options.length > 8 || new Set(value.options).size !== value.options.length) throw new Unknown('Invalid options');
+ result.options = value.options.map(v => text(v, 256));
+ if (!Array.isArray(value.actions) || !value.actions.length || value.actions.length > 3 || new Set(value.actions).size !== value.actions.length || value.actions.some(v => !['choose', 'later', 'comment'].includes(v)) || value.actions.includes('choose') && !result.options.length) throw new Unknown('Invalid declared actions');
+ result.actions = [...value.actions]; result.expiresAt = integer(value.expiresAt);
+ return result;
+}
+export const revisionIri = value => `${value.id}#revision-${value.revision}`;
+export const operationIri = id => `urn:rikuy:operation:${hexId(id)}`;
+export function requestPublication(value, boundary) {
+ const r = request(value, boundary), manifest = JSON.stringify(r);
+ if (utf8(manifest).length > 16384) throw new Unknown('Curated request too large');
+ return {graph: boundary.requestGraph, datums: [
+ {s: r.id, p: NS + 'currentRevision', o: int(r.revision)},
+ {s: revisionIri(r), p: NS + 'requestManifest', o: {kind: 'str', value: manifest}}
+ ]};
+}
+export function readRequest(row, boundary) {
+ scope(boundary);
+ exact(row, ['graph', 'requestId', 'currentRevision', 'revisionIri', 'manifest', 'complete']);
+ if (row.graph !== boundary.requestGraph || row.complete !== true || typeof row.manifest !== 'string' || utf8(row.manifest).length > 16384) throw new Unknown('Incomplete curated request');
+ let value; try {value = JSON.parse(row.manifest);} catch {throw new Unknown('Malformed curated request');}
+ const r = request(value, boundary);
+ if (JSON.stringify(r) !== row.manifest || r.id !== row.requestId || r.revision !== row.currentRevision || revisionIri(r) !== row.revisionIri) throw new Unknown('Foreign or ambiguous request revision');
+ return r;
+}
+export function intent(value, ask) {
+ exact(value, ['operationId', 'action', 'optionLabel', 'comment']);
+ const result = {operationId: hexId(value.operationId), action: value.action, optionLabel: value.optionLabel, comment: value.comment};
+ if (!ask.actions.includes(result.action) || typeof result.comment !== 'string') throw new Unknown('Undeclared action');
+ if (result.comment !== '') text(result.comment, 4096, true);
+ if (result.action === 'choose' ? !ask.options.includes(result.optionLabel) : result.optionLabel !== null) throw new Unknown('Undeclared option');
+ if (result.action === 'comment' && !result.comment.trim()) throw new Unknown('Comment required');
+ return result;
+}
+export function proposal(value) {
+ exact(value, ['signer', 'graph', 'datums', 'expect']);
+ const result = {signer: text(value.signer, 1024), graph: iri(value.graph)};
+ if (!Array.isArray(value.datums) || !value.datums.length || value.datums.length > 64 || !Array.isArray(value.expect) || value.expect.length > 16) throw new Unknown('Proposal bounds exceeded');
+ result.datums = value.datums.map(d => { exact(d, ['s', 'p', 'o']); return {s: iri(d.s), p: iri(d.p), o: typed(d.o)}; });
+ result.expect = value.expect.map(e => { exact(e, ['graph', 's', 'p', 'o']); return {graph: iri(e.graph), s: iri(e.s), p: iri(e.p), o: e.o === null ? null : typed(e.o)}; });
+ for (const [rows, key] of [[result.datums, d => JSON.stringify(d)], [result.expect, e => JSON.stringify([e.graph, e.s, e.p])]]) if (new Set(rows.map(key)).size !== rows.length) throw new Unknown('Duplicate proposal slot or datum');
+ if (utf8(JSON.stringify(result)).length > 32768) throw new Unknown('Proposal too large');
+ return result;
+}
+export const canonicalProposal = value => JSON.stringify(proposal(value));
+export function decodeProposal(raw) {
+ if (typeof raw !== 'string' || utf8(raw).length > 32768) throw new Unknown('Invalid manifest size');
+ let value; try { value = JSON.parse(raw); } catch { throw new Unknown('Invalid manifest JSON'); }
+ const normalized = proposal(value);
+ if (JSON.stringify(normalized) !== raw) throw new Unknown('Noncanonical or duplicate manifest field');
+ return normalized;
+}
+export function buildProposal({boundary, ask, freshItem, signer, action, now}) {
+ const b = scope(boundary), r = request(ask, b), a = intent(action, r);
+ exact(freshItem, ['graph', 'seed', 'revision', 'status']);
+ if (freshItem.graph !== b.itemGraph || freshItem.seed !== r.seed || freshItem.revision !== r.itemRevision || freshItem.status !== b.openStatus || !Number.isSafeInteger(now) || now >= r.expiresAt) throw new Unknown('Changed, closed or expired request');
+ const op = operationIri(a.operationId), rev = revisionIri(r);
+ const datums = [
+ {s: op, p: RDF_TYPE, o: ref(NS + (a.action === 'comment' ? 'Comment' : 'Decision'))},
+ {s: op, p: NS + 'request', o: ref(r.id)}, {s: op, p: NS + 'revision', o: int(r.revision)},
+ {s: op, p: NS + 'seed', o: ref(r.seed)}, {s: op, p: NS + 'action', o: str(a.action)},
+ {s: op, p: NS + 'operationId', o: str(a.operationId)}
+ ];
+ if (a.optionLabel !== null) datums.push({s: op, p: NS + 'optionLabel', o: str(a.optionLabel)});
+ if (a.comment !== '') datums.push({s: op, p: NS + 'comment', o: str(a.comment)});
+ if (a.action !== 'comment') datums.push({s: rev, p: NS + 'decided', o: ref(op)});
+ return proposal({signer, graph: b.answerGraph, datums, expect: [
+ {graph: b.requestGraph, s: r.id, p: NS + 'currentRevision', o: int(r.revision)},
+ {graph: b.answerGraph, s: rev, p: NS + 'decided', o: null},
+ {graph: b.itemGraph, s: r.seed, p: b.revisionPredicate, o: int(r.itemRevision)},
+ {graph: b.itemGraph, s: r.seed, p: b.statusPredicate, o: ref(b.openStatus)},
+ {graph: b.answerGraph, s: op, p: RDF_TYPE, o: null}
+ ]});
+}
diff --git a/web/deck/pending.mjs b/web/deck/pending.mjs
new file mode 100644
index 0000000..3b204d6
--- /dev/null
+++ b/web/deck/pending.mjs
@@ -0,0 +1,62 @@
+import {Unknown, exact, integer} from './value.mjs';
+import {canonicalProposal, proposal} from './model.mjs';
+import {admittedChallenge, identity, receiptProof, readbackProof} from './evidence.mjs';
+
+// An online nonce is committed at most once. Persistence and the trusted
+// transport are injected; no endpoint, bearer or writer is selected here.
+export class PendingWrite {
+ constructor({identity: who, store, transport, sign, now = () => Math.floor(Date.now() / 1000)}) {
+ Object.assign(this, {identity: identity(who), store, transport, sign, now});
+ this.busy = false;
+ }
+ async submit(expected, notAfter) {
+ if (this.busy) throw new Unknown('An intent is already active');
+ this.busy = true;
+ try {
+ const before = await this.store.get();
+ if (before) throw new Unknown('Reconcile the retained intent before another submission');
+ const p = proposal(expected);
+ integer(notAfter);
+ const issued = await this.transport.challenge(p);
+ const admitted = await admittedChallenge(issued, p, this.identity, this.now());
+ if (admitted.challenge.expires_at > notAfter) throw new Unknown('Server challenge would outlive the request');
+ // Durable custody precedes the authenticator and the one commit call.
+ const record = {proposal: p, nonce: admitted.challenge.nonce, contentHash: admitted.contentHash, notAfter, state: 'PENDING'};
+ await this.store.insert(record);
+ let assertion;
+ try { assertion = await this.sign(admitted); }
+ catch { return record; } // No commit; explicit operator reconciliation required.
+ exact(assertion, ['client_data_json', 'authenticator_data', 'signature']);
+ await this.store.change(record.nonce, {state: 'UNKNOWN'});
+ try { await this.transport.commit({nonce: record.nonce, ...assertion}); }
+ catch { /* A lost response proves neither acceptance nor refusal. */ }
+ return await this.reconcile();
+ } finally { this.busy = false; }
+ }
+ async reconcile() {
+ const record = await this.store.get();
+ if (!record) return null;
+ if (canonicalProposal(record.proposal) !== JSON.stringify(record.proposal)) throw new Unknown('Changed retained intent');
+ let proof;
+ try {
+ proof = await receiptProof(await this.transport.receipt({nonce: record.nonce}), record.proposal, this.identity, record.nonce);
+ if (proof.contentHash !== record.contentHash) throw new Unknown('Changed retained content hash');
+ if (proof.expiresAt > integer(record.notAfter)) throw new Unknown('Receipt exceeds retained request deadline');
+ readbackProof(await this.transport.readback(proof), proof);
+ } catch { return {...record, state: 'UNKNOWN'}; }
+ // The persistence adapter must compare the nonce atomically. Unknown,
+ // absent, malformed and foreign receipts never clear pending custody.
+ await this.store.change(record.nonce, {state: 'ACCEPTED', proof});
+ return {...record, state: 'ACCEPTED', proof};
+ }
+ async acknowledge() {
+ const record = await this.store.get();
+ if (record?.state !== 'ACCEPTED') throw new Unknown('Verified receipt and fact read-back required');
+ await this.store.remove(record.nonce, 'ACCEPTED');
+ }
+ async cancelUnsigned() {
+ const record = await this.store.get();
+ if (record?.state !== 'PENDING') throw new Unknown('A possibly submitted intent cannot be discarded');
+ await this.store.remove(record.nonce, 'PENDING');
+ }
+}
diff --git a/web/deck/sign.mjs b/web/deck/sign.mjs
new file mode 100644
index 0000000..506a531
--- /dev/null
+++ b/web/deck/sign.mjs
@@ -0,0 +1,18 @@
+import {Unknown, base64url, unbase64url} from './value.mjs';
+import {identity} from './evidence.mjs';
+
+export function webauthnSigner(who, environment) {
+ const registered = identity(who);
+ return async admitted => {
+ if (environment.secure !== true || environment.origin !== registered.origin || typeof environment.credentials?.get !== 'function') throw new Unknown('Enrolled secure-origin authenticator unavailable');
+ const credential = await environment.credentials.get({publicKey: {
+ challenge: unbase64url(admitted.webauthnChallenge), rpId: registered.rpId,
+ allowCredentials: [{id: unbase64url(registered.signer), type: 'public-key'}],
+ userVerification: 'required', timeout: 60000
+ }});
+ if (!credential || credential.type !== 'public-key' || base64url(credential.rawId) !== registered.signer) throw new Unknown('Enrolled credential not returned');
+ const response = credential.response;
+ for (const [name, max] of [['authenticatorData', 16384], ['clientDataJSON', 16384], ['signature', 512]]) if (!(response?.[name] instanceof ArrayBuffer) || !response[name].byteLength || response[name].byteLength > max) throw new Unknown('Invalid authenticator export');
+ return {client_data_json: base64url(response.clientDataJSON), authenticator_data: base64url(response.authenticatorData), signature: base64url(response.signature)};
+ };
+}
diff --git a/web/deck/storage.mjs b/web/deck/storage.mjs
new file mode 100644
index 0000000..4d26864
--- /dev/null
+++ b/web/deck/storage.mjs
@@ -0,0 +1,60 @@
+import {Unknown, exact, integer, hexId, sha256, utf8} from './value.mjs';
+import {proposal, scope} from './model.mjs';
+import {identity} from './evidence.mjs';
+
+// Separate database/domain from the offline outbox. One retained online
+// intent per operator/store/graph scope; never fall back to memory storage.
+export class PendingStore {
+ constructor(db) { this.db = db; }
+ static async open(who, boundary, indexedDB = globalThis.indexedDB) {
+ if (!indexedDB) throw new Unknown('Durable online intent storage unavailable');
+ const name = 'rikuy-online-intent-v1-' + await sha256(utf8(JSON.stringify([identity(who), scope(boundary)])));
+ const db = await new Promise((resolve, reject) => {
+ const request = indexedDB.open(name, 1);
+ request.onupgradeneeded = () => request.result.createObjectStore('pending');
+ request.onerror = () => reject(request.error);
+ request.onblocked = () => reject(new Unknown('Online intent storage blocked'));
+ request.onsuccess = () => resolve(request.result);
+ });
+ return new PendingStore(db);
+ }
+ operation(mode, fn) {
+ return new Promise((resolve, reject) => {
+ const tx = this.db.transaction('pending', mode), store = tx.objectStore('pending'); let result;
+ tx.oncomplete = () => resolve(structuredClone(result));
+ tx.onabort = () => reject(tx.error || new Unknown('Concurrent online intent change'));
+ tx.onerror = () => {};
+ const get = store.get('intent');
+ get.onsuccess = () => {
+ try {result = fn(get.result ?? null, store, tx);}
+ catch (error) {tx.abort(); reject(error);}
+ };
+ });
+ }
+ get() {return this.operation('readonly', value => value);}
+ insert(record) {
+ exact(record, ['proposal', 'nonce', 'contentHash', 'notAfter', 'state']);
+ const p = proposal(record.proposal); hexId(record.nonce); integer(record.notAfter);
+ if (record.state !== 'PENDING' || !/^sha256:[0-9a-f]{64}$/.test(record.contentHash)) throw new Unknown('Invalid initial intent');
+ return this.operation('readwrite', (old, store) => {
+ if (old) throw new Unknown('An intent already has durable custody');
+ const value = {...record, proposal: p}; store.add(structuredClone(value), 'intent'); return value;
+ });
+ }
+ change(nonce, patch) {
+ if (!patch || Object.keys(patch).some(k => !['state', 'proof'].includes(k)) || !['UNKNOWN', 'ACCEPTED'].includes(patch.state)) throw new Unknown('Immutable online intent');
+ return this.operation('readwrite', (old, store) => {
+ if (!old || old.nonce !== nonce || old.state === 'ACCEPTED' && patch.state !== 'ACCEPTED') throw new Unknown('Concurrent online intent change');
+ const value = {...old, ...structuredClone(patch)};
+ if (utf8(JSON.stringify(value)).length > 131072) throw new Unknown('Online intent storage bound exceeded');
+ store.put(value, 'intent'); return value;
+ });
+ }
+ remove(nonce, state) {
+ return this.operation('readwrite', (old, store) => {
+ if (!old || old.nonce !== nonce || old.state !== state || !['PENDING', 'ACCEPTED'].includes(state)) throw new Unknown('A possibly submitted intent cannot be discarded');
+ store.delete('intent'); return null;
+ });
+ }
+ close() {this.db.close();}
+}
diff --git a/web/deck/style.css b/web/deck/style.css
new file mode 100644
index 0000000..903b791
--- /dev/null
+++ b/web/deck/style.css
@@ -0,0 +1,7 @@
+:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
+body { margin: 2rem auto; max-width: 44rem; padding: 0 1rem; }
+h1 { line-height: 1.3; font-size: 1.6rem; }
+textarea { display: block; box-sizing: border-box; width: 100%; min-height: 6rem; margin: 1rem 0; }
+button { padding: .6rem 1rem; margin: .3rem; }
+[role="status"] { padding: 1rem; border: 1px solid currentColor; border-radius: .4rem; }
+section > p { white-space: pre-wrap; overflow-wrap: anywhere; }
diff --git a/web/deck/value.mjs b/web/deck/value.mjs
new file mode 100644
index 0000000..efa07c6
--- /dev/null
+++ b/web/deck/value.mjs
@@ -0,0 +1,49 @@
+// Bounded online attested-write values. This is not the offline wire protocol.
+export class Unknown extends Error {}
+export const NS = 'https://intigraph.github.io/rikuy/ns#';
+export const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
+export const utf8 = value => new TextEncoder().encode(value);
+export function exact(value, keys) {
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).sort().join('|') !== [...keys].sort().join('|')) throw new Unknown('Unknown or missing field');
+}
+export function text(value, max = 4096, multiline = false) {
+ if (typeof value !== 'string' || !value.length || utf8(value).length > max || /\p{Surrogate}/u.test(value) || (multiline ? /[\u0000-\u0008\u000b\u000c\u000e-\u001f]/u : /[\u0000-\u001f]/u).test(value)) throw new Unknown('Invalid bounded text');
+ return value;
+}
+export function integer(value) {
+ if (!Number.isSafeInteger(value) || value < 1) throw new Unknown('Positive exact integer required');
+ return value;
+}
+export function iri(value) {
+ text(value);
+ let url;
+ try { url = new URL(value); } catch { throw new Unknown('Absolute IRI required'); }
+ if (!['https:', 'http:', 'urn:'].includes(url.protocol) || url.username || url.password || /[<>"{}|^`\\\s]/u.test(value)) throw new Unknown('Unsupported IRI');
+ return value;
+}
+export function typed(value) {
+ exact(value, ['kind', 'value']);
+ if (value.kind === 'int') {
+ if (!Number.isSafeInteger(value.value)) throw new Unknown('Inexact integer');
+ } else if (value.kind === 'str') text(value.value, 4096, true);
+ else if (value.kind === 'iri') iri(value.value);
+ else throw new Unknown('Unknown value kind');
+ return {kind: value.kind, value: value.value};
+}
+export const str = value => typed({kind: 'str', value});
+export const ref = value => typed({kind: 'iri', value});
+export const int = value => typed({kind: 'int', value});
+export function base64url(bytes) {
+ return btoa(Array.from(new Uint8Array(bytes), n => String.fromCharCode(n)).join('')).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
+}
+export function unbase64url(value) {
+ if (typeof value !== 'string' || !value.length || value.length > 65536 || !/^[A-Za-z0-9_-]+$/.test(value)) throw new Unknown('Invalid base64url');
+ const bytes = Uint8Array.from(atob(value.replaceAll('-', '+').replaceAll('_', '/')), c => c.charCodeAt(0));
+ if (base64url(bytes) !== value) throw new Unknown('Noncanonical base64url');
+ return bytes;
+}
+export const sha256 = async bytes => Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), n => n.toString(16).padStart(2, '0')).join('');
+export function hexId(value) {
+ if (typeof value !== 'string' || !/^[0-9a-f]{64}$/.test(value)) throw new Unknown('Exact operation identifier required');
+ return value;
+}
diff --git a/web/deck/view.mjs b/web/deck/view.mjs
new file mode 100644
index 0000000..a557081
--- /dev/null
+++ b/web/deck/view.mjs
@@ -0,0 +1,79 @@
+import {Unknown, NS, sha256} from './value.mjs';
+import {scope, readRequest, buildProposal} from './model.mjs';
+import {identity, readAnswer, disposition} from './evidence.mjs';
+
+// The deployment supplies bounded admitted readers and a durable PendingWrite.
+// No endpoint, raw query, credential, enrollment or automatic retry is here.
+export async function mount(root, {enabled = false, boundary, identity: who, read, writer, now = () => Math.floor(Date.now() / 1000)} = {}) {
+ const document = root.ownerDocument;
+ const node = (tag, text) => {const n = document.createElement(tag); if (text !== undefined) n.textContent = text; return n;};
+ const status = node('p', 'Preview · signing is not configured.'), body = node('section');
+ status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite');
+ root.replaceChildren(status, body);
+ if (enabled !== true) return;
+ const b = scope(boundary), operator = identity(who);
+ if (typeof read !== 'function' || !writer || JSON.stringify(identity(writer.identity)) !== JSON.stringify(operator)) throw new Unknown('Admitted reader and matching durable writer required');
+ let bundle, r, admitted = [], busy = false;
+ const pending = () => writer.store.get();
+ const disable = () => body.querySelectorAll('button').forEach(button => button.disabled = true);
+ const settle = async result => {
+ if (result?.state !== 'ACCEPTED') {status.textContent = 'Pending · receipt or read-back UNKNOWN. Use Recover receipt; do not sign again.'; return;}
+ await refresh();
+ const op = result.proposal.datums.find(d => d.p === NS + 'operationId')?.o.value;
+ if (!admitted.some(a => a.operationId === op && a.receiptIri === result.proof.receiptIri)) throw new Unknown('Accepted intent is not in the current reader view');
+ await writer.acknowledge(); await refresh();
+ };
+ const run = async operation => {
+ if (busy) return;
+ busy = true; root.setAttribute('aria-busy', 'true'); disable();
+ try {await operation();}
+ catch {status.textContent = 'UNKNOWN · retained intent and source evidence need reconciliation.';}
+ finally {
+ busy = false;
+ try {await controls();} catch {disable(); status.textContent = 'UNKNOWN · durable intent storage unavailable.';}
+ root.setAttribute('aria-busy', 'false');
+ }
+ };
+ async function controls() {
+ const record = await pending();
+ body.querySelectorAll('[data-action]').forEach(button => button.disabled = busy || !!record || body.dataset.state !== 'WAITING');
+ const recover = body.querySelector('[data-recover]'); if (recover) recover.disabled = busy || !record;
+ const cancel = body.querySelector('[data-cancel]'); if (cancel) cancel.disabled = busy || record?.state !== 'PENDING';
+ }
+ async function refresh() {
+ const next = await read();
+ if (next?.complete !== true || !Array.isArray(next.rows) || next.rows.length > 100) throw new Unknown('Incomplete request/answer read');
+ const nextRequest = readRequest(next.requestRow, b);
+ const answers = await Promise.all(next.rows.map(({row, receipt}) => readAnswer(row, receipt, nextRequest, b, operator)));
+ const view = disposition(answers, nextRequest, b);
+ bundle = next; r = nextRequest; admitted = answers;
+ body.replaceChildren(); body.dataset.state = view.state;
+ body.append(node('h1', r.title), node('p', `Revision ${r.revision} · ${view.state}`));
+ const note = node('textarea'); note.maxLength = 4096; note.setAttribute('aria-label', 'Comment');
+ body.append(note);
+ const addAction = (label, action, optionLabel = null) => {
+ const button = node('button', label); button.dataset.action = action; button.disabled = true;
+ button.addEventListener('click', () => run(async () => {
+ const bytes = new Uint8Array(32); crypto.getRandomValues(bytes);
+ const operationId = await sha256(bytes);
+ const p = buildProposal({boundary: b, ask: r, freshItem: bundle.freshItem, signer: operator.signer, action: {operationId, action, optionLabel, comment: note.value}, now: now()});
+ status.textContent = 'Waiting for your authenticator. A lost response stays pending.';
+ await settle(await writer.submit(p, r.expiresAt));
+ }));
+ body.append(button);
+ };
+ if (r.actions.includes('choose')) for (const choice of r.options) addAction(choice, 'choose', choice);
+ if (r.actions.includes('later')) addAction('Later', 'later');
+ if (r.actions.includes('comment')) addAction('Add comment', 'comment');
+ const recover = node('button', 'Recover receipt'); recover.dataset.recover = ''; recover.disabled = true;
+ recover.addEventListener('click', () => run(async () => settle(await writer.reconcile())));
+ const cancel = node('button', 'Cancel unsigned attempt'); cancel.dataset.cancel = ''; cancel.disabled = true;
+ cancel.addEventListener('click', () => run(async () => {await writer.cancelUnsigned(); await refresh();}));
+ body.append(recover, cancel);
+ for (const comment of view.comments) body.append(node('p', `${comment.actor} · ${new Date(comment.recordedAt * 1000).toISOString()}: ${comment.text}`));
+ status.textContent = view.state === 'WAITING' ? 'Current request · no admitted decision.' : view.state === 'LATER' ? 'Later · retained in the visible Later state.' : `Answered · ${view.answer.actor} · ${new Date(view.answer.recordedAt * 1000).toISOString()}`;
+ await controls();
+ }
+ try {await refresh();}
+ catch {body.replaceChildren(); status.textContent = 'UNKNOWN · request or verified answer evidence unavailable.';}
+}