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

+35
View File
@@ -0,0 +1,35 @@
# ADR 0001 — Four independent decision dimensions
**Status:** accepted
## Context
The reference application collapsed moderation into a small number of booleans
(`blurThumbnail`, `hideReason`, `blockAutoplay`), computed in two places that
overlapped. A commerce consumer needs a state those booleans cannot express: a
product that is visible, inspectable, downranked, and unbuyable at once.
## Decision
A decision carries four independent dimensions, each an ordered ladder:
- `ranking`: `normal` → `downrank` (with accumulated weight)
- `visibility`: `allow` → `warn` → `restrict` → `hide` → `deny`
- `interaction`: `allow` → `require-explicit-action` → `deny`
- `transaction`: `allow` → `require-review` → `deny` (present only when a
profile can affect it)
Composition takes the ladder maximum per dimension.
## Consequences
Composition is commutative and associative, so the order decisions arrive in
cannot change the result — which is what lets an object be evaluated against
several targets and merged without an ordering rule.
Consumers must map dimensions to their own UI rather than reading a boolean.
That is more work at the call site and is the point: the mapping is application
policy, and putting it in the engine is how the duplication started.
`transaction` is absent rather than `allow` when no profile gate can reach it,
so a consumer can distinguish "explicitly permitted" from "not governed here".
@@ -0,0 +1,34 @@
# ADR 0002 — Capabilities resolve at merge time
**Status:** accepted
## Context
The reference application treated moderator status as a UI permission: an
editor list gated which buttons appeared, but the resulting events carried no
cryptographic authority. Anyone could publish the same event.
Revocation is the hard part. If a contribution's validity is decided when it is
ingested, revoking a role later requires finding and undoing everything that
actor ever contributed.
## Decision
Contributions are stored as-published, keyed by (actor, kind). Effective state
is derived by `reduceAdminState`, which resolves each actor's capabilities
against the *current* authority state every time it runs.
## Consequences
Revocation is immediate and requires no rewriting: drop the role, recompute,
and that actor's entries are gone. A revoked moderator's stored contributions
become inert rather than needing deletion.
Reduction cost is proportional to total contributions rather than to the change.
For realistic list sizes this is far cheaper than maintaining incremental
invalidation, and the store only recomputes when something actually changed.
An actor's contributions can also come *back* if their role is restored, which
is the correct behavior for a mistaken revocation but must be understood when
reasoning about "permanent" removal — permanence lives in the contribution
being retracted, not in the role.
@@ -0,0 +1,34 @@
# ADR 0003 — The engine carries no thresholds
**Status:** accepted
## Context
The extraction started from one application's numbers: blur at 3 trusted
reports, hide at 20 trusted mutes, a 60-day mute window. Those were nearly
promoted into the core as defaults, which would have made every future consumer
inherit a video application's editorial judgement.
## Decision
The core defines the *vocabulary* — target types, capabilities, effect ladders,
reason identifiers, aggregation and precedence rules — and no thresholds.
Applications supply a `PolicyDefinition` of named profiles with category
thresholds. The characterized reference values live in
`@nostr-governance/bitvid-compat` as one application's profile.
`NEUTRAL_POLICY` is the fallback: it enforces administrative denial and applies
no trust thresholds, so the generic engine never hides content merely because
one trusted account acted against it.
## Consequences
A new consumer writes a policy instead of arguing with a default. The commerce
example needed no core change, which is the test this decision has to pass.
An application that supplies no policy gets very little enforcement. That is
deliberate: silently applying someone else's moderation thresholds is worse than
applying none, because the operator would not know it was happening.
Threshold `0` means "gate disabled" rather than "fires on everything", so
zeroing a value is a safe way to switch a gate off.
+26
View File
@@ -0,0 +1,26 @@
# ADR 0004 — The core is hex-only
**Status:** accepted
## Context
Administrative lists in the wild contain both hex pubkeys and `npub` strings,
and the reference implementation normalized them everywhere it touched them.
Doing the same in the core would mean bech32 decoding in a package that is
meant to have no dependencies and no encoding opinions.
## Decision
`@nostr-governance/core` accepts 64-character hex only. Decoding lives in
`@nostr-governance/nostr` (`normalizePubkeyInput`, `normalizeEventIdInput`),
and codecs normalize at ingest, so the core only ever sees hex.
## Consequences
The core stays dependency-free and runs unchanged in Node and the browser.
The trade-off is a real footgun: `normalizePubkey` returns `""` for a valid
`npub` rather than throwing. Callers that hand user input straight to the core
will silently get nothing. Anything accepting operator or user input must
normalize through the codec package first — this is called out in the
integration guide.
+91 -45
View File
@@ -1,63 +1,109 @@
# Checkpoints
Scope note: this repository is the SDK. Migration-plan Phases 6–11 (BitVid
shadow mode, cutovers, and duplicate removal) modify the consumer repositories
and are tracked there, not here.
## Checkpoint 0 — Repo skeleton (completed)
Delivered:
- npm workspaces monorepo
- Node 22 + ESM
- five empty packages under `packages/`
- Vitest harness
- TypeScript JSDoc typecheck
- GitHub Actions CI
- reference import guard
- provenance docs
Delivered: npm workspaces monorepo, Node 22 + ESM, Vitest, JSDoc typecheck,
GitHub Actions CI, reference import guard, provenance docs.
Validation:
```bash
npm install
npm run check:references
npm run typecheck
npm test
npm run build:types
Later additions: GPL-3.0-or-later `LICENSE`, `CHANGELOG.md`, architecture
decision records under `docs/adr/`, and a working per-package type build. The
`types` fields previously pointed at paths the build never produced, so
consumers received no declarations; declarations now emit into each package.
## Checkpoint 1 — Characterization corpus (completed)
Delivered:
- 34 fixtures under `fixtures/bitvid/cases/`, generated by
`scripts/build-characterization-corpus.mjs`
- conformance harness (`packages/testing/src/conformance.js`) that builds a
snapshot, evaluates it, and diffs against a partial expectation
- conformance suite (`tests/conformance/bitvid-fixtures.test.js`) asserting
every required behavior, determinism, and input immutability
Characterized values, read from the pinned reference commit and cross-checked
against the migration plan's baseline section:
```text
Trusted report blur threshold: 3
Trusted report autoplay threshold: 3
Trusted mute hide threshold: 20
Trusted spam-report hide threshold: 5
Trusted mute validity window: 60 days
```
Gate: do not add runtime or BitVid codecs until fixture corpus exists.
These live in `@nostr-governance/bitvid-compat` as one application's profile,
not as engine defaults.
## Checkpoint 1 — BitVid fixture and conformance corpus (completed)
Provenance: fixtures are authored from the reference source and its documented
configuration, not captured from a live relay. They pin the behavior the
extraction must reproduce; they are not a recording of production traffic.
Delivered:
- fixture folders for BitVid cases (admin-state, reports, trusted-mutes, overrides, expectations)
- import script harness (`scripts/import-bitvid-fixtures.mjs`)
- characterization cases list (`fixtures/bitvid/characterization-cases.json`)
- conformance test framework (`tests/conformance/bitvid-fixtures.test.js`)
- testing utilities (`packages/testing/src/conformance.js`)
## Checkpoint 2 — Pure core (completed)
Validation:
```bash
npm run check:references # still passes
npm test # now includes conformance tests
```
Delivered: identifiers, targets, authority, administrative-state reduction,
reasons, evidence, policy engine, fingerprints, evaluator, adapter contract.
Status: Ready for real fixture extraction from BitVid.
Acceptance:
- identical snapshots produce identical decisions (asserted per fixture)
- evaluation performs no I/O and reads an injected clock
- evaluation mutates no input (asserted per fixture)
- the core imports no browser globals and has no dependencies
- the core contains no application-specific terms or thresholds
Gate: Do not implement evaluator until at least 3 real characterization cases are imported with expectations.
## Checkpoint 3 — Nostr codecs (completed)
Documentation: See `docs/phase1-summary.md` for full details.
Delivered: bech32/NIP-19 (verified against the NIP-19 test vector), canonical v1
on `kind:30078`, NIP-56 reports, NIP-51 mute lists, legacy administrative lists
covering all five migration stages, replaceable selection with a deterministic
same-timestamp tiebreak, injected signature verification.
## Checkpoint 2 — Pure core implementation (in progress)
## Checkpoint 4 — Runtime stores (completed)
Delivered:
- identifiers module (`packages/core/src/identifiers.js`)
- targets module (`packages/core/src/targets.js`)
- authority module (`packages/core/src/authority.js`)
- policy module (`packages/core/src/policy.js`)
- target creation, validation, and hierarchy utilities
- role and capability management
- policy definitions, decision making, and effect composition
- comprehensive test suites for all modules
Delivered: `GovernanceAdminStore`, `TrustGraphStore`, `ReportStore`,
`TrustedMuteStore`, `PolicyStore`, `OverrideStore`, `GovernanceRuntime`.
Gate: Complete core implementation before moving to Nostr codecs.
Acceptance: injected transport, storage, and clock; change-only emission; viewer
switching clears viewer-scoped state while keeping operator seeds; subscriptions
close on destroy; report subscriptions are chunked (200 per filter) rather than
opened per target; author evidence is queryable in batches; expired mute lists
prune against the injected clock.
## Next checkpoint
## Checkpoint 5 — Public commands (completed)
Checkpoint 3 — Nostr codecs implementation (event parsing, legacy support)
Delivered: contribution, role, policy, community-source, report, and override
commands with stable error codes.
Acceptance: every command checks capability locally before the signer is
invoked, so an unauthorized actor cannot produce a signed mutation; revoked
actors' contributions stop affecting effective state; protected actors are
refused at the command boundary; first relay acceptance completes the command
while remaining relay results stay observable.
## Checkpoint 6 — Reusability proof (completed)
Delivered: `examples/commerce`, a marketplace built entirely on the public API,
covering the ten proof-of-fit behaviors the migration plan requires — including
that no core change was needed. Plus `README.md`, `docs/integration-guide.md`,
and `examples/commerce/README.md`.
## Status
CI gates green: reference guard, typecheck, 556 tests, per-package type build.
## Not done here
Migration-plan Phases 6–11 operate on BitVid and BitRoad:
- Phase 6 — BitVid compatibility facade and shadow mode
- Phase 7 — cut over BitVid administrative state
- Phase 8 — cut over BitVid reports and trust
- Phase 9 — cut over BitVid policy
- Phase 10 — validate through BitRoad itself
- Phase 11 — remove duplicated BitVid logic
The commerce example stands in for Phase 10's API-shape validation without
touching the BitRoad repository.
+237
View File
@@ -0,0 +1,237 @@
# 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
```js
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.
```js
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:
```js
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:
```js
import { createMemoryTransport, createMemoryStorage } from "@nostr-governance/runtime";
```
## 5. Wire the pipeline
```js
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:
```js
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:
```js
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.