feat: adapter contract, commerce proof-of-fit, and reusability docs

Proves the engine did not stay video-specific during extraction.

- application adapter contract in core (createApplicationAdapter,
  evaluateObject, collectTargets): an object names the targets that govern it
  and the strictest verdict reaching any of them wins, so denying a seller
  takes their whole catalogue down with no cascade logic in the application
- examples/commerce: a marketplace on the public API covering all ten
  proof-of-fit behaviors the plan requires, including that no core change was
  needed. One product, one snapshot, four surfaces, four different verdicts.
- README, integration guide, per-example README, CHANGELOG, four ADRs, and
  GPL-3.0-or-later LICENSE (the license was listed in Phase 1 but missing)
- checkpoints rewritten to reflect actual state and to say plainly which
  migration-plan phases belong to the consumer repos rather than here

Fixes a real gap found by the proof-of-fit: composeDecisions kept only the
first decision's evidence, so an object governed by several targets lost the
evidence that drove its verdict. Evidence now merges — counts summed,
contributor lists unioned, flags or-ed.

556 tests; lint, typecheck, and per-package type build clean.
This commit is contained in:
thePR0M3TH3AN committed 2026-08-09 17:57:09 -04:00
1 parent 4d460c12fe
commit 2beb7a7bb5
24 files changed
+2732 -48

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
// Commerce application adapters.
//
// Each adapter answers two questions: which governance targets stand behind
// this object, and what does a decision mean for it. Note what is absent —
// no thresholds, no precedence rules, no policy logic. Those live in the
// policy definition and the engine respectively.
import { createApplicationAdapter } from "@nostr-governance/core";
/** Parameterized-replaceable kind used for commerce documents in this example. */
export const PRODUCT_KIND = 30078;
/**
* @typedef {Object} Product
* @property {string} id - Event id of the current revision
* @property {string} sellerPubkey
* @property {string} identifier - d-tag, stable across revisions
* @property {string} title
* @property {number} priceSats
*/
/**
* @typedef {Object} Storefront
* @property {string} id
* @property {string} sellerPubkey
* @property {string} identifier
* @property {string} name
*/
/**
* @typedef {Object} Review
* @property {string} id
* @property {string} reviewerPubkey
* @property {string} productIdentifier
* @property {number} rating
*/
/**
* Products answer to three targets.
*
* The address is what persists across revisions: denying it survives the seller
* republishing the listing under a new event id, which denying the exact event
* would not. Both are kept, because an exact-event denial must also stay
* possible for the case where only one revision was bad.
*
* @type {import('@nostr-governance/core').GovernanceApplicationAdapter<Product>}
*/
export const productAdapter = createApplicationAdapter({
applicationId: "commerce-example",
toTargets(product) {
return [
{ type: "user", pubkey: product.sellerPubkey },
{ type: "event", id: product.id, author: product.sellerPubkey, kind: PRODUCT_KIND },
{
type: "address",
kind: String(PRODUCT_KIND),
pubkey: product.sellerPubkey,
identifier: product.identifier,
},
];
},
getPrimaryTargetKey(product) {
return `address:${PRODUCT_KIND}:${product.sellerPubkey}:${product.identifier}`;
},
applyDecision(product, decision) {
return {
...product,
moderation: {
visible: decision.visibility.effect !== "hide" && decision.visibility.effect !== "deny",
warn: decision.visibility.effect === "warn",
blurred: decision.visibility.effect === "restrict",
downranked: decision.ranking.effect === "downrank",
rankPenalty: decision.ranking.weight,
checkoutAllowed: (decision.transaction?.effect ?? "allow") === "allow",
checkoutNeedsReview: decision.transaction?.effect === "require-review",
reasons: decision.reasons.map((reason) => reason.id),
},
};
},
});
/**
* Storefronts answer to the seller, the storefront address, and the exact event.
* @type {import('@nostr-governance/core').GovernanceApplicationAdapter<Storefront>}
*/
export const storefrontAdapter = createApplicationAdapter({
applicationId: "commerce-example",
toTargets(storefront) {
return [
{ type: "user", pubkey: storefront.sellerPubkey },
{ type: "event", id: storefront.id, author: storefront.sellerPubkey, kind: PRODUCT_KIND },
{
type: "address",
kind: String(PRODUCT_KIND),
pubkey: storefront.sellerPubkey,
identifier: storefront.identifier,
},
];
},
getPrimaryTargetKey(storefront) {
return `address:${PRODUCT_KIND}:${storefront.sellerPubkey}:${storefront.identifier}`;
},
});
/**
* Reviews answer to the reviewer and the exact review event.
*
* Deliberately not to the reviewed product: a bad product should not silence
* honest reviews of it, which is exactly the outcome an over-broad target list
* would produce.
*
* @type {import('@nostr-governance/core').GovernanceApplicationAdapter<Review>}
*/
export const reviewAdapter = createApplicationAdapter({
applicationId: "commerce-example",
toTargets(review) {
return [
{ type: "user", pubkey: review.reviewerPubkey },
{ type: "event", id: review.id, author: review.reviewerPubkey, kind: PRODUCT_KIND },
];
},
getPrimaryTargetKey(review) {
return `event:${review.id}`;
},
applyDecision(review, decision) {
return {
...review,
suppressed: decision.visibility.effect === "hide" || decision.visibility.effect === "deny",
downranked: decision.ranking.effect === "downrank",
};
},
});
/**
* Sellers answer only to their own user target.
* @type {import('@nostr-governance/core').GovernanceApplicationAdapter<{ pubkey: string }>}
*/
export const sellerAdapter = createApplicationAdapter({
applicationId: "commerce-example",
toTargets(seller) {
return [{ type: "user", pubkey: seller.pubkey }];
},
getPrimaryTargetKey(seller) {
return `user:${seller.pubkey}`;
},
});
+107
View File
@@ -0,0 +1,107 @@
// Marketplace pipeline.
//
// The application's job is reduced to: collect targets, ask the engine, apply
// the answer. It computes no thresholds and reimplements no precedence — which
// is the property the extraction is meant to guarantee.
import { collectTargets, evaluateObject } from "@nostr-governance/core";
import { productAdapter } from "./adapters.js";
/**
* @typedef {import('./adapters.js').Product} Product
*/
/**
* Govern a product feed for a surface.
*
* Targets are collected first and handed to the runtime in one call, so a
* hundred-item page opens a bounded number of subscriptions rather than one per
* listing.
*
* @param {Product[]} products
* @param {import('@nostr-governance/runtime').GovernanceRuntime} runtime
* @param {string} profile
* @returns {Array<Product & { moderation: any }>}
*/
export function governProductFeed(products, runtime, profile) {
runtime.setActiveTargets(collectTargets(products, productAdapter));
return products.map((product) => {
const decision = evaluateObject(product, productAdapter, (target) =>
runtime.evaluate(target, { profile }),
);
return /** @type {any} */ (productAdapter.applyDecision?.(product, decision) ?? product);
});
}
/**
* Render a marketplace grid: drop hidden listings, order downranked ones last.
*
* Downranking is a sort input, not a filter. A downranked listing still appears
* and can still be bought — conflating the two is what makes moderation feel
* like censorship to sellers.
*
* @param {Product[]} products
* @param {import('@nostr-governance/runtime').GovernanceRuntime} runtime
* @returns {Array<Product & { moderation: any }>}
*/
export function buildMarketplaceGrid(products, runtime) {
return governProductFeed(products, runtime, "public-marketplace")
.filter((product) => product.moderation.visible)
.sort((a, b) => a.moderation.rankPenalty - b.moderation.rankPenalty);
}
/**
* @typedef {Object} CheckoutVerdict
* @property {boolean} allowed
* @property {boolean} needsReview
* @property {string[]} reasons
*/
/**
* Check a product at checkout.
*
* Evaluated freshly against the checkout profile rather than reusing the
* listing page's decision: state may have changed since the page rendered, and
* checkout is the point where being wrong actually costs someone money.
*
* @param {Product} product
* @param {import('@nostr-governance/runtime').GovernanceRuntime} runtime
* @returns {CheckoutVerdict}
*/
export function checkoutVerdict(product, runtime) {
const decision = evaluateObject(product, productAdapter, (target) =>
runtime.evaluate(target, { profile: "checkout" }),
);
const effect = decision.transaction?.effect ?? "allow";
return {
allowed: effect === "allow",
needsReview: effect === "require-review",
reasons: decision.reasons.map((reason) => reason.id),
};
}
/**
* Explain a product's status to its seller.
*
* The seller-dashboard profile exposes evidence, so this is the one surface
* that can answer "how many people reported me, and for what".
*
* @param {Product} product
* @param {import('@nostr-governance/runtime').GovernanceRuntime} runtime
* @returns {{ visible: boolean, sellable: boolean, reasons: string[], evidence: any }}
*/
export function explainToSeller(product, runtime) {
const decision = evaluateObject(product, productAdapter, (target) =>
runtime.evaluate(target, { profile: "seller-dashboard" }),
);
return {
visible: decision.visibility.effect !== "hide" && decision.visibility.effect !== "deny",
sellable: (decision.transaction?.effect ?? "allow") === "allow",
reasons: decision.reasons.map((reason) => reason.id),
evidence: decision.evidence,
};
}
+144
View File
@@ -0,0 +1,144 @@
// Commerce policy profiles.
//
// This example exists to prove the engine is not video-specific. Nothing here
// touches @nostr-governance/core internals: it is built entirely from the
// public API, and the core carries none of these numbers.
//
// The interesting property is that one product can simultaneously be visible to
// its seller, downranked in discovery, inspectable on its detail page, and
// blocked from checkout — four different answers from one decision, which a
// single `hidden` boolean could not express.
import { createPolicyDefinition, createRoleDefinition } from "@nostr-governance/core";
/**
* Category thresholds for a marketplace.
*
* Malware is treated far more harshly than misleading copy: one trusted report
* blocks checkout, because the cost of a false negative is a compromised buyer,
* while an over-eager `misleading` threshold would just suppress lawful listings.
*
* @type {Record<string, import('@nostr-governance/core').CategoryThresholds>}
*/
export const MARKETPLACE_REPORT_THRESHOLDS = {
scam: {
downrank: 1,
warn: 2,
restrict: 3,
hide: 5,
transactionReview: 2,
transactionDeny: 3,
},
malware: {
warn: 1,
restrict: 1,
hide: 2,
transactionDeny: 1,
},
misleading: {
downrank: 2,
warn: 3,
restrict: 5,
hide: 8,
},
"not-as-described": {
downrank: 2,
warn: 4,
transactionReview: 5,
},
default: {
downrank: 2,
warn: 4,
},
};
/** Trusted mutes downrank a seller but never block a sale on their own. */
const MARKETPLACE_MUTE_THRESHOLDS = {
default: { downrank: 1 },
};
/**
* The marketplace policy.
*
* Every surface reads the same evidence and reaches a different conclusion,
* which is the whole point of profiles.
*/
export const COMMERCE_POLICY = createPolicyDefinition({
id: "commerce-example",
name: "Commerce example",
description: "Marketplace policy demonstrating the engine outside a video application.",
version: "1.0.0",
defaultProfile: "public-marketplace",
profiles: {
// Discovery: denied sellers disappear entirely and cannot transact.
"public-marketplace": {
name: "public-marketplace",
administrativeDeny: { visibility: "hide", interaction: "deny", transaction: "deny" },
viewerBlock: { visibility: "hide", interaction: "deny" },
allowViewerOverride: false,
requireAllowlist: true,
allowlistMiss: { visibility: "hide", interaction: "deny", transaction: "deny" },
reports: MARKETPLACE_REPORT_THRESHOLDS,
mutes: MARKETPLACE_MUTE_THRESHOLDS,
},
// Detail page: a shopper who followed a direct link may still inspect the
// listing behind a warning, but cannot buy it.
"product-detail": {
name: "product-detail",
administrativeDeny: { visibility: "restrict", interaction: "require-explicit-action", transaction: "deny" },
viewerBlock: { visibility: "hide", interaction: "deny" },
allowViewerOverride: true,
reports: MARKETPLACE_REPORT_THRESHOLDS,
mutes: MARKETPLACE_MUTE_THRESHOLDS,
},
// Checkout: visibility is irrelevant; only the transaction verdict matters,
// and it is re-checked here rather than trusted from the listing page.
checkout: {
name: "checkout",
administrativeDeny: { visibility: "allow", transaction: "deny" },
allowViewerOverride: false,
reports: MARKETPLACE_REPORT_THRESHOLDS,
mutes: {},
},
// Seller dashboard: the seller can always see their own listing and is told
// why it is restricted. An appeals screen that hides the evidence is useless.
"seller-dashboard": {
name: "seller-dashboard",
administrativeDeny: { visibility: "warn", interaction: "allow", transaction: "deny" },
allowViewerOverride: false,
exposeEvidence: true,
reports: MARKETPLACE_REPORT_THRESHOLDS,
mutes: MARKETPLACE_MUTE_THRESHOLDS,
},
},
});
/**
* Marketplace-specific roles, built from the generic capability vocabulary.
*
* These roles do not exist in the core; an application invents the roles it
* needs and the engine enforces them.
*/
export const COMMERCE_ROLES = {
listing_moderator: createRoleDefinition("listing_moderator", [
"contribute-event-deny",
"contribute-address-deny",
"review-evidence",
]),
seller_moderator: createRoleDefinition("seller_moderator", [
"contribute-user-deny",
"review-evidence",
]),
community_curator: createRoleDefinition("community_curator", ["contribute-user-deny"]),
};
/**
* Role capability map in the shape createAuthorityState expects.
* @type {Record<string, import('@nostr-governance/core').GovernanceCapability[]>}
*/
export const COMMERCE_ROLE_CAPABILITIES = Object.fromEntries(
Object.values(COMMERCE_ROLES).map((role) => [role.name, role.capabilities]),
);