A WebSocket error handler receives an Event, not an Error, so reading
.message off it was wrong. It only surfaced on a cold checkout — with built
declarations present, the inference happened to resolve differently, which is
why the previous commit passed locally and would have failed CI.
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.
A full-suite run failed intermittently, roughly one in six. Two causes, both
mine, both in test code rather than the library.
The performance fixture's setup repeated the same 100 mute-list writes 50 times
over with identical data — 5,000 calls where 100 give the same fixture — which
made construction expensive enough that individual tests occasionally exceeded
their timeout under load. The failures looked like assertion failures on
deterministic checks, which is what made them confusing. The fixture's shape
(5,000 targets, 500 authors, 100 muters per author, multiple categories and
contributors) is now asserted directly so it cannot silently degrade again.
The wall-clock assertion is gone. It measured the machine as much as the code —
85ms standalone versus 1606ms under vitest on the same commit — so it was flaky
rather than informative. Replaced with structural assertions that the snapshot
and its record maps are materialized once per batch, which is precisely the
regression that made a 5,000-target pass take ~47 seconds, and is deterministic.
Widget tests polled with vi.waitFor and its ~1s timeout. The async elements now
expose the in-flight promise as `pending`, so tests await completion directly.
That is also useful to hosts wanting to disable surrounding UI mid-action.
Six consecutive full-suite runs, 690 tests each, no failures.
npm audit reported 5 vulnerabilities (1 critical, 2 high, 2 moderate), all
transitive through [email protected]: vite -> esbuild, and postcss -> nanoid. They
were dev-only and never shipped — packages publish src and dist alone — but
they were also free to fix.
vitest 3 runs the existing suite unchanged: 687 tests pass, and npm audit now
reports zero vulnerabilities.
Follows bitlogin's layering: the engine stays headless and the widget is a
consumer of the published API, never a dependency of it. Building it was also a
test of that API — an element needing something unexposed would have been a
finding, the way the commerce example was.
Viewer elements: governance-veil (blur on restrict, disclosure on hide, warning
on warn), governance-report, governance-status. Moderator elements:
governance-capabilities, governance-action, governance-admin-panel.
Deliberate behaviors:
- hidden content leaves the accessibility tree rather than being visually
covered, so a screen reader does not read out what the viewer must not see
- a hidden decision renders a disclosure, not nothing: content that silently
vanishes is indistinguishable from a bug and leaves no route to appeal
- refused moderator actions stay visible and name the missing capability. A
hidden button teaches nothing about the authority model, and the command
layer enforces the same check independently — the UI is an explanation,
never the gate
- no element computes policy; wording maps from stable reason identifiers and
is overridable per application
Found while building it: GovernanceAdminStore emitted only when reduced denial
state changed, so revoking a role that denied nobody announced nothing, leaving
capability-gated UI and the decision cache stale.
The root tsconfig deliberately omits the DOM lib — that absence is what keeps
core, nostr, and runtime from reaching for browser globals — so the widget
carries its own DOM-aware config rather than relaxing the guarantee globally.
687 tests; lint, typecheck, and the declaration build pass from a cold checkout.
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.
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.
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.
Stores (Phase 4): admin, trust graph, reports, trusted mutes, policy, and
overrides. Each owns one slice of state and emits only when effective state
changes, so a consumer can treat every emission as a real invalidation.
- report storage is keyed target -> reporter -> category, making deduplication
structural: relays redeliver constantly and a redelivery must not inflate a count
- contribution lists replace by (actor, kind) and ignore out-of-order older
documents, so a late delivery cannot resurrect removed entries
- mute lists prune against an injected clock
- viewer switching clears blocks, mutes, and overrides but keeps operator seeds,
which are configuration rather than viewer data
Runtime: event routing, chunked report subscriptions (200 per filter, not one
per target), viewer lifecycle, evaluation, diagnostics, idempotent teardown.
Transport, signer, and storage are injected; in-memory doubles ship for tests.
Commands (Phase 5): every mutation checks capability locally before the signer
is invoked, so an unauthorized actor cannot produce a signed event at all.
Protected actors are refused at the command boundary as well as during
reduction. Partial relay acceptance succeeds while keeping failures visible.
Also fixes the type build: every package's "types" field pointed at a path the
build never produced, so consumers got no types at all. Declarations now emit
per package and resolve.
103 runtime tests; 505 total.
Event formats live in codecs so the core never learns which kind stores roles,
which d identifier is used, or how a legacy list is shaped.
- bech32/NIP-19 decoding, verified against the NIP-19 test vector, so the core
can stay hex-only and dependency-free
- canonical v1 codec on kind:30078 with namespaced d identifiers, role rosters
carrying both role labels and explicit capabilities, and size-bounded policy
documents that return null rather than throwing on malformed JSON
- NIP-56 reports, resolving the report type across the three tag layouts
clients use and excluding relay hints from the type position
- NIP-51 mute lists; encrypted entries are flagged, never decoded
- legacy admin-list codec supporting all five migration stages, with editors
surfaced separately so a UI permission cannot silently become signing authority
- replaceable selection with a deterministic same-timestamp tiebreak, without
which two relays returning events in different orders would produce different
state on different clients
129 codec tests; 402 total.
The evaluator was a skeleton with invented thresholds (5/3/10/5) and two
unimplemented branches. Rebuild it against the migration plan's spec and the
behavior characterized from the pinned reference commit.
Core:
- four independent decision dimensions (ranking, visibility, interaction,
transaction) with severity ladders and max-merge composition
- stable reason identifiers replacing free-text strings
- structured evidence, redacted unless a profile opts in
- administrative-state reduction with capability filtering at merge time, so
revoking a role drops that actor's entries immediately
- protected actors stripped from every denial set
- snapshot fingerprints for cache invalidation
- generic policy engine: applications supply profiles and category thresholds;
the core carries none of its own
Characterization:
- 34 generated fixtures covering every behavior the plan's Phase 0 requires
- conformance suite that actually evaluates them, replacing a placeholder that
asserted a list's length
- reference thresholds (3/3/20/5, 60-day mute window) live in bitvid-compat as
an application profile, not as engine defaults
Fixes found while building:
- reduceAdminState accepted malformed target identifiers, because getTargetKey
formats anything it is given
- community-user-deny fired for any contributor rather than only for
community-curated sources
273 tests pass; typecheck and the reference-import guard are clean.
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.