Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions docs/deck/browser-results.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
127 changes: 127 additions & 0 deletions docs/deck/contract.md
Original file line number Diff line number Diff line change
@@ -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.
64 changes: 64 additions & 0 deletions docs/deck/demo.md
Original file line number Diff line number Diff line change
@@ -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='<img src="https://invalid.example/secret" onerror="window.injection=true">';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(),'<img src="https://invalid.example/secret" onerror="window.injection=true">');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;});

```
Loading
Loading