From cc90771d625ed77cdec0f03b0d065c7d20a2248f Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Sun, 25 Oct 2026 16:40:28 -0400 Subject: [PATCH 1/4] Add disabled online request cards and attested answer interpretation --- docs/deck/browser-results.json | 61 ++++++++++++++ docs/deck/contract.md | 127 ++++++++++++++++++++++++++++ docs/deck/demo.md | 64 ++++++++++++++ docs/deck/native-results.json | 93 ++++++++++++++++++++ docs/deck/recovered.png | Bin 0 -> 29520 bytes docs/deck/recovery.webm | Bin 0 -> 155046 bytes docs/deck/source-assets.json | 14 +++ scripts/build.mjs | 2 +- scripts/qualify-deck-native.mjs | 143 +++++++++++++++++++++++++++++++ shapes/deck.shapes.ttl | 27 ++++++ tests/build.test.mjs | 8 ++ tests/deck-fixture.mjs | 27 ++++++ tests/deck.test.mjs | 145 ++++++++++++++++++++++++++++++++ tests/native/deck-fixture.rs | 67 +++++++++++++++ web/deck/app.mjs | 6 ++ web/deck/config.mjs | 2 + web/deck/evidence.mjs | 95 +++++++++++++++++++++ web/deck/index.html | 5 ++ web/deck/model.mjs | 86 +++++++++++++++++++ web/deck/pending.mjs | 62 ++++++++++++++ web/deck/sign.mjs | 18 ++++ web/deck/storage.mjs | 60 +++++++++++++ web/deck/style.css | 7 ++ web/deck/value.mjs | 49 +++++++++++ web/deck/view.mjs | 79 +++++++++++++++++ 25 files changed, 1246 insertions(+), 1 deletion(-) create mode 100644 docs/deck/browser-results.json create mode 100644 docs/deck/contract.md create mode 100644 docs/deck/demo.md create mode 100644 docs/deck/native-results.json create mode 100644 docs/deck/recovered.png create mode 100644 docs/deck/recovery.webm create mode 100644 docs/deck/source-assets.json create mode 100644 scripts/qualify-deck-native.mjs create mode 100644 shapes/deck.shapes.ttl create mode 100644 tests/deck-fixture.mjs create mode 100644 tests/deck.test.mjs create mode 100644 tests/native/deck-fixture.rs create mode 100644 web/deck/app.mjs create mode 100644 web/deck/config.mjs create mode 100644 web/deck/evidence.mjs create mode 100644 web/deck/index.html create mode 100644 web/deck/model.mjs create mode 100644 web/deck/pending.mjs create mode 100644 web/deck/sign.mjs create mode 100644 web/deck/storage.mjs create mode 100644 web/deck/style.css create mode 100644 web/deck/value.mjs create mode 100644 web/deck/view.mjs 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 0000000000000000000000000000000000000000..9b33b68ce20c17b3a6834c3efb8563535be6f693 GIT binary patch literal 29520 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 z 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%0AdKR+CcoR$IFCHfj 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 Na_$dOc;*N8zW{y;VN?JB literal 0 HcmV?d00001 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 5aafb7a..3c87b27 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'])await cp(resolve(root,'web',name),resolve(out,name),{recursive:true,filter}); +for(const name of ['knowledge','offline','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 cf04cdb..24fa2cd 100644 --- a/tests/build.test.mjs +++ b/tests/build.test.mjs @@ -10,3 +10,11 @@ test('deployment assets omit the synthetic demo entry point and fixture payload' await assert.rejects(access('dist/demo.mjs')); 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')); +}); 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.';} +} From d2aee85b5322fd6c470bbf434cec3a59a2bbc6c3 Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Sun, 25 Oct 2026 17:08:10 -0400 Subject: [PATCH 2/4] feat(deck): qualify bounded registered-query reader source --- docs/deck/reader-browser-results.json | 40 ++++++ docs/deck/reader-native-results.json | 111 ++++++++++++++++ docs/deck/reader.md | 75 +++++++++++ scripts/qualify-deck-native.mjs | 19 +++ scripts/qualify-deck-reader-browser.cjs | 48 +++++++ tests/deck-reader.test.mjs | 135 +++++++++++++++++++ tests/native/deck-fixture.rs | 32 +++++ web/deck/reader.mjs | 166 ++++++++++++++++++++++++ 8 files changed, 626 insertions(+) create mode 100644 docs/deck/reader-browser-results.json create mode 100644 docs/deck/reader-native-results.json create mode 100644 docs/deck/reader.md create mode 100644 scripts/qualify-deck-reader-browser.cjs create mode 100644 tests/deck-reader.test.mjs create mode 100644 web/deck/reader.mjs diff --git a/docs/deck/reader-browser-results.json b/docs/deck/reader-browser-results.json new file mode 100644 index 0000000..a16baac --- /dev/null +++ b/docs/deck/reader-browser-results.json @@ -0,0 +1,40 @@ +{ + "scope": "Actual browser Fetch/CORS, streamed body limits and Deck DOM; all responses intercepted synthetic fixtures, no native or human proof", + "results": [ + { + "mode": "answered", + "status": "Answered \u00b7 fixture-operator \u00b7 2030-03-17T17:46:41.000Z", + "synthetic_http_calls": 11, + "page_errors": [], + "unhandled_routes": [] + }, + { + "mode": "empty", + "status": "Current request \u00b7 no admitted decision.", + "synthetic_http_calls": 8, + "page_errors": [], + "unhandled_routes": [] + }, + { + "mode": "changed-query", + "status": "UNKNOWN \u00b7 request or verified answer evidence unavailable.", + "synthetic_http_calls": 6, + "page_errors": [], + "unhandled_routes": [] + }, + { + "mode": "missing-receipt", + "status": "UNKNOWN \u00b7 request or verified answer evidence unavailable.", + "synthetic_http_calls": 7, + "page_errors": [], + "unhandled_routes": [] + }, + { + "mode": "oversized", + "status": "UNKNOWN \u00b7 request or verified answer evidence unavailable.", + "synthetic_http_calls": 1, + "page_errors": [], + "unhandled_routes": [] + } + ] +} diff --git a/docs/deck/reader-native-results.json b/docs/deck/reader-native-results.json new file mode 100644 index 0000000..be16efa --- /dev/null +++ b/docs/deck/reader-native-results.json @@ -0,0 +1,111 @@ +{ + "candidate": "quipu 0.16.3\ngit_sha: 771e710de7294b718ceaa1a4b825fd28e4b34c23\ngit_dirty: false", + "native_library_sha256": "2ac7b3096f54903dd18664016fc7d102632cb8b079a4fbb239b8818b9d3e1ef8", + "helper_source_sha256": "3bcfd058028c532c711fb02dd3e6743ddd48484ed7813db6c24e5e82e65b2ce3", + "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": "choose-stored-reader", + "native_registered_queries": true, + "graph_creation_bound": true, + "typed_facts_and_provenance": true + }, + { + "case": "later", + "state": "LATER", + "canonical_native_match": true, + "native_verified": true, + "replay_no_mutation": true + }, + { + "case": "later-stored-reader", + "native_registered_queries": true, + "graph_creation_bound": true, + "typed_facts_and_provenance": true + }, + { + "case": "comment", + "state": "WAITING", + "canonical_native_match": true, + "native_verified": true, + "replay_no_mutation": true + }, + { + "case": "comment-stored-reader", + "native_registered_queries": true, + "graph_creation_bound": true, + "typed_facts_and_provenance": 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/reader.md b/docs/deck/reader.md new file mode 100644 index 0000000..55acbb8 --- /dev/null +++ b/docs/deck/reader.md @@ -0,0 +1,75 @@ +# Bounded online reader source + +`web/deck/reader.mjs` supplies the read function accepted by the online Deck +view. Importing it performs no request. The shipped app still has signing +disabled and selects no production endpoint, registration receipt or query. + +`readerDefinitions(boundary, provenanceNamespace)` constructs five stored-query +definitions: request, item, answers, facts and subject-to-receipt links. They +use explicit graph IRIs and validated IRI/integer parameters. The native +provenance namespace must equal the primitive's fixed vocabulary identifier; +a configurable lookalike could otherwise hide real answers as an empty set. +It is not the Rikuy vocabulary. The adapter sends named `/ask` requests, never raw `/query` text. +The exact rendered text returned by `/ask` must match the source definition. + +Before a deployment can use the adapter, its owner must register those exact +definitions through the normal governed path and admit their served plans and +read cost. Each configured query hash is SHA-256 of the UTF-8 JSON encoding of +the source definition. It binds name, template, parameter kinds and output +bound. A hash supplied by a caller is not a registration or read-cost receipt. +This source neither registers queries nor grants permission to run them. + +The reader also requires retained creation provenance for all three committed +graphs. It checks each through the bounded `GET /graphs?graph=` route. +An absent, replaced, overlay or ambiguous graph is UNKNOWN. This metadata +check does not prove graph protection, origin admission or operator enablement. +Native verified point receipts and exact typed fact/provenance read-back are +still required for every answer. + +Output limits deliberately include an overflow sentinel. Request and item +queries stop at three rows and require exactly one; answer queries stop at 21 +and admit at most 20; fact and link queries stop at 65 and admit at most 64. +Reaching the limit, truncation, duplicate rows, count mismatch, changed query, +missing receipt or incomplete read-back refuses the entire view. An empty +answer list needs the positive request/item reads and all graph bindings. +No missing evidence is converted to a settled answer. + +One cycle has a maximum six-second deadline, no overlapping cycle and at most +two requests in flight. An injected transport must honor abort and streaming +byte limits. `readerTransport` provides a browser Fetch implementation with +those byte limits, redirect refusal, omitted credentials, no cache and the +`daily-brief` client attribution. It permits only the pinned HTTPS origin's +graph, named-query and point-receipt routes; it has no commit or registration +method. It neither signs nor retries a write. + +Request and item reads bracket answer retrieval. A changed revision or closed +item refuses the result. These separate reads do not establish an atomic +snapshot or rule out every intervening change. The signed proposal's native +CAS remains authoritative at commit time. + +`just ci` covers complete positive reads, positive controls for empty answers, +graph and query identity, overflow, missing/foreign receipts, wrong fact types, +changed/closed items, request/item bookends, deadlines, overlapping cycles, +streamed byte limits and restricted HTTP routes. The native qualification +command in [contract.md](contract.md) now loads all five definitions into +disposable stores and uses actual stored-query execution, graph metadata, +point receipts and typed read-back for Choose, Later and comment. + +The native transport dispatch in that qualification is synthetic and local. +It proves neither served HTTP/CORS nor production query cost, profile-union +shape admission, graph protection or human/device enrollment. The result +archive in [reader-native-results.json](reader-native-results.json) records +the candidate and fixture hashes. No production adapter is selected by this +change, and there is no newly observable behavior in the disabled page. + +The browser qualification in `scripts/qualify-deck-reader-browser.cjs` mounts +the actual view with this reader and uses actual browser Fetch/CORS and body +streaming. Every HTTP reply is intercepted synthetic data. Its five controls +show Answered, empty-but-controlled Waiting, changed query UNKNOWN, missing +receipt UNKNOWN and oversized reply UNKNOWN. The archive is +[reader-browser-results.json](reader-browser-results.json). Run it with an +explicit local Playwright installation and Chrome: + +```sh +PLAYWRIGHT_MODULE=/path/to/playwright node scripts/qualify-deck-reader-browser.cjs +``` diff --git a/scripts/qualify-deck-native.mjs b/scripts/qualify-deck-native.mjs index cd034f4..3d24c9a 100644 --- a/scripts/qualify-deck-native.mjs +++ b/scripts/qualify-deck-native.mjs @@ -8,6 +8,7 @@ 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'; +import {DeckReader, readerDefinitions} from '../web/deck/reader.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, '..'); @@ -98,6 +99,24 @@ try { 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}); + const nativeNamespace = e.facts.find(d => d.p.endsWith('attestedBy')).p.slice(0, -'attestedBy'.length); + const definitions = readerDefinitions(boundary, nativeNamespace); + const queryHashes = Object.fromEntries(Object.entries(definitions).map(([role, def]) => [role, sha(Buffer.from(JSON.stringify(def)))])); + for (const def of Object.values(definitions)) command(state.db, 'query-load', def); + const registrations = [boundary.requestGraph, boundary.answerGraph, boundary.itemGraph].map(graph => { + const metadata = command(state.db, 'graphs', {graph}).graphs[0]; + return {iri: graph, creation: metadata.creation}; + }); + // Real stored-query rendering/execution, native point receipts and registry; + // synthetic transport dispatch only, with no HTTP or production endpoint. + const reader = new DeckReader({endpoint: 'https://reader.example.test/', boundary, operator: state.who, provenanceNamespace: nativeNamespace, queryHashes, registrations, request: async (address, {body}) => { + const url = new URL(address); + return command(state.db, url.pathname === '/graphs' ? 'graphs' : url.pathname === '/ask' ? 'ask' : 'receipt', url.pathname === '/graphs' ? {graph: url.searchParams.get('graph')} : body); + }}); + const observed = await reader.read(state.r.id); + assert.equal(observed.rows.length, 1); + assert.equal(observed.rows[0].receipt.receipt_iri, e.receipt.receipt_iri); + results.push({case: `${kind}-stored-reader`, native_registered_queries: true, graph_creation_bound: true, typed_facts_and_provenance: 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); diff --git a/scripts/qualify-deck-reader-browser.cjs b/scripts/qualify-deck-reader-browser.cjs new file mode 100644 index 0000000..da01688 --- /dev/null +++ b/scripts/qualify-deck-reader-browser.cjs @@ -0,0 +1,48 @@ +// # arming: agent-invoked synthetic browser qualification; no production traffic. +if (!process.env.PLAYWRIGHT_MODULE) throw Error('Explicit PLAYWRIGHT_MODULE required'); +const {chromium}=require(process.env.PLAYWRIGHT_MODULE); +const fs=require('fs'),path=require('path'),assert=require('assert/strict'); +const root=path.resolve(__dirname,'..'); +(async()=>{ + const browser=await chromium.launch({executablePath:'/usr/bin/google-chrome',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']});const results=[]; + try{for(const mode of ['answered','empty','changed-query','missing-receipt','oversized']){ + const context=await browser.newContext(),page=await context.newPage(),errors=[],blocked=[];let calls=0; + page.on('pageerror',e=>errors.push(e.message)); + await context.route('**/*',async route=>{ + const req=route.request(),url=new URL(req.url()); + if(url.origin==='https://sign.example.test'){ + if(url.pathname==='/'){await route.fulfill({contentType:'text/html',body:'
'});return;} + if(/^\/(web\/deck\/[a-z-]+|tests\/deck-fixture)\.mjs$/.test(url.pathname)){await route.fulfill({contentType:'text/javascript',body:fs.readFileSync(path.join(root,url.pathname))});return;} + } + if(url.origin==='https://reader.example.test'&&['/graphs','/ask','/attest/receipt'].includes(url.pathname)){ + const headers={'access-control-allow-origin':'https://sign.example.test','access-control-allow-headers':'content-type,x-quipu-client','access-control-allow-methods':'GET,POST,OPTIONS'}; + if(req.method()==='OPTIONS'){await route.fulfill({status:204,headers});return;} + calls++; + const value=await page.evaluate(async ({path,graph,body,mode})=>{ + const f=await import('/tests/deck-fixture.mjs'),m=await import('/web/deck/model.mjs'),r=await import('/web/deck/reader.mjs'); + const definitions=r.readerDefinitions(f.boundary,r.NATIVE_PROVENANCE_NS),e=await f.evidence(); + if(path==='/graphs')return{count:1,graphs:[{iri:graph,g:1,class:'committed',creation:{actor:'fixture-curator',timestamp:'2026-10-25T00:00:00Z'}}]}; + if(path==='/attest/receipt')return mode==='missing-receipt'?{found:false}:e.response; + const role=Object.keys(definitions).find(k=>definitions[k].name===body.name),def=definitions[role]; + const rows={request:[{revision:f.ask.revision,revisionIri:m.revisionIri(f.ask),manifest:m.requestPublication(f.ask,f.boundary).datums[1].o.value}],item:[{revision:f.ask.itemRevision,status:f.boundary.openStatus}],answers:mode==='empty'?[]:[{subject:e.row.subject,receiptIri:e.row.receiptIri,manifest:e.row.manifest}],facts:e.readback.datums.map(d=>({s:d.s,p:d.p,kind:d.o.kind,value:String(d.o.value)})),links:e.readback.attestedBy.map(({s})=>({s}))}[role]; + return{query:def.name,sparql:r.renderReaderQuery(def,body.params)+(mode==='changed-query'&&role==='answers'?' ':''),rows,count:rows.length,truncated:false}; + },{path:url.pathname,graph:url.searchParams.get('graph'),body:req.method()==='POST'?req.postDataJSON():null,mode}); + await route.fulfill({headers,contentType:'application/json',body:mode==='oversized'?' '.repeat(262145):JSON.stringify(value)});return; + } + blocked.push(url.href);await route.abort(); + }); + await page.goto('https://sign.example.test/'); + await page.evaluate(async()=>{ + const f=await import('/tests/deck-fixture.mjs'),r=await import('/web/deck/reader.mjs'),v=await import('/web/deck/value.mjs'),{mount}=await import('/web/deck/view.mjs'); + const definitions=r.readerDefinitions(f.boundary,r.NATIVE_PROVENANCE_NS),queryHashes=Object.fromEntries(await Promise.all(Object.entries(definitions).map(async([k,d])=>[k,await v.sha256(v.utf8(JSON.stringify(d)))]))); + const reader=new r.DeckReader({endpoint:'https://reader.example.test/',boundary:f.boundary,operator:f.who,provenanceNamespace:r.NATIVE_PROVENANCE_NS,queryHashes,registrations:[f.boundary.requestGraph,f.boundary.answerGraph,f.boundary.itemGraph].map(iri=>({iri,creation:{actor:'fixture-curator',timestamp:'2026-10-25T00:00:00Z'}})),request:r.readerTransport('https://reader.example.test/')}); + await mount(document.querySelector('main'),{enabled:true,boundary:f.boundary,identity:f.who,read:()=>reader.read(f.ask.id),writer:{identity:f.who,store:{get:async()=>null}},now:()=>1900000000}); + }); + const status=await page.locator('[role=status]').textContent(); + assert(mode==='answered'?status.startsWith('Answered'):mode==='empty'?status.startsWith('Current request'):status.startsWith('UNKNOWN'),status); + if(!['answered','empty'].includes(mode))assert.equal(await page.locator('button').count(),0); + assert.deepEqual(errors,[]);assert.deepEqual(blocked,[]);assert(calls>0&&calls<=13); + results.push({mode,status,synthetic_http_calls:calls,page_errors:errors,unhandled_routes:blocked});await context.close(); + }}finally{await browser.close();} + console.log(JSON.stringify({scope:'Actual browser Fetch/CORS, streamed body limits and Deck DOM; all responses intercepted synthetic fixtures, no native or human proof',results},null,2)); +})().catch(e=>{console.error(e.stack);process.exitCode=1;}); diff --git a/tests/deck-reader.test.mjs b/tests/deck-reader.test.mjs new file mode 100644 index 0000000..fa4317c --- /dev/null +++ b/tests/deck-reader.test.mjs @@ -0,0 +1,135 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {DeckReader, readerDefinitions, renderReaderQuery, readerTransport, NATIVE_PROVENANCE_NS} from '../web/deck/reader.mjs'; +import {requestPublication, revisionIri} from '../web/deck/model.mjs'; +import {sha256, utf8} from '../web/deck/value.mjs'; +import {ask, boundary, who, evidence} from './deck-fixture.mjs'; + +const provenance = NATIVE_PROVENANCE_NS; +async function fixture({change = value => value, deadlineMs = 6000} = {}) { + const definitions = readerDefinitions(boundary, provenance), ev = await evidence(); + const queryHashes = Object.fromEntries(await Promise.all(Object.entries(definitions).map(async ([k, v]) => [k, await sha256(utf8(JSON.stringify(v)))]))); + const registrations = [boundary.requestGraph, boundary.answerGraph, boundary.itemGraph].map((iri, i) => ({iri, creation: {actor: 'fixture-curator', timestamp: '2026-10-25T00:00:00Z'}, g: i + 1})); + let active = 0, peak = 0; + const calls = []; + const request = async (address, options) => { + const url = new URL(address); calls.push({path: url.pathname, body: options.body}); active++; peak = Math.max(active, peak); + try { + await new Promise(resolve => setImmediate(resolve)); + let role, result; + if (url.pathname === '/graphs') { const registration = registrations.find(r => r.iri === url.searchParams.get('graph')); result = {count: 1, graphs: [{...registration, class: 'committed'}]}; } + else if (url.pathname === '/attest/receipt') result = structuredClone(ev.response); + else { + assert.equal(url.pathname, '/ask'); + role = Object.keys(definitions).find(k => definitions[k].name === options.body.name); + const rows = { + request: [{revision: ask.revision, revisionIri: revisionIri(ask), manifest: requestPublication(ask, boundary).datums[1].o.value}], + item: [{revision: ask.itemRevision, status: boundary.openStatus}], + answers: [{subject: ev.row.subject, receiptIri: ev.row.receiptIri, manifest: ev.row.manifest}], + facts: ev.readback.datums.map(d => ({s: d.s, p: d.p, kind: d.o.kind, value: String(d.o.value)})), + links: ev.readback.attestedBy.map(({s}) => ({s})) + }[role]; + result = {query: definitions[role].name, sparql: renderReaderQuery(definitions[role], options.body.params), rows, count: rows.length, truncated: false}; + } + return await change(result, {role, path: url.pathname, options, calls}); + } finally {active--;} + }; + const options = {endpoint: 'https://reader.example.test/', boundary, operator: who, provenanceNamespace: provenance, queryHashes, registrations, request, deadlineMs}; + return {reader: new DeckReader(options), options, calls, peak: () => peak, ev}; +} + +test('bounded source adapter reads exact requests, native receipts and complete typed facts', async () => { + const f = await fixture(), result = await f.reader.read(ask.id); + assert.equal(result.complete, true); assert.equal(result.rows.length, 1); + assert.equal(result.rows[0].receipt.receipt_iri, f.ev.row.receiptIri); + assert.equal(result.requestRow.currentRevision, ask.revision); + assert.equal(f.peak(), 2); + assert.equal(f.calls.filter(c => c.path === '/attest/receipt').length, 1); + assert(f.calls.every(c => ['/graphs', '/ask', '/attest/receipt'].includes(c.path))); +}); + +test('empty answer population needs positive request/item controls and graph bindings', async () => { + const f = await fixture({change: (v, {role}) => role === 'answers' ? {...v, rows: [], count: 0} : v}); + assert.deepEqual((await f.reader.read(ask.id)).rows, []); + for (const role of ['request', 'item']) { + const bad = await fixture({change: (v, ctx) => ctx.role === role ? {...v, rows: [], count: 0} : v}); + await assert.rejects(() => bad.reader.read(ask.id)); + } +}); + +test('changed query, truncation, count mismatch, duplicate and overflow refuse', async () => { + for (const mutate of [v => ({...v, query: 'another_query'}), v => ({...v, sparql: v.sparql + ' '}), v => ({...v, truncated: true}), v => ({...v, truncated: undefined}), v => ({...v, count: 99}), v => ({...v, rows: [v.rows[0], v.rows[0]], count: 2}), v => ({...v, rows: Array.from({length: 21}, (_, i) => ({...v.rows[0], subject: `urn:overflow:${i}`})), count: 21})]) { + const f = await fixture({change: (v, {role}) => role === 'answers' ? mutate(v) : v}); + await assert.rejects(() => f.reader.read(ask.id)); + } +}); + +test('graph creation/class and pinned definition hashes must match', async () => { + for (const mutate of [g => ({...g, iri: boundary.itemGraph}), g => ({...g, class: 'overlay'}), g => ({...g, g: 0}), g => ({...g, creation: null})]) { + const f = await fixture({change: (v, {path}) => path === '/graphs' ? {...v, graphs: [mutate(v.graphs[0])]} : v}); + await assert.rejects(() => f.reader.read(ask.id)); + assert.equal(f.calls.some(c => c.path === '/ask'), false); + } + const f = await fixture(); f.reader.queryHashes.answers = '0'.repeat(64); + await assert.rejects(() => f.reader.read(ask.id)); assert.equal(f.calls.length, 0); +}); + +test('missing/foreign/unverified receipt and incomplete facts or links refuse', async () => { + const mutations = [ + (v, c) => c.path === '/attest/receipt' ? {...v, found: false} : v, + (v, c) => c.path === '/attest/receipt' ? {...v, verified: false} : v, + (v, c) => c.path === '/attest/receipt' ? {...v, receipt: {...v.receipt, challenge: {...v.receipt.challenge, audience: 'wrong-store'}}} : v, + (v, c) => c.role === 'facts' || c.role === 'links' ? {...v, rows: v.rows.slice(1), count: v.rows.length - 1} : v, + (v, c) => c.role === 'facts' ? {...v, rows: v.rows.map(r => r.kind === 'int' ? {...r, kind: 'str'} : r)} : v, + (v, c) => c.role === 'facts' ? {...v, rows: v.rows.map(r => r.kind === 'int' ? {...r, value: '9007199254740992'} : r)} : v + ]; + for (const change of mutations) {const f = await fixture({change}); await assert.rejects(() => f.reader.read(ask.id));} +}); + +test('changed/closed or ambiguous item refuses before receipt calls', async () => { + for (const row of [{revision: ask.itemRevision + 1, status: boundary.openStatus}, {revision: ask.itemRevision, status: 'urn:closed'}, {revision: String(ask.itemRevision), status: boundary.openStatus}]) { + const f = await fixture({change: (v, {role}) => role === 'item' ? {...v, rows: [row]} : v}); + await assert.rejects(() => f.reader.read(ask.id)); + assert.equal(f.calls.some(c => c.path === '/attest/receipt'), false); + } +}); + +test('request bookend refuses a changed revision even after valid answer read-back', async () => { + let count = 0; + const f = await fixture({change: (v, {role}) => role === 'request' && ++count === 2 ? {...v, rows: [] , count: 0} : v}); + await assert.rejects(() => f.reader.read(ask.id)); + assert.equal(f.calls.filter(c => c.body?.name === 'rikuy_deck_request_v1').length, 2); +}); + +test('item bookend refuses closure during an otherwise valid answer read', async () => { + let count = 0; + const f = await fixture({change: (v, {role}) => role === 'item' && ++count === 2 ? {...v, rows: [{revision: ask.itemRevision, status: 'urn:closed'}]} : v}); + await assert.rejects(() => f.reader.read(ask.id), /closed/); + assert.equal(f.calls.filter(c => c.body?.name === 'rikuy_deck_item_v1').length, 2); +}); + +test('one overall deadline bounds even a transport that fails to settle', async () => { + const f = await fixture({deadlineMs: 20, change: () => new Promise(() => {})}); + await assert.rejects(() => f.reader.read(ask.id), /deadline/); + assert.equal(f.reader.busy, false); +}); + +test('overlapping cycles refuse and no default or insecure endpoint is selected', async () => { + const f = await fixture(); const running = f.reader.read(ask.id); + await assert.rejects(() => f.reader.read(ask.id), /already active/); await running; + for (const endpoint of ['http://reader.example.test/', 'https://reader.example.test/path', 'https://name:secret@reader.example.test/']) assert.throws(() => new DeckReader({...f.options, endpoint})); + assert.throws(() => renderReaderQuery(readerDefinitions(boundary, provenance).request, {requestId: 'https://example.test/> SELECT'})); + assert.throws(() => readerDefinitions(boundary, 'https://example.test/lookalike/'), /vocabulary/); +}); + +test('streaming transport caps bytes and restricts origin, methods, redirects and credentials', async () => { + const calls = [], ctl = new AbortController(); + const transport = readerTransport('https://reader.example.test/', async (url, options) => {calls.push({url, options}); return new Response('{"ok":true}', {headers: {'content-type': 'application/json'}});}); + assert.deepEqual(await transport('https://reader.example.test/ask', {body: {name: 'fixture'}, signal: ctl.signal, cap: 100}), {ok: true}); + assert.equal(calls[0].options.method, 'POST'); assert.equal(calls[0].options.credentials, 'omit'); assert.equal(calls[0].options.redirect, 'error'); + await assert.rejects(() => transport('https://reader.example.test/ask', {body: {}, signal: ctl.signal, cap: 2}), /byte bound/); + for (const url of ['https://other.example.test/ask', 'https://reader.example.test/episode', 'https://reader.example.test/attest/commit', 'https://reader.example.test/ask?raw=query']) await assert.rejects(() => transport(url, {body: {}, signal: ctl.signal, cap: 100})); + for (const response of [new Response('denied', {status: 403}), new Response('{}', {headers: {'content-type': 'text/html'}}), new Response(new Uint8Array([255]), {headers: {'content-type': 'application/json'}})]) { + await assert.rejects(() => readerTransport('https://reader.example.test/', async () => response)('https://reader.example.test/ask', {body: {}, signal: ctl.signal, cap: 100})); + } +}); diff --git a/tests/native/deck-fixture.rs b/tests/native/deck-fixture.rs index 3268448..2f69823 100644 --- a/tests/native/deck-fixture.rs +++ b/tests/native/deck-fixture.rs @@ -46,6 +46,38 @@ fn main() -> Result<(), Box> { "commit" => quipu::tool_attest_commit(&mut s, &v)?, "receipt" => quipu::tool_attest_receipt(&s, &v)?, "store-id" => json!({"store_id":s.store_id()?}), + "graphs" => quipu::tool_graph_list(&s, &v)?, + "ask" => quipu::tool_ask(&s, &v)?, + "query-load" => { + use quipu::store::queries::{StoredParam, StoredQuery}; + let params = v["params"] + .as_object() + .unwrap() + .iter() + .map(|(name, kind)| StoredParam { + name: name.clone(), + kind: match kind.as_str().unwrap() { + "iri" => "iri".into(), + "int" => "int".into(), + _ => panic!("fixture parameter kind"), + }, + required: true, + default: None, + description: "Synthetic reader qualification".into(), + }) + .collect(); + s.query_load( + &StoredQuery { + name: v["name"].as_str().unwrap().into(), + description: "Disposable Rikuy reader definition".into(), + template: v["template"].as_str().unwrap().into(), + dataset: None, + params, + }, + "2026-10-25T00:00:00Z", + )?; + json!({"loaded":true}) + } "facts" => { let g = s.lookup(v["graph"].as_str().unwrap())?.unwrap(); let mut rows = Vec::new(); diff --git a/web/deck/reader.mjs b/web/deck/reader.mjs new file mode 100644 index 0000000..d24b7e1 --- /dev/null +++ b/web/deck/reader.mjs @@ -0,0 +1,166 @@ +import {Unknown, NS, exact, iri, integer, text, utf8, sha256} from './value.mjs'; +import {scope, readRequest, decodeProposal} from './model.mjs'; +import {identity, readAnswer, receiptProof, readbackProof} from './evidence.mjs'; + +// Native provenance vocabulary identifier, fixed by the primitive's contract. +// A configurable lookalike would turn existing answers into a false empty set. +export const NATIVE_PROVENANCE_NS = 'http://aegis.gastown.local/ontology/'; + +// Source definitions only. An operator must separately register these exact +// templates and admit their cost on the served store. No registration writer. +export function readerDefinitions(boundary, provenanceNamespace) { + const b = scope(boundary), q = iri(provenanceNamespace); + if (q !== NATIVE_PROVENANCE_NS) throw new Unknown('Unknown native provenance vocabulary'); + const by = `<${q}attestedBy>`, graph = `<${b.answerGraph}>`; + const define = (name, template, params, cap) => ({name: `rikuy_deck_${name}_v1`, template, params, cap}); + return { + request: define('request', `SELECT ?revision ?revisionIri ?manifest WHERE { GRAPH <${b.requestGraph}> { <{requestId}> <${NS}currentRevision> ?revision . ?revisionIri <${NS}requestManifest> ?manifest . FILTER(?revisionIri = IRI(CONCAT(STR(<{requestId}>), "#revision-", STR(?revision)))) } } LIMIT 3`, {requestId: 'iri'}, 3), + item: define('item', `SELECT ?revision ?status WHERE { GRAPH <${b.itemGraph}> { <{seed}> <${b.revisionPredicate}> ?revision ; <${b.statusPredicate}> ?status } } LIMIT 3`, {seed: 'iri'}, 3), + answers: define('answers', `SELECT ?subject ?receiptIri ?manifest WHERE { GRAPH ${graph} { ?subject <${NS}request> <{requestId}> ; <${NS}revision> {revision} ; ${by} ?receiptIri . ?receiptIri <${q}attestProposal> ?manifest } } LIMIT 21`, {requestId: 'iri', revision: 'int'}, 21), + facts: define('facts', `SELECT ?s ?p ?kind ?value WHERE { GRAPH ${graph} { ?s ${by} <{receiptIri}> ; ?p ?o . FILTER(?p != ${by}) BIND(STR(?o) AS ?value) BIND(IF(isIRI(?o), "iri", IF(datatype(?o) = , "int", IF(datatype(?o) = , "str", "unsupported"))) AS ?kind) } } LIMIT 65`, {receiptIri: 'iri'}, 65), + links: define('links', `SELECT ?s WHERE { GRAPH ${graph} { ?s ${by} <{receiptIri}> } } LIMIT 65`, {receiptIri: 'iri'}, 65) + }; +} + +export function renderReaderQuery(def, params) { + exact(params, Object.keys(def.params)); + let query = def.template; + for (const [key, kind] of Object.entries(def.params)) { + const value = kind === 'iri' ? iri(params[key]) : integer(params[key]); + query = query.replaceAll(`{${key}}`, String(value)); + } + return query; +} + +export function readerRows(response, def, params) { + if (!response || response.query !== def.name || response.sparql !== renderReaderQuery(def, params) || response.truncated !== false || !Array.isArray(response.rows) || response.count !== response.rows.length || response.rows.length >= def.cap || response.error) throw new Unknown('Query identity or bounded population UNKNOWN'); + const seen = new Set(); + for (const row of response.rows) { + if (!row || typeof row !== 'object' || Array.isArray(row)) throw new Unknown('Malformed query row'); + const key = JSON.stringify(Object.keys(row).sort().map(k => [k, row[k]])); + if (seen.has(key)) throw new Unknown('Duplicate query row'); + seen.add(key); + } + return response.rows; +} + +function creationBinding(response, expected) { + if (response?.count !== 1 || response.graphs?.length !== 1) throw new Unknown('Exact registered graph required'); + const graph = response.graphs[0]; + if (graph.iri !== expected.iri || graph.class !== 'committed' || !Number.isSafeInteger(graph.g) || graph.g <= 0 || !expected.creation || JSON.stringify(graph.creation) !== JSON.stringify(expected.creation)) throw new Unknown('Graph creation identity changed'); +} + +export function readerTransport(endpoint, fetcher = globalThis.fetch) { + const origin = new URL(endpoint); + if (origin.protocol !== 'https:' || origin.origin + '/' !== endpoint || origin.username || origin.password || typeof fetcher !== 'function') throw new Unknown('Explicit HTTPS reader origin required'); + return async (address, {body, signal, cap}) => { + const url = new URL(address); + if (url.origin !== origin.origin || !['/graphs', '/ask', '/attest/receipt'].includes(url.pathname) || !Number.isSafeInteger(cap) || cap < 1 || cap > 262144) throw new Unknown('Reader route or byte bound refused'); + if (url.pathname === '/graphs' ? body !== undefined || [...url.searchParams.keys()].join(',') !== 'graph' : url.search !== '' || body === undefined) throw new Unknown('Reader route parameters refused'); + const response = await fetcher(url.href, {method: body === undefined ? 'GET' : 'POST', body: body === undefined ? undefined : JSON.stringify(body), signal, redirect: 'error', credentials: 'omit', cache: 'no-store', headers: {'Content-Type': 'application/json', 'X-Quipu-Client': 'daily-brief'}}); + if (!response.ok || response.redirected || response.url && new URL(response.url).origin !== origin.origin || !response.body || !/^application\/json(?:;|$)/i.test(response.headers.get('content-type') ?? '')) throw new Unknown('Reader HTTP response unavailable'); + const reader = response.body.getReader(), chunks = []; let size = 0; + try { + while (true) { + if (signal.aborted) throw new Unknown('Reader response aborted'); + const {done, value} = await reader.read(); if (done) break; + size += value.byteLength; + if (size > cap) throw new Unknown('Reader response exceeds byte bound'); + chunks.push(value); + } + const bytes = new Uint8Array(size); let offset = 0; + for (const chunk of chunks) {bytes.set(chunk, offset); offset += chunk.byteLength;} + return JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(bytes)); + } finally {await reader.cancel().catch(() => {}); reader.releaseLock();} + }; +} + +// The injected transport accepts (url, {body, signal, cap}), enforces the byte +// cap while streaming and propagates abort. This adapter also bounds decoded +// replies, and refuses overlapping cycles. It selects no default endpoint. +export class DeckReader { + constructor({endpoint, boundary, operator, provenanceNamespace, queryHashes, registrations, request, deadlineMs = 6000}) { + const url = new URL(endpoint); + if (url.protocol !== 'https:' || url.origin + '/' !== endpoint || url.username || url.password || typeof request !== 'function' || !Number.isInteger(deadlineMs) || deadlineMs < 1 || deadlineMs > 6000) throw new Unknown('Explicit bounded HTTPS reader required'); + this.endpoint = endpoint; this.boundary = scope(boundary); this.operator = identity(operator); + this.definitions = readerDefinitions(this.boundary, provenanceNamespace); + exact(queryHashes, Object.keys(this.definitions)); + this.queryHashes = structuredClone(queryHashes); this.registrations = structuredClone(registrations); + this.request = request; this.deadlineMs = deadlineMs; this.busy = false; + } + async read(requestId) { + iri(requestId); + if (this.busy) throw new Unknown('Reader cycle already active'); + this.busy = true; + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), this.deadlineMs); + let aborted; + const abort = new Promise((_, reject) => {aborted = () => reject(new Unknown('Reader cycle deadline exceeded')); ctl.signal.addEventListener('abort', aborted, {once: true});}); + const call = async (path, body, cap = 262144) => { + if (ctl.signal.aborted) throw new Unknown('Reader cycle deadline exceeded'); + const value = await Promise.race([this.request(new URL(path, this.endpoint).href, {body, signal: ctl.signal, cap}), abort]); + if (utf8(JSON.stringify(value)).length > cap || ctl.signal.aborted) throw new Unknown('Reader response exceeds bound'); + return value; + }; + const ask = async (role, params) => { + const def = this.definitions[role]; + return readerRows(await call('/ask', {name: def.name, params}), def, params); + }; + const requestRow = async () => { + const rows = await ask('request', {requestId}); + if (rows.length !== 1) throw new Unknown('Request revision missing or ambiguous'); + exact(rows[0], ['revision', 'revisionIri', 'manifest']); + const row = {graph: this.boundary.requestGraph, requestId, currentRevision: rows[0].revision, revisionIri: rows[0].revisionIri, manifest: rows[0].manifest, complete: true}; + readRequest(row, this.boundary); return row; + }; + const itemRow = async askValue => { + const items = await ask('item', {seed: askValue.seed}); + if (items.length !== 1) throw new Unknown('Item revision/status ambiguous'); + exact(items[0], ['revision', 'status']); integer(items[0].revision); iri(items[0].status); + if (items[0].revision !== askValue.itemRevision || items[0].status !== this.boundary.openStatus) throw new Unknown('Changed or closed item'); + return {graph: this.boundary.itemGraph, seed: askValue.seed, ...items[0]}; + }; + try { + for (const [role, def] of Object.entries(this.definitions)) { + if (this.queryHashes[role] !== await sha256(utf8(JSON.stringify(def)))) throw new Unknown('Unadmitted reader definition'); + } + const graphs = [this.boundary.requestGraph, this.boundary.answerGraph, this.boundary.itemGraph]; + if (!Array.isArray(this.registrations) || this.registrations.length !== 3 || new Set(this.registrations.map(r => r.iri)).size !== 3) throw new Unknown('Three graph creation receipts required'); + for (const graph of graphs) { + const expected = this.registrations.find(r => r.iri === graph); + if (!expected) throw new Unknown('Graph registration receipt missing'); + creationBinding(await call('/graphs?graph=' + encodeURIComponent(graph)), expected); + } + const first = await requestRow(), askValue = readRequest(first, this.boundary); + const freshItem = await itemRow(askValue); + const candidates = await ask('answers', {requestId, revision: askValue.revision}); + const rows = [], operations = new Set(); + // At most two requests in flight; no fanout of one request per answer. + for (const candidate of candidates) { + exact(candidate, ['subject', 'receiptIri', 'manifest']); + if (!/^urn:quipu:attest:[0-9a-f]{64}$/.test(candidate.receiptIri) || operations.has(candidate.subject)) throw new Unknown('Ambiguous answer provenance'); + operations.add(candidate.subject); + const row = {graph: this.boundary.answerGraph, ...candidate}; + const receipt = await call('/attest/receipt', {nonce: candidate.receiptIri.slice('urn:quipu:attest:'.length)}, 131072); + await readAnswer(row, receipt, askValue, this.boundary, this.operator); + const proof = await receiptProof(receipt, decodeProposal(candidate.manifest), this.operator, candidate.receiptIri.slice('urn:quipu:attest:'.length)); + const [facts, links] = await Promise.all([ask('facts', {receiptIri: candidate.receiptIri}), ask('links', {receiptIri: candidate.receiptIri})]); + const datums = facts.map(f => { + exact(f, ['s', 'p', 'kind', 'value']); + text(f.value, 32768, true); + if (!['iri', 'str', 'int'].includes(f.kind) || f.kind === 'int' && !/^-?(0|[1-9][0-9]*)$/.test(f.value)) throw new Unknown('Unsupported read-back term'); + return {s: f.s, p: f.p, o: {kind: f.kind, value: f.kind === 'int' ? Number(f.value) : f.value}}; + }); + const attestedBy = links.map(link => {exact(link, ['s']); return {s: link.s, receiptIri: candidate.receiptIri};}); + readbackProof({graph: this.boundary.answerGraph, datums, attestedBy, complete: true}, proof); + rows.push({row, receipt}); + } + const last = await requestRow(); + if (JSON.stringify(first) !== JSON.stringify(last)) throw new Unknown('Request changed during read'); + if (JSON.stringify(freshItem) !== JSON.stringify(await itemRow(askValue))) throw new Unknown('Item changed during read'); + return {complete: true, requestRow: last, freshItem, rows}; + } finally { + clearTimeout(timer); ctl.signal.removeEventListener('abort', aborted); ctl.abort(); this.busy = false; + } + } +} From 5727afd66297160c64006558fe82433247c2bac9 Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Sun, 25 Oct 2026 18:22:09 -0400 Subject: [PATCH 3/4] feat(deck): add conditional curator publication custody --- docs/deck/contract.md | 5 +- docs/deck/curator.md | 80 +++++++++++++++++++++ scripts/deck-curator-job.mjs | 69 ++++++++++++++++++ scripts/demo-deck-curator.mjs | 41 +++++++++++ scripts/qualify-deck-curator.mjs | 103 +++++++++++++++++++++++++++ tests/deck-curator.test.mjs | 118 +++++++++++++++++++++++++++++++ web/deck/curator.mjs | 52 ++++++++++++++ 7 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 docs/deck/curator.md create mode 100644 scripts/deck-curator-job.mjs create mode 100644 scripts/demo-deck-curator.mjs create mode 100644 scripts/qualify-deck-curator.mjs create mode 100644 tests/deck-curator.test.mjs create mode 100644 web/deck/curator.mjs diff --git a/docs/deck/contract.md b/docs/deck/contract.md index bf7520d..c34c3ed 100644 --- a/docs/deck/contract.md +++ b/docs/deck/contract.md @@ -14,7 +14,10 @@ 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. +source does not deploy that job or grant it a credential. The separate +[conditional curator source](curator.md) implements plan construction and +durable publication custody with explicit adapters; production adapters, +credential restrictions and served admission remain separate gates. The admitted reader supplies the exact graph, request ID, current revision, revision subject and complete manifest. Unknown fields, duplicate JSON keys, diff --git a/docs/deck/curator.md b/docs/deck/curator.md new file mode 100644 index 0000000..d87994a --- /dev/null +++ b/docs/deck/curator.md @@ -0,0 +1,80 @@ +# Conditional curator source + +`publicationPlan` builds one existing SPARQL `/update` operation. A first +publication requires no current revision and revision 1. A later publication +requires the exact previous head and manifest and advances by exactly one. +The new immutable revision subject must have no facts. Both forms check the +canonical item's exact revision and open status, rejecting additional values +in either slot. The only DELETE/INSERT target is the request graph. Previous +manifests, answers and item facts are retained. + +`scripts/deck-curator-job.mjs` supplies `CuratorJob` with explicit injected +inspection, send and read-back adapters. It selects no endpoint, credential, +timer or production store. Before proceeding it checks the configured native +store lineage and retained graph-creation identity against inspection of a +registered committed graph. This separate inspection is not an atomic graph +replacement guard or proof of server-side credential restrictions. + +The job requires an existing owned directory with mode 0700 and no symlinked +path. It creates a mode-0600 custody file exclusively, containing the complete +plan, store lineage, graph pin and their digest. The file and directory are +synced before the one send. The custody slot is keyed by store, request graph, +request ID and revision; a different payload or graph pin conflicts. Partial, +corrupt, foreign-owned or symlinked records refuse. The directory must remain +on the job's durable volume across restarts; using a fresh directory defeats +that local custody guarantee. + +Every retained file stays UNKNOWN, including after successful confirmation. +Subsequent instances may only read it and reconcile; they never resend. A +lost response or read timeout therefore retains the exact publication intent. +`reconcile` can read an expired request's retained intent, while `publish` +refuses to initiate an expired publication. No automatic cleanup or retry is +provided. This is local job custody, not a cross-host exactly-once protocol. +Concurrent distinct publications are additionally controlled by the native +conditional update. + +A response must describe the exact request graph, one positive transaction, +two assertions and zero or one retraction. A zero-change report cannot be +mistaken for a changed transaction. CONFIRMED also requires complete exact +manifest/current-revision read-back from the pinned store. Read-only recovery +may confirm matching state without a transaction ID; it does not attribute +that state to this job or mint a signed receipt. Missing, foreign or changed +evidence remains UNKNOWN. No accepted decision or answered disposition is +created by publishing a request. + +Production adapters are still owed. They must pin the actual server/store, +native graph registration, bounded admitted reader definitions and cost, and +use the sanctioned authenticated write path. A dedicated named credential +must be restricted by the server's admitted policy to the request graph. +The generated query's narrow mutation target is not a security boundary for +a broad bearer. Declared actor/source fields are attribution, not verified +authentication. This source grants no credential, graph creation, protection, +registration, served enablement or enrollment authority. Production profile +union and external-writer/graph-replacement assumptions need admission too. + +## Repeatable qualification + +`just ci` covers query construction, strict transaction/read-back validation, +durable-before-send ordering, changed-payload refusal, restart/lost-response +reconciliation, concurrent job instances and unsafe custody. Negative target +controls reject missing/replaced/overlay graphs and wrong store lineage. + +The explicit qualifier starts one loopback native server with a new disposable +store, private HOME/configuration and a synthetic test bearer. It opens no +installed store and inherits no production credential or provider setting. +Its positive item facts control the graph-mutation checks. It runs the actual +generated update through HTTP, checks native reports and retained manifests, +races two distinct publications, and loses a reply after a real native commit +before read-only recovery by a new job instance. It reaps its child and removes +the fixture in `finally`, with a 90-second server bound. + +```sh +QUIPU_SERVER=/absolute/path/to/pinned/quipu-server \ +QUIPU_SERVER_SHA256= node scripts/qualify-deck-curator.mjs +``` + +[curator-native-results.json](curator-native-results.json) records the clean +release/full candidate and synthetic controls. That candidate is an explicit +source qualification artifact, not a claim about an installed or officially +released server. The fixture's broad synthetic bearer does not demonstrate +least-privilege credential enforcement, real enrollment or production cost. diff --git a/scripts/deck-curator-job.mjs b/scripts/deck-curator-job.mjs new file mode 100644 index 0000000..2e66544 --- /dev/null +++ b/scripts/deck-curator-job.mjs @@ -0,0 +1,69 @@ +// Source-only job, with explicit injected governed transport and reader. +// No endpoint, credential, timer or production adapter is selected here. +import {open, lstat, realpath} from 'node:fs/promises'; +import {constants} from 'node:fs'; +import {resolve, join} from 'node:path'; +import {createHash} from 'node:crypto'; +import {publicationPlan, publicationReport, publicationReadback} from '../web/deck/curator.mjs'; +import {Unknown, iri, text, exact, integer} from '../web/deck/value.mjs'; + +const hash = value => createHash('sha256').update(value).digest('hex'); +export class CuratorJob { + constructor({directory, storeId, registration, inspect, send, readBack}) { + if (typeof directory !== 'string' || typeof inspect !== 'function' || typeof send !== 'function' || typeof readBack !== 'function') throw new Unknown('Explicit custody and governed adapters required'); + exact(registration, ['iri', 'creation']); iri(registration.iri); + exact(registration.creation, ['actor', 'timestamp']); text(registration.creation.timestamp); + if (registration.creation.actor !== null) text(registration.creation.actor); + this.registration = structuredClone(registration); this.inspect = inspect; + this.directory = resolve(directory); this.storeId = iri(storeId); this.send = send; this.readBack = readBack; + } + async publish(input) {return this.run(input, false);} + async reconcile(input) {return this.run(input, true);} + async run(input, readOnly) { + const plan = publicationPlan(readOnly ? {...input, now: 0} : input), payload = JSON.stringify({storeId: this.storeId, registration: this.registration, plan}); + if (this.registration.iri !== plan.boundary.requestGraph) throw new Unknown('Foreign configured graph'); + const admitted = await this.inspect(this.registration.iri); + const graph = admitted.graph; integer(graph?.g); + if (admitted.storeId !== this.storeId || graph.iri !== this.registration.iri || graph.class !== 'committed' || JSON.stringify(graph.creation) !== JSON.stringify(this.registration.creation)) throw new Unknown('Unregistered or replaced publication target'); + const info = await lstat(this.directory); + if (!info.isDirectory() || info.uid !== process.getuid() || (info.mode & 0o777) !== 0o700 || await realpath(this.directory) !== this.directory) throw new Unknown('Owned private custody directory required'); + // Same request revision has ONE custody slot, including changed payloads. + const key = hash(JSON.stringify([this.storeId, plan.boundary.requestGraph, plan.next.id, plan.next.revision])); + const path = join(this.directory, key + '.json'); + const record = {state: 'UNKNOWN', digest: hash(payload), storeId: this.storeId, registration: this.registration, plan}; + const bytes = JSON.stringify(record) + '\n'; + if (Buffer.byteLength(bytes) > 131072) throw new Unknown('Publication custody too large'); + let fresh = false, handle; + try { + if (readOnly) {const error = new Error('Reconciliation requires retained custody'); error.code = 'EEXIST'; throw error;} + handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + await handle.writeFile(bytes); await handle.sync(); + await handle.close(); handle = null; + const dir = await open(this.directory, constants.O_RDONLY | constants.O_DIRECTORY); + try {await dir.sync();} finally {await dir.close();} + fresh = true; + } catch (error) { + if (handle) await handle.close(); + if (error.code !== 'EEXIST') throw error; + const retained = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + let existing; + try { + const stat = await retained.stat(); + if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o777) !== 0o600 || stat.size > 131072) throw new Unknown('Invalid retained custody'); + existing = JSON.parse(await retained.readFile('utf8')); + } finally {await retained.close();} + if (existing.state !== 'UNKNOWN' || existing.digest !== record.digest || JSON.stringify({storeId: existing.storeId, registration: existing.registration, plan: existing.plan}) !== payload) throw new Unknown('Changed or incomplete publication custody'); + } + let report = null; + if (fresh) { + try {report = await this.send(plan.query); publicationReport(report, plan);} catch {return {state: 'UNKNOWN', sent: true, transaction: null};} + } + // Retained UNKNOWN never resends. Read-only reconciliation is repeatable. + try { + const observed = await this.readBack(plan.next.id); + if (observed.storeId !== this.storeId) throw new Unknown('Foreign store readback'); + publicationReadback(observed.requestRow, plan); + return {state: 'CONFIRMED', sent: fresh, transaction: report?.tx ?? null}; + } catch {return {state: 'UNKNOWN', sent: fresh, transaction: null};} + } +} diff --git a/scripts/demo-deck-curator.mjs b/scripts/demo-deck-curator.mjs new file mode 100644 index 0000000..7592cad --- /dev/null +++ b/scripts/demo-deck-curator.mjs @@ -0,0 +1,41 @@ +// Focused library demo with synthetic adapters, not a native or served proof. +import assert from 'node:assert/strict'; +import {mkdtemp, chmod, rm, readdir, readFile} from 'node:fs/promises'; +import {join} from 'node:path'; +import {tmpdir} from 'node:os'; +import {spawnSync, execFileSync} from 'node:child_process'; +import {CuratorJob} from './deck-curator-job.mjs'; +import {requestPublication, revisionIri} from '../web/deck/model.mjs'; +import {ask, boundary} from '../tests/deck-fixture.mjs'; + +const directory = await mkdtemp(join(tmpdir(), 'rikuy-curator-demo-')); await chmod(directory, 0o700); +const next = {...ask, revision: 1}, input = {boundary, next, previous: null, now: 1}; +const storeId = 'urn:uuid:synthetic-demo-store'; +const creation = {actor: 'synthetic-curator', timestamp: '2026-10-25T00:00:00Z'}; +let sends = 0, present = false; +const options = {directory, storeId, registration: {iri: boundary.requestGraph, creation}, inspect: async () => ({storeId, graph: {iri: boundary.requestGraph, g: 1, class: 'committed', creation}}), send: async () => { + const file = (await readdir(directory))[0]; + const saved = JSON.parse(await readFile(join(directory, file), 'utf8')); + console.log('Before send, durable custody:', saved.state); assert.equal(saved.state, 'UNKNOWN'); + sends++; present = true; throw Error('Synthetic lost reply after fixture mutation'); +}, readBack: async () => { + assert(present); return {storeId, requestRow: {graph: boundary.requestGraph, requestId: next.id, currentRevision: 1, revisionIri: revisionIri(next), manifest: requestPublication(next, boundary).datums[1].o.value, complete: true}}; +}}; +try { + const base = 'b290c515bfb3cda35eba38af3533c6c9200a0003'; + assert.equal(spawnSync('git', ['cat-file', '-e', base + ':web/deck/model.mjs'], {stdio: 'ignore'}).status, 0); + const before = spawnSync('git', ['cat-file', '-e', base + ':scripts/deck-curator-job.mjs'], {stdio: 'ignore'}).status; + assert.equal(before, 128); + console.log('Base', base, ': request fact builder exists; publication job absent.'); + console.log('Demonstrated source HEAD:', execFileSync('git', ['rev-parse', 'HEAD'], {encoding: 'utf8'}).trim()); + console.log('Target: source library, synthetic transport and reader; no service or credential.'); + console.log('Trigger: first publication; transport loses its reply after the fixture changes.'); + const lost = await new CuratorJob(options).publish(input); console.log('First call:', JSON.stringify(lost)); assert.equal(lost.state, 'UNKNOWN'); + console.log('Restart with the SAME durable directory; reconcile by reading.'); + const recovered = await new CuratorJob(options).reconcile(input); console.log('Recovery:', JSON.stringify(recovered)); assert.equal(recovered.state, 'CONFIRMED'); assert.equal(recovered.sent, false); + console.log('Send count:', sends); assert.equal(sends, 1); + let refused = false; + try {await new CuratorJob(options).publish({...input, next: {...next, title: 'Changed payload'}});} catch {refused = true;} + console.log('Changed payload in same revision refused:', refused); assert(refused); assert.equal(sends, 1); + console.log('Observed: UNKNOWN -> CONFIRMED by read-back; one send retained.'); +} finally {await rm(directory, {recursive: true, force: true});} diff --git a/scripts/qualify-deck-curator.mjs b/scripts/qualify-deck-curator.mjs new file mode 100644 index 0000000..1ba98a3 --- /dev/null +++ b/scripts/qualify-deck-curator.mjs @@ -0,0 +1,103 @@ +// Synthetic loopback qualification only. Requires an explicitly pinned binary; +// never opens an installed/default store or reads a real credential. +import assert from 'node:assert/strict'; +import {mkdtemp, mkdir, writeFile, readFile, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join, isAbsolute} from 'node:path'; +import {createHash} from 'node:crypto'; +import {spawn} from 'node:child_process'; +import {createServer} from 'node:net'; +import {once} from 'node:events'; +import {publicationPlan, publicationReport} from '../web/deck/curator.mjs'; +import {CuratorJob} from './deck-curator-job.mjs'; +import {NS} from '../web/deck/value.mjs'; +import {revisionIri, requestPublication} from '../web/deck/model.mjs'; + +const binary = process.env.QUIPU_SERVER, expectedHash = process.env.QUIPU_SERVER_SHA256; +assert(binary && isAbsolute(binary) && /^[0-9a-f]{64}$/.test(expectedHash || ''), 'Explicit candidate binary and SHA-256 required'); +const hash = createHash('sha256').update(await readFile(binary)).digest('hex'); assert.equal(hash, expectedHash); +const root = await mkdtemp(join(tmpdir(), 'rikuy-curator-native-')); +const socket = createServer(); socket.listen(0, '127.0.0.1'); await once(socket, 'listening'); const port = socket.address().port; await new Promise(resolve => socket.close(resolve)); +const endpoint = `http://127.0.0.1:${port}`, credential = 'synthetic-local-fixture-only'; +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'}; +await mkdir(join(root, '.bobbin')); await mkdir(join(root, 'home')); +await writeFile(join(root, '.bobbin/config.toml'), `[quipu.server]\nbind = "127.0.0.1:${port}"\nauth_token = "${credential}"\nread_pool_size = 2\nread_processes = 0\nreserve_point_reads = false\nmemory_budget_mib = 256\n[quipu.embedding]\nauto_embed = false\n`, {mode: 0o600}); +let logs = '', ended = false; +const child = spawn(binary, ['--db', join(root, 'fixture.db'), '--bind', `127.0.0.1:${port}`], {cwd: root, env: {HOME: join(root, 'home'), PATH: '/usr/bin:/bin', LANG: 'C.UTF-8'}, stdio: ['ignore', 'pipe', 'pipe']}); +const exit = once(child, 'exit').then(([code, signal]) => {ended = true; return {code, signal};}); +child.stdout.on('data', data => {logs = (logs + data).slice(-16384);}); child.stderr.on('data', data => {logs = (logs + data).slice(-16384);}); +const budget = setTimeout(() => child.kill('SIGKILL'), 90000); budget.unref(); +const results = []; +async function http(path, body, type = 'application/json', authorized = true) { + const response = await fetch(endpoint + path, {method: body === undefined ? 'GET' : 'POST', headers: {'content-type': type, ...(authorized ? {authorization: `Bearer ${credential}`} : {}), 'X-Quipu-Client': 'agent-adhoc'}, body: body === undefined ? undefined : type === 'application/json' ? JSON.stringify(body) : body, signal: AbortSignal.timeout(3000), redirect: 'error'}); + const text = await response.text(); let value; try {value = JSON.parse(text);} catch {value = text;} + assert(response.ok, `HTTP ${response.status}: ${JSON.stringify(value)}`); return value; +} +const update = query => http('/update', query, 'application/sparql-update'); +const query = async sparql => {const value = await http('/query', {query: sparql}); assert.equal(value.truncated, false); assert.equal(value.rows.length, value.count); return value.rows;}; +const facts = graph => query(`SELECT ?s ?p ?o WHERE { GRAPH <${graph}> { ?s ?p ?o } } ORDER BY ?s ?p ?o LIMIT 100`); +const ask = id => ({id: 'https://example.test/request/' + id, revision: 1, seed: boundary.itemPrefix + 'one', itemRevision: 7, title: 'Synthetic curator request', options: ['Yes', 'No'], actions: ['choose', 'later', 'comment'], expiresAt: 2000000000}); +const plan = (next, previous = null) => publicationPlan({boundary, next, previous, now: 1}); +async function noChange(p, label) {const before = await facts(boundary.requestGraph); assert.equal(publicationReport(await update(p.query), p), 'NO_CHANGE'); assert.deepEqual(await facts(boundary.requestGraph), before); results.push({case: label, no_partial_request_mutation: true});} +try { + let ready = false; + for (let i = 0; i < 60; i++) {assert(!ended, logs); try {await http('/health'); ready = true; break;} catch {await new Promise(resolve => setTimeout(resolve, 100));}} + assert(ready, 'Candidate did not become ready'); + const version = await http('/version'); assert.equal(version.git_dirty, false); + for (const graph of Object.values(boundary).slice(0, 3)) await http('/graph/create', {graph}); + await update(`INSERT DATA { GRAPH <${boundary.itemGraph}> { <${boundary.itemPrefix}one> <${boundary.revisionPredicate}> 7 ; <${boundary.statusPredicate}> <${boundary.openStatus}> . } }`); + const itemBefore = await facts(boundary.itemGraph), answersBefore = await facts(boundary.answerGraph); + assert.equal(itemBefore.length, 2); assert.equal(answersBefore.length, 0); + const first = ask('normal'), initial = plan(first); + assert.equal(publicationReport(await update(initial.query), initial), 'CHANGED'); + await noChange(initial, 'identical-first-replay'); + const next = {...first, revision: 2, title: 'Quotes " and newline\nwith Unicode λ; } INSERT DATA { nope'}; + const advanced = plan(next, first); assert.equal(publicationReport(await update(advanced.query), advanced), 'CHANGED'); + const retained = await facts(boundary.requestGraph); + assert.equal(retained.filter(r => r.s === revisionIri(first)).length, 1); + assert.equal(retained.find(r => r.s === revisionIri(first)).o, requestPublication(first, boundary).datums[1].o.value); + results.push({case: 'initial-and-next', exact_old_manifest_retained: true, escaped_text_is_data: true}); + await noChange(plan({...next, title: 'Competing payload'}, first), 'stale-revision'); + for (const [name, delta] of [['changed-item', {itemRevision: 8}], ['foreign-item', {seed: boundary.itemPrefix + 'absent'}]]) await noChange(plan({...ask(name), ...delta}), name); + await update(`INSERT DATA { GRAPH <${boundary.itemGraph}> { <${first.seed}> <${boundary.revisionPredicate}> 8 . } }`); + await noChange(plan(ask('ambiguous-item')), 'ambiguous-item-revision'); + await update(`DELETE DATA { GRAPH <${boundary.itemGraph}> { <${first.seed}> <${boundary.revisionPredicate}> 8 . } }`); + await update(`INSERT DATA { GRAPH <${boundary.itemGraph}> { <${first.seed}> <${boundary.statusPredicate}> . } }`); + await noChange(plan(ask('ambiguous-status')), 'ambiguous-item-status'); + await update(`DELETE DATA { GRAPH <${boundary.itemGraph}> { <${first.seed}> <${boundary.statusPredicate}> <${boundary.openStatus}> . } }`); + await noChange(plan(ask('closed')), 'closed-item'); + await update(`DELETE { GRAPH <${boundary.itemGraph}> { <${first.seed}> <${boundary.statusPredicate}> . } } INSERT { GRAPH <${boundary.itemGraph}> { <${first.seed}> <${boundary.statusPredicate}> <${boundary.openStatus}> . } } WHERE {}`); + await update(`INSERT DATA { GRAPH <${boundary.requestGraph}> { <${first.id}> <${NS}currentRevision> 999 . } }`); + await noChange(plan({...next, revision: 3}, next), 'ambiguous-current-head'); + const occupied = ask('occupied'); await update(`INSERT DATA { GRAPH <${boundary.requestGraph}> { <${revisionIri(occupied)}> "occupied" . } }`); + await noChange(plan(occupied), 'occupied-immutable-revision'); + const race = ask('race'), race2 = {...race, title: 'Other curator'}; + const outcomes = await Promise.all([plan(race), plan(race2)].map(async p => publicationReport(await update(p.query), p))); + assert.deepEqual(outcomes.sort(), ['CHANGED', 'NO_CHANGE']); results.push({case: 'concurrent-different-payload', exactly_one_atomic_publication: true}); + const custody = join(root, 'custody'); await mkdir(custody, {mode: 0o700}); + const jobRequest = ask('lost-reply'), jobInput = {boundary, next: jobRequest, previous: null, now: 1}; + const storeId = (await http('/watermark')).store_id; let sends = 0; + const registered = (await http('/graphs?graph=' + encodeURIComponent(boundary.requestGraph))).graphs[0]; + const options = {directory: custody, storeId, registration: {iri: registered.iri, creation: registered.creation}, inspect: async graph => { + const [identity, metadata] = await Promise.all([http('/watermark'), http('/graphs?graph=' + encodeURIComponent(graph))]); + assert.equal(metadata.count, 1); assert.equal(metadata.graphs.length, 1); + return {storeId: identity.store_id, graph: metadata.graphs[0]}; + }, send: async text => {sends++; await update(text); throw Error('Synthetic dropped response after native commit');}, readBack: async id => { + const identity = (await http('/watermark')).store_id; + const rows = await query(`SELECT ?revision ?manifest WHERE { GRAPH <${boundary.requestGraph}> { <${id}> <${NS}currentRevision> ?revision . <${revisionIri(jobRequest)}> <${NS}requestManifest> ?manifest . } } LIMIT 3`); + assert.equal(rows.length, 1); + return {storeId: identity, requestRow: {graph: boundary.requestGraph, requestId: id, currentRevision: rows[0].revision, revisionIri: revisionIri(jobRequest), manifest: rows[0].manifest, complete: true}}; + }}; + assert.equal((await new CuratorJob(options).publish(jobInput)).state, 'UNKNOWN'); + const recovered = await new CuratorJob(options).reconcile({...jobInput, now: jobRequest.expiresAt + 1}); + assert.equal(recovered.state, 'CONFIRMED'); assert.equal(recovered.sent, false); assert.equal(sends, 1); + results.push({case: 'native-commit-lost-response-restart', read_only_recovery: true, sends}); + assert.deepEqual(await facts(boundary.itemGraph), itemBefore); assert.deepEqual(await facts(boundary.answerGraph), answersBefore); + results.push({case: 'graph-mutation-boundary', positive_item_control_unchanged: true, answer_graph_unchanged: true}); + console.log(JSON.stringify({candidate: version, candidate_sha256: hash, synthetic_loopback_only: true, real_credential_used: false, production_authority_proven: false, results}, null, 2)); +} catch (error) {console.error(logs); throw error;} +finally { + clearTimeout(budget); if (!ended) child.kill('SIGTERM'); + const escalation = setTimeout(() => {if (!ended) child.kill('SIGKILL');}, 3000); + await exit; clearTimeout(escalation); await rm(root, {recursive: true, force: true}); +} diff --git a/tests/deck-curator.test.mjs b/tests/deck-curator.test.mjs new file mode 100644 index 0000000..f80d5a8 --- /dev/null +++ b/tests/deck-curator.test.mjs @@ -0,0 +1,118 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp, rm, chmod, readdir, readFile, writeFile, symlink} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {publicationPlan, publicationReport, publicationReadback} from '../web/deck/curator.mjs'; +import {CuratorJob} from '../scripts/deck-curator-job.mjs'; +import {requestPublication, revisionIri} from '../web/deck/model.mjs'; +import {ask, boundary} from './deck-fixture.mjs'; + +const first = {...ask, revision: 1}; +const input = {boundary, next: first, previous: null, now: 1}; +const row = r => ({graph: boundary.requestGraph, requestId: r.id, currentRevision: r.revision, revisionIri: revisionIri(r), manifest: requestPublication(r, boundary).datums[1].o.value, complete: true}); +const report = (previous = null) => ({tx: 7, asserted: 2, retracted: previous ? 1 : 0, graphs: [{graph: boundary.requestGraph, tx: 7, asserted: 2, retracted: previous ? 1 : 0}]}); +async function withJob(fn, adapters = {}) { + const directory = await mkdtemp(join(tmpdir(), 'rikuy-curator-')); await chmod(directory, 0o700); + let sends = 0; + const creation = {actor: 'fixture-curator', timestamp: '2026-10-25T00:00:00Z'}; + const settings = {directory, storeId: 'urn:uuid:fixture-store', registration: {iri: boundary.requestGraph, creation}, inspect: async () => ({storeId: 'urn:uuid:fixture-store', graph: {iri: boundary.requestGraph, g: 1, class: 'committed', creation}}), send: async () => {sends++; return report();}, readBack: async () => ({storeId: 'urn:uuid:fixture-store', requestRow: row(first)}), ...adapters}; + try {await fn({directory, settings, job: new CuratorJob(settings), sends: () => sends});} finally {await rm(directory, {recursive: true, force: true});} +} + +test('first and next publication use one graph-confined conditional update', () => { + const p = publicationPlan(input), advanced = publicationPlan({...input, next: {...first, revision: 2, title: 'Recurated'}, previous: first}); + assert(p.query.startsWith('DELETE { GRAPH <' + boundary.requestGraph + '> { } }')); + assert(p.query.includes('sameTerm(?otherItemRevision')); + assert(p.query.includes('sameTerm(?otherStatus')); + assert(p.query.includes('?occupiedPredicate ?occupiedValue')); + assert(advanced.query.includes('sameTerm(?otherHead')); + assert(advanced.query.includes('sameTerm(?otherManifest')); + assert(!p.query.includes(boundary.answerGraph)); + assert.equal(publicationReport(report(), p), 'CHANGED'); + assert.equal(publicationReport(report(first), advanced), 'CHANGED'); + assert.deepEqual(publicationReadback(row(first), p), first); +}); + +test('foreign, skipped, inexact, expired and reused revisions refuse before a send', () => { + for (const delta of [{next: {...first, revision: 2}}, {next: {...first, expiresAt: 1}}, {next: {...first, revision: 3}, previous: first}, {next: {...first, revision: 2}, previous: {...first, id: 'urn:other'}}, {next: {...first, revision: 2}, previous: {...first, seed: boundary.itemPrefix + 'other'}}, {now: NaN}]) assert.throws(() => publicationPlan({...input, ...delta})); +}); + +test('report must bind exact graph, single transaction and change counts', () => { + const p = publicationPlan(input); + assert.equal(publicationReport({tx: null, asserted: 0, retracted: 0, graphs: []}, p), 'NO_CHANGE'); + for (const mutate of [r => ({...r, asserted: 1}), r => ({...r, tx: null}), r => ({...r, graphs: []}), r => ({...r, graphs: [{...r.graphs[0], graph: boundary.answerGraph}]}), r => ({...r, graphs: [{...r.graphs[0], tx: 8}]}), r => ({...r, unknown: true})]) assert.throws(() => publicationReport(mutate(report()), p)); + for (const r of [{...first, title: 'Changed'}, {...first, revision: 2}]) assert.throws(() => publicationReadback(row(r), p)); +}); + +test('durable UNKNOWN is synced before one send and retained across new job instances', async () => { + await withJob(async ({directory, settings, sends}) => { + const original = settings.send; + settings.send = async query => { + const files = await readdir(directory); assert.equal(files.length, 1); + const retained = JSON.parse(await readFile(join(directory, files[0]), 'utf8')); + assert.equal(retained.state, 'UNKNOWN'); assert.equal(retained.plan.query, query); + return original(query); + }; + assert.deepEqual(await new CuratorJob(settings).publish(input), {state: 'CONFIRMED', sent: true, transaction: 7}); + assert.deepEqual(await new CuratorJob(settings).publish(input), {state: 'CONFIRMED', sent: false, transaction: null}); + assert.equal(sends(), 1); + await assert.rejects(() => new CuratorJob(settings).publish({...input, next: {...first, title: 'Conflicting payload'}})); + assert.equal(sends(), 1); + }); +}); + +test('lost response and read timeout recover by reads, never a second send', async () => { + let writes = 0, available = false; + await withJob(async ({settings}) => { + const job = new CuratorJob(settings); + assert.deepEqual(await job.publish(input), {state: 'UNKNOWN', sent: true, transaction: null}); + assert.deepEqual(await new CuratorJob(settings).reconcile(input), {state: 'UNKNOWN', sent: false, transaction: null}); + available = true; + assert.deepEqual(await new CuratorJob(settings).reconcile({...input, now: first.expiresAt + 1}), {state: 'CONFIRMED', sent: false, transaction: null}); + assert.equal(writes, 1); + }, {send: async () => {writes++; throw Error('Lost reply');}, readBack: async () => {if (!available) throw Error('Read timeout'); return {storeId: 'urn:uuid:fixture-store', requestRow: row(first)};}}); +}); + +test('concurrent publishers produce exactly one send', async () => { + await withJob(async ({settings, sends}) => { + await Promise.all(Array.from({length: 5}, async () => { + try {await new CuratorJob(settings).publish(input);} catch {} // A partially written record stays UNKNOWN. + })); + assert.equal(sends(), 1); + assert.equal((await new CuratorJob(settings).reconcile(input)).state, 'CONFIRMED'); + }); +}); + +test('zero-change, malformed reports, incomplete and foreign-store evidence do not authorize resend', async () => { + for (const adapters of [{send: async () => ({tx: null, asserted: 0, retracted: 0, graphs: []}), readBack: async () => {throw Error('No matching row');}}, {send: async () => ({tx: 8})}, {readBack: async () => ({storeId: 'urn:wrong', requestRow: row(first)})}, {readBack: async () => ({storeId: 'urn:uuid:fixture-store', requestRow: {...row(first), complete: false}})}]) { + await withJob(async ({job}) => {assert.equal((await job.publish(input)).state, 'UNKNOWN'); assert.equal((await job.reconcile(input)).sent, false);}, adapters); + } +}); + +test('unsafe custody modes, symlinks, corrupted and absent retained records refuse', async () => { + await withJob(async ({directory, settings, job, sends}) => { + await assert.rejects(() => job.reconcile(input)); assert.equal(sends(), 0); + await chmod(directory, 0o755); await assert.rejects(() => job.publish(input)); assert.equal(sends(), 0); + await chmod(directory, 0o700); await job.publish(input); + const path = join(directory, (await readdir(directory))[0]); await writeFile(path, '{broken'); + await assert.rejects(() => new CuratorJob(settings).publish(input)); assert.equal(sends(), 1); + await rm(path); await symlink(join(directory, 'missing'), path); + await assert.rejects(() => job.publish(input)); assert.equal(sends(), 1); + }); +}); + +test('native target inspection requires retained creation, committed graph and store identity', async () => { + for (const mutate of [v => ({...v, storeId: 'urn:other'}), v => ({...v, graph: null}), v => ({...v, graph: {...v.graph, g: 0}}), v => ({...v, graph: {...v.graph, class: 'overlay'}}), v => ({...v, graph: {...v.graph, iri: boundary.answerGraph}}), v => ({...v, graph: {...v.graph, creation: {...v.graph.creation, timestamp: 'replaced'}}})]) { + await withJob(async ({settings, sends}) => {const inspect = settings.inspect; settings.inspect = async (...args) => mutate(await inspect(...args)); await assert.rejects(() => new CuratorJob(settings).publish(input)); assert.equal(sends(), 0);}); + } +}); + +test('replacing the retained graph pin cannot reuse an existing custody slot', async () => { + await withJob(async ({settings, job, sends}) => { + await job.publish(input); + const registration = {...settings.registration, creation: {...settings.registration.creation, timestamp: '2026-10-26T00:00:00Z'}}; + const changed = new CuratorJob({...settings, registration, inspect: async () => ({storeId: settings.storeId, graph: {iri: registration.iri, g: 2, class: 'committed', creation: registration.creation}})}); + await assert.rejects(() => changed.reconcile(input)); assert.equal(sends(), 1); + }); +}); diff --git a/web/deck/curator.mjs b/web/deck/curator.mjs new file mode 100644 index 0000000..464c80c --- /dev/null +++ b/web/deck/curator.mjs @@ -0,0 +1,52 @@ +import {request, requestPublication, readRequest, revisionIri, scope} from './model.mjs'; +import {Unknown, NS, exact, integer, utf8} from './value.mjs'; + +const term = value => `<${value}>`; +const literal = value => JSON.stringify(value); +const number = value => `"${value}"^^`; +const slot = (s, p, o) => `${term(s)} ${term(p)} ${o} .`; +function unique(s, p, expected, variable) { + return `${slot(s, p, expected)} FILTER NOT EXISTS { ${term(s)} ${term(p)} ?${variable} . FILTER (!sameTerm(?${variable}, ${expected})) }`; +} + +// One existing /update operation, with writes confined to the request graph. +// This builder neither registers a graph nor grants the caller write authority. +export function publicationPlan({boundary, next, previous, now}) { + const b = scope(boundary), r = request(next, b); + if (!Number.isSafeInteger(now) || now < 0 || now >= r.expiresAt) throw new Unknown('Expired publication'); + const old = previous === null ? null : request(previous, b); + if (old ? old.id !== r.id || old.seed !== r.seed || old.revision === Number.MAX_SAFE_INTEGER || r.revision !== old.revision + 1 : r.revision !== 1) throw new Unknown('Publication must advance exactly one revision'); + const pub = requestPublication(r, b), current = NS + 'currentRevision', manifest = NS + 'requestManifest'; + const removed = old ? slot(r.id, current, number(old.revision)) : ''; + const head = old ? unique(r.id, current, number(old.revision), 'otherHead') : `FILTER NOT EXISTS { ${term(r.id)} ${term(current)} ?head . }`; + const retained = old ? unique(revisionIri(old), manifest, literal(requestPublication(old, b).datums[1].o.value), 'otherManifest') : ''; + const inserted = `${slot(r.id, current, number(r.revision))}\n${slot(revisionIri(r), manifest, literal(pub.datums[1].o.value))}`; + const query = `DELETE { GRAPH ${term(b.requestGraph)} { ${removed} } } +INSERT { GRAPH ${term(b.requestGraph)} { ${inserted} } } +WHERE { + GRAPH ${term(b.itemGraph)} { + ${unique(r.seed, b.revisionPredicate, number(r.itemRevision), 'otherItemRevision')} + ${unique(r.seed, b.statusPredicate, term(b.openStatus), 'otherStatus')} + } + ${old ? `GRAPH ${term(b.requestGraph)} { ${head} ${retained} }` : `FILTER NOT EXISTS { GRAPH ${term(b.requestGraph)} { ${term(r.id)} ${term(current)} ?head . } }`} + FILTER NOT EXISTS { GRAPH ${term(b.requestGraph)} { ${term(revisionIri(r))} ?occupiedPredicate ?occupiedValue . } } +}`; + if (utf8(query).length > 65536) throw new Unknown('Publication update too large'); + return {boundary: b, next: r, previous: old, query}; +} + +export function publicationReport(value, plan) { + exact(value, ['tx', 'asserted', 'retracted', 'graphs']); + if (value.tx === null && value.asserted === 0 && value.retracted === 0 && Array.isArray(value.graphs) && value.graphs.length === 0) return 'NO_CHANGE'; + integer(value.tx); + if (value.asserted !== 2 || value.retracted !== (plan.previous === null ? 0 : 1) || !Array.isArray(value.graphs) || value.graphs.length !== 1) throw new Unknown('Unexpected publication changes'); + const g = value.graphs[0]; exact(g, ['graph', 'tx', 'asserted', 'retracted']); + if (g.graph !== plan.boundary.requestGraph || g.tx !== value.tx || g.asserted !== value.asserted || g.retracted !== value.retracted) throw new Unknown('Foreign publication transaction'); + return 'CHANGED'; +} + +export function publicationReadback(row, plan) { + const observed = readRequest(row, plan.boundary); + if (JSON.stringify(observed) !== JSON.stringify(plan.next)) throw new Unknown('Publication not confirmed'); + return observed; +} From 17714a261814eb12d786c8b968ce40816acebbb5 Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Sun, 25 Oct 2026 18:24:15 -0400 Subject: [PATCH 4/4] docs(deck): retain curator qualification and terminal demo --- docs/deck/curator-demo.md | 37 +++++++++++ docs/deck/curator-demo.output | 14 ++++ docs/deck/curator-demo.timing | 11 ++++ docs/deck/curator-native-results.json | 95 +++++++++++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 docs/deck/curator-demo.md create mode 100644 docs/deck/curator-demo.output create mode 100644 docs/deck/curator-demo.timing create mode 100644 docs/deck/curator-native-results.json diff --git a/docs/deck/curator-demo.md b/docs/deck/curator-demo.md new file mode 100644 index 0000000..c342e8f --- /dev/null +++ b/docs/deck/curator-demo.md @@ -0,0 +1,37 @@ +# Curator publication custody demo + +Trigger: a first publication changes a synthetic fixture, but its reply is +lost. Watch UNKNOWN become CONFIRMED after a new job instance reads the same +durable custody. The send count stays one; a changed payload in that revision +refuses. The adapters in this recording are synthetic, with no native server, +credential or production request. The separate native qualifier exercises the +generated HTTP update and real native commit/recovery. + +Base `b290c515bfb3cda35eba38af3533c6c9200a0003` has the request fact builder but +no publication job. The demo checks that exact tree with a positive module +control. Demonstrated source is +`5727afd66297160c64006558fe82433247c2bac9`; subsequent recording/result files +do not change its implementation. This demonstrates the new library, rather +than an installed effect or a before/after server benchmark. + +Reproduce from a checkout containing that base object: + +```sh +node scripts/demo-deck-curator.mjs +``` + +The small raw [output recording](curator-demo.output) also serves as the readable +transcript. Download it together with its [timing file](curator-demo.timing). +Playback was verified with the installed util-linux `scriptreplay`; the +recording retains real output and timing. This command deliberately slows its +brief output for reading: + +```sh +scriptreplay --log-out curator-demo.output --log-timing curator-demo.timing --divisor 0.05 +``` + +This is a terminal recording, with no inline video-player claim. Both small +files are retained in this repository's existing Deck evidence directory. +Neither the recording nor the synthetic native controls establish production +least-privilege credential enforcement, graph policy, served read cost or +human acceptance. diff --git a/docs/deck/curator-demo.output b/docs/deck/curator-demo.output new file mode 100644 index 0000000..4c823d9 --- /dev/null +++ b/docs/deck/curator-demo.output @@ -0,0 +1,14 @@ +Script started on 2026-10-25 18:22:44-04:00 [COMMAND="node scripts/demo-deck-curator.mjs" ] +Base b290c515bfb3cda35eba38af3533c6c9200a0003 : request fact builder exists; publication job absent. +Demonstrated source HEAD: 5727afd66297160c64006558fe82433247c2bac9 +Target: source library, synthetic transport and reader; no service or credential. +Trigger: first publication; transport loses its reply after the fixture changes. +Before send, durable custody: UNKNOWN +First call: {"state":"UNKNOWN","sent":true,"transaction":null} +Restart with the SAME durable directory; reconcile by reading. +Recovery: {"state":"CONFIRMED","sent":false,"transaction":null} +Send count: 1 +Changed payload in same revision refused: true +Observed: UNKNOWN -> CONFIRMED by read-back; one send retained. + +Script done on 2026-10-25 18:22:44-04:00 [COMMAND_EXIT_CODE="0"] diff --git a/docs/deck/curator-demo.timing b/docs/deck/curator-demo.timing new file mode 100644 index 0000000..b32365d --- /dev/null +++ b/docs/deck/curator-demo.timing @@ -0,0 +1,11 @@ +0.106361 102 +0.002925 68 +0.000018 83 +0.000022 82 +0.013912 39 +0.000146 64 +0.000115 64 +0.001911 65 +0.000533 15 +0.002559 48 +0.000092 65 diff --git a/docs/deck/curator-native-results.json b/docs/deck/curator-native-results.json new file mode 100644 index 0000000..fade116 --- /dev/null +++ b/docs/deck/curator-native-results.json @@ -0,0 +1,95 @@ +{ + "candidate": { + "features": { + "dev-tools": false, + "full": true, + "jemalloc": true, + "lancedb": false, + "mcp": true, + "onnx": true, + "owl": true, + "oxicompare": false, + "reactive-reasoner": true, + "remote": true, + "server": true, + "shacl": true + }, + "git_dirty": false, + "git_sha": "703e2ce83fd42992ded8214ff62320b84204b0d4", + "read_contracts": [ + "quipu-acquisition/v1", + "quipu-acquisition/composed-v1", + "quipu-eligibility/v1" + ], + "version": "0.16.3" + }, + "candidate_sha256": "bdba9adb323696e0b32e10cc36f6cd6a7af3f14462fcd4c4e869f85ffd0ce452", + "synthetic_loopback_only": true, + "real_credential_used": false, + "production_authority_proven": false, + "results": [ + { + "case": "identical-first-replay", + "no_partial_request_mutation": true + }, + { + "case": "initial-and-next", + "exact_old_manifest_retained": true, + "escaped_text_is_data": true + }, + { + "case": "stale-revision", + "no_partial_request_mutation": true + }, + { + "case": "changed-item", + "no_partial_request_mutation": true + }, + { + "case": "foreign-item", + "no_partial_request_mutation": true + }, + { + "case": "ambiguous-item-revision", + "no_partial_request_mutation": true + }, + { + "case": "ambiguous-item-status", + "no_partial_request_mutation": true + }, + { + "case": "closed-item", + "no_partial_request_mutation": true + }, + { + "case": "ambiguous-current-head", + "no_partial_request_mutation": true + }, + { + "case": "occupied-immutable-revision", + "no_partial_request_mutation": true + }, + { + "case": "concurrent-different-payload", + "exactly_one_atomic_publication": true + }, + { + "case": "native-commit-lost-response-restart", + "read_only_recovery": true, + "sends": 1 + }, + { + "case": "graph-mutation-boundary", + "positive_item_control_unchanged": true, + "answer_graph_unchanged": true + } + ], + "qualified_curator_source": "5727afd66297160c64006558fe82433247c2bac9", + "source_sha256": { + "web/deck/curator.mjs": "d6b3fa6a4bfdb7e25b4ec5169150c18714a4ffbf82471d6e4d9f41e9a52c6f8a", + "scripts/deck-curator-job.mjs": "9825094b63e6a403e2a8fc144a2da24fb8dba207ddedbe17cde09a26392a5461", + "scripts/qualify-deck-curator.mjs": "cbc3520daeba6a905161178c06eea8e31ba604e02ecfa02cd08176adb6c17d04" + }, + "home_guard": "command exit0;157 paths and5 UID resources CLEAN", + "owned_server_reaped_and_fixture_removed": true +}