mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-11-05 04:51:00 +00:00
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:
1 parent
4d460c12fe
commit
2beb7a7bb5
24 files changed
+2732
-48
No files matched your search
@@ -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());
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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()]));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user