diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e332e84
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,63 @@
+# Changelog
+
+Notable changes to this repository. The package version and the wire schema
+version move independently: a breaking package release does not imply a new
+event schema, and a new schema does not require a major package bump unless the
+public API changes with it.
+
+## [Unreleased]
+
+### Added
+
+- **Core decision model.** Four independent dimensions (`ranking`, `visibility`,
+ `interaction`, `transaction`) with severity ladders and commutative
+ max-merge composition.
+- **Stable reason identifiers** (`REASON_IDS`) as part of the public contract.
+ Renaming or removing one is a breaking change.
+- **Structured evidence**, redacted by default and merged across an object's
+ targets, so a multi-target object explains itself with everything that
+ contributed.
+- **Administrative-state reduction** with capability filtering at merge time,
+ community-source marking, and protected-actor stripping.
+- **Snapshot fingerprints** for cache invalidation and mismatch records.
+- **Application adapter contract** (`createApplicationAdapter`,
+ `evaluateObject`, `collectTargets`).
+- **Codecs**: canonical v1 on `kind:30078`, NIP-56 reports, NIP-51 mute lists,
+ legacy administrative lists, bech32/NIP-19, replaceable selection with a
+ deterministic same-timestamp tiebreak, injected signature verification.
+- **Runtime**: six stores, transport orchestration, chunked report
+ subscriptions, viewer lifecycle, diagnostics, idempotent teardown.
+- **Commands** with local capability checks, protected-target refusal, partial
+ relay acceptance, and stable error codes.
+- **Characterization corpus**: 34 generated fixtures covering the behaviors the
+ migration plan's Phase 0 requires, plus a conformance suite that evaluates
+ them.
+- **Commerce proof-of-fit** (`examples/commerce`) demonstrating the engine
+ outside a video application using only the public API.
+- GPL-3.0-or-later `LICENSE`, `README`, and an integration guide.
+
+### Fixed
+
+- Per-package `types` fields pointed at paths the build never produced, so
+ consumers received no type declarations at all. Declarations now emit per
+ package and resolve.
+- `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, making a direct moderator action indistinguishable
+ from a federated one.
+- `composeDecisions` kept only the first decision's evidence, so an object
+ governed by several targets lost the evidence that drove its verdict.
+- `OverrideStore.clear()` shadowed the inherited listener `clear()`, leaking
+ subscribers on teardown. Now `clearOverrides()`.
+
+### Changed
+
+- The evaluator no longer carries application thresholds. The previous invented
+ defaults (5/3/10/5) are gone; applications supply category thresholds through
+ a policy definition. The characterized reference values live in
+ `@nostr-governance/bitvid-compat` as one application's profile.
+- `createAuthorityState` takes an options object and always grants the root
+ every capability and protection.
+- Evaluation takes a `GovernanceSnapshot` and a `ViewerState` rather than a
+ flat state object, and reads an injected clock instead of wall time.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..095026d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,158 @@
+# nostr-governance
+
+A headless, application-agnostic governance engine for Nostr: moderation,
+access control, and trust-graph policy, with no opinion about what your
+application is.
+
+The engine carries **no thresholds of its own**. Applications supply a policy
+definition; the engine supplies precedence, aggregation, composition, and
+explanation. That separation is what lets one engine serve a video app and a
+marketplace without either leaking into the other.
+
+## Why it looks like this
+
+**Four independent decision dimensions.** A governance decision is not a
+`hidden` boolean. It is four answers:
+
+| Dimension | Values |
+| --- | --- |
+| `ranking` | `normal` → `downrank` (with weight) |
+| `visibility` | `allow` → `warn` → `restrict` → `hide` → `deny` |
+| `interaction` | `allow` → `require-explicit-action` → `deny` |
+| `transaction` | `allow` → `require-review` → `deny` |
+
+A marketplace listing can be visible to its seller, downranked in discovery,
+inspectable behind a warning on its detail page, and blocked at checkout — all
+from one evaluation. Collapsing that into one boolean is what forces
+applications to reimplement policy.
+
+**Composition is ladder-maximum per dimension**, which makes it commutative and
+associative: the order decisions arrive in cannot change the result.
+
+**Capabilities resolve at merge time, not ingest time.** Revoking a moderator's
+role drops their contributions from effective state immediately, without
+replaying or rewriting anything.
+
+**Trust seeds are a fallback, not an addition.** Once a viewer has real
+contacts, configured seeds stop contributing — following nobody must not be
+equivalent to following the operator's seed list.
+
+## Packages
+
+| Package | Purpose |
+| --- | --- |
+| `@nostr-governance/core` | Pure evaluation. No I/O, no browser globals, no dependencies. |
+| `@nostr-governance/nostr` | Codecs: canonical v1, NIP-56, NIP-51, legacy lists, bech32. |
+| `@nostr-governance/runtime` | Stores, transport orchestration, commands. |
+| `@nostr-governance/testing` | Conformance harness. |
+| `@nostr-governance/bitvid-compat` | Reference application profile for the characterization corpus. |
+
+## Quick start
+
+```js
+import { createPolicyDefinition, evaluateTarget } from "@nostr-governance/core";
+import { createGovernanceRuntime, createMemoryTransport } from "@nostr-governance/runtime";
+
+const policy = createPolicyDefinition({
+ id: "my-app",
+ version: "1.0.0",
+ defaultProfile: "feed",
+ profiles: {
+ feed: {
+ name: "feed",
+ administrativeDeny: { visibility: "hide", interaction: "deny" },
+ reports: {
+ spam: { downrank: 1, restrict: 3, hide: 8 },
+ malware: { hide: 1, interactionDeny: 1 },
+ },
+ mutes: { default: { downrank: 1, hide: 20 } },
+ muteWindowSeconds: 60 * 24 * 60 * 60,
+ },
+ },
+});
+
+const runtime = createGovernanceRuntime({
+ applicationId: "my-app",
+ namespace: "myapp",
+ transport: myRelayAdapter,
+ policy,
+ now: () => Math.floor(Date.now() / 1000),
+});
+
+runtime.setViewer(viewerPubkey);
+runtime.trust.setContacts(followList);
+await runtime.loadAdministrativeState();
+
+const decision = runtime.evaluate({ type: "user", pubkey: authorPubkey }, { profile: "feed" });
+if (decision.visibility.effect === "hide") return null;
+```
+
+## Extending to a new application
+
+Write an adapter saying what your object is in governance terms, and a policy
+saying what the evidence means to you. Nothing else.
+
+```js
+import { createApplicationAdapter, evaluateObject } from "@nostr-governance/core";
+
+const productAdapter = createApplicationAdapter({
+ applicationId: "my-shop",
+ toTargets: (product) => [
+ { type: "user", pubkey: product.sellerPubkey },
+ { type: "event", id: product.id, author: product.sellerPubkey },
+ { type: "address", kind: "30078", pubkey: product.sellerPubkey, identifier: product.sku },
+ ],
+ getPrimaryTargetKey: (product) => `address:30078:${product.sellerPubkey}:${product.sku}`,
+});
+
+const decision = evaluateObject(product, productAdapter, (target) =>
+ runtime.evaluate(target, { profile: "checkout" }),
+);
+```
+
+A worked example lives in [`examples/commerce`](examples/commerce) — a
+marketplace built entirely on the public API, demonstrating seller allowlists,
+address denial that survives republication, malware-driven checkout blocking,
+capability-scoped moderator roles, community curator ingestion, and
+seller-facing explanations. See [`docs/integration-guide.md`](docs/integration-guide.md).
+
+## Authority model
+
+Root-authorized contributors: the root publishes the role roster, each actor
+publishes contributions under their own key, and a contribution is accepted only
+if the signing actor holds the matching capability.
+
+```
+manage-roles manage-policy manage-community-sources
+contribute-user-allow contribute-user-deny contribute-event-deny
+contribute-address-deny contribute-trust-seed review-evidence
+```
+
+Roles are convenience bundles over these; applications define their own
+(`listing_moderator`, `seller_moderator`, …). No moderator ever needs the root
+key. Protected actors cannot be denied by any contributor list, however many
+curators list them.
+
+## Development
+
+```bash
+npm install
+npm run lint # reference guard + typecheck
+npm test # unit + conformance
+npm run build:types # per-package .d.ts
+```
+
+BitVid and BitRoad are **read-only reference repositories**, pinned by commit in
+[`docs/reference-map.md`](docs/reference-map.md). Behavior is copied via fixtures
+and characterization tests, never by importing their source;
+`npm run check:references` fails the build if anything does.
+
+Regenerate the characterization corpus with:
+
+```bash
+node scripts/build-characterization-corpus.mjs
+```
+
+## License
+
+GPL-3.0-or-later. See [`docs/license-and-provenance.md`](docs/license-and-provenance.md).
diff --git a/docs/adr/0001-four-decision-dimensions.md b/docs/adr/0001-four-decision-dimensions.md
new file mode 100644
index 0000000..2961ffc
--- /dev/null
+++ b/docs/adr/0001-four-decision-dimensions.md
@@ -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".
diff --git a/docs/adr/0002-capabilities-resolve-at-merge-time.md b/docs/adr/0002-capabilities-resolve-at-merge-time.md
new file mode 100644
index 0000000..af90944
--- /dev/null
+++ b/docs/adr/0002-capabilities-resolve-at-merge-time.md
@@ -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.
diff --git a/docs/adr/0003-generic-engine-application-policy.md b/docs/adr/0003-generic-engine-application-policy.md
new file mode 100644
index 0000000..99f34cc
--- /dev/null
+++ b/docs/adr/0003-generic-engine-application-policy.md
@@ -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.
diff --git a/docs/adr/0004-hex-only-core.md b/docs/adr/0004-hex-only-core.md
new file mode 100644
index 0000000..9395646
--- /dev/null
+++ b/docs/adr/0004-hex-only-core.md
@@ -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.
diff --git a/docs/checkpoints.md b/docs/checkpoints.md
index 80b2dc4..6d29b7a 100644
--- a/docs/checkpoints.md
+++ b/docs/checkpoints.md
@@ -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)
\ No newline at end of file
+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.
diff --git a/docs/integration-guide.md b/docs/integration-guide.md
new file mode 100644
index 0000000..8182d1f
--- /dev/null
+++ b/docs/integration-guide.md
@@ -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.
diff --git a/examples/commerce/README.md b/examples/commerce/README.md
new file mode 100644
index 0000000..939e2ce
--- /dev/null
+++ b/examples/commerce/README.md
@@ -0,0 +1,68 @@
+# Commerce proof-of-fit
+
+A marketplace built on `nostr-governance`, using only the published API.
+
+The point is not to ship marketplace moderation. It is to prove the engine did
+not accidentally stay video-specific during extraction: if a commerce consumer
+needs a change to `@nostr-governance/core`, the abstraction is wrong.
+
+## What it demonstrates
+
+The migration plan lists ten behaviors a commerce consumer should show before
+`1.0.0`. Each is a numbered block in
+[`test/proof-of-fit.test.js`](test/proof-of-fit.test.js):
+
+1. **Seller allowlist mode** — unlisted sellers are hidden from discovery, but
+ the allowlist is not enforced at checkout
+2. **Administrative seller denial** — takes the seller's whole catalogue down
+ while leaving them their own dashboard view
+3. **Product-address denial** — survives the seller republishing under a new
+ event id, and does not touch their other listings
+4. **Exact-event denial** — affects only the named revision
+5. **Trusted scam-report downranking** — one report reorders, three block
+ checkout; nothing is hidden on a single signal
+6. **Malware checkout denial** — one trusted malware report blocks the sale,
+ while three "misleading" reports do not
+7. **Moderator capability checks** — a listing moderator cannot deny a seller;
+ a seller moderator cannot deny a listing address
+8. **Community curator ingestion** — curated denials are honored, curator
+ contributions beyond their capability are dropped, and the root cannot be
+ denied through a curated list
+9. **Seller-dashboard explanation** — the seller sees who reported and why;
+ public surfaces see only counts
+10. **No core changes required** — four different verdicts for one product from
+ one snapshot, using only exported functions
+
+## Files
+
+| File | Contents |
+| --- | --- |
+| `src/policy.js` | Marketplace thresholds and roles. None of these numbers exist in the core. |
+| `src/adapters.js` | Products, storefronts, reviews, and sellers as governance targets. |
+| `src/marketplace.js` | Feed pipeline, grid ordering, checkout, seller explanation. |
+
+## The shape worth copying
+
+A product answers to three targets — its seller, its exact event, and its
+address coordinate — and the composed decision is the strictest verdict reaching
+it. That is what makes "deny the seller" remove every listing without a line of
+cascade logic in the application.
+
+One product, one snapshot, four surfaces:
+
+| Surface | Visibility | Transaction |
+| --- | --- | --- |
+| `public-marketplace` | `restrict` | `deny` |
+| `product-detail` | `restrict`, overridable | `deny` |
+| `checkout` | `allow` | `deny` |
+| `seller-dashboard` | `restrict`, with evidence | `deny` |
+
+Reviews deliberately answer to their reviewer and their own event — never to the
+product they review. Otherwise denying a bad product would silence the reviews
+warning people about it.
+
+## Run it
+
+```bash
+npm test -- examples/commerce
+```
diff --git a/examples/commerce/package.json b/examples/commerce/package.json
new file mode 100644
index 0000000..ebaddaf
--- /dev/null
+++ b/examples/commerce/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@nostr-governance/example-commerce",
+ "version": "0.1.0",
+ "type": "module",
+ "private": true,
+ "description": "Proof-of-fit: the governance engine driving a marketplace, using only the public API.",
+ "main": "src/marketplace.js",
+ "scripts": {
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@nostr-governance/core": "*",
+ "@nostr-governance/runtime": "*"
+ }
+}
diff --git a/examples/commerce/src/adapters.js b/examples/commerce/src/adapters.js
new file mode 100644
index 0000000..e55c420
--- /dev/null
+++ b/examples/commerce/src/adapters.js
@@ -0,0 +1,156 @@
+// Commerce application adapters.
+//
+// Each adapter answers two questions: which governance targets stand behind
+// this object, and what does a decision mean for it. Note what is absent —
+// no thresholds, no precedence rules, no policy logic. Those live in the
+// policy definition and the engine respectively.
+
+import { createApplicationAdapter } from "@nostr-governance/core";
+
+/** Parameterized-replaceable kind used for commerce documents in this example. */
+export const PRODUCT_KIND = 30078;
+
+/**
+ * @typedef {Object} Product
+ * @property {string} id - Event id of the current revision
+ * @property {string} sellerPubkey
+ * @property {string} identifier - d-tag, stable across revisions
+ * @property {string} title
+ * @property {number} priceSats
+ */
+
+/**
+ * @typedef {Object} Storefront
+ * @property {string} id
+ * @property {string} sellerPubkey
+ * @property {string} identifier
+ * @property {string} name
+ */
+
+/**
+ * @typedef {Object} Review
+ * @property {string} id
+ * @property {string} reviewerPubkey
+ * @property {string} productIdentifier
+ * @property {number} rating
+ */
+
+/**
+ * Products answer to three targets.
+ *
+ * The address is what persists across revisions: denying it survives the seller
+ * republishing the listing under a new event id, which denying the exact event
+ * would not. Both are kept, because an exact-event denial must also stay
+ * possible for the case where only one revision was bad.
+ *
+ * @type {import('@nostr-governance/core').GovernanceApplicationAdapter}
+ */
+export const productAdapter = createApplicationAdapter({
+ applicationId: "commerce-example",
+
+ toTargets(product) {
+ return [
+ { type: "user", pubkey: product.sellerPubkey },
+ { type: "event", id: product.id, author: product.sellerPubkey, kind: PRODUCT_KIND },
+ {
+ type: "address",
+ kind: String(PRODUCT_KIND),
+ pubkey: product.sellerPubkey,
+ identifier: product.identifier,
+ },
+ ];
+ },
+
+ getPrimaryTargetKey(product) {
+ return `address:${PRODUCT_KIND}:${product.sellerPubkey}:${product.identifier}`;
+ },
+
+ applyDecision(product, decision) {
+ return {
+ ...product,
+ moderation: {
+ visible: decision.visibility.effect !== "hide" && decision.visibility.effect !== "deny",
+ warn: decision.visibility.effect === "warn",
+ blurred: decision.visibility.effect === "restrict",
+ downranked: decision.ranking.effect === "downrank",
+ rankPenalty: decision.ranking.weight,
+ checkoutAllowed: (decision.transaction?.effect ?? "allow") === "allow",
+ checkoutNeedsReview: decision.transaction?.effect === "require-review",
+ reasons: decision.reasons.map((reason) => reason.id),
+ },
+ };
+ },
+});
+
+/**
+ * Storefronts answer to the seller, the storefront address, and the exact event.
+ * @type {import('@nostr-governance/core').GovernanceApplicationAdapter}
+ */
+export const storefrontAdapter = createApplicationAdapter({
+ applicationId: "commerce-example",
+
+ toTargets(storefront) {
+ return [
+ { type: "user", pubkey: storefront.sellerPubkey },
+ { type: "event", id: storefront.id, author: storefront.sellerPubkey, kind: PRODUCT_KIND },
+ {
+ type: "address",
+ kind: String(PRODUCT_KIND),
+ pubkey: storefront.sellerPubkey,
+ identifier: storefront.identifier,
+ },
+ ];
+ },
+
+ getPrimaryTargetKey(storefront) {
+ return `address:${PRODUCT_KIND}:${storefront.sellerPubkey}:${storefront.identifier}`;
+ },
+});
+
+/**
+ * Reviews answer to the reviewer and the exact review event.
+ *
+ * Deliberately not to the reviewed product: a bad product should not silence
+ * honest reviews of it, which is exactly the outcome an over-broad target list
+ * would produce.
+ *
+ * @type {import('@nostr-governance/core').GovernanceApplicationAdapter}
+ */
+export const reviewAdapter = createApplicationAdapter({
+ applicationId: "commerce-example",
+
+ toTargets(review) {
+ return [
+ { type: "user", pubkey: review.reviewerPubkey },
+ { type: "event", id: review.id, author: review.reviewerPubkey, kind: PRODUCT_KIND },
+ ];
+ },
+
+ getPrimaryTargetKey(review) {
+ return `event:${review.id}`;
+ },
+
+ applyDecision(review, decision) {
+ return {
+ ...review,
+ suppressed: decision.visibility.effect === "hide" || decision.visibility.effect === "deny",
+ downranked: decision.ranking.effect === "downrank",
+ };
+ },
+});
+
+/**
+ * Sellers answer only to their own user target.
+ * @type {import('@nostr-governance/core').GovernanceApplicationAdapter<{ pubkey: string }>}
+ */
+export const sellerAdapter = createApplicationAdapter({
+ applicationId: "commerce-example",
+
+ toTargets(seller) {
+ return [{ type: "user", pubkey: seller.pubkey }];
+ },
+
+ getPrimaryTargetKey(seller) {
+ return `user:${seller.pubkey}`;
+ },
+});
diff --git a/examples/commerce/src/marketplace.js b/examples/commerce/src/marketplace.js
new file mode 100644
index 0000000..a38c33e
--- /dev/null
+++ b/examples/commerce/src/marketplace.js
@@ -0,0 +1,107 @@
+// Marketplace pipeline.
+//
+// The application's job is reduced to: collect targets, ask the engine, apply
+// the answer. It computes no thresholds and reimplements no precedence — which
+// is the property the extraction is meant to guarantee.
+
+import { collectTargets, evaluateObject } from "@nostr-governance/core";
+
+import { productAdapter } from "./adapters.js";
+
+/**
+ * @typedef {import('./adapters.js').Product} Product
+ */
+
+/**
+ * Govern a product feed for a surface.
+ *
+ * Targets are collected first and handed to the runtime in one call, so a
+ * hundred-item page opens a bounded number of subscriptions rather than one per
+ * listing.
+ *
+ * @param {Product[]} products
+ * @param {import('@nostr-governance/runtime').GovernanceRuntime} runtime
+ * @param {string} profile
+ * @returns {Array}
+ */
+export function governProductFeed(products, runtime, profile) {
+ runtime.setActiveTargets(collectTargets(products, productAdapter));
+
+ return products.map((product) => {
+ const decision = evaluateObject(product, productAdapter, (target) =>
+ runtime.evaluate(target, { profile }),
+ );
+ return /** @type {any} */ (productAdapter.applyDecision?.(product, decision) ?? product);
+ });
+}
+
+/**
+ * Render a marketplace grid: drop hidden listings, order downranked ones last.
+ *
+ * Downranking is a sort input, not a filter. A downranked listing still appears
+ * and can still be bought — conflating the two is what makes moderation feel
+ * like censorship to sellers.
+ *
+ * @param {Product[]} products
+ * @param {import('@nostr-governance/runtime').GovernanceRuntime} runtime
+ * @returns {Array}
+ */
+export function buildMarketplaceGrid(products, runtime) {
+ return governProductFeed(products, runtime, "public-marketplace")
+ .filter((product) => product.moderation.visible)
+ .sort((a, b) => a.moderation.rankPenalty - b.moderation.rankPenalty);
+}
+
+/**
+ * @typedef {Object} CheckoutVerdict
+ * @property {boolean} allowed
+ * @property {boolean} needsReview
+ * @property {string[]} reasons
+ */
+
+/**
+ * Check a product at checkout.
+ *
+ * Evaluated freshly against the checkout profile rather than reusing the
+ * listing page's decision: state may have changed since the page rendered, and
+ * checkout is the point where being wrong actually costs someone money.
+ *
+ * @param {Product} product
+ * @param {import('@nostr-governance/runtime').GovernanceRuntime} runtime
+ * @returns {CheckoutVerdict}
+ */
+export function checkoutVerdict(product, runtime) {
+ const decision = evaluateObject(product, productAdapter, (target) =>
+ runtime.evaluate(target, { profile: "checkout" }),
+ );
+
+ const effect = decision.transaction?.effect ?? "allow";
+ return {
+ allowed: effect === "allow",
+ needsReview: effect === "require-review",
+ reasons: decision.reasons.map((reason) => reason.id),
+ };
+}
+
+/**
+ * Explain a product's status to its seller.
+ *
+ * The seller-dashboard profile exposes evidence, so this is the one surface
+ * that can answer "how many people reported me, and for what".
+ *
+ * @param {Product} product
+ * @param {import('@nostr-governance/runtime').GovernanceRuntime} runtime
+ * @returns {{ visible: boolean, sellable: boolean, reasons: string[], evidence: any }}
+ */
+export function explainToSeller(product, runtime) {
+ const decision = evaluateObject(product, productAdapter, (target) =>
+ runtime.evaluate(target, { profile: "seller-dashboard" }),
+ );
+
+ return {
+ visible: decision.visibility.effect !== "hide" && decision.visibility.effect !== "deny",
+ sellable: (decision.transaction?.effect ?? "allow") === "allow",
+ reasons: decision.reasons.map((reason) => reason.id),
+ evidence: decision.evidence,
+ };
+}
diff --git a/examples/commerce/src/policy.js b/examples/commerce/src/policy.js
new file mode 100644
index 0000000..7d5d232
--- /dev/null
+++ b/examples/commerce/src/policy.js
@@ -0,0 +1,144 @@
+// Commerce policy profiles.
+//
+// This example exists to prove the engine is not video-specific. Nothing here
+// touches @nostr-governance/core internals: it is built entirely from the
+// public API, and the core carries none of these numbers.
+//
+// The interesting property is that one product can simultaneously be visible to
+// its seller, downranked in discovery, inspectable on its detail page, and
+// blocked from checkout — four different answers from one decision, which a
+// single `hidden` boolean could not express.
+
+import { createPolicyDefinition, createRoleDefinition } from "@nostr-governance/core";
+
+/**
+ * Category thresholds for a marketplace.
+ *
+ * Malware is treated far more harshly than misleading copy: one trusted report
+ * blocks checkout, because the cost of a false negative is a compromised buyer,
+ * while an over-eager `misleading` threshold would just suppress lawful listings.
+ *
+ * @type {Record}
+ */
+export const MARKETPLACE_REPORT_THRESHOLDS = {
+ scam: {
+ downrank: 1,
+ warn: 2,
+ restrict: 3,
+ hide: 5,
+ transactionReview: 2,
+ transactionDeny: 3,
+ },
+ malware: {
+ warn: 1,
+ restrict: 1,
+ hide: 2,
+ transactionDeny: 1,
+ },
+ misleading: {
+ downrank: 2,
+ warn: 3,
+ restrict: 5,
+ hide: 8,
+ },
+ "not-as-described": {
+ downrank: 2,
+ warn: 4,
+ transactionReview: 5,
+ },
+ default: {
+ downrank: 2,
+ warn: 4,
+ },
+};
+
+/** Trusted mutes downrank a seller but never block a sale on their own. */
+const MARKETPLACE_MUTE_THRESHOLDS = {
+ default: { downrank: 1 },
+};
+
+/**
+ * The marketplace policy.
+ *
+ * Every surface reads the same evidence and reaches a different conclusion,
+ * which is the whole point of profiles.
+ */
+export const COMMERCE_POLICY = createPolicyDefinition({
+ id: "commerce-example",
+ name: "Commerce example",
+ description: "Marketplace policy demonstrating the engine outside a video application.",
+ version: "1.0.0",
+ defaultProfile: "public-marketplace",
+ profiles: {
+ // Discovery: denied sellers disappear entirely and cannot transact.
+ "public-marketplace": {
+ name: "public-marketplace",
+ administrativeDeny: { visibility: "hide", interaction: "deny", transaction: "deny" },
+ viewerBlock: { visibility: "hide", interaction: "deny" },
+ allowViewerOverride: false,
+ requireAllowlist: true,
+ allowlistMiss: { visibility: "hide", interaction: "deny", transaction: "deny" },
+ reports: MARKETPLACE_REPORT_THRESHOLDS,
+ mutes: MARKETPLACE_MUTE_THRESHOLDS,
+ },
+
+ // Detail page: a shopper who followed a direct link may still inspect the
+ // listing behind a warning, but cannot buy it.
+ "product-detail": {
+ name: "product-detail",
+ administrativeDeny: { visibility: "restrict", interaction: "require-explicit-action", transaction: "deny" },
+ viewerBlock: { visibility: "hide", interaction: "deny" },
+ allowViewerOverride: true,
+ reports: MARKETPLACE_REPORT_THRESHOLDS,
+ mutes: MARKETPLACE_MUTE_THRESHOLDS,
+ },
+
+ // Checkout: visibility is irrelevant; only the transaction verdict matters,
+ // and it is re-checked here rather than trusted from the listing page.
+ checkout: {
+ name: "checkout",
+ administrativeDeny: { visibility: "allow", transaction: "deny" },
+ allowViewerOverride: false,
+ reports: MARKETPLACE_REPORT_THRESHOLDS,
+ mutes: {},
+ },
+
+ // Seller dashboard: the seller can always see their own listing and is told
+ // why it is restricted. An appeals screen that hides the evidence is useless.
+ "seller-dashboard": {
+ name: "seller-dashboard",
+ administrativeDeny: { visibility: "warn", interaction: "allow", transaction: "deny" },
+ allowViewerOverride: false,
+ exposeEvidence: true,
+ reports: MARKETPLACE_REPORT_THRESHOLDS,
+ mutes: MARKETPLACE_MUTE_THRESHOLDS,
+ },
+ },
+});
+
+/**
+ * Marketplace-specific roles, built from the generic capability vocabulary.
+ *
+ * These roles do not exist in the core; an application invents the roles it
+ * needs and the engine enforces them.
+ */
+export const COMMERCE_ROLES = {
+ listing_moderator: createRoleDefinition("listing_moderator", [
+ "contribute-event-deny",
+ "contribute-address-deny",
+ "review-evidence",
+ ]),
+ seller_moderator: createRoleDefinition("seller_moderator", [
+ "contribute-user-deny",
+ "review-evidence",
+ ]),
+ community_curator: createRoleDefinition("community_curator", ["contribute-user-deny"]),
+};
+
+/**
+ * Role capability map in the shape createAuthorityState expects.
+ * @type {Record}
+ */
+export const COMMERCE_ROLE_CAPABILITIES = Object.fromEntries(
+ Object.values(COMMERCE_ROLES).map((role) => [role.name, role.capabilities]),
+);
diff --git a/examples/commerce/test/proof-of-fit.test.js b/examples/commerce/test/proof-of-fit.test.js
new file mode 100644
index 0000000..4b474a3
--- /dev/null
+++ b/examples/commerce/test/proof-of-fit.test.js
@@ -0,0 +1,508 @@
+// Commerce proof-of-fit.
+//
+// The plan requires a commerce consumer to demonstrate ten behaviors before the
+// package reaches 1.0.0, the tenth being that none of it needs a change to the
+// generic core. Each numbered case below is one of those requirements.
+
+import { describe, expect, it } from "vitest";
+
+import { createCommands, createGovernanceRuntime, createMemoryTransport } from "@nostr-governance/runtime";
+
+import { COMMERCE_POLICY, COMMERCE_ROLE_CAPABILITIES } from "../src/policy.js";
+import { productAdapter, reviewAdapter, sellerAdapter } from "../src/adapters.js";
+import {
+ buildMarketplaceGrid,
+ checkoutVerdict,
+ explainToSeller,
+ governProductFeed,
+} from "../src/marketplace.js";
+
+const ROOT = "a1".repeat(32);
+const LISTING_MOD = "b2".repeat(32);
+const SELLER_MOD = "c3".repeat(32);
+const CURATOR = "e5".repeat(32);
+const SELLER = "d4".repeat(32);
+const OTHER_SELLER = "f6".repeat(32);
+const BUYER = "ab".repeat(32);
+const NOW = 1_750_000_000;
+
+const trusted = (index) => index.toString(16).padStart(2, "0").repeat(32);
+const TRUSTED = Array.from({ length: 8 }, (_, index) => trusted(index + 1));
+
+/** @type {import('../src/adapters.js').Product} */
+const PRODUCT = {
+ id: "1b".repeat(32),
+ sellerPubkey: SELLER,
+ identifier: "sku-001",
+ title: "Widget",
+ priceSats: 1000,
+};
+
+/** @type {import('../src/adapters.js').Product} */
+const OTHER_PRODUCT = {
+ id: "2c".repeat(32),
+ sellerPubkey: SELLER,
+ identifier: "sku-002",
+ title: "Gadget",
+ priceSats: 2000,
+};
+
+/** @type {import('../src/adapters.js').Product} */
+const RIVAL_PRODUCT = {
+ id: "3d".repeat(32),
+ sellerPubkey: OTHER_SELLER,
+ identifier: "sku-100",
+ title: "Rival widget",
+ priceSats: 900,
+};
+
+function setup({ allowlisted = [SELLER, OTHER_SELLER] } = {}) {
+ const runtime = createGovernanceRuntime({
+ applicationId: "commerce-example",
+ namespace: "commerce",
+ transport: createMemoryTransport(),
+ policy: COMMERCE_POLICY,
+ now: () => NOW,
+ });
+
+ runtime.admin.setRoles({
+ root: ROOT,
+ roles: COMMERCE_ROLE_CAPABILITIES,
+ actors: {
+ [ROOT]: ["super_admin"],
+ [LISTING_MOD]: ["listing_moderator"],
+ [SELLER_MOD]: ["seller_moderator"],
+ [CURATOR]: ["community_curator"],
+ },
+ });
+
+ // Roles here are application-defined, so super_admin needs its default bundle
+ // restored alongside them.
+ runtime.admin.setRoles({
+ root: ROOT,
+ roles: {
+ ...COMMERCE_ROLE_CAPABILITIES,
+ super_admin: [
+ "manage-roles",
+ "manage-policy",
+ "manage-community-sources",
+ "contribute-user-allow",
+ "contribute-user-deny",
+ "contribute-event-deny",
+ "contribute-address-deny",
+ "contribute-trust-seed",
+ "review-evidence",
+ ],
+ },
+ actors: {
+ [ROOT]: ["super_admin"],
+ [LISTING_MOD]: ["listing_moderator"],
+ [SELLER_MOD]: ["seller_moderator"],
+ [CURATOR]: ["community_curator"],
+ },
+ });
+
+ if (allowlisted.length) {
+ runtime.admin.upsertContribution({
+ actor: ROOT,
+ kind: "user-allow",
+ targets: allowlisted.map((pubkey) => ({ type: "user", pubkey })),
+ createdAt: NOW,
+ });
+ }
+
+ // Set the viewer first: switching viewers clears viewer-scoped trust state,
+ // so seeding contacts beforehand would be wiped.
+ runtime.setViewer(BUYER);
+ runtime.trust.setContacts(TRUSTED);
+
+ return { runtime, commands: createCommands(runtime) };
+}
+
+/** Report a target from `count` distinct trusted accounts. */
+function reportFrom(runtime, targetKey, category, count) {
+ for (const reporter of TRUSTED.slice(0, count)) {
+ runtime.reports.ingest({ reporter, category, createdAt: NOW - 100 }, targetKey);
+ }
+}
+
+const productAddressKey = (product) =>
+ `address:30078:${product.sellerPubkey}:${product.identifier}`;
+
+describe("1. seller allowlist mode", () => {
+ it("hides a seller who is not allowlisted", () => {
+ const { runtime } = setup({ allowlisted: [] });
+ const [governed] = governProductFeed([PRODUCT], runtime, "public-marketplace");
+ expect(governed.moderation.visible).toBe(false);
+ expect(governed.moderation.reasons).toContain("allowlist-miss");
+ });
+
+ it("shows an allowlisted seller", () => {
+ const { runtime } = setup();
+ const [governed] = governProductFeed([PRODUCT], runtime, "public-marketplace");
+ expect(governed.moderation.visible).toBe(true);
+ });
+
+ it("does not enforce the allowlist on checkout", () => {
+ const { runtime } = setup({ allowlisted: [] });
+ expect(checkoutVerdict(PRODUCT, runtime).allowed).toBe(true);
+ });
+});
+
+describe("2. administrative seller denial", () => {
+ it("removes every product from a denied seller", () => {
+ const { runtime } = setup();
+ runtime.admin.upsertContribution({
+ actor: SELLER_MOD,
+ kind: "user-deny",
+ targets: [{ type: "user", pubkey: SELLER }],
+ createdAt: NOW,
+ });
+
+ const grid = buildMarketplaceGrid([PRODUCT, OTHER_PRODUCT, RIVAL_PRODUCT], runtime);
+ expect(grid.map((product) => product.identifier)).toEqual(["sku-100"]);
+ });
+
+ it("blocks checkout for the denied seller", () => {
+ const { runtime } = setup();
+ runtime.admin.upsertContribution({
+ actor: SELLER_MOD,
+ kind: "user-deny",
+ targets: [{ type: "user", pubkey: SELLER }],
+ createdAt: NOW,
+ });
+ expect(checkoutVerdict(PRODUCT, runtime).allowed).toBe(false);
+ });
+
+ it("leaves the seller their own dashboard view", () => {
+ const { runtime } = setup();
+ runtime.admin.upsertContribution({
+ actor: SELLER_MOD,
+ kind: "user-deny",
+ targets: [{ type: "user", pubkey: SELLER }],
+ createdAt: NOW,
+ });
+
+ const explanation = explainToSeller(PRODUCT, runtime);
+ expect(explanation.visible).toBe(true);
+ expect(explanation.sellable).toBe(false);
+ expect(explanation.reasons).toContain("admin-user-deny");
+ });
+});
+
+describe("3. product-address denial", () => {
+ it("denies one listing without touching the seller's others", () => {
+ const { runtime } = setup();
+ runtime.admin.upsertContribution({
+ actor: LISTING_MOD,
+ kind: "address-deny",
+ targets: [
+ { type: "address", kind: "30078", pubkey: SELLER, identifier: PRODUCT.identifier },
+ ],
+ createdAt: NOW,
+ });
+
+ const grid = buildMarketplaceGrid([PRODUCT, OTHER_PRODUCT], runtime);
+ expect(grid.map((product) => product.identifier)).toEqual(["sku-002"]);
+ });
+
+ it("persists across a revision that changes the event id", () => {
+ const { runtime } = setup();
+ runtime.admin.upsertContribution({
+ actor: LISTING_MOD,
+ kind: "address-deny",
+ targets: [
+ { type: "address", kind: "30078", pubkey: SELLER, identifier: PRODUCT.identifier },
+ ],
+ createdAt: NOW,
+ });
+
+ const republished = { ...PRODUCT, id: "99".repeat(32), title: "Widget v2" };
+ const [governed] = governProductFeed([republished], runtime, "public-marketplace");
+ expect(governed.moderation.visible).toBe(false);
+ });
+});
+
+describe("4. exact-event denial", () => {
+ it("affects only the named revision", () => {
+ const { runtime } = setup();
+ runtime.admin.upsertContribution({
+ actor: LISTING_MOD,
+ kind: "event-deny",
+ targets: [{ type: "event", id: PRODUCT.id }],
+ createdAt: NOW,
+ });
+
+ const [denied] = governProductFeed([PRODUCT], runtime, "public-marketplace");
+ expect(denied.moderation.visible).toBe(false);
+
+ const republished = { ...PRODUCT, id: "99".repeat(32) };
+ const [fresh] = governProductFeed([republished], runtime, "public-marketplace");
+ expect(fresh.moderation.visible).toBe(true);
+ });
+});
+
+describe("5. trusted scam-report downranking", () => {
+ it("downranks on a single trusted scam report without hiding", () => {
+ const { runtime } = setup();
+ reportFrom(runtime, productAddressKey(PRODUCT), "scam", 1);
+
+ const [governed] = governProductFeed([PRODUCT], runtime, "public-marketplace");
+ expect(governed.moderation.downranked).toBe(true);
+ expect(governed.moderation.visible).toBe(true);
+ });
+
+ it("orders downranked listings after clean ones", () => {
+ const { runtime } = setup();
+ reportFrom(runtime, productAddressKey(PRODUCT), "scam", 1);
+
+ const grid = buildMarketplaceGrid([PRODUCT, RIVAL_PRODUCT], runtime);
+ expect(grid.map((product) => product.identifier)).toEqual(["sku-100", "sku-001"]);
+ });
+
+ it("escalates to review then denial as reports accumulate", () => {
+ const { runtime } = setup();
+ reportFrom(runtime, productAddressKey(PRODUCT), "scam", 2);
+ expect(checkoutVerdict(PRODUCT, runtime).needsReview).toBe(true);
+
+ reportFrom(runtime, productAddressKey(PRODUCT), "scam", 3);
+ expect(checkoutVerdict(PRODUCT, runtime).allowed).toBe(false);
+ });
+});
+
+describe("6. malware-based checkout denial", () => {
+ it("blocks checkout on a single trusted malware report", () => {
+ const { runtime } = setup();
+ reportFrom(runtime, productAddressKey(PRODUCT), "malware", 1);
+ expect(checkoutVerdict(PRODUCT, runtime).allowed).toBe(false);
+ });
+
+ it("treats malware far more harshly than misleading copy", () => {
+ const { runtime } = setup();
+ reportFrom(runtime, productAddressKey(PRODUCT), "misleading", 3);
+ expect(checkoutVerdict(PRODUCT, runtime).allowed).toBe(true);
+
+ reportFrom(runtime, productAddressKey(RIVAL_PRODUCT), "malware", 1);
+ expect(checkoutVerdict(RIVAL_PRODUCT, runtime).allowed).toBe(false);
+ });
+});
+
+describe("7. moderator capability checks", () => {
+ const signerFor = (pubkey) => ({
+ async getPublicKey() {
+ return pubkey;
+ },
+ async signEvent(template) {
+ return { ...template, id: "ff".repeat(32), pubkey, sig: "00".repeat(64) };
+ },
+ });
+
+ function commandsAs(pubkey) {
+ const runtime = createGovernanceRuntime({
+ applicationId: "commerce-example",
+ namespace: "commerce",
+ transport: createMemoryTransport(),
+ signer: signerFor(pubkey),
+ policy: COMMERCE_POLICY,
+ now: () => NOW,
+ });
+ runtime.admin.setRoles({
+ root: ROOT,
+ roles: COMMERCE_ROLE_CAPABILITIES,
+ actors: {
+ [LISTING_MOD]: ["listing_moderator"],
+ [SELLER_MOD]: ["seller_moderator"],
+ [CURATOR]: ["community_curator"],
+ },
+ });
+ return createCommands(runtime);
+ }
+
+ it("lets a listing moderator deny an address", async () => {
+ expect((await commandsAs(LISTING_MOD).denyAddress("30078", SELLER, "sku-001")).ok).toBe(true);
+ });
+
+ it("stops a listing moderator denying a seller", async () => {
+ expect((await commandsAs(LISTING_MOD).denyUser(SELLER)).code).toBe("not-authorized");
+ });
+
+ it("lets a seller moderator deny a seller", async () => {
+ expect((await commandsAs(SELLER_MOD).denyUser(SELLER)).ok).toBe(true);
+ });
+
+ it("stops a seller moderator denying a listing address", async () => {
+ expect((await commandsAs(SELLER_MOD).denyAddress("30078", SELLER, "sku-001")).code).toBe(
+ "not-authorized",
+ );
+ });
+
+ it("stops a curator denying an exact event", async () => {
+ expect((await commandsAs(CURATOR).denyEvent("1b".repeat(32))).code).toBe("not-authorized");
+ });
+
+ it("stops any moderator changing roles", async () => {
+ expect((await commandsAs(SELLER_MOD).setRoles({ actors: {} })).code).toBe("not-authorized");
+ });
+});
+
+describe("8. community curator ingestion", () => {
+ it("honors a curator's seller denial", () => {
+ const { runtime } = setup();
+ runtime.admin.upsertContribution({
+ actor: CURATOR,
+ kind: "user-deny",
+ source: "curated-scam-list",
+ targets: [{ type: "user", pubkey: SELLER }],
+ createdAt: NOW,
+ });
+
+ const [governed] = governProductFeed([PRODUCT], runtime, "public-marketplace");
+ expect(governed.moderation.visible).toBe(false);
+ expect(governed.moderation.reasons).toContain("community-user-deny");
+ });
+
+ it("ignores a curator contribution beyond their capability", () => {
+ const { runtime } = setup();
+ runtime.admin.upsertContribution({
+ actor: CURATOR,
+ kind: "address-deny",
+ source: "curated-scam-list",
+ targets: [
+ { type: "address", kind: "30078", pubkey: SELLER, identifier: PRODUCT.identifier },
+ ],
+ createdAt: NOW,
+ });
+
+ const [governed] = governProductFeed([PRODUCT], runtime, "public-marketplace");
+ expect(governed.moderation.visible).toBe(true);
+ });
+
+ it("cannot deny the root through a curated list", () => {
+ const { runtime } = setup();
+ runtime.admin.upsertContribution({
+ actor: CURATOR,
+ kind: "user-deny",
+ source: "hostile-list",
+ targets: [{ type: "user", pubkey: ROOT }],
+ createdAt: NOW,
+ });
+ expect(runtime.admin.state.userDeny.has(`user:${ROOT}`)).toBe(false);
+ });
+});
+
+describe("9. seller-dashboard explanation", () => {
+ it("exposes evidence counts to the seller", () => {
+ const { runtime } = setup();
+ reportFrom(runtime, productAddressKey(PRODUCT), "scam", 3);
+
+ const explanation = explainToSeller(PRODUCT, runtime);
+ expect(explanation.evidence.trustedReportTotal).toBe(3);
+ expect(explanation.evidence.trustedReportsByCategory.scam).toBe(3);
+ expect(explanation.evidence.trustedReporterPubkeys).toHaveLength(3);
+ });
+
+ it("withholds reporter identities from public surfaces", () => {
+ const { runtime } = setup();
+ reportFrom(runtime, productAddressKey(PRODUCT), "scam", 3);
+
+ const decision = runtime.evaluate(
+ { type: "address", kind: "30078", pubkey: SELLER, identifier: PRODUCT.identifier },
+ { profile: "public-marketplace" },
+ );
+ expect(decision.evidence?.trustedReportTotal).toBe(3);
+ expect(decision.evidence?.trustedReporterPubkeys).toEqual([]);
+ });
+});
+
+describe("10. no core changes required", () => {
+ it("expresses four different verdicts for one product from one snapshot", () => {
+ const { runtime } = setup();
+ reportFrom(runtime, productAddressKey(PRODUCT), "scam", 3);
+
+ const target = {
+ type: "address",
+ kind: "30078",
+ pubkey: SELLER,
+ identifier: PRODUCT.identifier,
+ };
+
+ expect(runtime.evaluate(target, { profile: "public-marketplace" }).visibility.effect).toBe(
+ "restrict",
+ );
+ expect(runtime.evaluate(target, { profile: "seller-dashboard" }).visibility.effect).toBe(
+ "restrict",
+ );
+ expect(runtime.evaluate(target, { profile: "checkout" }).transaction?.effect).toBe("deny");
+ expect(runtime.evaluate(target, { profile: "product-detail" }).visibility.overridable).toBe(true);
+ });
+
+ it("governs reviews without letting a bad product silence them", () => {
+ const { runtime } = setup();
+ runtime.admin.upsertContribution({
+ actor: SELLER_MOD,
+ kind: "user-deny",
+ targets: [{ type: "user", pubkey: SELLER }],
+ createdAt: NOW,
+ });
+
+ const review = {
+ id: "7f".repeat(32),
+ reviewerPubkey: BUYER,
+ productIdentifier: PRODUCT.identifier,
+ rating: 1,
+ };
+
+ const decision = runtime.evaluate(reviewAdapter.toTargets(review)[0], {
+ profile: "product-detail",
+ });
+ expect(decision.visibility.effect).not.toBe("hide");
+ });
+
+ it("uses only the published core API", async () => {
+ // Every import in this example resolves through a package entry point; if
+ // one reached into a package's internals, the reference guard and this
+ // assertion are what would catch it.
+ const core = await import("@nostr-governance/core");
+ for (const name of [
+ "createApplicationAdapter",
+ "evaluateObject",
+ "collectTargets",
+ "createPolicyDefinition",
+ "createRoleDefinition",
+ ]) {
+ expect(typeof core[name]).toBe("function");
+ }
+ });
+
+ it("governs a seller through the same engine as a product", () => {
+ const { runtime } = setup();
+ runtime.admin.upsertContribution({
+ actor: SELLER_MOD,
+ kind: "user-deny",
+ targets: [{ type: "user", pubkey: SELLER }],
+ createdAt: NOW,
+ });
+
+ const decision = runtime.evaluate(sellerAdapter.toTargets({ pubkey: SELLER })[0], {
+ profile: "public-marketplace",
+ });
+ expect(decision.visibility.effect).toBe("hide");
+ });
+});
+
+describe("adapter composition", () => {
+ it("reports decisions against the product's own identity", () => {
+ const { runtime } = setup();
+ const decision = runtime.evaluate(productAdapter.toTargets(PRODUCT)[2], {
+ profile: "public-marketplace",
+ });
+ expect(decision.key).toBe(productAddressKey(PRODUCT));
+ });
+
+ it("collects deduplicated targets across a feed", () => {
+ const { runtime } = setup();
+ governProductFeed([PRODUCT, OTHER_PRODUCT], runtime, "public-marketplace");
+ // Two products from one seller: 2 events + 2 addresses + 1 shared seller.
+ expect(runtime.activeTargets.size).toBe(5);
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index e55c673..108e891 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,8 @@
"name": "nostr-governance",
"version": "0.1.0",
"workspaces": [
- "packages/*"
+ "packages/*",
+ "examples/*"
],
"devDependencies": {
"@types/node": "^22.10.0",
@@ -19,6 +20,14 @@
"node": ">=22"
}
},
+ "examples/commerce": {
+ "name": "@nostr-governance/example-commerce",
+ "version": "0.1.0",
+ "dependencies": {
+ "@nostr-governance/core": "*",
+ "@nostr-governance/runtime": "*"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -438,6 +447,10 @@
"resolved": "packages/core",
"link": true
},
+ "node_modules/@nostr-governance/example-commerce": {
+ "resolved": "examples/commerce",
+ "link": true
+ },
"node_modules/@nostr-governance/nostr": {
"resolved": "packages/nostr",
"link": true
diff --git a/package.json b/package.json
index eab468c..1b3d92f 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,8 @@
"node": ">=22"
},
"workspaces": [
- "packages/*"
+ "packages/*",
+ "examples/*"
],
"devDependencies": {
"@types/node": "^22.10.0",
diff --git a/packages/core/src/adapter.js b/packages/core/src/adapter.js
new file mode 100644
index 0000000..f5b290c
--- /dev/null
+++ b/packages/core/src/adapter.js
@@ -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} adapter
+ * @returns {GovernanceApplicationAdapter}
+ */
+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} 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} adapter
+ * @param {(target: GovernanceTarget) => GovernanceDecision} evaluate
+ * @returns {Map}
+ */
+export function evaluateObjects(objects, adapter, evaluate) {
+ /** @type {Map} */
+ 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} adapter
+ * @returns {GovernanceTarget[]}
+ */
+export function collectTargets(objects, adapter) {
+ /** @type {Map} */
+ 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());
+}
diff --git a/packages/core/src/evidence.js b/packages/core/src/evidence.js
index c4ed598..65a7b4d 100644
--- a/packages/core/src/evidence.js
+++ b/packages/core/src/evidence.js
@@ -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} */
+ const reporters = new Set();
+ /** @type {Set} */
+ 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.
*
diff --git a/packages/core/src/index.js b/packages/core/src/index.js
index b52794d..28258fa 100644
--- a/packages/core/src/index.js
+++ b/packages/core/src/index.js
@@ -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} GovernanceApplicationAdapter
*/
diff --git a/packages/core/src/policy.js b/packages/core/src/policy.js
index 06fda70..acc571e 100644
--- a/packages/core/src/policy.js
+++ b/packages/core/src/policy.js
@@ -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;
}
diff --git a/packages/core/test/adapter.test.js b/packages/core/test/adapter.test.js
new file mode 100644
index 0000000..e085b94
--- /dev/null
+++ b/packages/core/test/adapter.test.js
@@ -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} */
+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([]);
+ });
+});
diff --git a/packages/core/test/evidence.test.js b/packages/core/test/evidence.test.js
index ec0e665..ff1cb9e 100644
--- a/packages/core/test/evidence.test.js
+++ b/packages/core/test/evidence.test.js
@@ -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()]));
+ });
+});
diff --git a/vitest.config.js b/vitest.config.js
index 9c10986..a8f6a86 100644
--- a/vitest.config.js
+++ b/vitest.config.js
@@ -4,6 +4,7 @@ export default defineConfig({
test: {
include: [
"packages/**/test/**/*.test.js",
+ "examples/**/test/**/*.test.js",
"tests/**/*.test.js",
],
environment: "node",