diff --git a/challenge-ip-redaction-gate/README.md b/challenge-ip-redaction-gate/README.md new file mode 100644 index 00000000..dbe6e0b9 --- /dev/null +++ b/challenge-ip-redaction-gate/README.md @@ -0,0 +1,42 @@ +# Challenge IP Redaction Gate + +Self-contained milestone for SCIBASE.AI issue #18, Scientific Bounty System. + +This module protects solver intellectual property during scientific bounty +evaluation. It gives sponsors and reviewers useful evidence packets while +withholding unpaid proprietary content until the challenge has funded and +settled the relevant award. + +## What It Covers + +- Sponsor preview packets that redact unpaid solver IP. +- Reviewer packets with NDA and role-aware disclosure controls. +- Private challenge and NDA readiness checks before sponsor access. +- Settlement-gated IP transfer after funded payout. +- Deterministic audit digests for arbitration-ready evidence. +- Synthetic sample data only; no network, credentials, or external services. + +## Files + +- `index.js` - disclosure policy engine. +- `demo.js` - terminal demo for sponsor/reviewer/settlement flows. +- `test.js` - dependency-free regression tests. +- `demo.mp4` - short demo artifact for the bounty review flow. + +## Run + +```sh +node challenge-ip-redaction-gate/test.js +node challenge-ip-redaction-gate/demo.js +``` + +## Requirement Map + +| Issue #18 Requirement | Implementation | +| --- | --- | +| Challenge posting portal with public/private support | `validateChallengeDisclosure()` blocks private or sensitive challenge previews unless NDA and IP policies are configured. | +| Secure submission engine | `buildDisclosurePacket()` turns submission artifacts into role-specific evidence packets without exposing unpaid protected content. | +| Anonymous or named participation | Submission `team.displayMode` and audit metadata keep the solver identity separate from artifact disclosure decisions. | +| Arbitration and reward distribution | Deterministic `auditDigest` values and `withheldArtifacts` create reviewer/arbitrator evidence without leaking raw IP. | +| IP management options | `settlement.status === "funded_settled"` is required before sponsor packets disclose protected content or mark `ipTransferReady`. | +| Feedback loop between submitters and sponsors | Packets include `requiredActions` explaining the exact gating reason and remediation path. | diff --git a/challenge-ip-redaction-gate/demo.js b/challenge-ip-redaction-gate/demo.js new file mode 100644 index 00000000..1d74bbd0 --- /dev/null +++ b/challenge-ip-redaction-gate/demo.js @@ -0,0 +1,76 @@ +const { buildDisclosurePacket } = require("./index") + +const challenge = { + id: "bio-marker-forecast-2026", + visibility: "private", + ndaRequired: true, + ipPolicy: "transfer_on_payout", + evaluationMode: "reviewer_only", + sensitiveDomains: ["single-cell-rna", "clinical"], +} + +const submission = { + id: "sub-lab-17", + team: { + id: "solver-team-aurora", + displayMode: "anonymous_until_shortlist", + }, + artifacts: [ + { + id: "whitepaper", + title: "Biomarker Method Summary", + kind: "document", + confidential: true, + ipProtected: false, + content: "Feature-selection method, validation cohort, and benchmark summary.", + safeSummary: "High-level biomarker validation summary is available to NDA reviewers.", + }, + { + id: "model-weights", + title: "Trained Model Weights", + kind: "model", + confidential: true, + ipProtected: true, + content: "solver-owned weight matrix and proprietary feature ranking formula", + safeSummary: "Model weights are committed by hash and withheld until payout settlement.", + }, + { + id: "clinical-table", + title: "Clinical Feature Table", + kind: "dataset", + confidential: true, + containsHumanSubjectsData: true, + ipProtected: false, + content: "synthetic patient-level feature rows", + safeSummary: "Human-subjects table is held pending ethics clearance.", + }, + ], +} + +const sponsorBeforePayout = buildDisclosurePacket({ + challenge, + submission, + actor: { role: "sponsor", id: "sponsor-pharma", ndaSigned: true }, + settlement: { status: "under_review", ethicsCleared: false }, +}) + +const reviewerPacket = buildDisclosurePacket({ + challenge, + submission, + actor: { role: "reviewer", id: "reviewer-2", ndaSigned: true, clearance: "full" }, + settlement: { status: "under_review", ethicsCleared: true }, +}) + +const sponsorAfterSettlement = buildDisclosurePacket({ + challenge, + submission, + actor: { role: "sponsor", id: "sponsor-pharma", ndaSigned: true }, + settlement: { status: "funded_settled", ethicsCleared: true }, +}) + +console.log("Sponsor before payout") +console.log(JSON.stringify(sponsorBeforePayout, null, 2)) +console.log("\nReviewer packet") +console.log(JSON.stringify(reviewerPacket, null, 2)) +console.log("\nSponsor after funded settlement") +console.log(JSON.stringify(sponsorAfterSettlement, null, 2)) diff --git a/challenge-ip-redaction-gate/demo.mp4 b/challenge-ip-redaction-gate/demo.mp4 new file mode 100644 index 00000000..ceeee2dc Binary files /dev/null and b/challenge-ip-redaction-gate/demo.mp4 differ diff --git a/challenge-ip-redaction-gate/demo.svg b/challenge-ip-redaction-gate/demo.svg new file mode 100644 index 00000000..68105e8a --- /dev/null +++ b/challenge-ip-redaction-gate/demo.svg @@ -0,0 +1,13 @@ + + Challenge IP Redaction Gate demo slide + Demo summary for sponsor redaction, reviewer packet, settlement unlock, and test validation. + + + SCIBASE Scientific Bounty System + Challenge IP Redaction Gate + Sponsor preview - protected solver IP redacted + Reviewer packet - NDA access with audit digest + Funded settlement - IP transfer unlocks + Validation - node test.js - 6 tests passed + Dependency-free, synthetic-data-only, deterministic audit evidence. + diff --git a/challenge-ip-redaction-gate/index.js b/challenge-ip-redaction-gate/index.js new file mode 100644 index 00000000..a317e574 --- /dev/null +++ b/challenge-ip-redaction-gate/index.js @@ -0,0 +1,180 @@ +const crypto = require("node:crypto") + +const REDACTION_NOTICE = + "[REDACTED: solver IP withheld until funded settlement or authorized reviewer access]" + +function stableJson(value) { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]` + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`) + .join(",")}}` + } + return JSON.stringify(value) +} + +function digest(value) { + return crypto.createHash("sha256").update(stableJson(value)).digest("hex") +} + +function validateChallengeDisclosure(challenge) { + const requiredActions = [] + + if (!challenge.id) requiredActions.push("Add a stable challenge id.") + if (!challenge.ipPolicy) requiredActions.push("Define an IP policy.") + if (!challenge.evaluationMode) requiredActions.push("Define an evaluation mode.") + + if (challenge.visibility === "private" && !challenge.ndaRequired) { + requiredActions.push("Private challenges must require an NDA.") + } + + if ( + challenge.sensitiveDomains?.length > 0 && + challenge.evaluationMode === "open_preview" && + !challenge.ndaRequired + ) { + requiredActions.push( + "Sensitive-domain challenges need NDA-gated previews or reviewer-only packets.", + ) + } + + return { + ready: requiredActions.length === 0, + requiredActions, + } +} + +function actorCanViewProtectedIp(actor, challenge, settlement) { + if (actor.role === "sponsor") return settlement?.status === "funded_settled" + if (actor.role === "reviewer") return Boolean(actor.ndaSigned && actor.clearance === "full") + if (actor.role === "arbitrator") return Boolean(actor.ndaSigned) + return false +} + +function actorCanViewConfidential(actor, challenge) { + if (actor.role === "sponsor") return !challenge.ndaRequired || actor.ndaSigned + if (actor.role === "reviewer" || actor.role === "arbitrator") return actor.ndaSigned + return false +} + +function classifyArtifact(artifact, challenge, actor, settlement) { + if (artifact.ipProtected && !actorCanViewProtectedIp(actor, challenge, settlement)) { + return { + classification: "protected_ip_redacted", + discloseRaw: false, + reason: "Protected solver IP is withheld until funded settlement or authorized reviewer access.", + } + } + + if (artifact.confidential && !actorCanViewConfidential(actor, challenge)) { + return { + classification: "confidential_blocked", + discloseRaw: false, + reason: "Confidential artifact requires NDA-gated access.", + } + } + + if (artifact.containsHumanSubjectsData && actor.role === "sponsor" && !settlement?.ethicsCleared) { + return { + classification: "ethics_redacted", + discloseRaw: false, + reason: "Human-subjects data requires ethics clearance before sponsor disclosure.", + } + } + + return { + classification: "disclosed", + discloseRaw: true, + reason: "Actor is authorized for this artifact.", + } +} + +function redactArtifact(artifact, challenge, actor, settlement) { + const decision = classifyArtifact(artifact, challenge, actor, settlement) + const contentHash = digest({ + id: artifact.id, + title: artifact.title, + content: artifact.content, + }) + + if (decision.discloseRaw) { + return { + id: artifact.id, + title: artifact.title, + kind: artifact.kind, + classification: decision.classification, + content: artifact.content, + contentHash, + redacted: false, + } + } + + return { + id: artifact.id, + title: artifact.title, + kind: artifact.kind, + classification: decision.classification, + content: artifact.safeSummary || REDACTION_NOTICE, + contentHash, + redacted: true, + redactionReason: decision.reason, + } +} + +function buildDisclosurePacket({ challenge, submission, actor, settlement = {} }) { + const challengeReadiness = validateChallengeDisclosure(challenge) + const artifacts = submission.artifacts.map((artifact) => + redactArtifact(artifact, challenge, actor, settlement), + ) + const withheldArtifacts = artifacts.filter((artifact) => artifact.redacted) + const requiredActions = [...challengeReadiness.requiredActions] + + for (const artifact of withheldArtifacts) { + requiredActions.push(`${artifact.id}: ${artifact.redactionReason}`) + } + + const ipTransferReady = + actor.role === "sponsor" && + settlement.status === "funded_settled" && + challenge.ipPolicy === "transfer_on_payout" && + withheldArtifacts.length === 0 + + const packet = { + challengeId: challenge.id, + submissionId: submission.id, + actor: { + role: actor.role, + id: actor.id, + }, + team: { + id: submission.team.id, + displayMode: submission.team.displayMode, + }, + disclosureStatus: requiredActions.length === 0 ? "ready" : "gated", + ipTransferReady, + artifacts, + withheldArtifacts: withheldArtifacts.map((artifact) => ({ + id: artifact.id, + title: artifact.title, + classification: artifact.classification, + contentHash: artifact.contentHash, + reason: artifact.redactionReason, + })), + requiredActions, + } + + return { + ...packet, + auditDigest: digest(packet), + } +} + +module.exports = { + REDACTION_NOTICE, + buildDisclosurePacket, + classifyArtifact, + digest, + stableJson, + validateChallengeDisclosure, +} diff --git a/challenge-ip-redaction-gate/test.js b/challenge-ip-redaction-gate/test.js new file mode 100644 index 00000000..6bd8313f --- /dev/null +++ b/challenge-ip-redaction-gate/test.js @@ -0,0 +1,145 @@ +const assert = require("node:assert/strict") +const { + REDACTION_NOTICE, + buildDisclosurePacket, + digest, + validateChallengeDisclosure, +} = require("./index") + +function sampleChallenge(overrides = {}) { + return { + id: "quantum-noise-2026", + visibility: "private", + ndaRequired: true, + ipPolicy: "transfer_on_payout", + evaluationMode: "reviewer_only", + sensitiveDomains: ["quantum", "materials"], + ...overrides, + } +} + +function sampleSubmission() { + return { + id: "submission-42", + team: { + id: "solver-team", + displayMode: "anonymous_until_shortlist", + }, + artifacts: [ + { + id: "summary", + title: "Executive Summary", + kind: "document", + confidential: true, + ipProtected: false, + content: "A safe summary of the experiment design.", + }, + { + id: "solver-formula", + title: "Noise Reduction Formula", + kind: "notebook", + confidential: true, + ipProtected: true, + content: "proprietary coefficients and solver-owned algorithm", + safeSummary: "Formula committed by hash; raw notebook withheld until settlement.", + }, + ], + } +} + +function testSponsorPreviewRedactsUnpaidIp() { + const packet = buildDisclosurePacket({ + challenge: sampleChallenge(), + submission: sampleSubmission(), + actor: { role: "sponsor", id: "sponsor", ndaSigned: true }, + settlement: { status: "under_review" }, + }) + + const formula = packet.artifacts.find((artifact) => artifact.id === "solver-formula") + assert.equal(packet.disclosureStatus, "gated") + assert.equal(formula.redacted, true) + assert.equal(formula.content.includes("proprietary coefficients"), false) + assert.equal(packet.withheldArtifacts.length, 1) + assert.match(packet.requiredActions.join("\n"), /Protected solver IP/) +} + +function testReviewerWithClearanceCanInspectIpWithoutTransfer() { + const packet = buildDisclosurePacket({ + challenge: sampleChallenge(), + submission: sampleSubmission(), + actor: { role: "reviewer", id: "reviewer", ndaSigned: true, clearance: "full" }, + settlement: { status: "under_review" }, + }) + + const formula = packet.artifacts.find((artifact) => artifact.id === "solver-formula") + assert.equal(packet.disclosureStatus, "ready") + assert.equal(formula.redacted, false) + assert.equal(packet.ipTransferReady, false) +} + +function testSponsorSettlementUnlocksIpTransfer() { + const packet = buildDisclosurePacket({ + challenge: sampleChallenge(), + submission: sampleSubmission(), + actor: { role: "sponsor", id: "sponsor", ndaSigned: true }, + settlement: { status: "funded_settled" }, + }) + + const formula = packet.artifacts.find((artifact) => artifact.id === "solver-formula") + assert.equal(packet.disclosureStatus, "ready") + assert.equal(formula.redacted, false) + assert.equal(packet.ipTransferReady, true) +} + +function testPrivateChallengeRequiresNda() { + const readiness = validateChallengeDisclosure( + sampleChallenge({ visibility: "private", ndaRequired: false }), + ) + + assert.equal(readiness.ready, false) + assert.match(readiness.requiredActions.join("\n"), /Private challenges must require an NDA/) +} + +function testAuditDigestIsDeterministic() { + const input = { + challenge: sampleChallenge(), + submission: sampleSubmission(), + actor: { role: "sponsor", id: "sponsor", ndaSigned: true }, + settlement: { status: "under_review" }, + } + + const first = buildDisclosurePacket(input) + const second = buildDisclosurePacket(input) + assert.equal(first.auditDigest, second.auditDigest) + assert.equal(digest({ b: 2, a: 1 }), digest({ a: 1, b: 2 })) +} + +function testMissingNdaBlocksConfidentialArtifact() { + const packet = buildDisclosurePacket({ + challenge: sampleChallenge(), + submission: sampleSubmission(), + actor: { role: "sponsor", id: "sponsor", ndaSigned: false }, + settlement: { status: "funded_settled" }, + }) + + const summary = packet.artifacts.find((artifact) => artifact.id === "summary") + assert.equal(summary.redacted, true) + assert.equal(summary.content, REDACTION_NOTICE) + assert.match(packet.requiredActions.join("\n"), /Confidential artifact requires NDA/) +} + +const tests = [ + testSponsorPreviewRedactsUnpaidIp, + testReviewerWithClearanceCanInspectIpWithoutTransfer, + testSponsorSettlementUnlocksIpTransfer, + testPrivateChallengeRequiresNda, + testAuditDigestIsDeterministic, + testMissingNdaBlocksConfidentialArtifact, +] + +for (const test of tests) { + test() + console.log(`ok - ${test.name}`) +} + +console.log(`${tests.length} tests passed`)