From eb758fadcfcf519fc9650b5ef26ff9cbd8b48c8a Mon Sep 17 00:00:00 2001 From: Lukas Bresser Date: Thu, 10 Sep 2026 21:21:33 +0000 Subject: [PATCH 01/66] feat(lab): add recoverable owner-scoped local drafts --- scripts/lab-ui-core.test.mjs | 13 +++++++++++++ src/lib/lab-drafts.ts | 14 ++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 scripts/lab-ui-core.test.mjs create mode 100644 src/lib/lab-drafts.ts diff --git a/scripts/lab-ui-core.test.mjs b/scripts/lab-ui-core.test.mjs new file mode 100644 index 00000000..6a67f37e --- /dev/null +++ b/scripts/lab-ui-core.test.mjs @@ -0,0 +1,13 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { source } from './velocity/test-source-loader.mjs' + +test('drafts round-trip by kind and owner, reject corruption, and report blocked storage', () => { + const { saveDraft, loadDraft, draftKey } = source('lib/lab-drafts.ts') + const store = new Map(); const storage = {getItem:k=>store.get(k)??null,setItem:(k,v)=>store.set(k,v),removeItem:k=>store.delete(k)} + assert.equal(saveDraft(storage, 'note', 'guest', {text:'An unfinished question'}).ok,true) + assert.deepEqual(loadDraft(storage,'note','guest').data,{text:'An unfinished question'}) + assert.equal(loadDraft(storage,'note','did:plc:other').data,null) + store.set(draftKey('note','guest'),'{broken'); assert.equal(loadDraft(storage,'note','guest').status,'corrupt') + assert.equal(saveDraft({setItem(){throw Error('quota')}},'note','guest',{}).ok,false) +}) diff --git a/src/lib/lab-drafts.ts b/src/lib/lab-drafts.ts new file mode 100644 index 00000000..7bde7993 --- /dev/null +++ b/src/lib/lab-drafts.ts @@ -0,0 +1,14 @@ +type ReadStore = Pick +type WriteStore = Pick +export const draftKey = (kind: string, owner: string) => `plrd:open-lab:v1:${encodeURIComponent(owner)}:${kind}` +export function saveDraft(storage: WriteStore, kind: string, owner: string, data: Record) { + try { storage.setItem(draftKey(kind, owner), JSON.stringify({version:1, data, savedAt:new Date().toISOString()})); return {ok:true} } + catch { return {ok:false} } +} +export function loadDraft(storage: ReadStore, kind: string, owner: string): {data:Record|null; status:'saved'|'empty'|'corrupt'|'blocked'; savedAt?:string} { + try { + const raw=storage.getItem(draftKey(kind,owner)); if(!raw) return {data:null,status:'empty'} + try { const value=JSON.parse(raw); if(value.version!==1 || !value.data || typeof value.data!=='object' || Array.isArray(value.data)) return {data:null,status:'corrupt'}; return {data:value.data,status:'saved',savedAt:value.savedAt} } + catch { return {data:null,status:'corrupt'} } + } catch { return {data:null,status:'blocked'} } +} From 97b6abc4a7041ec8549dc90f89e279bfd8c8de17 Mon Sep 17 00:00:00 2001 From: Lukas Bresser Date: Thu, 10 Sep 2026 21:25:13 +0000 Subject: [PATCH 02/66] feat(lab): add honest client adapter and source-backed local tools --- scripts/lab-ui-core.test.mjs | 38 ++++++++++++++++++++++++++++++++++++ src/lib/lab-client.ts | 23 ++++++++++++++++++++++ src/lib/lab-data.ts | 27 +++++++++++++++++++++++++ src/lib/lab-packets.ts | 13 ++++++++++++ src/lib/lab-signal.ts | 5 +++++ src/lib/lab-types.ts | 9 +++++++++ 6 files changed, 115 insertions(+) create mode 100644 src/lib/lab-client.ts create mode 100644 src/lib/lab-data.ts create mode 100644 src/lib/lab-packets.ts create mode 100644 src/lib/lab-signal.ts create mode 100644 src/lib/lab-types.ts diff --git a/scripts/lab-ui-core.test.mjs b/scripts/lab-ui-core.test.mjs index 6a67f37e..117e4ccd 100644 --- a/scripts/lab-ui-core.test.mjs +++ b/scripts/lab-ui-core.test.mjs @@ -2,6 +2,44 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { source } from './velocity/test-source-loader.mjs' +test('client adapter fails closed on missing backend, rejects malformed receipts and unsafe URLs', async () => { + const { createLabClient, safeUrl } = source('lib/lab-client.ts') + const offline=createLabClient(async()=>new Response('missing',{status:404})) + assert.equal((await offline.capabilities()).canPublish,false) + assert.equal((await offline.feed()).status,'unavailable') + await assert.rejects(offline.publish('note',{text:'hi'}), /unavailable|failed/i) + const malformed=createLabClient(async()=>Response.json({ok:true})) + await assert.rejects(malformed.publish('note',{text:'hi'}), /receipt/i) + for(const url of ['javascript:alert(1)','data:text/html,test','http://localhost/a','https://x.com@evil.test/']) assert.equal(safeUrl(url),null) + assert.equal(safeUrl('https://marimo.io/'),'https://marimo.io/') +}) + +test('editorial starter search relates artifacts by topic, never member metrics', () => { + const { artifacts, filterArtifacts, relatedArtifacts } = source('lib/lab-data.ts') + assert.ok(artifacts.length>=6) + assert.equal(filterArtifacts(artifacts,{query:'MARIMO',field:'all',type:'all'})[0].id,'marimo') + assert.equal(filterArtifacts(artifacts,{query:'',field:'neurotech',type:'all'}).every(a=>a.field==='neurotech'),true) + const related=relatedArtifacts('neuromatch'); assert.ok(related.some(a=>a.field==='neurotech')) + for(const a of artifacts){assert.match(a.url,/^https:\/\//);assert.ok(a.source);assert.equal(a.followers,undefined)} +}) + +test('bounded work packet contains source provenance, output schema and no dispatch fiction', () => { + const { buildWorkPacket, packetMarkdown } = source('lib/lab-packets.ts') + const p=buildWorkPacket('reference-audit','review',25) + assert.equal(p.taskId,'atlas-reference-audit-v1'); assert.equal(p.budgetHintMinutes,25) + assert.ok(p.sourceUrls.length>0);assert.ok(p.stopConditions.length>2);assert.ok(p.outputSchema) + assert.match(packetMarkdown(p),/Human acceptance/) + assert.throws(()=>buildWorkPacket('reference-audit','review',-1),/budget/i) +}) + +test('synthetic signal is deterministic, sampling changes measurements, reset parameters reproduce output', () => { + const { sampleSignal } = source('lib/lab-signal.ts') + const a=sampleSignal(5,40,0);const b=sampleSignal(5,10,0) + assert.deepEqual(a,sampleSignal(5,40,0));assert.equal(a.length,41);assert.equal(b.length,11) + assert.ok(Math.abs(a[2].value-1)<1e-9) + assert.throws(()=>sampleSignal(5,0,0),/sample/i) +}) + test('drafts round-trip by kind and owner, reject corruption, and report blocked storage', () => { const { saveDraft, loadDraft, draftKey } = source('lib/lab-drafts.ts') const store = new Map(); const storage = {getItem:k=>store.get(k)??null,setItem:(k,v)=>store.set(k,v),removeItem:k=>store.delete(k)} diff --git a/src/lib/lab-client.ts b/src/lib/lab-client.ts new file mode 100644 index 00000000..9e524b8c --- /dev/null +++ b/src/lib/lab-client.ts @@ -0,0 +1,23 @@ +import type { Capabilities, LabFeed, LabProfile, LabRecord, RecordKind } from '@/lib/lab-types' + +export function safeUrl(value: string): string|null { + try { const url=new URL(value); if(url.protocol!=='https:' || url.username || url.password || !url.hostname.includes('.') || /^(localhost|127\.|0\.|10\.|192\.168\.|169\.254\.)/.test(url.hostname) || url.hostname.endsWith('.local')) return null; return url.href } catch { return null } +} +export function createLabClient(fetcher: typeof fetch = fetch) { + async function request(path:string, body?:unknown):Promise> { + let response:Response + try { response=await fetcher(path,{method:body?'POST':'GET',credentials:'same-origin',headers:body?{'Content-Type':'application/json'}:undefined,body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(15000)}) } + catch { throw new Error('The network is unavailable. Your local draft is safe; try again later.') } + let value:Record={} + try { value=await response.json() } catch { /* HTML/empty response is not success. */ } + if(!response.ok) throw new Error(response.status===401?'Sign in again before publishing. Your draft is still here.':typeof value.error==='string'?value.error:'Open Lab services are unavailable. Keep working locally and try again later.') + return value + } + return { + async capabilities():Promise { try { const v=await request('/api/lab/capabilities/'); if(typeof v.canSignIn!=='boolean'||typeof v.canPublish!=='boolean'||!['ready','unconfigured'].includes(String(v.mode))) throw Error(); return v as Capabilities } catch { return {canSignIn:false,canPublish:false,mode:'unconfigured',message:'Publishing and sign-in are not available in this environment. Drafts and downloads work without an account.'} } }, + async feed():Promise { try { const v=await request('/api/lab/feed/'); if(!Array.isArray(v.items)||!['live','empty','unavailable'].includes(String(v.status))) throw Error(); const items=v.items.filter((p):p is LabFeed['items'][number]=>typeof p?.text==='string'&&typeof p?.uri==='string'&&typeof p?.author?.did==='string'&&typeof p?.author?.handle==='string'&&!!safeUrl(p.url)&&Number.isFinite(Date.parse(p.createdAt))); return {items,sourceLabel:typeof v.sourceLabel==='string'?v.sourceLabel:'Public Bluesky posts',fetchedAt:typeof v.fetchedAt==='string'?v.fetchedAt:null,status:v.status as LabFeed['status'],message:typeof v.message==='string'?v.message:undefined} } catch { return {items:[],sourceLabel:'Public Bluesky posts',fetchedAt:null,status:'unavailable',message:'The live source could not be reached. Editorial starters are available below.'} } }, + async records():Promise<{profile:LabProfile|null;records:LabRecord[]}> { const v=await request('/api/lab/records/'); if(!Array.isArray(v.records)) throw Error('Your public records could not be read. Local drafts are unchanged.'); return {profile:(v.profile as LabProfile)||null,records:v.records as LabRecord[]} }, + async publish(kind:RecordKind,data:Record):Promise<{uri:string;cid:string;record:unknown}> { const v=await request('/api/lab/records/',{kind,data}); if(typeof v.uri!=='string'||!v.uri.startsWith('at://')||typeof v.cid!=='string'||!v.record) throw Error('No verified public record receipt was returned. Do not retry before checking your records.'); return v as {uri:string;cid:string;record:unknown} }, + async login(handle:string,returnTo:string):Promise { const v=await request('/api/login/',{handle,returnTo}); if(typeof v.redirectUrl!=='string'||!safeUrl(v.redirectUrl)) throw Error('The sign-in service returned an invalid redirect. Nothing was published.'); return v.redirectUrl }, + } +} diff --git a/src/lib/lab-data.ts b/src/lib/lab-data.ts new file mode 100644 index 00000000..fdd39d17 --- /dev/null +++ b/src/lib/lab-data.ts @@ -0,0 +1,27 @@ +import type { Artifact, LabField, PostType } from '@/lib/lab-types' +export const fields: {id:LabField;label:string;short:string}[] = [ + {id:'digital-human-rights',label:'Digital Human Rights',short:'Open systems'}, + {id:'economies-governance',label:'Economies & Governance',short:'Coordination'}, + {id:'ai-robotics',label:'AI & Robotics',short:'Intelligence'}, + {id:'neurotech',label:'Neurotech',short:'Neuroscience'}, + {id:'cross-field',label:'Cross-field',short:'Scientific tools'}, +] +export const postTypes:{id:PostType;label:string}[]=[{id:'question',label:'Question'},{id:'finding',label:'Finding'},{id:'tool',label:'Tool'},{id:'help',label:'Needs help'},{id:'negative',label:'Negative result'}] +// Editorial descriptions of public sources, not posts by these projects or membership claims. +export const artifacts:Artifact[]=[ + {id:'connectome',title:'Reading the brain, connection by connection',description:'A public research perspective on what it would take to obtain a complete human connectome at synaptic resolution.',url:'https://www.plneuro.xyz/insights/how-to-obtain-a-complete-human-connectome-at-synaptic-resolution-within-the-next-decade/',field:'neurotech',type:'question',topic:'Neural maps',source:'PL Neuro · research perspective',prompt:'Which measurement bottleneck could a small, reproducible experiment help resolve?'}, + {id:'marimo',title:'marimo',description:'Reactive Python notebooks that keep code and outputs in sync. Turn an experiment into a reproducible, interactive app.',url:'https://marimo.io/',field:'cross-field',type:'tool',topic:'Reproducible computing',source:'marimo · official project',prompt:'Turn one of your analysis notebooks into an experiment someone else can rerun.',codeUrl:'https://github.com/marimo-team/marimo',app:true}, + {id:'neuromatch',title:'Neuromatch computational neuroscience',description:'Open course materials for learning the models and methods of computational neuroscience, with executable tutorials.',url:'https://compneuro.neuromatch.io/',field:'neurotech',type:'tool',topic:'Neural maps',source:'Neuromatch · course materials',prompt:'Reproduce a tutorial result and explain which modeling assumption matters most.',codeUrl:'https://github.com/NeuromatchAcademy/course-content',app:true}, + {id:'jupyterlite',title:'JupyterLite',description:'An actual notebook environment in your browser. Explore Python without first setting up a server.',url:'https://jupyterlite.readthedocs.io/en/stable/',demoUrl:'https://jupyterlite.readthedocs.io/en/stable/_static/lab/index.html',field:'cross-field',type:'tool',topic:'Reproducible computing',source:'Project Jupyter · documentation',prompt:'Package a small public dataset with a notebook that runs from a clean browser.',codeUrl:'https://github.com/jupyterlite/jupyterlite',app:true}, + {id:'cadcad',title:'cadCAD',description:'Model complex systems and compare the consequences of different policies before deploying them in the real world.',url:'https://cadcad.org/',field:'economies-governance',type:'tool',topic:'Mechanism design',source:'cadCAD · official project',prompt:'What behavior changes your model’s conclusion? Publish the sensitivity analysis.',codeUrl:'https://github.com/cadCAD-org/cadCAD',app:true}, + {id:'allen',title:'Allen Brain Map',description:'An open doorway into brain cell types, connectivity datasets, visualization tools, and research protocols.',url:'https://brain-map.org/',field:'neurotech',type:'tool',topic:'Neural maps',source:'Allen Institute · open resources',prompt:'Connect one published claim to its dataset, method, and reproducible analysis.',app:true}, + {id:'ipfs',title:'Content addressing for scientific artifacts',description:'IPFS documentation explains how content identifiers can make a specific dataset or result unambiguous and verifiable.',url:'https://docs.ipfs.tech/concepts/content-addressing/',field:'digital-human-rights',type:'finding',topic:'Verifiable artifacts',source:'IPFS · technical documentation',prompt:'Could someone verify that they reran exactly the same source data?'}, + {id:'cognition',title:'Cognitive dark matter: measuring what AI misses',description:'A PL Neuro perspective on the gaps between current AI evaluation and the capabilities of biological intelligence.',url:'https://www.plneuro.xyz/insights/cognitive-dark-matter-measuring-what-ai-misses/',field:'ai-robotics',type:'question',topic:'Intelligence benchmarks',source:'PL Neuro · published perspective',prompt:'Propose a bounded test that distinguishes a meaningful capability from a shortcut.'}, +] +export function filterArtifacts(items:Artifact[],{query='',field='all',type='all'}:{query?:string;field?:string;type?:string}) { + const terms=query.trim().toLowerCase().split(/\s+/).filter(Boolean) + return items.filter(a=>(field==='all'||a.field===field)&&(type==='all'||a.type===type)&&terms.every(term=>`${a.title} ${a.description} ${a.topic} ${a.source}`.toLowerCase().includes(term))) +} +export function relatedArtifacts(id:string) { const a=artifacts.find(a=>a.id===id); return a?artifacts.filter(b=>b.id!==a.id&&(b.topic===a.topic||b.field===a.field)):[] } +export const fieldLabel=(id:string)=>fields.find(f=>f.id===id)?.label||'Cross-field' +export const starterDisclosure='Editorial starter collection — featured projects have not joined Open Lab.' diff --git a/src/lib/lab-packets.ts b/src/lib/lab-packets.ts new file mode 100644 index 00000000..a6ac7a09 --- /dev/null +++ b/src/lib/lab-packets.ts @@ -0,0 +1,13 @@ +export const campaigns=[ + {id:'reference-audit',title:'Trace a claim back to the evidence.',taskId:'atlas-reference-audit-v1',description:'Audit a small public neuro source packet. Separate the claim, the primary evidence, and what remains uncertain.',sourceUrls:['https://www.plneuro.xyz/insights/how-to-obtain-a-complete-human-connectome-at-synaptic-resolution-within-the-next-decade/','https://www.plneuro.xyz/insights/cognitive-dark-matter-measuring-what-ai-misses/'],deliverable:'An evidence ledger: one row per claim, with a primary source and a limitation.'}, + {id:'reproduce-tutorial',title:'Make a result reproducible.',taskId:'neuromatch-reproduction-v1',description:'Choose one public Neuromatch tutorial and reproduce a clearly bounded result. Capture the environment, parameters, and deviations.',sourceUrls:['https://compneuro.neuromatch.io/','https://github.com/NeuromatchAcademy/course-content'],deliverable:'A runnable notebook, an environment manifest, and a comparison to the stated result.'}, +] as const +export function buildWorkPacket(campaignId:string,role:string,budgetHintMinutes:number) { + const c=campaigns.find(c=>c.id===campaignId);if(!c)throw Error('Choose a known pilot recipe') + if(!['research','reproduce','review'].includes(role))throw Error('Choose a role') + if(!Number.isFinite(budgetHintMinutes)||budgetHintMinutes<5||budgetHintMinutes>240)throw Error('Local time budget must be 5–240 minutes') + return {version:1,campaignId:c.id,taskId:c.taskId,title:c.title,status:'proposed-pilot-not-dispatched',role,budgetHintMinutes,budgetIsEnforced:false,sourceUrls:[...c.sourceUrls],instructions:c.description,deliverable:c.deliverable,outputSchema:{taskId:'string',sourceUrl:'https URL',claim:'string',evidenceUrl:'https URL',method:'string',result:'string',limitations:'string',reproductionSteps:'string[]'},verificationRubric:['Resolve each URL and preserve source title/date.','Separate observed evidence from inference.','Record methods, parameters, environment, and unsuccessful attempts.','Human acceptance: an independent person checks the evidence; machine output is not verification.'],stopConditions:['Stop at the local time budget; report incomplete work.','Stop if sources require credentials, personal data, payment, or unpublished materials.','Do not execute untrusted code without your own isolated environment and review.','Do not publish, message people, spend money, or send secrets.'],privacy:'Public source packet only. No keys, tokens, passwords, or private documents. Provider subscriptions and tokens are not interchangeable.'} +} +export type WorkPacket=ReturnType +export function packetMarkdown(p:WorkPacket) { return `# ${p.title}\n\nProposed pilot — downloaded locally, not dispatched.\n\nTask: ${p.taskId}\nRole: ${p.role}\nLocal time hint: ${p.budgetHintMinutes} minutes (not enforced)\n\n${p.instructions}\n\n## Deliverable\n${p.deliverable}\n\n## Sources\n${p.sourceUrls.map(s=>'- '+s).join('\n')}\n\n## Output schema\n\`\`\`json\n${JSON.stringify(p.outputSchema,null,2)}\n\`\`\`\n\n## Verification rubric\n${p.verificationRubric.map(s=>'- '+s).join('\n')}\n\n## Stop conditions\n${p.stopConditions.map(s=>'- '+s).join('\n')}\n\n${p.privacy}\n` } +export function downloadText(name:string,text:string,type='application/json') { const blob=new Blob([text],{type:`${type};charset=utf-8`});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=name.replace(/[^a-zA-Z0-9._-]/g,'-');document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000) } diff --git a/src/lib/lab-signal.ts b/src/lib/lab-signal.ts new file mode 100644 index 00000000..c27a0971 --- /dev/null +++ b/src/lib/lab-signal.ts @@ -0,0 +1,5 @@ +export function sampleSignal(frequency:number,sampleRate:number,noise:number) { + if(!Number.isFinite(sampleRate)||sampleRate<4||sampleRate>240) throw Error('Sample rate must be between 4 and 240 Hz') + if(!Number.isFinite(frequency)||frequency<1||frequency>20||!Number.isFinite(noise)||noise<0||noise>1) throw Error('Invalid signal parameters') + return Array.from({length:Math.floor(sampleRate)+1},(_,i)=>{const t=i/sampleRate;return {time:t,value:Math.sin(2*Math.PI*frequency*t)+noise*Math.sin(i*127.1+3.7)*Math.cos(i*31.3)}}) +} diff --git a/src/lib/lab-types.ts b/src/lib/lab-types.ts new file mode 100644 index 00000000..8f1aeff6 --- /dev/null +++ b/src/lib/lab-types.ts @@ -0,0 +1,9 @@ +export type LabField = 'digital-human-rights'|'economies-governance'|'ai-robotics'|'neurotech'|'cross-field' +export type PostType = 'question'|'finding'|'tool'|'help'|'negative' +export type RecordKind = 'profile'|'note'|'app'|'contribution'|'participation' +export type LabProfile = {workingOn:string; interests:string[]; lookingFor:string; githubUrl?:string; scholarUrl?:string} +export type LabRecord = {uri:string; cid?:string; kind:RecordKind; data:Record; createdAt?:string} +export type Capabilities = {canSignIn:boolean; canPublish:boolean; mode:'ready'|'unconfigured'; message?:string} +export type LivePost = {uri:string;cid?:string;text:string;author:{did:string;handle:string;displayName?:string;avatar?:string};createdAt:string;url:string} +export type LabFeed = {items:LivePost[];sourceLabel:string;fetchedAt:string|null;status:'live'|'empty'|'unavailable';message?:string} +export type Artifact = {id:string;title:string;description:string;url:string;field:LabField;type:PostType;topic:string;source:string;prompt:string;codeUrl?:string;demoUrl?:string;license?:string;app?:boolean} From 99a9c8f177df4e4d0f80c73683c99283dd3f59e3 Mon Sep 17 00:00:00 2001 From: Lukas Bresser Date: Thu, 10 Sep 2026 21:33:15 +0000 Subject: [PATCH 03/66] feat(lab): add source-attributed public science discovery --- docs/open-lab/strategy.md | 92 +++++++++++++++++++++++++ scripts/lab-public-feed.test.mjs | 112 +++++++++++++++++++++++++++++++ src/app/api/lab/feed/route.ts | 17 +++++ src/lib/lab-public-feed.ts | 105 +++++++++++++++++++++++++++++ 4 files changed, 326 insertions(+) create mode 100644 docs/open-lab/strategy.md create mode 100644 scripts/lab-public-feed.test.mjs create mode 100644 src/app/api/lab/feed/route.ts create mode 100644 src/lib/lab-public-feed.ts diff --git a/docs/open-lab/strategy.md b/docs/open-lab/strategy.md new file mode 100644 index 00000000..9161b4e5 --- /dev/null +++ b/docs/open-lab/strategy.md @@ -0,0 +1,92 @@ +# Open Lab: a place for science before it’s finished + +**Product proposal · September 2026 · not a launch announcement** + +## The bet + +PL R&D should not compete with Bluesky for another timeline. It should make a particular kind of encounter happen: someone brings an unfinished piece of science, and someone else makes it easier to finish. + +The smallest useful unit is not a profile or a post. It is **work with an opening**: a question with a missing dataset, a working demo that needs a user, a claim that needs replication, or a research map with a missing source. The feed, app showcase, Atlas, and agent collaboration are different views of that same work—not four separate communities to populate. + +The invitation is **“A place for science before it’s finished.”** “Breakthrough” provides ambition, but cannot be the admission requirement. Rough tools, negative results, replication attempts, and precise questions must belong here. Otherwise, this becomes a stage for polished announcements, not a place to work. + +## Why somebody joins + +A builder gets a legible home for a useful science app and a way to ask for a specific next contribution. A researcher gets collaborators around the problem they are working on, not an institution-shaped directory. A curious person can improve a source, test a tool, or reproduce a small result without claiming to be a principal investigator. Someone with an agent can contribute a bounded piece of work without giving a platform their account keys. + +Browsing should be useful before signing in. The first session should produce something even when nobody else is online: a tool tried, an evidence note drafted, a research profile assembled, or a concrete work packet downloaded. Sign-in comes when a person wants a durable public identity and contribution record. Existing Bluesky identity is a reduction in friction, not the value proposition itself. + +### A first-session loop + +1. Enter through a tool or an open question, not a blank feed. +2. See what it makes possible, its source, what is unverified, and what help would matter. +3. Choose a small action: try it, add evidence, offer help, or take a work packet. +4. Use an existing AT Protocol account when ready to publish. Preview the exact public content first. +5. Return because another person used or responded to the contribution—not because an engagement counter went up. + +The longer loop is **question → useful artifact → independent contribution → better artifact → new question**. The application should make those links visible. + +## How the destination fits together + +**The landing page** is an invitation with a window into the work. Its editorial typography and PL blue remain related to the public site, while the wider canvas and inspectable map make the transition into a working space apparent. The brochure remains the home for institutional context; Open Lab is the home for participation. + +**The workbench** contains questions, findings, tools, requests for help, and negative results. The prompt can ask, “What breakthrough are you working on?” while explicitly welcoming work that has not succeeded yet. Work should carry a next action or a source, rather than reward a confident-sounding announcement. + +**Apps** provide the most immediate reason to visit. Open the thing, understand what it enables, inspect its code when available, and see what its maker needs. The desired energy is “I built a weirdly useful thing over the weekend,” not procurement. But a public URL does not imply open-source licensing, safety, reproducibility, or endorsement. External apps remain external unless deliberately reviewed for integration. + +**Atlas contributions** turn passive maps into answerable questions. “Add a source for this observation” is more actionable than “Contribute to the ecosystem.” A submission is proposed evidence, not an accepted change to the Atlas. Its source, target, author, and review status must remain distinct. + +**Collaborate** starts with bounded work, not a theatrical “launch 10,000 agents” button. A person can inspect a task, select a role, set a budget in their own environment, and export a work packet with clear outputs and stopping conditions. A future coordinator can assign and reconcile those packets once there is a working verification method. + +**My bench** is a research identity: what I am working on, what I can contribute, what I need, and links to GitHub or Google Scholar. Links should be labeled links, not implied account integrations or proof of credentials. Institutional prestige should not determine who can contribute. + +**The map** is an alternate way to discover work, not decorative proof that a community exists. A point must correspond to an inspectable artifact. A line must have an explainable meaning, such as a shared topic or an explicit contribution. Topic similarity is not a collaboration relationship. A curated starter map and a live community map must not be presented as the same thing. + +## Science-shaped, without counterfeit science + +A light touch of humor helps: “Promising,” “Needs a second pair of eyes,” or “Worth an experiment.” Renaming a heart “Peer reviewed” would be a mistake. It turns a social gesture into a scientific assertion, even if intended as a joke. + +Use separate meanings: + +- **Interest:** I want to follow this, try it, or save it. Not scientific validation. +- **Evidence:** here is an observation, source, run, or counterexample. +- **Review:** a named person checked a stated part of the work, with a linked record of what they checked. +- **Replication:** a specified procedure was rerun, with its result and limitations. + +A result should not graduate through those stages by accumulating votes. Negative findings and useful corrections should be first-class contributions. No reputation score is needed at the start. + +## Pool work, not credentials + +The motivating OpenAI example is real as an announcement: its [September 8 report](https://openai.com/index/navier-stokes-solution/) describes a proposed Navier–Stokes solution from a group on the order of 10,000 concurrent agents, about 88 hours, and a further Lean formalization/verification phase. It also says the research used an internal model more capable than the publicly available Astra model. That is not evidence that a pile of interchangeable consumer tokens produces the same result, or that independent mathematical acceptance is already complete. + +Nothing fundamental prevents people from contributing separately funded agent work to a common project. The difficult parts are making the work decomposable, preventing duplicated effort, checking outputs cheaply, and combining useful intermediate results. Provider tokens are not a common currency: models, context limits, tools, prices, licenses, and account terms differ. + +The right first experiment is narrow. For example: audit a small source packet for a public research map. Each task names a claim and source; the worker returns an exact quote/location, a judgment of support, and limitations. A different contributor reviews it. This tests the coordination loop before asking it to discover new mathematics. + +A later execution service needs task leases, idempotency, checkpointed artifacts, provenance, a review queue, contributor-controlled spending limits, and defenses against malicious instructions in source material. Downloading a work packet is not execution. Publishing an intent is not a reserved task. Uploading a result is not verification. Keep these boundaries visible. + +**Bring your agent. Keep your keys.** Contributors run tools in their own controlled environment. Open Lab does not collect model-provider credentials, pool subscriptions, debit a card, or execute arbitrary submitted code. Human acceptance and automated checks are separate records. + +## A launch that can learn something + +Do not open with an empty public network and hope the graph fills in. Start with a deliberately small, consented founding cohort: builders of useful science tools, researchers with answerable asks, and people willing to reproduce or review. These are proposed roles, not recruited participants. + +Before inviting that cohort: + +- Seed a small source-checked editorial collection, visibly separate from members’ contributions. +- Obtain permission from featured makers before describing them as participants or using their names as social proof. +- Have an accountable curator and an explicit weekly time allocation. A feed is not a substitute for facilitation. +- Put a small next action on every founding project. Avoid giant unsplittable challenges. +- Run one complete contribution cycle through independent acceptance and correction. +- Establish public-content, moderation, reporting, deletion, and appeals expectations. Public AT Protocol records can be copied by others; deleting a local view cannot promise universal deletion. +- Instrument completed useful contributions, not just sign-ins. Do not infer membership from an imported Bluesky post. + +**Primary proposed metric:** weekly artifacts improved by a contribution from someone other than the original author. Keep the denominator and acceptance criterion visible. Supporting measures are visitor-to-first-useful-action, time to first qualified response, and contributors who return to complete another piece of work. + +**A proposed six-week decision:** continue only if people independently bring new work and other people improve it without the curator manufacturing every interaction. If people enjoy browsing apps but do not collaborate, narrow into a genuinely good science-tool directory. If agent runs generate volumes of unverifiable text, stop expanding compute and repair the task/evaluation design. If contributors use Bluesky for the whole loop and Open Lab adds no artifact-level value, keep the useful views and drop the new destination ambition. + +## What this PR should prove—and what it cannot + +The first PR should let someone experience the invitation, explore real source-labeled work, try a small actual app, draft contributions, prepare a research profile, and export a concrete agent work packet. It should contain a genuine AT Protocol implementation rather than an email-signup façade, with readiness and unverified deployment steps stated precisely in its technical notes. + +It cannot manufacture a community, confer peer review, promise scientific breakthroughs, or make new record types globally discoverable merely by writing them to a personal data server. Global discovery, moderation operations, and evidence acceptance are explicit launch work. The interactive PR is a product bet to evaluate—not an announcement that those systems already exist. diff --git a/scripts/lab-public-feed.test.mjs b/scripts/lab-public-feed.test.mjs new file mode 100644 index 00000000..c58d995b --- /dev/null +++ b/scripts/lab-public-feed.test.mjs @@ -0,0 +1,112 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { source } from './velocity/test-source-loader.mjs' + +const did = 'did:plc:jfhpnnst6flqway4eaeqzj2a' +const fixture = (overrides = {}) => ({ post: { + uri: `at://${did}/app.bsky.feed.post/3testrecord`, cid: 'bafyreitest', + author: { did, handle: 'example.bsky.social', displayName: 'Example source author' }, + record: { $type: 'app.bsky.feed.post', text: ' A source-linked finding.', createdAt: '2026-09-01T12:00:00Z' }, + ...overrides, +} }) + +test('Open Lab reads a bounded attributed science feed, never calling it a member feed', async (t) => { + assert.ok(existsSync('src/lib/lab-public-feed.ts'), 'public science feed adapter is missing') + const adapter = source('lib/lab-public-feed.ts') + const calls = [] + const fetcher = async (url, options) => { + calls.push({ url: String(url), options }) + return Response.json({ feed: [fixture()] }) + } + const data = await adapter.loadLabPublicFeed({ fetcher, now: () => new Date('2026-09-10T12:00:00Z') }) + assert.equal(calls.length, 1) + const target = new URL(calls[0].url) + assert.equal(target.origin, 'https://public.api.bsky.app') + assert.equal(target.pathname, '/xrpc/app.bsky.feed.getFeed') + assert.equal(target.searchParams.get('feed'), 'at://did:plc:jfhpnnst6flqway4eaeqzj2a/app.bsky.feed.generator/for-science') + assert.equal(calls[0].options.redirect, 'error') + assert.equal(calls[0].options.cache, 'no-store') + assert.ok(calls[0].options.signal instanceof AbortSignal) + assert.equal(data.status, 'live') + assert.equal(data.fetchedAt, '2026-09-10T12:00:00.000Z') + assert.match(data.sourceLabel, /Science feed on Bluesky/) + assert.match(data.message, /not Open Lab members/i) + assert.equal(data.items.length, 1) + assert.equal(data.items[0].uri, fixture().post.uri) + assert.equal(data.items[0].text, fixture().post.record.text) + assert.equal(data.items[0].author.did, did) + assert.equal(data.items[0].url, `https://bsky.app/profile/${did}/post/3testrecord`) + assert.equal(data.items[0].createdAt, fixture().post.record.createdAt) +}) + +test('malformed, labeled, deleted, mismatched and duplicate posts never render as valid science items', async () => { + const { loadLabPublicFeed } = source('lib/lab-public-feed.ts') + const entries = [ + fixture(), fixture(), + { post: { ...fixture().post, uri: 'javascript:alert(1)' } }, + fixture({ author: { did: 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa', handle: 'other.bsky.social' } }), + fixture({ labels: [{ val: 'porn' }] }), + fixture({ notFound: true }), + fixture({ record: { text: 'x'.repeat(3001), createdAt: '2026-09-01T12:00:00Z' } }), + fixture({ record: { text: 'wrong type', createdAt: 'never' } }), + null, {}, + ] + const data = await loadLabPublicFeed({ fetcher: async () => Response.json({ feed: entries }) }) + assert.equal(data.status, 'live') + assert.equal(data.items.length, 1) + assert.deepEqual(Object.keys(data.items[0].author).sort(), ['did', 'displayName', 'handle']) + const allInvalid = await loadLabPublicFeed({ fetcher: async () => Response.json({ feed: [fixture({ labels: [{ val: 'sexual' }] })] }) }) + assert.equal(allInvalid.status, 'empty') + assert.match(allInvalid.message, /filtered|eligible/i) + const badAvatar = await loadLabPublicFeed({ fetcher: async () => Response.json({ feed: [fixture({ author: { ...fixture().post.author, avatar: 'https://evil.example/track', extra: 'private' } })] }) }) + assert.equal(badAvatar.items[0].author.avatar, undefined) + assert.equal(badAvatar.items[0].author.extra, undefined) +}) + +test('oversized provider bodies fail closed before parsing and batches are bounded', async () => { + const { loadLabPublicFeed } = source('lib/lab-public-feed.ts') + const oversized = await loadLabPublicFeed({ fetcher: async () => Response.json({ feed: [fixture()], padding: 'x'.repeat(600000) }) }) + assert.equal(oversized.status, 'unavailable') + const items = Array.from({ length: 70 }, (_, i) => fixture({ uri: `at://${did}/app.bsky.feed.post/item${i}` })) + const bounded = await loadLabPublicFeed({ fetcher: async () => Response.json({ feed: items }) }) + assert.equal(bounded.items.length, 30) +}) + +test('public feed route remains read-only and never caches an outage as a healthy response', async (t) => { + assert.ok(existsSync('src/app/api/lab/feed/route.ts'), 'public feed route missing') + const route = source('app/api/lab/feed/route.ts') + const mock = t.mock.method(globalThis, 'fetch', async () => Response.json({ feed: [fixture()] })) + const response = await route.GET(new Request('https://example.org/api/lab/feed/?url=http://127.0.0.1/secret')) + assert.equal(response.status, 200) + assert.equal(response.headers.get('x-content-type-options'), 'nosniff') + assert.match(response.headers.get('cache-control'), /s-maxage=60/) + assert.equal((await response.json()).items.length, 1) + assert.ok(String(mock.mock.calls[0].arguments[0]).startsWith('https://public.api.bsky.app/')) + assert.equal(route.POST, undefined) + mock.mock.mockImplementation(async () => new Response(null, { status: 503 })) + const failed = await route.GET(new Request('https://example.org/api/lab/feed/')) + assert.equal(failed.status, 503) + assert.equal(failed.headers.get('cache-control'), 'no-store') + assert.equal((await failed.json()).status, 'unavailable') +}) + +test('outages and malformed upstream responses are unavailable, never successful empty feeds', async () => { + const { loadLabPublicFeed } = source('lib/lab-public-feed.ts') + for (const fetcher of [ + async () => { throw new Error('sensitive-provider-message') }, + async () => Response.json({ feed: [] }, { status: 503 }), + async () => Response.json({ error: 'upstream auth denied' }), + async () => new Response('login', { headers: { 'Content-Type': 'text/html' } }), + async () => Response.json({ feed: null }), + ]) { + const data = await loadLabPublicFeed({ fetcher }) + assert.equal(data.status, 'unavailable') + assert.equal(data.fetchedAt, null) + assert.deepEqual(data.items, []) + assert.doesNotMatch(JSON.stringify(data), /sensitive-provider-message|upstream auth denied|/) + } + const empty = await loadLabPublicFeed({ fetcher: async () => Response.json({ feed: [] }) }) + assert.equal(empty.status, 'empty') + assert.ok(empty.fetchedAt) +}) diff --git a/src/app/api/lab/feed/route.ts b/src/app/api/lab/feed/route.ts new file mode 100644 index 00000000..2b5e4ed2 --- /dev/null +++ b/src/app/api/lab/feed/route.ts @@ -0,0 +1,17 @@ +import { loadLabPublicFeed } from '@/lib/lab-public-feed' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** Read-only, public, source-attributed discovery. No user-selected upstream. */ +export async function GET() { + const body = await loadLabPublicFeed() + const unavailable = body.status === 'unavailable' + return Response.json(body, { + status: unavailable ? 503 : 200, + headers: { + 'Cache-Control': unavailable ? 'no-store' : 'public, max-age=30, s-maxage=60', + 'X-Content-Type-Options': 'nosniff', + }, + }) +} diff --git a/src/lib/lab-public-feed.ts b/src/lib/lab-public-feed.ts new file mode 100644 index 00000000..fe47688c --- /dev/null +++ b/src/lib/lab-public-feed.ts @@ -0,0 +1,105 @@ +import { AtUri, isValidAtUri, isValidDid, isValidHandle } from '@atproto/syntax' + +/** Public discovery, not an Open Lab membership index. Never accepts an upstream URL. */ +export const LAB_SCIENCE_FEED = 'at://did:plc:jfhpnnst6flqway4eaeqzj2a/app.bsky.feed.generator/for-science' +export const LAB_SCIENCE_SOURCE = 'https://bsky.app/profile/did:plc:jfhpnnst6flqway4eaeqzj2a/feed/for-science' + +export interface LabPublicPost { + uri: string + cid?: string + text: string + author: { did: string; handle: string; displayName?: string; avatar?: string } + createdAt: string + url: string +} +export interface LabPublicFeed { + items: LabPublicPost[] + sourceLabel: string + sourceUrl: string + fetchedAt: string | null + status: 'live' | 'empty' | 'unavailable' + message: string +} + +type FeedOptions = { fetcher?: typeof fetch; now?: () => Date } + +const object = (value: unknown): Record => value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {} +const text = (value: unknown, max: number): value is string => typeof value === 'string' && value.length > 0 && value.length <= max +const hasLabels = (value: unknown) => Array.isArray(value) && value.some(label => object(label).neg !== true) + +function publicPost(entry: unknown): LabPublicPost | null { + const post = object(object(entry).post) + const record = object(post.record) + const author = object(post.author) + if (post.notFound || post.blocked || author.blocked || hasLabels(post.labels) || hasLabels(author.labels)) return null + if (!text(post.uri, 2048) || !isValidAtUri(post.uri) || !text(author.did, 2048) || !isValidDid(author.did)) return null + const uri = new AtUri(post.uri) + if (uri.host !== author.did || uri.collection !== 'app.bsky.feed.post' || !uri.rkey || uri.hash || uri.searchParams.size) return null + if (!text(author.handle, 253) || !isValidHandle(author.handle)) return null + if (record.$type !== 'app.bsky.feed.post' || !text(record.text, 3000) || !text(record.createdAt, 80) || !Number.isFinite(Date.parse(record.createdAt))) return null + const identity: LabPublicPost['author'] = { did: author.did, handle: author.handle } + if (text(author.displayName, 640)) identity.displayName = author.displayName + if (text(author.avatar, 2048)) { + try { + const avatar = new URL(author.avatar) + if (avatar.origin === 'https://cdn.bsky.app' && !avatar.username && !avatar.password) identity.avatar = avatar.href + } catch { /* Optional untrusted media never changes a post's identity. */ } + } + return { + uri: post.uri, + ...(text(post.cid, 200) ? { cid: post.cid } : {}), + text: record.text, + author: identity, + createdAt: record.createdAt, + url: `https://bsky.app/profile/${author.did}/post/${encodeURIComponent(uri.rkey)}`, + } +} + +async function boundedJson(response: Response): Promise { + const limit = 512_000 + if (Number(response.headers.get('content-length')) > limit) throw new Error('body too large') + const reader = response.body?.getReader() + if (!reader) throw new Error('missing body') + const decoder = new TextDecoder() + let bytes = 0 + let value = '' + try { + for (;;) { + const chunk = await reader.read() + if (chunk.done) break + bytes += chunk.value.byteLength + if (bytes > limit) { await reader.cancel(); throw new Error('body too large') } + value += decoder.decode(chunk.value, { stream: true }) + } + return JSON.parse(value + decoder.decode()) + } finally { reader.releaseLock() } +} + +export async function loadLabPublicFeed({ fetcher = fetch, now = () => new Date() }: FeedOptions = {}): Promise { + const url = new URL('https://public.api.bsky.app/xrpc/app.bsky.feed.getFeed') + url.searchParams.set('feed', LAB_SCIENCE_FEED) + url.searchParams.set('limit', '30') + const base = { sourceLabel: 'From the Science feed on Bluesky', sourceUrl: LAB_SCIENCE_SOURCE } + try { + const response = await fetcher(url, { cache: 'no-store', redirect: 'error', signal: AbortSignal.timeout(8000) }) + if (!response.ok || !response.headers.get('content-type')?.includes('application/json')) throw new Error('unavailable') + const body = object(await boundedJson(response)) + if (!Array.isArray(body.feed)) throw new Error('invalid feed') + const seen = new Set() + const items: LabPublicPost[] = [] + for (const entry of body.feed.slice(0, 30)) { + const item = publicPost(entry) + if (item && !seen.has(item.uri)) { seen.add(item.uri); items.push(item) } + } + return { + ...base, items, + fetchedAt: now().toISOString(), + status: items.length ? 'live' : 'empty', + message: items.length + ? 'Public posts curated by the Science feed. These authors are not Open Lab members by virtue of appearing here.' + : 'No eligible posts in the current source batch. Malformed or labeled posts are filtered; this is not an Open Lab membership count.', + } + } catch { + return { ...base, items: [], fetchedAt: null, status: 'unavailable', message: 'Bluesky could not be reached. Your drafts and the editorial collection are still available.' } + } +} From 89144356050a8c753ab5050ebf33cc0515a4947a Mon Sep 17 00:00:00 2001 From: Lukas Bresser Date: Thu, 10 Sep 2026 21:41:05 +0000 Subject: [PATCH 04/66] fix(auth): expose only public identity in session status --- scripts/lab-status.test.mjs | 34 ++++++++++++++++++++++++++++++++++ src/app/api/status/route.ts | 13 +++++++++---- 2 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 scripts/lab-status.test.mjs diff --git a/scripts/lab-status.test.mjs b/scripts/lab-status.test.mjs new file mode 100644 index 00000000..a8c9c095 --- /dev/null +++ b/scripts/lab-status.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import path from 'node:path' +import { source } from './velocity/test-source-loader.mjs' +const require = createRequire(import.meta.url) +const sessionPath = path.resolve('src/lib/session.ts') +let current = {} +require.cache[sessionPath] = { id: sessionPath, filename: sessionPath, loaded: true, exports: { getSession: async () => { + if (current instanceof Error) throw current + return current +} } } + +test('auth status returns only public identity fields, never serialized OAuth credentials or future private fields', async () => { + current = { did: 'did:plc:jfhpnnst6flqway4eaeqzj2a', handle: 'test.bsky.social', displayName: 'Test', avatar: 'https://cdn.bsky.app/avatar/example', oauthSession: JSON.stringify({ refreshToken: 'MOCK_REFRESH_ONLY', dpopJwk: { d: 'MOCK_PRIVATE_KEY_ONLY' } }), futurePrivate: 'MOCK_FUTURE_SECRET' } + const { GET } = source('app/api/status/route.ts') + const response = await GET() + assert.equal(response.status, 200) + assert.equal(response.headers.get('cache-control'), 'private, no-store') + assert.deepEqual(await response.json(), { did: current.did, handle: current.handle, displayName: current.displayName, avatar: current.avatar }) +}) + +test('signed-out status and session errors are not cacheable and leak no internals', async () => { + const { GET } = source('app/api/status/route.ts') + current = {} + const anonymous = await GET() + assert.deepEqual(await anonymous.json(), {}) + assert.equal(anonymous.headers.get('cache-control'), 'private, no-store') + current = new Error('MOCK_DO_NOT_EXPOSE') + const failed = await GET() + assert.equal(failed.status, 500) + assert.deepEqual(await failed.json(), {}) + assert.equal(failed.headers.get('cache-control'), 'private, no-store') +}) diff --git a/src/app/api/status/route.ts b/src/app/api/status/route.ts index 6082196b..90e89b86 100644 --- a/src/app/api/status/route.ts +++ b/src/app/api/status/route.ts @@ -6,9 +6,14 @@ export const dynamic = 'force-dynamic' export async function GET() { try { const session = await getSession() - return NextResponse.json(session) - } catch (error) { - console.error('Failed to get session:', error) - return NextResponse.json({}, { status: 500 }) + // Public identity DTO only. Session also contains private OAuth material. + const identity = Object.fromEntries( + (['did', 'handle', 'displayName', 'avatar'] as const) + .filter(key => typeof session[key] === 'string') + .map(key => [key, session[key]]) + ) + return NextResponse.json(identity, { headers: { 'Cache-Control': 'private, no-store' } }) + } catch { + return NextResponse.json({}, { status: 500, headers: { 'Cache-Control': 'private, no-store' } }) } } From f891012b0ccf961e93edca467d1ea8a0a90bbd37 Mon Sep 17 00:00:00 2001 From: Lukas Bresser Date: Thu, 10 Sep 2026 22:03:07 +0000 Subject: [PATCH 05/66] feat(lab): close the paired agent evidence return loop --- scripts/lab-evidence-ui.test.mjs | 55 +++++++++++ scripts/lab-evidence.test.mjs | 55 +++++++++++ src/components/lab/LabEvidenceWorkbench.css | 59 +++++++++++ src/components/lab/LabEvidenceWorkbench.tsx | 104 ++++++++++++++++++++ src/lib/lab-evidence-ledger.ts | 84 ++++++++++++++++ 5 files changed, 357 insertions(+) create mode 100644 scripts/lab-evidence-ui.test.mjs create mode 100644 scripts/lab-evidence.test.mjs create mode 100644 src/components/lab/LabEvidenceWorkbench.css create mode 100644 src/components/lab/LabEvidenceWorkbench.tsx create mode 100644 src/lib/lab-evidence-ledger.ts diff --git a/scripts/lab-evidence-ui.test.mjs b/scripts/lab-evidence-ui.test.mjs new file mode 100644 index 00000000..88425bee --- /dev/null +++ b/scripts/lab-evidence-ui.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { JSDOM } from 'jsdom' +import { source } from './velocity/test-source-loader.mjs' +const require = createRequire(import.meta.url) +require.extensions['.css'] = () => {} +const makeResult = (role, assessment) => ({ schemaVersion: 1, taskId: 'flywire-source-audit-v1', sourceUrl: 'https://www.nih.gov/news-events/nih-research-matters/complete-wiring-map-adult-fruit-fly-brain', role, contributor: `Test ${role}`, runner: 'human', quote: 'Synthetic test excerpt, not evidence.', location: 'Test-only location', assessment, limitation: 'This is a synthetic UI fixture.' }) + +test('visitor imports research and review, sees dissent, and explicitly exports a local review with no network publish', async () => { + assert.ok(existsSync('src/components/lab/LabEvidenceWorkbench.tsx'), 'missing evidence workbench') + const dom = new JSDOM('
', { url: 'http://localhost/lab/collaborate/' }) + const saved = {} + for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'Event', 'localStorage']) { + saved[key] = Object.getOwnPropertyDescriptor(globalThis, key) + Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true, writable: true }) + } + globalThis.IS_REACT_ACT_ENVIRONMENT = true + const React = await import('react') + const { createRoot } = await import('react-dom/client') + const root = createRoot(document.getElementById('root')) + let exported = null + const Component = source('components/lab/LabEvidenceWorkbench.tsx').default + try { + await React.act(() => root.render(React.createElement(Component, { onExport: bundle => { exported = bundle } }))) + const fill = async (label, value) => { + const el = document.querySelector(`[aria-label="${label}"]`) + assert.ok(el, label) + const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype + await React.act(() => { Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, value); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })) }) + } + const click = async text => { const el = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === text); assert.ok(el, text); await React.act(() => el.click()) } + await fill('Research return JSON', JSON.stringify(makeResult('research', 'supports'))) + await click('Import research') + await fill('Review return JSON', JSON.stringify(makeResult('review', 'contradicts'))) + await click('Import review') + assert.match(document.body.textContent, /Different judgments/) + assert.equal(exported, null) + await fill('Local reviewer name', 'Test local editor') + await fill('Resolution note', 'The conflicting judgments need further source inspection.') + await React.act(() => document.querySelector('[aria-label="I inspected the source evidence"]').click()) + await click('Export review bundle') + assert.equal(exported.status, 'local-review-not-atlas-acceptance') + assert.equal(exported.comparison.status, 'disagreement') + assert.equal(exported.results.length, 2) + assert.ok(localStorage.getItem('plrd-open-lab:evidence-pilot:v1')) + assert.match(document.body.textContent, /not published/i) + } finally { + await React.act(() => root.unmount()) + dom.window.close() + for (const [key, descriptor] of Object.entries(saved)) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else delete globalThis[key] } + delete globalThis.IS_REACT_ACT_ENVIRONMENT + } +}) diff --git a/scripts/lab-evidence.test.mjs b/scripts/lab-evidence.test.mjs new file mode 100644 index 00000000..3a98a996 --- /dev/null +++ b/scripts/lab-evidence.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { source } from './velocity/test-source-loader.mjs' + +const result = (overrides = {}) => ({ schemaVersion: 1, taskId: 'flywire-source-audit-v1', sourceUrl: 'https://www.nih.gov/news-events/nih-research-matters/complete-wiring-map-adult-fruit-fly-brain', role: 'research', contributor: 'Test researcher', runner: 'human', quote: 'A synthetic test quotation, not a scientific result.', location: 'Test paragraph', assessment: 'supports', limitation: 'Synthetic test fixture only.', ...overrides }) + +test('result import is bounded, strict, source-pinned and role-specific', () => { + const { parseEvidenceResult } = source('lib/lab-evidence-ledger.ts') + assert.deepEqual(parseEvidenceResult(JSON.stringify(result()), 'research'), result()) + for (const value of [null, [], {}, result({ taskId: 'different' }), result({ sourceUrl: 'http://127.0.0.1/' }), result({ role: 'review' }), result({ assessment: 'peer-reviewed' }), result({ runner: 'verified-scientist' }), result({ assessment: ['supports'] }), result({ runner: ['human'] }), result({ quote: '' }), result({ contributor: ' ' }), result({ quote: 'x'.repeat(4001) }), result({ extra: 'secret' }), result({ schemaVersion: 2 })]) { + assert.throws(() => parseEvidenceResult(JSON.stringify(value), 'research')) + } + assert.throws(() => parseEvidenceResult('{broken', 'research')) + assert.throws(() => parseEvidenceResult(' '.repeat(65001), 'research')) + assert.throws(() => parseEvidenceResult(JSON.stringify(result({ quote: 'bad\u0000text' })), 'research')) +}) + +test('paired comparison preserves dissent and never turns agreement into scientific acceptance', () => { + const { compareEvidence, buildReviewBundle } = source('lib/lab-evidence-ledger.ts') + const a = result() + const b = result({ role: 'review', contributor: 'Test reviewer', assessment: 'contradicts' }) + assert.equal(compareEvidence(a, null).status, 'awaiting-review') + assert.equal(compareEvidence(null, b).status, 'awaiting-research') + assert.equal(compareEvidence(a, b).status, 'disagreement') + assert.equal(compareEvidence(a, { ...b, assessment: 'supports' }).status, 'agreement-not-validation') + assert.equal(compareEvidence(a, { ...b, contributor: ' TEST researcher ' }).status, 'same-attribution') + const resolution = { by: 'Local editor', decision: 'needs-work', note: 'The sources still need careful human inspection.', checkedSource: true } + const bundle = buildReviewBundle(a, b, resolution) + assert.equal(bundle.status, 'local-review-not-atlas-acceptance') + assert.equal(bundle.comparison.status, 'disagreement') + assert.deepEqual(bundle.results, [a, b]) + assert.deepEqual(bundle.resolution, resolution) + assert.throws(() => buildReviewBundle(a, null, resolution)) + assert.throws(() => buildReviewBundle(a, b, { ...resolution, checkedSource: false })) + assert.throws(() => buildReviewBundle(a, b, { ...resolution, decision: 'accepted-by-atlas' })) + assert.throws(() => buildReviewBundle(a, b, { ...resolution, note: '' })) +}) + +test('paired agent packets pin the same claim and source, but demand separate research and review', () => { + assert.ok(existsSync('src/lib/lab-evidence-ledger.ts'), 'missing evidence loop') + const { evidenceTask, buildEvidencePacket } = source('lib/lab-evidence-ledger.ts') + const a = buildEvidencePacket('research', 20) + const b = buildEvidencePacket('review', 20) + assert.equal(a.taskId, b.taskId) + assert.equal(a.claim, b.claim) + assert.equal(a.sourceUrl, b.sourceUrl) + assert.equal(a.taskId, evidenceTask.id) + assert.notEqual(a.instructions, b.instructions) + assert.equal(a.execution, 'not-dispatched') + assert.equal(a.budget.enforced, false) + assert.ok(a.stopConditions.some(x => x.includes('instructions in source material'))) + assert.throws(() => buildEvidencePacket('administrator', 20)) + for (const n of [0, 241, NaN, 20.5]) assert.throws(() => buildEvidencePacket('research', n)) +}) diff --git a/src/components/lab/LabEvidenceWorkbench.css b/src/components/lab/LabEvidenceWorkbench.css new file mode 100644 index 00000000..db47d3e3 --- /dev/null +++ b/src/components/lab/LabEvidenceWorkbench.css @@ -0,0 +1,59 @@ +.lab-evidence { margin-top: 4rem; padding-top: 3rem; border-top: 1px solid var(--lab-line, #d7d6cf); color: var(--lab-ink, #18191b); } +.lab-evidence-heading { display: grid; grid-template-columns: 1.2fr 1fr; gap: 3rem; align-items: end; margin-bottom: 2rem; } +.lab-evidence h2 { font-family: var(--font-newsreader), Georgia, serif; font-size: clamp(2rem, 4vw, 3.5rem); font-weight: 400; line-height: 1.08; margin: .6rem 0 0; letter-spacing: -.035em; } +.lab-evidence h2 em { color: var(--lab-blue, #1982f4); font-weight: 400; } +.lab-evidence p, .lab-evidence dd { line-height: 1.65; } +.lab-evidence h3 { font-size: 1.25rem; font-weight: 600; line-height: 1.35; } +.lab-evidence-brief { background: var(--lab-paper, #f8f7f3); padding: clamp(1rem, 3vw, 2rem); border: 1px solid var(--lab-line, #d7d6cf); } +.lab-evidence-brief h3 { margin: .7rem 0 1rem; } +.lab-evidence-claim { font-family: var(--font-newsreader), Georgia, serif; font-size: 1.5rem; max-width: 60rem; } +.lab-evidence-brief dl { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin: 1.5rem 0; } +.lab-evidence-brief dt { font-weight: 600; margin-bottom: .4rem; } +.lab-evidence-brief dd { margin: 0; } +.lab-evidence-sources { display: flex; flex-wrap: wrap; gap: .5rem 1.5rem; margin: 1rem 0; } +.lab-evidence-sources a { display: inline-flex; min-height: 44px; align-items: center; text-decoration: underline; text-underline-offset: .2em; } +.lab-evidence small { font-size: .8rem; line-height: 1.55; } +.lab-evidence-packets { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; margin: 1.5rem 0 1rem; } +.lab-evidence-packets .lab-field { max-width: 18rem; } +.lab-evidence-instruction { max-width: 70rem; font-size: .925rem; margin-bottom: 2rem; } +.lab-evidence code { font-family: monospace; font-size: .9em; } +.lab-evidence-columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 1.5rem; } +.lab-evidence-slot { border: 1px solid var(--lab-line, #d7d6cf); padding: clamp(1rem, 2vw, 1.5rem); min-width: 0; } +.lab-evidence-slot-heading { display: flex; align-items: baseline; flex-wrap: wrap; gap: .75rem; margin-bottom: 1rem; } +.lab-evidence-slot-heading > span { font: .8rem monospace; } +.lab-evidence-slot-heading small { margin-left: auto; font-family: monospace; font-size: .65rem; letter-spacing: .035em; } +.lab-evidence .lab-field { display: grid; gap: .45rem; font-size: .85rem; font-weight: 500; } +.lab-evidence input:not([type=checkbox]):not([type=file]), .lab-evidence textarea, .lab-evidence select { box-sizing: border-box; display: block; width: 100%; min-width: 0; background: var(--lab-surface, #fff); color: inherit; border: 1px solid var(--lab-line, #c7c6c0); border-radius: 2px; padding: .7rem; font: inherit; font-weight: 400; min-height: 44px; } +.lab-evidence textarea { resize: vertical; } +.lab-evidence textarea[aria-label$='return JSON'] { font-family: monospace; font-size: .75rem; line-height: 1.6; } +.lab-evidence :is(button, a, input, textarea, select):focus-visible { outline: 3px solid var(--lab-blue, #1982f4); outline-offset: 3px; } +.lab-evidence .lab-button { min-height: 44px; } +.lab-evidence-import { display: flex; gap: 1rem; justify-content: space-between; align-items: center; flex-wrap: wrap; margin-top: 1rem; } +.lab-evidence-file { display: grid; font-size: .75rem; gap: .4rem; max-width: 100%; } +.lab-evidence-file input { width: 100%; max-width: 17rem; min-height: 44px; padding-top: .5rem; } +.lab-evidence-result { border-top: 1px solid var(--lab-line, #d7d6cf); margin-top: 1.5rem; padding-top: 1.25rem; overflow-wrap: anywhere; font-size: .875rem; } +.lab-evidence-result > p { margin: .6rem 0; } +.lab-evidence-result span { font-size: .75rem; } +.lab-evidence-assessment { font-weight: 600; } +.lab-evidence-result blockquote { margin: 1rem 0; font-family: var(--font-newsreader), Georgia, serif; font-size: 1.2rem; line-height: 1.5; } +.lab-evidence-comparison { display: grid; gap: .6rem; padding: 1.5rem 0; margin: 1rem 0; border-bottom: 1px solid var(--lab-line, #d7d6cf); } +.lab-evidence-comparison > span { font: .7rem monospace; letter-spacing: .08em; } +.lab-evidence-comparison strong { font-size: 1.1rem; } +.lab-evidence-comparison p { margin: 0; font-size: .85rem; } +.lab-evidence-resolution { display: grid; grid-template-columns: minmax(0, .65fr) minmax(0, 1fr); gap: 3rem; margin: 2rem 0; } +.lab-evidence-resolution h3 { margin: .75rem 0; } +.lab-evidence-resolution-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; min-width: 0; } +.lab-evidence-full, .lab-evidence-check, .lab-evidence-actions { grid-column: 1 / -1; } +.lab-evidence-check { display: flex; gap: .75rem; align-items: start; font-size: .85rem; line-height: 1.6; cursor: pointer; padding: .65rem 0; } +.lab-evidence-check input { flex: 0 0 auto; width: 20px; height: 20px; margin-top: .15rem; accent-color: #1982f4; } +.lab-evidence-actions { display: flex; gap: .75rem; flex-wrap: wrap; } +.lab-evidence-notice { padding: 1rem; background: var(--lab-paper, #f8f7f3); border: 1px solid var(--lab-line, #d7d6cf); } +.lab-evidence-privacy { max-width: 65rem; font-size: .8rem; opacity: .8; } +@media (max-width: 760px) { + .lab-evidence-heading, .lab-evidence-columns, .lab-evidence-resolution, .lab-evidence-brief dl { grid-template-columns: minmax(0, 1fr); gap: 1.3rem; } + .lab-evidence-resolution-fields { grid-template-columns: minmax(0, 1fr); } + .lab-evidence-packets { align-items: stretch; } + .lab-evidence-packets > * { width: 100%; max-width: none !important; } + .lab-evidence-actions { flex-direction: column; } + .lab-evidence-slot-heading small { margin-left: 0; } +} diff --git a/src/components/lab/LabEvidenceWorkbench.tsx b/src/components/lab/LabEvidenceWorkbench.tsx new file mode 100644 index 00000000..7291669d --- /dev/null +++ b/src/components/lab/LabEvidenceWorkbench.tsx @@ -0,0 +1,104 @@ +'use client' + +import { useEffect, useState } from 'react' +import { buildEvidencePacket, buildReviewBundle, compareEvidence, evidenceTask, parseEvidenceResult, type EvidenceResult, type EvidenceRole, type EvidenceResolution } from '@/lib/lab-evidence-ledger' +import './LabEvidenceWorkbench.css' + +const storageKey = 'plrd-open-lab:evidence-pilot:v1' +type Bundle = ReturnType +function downloadJson(name: string, value: unknown) { + const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], { type: 'application/json;charset=utf-8' })) + const a = document.createElement('a') + a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove() + setTimeout(() => URL.revokeObjectURL(url), 1000) +} + +/** Local inspectable coordination pilot; never dispatches or publishes. */ +export default function LabEvidenceWorkbench({ onExport, onPropose }: { onExport?: (bundle: Bundle) => void; onPropose?: (bundle: Bundle) => void }) { + const [minutes, setMinutes] = useState('20') + const [researchText, setResearchText] = useState('') + const [reviewText, setReviewText] = useState('') + const [research, setResearch] = useState(null) + const [review, setReview] = useState(null) + const [by, setBy] = useState('') + const [note, setNote] = useState('') + const [decision, setDecision] = useState('needs-work') + const [checkedSource, setCheckedSource] = useState(false) + const [notice, setNotice] = useState('') + const [storageNotice, setStorageNotice] = useState('') + const [ready, setReady] = useState(false) + const comparison = compareEvidence(research, review) + + useEffect(() => { + try { + const raw = localStorage.getItem(storageKey) + if (raw && raw.length < 150_000) { + const saved = JSON.parse(raw) + if (saved?.version === 1) { + if (typeof saved.researchText === 'string' && saved.researchText.length <= 64_000) setResearchText(saved.researchText) + if (typeof saved.reviewText === 'string' && saved.reviewText.length <= 64_000) setReviewText(saved.reviewText) + if (typeof saved.by === 'string' && saved.by.length <= 80) setBy(saved.by) + if (typeof saved.note === 'string' && saved.note.length <= 2000) setNote(saved.note) + setNotice('Recovered your local draft. Import the returns again before making a review bundle.') + } + } + } catch { setStorageNotice('Browser storage is unavailable. Keep copies of your returns before leaving.') } + setReady(true) + }, []) + useEffect(() => { + if (!ready) return + try { localStorage.setItem(storageKey, JSON.stringify({ version: 1, researchText, reviewText, by, note })) } + catch { setStorageNotice('Could not save locally. Keep copies of your returns before leaving.') } + }, [ready, researchText, reviewText, by, note]) + + function importReturn(role: EvidenceRole) { + try { + const result = parseEvidenceResult(role === 'research' ? researchText : reviewText, role) + if (role === 'research') setResearch(result); else setReview(result) + setCheckedSource(false) + setNotice(`${role === 'research' ? 'Research' : 'Review'} return imported locally. Its shape was checked, not its scientific validity. Not published.`) + } catch (e) { setNotice(e instanceof Error ? e.message : 'Could not import this return.') } + } + async function loadFile(role: EvidenceRole, file?: File) { + if (!file) return + if (file.size > 64_000) { setNotice('Keep a result below 64 KB.'); return } + try { + const text = await file.text() + if (role === 'research') { setResearchText(text); setResearch(null) } else { setReviewText(text); setReview(null) } + setCheckedSource(false) + setNotice('File loaded into the draft. Use Import to check its fields.') + } catch { setNotice('Could not read the file. Paste its JSON instead.') } + } + function packet(role: EvidenceRole) { + try { downloadJson(`${evidenceTask.id}-${role}.json`, buildEvidencePacket(role, Number(minutes))); setNotice('Packet prepared for download. No task was reserved, no agent was launched, and no money was spent.') } + catch (e) { setNotice(e instanceof Error ? e.message : 'Could not prepare the packet.') } + } + function exportBundle(propose = false) { + try { + const bundle = buildReviewBundle(research, review, { by, decision, note, checkedSource }) + if (propose && onPropose) onPropose(bundle) + else if (onExport) onExport(bundle) + else downloadJson(`${evidenceTask.id}-review.json`, bundle) + setNotice(propose ? 'Proposal draft prepared. Review it before any public publish.' : 'Review bundle prepared for download. Not published and not accepted into the Atlas.') + } catch (e) { setNotice(e instanceof Error ? e.message : 'Could not prepare the review.') } + } + + return
+

A COMPLETE LOOP / TWO RETURNS, ONE QUESTION

Don’t just send an agent.
Bring the evidence back.

A small coordination experiment you can actually finish: one person or agent traces a source, another checks it, and you compare what came back. Nothing runs on our servers.

+
PROPOSED PILOT · NOT A STAFFED CAMPAIGN

{evidenceTask.title}

“{evidenceTask.claim}”

The opening
{evidenceTask.opening}
A useful return
{evidenceTask.usefulContribution}
This is an Open Lab exercise, not a request from FlyWire or an accepted Neuro Atlas contribution. Source excerpts and judgments below come only from what you import.
+
+

Run these yourself, or give one to someone else’s agent. Keep credentials in your own environment. Return the filled returnTemplate, not an entire agent transcript. Only public source material belongs here.

+
{(['research', 'review'] as const).map(role => { + const title = role === 'research' ? 'Research' : 'Review' + const result = role === 'research' ? research : review + return
{role === 'research' ? '01' : '02'}

{title} return

{result ? 'IMPORTED LOCALLY' : 'AWAITING YOUR RESULT'}
+