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

+117
View File
@@ -0,0 +1,117 @@
// Application adapter contract.
//
// An adapter is how an application says "here is what my object is, in
// governance terms" and "here is what a decision means to me". Everything
// application-specific lives in the adapter; nothing application-specific lives
// in the engine. This is the seam that lets one engine serve a video app and a
// marketplace without either leaking into the other.
import { getTargetKey } from "./identifiers.js";
import { isValidTarget } from "./targets.js";
import { composeDecisions } from "./policy.js";
/**
* @typedef {import('./identifiers.js').GovernanceTarget} GovernanceTarget
* @typedef {import('./policy.js').GovernanceDecision} GovernanceDecision
*/
/**
* @template TObject
* @typedef {Object} GovernanceApplicationAdapter
* @property {string} applicationId
* @property {(object: TObject) => GovernanceTarget[]} toTargets - Every target that governs this object
* @property {(object: TObject) => string} getPrimaryTargetKey - The object's own identity
* @property {(object: TObject, decision: GovernanceDecision) => TObject} [applyDecision] - Map a decision back into application shape
*/
/**
* Define an adapter, validating its required members.
* @template TObject
* @param {GovernanceApplicationAdapter<TObject>} adapter
* @returns {GovernanceApplicationAdapter<TObject>}
*/
export function createApplicationAdapter(adapter) {
if (!adapter || typeof adapter !== "object") {
throw new Error("Adapter must be an object");
}
if (typeof adapter.applicationId !== "string" || !adapter.applicationId.trim()) {
throw new Error("Adapter requires an applicationId");
}
if (typeof adapter.toTargets !== "function") {
throw new Error("Adapter requires toTargets()");
}
if (typeof adapter.getPrimaryTargetKey !== "function") {
throw new Error("Adapter requires getPrimaryTargetKey()");
}
return adapter;
}
/**
* Evaluate every target an object maps to and compose one decision.
*
* Composition is the ladder maximum per dimension, so an object is governed by
* the strictest verdict reaching it: denying a seller takes their listings down
* even though each listing's own address is clean. Applications that want a
* different rule can compose the per-target decisions themselves.
*
* @template TObject
* @param {TObject} object
* @param {GovernanceApplicationAdapter<TObject>} adapter
* @param {(target: GovernanceTarget) => GovernanceDecision} evaluate
* @returns {GovernanceDecision}
*/
export function evaluateObject(object, adapter, evaluate) {
const targets = adapter.toTargets(object).filter((target) => isValidTarget(target));
if (targets.length === 0) {
throw new Error(`Adapter produced no valid targets for ${adapter.applicationId}`);
}
const decisions = targets.map((target) => evaluate(target));
const composed = composeDecisions(decisions);
// The composed decision is reported against the object's own identity, not
// against whichever contributing target happened to be most severe.
composed.key = adapter.getPrimaryTargetKey(object);
return composed;
}
/**
* Evaluate many objects, returning decisions keyed by primary target.
* @template TObject
* @param {TObject[]} objects
* @param {GovernanceApplicationAdapter<TObject>} adapter
* @param {(target: GovernanceTarget) => GovernanceDecision} evaluate
* @returns {Map<string, GovernanceDecision>}
*/
export function evaluateObjects(objects, adapter, evaluate) {
/** @type {Map<string, GovernanceDecision>} */
const results = new Map();
for (const object of objects ?? []) {
results.set(adapter.getPrimaryTargetKey(object), evaluateObject(object, adapter, evaluate));
}
return results;
}
/**
* Collect the deduplicated targets a batch of objects depends on.
*
* Used to tell the runtime which targets to keep evidence fresh for, without
* subscribing once per object.
*
* @template TObject
* @param {TObject[]} objects
* @param {GovernanceApplicationAdapter<TObject>} adapter
* @returns {GovernanceTarget[]}
*/
export function collectTargets(objects, adapter) {
/** @type {Map<string, GovernanceTarget>} */
const targets = new Map();
for (const object of objects ?? []) {
for (const target of adapter.toTargets(object)) {
if (isValidTarget(target)) {
targets.set(getTargetKey(target), target);
}
}
}
return Array.from(targets.values());
}
+61
View File
@@ -86,6 +86,67 @@ export function freezeEvidence(evidence) {
};
}
/**
* Merge evidence from several decisions about the same object.
*
* An object may answer to more than one target — a product to its seller, its
* address, and its exact event — and the evidence that explains the composed
* decision is the union of what each target carried. Counts are summed per
* category and contributor lists are unioned, so a seller-level report and an
* address-level report both show up in one explanation.
*
* @param {GovernanceEvidence[]} records
* @returns {GovernanceEvidence}
*/
export function mergeEvidence(records) {
const merged = createEmptyEvidence();
/** @type {Set<string>} */
const reporters = new Set();
/** @type {Set<string>} */
const muters = new Set();
for (const record of records ?? []) {
if (!record) {
continue;
}
for (const [category, count] of Object.entries(record.trustedReportsByCategory ?? {})) {
merged.trustedReportsByCategory[category] =
(merged.trustedReportsByCategory[category] ?? 0) + count;
}
for (const [category, count] of Object.entries(record.trustedMutesByCategory ?? {})) {
merged.trustedMutesByCategory[category] =
(merged.trustedMutesByCategory[category] ?? 0) + count;
}
merged.trustedReportTotal += record.trustedReportTotal ?? 0;
merged.trustedMuteTotal += record.trustedMuteTotal ?? 0;
for (const pubkey of record.trustedReporterPubkeys ?? []) {
reporters.add(pubkey);
}
for (const pubkey of record.trustedMuterPubkeys ?? []) {
muters.add(pubkey);
}
merged.personalBlock ||= record.personalBlock === true;
merged.personalMute ||= record.personalMute === true;
merged.userDenied ||= record.userDenied === true;
merged.eventDenied ||= record.eventDenied === true;
merged.addressDenied ||= record.addressDenied === true;
merged.userAllowed ||= record.userAllowed === true;
merged.protectedTarget ||= record.protectedTarget === true;
merged.thresholds = { ...merged.thresholds, ...record.thresholds };
}
merged.trustedReporterPubkeys = Array.from(reporters);
merged.trustedMuterPubkeys = Array.from(muters);
return freezeEvidence(merged);
}
/**
* Strip evidence down to counts, dropping the pubkey lists.
*
+9
View File
@@ -53,6 +53,7 @@ export {
export {
createEmptyEvidence,
freezeEvidence,
mergeEvidence,
redactEvidence,
} from "./evidence.js";
@@ -77,6 +78,13 @@ export {
composeDecisions,
} from "./policy.js";
export {
createApplicationAdapter,
evaluateObject,
evaluateObjects,
collectTargets,
} from "./adapter.js";
export {
canonicalStringify,
hashString,
@@ -115,4 +123,5 @@ export {
* @typedef {import('./evaluator.js').ViewerState} ViewerState
* @typedef {import('./evaluator.js').ReportRecord} ReportRecord
* @typedef {import('./evaluator.js').MuteRecord} MuteRecord
* @typedef {import('./adapter.js').GovernanceApplicationAdapter<any>} GovernanceApplicationAdapter
*/
+10
View File
@@ -9,6 +9,7 @@
// collapsing those states into a single "hidden" boolean.
import { createReason, dedupeReasons } from "./reasons.js";
import { mergeEvidence } from "./evidence.js";
/**
* @typedef {import('./reasons.js').GovernanceReason} GovernanceReason
@@ -548,6 +549,15 @@ export function composeDecisions(decisions) {
}
composed.reasons = dedupeReasons(composed.reasons);
// Evidence is unioned rather than taken from the first decision: an object
// governed by several targets must explain itself with everything that
// contributed, not just whichever target happened to be evaluated first.
const evidence = decisions.map((decision) => decision.evidence).filter(Boolean);
if (evidence.length) {
composed.evidence = mergeEvidence(/** @type {any} */ (evidence));
}
return composed;
}
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import {
collectTargets,
createApplicationAdapter,
evaluateObject,
evaluateObjects,
} from "../src/adapter.js";
import { createNeutralDecision } from "../src/policy.js";
const SELLER = "d4".repeat(32);
const EVENT_ID = "1b".repeat(32);
/** @type {import('../src/adapter.js').GovernanceApplicationAdapter<any>} */
const adapter = createApplicationAdapter({
applicationId: "test-app",
toTargets: (object) => [
{ type: "user", pubkey: object.author },
{ type: "event", id: object.id, author: object.author },
],
getPrimaryTargetKey: (object) => `event:${object.id}`,
});
const object = { id: EVENT_ID, author: SELLER };
/** Build an evaluator that returns a fixed visibility per target type. */
const evaluatorFor = (byType) => (target) => {
const decision = createNeutralDecision({ key: `${target.type}` });
const effect = byType[target.type];
if (effect) {
decision.visibility.effect = effect;
}
return decision;
};
describe("createApplicationAdapter", () => {
it("accepts a complete adapter", () => {
expect(adapter.applicationId).toBe("test-app");
});
it("rejects a missing applicationId", () => {
expect(() =>
createApplicationAdapter(/** @type {any} */ ({ toTargets: () => [], getPrimaryTargetKey: () => "" })),
).toThrow(/applicationId/);
});
it("rejects a missing toTargets", () => {
expect(() =>
createApplicationAdapter(/** @type {any} */ ({ applicationId: "a", getPrimaryTargetKey: () => "" })),
).toThrow(/toTargets/);
});
it("rejects a missing getPrimaryTargetKey", () => {
expect(() =>
createApplicationAdapter(/** @type {any} */ ({ applicationId: "a", toTargets: () => [] })),
).toThrow(/getPrimaryTargetKey/);
});
it("rejects a non-object", () => {
expect(() => createApplicationAdapter(/** @type {any} */ (null))).toThrow(/must be an object/);
});
});
describe("evaluateObject", () => {
it("keeps the strictest verdict reaching the object", () => {
const decision = evaluateObject(object, adapter, evaluatorFor({ user: "hide" }));
expect(decision.visibility.effect).toBe("hide");
});
it("is unaffected by which target was most severe when reporting identity", () => {
const decision = evaluateObject(object, adapter, evaluatorFor({ user: "hide" }));
expect(decision.key).toBe(`event:${EVENT_ID}`);
});
it("allows an object when no target objects", () => {
expect(evaluateObject(object, adapter, evaluatorFor({})).visibility.effect).toBe("allow");
});
it("escalates from any contributing target", () => {
expect(
evaluateObject(object, adapter, evaluatorFor({ event: "deny" })).visibility.effect,
).toBe("deny");
});
it("filters out invalid targets the adapter emits", () => {
const loose = createApplicationAdapter({
applicationId: "a",
toTargets: () => [
/** @type {any} */ ({ type: "user", pubkey: "junk" }),
{ type: "user", pubkey: SELLER },
],
getPrimaryTargetKey: () => "user:x",
});
expect(() => evaluateObject(object, loose, evaluatorFor({}))).not.toThrow();
});
it("throws when an adapter produces no usable targets", () => {
const broken = createApplicationAdapter({
applicationId: "broken",
toTargets: () => [],
getPrimaryTargetKey: () => "x",
});
expect(() => evaluateObject(object, broken, evaluatorFor({}))).toThrow(/no valid targets/);
});
});
describe("evaluateObjects", () => {
it("keys results by primary target", () => {
const results = evaluateObjects([object], adapter, evaluatorFor({}));
expect(results.has(`event:${EVENT_ID}`)).toBe(true);
});
it("handles an empty list", () => {
expect(evaluateObjects([], adapter, evaluatorFor({})).size).toBe(0);
});
});
describe("collectTargets", () => {
it("deduplicates shared targets across objects", () => {
const targets = collectTargets(
[object, { id: "2c".repeat(32), author: SELLER }],
adapter,
);
// Two events plus one shared author.
expect(targets).toHaveLength(3);
});
it("returns nothing for an empty list", () => {
expect(collectTargets([], adapter)).toEqual([]);
});
});
+37 -1
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
import { createEmptyEvidence, freezeEvidence, redactEvidence } from "../src/evidence.js";
import {
createEmptyEvidence,
freezeEvidence,
mergeEvidence,
redactEvidence,
} from "../src/evidence.js";
const populated = () => ({
...createEmptyEvidence(),
@@ -71,3 +76,34 @@ describe("redactEvidence", () => {
expect(redacted.trustedReportsByCategory).toEqual({ nudity: 1, spam: 2 });
});
});
describe("mergeEvidence", () => {
it("sums counts across targets", () => {
const merged = mergeEvidence([populated(), populated()]);
expect(merged.trustedReportTotal).toBe(6);
expect(merged.trustedReportsByCategory.spam).toBe(4);
});
it("unions contributor lists without duplicating", () => {
const merged = mergeEvidence([populated(), populated()]);
expect(merged.trustedReporterPubkeys).toEqual(["aa", "bb", "cc"]);
});
it("ors the boolean flags", () => {
const denied = { ...createEmptyEvidence(), userDenied: true };
const blocked = { ...createEmptyEvidence(), personalBlock: true };
const merged = mergeEvidence([denied, blocked]);
expect(merged.userDenied).toBe(true);
expect(merged.personalBlock).toBe(true);
expect(merged.eventDenied).toBe(false);
});
it("tolerates empty and malformed input", () => {
expect(mergeEvidence([]).trustedReportTotal).toBe(0);
expect(mergeEvidence(/** @type {any} */ ([null])).trustedReportTotal).toBe(0);
});
it("returns sorted, stable output", () => {
expect(mergeEvidence([populated()])).toEqual(mergeEvidence([populated()]));
});
});