25 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 4.8 251a78133a docs: record design-review note (position as a general policy engine)
Outside review: BitGate is a policy-evaluation engine (actor/object/context/
evidence -> permitted?), broader than moderation. Record it as a positioning/
docs task, explicitly guarded against becoming a scope-creep invitation --
the engine is already general.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB
2026-08-11 23:29:23 -04:00
thePR0M3TH3ANandClaude Opus 4.8 cecd70c55f nostr: resolveAuthorityRoster — moderator set as signed data
"Who is a moderator" should change without a redeploy, but apps had to
hand-assemble the createAuthorityState actors map. Add resolveAuthorityRoster
(@bitgate/nostr): turn signed kind-30000 "role set" events into the
actor->roles map, ready to spread into createAuthorityState.

It honors ONLY sets signed by an authorized key -- the root, or (with
`signer: <role>`) an actor already granted that role, giving one-role-
delegates-another chains in a single ordered pass. It keeps the latest set
per publisher (kind 30000 is replaceable) and ignores anyone unauthorized,
which is the security property: the roster is only as trustworthy as the key
that signed it. Signature verification stays the caller's job (inject
@bitgate/verify upstream).

Covered by roster.test.js: root-only grants, unauthorized-ignored,
delegation, replaceable-latest-wins, and that the output feeds
createAuthorityState. README headless section shows the pattern.

Addresses the "moderator-set as data" item of the Tessera feedback
(docs/TODO.md) -- previously marked PARTIAL.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB
2026-08-11 22:59:50 -04:00
thePR0M3TH3ANandClaude Opus 4.8 1abba23df3 docs: worked example of the ranking dimension as a feed multiplier
The `ranking` dimension is the soft one — "still show this, but lower" — and
integrators reinvented turning it into a feed score. Show it instead.

Add examples/feed-ranking.mjs (runnable): it reads decision.ranking
({ effect: "normal" | "downrank", weight }) and turns it into a multiplier
on the app's own feed score (base ** weight for a downrank). Two authors
that led on base score get downranked by trusted spam reports and drop below
a clean lower-base author -- without being hidden, since visibility is a
separate dimension (capped at "restrict" on a feed). The multiplier lives in
the example, not the engine: core ships no such numbers on purpose.

Pinned by tests/feed-ranking.test.js (downrank never hides on a feed; a
high-base downranked author reorders below a clean one; a clean author is
untouched at x1). README headless section points to it.

Addresses the "BitGate feeding a feed ranking" item of the Tessera feedback
(docs/TODO.md) -- the last open BitGate item.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB
2026-08-11 20:43:35 -04:00
thePR0M3TH3ANandClaude Opus 4.8 088e3cdabe standalone: ship the headless engine as one bundleable ESM file
A no-build static site couldn't vendor BitGate the way it vendors a single
dependency: bare @bitgate/* specifiers don't resolve in a browser, and the
headless path spans two packages (the evaluator in core, the kind-1985 label
codec in nostr). The widget bundle solved this for the DOM path only.

Add @bitgate/standalone (packages/standalone): an entry re-exporting core +
nostr, esbuilt into a single self-contained dist/bitgate.js (~44 KB). It's
the same canonical code the npm packages ship, with specifiers inlined, and
dependency-free -- @bitgate/verify's @noble deps are intentionally not pulled
in, so the bundle carries no third-party code. Wired into `npm run build` via
build:standalone.

Verified: no bare imports in the output (self-contained), and a smoke test
composes a full label -> decision flow from the single entry. README Packages
table and headless section document it.

Addresses the "standalone ESM single-file" item of the Tessera integration
feedback (docs/TODO.md).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB
2026-08-11 20:40:04 -04:00
thePR0M3TH3ANandClaude Opus 4.8 7eb4064556 docs: headless quickstart — NIP-32 labels to a four-dimension decision
Tessera's #1 reason for not adopting BitGate: the on-ramp they found was
the <bitgate-provider>/<bitgate-veil> widgets, so they never saw the
"just the engine + events over Nostr" path, and — not finding a canonical
decision event — invented their own kind-33604 shape.

Both concerns were already solved, just not surfaced:
- NIP-32 label events (kind 1985) ARE the canonical signed decision format;
  @bitgate/nostr already ships the codec and docs/labels.md documents interop.
  No 33604 needed.
- The headless path is four pure functions, no runtime transport, no DOM.

Add examples/headless-quickstart.mjs (runnable): moderators publish signed
kind-1985 deny labels -> decodeLabels -> labelsToContributions -> reduce
under an honored moderator set -> evaluateTarget against a plugin-registry
policy. It shows exactly the transaction-dimension case the feedback names —
a malware plugin stays visible (warn) but not installable (transaction
deny), where a `hidden` boolean can't reach.

Add a "Headless, straight from NIP-32 labels" README subsection stating
labels are the canonical wire format and linking the example and
docs/labels.md. Pin the behavior in tests/headless-quickstart.test.js
(clean installable; malware visible-but-not-installable; an out-of-set
labeller's deny ignored).

Addresses the "no just-the-engine path", "decision format is ours", and
"headless quickstart" items of the Tessera feedback (docs/TODO.md).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB
2026-08-11 20:36:09 -04:00
thePR0M3TH3ANandClaude Opus 4.8 af2818fbce core: add the allowlist policy preset
The engine already honored requireAllowlist + allowlistMiss, and the
commerce example configured a seller allowlist by hand -- but there was no
reusable, named "only show allowlisted publishers" preset, the symmetric
twin of the denylist. Integrators (Tessera) punted on allowlist mode for
lack of one.

Add ALLOWLIST_POLICY (registry name "allowlist"): a publisher is shown only
when an authorized actor has contributed a user-allow for them, and every
unlisted publisher misses and is hidden across all four dimensions.
Administrative denials still apply on top, and protected actors (root,
moderators) are never gated -- the people who curate the list can always
see. Populate userAllow exactly as the denylist: signed contributions
resolved from relays (e.g. NIP-32 allow labels).

Exported from @bitgate/core, registered in POLICY_PRESETS, documented in the
README, and covered by presets.test.js (unlisted hidden, allowlisted shown,
protected bypasses).

Addresses the "allowlist / whitelist mode" item of the Tessera integration
feedback (docs/TODO.md).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB
2026-08-11 20:30:52 -04:00
thePR0M3TH3AN 8674d6b33e Add deferred BitBlocks compatibility-layer roadmap 2026-08-10 12:27:36 -04:00
thePR0M3TH3AN a3c048936c Add BitGate distribution, API, policy, and explainability roadmap 2026-08-10 11:47:44 -04:00
thePR0M3TH3AN 3220889f01 feat(nostr): NIP-32 labelling — the interop surface
BitGate read standard events but published a private vocabulary, making it an
island. NIP-32 (kind 1985) is the shared moderation vocabulary; BitGate now
reads and writes it. See ADR 0007.

The design keeps flexibility central, since the goal is a reusable building
block. A label is (namespace, value, target) and carries no built-in meaning —
"deny", "nsfw", and "MIT" are the same kind of object. What a label means is a
policy decision an application supplies via labelMapping { namespace,
denyValues, allowValues }. The codec itself is vocabulary-free; decodeLabels
returns every label regardless of namespace for generic use.

A matching label becomes a contribution authored by the labeller, gated
identically: it denies someone only if the labeller holds the capability in the
roster, exactly like a community source. An untrusted labeller's deny does
nothing, and labels fail closed without verification like any administrative
event.

- consume: ingestEvent maps kind 1985; loadLabels(labellers) fetches them
- emit: commands.publishLabel(target, value), gated by the same capability as
  denying, so the shared form is no weaker than the private one
- the widget bundle re-exports the label API for the drop-in path

Validated against 40 real kind:1985 events from public relays (ISO-639-1
language labels among them): all decoded with targets and namespaces, zero
throws. 897 tests; cold gates green; audit clean.
2026-08-10 10:01:34 -04:00
thePR0M3TH3AN 3b95a63058 feat(verify): optional NIP-01 signature verification, on by default in the widget
Closes the gap the audit left open: administrative state fails closed without a
verifier, and BitGate shipped none.

@bitgate/verify is a separate package so core, nostr, and runtime keep no crypto
dependency and can be audited without one. The widget bundles it, so the
drop-in path is verified with no extra step; verify="off" disables it, sets
data-unverified on the element, and emits bitgate:unverified — meant for local
development, and meant to be visible.

Verification recomputes the event id before checking the signature. Checking
only the signature would let a forged event pass by reusing a genuine signature
over an unrelated id, which is the failure mode that matters here.

createVerifier also rejects events dated far in the future. A far-future
created_at wins replaceable-event selection indefinitely and pins stale state in
place; the signature is perfectly valid, so only a clock check catches it.

Found while testing: #adminIngestAllowed checked that a verifier *exists*, not
that this event was verified — so the public ingestEvent path, which
"bring your own relays" integrations use, bypassed verification entirely.
Verification now runs inside ingestEvent, with an already-verified flag for the
loaders. An async verifier cannot be awaited there and is treated as
unverified; ingestVerified() awaits properly.

Conformance validated against 120 live events from a public relay across kinds
0, 1, 3 and 10002 — 59 with non-ASCII content and 71 containing quotes,
newlines or tabs, which is where a serialization mismatch would surface. All
120 verified, zero id mismatches.

Bundle grows 72K -> 120K. 855 tests; audit clean.
2026-08-10 08:34:12 -04:00
thePR0M3TH3AN 94ef6a72f5 fix(security): close every finding from the audit
Critical
- Any pubkey could publish a kind:30078 roles document and become root of a
  deployment, granting itself every capability and denying anyone. Roles are now
  accepted only from the configured root, compared against the constructor
  value rather than current authority state so a bad ingest cannot move the
  goalposts. Reproduced end to end before the fix; regression test added.
- Administrative state now fails closed without signature verification. Without
  it, event.pubkey is an unauthenticated claim, so an authorship check alone
  proves nothing — a relay can put the root's key on an event the root never
  wrote. trustUnsignedEvents opts in for local and test use.

High
- Report categories could evade moderation entirely: resolveThresholds used
  bracket truthiness, so "constructor" resolved to the Object constructor (all
  gates undefined) and "__proto__" to Object.prototype, both bypassing the
  default threshold. Own-property lookups and null-prototype tables throughout
  core — policy, evaluator, evidence, authority, presets.
- Live subscriptions bypassed signature verification entirely; an attacker only
  had to wait for the subscription rather than the load.
- The default administrative query asked relays for every kind:30078 and
  kind:30000 event in existence, with no author, identifier, or limit. Now
  filtered to governance identifiers with a bounded limit.

Medium
- Cached state was trusted as authority: a cache naming a different root is now
  rejected, so same-origin tampering cannot persist a takeover.
- Provider attribute changes silently stopped working after the first one, and
  concurrent starts leaked a transport. Generation-guarded.
- Unbounded growth in the report store, mute store, decision cache, and the
  transport's per-subscription dedupe set — all now capped.

Also restores the on-wire `d` identifier to <namespace>:governance:<scope>:v1.
The BitGate rename replaced "governance:" too broadly and silently changed the
event format; both encoder and parser changed together, so round-trip tests
never noticed. The identifier names the kind of document, not the library.

Adds docs/security.md and 21 regression tests reproducing each finding.

832 tests.
2026-08-09 21:52:44 -04:00
thePR0M3TH3AN 7a45f1cf3e feat(nostr): outbox-model fetching, private mutes, contacts, NIP-56 vocabulary
Closes the four spec gaps from the audit, verified against the NIPs repo.

NIP-65 (kind 10002) — the one that was actually breaking things. Mute lists are
published by each contact to their own write relays, so querying a fixed relay
set finds only the subset who happen to write where this deployment reads. The
trust graph was silently under-fed and counts came out low, which looks like
"nobody muted them" rather than like a bug. Adds a relay-list codec, per-author
grouping with a fallback for accounts that have published no list, and
on-demand relay connections in the transport so an outbox query can reach
relays that were never configured.

NIP-51 private entries — the viewer's own private mutes are now decrypted
through their signer (NIP-44) and applied as viewer mutes. Someone who mutes
privately previously got no effect at all. The privacy boundary is asymmetric
and enforced at the codec: another account's private list is never decoded,
and the check lives there rather than in the caller's discipline.

NIP-02 (kind 3) — the runtime can build the trust graph itself, so
<bitgate-provider> no longer needs a host to supply a follow list. Set
trust="manual" to opt out.

NIP-56 vocabulary — SOCIAL_POLICY declared thresholds for "harassment", which
is not a NIP-56 type, so no standard client could ever trigger it. Replaced
with the defined vocabulary and asserted in tests. Commerce keeps its
marketplace-specific categories, now documented as deliberate, with defaults so
unclassified reports are never silently dropped.

Two bugs found by the new tests:
- checkout had no default threshold, so a flood of "other" reports did nothing
  at the one place money changes hands. Now earns review, never auto-denial.
- describe() spread diagnostics after a derived field, so a stale zero shadowed
  the live relay-list count.

Adds docs/nips.md recording coverage, deliberate choices, and remaining gaps —
NIP-32 labelling being the largest.

811 tests.
2026-08-09 20:52:21 -04:00
thePR0M3TH3AN adeba151b8 feat: rename to BitGate and close the drop-in gap
Renames the project and makes it as easy to adopt as BitLogin, which was the
stated bar and which it did not previously meet.

Rename (ADR 0005): @nostr-governance/* -> @bitgate/*, governance-* elements ->
bitgate-*, createGovernanceRuntime -> createBitGate. "Governance" reads as DAO
voting to this audience and named a category rather than a product; BitGate
names the benefit and joins the bit- family that BitVid and BitRoad compose
with. Domain types keep their names — the product is BitGate, the subject
matter is still governance. The migration plan is left as written; this ADR
records the change rather than rewriting history.

Drop-in distribution (ADR 0006): adopting BitGate meant supplying a transport,
authoring a policy, and wiring every element in JavaScript. Now:

- createRelayTransport(urls) — WebSocket transport with reconnection, backoff,
  subscription replay, and cross-relay dedupe
- policy presets (social, commerce, admin-only) addressable by name; the engine
  still carries no thresholds of its own
- <bitgate-provider> — configuration in markup, with descendants finding the
  runtime through a bubbling context event rather than DOM walking, which
  breaks across shadow boundaries
- npm run build:widget — a self-contained dist/bitgate.js

That last one came from running it in a browser rather than from a test. Bare
specifiers resolve in Node and vitest but not in a browser, so the widget
source worked everywhere except a plain static page — the one place it exists
to serve. The bundle is not committed: consumers build from reviewed source.

Also found by running it: the provider's ready event fires during element
upgrade, before a page that defines elements after parsing can attach a
listener, so it now exposes `ready` as a promise too.

Four bugs fixed along the way, three found by new tests and one by the browser:
admin state stayed silent when a roster change altered capabilities but no
denials; the relay transport sent every subscription twice on connect; the
commerce preset computed a meaningless visibility verdict at checkout and could
hide a listing from its own seller; <bitgate-status> threw on an unusable
decision.

759 tests; lint, typecheck, and both builds clean.
2026-08-09 19:58:45 -04:00
thePR0M3TH3AN da2c1cd923 feat(runtime): close the wiring gaps between interfaces and pipelines
Several contracts existed and were tested in isolation while nothing actually
used them. Reported as complete previously; this corrects that.

- storage was injected and never referenced. Adds persist/hydrate, hydration
  before querying relays, and last-known-good state with a stale flag when a
  relay is unreachable, which is what makes the characterized cache-fallback
  behavior real rather than reduction-level only
- signature verification existed as a tested helper the load path never called.
  Now applied through an injected verifier before state becomes authoritative,
  with rejection counts and an enabled/disabled readout in diagnostics
- trusted mute lists had no subscription flow, so the store never populated in
  a real deployment. Subscribes by author, chunked
- community source references decoded but were never resolved. Curator lists
  are now fetched and merged with a source marker, still capability-checked
- root-published policy was announced and discarded. Now applied, accepted only
  from the root, and a malformed document leaves the working policy intact
- adds import/export and capability queries

Two design corrections found while wiring:
- the cache key hashed the entire roster, so it rotated whenever a moderator
  was added and the cache was discarded. Now derived from root identity alone.
- the root administrator is a constructor option: it is deployment config, and
  hydration could not otherwise find its own cache, since the key derives from
  the very fingerprint the cache would have supplied.

634 tests; lint, typecheck, and the declaration build clean.
2026-08-09 19:04:05 -04:00
thePR0M3TH3AN c4477ae84b perf: memoize snapshot and fingerprint; add performance and security fixtures
The performance fixture the plan asks for found a real defect on its first run:
evaluation was quadratic in state size. Both the snapshot and its fingerprint
were rebuilt for every target, so each of 5,000 evaluations walked every report
and every mute list. A 5,000-target pass took ~47 seconds.

- the runtime memoizes the materialized snapshot and its fingerprint until a
  store reports a change
- evaluateMany computes the fingerprint once and shares it across the batch
- decisions are cached per (profile, target); report and mute changes
  invalidate only the targets they touch, while administrative, trust, policy,
  and viewer changes drop the whole cache
- cached decisions are deeply frozen, since the cache hands the same object to
  every caller

Same pass now takes ~85ms cold, ~6ms warm.

Adds tests/performance (5,000 targets, 500 authors, 100 muters per author) and
tests/security (one case per enumerated requirement testable at this layer).

605 tests; lint, typecheck, and the declaration build pass from a cold checkout.
2026-08-09 18:07:23 -04:00
thePR0M3TH3AN 2beb7a7bb5 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.
2026-08-09 17:57:09 -04:00
thePR0M3TH3AN d4c8e47b55 docs: update checkpoints for core policy implementation 2026-07-26 19:12:03 -04:00
thePR0M3TH3AN 8492411265 docs: update checkpoints for core authority implementation 2026-07-26 19:09:20 -04:00
thePR0M3TH3AN 2c1ae75b71 docs: update checkpoints for core targets implementation 2026-07-26 19:05:59 -04:00
thePR0M3TH3AN c6b93aed82 docs: update checkpoints for core identifiers implementation 2026-07-26 19:02:46 -04:00
thePR0M3TH3AN 358c6c0bc2 docs: update checkpoints to mark phase 1 complete 2026-07-26 18:59:57 -04:00
thePR0M3TH3AN 6bf577f718 docs: add phase 1 summary for fixture import harness 2026-07-26 18:59:46 -04:00
thePR0M3TH3AN 6873acae06 docs: update checkpoints for fixture harness completion 2026-07-26 18:58:52 -04:00
thePR0M3TH3AN 10326a2804 fix: make workspace install under npm and enforce real guardrails
Replace pnpm-only workspace: protocol with npm workspace deps, pin BitVid and
BitRoad reference commits, add Node types, and make the reference-import check
scan package graphs and source for forbidden bitvid/bitroad imports.
2026-07-26 18:55:21 -04:00
thePR0M3TH3AN 81998396b9 feat: bootstrap workspace with CI, typecheck, and import guardrails 2026-07-26 18:52:58 -04:00