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.
8.3 KiB
Integration guide
How to put this engine into an application that is not a video app.
Everything application-specific lives in two places you write: an adapter (what is my object, in governance terms) and a policy (what does the evidence mean to me). Nothing else needs to change, and nothing in the core needs to know your application exists.
1. Decide what your objects are
Three target types cover the model:
| Target | Use for |
|---|---|
user |
Accounts: authors, sellers, reviewers, reporters, curators |
event |
One exact, immutable event that should stay denied on its own |
address |
A replaceable coordinate: a product across revisions, a storefront, an article |
The distinction between event and address matters more than it looks.
Denying an address survives the author republishing under a new event id.
Denying an event does not — which is exactly what you want when only one
revision was bad. Most objects want both.
2. Write an adapter
import { createApplicationAdapter } from "@nostr-governance/core";
export const productAdapter = createApplicationAdapter({
applicationId: "my-shop",
// Every target whose status should reach this object.
toTargets: (product) => [
{ type: "user", pubkey: product.sellerPubkey },
{ type: "event", id: product.id, author: product.sellerPubkey, kind: 30078 },
{ type: "address", kind: "30078", pubkey: product.sellerPubkey, identifier: product.sku },
],
// The object's own identity, used to key its decision.
getPrimaryTargetKey: (product) => `address:30078:${product.sellerPubkey}:${product.sku}`,
// Optional: map the decision back into your shape.
applyDecision: (product, decision) => ({
...product,
hidden: decision.visibility.effect === "hide",
rankPenalty: decision.ranking.weight,
checkoutAllowed: (decision.transaction?.effect ?? "allow") === "allow",
}),
});
Composition across an object's targets is ladder-maximum per dimension: the strictest verdict reaching the object wins. That is what makes "deny the seller" take their whole catalogue down without you writing any cascade logic.
Be deliberate about what you don't include. A review should answer to its reviewer and its own event — not to the product it reviews, or a bad product would silence honest reviews of it.
3. Write a policy
A policy is a set of named profiles, one per surface. Profiles read the same evidence and are free to reach different conclusions.
import { createPolicyDefinition } from "@nostr-governance/core";
export const policy = createPolicyDefinition({
id: "my-shop",
version: "1.0.0",
defaultProfile: "browse",
profiles: {
browse: {
name: "browse",
administrativeDeny: { visibility: "hide", interaction: "deny", transaction: "deny" },
requireAllowlist: true,
allowlistMiss: { visibility: "hide", interaction: "deny" },
allowViewerOverride: false,
reports: {
malware: { hide: 1, transactionDeny: 1 },
scam: { downrank: 1, restrict: 3, transactionDeny: 3 },
default: { downrank: 2, warn: 4 },
},
mutes: { default: { downrank: 1 } },
muteWindowSeconds: 60 * 24 * 60 * 60,
},
checkout: {
name: "checkout",
administrativeDeny: { visibility: "allow", transaction: "deny" },
allowViewerOverride: false,
reports: { malware: { transactionDeny: 1 }, scam: { transactionDeny: 3 } },
mutes: {},
},
},
});
Profile options
| Option | Effect |
|---|---|
administrativeDeny |
Effects applied when the target is administratively denied |
viewerBlock |
Effects applied when the viewer personally blocks |
requireAllowlist / allowlistMiss |
Enforce an allowlist on this surface |
allowViewerOverride |
Whether a viewer may soften a decision |
exposeEvidence |
Whether contributor pubkeys reach the consumer |
bypassHide / bypassHideCeiling |
Cap a hide on discovery surfaces |
disabled |
Turn governance off for this surface, with a reason |
reports / mutes |
Category thresholds |
muteWindowSeconds |
Ignore mutes older than this |
Threshold gates
A gate fires when count >= threshold. A threshold of 0 means disabled,
not "always on" — so you can zero a gate out without it triggering on every
target.
downrank → ranking warn, restrict, hide, deny → visibility
requireExplicitAction, interactionDeny → interaction
transactionReview, transactionDeny → transaction
4. Supply a transport
The runtime depends on no relay pool. Implement three methods:
const transport = {
async list(filters, options) { /* → NostrEvent[] */ },
subscribe(filters, handlers, options) { /* → { close() } */ },
async publish(event, options) {
return { ok: true, accepted: ["wss://..."], failed: [] };
},
};
publish should report partial acceptance honestly: ok: true with a
non-empty failed list is a normal, successful outcome. The first acceptance
means the event exists on the network; the remaining results are diagnostics.
A signer (getPublicKey, signEvent) and storage (read, write, remove)
follow the same injection pattern. In-memory versions ship for testing:
import { createMemoryTransport, createMemoryStorage } from "@nostr-governance/runtime";
5. Wire the pipeline
import { collectTargets, evaluateObject } from "@nostr-governance/core";
runtime.setViewer(viewerPubkey); // do this FIRST — it clears viewer state
runtime.trust.setContacts(followList); // then seed trust
await runtime.loadAdministrativeState();
runtime.setActiveTargets(collectTargets(products, productAdapter));
runtime.subscribeToActiveTargetReports();
const governed = products.map((product) =>
productAdapter.applyDecision(
product,
evaluateObject(product, productAdapter, (target) =>
runtime.evaluate(target, { profile: "browse" }),
),
),
);
Order matters in one place: setViewer clears viewer-scoped state by design, so
seed contacts, blocks, and overrides after it, never before.
6. Define your roles
Roles are application-defined bundles over a fixed capability vocabulary:
import { createRoleDefinition } from "@nostr-governance/core";
const roles = {
listing_moderator: createRoleDefinition("listing_moderator", [
"contribute-event-deny",
"contribute-address-deny",
"review-evidence",
]),
seller_moderator: createRoleDefinition("seller_moderator", [
"contribute-user-deny",
"review-evidence",
]),
};
If you pass a custom roles map to setRoles, it replaces the defaults —
include super_admin yourself if you still want it. The root administrator
always holds every capability regardless, so a misconfigured roster cannot lock
you out of your own instance.
Every command checks capability locally before the signer is invoked. An unauthorized actor cannot produce a signed mutation at all, rather than publishing one that quietly fails to take effect.
Common mistakes
Recomputing policy in your view layer. If your renderer is comparing report
counts to thresholds, the extraction has failed. Consume decision.
Treating downrank as a filter. Downranking is a sort input. A downranked
item still appears and can still be acted on; conflating the two is what makes
moderation feel like censorship to the people affected.
Skipping the checkout re-check. Evaluate transaction-critical actions against their own profile at the moment of action. State moves between page render and button press.
Exposing evidence everywhere. exposeEvidence is off by default for a
reason: public surfaces get counts, appeal surfaces get identities.
Assuming an allowlist implies trust. Being allowed to publish grants no trust, no moderator authority, and no immunity from personal blocks. The two systems are deliberately separate.
Testing your integration
The conformance harness takes JSON fixtures and asserts decisions:
import { runConformanceCase } from "@nostr-governance/testing/conformance";
const { passed, mismatches } = runConformanceCase(fixture, policy);
Expectations are partial — assert the dimensions your case is about and stay
silent about the rest. See fixtures/bitvid/cases/ for the shape and
examples/commerce/test/proof-of-fit.test.js for a full application suite.