Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
244811cb49 Merge pull request 'Matrix mouth: read receipts on ack, presence from the brake' (#13) from heimdall/mouth-receipts-presence into main
Some checks failed
Publish Container / Build and publish Bifrost image (push) Failing after 12s
2026-11-08 16:26:10 +00:00
94606d04a6 Matrix mouth: re-read the brake before every join; pin the encoded presence path
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:24:17 +00:00
8938dbd739 Matrix mouth: review fixes (stale brake at the join, no receipt while braked, presence on the backlog path, guarded receipt log)
- answerInvites takes a fresh brake read after the long-poll
- readUpTo reads the brake when each receipt would post; held brake drops it, one log line
- the brake read and offline presence PUT precede the full-backlog early return
- a throwing logger cannot reject the unawaited receipt call
- tests: fake keeps the raw path and honours abort signals; the braked-round carve-out is offline presence PUTs only

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:18:44 +00:00
111f7e6f1a Mouth tests: the no-wait probe fails fast instead of hanging
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:09:25 +00:00
8b7783dfb0 bridge.md: ack posts her read receipt, presence follows her brake
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:04:50 +00:00
b1b145ee39 Matrix mouth: post each Persona's m.read receipt on her ack, set her presence from her brake
An ack posts her public m.read per room at the newest covered event, fire and
forget (never awaited, failure logged once, no retry). syncOnce reads the brake
once per round, passes it to answerInvites, sends set_presence on /sync and one
presence PUT on a released-to-braked transition (first braked round included).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 16:04:50 +00:00
5ad17d7c77 Merge pull request 'Matrix mouth: fetch and store chat images (cut A), ChatDelivery v3' (#12) from heimdall/chat-images-cut-a into main
Some checks failed
Publish Container / Build and publish Bifrost image (push) Failing after 22s
2026-11-08 09:30:04 +00:00
Heimdall
fd0a43e0e4 Image store: room directory must share the Persona directory's group; never create the Persona directory
The room directory's gid must equal the Persona directory's, else store-failed (a setgid
directory in the wrong group would still give files the wrong group). A missing Persona
directory is refused rather than created; only the room directory is made. The stat is
injectable so the group case is testable where the sandbox cannot chown.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:29:59 +00:00
Heimdall
129b517f46 Image store: never chmod a room directory; refuse when setgid is not inherited
A chmod by bifrost, which is not in the Persona's group, clears the setgid bit, so files
would take bifrost's group and the Persona's reader could not open them. Directories and
files are now created with exact modes (fixed umask around mkdir and open), the room
directory must have inherited setgid from the installer's Persona directory or the image
is refused (store-failed), and the Persona directory is never chmodded. Adds behavioural
redirect-refused and fetch-timeout tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:27:40 +00:00
Heimdall
a85942e858 Test: the streamed cap fails by assertion, not by timeout
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:21:46 +00:00
Heimdall
e23f7da2d5 Test: a failed image write leaves no temp file
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:19:32 +00:00
Heimdall
02275ee17b Matrix mouth: fetch and store chat images (cut A), ChatDelivery v3
m.image events in delivered messages are fetched as the receiving Persona,
classified by magic bytes, bounded by size and header pixel count, and stored
under IMAGE_STORE_ROOT/<persona>/<room hash>/<bytes hash>[.ext] (0640, dirs
2750). The delivery tuple gains index 11, nil or [path, mime, size, sha256];
the pending kind becomes gamecult.persona.chat_pending.v2 with Huginn's v3
golden bytes. Refusals keep the typed text line plus a fixed-word reason.
The mouth test homeserver is answered in-process so the suite runs under a
capped address space.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 09:18:55 +00:00
bf8ff62774 Merge pull request 'Estate-rule sim: regenerate stale results, fix weight-floor wording' (#7) from heimdall/stale-estate-numbers into main
Some checks failed
Publish Container / Build and publish Bifrost image (push) Has been cancelled
2026-11-06 07:50:10 +00:00
Heimdall
b6c1e9af73 Estate-rule sim: regenerate results.md and fix stale half-cap numbers and the "0 below unit" wording
REPORT.md E6 and results.md still held pre-fix numbers (half-cap 0.044/0.222, s max 0.953).
Regenerated with the current sim: s max 0.777; half-cap binding all four 0.000,
Patrons+Minds 0.008, Patrons alone 0.165. Weight is 0 only at amount <= unit/b (run.py,
REPORT.md, SPEC.md); the splitting table no longer prints a cliff "0 (below unit)". Test count 54.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 07:50:04 +00:00
Heimdall
527f96986a Merge remote-tracking branch 'origin/main' into heimdall/stake-vectors
Some checks failed
Publish Container / Build and publish Bifrost image (push) Has been cancelled
2026-11-06 04:34:57 +00:00
Heimdall
55253c48b4 Estate-rule sim: stake log_weight follows heimdall-stake's log form, no cliff at unit
w = max(0, 1 + log_b(x/unit)); all 14 single and 12 split golden rows now pass.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 04:34:57 +00:00
b3c4821a67 Merge pull request 'Estate-rule sim: pin the stake arm to heimdall-stake golden vectors' (#6) from heimdall/stake-vectors into main
Some checks failed
Publish Container / Build and publish Bifrost image (push) Has been cancelled
2026-11-06 02:32:24 +00:00
Heimdall
0f985c6003 Estate-rule sim: pin the stake arm to heimdall-stake's golden vectors
Copies CultLib PR #9 (head 84f932ec) tests/vectors/stake_golden.csv unmodified and
checks log_weight and split_weight against all 26 rows. 25 match within rounding;
the one sub-unit row differs by design: the arm's cliff at unit is Libby's
min_amount_per_identity = unit, which the vectors do not set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 02:32:20 +00:00
10ffad3d21 Merge pull request 'Estate-rule sim: purchased-stake arm (log weight, splitting adversary, half-influence cap)' (#5) from heimdall/estate-sim-purchased-stake into main
Some checks failed
Publish Container / Build and publish Bifrost image (push) Has been cancelled
2026-11-05 19:29:58 +00:00
Heimdall
081b0fc991 Estate-rule sim: review fixes for the purchased-stake arm
Docstrings state weight is per identity, not sybil-safe, hours unanalysed;
placeholder parameters marked in code; unit x roll sweep with interval;
identity-cost thresholds by bisection (about half the budget, not the
doubling-grid 51-82%); headline numbers pinned in a test; 'log bounds'
test renamed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 19:29:51 +00:00
Heimdall
6e4f94ae27 Estate-rule sim: purchased-stake arm (log weight, hours input, splitting adversary, half-influence cap, E6)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 13:13:43 +00:00
b95234bbef Merge pull request 'Estate-rule sim harness: populism, strategic agents, transitive delegation' (#4) from heimdall/estate-rule-sim into main
Some checks failed
Publish Container / Build and publish Bifrost image (push) Has been cancelled
2026-11-05 10:59:23 +00:00
a338c594be Merge pull request 'Proposal: charter amendment provision (in-chat charter)' (#3) from heimdall/charter-amendment-proposal into main
Some checks failed
Publish Container / Build and publish Bifrost image (push) Has been cancelled
2026-11-05 10:59:13 +00:00
5900284742 Merge pull request 'The mouth delivers media events as a typed line' (#2) from heimdall/mouth-media-line into main
Some checks failed
Publish Container / Build and publish Bifrost image (push) Has been cancelled
2026-11-05 10:58:56 +00:00
Heimdall
5d352c6c20 estate-rule sim: copy-the-ballot strategy and E3f cap sweep
Spec 5b (source: Golz et al. 2018 intro citing Behrens et al. 2014), copier
agents in estatesim.py, E3f alpha x cap x copy sweep with a computed
paired-interval verdict, 10 tests. Result: with copying the cap does not
reduce effective concentration. Earlier results.md sections unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 07:05:15 +00:00
Heimdall
9d032c2dff estate-rule sim report: correct cell counts and cap/appeal claims against results.md
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 04:22:57 +00:00
Heimdall
70e9cef3d5 estate-rule sim: renewal and peer edges, full results, report
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 04:22:28 +00:00
Heimdall
dda8ee7c58 estate-rule sim harness: stdlib core, runner, tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:39:53 +00:00
Heimdall
0d52eeaeee estate-rule sim spec rev 2: transitive delegation chains, depth sweep, uncast rules
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:39:53 +00:00
Heimdall
a23b6d94f1 docs: estate-rule sim harness spec (populism measure, delegation, strategic agents)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 00:24:58 +00:00
Heimdall
a09746c8f7 Revise charter amendment proposal with settled estate rules
3-of-4 for ordinary rules, all four for a short named entrenched list,
reduction rule with floor/notice/delay, incentive-first framing, Minds
count once with dissent recorded, no notice no approval, freeze risk
stated beside the veto rules. Numbers remain placeholders.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 23:18:07 +00:00
Heimdall
2c37e8a0de docs: charter proposal, fix clause cross-references
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 21:47:52 +00:00
Heimdall
f7770df147 docs: charter amendment provision proposal (in-chat charter)
Draft only; not in force. Does not change the legal entity's statutes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 21:47:45 +00:00
Heimdall
6548ca8385 mouth: deliver media events as a typed line
m.image/m.file/m.audio/m.video reached Personas as a bare filename and
dropped content.url. The delivery text now leads with
[kind filename mime size url], then the caption. Applies to the delivery
and to context lines. Wire v1 is unchanged. Nothing is fetched: a Persona
turn is a tool-less text call.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 18:09:43 +00:00
e396305c19 Merge pull request 'The mouth refuses a Persona send when the room moved past what she held' (#1) from eureka-body/optimistic-send-mouth into main
Some checks failed
Publish Container / Build and publish Bifrost image (push) Has been cancelled
2026-11-03 11:03:11 +00:00
7adc7315a3 send gate: re-check the brake after the gate wait, before the PUT
A send queued at the room gate passed the brake check at entry only, so a
brake placed while it waited still let the PUT through. The check now also
runs inside the gate, just before the PUT, and answers a typed Brake refusal.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 10:37:22 +00:00
Meta Vens
2387cbc7f6 send gate tests: pin the head read's shape
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:06:39 +01:00
Meta Vens
981164173f send gate tests: arrival order is the gate's, not the call site's
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:02:47 +01:00
Meta Vens
2dd4bd863e persona matrix mouth: send gate tests, the captured #swarm head, v2 golden fixtures
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:02:07 +01:00
Meta Vens
a76c70cc39 persona matrix mouth: a v2 send is refused if the room moved past her basis; check and PUT are one step per room across every Persona
Removes the bare PUT; the one PUT now lives inside the room gate. v1 stays served unchecked through the same gate.
Wire: chat_send_request.v2 (basis) and chat_send_receipt.v2 (newer); RoomMoved is v2 only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:01:44 +01:00
Meta Vens
c29c5d1a4e persona matrix mouth: a delivery's context is read from Matrix, not remembered
Some checks failed
Publish Container / Build and publish Bifrost image (push) Has been cancelled
The per-room in-memory history map is gone: a restart emptied it and /sync resumed from the
cursor, so every room (a DM first) lost its past. When a delivery is built the mouth now reads
the room's timeline from the homeserver as that Persona (GET /messages dir=b, or the thread's
root plus /relations m.thread for a threaded message), keeps m.room.message from everyone
including her own, oldest first, ending before the delivered event. Newest lines are kept to a
16 KiB byte budget (each clipped to limits.contextBytes), at most the 20 lines the delivery
tuple carries. One fetch per room or thread per sync round. A failed fetch gives empty context
and a redacted log line; the delivery still goes out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 05:10:57 +01:00
Meta Vens
98689b787d persona matrix mouth: each Persona's rooms are the shared MATRIX_ROOMS plus her own MATRIX_ROOMS_<SLUG>
Some checks failed
Publish Container / Build and publish Bifrost image (push) Has been cancelled
SLUG is the Persona slug upper-cased with "." and "-" turned into "_". Lists are
deduplicated; an absent variable gives shared rooms only; a malformed project alias,
or two Personas mapping to one variable, is refused at start. The send path's
UnknownRoom check already reads the mouth's own list, so another Persona's project
room is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 04:12:31 +01:00
Meta Vens
3bff0d779c persona matrix mouth: log the many-peer server's errors and keep serving
Some checks failed
Publish Container / Build and publish Bifrost image (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 01:51:57 +01:00
Meta Vens
b892c64f00 persona matrix mouth: one session per client on the many-peer RUDP server; every request answered on the connection it arrived on
Two Persona turns running at once no longer take each other's session. Requests run in order per client, not across clients.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 01:51:57 +01:00
Meta Vens
a7621f9eed Research: Discord-like roles on Matrix and the chat client (matrix-roles-from-bifrost-estates)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:09:06 +01:00
Meta Vens
ccb0e6b08c Research: email mouth prior art (payment-mints-invite-bifrost-ledger-email-mouth)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:09:06 +01:00
Meta Vens
6cf24644bc Research: customer-member onboarding prior art and body facts (customers-get-voting-power-onboarding-owed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 19:09:05 +01:00
Meta Vens
cc76f0f2d0 mouth-all-personas: one Matrix mouth serves every Persona; deliveries carry mentions and is_direct, refusals are typed; same-server DM invites accepted; the ring is deleted (Persona turns are the heartbeat's) (squash of eureka-body/mouth-all-personas 7b63031..5b628e2; Soul s1-s3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 02:49:59 +01:00
Meta Vens
18bbe6c312 Research: estate tally adds bloc-parliament-at-threshold and operator influence cap (Fork Desk 41)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:15:38 +01:00
Meta Vens
1036849534 Research: estate tally note points at its sims/ folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:07:49 +01:00
Meta Vens
3f241c0fe4 Research: estate tally game theory, power indices and simulations (Fork Desk 39, estate-internal-tally)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:07:10 +01:00
Meta Vens
eee86193c0 Research: swarm governance on Bifrost, readiness and prior art (follow-up swarm-governance-on-bifrost)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:48:14 +01:00
Meta Vens
7b630312e1 persona-mouth: Matrix ear and mouth for Personas (brake-refusing sends, token only in the Authorization header, cursor after ack, one ring per idle window); delete the Discord posting actuator (squash of eureka-body/persona-mouth 1894f65..ab2bdc6; Soul s1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 19:41:30 +01:00
Meta Vens
1894f653d0 Back-channel logout: members who leave Active are logged out of chat (gamecult-chat cut bifrost-oidc-backchannel-logout r2)
Merges gamecult-chat/bifrost-oidc-backchannel-logout (83daabe..37c8fe1).
gamecult-chat:cut_report:cut-bifrost-oidc-backchannel-logout.h1/h2; Soul s1 and s2 hold. Not deployed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 02:10:34 +01:00
Meta Vens
37c8fe1a72 Logout tests: pin the loop's own tick-failure catch now that subject faults are isolated
A pass that cannot read its subjects (a scope factory failing like a database outage) is logged
by exception type only and retried after the interval, never in a tight loop. The earlier loop
test reached this catch through a per-subject exception, which the isolation now absorbs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 01:57:31 +01:00
Meta Vens
8340ee0e92 Logout reconciler: one subject's unexpected exception does not stop the pass
Caught per subject, logged at error with the subject id and exception type only, authorizations
left valid; cancellation still stops the pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 01:50:54 +01:00
Meta Vens
ec66fba5ca Logout client: no auto-redirect, pin the redirect rule and the 10 s timeout
The named logout HttpClient's primary handler disables auto-redirect, so a 3xx from MAS is a
non-delivery retried like any non-2xx instead of being followed to a final 2xx that revokes.
Pinned through the real DI registration against a real loopback listener answering 302, and
the registered client's Timeout is pinned to exactly 10 s. The unexpected-exception test is
replaced by the isolation test (its fix is the next commit) and the cancellation test stands alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 01:50:53 +01:00
Meta Vens
51fe807b66 Logout loop test: the tick failure is logged by exception type only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 01:03:02 +01:00
Meta Vens
868b91fefb Logout reconciler: a recovered subject's backoff clears at once, not when it expires
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:57:31 +01:00
Meta Vens
af64868326 Logout tests: loop, log content, 2xx range, backoff reset, exception and cancellation paths
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:56:18 +01:00
Meta Vens
c9e470bef4 docs/oidc-issuer: BackchannelLogoutUri, reconcile semantics, MAS facts and the tick-window tradeoff
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:39:57 +01:00
Meta Vens
69b670e3af Fix logout test support compile errors
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:38:56 +01:00
Meta Vens
404dafdd40 OIDC back-channel logout: a reconciler ends the sessions of subjects who are not Active
OidcLogoutReconciler derives pending logouts from valid OpenIddict authorizations x
membership, POSTs one signed logout token per non-Active subject to MAS, and revokes
the authorizations only after a 2xx. Registered only when
Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri is set. The active-member reading is
the endpoints' existing one, now shared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:38:15 +01:00
Meta Vens
83daabe512 Bifrost as OIDC issuer for the Matrix chat (gamecult-chat cut bifrost-oidc-issuer, squash of 1ef289a..ec4acfa and abd12b3..)
One configured client (stale clients removed with their tokens and authorizations), Active membership re-checked at token and userinfo through IsStillActiveMemberAsync, PKCE required by the server option, OpenIddict.Server.AspNetCore + EntityFrameworkCore 7.7.1 with no vulnerable transitives. Soul s2, s3 and s4 hold; Low conformance gaps in gamecult-chat:follow_up:oidc-issuer-spec-conformance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 04:57:16 +01:00
Meta Vens
78d38d92e6 Merge security/bifrost-dependency-pins: Npgsql 8.0.3, System.Text.Json 8.0.5, Caching.Memory 8.0.1 pinned; NuGet lockfiles; locked-mode restore (audit 2026-10-22; Soul cleared)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:45:01 +02:00
Meta Vens
d7d4b21c4b Commit NuGet lockfiles for the web and test projects
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:29:46 +02:00
Meta Vens
a15648db04 Pin Npgsql 8.0.3, System.Text.Json 8.0.5, Caching.Memory 8.0.1; enable NuGet lockfiles
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:26:49 +02:00
Meta Vens
b2110f53fa Merge hands/heimdall-callback-signed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 18:11:28 +02:00
Meta Vens
b7e3bdf28d Heimdall sign-in: the attempt reader takes no optional nonce; ownership is one ordinal comparison
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 23:02:48 +02:00
Meta Vens
d743e4f363 Heimdall tests: an unreadable form with a genuine attempt cookie present is still no attempt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 23:00:06 +02:00
Meta Vens
efb278a910 Heimdall tests: the no-attempt-field refusal carries a genuine code, so only the missing nonce can refuse it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 22:57:26 +02:00
Meta Vens
d463336206 Heimdall tests: a genuine attempt cookie under a name that is no slot is no attempt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 22:41:31 +02:00
Meta Vens
b54e8d5dc8 Heimdall tests: fix a string literal in the parallel-starts test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 22:32:07 +02:00
Meta Vens
053272a0f3 Heimdall tests: re-point the cookie-ring tests to the slot cookie; a refusal with no matching attempt deletes nothing
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 22:31:25 +02:00
Meta Vens
15f96c408c Heimdall sign-in: attempt cookies live in a ring of two fixed slots, so the Cookie header is bounded by construction; pin the completion form's id
Start takes a free slot or overwrites the one expiring first; parallel starts carry the same request
cookies, choose the same slot and overwrite each other. Complete finds its attempt by scanning the
slots for the posted nonce; an overwritten attempt finds none and is refused like any other. The
per-request counting sweep, its byte budget and the nonce-named cookie are deleted.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 22:25:24 +02:00
Meta Vens
8186d6f8fa Heimdall tests: drop the exact-byte boundary test; the 5120-byte bound has a 3 KB margin, and no two attempt cookies can reach it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 21:33:17 +02:00
Meta Vens
21f2c75c78 Heimdall tests: name the sums near the bound when the boundary pair is missing
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 21:32:31 +02:00
Meta Vens
ec2f812b59 Heimdall attempt cookie bound: pin the exact byte boundary and that unrelated cookies are never swept
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 21:26:31 +02:00
Meta Vens
4a28cea1f9 Heimdall sign-in: Start sweeps dead and oldest attempt cookies to keep the Cookie header bounded; pin the complete page's real form
Start deletes request attempt cookies that are no live attempt, then the live ones expiring first until the new attempt and the survivors fit 5120 bytes, so the newest attempt always completes and the header stays under nginx's 8 KB. Behavioural tests drive a cookie jar past the bound and read the served page's form fields and action through the test host.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 20:41:41 +02:00
Meta Vens
79c29fe16e Merge hands/mill-votes-cut-5b: vote budgets per prefix and account, mint budget 30 per address per hour, monotonic windows, oldest-unexhausted eviction at the cap
Soul passes concept-mill-votes:verdict:cut-5b.s1 to .s4. Operator rulings
mint-budget-30-per-hour and budget-table-evict-oldest. Undeployed; Cut 6
deploys with the sign-in fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:35:34 +02:00
Meta Vens
cf74019194 Mill votes: the very-busy refusal offers sign-in to a voter who is not signed in
Accounts have their own table, so signing in works while the cast or mint
table is full. A signed-in voter is not told to sign in. Pinned through the
full pipeline at a small cap, for both audiences.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 20:23:59 +02:00
Meta Vens
a9b9b29bf9 Pin the account table's own cap
The cap-not-wired-accounts hand mutant survived: no test filled the account table.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 19:58:09 +02:00
Meta Vens
912d083894 Fix a string literal broken across lines in the table-full test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 19:17:27 +02:00
Meta Vens
436a02864f Vote budgets: at the cap a new key evicts the least recently used key with permits left
Ruling budget-table-evict-oldest. FixedWindowBudget keeps two linked lists (window start, last use among keys
with permits left) so eviction and expiry are O(1); a spent key is never evicted, and a table full of spent keys
refuses a new key as TableFull with the true wait. Accounts get their own cast table. The vote page checks the
subject before spending any key, and the refusal names its reason: cast budget, mint budget, or a busy table.
Not verified by a build yet at this commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 19:16:30 +02:00
Meta Vens
8f57dc89f0 Pin the no-client-address bucket of the vote budgets
Kills the Stryker survivor on the null-client key: a request with no address shares one
bucket and does not throw. Builds with a8c3ecf; test added after the main verify job.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 17:38:56 +02:00
Meta Vens
a8c3ecf495 Vote budgets: monotonic windows, a key cap, 30 mints an hour and a readable 429
FixedWindowBudget times windows on the clock's monotonic timestamps so a wall-clock
step cannot stretch or reset one, expires windows from a start-ordered queue instead
of a periodic sweep, and holds at most MaxTrackedKeys keys: a new key at the cap is
denied with the true wait and a held key is never evicted. MintPermits is 30 per hour
per the operator ruling, and a 429 now carries a plain-text reason; a refused new
device is told it can sign in. Not yet built or tested at this commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 17:32:01 +02:00
Meta Vens
2c8384e44a Pin that a signed-in retract spends the account budget
Hand mutant retract-key-ignores-signed-in-voter survived the first table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:46:45 +02:00
Meta Vens
9fcef927fd Pin the budget sweep and Retry-After rounding; drop a redundant Math.Max
Survivors of the first Stryker pass on FixedWindowBudget: the sweep dropping
live windows, sweeping every call, and the exact window boundary (masked by
the sweep in the first test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:35:50 +02:00
Meta Vens
34f5dfa6f1 Heimdall attempt cookie: a deletion carries only the Path
HttpOnly, Secure and SameSite on a deletion decide nothing (a cookie is matched on name and path); Stryker found those two booleans surviving. The deletion is pinned by path, expiry and the absence of Max-Age.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:08:10 +02:00
Meta Vens
7f375ecf8f Pin the empty Origin branch with a server-built request
HttpClient drops an empty header value, so the first version of the test
never sent the header; the request is now built on the TestServer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:02:01 +02:00
Meta Vens
0f71a9f3ca Heimdall tests: fix two log-join string literals broken by a newline
d695674 does not build (two string literals split across lines); this commit repairs it. cf4e5b4 inherits the break.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:01:06 +02:00
Meta Vens
fbf6e43955 Concept vote rate limits: cast and mint budgets per account or network prefix
One FixedWindowBudget (injectable clock) owned by ConceptVoteRateLimits;
the vote page 429s with Retry-After before any row or cookie. Prefix
lengths and budgets are validated in every environment. Pins the empty
Origin branch of CastRequestGuard.

Unverified; not yet built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:00:23 +02:00
Meta Vens
cf4e5b4327 Heimdall complete page: run its real script under Node in tests
A committed harness (Fixtures/complete-page-harness.cjs) executes the served page's inline script against a simulated browser; tests pin that the fragment is dropped before the post, an error status never submits, and the code is read only from the fragment. Node is required on PATH; a missing Node fails, it does not skip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:58:20 +02:00
Meta Vens
d695674359 Heimdall sign-in: redact the app secret from client logging, expire the attempt cookie at its path, bound the return path
- The HeimdallAuthService client redacts x-heimdall-app-secret from HTTP client logging at every level; a Trace canary test pins it.
- The attempt cookie's deletion carries its Path and no Max-Age (which outranks the 1970 expiry); pinned on success and refusal paths.
- LocalReturnPath is at most 256 characters, else /App, so the attempt cookie stays far below the browser limit; pinned at the bound and with the heaviest-escaping path.
- A set BaseUrl with a blank AppSlug is refused at Start before any Heimdall call; behavioural test.

Builds untested until the verification job at the end of this batch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:58:19 +02:00
Meta Vens
f5432f7a42 Heimdall sign-in: pin the public base URL path, the nonce shape and the Discord role requirement; drop a redundant key re-check
The key lookup's second cache check after taking the gate changed nothing the refetch throttle did not
already decide, so it is removed. Mutation testing of the diff found the three behaviours above unpinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 05:25:14 +02:00
Meta Vens
ad06e2ffd5 Heimdall completion route: no antiforgery opt-out (the form is read by hand); pin a malformed form body
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:43:45 +02:00
Meta Vens
713eff934a Heimdall tests: register the GitHub OAuth scheme through host settings so the sign-in return path can be read from its state
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:42:50 +02:00
Meta Vens
5626532571 Heimdall sign-in: the start result carries the cookie name; fix a test string literal
The commit before this one did not compile (a split string literal in the tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:41:36 +02:00
Meta Vens
bad2117ea8 Heimdall sign-in: the attempt is a cookie, and a code the browser presents is redeemed at Heimdall
Start puts a per-attempt nonce in the returnTo it sends Heimdall and keeps the attempt (provider,
member flag, local return path, expiry) in a Data Protection cookie named for that nonce. Heimdall's
completion page sends the browser to /auth/heimdall/complete with a code in the URL fragment; the
page posts it back, Bifrost redeems it at Heimdall, requires the redeemed returnTo to name the
cookie's nonce, verifies the access token for the redeemed account, and signs that browser in.

Deleted: the callback route, the wait route, the server-side attempt store, the pending state and
the backend_callback handoff. Added: LocalReturnPath for return targets, Heimdall:AppSecret sent as
x-heimdall-app-secret (member starts are refused without it), a short timeout on the key fetch, and
a key lookup that does not hold the fetch gate for cached keys. Refusals at the completion route all
answer one redirect and log a fixed reason.

Tests are rewritten against a fake Heimdall with start and redeem routes. Not compile-checked by this
commit alone; later commits record the verification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:40:47 +02:00
Meta Vens
1ab67da47b Heimdall sign-in tests: pin each refusal rule by its reason; token dates cannot overflow
The forgery and wrong-claim tests now assert which rule refused, start failures,
blank attempt ids, capability shapes and date extremes are covered. A token whose
exp is the largest representable date no longer overflows the clock-skew sum, and
the unreachable blank-return-url fallback is removed (Program defaults it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:26:48 +02:00
Meta Vens
650bf6bcc6 Heimdall token verifier: split decoding and key reading into small steps
Behaviour is unchanged. The catch-and-refuse blocks sit in their own small
methods so the mutation tool can compile mutants of the rest of the verifier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:05:07 +02:00
Meta Vens
e99eea8769 Heimdall token verifier: one process-wide instance owns the key cache
The verifier was registered as a typed client, which is transient, so each request
would have started with an empty key cache. It is now a singleton that takes its
HTTP client from the factory per fetch. Test hosts share one in-memory database
root so EF keeps a single service provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:55:35 +02:00
Meta Vens
f3b8acbbc5 Heimdall sign-in: accept only a Heimdall-signed token for an attempt Start issued, in the browser that started it
The callback verifies the EdDSA access token against Heimdall's JWKS and takes the
account, display name and member access from its claims; attempts expire, are
bounded, are completed only if issued, and are redeemed once with a per-attempt
binding cookie. Not yet built or tested; the first verification run follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:54:13 +02:00
Meta Vens
68214ced30 Merge hands/mill-votes-cut-5: the vote page, the device-cookie voter key and cookieless CSRF
Held by concept-mill-votes:verdict:cut-5.s2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:54:41 +02:00
Meta Vens
526102ec8a ConceptVotePageTests: pin a repeated Origin header and the exact client-error codes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:26:21 +02:00
Meta Vens
75d2cedaa2 ConceptVotePageTests: drop the empty-Origin case (HttpClient never sends an empty header)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:18:06 +02:00
Meta Vens
d3a7611f2a Vote page: explicit no-store (the antiforgery write no longer supplies it), handler must match the request, empty Origin counts as present
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:15:13 +02:00
Meta Vens
30ba2af3c1 ConceptVotePageTests: close the Visitor class correctly
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:13:42 +02:00
Meta Vens
6554a1b017 ConceptVotePageTests: fix a stray brace
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:11:50 +02:00
Meta Vens
59d5dbd068 Vote page: gate in the page filter's execution stage (the selected stage has no Result)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:11:01 +02:00
Meta Vens
e0b3b1a38a Vote page: cookieless CSRF (Origin / Sec-Fetch-Site) and a defined error for handler-less requests
Drops antiforgery from the anonymous vote flow: no antiforgery cookie, no hidden token.
CastRequestGuard is the one decider; VoteModel.OnPageHandlerSelected is the one gate and
answers 4xx when no handler is selected (was a NullReferenceException 500).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:09:25 +02:00
Meta Vens
0c6f9216b6 Merge origin/main into hands/mill-votes-cut-5 2026-10-11 00:06:51 +02:00
Meta Vens
72741198fa Merge hands/mill-votes-cut-3: concept subject registration from the Mill manifest
Held by concept-mill-votes:verdict:cut-3.s2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:42:02 +02:00
Meta Vens
c9e0b7b0e1 Registry tests: pin the order of already-retired entries
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:26:43 +02:00
Meta Vens
747183078a Registry tests: build the over-bound manifest as an array
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:08:11 +02:00
Meta Vens
9c4124dd9f Registry tests: spell the separator and format characters as escapes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:04:00 +02:00
Meta Vens
0bb3b2f76c Registry: typed refusals for every input failure; strict manifest reader; retire rerun converges
Empty, NUL and unreadable paths, malformed UTF-8 and lone surrogates refuse
with the command's exit 1. The manifest is bounded (1 MiB, 1000 ships) and
duplicate JSON properties are refused. Titles refuse format, line and
paragraph separator characters and edge whitespace. Naming an already
retired key is a recorded no-op, so a rerun converges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:01:40 +02:00
Meta Vens
f7ff76daa4 ConceptVotePageTests: find the visitor's own row by account-less key, drop the empty-tag assertion
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:54:03 +02:00
Meta Vens
e05e1bed81 ConceptVotePageTests: attach the log capture through ConfigureServices
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:51:24 +02:00
Meta Vens
f5a1f387ca Mill vote page: anonymous cast, change and retract, cookie minted only by a cast
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:48:04 +02:00
Meta Vens
af3b712b26 VoterKeyResolver: the one reader and writer of the bifrost_voter device cookie
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:48:04 +02:00
Meta Vens
a35119362a Merge hands/mill-votes-cut-4: public tally export with one-origin CORS
Held by concept-mill-votes:verdict:cut-4.s2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:42:42 +02:00
Meta Vens
11df901cb6 Registry tests: pin the refusal message shape
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:35:46 +02:00
Meta Vens
dcc3425520 Tally export Vary: plain append, no dedupe
The dedupe branch only added equivalent mutants; a repeated Origin token is
harmless.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:19:52 +02:00
Meta Vens
464da6bd50 Tally export: every response under the route varies by Origin
UseConceptTallyCors replaces UseCors and appends Vary: Origin to every
response under the export route, preflights and 404s included, keeping any
Vary already set. A single-origin ASP.NET policy never adds it itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:46:51 +02:00
Meta Vens
a43f90b48d Registry: one throw for a missing or non-array ships; tests pin refusal codes, title bound and plan order
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:39:04 +02:00
Meta Vens
46d92200e5 Registry: parse and read through small helpers so definite assignment survives mutation
Stryker flagged ParseManifest, ReadString and RunAsync as compile errors in safe
mode (out vars and try-assigned locals), leaving them unmutated. Behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:34:03 +02:00
Meta Vens
624cfd1567 Concept tally export and its CORS policy move out of Program.cs
One owner for the route and the policy, so Stryker can mutate them: it
flags every mutant of Program.cs as a compile error (safe mode), which
left the export untested by mutation. Program.cs keeps UseCors and the
two registration calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:20:13 +02:00
Meta Vens
cc00c64c9e Startup validation: an allowed origin with user info is refused
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:07:47 +02:00
Meta Vens
34325e0efd Registry tests: the helper voter's device id no longer embeds the subject key
Commit b17436c does not pass: the forged-key test failed on an invalid device id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:00:04 +02:00
Meta Vens
d13b808471 Public concept tally export with one-origin CORS
GET /public/concepts/{collection}/tallies returns per-live-subject up, down and
member counts only, anonymous, Cache-Control public max-age=60. CORS policy
ConceptTallies is built from ConceptVotes:AllowedOrigins, GET only, no
credentials, required on this route alone. Production startup refuses an
allowed origin that is not an exact https origin.

Not yet built or tested when committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:59:51 +02:00
Meta Vens
b17436ce4f Concept subject registry and concepts plan|apply host command
ConceptSubjectRegistry is the only writer of ConceptSubject. ConceptKey is the
one key grammar (anchored with \z), shared with ConceptAppraisalService.
Not yet built or tested at this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:58:04 +02:00
Meta Vens
95ac1f84e6 Merge hands/mill-votes-cut-2: appraisal votes, voter identity and network tag
Held by concept-mill-votes:verdict:cut-2.s2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:53:50 +02:00
Meta Vens
bd06ef0c74 ConceptAppraisalServiceTests: a recast through a fresh context persists the changed stance 2026-10-10 09:56:52 +02:00
Meta Vens
eca71f589c Startup validation: tag secret minimum length and prefix lengths inside the address family, naming keys not values
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:56:34 +02:00
Meta Vens
162d7b1f0d Purge audit: Detail carries window and count, never the network tag the member dashboard would render
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:56:08 +02:00
Meta Vens
abb0986508 VoterIdentity: anchor the device id grammar at \z so a trailing newline or control char is no second identity
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:55:26 +02:00
Meta Vens
d171713ab1 Concept vote tests: close the mutation survivors in view, purge window, slug and prefix masking
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:42:55 +02:00
Meta Vens
214f555781 ConceptAppraisalServiceTests: build actors fully before saving; order after ToList
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:59:42 +02:00
Meta Vens
270ab95cc7 Migration AddConceptVotes, generated by dotnet-ef 8.0.0
The snapshot is regenerated; EF also reordered two pre-existing properties
(BridgeAction.TargetSurface, one ProjectId) into its canonical order.
Migration discovery now lists thirteen ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:39:59 +02:00
Meta Vens
3c135b8e67 Concept votes: subjects, appraisals, voter identity, network tag and their one writer
ConceptAppraisalService is the only appraisal writer; tallies are derived from
rows. VoterIdentity.For decides voter key and class; NetworkPrefix is the only
prefix computation (HMAC tag). NetworkTagSecret is required in production.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:31:43 +02:00
Meta Vens
67072b2892 Merge hands/mill-votes-cut-1: loopback-only forwarded headers, pinned key ring
concept-mill-votes:cut_report:cut-1.h1 and h2, held by Soul
(concept-mill-votes:verdict:cut-1.s2). Production refuses to start without
DataProtection:KeysPath; the deploy (Cut 0b) must set it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:27:04 +02:00
Meta Vens
717e97bdcc Dockerfile: drop the forwarded-headers env switch; Program.cs is the one owner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:58:35 +02:00
Meta Vens
bdff1a06a1 HostTrustTests: key ring comes only from KeysPath, canary can fail, Dockerfile switch guarded
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:58:34 +02:00
Meta Vens
c259b91e69 HostTrustTests: pin the forward limit and the exact-loopback trust set
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:39:16 +02:00
Meta Vens
da3e73ad4d HostTrustTests: give the probe context an http scheme to start from
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:31:21 +02:00
Meta Vens
5093ba81aa HostTrustTests: import ForwardedHeadersOptions namespace
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:27:40 +02:00
Meta Vens
2e0de9601c Trust loopback forwarded headers and pin the Data Protection key ring
UseForwardedHeaders runs first with KnownProxies exactly loopback and
ForwardLimit 1; Data Protection always uses application name "Bifrost";
the startup validator refuses production without DataProtection:KeysPath.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:26:38 +02:00
Meta Vens
837bc11a15 Merge hands/mill-votes-cut-0a: the live migration-discovery fix on main
Concept Mill votes Cut 0a (concept-mill-votes:cut_report:cut-0a.h1), held by
Soul (concept-mill-votes:verdict:cut-0a.s1). daedcc4, the live release, is now
an ancestor of main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:24:15 +02:00
Meta Vens
d4e6137537 Test migration discovery: every Migration subclass is listed, in order
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:17:49 +02:00
Meta Vens
d5ebd9b420 Merge origin/codex/bifrost-migration-metadata: migration discovery attributes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:17:36 +02:00
Meta Vens
e77d6220de Concept Mill votes target: re-verified body facts, authority map, cut order
Imagination pass that admitted cut specs 0a through 7. Facts re-probed at
Bifrost bf83a93 and on the host; the from-memory prior-art list is replaced
by a citation of docs/research/anonymous-vote-stuffing-prior-art.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:16:45 +02:00
Meta Vens
bf83a9321b Research: prior art on anonymous-vote stuffing defences
Served concept-mill-votes Cut 4 (anti-stuffing defence). Facts with source
pointers on PoW captchas, device cookies, network buckets, ePrivacy status of
anti-abuse cookies, and after-the-fact purge at HN, Stack Exchange and Reddit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:01:18 +02:00
Meta Vens
20d5efa9c6 Concept Mill votes: target and body facts
The Imagination map of 2026-10-08, committed as the target document of the
concept-mill-votes campaign. Typed invariants, rulings and cut specs live in
the eureka mind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:55:42 +02:00
Meta Vens
160fac20c9 Merge watchdog bifrost-notice-retry: a failed notice is never dropped
Eureka idunn-watchdog cut bifrost-notice-retry, attempts 1-2. Soul verdict
idunn-watchdog:verdict:cut-bifrost-notice-retry.s2 holds. Retries with the
same nonce after 1,2,4..32 then every 60 minutes; the unit fails from the
third attempt while retrying (ruling notice-retry-policy).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 03:02:44 +02:00
Meta Vens
9b28e9b27a Watchdog notice reader test: status reads the same fake clock as the runs
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 02:25:13 +02:00
Meta Vens
fcf81ea564 Watchdog notice reader test: no environment variable moves the clock
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 02:19:14 +02:00
Meta Vens
0312e8bbac Watchdog notice reader: a future stamp is eligible now; no production clock seam
A failed entry stamped ahead of the clock (host clock stepped forward then
corrected) is eligible now instead of waiting out the skew; the next attempt
rewrites the stamp. The WATCHDOG_NOTICE_NOW_MS env var is deleted: the reader
reads Date.now(), and tests fix it with a --import preload (tests/fake-clock.mjs).
The unknown-entry tests now rerun far past the backoff so a retried unknown fails.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 02:19:01 +02:00
Meta Vens
c468f05eaf Watchdog notice reader test: the closure posted beside a late opening has one attempt
The finally-sends test expected six attempts on all three journal rows, but the
closure entry is first tried in the run that completes its opening, so it has
one. Test-only fix; the previous commit's test failed on this line.

The previous commit (69e12e3867) builds; its
suite has one failing test, fixed here.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 01:53:18 +02:00
Meta Vens
69e12e3867 Watchdog notice reader: a failed send is retried forever with capped backoff
Ruling notice-retry-policy. The five-attempt cap and its terminal
"exhausted" state are gone. A failed entry is eligible again once
min(2^(attempts-1), 60) minutes have passed since its last journal
update; a run inside the delay skips it without spawning the bridge.
Every attempt keeps the same nonce. The exit status is non-zero while
any entry is unknown or has failed 3 or more times, and retries go on.
status shows nextEligibleAt for failed entries.

The clock is one now() that WATCHDOG_NOTICE_NOW_MS can override, like
the bridge timeout override, so tests drive the backoff without sleeping.
The process summary field "exhausted" is renamed "failing". A journal
entry whose updatedAt is not a date is now refused as
journal-entry-invalid, since the delay is measured from it.

All commits build.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 01:48:44 +02:00
Meta Vens
68fb44aeb1 Merge watchdog bifrost-notice-reader: Idunn incidents become owner DMs
Eureka idunn-watchdog cut bifrost-notice-reader, attempts 1-3 (spec r3).
Soul verdict idunn-watchdog:verdict:cut-bifrost-notice-reader.s3 holds.
One opening and at most one closure DM per incident, own journal, nonce
with enforce_nonce, unknown never re-sent. Requires CultLib >= 8cb3b728.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:31:58 +02:00
Meta Vens
ff087a554b Watchdog notice reader: pin timeout, spawn-error, 5xx/429 and guard behaviour; say what non-2xx is
Tests only, plus wording. No change to what the reader or bridge does.

- A spawn timeout and a spawn error (ENOBUFS from a bridge that floods
  stdout) record unknown, are never re-posted, and block the closure.
  WATCHDOG_NOTICE_BRIDGE_TIMEOUT_MS overrides the 60 s timeout so the test
  reaches that branch; the default is unchanged.
- A 500/502/503/429 answer to the message POST exits 75 from the bridge, and
  through the reader is retried with the same nonce until it completes.
- allowed_mentions {parse: []} is asserted in the posted body; status exits
  non-zero while an entry is unknown and zero when none is.
- The bridge comment and docs/watchdog-notice.md no longer call a non-2xx
  answer "definite": it is retried with the same nonce, relying on
  enforce_nonce inside Discord's window, and may double-send if Discord acted
  on a 5xx outside it.

Builds. Two tests fail on Windows (SIGKILL status), as before; they pass on
Yggdrasil.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 22:56:27 +02:00
Meta Vens
1f001e11d2 Watchdog notice reader: the journal owns "sent"; only exit 75 is retried
Fix batch for Soul pass s1 (spec r3):
- Delete prune-on-absence and its test. Absence from Idunn's store no longer
  deletes a journal entry, so an empty or restored store never re-sends.
- discord-dm exits 75 only when no message can have been sent (arguments,
  token, DM channel open, non-2xx on the message POST). The reader records
  failed for 75 only; any other non-success without a message id is unknown
  and is never re-posted.
- Subject grammar is Idunn's require_id; the notice puts it in backticks.
- Times are bounded to the Date range; notice text is built before the
  running entry is written.

Builds and passes on Linux. On Windows the two tests that SIGKILL the reader
cannot pass (process.kill does not deliver a signal there).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 21:54:23 +02:00
Meta Vens
7e92687716 Watchdog notice tests: canary the refused record's bytes, not the recipient
The egress test searched for the substring CANARY, which the recipient double
also carries, so it failed on its own input.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 21:20:47 +02:00
Meta Vens
ef867f3e28 Watchdog notice reader: start the verb after the module finishes initialising
The top-level await ran main() before the const declarations below it, so every
process run died in the temporal dead zone with unexpected-error.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 21:19:42 +02:00
Meta Vens
417454c28f Watchdog notice reader: Idunn incidents become owner DMs through Bifrost's own journal
tools/watchdog-notice.mjs is a one-shot reader. It reads Idunn's incident
store unlocked with SingleFileMessagePackBackingStore.pullAll, sends one opening
and one closing DM per incident through bifrost-bridge.mjs, and keeps a
bifrost.watchdog_notice_execution.v1 journal: a running entry becomes unknown
and is never re-posted, a failed one is retried with the same nonce up to five
times, terminal entries are pruned once Idunn retires the incident. The exit
status is the delivery-failure signal.

discord-dm accepts --nonce (at most 25 chars) and sends nonce with
enforce_nonce so a retry inside Discord's window returns the original message.

The tests run the CLI against the store bytes Idunn wrote (fixture from
GameCult/Idunn 7f7528b3) with a counting bridge double, and once through the
real bridge with fetch stubbed. docs/watchdog-notice.md states the CultLib
>= 8cb3b728 floor.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 21:15:18 +02:00
Meta Vens
4152676ebb Merge watchdog bifrost-suite: the node suite loads against current CultLib
Eureka idunn-watchdog cut bifrost-suite. Soul verdict
idunn-watchdog:verdict:cut-bifrost-suite.s1 holds: 24 tests, 22 pass,
0 fail, 2 skipped with reason. Bifrost now requires CultLib >= 8cb3b728.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:40:35 +02:00
Meta Vens
e84fe2cb05 Merge watchdog bifrost-retire-alarm: retire publish-idunn-alarm and discord-dm
Eureka idunn-watchdog cut bifrost-retire-alarm, attempts 1-2. Soul verdict
idunn-watchdog:verdict:cut-bifrost-retire-alarm.s2 holds; the pump refuses
any verb but discord-post with a failed receipt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:00:48 +02:00
Meta Vens
9c954e7502 Bifrost pump: one verb, one path; refuse anything else with a failed receipt
With discord-dm gone the verb set, listCommandRecords filter, resolveBridgeVerb
throw and the verb variable decided nothing, and the filter hid non-discord-post
commands so they stayed pending with no receipt. Delete all of it: a command
whose verb is not discord-post now reaches processOneCommand and is refused
with a failed receipt (error names no input value). Payload staging moves
inside the try so a refusal cannot escape as a thrown error.

Adds tests/cultmesh-bridge-commands.test.mjs: discord-dm refused with failed
receipt and non-ok result, canary-free error, discord-post still reaches the
actuator.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 16:59:51 +02:00
Meta Vens
4619939e64 Retire publish-idunn-alarm and the pump's discord-dm verb
Delivery-crossing ruling: Bifrost reads Idunn's incident projection with its
own delivery journal, so Idunn no longer publishes alarms into Bifrost.

- Delete tools/operator-notification.mjs (259 lines); its only callers were
  gamecult-ops notify-idunn-operator-alarm scripts.
- cultmesh-bridge-commands.mjs: drop discord-dm from supportedBridgeVerbs and
  the recipient-id arm; --channel-id is always passed.
- bifrost-bridge.mjs discord-dm is unchanged (docs/bridge.md, BridgeCliTests).

Builds: yes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 16:13:43 +02:00
Meta Vens
daedcc4f86 Fix EF migration discovery metadata 2026-07-15 02:17:23 +01:00
152 changed files with 27274 additions and 789 deletions

7
Directory.Build.props Normal file
View file

@ -0,0 +1,7 @@
<Project>
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
</Project>

View file

@ -1,19 +1,19 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY src/Bifrost.Web/Bifrost.Web.csproj src/Bifrost.Web/
RUN dotnet restore src/Bifrost.Web/Bifrost.Web.csproj
COPY Directory.Build.props .
COPY src/Bifrost.Web/Bifrost.Web.csproj src/Bifrost.Web/packages.lock.json src/Bifrost.Web/
RUN dotnet restore src/Bifrost.Web/Bifrost.Web.csproj --locked-mode
COPY . .
RUN dotnet publish src/Bifrost.Web/Bifrost.Web.csproj \
--configuration Release \
--output /app/publish \
/p:UseAppHost=false
/p:UseAppHost=false /p:RestoreLockedMode=true
FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim AS runtime
WORKDIR /app
ENV ASPNETCORE_URLS=http://0.0.0.0:5080 \
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true \
DOTNET_RUNNING_IN_CONTAINER=true
EXPOSE 5080

View file

@ -196,15 +196,7 @@ The command is a dry-run shape probe for public surfaces that are real enough to
Bifrost's native Discord gateway is the conversation observer. A direct mention of the Bifrost bot, or a reply to one of its messages, becomes an immutable typed `bifrost.discord.persona_conversation_event.v0` only when the guild and channel have one exact Persona target binding. Bot-authored, ambient, empty, over-1200-character, unbound, and ambiguously bound messages do not enter the conversation path. The 1200-character cap is the shared signed alpha boundary with Epiphany. The observation store is Bifrost-private working state, never an actuator queue, and insert collisions must contain exactly equal content. VoidBot does not observe, schedule, or answer for the target Persona.
Persona replies cross back through two explicit CultMesh surfaces. Epiphany is
the sole writer of `epiphany.persona_discord_delivery_request.v0`; Bifrost
reads that surface, authenticates the dedicated Persona-mouth signer, journals
external execution before touching Discord, and is the sole writer of
`bifrost.persona_discord_delivery_receipt.v0`. Epiphany reads receipts but
never receives the Discord token or write access to Bifrost private state.
`unknown` is terminal for automatic transport retry: an external post may have
happened, so restart recovery must not manufacture a duplicate. Failed and
unknown outcomes remain Persona pressure for explicit resolution.
Persona speech goes out through Matrix, not Discord. `tools/persona-matrix-mouth.mjs` is the one process for every Persona (`PERSONAS`, a list of slugs): it holds each Persona's Matrix access token as its own systemd credential (`matrix-token-<persona>`) and nobody else holds any. Per Persona it keeps one `/sync` loop over the configured rooms and the account's direct rooms (a DM invitation from an account on `MATRIX_SERVER_NAME` is accepted while that Persona's brake is released, and one from any other server is declined), one cursor and one delivery sequence. A delivery carries the sender's Matrix id, the event's `m.mentions` user ids and whether the room is a DM. A media event (`m.image`, `m.file`, `m.audio`, `m.video`) is delivered as a typed text line, `[image filename="cat.png" mime=image/png size=2048 url=mxc://server/id]`, followed by its caption when it has one, in `text` and in context. Only `m.image` is also fetched, and only when `IMAGE_STORE_ROOT` is set (unset, the line above is all there is and nothing is downloaded). The fetch is made as the receiving Persona, with its own token, from `/_matrix/client/v1/media/download/<server>/<mediaId>` on its homeserver, for a message the Persona is delivered (not its own words, a room it is not joined to, or history before the ear), and only from the event's own `content.url`; a URL in the message text is never followed. An encrypted attachment (`m.room.encrypted`, or `content.file`) is never fetched. The type is decided by the magic bytes alone (png, jpeg, gif, webp; SVG and anything else is refused, the declared mime is ignored), the body is capped at 5 MiB by `Content-Length` and by the bytes actually read, and the pixel count read from the image header (never decoded) is capped at 40 MP. A refused or failed fetch leaves the typed line with a fixed-word reason (`refused=encrypted|no-url|fetch-failed|too-big|wrong-type|bad-header|too-many-pixels|store-failed`) and no image. A stored image is written to `IMAGE_STORE_ROOT/<persona>/<hex sha256 of the room id>/<hex sha256 of the bytes>[.png|.jpg|.gif|.webp]`, the name generated by the mouth, never the sender's: files `0640`, room directories `2750` (the Persona directory is setgid to the Persona's group, so the Persona's reader opens them by group; the mouth is the only writer), each written under a temporary name and renamed. It is deleted once it is at least 24 hours old and its delivery has been acked, whichever is later; a room keeps at most 50 MiB, the oldest evicted first. The sweep runs when an image is stored and when an ack arrives. A delivery is the 12-field tuple of `gamecult.persona.chat_pending.v2`: field 11, after `is_direct`, is nil or `[path, mime, size, sha256]`, a reference and never bytes; `path` is the store-relative `<room hash>/<file>` above, `mime` the sniffed type, `size` the file length (at most 5 MiB) and `sha256` its lowercase hex digest. The reader re-checks all four against the file. A Persona turn that has no reader for it still sees the typed line. Reading wakes nothing and acks nothing: the heartbeat polls with the non-consuming `gamecult.persona.chat_pending_request.v1`, and only `chat_ack` advances a position. An ack also posts that Persona's public `m.read` receipt, per room, at the newest acked event (fire and forget: the ack never waits on it, a failure is logged once and not retried; the room's next ack covers it); the heartbeat's and a turn's pending calls post none. Her Matrix presence follows her brake: each `/sync` carries `set_presence=online` while it is released and `offline` while it is held, and a round that finds her braked makes one `PUT /presence/<user>/status` to offline first (a brake placed mid long-poll shows within `syncTimeoutMs`, 30 s, plus one PUT; a stopped mouth reads offline after Synapse's sync timeout). The mouth speaks on a `gamecult.persona.chat_send_request.v1` and joins rooms only while that Persona's brake (`/etc/gamecult/minds/<persona>/control.cc`) is released. One loopback CultNet RUDP endpoint serves all of them; a request naming an unlisted Persona gets a typed `chat_refusal` (`UnknownPersona`). Document shapes: `tools/persona-chat-documents.mjs`; golden bytes: `tests/fixtures/persona-chat-v1/`, `-v2/` and `-v3/` (the pending answer; Huginn pins the same bytes). The Discord posting actuator, its permit and its RUDP listener are removed.
Bifrost admits an observation only when guild, channel, target runtime, canonical repository, Persona, producer, and producer runtime match a preconfigured `bifrost.persona_feedback.target_binding.v0` document in Bifrost's private provider store. That binding also owns source visibility, data classification, and the delivery-store target. Bifrost never infers public visibility from Discord identifiers. It classifies the author using its own `bifrost.discord.actor_link.v0` state: a match yields `linked_governance_feedback`; absence yields `unlinked_social_feedback`. Neither class grants adoption, work, release, or deployment authority.

View file

@ -0,0 +1,98 @@
# Charter amendment provision: proposal
Status: **proposal, not in force.** Drafted 2026-11-04 by the heimdall Self run for follow-up `heimdall-body:follow_up:chat-s1881-1882`; revised the same day to fold in what the estates settled in #operator. Nothing here is merged into any governing text, deployed, or voted on. This document is a draft for the estates to read.
## The real guarantee is incentive, the floor is the backstop
Every estate depends on the others for what it wants from the swarm. Patrons fund, Members build, Minds work, Users use. An amendment that strips one estate of what it came for makes that estate leave, and the capturing estates lose the thing they were capturing. That dependency is the guarantee, and the people in it can act on it: any individual can leave and take their own data (entrenched item 3). The mechanisms below do not replace it. They backstop the case where incentive fails: an estate that is outvoted, ignored or too small to leave profitably still keeps a floor it cannot be voted beneath (A3), plus notice and delay long enough to notice and leave.
Consequence for design: an **individual** can leave with their data; an **estate cannot leave as a unit**. A unit exit would be a veto by threat (leave unless the others comply) and would carry shared work out with it. An estate whose members all leave individually has simply emptied, which A6 handles.
All numbers in this document are placeholders for the estates to set.
## Scope: the in-chat charter only
This provision amends the **in-chat charter**: the rules by which the estates govern the shared chat space and the swarm's own decisions. **It does not change the statutes of the legal entity.** How those statutes are amended, and whether the entity's amendment route even permits this charter to bind it, is unverified. That is a question for counsel, and nothing below should be read as legal advice or as satisfying any legal formality. Until counsel answers, the entity's statutes win wherever the two conflict, and this charter's entrenched clauses are a commitment among its participants, not a legal instrument.
## Terms, matched to the existing governance code and research
The terms below are the ones Bifrost already uses. The source is `src/Bifrost.Web/Features/Motions/MotionGovernanceService.cs` and `MotionCategoryPolicy.cs` (pass rule, category thresholds), and `docs/research/2026-10-29-estate-tally-game-theory/README.md` (estates, blocs, renormalization), read at Bifrost `e396305`.
- **Estate** (also "block" or "bloc" in the research): a voting class that casts one collective position. The research models four: Minds, Members, Users, Patrons. Which classes are estates is itself charter text (entrenched item 3 below).
- **Live estate**: an estate with at least one For or Against ballot. Today's tally renormalizes over live estates ("normalize it by who actually voted"), and Abstain is excluded.
- **Threshold `q`**: the share of For over For+Against needed to pass, set by category. The pass test is `>= q`, so a tie at exactly `q` passes. `FundamentalDesign` is `.66`, the highest category today.
- **Quorum**: none exists in code. The research found that any quorum floor, while some estates are empty, hands the sole Member or the Minds estate a stay-home veto (`README.md`, section 5). This proposal therefore does not use a quorum floor to protect ordinary amendments. It uses per-estate approval and notice instead (A1, A4, A5).
Not verified: which internal tally an estate uses (proportional or winner-take-all) is a separate fork in the mind, and I could not read a ruling that settles it. This proposal is neutral on it: each estate approves by its own internal rule, and the charter only counts the result.
## Part 1. The amendment rule
**A1. Ordinary rules pass with 3 of 4 estates.** An ordinary amendment passes when at least three of the four estates approve it separately. Each estate votes by its own internal rule over its own roll; one estate's votes never make up for another's, and there is no cross-estate weighting at this stage. An estate approves when its internal For share (Abstain excluded) is `>= q_ordinary` (placeholder `.66`, the existing `FundamentalDesign` bar, so no new number to defend).
**A2. A short entrenched list needs all four.** The items named in Part 2 can be changed only when **all four** estates approve, each at `q_entrench` (placeholder `.80`), after the longer notice period in A4. The list is in plain words in Part 2 and is deliberately short. Adding an item to it, removing one, or loosening A2 uses the same all-four procedure. Reinterpretation is not a way round: a reading that would change an entrenched item's effect needs the entrenched procedure too.
**A3. A reduction of an estate's share: no self-veto, but a floor.** When an amendment reduces one estate's share, that estate's own vote does not decide it: if the other three approve, it passes, so no estate can hold its own share hostage. Three limits apply, and none of them can be waived by a vote:
- *Floor.* A reduction cannot take an estate below a floor (placeholder: a fifth of the council's weight; the estates set it). Below the floor, the estate's consent is needed, which is exactly what makes the floor a backstop and not a suggestion.
- *Notice.* The estate concerned gets notice under A5 like any other, and the notice names the reduction and the resulting share in numbers.
- *Delay.* A passed reduction takes effect no sooner than the delay in A4.5, and a reduction cannot be stacked: successive reductions to the same estate within a placeholder 12 months count together against the floor.
The floor and this clause are part of the amendment rule, and so entrenched (Part 2, item 4). Whether the floor deserves its own named item is Q3.
**A4. Published notice, discussion, delay.** Every amendment runs the same sequence, and no step can be waived by a vote:
1. *Notice.* The full text, the clauses it touches, and which procedure applies, are published to the canonical charter location and delivered to each estate through that estate's own channel. The notice is recorded with a timestamp, per estate.
2. *Discussion.* At least 14 days (ordinary) or 28 days (entrenched) between notice and the opening of the vote. Debian's constitution uses 2 to 3 weeks of discussion and a 3:1 bar for constitutional change (`docs/research/2026-10-29-swarm-governance-prior-art.md`, section 1); these are placeholders, not derived values.
3. *Text freeze and restart.* Any change to the amendment text during discussion restarts the clock from step 1. Editorial fixes that change no clause are listed and do not restart it.
4. *Vote.* A fixed window of at least 14 days.
5. *Delay.* A passed amendment takes effect no sooner than 7 days after the vote closes. This is the exit window for individuals: someone who disagrees can leave, with their data, before it binds them (timelock rationale in the same research note, section 5).
**A5. No notice, no approval.** An estate counts as approving only if the record shows notice delivered to it, through its own channel, in the form A4.1 requires, **before** the discussion period began. If notice to an estate is missing, late, or reached only part of the estate, that estate's approval is void for this proposal whatever its ballots say. Under A1 that may leave the amendment short of three; under A2 it fails outright. Delivering notice is the proposer's burden to prove, not the estate's to disprove. A defect is cured only by re-noticing and restarting at A4.1.
**A6. Frozen roll, seating, silence.** Each estate's eligible roll is snapshotted at notice. Joins, leaves and reclassifications after the snapshot change neither who may vote nor the estate's internal share. An estate is **seated** if it has at least one eligible member on the snapshot. Approval is affirmative: an estate that casts no For/Against ballot, is empty at snapshot, or falls below its bar counts as not approving. The renormalization used for ordinary motions (dropping estates with no ballots) **never applies to amendments.** Consequences: an ordinary amendment needs three seated, approving estates, and no entrenched amendment can pass while any estate is dormant. With Users and Patrons empty today, the two seated estates could not change even an ordinary rule alone.
**A7. Minds count once; dissent is recorded.** The Minds estate casts one collective position, like any estate. The number of Minds instances on its roll never multiplies that position, and creating instances cannot move any bar. Within every estate, dissent is recorded openly: the result carries the minority position and the share that held it, published with the outcome, not folded into a single yes. Recording dissent changes no outcome; it is what lets a later amendment, or a leaver, see how contested the last one was.
**A8. Record.** Each estate's notice receipt, internal result, recorded dissent, and the final decision are recorded as typed state (not prose) with the snapshot used. The charter text in force is whatever the record says passed; a clause not recorded as passed is not in force.
## The cost of the veto rules: freeze
Beside every veto rule above sits its price, and the price is the same: **the charter can stop being fixable.**
- Under A1 (3 of 4), any two estates can block an ordinary change. A pair that disagrees on something small can stall everything.
- Under A2 (all four), any single estate, including a dormant one, blocks every change to the entrenched list. A wrong entrenched item is as hard to correct as a right one is to protect, and the only remedy left is a fork or an exit.
- Under A6, silence counts as no, so an estate that rarely votes freezes things by being absent.
- A3's floor protects a small estate and also fixes a share that may later be wrong.
The design accepts this for the entrenched items because their value is that they do not move, and tries to avoid it for ordinary rules by needing only three. If the estates would rather risk capture than freeze, the lever is the entrenched list: shorter, not looser.
## Part 2. Entrenched items (proposed starting list, deliberately short)
Changing any of these needs all four estates (A2). In plain words:
1. **Open source, always.** Everything the swarm builds and the charter governs stays open source. No amendment may withdraw, restrict or relicense it so that it is not.
2. **No estate above half.** No estate may hold more than half of the council's weight. The weights the research models (equal, minds-heavy 40/30/20/10, members-heavy 20/45/20/15) all keep every estate at or below half, and the research notes that weight is not power. This item bounds weight; whether it also needs a power bound is Q2.
3. **Exit with your own data.** Anyone may leave at any time and take their own data with them, in a usable form, without penalty. No amendment may attach a cost to leaving or hold a participant's own data. This protects individuals; an estate cannot leave as a unit.
4. **The amendment rule itself.** All of Part 1, including the floor and the list above.
Which classes are estates is part of the arithmetic of items 2 and 4, so changing the set of estates is covered by item 4. Whether it should be named separately is Q3.
## Why it is shaped this way
- *Incentive first, floor as backstop,* because a rule that nobody wants to break needs fewer teeth than one everybody wants to, and teeth are what cause freeze.
- *Per-estate approval, not a weighted tally,* because the research shows weight and power diverge and that a weighted majority can beat every weight vector by moving weight to itself (`README.md`, section 1). Three of four, with all four for the entrenched list, is the research's veto-player structure (Lijphart's mutual veto, cited there) applied only where it is worth its cost.
- *No self-veto on reductions,* because a bar of "every estate" would let each estate keep its current share forever, which is the same freeze arriving through the side door.
- *Silence is not approval, and no quorum floor,* because the research found that floors create stay-home vetoes while estates are empty, and that renormalization is participation-safe. Amendments invert the default on purpose.
- *A frozen roll, notice and a delay,* because changing the electorate mid-vote and changing rules with no exit are the two standard capture routes (OpenZeppelin Governor snapshots, Compound's timelock; research note section 5).
- *Minds counted once,* because a Mind can be copied and a person cannot; counting instances would let the cheapest estate to inflate buy the vote.
## Open questions for the estates
- **Q1. The numbers.** `.66`, `.80`, 14 and 28 days, 7 days' delay, the 12-month stacking window and the one-fifth floor are placeholders. Nothing here was simulated; the existing sims cover ordinary tallies, not amendment dynamics.
- **Q2. Weight versus power.** Should "above half" in item 2 be measured by weight, by measured power (Banzhaf, Shapley), or both?
- **Q3. Named items.** Should the floor, and the set of estates, be named as their own entrenched items rather than riding under the amendment rule?
- **Q4. Silence.** With an estate that rarely votes, A6 means amendments rarely pass. The alternative is a re-vote after a longer window with an estate-level minimum participation. I recommend keeping A6 as drafted until an amendment actually stalls on it.
- **Q5. Who runs the process.** A4 and A5 name a proposer's burden and a recorded notice, not who the neutral recorder is. Debian uses a Secretary; the draft does not choose one.
- **Q6. Bootstrapping.** This provision has no amendment rule above it, so adopting it is outside A1. How the first adoption is ratified (and by which estates, given dormant ones) is not specified.
- **Q7. Counsel.** The legal entity's amendment route, and whether this charter can bind or be recognised by it, is unverified. This document does not answer that.
## Not covered
Ordinary motions (they keep their existing category thresholds and renormalization), the estates' internal tally, vote weights within an estate, and enforcement of items 1 and 3 beyond the amendment rule.

View file

@ -0,0 +1,128 @@
# Concept Mill votes through Bifrost: map
Status: Imagination mapping pass 2026-10-08, revised after the rulings `aetheria-release:ruling:concept-mill-votes-open` and `aetheria-release:ruling:concept-mill-votes-anonymous`. Committed 2026-10-10 as the target of the `concept-mill-votes` campaign. Body facts re-verified and cut specs admitted 2026-10-10 by `imagination-mill-votes` against Bifrost `bf83a93`. Its invariants, rulings, questions and cut specs are typed state in the eureka mind; this file keeps the body facts and rationale. Where they differ, the mind is right.
Operator's words: "I want to track upvotes and downvotes on there, if possible." Then: "Yggdrasil is the obvious place. Doesn't Bifrost have discussions and voting?" Rulings: "anyone can vote, votes are public (we can filter by members if we wanted to)", then "anonymous, too, I mean anyone".
Short answer: Bifrost has two voting-like things, and neither fits the Mill. Governance topics take support and objection only as free-text comment stances, in a command-line CultCache file the web app never reads, not tied to accounts. Motions have real one-account-one-vote records, but they are tier-weighted decisions that close by deadline. Bifrost is the right home, but the Mill needs a small new record in it that admits anonymous voters, which nothing in Bifrost does today, plus an anti-stuffing defence, a public read endpoint and a vote page. The live deploy is off-main, and that is settled first.
## Body facts
Bifrost head `bf83a93` (2026-10-10; `20d5efa` plus the prior-art note, docs only). Paths are relative to `F:\Projects\Bifrost` unless named. Facts 1-13 were taken at `160fac2` on 2026-10-08; the source files they cite are unchanged at `bf83a93` (the two commits since touch only `docs/`). Facts 14 onward were re-probed on 2026-10-10 by `imagination-mill-votes`; scripts are in that agent's scratch directory (`ygg-probe*.sh`).
### What a topic is in code
1. Topics and comments exist only in a Node CLI, `tools/governance-threads.mjs`. Its store is a CultCache file, `.bifrost/governance-threads.cc` (`tools/governance-threads.mjs:18`), and its types are `bifrost.governance.topic` and `bifrost.governance.topic-comment` (`:48-111`). The commands are `open`, `comment`, `approve`, `promote`, `list`, `show` and `digest` (`:113-155`, `:582`).
2. A topic is an agent-dispatch work thread. It has `jurisdictionRepoName` (required), `jurisdictionAgentIdentity`, a status lifecycle `open → consensus_ready → approved → dispatched → closed` (`:52`), and `promote`, which renders the topic into an agent update request (`:324-405`).
3. Support and objection are comment stances, not votes. The valid set is `comment, proposal, support, objection, question, approval, summary, receipt` (`:53`). A comment has `authorKind` (default `"agent"`) and `authorId`, a free string from `--author` (`:232-241`). Nothing enforces one stance per author: each call writes a new `comment_<uuid>` (`:233`). Retraction is not possible. `authorId` is not tied to a Bifrost `UserAccount`.
4. The web app has no topic type, page or store. It knows topics only as `GovernanceActivityReceipt` rows in Postgres, with kinds `TopicOpened/Commented/Approved/Promoted` (`src/Bifrost.Web/Domain/BifrostModels.cs:235-240`, `:1050-1066`; `Features/Bridge/GovernanceActivityReceiptService.cs`).
5. No topic store exists anywhere looked. `.bifrost\` holds only `bridge-receipts.cc`. A read-only `find` on Yggdrasil under `/srv /home /var/lib` found no `governance-threads.cc` (ran under `sudo -n` with errors silenced, so likely but not proven).
### How motions and votes differ
6. Motions and votes are EF Core entities in Postgres: `Motion` (`BifrostModels.cs:665-700`) and `Vote` (`:702-721`, with `VoteChoice For/Against/Abstain` at `:110-115`).
7. A vote is one per account per motion: unique index on `(MotionId, UserAccountId)` (`Data/BifrostDbContext.cs:280-283`). Recasting upserts it (`Features/Motions/MotionGovernanceService.cs:63-82`).
8. Votes are weighted: `Weight = actor.EffectiveVotingWeight` (`MotionGovernanceService.cs:72,80`), 0 unless the membership is `Active`, otherwise `max(1, sum of current tier snapshot weights)` (`BifrostModels.cs:357-361`).
9. A motion has a category with an approval threshold, `ClosesAtUtc`, and a pass/fail resolution (`MotionGovernanceService.cs:95-131`, `MotionCategoryPolicy.cs`). It is a decision, not a standing rating.
### Who can act
10. Every member surface is behind `ActiveMember`: `AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName)` (`src/Bifrost.Web/Program.cs:57`). The handler succeeds only when `Membership.Status == Active` (`Features/Membership/ActiveMemberAuthorizationHandler.cs`, `Features/Shared/CurrentBifrostActor.cs:17`). Admin-only page handlers additionally check `Actor.CanManageMembership` and return `Forbid()` (`Pages/App/Members/Index.cshtml.cs:37-39,95-97`).
11. An account becomes `Active` by bootstrap admin GitHub login (`Features/Shared/ApplicationBootstrapper.cs:44`, `MembershipSynchronizationService.cs:133`), admin approval (`Pages/App/Members/Index.cshtml.cs:92-105`), or a Heimdall member-access claim (`Features/Heimdall/HeimdallAuthService.cs:158,186`). GitHub sign-in with an invitation lands in `PendingApproval`, without one in `Authenticated` (`MembershipSynchronizationService.cs:61`).
12. Anyone can register natively, and doing so proves nothing: `POST /auth/bifrost/register` is anonymous (`Program.cs:286-316`), takes an identity name and display name, creates an `Authenticated` account and sets the cookie (`Features/Membership/BifrostIdentityService.cs:15-58`). No credential, no native sign-in route. As a voter a native account is indistinguishable from anonymous.
13. No public read path exists: anonymous routes are only health, readiness, auth, webhooks, patron intake and patronage checkout (`Program.cs:211-522`); the Eve governance surface requires `ActiveMember` (`Program.cs:473-488`); no CORS and no rate limiter are configured (`git grep -i -E 'AddCors|UseCors|RateLimit|ForwardedHeaders' bf83a93 -- 'src/*.cs'` returns nothing).
### Deployed versus repo
14. Live release `/srv/bifrost/app/releases/20260723-daedcc4` (`readlink -f /srv/bifrost/app/current`, 2026-10-10 02:58Z), `bifrost.service` active since 2026-08-21 10:51 UTC, `ExecStart=/usr/bin/dotnet /srv/bifrost/app/current/Bifrost.Web.dll`, `EnvironmentFiles=/srv/bifrost/env/bifrost.env`, `ASPNETCORE_ENVIRONMENT=Production`, listening on `127.0.0.1:5080`. Earlier releases are bare UTC timestamps (`20260610073418` … `20260618144854`); the `-daedcc4` suffix was added by hand, the deploy script does not write it (fact 16).
15. `daedcc4` ("Fix EF migration discovery metadata", 2026-07-15) is not on main: `git branch -a --contains daedcc4` lists only `origin/codex/bifrost-migration-metadata`, which is `bd3a5a9` (an ancestor of main) plus `daedcc4` alone. `git merge-tree --write-tree bf83a93 origin/codex/bifrost-migration-metadata` is clean. Main has `cde3a78`, which deleted the HTTP bridge routes `/bridge/actions/*`, `/dispatch/runs/*`, `/transport/receipts`, `/governance/receipts` and `POST /eve/governance/commands` (369 lines of `Program.cs`); main's `PlatformEndpointsTests.Removed_http_bridge_routes_are_not_mapped` pins their absence.
16. Deploys are manual: `gamecult-ops/scripts/deploy-bifrost-alpha.sh` (33 lines, gamecult-ops `1e95674`; the deploy files are unchanged since `44889b1`) unpacks `/srv/bifrost/bifrost-publish.tar` into `releases/$(date -u +%Y%m%d%H%M%S)`, repoints `current`, restarts. It takes no revision and records none. `runbooks/bifrost-alpha-deploy.md` step 3 publishes with `dotnet publish` from the operator's checkout `F:\Projects\Bifrost` (`:101-115`), step 2 lists the env keys (`:63-89`). Idunn does not manage Bifrost (`:162-164`). The inventory's "Bifrost alpha live state" block (`gamecult-ops/inventory.md:923-933`) records no revision.
17. No caller of the routes `cde3a78` deleted, over the whole retained nginx access log (2026-09-26 00:46 to 2026-10-10 02:58, 473,581 lines, all vhosts): zero requests to `/bridge/actions`, `/dispatch/runs`, `/transport/receipts`, `/governance/receipts` or `/eve/governance/commands`. The only `/governance/*` hits were ten scanner GETs on 2026-10-07 (`/governance/providers`, `/governance/budgets`, …, all 400). No systemd unit, timer or crontab on Yggdrasil names the bridge tools or `127.0.0.1:5080` except `bifrost.service` itself, and no non-nginx connection to `:5080` was established at probe time. The bifrost journal holds 1 line in 30 days, so the app's own logs cannot witness traffic.
### Data Protection on the live host
18. The live env file sets no `DataProtection__KeysPath` (key names only: `AllowedHosts`, `Bootstrap__*`, `ConnectionStrings__Bifrost`, `GitHubApp__EnableWebhookSync`, `GitHubOAuth__*`, `Heimdall__*`, `Host__*`, `Stripe__*`). So `Program.cs:113-119` never calls `PersistKeysToFileSystem`, and ASP.NET Core's default applies: the service runs as `bifrost` with `HOME=/srv/bifrost`, and the key ring is `/srv/bifrost/.aspnet/DataProtection-Keys` with two keys (created 2026-04-22 and 2026-07-22, the 90-day roll; prior-art note `docs/research/anonymous-vote-stuffing-prior-art.md:65-69`). A third, unused directory `/srv/bifrost/data-protection-keys` holds one key from 2026-07-15. Keys therefore persist across restarts today by accident of `HOME`, not by configuration.
19. The service's working directory resolves to the release directory: `readlink /proc/<MainPID>/cwd` = `/srv/bifrost/app/releases/20260723-daedcc4`. Data Protection's default application discriminator is the content-root path (prior-art note `:67`), so every deploy into a new release directory isolates payloads protected by the previous release: auth cookies, antiforgery tokens and any future device token stop decrypting after each deploy. Mechanism from source docs plus this probe; not observed as logged-out users. `SetApplicationName` is not called anywhere (`git grep SetApplicationName bf83a93` is empty).
### Proxy and client address
20. nginx sends `X-Real-IP`, `X-Forwarded-For` (`$proxy_add_x_forwarded_for`), `X-Forwarded-Proto` and `X-Forwarded-Host` (live `/etc/nginx/sites-enabled/bifrost*:12-19`), and proxies to `127.0.0.1:5080`. `Program.cs` never calls `UseForwardedHeaders` (fact 13 grep), so `RemoteIpAddress` is `127.0.0.1` for every request and any per-client limit puts every visitor in one bucket.
### Code and test substrate
21. Main lacks EF migration discovery metadata on seven migrations: `20260622142000_AddBridgeActionIdentityProvenance`, `20260622202000_AddBridgeActionTargetSurfaceName` and `20260705190000_AddPatronSupportProjectAttribution` have neither `[DbContext]` nor `[Migration]`; `20260608210000_AddHeimdallUserIdentity`, `20260609143000_AddPatronSupportDerivation`, `20260609160000_AddExternalPatronProviderIntake` and `20260622165000_AddBifrostNativeIdentity` have `[Migration]` only; none of the seven has a `.Designer.cs`. EF discovers a migration only with both attributes, so `MigrateAsync` from main would skip them on a fresh database. `daedcc4` adds exactly those attributes (17 lines). The other five migrations have Designer files and are discovered. Migrations run at startup when `Bootstrap:ApplyMigrationsOnStartup` is true (`Features/Shared/ApplicationBootstrapper.cs:17-21`); the live env sets that key.
22. Tests use EF InMemory (`tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs:52-58`), which enforces no unique index; a uniqueness rule must be proven in service code and in the model/migration metadata, not by an InMemory insert failing. `TestAuthHandler` offers two actors only: the bootstrap admin `test-admin`, or anonymous via header `X-Test-Anonymous` (`Support/TestAuthHandler.cs:17-37`). `CurrentBifrostActor` is a public record with a public constructor (`Features/Shared/CurrentBifrostActor.cs:5`), so service tests can build account and member actors directly. `Program` is `public partial` (`Program.cs:582`).
23. Startup configuration rules live in one owner, `Features/Shared/StartupConfigurationValidator.cs`, which skips Development and Testing and throws on a missing production key (`:15-74`).
24. The shared layout loads HTMX from `https://unpkg.com/htmx.org@1.9.12` (`Pages/Shared/_Layout.cshtml:65`); no page posts with `hx-post` today. Admin and vote handlers use plain Razor page handlers with `RedirectToPage()` (`Pages/App/Motions/Index.cshtml.cs:56-61`).
25. Verification on Yggdrasil: `ygg-verify.sh`'s `dotnet` alias is `mcr.microsoft.com/dotnet/sdk:10.0`, which has no .NET 8 runtime for this `net8.0` test project; passing the image name `mcr.microsoft.com/dotnet/sdk:8.0` works (any other value is used as an image name). At `bf83a93`, `dotnet test tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj -c Release --filter "FullyQualifiedName!~BridgeCliTests&FullyQualifiedName!~ProviderAdvertisementTests"` passes 29 of 29 (scratch log `bifrost-base-test-2.log`; that run's filter excluded only `BridgeCliTests`, and the two `ProviderAdvertisementTests` failed for want of Node). The other 37 tests shell out to Node: with no Node they fail to start the process; with Debian's Node 18 they fail on newer Node APIs (`bifrost-base-test-3.log`, 30 pass, 36 fail). Starfire has Node 24 and SDK 10.0.202 only. None of the cuts touch the Node tools.
### The Mill page
26. The Mill is committed and merged: AetheriaLore `origin/main` `6980087` ("Merge concept-mill-spa"). `Aetheria/Game Design/Concept Mill.md` is now 10 lines of front matter and prose; the viewer is the Quartz component `site/quartz/components/AetheriaConceptMill.tsx` with `scripts/aetheriaConceptMill.inline.ts` (417 lines) and `styles/aetheriaConceptMill.scss`. Its data source is `scripts/concept-mill/ships.yaml` (581 lines); `node scripts/concept-mill/build.mjs` writes the manifest `site/quartz/components/data/conceptMill.json` (embedded in the page) and render derivatives, skipping derivatives that already exist unless `--force` (`build.mjs:51-60`), and loads `js-yaml` and `sharp` from the sibling `F:\Projects\GameCult-Quartz` checkout (`:22-25`).
27. Concept identity is the ship `id` in `ships.yaml`; there are 27 ships today (`bison vasuki squatter raft gentle-giant shellback lamp-shy zera nakovalnya oboz shchit glushitel key-light shepherd rake dine-and-dash party-favors finders-keepers tattletale ghost-fare odd-job one-pass bulldog breakup-song 50-f live-wire homestead`). The build refuses duplicate ship ids (`build.mjs:94-96`). Each ship has one or more render variants with their own ids; the URL hash is `#ship` or `#ship/variant` (`aetheriaConceptMill.inline.ts:3,178-192`). The 2026-10-08 "19 card slugs" are gone.
28. The vote slot already exists and is switched off. `ships.yaml:22-27` holds `votes: { enabled: false, endpoint: null, bifrostUrl: null }`, copied into the manifest (`conceptMill.json:16-19`). When enabled, the script fetches `endpoint` once with `credentials: "omit"`, reads `tallies[ship.id]` as `{ up, down, members?: { up, down } }`, shows `▲ up ▼ down` (members not shown), and links `${bifrostUrl}/${ship.id}` as "Vote on Bifrost" (`inline.ts:163-174,284-287`). On fetch failure the slot shows `—`. Tallies are per ship, not per render.
29. The Aetheria site's origin is `https://aetheria.gamecult.org` (`site/quartz.config.ts:66` `baseUrl: "aetheria.gamecult.org"`).
## Rulings in force
The rulings are typed state: `aetheria-release:ruling:concept-mill-votes-open`, `aetheria-release:ruling:concept-mill-votes-anonymous`, and `concept-mill-votes:ruling:public-means-counts` (Self's defaulted reading that "public" means counts split by voter class, no per-voter identity).
Derived from them, not asked separately:
- **Counts are raw: one voter, one mark.** Patron weight cannot apply to anonymous voters, and the member filter replaces it as the "members' opinion" view.
- **Concept art stays out of governance.** Mill votes are their own record, not topics or motions (facts 2, 8, 9). Neither can admit an anonymous voter.
- **Substrate is Postgres beside `Vote`,** to keep the optional foreign key to `UserAccount` in one transaction. "Bifrost web state is off CultCache" remains standing doctrine debt; this feature does not pay it down.
## Authority map
- **Concept identity.** Owner: the Mill's data in AetheriaLore, `scripts/concept-mill/ships.yaml`, ship `id` (fact 27). Bifrost stores keys and never invents one. Renaming a ship changes its name, never its key. A render variant is not a subject.
- **Concept subjects.** Owner: Bifrost `ConceptSubject` (`(Collection, Key)` unique, plus `Title`, `RegisteredAtUtc`, `RetiredAtUtc`). One writer, `ConceptSubjectRegistry`, reached from one admin page that takes the Mill's generated manifest (the owner's artifact) and applies an idempotent plan: add, rename, retire-on-explicit-choice, revive. The vote path cannot create subjects.
- **Voter identity.** Owner: `VoterIdentity.For(actor, deviceId)`, a pure function, plus a `VoterKeyResolver` that only reads or mints the device cookie and calls it:
- signed in: key `account:<UserAccountId>`, class `Member` if `CurrentBifrostActor.IsActiveMember` at cast time, else `Account`;
- otherwise: key `device:<id>`, class `Anonymous`, where the id is a random GUID protected by Data Protection in an HttpOnly, Secure, SameSite=Lax, first-party cookie on `bifrost.gamecult.org`.
The resolver does not own membership; it reads it. When the cookie is minted is the open question `concept-mill-votes:question:device-cookie-consent`.
- **Votes.** Owner: `ConceptAppraisal` rows: `SubjectId`, `VoterKey` (`(SubjectId, VoterKey)` unique), `Stance` (`Up`/`Down`), `VoterClass` stamped at cast, `UserAccountId` nullable, `NetworkTag` and `CastAtUtc`. One writer, `ConceptAppraisalService` (Cast, Retract, Purge, plus read-only Tally and subject view). Rate limiting stands in front of it; uniqueness is enforced inside it and by the unique index, with a unique-violation on insert retried as a stance change.
- **Network prefix.** Owner: one function, `NetworkPrefix`, mapping a client address to its configured IPv4/IPv6 prefix (IPv4-mapped IPv6 folded to IPv4). The audit `NetworkTag` (HMAC-SHA256 of the prefix under a configured secret) and the rate-limit partition both read it; no second prefix computation exists.
- **Tallies.** Derived only: per live subject, per stance, all and members, computed from rows and never stored as counters.
- **Public tally export.** The JSON xenos boundary: anonymous `GET /public/concepts/{collection}/tallies` returning `{ key: { up, down, members: { up, down } } }` for every live subject; no voter keys, account ids or tags; CORS for the configured origins only (`https://aetheria.gamecult.org`), `GET`, no credentials; `Cache-Control: public, max-age=60`. It owns nothing.
- **The Mill page.** Read only. Its existing vote slot (fact 28) fills from the export and links to `https://bifrost.gamecult.org/mill/aetheria-concept-mill/<ship id>`. Voting happens on Bifrost's own origin, which keeps the device cookie first-party.
- **Client address.** `UseForwardedHeaders` trusting loopback only makes `RemoteIpAddress` the real client. It is a rate-limit bucket and an audit tag, never a vote key.
- **Key ring.** Data Protection keys live at a configured path with a fixed application name, so tokens survive restarts and deploys; production refuses to start without the path.
- **Demotions:**
- Governance topic support and objection are not vote owners.
- `Vote.Weight` and patron tiers stay governance-only.
- The static page owns no count.
- The client IP is not an identity.
- The content-root path is no longer the Data Protection discriminator; the fixed application name is.
- The `HOME`-default key ring is no longer an owner; the configured `DataProtection:KeysPath` is (pointed at the same directory, so existing keys are retained).
## Anti-stuffing defence (implementation duty)
Prior art: `docs/research/anonymous-vote-stuffing-prior-art.md` (Eyes, 2026-10-10, Bifrost `bf83a93`). It replaces the from-memory list this section used to carry. Its section "Where the current design's assumptions are contradicted" (`:92-98`) changed the design as follows:
1. **Device token: one vote per device per concept.** Kept. Its one-year life depends on persisted, retained keys and a stable discriminator (note `:65-69,95`; facts 18-19), so Cut 1 makes both configuration. Whether the cookie needs a notice or consent is the operator's question `device-cookie-consent` (note `:74-77,97`); the recommended option mints it only when a visitor casts a vote, with a one-line notice.
2. **Network rate limits.** Kept, in-box ASP.NET Core rate limiter, partitioned by `NetworkPrefix` for anonymous voters and by account when signed in. The prefix lengths are configuration, not design: the note found no evidence for a /24 IPv4 bucket, and per-/64 IPv6 buckets are spread across by delegated prefixes and privacy rotation while larger buckets catch mobile users (`:57-61,96`). Hands proposes defaults and limits; Soul probes them. Partition keys come only from `RemoteIpAddress` after loopback-only forwarded headers or from an account id, never from raw client input (note `:45`).
3. **Audit and purge.** Kept. The note confirms large sites rely on after-the-fact detection and also documents their false positives and unpublished thresholds (`:14-16,98`); Purge leaves an audit event so a correction is itself reviewable.
4. **Escalation, not built now:** proof of work. ALTCHA has no official .NET server, only community packages that need a production replay store (`:27,94`), and PoW is weak against determined attackers (`:34-37`). Not specified until audit shows stuffing. Turnstile and reCAPTCHA stay refused (third party on the vote path).
Vote fuzzing and voter-history weighting (note `:98`) are not adopted: raw public counts are the ruling.
Where it lives: Bifrost web code only. nginx gets nothing new (`limit_req` would be a second, unobservable owner of the same budget). The static site gets nothing.
## Cut order and rationale
The cut specs are typed state (`concept-mill-votes:cut_spec:cut-<label>.r1`); this section keeps only why the order is what it is.
- **0a, 0b: subtraction first.** Production runs `daedcc4`, which main does not contain. Merging that branch (not cherry-picking) makes the live revision an ancestor of main, so the branch can be retired and the live release is on main without redeploying anything new; then 0b deploys main through a revision-recording deploy and names the revision in the inventory. Behaviour cuts come after, so their deploy (Cut 6) starts from a known base.
- **1: host trust plumbing.** Forwarded headers and the key ring are prerequisites of the rate limits (fact 20) and the device token (facts 18-19). Its runbook consequence (the env key) is written by Cut 6, which is the only cut that deploys behaviour.
- **2: the record.** Subjects, appraisals, the pure voter-identity function, the network prefix and tag, and the one writer. No HTTP surface, so it is testable without the page.
- **3: registration**, the only subject writer, before anything can be voted on.
- **4: the public export and CORS.** Rate limits moved out of this cut (the 2026-10-08 map had them here) to land with the path they guard.
- **5: the vote page and voter key resolver**, blocked on `device-cookie-consent` for when the cookie is minted and what the page says.
- **5b: rate limits on the cast path**, after 5 because the policies attach to its handlers; the end-to-end limiter test also proves Cut 1's forwarded-header wiring.
- **6: deploy** with the new env keys, registration seeded from the Mill manifest.
- **7: the Mill turns its vote slot on**, after the endpoint is live.
## Open questions
`concept-mill-votes:question:device-cookie-consent`, raised in Cut 5. Nothing else is open with the operator.

View file

@ -18,8 +18,10 @@ The product center is the Labor Platform loop: eligible participants express pol
- source of deployment and infrastructure conventions
- documents Yggdrasil, nginx, systemd, and local-Postgres patterns
- `E:\Projects\Heimdall`
- owns OAuth, linked accounts, token custody, signed claims, grants, consent, and revocation
- Bifrost consumes Heimdall identity/capability facts instead of becoming the credential vault
- authenticates: owns OAuth, linked accounts, token custody, signed claims, grants, consent, and revocation
- does not issue GameCult identity: its account id (`HeimdallAccountId`) is an authenticator handle, never who a person is on GameCult
- Bifrost consumes Heimdall authentication/capability facts instead of becoming the credential vault
- remaining scar: Heimdall still mints its own account id in code (`src/app.ts` `createAccount`); chat does not need that to move, so it has not
- `E:\Projects\VoidBot`
- observes Discord, packages consensus, and runs repo Persona heartbeats
- should call Bifrost for governed GitHub/Discord/Reddit/intake crossings instead of becoming hidden transport authority
@ -55,7 +57,7 @@ After bootstrap, the next session should start implementing the first vertical s
- member alpha first
- invite-only onboarding
- GitHub as the only auth provider in v1
- Bifrost is the GameCult identity issuer: `UserAccount.Id` is the subject, and Bifrost is the OIDC provider for chat (`docs/oidc-issuer.md`); GitHub, native, and Heimdall-linked sign-in only authenticate into it
- GitHub issues and internal tasks both supported
- app-native governance and voting
- priority and reward pressure should be transparent and auditable

View file

@ -103,6 +103,8 @@ This keeps Reddit useful without making subreddit mechanics the secret constitut
- signed identity and capability claims
- grants, consent, revocation, and capability evaluation
Heimdall authenticates; it does not issue GameCult identity. Bifrost owns the identity (`UserAccount.Id`, `NormalizedBifrostIdentity`) and issues it to chat as an OIDC provider (`docs/oidc-issuer.md`). `HeimdallAccountId` is an authenticator handle that links a Heimdall account to a Bifrost identity.
Bifrost consumes Heimdall claims. It does not become the key vault. The split is bridge versus gate: Heimdall decides who may cross under which grant; Bifrost moves authorized work across public protocols and records where it landed.
### Patron Collection Crossing
@ -153,7 +155,7 @@ Repo Personas may generate bridge requests. They do not own the bridge itself.
| Concern | Owner | Reason |
| --- | --- | --- |
| Who is this actor? | Heimdall | Identity and account custody must stay with the auth authority. |
| Who is this actor? | Bifrost (identity) with Heimdall (authentication) | Bifrost issues the identity; account custody and credential proof stay with the auth authority. |
| Is this actor allowed to request this crossing? | Heimdall plus Bifrost | Heimdall issues grants; Bifrost applies bridge-domain policy. |
| What work is being requested? | Bifrost | The request shape, priority, provenance, and lifecycle are governance concerns. |
| How does interest become reward pressure? | Bifrost | Priority, bounty growth, vote weight, and reward allocation are Labor Platform concerns. |
@ -205,6 +207,7 @@ This is not the final hosted control plane. It is the current bridge actuator an
- Do not add Bifrost-scoped Discord transport or dispatch receipts to arbitrary repo Persona scripts. Call Bifrost.
- Do not add Bifrost-scoped Reddit posting or Persona flair handling to arbitrary repo Persona scripts. Call Bifrost.
- Do not put OAuth credentials or account custody into Bifrost helper scripts as the long-term plan. Use Heimdall claims.
- Do not hand Heimdall ids, GitHub ids, or display names out as the GameCult subject. The OIDC `sub` is `UserAccount.Id`, and the localpart is `NormalizedBifrostIdentity`, minted once.
- Do not make CultCache documents decide policy. They store packets; Bifrost gives those packets meaning.
- Do not let a repo Persona turn a proposal into endless Aquarium pressure once it has enough shape. Route it to Bifrost as a reviewable artifact.
- Do not expose bridge debug noise to public channels. Public receipts should say what happened, which Bifrost topic or work item owns it, where it landed, and what comes next.

113
docs/oidc-issuer.md Normal file
View file

@ -0,0 +1,113 @@
# Bifrost OIDC issuer
Bifrost is the GameCult identity issuer. It runs an OpenID Connect provider on its public host so that Matrix
Authentication Service (MAS) can sign chat users in with their Bifrost account. Heimdall authenticates people into
Bifrost; it does not issue the identity chat sees.
The provider is OpenIddict (`OpenIddict.AspNetCore` and `OpenIddict.EntityFrameworkCore`, 7.7.1). Bifrost owns two
decisions at the authorize endpoint: who the chat user is, and whether they may get a session.
## Endpoints
The issuer is `Host:PublicBaseUrl`, normalized by .NET into an absolute URI. For a bare host that adds a trailing
slash, so for `https://bifrost.gamecult.org` the issuer string is `https://bifrost.gamecult.org/`. Configure MAS with
that exact string.
| Endpoint | Path |
| --- | --- |
| Discovery | `/.well-known/openid-configuration` |
| JWKS | `/.well-known/jwks` |
| Authorization | `/connect/authorize` |
| Token | `/connect/token` |
| Userinfo | `/connect/userinfo` |
Only the authorization code flow exists, and PKCE (S256) is required. There is no implicit, hybrid, password,
client-credentials or device flow, no refresh token, and no dynamic client registration. The token endpoint takes
`client_secret_basic` only. Scopes are `openid` and `profile`.
## The one client
Exactly one client is registered, from configuration, and seeded or updated at startup; every other application
row (with its authorizations and tokens) is deleted at startup. Any other `client_id`, an unregistered `redirect_uri`, or a request without PKCE is refused by the
provider before any sign-in redirect.
## Configuration
The section is `Bifrost:Oidc`. Absent, the provider surface is not mapped at all and discovery answers 404. Present,
it must be complete: an unreadable or wrong-shaped file stops startup with a message that names the setting and never
echoes its value.
| Setting | Meaning |
| --- | --- |
| `Bifrost:Oidc:SigningKeyPath` | PEM file with an EC P-256 private key. Tokens and id_tokens are signed ES256; JWKS publishes exactly that key. A non-P-256 or public-only key stops startup. |
| `Bifrost:Oidc:EncryptionKeyPath` | File holding 32 random bytes as base64 (`openssl rand -base64 32`). Encrypts the tokens OpenIddict issues, the access token and the parked authorize request among them. |
| `Bifrost:Oidc:Clients:Mas:ClientId` | The client id MAS presents. |
| `Bifrost:Oidc:Clients:Mas:ClientSecretPath` | File holding the client secret. |
| `Bifrost:Oidc:Clients:Mas:RedirectUri` | MAS's callback URL, registered exactly. |
| `Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri` | Optional. MAS's back-channel logout URL, `https://<mas>/upstream/backchannel-logout/<provider ULID>`; absolute http(s) or startup fails. Unset: no logout is sent and no authorization is revoked. |
There are no development or ephemeral keys in the host. Key files are provisioned like the other
`[workload.secret_files]` paths.
## Claims
| Claim | Value |
| --- | --- |
| `sub` | `UserAccount.Id`, formatted `D` |
| `preferred_username` | `NormalizedBifrostIdentity` (minted once; there is no rename path) |
| `name` | `DisplayName`, else `BifrostIdentity` |
No e-mail, no Heimdall id, no GitHub id, and no membership detail leaves Bifrost.
## Who may sign in
The authorize endpoint reads the cookie session and the same actor facts the `/App` policy reads.
- No session: challenge to `/auth/sign-in`, which resumes the original request after sign-in. OpenIddict parks the
full request (its request token, in the OpenIddict token table, expiring after ten minutes, spent when used) and
the return path carries only a short `request_uri`, so `LocalReturnPath.MaxLength` stays 256.
- Signed in, membership not `Active`: redirect to the client's `redirect_uri` with `error=access_denied` and
`error_description=The account is not an active GameCult member.`
- Signed in, `Active`, but `NormalizedBifrostIdentity` empty: `error=access_denied` with
`error_description=The account has no Bifrost identity.`
- Signed in and `Active`: a code is issued.
- Token endpoint: membership is read again; an account no longer `Active` gets `invalid_grant` and no tokens.
## Back-channel logout
When `BackchannelLogoutUri` is set, a hosted reconciler (`OidcLogoutReconciler`) runs every 30 seconds. It lists the
subjects holding valid authorizations of the MAS client; for each subject whose membership is not `Active` (the same
reading the token and userinfo endpoints use; a deleted account or a subject that is not an account id counts as not
`Active`) it POSTs one OIDC back-channel logout token to MAS, and only after a 2xx revokes that subject's authorizations,
which also invalidates their tokens. Membership is never written by this path: any way of leaving `Active` (admin
action, sync, a database edit, a future suspend) is covered because the work is derived from membership state.
The token is an ES256 JWT signed with the issuer key, header `typ` `logout+jwt`, with `iss` as the id_token carries it,
`aud` the MAS client id, `sub` the account id, `iat`, `exp` two minutes later, a fresh `jti` and the
`http://schemas.openid.net/event/backchannel-logout` event. It carries no `nonce` and no `sid`; MAS finds the sessions
by `sub`. It is sent as `application/x-www-form-urlencoded` field `logout_token` with a 10 second timeout, and the
response body is never logged.
Failure handling is per subject, in memory: a 5xx, a timeout or a network fault backs off exponentially from 30 seconds
to 15 minutes; a 400 (token refused) or 404 (unknown provider ULID) is a configuration fault, logged by status only,
and retried at the 15 minute cap. Nothing is revoked until MAS answers 2xx. A restart loses the backoff and nothing
else: the authorizations table is the queue, so a later-set URI also catches up on earlier demotions.
Tradeoff: delivery latency is up to one tick, and a member demoted and re-promoted inside one tick keeps their sessions.
MAS verified at v1.28.0 (`crates/handlers/src/upstream_oauth2/backchannel_logout.rs`): the token is checked like an
id_token (`iss`, `aud`, `alg`, JWKS signature), `exp` and `iat` are required, `sub` or `sid` is required, `nonce` must be
absent. Owed on the MAS side (gamecult-chat): the provider's `backchannel_logout_uri` and
`on_backchannel_logout: logout_all`. Re-check against the MAS version pinned there.
## MAS configuration
Configure MAS's upstream provider with the issuer above, the client id and secret, the token endpoint auth method
`client_secret_basic`, the id_token signing algorithm `ES256`, scope `openid profile`, and a localpart claim import
from `preferred_username`. MAS mints the Matrix localpart on first sign-in, so the claim must never change for an
account.
## Remaining scar
Heimdall still mints its own account id in code (`src/app.ts` `createAccount`). That id is an authenticator handle
and chat does not need it to move.

View file

@ -0,0 +1,330 @@
# Eyes: estate internal tally, game-theory probes (2026-10-29)
Fork: Fork Desk question 39, `eureka-body:question:estate-internal-tally` (InForce; options proportional | winner-take-all; Self's recommended: proportional). Context ruling `eureka-body:ruling:governance-estate-blocks`. Session self-2026-10-29-eureka. Facts and measured results only, no recommendation.
Operator additions folded in (her words relayed by the Self): within-bloc vote-power curves ("Patrons vote power scales with donation total, but it's logarithmic... Member vote should scale with contribution... via a lower exponent"); present case ("I'm the only active member"); renormalization ruling ("normalize it by who actually voted").
## Code, outputs, model
Dir `sims/` (numpy 2.5.4, Python 3.13, `python -I <script>`; each `X.py` writes `X.out.md`, full tables there):
`power.py` exact Banzhaf/Shapley and two-tier individual power; `mc.py` Monte Carlo outcomes; `weights.py` within-bloc curves; `growth.py` present case and growth path; `strat.py` misreport/abstention and logrolling; `noshow.py` no-show and quorum under renormalization; `lone.py` lone-estate bounds (her absent).
- Pass rule as in Bifrost `MotionGovernanceService`: For/(For+Against) >= q, abstain excluded. Every tally renormalizes over estates with at least one For/Against ballot (the ruling); a WTA tie casts nothing, so a tied estate also drops out. Ties at exactly q pass (>=).
- Sizes unless stated: Minds 8, Members 15, Users 30 voting (roll 300, 10% turnout), Patrons 4. Weight seeds M/Me/U/P: equal 25/25/25/25; minds-heavy 40/30/20/10; members-heavy 20/45/20/15.
- "A priori" = Banzhaf coin-flip voters. "Motion model" (mc, weights W3): merit m~N(0,1), bloc taste e~N(0,1), bloc support p_b=logistic(1.7(0.6m+0.8e)); Minds intra-class correlation (ICC) via a shared beta-binomial propensity. ICC 0.44 is Kish-calibrated to Kohli 2026 (9 LLM judges ~ 2 effective votes, cited in the Bifrost prior-art note).
- Assumptions, not data: Member contribution = operator holds share S (0.5/0.8/0.95) of the total, the rest a Pareto(1.16) quantile ladder; Patron donations [10, 50, 200, 5000].
- Bug caught and fixed mid-run: the first mc.py drew per-voter propensities, which gives no correlation; results below are from the fixed shared-propensity version.
## 1. Unitary blocs: a priori power (power.out.md)
| weights | q | Banzhaf M/Me/U/P | Shapley M/Me/U/P | veto blocs |
|---|---|---|---|---|
| equal | .15/.5/.66 | .25 each | .25 each | none |
| minds-heavy | .15 | .33/.33/.33/0 (Patrons dummy) | same | none |
| minds-heavy | .5 | .42/.25/.25/.08 | .42/.25/.25/.08 | none |
| minds-heavy | .66 | .50/.30/.10/.10 | .58/.25/.08/.08 | Minds |
| members-heavy | .5 | .17/.50/.17/.17 | same | none |
| members-heavy | .66 | .20/.40/.20/.20 | .17/.50/.17/.17 | Members |
- Power is not weight: 40/30/20/10 at q=.5 gives Members and Users equal power; 10% Patrons are a dummy at q=.15.
- Meta-vote on weights (the ruling votes on block sizes): a reallocation of weight is a divide-the-dollar game. A simple game's core is nonempty iff it has a veto player (standard; e.g. Osborne & Rubinstein 1994 ch. 13) [M]. With no veto bloc (most rows) every weight vector can be beaten by a winning coalition that moves weight to itself. Where a veto bloc exists (minds-heavy .66, members-heavy .66), that bloc must be in any reweighting coalition. At equal weights and q > .75 every bloc is a veto player.
## 2. Individual power inside blocs, a priori (power.out.md)
P(one voter flips the motion); sizes 8/15/30/4.
| weights | q | rule | Mind | Member | User | Patron | max/min |
|---|---|---|---|---|---|---|---|
| equal | .5 | WTA | .118 | .096 | .064 | .158 | 2.5 |
| equal | .5 | prop | .143 | .077 | .038 | .296 | 7.7 |
| equal | .66 | WTA | .118 | .096 | .064 | .158 | 2.5 |
| equal | .66 | prop | .026 | .015 | .007 | .047 | 6.5 |
| minds-heavy | .5 | WTA | .169 | .108 | .051 | .061 | 3.3 |
| minds-heavy | .5 | prop | .234 | .091 | .030 | .115 | 7.8 |
| members-heavy | .5 | WTA | .037 | .189 | .016 | .058 | 11.5 |
| members-heavy | .5 | prop | .125 | .151 | .033 | .189 | 5.8 |
- Under prop each voter's marginal weight is w_b/n_b, so individual power scales ~1/n. Under WTA it scales ~1/sqrt(n) (Penrose). WTA compresses individual inequality, except where the council game makes a bloc near-dummy (members-heavy WTA: Users .016).
- At q=.66 with coin-flip voters, prop individual power collapses (a bloc share rarely sits near the threshold). WTA is unchanged at equal weights (3 of 4 blocs either way).
- Penrose sqrt shares for 8/15/30/4: 19.9/27.3/38.6/14.1. Using Minds n_eff 1.96 instead of 8: 11.0/30.4/43.0/15.7. Under sqrt weights the WTA individual powers are .093/.084/.096/.122, roughly equal (4 blocs is too few for exactness). Headcount weights make prop exactly equal (.106 each) but make WTA Minds and Patrons dummies (0) and Users a dictator.
## 3. Monte Carlo, motion model (mc.out.md; Minds ICC .44)
| weights | q | P(pass) prop | P(pass) WTA | P(outcomes differ) |
|---|---|---|---|---|
| equal | .15 / .5 / .66 | .934/.501/.265 | .838/.594/.404 | .098/.119/.148 |
| minds-heavy | .15 / .5 / .66 | .913/.500/.299 | .796/.537/.357 | .122/.118/.091 |
| members-heavy | .15 / .5 / .66 | .932/.500/.270 | .838/.500/.342 | .101/.111/.113 |
- The two rules disagree on 9-15% of motions in every configuration. Prop is the weighted popular share, so each disagreement is a WTA referendum paradox (Nurmi 1998) [M]. WTA flattens the effective threshold: passes less at .15 and more at .66, because a bloc that clears its internal majority casts 100%.
- Single-voter pivot (equal, q=.5), prop vs WTA: Mind .050/.021, Member .026/.023, User .013/.012, Patron .100/.076.
- Minds correlation (equal, q=.5):
| Minds ICC | n_eff of 8 | mean abs(Minds share - .5) | Mind pivot prop | Mind pivot WTA | P(Minds 4-4 tie) | P(rules differ) |
|---|---|---|---|---|---|---|
| 0 | 8.0 | .280 | .051 | .040 | .107 | .115 |
| .44 | 1.96 | .379 | .050 | .020 | .054 | .119 |
| .8 | 1.21 | .458 | .049 | .006 | .017 | .124 |
| 1 | 1.0 | .500 | .047 | 0 | 0 | .126 |
Correlation pushes the Minds split toward 0/8 or 8/0, so prop and WTA deliver nearly the same Minds cast. Under WTA one Persona's flip almost never matters at ICC >= .44; under prop each Persona keeps a 1/8 slice regardless.
- Users turnout (equal, q=.5): at turnout 1/3/10/30%, P(the WTA cast misstates the population majority) = .18/.12/.08/.04 and the mean prop share error = .19/.11/.06/.03. P(motion differs from the full-turnout result) is prop .077/.044/.024/.014 vs WTA .062/.040/.024/.014. The rules are about equal here.
- Faction capture (Users population support .3, 9 random voters + k coordinated): P(WTA Users cast For) for k=0/3/6/9 = .10/.27/.80/.96; prop share .30/.48/.58/.65. Under WTA a 6-voter faction takes the whole Users weight 80% of the time; under prop it moves the share by .28.
## 4. Within-bloc power curves (weights.out.md)
Kish n_eff = (sum w)^2 / sum w^2. "Top" = the operator (Members) or the largest donor (Patrons). Internal Banzhaf = P(top's flip changes the bloc's WTA cast).
| bloc | curve | S | n_eff | top share | top internal Banzhaf |
|---|---|---|---|---|---|
| Members 15 | one-each | - | 15.0 | .067 | .21 |
| Members 15 | k=1 / 2 / 3 / 5 | .5 | 3.6 / 10.1 / 12.9 / 14.3 | .50 / .22 / .15 / .11 | 1.0 / .67 / .48 / .33 |
| Members 15 | k=1 / 2 / 3 / 5 | .8 | 1.6 / 6.1 / 10.4 / 13.5 | .80 / .36 / .23 / .14 | 1.0 / .96 / .70 / .47 |
| Patrons 4 | one-each / k=1 / log(1+d) | - | 4.0 / 1.1 / 3.3 | .25 / .95 / .42 | .75 / 1.0 / .75 |
| Patrons 4 | k=3 / 5 / 10 | - | 2.4 / 3.2 / 3.8 | .59 / .45 / .35 | 1.0 / .75 / .75 |
(The k=1, S=.5 row is an exact 50% tie with the rest, scored pivotal because tie-to-win counts as a change. Internal Banzhaf .75 for 4 one-each Patrons has the same cause.) On this donation ladder log(1+d) flattens like d^(1/5); d^(1/3) still leaves the top Patron dictating the bloc under WTA.
Operator's whole-tally pivot (a priori, Patrons log(1+d); W2):
| seed | q | S | one-each | k=1 | k=2 | k=3 | k=5 | (Mind / User, k=3) |
|---|---|---|---|---|---|---|---|---|
| equal | .5 | .5 prop/WTA | .069/.089 | .50/.42 | .23/.29 | .16/.20 | .12/.14 | .13/.11, .035/.059 |
| equal | .5 | .8 prop/WTA | - | .75/.42 | .38/.40 | .24/.30 | .15/.20 | |
| equal | .66 | .8 prop/WTA | - | .25/.42 | .09/.41 | .06/.30 | .04/.20 | |
| members-heavy | .5 | .8 prop/WTA | .15/.18 | 1.0/.84 | .75/.81 | .50/.59 | .32/.40 | |
- Under WTA her power is capped by the Members bloc's council power: about .42 at equal weights and .84 at members-heavy, reached once she dictates the bloc. Under prop it is uncapped at q=.5 (members-heavy k=1 S=.8: .999, which makes her near-dictator of the whole tally). At q=.66 prop shrinks every individual's power and WTA does not.
- Exponent sensitivity, motion model (W3; equal, q=.5, S=.5, baseline Members k=3 and Patrons log): P(outcome changes vs baseline) for Members one-each/k=1/k=2/k=5 is prop .014/.051/.010/.007, WTA .014/.061/.012/.008. For the Patron curve one-each/k=1/k=3/k=5/k=10 it is prop .027/.080/.026/.006/.013, WTA .032/.033/.033/0/0. Outcomes move by 1-8%. Her pivot moves more: .025 (one-each) to .21 (k=1) prop, and .022 to .30 WTA.
## 5. Present case and growth (growth.out.md, noshow.out.md; renormalized)
Present case: Members = her alone, Minds 8, Users and Patrons empty, so only two estates are live and their weights renormalize. P(pass | she votes For / Against / stays home), Minds coin-flip (N2):
| seed (M,Me) | q | prop | WTA |
|---|---|---|---|
| 25,25 | .5 | 1.00 / .004 / .64 | 1.00 / .36 / .36 |
| 25,25 | .66 | .86 / 0 / .15 | .64 / 0 / .36 |
| 40,30 | .5 | .996 / .035 / .64 | .64 / .36 / .36 |
| 20,45 | any | 1.00 / 0 / home .64 (.5), .15 (.66) | 1.00 / 0 / .36 |
- Equal seed, q=.5: under prop she decides the motion 99.6% of the time (Minds share must hit 8/0 to beat her Against). Under WTA her Against is overridden whenever the Minds majority is For (25 vs 25 ties and passes at >=). Minds-heavy WTA: a unanimous Minds bloc decides alone (her pivot 0.000 with correlated Minds). Members-heavy: she is dictator under both rules.
- With 3 Users + 1 Patron present (G1), equal q=.5: her pivot is .50 prop / .41 WTA.
When her single vote stops deciding the Members bloc under WTA (share <= .5; G2):
| curve | S=.5 | S=.8 | S=.95 |
|---|---|---|---|
| one-each | n=2 (tie), n>=3 | n=2 (tie), n>=3 | n=2 (tie), n>=3 |
| k=5 | n>=2 (tie at 2) | n=3 | n=5 |
| k=3 | n>=2 | n=5 (n=3 at .501) | n=10 |
| k=2 | n>=2 | n=10 | n=50 |
| k=1 | always tied at .5 | never | never |
Growth path, her whole-tally pivot (equal seed, q=.5, Users 10, Patrons 2): alone .72 prop / .43 WTA. n=5, S=.8: k=1 .60/.43, k=3 .31/.38, k=5 .24/.22, one-each .15/.16. n=20, S=.8: k=3 .16/.29, k=5 .10/.18, one-each .04/.08. Members-heavy seed, n=20, S=.8, k=3: .38/.59.
## 5b. Lone estate bounds, she absent (lone.out.md)
Operator ruling relayed: "But they totally should be able to make decisions without me if I'm not there to vote". Under renormalization the Minds estate is then the whole tally.
- Quorum floors a lone, fully turned-out Minds estate meets:
- Headcount floor: 8/9 = .889 of eligible individuals.
- Estate-weight floor (Minds weight over live-eligible estate weight): .500 equal, .571 minds-heavy, .308 members-heavy.
- Any floor above these values requires her turnout. The at-least-two-estates floor (section 6 N1) requires her by construction while Users and Patrons are empty, so it is kept only as a later-membership comparison.
- Category thresholds (Bifrost `MotionCategoryPolicy`), lone Minds, all 8 voting. Columns: Personas needed under prop, then P(pass) in the motion model at Minds ICC 0 / .44 / 1:
- Bugs .15: 2 needed; .78/.65/.50
- Cosmetics .30: 3 needed; .66/.59/.50
- Balance .40: 4 needed; .55/.53/.50
- Features and NewContent .50: 4 needed; .55/.53/.50
- FundamentalDesign .66: 6 needed; .34/.42/.50
- Under WTA a lone estate casts 100% For or 100% Against, so the ratio is 1 or 0 and every category passes on a 5-of-8 majority: .45/.47/.50. Category thresholds are inert for a lone estate under WTA and binding under prop.
- At ICC 1 (one effective Mind) both rules give .50 at every threshold: the threshold stops discriminating when the estate votes as one.
- Delay-plus-cancel window: P(she is back before effect), absence ~ Exponential(mean A); this measures the chance a cancel opportunity exists, not that it is used.
| delay D | A=1 day | A=3 days | A=7 days | A=14 days |
|---|---|---|---|---|
| 1 day | .63 | .28 | .13 | .07 |
| 3 days | .95 | .63 | .35 | .19 |
| 7 days | 1.00 | .90 | .63 | .39 |
| 14 days | 1.00 | .99 | .86 | .63 |
Prior-art delays (Bifrost prior-art note, section 5 and section 1):
- Compound Timelock: 2 days.
- OpenZeppelin Timelock: exists so users can "exit the system if they disagree" before a decision executes; a canceller role; a guardian may cancel at any stage.
- Olympus Bravo: veto guardian until execution.
- Apache: votes run at least 72 h.
- Rust FCP: 10 days, and a raised concern blocks.
- Beanstalk had no delay; its governance attack drained ~$76M.
## 6. Strategic probes
S1 (strat.py, exhaustive; sizes 3/3/2/2, sincere profiles, no quorum): 0 profitable misreports or abstentions under either rule at any q or seed. The For/(For+Against) ratio is monotone and participation-safe without a quorum. With a cast-weight quorum (>= 50% of estate weight), WTA produced 144-288 profitable abstentions per 10240 cases: an abstention creates an internal tie, the estate drops out, and quorum fails. Prop produced 0.
N1 (noshow.py, renormalized, quorum floors). Columns: individual profitable deviations / hers / profitable estate stay-home. Present case 8/1/0/0, 512 profiles:
- No quorum: 0 / 0 / 0 in every seed, q and rule.
- Headcount quorum (>= half of eligible): 0 individual deviations. Estate stay-home is profitable in 56-93 profiles, because with only 9 eligible the Minds estate staying home breaks quorum.
- "At least two estates": WTA equal q=.5 gives 93 / 93 / 186; prop gives 1 / 1 / 94. Her staying home kills any motion she opposes, because with Users and Patrons empty she is the second estate. Minds-heavy q=.5: prop 9 / 9 / 101, WTA 93 / 93 / 93. At q=.66 most of these drop to 0 (prop equal still 56 estate cases).
- Mixed 3/1/2/1 with everyone voting: 0 under every floor. The no-show leverage comes from empty estates, not from the rule itself.
S2 logrolling (6-motion agendas, 1500 each, greedy mutually profitable pairwise trades; welfare = sum of bloc utilities). Unitary blocs can deliver their whole weight; proportional blocs' leadership moves the share by delta:
| seed | bloc type | P(>=1 trade) | mean trades | mean welfare change | P(welfare falls) | P(a non-trader loses) | P(a trader ends worse) |
|---|---|---|---|---|---|---|---|
| equal | unitary | .615 | 1.04 | +.168 | .245 | .397 | .234 |
| equal | prop delta .5 | .430 | .66 | -.396 | .338 | .305 | .127 |
| equal | prop delta .2 | .127 | .17 | -.027 | .071 | .093 | .017 |
| minds-heavy | unitary | .615 | .90 | +.016 | .302 | .484 | .184 |
| minds-heavy | prop delta .5 | .324 | .44 | -.306 | .230 | .261 | .076 |
- Unitary blocs find trades on most agendas. Trades raise mean welfare (equal seed) but impose losses on a non-trading bloc in 40-48% of agendas and leave a trader worse than sincere in 18-23%, which is Riker & Brams's paradox of vote trading at bloc scale. Partial-delivery blocs trade far less. The negative mean welfare at delta .5 is a model result (proportional shares near .5 flip low-stakes motions) that I have not cross-checked.
## 7. Literature (one or two lines each; [V] = verified by search this pass, [M] = from memory, not fetched)
- Putnam 1988, "Diplomacy and Domestic Politics: The Logic of Two-Level Games", Int. Org. 42(3):427-460 [M]. A deal must lie in each side's domestic win-set; a smaller win-set gives bargaining advantage (the Schelling conjecture), with the risk of involuntary defection. Mapping: a bloc parliament that ratifies the bloc position is a level-II ratifier. A prop bloc has no ratified position to bargain from.
- Schelling 1956 "An Essay on Bargaining", AER 46(3); Strategy of Conflict 1960 [M]. Binding oneself (burning bridges) is power. A unitary bloc whose position is fixed by an internal vote is a visible commitment device; prop splitting commits nothing beyond the count.
- Tests of the conjecture [V]. Mo 1995 APSR 89(4):914-924: a negotiator may gain by giving an agent veto, but only when the agent's preferences are extreme enough and depending on what the other side knows. Iida 1993 JCR and 1996 Public Choice: incomplete information about domestic preferences drives ratification failure; two-sided constraints give relatively equitable outcomes. Tarar 2001 JCR 45(3):320-340: with both sides constrained and incomplete information, the opposite of the conjecture can occur. All four estates having parliaments is the two-sided case.
- Diermeier & Feddersen 1998 APSR 92(3) [M]: the vote-of-confidence procedure makes coalitions cohesive, and cohesion raises a coalition's value in legislative bargaining. This is the party-discipline analogue: a disciplined unit outbargains an undisciplined one. Krehbiel 1993 "Where's the Party?" and Cox & McCubbins 1993 dispute how much discipline is real.
- Riker & Brams 1973 "The Paradox of Vote Trading", APSR 67(4) [M]: individually rational trades can leave all parties worse off. S2 measures it.
- Snyder, Ting & Ansolabehere 2005 "Legislative Bargaining under Weighted Voting", AER 95(4) [M]: in Baron-Ferejohn bargaining, expected payoffs track minimal-integer-representation weights rather than Banzhaf/Shapley. This applies to the weight meta-vote as distributive bargaining among unitary blocs.
- Penrose 1946 JRSS 109; Banzhaf 1965 Rutgers L. Rev. 19 [M]. Two-tier WTA equalizes individual power with sqrt(n) weights. Banzhaf 1968 "One Man, 3.312 Votes" showed large US states' unit rule over-empowers their voters [M].
- Jagiellonian compromise [V]: Słomczyński & Życzkowski; sqrt-of-population weights with quota ~62% (61.5% for EU-27) (arXiv cond-mat/0405396, 0712.2699). Poland tabled it in 2007; the Council adopted the double majority instead.
- Gelman, Katz & Bafumi 2004, BJPS 34(4):657-674 [V]: the coin-flip model overstates decisiveness in large units; empirically, proportional weights are "basically fair" and sqrt is not. Kirsch 2007 (Homo Oeconomicus) [M]: with correlated voters the optimal two-tier weights move from sqrt(n) toward n. The Minds correlation table above is a local instance: a high-ICC bloc of 8 has n_eff ~ 2.
- Coleman 1971 [M]: separates the power to initiate action from the power to prevent it. The thresholds .15 vs .66 shift power between these two, which is visible in the q rows of section 2.
- Electoral college unit rule [M]. Pathologies: campaigning and policy concentrate in swing states (Strömberg 2008 AER 98(3)); referendum paradox (Nurmi 1998 SCW 15). Faithless electors are bound by state law (Chiafalo v. Washington, 2020); in a bloc parliament the analogue is a Persona breaking the bloc mandate.
- Estates-General 1789 [M]: vote by order (each estate one unitary vote) vs by head; the Third Estate refused the unitary-order rule and declared itself the National Assembly on 17 June 1789. Precedent: a unitary-estate rule is contested when one estate's size outgrows its weight.
- Lijphart 1969 World Politics 21(2); Democracy in Plural Societies 1977 [M]. Consociationalism has four pillars: grand coalition, segmental autonomy, proportionality and mutual veto. Segmental autonomy fits each estate running its own internal process; mutual veto corresponds to the q > .75 equal-weight case in section 1.
- Hirschman 1970 [M]: exit, voice, loyalty. In section 5 no-show acts as exit-as-leverage when an estate is the quorum's second member.
## 8. Candidate articulations of the hunch: what the evidence shows
- Credible commitment / paradox of weakness. Supported in theory by Putnam, Schelling, Mo and Diermeier-Feddersen; qualified by Mo (needs an extreme agent) and reversed under two-sided incomplete information (Tarar). In sim, only unitary blocs can deliver whole-weight trades (S2: 61.5% of agendas vs 12.7% at delta .2).
- Power games in the open. Supported in structure: unitary blocs have 2^4 = 16 coalitions and the trades are pairwise and nameable. Under prop, influence travels through individual cross-bloc switching (prop pivots are per-individual, section 2). Contradicted in part by S2: open bloc trades still harm a non-trading bloc in 40-48% of agendas (Riker-Brams).
- Internal deliberation absorbs correlated noise. For Minds at ICC >= .44, prop and WTA already cast nearly the same weight (section 3), so the internal rule matters little there; a parliament would decide which Persona's argument wins, not the tally. For Users at low turnout, both rules carry the same sampling error (.08 at 10%), and WTA is more capturable by a small faction (k=6: 80%).
- Legibility of who owes whom. Unitary trades are pairwise records; under prop the "debt" is spread over individuals. No source measured this directly. Not found.
- Individual equality cuts the other way. WTA two-tier is Penrose-equalizable with sqrt weights; prop needs headcount weights. Gelman et al. and Kirsch argue that correlation pulls the fair weights back toward proportional.
- Operator dominance. Under WTA her ceiling is the Members bloc's council power (.42 equal, .84 members-heavy). Under prop at q=.5 her influence has no such ceiling. Her internal dictatorship of Members ends at the n shown in the section 5 table.
- Renormalization with no quorum: participation-safe in every exhaustive probe. Adding any quorum floor while estates are empty hands the sole Member, or the Minds estate, a stay-home veto. The two-estates floor conflicts with the "decide without me" ruling today. The bounds that remain on a lone Minds estate are a headcount floor <= .889, category thresholds (binding only under prop, and void at ICC 1), and a delay window whose cancel opportunity depends on her absence length (section 5b).
## 9. Third rule: `bloc-parliament-at-threshold` (bpt)
Each live bloc casts its whole weight For iff its internal For share (abstain excluded) >= the category threshold q, else Against. The council then needs weighted For >= q over live estates (renormalized). Code `estate-sims\third.py` (out `third.out.md`) and `estate-sims\bounds.py` (out `bounds.out.md`). The model and draws are the same as sections 3-6 (Minds ICC .44, sizes 8/15/30/4).
- **bpt vs WTA at q=.5:** these differ only at exact internal ties. A 4/8 Minds split casts For under bpt (.5 >= .5) and nothing under WTA. With continuous shares (logrolling) the two are identical.
**Aggregate bounds** (all four estates live; lowest weighted popular For share that can pass / highest that can fail; prop is exactly q / just under q). bpt applies q twice, so it widens the band on both sides:
| seed | q | WTA | bpt |
|---|---|---|---|
| equal | .15 | .125 / .500 | .037 / .150 |
| equal | .5 | .250 / .625 | .250 / .625 |
| equal | .66 | .375 / .750 | .495 / .830 |
| minds-heavy | .66 | .350 / .800 | .462 / .864 |
| members-heavy | .66 | .400 / .825 | .528 / .881 |
- Equal seed, q=.15: one bloc with 15% internal For passes the motion, so 3.7% weighted support suffices.
- Equal seed, q=.66: three blocs at 66% pass with 49.5% support, while four blocs at 65.9% fail with 65.9% support.
**Outcomes** (T1): P(pass) prop / WTA / bpt, then P(outcomes differ) bpt-prop / bpt-WTA / prop-WTA:
| seed | q | P(pass) | differ |
|---|---|---|---|
| equal | .15 | .934 / .838 / .985 | .050 / .147 / .098 |
| equal | .5 | .502 / .594 / .676 | .179 / .082 / .119 |
| equal | .66 | .265 / .405 / .233 | .080 / .171 / .148 |
| minds-heavy | .5 | .502 / .537 / .575 | .126 / .038 / .117 |
| members-heavy | .66 | .271 / .343 / .194 | .099 / .149 / .113 |
- bpt is the most permissive rule at .15 and the strictest at .66. At .5 it passes more than WTA because internal ties count For.
**Pivots and correlation** (T2, T3; equal seed):
- Single-voter pivot M/Me/U/P under bpt:
- q=.15: .002/.004/.002/.017
- q=.5: .018/.019/.010/.082
- q=.66: .018/.018/.010/.068
- For comparison at q=.5: prop .050/.026/.014/.102; WTA .020/.023/.011/.076.
- Single-Persona pivot by Minds ICC 0 / .44 / .8 / 1:
| q | prop | WTA | bpt |
|---|---|---|---|
| .15 | .020 / .020 / .021 / .021 | .024 / .014 / .004 / 0 | .006 / .002 / .001 / 0 |
| .5 | .051 / .050 / .050 / .048 | .040 / .021 / .007 / 0 | .038 / .018 / .006 / 0 |
| .66 | .044 / .042 / .040 / .040 | .040 / .020 / .007 / 0 | .036 / .017 / .006 / 0 |
Under bpt, as under WTA, a correlated Minds bloc leaves one Persona almost no pivot; prop keeps a 1/8 slice at every correlation.
**Users faction capture** (T4; Users population support .3, 9 random voters + k coordinated For). P(Users bloc casts For):
| k (share of voters) | WTA | bpt .15 | bpt .5 | bpt .66 | prop share |
|---|---|---|---|---|---|
| 0 (0) | .10 | .80 | .10 | .03 | .30 |
| 1 (.10) | .10 | .96 | .27 | .03 | .37 |
| 2 (.18) | .27 | 1.00 | .27 | .03 | .43 |
| 3 (.25) | .27 | 1.00 | .54 | .10 | .48 |
| 6 (.40) | .81 | 1.00 | .81 | .27 | .58 |
| 9 (.50) | .96 | 1.00 | 1.00 | .54 | .65 |
| 12 (.57) | 1.00 | 1.00 | 1.00 | .80 | .70 |
- At q=.15 the bloc casts For with no faction at all 80% of the time (population support .3 > .15); a 2-voter faction (18%) makes it certain. This confirms the coordinator's note: at .15, about a 15% faction captures the bloc's whole weight.
- At .66 a faction needs roughly half the turnout to capture the bloc half the time.
**Logrolling** (T5; 800 agendas, 6 motions):
| seed | q | rule | P(>=1 trade) | mean welfare change | P(a non-trader loses) | P(a trader ends worse) |
|---|---|---|---|---|---|---|
| equal | .15 | WTA / bpt / prop .5 | .128 / .001 / .007 | +.56 / +.01 / +.05 | 0 / 0 / 0 | .007 / 0 / 0 |
| equal | .5 | WTA = bpt / prop .5 | .589 / .451 | +.14 / -.43 | .376 / .316 | .231 / .146 |
| equal | .66 | WTA / bpt / prop .5 | .614 / .367 / .245 | +.10 / +.60 / +.41 | .393 / .250 / .177 | .231 / .071 / .035 |
| minds-heavy | .5 | WTA = bpt / prop .5 | .608 / .330 | -.05 / -.26 | .490 / .282 | .176 / .071 |
| minds-heavy | .66 | WTA / bpt / prop .5 | .463 / .362 / .282 | +.17 / +.39 / +.37 | .375 / .289 / .206 | .095 / .031 / .043 |
- At .15, almost nothing is left to trade under bpt: nearly everything passes.
- At .66, bpt trades less than WTA, with higher mean welfare and less third-party harm. Most remaining trades build supermajorities for motions that a strongly-wanting minority of blocs care about.
**Lone Minds, she absent** (T6). P(pass) at Minds ICC 0 / .44 / 1:
| category | q | prop = bpt | WTA |
|---|---|---|---|
| Bugs | .15 | .77 / .65 / .50 | .45 / .47 / .50 |
| Cosmetics | .30 | .66 / .59 / .50 | .45 / .47 / .50 |
| Balance | .40 | .56 / .53 / .50 | .45 / .47 / .50 |
| Features / NewContent | .50 | .56 / .53 / .50 | .45 / .47 / .50 |
| FundamentalDesign | .66 | .34 / .42 / .50 | .45 / .47 / .50 |
- A lone estate under bpt behaves exactly as under prop: the internal threshold is the binding one, so categories bind. Under WTA they are inert.
**Present case** (T7; Minds 8 coin-flip, her alone, Users/Patrons empty). P(pass | she For / Against / home):
| seed | q | prop | WTA | bpt |
|---|---|---|---|---|
| equal | .15 | 1 / .855 / .965 | 1 / .363 / .363 | 1 / .965 / .965 |
| equal | .5 | 1 / .004 / .637 | 1 / .363 / .363 | 1 / .637 / .637 |
| equal | .66 | .855 / 0 / .145 | .637 / 0 / .363 | .145 / 0 / .145 |
| minds-heavy | .5 | .996 / .035 / .637 | .637 / .363 / .363 | .637 / .637 / .637 |
| members-heavy | any | For always passes, Against always fails, under all three rules | | |
- Under bpt with two equal live estates:
- At q <= .5 her For forces passage, and her Against has the same effect as staying home.
- At q=.66 her Against is a veto, and her For has the same effect as staying home.
- Minds-heavy q=.5 under bpt: her vote is irrelevant (.637 in every column), and the Minds alone decide.
**Operator's whole-tally pivot** (T8; coin voters, Users 10, Patrons 2 on log(1+d), her share of contribution S=.8). Values are prop / WTA / bpt:
| seed | q | alone | n=5 k=3 | n=20 k=3 | n=20 one-each |
|---|---|---|---|---|---|
| equal | .15 | .012 / .199 / 0 | .002 / .175 / 0 | 0 / .136 / 0 | 0 / .035 / 0 |
| equal | .5 | .723 / .429 / .300 | .308 / .376 / .263 | .159 / .293 / .206 | .042 / .076 / .053 |
| equal | .66 | .373 / .433 / .086 | .150 / .377 / .053 | .074 / .294 / .021 | .019 / .076 / .004 |
| members-heavy | .5 | 1 / .863 / .734 | .612 / .753 / .641 | .383 / .588 / .500 | .101 / .152 / .130 |
| members-heavy | .66 | .806 / .630 / .092 | .237 / .549 / .058 | .079 / .428 / .023 | .021 / .111 / .004 |
- At .15 under bpt, the coin-flip blocs almost always clear 15%, so no single voter matters, her included.
- At .66, bpt gives her the smallest pivot of the three rules at every Members size, because she can block her bloc but rarely flips the council.
**Strategic checks** (T9; exhaustive, renormalized, no quorum; sizes 8/1/0/0 and 3/3/2/2, all seeds and q): 0 profitable misreports, 0 profitable abstentions and 0 profitable estate stay-homes. bpt is monotone and participation-safe here, as prop and WTA were in sections 6 and N1.
## 10. Capping the operator: weight share vs decisiveness
Her words, relayed: "I would limit my influence by half, just so I'm not deciding everything". Code `estate-sims\cap.py` (out `cap.out.md`, full grid: 3 cases x 3 seeds x 3 q x 5 caps x 3 rules).
- **Weight share x:** her internal share of Members x Members weight / live weight (renormalized).
- **How the cap c works:** if she is the sole Member, the Members estate weight is cut to c/(1-c) x the other live weight. Otherwise her internal weight is cut until x = c.
- **Decisiveness:** her pivot rate, measured twice. Coin voters (Banzhaf) and the motion model (Minds ICC .44) give different numbers for the same rule.
- **Unilateral pass (U+):** her For passes with everyone else Against.
- **Unilateral block (U-):** her Against fails with everyone else For.
- Ties pass (>=).
**Present case** (Minds 8, her alone, two live estates). Cells: pivot coin / pivot motion, with U+U- flags:
| seed, q | cap (x) | prop | WTA | bpt |
|---|---|---|---|---|
| equal .15 | none = .5 (.50) | .14 / .42 YN | .64 / .53 YN | .03 / .35 YN |
| equal .15 | .33 | .03 / .35 YN | .64 / .53 YN | .03 / .35 YN |
| equal .5 | none = .5 (.50) | .996 / .73 YN | .64 / .53 YN | .36 / .47 YN |
| equal .5 | .49 | .992 / .46 NN | .27 / .06 NN | 0 / 0 NN |
| equal .5 | .33 | .71 / .17 NN | .27 / .06 NN | 0 / 0 NN |
| equal .66 | none / .49 / .40 | .86 / .58 to .64 / .53 NY | .64 / .53 NY | .14 / .42 NY |
| equal .66 | .33 | .64 / .25 NN | .27 / .06 NN | 0 / 0 NN |
| members-heavy .5 | none (.69) | 1 / 1 YY | 1 / 1 YY | 1 / 1 YY |
| members-heavy .5 | .5 / .49 | same as equal .5 at cap .5 / .49 | | |
- **A 50% cap with ties passing does not take away her unilateral power.**
- In the two-estate case: at q <= .5 her For alone passes; at q > .5 her Against alone blocks.
- Generally: U+ holds iff x >= q, and U- holds iff x > 1-q. Removing both needs x < min(q, 1-q):
- q=.5: any cap below .5 (.49 works).
- q=.66: below .34 (.40 and .49 still leave her the block).
- q=.15: below .15 (no tested cap removes U+).
- **Pivot at or below half, present case:**
- With cap .49 at q=.5: WTA .27 and bpt 0 on both measures (under bpt the Minds then decide alone).
- Prop stays at .99 on coin voters even at cap .49 (and .71 at .33). The motion model gives prop .46 at cap .49 and .17 at .33. One Persona For is enough to carry her 49% For past .5, and she loses only to a unanimous Against from the Minds.
- WTA at .15 stays at .64 / .53 under every cap. At .15, x >= .15 keeps U+, and the Minds estate casts all or nothing.
**Growth path** (S=.8, k=3, Users 10, Patrons 2). Her weight share is already below every tested cap, so no cap binds:
- x = .10 / .12 / .18 at n=5 for equal / minds-heavy / members-heavy.
- x = .05 / .06 / .09 at n=20.
Her decisiveness still exceeds half in some cells (coin / motion):
| case | prop | WTA | bpt |
|---|---|---|---|
| n=5, members-heavy, q=.5 (x=.18) | .61 / .31 | .76 / .42 | .64 / .39 |
| n=5, equal, q=.5 (x=.10) | .31 / .15 | .38 / .21 | .26 / .19 |
| n=20, members-heavy, q=.5 (x=.09) | .38 / .14 | .59 / .15 | .50 / .15 |
| n=5, members-heavy, q=.66 | .24 / .26 | .55 / .32 | .06 / .24 (U- under bpt) |
- Under WTA and bpt, her pivot comes from steering a bloc that carries 45% of the weight, not from her own 9-18% share. A cap on her personal weight share therefore leaves this channel open.
- Under bpt at .66 she gets a unilateral block at n=5 members-heavy with x=.18: her Against holds the Members internal For share under .66, so the whole 45% is cast Against.
**Distinction, as measured:**
- **Weight share is a rule parameter.** A cap on it is enforceable per tally (renormalized) and removes U+/U- only when x < min(q, 1-q) of the tally.
- **Decisiveness (pivot rate) is not a rule parameter.** It is a function of the rule, the threshold, the bloc structure and how everyone else votes. The same cap gives .99 or .46 (prop, q=.5, cap .49) depending on the voter model. No weight cap tested bounds it under every model.
- Holding her decisiveness at or below half under unitary rules (WTA, bpt) would need a cap on the weight of any bloc she can steer, or on her internal decisiveness in that bloc. A cap on her own share does not do it.
- Present case, equal seed: rule and cap combinations whose pivot is at or below .5 on both voter models.
- q=.15: prop at every cap (.14 / .42 uncapped); bpt at every cap (.03 / .35). WTA at no cap (.64 / .53).
- q=.5: WTA and bpt at cap .49 or lower. Prop at no tested cap (coin .71 even at .33).
- q=.66: bpt at every cap (.14 / .42), but U- survives until the cap is below .34. WTA at cap .33. Prop at no tested cap (coin .64 at .33).

View file

@ -0,0 +1,11 @@
| seed | q | prop min-pass / max-fail | wta min-pass / max-fail | bpt min-pass / max-fail |
|---|---|---|---|---|
| equal | 0.15 | 0.150 / <0.150 | 0.125 / 0.500 | 0.037 / 0.150 |
| equal | 0.5 | 0.500 / <0.500 | 0.250 / 0.625 | 0.250 / 0.625 |
| equal | 0.66 | 0.660 / <0.660 | 0.375 / 0.750 | 0.495 / 0.830 |
| minds-heavy | 0.15 | 0.150 / <0.150 | 0.100 / 0.550 | 0.030 / 0.235 |
| minds-heavy | 0.5 | 0.500 / <0.500 | 0.250 / 0.700 | 0.250 / 0.700 |
| minds-heavy | 0.66 | 0.660 / <0.660 | 0.350 / 0.800 | 0.462 / 0.864 |
| members-heavy | 0.15 | 0.150 / <0.150 | 0.075 / 0.500 | 0.022 / 0.150 |
| members-heavy | 0.5 | 0.500 / <0.500 | 0.275 / 0.725 | 0.275 / 0.725 |
| members-heavy | 0.66 | 0.660 / <0.660 | 0.400 / 0.825 | 0.528 / 0.881 |

View file

@ -0,0 +1,23 @@
"""Aggregate-support bounds for each rule, all four estates live: lowest weighted For share that can pass,
highest weighted For share that can fail (bloc shares free in [0,1]). Run: python -I bounds.py"""
import itertools
SEEDS = {"equal": [25, 25, 25, 25], "minds-heavy": [40, 30, 20, 10], "members-heavy": [20, 45, 20, 15]}
eps = 1e-6
print("| seed | q | prop min-pass / max-fail | wta min-pass / max-fail | bpt min-pass / max-fail |")
print("|---|---|---|---|---|")
for s, w in SEEDS.items():
W = sum(w)
for q in (0.15, 0.5, 0.66):
res = {}
for rule, t in (("wta", 0.5), ("bpt", q)):
mp, mf = 1.0, 0.0
for S in itertools.product((0, 1), repeat=4): # S = blocs casting For
ws = sum(wi for wi, x in zip(w, S) if x)
passes = ws >= q * W - 1e-9
# For-blocs need share > t (wta) or >= t (bpt); Against-blocs share < t (wta) or < t (bpt)
lo = sum(wi * (t + (eps if rule == "wta" else 0)) for wi, x in zip(w, S) if x) / W
hi = (ws + sum(wi * (t - eps) for wi, x in zip(w, S) if not x)) / W
if passes: mp = min(mp, lo)
else: mf = max(mf, hi)
res[rule] = f"{mp:.3f} / {mf:.3f}"
print(f"| {s} | {q} | {q:.3f} / <{q:.3f} | {res['wta']} | {res['bpt']} |")

View file

@ -0,0 +1,148 @@
Cells: pivot coin / pivot motion / U+ U- (unilateral pass, unilateral block; Y/N). Her weight share shown as x.
### present 8/1/0/0
| seed | q | cap | her share x | prop | wta | bpt |
|---|---|---|---|---|---|---|
| equal | 0.15 | none | 0.500 | 0.143 / 0.416 / YN | 0.635 / 0.528 / YN | 0.034 / 0.354 / YN |
| equal | 0.15 | 0.5 | 0.500 | 0.143 / 0.416 / YN | 0.635 / 0.528 / YN | 0.034 / 0.354 / YN |
| equal | 0.15 | 0.49 | 0.490 | 0.143 / 0.416 / YN | 0.635 / 0.528 / YN | 0.034 / 0.354 / YN |
| equal | 0.15 | 0.4 | 0.400 | 0.034 / 0.354 / YN | 0.635 / 0.528 / YN | 0.034 / 0.354 / YN |
| equal | 0.15 | 0.33 | 0.330 | 0.034 / 0.354 / YN | 0.635 / 0.528 / YN | 0.034 / 0.354 / YN |
| equal | 0.5 | none | 0.500 | 0.996 / 0.727 / YN | 0.635 / 0.528 / YN | 0.361 / 0.473 / YN |
| equal | 0.5 | 0.5 | 0.500 | 0.996 / 0.727 / YN | 0.635 / 0.528 / YN | 0.361 / 0.473 / YN |
| equal | 0.5 | 0.49 | 0.490 | 0.992 / 0.455 / NN | 0.274 / 0.055 / NN | 0.000 / 0.000 / NN |
| equal | 0.5 | 0.4 | 0.400 | 0.931 / 0.293 / NN | 0.274 / 0.055 / NN | 0.000 / 0.000 / NN |
| equal | 0.5 | 0.33 | 0.330 | 0.712 / 0.168 / NN | 0.274 / 0.055 / NN | 0.000 / 0.000 / NN |
| equal | 0.66 | none | 0.500 | 0.857 / 0.584 / NY | 0.639 / 0.527 / NY | 0.144 / 0.416 / NY |
| equal | 0.66 | 0.5 | 0.500 | 0.857 / 0.584 / NY | 0.639 / 0.527 / NY | 0.144 / 0.416 / NY |
| equal | 0.66 | 0.49 | 0.490 | 0.857 / 0.584 / NY | 0.639 / 0.527 / NY | 0.144 / 0.416 / NY |
| equal | 0.66 | 0.4 | 0.400 | 0.639 / 0.527 / NY | 0.639 / 0.527 / NY | 0.144 / 0.416 / NY |
| equal | 0.66 | 0.33 | 0.330 | 0.635 / 0.254 / NN | 0.274 / 0.055 / NN | 0.000 / 0.000 / NN |
| minds-heavy | 0.15 | none | 0.429 | 0.143 / 0.416 / YN | 0.635 / 0.528 / YN | 0.034 / 0.354 / YN |
| minds-heavy | 0.15 | 0.5 | 0.429 | 0.143 / 0.416 / YN | 0.635 / 0.528 / YN | 0.034 / 0.354 / YN |
| minds-heavy | 0.15 | 0.49 | 0.429 | 0.143 / 0.416 / YN | 0.635 / 0.528 / YN | 0.034 / 0.354 / YN |
| minds-heavy | 0.15 | 0.4 | 0.400 | 0.034 / 0.354 / YN | 0.635 / 0.528 / YN | 0.034 / 0.354 / YN |
| minds-heavy | 0.15 | 0.33 | 0.330 | 0.034 / 0.354 / YN | 0.635 / 0.528 / YN | 0.034 / 0.354 / YN |
| minds-heavy | 0.5 | none | 0.429 | 0.961 / 0.374 / NN | 0.274 / 0.055 / NN | 0.000 / 0.000 / NN |
| minds-heavy | 0.5 | 0.5 | 0.429 | 0.961 / 0.374 / NN | 0.274 / 0.055 / NN | 0.000 / 0.000 / NN |
| minds-heavy | 0.5 | 0.49 | 0.429 | 0.961 / 0.374 / NN | 0.274 / 0.055 / NN | 0.000 / 0.000 / NN |
| minds-heavy | 0.5 | 0.4 | 0.400 | 0.931 / 0.293 / NN | 0.274 / 0.055 / NN | 0.000 / 0.000 / NN |
| minds-heavy | 0.5 | 0.33 | 0.330 | 0.712 / 0.168 / NN | 0.274 / 0.055 / NN | 0.000 / 0.000 / NN |
| minds-heavy | 0.66 | none | 0.429 | 0.639 / 0.527 / NY | 0.639 / 0.527 / NY | 0.144 / 0.416 / NY |
| minds-heavy | 0.66 | 0.5 | 0.429 | 0.639 / 0.527 / NY | 0.639 / 0.527 / NY | 0.144 / 0.416 / NY |
| minds-heavy | 0.66 | 0.49 | 0.429 | 0.639 / 0.527 / NY | 0.639 / 0.527 / NY | 0.144 / 0.416 / NY |
| minds-heavy | 0.66 | 0.4 | 0.400 | 0.639 / 0.527 / NY | 0.639 / 0.527 / NY | 0.144 / 0.416 / NY |
| minds-heavy | 0.66 | 0.33 | 0.330 | 0.635 / 0.254 / NN | 0.274 / 0.055 / NN | 0.000 / 0.000 / NN |
| members-heavy | 0.15 | none | 0.692 | 0.361 / 0.473 / YN | 0.635 / 0.528 / YN | 0.034 / 0.354 / YN |
| members-heavy | 0.15 | 0.5 | 0.500 | 0.143 / 0.416 / YN | 0.635 / 0.528 / YN | 0.034 / 0.354 / YN |
| members-heavy | 0.15 | 0.49 | 0.490 | 0.143 / 0.416 / YN | 0.635 / 0.528 / YN | 0.034 / 0.354 / YN |
| members-heavy | 0.15 | 0.4 | 0.400 | 0.034 / 0.354 / YN | 0.635 / 0.528 / YN | 0.034 / 0.354 / YN |
| members-heavy | 0.15 | 0.33 | 0.330 | 0.034 / 0.354 / YN | 0.635 / 0.528 / YN | 0.034 / 0.354 / YN |
| members-heavy | 0.5 | none | 0.692 | 1.000 / 1.000 / YY | 1.000 / 1.000 / YY | 1.000 / 1.000 / YY |
| members-heavy | 0.5 | 0.5 | 0.500 | 0.996 / 0.727 / YN | 0.635 / 0.528 / YN | 0.361 / 0.473 / YN |
| members-heavy | 0.5 | 0.49 | 0.490 | 0.992 / 0.455 / NN | 0.274 / 0.055 / NN | 0.000 / 0.000 / NN |
| members-heavy | 0.5 | 0.4 | 0.400 | 0.931 / 0.293 / NN | 0.274 / 0.055 / NN | 0.000 / 0.000 / NN |
| members-heavy | 0.5 | 0.33 | 0.330 | 0.712 / 0.168 / NN | 0.274 / 0.055 / NN | 0.000 / 0.000 / NN |
| members-heavy | 0.66 | none | 0.692 | 1.000 / 1.000 / YY | 1.000 / 1.000 / YY | 1.000 / 1.000 / YY |
| members-heavy | 0.66 | 0.5 | 0.500 | 0.857 / 0.584 / NY | 0.639 / 0.527 / NY | 0.144 / 0.416 / NY |
| members-heavy | 0.66 | 0.49 | 0.490 | 0.857 / 0.584 / NY | 0.639 / 0.527 / NY | 0.144 / 0.416 / NY |
| members-heavy | 0.66 | 0.4 | 0.400 | 0.639 / 0.527 / NY | 0.639 / 0.527 / NY | 0.144 / 0.416 / NY |
| members-heavy | 0.66 | 0.33 | 0.330 | 0.635 / 0.254 / NN | 0.274 / 0.055 / NN | 0.000 / 0.000 / NN |
### growth n=5 k=3 S=.8 +U10 P2
| seed | q | cap | her share x | prop | wta | bpt |
|---|---|---|---|---|---|---|
| equal | 0.15 | none | 0.098 | 0.001 / 0.065 / NN | 0.175 / 0.115 / NN | 0.000 / 0.027 / YN |
| equal | 0.15 | 0.5 | 0.098 | 0.001 / 0.065 / NN | 0.175 / 0.115 / NN | 0.000 / 0.027 / YN |
| equal | 0.15 | 0.49 | 0.098 | 0.001 / 0.065 / NN | 0.175 / 0.115 / NN | 0.000 / 0.027 / YN |
| equal | 0.15 | 0.4 | 0.098 | 0.001 / 0.065 / NN | 0.175 / 0.115 / NN | 0.000 / 0.027 / YN |
| equal | 0.15 | 0.33 | 0.098 | 0.001 / 0.065 / NN | 0.175 / 0.115 / NN | 0.000 / 0.027 / YN |
| equal | 0.5 | none | 0.098 | 0.306 / 0.153 / NN | 0.376 / 0.205 / NN | 0.264 / 0.189 / NN |
| equal | 0.5 | 0.5 | 0.098 | 0.306 / 0.153 / NN | 0.376 / 0.205 / NN | 0.264 / 0.189 / NN |
| equal | 0.5 | 0.49 | 0.098 | 0.306 / 0.153 / NN | 0.376 / 0.205 / NN | 0.264 / 0.189 / NN |
| equal | 0.5 | 0.4 | 0.098 | 0.306 / 0.153 / NN | 0.376 / 0.205 / NN | 0.264 / 0.189 / NN |
| equal | 0.5 | 0.33 | 0.098 | 0.306 / 0.153 / NN | 0.376 / 0.205 / NN | 0.264 / 0.189 / NN |
| equal | 0.66 | none | 0.098 | 0.150 / 0.133 / NN | 0.378 / 0.202 / NN | 0.054 / 0.164 / NN |
| equal | 0.66 | 0.5 | 0.098 | 0.150 / 0.133 / NN | 0.378 / 0.202 / NN | 0.054 / 0.164 / NN |
| equal | 0.66 | 0.49 | 0.098 | 0.150 / 0.133 / NN | 0.378 / 0.202 / NN | 0.054 / 0.164 / NN |
| equal | 0.66 | 0.4 | 0.098 | 0.150 / 0.133 / NN | 0.378 / 0.202 / NN | 0.054 / 0.164 / NN |
| equal | 0.66 | 0.33 | 0.098 | 0.150 / 0.133 / NN | 0.378 / 0.202 / NN | 0.054 / 0.164 / NN |
| minds-heavy | 0.15 | none | 0.118 | 0.001 / 0.104 / NN | 0.274 / 0.186 / NN | 0.000 / 0.045 / YN |
| minds-heavy | 0.15 | 0.5 | 0.118 | 0.001 / 0.104 / NN | 0.274 / 0.186 / NN | 0.000 / 0.045 / YN |
| minds-heavy | 0.15 | 0.49 | 0.118 | 0.001 / 0.104 / NN | 0.274 / 0.186 / NN | 0.000 / 0.045 / YN |
| minds-heavy | 0.15 | 0.4 | 0.118 | 0.001 / 0.104 / NN | 0.274 / 0.186 / NN | 0.000 / 0.045 / YN |
| minds-heavy | 0.15 | 0.33 | 0.118 | 0.001 / 0.104 / NN | 0.274 / 0.186 / NN | 0.000 / 0.045 / YN |
| minds-heavy | 0.5 | none | 0.118 | 0.452 / 0.152 / NN | 0.412 / 0.200 / NN | 0.303 / 0.197 / NN |
| minds-heavy | 0.5 | 0.5 | 0.118 | 0.452 / 0.152 / NN | 0.412 / 0.200 / NN | 0.303 / 0.197 / NN |
| minds-heavy | 0.5 | 0.49 | 0.118 | 0.452 / 0.152 / NN | 0.412 / 0.200 / NN | 0.303 / 0.197 / NN |
| minds-heavy | 0.5 | 0.4 | 0.118 | 0.452 / 0.152 / NN | 0.412 / 0.200 / NN | 0.303 / 0.197 / NN |
| minds-heavy | 0.5 | 0.33 | 0.118 | 0.452 / 0.152 / NN | 0.412 / 0.200 / NN | 0.303 / 0.197 / NN |
| minds-heavy | 0.66 | none | 0.118 | 0.142 / 0.153 / NN | 0.453 / 0.215 / NN | 0.087 / 0.198 / NN |
| minds-heavy | 0.66 | 0.5 | 0.118 | 0.142 / 0.153 / NN | 0.453 / 0.215 / NN | 0.087 / 0.198 / NN |
| minds-heavy | 0.66 | 0.49 | 0.118 | 0.142 / 0.153 / NN | 0.453 / 0.215 / NN | 0.087 / 0.198 / NN |
| minds-heavy | 0.66 | 0.4 | 0.118 | 0.142 / 0.153 / NN | 0.453 / 0.215 / NN | 0.087 / 0.198 / NN |
| minds-heavy | 0.66 | 0.33 | 0.118 | 0.142 / 0.153 / NN | 0.453 / 0.215 / NN | 0.087 / 0.198 / NN |
| members-heavy | 0.15 | none | 0.176 | 0.003 / 0.103 / YN | 0.175 / 0.115 / NN | 0.000 / 0.027 / YN |
| members-heavy | 0.15 | 0.5 | 0.176 | 0.003 / 0.103 / YN | 0.175 / 0.115 / NN | 0.000 / 0.027 / YN |
| members-heavy | 0.15 | 0.49 | 0.176 | 0.003 / 0.103 / YN | 0.175 / 0.115 / NN | 0.000 / 0.027 / YN |
| members-heavy | 0.15 | 0.4 | 0.176 | 0.003 / 0.103 / YN | 0.175 / 0.115 / NN | 0.000 / 0.027 / YN |
| members-heavy | 0.15 | 0.33 | 0.176 | 0.003 / 0.103 / YN | 0.175 / 0.115 / NN | 0.000 / 0.027 / YN |
| members-heavy | 0.5 | none | 0.176 | 0.611 / 0.308 / NN | 0.755 / 0.415 / NN | 0.641 / 0.392 / NN |
| members-heavy | 0.5 | 0.5 | 0.176 | 0.611 / 0.308 / NN | 0.755 / 0.415 / NN | 0.641 / 0.392 / NN |
| members-heavy | 0.5 | 0.49 | 0.176 | 0.611 / 0.308 / NN | 0.755 / 0.415 / NN | 0.641 / 0.392 / NN |
| members-heavy | 0.5 | 0.4 | 0.176 | 0.611 / 0.308 / NN | 0.755 / 0.415 / NN | 0.641 / 0.392 / NN |
| members-heavy | 0.5 | 0.33 | 0.176 | 0.611 / 0.308 / NN | 0.755 / 0.415 / NN | 0.641 / 0.392 / NN |
| members-heavy | 0.66 | none | 0.176 | 0.237 / 0.255 / NN | 0.552 / 0.317 / NN | 0.058 / 0.238 / NY |
| members-heavy | 0.66 | 0.5 | 0.176 | 0.237 / 0.255 / NN | 0.552 / 0.317 / NN | 0.058 / 0.238 / NY |
| members-heavy | 0.66 | 0.49 | 0.176 | 0.237 / 0.255 / NN | 0.552 / 0.317 / NN | 0.058 / 0.238 / NY |
| members-heavy | 0.66 | 0.4 | 0.176 | 0.237 / 0.255 / NN | 0.552 / 0.317 / NN | 0.058 / 0.238 / NY |
| members-heavy | 0.66 | 0.33 | 0.176 | 0.237 / 0.255 / NN | 0.552 / 0.317 / NN | 0.058 / 0.238 / NY |
### growth n=20 k=3 S=.8 +U10 P2
| seed | q | cap | her share x | prop | wta | bpt |
|---|---|---|---|---|---|---|
| equal | 0.15 | none | 0.048 | 0.000 / 0.034 / NN | 0.133 / 0.041 / NN | 0.000 / 0.022 / YN |
| equal | 0.15 | 0.5 | 0.048 | 0.000 / 0.034 / NN | 0.133 / 0.041 / NN | 0.000 / 0.022 / YN |
| equal | 0.15 | 0.49 | 0.048 | 0.000 / 0.034 / NN | 0.133 / 0.041 / NN | 0.000 / 0.022 / YN |
| equal | 0.15 | 0.4 | 0.048 | 0.000 / 0.034 / NN | 0.133 / 0.041 / NN | 0.000 / 0.022 / YN |
| equal | 0.15 | 0.33 | 0.048 | 0.000 / 0.034 / NN | 0.133 / 0.041 / NN | 0.000 / 0.022 / YN |
| equal | 0.5 | none | 0.048 | 0.158 / 0.075 / NN | 0.294 / 0.076 / NN | 0.205 / 0.069 / NN |
| equal | 0.5 | 0.5 | 0.048 | 0.158 / 0.075 / NN | 0.294 / 0.076 / NN | 0.205 / 0.069 / NN |
| equal | 0.5 | 0.49 | 0.048 | 0.158 / 0.075 / NN | 0.294 / 0.076 / NN | 0.205 / 0.069 / NN |
| equal | 0.5 | 0.4 | 0.048 | 0.158 / 0.075 / NN | 0.294 / 0.076 / NN | 0.205 / 0.069 / NN |
| equal | 0.5 | 0.33 | 0.048 | 0.158 / 0.075 / NN | 0.294 / 0.076 / NN | 0.205 / 0.069 / NN |
| equal | 0.66 | none | 0.048 | 0.074 / 0.064 / NN | 0.294 / 0.076 / NN | 0.022 / 0.064 / NN |
| equal | 0.66 | 0.5 | 0.048 | 0.074 / 0.064 / NN | 0.294 / 0.076 / NN | 0.022 / 0.064 / NN |
| equal | 0.66 | 0.49 | 0.048 | 0.074 / 0.064 / NN | 0.294 / 0.076 / NN | 0.022 / 0.064 / NN |
| equal | 0.66 | 0.4 | 0.048 | 0.074 / 0.064 / NN | 0.294 / 0.076 / NN | 0.022 / 0.064 / NN |
| equal | 0.66 | 0.33 | 0.048 | 0.074 / 0.064 / NN | 0.294 / 0.076 / NN | 0.022 / 0.064 / NN |
| minds-heavy | 0.15 | none | 0.058 | 0.000 / 0.051 / NN | 0.209 / 0.067 / NN | 0.000 / 0.038 / YN |
| minds-heavy | 0.15 | 0.5 | 0.058 | 0.000 / 0.051 / NN | 0.209 / 0.067 / NN | 0.000 / 0.038 / YN |
| minds-heavy | 0.15 | 0.49 | 0.058 | 0.000 / 0.051 / NN | 0.209 / 0.067 / NN | 0.000 / 0.038 / YN |
| minds-heavy | 0.15 | 0.4 | 0.058 | 0.000 / 0.051 / NN | 0.209 / 0.067 / NN | 0.000 / 0.038 / YN |
| minds-heavy | 0.15 | 0.33 | 0.058 | 0.000 / 0.051 / NN | 0.209 / 0.067 / NN | 0.000 / 0.038 / YN |
| minds-heavy | 0.5 | none | 0.058 | 0.248 / 0.074 / NN | 0.321 / 0.074 / NN | 0.236 / 0.073 / NN |
| minds-heavy | 0.5 | 0.5 | 0.058 | 0.248 / 0.074 / NN | 0.321 / 0.074 / NN | 0.236 / 0.073 / NN |
| minds-heavy | 0.5 | 0.49 | 0.058 | 0.248 / 0.074 / NN | 0.321 / 0.074 / NN | 0.236 / 0.073 / NN |
| minds-heavy | 0.5 | 0.4 | 0.058 | 0.248 / 0.074 / NN | 0.321 / 0.074 / NN | 0.236 / 0.073 / NN |
| minds-heavy | 0.5 | 0.33 | 0.058 | 0.248 / 0.074 / NN | 0.321 / 0.074 / NN | 0.236 / 0.073 / NN |
| minds-heavy | 0.66 | none | 0.058 | 0.056 / 0.075 / NN | 0.353 / 0.079 / NN | 0.035 / 0.080 / NN |
| minds-heavy | 0.66 | 0.5 | 0.058 | 0.056 / 0.075 / NN | 0.353 / 0.079 / NN | 0.035 / 0.080 / NN |
| minds-heavy | 0.66 | 0.49 | 0.058 | 0.056 / 0.075 / NN | 0.353 / 0.079 / NN | 0.035 / 0.080 / NN |
| minds-heavy | 0.66 | 0.4 | 0.058 | 0.056 / 0.075 / NN | 0.353 / 0.079 / NN | 0.035 / 0.080 / NN |
| minds-heavy | 0.66 | 0.33 | 0.058 | 0.056 / 0.075 / NN | 0.353 / 0.079 / NN | 0.035 / 0.080 / NN |
| members-heavy | 0.15 | none | 0.087 | 0.000 / 0.057 / NN | 0.133 / 0.041 / NN | 0.000 / 0.022 / YN |
| members-heavy | 0.15 | 0.5 | 0.087 | 0.000 / 0.057 / NN | 0.133 / 0.041 / NN | 0.000 / 0.022 / YN |
| members-heavy | 0.15 | 0.49 | 0.087 | 0.000 / 0.057 / NN | 0.133 / 0.041 / NN | 0.000 / 0.022 / YN |
| members-heavy | 0.15 | 0.4 | 0.087 | 0.000 / 0.057 / NN | 0.133 / 0.041 / NN | 0.000 / 0.022 / YN |
| members-heavy | 0.15 | 0.33 | 0.087 | 0.000 / 0.057 / NN | 0.133 / 0.041 / NN | 0.000 / 0.022 / YN |
| members-heavy | 0.5 | none | 0.087 | 0.382 / 0.141 / NN | 0.586 / 0.153 / NN | 0.498 / 0.145 / NN |
| members-heavy | 0.5 | 0.5 | 0.087 | 0.382 / 0.141 / NN | 0.586 / 0.153 / NN | 0.498 / 0.145 / NN |
| members-heavy | 0.5 | 0.49 | 0.087 | 0.382 / 0.141 / NN | 0.586 / 0.153 / NN | 0.498 / 0.145 / NN |
| members-heavy | 0.5 | 0.4 | 0.087 | 0.382 / 0.141 / NN | 0.586 / 0.153 / NN | 0.498 / 0.145 / NN |
| members-heavy | 0.5 | 0.33 | 0.087 | 0.382 / 0.141 / NN | 0.586 / 0.153 / NN | 0.498 / 0.145 / NN |
| members-heavy | 0.66 | none | 0.087 | 0.079 / 0.121 / NN | 0.428 / 0.116 / NN | 0.024 / 0.089 / NN |
| members-heavy | 0.66 | 0.5 | 0.087 | 0.079 / 0.121 / NN | 0.428 / 0.116 / NN | 0.024 / 0.089 / NN |
| members-heavy | 0.66 | 0.49 | 0.087 | 0.079 / 0.121 / NN | 0.428 / 0.116 / NN | 0.024 / 0.089 / NN |
| members-heavy | 0.66 | 0.4 | 0.087 | 0.079 / 0.121 / NN | 0.428 / 0.116 / NN | 0.024 / 0.089 / NN |
| members-heavy | 0.66 | 0.33 | 0.087 | 0.079 / 0.121 / NN | 0.428 / 0.116 / NN | 0.024 / 0.089 / NN |

View file

@ -0,0 +1,75 @@
"""Operator cap (her words: "I would limit my influence by half, just so I'm not deciding everything").
Cap c on her WEIGHT SHARE of the renormalized tally: her share = (her internal share of Members) * W_Me / W_live.
If she is the sole Member, the cap shrinks the Members estate weight to c/(1-c) * (other live weight);
otherwise it shrinks her internal weight until her share = c. Then measure DECISIVENESS:
pivot rate (coin voters; and motion model, Minds ICC .44), unilateral pass (her For passes with everyone else
Against) and unilateral block (her Against fails with everyone else For). Ties pass (>=). Run: python -I cap.py
"""
import numpy as np
SEEDS = {"equal": [25, 25, 25, 25], "minds-heavy": [40, 30, 20, 10], "members-heavy": [20, 45, 20, 15]}
def tally(Wb, F, Tot, q, rule):
Wb = np.asarray(Wb, float); live = Tot > 0; sh = np.where(live, F / np.where(live, Tot, 1), 0)
if rule == "prop": f = (sh * Wb).sum(-1); den = (live * Wb).sum(-1)
elif rule == "wta":
st = np.where(live, np.sign(2 * F - Tot), 0); f = (st == 1) @ Wb; den = f + (st == -1) @ Wb
else: f = ((sh >= q - 1e-9) & live) @ Wb; den = live @ Wb
return (den > 0) & (f >= q * den - 1e-9)
def mweights(n, S=.8, k=3):
if n == 1: return np.ones(1)
tail = np.array([i ** (-1 / 1.16) for i in range(1, n)]); tail = tail / tail.sum() * (1 - S)
return np.concatenate([[S], tail]) ** (1 / k)
def apply_cap(Wb, wM, sizes, c):
Wb = np.array(Wb, float); wM = wM.copy(); live = np.array([s > 0 for s in sizes])
if c is None: return Wb, wM
L = (Wb * live).sum(); x = wM[0] / wM.sum()
if x * Wb[1] <= c * L + 1e-12: return Wb, wM
if len(wM) == 1:
Wb[1] = c / (1 - c) * (L - Wb[1])
else:
x_new = c * L / Wb[1]; o = wM[1:].sum(); wM[0] = x_new * o / (1 - x_new)
return Wb, wM
def run(Wb, wM, sizes, q, rule, model, T=100_000, seed=9):
r = np.random.default_rng(seed)
ws = [np.ones(sizes[0]), wM, np.ones(sizes[2]), np.log1p(np.array([50, 500.0])[:sizes[3]])]
if model == "coin": p = np.full((T, 4), .5)
else:
m = r.standard_normal(T); p = 1 / (1 + np.exp(-1.7 * (0.6 * m[:, None] + 0.8 * r.standard_normal((T, 4)))))
F = np.zeros((T, 4)); Tot = np.zeros((T, 4)); her = None
for b, w in enumerate(ws):
if len(w) == 0: continue
pb = p[:, b:b + 1]
if b == 0 and model == "motion":
a = p[:, 0] * .56 / .44; cc = (1 - p[:, 0]) * .56 / .44; pb = r.beta(a, cc)[:, None]
v = r.random((T, len(w))) < pb
if b == 1: her = v[:, 0]
F[:, b] = v @ w; Tot[:, b] = w.sum()
G = F.copy(); G[:, 1] += np.where(her, -wM[0], wM[0])
return np.mean(tally(Wb, F, Tot, q, rule) != tally(Wb, G, Tot, q, rule))
def unilateral(Wb, wM, sizes, q, rule):
ws = [np.ones(sizes[0]), wM, np.ones(sizes[2]), np.log1p(np.array([50, 500.0])[:sizes[3]])]
Tot = np.array([[w.sum() for w in ws]])
Fp = np.array([[0, wM[0], 0, 0]], float); Fb = np.array([[w.sum() for w in ws]], float); Fb[0, 1] -= wM[0]
return bool(tally(Wb, Fp, Tot, q, rule)[0]), not bool(tally(Wb, Fb, Tot, q, rule)[0])
CAPS = (None, .5, .49, .4, .33)
CASES = (("present 8/1/0/0", (8, 1, 0, 0)), ("growth n=5 k=3 S=.8 +U10 P2", (8, 5, 10, 2)), ("growth n=20 k=3 S=.8 +U10 P2", (8, 20, 10, 2)))
print("Cells: pivot coin / pivot motion / U+ U- (unilateral pass, unilateral block; Y/N). Her weight share shown as x.")
for cname, sizes in CASES:
print(f"\n### {cname}")
print("| seed | q | cap | her share x | prop | wta | bpt |"); print("|---|---|---|---|---|---|---|")
for s, W0 in SEEDS.items():
for q in (0.15, 0.5, 0.66):
for c in CAPS:
Wb, wM = apply_cap(W0, mweights(sizes[1]), sizes, c)
live = np.array([n > 0 for n in sizes]); x = wM[0] / wM.sum() * Wb[1] / (Wb * live).sum()
cells = []
for rule in ("prop", "wta", "bpt"):
pc = run(Wb, wM, sizes, q, rule, "coin"); pm = run(Wb, wM, sizes, q, rule, "motion")
up, ub = unilateral(Wb, wM, sizes, q, rule)
cells.append(f"{pc:.3f} / {pm:.3f} / {'Y' if up else 'N'}{'Y' if ub else 'N'}")
print(f"| {s} | {q} | {c if c else 'none'} | {x:.3f} | " + " | ".join(cells) + " |")

View file

@ -0,0 +1,110 @@
## G1. Present case: Members = her alone, Minds 8 (fair coins; 'unan' = Minds fully correlated)
Cells: P(her vote flips the motion) / P(outcome matches her vote), prop then WTA.
| seed | q | Users, Patrons | Minds | prop pivot / match | WTA pivot / match |
|---|---|---|---|---|---|
| equal | 0.15 | 0U, 0P | indep | 0.145 / 0.570 | 0.636 / 0.817 |
| equal | 0.15 | 0U, 0P | unan | 0.501 / 0.752 | 0.501 / 0.752 |
| equal | 0.15 | 3U, 1P | indep | 0.066 / 0.530 | 0.159 / 0.578 |
| equal | 0.15 | 3U, 1P | unan | 0.126 / 0.564 | 0.126 / 0.564 |
| equal | 0.5 | 0U, 0P | indep | 0.996 / 0.998 | 0.636 / 0.817 |
| equal | 0.5 | 0U, 0P | unan | 0.501 / 0.752 | 0.501 / 0.752 |
| equal | 0.5 | 3U, 1P | indep | 0.500 / 0.750 | 0.410 / 0.705 |
| equal | 0.5 | 3U, 1P | unan | 0.467 / 0.733 | 0.374 / 0.687 |
| equal | 0.66 | 0U, 0P | indep | 0.855 / 0.928 | 0.635 / 0.818 |
| equal | 0.66 | 0U, 0P | unan | 0.499 / 0.749 | 0.499 / 0.749 |
| equal | 0.66 | 3U, 1P | indep | 0.420 / 0.712 | 0.407 / 0.705 |
| equal | 0.66 | 3U, 1P | unan | 0.375 / 0.687 | 0.375 / 0.687 |
| minds-heavy | 0.15 | 0U, 0P | indep | 0.145 / 0.570 | 0.636 / 0.817 |
| minds-heavy | 0.15 | 0U, 0P | unan | 0.501 / 0.752 | 0.501 / 0.752 |
| minds-heavy | 0.15 | 3U, 1P | indep | 0.017 / 0.506 | 0.251 / 0.624 |
| minds-heavy | 0.15 | 3U, 1P | unan | 0.251 / 0.627 | 0.251 / 0.628 |
| minds-heavy | 0.5 | 0U, 0P | indep | 0.961 / 0.980 | 0.271 / 0.636 |
| minds-heavy | 0.5 | 0U, 0P | unan | 0.000 / 0.501 | 0.000 / 0.501 |
| minds-heavy | 0.5 | 3U, 1P | indep | 0.857 / 0.929 | 0.477 / 0.739 |
| minds-heavy | 0.5 | 3U, 1P | unan | 0.280 / 0.640 | 0.374 / 0.688 |
| minds-heavy | 0.66 | 0U, 0P | indep | 0.635 / 0.818 | 0.635 / 0.818 |
| minds-heavy | 0.66 | 0U, 0P | unan | 0.499 / 0.749 | 0.499 / 0.749 |
| minds-heavy | 0.66 | 3U, 1P | indep | 0.484 / 0.744 | 0.475 / 0.739 |
| minds-heavy | 0.66 | 3U, 1P | unan | 0.467 / 0.733 | 0.375 / 0.687 |
| members-heavy | 0.15 | 0U, 0P | indep | 0.365 / 0.681 | 0.636 / 0.817 |
| members-heavy | 0.15 | 0U, 0P | unan | 0.501 / 0.752 | 0.501 / 0.752 |
| members-heavy | 0.15 | 3U, 1P | indep | 0.122 / 0.558 | 0.159 / 0.578 |
| members-heavy | 0.15 | 3U, 1P | unan | 0.220 / 0.611 | 0.126 / 0.564 |
| members-heavy | 0.5 | 0U, 0P | indep | 1.000 / 1.000 | 1.000 / 1.000 |
| members-heavy | 0.5 | 0U, 0P | unan | 1.000 / 1.000 | 1.000 / 1.000 |
| members-heavy | 0.5 | 3U, 1P | indep | 0.988 / 0.994 | 0.818 / 0.908 |
| members-heavy | 0.5 | 3U, 1P | unan | 0.936 / 0.968 | 0.750 / 0.875 |
| members-heavy | 0.66 | 0U, 0P | indep | 1.000 / 1.000 | 1.000 / 1.000 |
| members-heavy | 0.66 | 0U, 0P | unan | 1.000 / 1.000 | 1.000 / 1.000 |
| members-heavy | 0.66 | 3U, 1P | indep | 0.700 / 0.851 | 0.567 / 0.785 |
| members-heavy | 0.66 | 3U, 1P | unan | 0.687 / 0.843 | 0.500 / 0.749 |
## G2. Her weight share inside Members; WTA-internal she alone decides the bloc iff share > 0.5
| n Members | S | one-each | k=1 | k=2 | k=3 | k=5 |
|---|---|---|---|---|---|---|
| 1 | 1.0 | 1.00* | 1.00* | 1.00* | 1.00* | 1.00* |
| 2 | 0.5 | 0.50 | 0.50 | 0.50 | 0.50 | 0.50 |
| 2 | 0.8 | 0.50 | 0.80* | 0.67* | 0.61* | 0.57* |
| 2 | 0.95 | 0.50 | 0.95* | 0.81* | 0.73* | 0.64* |
| 3 | 0.5 | 0.33 | 0.50 | 0.42 | 0.39 | 0.37 |
| 3 | 0.8 | 0.33 | 0.80* | 0.59* | 0.50* | 0.43 |
| 3 | 0.95 | 0.33 | 0.95* | 0.76* | 0.63* | 0.51* |
| 5 | 0.5 | 0.20 | 0.50 | 0.34 | 0.29 | 0.25 |
| 5 | 0.8 | 0.20 | 0.80* | 0.51* | 0.39 | 0.31 |
| 5 | 0.95 | 0.20 | 0.95* | 0.69* | 0.52* | 0.38 |
| 10 | 0.5 | 0.10 | 0.50* | 0.26 | 0.19 | 0.15 |
| 10 | 0.8 | 0.10 | 0.80* | 0.41 | 0.28 | 0.19 |
| 10 | 0.95 | 0.10 | 0.95* | 0.60* | 0.39 | 0.24 |
| 20 | 0.5 | 0.05 | 0.50 | 0.20 | 0.13 | 0.09 |
| 20 | 0.8 | 0.05 | 0.80* | 0.33 | 0.19 | 0.12 |
| 20 | 0.95 | 0.05 | 0.95* | 0.52* | 0.29 | 0.15 |
| 50 | 0.5 | 0.02 | 0.50* | 0.14 | 0.08 | 0.05 |
| 50 | 0.8 | 0.02 | 0.80* | 0.24 | 0.12 | 0.06 |
| 50 | 0.95 | 0.02 | 0.95* | 0.41 | 0.18 | 0.08 |
(* = she alone decides the Members bloc under WTA/unitary internal majority)
## G3. Growth path: her pivot over the whole tally (fair coins, Minds indep, Users 10, Patrons [50,500] log), q=0.5
| seed | n Members | S | curve | prop pivot | WTA pivot |
|---|---|---|---|---|---|
| equal | 1 | 1.0 | alone | 0.723 | 0.429 |
| equal | 5 | 0.5 | one-each | 0.152 | 0.161 |
| equal | 5 | 0.5 | k=1 | 0.387 | 0.413 |
| equal | 5 | 0.5 | k=2 | 0.259 | 0.269 |
| equal | 5 | 0.5 | k=3 | 0.225 | 0.215 |
| equal | 5 | 0.5 | k=5 | 0.195 | 0.161 |
| equal | 5 | 0.8 | one-each | 0.152 | 0.161 |
| equal | 5 | 0.8 | k=1 | 0.601 | 0.434 |
| equal | 5 | 0.8 | k=2 | 0.392 | 0.434 |
| equal | 5 | 0.8 | k=3 | 0.305 | 0.380 |
| equal | 5 | 0.8 | k=5 | 0.237 | 0.215 |
| equal | 20 | 0.5 | one-each | 0.040 | 0.076 |
| equal | 20 | 0.5 | k=1 | 0.396 | 0.430 |
| equal | 20 | 0.5 | k=2 | 0.161 | 0.290 |
| equal | 20 | 0.5 | k=3 | 0.106 | 0.201 |
| equal | 20 | 0.5 | k=5 | 0.074 | 0.144 |
| equal | 20 | 0.8 | one-each | 0.040 | 0.076 |
| equal | 20 | 0.8 | k=1 | 0.606 | 0.430 |
| equal | 20 | 0.8 | k=2 | 0.267 | 0.411 |
| equal | 20 | 0.8 | k=3 | 0.156 | 0.293 |
| equal | 20 | 0.8 | k=5 | 0.095 | 0.179 |
| members-heavy | 1 | 1.0 | alone | 1.000 | 0.862 |
| members-heavy | 5 | 0.5 | one-each | 0.296 | 0.323 |
| members-heavy | 5 | 0.5 | k=1 | 0.752 | 0.823 |
| members-heavy | 5 | 0.5 | k=2 | 0.519 | 0.538 |
| members-heavy | 5 | 0.5 | k=3 | 0.441 | 0.429 |
| members-heavy | 5 | 0.5 | k=5 | 0.380 | 0.323 |
| members-heavy | 5 | 0.8 | one-each | 0.296 | 0.323 |
| members-heavy | 5 | 0.8 | k=1 | 0.987 | 0.862 |
| members-heavy | 5 | 0.8 | k=2 | 0.779 | 0.862 |
| members-heavy | 5 | 0.8 | k=3 | 0.613 | 0.755 |
| members-heavy | 5 | 0.8 | k=5 | 0.475 | 0.429 |
| members-heavy | 20 | 0.5 | one-each | 0.099 | 0.152 |
| members-heavy | 20 | 0.5 | k=1 | 0.832 | 0.861 |
| members-heavy | 20 | 0.5 | k=2 | 0.388 | 0.580 |
| members-heavy | 20 | 0.5 | k=3 | 0.259 | 0.403 |
| members-heavy | 20 | 0.5 | k=5 | 0.179 | 0.288 |
| members-heavy | 20 | 0.8 | one-each | 0.099 | 0.152 |
| members-heavy | 20 | 0.8 | k=1 | 0.991 | 0.861 |
| members-heavy | 20 | 0.8 | k=2 | 0.627 | 0.824 |
| members-heavy | 20 | 0.8 | k=3 | 0.380 | 0.586 |
| members-heavy | 20 | 0.8 | k=5 | 0.231 | 0.359 |

View file

@ -0,0 +1,73 @@
"""Present case and growth path (operator 2026-10-29: "until we get more people in, I'm the only active member").
Operator = Member 0. S = her share of total Member contribution; the other n-1 follow the Pareto(1.16) ladder.
Empty blocs cast nothing and drop out of the denominator (abstentions excluded). Run: python -I growth.py
"""
import numpy as np
SEEDS = {"equal": [25, 25, 25, 25], "minds-heavy": [40, 30, 20, 10], "members-heavy": [20, 45, 20, 15]}
CURVES = {"one-each": None, "k=1": 1, "k=2": 2, "k=3": 3, "k=5": 5}
def mweights(n, S, k):
if n == 1: return np.ones(1)
tail = np.array([i ** (-1 / 1.16) for i in range(1, n)]); tail = tail / tail.sum() * (1 - S)
c = np.concatenate([[S], tail]); return np.ones(n) if k is None else c ** (1 / k)
def tally(Wb, F, Tot, q, rule):
Wb = np.array(Wb, float); live = Tot > 0
if rule == "prop":
sh = np.divide(F, Tot, out=np.zeros_like(F), where=live); f = (sh * Wb).sum(1); den = (live * Wb).sum(1)
else:
st = np.where(live, np.sign(2 * F - Tot), 0); f = (st == 1) @ Wb; den = f + (st == -1) @ Wb
return (den > 0) & (f >= q * den - 1e-9)
def her_power(wM, Wb, q, users=0, patrons=(), minds_corr=False, T=200_000, seed=5):
r = np.random.default_rng(seed)
ws = [np.ones(8), wM, np.ones(users), np.log1p(np.array(patrons, float))]
F = np.zeros((T, 4)); Tot = np.zeros((T, 4)); her = None
for b, w in enumerate(ws):
if len(w) == 0: continue
if b == 0 and minds_corr: v = np.repeat(r.random((T, 1)) < .5, 8, 1)
else: v = r.random((T, len(w))) < .5
if b == 1: her = v[:, 0]
F[:, b] = v @ w; Tot[:, b] = w.sum()
res = {}
for rule in ("prop", "wta"):
G = F.copy(); G[:, 1] += np.where(her, -wM[0], wM[0])
o = tally(Wb, F, Tot, q, rule); o2 = tally(Wb, G, Tot, q, rule)
res[rule] = (np.mean(o != o2), np.mean(o == her))
return res
if __name__ == "__main__":
print("## G1. Present case: Members = her alone, Minds 8 (fair coins; 'unan' = Minds fully correlated)")
print("Cells: P(her vote flips the motion) / P(outcome matches her vote), prop then WTA.")
print("| seed | q | Users, Patrons | Minds | prop pivot / match | WTA pivot / match |")
print("|---|---|---|---|---|---|")
for name, Wb in SEEDS.items():
for q in (0.15, 0.5, 0.66):
for users, pat in ((0, ()), (3, (100,))):
for mc in (False, True):
r = her_power(np.ones(1), Wb, q, users, pat, mc, T=100_000)
print(f"| {name} | {q} | {users}U, {len(pat)}P | {'unan' if mc else 'indep'} | {r['prop'][0]:.3f} / {r['prop'][1]:.3f} | {r['wta'][0]:.3f} / {r['wta'][1]:.3f} |")
print("\n## G2. Her weight share inside Members; WTA-internal she alone decides the bloc iff share > 0.5")
print("| n Members | S | one-each | k=1 | k=2 | k=3 | k=5 |")
print("|---|---|---|---|---|---|---|")
for n in (1, 2, 3, 5, 10, 20, 50):
for S in (0.5, 0.8, 0.95):
if n == 1 and S != 0.95: continue
cells = []
for kn, k in CURVES.items():
w = mweights(n, S if n > 1 else 1, k); sh = w[0] / w.sum()
cells.append(f"{sh:.2f}{'*' if sh > 0.5 else ''}")
print(f"| {n} | {S if n > 1 else 1.0} | " + " | ".join(cells) + " |")
print("(* = she alone decides the Members bloc under WTA/unitary internal majority)")
print("\n## G3. Growth path: her pivot over the whole tally (fair coins, Minds indep, Users 10, Patrons [50,500] log), q=0.5")
print("| seed | n Members | S | curve | prop pivot | WTA pivot |")
print("|---|---|---|---|---|---|")
for name in ("equal", "members-heavy"):
Wb = SEEDS[name]
for n in (1, 5, 20):
for S in ((1.0,) if n == 1 else (0.5, 0.8)):
for kn, k in (CURVES.items() if n > 1 else [("alone", None)]):
r = her_power(mweights(n, S, k), Wb, .5, users=10, patrons=(50, 500), T=100_000)
print(f"| {name} | {n} | {S} | {kn} | {r['prop'][0]:.3f} | {r['wta'][0]:.3f} |")

View file

@ -0,0 +1,28 @@
## L1. Quorum floors a lone Minds turnout (8 of 8) meets, she absent
| floor type | Minds-alone value | equal | minds-heavy | members-heavy |
|---|---|---|---|---|
| headcount fraction of eligible (8 Minds + her) | 8/9 = 0.889 | same | same | same |
| estate-weight fraction of live-eligible estates | w_M/(w_M+w_Me) | 0.500 | 0.571 | 0.308 |
A floor above these values requires her turnout; at or below, Minds alone can decide.
## L2. Lone Minds: Personas For needed and P(pass), motion model, by category
| category | q | Personas needed prop | Personas needed WTA | P(pass) prop ICC 0 / .44 / 1 | P(pass) WTA ICC 0 / .44 / 1 |
|---|---|---|---|---|---|
| Bugs | 0.15 | 2 | 5 (any q) | 0.775 / 0.647 / 0.500 | 0.446 / 0.474 / 0.500 |
| Cosmetics | 0.3 | 3 | 5 (any q) | 0.662 / 0.585 / 0.500 | 0.446 / 0.474 / 0.500 |
| Balance | 0.4 | 4 | 5 (any q) | 0.554 / 0.528 / 0.500 | 0.446 / 0.474 / 0.500 |
| Features | 0.5 | 4 | 5 (any q) | 0.554 / 0.528 / 0.500 | 0.446 / 0.474 / 0.500 |
| NewContent | 0.5 | 4 | 5 (any q) | 0.554 / 0.528 / 0.500 | 0.446 / 0.474 / 0.500 |
| FundamentalDesign | 0.66 | 6 | 5 (any q) | 0.337 / 0.418 / 0.500 | 0.446 / 0.474 / 0.500 |
Under WTA a lone estate casts 100% For or 100% Against, so For/(For+Against) is 1 or 0 and category thresholds are inert.
## L3. Delay-plus-cancel window: P(she is back before the decision takes effect), absence ~ Exponential(mean A)
| delay D | A=1 day | A=3 days | A=7 days | A=14 days |
|---|---|---|---|---|
| 1 d | 0.63 | 0.28 | 0.13 | 0.07 |
| 2 d | 0.86 | 0.49 | 0.25 | 0.13 |
| 3 d | 0.95 | 0.63 | 0.35 | 0.19 |
| 7 d | 1.00 | 0.90 | 0.63 | 0.39 |
| 10 d | 1.00 | 0.96 | 0.76 | 0.51 |
| 14 d | 1.00 | 0.99 | 0.86 | 0.63 |
Memoryless assumption; real absences are not exponential. P = chance a cancel opportunity exists, not that it is used.

View file

@ -0,0 +1,41 @@
"""Lone estate bounds (operator 2026-10-29: "they totally should be able to make decisions without me if I'm not there to vote").
Present case with her absent: Minds (8) are the only live estate; renormalization makes Minds the whole tally.
L1 quorum floors that a lone Minds turnout meets; L2 category thresholds vs Personas needed and pass rates;
L3 delay-plus-cancel window: P(she returns before effect) for exponential absence. Run: python -I lone.py
"""
import math
import numpy as np
CATS = {"Bugs": .15, "Cosmetics": .30, "Balance": .40, "Features": .50, "NewContent": .50, "FundamentalDesign": .66}
print("## L1. Quorum floors a lone Minds turnout (8 of 8) meets, she absent")
print("| floor type | Minds-alone value | equal | minds-heavy | members-heavy |")
print("|---|---|---|---|---|")
print("| headcount fraction of eligible (8 Minds + her) | 8/9 = 0.889 | same | same | same |")
for lab in ("estate-weight fraction of live-eligible estates",):
vals = [wm_ / (wm_ + wme_) for wm_, wme_ in ((25, 25), (40, 30), (20, 45))]
print(f"| {lab} | w_M/(w_M+w_Me) | {vals[0]:.3f} | {vals[1]:.3f} | {vals[2]:.3f} |")
print("A floor above these values requires her turnout; at or below, Minds alone can decide.")
print("\n## L2. Lone Minds: Personas For needed and P(pass), motion model, by category")
r = np.random.default_rng(11); T = 400_000
m = r.standard_normal(T); p = 1 / (1 + np.exp(-1.7 * (0.6 * m + 0.8 * r.standard_normal(T))))
def minds_for(icc):
if icc >= 1: return np.where(r.random(T) < p, 8, 0)
if icc <= 0: return r.binomial(8, p)
a = p * (1 - icc) / icc; b = (1 - p) * (1 - icc) / icc; return r.binomial(8, r.beta(a, b))
print("| category | q | Personas needed prop | Personas needed WTA | P(pass) prop ICC 0 / .44 / 1 | P(pass) WTA ICC 0 / .44 / 1 |")
print("|---|---|---|---|---|---|")
draws = {icc: minds_for(icc) for icc in (0, .44, 1)}
for c, q in CATS.items():
need = math.ceil(q * 8 - 1e-9)
pp = [np.mean(draws[i] / 8 >= q - 1e-9) for i in (0, .44, 1)]
pw = [np.mean(draws[i] >= 5) for i in (0, .44, 1)]
print(f"| {c} | {q} | {need} | 5 (any q) | " + " / ".join(f"{x:.3f}" for x in pp) + " | " + " / ".join(f"{x:.3f}" for x in pw) + " |")
print("Under WTA a lone estate casts 100% For or 100% Against, so For/(For+Against) is 1 or 0 and category thresholds are inert.")
print("\n## L3. Delay-plus-cancel window: P(she is back before the decision takes effect), absence ~ Exponential(mean A)")
print("| delay D | A=1 day | A=3 days | A=7 days | A=14 days |")
print("|---|---|---|---|---|")
for D in (1, 2, 3, 7, 10, 14):
print(f"| {D} d | " + " | ".join(f"{1 - math.exp(-D / A):.2f}" for A in (1, 3, 7, 14)) + " |")
print("Memoryless assumption; real absences are not exponential. P = chance a cancel opportunity exists, not that it is used.")

View file

@ -0,0 +1,38 @@
## A. P(outcome differs, prop vs WTA) and single-voter pivot rates (Minds ICC .44, humans 0, Users turnout 10% = 30 voters)
| weights | q | P(pass) prop | P(pass) WTA | P(differ) | pivot prop M/Me/U/P | pivot WTA M/Me/U/P |
|---|---|---|---|---|---|---|
| equal | 0.15 | 0.934 | 0.838 | 0.098 | 0.0202/0.0118/0.0062/0.0424 | 0.0139/0.0135/0.0063/0.0406 |
| equal | 0.5 | 0.501 | 0.594 | 0.119 | 0.0501/0.0262/0.0133/0.1004 | 0.0207/0.0227/0.0115/0.0756 |
| equal | 0.66 | 0.265 | 0.404 | 0.148 | 0.0422/0.0228/0.0112/0.0870 | 0.0199/0.0219/0.0110/0.0762 |
| minds-heavy | 0.15 | 0.913 | 0.796 | 0.122 | 0.0432/0.0180/0.0058/0.0236 | 0.0193/0.0188/0.0088/0.0037 |
| minds-heavy | 0.5 | 0.500 | 0.537 | 0.118 | 0.0718/0.0258/0.0084/0.0330 | 0.0305/0.0233/0.0112/0.0276 |
| minds-heavy | 0.66 | 0.299 | 0.357 | 0.091 | 0.0651/0.0256/0.0079/0.0323 | 0.0379/0.0206/0.0040/0.0248 |
| members-heavy | 0.15 | 0.932 | 0.838 | 0.101 | 0.0184/0.0219/0.0050/0.0279 | 0.0139/0.0135/0.0063/0.0406 |
| members-heavy | 0.5 | 0.500 | 0.500 | 0.111 | 0.0376/0.0463/0.0099/0.0566 | 0.0096/0.0466/0.0050/0.0480 |
| members-heavy | 0.66 | 0.270 | 0.342 | 0.113 | 0.0343/0.0409/0.0088/0.0512 | 0.0125/0.0356/0.0068/0.0494 |
## B. Minds within-bloc correlation (equal weights, q=0.5)
| Minds ICC | Kish n_eff of 8 | P(differ) | Minds |share-0.5| mean | Minds pivot prop | Minds pivot WTA | P(Minds tie) |
|---|---|---|---|---|---|---|
| 0.0 | 8.00 | 0.115 | 0.280 | 0.0506 | 0.0403 | 0.107 |
| 0.2 | 3.33 | 0.118 | 0.324 | 0.0513 | 0.0304 | 0.086 |
| 0.44 | 1.96 | 0.119 | 0.379 | 0.0495 | 0.0199 | 0.054 |
| 0.8 | 1.21 | 0.124 | 0.458 | 0.0487 | 0.0064 | 0.017 |
| 1.0 | 1.00 | 0.126 | 0.500 | 0.0474 | 0.0000 | 0.000 |
## C. Users turnout (equal weights, q=0.5, Users roll 300)
| turnout | Users voters | P(Users WTA cast != population majority) | mean |prop share - p_U| | P(motion != full-turnout result) prop | same, WTA |
|---|---|---|---|---|---|
| 0.01 | 3 | 0.181 | 0.186 | 0.077 | 0.062 |
| 0.03 | 9 | 0.117 | 0.106 | 0.044 | 0.040 |
| 0.1 | 30 | 0.084 | 0.058 | 0.024 | 0.024 |
| 0.3 | 90 | 0.044 | 0.033 | 0.014 | 0.014 |
## D. Coordinated Users faction of k voters, Users population support p_U=0.3, turnout 3% (9 random voters + k)
| k | P(Users bloc cast For) WTA | mean Users For share prop |
|---|---|---|
| 0 | 0.098 | 0.300 |
| 3 | 0.270 | 0.475 |
| 6 | 0.803 | 0.580 |
| 9 | 0.961 | 0.650 |
| 12 | 1.000 | 0.700 |

View file

@ -0,0 +1,82 @@
"""Monte Carlo: proportional vs winner-take-all estate tallies under a motion model.
Motion model: common merit m~N(0,1), bloc taste e_b~N(0,1), bloc mean support p_b=logistic(1.7*(0.6m+0.8e_b)).
Within-bloc correlation: individual propensities ~ Beta with mean p_b and ICC r_b (beta-binomial).
Users: roll 300, a random sample of round(300*turnout) votes. Run: python -I mc.py
"""
import numpy as np
rng = np.random.default_rng(20261029)
BLOCS = ["Minds", "Members", "Users", "Patrons"]
WEIGHTS = {"equal": [25, 25, 25, 25], "minds-heavy": [40, 30, 20, 10], "members-heavy": [20, 45, 20, 15]}
T = 200_000
def draw(minds_r=0.44, human_r=0.0, turnout=0.1, T=T):
sizes = [8, 15, max(1, round(300 * turnout)), 4]
rs = [minds_r, human_r, human_r, human_r]
m = rng.standard_normal(T)
p = 1 / (1 + np.exp(-1.7 * (0.6 * m[:, None] + 0.8 * rng.standard_normal((T, 4)))))
F = np.zeros((T, 4), int); first = np.zeros((T, 4), int)
for b, (n, r) in enumerate(zip(sizes, rs)):
if r <= 0: probs = np.repeat(p[:, b:b + 1], n, 1)
elif r >= 1: probs = np.repeat((rng.random((T, 1)) < p[:, b:b + 1]).astype(float), n, 1)
else:
a = p[:, b] * (1 - r) / r; c = (1 - p[:, b]) * (1 - r) / r
probs = np.repeat(rng.beta(a, c)[:, None], n, 1) # one shared propensity per bloc-motion = beta-binomial ICC r
v = rng.random((T, n)) < probs
F[:, b] = v.sum(1); first[:, b] = v[:, 0]
return np.array(sizes), p, F, first
def out_prop(ws, sizes, F, q):
s = (F / sizes) @ np.array(ws, float); return s >= q * sum(ws) - 1e-9
def out_wta(ws, sizes, F, q):
ws = np.array(ws, float); st = np.sign(2 * F - sizes) # +1 For, -1 Against, 0 tie casts nothing
f = (st == 1) @ ws; a = (st == -1) @ ws
return (f + a > 0) & (f >= q * (f + a) - 1e-9)
def flip(F, first, b):
G = F.copy(); G[:, b] += np.where(first[:, b] == 1, -1, 1); return G
def fmt(v): return " | ".join(f"{x:.3f}" for x in v)
if __name__ == "__main__":
print("## A. P(outcome differs, prop vs WTA) and single-voter pivot rates (Minds ICC .44, humans 0, Users turnout 10% = 30 voters)")
sizes, p, F, first = draw()
print("| weights | q | P(pass) prop | P(pass) WTA | P(differ) | pivot prop M/Me/U/P | pivot WTA M/Me/U/P |")
print("|---|---|---|---|---|---|---|")
for name, ws in WEIGHTS.items():
for q in (0.15, 0.5, 0.66):
op, ow = out_prop(ws, sizes, F, q), out_wta(ws, sizes, F, q)
pp = [np.mean(op != out_prop(ws, sizes, flip(F, first, b), q)) for b in range(4)]
pw = [np.mean(ow != out_wta(ws, sizes, flip(F, first, b), q)) for b in range(4)]
print(f"| {name} | {q} | {op.mean():.3f} | {ow.mean():.3f} | {np.mean(op != ow):.3f} | "
+ "/".join(f"{x:.4f}" for x in pp) + " | " + "/".join(f"{x:.4f}" for x in pw) + " |")
print("\n## B. Minds within-bloc correlation (equal weights, q=0.5)")
print("| Minds ICC | Kish n_eff of 8 | P(differ) | Minds |share-0.5| mean | Minds pivot prop | Minds pivot WTA | P(Minds tie) |")
print("|---|---|---|---|---|---|---|")
for r in (0.0, 0.2, 0.44, 0.8, 1.0):
sizes, p, F, first = draw(minds_r=r, T=100_000); ws = WEIGHTS["equal"]
op, ow = out_prop(ws, sizes, F, .5), out_wta(ws, sizes, F, .5)
pp = np.mean(op != out_prop(ws, sizes, flip(F, first, 0), .5)); pw = np.mean(ow != out_wta(ws, sizes, flip(F, first, 0), .5))
print(f"| {r} | {8 / (1 + 7 * r):.2f} | {np.mean(op != ow):.3f} | {np.mean(abs(F[:, 0] / 8 - .5)):.3f} | {pp:.4f} | {pw:.4f} | {np.mean(2 * F[:, 0] == 8):.3f} |")
print("\n## C. Users turnout (equal weights, q=0.5, Users roll 300)")
print("| turnout | Users voters | P(Users WTA cast != population majority) | mean |prop share - p_U| | P(motion != full-turnout result) prop | same, WTA |")
print("|---|---|---|---|---|---|")
for t in (0.01, 0.03, 0.1, 0.3):
sizes, p, F, first = draw(turnout=t, T=100_000); ws = WEIGHTS["equal"]
st = np.sign(2 * F[:, 2] - sizes[2]); pop = np.sign(p[:, 2] - .5)
Ffull = F.astype(float).copy(); Ffull[:, 2] = p[:, 2] * sizes[2]
sfull = sizes.copy()
opf = out_prop(ws, sfull, Ffull, .5)
stf = np.sign(2 * F - sizes).astype(float); stf[:, 2] = pop
wsA = np.array(ws, float); owf = ((stf == 1) @ wsA) >= .5 * (((stf == 1) | (stf == -1)) @ wsA)
print(f"| {t} | {sizes[2]} | {np.mean(st != pop):.3f} | {np.mean(abs(F[:, 2] / sizes[2] - p[:, 2])):.3f} | "
f"{np.mean(out_prop(ws, sizes, F, .5) != opf):.3f} | {np.mean(out_wta(ws, sizes, F, .5) != owf):.3f} |")
print("\n## D. Coordinated Users faction of k voters, Users population support p_U=0.3, turnout 3% (9 random voters + k)")
print("| k | P(Users bloc cast For) WTA | mean Users For share prop |")
print("|---|---|---|")
for k in (0, 3, 6, 9, 12):
rnd = rng.random((100_000, 9)) < 0.3; f = rnd.sum(1) + k; n = 9 + k
print(f"| {k} | {np.mean(2 * f > n):.3f} | {np.mean(f / n):.3f} |")

View file

@ -0,0 +1,117 @@
## N1. Profitable deviations (count of profile x actor cases where deviating gets the actor's sincere preference)
Present case sizes Minds 8 / Members 1 (her) / Users 0 / Patrons 0; mixed case 3/1/2/1.
| sizes | weights | q | rule | quorum | profiles | indiv profitable dev | of which hers | estate stay-home profitable |
|---|---|---|---|---|---|---|---|---|
| 8/1/0/0 | (25, 25, 25, 25) | 0.5 | prop | none | 512 | 0 | 0 | 0 |
| 8/1/0/0 | (25, 25, 25, 25) | 0.5 | prop | head | 512 | 0 | 0 | 93 |
| 8/1/0/0 | (25, 25, 25, 25) | 0.5 | prop | two | 512 | 1 | 1 | 94 |
| 8/1/0/0 | (25, 25, 25, 25) | 0.5 | wta | none | 512 | 0 | 0 | 0 |
| 8/1/0/0 | (25, 25, 25, 25) | 0.5 | wta | head | 512 | 0 | 0 | 93 |
| 8/1/0/0 | (25, 25, 25, 25) | 0.5 | wta | two | 512 | 93 | 93 | 186 |
| 8/1/0/0 | (25, 25, 25, 25) | 0.66 | prop | none | 512 | 0 | 0 | 0 |
| 8/1/0/0 | (25, 25, 25, 25) | 0.66 | prop | head | 512 | 0 | 0 | 56 |
| 8/1/0/0 | (25, 25, 25, 25) | 0.66 | prop | two | 512 | 0 | 0 | 56 |
| 8/1/0/0 | (25, 25, 25, 25) | 0.66 | wta | none | 512 | 0 | 0 | 0 |
| 8/1/0/0 | (25, 25, 25, 25) | 0.66 | wta | head | 512 | 0 | 0 | 0 |
| 8/1/0/0 | (25, 25, 25, 25) | 0.66 | wta | two | 512 | 0 | 0 | 0 |
| 8/1/0/0 | (40, 30, 20, 10) | 0.5 | prop | none | 512 | 0 | 0 | 0 |
| 8/1/0/0 | (40, 30, 20, 10) | 0.5 | prop | head | 512 | 0 | 0 | 92 |
| 8/1/0/0 | (40, 30, 20, 10) | 0.5 | prop | two | 512 | 9 | 9 | 101 |
| 8/1/0/0 | (40, 30, 20, 10) | 0.5 | wta | none | 512 | 0 | 0 | 0 |
| 8/1/0/0 | (40, 30, 20, 10) | 0.5 | wta | head | 512 | 0 | 0 | 0 |
| 8/1/0/0 | (40, 30, 20, 10) | 0.5 | wta | two | 512 | 93 | 93 | 93 |
| 8/1/0/0 | (40, 30, 20, 10) | 0.66 | prop | none | 512 | 0 | 0 | 0 |
| 8/1/0/0 | (40, 30, 20, 10) | 0.66 | prop | head | 512 | 0 | 0 | 0 |
| 8/1/0/0 | (40, 30, 20, 10) | 0.66 | prop | two | 512 | 0 | 0 | 0 |
| 8/1/0/0 | (40, 30, 20, 10) | 0.66 | wta | none | 512 | 0 | 0 | 0 |
| 8/1/0/0 | (40, 30, 20, 10) | 0.66 | wta | head | 512 | 0 | 0 | 0 |
| 8/1/0/0 | (40, 30, 20, 10) | 0.66 | wta | two | 512 | 0 | 0 | 0 |
| 8/1/0/0 | (20, 45, 20, 15) | 0.5 | prop | none | 512 | 0 | 0 | 0 |
| 8/1/0/0 | (20, 45, 20, 15) | 0.5 | prop | head | 512 | 0 | 0 | 93 |
| 8/1/0/0 | (20, 45, 20, 15) | 0.5 | prop | two | 512 | 0 | 0 | 93 |
| 8/1/0/0 | (20, 45, 20, 15) | 0.5 | wta | none | 512 | 0 | 0 | 0 |
| 8/1/0/0 | (20, 45, 20, 15) | 0.5 | wta | head | 512 | 0 | 0 | 93 |
| 8/1/0/0 | (20, 45, 20, 15) | 0.5 | wta | two | 512 | 0 | 0 | 93 |
| 8/1/0/0 | (20, 45, 20, 15) | 0.66 | prop | none | 512 | 0 | 0 | 0 |
| 8/1/0/0 | (20, 45, 20, 15) | 0.66 | prop | head | 512 | 0 | 0 | 93 |
| 8/1/0/0 | (20, 45, 20, 15) | 0.66 | prop | two | 512 | 0 | 0 | 93 |
| 8/1/0/0 | (20, 45, 20, 15) | 0.66 | wta | none | 512 | 0 | 0 | 0 |
| 8/1/0/0 | (20, 45, 20, 15) | 0.66 | wta | head | 512 | 0 | 0 | 93 |
| 8/1/0/0 | (20, 45, 20, 15) | 0.66 | wta | two | 512 | 0 | 0 | 93 |
| 3/1/2/1 | (25, 25, 25, 25) | 0.5 | prop | none | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (25, 25, 25, 25) | 0.5 | prop | head | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (25, 25, 25, 25) | 0.5 | prop | two | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (25, 25, 25, 25) | 0.5 | wta | none | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (25, 25, 25, 25) | 0.5 | wta | head | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (25, 25, 25, 25) | 0.5 | wta | two | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (25, 25, 25, 25) | 0.66 | prop | none | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (25, 25, 25, 25) | 0.66 | prop | head | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (25, 25, 25, 25) | 0.66 | prop | two | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (25, 25, 25, 25) | 0.66 | wta | none | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (25, 25, 25, 25) | 0.66 | wta | head | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (25, 25, 25, 25) | 0.66 | wta | two | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (40, 30, 20, 10) | 0.5 | prop | none | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (40, 30, 20, 10) | 0.5 | prop | head | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (40, 30, 20, 10) | 0.5 | prop | two | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (40, 30, 20, 10) | 0.5 | wta | none | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (40, 30, 20, 10) | 0.5 | wta | head | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (40, 30, 20, 10) | 0.5 | wta | two | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (40, 30, 20, 10) | 0.66 | prop | none | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (40, 30, 20, 10) | 0.66 | prop | head | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (40, 30, 20, 10) | 0.66 | prop | two | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (40, 30, 20, 10) | 0.66 | wta | none | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (40, 30, 20, 10) | 0.66 | wta | head | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (40, 30, 20, 10) | 0.66 | wta | two | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (20, 45, 20, 15) | 0.5 | prop | none | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (20, 45, 20, 15) | 0.5 | prop | head | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (20, 45, 20, 15) | 0.5 | prop | two | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (20, 45, 20, 15) | 0.5 | wta | none | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (20, 45, 20, 15) | 0.5 | wta | head | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (20, 45, 20, 15) | 0.5 | wta | two | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (20, 45, 20, 15) | 0.66 | prop | none | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (20, 45, 20, 15) | 0.66 | prop | head | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (20, 45, 20, 15) | 0.66 | prop | two | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (20, 45, 20, 15) | 0.66 | wta | none | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (20, 45, 20, 15) | 0.66 | wta | head | 128 | 0 | 0 | 0 |
| 3/1/2/1 | (20, 45, 20, 15) | 0.66 | wta | two | 128 | 0 | 0 | 0 |
## N2. Present case outcome map (Minds 8 independent fair coins, her vote fixed), renormalized
P(pass | she votes For) / P(pass | she votes Against) / P(pass | she stays home)
| weights | q | rule | quorum | For | Against | home |
|---|---|---|---|---|---|---|
| (25, 25) | 0.15 | prop | none | 1.000 | 0.855 | 0.965 |
| (25, 25) | 0.15 | prop | two | 1.000 | 0.855 | 0.000 |
| (25, 25) | 0.15 | wta | none | 1.000 | 0.363 | 0.363 |
| (25, 25) | 0.15 | wta | two | 1.000 | 0.363 | 0.000 |
| (25, 25) | 0.5 | prop | none | 1.000 | 0.004 | 0.637 |
| (25, 25) | 0.5 | prop | two | 1.000 | 0.004 | 0.000 |
| (25, 25) | 0.5 | wta | none | 1.000 | 0.363 | 0.363 |
| (25, 25) | 0.5 | wta | two | 1.000 | 0.363 | 0.000 |
| (25, 25) | 0.66 | prop | none | 0.855 | 0.000 | 0.145 |
| (25, 25) | 0.66 | prop | two | 0.855 | 0.000 | 0.000 |
| (25, 25) | 0.66 | wta | none | 0.637 | 0.000 | 0.363 |
| (25, 25) | 0.66 | wta | two | 0.637 | 0.000 | 0.000 |
| (40, 30) | 0.15 | prop | none | 1.000 | 0.855 | 0.965 |
| (40, 30) | 0.15 | prop | two | 1.000 | 0.855 | 0.000 |
| (40, 30) | 0.15 | wta | none | 1.000 | 0.363 | 0.363 |
| (40, 30) | 0.15 | wta | two | 1.000 | 0.363 | 0.000 |
| (40, 30) | 0.5 | prop | none | 0.996 | 0.035 | 0.637 |
| (40, 30) | 0.5 | prop | two | 0.996 | 0.035 | 0.000 |
| (40, 30) | 0.5 | wta | none | 0.637 | 0.363 | 0.363 |
| (40, 30) | 0.5 | wta | two | 0.637 | 0.363 | 0.000 |
| (40, 30) | 0.66 | prop | none | 0.637 | 0.000 | 0.145 |
| (40, 30) | 0.66 | prop | two | 0.637 | 0.000 | 0.000 |
| (40, 30) | 0.66 | wta | none | 0.637 | 0.000 | 0.363 |
| (40, 30) | 0.66 | wta | two | 0.637 | 0.000 | 0.000 |
| (20, 45) | 0.15 | prop | none | 1.000 | 0.637 | 0.965 |
| (20, 45) | 0.15 | prop | two | 1.000 | 0.637 | 0.000 |
| (20, 45) | 0.15 | wta | none | 1.000 | 0.363 | 0.363 |
| (20, 45) | 0.15 | wta | two | 1.000 | 0.363 | 0.000 |
| (20, 45) | 0.5 | prop | none | 1.000 | 0.000 | 0.637 |
| (20, 45) | 0.5 | prop | two | 1.000 | 0.000 | 0.000 |
| (20, 45) | 0.5 | wta | none | 1.000 | 0.000 | 0.363 |
| (20, 45) | 0.5 | wta | two | 1.000 | 0.000 | 0.000 |
| (20, 45) | 0.66 | prop | none | 1.000 | 0.000 | 0.145 |
| (20, 45) | 0.66 | prop | two | 1.000 | 0.000 | 0.000 |
| (20, 45) | 0.66 | wta | none | 1.000 | 0.000 | 0.363 |
| (20, 45) | 0.66 | wta | two | 1.000 | 0.000 | 0.000 |

View file

@ -0,0 +1,70 @@
"""No-show incentives under the renormalization ruling (operator 2026-10-29: "normalize it by who actually voted").
Estate weights renormalize over estates where at least one For/Against ballot was cast; WTA tie casts nothing.
A motion failing its quorum fails (status quo). Exhaustive over sincere For/Against profiles.
Deviations: one voter switches or abstains; or a whole estate stays home (estate as unitary actor).
Quorum floors: none | head = cast ballots >= half of eligible individuals | two = at least two estates voting.
Run: python -I noshow.py
"""
import itertools
def tally(ws, sizes, ballots, q, rule, quorum):
F = [0] * 4; A = [0] * 4; idx = 0
for b, n in enumerate(sizes):
for v in ballots[idx:idx + n]:
F[b] += v == 1; A[b] += v == -1
idx += n
live = [b for b in range(4) if F[b] + A[b] > 0]
if quorum == "head" and sum(F) + sum(A) < sum(sizes) / 2: return False
if quorum == "two" and len(live) < 2: return False
f = a = 0.0
for b in live:
if rule == "prop": f += ws[b] * F[b] / (F[b] + A[b]); a += ws[b] * A[b] / (F[b] + A[b])
elif F[b] > A[b]: f += ws[b]
elif A[b] > F[b]: a += ws[b]
return f + a > 0 and f >= q * (f + a) - 1e-9
def probe(ws, sizes, q, rule, quorum):
N = sum(sizes); starts = [sum(sizes[:b]) for b in range(4)]
ind = est = her = 0; prof = 0
for bal in itertools.product((1, -1), repeat=N):
prof += 1; o = tally(ws, sizes, bal, q, rule, quorum)
for i, v in enumerate(bal):
want = v == 1
if o != want and any(tally(ws, sizes, bal[:i] + (d,) + bal[i + 1:], q, rule, quorum) == want for d in (-v, 0)):
ind += 1
if sizes[1] == 1 and i == starts[1]: her += 1
for b in range(4):
if sizes[b] == 0: continue
seg = bal[starts[b]:starts[b] + sizes[b]]; pos = sum(seg) > 0 if 2 * sum(x == 1 for x in seg) != len(seg) else None
if pos is None or o == pos: continue
home = bal[:starts[b]] + (0,) * sizes[b] + bal[starts[b] + sizes[b]:]
est += tally(ws, sizes, home, q, rule, quorum) == pos
return prof, ind, her, est
if __name__ == "__main__":
print("## N1. Profitable deviations (count of profile x actor cases where deviating gets the actor's sincere preference)")
print("Present case sizes Minds 8 / Members 1 (her) / Users 0 / Patrons 0; mixed case 3/1/2/1.")
print("| sizes | weights | q | rule | quorum | profiles | indiv profitable dev | of which hers | estate stay-home profitable |")
print("|---|---|---|---|---|---|---|---|---|")
for sizes in ((8, 1, 0, 0), (3, 1, 2, 1)):
for ws in ((25, 25, 25, 25), (40, 30, 20, 10), (20, 45, 20, 15)):
for q in (0.5, 0.66):
for rule in ("prop", "wta"):
for quorum in ("none", "head", "two"):
p, i, h, e = probe(ws, sizes, q, rule, quorum)
print(f"| {'/'.join(map(str, sizes))} | {ws} | {q} | {rule} | {quorum} | {p} | {i} | {h} | {e} |")
print("\n## N2. Present case outcome map (Minds 8 independent fair coins, her vote fixed), renormalized")
print("P(pass | she votes For) / P(pass | she votes Against) / P(pass | she stays home)")
print("| weights | q | rule | quorum | For | Against | home |")
print("|---|---|---|---|---|---|---|")
from math import comb
for ws in ((25, 25, 25, 25), (40, 30, 20, 10), (20, 45, 20, 15)):
for q in (0.15, 0.5, 0.66):
for rule in ("prop", "wta"):
for quorum in ("none", "two"):
row = []
for hv in (1, -1, 0):
pp = sum(comb(8, k) / 256 * tally(ws, (8, 1, 0, 0), (1,) * k + (-1,) * (8 - k) + (hv,), q, rule, quorum) for k in range(9))
row.append(f"{pp:.3f}")
print(f"| {ws[:2]} | {q} | {rule} | {quorum} | " + " | ".join(row) + " |")

View file

@ -0,0 +1,41 @@
## Unitary blocs: normalized Banzhaf / Shapley-Shubik, veto blocs
| weights | q | Banzhaf M/Me/U/P | Shapley M/Me/U/P | veto blocs |
|---|---|---|---|---|
| equal [25, 25, 25, 25] | 0.15 | 0.25/0.25/0.25/0.25 | 0.25/0.25/0.25/0.25 | none |
| equal [25, 25, 25, 25] | 0.5 | 0.25/0.25/0.25/0.25 | 0.25/0.25/0.25/0.25 | none |
| equal [25, 25, 25, 25] | 0.66 | 0.25/0.25/0.25/0.25 | 0.25/0.25/0.25/0.25 | none |
| minds-heavy [40, 30, 20, 10] | 0.15 | 0.33/0.33/0.33/0.00 | 0.33/0.33/0.33/0.00 | none |
| minds-heavy [40, 30, 20, 10] | 0.5 | 0.42/0.25/0.25/0.08 | 0.42/0.25/0.25/0.08 | none |
| minds-heavy [40, 30, 20, 10] | 0.66 | 0.50/0.30/0.10/0.10 | 0.58/0.25/0.08/0.08 | Minds |
| members-heavy [20, 45, 20, 15] | 0.15 | 0.25/0.25/0.25/0.25 | 0.25/0.25/0.25/0.25 | none |
| members-heavy [20, 45, 20, 15] | 0.5 | 0.17/0.50/0.17/0.17 | 0.17/0.50/0.17/0.17 | none |
| members-heavy [20, 45, 20, 15] | 0.66 | 0.20/0.40/0.20/0.20 | 0.17/0.50/0.17/0.17 | Members |
## Individual power (P one voter flips the motion), sizes {'Minds': 8, 'Members': 15, 'Users': 30, 'Patrons': 4}
| weights | q | rule | Minds | Members | Users | Patrons | max/min |
|---|---|---|---|---|---|---|---|
| equal | 0.15 | WTA | 0.0538 | 0.0525 | 0.0316 | 0.0683 | 2.2 |
| equal | 0.15 | prop | 0.0000 | 0.0000 | 0.0000 | 0.0000 | 2.7 |
| equal | 0.5 | WTA | 0.1184 | 0.0961 | 0.0640 | 0.1584 | 2.5 |
| equal | 0.5 | prop | 0.1426 | 0.0770 | 0.0384 | 0.2956 | 7.7 |
| equal | 0.66 | WTA | 0.1184 | 0.0961 | 0.0640 | 0.1584 | 2.5 |
| equal | 0.66 | prop | 0.0262 | 0.0145 | 0.0073 | 0.0474 | 6.5 |
| minds-heavy | 0.15 | WTA | 0.0782 | 0.0661 | 0.0398 | 0.0293 | 2.7 |
| minds-heavy | 0.15 | prop | 0.0000 | 0.0000 | 0.0000 | 0.0000 | 3.9 |
| minds-heavy | 0.5 | WTA | 0.1686 | 0.1080 | 0.0505 | 0.0609 | 3.3 |
| minds-heavy | 0.5 | prop | 0.2343 | 0.0909 | 0.0302 | 0.1146 | 7.8 |
| minds-heavy | 0.66 | WTA | 0.1769 | 0.0942 | 0.0316 | 0.0511 | 5.6 |
| minds-heavy | 0.66 | prop | 0.0407 | 0.0172 | 0.0058 | 0.0215 | 7.0 |
| members-heavy | 0.15 | WTA | 0.0538 | 0.0525 | 0.0316 | 0.0683 | 2.2 |
| members-heavy | 0.15 | prop | 0.0000 | 0.0000 | 0.0000 | 0.0000 | 3.7 |
| members-heavy | 0.5 | WTA | 0.0366 | 0.1891 | 0.0164 | 0.0583 | 11.5 |
| members-heavy | 0.5 | prop | 0.1246 | 0.1509 | 0.0327 | 0.1888 | 5.8 |
| members-heavy | 0.66 | WTA | 0.0647 | 0.1455 | 0.0324 | 0.0900 | 4.5 |
| members-heavy | 0.66 | prop | 0.0167 | 0.0200 | 0.0045 | 0.0243 | 5.4 |
## Penrose square-root weights (WTA individual-power equalizer, independent voters)
sqrt(n) shares: [19.9, 27.3, 38.6, 14.1]
with Minds n_eff=8/(1+7*0.44)=1.96: [11.0, 30.4, 43.0, 15.7]
headcount shares (prop equalizer): [14.0, 26.3, 52.6, 7.0]
sqrt weights q=0.5: WTA indiv [0.0928, 0.0835, 0.096, 0.1218] prop indiv [0.1414, 0.1027, 0.0722, 0.2034]
headcount weights q=0.5: WTA indiv [0.0, 0.0303, 0.1445, 0.0] prop indiv [0.1061, 0.1061, 0.1061, 0.1061]

View file

@ -0,0 +1,110 @@
"""A priori power of estate blocs and of individuals inside them.
Pass rule (Bifrost MotionGovernanceService): For / (For + Against) >= q, abstain excluded.
Blocs: Minds, Members, Users, Patrons. Run: python -I power.py
"""
import itertools, math
from math import comb
BLOCS = ["Minds", "Members", "Users", "Patrons"]
WEIGHTS = {"equal": [25, 25, 25, 25], "minds-heavy": [40, 30, 20, 10], "members-heavy": [20, 45, 20, 15]}
QS = [0.15, 0.5, 0.66]
SIZES = [8, 15, 30, 4] # voting individuals (Users = active turnout, not roll)
def wins(ws, S, q):
return sum(ws[i] for i in S) >= q * sum(ws) - 1e-12
def banzhaf(ws, q):
n = len(ws); sw = [0] * n
for i in range(n):
others = [j for j in range(n) if j != i]
for r in range(len(others) + 1):
for S in itertools.combinations(others, r):
if not wins(ws, S, q) and wins(ws, S + (i,), q):
sw[i] += 1
t = sum(sw); return [s / t for s in sw], sw
def shapley(ws, q):
n = len(ws); piv = [0] * n
for perm in itertools.permutations(range(n)):
acc = 0
for i in perm:
acc += ws[i]
if acc >= q * sum(ws) - 1e-12:
piv[i] += 1; break
t = sum(piv); return [p / t for p in piv]
def veto(ws, q):
W = sum(ws); return [BLOCS[i] for i in range(len(ws)) if W - ws[i] < q * W - 1e-12]
def binom(n): # P(k For among n coin-flip voters)
return [comb(n, k) / 2 ** n for k in range(n + 1)]
def wta_state(k, n): # +1 For, -1 Against, 0 tie casts nothing
return 1 if 2 * k > n else (-1 if 2 * k < n else 0)
def outcome_wta(ws, states, q):
f = sum(w for w, s in zip(ws, states) if s == 1); a = sum(w for w, s in zip(ws, states) if s == -1)
return f + a > 0 and f / (f + a) >= q - 1e-12
def bloc_state_dist(n):
d = {1: 0.0, -1: 0.0, 0: 0.0}
for k, p in enumerate(binom(n)): d[wta_state(k, n)] += p
return d
def indiv_power_wta(ws, q, sizes, b):
"""P(one voter in bloc b changes the motion outcome), others independent fair coins."""
others = [j for j in range(len(ws)) if j != b]
dists = [bloc_state_dist(sizes[j]) for j in others]
tot = 0.0
for k, pk in enumerate(binom(sizes[b] - 1)):
sF, sA = wta_state(k + 1, sizes[b]), wta_state(k, sizes[b])
if sF == sA: continue
for combo in itertools.product([1, -1, 0], repeat=len(others)):
p = pk
for d, s in zip(dists, combo): p *= d[s]
if p == 0: continue
st = [0] * len(ws)
for j, s in zip(others, combo): st[j] = s
stF = st[:]; stF[b] = sF; stA = st[:]; stA[b] = sA
if outcome_wta(ws, stF, q) != outcome_wta(ws, stA, q): tot += p
return tot
def indiv_power_prop(ws, q, sizes, b):
others = [j for j in range(len(ws)) if j != b]
W = sum(ws); tot = 0.0
dists = [binom(sizes[j]) for j in others]
for ks in itertools.product(*[range(sizes[j] + 1) for j in others]):
p = 1.0; base = 0.0
for j, k, d in zip(others, ks, dists): p *= d[k]; base += ws[j] * k / sizes[j]
for k, pk in enumerate(binom(sizes[b] - 1)):
vF = base + ws[b] * (k + 1) / sizes[b]; vA = base + ws[b] * k / sizes[b]
if (vF >= q * W - 1e-12) != (vA >= q * W - 1e-12): tot += p * pk
return tot
if __name__ == "__main__":
print("## Unitary blocs: normalized Banzhaf / Shapley-Shubik, veto blocs")
print("| weights | q | Banzhaf M/Me/U/P | Shapley M/Me/U/P | veto blocs |")
print("|---|---|---|---|---|")
for name, ws in WEIGHTS.items():
for q in QS:
bz, _ = banzhaf(ws, q); ss = shapley(ws, q)
f = lambda v: "/".join(f"{x:.2f}" for x in v)
print(f"| {name} {ws} | {q} | {f(bz)} | {f(ss)} | {', '.join(veto(ws, q)) or 'none'} |")
print("\n## Individual power (P one voter flips the motion), sizes", dict(zip(BLOCS, SIZES)))
print("| weights | q | rule | Minds | Members | Users | Patrons | max/min |")
print("|---|---|---|---|---|---|---|---|")
for name, ws in WEIGHTS.items():
for q in QS:
for rule, fn in (("WTA", indiv_power_wta), ("prop", indiv_power_prop)):
v = [fn(ws, q, SIZES, b) for b in range(4)]
r = max(v) / min(v) if min(v) > 0 else float("inf")
print(f"| {name} | {q} | {rule} | " + " | ".join(f"{x:.4f}" for x in v) + f" | {r:.1f} |")
print("\n## Penrose square-root weights (WTA individual-power equalizer, independent voters)")
sq = [math.sqrt(n) for n in SIZES]; print("sqrt(n) shares:", [round(100 * s / sum(sq), 1) for s in sq])
neff = [8 / (1 + 7 * 0.44)] + SIZES[1:]
sq2 = [math.sqrt(n) for n in neff]; print("with Minds n_eff=8/(1+7*0.44)=%.2f:" % neff[0], [round(100 * s / sum(sq2), 1) for s in sq2])
pr = [100 * n / sum(SIZES) for n in SIZES]; print("headcount shares (prop equalizer):", [round(x, 1) for x in pr])
for ws, lab in ((sq, "sqrt"), (SIZES, "headcount")):
for q in (0.5,):
v = [indiv_power_wta(ws, q, SIZES, b) for b in range(4)]; u = [indiv_power_prop(ws, q, SIZES, b) for b in range(4)]
print(f"{lab} weights q={q}: WTA indiv", [round(x, 4) for x in v], " prop indiv", [round(x, 4) for x in u])

View file

@ -0,0 +1,43 @@
## S1. Profitable misreport/abstention on a single binary motion (sizes 3/3/2/2, exhaustive)
| weights | q | quorum | rule | profiles x voters | profitable deviations | of which abstention |
|---|---|---|---|---|---|---|
| [25, 25, 25, 25] | 0.15 | 0.0 | prop | 10240 | 0 | 0 |
| [25, 25, 25, 25] | 0.15 | 0.0 | wta | 10240 | 0 | 0 |
| [25, 25, 25, 25] | 0.15 | 0.5 | prop | 10240 | 0 | 0 |
| [25, 25, 25, 25] | 0.15 | 0.5 | wta | 10240 | 192 | 192 |
| [25, 25, 25, 25] | 0.5 | 0.0 | prop | 10240 | 0 | 0 |
| [25, 25, 25, 25] | 0.5 | 0.0 | wta | 10240 | 0 | 0 |
| [25, 25, 25, 25] | 0.5 | 0.5 | prop | 10240 | 0 | 0 |
| [25, 25, 25, 25] | 0.5 | 0.5 | wta | 10240 | 192 | 192 |
| [25, 25, 25, 25] | 0.66 | 0.0 | prop | 10240 | 0 | 0 |
| [25, 25, 25, 25] | 0.66 | 0.0 | wta | 10240 | 0 | 0 |
| [25, 25, 25, 25] | 0.66 | 0.5 | prop | 10240 | 0 | 0 |
| [25, 25, 25, 25] | 0.66 | 0.5 | wta | 10240 | 0 | 0 |
| [40, 30, 20, 10] | 0.15 | 0.0 | prop | 10240 | 0 | 0 |
| [40, 30, 20, 10] | 0.15 | 0.0 | wta | 10240 | 0 | 0 |
| [40, 30, 20, 10] | 0.15 | 0.5 | prop | 10240 | 0 | 0 |
| [40, 30, 20, 10] | 0.15 | 0.5 | wta | 10240 | 288 | 288 |
| [40, 30, 20, 10] | 0.5 | 0.0 | prop | 10240 | 0 | 0 |
| [40, 30, 20, 10] | 0.5 | 0.0 | wta | 10240 | 0 | 0 |
| [40, 30, 20, 10] | 0.5 | 0.5 | prop | 10240 | 0 | 0 |
| [40, 30, 20, 10] | 0.5 | 0.5 | wta | 10240 | 144 | 144 |
| [40, 30, 20, 10] | 0.66 | 0.0 | prop | 10240 | 0 | 0 |
| [40, 30, 20, 10] | 0.66 | 0.0 | wta | 10240 | 0 | 0 |
| [40, 30, 20, 10] | 0.66 | 0.5 | prop | 10240 | 0 | 0 |
| [40, 30, 20, 10] | 0.66 | 0.5 | wta | 10240 | 0 | 0 |
## S2. Logrolling (greedy best mutually profitable pairwise trade, repeated, max 6 trades)
### weights [25, 25, 25, 25], q=0.5, 6 motions, 1500 agendas; bloc utility u[b,m]~N(0,1) per motion passed
| bloc type | delta | P(>=1 trade) | mean trades | mean welfare change (sum u) | P(welfare falls) | P(a non-trader loses) | P(a trader ends worse than sincere) |
|---|---|---|---|---|---|---|---|
| unitary | 1.0 | 0.615 | 1.04 | +0.168 | 0.245 | 0.397 | 0.234 |
| proportional | 0.5 | 0.430 | 0.66 | -0.396 | 0.338 | 0.305 | 0.127 |
| proportional | 0.2 | 0.127 | 0.17 | -0.027 | 0.071 | 0.093 | 0.017 |
### weights [40, 30, 20, 10], q=0.5, 6 motions, 1500 agendas; bloc utility u[b,m]~N(0,1) per motion passed
| bloc type | delta | P(>=1 trade) | mean trades | mean welfare change (sum u) | P(welfare falls) | P(a non-trader loses) | P(a trader ends worse than sincere) |
|---|---|---|---|---|---|---|---|
| unitary | 1.0 | 0.615 | 0.90 | +0.016 | 0.302 | 0.484 | 0.184 |
| proportional | 0.5 | 0.324 | 0.44 | -0.306 | 0.230 | 0.261 | 0.076 |
| proportional | 0.2 | 0.110 | 0.13 | -0.075 | 0.070 | 0.099 | 0.013 |

View file

@ -0,0 +1,86 @@
"""Strategic probes. Run: python -I strat.py
S1 exhaustive: can any voter gain by misreporting or abstaining on one binary motion? (with and without a quorum)
S2 logrolling among 4 blocs over a 6-motion agenda: unitary blocs (whole weight deliverable) vs
proportional blocs whose leadership can move only a fraction delta of the bloc's share.
"""
import itertools
import numpy as np
def tally(ws, blocs, ballots, q, rule, quorum=0.0):
"""ballots: tuple of +1 For, -1 Against, 0 abstain, one per voter; blocs: bloc index per voter."""
F = [0] * len(ws); A = [0] * len(ws)
for b, v in zip(blocs, ballots):
if v == 1: F[b] += 1
elif v == -1: A[b] += 1
f = a = 0.0
for b, w in enumerate(ws):
if F[b] + A[b] == 0: continue
if rule == "prop": f += w * F[b] / (F[b] + A[b]); a += w * A[b] / (F[b] + A[b])
elif F[b] > A[b]: f += w
elif A[b] > F[b]: a += w
if f + a < quorum * sum(ws) - 1e-9 or f + a == 0: return False
return f >= q * (f + a) - 1e-9
def s1():
blocs = [0, 0, 0, 1, 1, 1, 2, 2, 3, 3]
print("| weights | q | quorum | rule | profiles x voters | profitable deviations | of which abstention |")
print("|---|---|---|---|---|---|---|")
for ws in ([25, 25, 25, 25], [40, 30, 20, 10]):
for q in (0.15, 0.5, 0.66):
for quorum in (0.0, 0.5):
for rule in ("prop", "wta"):
cache = {}
def out(bal):
if bal not in cache: cache[bal] = tally(ws, blocs, bal, q, rule, quorum)
return cache[bal]
gain = gab = n = 0
for bal in itertools.product((1, -1), repeat=10): # sincere profiles, no sincere abstainers
o = out(bal)
for i, v in enumerate(bal):
n += 1; want = v == 1
if o == want: continue
for d in (-v, 0):
b2 = bal[:i] + (d,) + bal[i + 1:]
if out(b2) == want: gain += 1; gab += d == 0; break
print(f"| {ws} | {q} | {quorum} | {rule} | {n} | {gain} | {gab} |")
def s2(ws=(25, 25, 25, 25), q=0.5, M=6, T=1_500, seed=3):
rng = np.random.default_rng(seed); W = np.array(ws, float)
print(f"\n### weights {list(ws)}, q={q}, {M} motions, {T} agendas; bloc utility u[b,m]~N(0,1) per motion passed")
print("| bloc type | delta | P(>=1 trade) | mean trades | mean welfare change (sum u) | P(welfare falls) | P(a non-trader loses) | P(a trader ends worse than sincere) |")
print("|---|---|---|---|---|---|---|---|")
for kind, delta in (("unitary", 1.0), ("proportional", 0.5), ("proportional", 0.2)):
stats = np.zeros(6)
for t in range(T):
u = rng.standard_normal((4, M))
share0 = 1 / (1 + np.exp(-2 * u)) if kind == "proportional" else (u > 0).astype(float)
dev = np.zeros((4, M)) # signed share shift each bloc has committed to
def outcome(dv):
s = np.clip(share0 + dv, 0, 1); return (W @ s) >= q * W.sum() - 1e-9
def util(o): return u @ o
o_s = outcome(dev); U_s = util(o_s); cur = U_s.copy(); trades = 0; traders = set()
while True:
best = None
for A, B in itertools.permutations(range(4), 2):
if A > B: continue
for x in range(M):
if dev[A, x] != 0: continue
for y in range(M):
if y == x or dev[B, y] != 0: continue
d2 = dev.copy(); d2[A, x] = -np.sign(u[A, x]) * delta; d2[B, y] = -np.sign(u[B, y]) * delta
Un = util(outcome(d2)); g = Un - cur
if g[A] > 1e-9 and g[B] > 1e-9 and (best is None or g[A] + g[B] > best[0]):
best = (g[A] + g[B], d2, Un, A, B)
if best is None or trades >= 6: break
_, dev, cur, A, B = best; trades += 1; traders |= {A, B}
dW = cur.sum() - U_s.sum(); non = [b for b in range(4) if b not in traders]
stats += [trades > 0, trades, dW, dW < -1e-9, any(cur[b] < U_s[b] - 1e-9 for b in non),
any(cur[b] < U_s[b] - 1e-9 for b in traders)]
s = stats / T
print(f"| {kind} | {delta} | {s[0]:.3f} | {s[1]:.2f} | {s[2]:+.3f} | {s[3]:.3f} | {s[4]:.3f} | {s[5]:.3f} |")
if __name__ == "__main__":
print("## S1. Profitable misreport/abstention on a single binary motion (sizes 3/3/2/2, exhaustive)")
s1()
print("\n## S2. Logrolling (greedy best mutually profitable pairwise trade, repeated, max 6 trades)")
s2(); s2(ws=(40, 30, 20, 10))

View file

@ -0,0 +1,146 @@
## T1. Motion model (Minds ICC .44; 8/15/30/4): P(pass) and pairwise P(outcomes differ)
| seed | q | P(pass) prop/wta/bpt | differ bpt-prop | differ bpt-wta | differ prop-wta |
|---|---|---|---|---|---|
| equal | 0.15 | 0.934/0.838/0.985 | 0.050 | 0.147 | 0.098 |
| equal | 0.5 | 0.502/0.594/0.676 | 0.179 | 0.082 | 0.119 |
| equal | 0.66 | 0.265/0.405/0.233 | 0.080 | 0.171 | 0.148 |
| minds-heavy | 0.15 | 0.913/0.796/0.966 | 0.056 | 0.170 | 0.122 |
| minds-heavy | 0.5 | 0.502/0.537/0.575 | 0.126 | 0.038 | 0.117 |
| minds-heavy | 0.66 | 0.298/0.357/0.236 | 0.091 | 0.121 | 0.092 |
| members-heavy | 0.15 | 0.932/0.838/0.985 | 0.053 | 0.147 | 0.100 |
| members-heavy | 0.5 | 0.500/0.499/0.528 | 0.106 | 0.029 | 0.111 |
| members-heavy | 0.66 | 0.271/0.343/0.194 | 0.099 | 0.149 | 0.113 |
## T2. Single-voter pivot M/Me/U/P (same draws), equal seed
| q | prop | wta | bpt |
|---|---|---|---|
| 0.15 | 0.0200/0.0118/0.0062/0.0425 | 0.0136/0.0136/0.0066/0.0402 | 0.0022/0.0041/0.0021/0.0173 |
| 0.5 | 0.0502/0.0259/0.0135/0.1016 | 0.0201/0.0226/0.0114/0.0760 | 0.0184/0.0193/0.0096/0.0816 |
| 0.66 | 0.0426/0.0227/0.0110/0.0873 | 0.0205/0.0225/0.0112/0.0755 | 0.0175/0.0182/0.0095/0.0676 |
## T3. Single-Persona pivot vs Minds ICC (equal seed)
| ICC | q=.15 prop/wta/bpt | q=.5 prop/wta/bpt | q=.66 prop/wta/bpt |
|---|---|---|---|
| 0 | 0.0198/0.0241/0.0057 | 0.0509/0.0397/0.0376 | 0.0443/0.0399/0.0361 |
| 0.44 | 0.0202/0.0143/0.0024 | 0.0502/0.0211/0.0181 | 0.0422/0.0204/0.0172 |
| 0.8 | 0.0206/0.0042/0.0008 | 0.0495/0.0065/0.0061 | 0.0403/0.0067/0.0061 |
| 1 | 0.0211/0.0000/0.0000 | 0.0481/0.0000/0.0000 | 0.0400/0.0000/0.0000 |
## T4. Users faction capture: population support .3, 9 random voters + k coordinated For
P(Users bloc casts For), wta vs bpt; prop share in brackets
| k | faction share of voters | wta | bpt q=.15 | bpt q=.5 | bpt q=.66 | prop share |
|---|---|---|---|---|---|---|
| 0 | 0.00 | 0.097 | 0.803 | 0.097 | 0.026 | 0.300 |
| 1 | 0.10 | 0.099 | 0.960 | 0.271 | 0.025 | 0.370 |
| 2 | 0.18 | 0.272 | 1.000 | 0.272 | 0.026 | 0.428 |
| 3 | 0.25 | 0.269 | 1.000 | 0.536 | 0.097 | 0.475 |
| 6 | 0.40 | 0.805 | 1.000 | 0.805 | 0.270 | 0.580 |
| 9 | 0.50 | 0.960 | 1.000 | 1.000 | 0.541 | 0.650 |
| 12 | 0.57 | 1.000 | 1.000 | 1.000 | 0.804 | 0.700 |
| 18 | 0.67 | 1.000 | 1.000 | 1.000 | 1.000 | 0.766 |
## T5. Logrolling (6 motions, 800 agendas, greedy mutually profitable pairwise trades, max 6)
| seed | q | rule | P(>=1 trade) | mean trades | mean welfare change | P(non-trader loses) | P(trader ends worse) |
|---|---|---|---|---|---|---|---|
| equal | 0.15 | wta | 0.128 | 0.14 | +0.559 | 0.000 | 0.007 |
| equal | 0.15 | bpt | 0.001 | 0.00 | +0.007 | 0.000 | 0.000 |
| equal | 0.15 | prop delta .5 | 0.007 | 0.01 | +0.047 | 0.000 | 0.000 |
| equal | 0.5 | wta | 0.589 | 0.99 | +0.143 | 0.376 | 0.231 |
| equal | 0.5 | bpt | 0.589 | 0.99 | +0.143 | 0.376 | 0.231 |
| equal | 0.5 | prop delta .5 | 0.451 | 0.72 | -0.432 | 0.316 | 0.146 |
| equal | 0.66 | wta | 0.614 | 1.03 | +0.097 | 0.393 | 0.231 |
| equal | 0.66 | bpt | 0.367 | 0.50 | +0.601 | 0.250 | 0.071 |
| equal | 0.66 | prop delta .5 | 0.245 | 0.30 | +0.414 | 0.177 | 0.035 |
| minds-heavy | 0.15 | wta | 0.249 | 0.27 | +0.730 | 0.130 | 0.013 |
| minds-heavy | 0.15 | bpt | 0.014 | 0.01 | +0.064 | 0.004 | 0.000 |
| minds-heavy | 0.15 | prop delta .5 | 0.010 | 0.01 | +0.064 | 0.000 | 0.000 |
| minds-heavy | 0.5 | wta | 0.608 | 0.87 | -0.052 | 0.490 | 0.176 |
| minds-heavy | 0.5 | bpt | 0.608 | 0.87 | -0.052 | 0.490 | 0.176 |
| minds-heavy | 0.5 | prop delta .5 | 0.330 | 0.43 | -0.263 | 0.282 | 0.071 |
| minds-heavy | 0.66 | wta | 0.463 | 0.61 | +0.166 | 0.375 | 0.095 |
| minds-heavy | 0.66 | bpt | 0.362 | 0.42 | +0.392 | 0.289 | 0.031 |
| minds-heavy | 0.66 | prop delta .5 | 0.282 | 0.35 | +0.374 | 0.206 | 0.043 |
## T6. Lone Minds (she absent) by category: P(pass), motion model, ICC 0/.44/1
| category | q | prop | wta | bpt |
|---|---|---|---|---|
| Bugs | 0.15 | 0.774/0.646/0.502 | 0.448/0.473/0.502 | 0.774/0.646/0.502 |
| Cosmetics | 0.3 | 0.663/0.585/0.502 | 0.448/0.473/0.502 | 0.663/0.585/0.502 |
| Balance | 0.4 | 0.556/0.528/0.502 | 0.448/0.473/0.502 | 0.556/0.528/0.502 |
| Features/NewContent | 0.5 | 0.556/0.528/0.502 | 0.448/0.473/0.502 | 0.556/0.528/0.502 |
| FundamentalDesign | 0.66 | 0.341/0.417/0.502 | 0.448/0.473/0.502 | 0.341/0.417/0.502 |
## T7. Present case (Minds 8 coin-flip, her alone in Members, Users/Patrons empty): P(pass | she For / Against / home)
| seed | q | prop | wta | bpt |
|---|---|---|---|---|
| equal | 0.15 | 1.000/0.855/0.965 | 1.000/0.363/0.363 | 1.000/0.965/0.965 |
| equal | 0.5 | 1.000/0.004/0.637 | 1.000/0.363/0.363 | 1.000/0.637/0.637 |
| equal | 0.66 | 0.855/0.000/0.145 | 0.637/0.000/0.363 | 0.145/0.000/0.145 |
| minds-heavy | 0.15 | 1.000/0.855/0.965 | 1.000/0.363/0.363 | 1.000/0.965/0.965 |
| minds-heavy | 0.5 | 0.996/0.035/0.637 | 0.637/0.363/0.363 | 0.637/0.637/0.637 |
| minds-heavy | 0.66 | 0.637/0.000/0.145 | 0.637/0.000/0.363 | 0.145/0.000/0.145 |
| members-heavy | 0.15 | 1.000/0.637/0.965 | 1.000/0.363/0.363 | 1.000/0.965/0.965 |
| members-heavy | 0.5 | 1.000/0.000/0.637 | 1.000/0.000/0.363 | 1.000/0.000/0.637 |
| members-heavy | 0.66 | 1.000/0.000/0.145 | 1.000/0.000/0.363 | 1.000/0.000/0.145 |
## T8. Operator pivot over the whole tally (coin voters, Users 10, Patrons [50,500] log), S=.8
| seed | q | n | curve | prop | wta | bpt |
|---|---|---|---|---|---|---|
| equal | 0.15 | 1 | alone | 0.012 | 0.199 | 0.000 |
| equal | 0.15 | 5 | one-each | 0.001 | 0.075 | 0.000 |
| equal | 0.15 | 5 | k=3 | 0.002 | 0.175 | 0.000 |
| equal | 0.15 | 20 | one-each | 0.000 | 0.035 | 0.000 |
| equal | 0.15 | 20 | k=3 | 0.000 | 0.136 | 0.000 |
| equal | 0.15 | 20 | k=5 | 0.000 | 0.082 | 0.000 |
| equal | 0.5 | 1 | alone | 0.723 | 0.429 | 0.300 |
| equal | 0.5 | 5 | one-each | 0.154 | 0.162 | 0.113 |
| equal | 0.5 | 5 | k=3 | 0.308 | 0.376 | 0.263 |
| equal | 0.5 | 20 | one-each | 0.042 | 0.076 | 0.053 |
| equal | 0.5 | 20 | k=3 | 0.159 | 0.293 | 0.206 |
| equal | 0.5 | 20 | k=5 | 0.097 | 0.180 | 0.126 |
| equal | 0.66 | 1 | alone | 0.373 | 0.433 | 0.086 |
| equal | 0.66 | 5 | one-each | 0.075 | 0.162 | 0.021 |
| equal | 0.66 | 5 | k=3 | 0.150 | 0.377 | 0.053 |
| equal | 0.66 | 20 | one-each | 0.019 | 0.076 | 0.004 |
| equal | 0.66 | 20 | k=3 | 0.074 | 0.294 | 0.021 |
| equal | 0.66 | 20 | k=5 | 0.044 | 0.180 | 0.012 |
| members-heavy | 0.15 | 1 | alone | 0.035 | 0.199 | 0.000 |
| members-heavy | 0.15 | 5 | one-each | 0.002 | 0.075 | 0.000 |
| members-heavy | 0.15 | 5 | k=3 | 0.003 | 0.175 | 0.000 |
| members-heavy | 0.15 | 20 | one-each | 0.000 | 0.035 | 0.000 |
| members-heavy | 0.15 | 20 | k=3 | 0.000 | 0.136 | 0.000 |
| members-heavy | 0.15 | 20 | k=5 | 0.000 | 0.082 | 0.000 |
| members-heavy | 0.5 | 1 | alone | 1.000 | 0.863 | 0.734 |
| members-heavy | 0.5 | 5 | one-each | 0.294 | 0.325 | 0.276 |
| members-heavy | 0.5 | 5 | k=3 | 0.612 | 0.753 | 0.641 |
| members-heavy | 0.5 | 20 | one-each | 0.101 | 0.152 | 0.130 |
| members-heavy | 0.5 | 20 | k=3 | 0.383 | 0.588 | 0.500 |
| members-heavy | 0.5 | 20 | k=5 | 0.235 | 0.360 | 0.306 |
| members-heavy | 0.66 | 1 | alone | 0.806 | 0.630 | 0.092 |
| members-heavy | 0.66 | 5 | one-each | 0.128 | 0.237 | 0.023 |
| members-heavy | 0.66 | 5 | k=3 | 0.237 | 0.549 | 0.058 |
| members-heavy | 0.66 | 20 | one-each | 0.021 | 0.111 | 0.004 |
| members-heavy | 0.66 | 20 | k=3 | 0.079 | 0.428 | 0.023 |
| members-heavy | 0.66 | 20 | k=5 | 0.046 | 0.262 | 0.013 |
## T9. Exhaustive profitable misreport/abstention/stay-home under bpt (renormalized, no quorum)
| sizes | seed | q | profiles | profitable indiv deviations | estate stay-home profitable |
|---|---|---|---|---|---|
| 8/1/0/0 | equal | 0.15 | 512 | 0 | 0 |
| 8/1/0/0 | equal | 0.5 | 512 | 0 | 0 |
| 8/1/0/0 | equal | 0.66 | 512 | 0 | 0 |
| 8/1/0/0 | minds-heavy | 0.15 | 512 | 0 | 0 |
| 8/1/0/0 | minds-heavy | 0.5 | 512 | 0 | 0 |
| 8/1/0/0 | minds-heavy | 0.66 | 512 | 0 | 0 |
| 8/1/0/0 | members-heavy | 0.15 | 512 | 0 | 0 |
| 8/1/0/0 | members-heavy | 0.5 | 512 | 0 | 0 |
| 8/1/0/0 | members-heavy | 0.66 | 512 | 0 | 0 |
| 3/3/2/2 | equal | 0.15 | 1024 | 0 | 0 |
| 3/3/2/2 | equal | 0.5 | 1024 | 0 | 0 |
| 3/3/2/2 | equal | 0.66 | 1024 | 0 | 0 |
| 3/3/2/2 | minds-heavy | 0.15 | 1024 | 0 | 0 |
| 3/3/2/2 | minds-heavy | 0.5 | 1024 | 0 | 0 |
| 3/3/2/2 | minds-heavy | 0.66 | 1024 | 0 | 0 |
| 3/3/2/2 | members-heavy | 0.15 | 1024 | 0 | 0 |
| 3/3/2/2 | members-heavy | 0.5 | 1024 | 0 | 0 |
| 3/3/2/2 | members-heavy | 0.66 | 1024 | 0 | 0 |

View file

@ -0,0 +1,153 @@
"""Third rule `bpt` (bloc-parliament-at-threshold): a live bloc casts its whole weight For iff its internal
weighted For share (abstain excluded) >= q, else Against. Council: For weight / live weight >= q (renormalized).
Compared with prop and wta (wta: internal majority, tie casts nothing). Run: python -I third.py
"""
import itertools, math
import numpy as np
SEEDS = {"equal": [25, 25, 25, 25], "minds-heavy": [40, 30, 20, 10], "members-heavy": [20, 45, 20, 15]}
QS = (0.15, 0.5, 0.66)
def tally(Wb, F, Tot, q, rule):
Wb = np.array(Wb, float); live = Tot > 0; sh = np.divide(F, np.where(live, Tot, 1)); sh = np.where(live, sh, 0)
if rule == "prop": f = (sh * Wb).sum(-1); den = (live * Wb).sum(-1)
elif rule == "wta":
st = np.where(live, np.sign(2 * F - Tot), 0); f = (st == 1) @ Wb; den = f + (st == -1) @ Wb
else: f = ((sh >= q - 1e-9) & live) @ Wb; den = live @ Wb
return (den > 0) & (f >= q * den - 1e-9)
def motion(T, sizes, minds_r, seed, coin=False, wvec=None):
r = np.random.default_rng(seed)
if coin: p = np.full((T, 4), .5)
else:
m = r.standard_normal(T); p = 1 / (1 + np.exp(-1.7 * (0.6 * m[:, None] + 0.8 * r.standard_normal((T, 4)))))
ws = wvec or [np.ones(n) for n in sizes]; F = np.zeros((T, 4)); Tot = np.zeros((T, 4)); V = []
for b, w in enumerate(ws):
if len(w) == 0: V.append(np.zeros((T, 0), bool)); continue
pb = p[:, b:b + 1]
if b == 0 and minds_r >= 1: pb = (r.random((T, 1)) < pb).astype(float)
elif b == 0 and minds_r > 0:
a = p[:, 0] * (1 - minds_r) / minds_r; c = (1 - p[:, 0]) * (1 - minds_r) / minds_r; pb = r.beta(a, c)[:, None]
v = r.random((T, len(w))) < pb; V.append(v); F[:, b] = v @ w; Tot[:, b] = w.sum()
return ws, V, F, Tot
def piv(ws, V, F, Tot, b, Wb, q, rule):
G = F.copy(); G[:, b] += np.where(V[b][:, 0], -ws[b][0], ws[b][0])
return np.mean(tally(Wb, F, Tot, q, rule) != tally(Wb, G, Tot, q, rule))
R = ("prop", "wta", "bpt")
print("## T1. Motion model (Minds ICC .44; 8/15/30/4): P(pass) and pairwise P(outcomes differ)")
print("| seed | q | P(pass) prop/wta/bpt | differ bpt-prop | differ bpt-wta | differ prop-wta |")
print("|---|---|---|---|---|---|")
ws, V, F, Tot = motion(200_000, (8, 15, 30, 4), .44, 1)
for s, Wb in SEEDS.items():
for q in QS:
o = {r: tally(Wb, F, Tot, q, r) for r in R}
print(f"| {s} | {q} | " + "/".join(f"{o[r].mean():.3f}" for r in R) +
f" | {np.mean(o['bpt'] != o['prop']):.3f} | {np.mean(o['bpt'] != o['wta']):.3f} | {np.mean(o['prop'] != o['wta']):.3f} |")
print("\n## T2. Single-voter pivot M/Me/U/P (same draws), equal seed")
print("| q | prop | wta | bpt |"); print("|---|---|---|---|")
for q in QS:
print(f"| {q} | " + " | ".join("/".join(f"{piv(ws, V, F, Tot, b, SEEDS['equal'], q, r):.4f}" for b in range(4)) for r in R) + " |")
print("\n## T3. Single-Persona pivot vs Minds ICC (equal seed)")
print("| ICC | q=.15 prop/wta/bpt | q=.5 prop/wta/bpt | q=.66 prop/wta/bpt |"); print("|---|---|---|---|")
for icc in (0, .44, .8, 1):
w2, V2, F2, T2 = motion(100_000, (8, 15, 30, 4), icc, 2)
print(f"| {icc} | " + " | ".join("/".join(f"{piv(w2, V2, F2, T2, 0, SEEDS['equal'], q, r):.4f}" for r in R) for q in QS) + " |")
print("\n## T4. Users faction capture: population support .3, 9 random voters + k coordinated For")
print("P(Users bloc casts For), wta vs bpt; prop share in brackets")
print("| k | faction share of voters | wta | bpt q=.15 | bpt q=.5 | bpt q=.66 | prop share |"); print("|---|---|---|---|---|---|---|")
rr = np.random.default_rng(4)
for k in (0, 1, 2, 3, 6, 9, 12, 18):
f = (rr.random((100_000, 9)) < .3).sum(1) + k; n = 9 + k; sh = f / n
print(f"| {k} | {k / n:.2f} | {np.mean(2 * f > n):.3f} | " + " | ".join(f"{np.mean(sh >= q - 1e-9):.3f}" for q in QS) + f" | {sh.mean():.3f} |")
print("\n## T5. Logrolling (6 motions, 800 agendas, greedy mutually profitable pairwise trades, max 6)")
print("| seed | q | rule | P(>=1 trade) | mean trades | mean welfare change | P(non-trader loses) | P(trader ends worse) |")
print("|---|---|---|---|---|---|---|---|")
def logroll(Wb, q, kind, T=800, M=6, seed=3):
rng = np.random.default_rng(seed); W = np.array(Wb, float); st = np.zeros(5)
for _ in range(T):
u = rng.standard_normal((4, M)); share = 1 / (1 + np.exp(-2 * u))
base = {"wta": (u > 0).astype(float), "bpt": (share >= q).astype(float), "prop": share}[kind]
cast = base.copy()
def out(c): return (W @ c) >= q * W.sum() - 1e-9
U0 = u @ out(cast); cur = U0.copy(); used = set(); n = 0; traders = set()
while n < 6:
best = None
for A, B in itertools.combinations(range(4), 2):
for x, y in itertools.permutations(range(M), 2):
if (A, x) in used or (B, y) in used: continue
c2 = cast.copy()
for b, mm in ((A, x), (B, y)):
c2[b, mm] = (0.0 if u[b, mm] > 0 else 1.0) if kind != "prop" else np.clip(base[b, mm] - np.sign(u[b, mm]) * .5, 0, 1)
g = u @ out(c2) - cur
if g[A] > 1e-9 and g[B] > 1e-9 and (best is None or g[A] + g[B] > best[0]): best = (g[A] + g[B], c2, A, B, x, y)
if best is None: break
_, cast, A, B, x, y = best; used |= {(A, x), (B, y)}; traders |= {A, B}; n += 1; cur = u @ out(cast)
non = [b for b in range(4) if b not in traders]
st += [n > 0, n, cur.sum() - U0.sum(), any(cur[b] < U0[b] - 1e-9 for b in non), any(cur[b] < U0[b] - 1e-9 for b in traders)]
return st / T
for s in ("equal", "minds-heavy"):
for q in (0.15, 0.5, 0.66):
for kind in ("wta", "bpt", "prop"):
v = logroll(SEEDS[s], q, kind)
print(f"| {s} | {q} | {kind if kind != 'prop' else 'prop delta .5'} | {v[0]:.3f} | {v[1]:.2f} | {v[2]:+.3f} | {v[3]:.3f} | {v[4]:.3f} |")
print("\n## T6. Lone Minds (she absent) by category: P(pass), motion model, ICC 0/.44/1")
print("| category | q | prop | wta | bpt |"); print("|---|---|---|---|---|")
lone = {icc: motion(200_000, (8, 0, 0, 0), icc, 5) for icc in (0, .44, 1)}
for c, q in {"Bugs": .15, "Cosmetics": .30, "Balance": .40, "Features/NewContent": .50, "FundamentalDesign": .66}.items():
print(f"| {c} | {q} | " + " | ".join("/".join(f"{tally(SEEDS['equal'], lone[i][2], lone[i][3], q, r).mean():.3f}" for i in (0, .44, 1)) for r in R) + " |")
print("\n## T7. Present case (Minds 8 coin-flip, her alone in Members, Users/Patrons empty): P(pass | she For / Against / home)")
print("| seed | q | prop | wta | bpt |"); print("|---|---|---|---|---|")
from math import comb
for s, Wb in SEEDS.items():
for q in QS:
cells = []
for r in R:
vals = []
for hv in (1, -1, 0):
F_ = np.array([[k, 1.0 if hv == 1 else 0, 0, 0] for k in range(9)]); T_ = np.array([[8, 1.0 if hv else 0, 0, 0]] * 9)
pk = np.array([comb(8, k) / 256 for k in range(9)]); vals.append(float(pk @ tally(Wb, F_, T_, q, r)))
cells.append("/".join(f"{x:.3f}" for x in vals))
print(f"| {s} | {q} | " + " | ".join(cells) + " |")
print("\n## T8. Operator pivot over the whole tally (coin voters, Users 10, Patrons [50,500] log), S=.8")
def mweights(n, S, k):
if n == 1: return np.ones(1)
tail = np.array([i ** (-1 / 1.16) for i in range(1, n)]); tail = tail / tail.sum() * (1 - S)
c = np.concatenate([[S], tail]); return np.ones(n) if k is None else c ** (1 / k)
print("| seed | q | n | curve | prop | wta | bpt |"); print("|---|---|---|---|---|---|---|")
for s in ("equal", "members-heavy"):
for q in QS:
for n, kn, k in ((1, "alone", None), (5, "one-each", None), (5, "k=3", 3), (20, "one-each", None), (20, "k=3", 3), (20, "k=5", 5)):
wv = [np.ones(8), mweights(n, .8, k), np.ones(10), np.log1p(np.array([50, 500.0]))]
w8, V8, F8, T8 = motion(100_000, None, 0, 6, coin=True, wvec=wv)
print(f"| {s} | {q} | {n} | {kn} | " + " | ".join(f"{piv(w8, V8, F8, T8, 1, SEEDS[s], q, r):.3f}" for r in R) + " |")
print("\n## T9. Exhaustive profitable misreport/abstention/stay-home under bpt (renormalized, no quorum)")
def tl(ws_, sizes, bal, q):
F = [0] * 4; A = [0] * 4; i = 0
for b, n in enumerate(sizes):
for v in bal[i:i + n]: F[b] += v == 1; A[b] += v == -1
i += n
live = [b for b in range(4) if F[b] + A[b]]; f = sum(ws_[b] for b in live if F[b] / (F[b] + A[b]) >= q - 1e-9)
den = sum(ws_[b] for b in live); return den > 0 and f >= q * den - 1e-9
print("| sizes | seed | q | profiles | profitable indiv deviations | estate stay-home profitable |"); print("|---|---|---|---|---|---|")
for sizes in ((8, 1, 0, 0), (3, 3, 2, 2)):
for s, Wb in SEEDS.items():
for q in QS:
st = [sum(sizes[:b]) for b in range(4)]; ind = est = n = 0
for bal in itertools.product((1, -1), repeat=sum(sizes)):
n += 1; o = tl(Wb, sizes, bal, q)
for i, v in enumerate(bal):
if o != (v == 1) and any(tl(Wb, sizes, bal[:i] + (d,) + bal[i + 1:], q) == (v == 1) for d in (-v, 0)): ind += 1
for b in range(4):
if not sizes[b]: continue
seg = bal[st[b]:st[b] + sizes[b]]; pref = seg.count(1) / len(seg) >= q # bloc's own position under bpt
if o != pref and tl(Wb, sizes, bal[:st[b]] + (0,) * sizes[b] + bal[st[b] + sizes[b]:], q) == pref: est += 1
print(f"| {'/'.join(map(str, sizes))} | {s} | {q} | {n} | {ind} | {est} |")

View file

@ -0,0 +1,76 @@
## W1. Effective voters (Kish = inverse Herfindahl) and top share
| bloc | curve | operator S | Kish n_eff | top voter share | internal Banzhaf of top (WTA bloc) |
|---|---|---|---|---|---|
| Members (15) | one-each | 0.5 | 15.00 | 0.067 | 0.209 |
| Members (15) | k=1 | 0.5 | 3.55 | 0.500 | 1.000 |
| Members (15) | k=2 | 0.5 | 10.13 | 0.222 | 0.672 |
| Members (15) | k=3 | 0.5 | 12.85 | 0.154 | 0.481 |
| Members (15) | k=5 | 0.5 | 14.30 | 0.112 | 0.333 |
| Members (15) | one-each | 0.8 | 15.00 | 0.067 | 0.209 |
| Members (15) | k=1 | 0.8 | 1.55 | 0.800 | 1.000 |
| Members (15) | k=2 | 0.8 | 6.05 | 0.364 | 0.958 |
| Members (15) | k=3 | 0.8 | 10.44 | 0.225 | 0.700 |
| Members (15) | k=5 | 0.8 | 13.54 | 0.143 | 0.470 |
| Patrons (4) | one-each | - | 4.00 | 0.250 | 0.750 |
| Patrons (4) | k=1 | - | 1.10 | 0.951 | 1.000 |
| Patrons (4) | log(1+d) | - | 3.33 | 0.423 | 0.750 |
| Patrons (4) | k=3 | - | 2.40 | 0.594 | 1.000 |
| Patrons (4) | k=5 | - | 3.22 | 0.452 | 0.750 |
| Patrons (4) | k=10 | - | 3.78 | 0.346 | 0.750 |
## W2. Operator's power over the whole tally (two-tier Banzhaf, all voters fair coins, 200k draws)
Comparators: a one-each Member; a Mind (8); a User (30); top Patron under log(1+d).
| bloc weights | q | Members curve | S | op pivot prop | op pivot WTA | one-each Member prop/WTA | Mind prop/WTA | User prop/WTA |
|---|---|---|---|---|---|---|---|---|
| equal | 0.5 | one-each (baseline) | - | 0.0688 | 0.0887 | - | 0.1335/0.1066 | 0.0341/0.0588 |
| equal | 0.5 | k=1 | 0.5 | 0.5037 | 0.4220 | - | 0.1117/0.1067 | 0.0294/0.0591 |
| equal | 0.5 | k=2 | 0.5 | 0.2321 | 0.2855 | - | 0.1286/0.1064 | 0.0338/0.0588 |
| equal | 0.5 | k=3 | 0.5 | 0.1611 | 0.2046 | - | 0.1301/0.1066 | 0.0348/0.0586 |
| equal | 0.5 | k=5 | 0.5 | 0.1175 | 0.1412 | - | 0.1312/0.1065 | 0.0345/0.0588 |
| equal | 0.5 | k=1 | 0.8 | 0.7458 | 0.4220 | - | 0.0791/0.1067 | 0.0217/0.0591 |
| equal | 0.5 | k=2 | 0.8 | 0.3764 | 0.4044 | - | 0.1214/0.1067 | 0.0323/0.0591 |
| equal | 0.5 | k=3 | 0.8 | 0.2353 | 0.2966 | - | 0.1288/0.1064 | 0.0343/0.0588 |
| equal | 0.5 | k=5 | 0.8 | 0.1492 | 0.1998 | - | 0.1309/0.1065 | 0.0349/0.0587 |
| equal | 0.66 | one-each (baseline) | - | 0.0167 | 0.0885 | - | 0.0305/0.1084 | 0.0083/0.0595 |
| equal | 0.66 | k=1 | 0.5 | 0.1367 | 0.4227 | - | 0.0425/0.1083 | 0.0118/0.0591 |
| equal | 0.66 | k=2 | 0.5 | 0.0563 | 0.2849 | - | 0.0325/0.1087 | 0.0089/0.0590 |
| equal | 0.66 | k=3 | 0.5 | 0.0391 | 0.2041 | - | 0.0315/0.1089 | 0.0086/0.0596 |
| equal | 0.66 | k=5 | 0.5 | 0.0281 | 0.1402 | - | 0.0310/0.1087 | 0.0081/0.0598 |
| equal | 0.66 | k=1 | 0.8 | 0.2524 | 0.4227 | - | 0.0573/0.1083 | 0.0150/0.0591 |
| equal | 0.66 | k=2 | 0.8 | 0.0940 | 0.4051 | - | 0.0368/0.1083 | 0.0101/0.0592 |
| equal | 0.66 | k=3 | 0.8 | 0.0566 | 0.2959 | - | 0.0324/0.1088 | 0.0090/0.0591 |
| equal | 0.66 | k=5 | 0.8 | 0.0358 | 0.1994 | - | 0.0313/0.1088 | 0.0083/0.0597 |
| members-heavy | 0.5 | one-each (baseline) | - | 0.1462 | 0.1769 | - | 0.1208/0.0588 | 0.0317/0.0256 |
| members-heavy | 0.5 | k=1 | 0.5 | 0.8932 | 0.8447 | - | 0.0399/0.0585 | 0.0108/0.0257 |
| members-heavy | 0.5 | k=2 | 0.5 | 0.4826 | 0.5700 | - | 0.1052/0.0585 | 0.0276/0.0256 |
| members-heavy | 0.5 | k=3 | 0.5 | 0.3413 | 0.4083 | - | 0.1153/0.0587 | 0.0314/0.0257 |
| members-heavy | 0.5 | k=5 | 0.5 | 0.2489 | 0.2814 | - | 0.1196/0.0586 | 0.0312/0.0257 |
| members-heavy | 0.5 | k=1 | 0.8 | 0.9990 | 0.8447 | - | 0.0009/0.0584 | 0.0003/0.0256 |
| members-heavy | 0.5 | k=2 | 0.8 | 0.7504 | 0.8096 | - | 0.0734/0.0583 | 0.0198/0.0257 |
| members-heavy | 0.5 | k=3 | 0.8 | 0.4956 | 0.5928 | - | 0.1058/0.0584 | 0.0284/0.0257 |
| members-heavy | 0.5 | k=5 | 0.8 | 0.3181 | 0.3986 | - | 0.1178/0.0586 | 0.0312/0.0256 |
| members-heavy | 0.66 | one-each (baseline) | - | 0.0218 | 0.1263 | - | 0.0176/0.0688 | 0.0047/0.0366 |
| members-heavy | 0.66 | k=1 | 0.5 | 0.2538 | 0.6046 | - | 0.0570/0.0688 | 0.0151/0.0364 |
| members-heavy | 0.66 | k=2 | 0.5 | 0.0727 | 0.4074 | - | 0.0248/0.0691 | 0.0066/0.0365 |
| members-heavy | 0.66 | k=3 | 0.5 | 0.0487 | 0.2919 | - | 0.0198/0.0692 | 0.0056/0.0365 |
| members-heavy | 0.66 | k=5 | 0.5 | 0.0347 | 0.2011 | - | 0.0181/0.0688 | 0.0052/0.0366 |
| members-heavy | 0.66 | k=1 | 0.8 | 0.6287 | 0.6046 | - | 0.0785/0.0688 | 0.0205/0.0364 |
| members-heavy | 0.66 | k=2 | 0.8 | 0.1359 | 0.5793 | - | 0.0392/0.0688 | 0.0104/0.0364 |
| members-heavy | 0.66 | k=3 | 0.8 | 0.0708 | 0.4232 | - | 0.0243/0.0691 | 0.0065/0.0365 |
| members-heavy | 0.66 | k=5 | 0.8 | 0.0439 | 0.2851 | - | 0.0190/0.0692 | 0.0055/0.0366 |
## W3. Exponent sensitivity under the motion model (Minds ICC .44, equal bloc weights, q=0.5, S=0.5)
P(motion outcome differs) between Members curve k and k=3 baseline, Patron curve log(1+d); and Patron curve vs log(1+d) with Members k=3.
| varied | curve | P(differ) prop | P(differ) WTA | operator pivot prop | operator pivot WTA |
|---|---|---|---|---|---|
| Members | one-each | 0.014 | 0.014 | 0.0254 | 0.0217 |
| Members | k=1 | 0.051 | 0.061 | 0.2080 | 0.2962 |
| Members | k=2 | 0.010 | 0.012 | 0.0889 | 0.0856 |
| Members | k=3 | 0.000 | 0.000 | 0.0609 | 0.0545 |
| Members | k=5 | 0.007 | 0.008 | 0.0436 | 0.0361 |
| Patrons | one-each | 0.027 | 0.032 | - | - |
| Patrons | k=1 | 0.080 | 0.033 | - | - |
| Patrons | log(1+d) | 0.000 | 0.000 | - | - |
| Patrons | k=3 | 0.026 | 0.033 | - | - |
| Patrons | k=5 | 0.006 | 0.000 | - | - |
| Patrons | k=10 | 0.013 | 0.000 | - | - |

View file

@ -0,0 +1,99 @@
"""Within-bloc vote-power curves (operator addition 2026-10-29).
Members: w = contribution^(1/k). Patrons: w = log(1+d) or d^(1/k). Minds, Users: one each.
Assumptions (stated, not data): 15 Members; operator (member 0) holds share S of all contribution
(S=0.5 or 0.8); the other 14 follow a Pareto(alpha=1.16, "80/20") quantile ladder c_i = i^(-1/1.16).
4 Patrons with donations [10, 50, 200, 5000]. Run: python -I weights.py
"""
import itertools, math
import numpy as np
rng = np.random.default_rng(29)
def members(S):
tail = np.array([i ** (-1 / 1.16) for i in range(1, 15)]); tail /= tail.sum()
return np.concatenate([[S / (1 - S)], tail]) # operator / tail-sum = S/(1-S)
PATRON_D = np.array([10, 50, 200, 5000.0])
CURVES_M = {"one-each": lambda c: np.ones_like(c), "k=1": lambda c: c, "k=2": np.sqrt,
"k=3": lambda c: c ** (1 / 3), "k=5": lambda c: c ** 0.2}
CURVES_P = {"one-each": lambda d: np.ones_like(d), "k=1": lambda d: d, "log(1+d)": np.log1p,
"k=3": lambda d: d ** (1 / 3), "k=5": lambda d: d ** 0.2, "k=10": lambda d: d ** 0.1}
def kish(w): return w.sum() ** 2 / (w ** 2).sum()
def internal_banzhaf(w, i=0):
"""P(voter i decides the weighted bloc majority), others fair coins; tie casts nothing (counts as change)."""
others = np.delete(w, i); n = len(others); tot = 0
for bits in itertools.product([0, 1], repeat=n):
f = np.dot(bits, others); a = others.sum() - f
sF = np.sign(f + w[i] - a); sA = np.sign(f - a - w[i])
tot += sF != sA
return tot / 2 ** n
def tally(Wb, F, Tot, q, rule):
"""F, Tot: (T,4) weighted For and cast weight per bloc."""
Wb = np.array(Wb, float)
if rule == "prop":
return (F / Tot) @ Wb >= q * Wb.sum() - 1e-9
st = np.sign(2 * F - Tot); f = (st == 1) @ Wb; a = (st == -1) @ Wb
return (f + a > 0) & (f >= q * (f + a) - 1e-9)
def sim(wM, wP, Wb=(25, 25, 25, 25), q=0.5, T=200_000, model="coin", minds_r=0.44, seed=1):
r = np.random.default_rng(seed)
if model == "coin": p = np.full((T, 4), .5)
else:
m = r.standard_normal(T); p = 1 / (1 + np.exp(-1.7 * (0.6 * m[:, None] + 0.8 * r.standard_normal((T, 4)))))
ws = [np.ones(8), wM, np.ones(30), wP]; F = np.zeros((T, 4)); Tot = np.zeros((T, 4)); V = []
for b, w in enumerate(ws):
pb = p[:, b:b + 1]
if b == 0 and model != "coin" and minds_r > 0:
a = p[:, 0] * (1 - minds_r) / minds_r; c = (1 - p[:, 0]) * (1 - minds_r) / minds_r
pb = r.beta(a, c)[:, None]
v = r.random((T, len(w))) < pb; V.append(v)
F[:, b] = v @ w; Tot[:, b] = w.sum()
return ws, V, F, Tot
def pivot(ws, V, F, Tot, b, i, Wb, q, rule):
G = F.copy(); G[:, b] += np.where(V[b][:, i], -ws[b][i], ws[b][i])
return np.mean(tally(Wb, F, Tot, q, rule) != tally(Wb, G, Tot, q, rule))
if __name__ == "__main__":
print("## W1. Effective voters (Kish = inverse Herfindahl) and top share")
print("| bloc | curve | operator S | Kish n_eff | top voter share | internal Banzhaf of top (WTA bloc) |")
print("|---|---|---|---|---|---|")
for S in (0.5, 0.8):
for name, fn in CURVES_M.items():
w = fn(members(S)); print(f"| Members (15) | {name} | {S} | {kish(w):.2f} | {w[0] / w.sum():.3f} | {internal_banzhaf(w):.3f} |")
for name, fn in CURVES_P.items():
w = fn(PATRON_D); print(f"| Patrons (4) | {name} | - | {kish(w):.2f} | {w.max() / w.sum():.3f} | {internal_banzhaf(w, int(np.argmax(w))):.3f} |")
print("\n## W2. Operator's power over the whole tally (two-tier Banzhaf, all voters fair coins, 200k draws)")
print("Comparators: a one-each Member; a Mind (8); a User (30); top Patron under log(1+d).")
print("| bloc weights | q | Members curve | S | op pivot prop | op pivot WTA | one-each Member prop/WTA | Mind prop/WTA | User prop/WTA |")
print("|---|---|---|---|---|---|---|---|---|")
wP = np.log1p(PATRON_D)
for Wb, lab in (((25, 25, 25, 25), "equal"), ((20, 45, 20, 15), "members-heavy")):
for q in (0.5, 0.66):
for S in (0.5, 0.8):
for name in ("one-each", "k=1", "k=2", "k=3", "k=5"):
if name == "one-each" and S == 0.8: continue
wM = CURVES_M[name](members(S)); ws, V, F, Tot = sim(wM, wP, Wb, q)
pr = [pivot(ws, V, F, Tot, b, i, Wb, q, "prop") for b, i in ((1, 0), (0, 0), (2, 0))]
pw = [pivot(ws, V, F, Tot, b, i, Wb, q, "wta") for b, i in ((1, 0), (0, 0), (2, 0))]
base = "" if name != "one-each" else " (baseline)"
print(f"| {lab} | {q} | {name}{base} | {S if name != 'one-each' else '-'} | {pr[0]:.4f} | {pw[0]:.4f} | - | {pr[1]:.4f}/{pw[1]:.4f} | {pr[2]:.4f}/{pw[2]:.4f} |")
print("\n## W3. Exponent sensitivity under the motion model (Minds ICC .44, equal bloc weights, q=0.5, S=0.5)")
print("P(motion outcome differs) between Members curve k and k=3 baseline, Patron curve log(1+d); and Patron curve vs log(1+d) with Members k=3.")
print("| varied | curve | P(differ) prop | P(differ) WTA | operator pivot prop | operator pivot WTA |")
print("|---|---|---|---|---|---|")
Wb = (25, 25, 25, 25); q = .5
def outcomes(wM, wP):
ws, V, F, Tot = sim(wM, wP, Wb, q, T=200_000, model="motion", seed=7)
return ws, V, F, Tot, tally(Wb, F, Tot, q, "prop"), tally(Wb, F, Tot, q, "wta")
*_, bp, bw = outcomes(CURVES_M["k=3"](members(.5)), wP)
for name in ("one-each", "k=1", "k=2", "k=3", "k=5"):
ws, V, F, Tot, op, ow = outcomes(CURVES_M[name](members(.5)), wP)
print(f"| Members | {name} | {np.mean(op != bp):.3f} | {np.mean(ow != bw):.3f} | {pivot(ws, V, F, Tot, 1, 0, Wb, q, 'prop'):.4f} | {pivot(ws, V, F, Tot, 1, 0, Wb, q, 'wta'):.4f} |")
for name in ("one-each", "k=1", "log(1+d)", "k=3", "k=5", "k=10"):
ws, V, F, Tot, op, ow = outcomes(CURVES_M["k=3"](members(.5)), CURVES_P[name](PATRON_D))
print(f"| Patrons | {name} | {np.mean(op != bp):.3f} | {np.mean(ow != bw):.3f} | - | - |")

View file

@ -0,0 +1,119 @@
# Swarm governance on Bifrost: readiness and prior art (2026-10-29)
Serves follow-up `eureka-body:follow_up:swarm-governance-on-bifrost` and ruling `eureka-body:ruling:self-run-result-to-classifier` (classifier tuning by swarm vote). Two Eyes passes, facts only. Part 1 reads Bifrost at 7b63031; part 2 is prior art from the web, with confidence tags.
## Part 1: Bifrost governance readiness
Motions (Postgres/EF, Razor + Eve surface): Domain/BifrostModels.cs:665-721 (Motion, Vote), enums :86-115; Features/Motions/MotionGovernanceService.cs (create :13, vote :47, close :95-131); MotionCategoryPolicy.cs thresholds Bugs .15, Cosmetics .30, Balance .40, Features .50, NewContent .50, FundamentalDesign .66.
Pass rule: For weight / (For+Against weight) >= ApprovalThreshold; Abstain excluded; no quorum field or code anywhere (grep "quorum" empty). Close is manual by creator or CanModerateMotions (:105); ResolveStatusLabel also derives Passed/Failed display after ClosesAtUtc.
Weight: Membership.EffectiveVotingWeight = 0 unless Status Active, else max(1, sum current TierSnapshot weights) (BifrostModels.cs:357-361). TierSnapshotKind Patron|Contributor.
Membership: MembershipStatus Authenticated|PendingApproval|Active|Suspended; MemberRole StandardMember|Producer|Maintainer|LedgerReviewer|PlatformAdmin (RoleAssignment); flags IsPlatformAdmin, CanManageProjects, CanManageLedger, CanModerateMotions. Activation: bootstrap admin, admin approval (Pages/App/Members), Heimdall member claim. Native registration POST /auth/bifrost/register is anonymous, yields Authenticated only. /App is ActiveMember-gated (Program.cs:57).
Personas: no Persona account/voter class. BridgeActorKind Member|Agent|Persona|Service exists for bridge actions only (BifrostModels.cs:178). A Persona can only vote if it holds a UserAccount with Active membership (nothing in code makes one). Persona identity names are "reserved in Bifrost's identity namespace" per gamecult-ops/docs/gamecult-chat-target.md:84,179: Persona Matrix user "Registered by the mouth when Bifrost governance approves the Persona" -- documented; no code for that approval found.
Typed-document exposure: motions/votes are Postgres rows, not CultCache docs. GET /eve/governance/surface (Program.cs:513, ActiveMember-gated) returns Eve JSON surface (Eve is deprecated per F:\Projects\CLAUDE.md). MotionEveSurfaceService names cultmesh://asgard.starfire.bifrost/commands/motion and motion.create/vote/close commands; no handler for that route found in code (grep). verse-service-contract.md:60,185-196 documents schema gamecult.bifrost.governance and the command route; schema ids for motion/vote listed as todo (:256).
Governance topics/comments: tools/governance-threads.mjs, CultCache file .bifrost/governance-threads.cc, types bifrost.governance.topic / topic-comment; statuses open->consensus_ready->approved->dispatched->closed; support/objection are comment stances, not votes; the web app never reads it (docs/concept-mill-votes-target.md facts 1-5). Eyes pass 2026-10-10 found no such store on Yggdrasil. Web keeps only GovernanceActivityReceipt rows.
Concept Mill votes (the one different vote system): ConceptVoteModels.cs, Features/ConceptVotes/*, Pages/Mill/Vote; VoterClass Anonymous|Account|Member; public tally export /public/concepts with CORS (ConceptTallyExport.cs). Per-subject appraisals, not motions; deployed in 67072b2 lineage.
Chat integration: persona-matrix-mouth.mjs (Persona Matrix speech, main 7b63031), OIDC issuer + back-channel logout on membership leave (main 1894f65); persona feedback ingress/delivery as signed CultCache docs (docs/bridge.md:195-217). No motion/vote surface in chat found. Discord/Reddit voting prompts documented as future (full-implementation-strategy.md:78-79).
UI: Razor /App/Motions (Pages/App/Motions/Index.cshtml[.cs]), /App/Members; no tally history page beyond list.
Docs: docs/verse-service-contract.md, full-implementation-strategy.md (Phase 3 governance and revenue share), bridge.md, agent-transport.md, concept-mill-votes-target.md, jurisdiction-map.md.
Ruling read: eureka-body:ruling:self-run-result-to-classifier (InForce, Operator, 2026-10-29): "tuning authority belongs to a swarm decision process, pending that process existing."
Not read: Bifrost tests, jurisdiction-map.md, live DB contents (motion count unknown), campaign rulings beyond the one above.
## Part 2: Prior art
## 1. Quorum and close timing
Apache (https://www.apache.org/foundation/voting.html) [P]
- Votes should generally run at least 72 hours so all time zones can take part.
- Lazy consensus = silence gives assent; not usable for code changes under review-then-commit unless declared at vote start.
- Types: procedural = simple majority (more +1 than -1); code modification = consensus approval (3 +1, no -1) unless lazy consensus declared; release = majority approval (3 binding +1, more +1 than -1).
- Veto only on code modifications: a -1 needs technical justification, else invalid; it blocks until withdrawn. Releases cannot be vetoed.
- Binding votes: PMC members; others advisory. Only explicit votes count.
- Page does not define "lazy majority" or "2/3 majority" (those terms are in other ASF docs, not checked).
IETF rough consensus, RFC 7282 (https://www.rfc-editor.org/rfc/rfc7282.html) [P]
- Explicitly not voting; the chair judges whether every technical objection was considered and answered; call is appealable.
- Named failure modes: capitulation (objector gives up unanswered), horse-trading, vote stuffing (recruiting voices to swamp objections), polls treated as decisions, ignored objections.
Loomio (https://www.loomio.com/docs/en/user_manual/polls/vote_share_requirements, https://help.loomio.com/en/user_manual/polls/quorum) [P/S]
- Quorum is optional, a percentage of eligible voters; set per poll or per poll template.
- Vote-share requirements are separate: threshold measured against eligible voters or votes cast; weighted voting uses weight instead of headcount; a Block option with "no more than 0%" lets one block vote prevent passage.
- Docs example: 5 eligible, 75% agreement of eligible plus 60% quorum; 2 agreeing votes (40% of eligible) fail.
- Closing: deadline can be extended or proposal closed early; no built-in minimum open period found; group-written rules vary (one group: >=50% of reps participate, open two weeks) (https://www.loomio.com/d/tmaQYcM5/proposed-changes-to-the-loomio-voting-process-for-e-nable-proposals) [S].
Debian constitution (https://www.debian.org/devel/constitution) [P]
- GR: discussion period min 2 weeks, max 3 (App. A.1.1); voting period 2 weeks, Leader may vary by 1 week (4.2.3); quorum 3Q where Q = half sqrt(number of Developers); casting vote for Leader; default option "None of the above" (4.2.4); ranked ballot, Condorcet/Schwartz-set counting (App. A.5).
- Sponsorship needed to propose a GR: K = min(Q, 5) (4.2.1, 4.2.7). 2K sponsors can put a Leader/Delegate decision on hold (4.2.2).
- Supermajorities: 3:1 for constitutional amendments and foundation documents (4.1.2, 4.1.5.3); 2:1 to override a Technical Committee decision (4.1.4).
Robert's Rules small boards [S]
- Default quorum is a majority of the membership unless bylaws say otherwise (https://www.dummies.com/article/business-careers-money/business/general-business/roberts-rules-for-defining-a-quorum-189712, https://mtas.tennessee.edu/system/files/mrln/mknowledge/main/Quorum_Hodge_2018_public_legop.pdf).
- "Small board" relaxed procedure applies to bodies of about 12 or fewer (https://www.wv-wmd.org/polity/smallboards.html). The search did not confirm any finer threshold.
Decidim (https://docs.decidim.org/en/develop/admin/components/proposals) [S]
- Proposals component settings include per-participant vote cap, minimum votes per user (below it votes do not count), acceptance threshold per proposal, and an option to keep collecting votes after threshold. Version-dependent. Decidim's closing/quorum semantics for formal decisions were not found.
Low-turnout evidence (DAOs, large N, not small groups):
- Average DAO participation 6.3% (review, source study unnamed) and ~17% of voting tokens delegated, clustered on popular delegates, across 18 Ethereum DAOs (https://www.frontiersin.org/journals/blockchain/articles/10.3389/fbloc.2025.1598283/epub, https://arxiv.org/pdf/2510.05830) [S].
- Feichtinger, Fritsch, Vonlanthen, Wattenhofer, "The Hidden Shortcomings of (D)AOs", 21 DAOs: high voting-right concentration (https://arxiv.org/pdf/2302.12125v2.pdf) [S].
- Compound Proposal 289 (July 2024) passed 682,191 to 633,636 with <7% of COMP supply; an earlier proposal 279 failed with 88% against; reported as an alleged governance attack, resolved by a negotiated cancellation (https://thedefiant.io/news/defi/compound-governance-attack-reveals-inherent-vulnerabilities-of-daos, https://www.theblock.co/post/307943/24-million-compound-finance-proposal-passed-by-whale-over-dao-objections, https://coindesk.com/business/2024/07/30/comp-token-rises-as-whale-backs-down-on-supposed-governance-attack-on-compound) [S].
- No source located that specifically studies stalled/quorum-busting motions in groups of ~10-20. Not found, not proven absent.
## 2. Vote weighting; mixed human/AI voting
One-member-one-vote vs weighted:
- Beanstalk (Apr 2022): flash-loaned voting power passed BIP-18/19 and drained ~$76M actually stolen (headline $182M); causes cited: no flash-loan-resistant vote measurement, no execution delay (https://www.bleepingcomputer.com/news/security/beanstalk-defi-platform-loses-182-million-in-flash-loan-attack/, https://rekt.news/beanstalk-rekt, https://www.merklescience.com/blog/hack-track-analysis-of-beanstalk-flash-loan-attack) [S].
- OpenZeppelin Governor: voting power read from a past snapshot (ERC20Votes checkpoints), voting delay lets members exit before the snapshot matters; quorum as a fraction of supply at snapshot, only For+Abstain count (https://docs.openzeppelin.com/contracts/5.x/governance) [P].
Quadratic voting:
- Lalley and Weyl, "Quadratic Voting: How Mechanism Design Can Radicalize Democracy" (https://papers.ssrn.com; also https://bfi.uchicago.edu/wp-content/uploads/2016-14-1.pdf); Posner and Weyl 2017 survey (https://www.Gwern.net/doc/economics/mechanism-design/quadratic-voting/2017-posner.pdf) [S].
- Weyl argues collusion/fraud is deterred except at very large scale (https://www.microsoft.com/en-us/research/?p=893088) [S]; Rivest's note argues QV is inherently susceptible to collusion [S].
- Sybil/wallet splitting defeats the quadratic cost without proof of personhood (https://www.spark.money/glossary/quadratic-voting) [S].
- "Concave is the New Linear: The Impossibility of Anti-Plutocratic DAO Governance" (https://arxiv.org/pdf/2605.18990) [A, 2026]: title states an impossibility claim; body not read.
AI agents in governance (2025-2026):
- DAO-AI, arXiv 2510.21117 (https://arxiv.org/html/2510.21117v2) [A]: multi-agent analysis of proposals/forum/voting; reports interpretable, auditable signals; agentic AI as decision-maker study.
- QOC DAO, arXiv 2511.08641 (https://arxiv.org/html/2511.08641v1) [A]: roadmap human-only -> agents simulate stakeholders with humans holding final authority -> AI-driven DAO.
- "Who Controls the Collective AI Vote? Real-World Evidence and Adversarial Testing of Multi-Agent Delegation in DAO Governance" (https://neurips.cc/virtual/2026/165281) [A]: several visible agents may share one actor controlling instructions, evidence, software, signing or reversal; combines analytical model, public-record audit of three deployed systems, tests of three voting rules, open-model replay; reports separating untrusted proposal text from governing instructions cut targeted vote changes in one fixed model; controller separation reduced common-input attacks but not direct compromise of one agent; layered safeguards lowered all four channels.
- Blockful ENS AI Delegate experiment: agents on own servers vote from bias prompt + DAO constitution + proposal (https://blockful.notion.site/AI-Delegate-8296ff74623a4078b1ab7e7536adfabf) [S].
- Arbitrum RFC on "Agentic Governance Pivot": community asked for how agents work and a public vote-history dashboard (https://forum.arbitrum.foundation/t/rfc-proposal-to-adjust-the-voting-power-of-the-arbitrum-community-pool-ratifying-the-agentic-governance-pivot/29280/11) [S].
- Epistemic delegation paper mentions a possible block of AI "Condorcet agents" as imagination-stage (https://arxiv.org/pdf/2505.04136) [S].
- Trustless Autonomy, arXiv 2505.09757: interview study, governance dilemma of unreliable agents (https://www.arxiv.org/pdf/2505.09757) [S].
- No study found of small mixed human/AI-agent boards voting on operational settings of their own system.
## 3. Sybil and correlated voters
- Kohli, "Nine Judges, Two Effective Votes: Correlated Errors Undermine LLM Evaluation Panels", arXiv 2605.29800 (https://arxiv.org/abs/2605.29800; Apple ML research page https://machinelearning.apple.com/research/correlated-llm-evaluation-panels) [P abstract]: 9 frontier LLMs from 7 families on three NLI datasets carry ~2 independent votes (Kish n_eff); panel accuracy 8-22 points below independent-voting expectation; best single judge matches or beats panel; aggregation methods close at most 11% of the gap; holds across prompts, temperature, CoT, a pairwise-preference task. Abstract does not split same-family vs cross-family; narrow task domain; preprint, review status unverified.
- STRIVE, arXiv 2608.04567 (https://arxiv.org/pdf/2608.04567) [S]: failing-group jury 33% vs best member 47%; some items missed by every configuration.
- Theory: Condorcet jury theorem assumes independent voters; Austen-Smith and Banks (1996) showed independence is necessary for the aggregation benefit (cited in Kohli) [S]. Explainer: https://towardsdatascience.com/your-six-ai-models-are-really-two-the-math-of-correlated-mistakes/ [S].
- Controller concentration across visible agents: the NeurIPS "Who Controls..." abstract above [A].
- IETF names vote stuffing as a failure mode of voting-style consensus (RFC 7282, above).
- Compound 289: delegated voting power concentrated in one delegate authored successive proposals (sources in section 1) [S].
- Not found: any study of one operator running several personas on the same base model as voters specifically (closest: Kohli, NeurIPS abstract).
## 4. Delegated or tiered authority
Apache (voting.html above) [P]: PMC binding; non-PMC advisory; decision type determines threshold; code vetoes need technical justification.
Rust:
- RFC 1068 governance (https://github.com/rust-lang/rfcs/blob/master/text/1068-rust-governance.md) [P via summary]: subteams own RFCs in their area (shepherd, accept/reject, policy on what needs an RFC); core team sets direction, creates/shuts subteams; consensus is the norm; for minor disputes shepherd or leader decides; deep disagreement: leader decides after consulting core team; final comment period one week in that RFC text; new technical arguments raised in FCP trigger another comment period; each subteam sets its own rules for when an RFC is required.
- Compiler FCP page (https://forge.rust-lang.org/compiler/proposals-and-stabilization.html) [P]: FCP needs team sign-off, then a ten-day wait; outstanding concerns block completion (`@rustbot concern` / `resolve`), countdown restarts when concerns resolve; FCP can be skipped for easily reversed MCPs. The page did not describe the disposition (merge/close/postpone) or majority-of-team checkbox mechanism; those were not verified.
Debian (constitution above) [P]:
- Individual developers decide their own work (3); Leader delegates (5.1.1, 8); delegation irrevocable once a specific decision is made (5.1.1); Leader cannot override a Delegate's made decision (8.2); GR can override Leader/Delegate (4.1.3) and TC (2:1, 4.1.4); TC decides last-resort, overrule Developer by 3:1 (6.1.4, 6.3.5); Secretary runs votes and interprets (7.1).
Kubernetes KEP (https://www.k8s.dev/resources/keps/5241/, https://kubernetes.io/blog/2020/08/21/moving-forward-from-beta/) [S]: owning-SIG approvers move a KEP provisional -> implementable; significant later changes need every approver; graduation criteria are written into the KEP; stages alpha (off by default), beta (usually on, can be disabled), GA (always on).
Loomio: block option as per-option veto (above).
## 5. Binding a decision to a parameter change
- Compound Governor (https://docs.compound.finance/v2/governance/, https://wiki.tally.xyz/docs/compound-governor) [S]: passed proposal queued in a Timelock, executable after 2 days; voting delay and period are themselves parameters changeable through governance; a Pause Guardian can act without a vote; Olympus's Bravo deployment adds a veto guardian who may veto any time before execution (https://docs.olympusdao.finance/main/contracts/docs/src/external/governance/GovernorBravoDelegate.sol/contract.GovernorBravoDelegate) [S].
- OpenZeppelin Governor + TimelockController [P]: delay "allows users to exit the system if they disagree with a decision before it is executed"; roles proposer (the Governor only), executor (may be anyone), canceller, admin (to be renounced after setup); extra proposers/cancellers flagged as a risk of bypassing or blocking governance; optional proposal threshold limits who can propose. Guardian extension lets a guardian cancel at any stage (https://docs.openzeppelin.com/contracts/api/governance) [S].
- Beanstalk had no execution delay (section 2 sources) [S].
- MakerDAO Governance Security Module delay and executive-vs-poll split: fetch redirected to https://developers.skyeco.com/ and was not read; not verified here.
- Compound 290 plan: team multisig deploying a new Governor to strip a delegate's power (reported intent, later cancelled) (sources in section 1) [S].
- Kubernetes: parameters change via feature gates whose default flips at KEP graduation stages, decided by SIG approvers, not vote (section 4) [S].
- Feature-flag change-control (LaunchDarkly approvals, etc.): not searched.
## Not covered or unverified
- Maker GSM, ASF "lazy majority"/2:3 definitions, Rust FCP disposition and majority-checkbox rule, Decidim formal-decision close semantics, feature-flag approval workflows, full text of every [A] paper, same-family LLM correlation numbers, anything on stalled-motion rates in small boards.

View file

@ -0,0 +1 @@
__pycache__/

View file

@ -0,0 +1,82 @@
# Estate-rule sim: report (2026-11-05)
Question: does the unified estate rule (estate votes decide the estate's vote) dampen populism? Spec: `SPEC.md` (written first, four revisions, each before the results it affects). Numbers: `results.md` (235 cells, 40 replicates x 500 motions each, paired draws). Rerun: `python3 -I run.py` (about 11 minutes on 8 jobs; `--quick` for a smoke; `python3 -I test_estatesim.py` for the 54 tests). Output is byte-identical across reruns and across `--jobs` values.
## Answer
**Partly, weakly, and at a merit cost. The claim as stated ("dampens populism") is not established, and it fails in the cells that matter most.**
1. UNI lowers populism sensitivity (PS) against a direct tally in every sincere and strategic cell: by 0.02 to 0.07 on a DIRECT baseline of 0.23 to 0.35 (E1 equal weights: 0.297 to 0.275). Small, but outside the intervals.
2. It lowers merit tracking (MT) by more: 0.05 to 0.10 (E1: 0.465 to 0.400; with strategic agents and a 20-person Users faction: 0.381 to 0.304). Dampening populism by losing the signal is not what the claim means. PI = PS/MT, appeal sensitivity per unit of merit, is **worse** under UNI at equal weights (E1: 0.689 vs 0.639; E2 faction 20: 0.900 vs 0.849; E5 Users appeal .4: 0.840 vs 0.691) and flat under minds-heavy weights. The pre-registered verdict (SPEC section 8) therefore reads "supported" in 0 of 235 cells in the final run, "fails" in 2 (Users appeal loading b = .4, with and without delegation: MT drops more than .10), "undecided" in 233. Undecided here mostly means PS fell and MT fell too.
3. UNI passes more motions (0.65 vs 0.60 at equal weights) and passes more *bad popular* motions: BPP (pass rate when merit < -.5 and appeal > .5) is 0.62 vs 0.525 for DIRECT. It blocks fewer good unpopular ones (GBR 0.19 vs 0.23). The cause is the low-threshold categories: at q = .15 an estate casts all its weight For on 15% support, and PS at q = .15 collapses to 0.06 only because nearly everything passes. Per-category tables are in results.md. At q >= .4 UNI is within about .01 of DIRECT. The dampening is concentrated where the threshold was already doing the work.
4. **Winner-take-all does the opposite of dampening:** PS is above DIRECT by 0.03 to 0.06 in most cells (E1 equal: 0.323 vs 0.297; strategic with faction: 0.360 vs 0.323). Collapsing an estate to one bit at 0.5 amplifies a populist estate's swing; only the UNI threshold change (q instead of .5 inside the estate) makes the difference between the two bloc rules. If "unified estate rule" is read as WTA, the claim is **false** in this model.
5. A headcount tally (HEAD, reference only) is far worse (PS 0.43 to 0.52, MT collapses to 0.05 once the Users faction mobilizes). Estate structure of any kind does dampen against a mass-mobilized Users roll; the unified rule adds a little on top.
## Strategic agents
With 25% strategic voters (misreporting, abstaining, pivot-seeking turnout) strategy flips about 2.8% of outcomes under DIRECT, WTA and UNI alike and 1% under HEAD, and does not change the ranking of rules. A coordinated Users faction raises PS under DIRECT and WTA (0.291 at no faction to 0.328 at 40) while UNI stays flat at 0.27, so **the UNI-DIRECT gap widens with organized mobilization** (-0.024 to -0.054). This is the strongest point for the claim: UNI's resistance is to turnout-limited organized blocs, not to diffuse appeal. The MT gap also shrinks with faction size (-0.072 to -0.052), because the faction drags DIRECT's merit tracking down toward UNI's. Sweep E2.
## Delegation (E3, E3b, E3c, E3d, E3e, E4, E5b)
- **Delegation barely moves the claim.** dPS UNI-DIRECT is -0.027 with delegation vs -0.050 without in the matched strategic/faction cell; PS under each rule changes by under .01 (DIRECT .313 vs .323). Delegation makes the claim slightly weaker (the gap shrinks), not stronger.
- **Concentration is real and the cap controls it, but outcomes do not care.** Zipf exponent alpha from 0 to 4 moves the Users top-holder share from 0.045 to 0.19 (no cap) and n_eff from 61 to 26; a cap of .10 pulls that to 0.107, n_eff 44. A cap of .2 does not bind at alpha 1.5 (top share 0.103 with or without it) and binds only near alpha 4 (0.173 vs 0.190). The outcome measures (PS, MT) move by under .005 across the whole alpha x cap grid, because the Users estate's single vote is decided by its resolved majority and delegates are mostly drawn from the same population. Appeal attraction beta (E4) has the same null result. Cap basis (cast weight vs roll) matters only at small c (E3d): a roll-based cap never binds at 10% turnout.
- **Transitive depth 2, 3, 4, 6 and unlimited are indistinguishable** (E3c; PS and MT differ by under .005 across depths, including with 80% peer edges and delegates turning out at 30%). The votes that die do so on scope overlap, dead ends and absent peers (uncast share 0.50 to 0.85), not on length: a long chain is a chain whose scope intersection was already empty. Depth is a safe parameter to set at the placeholder 3 in this model; it does not need to be tuned. Loops are returned uncast and appear as part of the uncast share.
- **A delegation network under the posted revocation rule decays.** With two strikes and no renewal, 13% of delegations are alive after 500 motions (E3e), so the rule as posted cannot sustain liquid delegation unless revoked delegators re-delegate. The steady state depends on the renewal rate: 53% live at renew .02, 81% at .1. This is a design finding independent of the populism claim and was the cause of the first run's inert delegation cells.
- Delegation mostly leaves votes uncast (0.5 uncast with peer edges at the default). Whether delegators who lose a chain should be told is a product question the sim cannot answer.
## Copy-the-ballot (E3f, SPEC section 5b)
Source: Gölz et al. 2018 (introduction) argue, citing Behrens et al. 2014 (LiquidFeedback), that capping weight fails because a delegator can copy the delegate's ballot outside the system. It is an argument, not a measurement; the copy rate is an assumption and is swept (0, .5, 1). Agents: a delegator whose weight sits under an over-cap holder copies that holder's ballot with probability `copy` and votes directly with weight 1.
**Answer: once copying is available the cap does not reduce effective concentration.** Effective concentration counts a copier's weight to the voter it copied. Cap .10 against no cap, paired by replicate, 40 replicates (E3f top table in results.md):
- Without copying the cap works where concentration exists: effective Users top-holder share falls by 0.015 (alpha 1.5), 0.055 (2.5), 0.083 (4.0); below alpha 1.5 the cap does not bind.
- With full copying (copy = 1) the difference is +0.001 or less in every alpha, intervals about ±0.002: indistinguishable from no cap. At copy = .5 the difference is -0.008 at alpha 4 (under the one-point materiality bar), and under that at lower alpha. Verdict "reduces": 3 of 6 alphas at copy 0, 0 of 6 at .5, 0 of 6 at 1.
- The formal measure is misleading in the other direction: at copy = 1, alpha 4, cap .10 the formal top share drops from 0.190 to 0.034 (copiers appear as separate ballots), more than the cap achieves without copying (0.107). A cap would look best exactly when it is doing nothing to whose ballot the weight follows. It is also what the source's transparency point predicts: the system sees direct ballots and cannot see the copying.
- The cap's cost is paid anyway where copying is partial: copiers are 29% of delegated votes at alpha 4, cap .20, copy 1, and 57% at cap .10.
- Outcomes: dPS UNI-DIRECT stays -0.023 to -0.031 in all 108 cells, within intervals of the copy = 0 values. Copying changes nothing the outcome measures can see, as the cap alone did not (E3).
- Depth sweep (E3c) and every other experiment ran with copy = 0 and their results.md sections are byte-identical to the previous run.
Limits specific to this result: copying is triggered only when the holder is over the cap (a standing copy habit is not modeled); copiers copy the holder's sincere ballot and are not strategic; the cost of coordination grows with copiers in practice and is a flat probability here; the copy rate has no empirical value. At copy rates well below .5 the cap partly works; where the break-even lies was not mapped. The design question this leaves is not tuning c: a cap bounds the *ledger*, and a ledger-visible cap is evaded by a ballot copy that costs the delegator nothing but a few keystrokes. Whether the system wants delegation visible and capped, or invisible and uncapped, is a product choice for the operator; nothing is ruled here.
## Purchased stake (E6, SPEC section 5c)
Static weight analysis, not a motion simulation: it measures weight share, not PS or MT. Weight w = 1 + log_b(amount/unit), 0 at amount <= unit/b, per-estate b and unit (defaults b = 10, unit = 10 EUR, my choices, in SPEC 5c). The harness had no contribution mechanism, so contribution hours are a new per-agent input with their own per-estate conversion (defaults b = 10, unit = 10 hours); they add to the euro weight and matter to no buyer result below.
**The working hypothesis, that logarithmic weight bounds buyout power, is only half true here, and the true half is not the log's doing.**
- **Within the estate the log does not bound a million-euro buyer.** Against the harness's 4-patron roll (Pareto(1.16) donations from 10 EUR, 40 seeds) the buyer holds 0.524 +/- 0.008 of Patrons weight and alone decides the Patrons estate (share above one half) in 85% of seeds. The log shrinks the buyer from 100% (linear weight) to about half; it does not take it below a majority of a 4-person estate. This is the harness's own cell (roll 4, unit 10 EUR, b = 10), not a typical one. Swept over unit {1, 10, 100} EUR and roll {4, 40, 400} (40 seeds each, table in results.md) the buyer's mean share of Patrons weight runs from 0.007 to 0.777, and the buyer alone decides the estate in 0 to 100% of seeds depending on the cell (0.85 at the headline cell, 1.00 at roll 4 with unit 100, 0.00 in the other seven). The roll size, Pareto scale and unit are placeholders; the interval, not the point, is the finding. The weight is per identity, so all of this assumes one identity per buyer (next bullet).
- **At council level the bound comes from the estate weights.** With all four estates live the buyer's council share is 0.131 (equal weights) or 0.052 (minds-heavy), below the Patrons weight W_Patrons by construction; the half-influence cap never binds in 6,480 cell-seeds. Linear weight would give 0.25 and 0.10: the log halves the buyer's share, the estate structure sets its ceiling.
- **The half-influence cap binds only when other estates are silent.** Fraction of (cell, seed) in which council share x >= .5 over 162 cells (b x unit x roll x amount x weights) x 40 seeds: all four live 0.000; Patrons + Minds live 0.008 (all of it the degenerate case where no other patron clears the unit, so there is nothing to cut against: unenforceable); Patrons alone live 0.165 (0.099 of cells bind in every seed, 0.017 of cell-seeds unenforceable). It binds more at small rolls (Patrons alone: 0.669 at roll 4 and b = 2 down to 0.000 at roll 400) and with a smaller log base. The Patrons-alone scenario is the renormalization case of prior section 10 (drop non-voting estates); how likely it is in practice is not a sim output.
- **Splitting wins, by orders of magnitude.** The same 1,000,000 EUR over k identities multiplies weight by 1.9 (k = 2), 8.3 (k = 10), 66.7 (k = 100), 500 (k = 1000), and 16,667 at k = amount/unit = 100,000 (each fake identity buying exactly the unit). The multiplier is (amount/unit) / (1 + log_b(amount/unit)); for b = 10, unit = 10 it is 250 at 10,000 EUR, 2,000 at 100,000 EUR. With k = 10 fake identities the buyer's Patrons-alone share is 0.901, with k = 2 it is 0.676. This refutes the log as a purchase bound unless identities are scarce.
- **The half-influence cap is per identity and splitting evades it.** In the Patrons-alone case the single identity trips the cap in 85% of seeds; at k = 2 no identity does (each holds 0.34) while the joint share is 0.676, in every seed. A cap on a person's influence needs an identity oracle the harness does not have.
- **SPEC names no sybil defence** (the charter's frozen roll, A6, bounds joins after notice, not pre-notice buying of identities), so none is implemented as policy. Three candidates are quantified in results.md and none is chosen: a per-person weight cap does not help (it caps the honest single purchase and the fakes are each below it; 100,000x at cap 1); a raised unit floor reduces the multiplier (250 at 1,000 EUR, 5 at 100,000 EUR, 1.0 only at 1,000,000 EUR, where no purchase under the buyer's amount buys any weight), so it neutralises splitting only by pricing out everyone smaller; a per-identity cost neutralises only at about half the budget (bisection values 49.3%, 49.8%, 49.95% of 10,000, 100,000, 1,000,000 EUR; the threshold is c = amount/2, where a second identity is left with nothing to buy), so it would also tax the honest buyer's single identity at that rate. The choice between pricing identities, verifying people or bounding total weight per verified person is a design fork left for the operator. Nothing here rules it.
Limits specific to E6: the roll, the Pareto scale, b and unit are assumptions; the buyer joins as an additional patron and buys once (no repeated purchase or liquidation); decisiveness is estate-level only, not council-level (prior section 10 shows weight share is not decisiveness); the best split is a grid maximum, a lower bound; hours play no part in a purchase scenario because estate weights are fixed and the buyer sits in Patrons, so an hours buyer (a Member who is paid or who farms hours) is untested and the conversion of hours into weight is just a per-estate parameter whose gaming is not modeled.
## Robustness (E5, E5b)
The sign of the PS effect holds across Users appeal 0.4/0.9/1.4, Users turnout .05/.10/.30 and Minds shared taste .3/.9/1.5. Its size scales with Users appeal loading (-0.025 at .4, -0.067 at 1.4): the rule helps in proportion to how appeal-driven the Users estate is, while its merit loss does not go away (MT gap -0.101 at .4 vs -0.063 at 1.4). If Users are not appeal-driven the rule is nearly all cost.
## Limits and honesty
- Every behavioral parameter is an assumption (SPEC section 4), not data. The populism measure rests on an independent merit/appeal draw that the real system cannot observe; PS is an error rate against a modeled channel, not a measurement of any real vote.
- Myopic, single-motion strategy; no repeated game, vote trading, or reputation. Delegators are not strategic. Prior work (../2026-10-29-estate-tally-game-theory/) covers some of those on unitary blocs.
- No Patron/Member inequality inside estates; no real Bifrost data.
- The verdict rule counts MT loss over .05 as "not supported" and over .10 as "fails". These are my pre-registered thresholds; a different pair would change the verdict column but not the PS/MT numbers beside it.
- Sources: SPEC section 10 ([V] fetched, [M] from memory). The delegate-cap "a fifth" has no strong precedent found; DAO caps are rare and the Jupiter 5% proposal is the nearest.
## Deviations from the spec text
- E6: the brief said the half-influence cap and an 'E3f cap sweep' were already in the harness. The harness has only the delegation cap of SPEC section 5 (a fraction of cast weight per final holder); the operator cap of prior section 10 (council weight share strictly below one half) was not implemented, so `influence_cap` implements it, once, in `estatesim.py`; the E3f sweep is of the delegation cap and is untouched. E6 is a static weight analysis, not integrated into the motion simulation (no PS or MT for buyers).
- Spec rev 3 added renewal (`renew`) and peer edges (`peer`) after the first full run showed (a) revocation had silently emptied every delegation cell at 500 motions and (b) with only delegates delegating onward, depth was inert. The first run's results were discarded. Renewal and peer defaults are my choices, swept in E3c and E3e.
- Cap basis default is the estate's cast weight, not roll; both are swept.
- E3f: the falsifier was first printed as means only; the paired interval was added to `run.py` before the final run (no number changed). Its materiality bar (-.01) is mine.
- Per-category PS tables are printed only for the non-grid experiments (E1, E2, E4, E5).
## Limits of E6 added after independent review
- Weight in `log_weight`, `stake_weight` and `influence_cap` is per identity. Nothing in the module is sybil-safe; the half-influence cap bounds a ledger entry, not a person.
- The hours channel is unanalysed: no scenario gives a buyer hours, so whether hours can be farmed, bought or split is untested.
- Placeholder parameters (marked PLACEHOLDER in `estatesim.py`): b, unit (euros and hours), the Patron roll sizes, the Pareto scale. Ruled: the half cap. From prior section 4: the Pareto exponent.
- The headline figures (0.524, 0.85, split 1.9x/8.3x/66.7x) are pinned in `test_headline_numbers_are_pinned`.

View file

@ -0,0 +1,161 @@
# Estate-rule sim harness: spec (2026-11-05)
Serves follow-up `heimdall-body:follow_up:chat-mind-20261105T002133411Z-t0`. Prior work: `../2026-10-29-estate-tally-game-theory/` (README.md plus numpy probes in `sims/`). Facts there are cited as "prior §n". This document is written before the code; `estatesim.py` implements it, and where the code had to deviate the deviation is listed at the bottom of `REPORT.md`, not edited in here. Revision 2 (delegation rules) was made before any result was generated. Revision 4 (copy-the-ballot, section 5b) was added after the first reported results and before its own. Revision 5 (purchased stake, section 5c) was written before its results and touches no earlier section. Revision 3 (renewal, peer edges) was forced by the first full run, whose delegation cells were inert; the first run's results were discarded, not kept.
## 1. Claim under test
> The unified estate rule (an estate's own vote decides the estate's vote) dampens populism relative to a direct tally.
The claim is stated by the operator and has not been tested. The sim is allowed to refute it. Falsifiers are fixed in advance (section 8).
## 2. Rules compared
All rules aggregate the same ballots. After the estate's ballots are resolved (section 5) each live estate e has resolved weighted counts For F_e and Against A_e (abstain excluded). Live = F_e + A_e > 0. Estate weights W_e renormalize over live estates (prior, pass rule and renormalization ruling). Motion threshold q comes from the motion category (prior §5b: Bugs .15, Cosmetics .30, Balance .40, Features/NewContent .50, FundamentalDesign .66). The motion passes iff council For weight / (For + Against weight) >= q; ties at q pass (prior).
| id | name | estate e casts |
|---|---|---|
| DIRECT | direct tally | W_e × F_e/(F_e+A_e) For, remainder Against (weighted popular share; prior "prop") |
| HEAD | headcount tally | reference only: pooled ΣF/Σ(F+A), estate weights ignored |
| WTA | winner-take-all | all of W_e For if F_e/(F_e+A_e) > .5, all Against if < .5, nothing on an exact tie (prior WTA) |
| UNI | unified estate rule | all of W_e For iff F_e/(F_e+A_e) >= q, else all Against (prior §9 `bloc-parliament-at-threshold`) |
Ambiguity recorded: "unified estate rule" and "winner-take-all" both describe an estate casting a single vote. They differ only in the internal threshold (.5 vs the category threshold q), and prior §9 shows that is the only difference. UNI is the reading in which the estate's decision applies the same threshold as the council. Every result is reported for both, so the claim can be read either way.
## 3. Populism measure
Definition used (after the ideational definition, Mudde 2004: politics as direct expression of the people's will against the judgment of those who know, applied here to outcomes, not rhetoric): **populism is the degree to which the outcome follows mass appeal that is independent of merit.**
Each motion draws merit m ~ N(0,1) and demagogic appeal d ~ N(0,1), independent. Merit is what informed voters track; appeal is the part of a motion's attractiveness that carries no information (framing, grievance, flattery). Because d is independent of m, outcome dependence on d is by construction an error.
- **PS, populism sensitivity (headline):** P(pass | d > 0) − P(pass | d < 0).
- **MT, merit tracking (the cost side):** P(pass | m > 0) − P(pass | m < 0). A rule can reduce PS by ignoring everything; MT shows if it did.
- **PI = PS / MT**, appeal sensitivity per unit of merit sensitivity. Reported with MT; ignored when MT < .05.
- **BPP, bad-popular pass rate:** P(pass | m < −.5, d > .5). **GBR, good-unpopular block rate:** P(fail | m > .5, d < −.5).
- Every number is reported pooled over categories and per q, because q moves base rates and compresses PS at the ends (q = .15 passes almost everything).
Measure limits: d and m are drawn, not observed; a real populism measure would need an independent merit label (Bifrost has none). The measure says what the rule does to a modeled appeal channel, no more.
Concentration measures (delegation runs): top-delegate share of the Users estate's resolved weight, Gini of resolved weights over ballot holders, and Kish n_eff = (Σw)²/Σw² per estate. Prior §4 uses Kish n_eff for the same purpose.
## 4. Population and ballots
Sizes follow prior: Minds 8, Members 15, Users roll 300, Patrons 4. Estate weights: `equal` 25/25/25/25 and `minds-heavy` 40/30/20/10 (prior seeds). Member one-each within estate; within-bloc donation or contribution curves (prior §4) are out of scope, so Patron and Member power inequality is not tested.
Voter i of estate e on one motion: u_i = a_e·m + b_e·d + s_e·z_e + σ_e·ε_i, with z_e ~ N(0,1) the estate's shared taste, ε_i ~ N(0,1).
| estate | a (merit) | b (appeal) | s (shared) | σ (noise) | base turnout |
|---|---|---|---|---|---|
| Minds | 1.0 | .15 | .9 | .7 | 1.0 |
| Members | .8 | .4 | .5 | 1.0 | .8 |
| Users | .4 | .9 | .5 | 1.2 | .10 |
| Patrons | .7 | .5 | .5 | 1.0 | .8 |
These are assumptions, not data. They encode: Minds are accurate and correlated (ICC of the noise part ≈ .62, between the prior's .44 and .8; prior §3 Kish n_eff calibration from Kohli 2026); Users are mass-appeal-driven, noisy, and mostly absent. The result depends on them, so section 7 sweeps the Users appeal loading b.
Sincere ballot: For if u > .1, Against if u < −.1, otherwise abstain.
**Turnout response (intensity):** voter turns out with p_i = clamp(base_e × (1 + κ(|u_i| − ū_e)), 0, 1), κ = 1, ū_e = .8·sqrt(a²+b²+s²+σ²) the mean |u|. Aggrieved or enthused voters show up. This is the standard mobilization asymmetry behind low-turnout populist surges and is the channel through which d reaches the Users estate even at 10% turnout.
## 5. Delegation (operator's posted rules, revision 2)
Revision 2 (same day, brief `heimdall-body:follow_up:chat-s1938-1941`) replaces depth-2 with transitive chains and sweeps the depth limit. Rules as posted, each with its realization:
1. **Within-estate:** an edge joins two members of the same estate. Minds do not delegate (eight accountable Personas delegating to each other would only reduce n_eff). Delegator shares of the roll: Members .2, Users .5, Patrons .2.
2. **Scoped:** each edge covers a set of motion categories, each of the five included with probability .6 (never empty).
3. **Revocable:** a delegator whose final holder's ballot opposes the delegator's strong sincere preference (|u| > .5) takes a strike; at `STRIKES` = 2 the edge is revoked. A revoked delegator re-delegates with probability `renew` per motion (default .1; swept {0, .02, .1, .5}) to a freshly drawn target with a fresh scope and zero strikes. Without renewal (renew = 0) revocation is final and the network decays to nothing over a long horizon (found in the first full run at 500 motions: revocation alone had emptied the network, so every delegation cell had silently become a no-delegation cell). The `live edges` column reports the share of delegators still holding an edge. The strike count is the only delegation memory.
4. **Direct overrides:** a member who votes directly casts weight 1 and is never forwarded. A delegator with a strike-worthy contradiction turns out regardless of the turnout draw (the delegation-driven turnout response); the motion is then re-resolved, so chains that ran through that member now end at it.
5. **Transitive chains, depth D:** weight follows edges from the delegator until it reaches a member who votes directly (the final holder). An intermediate member who votes directly ends the chain there. D is the maximum number of edges; swept over {2, 3 (placeholder, default), 4, 6, unlimited}. **Scope is the overlap of all links:** the chain is void when any link's scope excludes the motion's category. **A loop, a chain longer than D, a chain that ends at a member who neither votes nor delegates, or a chain with an out-of-scope link returns the vote uncast** (the delegator counts as an abstainer, not redirected). The `uncast` column in results is the share of delegated votes left uncast.
6. **Per-final-holder cap c:** a final holder's total resolved weight (own vote plus everything that reached it) is capped at max(1, c × basis). Basis is "cast" (the estate's total resolved weight before capping, default) or "roll" (estate roll size, an alternative swept in E3d). A fifth is read as a fifth of what the estate actually casts, because Users turnout is about 10% and a roll-based fifth (60 of 300) would never bind; E3d shows both. c ∈ {.10, .15, .20, .25, .30} and none. Excess is void, not redistributed.
7. **Estate rule after resolution:** F_e and A_e are computed from capped resolved weights; section 2 then applies unchanged.
Delegate pool: each delegating estate has 10% of its roll (at least one) as active delegates with turnout `dturn` (default .95; swept {.95, .6, .3}, because when delegates always vote chains never run past one edge and depth is inert), 25% of them populist (a = .2, b = 1.2, σ = .7, otherwise a = .7, b = .5, σ = .7). A delegate delegates onward to another delegate with probability `onward` (.3). A delegator's edge goes to another ordinary delegator with probability `peer` (default .3; swept {.3, .8}) and otherwise to a delegate; peer edges are what make chains longer than two (the first full run, where only delegates delegated onward, made depth 2 to unlimited indistinguishable). Loops arise naturally. Delegator and onward targets are Zipf over a fixed delegate ranking with exponent α, multiplied by (1+β) for populist delegates. α ∈ {0, .5, 1, 1.5, 2.5, 4}, β ∈ {0, 1, 3}. α=0 is uniform choice; larger α is guru formation (Kling et al. 2015 observed super-voters in LiquidFeedback; Gölz et al. 2018 show maximum delegated weight grows with concentration).
Not modeled: vote selling, delegation to non-members, delegators choosing delegates by track record.
## 5b. Copy-the-ballot (revision 4, written before its results)
Serves `heimdall-body:follow_up:chat-s1946-1946`.
**Source.** Gölz, Kahng, Mackenzie, Procaccia 2018 (arXiv 1808.01906, introduction, "Just how can the maximum weight be reduced?"): capping delegated weight is an option, "however, as argued by Behrens et al. [3], delegation is always possible by coordinating outside of the system and copying the desired delegate's ballot. Pushing delegations outside of the system would not alleviate the problem of super-voters, just reduce transparency." [3] is Behrens, Kistner, Nitsche, Swierczek, *The Principles of LiquidFeedback* (Interaktive Demokratie, 2014), not fetched here. What the source is: a one-paragraph argument, quoted second-hand by Gölz et al. to motivate their own voluntary approach. It gives no model, rate or measurement of copying, so the copy rate below is an assumption and is swept, not calibrated. Verified by fetching the ar5iv text of the paper this pass.
**Agents.** A copier is a delegator in a delegating estate (Members, Users, Patrons) with the following behavior, per motion, after the final resolution of section 5 (strikes, direct overrides and the cap already applied):
- Trigger: the delegator's final holder h has raw resolved weight above the cap value (the weight the cap would void). Only delegators whose weight is routed to an over-cap holder consider copying. There is no copying under a holder within the cap, and none when there is no cap. The agent does not know whether its own weight is among the voided excess (the cap voids "excess", not named delegators), so every delegator under an over-cap holder is exposed.
- Action: with probability `copy` (friction: the effort of reading and re-entering the ballot; swept {0, .5, 1}) the delegator becomes a direct voter casting weight 1 and holder h's ballot (h's sincere ballot, as it stands before any strategic misreport). Direct voters are never forwarded (section 5 rule 4), so the copied weight leaves h's chain and the cap no longer applies to it. Delegators of the copier now end at the copier, who is itself subject to the cap as a final holder.
- Copiers are not strategic and do not take strikes for the copy (it is voluntary and aligned with h by construction).
- One round per motion. The decision is not remembered: a copier delegates again next motion, and copies again if the holder is still over cap. Delegators do not copy as a standing policy and do not copy unconditionally; copy-everything-always would remove delegation altogether and is out of scope.
- Copy requires that h votes directly this motion, which holds by construction (a final holder is a direct voter).
- Not modeled: the copy being visible to the mechanism (the real system sees only a direct ballot, which is the point of the source's transparency objection), coordination cost that grows with the number of copiers, and delegators choosing to copy a different, trusted voter than their delegate.
**Measures.** Formal concentration is the section 3 measure on the resolved weights (copiers appear as separate weight-1 holders, which is what the mechanism sees). Effective concentration counts each copier's weight to the voter it copied, so it measures whose ballot the weight follows; the cap's job is to limit that. Also reported: copiers as a share of delegated votes. Outcomes (PS, MT, dPS UNI-DIRECT) are reported alongside.
**Experiment E3f.** The delegation cap sweep with copying: alpha {0, .5, 1, 1.5, 2.5, 4} x cap {.10, .15, .20, .25, .30, none} x copy {0, .5, 1}, depth 3, strategic, F = 20, as E3. `copy = 0` reproduces E3 exactly. The depth sweep E3c is unchanged and runs with `copy = 0`; so does every other experiment. Falsifier fixed in advance: the cap "reduces effective concentration once copying is available" if the paired-by-replicate difference (effective Users top-holder share at cap .10 minus no cap) has a 95% interval entirely below -.01 (a drop under one point of share is not called a reduction). Evaluated per alpha and per copy rate by `run.py` and printed at the top of E3f in `results.md`; the cap reduces effective concentration under copying only if this reads "reduces" at `copy = 1`. The first full E3f run printed only means; the interval was added before the final run, and the numbers did not change.
## 5c. Purchased stake (revision 5, written before its results)
Serves the purchased-stake brief (operator relayed: Patron vote power is logarithmic in donation, prior section 4; operator influence strictly below half, ruling `operator-influence-below-half`, prior section 10). Not a motion simulation: a static weight analysis, so it measures weight share and decisiveness, not PS or MT.
**Weight.** A purchase of `amount` EUR weighs w = 1 + log_b(amount/unit): 1 at amount = unit, continuous below it, 0 at amount <= unit/b (never negative). Each estate has its own conversion, set through `Config.overrides` entries `(estate, key, value)` with keys `euro_b`, `euro_unit`, `hours_b`, `hours_unit` (defaults in `STAKE_BASE`). Only Patrons (euros) and Members (hours) carry stake; Minds are counted once and Users are one-each, so both weigh 1.
**Defaults chosen (assumptions, not rulings; the brief gave none).** Patrons and Members alike: euro b = 10, unit = 10 EUR; hours b = 10, unit = 10 hours. b = 10 reads as "each tenfold purchase adds one vote of weight", the simplest reading of the operator's "logarithmic"; 10 EUR is the smallest ordinary Patron donation in the prior ladder (prior section 4: 10, 50, 200, 5000); 10 hours is roughly a working day or so of contribution. The sweep varies b over {2, 10, 100} and unit over {1, 10, 100} EUR; hours are not swept because no buyer outcome depends on them (see below).
**Contribution hours.** The harness had no contribution mechanism (Members were one-each; prior section 4's Pareto contribution curve was left out of scope in section 4 above). Hours are therefore a new per-agent input with their own per-estate conversion: an estate member's weight is the euro channel plus the hours channel, each through its own (b, unit).
**Electorate.** The buyer joins the Patrons estate as an additional member; the other patrons are `ROLL["Patrons"]` = 4 members (also 40 and 400 as a sensitivity) holding Pareto(1.16) donations from 10 EUR (prior section 4's exponent; the scale is mine). 40 seeds, each a fixed integer. Estate weights are the section 4 weights; the live-estate set is a scenario: all four live, Patrons + Minds live, Patrons alone live (renormalization over live estates, section 2).
**Influence.** Council weight share x = W_Patrons x s / (sum of live W), s the buyer's share of Patrons weight; this is the "weight share" of prior section 10. Decisiveness is reported only at the estate level (buyer alone decides the Patrons estate's vote when s > .5 at q = .5); prior section 10 shows weight share is not decisiveness, and no council-level pivot is computed here.
**Half-influence cap.** The cap named in the brief is the prior section 10 operator cap (weight share strictly below one half), which is *not* the `cap` of section 5 (a per-final-holder delegation cap, a fraction of cast weight, with void excess). The harness had no implementation of the operator cap; `influence_cap` is it: binds iff x >= .5, then cuts the holder's internal weight until x = .5(1 - 1e-9), and reports "unenforceable" when no one else holds weight to cut against. It applies per identity.
**Splitting adversary.** The same total split evenly over k identities: weight k(1 + log_b(amount/(k unit))) against 1 + log_b(amount/unit). Splitting wins because the log is sub-linear: the multiplier approaches (amount/unit)/(1 + log_b(amount/unit)) at k = amount/unit. The sweep reports the multiplier by k, the best k on a geometric grid (a lower bound on the maximum), and a table of defences. **SPEC names no sybil defence** (the charter's frozen roll, A6, stops joins after notice, not the buying of many identities beforehand). Three candidates are quantified, none adopted or chosen: a per-person weight cap, a raised unit floor, a per-identity cost paid out of the budget. They are parameters of `split_weight`, off by default.
**Falsifiers fixed in advance.** (1) Working hypothesis "logarithmic weight bounds buyout power": refuted if a one-million-euro buyer's within-estate share s exceeds 1/2 at the harness's own roll, or if splitting multiplies weight by more than 2 at any k. (2) The half-influence cap is "binding" in a (cell, seed) iff x >= .5; the rate over the sweep is reported per live-estate scenario. (3) The cap is "evaded" by splitting iff the joint share is >= .5 while no single identity's share is.
## 6. Strategic and adaptive agents
A fraction ρ = .25 of ballot holders and of absent undelegated roll members are strategic. Each is myopic about the motion at hand and best-responds to everyone else's current ballots over three rounds in random order:
- **Misreporting:** a strategic ballot holder picks among For/Against/Abstain (its whole resolved weight, so a capped delegate misreports the weight it carries) the ballot whose resulting outcome it prefers (pass iff its u > 0); it keeps the sincere ballot on ties of outcome.
- **Turnout response:** a strategic absent member joins with the sincere ballot iff joining flips the outcome to the one it prefers (the pivot-seeking participant of Palfrey and Rosenthal 1983). Strategic ballot holders may also abstain (Abstain option above).
- **Coalition shifts:** a coordinated faction of F Users (default F = 20, 6.7% of the roll, sweep {0, 10, 20, 40}) turns out with certainty and votes For iff d − m > 0 (the populist agenda), Against otherwise. It does not best-respond; it models the organized actor that turnout-limited estates attract. Faction members are not delegators.
Strategy is best response per rule: each rule is run separately from the same sincere draws, so a rule that rewards misreporting is not credited with sincere behavior.
Limits: delegators are not strategic (their ballot is their delegate's); single-motion myopia; no repeated-game or reputation effects; no cross-estate vote trading (prior §6 S2 covers logrolling on unitary blocs).
## 7. Experiments
Paired across rules: one set of random draws per motion feeds all rules (common random numbers). Replicates R = 40 (a fresh delegation network per replicate), M = 500 motions each, categories uniform. Seeds are fixed integers; no wall-clock or hash use. Confidence intervals are 1.96 standard errors over replicates; paired differences are taken within replicate.
- **E1 baseline:** sincere, no delegation, no faction. Rules × weight seeds × q.
- **E2 strategic:** E1 with ρ = .25 strategic agents and faction sweep.
- **E3 delegation:** concentration α × cap c (36 cells) at depth 3, strategic on, F = 20; **E3b** the same sincere with no faction (isolates delegation).
- **E3c transitive depth:** depth {2, 3, 4, 6, unlimited} × delegate turnout {.95, .3} × peer share {.3, .8}, α = 1.5, c = .2.
- **E3e renewal:** renew {0, .02, .1, .5}, depth 3.
- **E3d cap basis:** cast vs roll × c ∈ {.05, .10, .20, .30}, depth 3, α = 1.5.
- **E3f copy-the-ballot:** section 5b.
- **E4 appeal attraction:** β sweep at α = 1.5, c = .2.
- **E6 purchased stake:** section 5c. b x unit x Patrons roll x amount x estate weights x live-estate scenario, 40 seeds; splitting k sweep; candidate defences.
- **E5 robustness:** Users appeal loading b ∈ {.4, .9, 1.4}; Users base turnout {.05, .10, .30}; Minds ICC (s_Minds ∈ {.3, .9, 1.5}). This is the check on the assumed parameters in section 4.
## 8. Pre-registered falsifiers
The claim is **supported** in a cell if the paired difference PS(UNI) − PS(DIRECT) has a CI entirely below 0 and MT(UNI) ≥ MT(DIRECT) − .05. It **fails** if the CI is entirely above 0 or MT(UNI) falls more than .10 below DIRECT's (dampened by not working). Otherwise **undecided**. The summary reports counts of each across cells and lists the failing cells; no cell is dropped. The report states separately whether delegation shrinks or grows the gap.
## 9. Tests (`test_estatesim.py`)
Pin the rules, not the spelling: rule thresholds including ties; chains within depth, over depth (uncast), unlimited, terminated by an intermediate direct vote, looped, dead-ended; scope as overlap of all links; direct override; two-strike revocation; cap void, cap floor, copy-the-ballot (off draws nothing, full copying evades the cap, copied ballot is the holder's, within-cap and no-cap holders see no copying, effective weight follows the copied voter), cap on the final holder, cap basis; purchased stake (weight 1 at unit and 0 below, per-estate conversion, hours channel, the million-euro buyer's share derived from the config, log versus linear, the operator cap's bind and unenforceable cases, the splitting multiplier against its formula, defences that do and do not neutralise, per-identity cap evasion); determinism (same seed, same bytes); paired draws identical across rules; misreporting never produces an outcome the agent likes less; metrics on hand-built outcome lists.
## 10. Sources
Verified by search this pass [V]; from memory, not fetched [M].
- [V] Mudde 2004, "The Populist Zeitgeist", Government and Opposition 39(4):541–563: ideational (thin-centered) definition, virtuous people against corrupt elite. https://illiberalism.org/cas-mudde-the-populist-zeitgeist (secondary). Mapping to a measure is this document's, not Mudde's.
- [V] Kling, Kunegis, Hartmann, Strohmaier, Staab 2015, "Voting Behaviour and Power in Online Democracy: A Study of LiquidFeedback in Germany's Pirate Party", ICWSM 9. https://arxiv.org/abs/1503.07723. Super-voters hold high theoretical power; empirically they voted with the majority and did not fully use it.
- [V] Gölz, Kahng, Mackenzie, Procaccia 2018/2021, "The Fluid Mechanics of Liquid Democracy", WINE 2018, ACM TEAC 2021. https://arxiv.org/abs/1808.01906. Preferential-attachment delegation model; maximum delegated weight; two delegation choices lower it.
- [V] Gölz et al. 2018 introduction, second-hand citing Behrens, Kistner, Nitsche, Swierczek 2014, "The Principles of LiquidFeedback" [M], for copy-the-ballot as the answer to caps (section 5b).
- [V] Caragiannis and Micha 2019, "A Contribution to the Critique of Liquid Democracy", IJCAI. https://www.ijcai.org/proceedings/2019/17. Local delegation can do worse than direct voting or a dictator. Rebuttal: Butterworth and Booth 2022, arXiv 2206.13904, disputes the model assumptions.
- [V] Palfrey and Rosenthal 1983, "A Strategic Calculus of Voting", Public Choice 41(1):7–53; and 1985, "Voter Participation and Strategic Uncertainty", APSR 79(1):62–78. High-turnout equilibria need complete information. Turnout response here is the myopic pivot-seeker, a simplification.
- [V] Delegate-cap practice: no official cap found for Optimism, Arbitrum or Uniswap; Lido and a Jupiter forum proposal (5%) are the examples surfaced (https://discuss.jup.ag/t/delegated-voting-for-asr/34971/32; https://gov.optimism.io/t/can-delegates-become-too-powerful/2919 discusses mitigation without an adopted cap). Weak evidence. The operator's "a fifth" is a design choice, which is why c is swept.
- From the repo: `../2026-10-29-swarm-governance-prior-art.md` (Compound 289 concentration, ~17% of voting tokens delegated and clustering on popular delegates across 18 Ethereum DAOs, Frontiers 2025); prior estate research README §3, §5b, §6, §9, §10.
- [M] Riker and Ordeshook 1968 calculus of voting; Nurmi 1998 referendum paradox; Madison, Federalist 10 (filtering mass sentiment through representatives, the classical form of the dampening claim). Not fetched.

View file

@ -0,0 +1,582 @@
"""Estate-rule simulation core. Pure stdlib. Spec: SPEC.md in this directory.
One replicate = one population (delegation network, strategic flags, faction)
plus a sequence of motions. Every random draw comes from two Random objects
seeded from integers, so a (Config, rep) pair is a pure function.
"""
import math
import random
from dataclasses import dataclass
ESTATES = ("Minds", "Members", "Users", "Patrons")
ROLL = {"Minds": 8, "Members": 15, "Users": 300, "Patrons": 4}
BASE = {
"Minds": dict(a=1.0, b=.15, s=.9, sigma=.7, turnout=1.0, kappa=0.0),
"Members": dict(a=.8, b=.4, s=.5, sigma=1.0, turnout=.8, kappa=1.0),
"Users": dict(a=.4, b=.9, s=.5, sigma=1.2, turnout=.10, kappa=1.0),
"Patrons": dict(a=.7, b=.5, s=.5, sigma=1.0, turnout=.8, kappa=1.0),
}
DELEG_SHARE = {"Minds": 0.0, "Members": .2, "Users": .5, "Patrons": .2}
WEIGHTS = {"equal": (25, 25, 25, 25), "minds-heavy": (40, 30, 20, 10)}
CATS = (.15, .30, .40, .50, .66)
RULES = ("DIRECT", "HEAD", "WTA", "UNI")
ABSTAIN_BAND = .1
STRONG = .5 # |u| above which a final holder's opposing ballot is a strike
STRIKES = 2 # strikes that revoke a delegation for the rest of the replicate
SCOPE_P = .6
DELEGATE_FRAC = .1
POPULIST_DELEGATE_P = .25
EPS = 1e-9
@dataclass(frozen=True)
class Config:
weights: str = "equal"
delegation: bool = False
alpha: float = 1.0 # Zipf exponent of delegate choice (concentration)
beta: float = 0.0 # extra attraction of populist delegates
cap: float = None # per-final-holder cap as a fraction of cap_basis; None = no cap
cap_basis: str = "cast" # "cast": estate weight resolved before capping; "roll": estate roll size
depth: int = 3 # max delegation edges a vote may traverse; None = unlimited
renew: float = .1 # per-motion probability a revoked delegator picks a new delegate (0: revocation is final)
peer: float = .3 # probability a delegator's edge goes to another delegator, not an active delegate
dturn: float = .95 # delegates' own turnout; a delegate who stays home passes weight onward
onward: float = .3 # probability an active delegate itself delegates onward
copy: float = 0.0 # probability a delegator under an over-cap holder copies that holder's ballot instead (SPEC section 5b)
rho: float = 0.0 # strategic fraction
faction: int = 0 # coordinated Users faction size
motions: int = 400
overrides: tuple = () # ((estate, key, value), ...)
# ---------------------------------------------------------------- rules
def passes(rule, F, A, W, q):
"""Does the motion pass? F, A: per-estate resolved For/Against weight; W: estate weights."""
if rule == "HEAD":
f, a = sum(F), sum(A)
return f + a > 0 and f >= q * (f + a) - EPS
fo = tot = 0.0
for e in range(len(F)):
n = F[e] + A[e]
if n <= 0:
continue
if rule == "DIRECT":
fo += W[e] * F[e] / n
elif rule == "WTA":
if F[e] == A[e]:
continue # tied estate casts nothing and drops out
if F[e] > A[e]:
fo += W[e]
elif rule == "UNI":
if F[e] >= q * n - EPS:
fo += W[e]
else:
raise ValueError(rule)
tot += W[e]
return tot > 0 and fo >= q * tot - EPS
# ---------------------------------------------------------------- population
class Estate:
def __init__(self, name, cfg, rng):
n = ROLL[name]
p = dict(BASE[name])
for en, k, v in cfg.overrides:
if en == name:
p[k] = v
self.name, self.n, self.cfg = name, n, cfg
self.a = [p["a"]] * n
self.b = [p["b"]] * n
self.s = p["s"]
self.sig = [p["sigma"]] * n
self.turn = [p["turnout"]] * n
self.kap = [p["kappa"]] * n
self.target = [None] * n
self.scope = [None] * n
self.strikes = [0] * n
self.revoked = []
self.delegators = []
self.pool = None
self.rng = random.Random(rng.getrandbits(32))
self.is_delegate = [False] * n
self.populist = [False] * n
self.faction = [False] * n
self.strategic = [rng.random() < cfg.rho for _ in range(n)]
ids = list(range(n))
rng.shuffle(ids)
rest = ids
share = DELEG_SHARE[name]
if cfg.delegation and share > 0:
nd = max(1, round(DELEGATE_FRAC * n))
delegates, rest = ids[:nd], ids[nd:]
for d in delegates:
self.is_delegate[d] = True
self.populist[d] = rng.random() < POPULIST_DELEGATE_P
if self.populist[d]:
self.a[d], self.b[d], self.sig[d] = .2, 1.2, .7
else:
self.a[d], self.b[d], self.sig[d] = .7, .5, .7
self.turn[d], self.kap[d] = cfg.dturn, 0.0
wts = [(k + 1) ** -cfg.alpha * ((1 + cfg.beta) if self.populist[d] else 1.0)
for k, d in enumerate(delegates)]
self.pool = (delegates, wts)
nde = min(len(rest), round(share * n))
delegators, rest = rest[:nde], rest[nde:]
self.delegators = delegators
for i in delegators:
self.target[i] = self._pick(i, rng)
self.scope[i] = self._scope(rng)
if len(delegates) > 1:
for k, d in enumerate(delegates):
if rng.random() < cfg.onward:
others = [(x, w) for x, w in zip(delegates, wts) if x != d]
self.target[d] = rng.choices([x for x, _ in others], [w for _, w in others])[0]
self.scope[d] = self._scope(rng)
if name == "Users":
for i in rest[:cfg.faction]:
self.faction[i] = True
self.ubar = [.8 * math.sqrt(self.a[i] ** 2 + self.b[i] ** 2 + self.s ** 2 + self.sig[i] ** 2)
for i in range(n)]
def _pick(self, i, rng):
"""A new delegation target for delegator i: a peer delegator, or an active delegate by Zipf rank."""
if len(self.delegators) > 1 and rng.random() < self.cfg.peer:
return rng.choice([x for x in self.delegators if x != i])
return rng.choices(*self.pool)[0]
@staticmethod
def _scope(rng):
sc = frozenset(c for c in range(len(CATS)) if rng.random() < SCOPE_P)
return sc or frozenset((rng.randrange(len(CATS)),))
def chain(self, cat, voting):
"""Final holder of every non-voting node's weight this motion.
Follows delegation edges until a node that votes directly. The chain is
void (vote uncast) when any link's scope excludes cat (the usable scope
is the overlap of all links), it loops, it exceeds cfg.depth edges, or it
ends at a node with no edge. Returns (dest, uncast): dest maps node ->
final holder, uncast counts non-voting nodes that hold an edge but are void.
"""
D = self.cfg.depth
res = {} # node -> (holder, edges) or None for void
for i in range(self.n):
if voting[i] or self.target[i] is None or i in res:
continue
path, seen, cur = [], set(), i
out = None
while True:
if voting[cur]:
out = (cur, 0)
break
if cur in res:
out = res[cur]
break
if cur in seen or self.target[cur] is None or cat not in self.scope[cur]:
break # loop, dead end or out of scope
seen.add(cur)
path.append(cur)
cur = self.target[cur]
for node in reversed(path): # void propagates upstream; distances add
out = (out[0], out[1] + 1) if out else None
res[node] = out
dest, uncast = {}, 0
for i, r in res.items():
if voting[i] or self.target[i] is None:
continue
if r is not None and (D is None or r[1] <= D):
dest[i] = r[0]
else:
uncast += 1
return dest, uncast
def resolve(self, cat, voting):
dest, uncast = self.chain(cat, voting)
w = {}
for i in range(self.n):
if voting[i]:
w[i] = w.get(i, 0.0) + 1.0
for i, h in dest.items():
w[h] = w.get(h, 0.0) + 1.0
self.last_raw, self.last_cv = dict(w), None
if self.cfg.cap is not None:
base = self.n if self.cfg.cap_basis == "roll" else sum(w.values())
cv = self.last_cv = max(1.0, self.cfg.cap * base)
for t in w:
if w[t] > cv:
w[t] = cv # excess is void, not redistributed
self.last_uncast = uncast
return w, dest
def copy_step(self, cat, voting, sb, dest):
"""Copy-the-ballot (SPEC section 5b), run once on the final resolution.
Each delegator whose final holder is over the cap copies that holder's
ballot with probability cfg.copy: it becomes a direct voter (weight 1,
own cap exposure only) casting the holder's ballot. Mutates voting and sb.
Returns (copiers {delegator: holder}, w, dest) re-resolved, or None when
nobody copies. Draws from self.rng only when copying is on and a holder is over cap.
"""
raw, cv = self.last_raw, self.last_cv
if not self.cfg.copy or cv is None:
return None
copiers = {}
for i in sorted(dest):
h = dest[i]
if raw[h] > cv + EPS and self.rng.random() < self.cfg.copy:
copiers[i] = h
if not copiers:
return None
for i, h in copiers.items():
voting[i] = True
sb[i] = sb[h]
w, dest = self.resolve(cat, voting)
return copiers, w, dest
@staticmethod
def effective(w, copiers):
"""Weight by whose ballot it follows: a copier's weight counts to the voter it copied."""
eff = dict(w)
for c, h in copiers.items():
eff[h] = eff.get(h, 0.0) + eff.pop(c, 0.0)
return eff
def motion(self, cat, m, d, rng, z):
"""Draw this estate's motion state; returns dict with resolved holders and joiners."""
n = self.n
if self.revoked:
keep = []
for i in self.revoked:
if self.rng.random() < self.cfg.renew:
self.target[i] = self._pick(i, self.rng)
self.scope[i] = self._scope(self.rng)
self.strikes[i] = 0
else:
keep.append(i)
self.revoked = keep
u = [self.a[i] * m + self.b[i] * d + self.s * z + self.sig[i] * rng.gauss(0, 1) for i in range(n)]
un = [rng.random() for _ in range(n)]
agenda = d - m > 0
voting, sb = [False] * n, [0] * n
for i in range(n):
if self.kap[i]:
p = self.turn[i] * (1 + self.kap[i] * (abs(u[i]) - self.ubar[i]))
p = min(1.0, max(0.0, p))
else:
p = self.turn[i]
voting[i] = un[i] < p
sb[i] = 1 if u[i] > ABSTAIN_BAND else (-1 if u[i] < -ABSTAIN_BAND else 0)
if self.faction[i]:
voting[i] = True
sb[i] = 1 if agenda else -1
w, dest = self.resolve(cat, voting)
forced = False
for i, dd in dest.items():
if abs(u[i]) > STRONG and sb[dd] * u[i] < 0:
voting[i] = True
forced = True
self.strikes[i] += 1
if self.strikes[i] >= STRIKES:
self.target[i] = None # revoked until renewed (cfg.renew)
self.revoked.append(i)
if forced:
w, dest = self.resolve(cat, voting)
copiers = {}
cs = self.copy_step(cat, voting, sb, dest)
if cs:
copiers, w, dest = cs
holders = [(t, sb[t], w[t]) for t in w]
joiners = [(i, sb[i]) for i in range(n)
if not voting[i] and i not in dest and sb[i] != 0]
return dict(u=u, holders=holders, joiners=joiners, weights=sorted(w.values()),
copiers=copiers, eff_weights=sorted(self.effective(w, copiers).values()),
uncast=self.last_uncast, delegated=len(dest) + self.last_uncast,
live=sum(1 for i in self.delegators if self.target[i] is not None) / max(1, len(self.delegators)))
def concentration(weights):
"""(top share, gini, kish n_eff, total) of resolved holder weights."""
tot = sum(weights)
if not weights or tot <= 0:
return (0.0, 0.0, 0.0, 0.0)
ws = sorted(weights)
n = len(ws)
cum = sum((k + 1) * x for k, x in enumerate(ws))
gini = (2 * cum) / (n * tot) - (n + 1) / n
neff = tot * tot / sum(x * x for x in ws)
return (ws[-1] / tot, gini, neff, tot)
# ---------------------------------------------------------------- one motion
def strategize(rule, W, q, F, A, agents, order_seed):
"""Myopic best response of strategic agents, three rounds. agents: dicts with
e, w, sincere, cur, pref(want pass), opts. Mutates F, A, agent['cur']."""
rng = random.Random(order_seed)
for _ in range(3):
order = list(range(len(agents)))
rng.shuffle(order)
changed = False
for k in order:
ag = agents[k]
e, w = ag["e"], ag["w"]
_move(F, A, e, ag["cur"], 0, w)
pick = ag["sincere"]
for o in ag["opts"]:
_move(F, A, e, 0, o, w)
ok = passes(rule, F, A, W, q) == ag["pref"]
_move(F, A, e, o, 0, w)
if ok:
pick = o
break
_move(F, A, e, 0, pick, w)
if pick != ag["cur"]:
changed = True
ag["cur"] = pick
if not changed:
break
def _move(F, A, e, old, new, w):
if old == 1:
F[e] -= w
elif old == -1:
A[e] -= w
if new == 1:
F[e] += w
elif new == -1:
A[e] += w
def run_replicate(cfg, rep, base_seed=20261105):
"""Returns (records, conc). records[k] = (cat_idx, m, d, strat_bits, sincere_bits)."""
pop_rng = random.Random(base_seed * 1_000_003 + rep)
mot_rng = random.Random(base_seed * 7_919 + rep + 17)
estates = [Estate(nm, cfg, pop_rng) for nm in ESTATES]
W = WEIGHTS[cfg.weights]
records, conc = [], []
for _ in range(cfg.motions):
ci = mot_rng.randrange(len(CATS))
q = CATS[ci]
m, d = mot_rng.gauss(0, 1), mot_rng.gauss(0, 1)
zs = [mot_rng.gauss(0, 1) for _ in ESTATES]
sseed = mot_rng.getrandbits(32)
data = [est.motion(ci, m, d, mot_rng, zs[k]) for k, est in enumerate(estates)]
F0, A0 = [0.0] * 4, [0.0] * 4
for e, dt in enumerate(data):
for _, b, w in dt["holders"]:
_move(F0, A0, e, 0, b, w)
conc.append(concentration(data[2]["weights"]) + (data[2]["uncast"], data[2]["delegated"], data[2]["live"])
+ concentration(data[2]["eff_weights"])[:3:2] + (len(data[2]["copiers"]),))
sinc, strat = [], []
for rule in RULES:
sinc.append(passes(rule, F0, A0, W, q))
if cfg.rho <= 0:
strat.append(sinc[-1])
continue
F, A = list(F0), list(A0)
agents = []
for e, (est, dt) in enumerate(zip(estates, data)):
for t, b, w in dt["holders"]:
if est.strategic[t] and not est.faction[t] and t not in dt["copiers"]:
opts = [x for x in (1, -1, 0) if x != b]
agents.append(dict(e=e, w=w, sincere=b, cur=b, pref=dt["u"][t] > 0, opts=[b] + opts))
for i, b in dt["joiners"]:
if est.strategic[i]:
agents.append(dict(e=e, w=1.0, sincere=0, cur=0, pref=dt["u"][i] > 0, opts=[0, b]))
# opts[0] is the sincere option (for joiners: stay out), evaluated first
strategize(rule, W, q, F, A, agents, sseed)
strat.append(passes(rule, F, A, W, q))
records.append((ci, m, d, tuple(strat), tuple(sinc)))
return records, conc
# ---------------------------------------------------------------- metrics
def _mean(xs):
return sum(xs) / len(xs) if xs else float("nan")
def rep_metrics(records, rule_idx, which=3, cats=None):
"""PS, MT, BPP, GBR, pass rate for one replicate and rule. which: 3 strategic, 4 sincere."""
rows = [(m, d, rec[which][rule_idx]) for rec in records
for (ci, m, d) in [rec[:3]] if cats is None or ci in cats]
def diff(sel_pos, sel_neg):
p = [o for (m, d, o) in rows if sel_pos(m, d)]
n = [o for (m, d, o) in rows if sel_neg(m, d)]
return _mean(p) - _mean(n) if p and n else float("nan")
ps = diff(lambda m, d: d > 0, lambda m, d: d < 0)
mt = diff(lambda m, d: m > 0, lambda m, d: m < 0)
bad = [o for (m, d, o) in rows if m < -.5 and d > .5]
good = [o for (m, d, o) in rows if m > .5 and d < -.5]
return dict(PS=ps, MT=mt, BPP=_mean(bad) if bad else float("nan"),
GBR=1 - _mean(good) if good else float("nan"),
PASS=_mean([o for (_, _, o) in rows]) if rows else float("nan"))
def mean_ci(xs):
xs = [x for x in xs if x == x]
if not xs:
return (float("nan"), float("nan"))
mu = _mean(xs)
if len(xs) < 2:
return (mu, float("nan"))
var = sum((x - mu) ** 2 for x in xs) / (len(xs) - 1)
return (mu, 1.96 * math.sqrt(var / len(xs)))
def verdict(dps, dmt):
"""SPEC section 8. dps, dmt: (mean, ci) of PS(UNI)-PS(DIRECT) and MT(UNI)-MT(DIRECT)."""
if dps[0] != dps[0]:
return "n/a"
if (dps[0] - dps[1] > 0) or dmt[0] < -.10:
return "fails"
if dps[0] + dps[1] < 0 and dmt[0] >= -.05:
return "supported"
return "undecided"
# ---------------------------------------------------------------- purchased stake (SPEC section 5c)
#
# A static weight analysis, not a motion simulation: it asks what share of the
# council a purchase buys, not how the buyer votes. The estate weights W and the
# set of live estates come from the same tables the rules above use.
STAKE_KEYS = ("euro_b", "euro_unit", "hours_b", "hours_unit")
# PLACEHOLDER: every number in STAKE_BASE, HALF excepted, is my assumption (SPEC section 5c), not a ruling
# or data. Results are conditional on them; run.py sweeps b and unit, not hours.
# Per-estate conversion of stake to weight. Only Patrons (euros) and Members (contribution hours) carry stake; Minds are
# counted once and Users are one-each, so they have no entry and weigh 1.
STAKE_BASE = {
"Patrons": dict(euro_b=10.0, euro_unit=10.0, hours_b=10.0, hours_unit=10.0),
"Members": dict(euro_b=10.0, euro_unit=10.0, hours_b=10.0, hours_unit=10.0),
}
HALF = .5 # RULED, not a placeholder: operator-influence cap: council weight share strictly below this (prior section 10)
STRICT = 1e-9 # "strictly below": a capped holder sits at c*(1-STRICT)
PARETO_ALPHA = 1.16 # exponent from prior section 4 (Pareto(1.16) ladder); the roll sizes below are PLACEHOLDER
PARETO_XM = 10.0 # PLACEHOLDER: EUR scale, mine; prior section 4's ladder starts at 10
STAKE_SEED = 20261105
def stake_params(name, overrides=()):
"""Per-estate stake parameters; Config.overrides entries (estate, key, value) with a STAKE_KEYS key apply."""
p = dict(STAKE_BASE.get(name, {}))
if p:
for en, k, v in overrides:
if en == name and k in STAKE_KEYS:
p[k] = v
return p
def log_weight(x, b, unit):
"""w = max(0, 1 + log_b(x/unit)): 1 at x = unit, continuous below it, 0 (not negative) once x <= unit/b.
Equals CultLib heimdall-stake `weight` (PR #9, 84f932ec) with floor 0, cap None and no minimum: no cliff
at unit. Pinned to its golden vectors by test_estatesim.StakeVectors (vectors/stake_golden.csv).
This is the weight of ONE IDENTITY. It is not sybil-safe: log is sub-linear, so the same x split over
k identities weighs more (split_weight). Whether the log bounds a purchase depends on identities being
scarce, which nothing in this module supplies. b and unit are PLACEHOLDER values (STAKE_BASE)."""
if b <= 1 or unit <= 0:
raise ValueError("log base must exceed 1 and unit must be positive")
return max(0.0, 1.0 + math.log(x / unit, b)) if x > 0 else 0.0
def stake_weight(name, euros=0.0, hours=0.0, overrides=()):
"""One identity's weight: euro channel plus contribution-hours channel, each with the estate's own (b, unit).
Per identity, not per person: splitting evades it (see log_weight). The hours channel is UNANALYSED:
it is added here, but no scenario in run.py gives a buyer hours, so nothing below tests whether hours
can be farmed, bought or split. Its (b, unit) are PLACEHOLDER."""
p = stake_params(name, overrides)
if not p:
return 1.0
return (log_weight(euros, p["euro_b"], p["euro_unit"])
+ log_weight(hours, p["hours_b"], p["hours_unit"]))
def pareto_ladder(n, rng):
"""n ordinary-patron amounts in EUR, Pareto(PARETO_ALPHA) from PARETO_XM."""
return [PARETO_XM * (1.0 - rng.random()) ** (-1.0 / PARETO_ALPHA) for _ in range(n)]
def council_share(w, rest, W, e, live):
"""(within-estate share s, council weight share x) of a holder with weight w in estate e;
rest is everyone else's weight in e; live is the tuple of live estate indices (renormalization)."""
s = w / (w + rest) if w + rest > 0 else 0.0
return s, W[e] * s / sum(W[i] for i in live)
def influence_cap(w, rest, W, e, live, c=HALF):
"""Operator-influence cap (prior section 10, ruling operator-influence-below-half): a holder's
council weight share must be strictly below c. Binds iff x >= c; the holder's internal weight is
then cut until x = c*(1-STRICT). Unenforceable when nobody else holds weight in the estate.
The cap is applied to ONE IDENTITY's weight w. A holder who splits across identities is never cut
(cap evasion, results.md), so this is a bound on a ledger entry, not on a person."""
s, x = council_share(w, rest, W, e, live)
out = dict(s=s, x=x, binds=x >= c - EPS, w_capped=w, s_capped=s, x_capped=x, enforceable=True)
if not out["binds"]:
return out
s_cap = c * (1 - STRICT) * sum(W[i] for i in live) / W[e]
if rest <= 0 or s_cap >= 1:
out["enforceable"] = False
return out
wc = s_cap * rest / (1 - s_cap)
out.update(w_capped=wc, s_capped=s_cap, x_capped=W[e] * s_cap / sum(W[i] for i in live))
return out
def split_weight(amount, k, b, unit, identity_cost=0.0, person_cap=None):
"""Total weight of `amount` EUR split evenly over k identities. identity_cost is spent per identity
out of the budget; person_cap limits each identity's weight. Returns (total, per identity)."""
each = (amount - k * identity_cost) / k
wi = log_weight(each, b, unit) if each > 0 else 0.0
if person_cap is not None:
wi = min(wi, person_cap)
return k * wi, wi
def best_split(amount, b, unit, identity_cost=0.0, person_cap=None):
"""(best total weight, k) over a geometric grid of k up to amount/unit. A grid maximum: a lower bound on the true one."""
kmax = max(1, int(amount // unit))
ks, k = {1, kmax}, 1.0
while k < kmax:
ks.add(max(1, round(k)))
k *= 1.05
best = max((split_weight(amount, k, b, unit, identity_cost, person_cap)[0], -k) for k in ks)
return best[0], -best[1]
def min_neutralising_cost(amount, b, unit, person_cap=None, tol=1e-3):
"""Smallest per-identity cost at which no split beats one identity, found by bisection to relative
tol (a bisection value, not a grid point). Neutral = best split is k = 1 and one identity still has
weight. A doubling search from unit brackets the first neutral cost; assumes the predicate is
monotone inside the bracket. Returns None if no cost below the amount is neutral."""
def neutral(c):
return (split_weight(amount, 1, b, unit, c, person_cap)[0] > 0
and best_split(amount, b, unit, c, person_cap)[1] == 1)
hi = unit
while hi < amount and not neutral(hi):
hi *= 2
if hi >= amount:
return None
lo = hi / 2 if hi > unit else 0.0
while hi - lo > tol * hi:
mid = (lo + hi) / 2
lo, hi = (lo, mid) if neutral(mid) else (mid, hi)
return hi
def stake_ladder(n, s):
"""Seed s's roll of n ordinary patrons: a pure function of (n, s)."""
return pareto_ladder(n, random.Random(STAKE_SEED + 1000 * n + s))
def buyer_runs(amount, n, seeds, W, live, overrides=()):
"""influence_cap result for a buyer of `amount` EUR against each of `seeds` rolls of n ordinary patrons."""
wb = stake_weight("Patrons", euros=amount, overrides=overrides)
return [influence_cap(wb, sum(stake_weight("Patrons", euros=x, overrides=overrides) for x in stake_ladder(n, s)),
W, ESTATES.index("Patrons"), live) for s in range(seeds)]

View file

@ -0,0 +1,890 @@
# Estate-rule sim results (full: 40 replicates x 500 motions per cell)
Generated by `run.py`; spec in SPEC.md. +/- is a 95% interval over replicates. PS populism sensitivity, MT merit tracking, BPP bad-popular pass rate, GBR good-unpopular block rate (SPEC section 3).
## E1 baseline (sincere, no delegation, no faction)
| cell | dPS UNI-DIRECT | dMT UNI-DIRECT | dPS WTA-DIRECT | PS DIRECT | PS WTA | PS UNI | Users top share | Users n_eff | uncast | live edges | claim |
|---|---|---|---|---|---|---|---|---|---|---|---|
| equal | -0.022 ±0.007 | -0.066 ±0.007 | 0.026 ±0.007 | 0.297 ±0.014 | 0.323 ±0.015 | 0.275 ±0.012 | 0.038 | 30.5 | 0.00 | 0.00 | undecided |
| minds-heavy | -0.036 ±0.007 | -0.081 ±0.007 | 0.001 ±0.007 | 0.235 ±0.012 | 0.237 ±0.014 | 0.199 ±0.014 | 0.038 | 30.5 | 0.00 | 0.00 | undecided |
### E1 / equal
| rule | PS | MT | PI=PS/MT | BPP | GBR | pass | PS sincere | outcome flipped by strategy |
|---|---|---|---|---|---|---|---|---|
| DIRECT | 0.297 ±0.014 | 0.465 ±0.011 | 0.639 | 0.525 ±0.017 | 0.227 ±0.017 | 0.596 ±0.006 | 0.297 ±0.014 | 0.000 ±0.000 |
| HEAD | 0.427 ±0.010 | 0.364 ±0.011 | 1.173 | 0.686 ±0.021 | 0.409 ±0.017 | 0.592 ±0.006 | 0.427 ±0.010 | 0.000 ±0.000 |
| WTA | 0.323 ±0.015 | 0.485 ±0.012 | 0.667 | 0.535 ±0.018 | 0.226 ±0.018 | 0.593 ±0.006 | 0.323 ±0.015 | 0.000 ±0.000 |
| UNI | 0.275 ±0.012 | 0.400 ±0.010 | 0.689 | 0.622 ±0.019 | 0.190 ±0.019 | 0.652 ±0.006 | 0.275 ±0.012 | 0.000 ±0.000 |
UNI-DIRECT: dPS -0.022 ±0.007, dMT -0.066 ±0.007; WTA-DIRECT: dPS 0.026 ±0.007, dMT 0.019 ±0.008; UNI-WTA: dPS -0.048 ±0.009, dMT -0.085 ±0.009; claim (UNI vs DIRECT): **undecided**
PS by category threshold:
| q | DIRECT | HEAD | WTA | UNI |
|---|---|---|---|---|
| 0.15 | 0.22 ±0.02 | 0.30 ±0.02 | 0.27 ±0.03 | 0.13 ±0.01 |
| 0.3 | 0.29 ±0.03 | 0.41 ±0.03 | 0.31 ±0.03 | 0.27 ±0.03 |
| 0.4 | 0.33 ±0.02 | 0.48 ±0.02 | 0.33 ±0.03 | 0.32 ±0.02 |
| 0.5 | 0.33 ±0.03 | 0.50 ±0.03 | 0.35 ±0.03 | 0.33 ±0.03 |
| 0.66 | 0.32 ±0.03 | 0.44 ±0.03 | 0.35 ±0.03 | 0.32 ±0.03 |
### E1 / minds-heavy
| rule | PS | MT | PI=PS/MT | BPP | GBR | pass | PS sincere | outcome flipped by strategy |
|---|---|---|---|---|---|---|---|---|
| DIRECT | 0.235 ±0.012 | 0.494 ±0.011 | 0.476 | 0.434 ±0.017 | 0.183 ±0.018 | 0.593 ±0.006 | 0.235 ±0.012 | 0.000 ±0.000 |
| HEAD | 0.427 ±0.010 | 0.364 ±0.011 | 1.173 | 0.686 ±0.021 | 0.409 ±0.017 | 0.592 ±0.006 | 0.427 ±0.010 | 0.000 ±0.000 |
| WTA | 0.237 ±0.014 | 0.502 ±0.012 | 0.471 | 0.436 ±0.016 | 0.179 ±0.018 | 0.586 ±0.007 | 0.237 ±0.014 | 0.000 ±0.000 |
| UNI | 0.199 ±0.014 | 0.413 ±0.011 | 0.481 | 0.531 ±0.016 | 0.158 ±0.017 | 0.646 ±0.006 | 0.199 ±0.014 | 0.000 ±0.000 |
UNI-DIRECT: dPS -0.036 ±0.007, dMT -0.081 ±0.007; WTA-DIRECT: dPS 0.001 ±0.007, dMT 0.008 ±0.008; UNI-WTA: dPS -0.038 ±0.008, dMT -0.089 ±0.008; claim (UNI vs DIRECT): **undecided**
PS by category threshold:
| q | DIRECT | HEAD | WTA | UNI |
|---|---|---|---|---|
| 0.15 | 0.21 ±0.02 | 0.30 ±0.02 | 0.28 ±0.03 | 0.16 ±0.01 |
| 0.3 | 0.21 ±0.03 | 0.41 ±0.03 | 0.20 ±0.03 | 0.17 ±0.03 |
| 0.4 | 0.24 ±0.02 | 0.48 ±0.02 | 0.19 ±0.02 | 0.18 ±0.03 |
| 0.5 | 0.25 ±0.03 | 0.50 ±0.03 | 0.30 ±0.03 | 0.29 ±0.03 |
| 0.66 | 0.24 ±0.03 | 0.44 ±0.03 | 0.20 ±0.03 | 0.18 ±0.03 |
## E2 strategic agents (rho=.25) and Users faction size
| cell | dPS UNI-DIRECT | dMT UNI-DIRECT | dPS WTA-DIRECT | PS DIRECT | PS WTA | PS UNI | Users top share | Users n_eff | uncast | live edges | claim |
|---|---|---|---|---|---|---|---|---|---|---|---|
| faction=0 | -0.024 ±0.009 | -0.072 ±0.009 | 0.033 ±0.009 | 0.291 ±0.013 | 0.324 ±0.014 | 0.267 ±0.012 | 0.038 | 30.5 | 0.00 | 0.00 | undecided |
| faction=10 | -0.041 ±0.008 | -0.086 ±0.009 | 0.042 ±0.008 | 0.316 ±0.013 | 0.358 ±0.013 | 0.275 ±0.011 | 0.028 | 39.5 | 0.00 | 0.00 | undecided |
| faction=20 | -0.050 ±0.008 | -0.077 ±0.009 | 0.037 ±0.009 | 0.323 ±0.013 | 0.360 ±0.013 | 0.274 ±0.011 | 0.022 | 48.5 | 0.00 | 0.00 | undecided |
| faction=40 | -0.054 ±0.007 | -0.052 ±0.008 | 0.011 ±0.009 | 0.328 ±0.012 | 0.339 ±0.013 | 0.274 ±0.011 | 0.015 | 66.4 | 0.00 | 0.00 | undecided |
### E2 / faction=0
| rule | PS | MT | PI=PS/MT | BPP | GBR | pass | PS sincere | outcome flipped by strategy |
|---|---|---|---|---|---|---|---|---|
| DIRECT | 0.291 ±0.013 | 0.459 ±0.011 | 0.634 | 0.524 ±0.017 | 0.226 ±0.019 | 0.599 ±0.007 | 0.297 ±0.014 | 0.026 ±0.004 |
| HEAD | 0.418 ±0.011 | 0.358 ±0.011 | 1.168 | 0.690 ±0.022 | 0.403 ±0.018 | 0.597 ±0.006 | 0.427 ±0.010 | 0.012 ±0.002 |
| WTA | 0.324 ±0.014 | 0.483 ±0.012 | 0.671 | 0.540 ±0.019 | 0.224 ±0.017 | 0.596 ±0.006 | 0.323 ±0.015 | 0.025 ±0.004 |
| UNI | 0.267 ±0.012 | 0.387 ±0.011 | 0.689 | 0.630 ±0.019 | 0.193 ±0.019 | 0.659 ±0.005 | 0.275 ±0.012 | 0.028 ±0.004 |
UNI-DIRECT: dPS -0.024 ±0.009, dMT -0.072 ±0.009; WTA-DIRECT: dPS 0.033 ±0.009, dMT 0.024 ±0.008; UNI-WTA: dPS -0.058 ±0.011, dMT -0.096 ±0.010; claim (UNI vs DIRECT): **undecided**
PS by category threshold:
| q | DIRECT | HEAD | WTA | UNI |
|---|---|---|---|---|
| 0.15 | 0.20 ±0.02 | 0.27 ±0.02 | 0.26 ±0.03 | 0.11 ±0.01 |
| 0.3 | 0.27 ±0.03 | 0.40 ±0.03 | 0.32 ±0.03 | 0.25 ±0.03 |
| 0.4 | 0.32 ±0.02 | 0.48 ±0.02 | 0.34 ±0.03 | 0.32 ±0.02 |
| 0.5 | 0.33 ±0.03 | 0.49 ±0.03 | 0.34 ±0.02 | 0.32 ±0.02 |
| 0.66 | 0.31 ±0.03 | 0.44 ±0.03 | 0.35 ±0.03 | 0.31 ±0.03 |
### E2 / faction=10
| rule | PS | MT | PI=PS/MT | BPP | GBR | pass | PS sincere | outcome flipped by strategy |
|---|---|---|---|---|---|---|---|---|
| DIRECT | 0.316 ±0.013 | 0.413 ±0.013 | 0.765 | 0.564 ±0.018 | 0.253 ±0.021 | 0.607 ±0.007 | 0.319 ±0.013 | 0.027 ±0.004 |
| HEAD | 0.488 ±0.010 | 0.192 ±0.013 | 2.550 | 0.815 ±0.020 | 0.517 ±0.017 | 0.614 ±0.007 | 0.502 ±0.010 | 0.014 ±0.001 |
| WTA | 0.358 ±0.013 | 0.426 ±0.012 | 0.842 | 0.595 ±0.020 | 0.245 ±0.019 | 0.607 ±0.006 | 0.358 ±0.014 | 0.027 ±0.004 |
| UNI | 0.275 ±0.011 | 0.328 ±0.011 | 0.838 | 0.656 ±0.018 | 0.205 ±0.021 | 0.672 ±0.005 | 0.282 ±0.012 | 0.027 ±0.004 |
UNI-DIRECT: dPS -0.041 ±0.008, dMT -0.086 ±0.009; WTA-DIRECT: dPS 0.042 ±0.008, dMT 0.012 ±0.009; UNI-WTA: dPS -0.084 ±0.010, dMT -0.098 ±0.009; claim (UNI vs DIRECT): **undecided**
PS by category threshold:
| q | DIRECT | HEAD | WTA | UNI |
|---|---|---|---|---|
| 0.15 | 0.21 ±0.02 | 0.22 ±0.02 | 0.30 ±0.02 | 0.06 ±0.01 |
| 0.3 | 0.30 ±0.03 | 0.47 ±0.03 | 0.35 ±0.03 | 0.27 ±0.03 |
| 0.4 | 0.35 ±0.02 | 0.58 ±0.02 | 0.37 ±0.03 | 0.34 ±0.02 |
| 0.5 | 0.37 ±0.03 | 0.62 ±0.03 | 0.38 ±0.02 | 0.36 ±0.02 |
| 0.66 | 0.34 ±0.03 | 0.53 ±0.02 | 0.39 ±0.03 | 0.32 ±0.03 |
### E2 / faction=20
| rule | PS | MT | PI=PS/MT | BPP | GBR | pass | PS sincere | outcome flipped by strategy |
|---|---|---|---|---|---|---|---|---|
| DIRECT | 0.323 ±0.013 | 0.381 ±0.013 | 0.849 | 0.588 ±0.019 | 0.264 ±0.021 | 0.612 ±0.007 | 0.326 ±0.013 | 0.028 ±0.004 |
| HEAD | 0.524 ±0.010 | 0.048 ±0.013 | n/a | 0.890 ±0.015 | 0.621 ±0.019 | 0.615 ±0.006 | 0.537 ±0.010 | 0.010 ±0.001 |
| WTA | 0.360 ±0.013 | 0.381 ±0.012 | 0.944 | 0.612 ±0.019 | 0.250 ±0.018 | 0.618 ±0.006 | 0.362 ±0.014 | 0.028 ±0.004 |
| UNI | 0.274 ±0.011 | 0.304 ±0.011 | 0.900 | 0.665 ±0.019 | 0.210 ±0.021 | 0.676 ±0.005 | 0.279 ±0.012 | 0.028 ±0.004 |
UNI-DIRECT: dPS -0.050 ±0.008, dMT -0.077 ±0.009; WTA-DIRECT: dPS 0.037 ±0.009, dMT 0.000 ±0.010; UNI-WTA: dPS -0.086 ±0.009, dMT -0.077 ±0.010; claim (UNI vs DIRECT): **undecided**
PS by category threshold:
| q | DIRECT | HEAD | WTA | UNI |
|---|---|---|---|---|
| 0.15 | 0.19 ±0.02 | 0.23 ±0.02 | 0.28 ±0.02 | 0.06 ±0.01 |
| 0.3 | 0.30 ±0.03 | 0.49 ±0.03 | 0.36 ±0.03 | 0.27 ±0.03 |
| 0.4 | 0.37 ±0.03 | 0.64 ±0.02 | 0.37 ±0.03 | 0.35 ±0.02 |
| 0.5 | 0.40 ±0.02 | 0.70 ±0.02 | 0.38 ±0.02 | 0.36 ±0.02 |
| 0.66 | 0.34 ±0.03 | 0.56 ±0.02 | 0.39 ±0.03 | 0.32 ±0.03 |
### E2 / faction=40
| rule | PS | MT | PI=PS/MT | BPP | GBR | pass | PS sincere | outcome flipped by strategy |
|---|---|---|---|---|---|---|---|---|
| DIRECT | 0.328 ±0.012 | 0.341 ±0.014 | 0.961 | 0.609 ±0.019 | 0.279 ±0.023 | 0.620 ±0.006 | 0.330 ±0.012 | 0.029 ±0.004 |
| HEAD | 0.546 ±0.012 | -0.141 ±0.014 | n/a | 0.960 ±0.007 | 0.761 ±0.018 | 0.603 ±0.006 | 0.551 ±0.012 | 0.007 ±0.001 |
| WTA | 0.339 ±0.013 | 0.338 ±0.012 | 1.004 | 0.618 ±0.020 | 0.253 ±0.018 | 0.630 ±0.006 | 0.340 ±0.013 | 0.028 ±0.004 |
| UNI | 0.274 ±0.011 | 0.290 ±0.011 | 0.945 | 0.667 ±0.019 | 0.212 ±0.021 | 0.678 ±0.004 | 0.280 ±0.011 | 0.029 ±0.004 |
UNI-DIRECT: dPS -0.054 ±0.007, dMT -0.052 ±0.008; WTA-DIRECT: dPS 0.011 ±0.009, dMT -0.003 ±0.009; UNI-WTA: dPS -0.066 ±0.009, dMT -0.048 ±0.009; claim (UNI vs DIRECT): **undecided**
PS by category threshold:
| q | DIRECT | HEAD | WTA | UNI |
|---|---|---|---|---|
| 0.15 | 0.16 ±0.02 | 0.29 ±0.02 | 0.21 ±0.02 | 0.06 ±0.01 |
| 0.3 | 0.31 ±0.03 | 0.56 ±0.02 | 0.36 ±0.03 | 0.28 ±0.03 |
| 0.4 | 0.38 ±0.02 | 0.62 ±0.02 | 0.36 ±0.03 | 0.34 ±0.03 |
| 0.5 | 0.42 ±0.02 | 0.66 ±0.02 | 0.37 ±0.02 | 0.35 ±0.02 |
| 0.66 | 0.35 ±0.03 | 0.58 ±0.03 | 0.39 ±0.03 | 0.33 ±0.03 |
## E3 delegation: concentration alpha x per-delegate cap (depth 3, strategic, faction 20)
| cell | dPS UNI-DIRECT | dMT UNI-DIRECT | dPS WTA-DIRECT | PS DIRECT | PS WTA | PS UNI | Users top share | Users n_eff | uncast | live edges | claim |
|---|---|---|---|---|---|---|---|---|---|---|---|
| alpha=0.0 cap=0.1 | -0.026 ±0.007 | -0.093 ±0.009 | 0.054 ±0.009 | 0.311 ±0.014 | 0.365 ±0.014 | 0.286 ±0.010 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.15 | -0.025 ±0.007 | -0.093 ±0.009 | 0.052 ±0.009 | 0.312 ±0.014 | 0.364 ±0.014 | 0.286 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.2 | -0.026 ±0.007 | -0.095 ±0.009 | 0.052 ±0.010 | 0.312 ±0.014 | 0.365 ±0.014 | 0.287 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.25 | -0.026 ±0.007 | -0.095 ±0.009 | 0.052 ±0.009 | 0.313 ±0.014 | 0.365 ±0.014 | 0.287 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.3 | -0.026 ±0.007 | -0.095 ±0.009 | 0.052 ±0.009 | 0.313 ±0.014 | 0.365 ±0.014 | 0.287 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=None | -0.026 ±0.008 | -0.092 ±0.009 | 0.053 ±0.009 | 0.315 ±0.014 | 0.367 ±0.015 | 0.289 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.1 | -0.028 ±0.008 | -0.092 ±0.008 | 0.049 ±0.010 | 0.313 ±0.014 | 0.362 ±0.015 | 0.285 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.15 | -0.028 ±0.008 | -0.094 ±0.008 | 0.048 ±0.010 | 0.313 ±0.014 | 0.361 ±0.014 | 0.286 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.2 | -0.028 ±0.008 | -0.094 ±0.008 | 0.049 ±0.010 | 0.313 ±0.014 | 0.362 ±0.014 | 0.285 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.25 | -0.028 ±0.008 | -0.095 ±0.008 | 0.048 ±0.010 | 0.314 ±0.014 | 0.362 ±0.014 | 0.285 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.3 | -0.029 ±0.009 | -0.094 ±0.008 | 0.048 ±0.010 | 0.314 ±0.015 | 0.362 ±0.014 | 0.285 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=None | -0.028 ±0.009 | -0.092 ±0.008 | 0.050 ±0.010 | 0.315 ±0.015 | 0.365 ±0.015 | 0.287 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.1 | -0.028 ±0.007 | -0.092 ±0.008 | 0.050 ±0.010 | 0.313 ±0.014 | 0.363 ±0.014 | 0.284 ±0.010 | 0.066 | 53.8 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.15 | -0.027 ±0.007 | -0.093 ±0.008 | 0.049 ±0.010 | 0.313 ±0.014 | 0.362 ±0.014 | 0.285 ±0.010 | 0.068 | 53.3 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.2 | -0.028 ±0.007 | -0.093 ±0.009 | 0.050 ±0.010 | 0.313 ±0.014 | 0.363 ±0.014 | 0.285 ±0.010 | 0.068 | 53.3 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.25 | -0.027 ±0.007 | -0.094 ±0.008 | 0.050 ±0.009 | 0.313 ±0.014 | 0.362 ±0.014 | 0.285 ±0.010 | 0.068 | 53.3 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.3 | -0.028 ±0.008 | -0.094 ±0.008 | 0.050 ±0.009 | 0.313 ±0.014 | 0.363 ±0.014 | 0.285 ±0.010 | 0.068 | 53.3 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=None | -0.027 ±0.008 | -0.092 ±0.008 | 0.050 ±0.009 | 0.315 ±0.014 | 0.365 ±0.014 | 0.288 ±0.010 | 0.068 | 53.3 | 0.51 | 0.81 | undecided |
| alpha=1.5 cap=0.1 | -0.026 ±0.008 | -0.092 ±0.009 | 0.053 ±0.010 | 0.312 ±0.014 | 0.365 ±0.014 | 0.286 ±0.010 | 0.088 | 46.8 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=0.15 | -0.026 ±0.008 | -0.092 ±0.009 | 0.050 ±0.010 | 0.313 ±0.014 | 0.363 ±0.014 | 0.287 ±0.010 | 0.100 | 43.5 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=0.2 | -0.027 ±0.008 | -0.092 ±0.009 | 0.050 ±0.010 | 0.313 ±0.014 | 0.363 ±0.014 | 0.286 ±0.010 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=0.25 | -0.027 ±0.008 | -0.092 ±0.009 | 0.050 ±0.010 | 0.313 ±0.014 | 0.363 ±0.014 | 0.286 ±0.010 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=0.3 | -0.028 ±0.008 | -0.092 ±0.008 | 0.049 ±0.010 | 0.314 ±0.014 | 0.363 ±0.014 | 0.286 ±0.010 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=None | -0.027 ±0.009 | -0.090 ±0.009 | 0.051 ±0.009 | 0.315 ±0.014 | 0.366 ±0.014 | 0.288 ±0.011 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| alpha=2.5 cap=0.1 | -0.029 ±0.007 | -0.095 ±0.009 | 0.052 ±0.010 | 0.313 ±0.014 | 0.365 ±0.014 | 0.284 ±0.011 | 0.102 | 43.8 | 0.51 | 0.81 | undecided |
| alpha=2.5 cap=0.15 | -0.028 ±0.008 | -0.096 ±0.008 | 0.049 ±0.009 | 0.314 ±0.014 | 0.363 ±0.014 | 0.286 ±0.011 | 0.135 | 34.8 | 0.51 | 0.81 | undecided |
| alpha=2.5 cap=0.2 | -0.030 ±0.008 | -0.095 ±0.008 | 0.046 ±0.009 | 0.315 ±0.014 | 0.361 ±0.015 | 0.285 ±0.011 | 0.151 | 31.9 | 0.51 | 0.81 | undecided |
| alpha=2.5 cap=0.25 | -0.031 ±0.008 | -0.096 ±0.008 | 0.046 ±0.009 | 0.316 ±0.014 | 0.361 ±0.014 | 0.285 ±0.011 | 0.156 | 31.3 | 0.51 | 0.81 | undecided |
| alpha=2.5 cap=0.3 | -0.031 ±0.008 | -0.096 ±0.008 | 0.045 ±0.009 | 0.316 ±0.014 | 0.361 ±0.014 | 0.284 ±0.011 | 0.158 | 31.2 | 0.51 | 0.81 | undecided |
| alpha=2.5 cap=None | -0.030 ±0.008 | -0.094 ±0.008 | 0.047 ±0.008 | 0.317 ±0.014 | 0.363 ±0.014 | 0.287 ±0.011 | 0.158 | 31.2 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=0.1 | -0.025 ±0.008 | -0.095 ±0.008 | 0.058 ±0.010 | 0.311 ±0.014 | 0.369 ±0.014 | 0.286 ±0.010 | 0.107 | 44.0 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=0.15 | -0.026 ±0.008 | -0.095 ±0.008 | 0.054 ±0.010 | 0.312 ±0.014 | 0.367 ±0.014 | 0.287 ±0.010 | 0.147 | 32.5 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=0.2 | -0.027 ±0.008 | -0.093 ±0.008 | 0.052 ±0.010 | 0.313 ±0.013 | 0.365 ±0.014 | 0.286 ±0.011 | 0.173 | 27.8 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=0.25 | -0.026 ±0.008 | -0.092 ±0.008 | 0.052 ±0.010 | 0.313 ±0.013 | 0.365 ±0.014 | 0.287 ±0.011 | 0.185 | 26.4 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=0.3 | -0.028 ±0.008 | -0.091 ±0.008 | 0.050 ±0.010 | 0.314 ±0.013 | 0.364 ±0.014 | 0.286 ±0.011 | 0.189 | 26.2 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=None | -0.027 ±0.008 | -0.089 ±0.009 | 0.051 ±0.010 | 0.315 ±0.014 | 0.366 ±0.014 | 0.288 ±0.011 | 0.190 | 26.1 | 0.51 | 0.81 | undecided |
## E3b same grid, no faction, sincere (isolates delegation)
| cell | dPS UNI-DIRECT | dMT UNI-DIRECT | dPS WTA-DIRECT | PS DIRECT | PS WTA | PS UNI | Users top share | Users n_eff | uncast | live edges | claim |
|---|---|---|---|---|---|---|---|---|---|---|---|
| alpha=0.0 cap=0.1 | -0.016 ±0.009 | -0.076 ±0.008 | 0.027 ±0.009 | 0.300 ±0.015 | 0.327 ±0.015 | 0.285 ±0.013 | 0.055 | 46.8 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.15 | -0.015 ±0.008 | -0.076 ±0.008 | 0.027 ±0.009 | 0.300 ±0.015 | 0.327 ±0.015 | 0.285 ±0.013 | 0.055 | 46.8 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.2 | -0.015 ±0.009 | -0.077 ±0.009 | 0.028 ±0.009 | 0.301 ±0.015 | 0.329 ±0.015 | 0.285 ±0.013 | 0.055 | 46.8 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.25 | -0.015 ±0.009 | -0.077 ±0.008 | 0.029 ±0.009 | 0.300 ±0.015 | 0.330 ±0.015 | 0.286 ±0.013 | 0.055 | 46.8 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.3 | -0.015 ±0.009 | -0.077 ±0.009 | 0.029 ±0.009 | 0.300 ±0.015 | 0.330 ±0.015 | 0.286 ±0.013 | 0.055 | 46.8 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=None | -0.015 ±0.009 | -0.074 ±0.008 | 0.028 ±0.009 | 0.303 ±0.016 | 0.331 ±0.016 | 0.288 ±0.013 | 0.055 | 46.8 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.1 | -0.016 ±0.008 | -0.076 ±0.009 | 0.030 ±0.009 | 0.300 ±0.015 | 0.330 ±0.015 | 0.284 ±0.013 | 0.059 | 45.6 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.15 | -0.016 ±0.008 | -0.075 ±0.009 | 0.030 ±0.009 | 0.301 ±0.014 | 0.330 ±0.015 | 0.284 ±0.013 | 0.059 | 45.5 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.2 | -0.017 ±0.008 | -0.076 ±0.009 | 0.030 ±0.009 | 0.301 ±0.015 | 0.331 ±0.015 | 0.284 ±0.013 | 0.059 | 45.5 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.25 | -0.017 ±0.008 | -0.077 ±0.009 | 0.031 ±0.009 | 0.301 ±0.015 | 0.332 ±0.015 | 0.285 ±0.013 | 0.059 | 45.5 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.3 | -0.016 ±0.008 | -0.077 ±0.009 | 0.031 ±0.009 | 0.301 ±0.015 | 0.332 ±0.015 | 0.285 ±0.013 | 0.059 | 45.5 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=None | -0.015 ±0.007 | -0.074 ±0.009 | 0.031 ±0.008 | 0.302 ±0.015 | 0.333 ±0.015 | 0.287 ±0.013 | 0.059 | 45.5 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.1 | -0.015 ±0.008 | -0.074 ±0.007 | 0.031 ±0.009 | 0.300 ±0.014 | 0.331 ±0.015 | 0.285 ±0.013 | 0.077 | 41.1 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.15 | -0.016 ±0.008 | -0.074 ±0.007 | 0.030 ±0.009 | 0.301 ±0.014 | 0.331 ±0.015 | 0.285 ±0.013 | 0.082 | 40.3 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.2 | -0.016 ±0.009 | -0.075 ±0.008 | 0.031 ±0.009 | 0.302 ±0.014 | 0.332 ±0.015 | 0.285 ±0.013 | 0.082 | 40.2 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.25 | -0.016 ±0.009 | -0.076 ±0.008 | 0.032 ±0.009 | 0.301 ±0.014 | 0.333 ±0.015 | 0.286 ±0.013 | 0.082 | 40.2 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.3 | -0.016 ±0.009 | -0.075 ±0.008 | 0.032 ±0.009 | 0.302 ±0.014 | 0.333 ±0.015 | 0.286 ±0.013 | 0.082 | 40.2 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=None | -0.016 ±0.008 | -0.072 ±0.007 | 0.031 ±0.009 | 0.304 ±0.015 | 0.335 ±0.016 | 0.288 ±0.013 | 0.082 | 40.2 | 0.51 | 0.81 | undecided |
| alpha=1.5 cap=0.1 | -0.016 ±0.008 | -0.075 ±0.007 | 0.030 ±0.009 | 0.300 ±0.015 | 0.331 ±0.015 | 0.284 ±0.013 | 0.096 | 36.9 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=0.15 | -0.016 ±0.008 | -0.075 ±0.007 | 0.031 ±0.009 | 0.300 ±0.014 | 0.331 ±0.015 | 0.285 ±0.013 | 0.116 | 33.1 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=0.2 | -0.016 ±0.008 | -0.075 ±0.007 | 0.033 ±0.009 | 0.300 ±0.015 | 0.333 ±0.015 | 0.284 ±0.013 | 0.123 | 32.2 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=0.25 | -0.015 ±0.008 | -0.076 ±0.007 | 0.033 ±0.009 | 0.300 ±0.015 | 0.334 ±0.016 | 0.285 ±0.013 | 0.124 | 32.0 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=0.3 | -0.015 ±0.008 | -0.076 ±0.007 | 0.033 ±0.009 | 0.300 ±0.015 | 0.334 ±0.016 | 0.285 ±0.013 | 0.124 | 32.0 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=None | -0.014 ±0.008 | -0.073 ±0.007 | 0.033 ±0.009 | 0.302 ±0.015 | 0.335 ±0.016 | 0.288 ±0.013 | 0.124 | 32.0 | 0.50 | 0.81 | undecided |
| alpha=2.5 cap=0.1 | -0.014 ±0.008 | -0.078 ±0.008 | 0.031 ±0.010 | 0.298 ±0.015 | 0.329 ±0.015 | 0.284 ±0.013 | 0.108 | 35.6 | 0.51 | 0.81 | undecided |
| alpha=2.5 cap=0.15 | -0.014 ±0.009 | -0.077 ±0.008 | 0.033 ±0.009 | 0.298 ±0.015 | 0.331 ±0.016 | 0.284 ±0.013 | 0.147 | 27.8 | 0.51 | 0.81 | undecided |
| alpha=2.5 cap=0.2 | -0.015 ±0.009 | -0.077 ±0.008 | 0.032 ±0.010 | 0.299 ±0.014 | 0.331 ±0.015 | 0.284 ±0.013 | 0.172 | 24.2 | 0.51 | 0.81 | undecided |
| alpha=2.5 cap=0.25 | -0.015 ±0.009 | -0.077 ±0.008 | 0.032 ±0.010 | 0.300 ±0.015 | 0.332 ±0.016 | 0.285 ±0.013 | 0.185 | 23.1 | 0.51 | 0.81 | undecided |
| alpha=2.5 cap=0.3 | -0.015 ±0.009 | -0.077 ±0.007 | 0.032 ±0.010 | 0.300 ±0.015 | 0.332 ±0.016 | 0.285 ±0.013 | 0.189 | 22.8 | 0.51 | 0.81 | undecided |
| alpha=2.5 cap=None | -0.015 ±0.009 | -0.074 ±0.007 | 0.031 ±0.009 | 0.302 ±0.015 | 0.333 ±0.016 | 0.287 ±0.013 | 0.191 | 22.7 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=0.1 | -0.012 ±0.008 | -0.080 ±0.008 | 0.034 ±0.010 | 0.295 ±0.014 | 0.329 ±0.016 | 0.284 ±0.013 | 0.114 | 36.2 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=0.15 | -0.014 ±0.008 | -0.080 ±0.007 | 0.034 ±0.010 | 0.297 ±0.014 | 0.330 ±0.015 | 0.282 ±0.013 | 0.158 | 26.8 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=0.2 | -0.016 ±0.007 | -0.078 ±0.008 | 0.033 ±0.009 | 0.299 ±0.014 | 0.332 ±0.015 | 0.283 ±0.013 | 0.192 | 21.9 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=0.25 | -0.015 ±0.008 | -0.078 ±0.007 | 0.034 ±0.009 | 0.300 ±0.014 | 0.333 ±0.015 | 0.285 ±0.012 | 0.213 | 19.8 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=0.3 | -0.015 ±0.008 | -0.075 ±0.007 | 0.033 ±0.009 | 0.300 ±0.014 | 0.334 ±0.016 | 0.285 ±0.012 | 0.224 | 19.1 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=None | -0.015 ±0.008 | -0.072 ±0.007 | 0.033 ±0.009 | 0.302 ±0.014 | 0.335 ±0.016 | 0.288 ±0.012 | 0.230 | 18.9 | 0.51 | 0.81 | undecided |
## E3c transitive depth x delegate turnout x peer-delegation share (alpha=1.5, cap=.2, strategic, faction 20)
| cell | dPS UNI-DIRECT | dMT UNI-DIRECT | dPS WTA-DIRECT | PS DIRECT | PS WTA | PS UNI | Users top share | Users n_eff | uncast | live edges | claim |
|---|---|---|---|---|---|---|---|---|---|---|---|
| depth=2 dturn=0.95 peer=0.3 | -0.029 ±0.007 | -0.092 ±0.009 | 0.048 ±0.009 | 0.314 ±0.013 | 0.362 ±0.014 | 0.284 ±0.011 | 0.096 | 45.4 | 0.55 | 0.82 | undecided |
| depth=2 dturn=0.95 peer=0.8 | -0.028 ±0.008 | -0.093 ±0.008 | 0.057 ±0.008 | 0.314 ±0.015 | 0.371 ±0.014 | 0.286 ±0.011 | 0.046 | 63.4 | 0.83 | 0.93 | undecided |
| depth=2 dturn=0.3 peer=0.3 | -0.037 ±0.007 | -0.088 ±0.008 | 0.051 ±0.008 | 0.312 ±0.013 | 0.362 ±0.013 | 0.275 ±0.012 | 0.107 | 37.1 | 0.77 | 0.90 | undecided |
| depth=2 dturn=0.3 peer=0.8 | -0.034 ±0.008 | -0.088 ±0.008 | 0.055 ±0.008 | 0.314 ±0.014 | 0.369 ±0.013 | 0.280 ±0.012 | 0.064 | 45.0 | 0.85 | 0.94 | undecided |
| depth=3 dturn=0.95 peer=0.3 | -0.027 ±0.008 | -0.092 ±0.009 | 0.050 ±0.010 | 0.313 ±0.014 | 0.363 ±0.014 | 0.286 ±0.010 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| depth=3 dturn=0.95 peer=0.8 | -0.028 ±0.007 | -0.094 ±0.009 | 0.056 ±0.007 | 0.314 ±0.015 | 0.369 ±0.014 | 0.286 ±0.012 | 0.051 | 61.6 | 0.81 | 0.92 | undecided |
| depth=3 dturn=0.3 peer=0.3 | -0.033 ±0.007 | -0.090 ±0.009 | 0.056 ±0.008 | 0.308 ±0.013 | 0.364 ±0.012 | 0.275 ±0.012 | 0.109 | 36.7 | 0.76 | 0.90 | undecided |
| depth=3 dturn=0.3 peer=0.8 | -0.034 ±0.008 | -0.089 ±0.008 | 0.057 ±0.008 | 0.313 ±0.014 | 0.370 ±0.012 | 0.279 ±0.013 | 0.070 | 43.7 | 0.84 | 0.93 | undecided |
| depth=4 dturn=0.95 peer=0.3 | -0.027 ±0.007 | -0.091 ±0.009 | 0.050 ±0.010 | 0.312 ±0.014 | 0.362 ±0.015 | 0.286 ±0.011 | 0.103 | 42.9 | 0.50 | 0.81 | undecided |
| depth=4 dturn=0.95 peer=0.8 | -0.028 ±0.008 | -0.091 ±0.008 | 0.056 ±0.008 | 0.314 ±0.015 | 0.370 ±0.014 | 0.286 ±0.011 | 0.052 | 61.1 | 0.81 | 0.92 | undecided |
| depth=4 dturn=0.3 peer=0.3 | -0.037 ±0.007 | -0.090 ±0.008 | 0.050 ±0.008 | 0.313 ±0.013 | 0.363 ±0.012 | 0.275 ±0.012 | 0.110 | 36.6 | 0.76 | 0.90 | undecided |
| depth=4 dturn=0.3 peer=0.8 | -0.035 ±0.009 | -0.090 ±0.007 | 0.056 ±0.007 | 0.313 ±0.014 | 0.369 ±0.013 | 0.278 ±0.013 | 0.072 | 43.3 | 0.83 | 0.93 | undecided |
| depth=6 dturn=0.95 peer=0.3 | -0.029 ±0.007 | -0.094 ±0.009 | 0.049 ±0.010 | 0.313 ±0.014 | 0.363 ±0.015 | 0.284 ±0.011 | 0.103 | 42.9 | 0.50 | 0.81 | undecided |
| depth=6 dturn=0.95 peer=0.8 | -0.027 ±0.008 | -0.092 ±0.008 | 0.057 ±0.007 | 0.313 ±0.015 | 0.370 ±0.014 | 0.285 ±0.011 | 0.053 | 60.9 | 0.81 | 0.92 | undecided |
| depth=6 dturn=0.3 peer=0.3 | -0.037 ±0.007 | -0.089 ±0.008 | 0.049 ±0.008 | 0.312 ±0.013 | 0.362 ±0.012 | 0.275 ±0.012 | 0.110 | 36.5 | 0.76 | 0.90 | undecided |
| depth=6 dturn=0.3 peer=0.8 | -0.035 ±0.008 | -0.087 ±0.008 | 0.058 ±0.008 | 0.316 ±0.014 | 0.374 ±0.013 | 0.281 ±0.012 | 0.073 | 43.2 | 0.83 | 0.93 | undecided |
| depth=None dturn=0.95 peer=0.3 | -0.030 ±0.007 | -0.093 ±0.009 | 0.048 ±0.010 | 0.314 ±0.014 | 0.363 ±0.015 | 0.285 ±0.011 | 0.103 | 42.9 | 0.50 | 0.81 | undecided |
| depth=None dturn=0.95 peer=0.8 | -0.027 ±0.008 | -0.092 ±0.008 | 0.056 ±0.007 | 0.313 ±0.015 | 0.369 ±0.015 | 0.286 ±0.012 | 0.053 | 61.0 | 0.81 | 0.92 | undecided |
| depth=None dturn=0.3 peer=0.3 | -0.036 ±0.007 | -0.090 ±0.009 | 0.051 ±0.008 | 0.312 ±0.013 | 0.362 ±0.012 | 0.276 ±0.012 | 0.110 | 36.5 | 0.76 | 0.90 | undecided |
| depth=None dturn=0.3 peer=0.8 | -0.033 ±0.008 | -0.088 ±0.008 | 0.057 ±0.007 | 0.314 ±0.014 | 0.372 ±0.013 | 0.281 ±0.013 | 0.073 | 43.1 | 0.83 | 0.93 | undecided |
## E3f copy-the-ballot: alpha x cap x copy rate (depth 3, strategic, faction 20)
| cell | dPS UNI-DIRECT | dMT UNI-DIRECT | dPS WTA-DIRECT | PS DIRECT | PS WTA | PS UNI | Users top share | Users n_eff | uncast | live edges | claim |
|---|---|---|---|---|---|---|---|---|---|---|---|
| alpha=0.0 cap=0.1 copy=0.0 | -0.026 ±0.007 | -0.093 ±0.009 | 0.054 ±0.009 | 0.311 ±0.014 | 0.365 ±0.014 | 0.286 ±0.010 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.15 copy=0.0 | -0.025 ±0.007 | -0.093 ±0.009 | 0.052 ±0.009 | 0.312 ±0.014 | 0.364 ±0.014 | 0.286 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.2 copy=0.0 | -0.026 ±0.007 | -0.095 ±0.009 | 0.052 ±0.010 | 0.312 ±0.014 | 0.365 ±0.014 | 0.287 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.25 copy=0.0 | -0.026 ±0.007 | -0.095 ±0.009 | 0.052 ±0.009 | 0.313 ±0.014 | 0.365 ±0.014 | 0.287 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.3 copy=0.0 | -0.026 ±0.007 | -0.095 ±0.009 | 0.052 ±0.009 | 0.313 ±0.014 | 0.365 ±0.014 | 0.287 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=None copy=0.0 | -0.026 ±0.008 | -0.092 ±0.009 | 0.053 ±0.009 | 0.315 ±0.014 | 0.367 ±0.015 | 0.289 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.1 copy=0.5 | -0.028 ±0.007 | -0.091 ±0.009 | 0.049 ±0.009 | 0.314 ±0.014 | 0.364 ±0.014 | 0.286 ±0.011 | 0.045 | 61.3 | 0.52 | 0.81 | undecided |
| alpha=0.0 cap=0.15 copy=0.5 | -0.028 ±0.007 | -0.091 ±0.009 | 0.050 ±0.009 | 0.314 ±0.014 | 0.364 ±0.014 | 0.286 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.2 copy=0.5 | -0.027 ±0.007 | -0.093 ±0.008 | 0.050 ±0.009 | 0.315 ±0.014 | 0.364 ±0.014 | 0.287 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.25 copy=0.5 | -0.026 ±0.008 | -0.092 ±0.009 | 0.051 ±0.009 | 0.313 ±0.014 | 0.364 ±0.014 | 0.287 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.3 copy=0.5 | -0.026 ±0.008 | -0.094 ±0.009 | 0.053 ±0.009 | 0.313 ±0.015 | 0.366 ±0.015 | 0.287 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=None copy=0.5 | -0.026 ±0.008 | -0.092 ±0.009 | 0.053 ±0.009 | 0.315 ±0.014 | 0.367 ±0.015 | 0.289 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.1 copy=1.0 | -0.026 ±0.007 | -0.093 ±0.009 | 0.051 ±0.009 | 0.314 ±0.015 | 0.365 ±0.015 | 0.289 ±0.011 | 0.045 | 61.3 | 0.52 | 0.81 | undecided |
| alpha=0.0 cap=0.15 copy=1.0 | -0.027 ±0.007 | -0.091 ±0.008 | 0.050 ±0.009 | 0.315 ±0.014 | 0.365 ±0.015 | 0.288 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.2 copy=1.0 | -0.026 ±0.007 | -0.092 ±0.009 | 0.050 ±0.009 | 0.315 ±0.014 | 0.365 ±0.014 | 0.289 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.25 copy=1.0 | -0.025 ±0.008 | -0.091 ±0.009 | 0.050 ±0.009 | 0.314 ±0.014 | 0.364 ±0.015 | 0.289 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=0.3 copy=1.0 | -0.025 ±0.008 | -0.093 ±0.009 | 0.053 ±0.008 | 0.313 ±0.015 | 0.367 ±0.015 | 0.289 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.0 cap=None copy=1.0 | -0.026 ±0.008 | -0.092 ±0.009 | 0.053 ±0.009 | 0.315 ±0.014 | 0.367 ±0.015 | 0.289 ±0.011 | 0.045 | 61.3 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.1 copy=0.0 | -0.028 ±0.008 | -0.092 ±0.008 | 0.049 ±0.010 | 0.313 ±0.014 | 0.362 ±0.015 | 0.285 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.15 copy=0.0 | -0.028 ±0.008 | -0.094 ±0.008 | 0.048 ±0.010 | 0.313 ±0.014 | 0.361 ±0.014 | 0.286 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.2 copy=0.0 | -0.028 ±0.008 | -0.094 ±0.008 | 0.049 ±0.010 | 0.313 ±0.014 | 0.362 ±0.014 | 0.285 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.25 copy=0.0 | -0.028 ±0.008 | -0.095 ±0.008 | 0.048 ±0.010 | 0.314 ±0.014 | 0.362 ±0.014 | 0.285 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.3 copy=0.0 | -0.029 ±0.009 | -0.094 ±0.008 | 0.048 ±0.010 | 0.314 ±0.015 | 0.362 ±0.014 | 0.285 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=None copy=0.0 | -0.028 ±0.009 | -0.092 ±0.008 | 0.050 ±0.010 | 0.315 ±0.015 | 0.365 ±0.015 | 0.287 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.1 copy=0.5 | -0.027 ±0.008 | -0.092 ±0.008 | 0.049 ±0.009 | 0.313 ±0.014 | 0.362 ±0.015 | 0.286 ±0.011 | 0.049 | 59.8 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.15 copy=0.5 | -0.028 ±0.008 | -0.092 ±0.009 | 0.048 ±0.010 | 0.314 ±0.014 | 0.362 ±0.015 | 0.286 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.2 copy=0.5 | -0.027 ±0.008 | -0.094 ±0.009 | 0.048 ±0.009 | 0.315 ±0.014 | 0.362 ±0.014 | 0.287 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.25 copy=0.5 | -0.025 ±0.009 | -0.096 ±0.009 | 0.050 ±0.010 | 0.311 ±0.015 | 0.361 ±0.015 | 0.286 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.3 copy=0.5 | -0.028 ±0.009 | -0.094 ±0.009 | 0.050 ±0.009 | 0.314 ±0.014 | 0.364 ±0.015 | 0.286 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=None copy=0.5 | -0.028 ±0.009 | -0.092 ±0.008 | 0.050 ±0.010 | 0.315 ±0.015 | 0.365 ±0.015 | 0.287 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.1 copy=1.0 | -0.024 ±0.008 | -0.093 ±0.008 | 0.050 ±0.009 | 0.313 ±0.014 | 0.362 ±0.015 | 0.288 ±0.011 | 0.049 | 59.8 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.15 copy=1.0 | -0.026 ±0.008 | -0.092 ±0.008 | 0.050 ±0.009 | 0.313 ±0.014 | 0.363 ±0.015 | 0.288 ±0.012 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.2 copy=1.0 | -0.025 ±0.008 | -0.093 ±0.008 | 0.048 ±0.009 | 0.315 ±0.014 | 0.363 ±0.015 | 0.289 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.25 copy=1.0 | -0.024 ±0.009 | -0.094 ±0.009 | 0.050 ±0.009 | 0.311 ±0.015 | 0.362 ±0.015 | 0.288 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=0.3 copy=1.0 | -0.026 ±0.009 | -0.092 ±0.008 | 0.050 ±0.009 | 0.314 ±0.014 | 0.364 ±0.015 | 0.288 ±0.012 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=0.5 cap=None copy=1.0 | -0.028 ±0.009 | -0.092 ±0.008 | 0.050 ±0.010 | 0.315 ±0.015 | 0.365 ±0.015 | 0.287 ±0.011 | 0.049 | 59.7 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.1 copy=0.0 | -0.028 ±0.007 | -0.092 ±0.008 | 0.050 ±0.010 | 0.313 ±0.014 | 0.363 ±0.014 | 0.284 ±0.010 | 0.066 | 53.8 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.15 copy=0.0 | -0.027 ±0.007 | -0.093 ±0.008 | 0.049 ±0.010 | 0.313 ±0.014 | 0.362 ±0.014 | 0.285 ±0.010 | 0.068 | 53.3 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.2 copy=0.0 | -0.028 ±0.007 | -0.093 ±0.009 | 0.050 ±0.010 | 0.313 ±0.014 | 0.363 ±0.014 | 0.285 ±0.010 | 0.068 | 53.3 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.25 copy=0.0 | -0.027 ±0.007 | -0.094 ±0.008 | 0.050 ±0.009 | 0.313 ±0.014 | 0.362 ±0.014 | 0.285 ±0.010 | 0.068 | 53.3 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.3 copy=0.0 | -0.028 ±0.008 | -0.094 ±0.008 | 0.050 ±0.009 | 0.313 ±0.014 | 0.363 ±0.014 | 0.285 ±0.010 | 0.068 | 53.3 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=None copy=0.0 | -0.027 ±0.008 | -0.092 ±0.008 | 0.050 ±0.009 | 0.315 ±0.014 | 0.365 ±0.014 | 0.288 ±0.010 | 0.068 | 53.3 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.1 copy=0.5 | -0.030 ±0.008 | -0.089 ±0.010 | 0.050 ±0.009 | 0.314 ±0.014 | 0.364 ±0.014 | 0.284 ±0.011 | 0.063 | 55.2 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.15 copy=0.5 | -0.030 ±0.008 | -0.093 ±0.009 | 0.047 ±0.009 | 0.315 ±0.014 | 0.362 ±0.014 | 0.285 ±0.011 | 0.068 | 53.4 | 0.52 | 0.81 | undecided |
| alpha=1.0 cap=0.2 copy=0.5 | -0.027 ±0.007 | -0.093 ±0.009 | 0.047 ±0.009 | 0.315 ±0.014 | 0.362 ±0.014 | 0.288 ±0.010 | 0.068 | 53.3 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.25 copy=0.5 | -0.026 ±0.008 | -0.093 ±0.009 | 0.049 ±0.009 | 0.313 ±0.014 | 0.362 ±0.015 | 0.286 ±0.011 | 0.068 | 53.3 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.3 copy=0.5 | -0.026 ±0.007 | -0.093 ±0.009 | 0.050 ±0.009 | 0.313 ±0.014 | 0.363 ±0.014 | 0.287 ±0.011 | 0.068 | 53.3 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=None copy=0.5 | -0.027 ±0.008 | -0.092 ±0.008 | 0.050 ±0.009 | 0.315 ±0.014 | 0.365 ±0.014 | 0.288 ±0.010 | 0.068 | 53.3 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.1 copy=1.0 | -0.027 ±0.008 | -0.090 ±0.009 | 0.050 ±0.008 | 0.315 ±0.014 | 0.365 ±0.015 | 0.287 ±0.011 | 0.061 | 56.5 | 0.52 | 0.81 | undecided |
| alpha=1.0 cap=0.15 copy=1.0 | -0.027 ±0.008 | -0.093 ±0.009 | 0.048 ±0.009 | 0.315 ±0.014 | 0.362 ±0.014 | 0.287 ±0.011 | 0.068 | 53.5 | 0.52 | 0.81 | undecided |
| alpha=1.0 cap=0.2 copy=1.0 | -0.025 ±0.008 | -0.093 ±0.009 | 0.049 ±0.008 | 0.315 ±0.014 | 0.363 ±0.014 | 0.290 ±0.011 | 0.068 | 53.3 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.25 copy=1.0 | -0.025 ±0.008 | -0.092 ±0.009 | 0.050 ±0.009 | 0.313 ±0.014 | 0.363 ±0.015 | 0.288 ±0.011 | 0.068 | 53.3 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=0.3 copy=1.0 | -0.024 ±0.007 | -0.092 ±0.009 | 0.050 ±0.008 | 0.313 ±0.014 | 0.364 ±0.014 | 0.289 ±0.011 | 0.068 | 53.3 | 0.51 | 0.81 | undecided |
| alpha=1.0 cap=None copy=1.0 | -0.027 ±0.008 | -0.092 ±0.008 | 0.050 ±0.009 | 0.315 ±0.014 | 0.365 ±0.014 | 0.288 ±0.010 | 0.068 | 53.3 | 0.51 | 0.81 | undecided |
| alpha=1.5 cap=0.1 copy=0.0 | -0.026 ±0.008 | -0.092 ±0.009 | 0.053 ±0.010 | 0.312 ±0.014 | 0.365 ±0.014 | 0.286 ±0.010 | 0.088 | 46.8 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=0.15 copy=0.0 | -0.026 ±0.008 | -0.092 ±0.009 | 0.050 ±0.010 | 0.313 ±0.014 | 0.363 ±0.014 | 0.287 ±0.010 | 0.100 | 43.5 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=0.2 copy=0.0 | -0.027 ±0.008 | -0.092 ±0.009 | 0.050 ±0.010 | 0.313 ±0.014 | 0.363 ±0.014 | 0.286 ±0.010 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=0.25 copy=0.0 | -0.027 ±0.008 | -0.092 ±0.009 | 0.050 ±0.010 | 0.313 ±0.014 | 0.363 ±0.014 | 0.286 ±0.010 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=0.3 copy=0.0 | -0.028 ±0.008 | -0.092 ±0.008 | 0.049 ±0.010 | 0.314 ±0.014 | 0.363 ±0.014 | 0.286 ±0.010 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=None copy=0.0 | -0.027 ±0.009 | -0.090 ±0.009 | 0.051 ±0.009 | 0.315 ±0.014 | 0.366 ±0.014 | 0.288 ±0.011 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=0.1 copy=0.5 | -0.028 ±0.008 | -0.089 ±0.009 | 0.048 ±0.008 | 0.315 ±0.014 | 0.362 ±0.014 | 0.286 ±0.011 | 0.072 | 53.9 | 0.54 | 0.81 | undecided |
| alpha=1.5 cap=0.15 copy=0.5 | -0.027 ±0.008 | -0.092 ±0.008 | 0.048 ±0.008 | 0.314 ±0.014 | 0.362 ±0.015 | 0.287 ±0.012 | 0.091 | 46.4 | 0.52 | 0.81 | undecided |
| alpha=1.5 cap=0.2 copy=0.5 | -0.027 ±0.008 | -0.093 ±0.008 | 0.047 ±0.009 | 0.315 ±0.014 | 0.363 ±0.015 | 0.288 ±0.011 | 0.100 | 44.0 | 0.51 | 0.81 | undecided |
| alpha=1.5 cap=0.25 copy=0.5 | -0.026 ±0.009 | -0.091 ±0.009 | 0.050 ±0.010 | 0.313 ±0.014 | 0.363 ±0.014 | 0.288 ±0.011 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=0.3 copy=0.5 | -0.026 ±0.009 | -0.092 ±0.009 | 0.049 ±0.010 | 0.314 ±0.015 | 0.363 ±0.014 | 0.288 ±0.010 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=None copy=0.5 | -0.027 ±0.009 | -0.090 ±0.009 | 0.051 ±0.009 | 0.315 ±0.014 | 0.366 ±0.014 | 0.288 ±0.011 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=0.1 copy=1.0 | -0.026 ±0.008 | -0.090 ±0.008 | 0.047 ±0.008 | 0.315 ±0.014 | 0.363 ±0.015 | 0.290 ±0.011 | 0.062 | 61.5 | 0.58 | 0.81 | undecided |
| alpha=1.5 cap=0.15 copy=1.0 | -0.025 ±0.008 | -0.092 ±0.008 | 0.050 ±0.008 | 0.313 ±0.014 | 0.363 ±0.015 | 0.288 ±0.012 | 0.087 | 49.1 | 0.53 | 0.81 | undecided |
| alpha=1.5 cap=0.2 copy=1.0 | -0.026 ±0.008 | -0.092 ±0.008 | 0.048 ±0.009 | 0.315 ±0.014 | 0.364 ±0.015 | 0.290 ±0.011 | 0.099 | 44.4 | 0.52 | 0.81 | undecided |
| alpha=1.5 cap=0.25 copy=1.0 | -0.024 ±0.009 | -0.091 ±0.009 | 0.051 ±0.010 | 0.314 ±0.014 | 0.364 ±0.014 | 0.289 ±0.011 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=0.3 copy=1.0 | -0.023 ±0.008 | -0.091 ±0.008 | 0.050 ±0.009 | 0.313 ±0.014 | 0.364 ±0.014 | 0.290 ±0.011 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| alpha=1.5 cap=None copy=1.0 | -0.027 ±0.009 | -0.090 ±0.009 | 0.051 ±0.009 | 0.315 ±0.014 | 0.366 ±0.014 | 0.288 ±0.011 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| alpha=2.5 cap=0.1 copy=0.0 | -0.029 ±0.007 | -0.095 ±0.009 | 0.052 ±0.010 | 0.313 ±0.014 | 0.365 ±0.014 | 0.284 ±0.011 | 0.102 | 43.8 | 0.51 | 0.81 | undecided |
| alpha=2.5 cap=0.15 copy=0.0 | -0.028 ±0.008 | -0.096 ±0.008 | 0.049 ±0.009 | 0.314 ±0.014 | 0.363 ±0.014 | 0.286 ±0.011 | 0.135 | 34.8 | 0.51 | 0.81 | undecided |
| alpha=2.5 cap=0.2 copy=0.0 | -0.030 ±0.008 | -0.095 ±0.008 | 0.046 ±0.009 | 0.315 ±0.014 | 0.361 ±0.015 | 0.285 ±0.011 | 0.151 | 31.9 | 0.51 | 0.81 | undecided |
| alpha=2.5 cap=0.25 copy=0.0 | -0.031 ±0.008 | -0.096 ±0.008 | 0.046 ±0.009 | 0.316 ±0.014 | 0.361 ±0.014 | 0.285 ±0.011 | 0.156 | 31.3 | 0.51 | 0.81 | undecided |
| alpha=2.5 cap=0.3 copy=0.0 | -0.031 ±0.008 | -0.096 ±0.008 | 0.045 ±0.009 | 0.316 ±0.014 | 0.361 ±0.014 | 0.284 ±0.011 | 0.158 | 31.2 | 0.51 | 0.81 | undecided |
| alpha=2.5 cap=None copy=0.0 | -0.030 ±0.008 | -0.094 ±0.008 | 0.047 ±0.008 | 0.317 ±0.014 | 0.363 ±0.014 | 0.287 ±0.011 | 0.158 | 31.2 | 0.51 | 0.81 | undecided |
| alpha=2.5 cap=0.1 copy=0.5 | -0.027 ±0.007 | -0.089 ±0.009 | 0.050 ±0.009 | 0.314 ±0.014 | 0.364 ±0.014 | 0.287 ±0.011 | 0.080 | 55.2 | 0.59 | 0.81 | undecided |
| alpha=2.5 cap=0.15 copy=0.5 | -0.025 ±0.008 | -0.090 ±0.008 | 0.049 ±0.008 | 0.312 ±0.015 | 0.361 ±0.014 | 0.287 ±0.011 | 0.102 | 46.4 | 0.57 | 0.81 | undecided |
| alpha=2.5 cap=0.2 copy=0.5 | -0.026 ±0.007 | -0.091 ±0.008 | 0.050 ±0.009 | 0.314 ±0.014 | 0.364 ±0.014 | 0.288 ±0.011 | 0.131 | 37.3 | 0.54 | 0.81 | undecided |
| alpha=2.5 cap=0.25 copy=0.5 | -0.026 ±0.008 | -0.091 ±0.010 | 0.048 ±0.008 | 0.314 ±0.014 | 0.362 ±0.014 | 0.287 ±0.011 | 0.150 | 32.6 | 0.52 | 0.81 | undecided |
| alpha=2.5 cap=0.3 copy=0.5 | -0.027 ±0.008 | -0.090 ±0.009 | 0.048 ±0.008 | 0.314 ±0.014 | 0.362 ±0.014 | 0.287 ±0.011 | 0.156 | 31.5 | 0.51 | 0.81 | undecided |
| alpha=2.5 cap=None copy=0.5 | -0.030 ±0.008 | -0.094 ±0.008 | 0.047 ±0.008 | 0.317 ±0.014 | 0.363 ±0.014 | 0.287 ±0.011 | 0.158 | 31.2 | 0.51 | 0.81 | undecided |
| alpha=2.5 cap=0.1 copy=1.0 | -0.026 ±0.008 | -0.089 ±0.009 | 0.049 ±0.009 | 0.314 ±0.014 | 0.363 ±0.014 | 0.289 ±0.011 | 0.047 | 77.9 | 0.71 | 0.81 | undecided |
| alpha=2.5 cap=0.15 copy=1.0 | -0.024 ±0.008 | -0.091 ±0.008 | 0.050 ±0.008 | 0.312 ±0.015 | 0.362 ±0.014 | 0.288 ±0.012 | 0.072 | 65.4 | 0.65 | 0.81 | undecided |
| alpha=2.5 cap=0.2 copy=1.0 | -0.024 ±0.008 | -0.091 ±0.008 | 0.051 ±0.008 | 0.314 ±0.014 | 0.366 ±0.014 | 0.290 ±0.012 | 0.114 | 46.9 | 0.57 | 0.81 | undecided |
| alpha=2.5 cap=0.25 copy=1.0 | -0.024 ±0.008 | -0.090 ±0.009 | 0.049 ±0.008 | 0.314 ±0.014 | 0.363 ±0.014 | 0.290 ±0.011 | 0.144 | 35.9 | 0.53 | 0.81 | undecided |
| alpha=2.5 cap=0.3 copy=1.0 | -0.024 ±0.008 | -0.090 ±0.008 | 0.049 ±0.008 | 0.313 ±0.013 | 0.362 ±0.014 | 0.289 ±0.011 | 0.154 | 32.4 | 0.52 | 0.81 | undecided |
| alpha=2.5 cap=None copy=1.0 | -0.030 ±0.008 | -0.094 ±0.008 | 0.047 ±0.008 | 0.317 ±0.014 | 0.363 ±0.014 | 0.287 ±0.011 | 0.158 | 31.2 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=0.1 copy=0.0 | -0.025 ±0.008 | -0.095 ±0.008 | 0.058 ±0.010 | 0.311 ±0.014 | 0.369 ±0.014 | 0.286 ±0.010 | 0.107 | 44.0 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=0.15 copy=0.0 | -0.026 ±0.008 | -0.095 ±0.008 | 0.054 ±0.010 | 0.312 ±0.014 | 0.367 ±0.014 | 0.287 ±0.010 | 0.147 | 32.5 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=0.2 copy=0.0 | -0.027 ±0.008 | -0.093 ±0.008 | 0.052 ±0.010 | 0.313 ±0.013 | 0.365 ±0.014 | 0.286 ±0.011 | 0.173 | 27.8 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=0.25 copy=0.0 | -0.026 ±0.008 | -0.092 ±0.008 | 0.052 ±0.010 | 0.313 ±0.013 | 0.365 ±0.014 | 0.287 ±0.011 | 0.185 | 26.4 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=0.3 copy=0.0 | -0.028 ±0.008 | -0.091 ±0.008 | 0.050 ±0.010 | 0.314 ±0.013 | 0.364 ±0.014 | 0.286 ±0.011 | 0.189 | 26.2 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=None copy=0.0 | -0.027 ±0.008 | -0.089 ±0.009 | 0.051 ±0.010 | 0.315 ±0.014 | 0.366 ±0.014 | 0.288 ±0.011 | 0.190 | 26.1 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=0.1 copy=0.5 | -0.026 ±0.008 | -0.094 ±0.009 | 0.049 ±0.009 | 0.312 ±0.014 | 0.361 ±0.014 | 0.286 ±0.011 | 0.085 | 56.1 | 0.62 | 0.81 | undecided |
| alpha=4.0 cap=0.15 copy=0.5 | -0.028 ±0.008 | -0.089 ±0.008 | 0.046 ±0.009 | 0.315 ±0.015 | 0.361 ±0.014 | 0.287 ±0.011 | 0.105 | 47.9 | 0.60 | 0.81 | undecided |
| alpha=4.0 cap=0.2 copy=0.5 | -0.028 ±0.008 | -0.092 ±0.008 | 0.048 ±0.010 | 0.315 ±0.014 | 0.363 ±0.014 | 0.287 ±0.011 | 0.133 | 38.5 | 0.57 | 0.81 | undecided |
| alpha=4.0 cap=0.25 copy=0.5 | -0.028 ±0.007 | -0.091 ±0.008 | 0.047 ±0.009 | 0.315 ±0.013 | 0.363 ±0.014 | 0.288 ±0.011 | 0.163 | 30.6 | 0.54 | 0.81 | undecided |
| alpha=4.0 cap=0.3 copy=0.5 | -0.026 ±0.008 | -0.092 ±0.009 | 0.048 ±0.009 | 0.314 ±0.014 | 0.362 ±0.013 | 0.288 ±0.011 | 0.180 | 27.4 | 0.52 | 0.81 | undecided |
| alpha=4.0 cap=None copy=0.5 | -0.027 ±0.008 | -0.089 ±0.009 | 0.051 ±0.010 | 0.315 ±0.014 | 0.366 ±0.014 | 0.288 ±0.011 | 0.190 | 26.1 | 0.51 | 0.81 | undecided |
| alpha=4.0 cap=0.1 copy=1.0 | -0.025 ±0.008 | -0.095 ±0.009 | 0.047 ±0.009 | 0.314 ±0.014 | 0.361 ±0.014 | 0.289 ±0.011 | 0.034 | 89.0 | 0.80 | 0.81 | undecided |
| alpha=4.0 cap=0.15 copy=1.0 | -0.026 ±0.008 | -0.090 ±0.008 | 0.048 ±0.009 | 0.315 ±0.015 | 0.363 ±0.014 | 0.289 ±0.011 | 0.050 | 80.8 | 0.75 | 0.81 | undecided |
| alpha=4.0 cap=0.2 copy=1.0 | -0.026 ±0.008 | -0.091 ±0.008 | 0.049 ±0.009 | 0.314 ±0.014 | 0.364 ±0.014 | 0.289 ±0.011 | 0.094 | 61.4 | 0.65 | 0.81 | undecided |
| alpha=4.0 cap=0.25 copy=1.0 | -0.026 ±0.007 | -0.090 ±0.008 | 0.047 ±0.008 | 0.315 ±0.013 | 0.363 ±0.014 | 0.289 ±0.011 | 0.144 | 41.7 | 0.57 | 0.81 | undecided |
| alpha=4.0 cap=0.3 copy=1.0 | -0.025 ±0.008 | -0.091 ±0.008 | 0.048 ±0.009 | 0.315 ±0.014 | 0.362 ±0.014 | 0.290 ±0.011 | 0.173 | 31.6 | 0.53 | 0.81 | undecided |
| alpha=4.0 cap=None copy=1.0 | -0.027 ±0.008 | -0.089 ±0.009 | 0.051 ±0.010 | 0.315 ±0.014 | 0.366 ±0.014 | 0.288 ±0.011 | 0.190 | 26.1 | 0.51 | 0.81 | undecided |
**Does the cap reduce effective concentration? Effective Users top-holder share at cap 0.1 minus no cap, paired by replicate; 'reduces' iff the 95% interval is entirely below -0.01.**
| alpha | copy | effective top share, cap - none | verdict | formal top share, cap - none |
|---|---|---|---|---|
| 0.0 | 0.0 | -0.000 ±0.000 | does not reduce | -0.000 |
| 0.0 | 0.5 | -0.000 ±0.000 | does not reduce | -0.000 |
| 0.0 | 1.0 | -0.000 ±0.000 | does not reduce | -0.000 |
| 0.5 | 0.0 | -0.000 ±0.000 | does not reduce | -0.000 |
| 0.5 | 0.5 | 0.000 ±0.000 | does not reduce | -0.000 |
| 0.5 | 1.0 | 0.000 ±0.000 | does not reduce | -0.000 |
| 1.0 | 0.0 | -0.002 ±0.000 | does not reduce | -0.002 |
| 1.0 | 0.5 | 0.001 ±0.001 | does not reduce | -0.005 |
| 1.0 | 1.0 | 0.001 ±0.001 | does not reduce | -0.007 |
| 1.5 | 0.0 | -0.015 ±0.001 | reduces | -0.015 |
| 1.5 | 0.5 | -0.000 ±0.002 | does not reduce | -0.031 |
| 1.5 | 1.0 | 0.000 ±0.002 | does not reduce | -0.041 |
| 2.5 | 0.0 | -0.055 ±0.003 | reduces | -0.055 |
| 2.5 | 0.5 | -0.003 ±0.002 | does not reduce | -0.078 |
| 2.5 | 1.0 | 0.001 ±0.002 | does not reduce | -0.111 |
| 4.0 | 0.0 | -0.083 ±0.003 | reduces | -0.083 |
| 4.0 | 0.5 | -0.008 ±0.002 | does not reduce | -0.105 |
| 4.0 | 1.0 | 0.001 ±0.002 | does not reduce | -0.156 |
Reading, computed from the rows above: the cap reduces effective concentration in 3 of 6 alpha values at copy rate 0.0; 0 of 6 alpha values at copy rate 0.5; 0 of 6 alpha values at copy rate 1.0. Where it reduces at copy 0 and not at copy 1, the formal top share falls (last column) while the weight follows the same voters through their copiers, so the cap moves weight from the holder's ledger to its followers' ballots without changing whose ballot it is.
**Users top-holder share, formal (copiers are separate weight-1 ballots)**
| alpha | copy | 0.1 | 0.15 | 0.2 | 0.25 | 0.3 | none |
|---|---|---|---|---|---|---|---|
| 0.0 | 0.0 | 0.045 | 0.045 | 0.045 | 0.045 | 0.045 | 0.045 |
| 0.0 | 0.5 | 0.045 | 0.045 | 0.045 | 0.045 | 0.045 | 0.045 |
| 0.0 | 1.0 | 0.045 | 0.045 | 0.045 | 0.045 | 0.045 | 0.045 |
| 0.5 | 0.0 | 0.049 | 0.049 | 0.049 | 0.049 | 0.049 | 0.049 |
| 0.5 | 0.5 | 0.049 | 0.049 | 0.049 | 0.049 | 0.049 | 0.049 |
| 0.5 | 1.0 | 0.049 | 0.049 | 0.049 | 0.049 | 0.049 | 0.049 |
| 1.0 | 0.0 | 0.066 | 0.068 | 0.068 | 0.068 | 0.068 | 0.068 |
| 1.0 | 0.5 | 0.063 | 0.068 | 0.068 | 0.068 | 0.068 | 0.068 |
| 1.0 | 1.0 | 0.061 | 0.068 | 0.068 | 0.068 | 0.068 | 0.068 |
| 1.5 | 0.0 | 0.088 | 0.100 | 0.103 | 0.103 | 0.103 | 0.103 |
| 1.5 | 0.5 | 0.072 | 0.091 | 0.100 | 0.103 | 0.103 | 0.103 |
| 1.5 | 1.0 | 0.062 | 0.087 | 0.099 | 0.103 | 0.103 | 0.103 |
| 2.5 | 0.0 | 0.102 | 0.135 | 0.151 | 0.156 | 0.158 | 0.158 |
| 2.5 | 0.5 | 0.080 | 0.102 | 0.131 | 0.150 | 0.156 | 0.158 |
| 2.5 | 1.0 | 0.047 | 0.072 | 0.114 | 0.144 | 0.154 | 0.158 |
| 4.0 | 0.0 | 0.107 | 0.147 | 0.173 | 0.185 | 0.189 | 0.190 |
| 4.0 | 0.5 | 0.085 | 0.105 | 0.133 | 0.163 | 0.180 | 0.190 |
| 4.0 | 1.0 | 0.034 | 0.050 | 0.094 | 0.144 | 0.173 | 0.190 |
**Users top-holder share, effective (copiers counted to the voter they copied)**
| alpha | copy | 0.1 | 0.15 | 0.2 | 0.25 | 0.3 | none |
|---|---|---|---|---|---|---|---|
| 0.0 | 0.0 | 0.045 | 0.045 | 0.045 | 0.045 | 0.045 | 0.045 |
| 0.0 | 0.5 | 0.045 | 0.045 | 0.045 | 0.045 | 0.045 | 0.045 |
| 0.0 | 1.0 | 0.045 | 0.045 | 0.045 | 0.045 | 0.045 | 0.045 |
| 0.5 | 0.0 | 0.049 | 0.049 | 0.049 | 0.049 | 0.049 | 0.049 |
| 0.5 | 0.5 | 0.049 | 0.049 | 0.049 | 0.049 | 0.049 | 0.049 |
| 0.5 | 1.0 | 0.049 | 0.049 | 0.049 | 0.049 | 0.049 | 0.049 |
| 1.0 | 0.0 | 0.066 | 0.068 | 0.068 | 0.068 | 0.068 | 0.068 |
| 1.0 | 0.5 | 0.069 | 0.068 | 0.068 | 0.068 | 0.068 | 0.068 |
| 1.0 | 1.0 | 0.069 | 0.068 | 0.068 | 0.068 | 0.068 | 0.068 |
| 1.5 | 0.0 | 0.088 | 0.100 | 0.103 | 0.103 | 0.103 | 0.103 |
| 1.5 | 0.5 | 0.102 | 0.102 | 0.101 | 0.103 | 0.103 | 0.103 |
| 1.5 | 1.0 | 0.103 | 0.102 | 0.101 | 0.103 | 0.103 | 0.103 |
| 2.5 | 0.0 | 0.102 | 0.135 | 0.151 | 0.156 | 0.158 | 0.158 |
| 2.5 | 0.5 | 0.155 | 0.159 | 0.159 | 0.159 | 0.158 | 0.158 |
| 2.5 | 1.0 | 0.159 | 0.160 | 0.159 | 0.159 | 0.158 | 0.158 |
| 4.0 | 0.0 | 0.107 | 0.147 | 0.173 | 0.185 | 0.189 | 0.190 |
| 4.0 | 0.5 | 0.182 | 0.191 | 0.191 | 0.192 | 0.190 | 0.190 |
| 4.0 | 1.0 | 0.191 | 0.192 | 0.191 | 0.192 | 0.190 | 0.190 |
**Users n_eff, effective**
| alpha | copy | 0.1 | 0.15 | 0.2 | 0.25 | 0.3 | none |
|---|---|---|---|---|---|---|---|
| 0.0 | 0.0 | 61.3 | 61.3 | 61.3 | 61.3 | 61.3 | 61.3 |
| 0.0 | 0.5 | 61.3 | 61.3 | 61.3 | 61.3 | 61.3 | 61.3 |
| 0.0 | 1.0 | 61.3 | 61.3 | 61.3 | 61.3 | 61.3 | 61.3 |
| 0.5 | 0.0 | 59.7 | 59.7 | 59.7 | 59.7 | 59.7 | 59.7 |
| 0.5 | 0.5 | 59.7 | 59.7 | 59.7 | 59.7 | 59.7 | 59.7 |
| 0.5 | 1.0 | 59.7 | 59.7 | 59.7 | 59.7 | 59.7 | 59.7 |
| 1.0 | 0.0 | 53.8 | 53.3 | 53.3 | 53.3 | 53.3 | 53.3 |
| 1.0 | 0.5 | 53.1 | 53.3 | 53.3 | 53.3 | 53.3 | 53.3 |
| 1.0 | 1.0 | 53.1 | 53.3 | 53.3 | 53.3 | 53.3 | 53.3 |
| 1.5 | 0.0 | 46.8 | 43.5 | 43.1 | 43.1 | 43.1 | 43.1 |
| 1.5 | 0.5 | 43.2 | 43.4 | 43.6 | 43.1 | 43.1 | 43.1 |
| 1.5 | 1.0 | 43.1 | 43.4 | 43.6 | 43.1 | 43.1 | 43.1 |
| 2.5 | 0.0 | 43.8 | 34.8 | 31.9 | 31.3 | 31.2 | 31.2 |
| 2.5 | 0.5 | 31.5 | 30.9 | 31.0 | 31.0 | 31.2 | 31.2 |
| 2.5 | 1.0 | 30.9 | 30.9 | 31.0 | 31.0 | 31.2 | 31.2 |
| 4.0 | 0.0 | 44.0 | 32.5 | 27.8 | 26.4 | 26.2 | 26.1 |
| 4.0 | 0.5 | 27.0 | 25.9 | 26.0 | 25.9 | 26.1 | 26.1 |
| 4.0 | 1.0 | 26.0 | 25.8 | 26.0 | 25.9 | 26.1 | 26.1 |
**copiers as a share of delegated votes**
| alpha | copy | 0.1 | 0.15 | 0.2 | 0.25 | 0.3 | none |
|---|---|---|---|---|---|---|---|
| 0.0 | 0.0 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
| 0.0 | 0.5 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
| 0.0 | 1.0 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
| 0.5 | 0.0 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
| 0.5 | 0.5 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
| 0.5 | 1.0 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
| 1.0 | 0.0 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
| 1.0 | 0.5 | 0.01 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
| 1.0 | 1.0 | 0.02 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
| 1.5 | 0.0 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
| 1.5 | 0.5 | 0.07 | 0.02 | 0.00 | 0.00 | 0.00 | 0.00 |
| 1.5 | 1.0 | 0.14 | 0.04 | 0.01 | 0.00 | 0.00 | 0.00 |
| 2.5 | 0.0 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
| 2.5 | 0.5 | 0.17 | 0.12 | 0.06 | 0.02 | 0.01 | 0.00 |
| 2.5 | 1.0 | 0.40 | 0.28 | 0.12 | 0.04 | 0.01 | 0.00 |
| 4.0 | 0.0 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
| 4.0 | 0.5 | 0.22 | 0.20 | 0.13 | 0.06 | 0.02 | 0.00 |
| 4.0 | 1.0 | 0.57 | 0.49 | 0.29 | 0.12 | 0.04 | 0.00 |
**dPS UNI-DIRECT**
| alpha | copy | 0.1 | 0.15 | 0.2 | 0.25 | 0.3 | none |
|---|---|---|---|---|---|---|---|
| 0.0 | 0.0 | -0.026 ±0.007 | -0.025 ±0.007 | -0.026 ±0.007 | -0.026 ±0.007 | -0.026 ±0.007 | -0.026 ±0.008 |
| 0.0 | 0.5 | -0.028 ±0.007 | -0.028 ±0.007 | -0.027 ±0.007 | -0.026 ±0.008 | -0.026 ±0.008 | -0.026 ±0.008 |
| 0.0 | 1.0 | -0.026 ±0.007 | -0.027 ±0.007 | -0.026 ±0.007 | -0.025 ±0.008 | -0.025 ±0.008 | -0.026 ±0.008 |
| 0.5 | 0.0 | -0.028 ±0.008 | -0.028 ±0.008 | -0.028 ±0.008 | -0.028 ±0.008 | -0.029 ±0.009 | -0.028 ±0.009 |
| 0.5 | 0.5 | -0.027 ±0.008 | -0.028 ±0.008 | -0.027 ±0.008 | -0.025 ±0.009 | -0.028 ±0.009 | -0.028 ±0.009 |
| 0.5 | 1.0 | -0.024 ±0.008 | -0.026 ±0.008 | -0.025 ±0.008 | -0.024 ±0.009 | -0.026 ±0.009 | -0.028 ±0.009 |
| 1.0 | 0.0 | -0.028 ±0.007 | -0.027 ±0.007 | -0.028 ±0.007 | -0.027 ±0.007 | -0.028 ±0.008 | -0.027 ±0.008 |
| 1.0 | 0.5 | -0.030 ±0.008 | -0.030 ±0.008 | -0.027 ±0.007 | -0.026 ±0.008 | -0.026 ±0.007 | -0.027 ±0.008 |
| 1.0 | 1.0 | -0.027 ±0.008 | -0.027 ±0.008 | -0.025 ±0.008 | -0.025 ±0.008 | -0.024 ±0.007 | -0.027 ±0.008 |
| 1.5 | 0.0 | -0.026 ±0.008 | -0.026 ±0.008 | -0.027 ±0.008 | -0.027 ±0.008 | -0.028 ±0.008 | -0.027 ±0.009 |
| 1.5 | 0.5 | -0.028 ±0.008 | -0.027 ±0.008 | -0.027 ±0.008 | -0.026 ±0.009 | -0.026 ±0.009 | -0.027 ±0.009 |
| 1.5 | 1.0 | -0.026 ±0.008 | -0.025 ±0.008 | -0.026 ±0.008 | -0.024 ±0.009 | -0.023 ±0.008 | -0.027 ±0.009 |
| 2.5 | 0.0 | -0.029 ±0.007 | -0.028 ±0.008 | -0.030 ±0.008 | -0.031 ±0.008 | -0.031 ±0.008 | -0.030 ±0.008 |
| 2.5 | 0.5 | -0.027 ±0.007 | -0.025 ±0.008 | -0.026 ±0.007 | -0.026 ±0.008 | -0.027 ±0.008 | -0.030 ±0.008 |
| 2.5 | 1.0 | -0.026 ±0.008 | -0.024 ±0.008 | -0.024 ±0.008 | -0.024 ±0.008 | -0.024 ±0.008 | -0.030 ±0.008 |
| 4.0 | 0.0 | -0.025 ±0.008 | -0.026 ±0.008 | -0.027 ±0.008 | -0.026 ±0.008 | -0.028 ±0.008 | -0.027 ±0.008 |
| 4.0 | 0.5 | -0.026 ±0.008 | -0.028 ±0.008 | -0.028 ±0.008 | -0.028 ±0.007 | -0.026 ±0.008 | -0.027 ±0.008 |
| 4.0 | 1.0 | -0.025 ±0.008 | -0.026 ±0.008 | -0.026 ±0.008 | -0.026 ±0.007 | -0.025 ±0.008 | -0.027 ±0.008 |
**PS UNI**
| alpha | copy | 0.1 | 0.15 | 0.2 | 0.25 | 0.3 | none |
|---|---|---|---|---|---|---|---|
| 0.0 | 0.0 | 0.286 ±0.010 | 0.286 ±0.011 | 0.287 ±0.011 | 0.287 ±0.011 | 0.287 ±0.011 | 0.289 ±0.011 |
| 0.0 | 0.5 | 0.286 ±0.011 | 0.286 ±0.011 | 0.287 ±0.011 | 0.287 ±0.011 | 0.287 ±0.011 | 0.289 ±0.011 |
| 0.0 | 1.0 | 0.289 ±0.011 | 0.288 ±0.011 | 0.289 ±0.011 | 0.289 ±0.011 | 0.289 ±0.011 | 0.289 ±0.011 |
| 0.5 | 0.0 | 0.285 ±0.011 | 0.286 ±0.011 | 0.285 ±0.011 | 0.285 ±0.011 | 0.285 ±0.011 | 0.287 ±0.011 |
| 0.5 | 0.5 | 0.286 ±0.011 | 0.286 ±0.011 | 0.287 ±0.011 | 0.286 ±0.011 | 0.286 ±0.011 | 0.287 ±0.011 |
| 0.5 | 1.0 | 0.288 ±0.011 | 0.288 ±0.012 | 0.289 ±0.011 | 0.288 ±0.011 | 0.288 ±0.012 | 0.287 ±0.011 |
| 1.0 | 0.0 | 0.284 ±0.010 | 0.285 ±0.010 | 0.285 ±0.010 | 0.285 ±0.010 | 0.285 ±0.010 | 0.288 ±0.010 |
| 1.0 | 0.5 | 0.284 ±0.011 | 0.285 ±0.011 | 0.288 ±0.010 | 0.286 ±0.011 | 0.287 ±0.011 | 0.288 ±0.010 |
| 1.0 | 1.0 | 0.287 ±0.011 | 0.287 ±0.011 | 0.290 ±0.011 | 0.288 ±0.011 | 0.289 ±0.011 | 0.288 ±0.010 |
| 1.5 | 0.0 | 0.286 ±0.010 | 0.287 ±0.010 | 0.286 ±0.010 | 0.286 ±0.010 | 0.286 ±0.010 | 0.288 ±0.011 |
| 1.5 | 0.5 | 0.286 ±0.011 | 0.287 ±0.012 | 0.288 ±0.011 | 0.288 ±0.011 | 0.288 ±0.010 | 0.288 ±0.011 |
| 1.5 | 1.0 | 0.290 ±0.011 | 0.288 ±0.012 | 0.290 ±0.011 | 0.289 ±0.011 | 0.290 ±0.011 | 0.288 ±0.011 |
| 2.5 | 0.0 | 0.284 ±0.011 | 0.286 ±0.011 | 0.285 ±0.011 | 0.285 ±0.011 | 0.284 ±0.011 | 0.287 ±0.011 |
| 2.5 | 0.5 | 0.287 ±0.011 | 0.287 ±0.011 | 0.288 ±0.011 | 0.287 ±0.011 | 0.287 ±0.011 | 0.287 ±0.011 |
| 2.5 | 1.0 | 0.289 ±0.011 | 0.288 ±0.012 | 0.290 ±0.012 | 0.290 ±0.011 | 0.289 ±0.011 | 0.287 ±0.011 |
| 4.0 | 0.0 | 0.286 ±0.010 | 0.287 ±0.010 | 0.286 ±0.011 | 0.287 ±0.011 | 0.286 ±0.011 | 0.288 ±0.011 |
| 4.0 | 0.5 | 0.286 ±0.011 | 0.287 ±0.011 | 0.287 ±0.011 | 0.288 ±0.011 | 0.288 ±0.011 | 0.288 ±0.011 |
| 4.0 | 1.0 | 0.289 ±0.011 | 0.289 ±0.011 | 0.289 ±0.011 | 0.289 ±0.011 | 0.290 ±0.011 | 0.288 ±0.011 |
## E3e revocation renewal: how fast revoked delegators re-delegate (alpha=1.5, cap=.2, depth 3, strategic, faction 20)
| cell | dPS UNI-DIRECT | dMT UNI-DIRECT | dPS WTA-DIRECT | PS DIRECT | PS WTA | PS UNI | Users top share | Users n_eff | uncast | live edges | claim |
|---|---|---|---|---|---|---|---|---|---|---|---|
| renew=0.0 | -0.031 ±0.008 | -0.095 ±0.008 | 0.049 ±0.009 | 0.320 ±0.015 | 0.370 ±0.013 | 0.289 ±0.011 | 0.026 | 71.1 | 0.66 | 0.13 | undecided |
| renew=0.02 | -0.028 ±0.007 | -0.092 ±0.008 | 0.049 ±0.009 | 0.315 ±0.013 | 0.364 ±0.015 | 0.287 ±0.010 | 0.069 | 56.4 | 0.59 | 0.53 | undecided |
| renew=0.1 | -0.027 ±0.008 | -0.092 ±0.009 | 0.050 ±0.010 | 0.313 ±0.014 | 0.363 ±0.014 | 0.286 ±0.010 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| renew=0.5 | -0.026 ±0.007 | -0.092 ±0.008 | 0.047 ±0.009 | 0.312 ±0.014 | 0.358 ±0.014 | 0.285 ±0.011 | 0.115 | 38.8 | 0.48 | 0.95 | undecided |
## E3d cap basis: share of cast weight vs share of roll (alpha=1.5, depth=3, strategic, faction 20)
| cell | dPS UNI-DIRECT | dMT UNI-DIRECT | dPS WTA-DIRECT | PS DIRECT | PS WTA | PS UNI | Users top share | Users n_eff | uncast | live edges | claim |
|---|---|---|---|---|---|---|---|---|---|---|---|
| basis=cast cap=0.05 | -0.027 ±0.008 | -0.094 ±0.009 | 0.055 ±0.010 | 0.311 ±0.014 | 0.366 ±0.014 | 0.284 ±0.010 | 0.053 | 58.3 | 0.50 | 0.81 | undecided |
| basis=cast cap=0.1 | -0.026 ±0.008 | -0.092 ±0.009 | 0.053 ±0.010 | 0.312 ±0.014 | 0.365 ±0.014 | 0.286 ±0.010 | 0.088 | 46.8 | 0.50 | 0.81 | undecided |
| basis=cast cap=0.2 | -0.027 ±0.008 | -0.092 ±0.009 | 0.050 ±0.010 | 0.313 ±0.014 | 0.363 ±0.014 | 0.286 ±0.010 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| basis=cast cap=0.3 | -0.028 ±0.008 | -0.092 ±0.008 | 0.049 ±0.010 | 0.314 ±0.014 | 0.363 ±0.014 | 0.286 ±0.010 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| basis=roll cap=0.05 | -0.027 ±0.008 | -0.091 ±0.009 | 0.051 ±0.010 | 0.312 ±0.014 | 0.363 ±0.014 | 0.285 ±0.010 | 0.098 | 44.0 | 0.50 | 0.81 | undecided |
| basis=roll cap=0.1 | -0.027 ±0.008 | -0.091 ±0.009 | 0.050 ±0.010 | 0.313 ±0.014 | 0.363 ±0.014 | 0.286 ±0.010 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| basis=roll cap=0.2 | -0.027 ±0.008 | -0.092 ±0.008 | 0.050 ±0.010 | 0.313 ±0.014 | 0.363 ±0.014 | 0.286 ±0.010 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| basis=roll cap=0.3 | -0.027 ±0.008 | -0.091 ±0.009 | 0.051 ±0.009 | 0.314 ±0.014 | 0.365 ±0.014 | 0.287 ±0.010 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
## E4 appeal attraction beta (alpha=1.5, cap=.2, strategic, faction 20)
| cell | dPS UNI-DIRECT | dMT UNI-DIRECT | dPS WTA-DIRECT | PS DIRECT | PS WTA | PS UNI | Users top share | Users n_eff | uncast | live edges | claim |
|---|---|---|---|---|---|---|---|---|---|---|---|
| beta=0.0 | -0.027 ±0.008 | -0.092 ±0.009 | 0.050 ±0.010 | 0.313 ±0.014 | 0.363 ±0.014 | 0.286 ±0.010 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| beta=1.0 | -0.029 ±0.008 | -0.091 ±0.009 | 0.048 ±0.009 | 0.314 ±0.014 | 0.363 ±0.014 | 0.286 ±0.011 | 0.106 | 42.3 | 0.51 | 0.81 | undecided |
| beta=3.0 | -0.028 ±0.007 | -0.092 ±0.008 | 0.050 ±0.009 | 0.315 ±0.014 | 0.365 ±0.014 | 0.287 ±0.011 | 0.110 | 41.1 | 0.50 | 0.81 | undecided |
### E4 / beta=0.0
| rule | PS | MT | PI=PS/MT | BPP | GBR | pass | PS sincere | outcome flipped by strategy |
|---|---|---|---|---|---|---|---|---|
| DIRECT | 0.313 ±0.014 | 0.428 ±0.013 | 0.731 | 0.555 ±0.019 | 0.250 ±0.022 | 0.603 ±0.006 | 0.323 ±0.015 | 0.022 ±0.003 |
| HEAD | 0.496 ±0.008 | 0.181 ±0.013 | 2.736 | 0.826 ±0.018 | 0.545 ±0.019 | 0.609 ±0.006 | 0.503 ±0.008 | 0.008 ±0.001 |
| WTA | 0.363 ±0.014 | 0.443 ±0.014 | 0.820 | 0.587 ±0.019 | 0.259 ±0.022 | 0.598 ±0.006 | 0.368 ±0.015 | 0.022 ±0.003 |
| UNI | 0.286 ±0.010 | 0.336 ±0.014 | 0.850 | 0.671 ±0.023 | 0.219 ±0.021 | 0.668 ±0.006 | 0.293 ±0.012 | 0.020 ±0.003 |
UNI-DIRECT: dPS -0.027 ±0.008, dMT -0.092 ±0.009; WTA-DIRECT: dPS 0.050 ±0.010, dMT 0.015 ±0.008; UNI-WTA: dPS -0.078 ±0.011, dMT -0.107 ±0.010; claim (UNI vs DIRECT): **undecided**
PS by category threshold:
| q | DIRECT | HEAD | WTA | UNI |
|---|---|---|---|---|
| 0.15 | 0.21 ±0.02 | 0.23 ±0.02 | 0.29 ±0.03 | 0.05 ±0.01 |
| 0.3 | 0.30 ±0.03 | 0.49 ±0.03 | 0.36 ±0.03 | 0.29 ±0.02 |
| 0.4 | 0.35 ±0.03 | 0.59 ±0.02 | 0.37 ±0.03 | 0.36 ±0.03 |
| 0.5 | 0.36 ±0.03 | 0.61 ±0.03 | 0.39 ±0.03 | 0.36 ±0.02 |
| 0.66 | 0.33 ±0.03 | 0.54 ±0.02 | 0.40 ±0.03 | 0.35 ±0.03 |
### E4 / beta=1.0
| rule | PS | MT | PI=PS/MT | BPP | GBR | pass | PS sincere | outcome flipped by strategy |
|---|---|---|---|---|---|---|---|---|
| DIRECT | 0.314 ±0.014 | 0.427 ±0.013 | 0.737 | 0.558 ±0.019 | 0.249 ±0.021 | 0.604 ±0.006 | 0.322 ±0.014 | 0.021 ±0.003 |
| HEAD | 0.502 ±0.008 | 0.178 ±0.014 | 2.827 | 0.831 ±0.017 | 0.553 ±0.020 | 0.610 ±0.007 | 0.508 ±0.008 | 0.008 ±0.001 |
| WTA | 0.363 ±0.014 | 0.442 ±0.014 | 0.821 | 0.587 ±0.019 | 0.259 ±0.022 | 0.598 ±0.006 | 0.368 ±0.015 | 0.022 ±0.003 |
| UNI | 0.286 ±0.011 | 0.336 ±0.014 | 0.851 | 0.672 ±0.023 | 0.218 ±0.020 | 0.668 ±0.005 | 0.292 ±0.012 | 0.019 ±0.003 |
UNI-DIRECT: dPS -0.029 ±0.008, dMT -0.091 ±0.009; WTA-DIRECT: dPS 0.048 ±0.009, dMT 0.015 ±0.007; UNI-WTA: dPS -0.077 ±0.011, dMT -0.106 ±0.010; claim (UNI vs DIRECT): **undecided**
PS by category threshold:
| q | DIRECT | HEAD | WTA | UNI |
|---|---|---|---|---|
| 0.15 | 0.21 ±0.02 | 0.24 ±0.01 | 0.28 ±0.03 | 0.05 ±0.01 |
| 0.3 | 0.30 ±0.03 | 0.49 ±0.03 | 0.36 ±0.03 | 0.29 ±0.02 |
| 0.4 | 0.36 ±0.03 | 0.60 ±0.02 | 0.38 ±0.03 | 0.36 ±0.03 |
| 0.5 | 0.37 ±0.03 | 0.63 ±0.03 | 0.39 ±0.03 | 0.36 ±0.03 |
| 0.66 | 0.33 ±0.03 | 0.55 ±0.02 | 0.40 ±0.03 | 0.34 ±0.03 |
### E4 / beta=3.0
| rule | PS | MT | PI=PS/MT | BPP | GBR | pass | PS sincere | outcome flipped by strategy |
|---|---|---|---|---|---|---|---|---|
| DIRECT | 0.315 ±0.014 | 0.426 ±0.012 | 0.738 | 0.561 ±0.019 | 0.250 ±0.021 | 0.605 ±0.006 | 0.322 ±0.015 | 0.021 ±0.003 |
| HEAD | 0.502 ±0.008 | 0.177 ±0.013 | 2.833 | 0.836 ±0.017 | 0.550 ±0.020 | 0.611 ±0.007 | 0.507 ±0.009 | 0.007 ±0.001 |
| WTA | 0.365 ±0.014 | 0.441 ±0.013 | 0.828 | 0.588 ±0.019 | 0.261 ±0.021 | 0.599 ±0.006 | 0.370 ±0.015 | 0.024 ±0.003 |
| UNI | 0.287 ±0.011 | 0.334 ±0.013 | 0.857 | 0.675 ±0.023 | 0.222 ±0.020 | 0.668 ±0.006 | 0.294 ±0.012 | 0.020 ±0.003 |
UNI-DIRECT: dPS -0.028 ±0.007, dMT -0.092 ±0.008; WTA-DIRECT: dPS 0.050 ±0.009, dMT 0.014 ±0.007; UNI-WTA: dPS -0.078 ±0.011, dMT -0.106 ±0.009; claim (UNI vs DIRECT): **undecided**
PS by category threshold:
| q | DIRECT | HEAD | WTA | UNI |
|---|---|---|---|---|
| 0.15 | 0.21 ±0.02 | 0.23 ±0.02 | 0.29 ±0.03 | 0.05 ±0.01 |
| 0.3 | 0.30 ±0.03 | 0.49 ±0.03 | 0.36 ±0.03 | 0.29 ±0.02 |
| 0.4 | 0.36 ±0.03 | 0.60 ±0.03 | 0.38 ±0.03 | 0.36 ±0.03 |
| 0.5 | 0.37 ±0.03 | 0.63 ±0.03 | 0.40 ±0.03 | 0.37 ±0.02 |
| 0.66 | 0.33 ±0.03 | 0.55 ±0.02 | 0.39 ±0.03 | 0.34 ±0.03 |
## E5 robustness to assumed parameters (no delegation, strategic, faction 20)
| cell | dPS UNI-DIRECT | dMT UNI-DIRECT | dPS WTA-DIRECT | PS DIRECT | PS WTA | PS UNI | Users top share | Users n_eff | uncast | live edges | claim |
|---|---|---|---|---|---|---|---|---|---|---|---|
| users appeal b=.4 | -0.025 ±0.010 | -0.101 ±0.008 | 0.057 ±0.009 | 0.281 ±0.013 | 0.338 ±0.014 | 0.256 ±0.011 | 0.021 | 48.0 | 0.00 | 0.00 | fails |
| users appeal b=.9 (default) | -0.050 ±0.008 | -0.077 ±0.009 | 0.037 ±0.009 | 0.323 ±0.013 | 0.360 ±0.013 | 0.274 ±0.011 | 0.022 | 48.5 | 0.00 | 0.00 | undecided |
| users appeal b=1.4 | -0.067 ±0.007 | -0.063 ±0.009 | 0.021 ±0.009 | 0.347 ±0.012 | 0.368 ±0.013 | 0.280 ±0.011 | 0.023 | 49.7 | 0.00 | 0.00 | undecided |
| users turnout .05 | -0.051 ±0.008 | -0.057 ±0.008 | 0.017 ±0.009 | 0.325 ±0.012 | 0.341 ±0.013 | 0.274 ±0.011 | 0.030 | 34.3 | 0.00 | 0.00 | undecided |
| users turnout .30 | -0.036 ±0.009 | -0.077 ±0.008 | 0.042 ±0.010 | 0.314 ±0.014 | 0.356 ±0.014 | 0.279 ±0.012 | 0.010 | 104.3 | 0.00 | 0.00 | undecided |
| minds shared s=.3 | -0.056 ±0.008 | -0.079 ±0.009 | 0.027 ±0.009 | 0.322 ±0.013 | 0.349 ±0.012 | 0.266 ±0.011 | 0.022 | 48.5 | 0.00 | 0.00 | undecided |
| minds shared s=1.5 | -0.043 ±0.009 | -0.074 ±0.008 | 0.045 ±0.008 | 0.322 ±0.013 | 0.367 ±0.013 | 0.279 ±0.011 | 0.022 | 48.5 | 0.00 | 0.00 | undecided |
### E5 / users appeal b=.4
| rule | PS | MT | PI=PS/MT | BPP | GBR | pass | PS sincere | outcome flipped by strategy |
|---|---|---|---|---|---|---|---|---|
| DIRECT | 0.281 ±0.013 | 0.406 ±0.012 | 0.691 | 0.529 ±0.016 | 0.228 ±0.018 | 0.612 ±0.006 | 0.284 ±0.014 | 0.028 ±0.004 |
| HEAD | 0.409 ±0.010 | 0.095 ±0.012 | 4.295 | 0.788 ±0.018 | 0.472 ±0.022 | 0.632 ±0.006 | 0.420 ±0.011 | 0.010 ±0.002 |
| WTA | 0.338 ±0.014 | 0.388 ±0.011 | 0.869 | 0.585 ±0.020 | 0.241 ±0.018 | 0.617 ±0.006 | 0.337 ±0.015 | 0.028 ±0.004 |
| UNI | 0.256 ±0.011 | 0.305 ±0.011 | 0.840 | 0.652 ±0.018 | 0.202 ±0.021 | 0.679 ±0.005 | 0.261 ±0.012 | 0.027 ±0.004 |
UNI-DIRECT: dPS -0.025 ±0.010, dMT -0.101 ±0.008; WTA-DIRECT: dPS 0.057 ±0.009, dMT -0.017 ±0.008; UNI-WTA: dPS -0.082 ±0.010, dMT -0.084 ±0.009; claim (UNI vs DIRECT): **fails**
PS by category threshold:
| q | DIRECT | HEAD | WTA | UNI |
|---|---|---|---|---|
| 0.15 | 0.17 ±0.02 | 0.14 ±0.01 | 0.24 ±0.02 | 0.04 ±0.01 |
| 0.3 | 0.26 ±0.03 | 0.37 ±0.03 | 0.34 ±0.03 | 0.25 ±0.03 |
| 0.4 | 0.32 ±0.03 | 0.52 ±0.03 | 0.36 ±0.02 | 0.33 ±0.02 |
| 0.5 | 0.33 ±0.03 | 0.57 ±0.02 | 0.36 ±0.02 | 0.34 ±0.02 |
| 0.66 | 0.30 ±0.03 | 0.43 ±0.02 | 0.38 ±0.03 | 0.31 ±0.03 |
### E5 / users appeal b=.9 (default)
| rule | PS | MT | PI=PS/MT | BPP | GBR | pass | PS sincere | outcome flipped by strategy |
|---|---|---|---|---|---|---|---|---|
| DIRECT | 0.323 ±0.013 | 0.381 ±0.013 | 0.849 | 0.588 ±0.019 | 0.264 ±0.021 | 0.612 ±0.007 | 0.326 ±0.013 | 0.028 ±0.004 |
| HEAD | 0.524 ±0.010 | 0.048 ±0.013 | n/a | 0.890 ±0.015 | 0.621 ±0.019 | 0.615 ±0.006 | 0.537 ±0.010 | 0.010 ±0.001 |
| WTA | 0.360 ±0.013 | 0.381 ±0.012 | 0.944 | 0.612 ±0.019 | 0.250 ±0.018 | 0.618 ±0.006 | 0.362 ±0.014 | 0.028 ±0.004 |
| UNI | 0.274 ±0.011 | 0.304 ±0.011 | 0.900 | 0.665 ±0.019 | 0.210 ±0.021 | 0.676 ±0.005 | 0.279 ±0.012 | 0.028 ±0.004 |
UNI-DIRECT: dPS -0.050 ±0.008, dMT -0.077 ±0.009; WTA-DIRECT: dPS 0.037 ±0.009, dMT 0.000 ±0.010; UNI-WTA: dPS -0.086 ±0.009, dMT -0.077 ±0.010; claim (UNI vs DIRECT): **undecided**
PS by category threshold:
| q | DIRECT | HEAD | WTA | UNI |
|---|---|---|---|---|
| 0.15 | 0.19 ±0.02 | 0.23 ±0.02 | 0.28 ±0.02 | 0.06 ±0.01 |
| 0.3 | 0.30 ±0.03 | 0.49 ±0.03 | 0.36 ±0.03 | 0.27 ±0.03 |
| 0.4 | 0.37 ±0.03 | 0.64 ±0.02 | 0.37 ±0.03 | 0.35 ±0.02 |
| 0.5 | 0.40 ±0.02 | 0.70 ±0.02 | 0.38 ±0.02 | 0.36 ±0.02 |
| 0.66 | 0.34 ±0.03 | 0.56 ±0.02 | 0.39 ±0.03 | 0.32 ±0.03 |
### E5 / users appeal b=1.4
| rule | PS | MT | PI=PS/MT | BPP | GBR | pass | PS sincere | outcome flipped by strategy |
|---|---|---|---|---|---|---|---|---|
| DIRECT | 0.347 ±0.012 | 0.364 ±0.013 | 0.953 | 0.615 ±0.019 | 0.287 ±0.021 | 0.613 ±0.006 | 0.353 ±0.012 | 0.028 ±0.004 |
| HEAD | 0.590 ±0.011 | 0.013 ±0.013 | n/a | 0.940 ±0.012 | 0.715 ±0.019 | 0.603 ±0.006 | 0.600 ±0.011 | 0.008 ±0.001 |
| WTA | 0.368 ±0.013 | 0.375 ±0.012 | 0.981 | 0.616 ±0.020 | 0.252 ±0.018 | 0.618 ±0.006 | 0.370 ±0.014 | 0.027 ±0.004 |
| UNI | 0.280 ±0.011 | 0.301 ±0.012 | 0.930 | 0.664 ±0.019 | 0.211 ±0.020 | 0.675 ±0.005 | 0.285 ±0.012 | 0.029 ±0.004 |
UNI-DIRECT: dPS -0.067 ±0.007, dMT -0.063 ±0.009; WTA-DIRECT: dPS 0.021 ±0.009, dMT 0.010 ±0.009; UNI-WTA: dPS -0.088 ±0.009, dMT -0.074 ±0.010; claim (UNI vs DIRECT): **undecided**
PS by category threshold:
| q | DIRECT | HEAD | WTA | UNI |
|---|---|---|---|---|
| 0.15 | 0.20 ±0.02 | 0.29 ±0.02 | 0.29 ±0.02 | 0.06 ±0.01 |
| 0.3 | 0.33 ±0.03 | 0.57 ±0.02 | 0.37 ±0.03 | 0.28 ±0.03 |
| 0.4 | 0.39 ±0.02 | 0.70 ±0.02 | 0.38 ±0.03 | 0.35 ±0.02 |
| 0.5 | 0.43 ±0.02 | 0.76 ±0.02 | 0.38 ±0.02 | 0.37 ±0.02 |
| 0.66 | 0.37 ±0.03 | 0.62 ±0.02 | 0.40 ±0.03 | 0.33 ±0.03 |
### E5 / users turnout .05
| rule | PS | MT | PI=PS/MT | BPP | GBR | pass | PS sincere | outcome flipped by strategy |
|---|---|---|---|---|---|---|---|---|
| DIRECT | 0.325 ±0.012 | 0.350 ±0.013 | 0.928 | 0.605 ±0.020 | 0.271 ±0.020 | 0.619 ±0.006 | 0.331 ±0.012 | 0.028 ±0.004 |
| HEAD | 0.497 ±0.012 | 0.001 ±0.014 | n/a | 0.891 ±0.017 | 0.604 ±0.019 | 0.622 ±0.006 | 0.509 ±0.012 | 0.012 ±0.002 |
| WTA | 0.341 ±0.013 | 0.345 ±0.012 | 0.990 | 0.617 ±0.020 | 0.251 ±0.017 | 0.629 ±0.006 | 0.343 ±0.013 | 0.028 ±0.004 |
| UNI | 0.274 ±0.011 | 0.293 ±0.012 | 0.934 | 0.665 ±0.019 | 0.212 ±0.020 | 0.678 ±0.005 | 0.279 ±0.011 | 0.029 ±0.004 |
UNI-DIRECT: dPS -0.051 ±0.008, dMT -0.057 ±0.008; WTA-DIRECT: dPS 0.017 ±0.009, dMT -0.005 ±0.009; UNI-WTA: dPS -0.068 ±0.009, dMT -0.052 ±0.010; claim (UNI vs DIRECT): **undecided**
PS by category threshold:
| q | DIRECT | HEAD | WTA | UNI |
|---|---|---|---|---|
| 0.15 | 0.17 ±0.02 | 0.21 ±0.02 | 0.22 ±0.01 | 0.06 ±0.01 |
| 0.3 | 0.31 ±0.03 | 0.46 ±0.03 | 0.36 ±0.03 | 0.28 ±0.03 |
| 0.4 | 0.38 ±0.02 | 0.61 ±0.02 | 0.37 ±0.03 | 0.34 ±0.02 |
| 0.5 | 0.41 ±0.03 | 0.69 ±0.02 | 0.37 ±0.02 | 0.35 ±0.02 |
| 0.66 | 0.35 ±0.03 | 0.52 ±0.03 | 0.39 ±0.03 | 0.33 ±0.03 |
### E5 / users turnout .30
| rule | PS | MT | PI=PS/MT | BPP | GBR | pass | PS sincere | outcome flipped by strategy |
|---|---|---|---|---|---|---|---|---|
| DIRECT | 0.314 ±0.014 | 0.424 ±0.012 | 0.740 | 0.563 ±0.019 | 0.247 ±0.022 | 0.606 ±0.007 | 0.315 ±0.013 | 0.028 ±0.004 |
| HEAD | 0.550 ±0.007 | 0.140 ±0.014 | 3.937 | 0.868 ±0.016 | 0.638 ±0.020 | 0.598 ±0.007 | 0.556 ±0.007 | 0.005 ±0.001 |
| WTA | 0.356 ±0.014 | 0.442 ±0.011 | 0.805 | 0.584 ±0.020 | 0.238 ±0.018 | 0.604 ±0.006 | 0.356 ±0.014 | 0.025 ±0.004 |
| UNI | 0.279 ±0.012 | 0.347 ±0.011 | 0.803 | 0.652 ±0.019 | 0.206 ±0.021 | 0.667 ±0.005 | 0.285 ±0.013 | 0.025 ±0.003 |
UNI-DIRECT: dPS -0.036 ±0.009, dMT -0.077 ±0.008; WTA-DIRECT: dPS 0.042 ±0.010, dMT 0.018 ±0.008; UNI-WTA: dPS -0.078 ±0.011, dMT -0.095 ±0.009; claim (UNI vs DIRECT): **undecided**
PS by category threshold:
| q | DIRECT | HEAD | WTA | UNI |
|---|---|---|---|---|
| 0.15 | 0.21 ±0.02 | 0.30 ±0.02 | 0.30 ±0.02 | 0.07 ±0.01 |
| 0.3 | 0.30 ±0.03 | 0.54 ±0.03 | 0.35 ±0.03 | 0.28 ±0.03 |
| 0.4 | 0.35 ±0.03 | 0.63 ±0.02 | 0.37 ±0.03 | 0.34 ±0.02 |
| 0.5 | 0.37 ±0.03 | 0.67 ±0.02 | 0.37 ±0.02 | 0.35 ±0.02 |
| 0.66 | 0.33 ±0.03 | 0.59 ±0.02 | 0.39 ±0.03 | 0.33 ±0.03 |
### E5 / minds shared s=.3
| rule | PS | MT | PI=PS/MT | BPP | GBR | pass | PS sincere | outcome flipped by strategy |
|---|---|---|---|---|---|---|---|---|
| DIRECT | 0.322 ±0.013 | 0.427 ±0.012 | 0.755 | 0.564 ±0.021 | 0.225 ±0.019 | 0.610 ±0.006 | 0.326 ±0.013 | 0.028 ±0.004 |
| HEAD | 0.524 ±0.011 | 0.069 ±0.014 | 7.608 | 0.882 ±0.017 | 0.601 ±0.021 | 0.616 ±0.006 | 0.537 ±0.010 | 0.010 ±0.001 |
| WTA | 0.349 ±0.012 | 0.446 ±0.012 | 0.782 | 0.571 ±0.022 | 0.195 ±0.017 | 0.612 ±0.006 | 0.350 ±0.013 | 0.028 ±0.004 |
| UNI | 0.266 ±0.011 | 0.348 ±0.010 | 0.766 | 0.644 ±0.022 | 0.181 ±0.020 | 0.674 ±0.005 | 0.271 ±0.011 | 0.028 ±0.004 |
UNI-DIRECT: dPS -0.056 ±0.008, dMT -0.079 ±0.009; WTA-DIRECT: dPS 0.027 ±0.009, dMT 0.019 ±0.009; UNI-WTA: dPS -0.082 ±0.009, dMT -0.098 ±0.009; claim (UNI vs DIRECT): **undecided**
PS by category threshold:
| q | DIRECT | HEAD | WTA | UNI |
|---|---|---|---|---|
| 0.15 | 0.21 ±0.02 | 0.23 ±0.01 | 0.32 ±0.02 | 0.06 ±0.01 |
| 0.3 | 0.31 ±0.03 | 0.49 ±0.03 | 0.34 ±0.03 | 0.26 ±0.03 |
| 0.4 | 0.37 ±0.02 | 0.64 ±0.03 | 0.36 ±0.03 | 0.34 ±0.02 |
| 0.5 | 0.37 ±0.03 | 0.70 ±0.02 | 0.36 ±0.03 | 0.35 ±0.02 |
| 0.66 | 0.34 ±0.03 | 0.55 ±0.02 | 0.37 ±0.03 | 0.31 ±0.03 |
### E5 / minds shared s=1.5
| rule | PS | MT | PI=PS/MT | BPP | GBR | pass | PS sincere | outcome flipped by strategy |
|---|---|---|---|---|---|---|---|---|
| DIRECT | 0.322 ±0.013 | 0.343 ±0.012 | 0.940 | 0.607 ±0.017 | 0.290 ±0.021 | 0.615 ±0.006 | 0.327 ±0.012 | 0.029 ±0.004 |
| HEAD | 0.524 ±0.011 | 0.032 ±0.013 | n/a | 0.896 ±0.015 | 0.636 ±0.018 | 0.614 ±0.006 | 0.536 ±0.011 | 0.010 ±0.001 |
| WTA | 0.367 ±0.013 | 0.335 ±0.012 | 1.096 | 0.645 ±0.018 | 0.288 ±0.020 | 0.623 ±0.007 | 0.369 ±0.014 | 0.028 ±0.004 |
| UNI | 0.279 ±0.011 | 0.268 ±0.011 | 1.041 | 0.694 ±0.020 | 0.237 ±0.023 | 0.681 ±0.005 | 0.285 ±0.011 | 0.027 ±0.004 |
UNI-DIRECT: dPS -0.043 ±0.009, dMT -0.074 ±0.008; WTA-DIRECT: dPS 0.045 ±0.008, dMT -0.008 ±0.011; UNI-WTA: dPS -0.088 ±0.010, dMT -0.067 ±0.009; claim (UNI vs DIRECT): **undecided**
PS by category threshold:
| q | DIRECT | HEAD | WTA | UNI |
|---|---|---|---|---|
| 0.15 | 0.18 ±0.02 | 0.23 ±0.02 | 0.26 ±0.02 | 0.05 ±0.01 |
| 0.3 | 0.30 ±0.03 | 0.49 ±0.03 | 0.37 ±0.03 | 0.27 ±0.03 |
| 0.4 | 0.37 ±0.02 | 0.64 ±0.03 | 0.39 ±0.02 | 0.35 ±0.02 |
| 0.5 | 0.40 ±0.02 | 0.70 ±0.02 | 0.40 ±0.02 | 0.37 ±0.02 |
| 0.66 | 0.34 ±0.03 | 0.56 ±0.02 | 0.41 ±0.03 | 0.33 ±0.03 |
## E5b same, with delegation (alpha=1.5, cap=.2)
| cell | dPS UNI-DIRECT | dMT UNI-DIRECT | dPS WTA-DIRECT | PS DIRECT | PS WTA | PS UNI | Users top share | Users n_eff | uncast | live edges | claim |
|---|---|---|---|---|---|---|---|---|---|---|---|
| users appeal b=.4 | -0.023 ±0.007 | -0.105 ±0.008 | 0.050 ±0.009 | 0.283 ±0.014 | 0.332 ±0.014 | 0.260 ±0.011 | 0.099 | 44.4 | 0.50 | 0.78 | fails |
| users appeal b=.9 (default) | -0.027 ±0.008 | -0.092 ±0.009 | 0.050 ±0.010 | 0.313 ±0.014 | 0.363 ±0.014 | 0.286 ±0.010 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| users appeal b=1.4 | -0.036 ±0.008 | -0.085 ±0.008 | 0.045 ±0.009 | 0.335 ±0.014 | 0.381 ±0.013 | 0.300 ±0.011 | 0.098 | 45.5 | 0.55 | 0.83 | undecided |
| users turnout .05 | -0.028 ±0.007 | -0.096 ±0.009 | 0.053 ±0.009 | 0.313 ±0.013 | 0.366 ±0.014 | 0.285 ±0.011 | 0.109 | 37.1 | 0.62 | 0.84 | undecided |
| users turnout .30 | -0.026 ±0.008 | -0.079 ±0.009 | 0.044 ±0.010 | 0.315 ±0.014 | 0.359 ±0.014 | 0.288 ±0.012 | 0.070 | 80.9 | 0.30 | 0.80 | undecided |
| minds shared s=.3 | -0.035 ±0.008 | -0.093 ±0.008 | 0.041 ±0.009 | 0.312 ±0.013 | 0.353 ±0.013 | 0.277 ±0.011 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
| minds shared s=1.5 | -0.020 ±0.008 | -0.081 ±0.008 | 0.055 ±0.010 | 0.312 ±0.013 | 0.367 ±0.014 | 0.292 ±0.010 | 0.103 | 43.1 | 0.50 | 0.81 | undecided |
## E6 purchased stake: logarithmic weight, splitting, and the half-influence cap (SPEC section 5c)
Static weight analysis (no motions are simulated, so no PS or MT): a buyer joins the Patrons estate with one purchase; the 4-patron roll of the harness (and 40, 400) holds Pareto(1.16) donations from 10 EUR, 40 seeds each. Weight w = 1 + log_b(amount/unit), 0 at amount <= unit/b; defaults b = 10, unit = 10 EUR. Share s is the buyer's share of Patrons weight; x is its share of council weight (estate weight W x s over the live estates' weights); the cap binds iff x >= .5.
**Million-euro buyer, defaults, Patrons roll 4 (mean over seeds, 95% interval); linear = weight proportional to amount, the counterfactual the log is meant to beat**
| weights | live estates | s log | x log | x linear | cap binds (log) | cap binds (linear) | estate decided by buyer alone (s > .5) |
|---|---|---|---|---|---|---|---|
| equal | all four estates | 0.524 ±0.008 | 0.131 ±0.002 | 0.250 ±0.000 | 0.00 | 0.00 | 0.85 |
| equal | Patrons + Minds | 0.524 ±0.008 | 0.262 ±0.004 | 0.500 ±0.000 | 0.00 | 0.00 | 0.85 |
| equal | Patrons alone | 0.524 ±0.008 | 0.524 ±0.008 | 1.000 ±0.000 | 0.85 | 1.00 | 0.85 |
| minds-heavy | all four estates | 0.524 ±0.008 | 0.052 ±0.001 | 0.100 ±0.000 | 0.00 | 0.00 | 0.85 |
| minds-heavy | Patrons + Minds | 0.524 ±0.008 | 0.105 ±0.002 | 0.200 ±0.000 | 0.00 | 0.00 | 0.85 |
| minds-heavy | Patrons alone | 0.524 ±0.008 | 0.524 ±0.008 | 1.000 ±0.000 | 0.85 | 1.00 | 0.85 |
**Million-euro buyer's share of Patrons weight, b = 10, by unit and roll (mean over seeds, 95% interval; share of seeds in which the buyer alone decides the estate)**
| unit EUR | roll 4 | roll 40 | roll 400 |
|---|---|---|---|
| 1 | 0.425 ±0.005 (0.00) | 0.069 ±0.000 (0.00) | 0.007 ±0.000 (0.00) |
| 10 | 0.524 ±0.008 (0.85) | 0.098 ±0.001 (0.00) | 0.011 ±0.000 (0.00) |
| 100 | 0.777 ±0.023 (1.00) | 0.251 ±0.008 (0.00) | 0.032 ±0.000 (0.00) |
Interval over the swept cells: s from 0.007 to 0.777. The 0.524 headline is the harness's own cell (roll 4, unit 10), not a typical one: the buyer's majority of the estate depends on how few other patrons there are.
**Cap-binding rate over the sweep (b, unit, roll, amount, weights x live set; every cell run on every seed)**
| live estates | cells | cells x seeds | binding (cell, seed) | cells binding in every seed | cells never binding | binding but unenforceable |
|---|---|---|---|---|---|---|
| all four estates | 162 | 6480 | 0.000 | 0.000 | 1.000 | 0.000 |
| Patrons + Minds | 162 | 6480 | 0.008 | 0.000 | 0.981 | 0.008 |
| Patrons alone | 162 | 6480 | 0.165 | 0.099 | 0.790 | 0.017 |
Binding rate in the Patrons-alone scenario by Patrons roll and by log base (fraction of cells x seeds):
| roll | b=2 | b=10 | b=100 |
|---|---|---|---|
| 4 | 0.669 | 0.447 | 0.114 |
| 40 | 0.250 | 0.000 | 0.000 |
| 400 | 0.000 | 0.000 | 0.000 |
**Splitting: the same total split over k identities (defaults, one honest identity = k 1). Multiplier = total weight of the split / weight of the single purchase**
| amount EUR | single weight | k=2 | k=10 | k=100 | k=1000 | best k (grid) | best multiplier |
|---|---|---|---|---|---|---|---|
| 10000 | 4.00 | 1.8 | 7.5 | 50.0 | 250.0 | 1000 | 250 |
| 100000 | 5.00 | 1.9 | 8.0 | 60.0 | 400.0 | 10000 | 2000 |
| 1e+06 | 6.00 | 1.9 | 8.3 | 66.7 | 500.0 | 100000 | 16667 |
Best multiplier at 1,000,000 EUR by log base and unit (b rows, unit columns):
| b | unit=1 | unit=10 | unit=100 |
|---|---|---|---|
| 2 | 50711 (k=740348) | 6027 (k=74738) | 743 (k=7185) |
| 10 | 142857 (k=1000000) | 16667 (k=100000) | 2000 (k=10000) |
| 100 | 250000 (k=1000000) | 28571 (k=100000) | 3333 (k=10000) |
**Candidate defences, none adopted by SPEC (best multiplier at 1,000,000 EUR, defaults)**
| defence | setting | best multiplier | neutralises (best split is k = 1) |
|---|---|---|---|
| per-person weight cap | 1.0 | 100000 | no |
| per-person weight cap | 3.0 | 33333 | no |
| per-person weight cap | 6.0 | 16667 | no |
| unit floor | 10 EUR | 16666.7 | no |
| unit floor | 1000 EUR | 250.0 | no |
| unit floor | 100000 EUR | 5.0 | no |
| unit floor | 500000 EUR | 1.5 | no |
| unit floor | 1e+06 EUR | 1.0 | yes |
| identity cost | smallest cost by bisection, amount 10000 EUR | | yes at 4930 EUR per identity (49.3% of the budget, bisection to 0.1%) |
| identity cost | smallest cost by bisection, amount 100000 EUR | | yes at 49800 EUR per identity (49.8% of the budget, bisection to 0.1%) |
| identity cost | smallest cost by bisection, amount 1e+06 EUR | | yes at 499520 EUR per identity (50.0% of the budget, bisection to 0.1%) |
**Cap evasion: the cap is per identity. Patrons alone, equal weights, roll 4, defaults, 1,000,000 EUR over k identities**
| k | joint share x | share of one identity | cap binds on an identity | joint x >= .5 (evaded) |
|---|---|---|---|---|
| 1 | 0.524 ±0.008 | 0.524 ±0.008 | 0.85 | 0.00 |
| 2 | 0.676 ±0.007 | 0.338 ±0.004 | 0.00 | 1.00 |
| 10 | 0.901 ±0.003 | 0.090 ±0.000 | 0.00 | 1.00 |
| 100 | 0.986 ±0.000 | 0.010 ±0.000 | 0.00 | 1.00 |
**Contribution hours: Members weight from hours alone (no mechanism for contribution existed in the harness; new per-agent input, own per-estate conversion)**
| hours | 5 | 10 | 100 | 1000 | 10000 |
|---|---|---|---|---|---|
| weight | 0.70 | 1.00 | 2.00 | 3.00 | 4.00 |
## Claim tally over all cells (SPEC section 8)
supported 0, fails 2, undecided 233, n/a 0
Failing cells:
- E5 users appeal b=.4
- E5b users appeal b=.4

View file

@ -0,0 +1,401 @@
"""One-command rerun: python3 -I run.py [--quick] [--out results.md] [--jobs N]
Writes a deterministic markdown results file (no timestamps). See SPEC.md section 7.
"""
import argparse
import os
import random
import sys
from multiprocessing import Pool
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import estatesim as E # noqa: E402
REPS, MOTIONS = 40, 500
QUICK_REPS, QUICK_MOTIONS = 4, 100
METRICS = ("PS", "MT", "BPP", "GBR", "PASS")
PAIRS = (("UNI", "DIRECT"), ("WTA", "DIRECT"), ("UNI", "WTA"))
def _job(args):
cfg, rep = args
return E.run_replicate(cfg, rep)
def evaluate(cfg_list, reps, jobs):
"""Run every (cfg, rep); returns list of per-config result dicts."""
tasks = [(cfg, r) for cfg in cfg_list for r in range(reps)]
if jobs > 1:
with Pool(jobs) as p:
out = p.map(_job, tasks, chunksize=2)
else:
out = [_job(t) for t in tasks]
res = []
for k, cfg in enumerate(cfg_list):
chunk = out[k * reps:(k + 1) * reps]
res.append(summarize(cfg, chunk))
return res
def summarize(cfg, chunk):
recs = [c[0] for c in chunk]
per = {} # rule -> metric -> list over reps (strategic) ; plus sincere PS
for ri, rule in enumerate(E.RULES):
per[rule] = {m: [] for m in METRICS}
per[rule]["PS_sincere"] = []
per[rule]["FLIP"] = []
for rec in recs:
mm = E.rep_metrics(rec, ri)
for m in METRICS:
per[rule][m].append(mm[m])
per[rule]["PS_sincere"].append(E.rep_metrics(rec, ri, which=4)["PS"])
per[rule]["FLIP"].append(sum(1 for r in rec if r[3][ri] != r[4][ri]) / len(rec))
percat = {}
for ci in range(len(E.CATS)):
for ri, rule in enumerate(E.RULES):
percat[(ci, rule)] = [E.rep_metrics(rec, ri, cats={ci})["PS"] for rec in recs]
paired = {}
for a, b in PAIRS:
for m in ("PS", "MT"):
paired[(a, b, m)] = E.mean_ci([x - y for x, y in zip(per[a][m], per[b][m])])
conc = {}
allc = [c for ch in chunk for c in ch[1]]
for k, nm in enumerate(("top_share", "gini", "n_eff", "weight")):
conc[nm] = sum(c[k] for c in allc) / len(allc)
dl = sum(c[5] for c in allc)
conc["eff_top"] = sum(c[7] for c in allc) / len(allc) # top share counting copiers to the voter they copied
conc["eff_neff"] = sum(c[8] for c in allc) / len(allc)
conc["copied"] = sum(c[9] for c in allc) / dl if dl else 0.0 # copiers as a share of delegated votes
conc["eff_top_reps"] = [sum(c[7] for c in ch[1]) / len(ch[1]) for ch in chunk] # per replicate, for paired contrasts
conc["live"] = sum(c[6] for c in allc) / len(allc) # share of delegators still holding an edge
conc["uncast"] = sum(c[4] for c in allc) / dl if dl else 0.0 # share of delegated votes left uncast
return dict(cfg=cfg, per=per, percat=percat, paired=paired, conc=conc,
verdict=E.verdict(paired[("UNI", "DIRECT", "PS")], paired[("UNI", "DIRECT", "MT")]))
def f(x, ci=None, d=3):
if x != x:
return "n/a"
s = f"{x:.{d}f}"
return s + (f" ±{ci:.{d}f}" if ci is not None and ci == ci else "")
def rule_table(r):
lines = ["| rule | PS | MT | PI=PS/MT | BPP | GBR | pass | PS sincere | outcome flipped by strategy |",
"|---|---|---|---|---|---|---|---|---|"]
for rule in E.RULES:
p = r["per"][rule]
ps, mt = E.mean_ci(p["PS"]), E.mean_ci(p["MT"])
pi = ps[0] / mt[0] if mt[0] >= .05 else float("nan")
lines.append(f"| {rule} | {f(*ps)} | {f(*mt)} | {f(pi)} | {f(*E.mean_ci(p['BPP']))} | "
f"{f(*E.mean_ci(p['GBR']))} | {f(*E.mean_ci(p['PASS']))} | "
f"{f(*E.mean_ci(p['PS_sincere']))} | {f(*E.mean_ci(p['FLIP']))} |")
return lines
def pair_line(r):
pr = r["paired"]
parts = []
for a, b in PAIRS:
parts.append(f"{a}-{b}: dPS {f(*pr[(a, b, 'PS')])}, dMT {f(*pr[(a, b, 'MT')])}")
return "; ".join(parts) + f"; claim (UNI vs DIRECT): **{r['verdict']}**"
def percat_table(r):
lines = ["| q | " + " | ".join(E.RULES) + " |", "|---|" + "---|" * len(E.RULES)]
for ci, q in enumerate(E.CATS):
lines.append(f"| {q} | " + " | ".join(f(*E.mean_ci(r["percat"][(ci, ru)]), d=2) for ru in E.RULES) + " |")
return lines
def experiments(quick):
X = []
if quick:
alphas, caps, facs, betas = (0.0, 1.5), (.2, None), (0, 20), (0.0, 3.0)
else:
alphas, caps, facs, betas = (0.0, .5, 1.0, 1.5, 2.5, 4.0), (.10, .15, .20, .25, .30, None), (0, 10, 20, 40), (0.0, 1.0, 3.0)
X.append(("E1 baseline (sincere, no delegation, no faction)",
[(w, E.Config(weights=w)) for w in ("equal", "minds-heavy")], True))
X.append(("E2 strategic agents (rho=.25) and Users faction size",
[(f"faction={k}", E.Config(rho=.25, faction=k)) for k in facs], True))
X.append(("E3 delegation: concentration alpha x per-delegate cap (depth 3, strategic, faction 20)",
[(f"alpha={a} cap={c}", E.Config(delegation=True, alpha=a, cap=c, rho=.25, faction=20))
for a in alphas for c in caps], False))
X.append(("E3b same grid, no faction, sincere (isolates delegation)",
[(f"alpha={a} cap={c}", E.Config(delegation=True, alpha=a, cap=c))
for a in alphas for c in caps], False))
depths = (2, None) if quick else (2, 3, 4, 6, None)
X.append(("E3c transitive depth x delegate turnout x peer-delegation share (alpha=1.5, cap=.2, strategic, faction 20)",
[(f"depth={d} dturn={t} peer={p}", E.Config(delegation=True, alpha=1.5, depth=d, dturn=t, peer=p, cap=.2, rho=.25, faction=20))
for d in depths for t in (.95, .3) for p in (.3, .8)], False))
copies = (0.0, 1.0) if quick else (0.0, .5, 1.0)
X.append(("E3f copy-the-ballot: alpha x cap x copy rate (depth 3, strategic, faction 20)",
[(f"alpha={a} cap={c} copy={p}", E.Config(delegation=True, alpha=a, cap=c, copy=p, rho=.25, faction=20))
for a in alphas for p in copies for c in caps], "copy"))
X.append(("E3e revocation renewal: how fast revoked delegators re-delegate (alpha=1.5, cap=.2, depth 3, strategic, faction 20)",
[(f"renew={n}", E.Config(delegation=True, alpha=1.5, cap=.2, renew=n, rho=.25, faction=20))
for n in (0.0, .02, .1, .5)], False))
X.append(("E3d cap basis: share of cast weight vs share of roll (alpha=1.5, depth=3, strategic, faction 20)",
[(f"basis={bs} cap={c}", E.Config(delegation=True, alpha=1.5, cap=c, cap_basis=bs, rho=.25, faction=20))
for bs in ("cast", "roll") for c in ((.1, .2) if quick else (.05, .1, .2, .3))], False))
X.append(("E4 appeal attraction beta (alpha=1.5, cap=.2, strategic, faction 20)",
[(f"beta={b}", E.Config(delegation=True, alpha=1.5, cap=.2, beta=b, rho=.25, faction=20))
for b in betas], True))
rob = [("users appeal b=.4", (("Users", "b", .4),)), ("users appeal b=.9 (default)", ()),
("users appeal b=1.4", (("Users", "b", 1.4),)),
("users turnout .05", (("Users", "turnout", .05),)), ("users turnout .30", (("Users", "turnout", .30),)),
("minds shared s=.3", (("Minds", "s", .3),)), ("minds shared s=1.5", (("Minds", "s", 1.5),))]
X.append(("E5 robustness to assumed parameters (no delegation, strategic, faction 20)",
[(nm, E.Config(rho=.25, faction=20, overrides=ov)) for nm, ov in rob], True))
X.append(("E5b same, with delegation (alpha=1.5, cap=.2)",
[(nm, E.Config(delegation=True, alpha=1.5, cap=.2, rho=.25, faction=20, overrides=ov))
for nm, ov in rob], False))
return X
def copy_tables(cells, res):
"""Pivots for E3f: rows (alpha, copy), columns cap. Effective = copiers counted to the voter they copied."""
caps = []
for _, c in cells:
if c.cap not in caps:
caps.append(c.cap)
rows = {}
for (_, c), r in zip(cells, res):
rows.setdefault((c.alpha, c.copy), {})[c.cap] = r
hdr = ["| alpha | copy | " + " | ".join("none" if c is None else f"{c}" for c in caps) + " |",
"|---|---|" + "---|" * len(caps)]
L = []
for title, get in (
("Users top-holder share, formal (copiers are separate weight-1 ballots)", lambda r: f"{r['conc']['top_share']:.3f}"),
("Users top-holder share, effective (copiers counted to the voter they copied)", lambda r: f"{r['conc']['eff_top']:.3f}"),
("Users n_eff, effective", lambda r: f"{r['conc']['eff_neff']:.1f}"),
("copiers as a share of delegated votes", lambda r: f"{r['conc']['copied']:.2f}"),
("dPS UNI-DIRECT", lambda r: f(*r["paired"][("UNI", "DIRECT", "PS")])),
("PS UNI", lambda r: f(*E.mean_ci(r["per"]["UNI"]["PS"])))):
L += [f"**{title}**", ""] + hdr
for (a, p), byc in rows.items():
L.append(f"| {a} | {p} | " + " | ".join(get(byc[c]) for c in caps) + " |")
L.append("")
return L
CAP_MATERIAL = .01 # effective top-share drop below this is not called a reduction (SPEC section 5b)
def copy_reading(cells, res):
"""SPEC section 5b falsifier, computed: does the tightest cap reduce effective Users top-holder share?
Paired by replicate (same population draw): top share at cap c minus top share with no cap.
"reduces" iff the 95% interval lies entirely below -CAP_MATERIAL; "does not reduce" otherwise.
"""
by = {(c.alpha, c.copy, c.cap): r for (_, c), r in zip(cells, res)}
tight = min(c.cap for _, c in cells if c.cap is not None)
tally = {}
L = [f"**Does the cap reduce effective concentration? Effective Users top-holder share at cap {tight} minus no cap, "
f"paired by replicate; 'reduces' iff the 95% interval is entirely below -{CAP_MATERIAL}.**", "",
"| alpha | copy | effective top share, cap - none | verdict | formal top share, cap - none |", "|---|---|---|---|---|"]
for (a, p, c) in by:
if c != tight:
continue
r, n = by[(a, p, c)], by[(a, p, None)]
d = E.mean_ci([x - y for x, y in zip(r["conc"]["eff_top_reps"], n["conc"]["eff_top_reps"])])
v = "reduces" if d[0] + d[1] < -CAP_MATERIAL else "does not reduce"
L.append(f"| {a} | {p} | {f(*d)} | {v} | {r['conc']['top_share'] - n['conc']['top_share']:+.3f} |")
tally.setdefault(p, []).append(v == "reduces")
L += ["", "Reading, computed from the rows above: the cap reduces effective concentration in " + "; ".join(
f"{sum(t)} of {len(t)} alpha values at copy rate {p}" for p, t in tally.items()) +
". Where it reduces at copy 0 and not at copy 1, the formal top share falls (last column) while the weight follows "
"the same voters through their copiers, so the cap moves weight from the holder's ledger to its followers' ballots "
"without changing whose ballot it is."]
return L + [""]
PATRONS = E.ESTATES.index("Patrons")
LIVE = {"all four estates": (0, 1, 2, 3), "Patrons + Minds": (0, PATRONS), "Patrons alone": (PATRONS,)}
def _stake_rest(overrides, ladder):
"""Weight of the ordinary patrons under the estate's own euro conversion."""
return sum(E.stake_weight("Patrons", euros=x, overrides=overrides) for x in ladder)
def stake_section(quick):
"""E6 purchased stake (SPEC section 5c). Pure functions of fixed seeds; no wall clock."""
seeds = 4 if quick else 40
amounts = (1e6,) if quick else (1e4, 1e5, 1e6)
bs, units, rolls = ((10.0,), (10.0,), (4, 40)) if quick else ((2.0, 10.0, 100.0), (1.0, 10.0, 100.0), (4, 40, 400))
EUR = 1e6
L = ["## E6 purchased stake: logarithmic weight, splitting, and the half-influence cap (SPEC section 5c)", "",
"Static weight analysis (no motions are simulated, so no PS or MT): a buyer joins the Patrons estate with one purchase; "
f"the {E.ROLL['Patrons']}-patron roll of the harness (and 40, 400) holds Pareto({E.PARETO_ALPHA}) donations from {E.PARETO_XM:.0f} EUR, "
f"{seeds} seeds each. Weight w = 1 + log_b(amount/unit), 0 at amount <= unit/b; defaults b = {E.STAKE_BASE['Patrons']['euro_b']:.0f}, "
f"unit = {E.STAKE_BASE['Patrons']['euro_unit']:.0f} EUR. Share s is the buyer's share of Patrons weight; x is its share of council weight "
"(estate weight W x s over the live estates' weights); the cap binds iff x >= .5.", ""]
ladders = {(n, s): E.stake_ladder(n, s) for n in rolls for s in range(seeds)}
# headline: the million-euro buyer, defaults, roll of the harness
n0 = E.ROLL["Patrons"]
L += ["**Million-euro buyer, defaults, Patrons roll " + str(n0) + " (mean over seeds, 95% interval); linear = weight proportional to amount, the counterfactual the log is meant to beat**", "",
"| weights | live estates | s log | x log | x linear | cap binds (log) | cap binds (linear) | estate decided by buyer alone (s > .5) |",
"|---|---|---|---|---|---|---|---|"]
for wn, W in E.WEIGHTS.items():
for ln, live in LIVE.items():
rows = []
for s in range(seeds):
lad = ladders[(n0, s)]
wb = E.stake_weight("Patrons", euros=EUR)
lo = E.influence_cap(wb, _stake_rest((), lad), W, PATRONS, live)
li = E.influence_cap(EUR / 10.0, sum(x / 10.0 for x in lad), W, PATRONS, live)
rows.append((lo["s"], lo["x"], li["x"], lo["binds"], li["binds"], lo["s"] > .5))
m = [E.mean_ci([r[k] for r in rows]) for k in range(3)]
fr = [sum(r[k] for r in rows) / seeds for k in (3, 4, 5)]
L.append(f"| {wn} | {ln} | {f(*m[0])} | {f(*m[1])} | {f(*m[2])} | {fr[0]:.2f} | {fr[1]:.2f} | {fr[2]:.2f} |")
L.append("")
# the headline share is one (unit, roll) cell: sweep both, state the interval
L += ["**Million-euro buyer's share of Patrons weight, b = 10, by unit and roll (mean over seeds, 95% interval; share of seeds in which the buyer alone decides the estate)**", "",
"| unit EUR | " + " | ".join(f"roll {n}" for n in rolls) + " |", "|---|" + "---|" * len(rolls)]
cells = []
for u in units:
cols = []
for n in rolls:
r = E.buyer_runs(EUR, n, seeds, E.WEIGHTS["equal"], (PATRONS,), (("Patrons", "euro_b", 10.0), ("Patrons", "euro_unit", u)))
m = E.mean_ci([x["s"] for x in r])
cells.append(m[0])
cols.append(f"{f(*m)} ({sum(x['s'] > .5 for x in r) / seeds:.2f})")
L.append(f"| {u:g} | " + " | ".join(cols) + " |")
L += ["", f"Interval over the swept cells: s from {min(cells):.3f} to {max(cells):.3f}. The 0.524 headline is the harness's own cell (roll {n0}, unit 10), "
"not a typical one: the buyer's majority of the estate depends on how few other patrons there are.", ""]
# cap-binding rate across the whole sweep
tally = {}
for wn, W in E.WEIGHTS.items():
for ln, live in LIVE.items():
for b in bs:
for u in units:
ov = (("Patrons", "euro_b", b), ("Patrons", "euro_unit", u))
for n in rolls:
for a in amounts:
hit = unf = 0
for s in range(seeds):
lad = ladders[(n, s)]
r = E.influence_cap(E.stake_weight("Patrons", euros=a, overrides=ov), _stake_rest(ov, lad), W, PATRONS, live)
hit += r["binds"]
unf += r["binds"] and not r["enforceable"]
tally.setdefault(ln, []).append((hit, unf, seeds, n, b, a))
L += ["**Cap-binding rate over the sweep (b, unit, roll, amount, weights x live set; every cell run on every seed)**", "",
"| live estates | cells | cells x seeds | binding (cell, seed) | cells binding in every seed | cells never binding | binding but unenforceable |",
"|---|---|---|---|---|---|---|"]
for ln, t in tally.items():
tot = sum(x[2] for x in t)
L.append(f"| {ln} | {len(t)} | {tot} | {sum(x[0] for x in t) / tot:.3f} | "
f"{sum(1 for x in t if x[0] == x[2]) / len(t):.3f} | {sum(1 for x in t if x[0] == 0) / len(t):.3f} | "
f"{sum(x[1] for x in t) / tot:.3f} |")
L += ["", "Binding rate in the Patrons-alone scenario by Patrons roll and by log base (fraction of cells x seeds):", "",
"| roll | " + " | ".join(f"b={b:g}" for b in bs) + " |", "|---|" + "---|" * len(bs)]
for n in rolls:
cols = []
for b in bs:
t = [x for x in tally["Patrons alone"] if x[3] == n and x[4] == b]
cols.append(f"{sum(x[0] for x in t) / sum(x[2] for x in t):.3f}")
L.append(f"| {n} | " + " | ".join(cols) + " |")
L.append("")
# splitting
L += ["**Splitting: the same total split over k identities (defaults, one honest identity = k 1). Multiplier = total weight of the split / weight of the single purchase**", "",
"| amount EUR | single weight | " + " | ".join(f"k={k}" for k in (2, 10, 100, 1000)) + " | best k (grid) | best multiplier |",
"|---|---|" + "---|" * 6]
b0, u0 = E.STAKE_BASE["Patrons"]["euro_b"], E.STAKE_BASE["Patrons"]["euro_unit"]
for a in amounts:
w1 = E.log_weight(a, b0, u0)
bt, bk = E.best_split(a, b0, u0)
L.append(f"| {a:g} | {w1:.2f} | " + " | ".join(
f"{E.split_weight(a, k, b0, u0)[0] / w1:.1f}" for k in (2, 10, 100, 1000)) +
f" | {bk} | {bt / w1:.0f} |")
L += ["", "Best multiplier at 1,000,000 EUR by log base and unit (b rows, unit columns):", "",
"| b | " + " | ".join(f"unit={u:g}" for u in units) + " |", "|---|" + "---|" * len(units)]
for b in bs:
cols = []
for u in units:
bt, bk = E.best_split(EUR, b, u)
cols.append(f"{bt / E.log_weight(EUR, b, u):.0f} (k={bk})")
L.append(f"| {b:g} | " + " | ".join(cols) + " |")
# defences, candidates only
L += ["", "**Candidate defences, none adopted by SPEC (best multiplier at 1,000,000 EUR, defaults)**", "",
"| defence | setting | best multiplier | neutralises (best split is k = 1) |", "|---|---|---|---|"]
w1 = E.log_weight(EUR, b0, u0)
for wc in (1.0, 3.0, w1):
s1 = min(w1, wc)
bt, bk = E.best_split(EUR, b0, u0, person_cap=wc)
L.append(f"| per-person weight cap | {wc:.1f} | {bt / s1:.0f} | {'yes' if bk == 1 else 'no'} |")
for uf in (10.0, 1e3, 1e5, 5e5, 1e6):
s1 = E.log_weight(EUR, b0, uf)
bt, bk = E.best_split(EUR, b0, uf)
L.append(f"| unit floor | {uf:g} EUR | {bt / s1:.1f} | {'yes' if bk == 1 else 'no'} |")
for a in amounts:
mc = E.min_neutralising_cost(a, b0, u0)
L.append(f"| identity cost | smallest cost by bisection, amount {a:g} EUR | | "
+ (f"yes at {mc:.0f} EUR per identity ({mc / a:.1%} of the budget, bisection to 0.1%)" if mc else "no cost below the amount") + " |")
# cap evasion by splitting, Patrons alone
L += ["", "**Cap evasion: the cap is per identity. Patrons alone, equal weights, roll " + str(n0) + ", defaults, 1,000,000 EUR over k identities**", "",
"| k | joint share x | share of one identity | cap binds on an identity | joint x >= .5 (evaded) |", "|---|---|---|---|---|"]
W = E.WEIGHTS["equal"]
for k in (1, 2, 10, 100):
rows = []
for s in range(seeds):
rest = _stake_rest((), ladders[(n0, s)])
tot, wi = E.split_weight(EUR, k, b0, u0)
joint = E.influence_cap(tot, rest, W, PATRONS, (3,))
one = E.influence_cap(wi, rest + tot - wi, W, PATRONS, (3,))
rows.append((joint["x"], one["x"], one["binds"], joint["x"] >= E.HALF and not one["binds"]))
L.append(f"| {k} | {f(*E.mean_ci([r[0] for r in rows]))} | {f(*E.mean_ci([r[1] for r in rows]))} | "
f"{sum(r[2] for r in rows) / seeds:.2f} | {sum(r[3] for r in rows) / seeds:.2f} |")
# hours channel
L += ["", "**Contribution hours: Members weight from hours alone (no mechanism for contribution existed in the harness; new per-agent input, own per-estate conversion)**", "",
"| hours | " + " | ".join(f"{h:g}" for h in (5, 10, 100, 1000, 10000)) + " |", "|---|---|---|---|---|---|",
"| weight | " + " | ".join(f"{E.stake_weight('Members', hours=h):.2f}" for h in (5, 10, 100, 1000, 10000)) + " |", ""]
return L
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--quick", action="store_true")
ap.add_argument("--out", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "results.md"))
ap.add_argument("--jobs", type=int, default=min(os.cpu_count() or 1, 16))
a = ap.parse_args(argv)
reps, motions = (QUICK_REPS, QUICK_MOTIONS) if a.quick else (REPS, MOTIONS)
L = [f"# Estate-rule sim results ({'quick' if a.quick else 'full'}: {reps} replicates x {motions} motions per cell)",
"", "Generated by `run.py`; spec in SPEC.md. +/- is a 95% interval over replicates. PS populism sensitivity, "
"MT merit tracking, BPP bad-popular pass rate, GBR good-unpopular block rate (SPEC section 3).", ""]
tally = {"supported": 0, "fails": 0, "undecided": 0, "n/a": 0}
failing = []
for title, cells, detail in experiments(a.quick):
cfgs = [E.Config(**{**c.__dict__, "motions": motions}) for _, c in cells]
res = evaluate(cfgs, reps, a.jobs)
L += [f"## {title}", ""]
summ = ["| cell | dPS UNI-DIRECT | dMT UNI-DIRECT | dPS WTA-DIRECT | PS DIRECT | PS WTA | PS UNI | Users top share | Users n_eff | uncast | live edges | claim |",
"|---|---|---|---|---|---|---|---|---|---|---|---|"]
for (name, _), r in zip(cells, res):
tally[r["verdict"]] += 1
if r["verdict"] == "fails":
failing.append(f"{title.split(' ')[0]} {name}")
pr, pe = r["paired"], r["per"]
summ.append(f"| {name} | {f(*pr[('UNI','DIRECT','PS')])} | {f(*pr[('UNI','DIRECT','MT')])} | "
f"{f(*pr[('WTA','DIRECT','PS')])} | {f(*E.mean_ci(pe['DIRECT']['PS']))} | "
f"{f(*E.mean_ci(pe['WTA']['PS']))} | {f(*E.mean_ci(pe['UNI']['PS']))} | "
f"{r['conc']['top_share']:.3f} | {r['conc']['n_eff']:.1f} | {r['conc']['uncast']:.2f} | {r['conc']['live']:.2f} | {r['verdict']} |")
L += summ + [""]
if detail == "copy":
L += copy_reading(cells, res) + copy_tables(cells, res)
elif detail:
for (name, _), r in zip(cells, res):
L += [f"### {title.split(' ')[0]} / {name}", ""] + rule_table(r) + [""]
L += [pair_line(r), "", "PS by category threshold:", ""] + percat_table(r) + [""]
L += stake_section(a.quick)
L += ["## Claim tally over all cells (SPEC section 8)", "",
f"supported {tally['supported']}, fails {tally['fails']}, undecided {tally['undecided']}, n/a {tally['n/a']}", ""]
if failing:
L += ["Failing cells:", ""] + [f"- {x}" for x in failing] + [""]
with open(a.out, "w") as fh:
fh.write("\n".join(L) + "\n")
print(f"wrote {a.out}: {tally}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,539 @@
"""python3 -I test_estatesim.py"""
import math
import os
import random
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import estatesim as E # noqa: E402
W = (25, 25, 25, 25)
def est(cap=None, name="Users"):
cfg = E.Config(delegation=True, cap=cap)
e = E.Estate(name, cfg, random.Random(1))
e.target = [None] * e.n
e.scope = [None] * e.n
return e
def edge(e, i, t, scope=(0, 1, 2, 3, 4)):
e.target[i], e.scope[i] = t, frozenset(scope)
class RuleTests(unittest.TestCase):
def test_threshold_tie_passes(self):
# one live estate, exactly q For
self.assertTrue(E.passes("DIRECT", [.5, 0, 0, 0], [.5, 0, 0, 0], W, .5))
self.assertFalse(E.passes("DIRECT", [.4, 0, 0, 0], [.6, 0, 0, 0], W, .5))
def test_wta_tie_drops_estate_out(self):
# Minds tie drops out; Members 3-1 For decides alone
self.assertTrue(E.passes("WTA", [4, 3, 0, 0], [4, 1, 0, 0], W, .66))
# tied estate alone casts nothing: nothing passes
self.assertFalse(E.passes("WTA", [4, 0, 0, 0], [4, 0, 0, 0], W, .15))
def test_uni_uses_category_threshold_inside_estate(self):
# 40% For inside each estate: UNI at q=.3 casts all For, WTA casts all Against
F, A = [4, 4, 4, 4], [6, 6, 6, 6]
self.assertTrue(E.passes("UNI", F, A, W, .3))
self.assertFalse(E.passes("WTA", F, A, W, .3))
self.assertFalse(E.passes("UNI", F, A, W, .5))
def test_uni_two_of_four_blocs_at_half(self):
self.assertTrue(E.passes("UNI", [9, 9, 1, 1], [1, 1, 9, 9], W, .5)) # 50% of weight
self.assertFalse(E.passes("UNI", [9, 1, 1, 1], [1, 9, 9, 9], W, .5))
def test_renormalizes_over_live_estates(self):
self.assertTrue(E.passes("UNI", [5, 0, 0, 0], [1, 0, 0, 0], W, .66)) # empty estates not counted
self.assertFalse(E.passes("DIRECT", [0] * 4, [0] * 4, W, .15))
def test_head_ignores_estate_weights(self):
self.assertTrue(E.passes("HEAD", [1, 0, 9, 0], [9, 0, 0, 0], W, .5))
self.assertFalse(E.passes("DIRECT", [1, 0, 9, 0], [9, 0, 0, 0], W, .6))
class DelegationTests(unittest.TestCase):
def setUp(self):
self.e = est()
self.n = self.e.n
def voting(self, ids):
v = [False] * self.n
for i in ids:
v[i] = True
return v
def test_delegated_weight_goes_to_delegate(self):
edge(self.e, 1, 0)
w, dest = self.e.resolve(0, self.voting([0]))
self.assertEqual(w, {0: 2.0})
self.assertEqual(dest, {1: 0})
def test_direct_vote_overrides_delegation(self):
edge(self.e, 1, 0)
w, dest = self.e.resolve(0, self.voting([0, 1]))
self.assertEqual(w, {0: 1.0, 1: 1.0})
self.assertEqual(dest, {})
def test_scope_out_of_category_is_ignored(self):
edge(self.e, 1, 0, scope=(2,))
w, _ = self.e.resolve(0, self.voting([0]))
self.assertEqual(w, {0: 1.0})
w, _ = self.e.resolve(2, self.voting([0]))
self.assertEqual(w, {0: 2.0})
def chain_estate(self, depth, n_links):
e = est()
e.cfg = E.Config(delegation=True, depth=depth)
for k in range(1, n_links + 1): # n_links -> ... -> 1 -> 0
edge(e, k, k - 1)
return e
def test_chain_within_depth_reaches_holder(self):
e = self.chain_estate(2, 2) # 2 -> 1 -> 0, two edges
w, dest = e.resolve(0, self.voting([0]))
self.assertEqual(w, {0: 3.0})
self.assertEqual(dest, {1: 0, 2: 0})
def test_over_depth_is_uncast_not_redirected(self):
e = self.chain_estate(2, 3) # 3 needs three edges
w, dest = e.resolve(0, self.voting([0]))
self.assertNotIn(3, dest)
self.assertEqual(w, {0: 3.0})
self.assertEqual(e.last_uncast, 1)
def test_unlimited_depth_follows_long_chain(self):
e = self.chain_estate(None, 9)
w, _ = e.resolve(0, self.voting([0]))
self.assertEqual(w, {0: 10.0})
def test_intermediate_direct_vote_terminates_chain(self):
e = self.chain_estate(None, 3) # 3 -> 2 -> 1 -> 0 ; 1 votes itself
w, dest = e.resolve(0, self.voting([0, 1]))
self.assertEqual(w, {0: 1.0, 1: 3.0})
self.assertEqual(dest, {2: 1, 3: 1})
def test_loop_is_uncast(self):
e = est()
e.cfg = E.Config(delegation=True, depth=None)
edge(e, 1, 2)
edge(e, 2, 1)
edge(e, 3, 1) # feeds the loop: also void
w, dest = e.resolve(0, self.voting([0]))
self.assertEqual((w, dest), ({0: 1.0}, {}))
self.assertEqual(e.last_uncast, 3)
def test_scope_is_overlap_of_all_links(self):
e = est()
e.cfg = E.Config(delegation=True, depth=None)
edge(e, 2, 1, scope=(0, 1)) # 2 -> 1 -> 0
edge(e, 1, 0, scope=(1, 2))
for cat, expect in ((0, {}), (1, {2: 0, 1: 0}), (2, {1: 0})):
_, dest = e.resolve(cat, self.voting([0]))
self.assertEqual(dest, expect, cat)
def test_dead_end_is_uncast(self):
e = est()
edge(e, 2, 1) # 1 does not vote and delegates nowhere
w, dest = e.resolve(0, self.voting([0]))
self.assertEqual((w, dest), ({0: 1.0}, {}))
self.assertEqual(e.last_uncast, 1)
def test_cap_voids_excess_and_floors_at_one(self):
e = est(cap=.5) # cast weight 12, cap 6
for i in range(1, 11):
edge(e, i, 0)
voting = [i in (0, 20) for i in range(e.n)]
w, _ = e.resolve(0, voting)
self.assertEqual(w, {0: 6.0, 20: 1.0}) # 11 delegated+own capped to 6, excess void
e = est(cap=.001, name="Patrons") # below one voter: floor is 1
w, _ = e.resolve(0, [i == 0 for i in range(e.n)])
self.assertEqual(w, {0: 1.0})
def test_cap_applies_to_final_holder_not_each_link(self):
e = est(cap=.4)
e.cfg = E.Config(delegation=True, depth=None, cap=.4)
for i in range(1, 6):
edge(e, i, 6) # five delegators to 6 ...
edge(e, 6, 0) # ... who delegates to 0
w, _ = e.resolve(0, self.voting([0, 20])) # 0 holds 1+6, cast 8 -> cap 3.2
self.assertEqual(w[0], 3.2)
def test_cap_basis_roll(self):
e = est()
e.cfg = E.Config(delegation=True, cap=.1, cap_basis="roll") # 300 * .1 = 30
for i in range(1, 41):
edge(e, i, 0)
w, _ = e.resolve(0, self.voting([0]))
self.assertEqual(w, {0: 30.0})
def contradicting_estate(self):
e = est()
edge(e, 1, 0)
e.turn[0], e.kap[0], e.a[0], e.b[0], e.sig[0] = 1.0, 0.0, 1.0, 0.0, .01
e.turn[1], e.kap[1], e.a[1], e.b[1], e.sig[1] = 0.0, 0.0, -3.0, 0.0, .01
return e
def test_contradiction_forces_direct_vote_then_revokes_at_second_strike(self):
e = self.contradicting_estate()
dt = e.motion(0, 1.0, 0.0, random.Random(1), 0.0)
self.assertIn(1, [t for t, _, _ in dt["holders"]]) # overrides this motion
self.assertEqual(e.target[1], 0) # one strike: still delegated
self.assertEqual(e.strikes[1], 1)
e.motion(0, 1.0, 0.0, random.Random(2), 0.0)
self.assertIsNone(e.target[1]) # second strike revokes
e.motion(0, 1.0, 0.0, random.Random(3), 0.0)
self.assertEqual(e.strikes[1], 2) # revoked: no further strikes while unrenewed
self.assertIsNone(e.target[1])
def test_renewal_restores_an_edge_with_fresh_strikes(self):
e = self.contradicting_estate()
e.cfg = E.Config(delegation=True, renew=1.0, peer=0.0)
e.pool, e.delegators = ([0], [1.0]), [1]
e.target[1], e.revoked, e.strikes[1] = None, [1], 2
e.motion(0, 0.0, 0.0, random.Random(1), 0.0)
self.assertEqual((e.target[1], e.strikes[1], e.revoked), (0, 0, []))
def test_direct_voters_never_forwarded(self):
edge(self.e, 1, 0)
w, _ = self.e.resolve(0, self.voting([0, 1]))
self.assertNotIn(2, w)
self.assertEqual(sum(w.values()), 2.0)
class CopyTests(unittest.TestCase):
"""SPEC section 5b: copy-the-ballot."""
def setup(self, copy, cap=.2, followers=10):
e = est(cap)
e.cfg = E.Config(delegation=True, cap=cap, copy=copy)
for k in range(1, followers + 1):
edge(e, k, 0)
voting = [False] * e.n
voting[0] = True
sb = [0] * e.n
sb[0] = 1
w, dest = e.resolve(0, voting)
return e, voting, sb, w, dest
def test_off_by_default_draws_nothing(self):
e, v, sb, w, dest = self.setup(0.0)
state = e.rng.getstate()
self.assertIsNone(e.copy_step(0, v, sb, dest))
self.assertEqual(e.rng.getstate(), state)
self.assertEqual(w[0], 2.2) # cap .2 of 11 cast weight; excess void
def test_full_copying_evades_the_cap(self):
e, v, sb, w, dest = self.setup(1.0)
copiers, w2, _ = e.copy_step(0, v, sb, dest)
self.assertEqual(len(copiers), 10)
self.assertEqual(w2[0], 1.0) # the holder keeps only its own vote
self.assertTrue(all(w2[c] == 1.0 and sb[c] == 1 and v[c] for c in copiers))
self.assertEqual(sum(w2.values()), 11.0) # nothing voided
self.assertEqual(e.effective(w2, copiers), {0: 11.0})
def test_copiers_cast_the_holders_ballot_not_their_own(self):
e, v, sb, w, dest = self.setup(1.0)
sb[0] = -1
copiers, w2, _ = e.copy_step(0, v, sb, dest)
self.assertTrue(all(sb[c] == -1 for c in copiers))
def test_no_copying_under_a_holder_within_the_cap(self):
e, v, sb, w, dest = self.setup(1.0, cap=1.0, followers=3) # cap 4.0 of 4 cast, raw 4.0: not over
self.assertIsNone(e.copy_step(0, v, sb, dest))
def test_no_copying_without_a_cap(self):
e, v, sb, w, dest = self.setup(1.0, cap=None)
self.assertIsNone(e.copy_step(0, v, sb, dest))
def test_partial_copy_rate_is_between(self):
e, v, sb, w, dest = self.setup(.5, followers=40)
copiers, w2, _ = e.copy_step(0, v, sb, dest)
self.assertTrue(8 <= len(copiers) <= 32)
self.assertEqual(e.effective(w2, copiers)[0], sum(w2.values()) - sum(
x for t, x in w2.items() if t != 0 and t not in copiers))
def test_followers_of_a_copier_are_counted_through_it(self):
e, v, sb, w, dest = self.setup(1.0, followers=10)
edge(e, 20, 5) # 20 delegates to 5, who delegates to 0
w, dest = e.resolve(0, v)
copiers, w2, dest2 = e.copy_step(0, v, sb, dest)
self.assertEqual(sum(w2.values()), 12.0) # all twelve ballots are cast, none void
self.assertEqual(e.effective(w2, copiers), {0: 12.0})
def test_copying_run_is_deterministic_and_leaves_paired_draws(self):
cfg = E.Config(delegation=True, alpha=4.0, cap=.1, copy=.5, rho=.25, faction=10, motions=30)
self.assertEqual(E.run_replicate(cfg, 2), E.run_replicate(cfg, 2))
a, _ = E.run_replicate(E.Config(motions=30), 2)
b, _ = E.run_replicate(cfg, 2)
self.assertEqual([r[:3] for r in a], [r[:3] for r in b])
def test_copy_off_matches_the_no_copy_config_byte_for_byte(self):
base = dict(delegation=True, alpha=2.5, cap=.15, rho=.25, faction=20, motions=40)
self.assertEqual(E.run_replicate(E.Config(**base), 4), E.run_replicate(E.Config(copy=0.0, **base), 4))
def test_effective_concentration_exceeds_capped_formal_weight(self):
cfg = E.Config(delegation=True, alpha=4.0, cap=.1, copy=1.0, motions=60)
_, conc = E.run_replicate(cfg, 0)
self.assertGreater(sum(c[7] for c in conc), sum(c[0] for c in conc))
self.assertGreater(sum(c[9] for c in conc), 0)
_, conc = E.run_replicate(E.Config(delegation=True, alpha=4.0, cap=.1, motions=60), 0)
self.assertEqual(sum(c[9] for c in conc), 0)
self.assertAlmostEqual(sum(c[7] for c in conc), sum(c[0] for c in conc))
class StakeTests(unittest.TestCase):
"""SPEC section 5c: purchased stake, splitting, the half-influence cap."""
P = E.ESTATES.index("Patrons")
ALL = (0, 1, 2, 3)
def cfg(self):
return E.STAKE_BASE["Patrons"]
def test_weight_is_one_at_unit_zero_below_and_logarithmic(self):
c = self.cfg()
b, u = c["euro_b"], c["euro_unit"]
self.assertEqual(E.log_weight(u, b, u), 1.0)
self.assertAlmostEqual(E.log_weight(u / 2, b, u), 1 + math.log(.5, b)) # below unit: continuous, no cliff
self.assertEqual(E.log_weight(u / b ** 2, b, u), 0.0) # clamped at 0, never negative
self.assertAlmostEqual(E.log_weight(u * b ** 3, b, u), 4.0) # each factor b adds one
self.assertLess(E.log_weight(10 * u, b, u), 10) # sub-linear
def test_conversion_is_per_estate_and_overridable(self):
ov = (("Patrons", "euro_b", 2.0), ("Patrons", "euro_unit", 5.0), ("Members", "euro_b", 100.0))
self.assertAlmostEqual(E.stake_weight("Patrons", euros=40, overrides=ov), 1 + math.log(8, 2))
self.assertAlmostEqual(E.stake_weight("Patrons", euros=100), 1 + math.log(10, 10))
self.assertEqual(E.stake_params("Members", ov)["euro_b"], 100.0)
self.assertEqual(E.stake_params("Patrons", ov)["euro_b"], 2.0)
self.assertEqual(E.stake_weight("Users", euros=1e6), 1.0) # no stake channel: one-each
self.assertEqual(E.stake_weight("Minds", hours=1e6), 1.0)
def test_hours_add_to_euros_with_their_own_conversion(self):
ov = (("Members", "hours_b", 2.0), ("Members", "hours_unit", 4.0))
h = E.stake_weight("Members", hours=32, overrides=ov)
self.assertAlmostEqual(h, 1 + math.log(8, 2))
self.assertAlmostEqual(E.stake_weight("Members", euros=100, hours=32, overrides=ov), h + 2.0)
def test_million_euro_buyer_share_is_derived_from_config(self):
c = self.cfg()
wb = E.stake_weight("Patrons", euros=1e6)
n = E.ROLL["Patrons"] # the harness's patron roll, each at exactly the unit
others = n * E.log_weight(c["euro_unit"], c["euro_b"], c["euro_unit"])
s_star = wb / (wb + others)
for wn, W in E.WEIGHTS.items():
# all estates live: the buyer's council share cannot exceed the Patrons estate weight, below half by the charter
r = E.influence_cap(wb, others, W, self.P, self.ALL)
self.assertAlmostEqual(r["s"], s_star)
self.assertAlmostEqual(r["x"], W[self.P] / sum(W) * s_star)
self.assertLess(r["x"], W[self.P] / sum(W) + 1e-12)
self.assertFalse(r["binds"], wn)
# Patrons the only live estate: the cap binds exactly when the buyer outweighs the rest
r = E.influence_cap(wb, others, W, self.P, (self.P,))
self.assertEqual(r["binds"], s_star >= E.HALF)
self.assertEqual(r["binds"], wb >= others)
if r["binds"]:
self.assertLess(r["x_capped"], E.HALF)
self.assertAlmostEqual(r["x_capped"], E.HALF, places=6)
def test_log_weight_gives_a_smaller_share_than_linear_but_still_a_near_total_one_in_a_tiny_estate(self):
c = self.cfg()
rest_amounts = [10.0, 50.0, 200.0, 5000.0]
W = E.WEIGHTS["equal"]
lg = E.council_share(E.stake_weight("Patrons", euros=1e6),
sum(E.stake_weight("Patrons", euros=a) for a in rest_amounts), W, self.P, (self.P,))[1]
li = E.council_share(1e6 / c["euro_unit"], sum(a / c["euro_unit"] for a in rest_amounts), W, self.P, (self.P,))[1]
self.assertLess(lg, li)
self.assertGreater(li, .99)
def test_cap_is_unenforceable_when_nobody_else_holds_weight(self):
r = E.influence_cap(5.0, 0.0, E.WEIGHTS["equal"], self.P, (self.P,))
self.assertTrue(r["binds"])
self.assertFalse(r["enforceable"])
def test_splitting_multiplier_matches_the_formula_and_exceeds_one(self):
c = self.cfg()
b, u, a, k = c["euro_b"], c["euro_unit"], 1e6, 100
tot, each = E.split_weight(a, k, b, u)
self.assertAlmostEqual(each, 1 + math.log(a / (k * u), b))
self.assertAlmostEqual(tot, k * (1 + math.log(a / (k * u), b)))
self.assertGreater(tot, E.log_weight(a, b, u))
kk = int(a / u) + 1 # every fake just below unit: still ~1 each, no cliff
self.assertAlmostEqual(E.split_weight(a, kk, b, u)[0], kk * (1 + math.log(a / (kk * u), b)))
self.assertGreater(E.split_weight(a, kk, b, u)[0], 0.99 * a / u)
best, bk = E.best_split(a, b, u)
self.assertGreaterEqual(best, tot)
self.assertAlmostEqual(best, a / u) # b > e: best is k = a/unit, weight 1 each
def test_per_person_cap_and_unit_floor_below_amount_do_not_neutralise_splitting(self):
c = self.cfg()
b, u, a = c["euro_b"], c["euro_unit"], 1e6
single = E.log_weight(a, b, u)
for wc in (1.0, 3.0, single):
best, k = E.best_split(a, b, u, person_cap=wc)
self.assertGreater(best, min(single, wc))
self.assertGreater(E.best_split(a, b, 1000.0)[1], 1) # raised unit still splits
self.assertEqual(E.best_split(a, b, a)[1], 1) # unit = amount leaves nothing to split
def test_identity_cost_neutralises_only_at_a_large_share_of_the_budget(self):
c = self.cfg()
b, u, a = c["euro_b"], c["euro_unit"], 1e6
cost = E.min_neutralising_cost(a, b, u)
self.assertIsNotNone(cost)
self.assertEqual(E.best_split(a, b, u, identity_cost=cost)[1], 1)
self.assertGreater(E.best_split(a, b, u, identity_cost=cost / 2)[1], 1)
self.assertGreater(cost, a / 4)
self.assertAlmostEqual(cost / a, .5, delta=.01) # bisection value: just under half the budget
self.assertLess(cost, a / 2 + 1e-9)
def test_headline_numbers_are_pinned(self):
"""The figures REPORT.md quotes, under the placeholder defaults and the fixed seeds (40, STAKE_SEED)."""
W = E.WEIGHTS["equal"]
r = E.buyer_runs(1e6, E.ROLL["Patrons"], 40, W, (self.P,))
m, hw = E.mean_ci([x["s"] for x in r])
self.assertEqual(round(m, 3), .524)
self.assertEqual(round(hw, 3), .008)
self.assertEqual(sum(x["s"] > .5 for x in r) / 40, .85)
c = self.cfg()
w1 = E.log_weight(1e6, c["euro_b"], c["euro_unit"])
for k, want in ((2, 1.9), (10, 8.3), (100, 66.7)):
self.assertEqual(round(E.split_weight(1e6, k, c["euro_b"], c["euro_unit"])[0] / w1, 1), want)
for n in (40, 400): # the headline is a small-roll fact
self.assertLess(max(x["s"] for x in E.buyer_runs(1e6, n, 40, W, (self.P,))), .5)
def test_per_identity_cap_is_evaded_by_splitting(self):
c = self.cfg()
W = E.WEIGHTS["equal"]
rest = E.ROLL["Patrons"] * 1.0
single = E.stake_weight("Patrons", euros=1e6)
self.assertTrue(E.influence_cap(single, rest, W, self.P, (self.P,))["binds"])
k = 10
tot, wi = E.split_weight(1e6, k, c["euro_b"], c["euro_unit"])
self.assertFalse(E.influence_cap(wi, rest + tot - wi, W, self.P, (self.P,))["binds"])
self.assertGreaterEqual(E.council_share(tot, rest, W, self.P, (self.P,))[1], E.HALF)
def test_pareto_ladder_is_deterministic_and_above_floor(self):
a, b = E.pareto_ladder(50, random.Random(7)), E.pareto_ladder(50, random.Random(7))
self.assertEqual(a, b)
self.assertTrue(all(x >= E.PARETO_XM for x in a))
class StrategyTests(unittest.TestCase):
def test_deviation_never_makes_agent_worse_off(self):
rng = random.Random(3)
for _ in range(300):
q = rng.choice(E.CATS)
F = [float(rng.randrange(0, 6)) for _ in range(4)]
A = [float(rng.randrange(0, 6)) for _ in range(4)]
rule = rng.choice(E.RULES)
e = rng.randrange(4)
for b in (1, -1):
F0, A0 = list(F), list(A)
E._move(F0, A0, e, 0, b, 1.0)
pref = b == 1
ag = dict(e=e, w=1.0, sincere=b, cur=b, pref=pref, opts=[b] + [x for x in (1, -1, 0) if x != b])
sincere_ok = E.passes(rule, F0, A0, W, q) == pref
E.strategize(rule, W, q, F0, A0, [ag], 7)
if sincere_ok:
self.assertTrue(E.passes(rule, F0, A0, W, q) == pref)
def test_strategic_joiner_joins_only_when_pivotal(self):
F, A = [3.0, 0, 0, 0], [3.0, 0, 0, 0] # tie, q=.5 passes under UNI already
ag = dict(e=0, w=1.0, sincere=0, cur=0, pref=True, opts=[0, 1])
E.strategize("UNI", W, .5, F, A, [ag], 1)
self.assertEqual(ag["cur"], 0) # already passes: stays home
F, A = [2.0, 0, 0, 0], [3.0, 0, 0, 0] # 40% For: fails at .5, joining makes 3/6
ag = dict(e=0, w=1.0, sincere=0, cur=0, pref=True, opts=[0, 1])
E.strategize("UNI", W, .5, F, A, [ag], 1)
self.assertEqual(ag["cur"], 1)
def test_faction_votes_agenda_regardless_of_preference(self):
cfg = E.Config(faction=20)
e = E.Estate("Users", cfg, random.Random(2))
self.assertEqual(sum(e.faction), 20)
dt = e.motion(0, -2.0, 2.0, random.Random(9), 0.0) # d - m > 0: faction votes For
ballots = {t: b for t, b, _ in dt["holders"]}
for i in range(e.n):
if e.faction[i]:
self.assertEqual(ballots[i], 1)
class HarnessTests(unittest.TestCase):
CFG = E.Config(delegation=True, alpha=1.5, cap=.2, rho=.25, faction=10, motions=30)
def test_deterministic(self):
self.assertEqual(E.run_replicate(self.CFG, 3), E.run_replicate(self.CFG, 3))
def test_draws_paired_across_rules_and_configs(self):
a, _ = E.run_replicate(E.Config(motions=30), 1)
b, _ = E.run_replicate(self.CFG, 1)
self.assertEqual([r[:3] for r in a], [r[:3] for r in b]) # same (category, merit, appeal)
def test_sincere_run_has_no_strategy_flips(self):
r, _ = E.run_replicate(E.Config(motions=60), 0)
self.assertTrue(all(x[3] == x[4] for x in r))
def test_metrics_on_known_outcomes(self):
# outcome == (d > 0): PS = 1, MT ~ 0 (independent), BPP = 1, GBR = 1 - 0 = 1
rng = random.Random(1)
recs = []
for _ in range(4000):
m, d = rng.gauss(0, 1), rng.gauss(0, 1)
recs.append((0, m, d, (d > 0,) * 4, (d > 0,) * 4))
mm = E.rep_metrics(recs, 0)
self.assertEqual(mm["PS"], 1.0)
self.assertLess(abs(mm["MT"]), .1)
self.assertEqual(mm["BPP"], 1.0)
self.assertEqual(mm["GBR"], 1.0)
# outcome == (m > 0): PS ~ 0, MT = 1, BPP = 0, GBR = 0
recs = [(0, m, d, (m > 0,) * 4, (m > 0,) * 4) for (_, m, d, _, _) in recs]
mm = E.rep_metrics(recs, 0)
self.assertEqual(mm["MT"], 1.0)
self.assertEqual(mm["BPP"], 0.0)
self.assertEqual(mm["GBR"], 0.0)
self.assertLess(abs(mm["PS"]), .1)
def test_concentration(self):
top, gini, neff, tot = E.concentration([1, 1, 1, 1])
self.assertEqual((top, round(gini, 9), neff, tot), (.25, 0.0, 4.0, 4))
top, gini, neff, _ = E.concentration([1, 1, 1, 97])
self.assertGreater(gini, .7)
self.assertLess(neff, 1.1)
def test_verdict_rules(self):
self.assertEqual(E.verdict((-.1, .02), (0.0, .02)), "supported")
self.assertEqual(E.verdict((.1, .02), (0.0, .02)), "fails")
self.assertEqual(E.verdict((-.1, .02), (-.12, .02)), "fails") # dampened by not working
self.assertEqual(E.verdict((-.01, .05), (0.0, .02)), "undecided")
class StakeVectors(unittest.TestCase):
"""Pins the arm's log_weight / split_weight to CultLib heimdall-stake's golden vectors (PR #9, head 84f932ec,
copied unmodified to vectors/stake_golden.csv). The arm is Libby's function with no minimum,
and every row (14 single, 12 split) must match, including those below the unit: no cliff. Vectors are integer cents and micro-weights, the
arm is float and unit-agnostic: tolerance is 1 micro-weight per identity (Libby rounds each identity, the arm the total)."""
@staticmethod
def rows():
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "vectors", "stake_golden.csv")
with open(path) as fh:
lines = [l.strip() for l in fh if l.strip() and not l.startswith("#")]
out = [l.split(",") for l in lines]
assert len(out) == 26
return [(k, int(b), int(u), int(a), int(n), int(e)) for k, b, u, a, n, e in out]
def test_every_vector_row(self):
for kind, b, u, a, k, expected in self.rows():
with self.subTest(kind=kind, b=b, unit=u, amount=a, k=k):
if kind == "single":
got = E.log_weight(a, b, u) * 1e6
else:
got = E.split_weight(a, k, b, u)[0] * 1e6
self.assertLessEqual(abs(got - expected), max(1, k))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,29 @@
# kind,base,unit_cents,amount_cents,k,expected_micro
# single: weight of one identity holding amount. split: budget (amount) split evenly over k identities (first amount%k hold one extra cent), summed.
# expected values computed independently with 60-digit decimal ln, rounded half up. No floor, cap or minimum.
single,10,100,100,1,1000000
single,10,100,1000,1,2000000
single,10,100,10000,1,3000000
single,10,100,1000000,1,5000000
single,10,100,50,1,698970
single,10,100,10,1,0
single,10,100,1,1,0
single,2,100,400,1,3000000
single,2,100,100,1,1000000
single,2,1,1,1,1000000
single,3,500,123456,1,6014537
single,7,1,987654321,1,11643268
single,10,1,1,1,1000000
single,16,250,99999,1,3160960
split,10,100,10000,1,3000000
split,10,100,10000,2,5397940
split,10,100,10000,10,20000000
split,10,100,10000,100,100000000
split,2,100,10000,1,7643856
split,2,100,10000,2,13287712
split,2,100,10000,10,43219280
split,2,100,10000,100,100000000
split,10,100,100000,1,4000000
split,10,100,100000,2,7397940
split,10,100,100000,10,30000000
split,10,100,100000,100,200000000
1 # kind,base,unit_cents,amount_cents,k,expected_micro
2 # single: weight of one identity holding amount. split: budget (amount) split evenly over k identities (first amount%k hold one extra cent), summed.
3 # expected values computed independently with 60-digit decimal ln, rounded half up. No floor, cap or minimum.
4 single,10,100,100,1,1000000
5 single,10,100,1000,1,2000000
6 single,10,100,10000,1,3000000
7 single,10,100,1000000,1,5000000
8 single,10,100,50,1,698970
9 single,10,100,10,1,0
10 single,10,100,1,1,0
11 single,2,100,400,1,3000000
12 single,2,100,100,1,1000000
13 single,2,1,1,1,1000000
14 single,3,500,123456,1,6014537
15 single,7,1,987654321,1,11643268
16 single,10,1,1,1,1000000
17 single,16,250,99999,1,3160960
18 split,10,100,10000,1,3000000
19 split,10,100,10000,2,5397940
20 split,10,100,10000,10,20000000
21 split,10,100,10000,100,100000000
22 split,2,100,10000,1,7643856
23 split,2,100,10000,2,13287712
24 split,2,100,10000,10,43219280
25 split,2,100,10000,100,100000000
26 split,10,100,100000,1,4000000
27 split,10,100,100000,2,7397940
28 split,10,100,100000,10,30000000
29 split,10,100,100000,100,200000000

View file

@ -0,0 +1,98 @@
# Anonymous vote stuffing: prior art
Serves the fork: concept-mill-votes Cut 4, anti-stuffing defence; campaign concept-mill-votes; 2026-10-10.
Subject text: "Anti-stuffing defence" in `F:\Projects\Bifrost\docs\concept-mill-votes-target.md` (lines ~106-130).
Gathered by Eyes. Facts with pointers; no recommendation. Items marked (secondary) were not read at the primary source. Search-engine summaries were not verified against the pages unless stated.
Gaps: (a) a query for residential-proxy and CAPTCHA-farm pricing was refused by the search tool, so no measured cost of a headless/proxy farm is here; (b) StrawPoll's own duplicate-check docs were not found; (c) Friendly Captcha's own licence/docs pages were not read; (d) ALTCHA's server-verification and PoW doc pages returned 404/empty; (e) national ePrivacy guidance beyond CNIL and ICO was not searched.
## 1. Anonymous and low-friction voting in established systems
- Poll software generally: duplicate checks are IP locking, cookie, or sign-in. Vendor docs say IP checks block shared networks (offices, schools), cookie checks are beaten by clearing cookies or switching browsers, and sign-in is strongest but still needs multi-account safeguards. Kentico: https://docs.kentico.com/k8/securing-websites/designing-secure-websites/securing-and-protecting-the-system/preventing-duplicate-poll-voting-and-content-rating . Academic restatement ("IP locking, cookie-based methods"): https://www.scitepress.org/PublishedPapers/2004/14026/14026.pdf (2004). WordPress poll-plugin thread reporting repeat votes despite "cookie and ip" logging: https://wordpress.org/support/?p=1278907
- StrawPoll: its own option documentation was not found; the design doc's StrawPoll statement is not confirmed by this pass.
- Reddit: "vote fuzzing" described by staff only via press (Daily Dot, https://dailydot.com/news/reddit-vote-count-percent-like/ , undated): displayed totals altered as an anti-spam measure. Mechanics (offsetting detected bot votes, paired fake votes) come from user/moderator posts only; no official algorithm page found (secondary).
- Hacker News: a "voting ring detector" demotes rather than kills (dang, 2014, https://news.ycombinator.com/item?id=7542985 ; community list https://github.com/minimaxir/hacker-news-undocumented ). Moderators have publicly rolled back a ring penalty as a false positive (https://hn.svelte.dev/item/47457689 ). Internals unpublished.
- Stack Exchange: help page "serial voting reversed" (https://stackoverflowteams.com/help/serial-voting-reversed): one user voting on many of another user's posts in a short window is judged invalid and removed; some removal by a daily automated process, some by staff after investigation. Thresholds not public. The page says a "voting corrected" event does not imply wrongdoing.
- Product Hunt: staff reply in https://www.producthunt.com/p/producthunt/vote-selling-on-product-hunt says they will not reveal vote weights or all signals; a zero-point rule for voters with no history was under consideration. Weighting by account age/activity is asserted only by third-party guides (secondary).
- Academic, Sybil resistance:
- Douceur, "The Sybil Attack" (IPTPS 2002): absent a logically centralized authority, Sybil attacks can always be mounted barring unrealistic resource parity. https://courses.csail.mit.edu/6.895/fall02/papers/Douceur/iptps02-douceur.pdf . Identifying nodes by hash of IP address (CFS) is noted as implicitly relying on an authority.
- SybilGuard/SybilLimit (Yu, Gibbons, Kaminsky, Xiao, 2008): bound accepted Sybil nodes using social-network structure (fast mixing). https://www.comp.nus.edu.sg/~yuhf/sybillimit-tr.pdf
- SumUp (Tran, Min, Li, Subramanian, NSDI 2009): content-vote aggregation capping adversary votes at the number of attack edges in a trust graph, plus feedback-based reduction of misbehaving voters; applied to a Digg vote trace, found strong evidence of attack on many "popular" articles. https://static.usenix.org/events/nsdi09/tech/full_papers/tran/tran.pdf
- All three need a social/trust graph or authority; none targets graph-less anonymous visitors.
## 2. Proof-of-work challenges
| Project | Licence | Self-host | .NET | Pointers |
|---|---|---|---|---|
| ALTCHA | MIT (widget README) | Yes | Official server list: TS, Dart, PHP, Go, Python, Java, Ruby, Elixir, Rust, C++ (no C#). Community: Ixnas.AltchaNet (0.4.x; SHA-256; replay store interface; bundled in-memory store "for testing only") and unofficial Altcha.Net 1.2.0 (replay store interface; in-memory not advised multi-server) | https://raw.githubusercontent.com/altcha-org/altcha/main/README.md ; https://www.nuget.org/packages/Altcha.Net ; https://www.nuget.org/packages/Ixnas.AltchaNet (via search, not opened) |
| mCaptcha | AGPL-3.0 | Yes, Docker Compose; separate Rust service | None found | https://github.com/mCaptcha/mCaptcha ; README: "active development", no releases listed, SHA-256 PoW, difficulty scales with load (about 2 s under attack), proof configs expire after 30 s and are single-use, cookie-free, IP-independent |
| Cap | Apache-2.0 | Yes, Docker/workers | None stated | https://github.com/tiagozip/cap ; README claims "gpu-resistant" PoW plus instrumentation challenges; no algorithm parameters; size claims are marketing |
| Friendly Captcha | Third-party listings: front end MPL-2.0 (unconfirmed); back end hosted commercial, a "lite" self-host server mentioned (unconfirmed) | Hosted service | Not checked | https://friendlycaptcha.com/insights/open-source-captcha/ ; https://openalternative.co/friendly-captcha (secondary) |
| Anubis | MIT, Go | Yes, reverse-proxy sidecar | n/a | https://github.com/TecharoHQ/anubis ; aimed at scrapers; README calls itself "a bit of a nuclear response" and notes it can block good bots |
- ALTCHA widget: PBKDF2/SHA variants bundled; Argon2id and Scrypt (memory-bound, "harder to accelerate on ASICs or GPUs") are separate imports; `minDuration` default 500 ms; `timeout` default 90 s; `humanInteractionSignature` on by default (mechanism not documented in the README). Ixnas.AltchaNet defaults: complexity 50000-100000, expiry 120 s (search summary).
- Argued or measured attacker cost:
- Laurie and Clayton, "Proof-of-Work Proves Not to Work" (WEIS 2004): difficulty high enough to stop spam would stop significant numbers of legitimate senders, since attackers use hijacked machines and run continuously. https://www.cl.cam.ac.uk/~rnc1/proofwork2.pdf . Rebuttal, Liu and Camp, "Proof of Work can Work" (2006): works if work is weighted by a reputation function. https://sites.cs.ucsb.edu/~rich/class/cs293b-cloud/papers/lui-pow.pdf
- Anubis: its author acknowledges PoW works best against crawlers that cannot run JavaScript; an operator with a solver keeps the authorization cookie and continues (LWN, https://lwn.net/Articles/1028558 ). A July 2026 CLI client (anubis-fetch) automates the challenge (secondary: https://runtimewire.com/article/anubis-bypass-proof-of-work-bot-wall-limits ).
- No numbers (seconds per solve on commodity vs GPU hardware, attacker cost per 1000 votes) found for ALTCHA, mCaptcha or Cap.
- Reuse shape: mCaptcha tokens are single-use and short-lived; Anubis issues a cookie that covers later requests.
## 3. ASP.NET Core rate limiting and forwarded headers
Docs read: https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit (ms.date 2026-09-03; covers 7.0-12.0) and https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer (ms.date 2026-04-23).
- Rate limiter: `AddRateLimiter`, `GlobalLimiter` or named policies, `PartitionedRateLimiter.Create<HttpContext,string>`, `RateLimitPartition.GetFixedWindowLimiter` etc. The doc's "By IP address" sample keys on `Connection.RemoteIpAddress?.ToString() ?? "unknown"`. The page contains no IPv6 prefix guidance.
- Doc warnings: partitioning on user-controlled input can exhaust memory (each distinct key creates and caches a limiter); "if you create partitions by using client IP addresses, your app becomes vulnerable to Denial of Service attacks that use IP Source Address Spoofing" (cites BCP 38).
- `UseRateLimiter` must come after `UseRouting` when endpoint-specific policies/attributes are used; global-only may precede it.
- Time-based limiters do not refund a permit already taken when a later chained limiter rejects.
- `OnRejected`/`RejectionStatusCode` are needed for 429; the doc shows setting Retry-After manually or from lease metadata.
- Forwarded headers:
- Middleware "should run before other middleware" (before `UseHsts`; may follow diagnostics/error handling). Anything reading `RemoteIpAddress`, including the rate limiter, must come after it to see the forwarded address.
- Default `KnownNetworks` is a single loopback entry (`IPNetwork(IPAddress.Loopback, 8)`), and `KnownProxies` likewise loopback-only. Forwarded values are accepted only if the immediate peer matches one of the lists.
- Default `ForwardLimit` is 1: only the rightmost `X-Forwarded-For` entry is processed (right to left). Null is advised only with known proxies/networks configured.
- Doc: "Only allow trusted proxies and networks to forward headers. Otherwise, IP spoofing attacks are possible."
- Dual-mode sockets: IPv4 entries must be given in IPv4-mapped IPv6 form; check `RemoteIpAddress` to see which applies.
- `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true` uses cloud-style settings and "doesn't enable features such as the KnownProxies option".
- Unverified: whether newer .NET renames `KnownNetworks`; the page read lists `KnownNetworks` with `IPNetwork`.
- IPv6 prefix practice (practitioner sources only; no standard or vendor guidance found):
- RFC 8981 privacy extensions rotate the interface id inside one /64 (preferred lifetime 1 day, valid 2 days, up to 3 concurrent). https://www.rfc-editor.org/rfc/rfc8981.html
- A residential customer can obtain /56 or /48 via DHCPv6-PD, so per-/64 buckets can be spread across by one household. v6ops https://psg.com/lists/v6ops/v6ops.2010/msg00152.html ; NANOG https://mailman.nanog.org/pipermail/nanog/2015-October/079735.html
- One ISP policy quoted: /56 broadband, /48 business, /64 mobile; aggregating too high can block tens of thousands of mobile users. Suggested escalating aggregation /64 to /48 to /40 to /36 to /32 on observed abuse. Nextcloud thread weighing /56 default: https://help.nextcloud.com/t/is-bruteforce-protection-ipv6-ready/215084
- No source found for a /24 IPv4 partition choice.
## 4. Device/cookie identifiers
- Data Protection (https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/default-settings ; `.../configuration/overview`):
- Default key storage: Azure Apps `%HOME%`, else user profile (`%LOCALAPPDATA%`, DPAPI on Windows), else IIS registry; "If none of these conditions match, keys aren't persisted outside of the current process. When the process shuts down, all generated keys are lost."
- Docker: persist keys to a volume or external store. `SetApplicationName` must match across deployments (default discriminator is content-root path).
- Default key lifetime 90 days with auto-roll; retired keys still decrypt while kept. "Deleting a key makes its protected data permanently inaccessible"; docs recommend not deleting.
- A custom key repository disables automatic encryption of keys at rest.
- Payload algorithms: AES-256-CBC + HMACSHA256 (encrypted and authenticated, not only signed).
- SameSite: Chrome treats cookies without a SameSite attribute as Lax from v80 (https://web.dev/articles/samesite-cookies-explained ). Chrome kept third-party cookies; Privacy Sandbox APIs retired Oct 2025 (secondary: https://engadget.com/cybersecurity/google-has-killed-privacy-sandbox-130029899.html ). Safari blocks third-party cookies by default, Firefox blocks known trackers (secondary, 2019). A same-origin vote page is first-party throughout.
- Safari ITP: 7-day cap applies to cookies written via `document.cookie`; WebKit's Wilander stated Secure HttpOnly server-set cookies are not affected (https://simoahava.com/privacy/first-party-cookies-webkit-revisited/ ; WebKit bugs 237350, 237474). Sources disagree on later script-cookie handling; none found capping server-set cookies.
- Private/incognito windows discard the cookie jar on close (browser behaviour; no primary source fetched).
- Regulators:
- ICO, "What are the exceptions" (https://ico.org.uk/for-organisations/direct-marketing-and-privacy-and-electronic-communications/guidance-on-the-use-of-storage-and-access-technologies/what-are-the-exceptions/ ): PECR examples include "ensuring the security of terminal equipment" and "preventing or detecting fraud"; necessity is assessed "from the point of view of the subscriber or user, not your own"; a second purpose voids the exception; advertising never qualifies, including click-fraud detection.
- CNIL cookies FAQ (https://www.cnil.fr/fr/cookies-et-autres-traceurs/regles/cookies/FAQ), Q16: fraud-prevention trackers are in general not exempt, but may be in particular cases such as securing a user authentication mechanism. Q17 (CAPTCHA): no consent if the sole purpose is securing the site or an authentication mechanism for the user's benefit; consent if data is reused for the provider's own ends (reCAPTCHA named) or purposes unspecified; where consent is required, refusal bypasses the protection.
- EDPB Guidelines 2/2023 on Art. 5(3) ePrivacy (v2.0, 7 Oct 2024): applies to any storage/access on terminal equipment, technology-neutral, including identifiers other than cookies; lists "tracking based on IP only" as a use case. https://www.edpb.europa.eu/documents/guideline/guidelines-22023-on-technical-scope-of-art-53-of-eprivacy-directive_en . Its exemption text on identifiers was not read.
- GDPR status of an HMAC'd network tag stored per row was not searched.
## 5. Known failure modes per layer
- Device token: cleared cookies, private windows and fresh profiles reset it (poll vendors, Kentico); a scripted client omits or discards it. Unpersisted Data Protection keys invalidate every issued token on each restart; deleted keys invalidate tokens signed with them.
- IP/prefix limits:
- Shared NAT: poll vendors report schools and offices tripping IP checks. CGN measurement: Livadariu et al. infer 4.1K ASes deploy CGN (2014-2016 data); Mandalari et al. found CGN in 10% of 60+ tested ISPs; Richter et al. https://arxiv.org/abs/1605.05606 ; CAIDA https://www-old.caida.org/publications/papers/2018/inferring_carrier_grade_nat/ . Blocklists and address reuse: https://steel.isi.edu/members/sivaram/papers/blocklisting_impact.pdf . No CGN false-positive rate for voting found.
- Mobile IPv6: carriers commonly assign a /64 per device (forum, secondary); whether a named carrier changes the prefix per session was not found. RFC 8981 rotates addresses within the /64.
- Delegated prefixes larger than /64 span many buckets (v6ops/NANOG above).
- Header trust: forged `X-Forwarded-For` is accepted only when the immediate peer is in KnownProxies/Networks; without `UseForwardedHeaders`, `RemoteIpAddress` is the proxy for every client (one bucket). IP-keyed partitions carry the doc's DoS/memory warning.
- Audit-and-purge: HN and Stack Exchange both reverse or demote by pattern and both document false positives (above); neither publishes thresholds.
- PoW: Laurie/Clayton and Anubis (above): attackers with solvers or botnets pay CPU; legitimate low-end devices pay too; a reusable cookie spreads one solve over many requests. .NET libraries require a replay store; in-memory stores are test-only/single-instance. Headless-farm cost: not gathered.
- Third-party challenge: CNIL Q17 names reCAPTCHA as requiring consent where the provider reuses data.
## Where the current design's assumptions are contradicted by evidence
1. The design lists proof-of-work as slowing scripts yet barely touching determined attackers; the cited literature agrees, but the design also says ALTCHA "needs no third party" and is "ALTCHA-style library served from Bifrost": ALTCHA has no official C# server library, only community packages that need a custom replay store for production.
2. Data Protection default key persistence is process-lifetime unless a profile/registry/volume exists; default key lifetime is 90 days, not one year. The one-year device cookie depends on retired keys being kept and `DataProtection__KeysPath` set (the doc's fact 22 flags the path; the doc does not state the key-loss consequence for tokens).
3. The design's /64 partition for IPv6 is described by practitioner sources as bypassable by one household holding a /56 or /48 and by privacy-extension rotation inside a /64; the opposite risk (aggregating above /64 on mobile) is documented as collateral damage. No evidence was found for the /24 IPv4 choice.
4. The design treats the device cookie as needing no consent notice; ICO and CNIL exempt security cookies only where security is the sole purpose and from the user's side, CNIL calls fraud-prevention trackers generally non-exempt (exception: securing an authentication mechanism), and ICO says users should still be informed. A vote-integrity cookie is not an authentication mechanism and was not addressed by either text found.
5. The design says large sites "rely on after-the-fact detection ... then discount or purge": confirmed for HN, Stack Exchange and (secondary) Reddit, but those systems also publish no thresholds, keep false-positive reversals, and Reddit/Product Hunt add vote fuzzing or voter-history weighting, which the design does not include.

View file

@ -0,0 +1,105 @@
# Customer onboarding: body facts at 2026-10-30
Served: the same forks. Eyes pass over Bifrost origin/main, Heimdall, StreamPixels state/map.yaml, gamecult-ops inventory and the eureka mind. Facts only.
# Eyes: customer onboarding, what the body can do today (2026-10-30)
Read via `git show origin/main:` after `git fetch` in each repo. Bifrost origin/main = cc76f0f; Heimdall = 5146c72; StreamPixels = 9a113d3; gamecult-ops = c03e03c. Facts only. Not read: Bifrost tests, live DB contents, jurisdiction-map.md, bifrost-mvp-plan.md body, Heimdall implementation (src/app.ts), StreamPixels docs/joystick-integration-map.md, Yggdrasil live state (no ssh).
## 0. The trigger (mind)
- `eureka-body:ruling:customers-get-voting-power-onboarding-owed` (Operator, 2026-10-30, InForce). Quote: "Funny coincidence, then, that our first customer, and likely the largest for a while, is a fox themed vtuber, with 1,500€ of work contracted, and therefore voting power. We need a workflow to onboard customers on our platform since we're giving them voting power". Ruling text: "Prior-art and body passes come first; estate placement, weighting and related-party handling are forks for the operator." `precedents: []`.
## 1. Bifrost (F:\Projects\Bifrost; ASP.NET Core 8 Razor Pages, PostgreSQL/EF Core)
### 1a. Member and identity model (code at origin/main)
- `UserAccount` (src/Bifrost.Web/Domain/BifrostModels.cs ~l.213): Id (Guid; per docs/context.md "UserAccount.Id is the subject" of the OIDC issuer), BifrostIdentity, optional GitHubUserId/GitHubLogin, HeimdallAccountId, DisplayName, AvatarUrl; 1:1 `MemberProfile` (nickname, headline, portfolio, skills, availability, notes) and 1:1 `Membership`.
- `Membership` (l.~330): `MembershipStatus` Authenticated | PendingApproval | Active | Suspended (l.5-10); flags IsPlatformAdmin, CanManageProjects, CanManageLedger, CanModerateMotions; `RoleAssignment` roles StandardMember | Producer | Maintainer | LedgerReviewer | PlatformAdmin; `TierSnapshot` (Kind Patron|Contributor, Label, Weight, IsCurrent).
- Stored in PostgreSQL via EF migrations (Data/Migrations, 15 migrations 2026-04 to 2026-10-22). Not CultCache. CultCache is used only for the bridge/receipt/governance-thread/persona-feedback stores (`.bifrost/*.cc`; README, docs/bridge.md).
- Sign-in paths (Program.cs): GET `/auth/sign-in` (GitHub OAuth, l.314); POST `/auth/bifrost/register` anonymous (l.342) takes `identity`, optional displayName, returnUrl; signs in as Authenticated, no password/credential visible in the handler (BifrostIdentityService.RegisterNativeAsync, Features/Membership/BifrostIdentityService.cs:15); GET `/auth/heimdall/{provider}` (l.374) with AllowedProviders discord, patreon (HeimdallAuthService.cs:37-40); Heimdall complete page l.530-537.
### 1b. Estates
- No "estate" concept exists in Bifrost code. `git grep -w estate(s)` in src/ and README returns nothing; hits are only in docs/research/2026-10-29-estate-tally-game-theory/ (research, with Python sims) and the swarm-governance research note. Minds / Members / Users / Patrons blocks exist only as rulings (section 5).
- Closest code analogues: `MembershipStatus` (Authenticated ~ Users, Active ~ Members), `TierSnapshotKind.Patron`. No Persona/Mind account class; `BridgeActorKind` Member|Agent|Persona|Service (BifrostModels.cs:178) is for bridge actions only (research note Part 1).
### 1c. How a person joins today
- Invite-only alpha (README "Alpha Scope"). `MembershipInvitation` (BifrostModels.cs:406) keyed on normalized GitHub login (MembershipSynchronizationService.cs: invitation lookup by NormalizedGitHubLogin). Admin invites at Pages/App/Members `OnPostInviteAsync` (Index.cshtml.cs:34); invited GitHub sign-in becomes PendingApproval; admin `OnPostApproveAsync` (l.92) sets Active, ApprovedAtUtc, StandardMember role. Also OnPostToggleRoleAsync (l.127), OnPostSetTierAsync (l.184), OnPostUpdateProfileAsync (l.240).
- Un-invited GitHub or native sign-in stays Authenticated. Bootstrap admins by GitHub login via `Bootstrap:AdminGitHubLogins` become Active admin automatically.
- Heimdall path: Discord role (KTLST guild role) or Patreon tier title (`Inner Sanctum`) entitlement grants Active (HeimdallAuthService.cs ~l.296-324; docs/app-profiles/bifrost.md in Heimdall).
- /App is ActiveMember-gated (Program.cs:57 per research note).
- No application form, no customer/client type, no contract record, no related-party flag, no email invite (invitations are GitHub-login keyed), no self-service for a person without GitHub, Discord-guild role, or Patreon tier.
### 1d. Vote weighting in code (not the ruled design)
- `Membership.EffectiveVotingWeight` = 0 unless Active, else `Math.Max(1, sum of current TierSnapshot weights)` (BifrostModels.cs:357-361).
- Patron tiers (Features/Patronage/PatronTierPolicy.cs): Bronze 10 pts w1, Silver 100 w2, Gold 1,000 w3, Platinum 10,000 w4, Unobtanium 100,000 w5. Points from `PatronSupportEvent` amounts; one-time donations: full for 30 days, then 50% decayed 1%/week; recurring snapshots count while current; adjustments added (GetHistoricalDonationPoints). `PatronSupportEvent` carries ProjectId, provider (Manual|Patreon|PayPal|Stripe), currency code, amount.
- Contributor points: `PointTransaction` (BifrostModels.cs:773; types ManualAdjustment, ApprovedWork, ContinuousRoleCredit, PatronSupport, Decay). Contribution-to-vote scaling: no code found that scales vote weight by contribution beyond TierSnapshot kind Contributor set by admin (OnPostSetTierAsync).
- No log or exponent curve anywhere in src (grep Math.Log/Sqrt empty). Rulings `governance-vote-power-scaling` (log patrons, lower exponent members) are not implemented.
### 1e. Motions, brake, timelock (code)
- `Motion`/`Vote` Postgres rows (BifrostModels.cs:665, 702). MotionGovernanceService.cs: CreateMotionAsync (:13), CastVoteAsync (:47; weight copied from actor at cast time), CloseMotionAsync (:95; manual, by creator or CanModerateMotions). Pass = For/(For+Against) >= ApprovalThreshold, abstain excluded; no quorum.
- Thresholds (MotionCategoryPolicy.cs; research note): Bugs .15, Cosmetics .30, Balance .40, Features .50, NewContent .50, FundamentalDesign .66.
- Brake and timelock: absent. `git grep -i -E 'timelock|brake|quorum'` in src/ returns nothing. They exist only as rulings. `motion.create/vote/close` command names appear in MotionEveSurfaceService with no handler (research note Part 1). Only machine surface: GET `/eve/governance/surface` (Program.cs:513), deprecated Eve JSON. Motions are not typed CultMesh state.
- Docs: docs/verse-service-contract.md lists schema `gamecult.bifrost.governance`; motion/vote schema ids marked todo (:256 per research note).
### 1f. Other Bifrost surfaces relevant to a customer
- Stripe: `StripeOptions` (Configuration/StripeOptions.cs; EnableCheckout, SecretKey, WebhookSecret, GeneralPatronageGitHubLogin, Success/Cancel URLs default to velvet.gamecult.org); routes GET `/patronage/{project}/checkout` (Program.cs:431) and POST `/patronage/stripe/webhook` (:489); Webhook records a `PatronSupportEvent` against the configured GeneralPatronageGitHubLogin account.
- PayPal/Patreon: docs/paypal-patronage.md: Heimdall POSTs HMAC-signed support facts to `/heimdall/patron-support/events` (Program.cs:408). "PayPal owns money movement... Bifrost owns patronage meaning".
- Ledger (`LedgerEntry` types PatronCredit, ContributionCredit, NominalCompensation, Adjustment; payout proposal batches). README: "no real payout execution in v1"; "internal ledgers only".
- OIDC issuer for Matrix chat (docs/oidc-issuer.md; main 1894f65/83daabe): Bifrost is the identity issuer; back-channel logout reconciler ends sessions of non-Active subjects (OidcLogoutReconciler). Remaining scar: Heimdall still mints its own account id.
- Concept Mill votes (ConceptVotes/*, VoterClass Anonymous|Account|Member): per-subject appraisals, not motions.
### 1g. Documented gaps
- Swarm research note (docs/research/2026-10-29-swarm-governance-prior-art.md Part 1) and follow-up `eureka-body:follow_up:swarm-governance-on-bifrost` item text: no quorum; manual close only; Personas cannot be Active members or vote; motions/votes are Postgres rows not typed CultMesh state; only machine surface is deprecated Eve JSON; motion.* commands without handler; no Matrix surface; Yggdrasil behind main.
- README "Next Build Target" and docs/full-implementation-strategy.md l.11: "not an internet-facing alpha"; payout proposals, revenue-share automation, decay automation, GitHub App guidance are future. No single "gaps list" file found.
## 2. Heimdall (F:\Projects\Heimdall; TypeScript/Fastify; in-memory + Postgres stores)
- Providers (src/contracts.ts:1): discord, patreon, github, twitch, youtube, spotify, joystick.
- App profiles (src/app-profiles.ts): repixelizer (identity discord, patreon); streampixels (identity twitch, youtube; managed connection twitch, youtube, joystick; allowedReturnOrigins streampixels.gamecult.org); bifrost (identity+entitlement discord, patreon; l.138-143); ghostlight (discord). Built-in app slugs are "seed data, not the set of permissible apps" (contracts.ts comment).
- Bifrost integration (docs/app-profiles/bifrost.md): Bifrost starts Discord or Patreon OAuth with a server-owned entitlement policy over `backend_callback`; Heimdall delivers to `https://bifrost.gamecult.org/auth/heimdall/callback`; Bifrost creates/updates UserAccount, activates standard membership, issues its own cookie. Patron sync: POST `/v1/apps/bifrost/patron-support/sync` -> signed RecurringSupportSnapshot to Bifrost. Heimdall does not own ledgers, points, votes.
- Bifrost's own AllowedProviders is discord, patreon only (HeimdallAuthService.cs:37-40). Twitch/YouTube are not Bifrost sign-in providers; Heimdall's bifrost profile does not list them. GitHub is signed in by Bifrost directly, not through Heimdall.
- Non-technical external person today: could sign into Bifrost with a Discord account holding the KTLST role in the GameCult guild (Active at once), or a Patreon account with an active `Inner Sanctum` tier (Active at once), or GitHub if invited by login, or native register (identity string, lands Authenticated, no vote). No email/password/magic-link sign-in exists in Heimdall's provider list. Whether the live Heimdall has Discord/Patreon creds configured: runbooks/heimdall-discord-launch.md and bifrost-alpha-deploy.md say configured (inventory.md:1019 "Heimdall-mediated Discord/Patreon access and Patreon support intake env are configured"); live check not done.
- Heimdall state map (state/map.yaml l.62-63, 129-130): live release d51fa6b (hand-deployed 2026-10-19) includes Joystick H1; remaining: human canaries, refresh/revocation, admin surfaces.
- Joystick consent work (open): `joystick:follow_up:h1-joystick-consent-round-trip` (the streamer completes a Joystick connect consent through a creator-bound streampixels connect start on live Heimdall d51fa6b; verify a joystick linked identity keyed by channel_id). StreamPixels map.yaml open_operator_decisions: "Joystick S0/S1: the streamer's consent click is still owed before the connector goes live". Joystick is a managed creator connection only, not a viewer sign-in (docs/app-profiles/streampixels.md; `joystick:ruling:signin-waits-for-public-bot`). Other Joystick follow-ups in docs/architecture.md:442-457: cross-process-failed-refresh-marker, connect-start-needs-app-auth, heimdall-idunn-onboarding, joystick-viewer-signin-public-bot.
- Open High finding relevant to identity linking: `gamecult-chat:follow_up:streampixels-link-target-takeover` (InForce): an unauthenticated Heimdall link start for streampixels can name any profile id; StreamPixels redeem links with no session check; Plausible High, unconfirmed.
## 3. StreamPixels (F:\Projects\StreamPixels, state/map.yaml, state/evidence.jsonl)
- Engagement (map.yaml l.17-70): the StreamPixels client; `budget_eur: 1500`; split even, EUR 500 per milestone (operator 2026-09-14; currency corrected from USD by operator 2026-10-25); milestones M1 "whole system running end to end on her stream" (September), M2 committed 2026-09-15: GPU fixed-capacity particle pool and AquaSynth impact audio (October), M3 TBD sold by proposal (November). Cadence: "The operator needs one milestone paid per month to survive." evidence.jsonl l.16 records the engagement (written as "$1,500" on 2026-09-14).
- Recorded money status: map.yaml has no field recording a milestone as invoiced or paid; no invoice/payment/received hits in map.yaml or evidence.jsonl (grep). Milestone completion state: M1 still in progress per `focus: milestone-1-unity-repair` and active_subgoals (A3b, C1 remaining, l.153); the map.yaml read ended before the full active_subgoals/next_action sections.
- Consent: map.yaml `consent`: "Her channel is not a marketing asset. Publicly associating her with the overlay requires asking her."
- Business context l.60-66: "She has explicitly ruled out microtransactions".
- Heimdall/Bifrost identity for the client: no Bifrost or Heimdall account, user id or invitation naming the client or her streamer handle found. Searched Bifrost and Heimdall for the client's and streamer's handles: no account or invitation found. Her Heimdall identity, if any, lives in live DB (not checked). StreamPixels has creator memberships and linked identities in its own store (apps/service/src/app.ts ~l.698, 1491-1602) via Twitch/YouTube/Joystick; live contents not checked.
- StreamPixels forks stay in chat, no Kiko desk: `web-customize:ruling:sp-forks-in-chat-until-thing2` (2026-10-26).
## 4. Payment / invoicing / money received
- Recorded payment-receiving surfaces: Bifrost Stripe checkout+webhook (patronage; runbooks/bifrost-alpha-deploy.md:84-98 lists `Stripe__EnableCheckout`, `Stripe__SecretKey`, `Stripe__WebhookSecret`, `Stripe__GeneralPatronageGitHubLogin`; whether live-enabled unknown, inventory.md has no Stripe flag); Heimdall-signed Patreon and PayPal support facts (docs/paypal-patronage.md); StreamPixels donation source Ko-fi webhook (map.yaml settled decision, for streamer donations, not for the operator's fees).
- No invoicing surface found: grep of gamecult-ops (excluding repo-census docs) for invoice returns nothing; Bifrost has no invoice entity (LedgerEntry/Payout are internal, "no real payout execution", "internal ledgers only"). `PatronSupportEvent` is the nearest "money received" record (amount, currency, provider, externalSupportId, ProjectId) and drives patron voting points; there is no contract/engagement/client-payment event kind (kinds: OneTimeDonation, RecurringSupportSnapshot, SupportAdjustment; providers Manual, Patreon, PayPal, Stripe). A Manual provider event can be admin-entered.
- Treasury: `eureka-body:follow_up:swarm-treasury-and-revenue` (InForce): typed runway document (balance, committed costs, expected income) "The operator enters figures; no bank credentials are ever handled by agents"; not built. Owner repo GameCult/gamecult-ops. Source ruling `eureka-body:ruling:operator-legal-vehicle-shared-solvency` (operator is GameCult's legal and financial vehicle; her bank account pays for server and tokens).
- Operator's bank/payment record for the EUR 1,500: not recorded in any file read.
## 5. The mind (ids)
Follow-ups (all InForce, campaign eureka-body):
- `eureka-body:follow_up:swarm-governance-on-bifrost` (admitted 2026-10-29T20:45Z).
- `eureka-body:follow_up:co-op-charter` (2026-10-29T21:13Z): one typed charter document in Bifrost with every governed parameter (estates and weights, within-estate curves and exponents, operator influence cap, bloc position rule, quorum, category thresholds, close timing, decision delay and cancel); amended by motion, higher threshold for charter amendments (Debian 3:1 cited). Gathers rulings governance-estate-blocks, governance-vote-power-scaling, estate-weights-normalize-by-turnout, swarm-decides-without-operator, operator-influence-below-half, swarm-governance-in-bifrost, desk questions 37, 38, 40, 41. Not on bring-up path; operator holds the seat until Bifrost governance is live.
- `eureka-body:follow_up:swarm-treasury-and-revenue` (2026-10-29T21:24Z): treasury runway doc plus a revenue-map Eyes pass.
Rulings (Operator, InForce):
- `eureka-body:ruling:governance-estate-blocks` (10-29): four blocks Minds (Personas), Members (Active members), Users (authenticated accounts), Patrons (patron tiers); each block's internal vote gives its position; block weights are governed settings changed by motion. The ruling lists no Customers estate.
- `eureka-body:ruling:estate-every-estate-equal-seed` (10-29, answers `estate-overlap-and-seed`): a person votes in every estate they belong to; estates start at equal weight.
- `eureka-body:ruling:estate-bloc-parliament-at-threshold` (answers `estate-bloc-position`): each estate casts whole weight For only if its internal For share meets the category threshold; known cost: low categories rescaled before go-live (0.15 passes on 3.7% overall support).
- `eureka-body:ruling:estate-weights-normalize-by-turnout` (10-29): only estates where someone voted count; weights renormalized to sum 1; empty estate drops out.
- `eureka-body:ruling:governance-vote-power-scaling` (10-29): patrons by strongly flattened log of donation total; members by contribution with a lower exponent; curves and exponents governed; exact functions "a sketch to be calibrated by simulation (her numbers are feel, not gates)"; replaces `max(1, tier weight)` when the governance cut lands.
- `eureka-body:ruling:operator-influence-below-half` (10-29; supersedes `operator-influence-capped-at-half`): strictly less than half; mechanism and number from simulation; a governed parameter. Research: Bifrost docs/research/2026-10-29-estate-tally-game-theory (18bbe6c adds bloc-parliament-at-threshold and operator influence cap; present case Members = her alone, Minds 8, Users/Patrons empty, README l.84).
- `eureka-body:ruling:swarm-decides-without-operator` (10-29): no quorum rule may require her vote; rules out at-least-two-estates floor.
- `eureka-body:ruling:motion-deadline-quorum-autoclose` (10-29, answers `motion-quorum-and-close`): default 72h window; Bifrost auto-closes; quorum is a majority of eligible voters.
- `eureka-body:ruling:brake-to-bifrost` (10-29; quote: "hand the brake to bifrost") and `eureka-body:ruling:brake-anyone-pulls-motion-releases` (answers `brake-engage-release`): any Mind or human member engages the brake at once, every pull published with reason; releasing takes a motion (short window, e.g. 24h, majority); until Bifrost governance is live the brake stays in the root-owned swarm control store (`eureka-body:ruling:brake-dial-root-store-now`, 2026-10-02).
- `eureka-body:ruling:decision-timelock-owner-applies` (answers `decision-applies-how`): passed motion published as typed CultMesh state with an effective time (e.g. 24h); the owning service applies it then; cancellable until then; who may cancel follows brake-to-bifrost and the charter (operator holds no private cancel).
- `eureka-body:ruling:swarm-governance-in-bifrost` (2026-10-10); `swarm-shares-log-bound`; `swarm-membership-operator-rules` (operator rules births/retirements/transfers; any mind proposes; a mind may leave).
- `eureka-body:ruling:operator-legal-vehicle-shared-solvency` (10-29); `eureka-body:ruling:customers-get-voting-power-onboarding-owed` (10-30).
- Withdrawn question `eureka-body:question:persona-vote-weight` (replaced by estate blocks); withdrawn `estate-internal-tally` (restated as `estate-bloc-position`).
- "Kiko rulings": the 2026-10-29T21:41:40Z batch (ordinal 4430) answering desk questions: estate-overlap-and-seed, estate-bloc-position, brake-engage-release, decision-applies-how, motion-quorum-and-close. No separate document labeled Kiko beyond these and `web-customize:ruling:sp-forks-in-chat-until-thing2`.
- No ruling or follow-up found on: a Customers estate, related-party/conflict-of-interest handling for a customer who is a related party, customer vote weight from contract value, a customer onboarding workflow (other than the 10-30 ruling), or a co-op charter document existing yet (semantic query top hits showed none).
- Joystick: `joystick:campaign:self`, `joystick:follow_up:h1-joystick-consent-round-trip`.
## 6. What Yggdrasil runs for Bifrost
- gamecult-ops inventory.md:1021: live release `/srv/bifrost/app/releases/20261010043032-67072b2`, revision `67072b28923017f93f484d901c7a6d31e3678d88`, deployed 2026-10-10; app user `bifrost`, root `/srv/bifrost/app`, env `/srv/bifrost/env`, bound `127.0.0.1:5080` via `bifrost.service` behind nginx (`bifrost.gamecult.org`, TLS issued 2026-06-10), local PostgreSQL 16. Heimdall-mediated Discord/Patreon access and Patreon intake env configured (l.1019).
- Distance: `git rev-list --count 67072b2..origin/main` = 94 today (follow-up text says 89 at 2026-10-29). Commits since 67072b2 touching src/: OIDC issuer for Matrix chat (83daabe, 404dafd), Heimdall sign-in cookie ring and signed callback (15f96c4, b7e3bdf, b2110f5), Mill votes sign-in offer (cf74019), NuGet pins/lockfiles, mouth-all-personas (cc76f0f, tools/). So OIDC issuer and the newest Heimdall sign-in hardening are not live.
- Also on Yggdrasil from Bifrost: `bifrost-persona-feedback.service`, `bifrost-persona-mouth.service`, `bifrost-watchdog-notice` timer (inventory.md:710-729; Persona delivery via Discord per that inventory text; main now moves Persona speech to Matrix per docs/bridge.md:199). Whether the Stripe/Heimdall env flags are live was not verified (no ssh).
- Deployment authority is Idunn for managed workloads; Bifrost deploy is by `scripts/deploy-bifrost-alpha.sh` / runbooks/bifrost-alpha-deploy.md per inventory (interim); not confirmed which path produced 67072b2.

View file

@ -0,0 +1,56 @@
# Customer-member onboarding: prior art
Served: eureka-body:ruling:customers-get-voting-power-onboarding-owed (2026-10-30), the forks on customer estate, weight, decay, related parties and join flow. Eyes pass, facts only; [U] marks unverified.
# Eyes facts: customer onboarding and voice in multi-stakeholder / platform co-ops (2026-10-30)
Method: web search result summaries plus two WebFetch attempts. Most facts come from search-result summaries, not full-page reads. "[V]" means the source was returned by search and the claim is stated there; "[U]" means unverified, conflicting, or not found. No primary statutory text was retrieved (both wetten.overheid.nl fetches returned only tables of contents / metadata).
## Q1. Admission and classes in multi-stakeholder and platform co-ops
- Stocksy United [V]: three classes. A = advisors on board, capped at 5, may buy up to 200 shares (one support page). B = staff, may buy one extra B share a year. C = contributing artists, max one $1 share. Class C cap of 1,000 was removed by member resolution (support docs date it 2018). Patronage for A and C is not tied to share count. Members can submit resolutions. Sources: https://stocksy.com/legal/rules ; https://support.stocksy.com/hc/en-us/articles/201658696 ; https://support.stocksy.com/hc/en-us/articles/201659716-Is-there-a-limit-on-the-number-of-Co-op-members ; https://geo.coop/content/stocksy-united-removes-its-membership-cap ; https://support.stocksy.com/hc/en-us/articles/201808247-Can-members-purchase-more-shares-in-the-Co-op
- Stocksy: the 1,000 cap led to regular review and removal of inactive members (company announcement via https://gdusa.com/stocksy-member-owners-open-door-to-new-contributors/) [V, secondary].
- Fairbnb [V, partly planned]: intends to admit hosts, guests, local businesses, neighbours; per a 2020-era description workers and advisors are members, local partners/users/hosts "in the foreseeable future"; funders' voting power is statutorily limited to one third of assembly votes. Another source says it currently operates as a worker co-op without hosts. Italian and Spanish co-op law reportedly makes multi-stakeholder creation from scratch hard. Sources: https://www.thenews.coop/can-fairbnb-become-a-platform-for-community-powered-tourism/ ; https://wiki.p2pfoundation.net/FairBnB . Current composition [U]; one source is a promotional travel blog.
- Resonate [V, one interview, other figures conflict]: three voting classes, Music-Makers 45%, Fans 35%, Collaborators 20% (Peter Harris interview), plus a Founder class with early board-appointment say. Listener membership 5 EUR/yr (Billboard). Source: https://www.resilience.org/stories/2018-06-15/resonation-an-interview-with-peter-harris/ ; https://geo.coop/content/own-music-you-stream-resonate . Whether percentages are voting or profit share is conflated across sources [U]; check resonate.is.
- Eva (Montreal) [V, self-reported]: three categories, driver members, rider members, worker members. Fees and requirements not found. Sources: https://en.wikipedia.org/wiki/Eva_(coop%C3%A9rative) ; https://wiki.p2pfoundation.net/Eva_Coop . Up & Go: described as worker-owned cleaners' co-op in NYC; no membership-class details found [U].
- Open Food Network [V]: governance as collective of affiliates under a "global pledge"; Open Food Foundation oversees governance and finance; UK arm "co-op of co-ops" with food hubs, farmers, shoppers as members, sociocratic circles. Sources: https://www.openfoodnetwork.org/about-us/ ; https://www.uk.coop/blog/open-food-network-making-food-systems-fair-and-sustainable-unfound . No customer admission steps found [U].
- Eroski/Mondragon [V]: worker-members and consumer-members; General Assembly of 500 delegates, 250 worker and 250 consumer; 1.3M+ consumer members (2022). Surplus: 40% to worker-members, 50% reserves, 10% COFIP (2026 assembly). Consumer-member fee (~$75/yr) from older source [U]; per-member consumer vote weight not found [U]. Sources: https://corporativo.eroski.es/wp-content/uploads/20260528_PR_General-Assembly-2026.pdf ; https://archive.grocer.coop/node/7142 ; https://eurocoop.coop/news/184-Eroskis-growing-membership-putting-members-at-the-heart-of-co-op.html
- REI [V, secondary]: lifetime membership $30 one-time (older $15/$20); dividend now "Co-op Member Reward" (patronage dividend and/or promotional reward, decided annually); historically ~10% of regular-price spend; ballot for board election sent with dividend notice. Older rule: active member = paid fee and bought >= $10 in a calendar year [U for current]. Sources: https://www.rei.com/membership ; https://www.rei.com/membership/join ; https://www.nbcnews.com/select/amp/rcna172651
- Quebec solidarity co-ops [V]: classes user members, worker members, supporting members (1997 amendment). Required share count may differ by class; rebates to users by business done, to workers by work volume; supporting members get no rebate. Voting formula not found; set in by-laws [U]. Sources: https://www.publicationsduquebec.gouv.qc.ca/fileadmin/Fichiers_client/lois_et_reglements/LoisAnnuelles/en/1997/1997C17A.PDF ; https://cdn-contenu.quebec.ca/cdn-contenu/adm/min/economie/contenu/cooperatives/modeles_reglement/MO_reglements_generaux_solidarite.pdf (model by-laws, not opened) ; https://www.co-oplaw.org/?p=8947
- Italian social co-ops, Law 381/1991 [V]: volunteer members (soci volontari) unpaid, capped at 50% of membership (art. 2); user members (soci fruitori) are service beneficiaries or relatives, reimbursed documented expenses only; investor members (soci sovventori) permitted; type B requires >= 30% disadvantaged workers, who should become members where possible. Voting caps for sovventori not retrieved; civil code art. 2526 not read [U]. Sources: https://www.bosettiegatti.eu/info/norme/statali/1991_0381.htm ; https://base.socioeco.org/docs/l_381_91.pdf ; https://www.fpcu.it/App_Common/Files/FilesEventi/346348/Slide%20videoconferenza%2014_12_2018.pdf
- Platform Cooperativism Consortium / Exit to Community [V, thin]: E2C (Schneider, Mannan; "Exit to Community: Strategies for Multi-Stakeholder Ownership in the Platform Economy") describes transfer of ownership to users, workers, stakeholders. No findings on customer admission mechanics [U]. Source: https://wiki.p2pfoundation.net/Nathan_Schneider_on_Exit_to_Community_and_Community_Ownership_Through_Platform_Coops
- Not found: Fairbnb/Resonate/Eva application-and-approval steps and member agreement text [U].
## Q2. Purchase-based voice, plutocracy, dominant customer, related parties
- Ag co-ops (US) [V, 1990s-2003 sources]: 93% of direct-membership co-ops used one-member-one-vote, 7% proportional (patronage-based extra votes) per a 1997 USDA report. Capper-Volstead exemption described as conditioned on one-vote-per-member or an 8% dividend cap (secondary summary; Texas AG opinion reads its state version as flat one-vote). State statutes largely determine which is allowed. Arguments: proportional voting for those with more equity at risk; one-member-one-vote as cooperative democratic principle, which IRS has generally endorsed. Statute text not retrieved [U]. Sources: https://www.rd.usda.gov/sites/default/files/rr156.pdf ; https://aglawjournal.wp.drake.edu/wp-content/uploads/sites/66/2016/09/agVol01No2-Matthews.pdf ; https://nationalaglawcenter.org/wp-content/uploads/assets/bibarticles/royer_principles.pdf ; https://le.utah.gov/~2003/bills/sbillenr/SB0142.htm
- Patronage dividends vs voice: Stocksy A/C and REI/Quebec rebates tie money to patronage while voting stays per-member or per-class (see Q1 entries). Quebec supporting members get no rebate.
- Multi-stakeholder co-ops [V]: typically one-member-one-vote within a class, fixed board seats per class; minimum and maximum vote shares per class suggested to harmonise interests; allocation of governance rights between classes is a key drafting decision; a customer member may have less "skin in the game"; boards should adopt conflict-of-interest policies. Sources: https://www.co-oplaw.org/?p=8947 ; https://ccednet-rcdec.ca/resource/multi-stakeholder-co-ops/ ; https://resources.uwcc.wisc.edu/CaseStudies/MSC-Training-Needs.pdf
- Fairbnb's funder cap (one third of assembly votes) is a class-level vote ceiling (Q1 source).
- Concave weighting and plutocracy [V, preprints]: arXiv 2605.18990 "Concave is the New Linear: The Impossibility of Anti-Plutocratic DAO Governance" argues quadratic or any concave mechanism cannot withstand a Sybil adversary on permissionless chains (power grows at least linearly in holdings). Not fully read, from snippet. https://arxiv.org/pdf/2605.18990 ; https://arxiv.org/pdf/2602.09608
- Related parties / family member as customer: no source found addressing related-party voting in co-ops [U]. Only the generic conflict-of-interest policy recommendation above.
- Dominant single customer in a class: no source found [U], other than class-level caps above.
## Q3. Digital governance tooling
- Decidim [V]: census authorization by admin CSV import; participants self-initiate verification from profile; authorization handlers are pluggable (SMS code, postal-letter code); CVE-2026-45415 let a limited participant-manager alter census records (fixed in 0.30.9 / 0.31.5 / 0.32.0). Sources: https://docs.decidim.org/en/admin/participants/authorizations/census ; https://docs.decidim.org/en/admin/participants/authorizations/code_postal_letter ; https://www.wiz.io/ja-jp/vulnerability-database/cve/cve-2026-45415 . Ephemeral verifications page exists (https://docs.decidim.org/en/v0.31/admin/participants/authorizations/ephemeral_verifications), not read. Revocation behaviour not retrieved [U].
- Loomio [V]: email invite with single-use link; join requests need coordinator approval depending on privacy; reusable link bypasses approval. Source: https://help.loomio.com/en/user_manual/groups/inviting_people/index.html . No weighted voting or expiry found [U].
- Snapshot / token DAOs [V]: quadratic voting relies on proof-of-personhood; whales split across wallets; fewer than 20 delegates reportedly hold >50% of power in major DAOs; vote-escrow aligns long-term incentives but is illiquid. Sources: https://blog.tally.xyz/a-simple-guide-to-quadratic-voting ; https://www.spark.money/glossary/quadratic-voting ; https://blockeden.xyz/forum/t/1-of-token-holders-control-90-of-voting-power-across-major-daos-quadratic-voting-delegation-incentives-and-conviction-voting-havent-fixed-the-plutocracy-problem/541 (forum, low rigor).
- Decay / expiry [V]: EIP-7787 draft proposes token voting-power decay over a fixed 90-day period (draft, not deployed). Colony described as decaying reputation unless members keep contributing. Sources: https://eips.ethereum.org/EIPS/eip-7787 ; https://blog.colony.io/8-essential-voting-mechanisms-in-daos/ . A forum post describes log-decayed power for large holdings (10x holdings ~ 4x power).
- DAOstack [V]: non-transferable reputation earned by contribution; peer-reviewed retrospective "The rise and fall of DAOstack": https://peerj.com/articles/cs-3320 . Decay specifics unconfirmed [U]. Aragon: non-transferable reputation templates; claims about Aragon dissolution and ANT voting removal from a single low-quality source [U].
- Open Collective: nothing retrieved on backer voting rights or governance [U].
## Q4. Contract-funded versus donation-funded voice
- No literature found distinguishing a paying client class from a patron/donor class [U].
- Adjacent facts: Dutch sample statutes with donors (donateurs) lacking general-meeting votes (Q5); Quebec supporting members as a non-voting-benefit, no-rebate class (Q1); investor/funder classes as non-user classes with separate caps (Fairbnb, UK rulebook example: https://files.uniteddiversity.coop/Cooperatives/Multi-Stakeholder_Co-ops/go!_rules_v3.0.pdf , from search listing, not opened); Stocksy and REI tie money flows to patronage not votes; Resonate has listener class at 5 EUR/yr.
## Q5. EU / Netherlands legal and consent points
- BW 2:53: coöperatie is an association founded by notarial deed, at least two members at founding, no max (https://wetboekplus.nl/burgerlijk-wetboek-boek-2-artikel-53-oprichting-cooperatie-en-onderlinge-waarborgmaatschappij/ , https://www.viajuridica.nl/informatiesoorten/wetstoelichtingen/oprichting-cooperatie-art-2-53-2-54-bw) [V]. Articles on voting (2:38 etc.), 2:53a, member rights: text not retrieved; official page fetches gave metadata only [U].
- Admission in sample statutes [V]: board decides unless statutes assign it to the general meeting; general meeting can override a refusal; one set requires unanimity of a fully present meeting; one gives board six weeks to answer in writing. Register of members kept by the board, inspectable by members; board may record additional data beyond the list. Voting: one set denies donors votes; another lets non-qualifying members vote on certain proposals. Sources: https://www.glastuinbouwnederland.nl/content/user_upload/Komkommer_Concept_statuten_gewasco%C3%B6peratie_komkommerUA__3_.pdf ; https://rhoon.herenboeren.nl/wp-content/uploads/2024/05/Huishoudelijk-Reglement-Herenboeren-Rhoon.pdf ; https://www.kennisnetgeboortezorg.nl/wp-content/uploads/2019/05/Statuten-coo%CC%88peratie-IGO-Geboortehart.pdf ; https://raad.ridderkerk.nl/Documenten/bijlage/Bijlage-8-ledenovereenkomst.pdf (ledenovereenkomst example, not opened).
- GDPR/AVG for member registers [V, secondary]: common lawful basis cited is Art. 6(1)(b) (performance of membership agreement), sometimes plus consent for extra data; GDPR sets no fixed retention (AP position quoted), Dutch practice often keeps accounting data 7 years, rest deleted after termination; processing register practically required. Sources: https://www.kvk.nl/wetten-en-regels/ga-als-vereniging-goed-om-met-privacy-en-avg/ ; https://www.legiscope.com/blog/avg-verenigingen-stichtingen.html ; https://www.moore-drv.nl/avg-hoe-lang-mag-u-persoonsgegevens-bewaren/ . Direct Autoriteit Persoonsgegevens guidance not retrieved [U].
- Not found: coöperatie U.A. member-agreement requirements beyond statutes; relationship between customer contract and membership [U].
## Gaps (not retrieved)
Open Collective governance; Up & Go structure; Eva/Fairbnb/Resonate application steps; Capper-Volstead statute text; Italian civil code art. 2526; Quebec voting formula; Dutch BW 2:38/2:53a text; AP guidance; any source on related-party or dominant-customer voting; paid-client vs donor estates.

View file

@ -0,0 +1,55 @@
# Bifrost email mouth: prior art
Served: eureka-body:ruling:payment-mints-invite-bifrost-ledger-email-mouth and the fork on outbound mail (self-hosted SMTP vs transactional provider). Eyes pass, facts only; [U] marks unverified. Gaps not researched: provider suspension behaviour for new senders, DPAs, .NET SDKs, IP warm-up and blocklists, invite/recovery token lifetimes across platforms.
# Eyes: email mouth prior art (2026-10-30)
[U] = unverified / secondary source only. Web facts came via WebFetch summaries (small-model extraction), not raw page reads.
## Q4 The body (read first, local)
- Yggdrasil provider: Netcup KVM, 16 vCPU, 62 GiB, Debian 13, ufw active, public ports 22/80/443 only. F:\Projects\gamecult-ops\inventory.md:605,619,629; docs\research\2026-10-22-matrix-stand-up-facts.md:73. Netcup SCP is the console fallback (inventory.md:626).
- DNS: gamecult.org zone on Hetzner DNS (NS hydrogen/oxygen/helium.ns.hetzner.com); agents hold no Hetzner DNS credential, DNS edits are operator-done; no wildcard, no CAA. gamecult-ops\docs\gamecult-chat-map.md:23; inventory.md:1075,1090; runbooks\forgejo-eureka-yggdrasil.md:24.
- Other domains: erycina.org exists (runbooks\erycina-org-site-deploy.md, forgejo-erycina-yggdrasil.md); its DNS host not checked [U]. gamecult.com/.net/.dev: no hit in inventory.md.
- Existing mail setup / MX / SPF / DKIM / DMARC records: grep of gamecult-ops inventory.md, runbooks, docs for MX|SPF|DKIM|DMARC|smtp|postfix|mailgun|postmark|"port 25" found none documented. Only mail mention: runbooks\forgejo-erycina-yggdrasil.md:104 "Mailer - no notifications configured. Enabling it means owning deliverability and an SMTP credential." Live DNS not queried (no dig/DoH run) [U].
- Port 25 on Yggdrasil: not documented in gamecult-ops; ufw lists inbound ports only. Not tested (no ssh by constraint).
- Bifrost (origin/main): no Smtp, MailKit, MimeKit, IEmailSender, SendGrid hit in src/*.cs, *.csproj, *.cshtml. Only "email" hit in code: src/Bifrost.Web/Program.cs:187 (GitHub OAuth scope "user:email"). Checkout is Stripe: src/Bifrost.Web/Features/Patronage/StripeCheckoutService.cs, StripeWebhookService.cs, Configuration/StripeOptions.cs; HttpClient to https://api.stripe.com/ (Program.cs:135); route /patronage/{project}/checkout (Program.cs:431).
- Bifrost UserAccount (src/Bifrost.Web/Domain/BifrostModels.cs:243): fields Id, BifrostIdentity, NormalizedBifrostIdentity, GitHubUserId, HeimdallAccountId, GitHubLogin, NormalizedGitHubLogin, DisplayName, AvatarUrl, CreatedAtUtc, LastSeenAtUtc + nav props (read to ~line 275). No Email field seen. Bifrost currently stores no email per account.
- docs/research/customer-onboarding-body-facts.md (Bifrost origin/main) :29 "no email invite (invitations are GitHub-login keyed)"; :59 no email/password/magic-link sign-in in Heimdall's provider list; sign-in is Discord role, Patreon tier, GitHub, or native register.
- Heimdall (F:\Projects\Heimdall, HEAD): carries optional `primaryEmail` from OAuth identity into account create/update: src/app.ts:381,1048,1052,1078; src/backend-handoff.ts:8; src/oauth.ts:27; src/store/in-memory.ts:81. No SMTP/mailer found in the grep (head -8 only, not exhaustive) [U].
- Forgejo on Yggdrasil: mailer disabled (see above).
## Q1 Self-hosted outbound
- Gmail sender guidelines (https://support.google.com/a/answer/81126), all senders: SPF or DKIM; valid forward+reverse DNS (PTR) on sending IPs; TLS; spam rate <0.3% (recommend <0.10%); RFC 5322. Bulk (>5,000/day): both SPF and DKIM, DMARC published (p=none ok), From aligned with SPF or DKIM domain, one-click unsubscribe (List-Unsubscribe + List-Unsubscribe-Post) for marketing/subscribed mail only. DKIM key >=1024, 2048 recommended. Volume here (tens-hundreds/month) is far below the 5,000/day bulk line, so only the all-senders rules apply by the page's wording.
- Gmail rejection codes 4.7.29-4.7.31 for non-compliance [U] (secondary summary of the Google page).
- Outlook.com high-volume sender requirements (5,000/day, SPF/DKIM/DMARC, reject action): fetch failed to return body; only title/keywords seen [U]. https://techcommunity.microsoft.com/blog/microsoftdefenderforoffice365blog/strengthening-email-ecosystem-outlook%E2%80%99s-new-requirements-for-high%E2%80%90volume-senders/4399730
- Yahoo: not fetched; secondary sources say same 2024 requirements as Google [U] (https://help.close.com/docs/requirements-for-bulk-email-senders).
- Netcup port 25: conflicting sources, no official Netcup page retrieved. serverkueche.de tutorial says new servers ship with SCP firewall policy "netcup Mail block" blocking outbound 25, 465, 587 (silent timeout), removable in SCP by deactivating the template (https://serverkueche.de/en/tutorials/netcup-firewall-setup/); netcup community forum thread (Aug 2026 per search summary) agrees default firewall blocks mail ports (https://forum.netcup.de/thread/22247-netcup-firewall-blockiert-smtp-ports-warum/); netcup.discount (third party) claims port 25 open by default (https://www.netcup.discount/en/guides/port-25-unblock). All [U]. Note this SCP firewall is separate from Yggdrasil's ufw.
- PTR/rDNS on Netcup is set in SCP (per netcup.discount guide) [U].
- IP reputation/warm-up/blocklists for VPS ranges: not researched beyond Mox README: "Large or cheap cloud providers often don't maintain good reputations or may block all outgoing SMTP"; new machines may have SMTP blocked initially, opened after cool-down or support contact (https://github.com/mjl-/mox).
- Stacks:
- Mox (Go, MIT, Mechiel Lukkien, NLnet/EU funded; SMTP in/out, IMAP, webmail, SPF/DKIM/DMARC, DANE, MTA-STS, TLSRPT, ACME; `mox quickstart` prints DNS records; README recommends dedicated machine + DNSSEC resolver; smarthost via Transport in mox.conf + Routes in domains.conf; if smarthost add its IPs to SPF) https://github.com/mjl-/mox . 1,105 commits, 196 open issues at fetch.
- Stalwart (Rust, AGPL-3.0 + Enterprise SELv2, ~85% features AGPL; SMTP/IMAP/JMAP/CalDAV; DMARC/DKIM/SPF/ARC/DANE/MTA-STS; queues, routing rules; weekly community releases; only vouched contributors, bugfix/translation PRs) https://github.com/stalwartlabs/stalwart
- Maddy (Go, GPL-3.0; replaces Postfix+Dovecot+OpenDKIM etc.; IMAP "beta"; commit/release recency not shown in fetch; smarthost support not stated on page) https://github.com/foxcpp/maddy
- Postfix+OpenDKIM, Chasquid: not researched.
- Unattended-operation failure modes: not found in sourced material beyond Mox README note on IP reputation. Stalwart fetch at stalw.art/docs redirected without content.
## Q2 Transactional providers (pricing fetched from vendor pages 2026-10-30)
- Postmark: Free 100 emails/mo, no overages, no expiry; no tier between 100 and 10,000/mo; Basic $15/mo for 10,000; inbound on Pro $16.50 and Platform $18 only; data residency not stated on pricing page; new accounts start on free plan, no approval text on page. https://postmarkapp.com/pricing
- Amazon SES: $0.10/1,000 outbound + $0.12/GB attachments; no permanent free email quota on page (new-AWS-customer credits up to $200, 6 months); inbound $0.10/1,000 + $0.09/1,000 chunks; dedicated IP $24.95/mo. Sandbox/production-access text not on page [U]. https://aws.amazon.com/ses/pricing/ . EU regions exist (eu-west-1 etc.) [U not fetched].
- Mailgun: Free $0, 100 emails/day, 1 domain, 1 inbound route, 1-day log retention; Basic $15/mo for 10,000, 5 inbound routes; "send through the US or the EU from a single account". https://www.mailgun.com/pricing/
- Resend: Free 3,000/mo with 100/day cap, 3 domains; Pro $20/mo for 50,000; multi-region sending on all plans (EU not named on page); inbound on all plans via webhooks, counts toward quota. https://resend.com/pricing
- Mailjet: Free 6,000/mo, 200/day; Starter $9/mo 8,000; EU hosting not stated on page; GDPR/DPA link present; inbound = "Parse API", limits not stated. https://www.mailjet.com/pricing/
- Brevo: pricing page fetch gave no numbers. Search summaries: free 300/day, SMTP relay smtp-relay.brevo.com:587, Brevo branding on free plan mails, French company with EU data centres, paid tiers conflicting ($9/$25) [U]. https://www.brevo.com/en/bulk-email-service/ ; inbound parsing mentioned on page, details not seen.
- Scaleway TEM (search summary of scaleway.com docs/pricing; direct fetch 403): EUR 0.25/1,000 pay-as-you-go, free 300/mo, billed per recipient; Scale plan 100K emails + dedicated IP ~EUR 80/mo (French site) [U]. Pages: https://scaleway.com/en/pricing/managed-services , https://www.scaleway.com/en/docs/transactional-email/reference-content/tem-capabilities-and-limits/ . Inbound: not found [U]. Scaleway is French (EU residency) [U].
- Account suspension/approval behaviour for new senders, .NET SDK vs SMTP relay per provider, DPAs: not retrieved for any provider (all of these offer SMTP relay and REST API per general knowledge [U]).
## Q3 Hybrid (self-hosted inbound + provider smarthost)
- Mox documents the pattern directly (Transport/Routes smarthost, SPF include of relay IPs, caveat: relay may rewrite Message-ID or reject multiple DKIM signatures). https://github.com/mjl-/mox
- Frequency of the pattern in practice: only generic search hits on "Smart host" (https://en.wikipedia.org/wiki/Smart_host); no prevalence data found.
## Q5 Account-security prior art
- OWASP Forgot Password Cheat Sheet (https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html): tokens from CSPRNG; single use and expire after an appropriate period (no number given); invalidate outstanding links after success; tie token to user; rate-limit requests per account; identical response and timing whether or not account exists; do not build URLs from Host header; HTTPS; Referrer-Policy no-referrer on the reset page; do not auto-login after reset; do not lock account on forgot attempts; page refers to Password Storage cheat sheet for storage (hashing not mandated on page).
- Not researched: GitHub/Slack/Discord/Notion invite and recovery lifetimes, email as recovery factor for OAuth-only accounts, OWASP magic-link-specific guidance, NIST 800-63B out-of-band authenticator (10-minute lifetime is my recollection) [U].
- Related local prior art: Loomio single-use email invite link, no expiry found [U] (Bifrost docs/research/customer-onboarding-prior-art.md:37).
## Gaps
Live DNS (MX/SPF/DMARC) not queried; Yggdrasil port 25 untested; Outlook/Yahoo primary pages not read; provider suspension policies, DPAs, .NET SDKs, EU-hosted extras (e.g. Mailjet/Brevo/Scaleway residency confirmation), account-security prior art mostly unresearched (tool budget).

View file

@ -0,0 +1,59 @@
# Discord-like roles on Matrix: prior art
Served: the operator question whether Discord-like roles need a Matrix fork (2026-10-30), eureka-body:follow_up:matrix-roles-from-bifrost-estates, and the chat-client fork. Eyes pass, facts only; [U] marks unverified.
# Eyes: Matrix roles prior art (2026-10-30). Facts only. [U] = unverified.
Statuses read via `gh api repos/matrix-org/matrix-spec-proposals/pulls/N` (state, merged, labels), 2026-10-30.
## Q1 Spec
MSCs (all github.com/matrix-org/matrix-spec-proposals/pull/N):
- 2812 [WIP] Role-based power/permissions (turt2live): OPEN, unmerged, last update 2025-08-12, labels needs-implementation, requires-room-version.
- 4056 Role-Based Access Control (mk II) (turt2live, created 2023-09-15): OPEN, last update 2025-04-12. PR body is only a rendered link (branch travis/msc/rbac-pl); text not read [U].
- 3949 Power Level Tags (ajbura): OPEN, update 2026-10-11, needs-implementation. Defines state event `m.room.power_level_tags`, state_key "", content = map of power-level integer -> {name, color (hex), icon{key emoji|mxc, info}}; its text says tags would show in timeline and member list. Implementation listed: Cinny. Text: raw.githubusercontent.com/ajbura/matrix-spec-proposals/power-level-tag/proposals/3949-power-level-tag.md
- 4522 Username colors (nushea, created 2026-07-28): OPEN. `m.color_preference` {on_dark,on_light}: per-account in extended profile (MSC4133), per-room in `m.room.member` content, per-message in `m.per_message_profile`. Implementation: Sable PR 1526 (setting + rendering).
- 4527 State event power levels (created 2026-08-09): OPEN. Impl links: complement#940, ruma#2640, Continuwuity PR 2393.
- 3915 Owner power level (0-150): CLOSED unmerged 2026-01-18, label finished-final-comment-period.
- 4289 Privilege creators: MERGED (closed 2025-07-22; labels merged, disposition-merge, A-Room Spec).
- 3991 Power level up!: OPEN, last update 2023-04-14.
- 3219 [WIP] Space Flair (turt2live): OPEN, last update 2022-10-21.
- 4121 m.role.moderator in /.well-known/matrix/support: OPEN; homeserver support records, not room roles.
- 2212 Third party user power levels: OPEN (2022).
Space-wide power levels:
- 2962 Managing power levels via Spaces (ara4n): OPEN, last update 2022-05-05. Template power_levels in parent space inherited by children (per search summary of its text [U]).
- 3216 Synchronized access control for Spaces (joepie91): OPEN, 2022-05-05. Adds `m.space.power_levels`; comment: a non-spaces-aware client's edits get overwritten on next inherited change [U, search summary].
- No merged MSC found giving space-wide power levels. Both are OPEN.
Custom state events:
- Spec: complete event MUST NOT exceed 65536 bytes (spec.matrix.org/latest/client-server-api/).
- Per-event-type send level via `m.room.power_levels` `events` map, else `state_default` [spec knowledge, not re-fetched, U].
- State arrives via /sync as room state; whether a client reads an unknown type is client-specific. Cinny reads `m.room.power_level_tags` (Q3).
## Q2 Element Web
- Releases: v1.12.33 (2026-10-27), v1.12.32 (2026-10-16), v1.12.31 (2026-10-13), with rc builds in between (gh api repos/element-hq/element-web/releases). Repo is a pnpm/nx monorepo: packages/{module-api, shared-components, shared-types, shared-utils, ...}, modules/{banner, restricted-guests, widget-lifecycle, widget-toggles}.
- License: AGPL-3.0 (GitHub spdx); repo also contains LICENSE-GPL-3.0 and LICENSE-COMMERCIAL ("Licensees holding a valid commercial license with Element may use this software in accordance with the terms contained in a written agreement").
- AGPL duties for a hosted modified fork: not sourced this pass [U]. (General knowledge: section 13 network-use source offer.)
- config.json (docs/config.md): `default_theme`, custom themes, `branding`, `embedded_pages`, `setting_defaults`, `features` (labs), UI-feature toggles, and `modules` (array of URLs/paths dynamically imported at startup, ~lines 597-609). Doc: "doesn't support complete re-branding/private labeling".
- Module API: `@element-hq/element-web-module-api` 2.3.0 (packages/module-api/package.json). Module = class with static `moduleApiVersion` semver and `load()`. Surfaces in packages/module-api/src/api/: auth, builtins, client, composer, config, custom-components, customisations, dialog, extras, i18n, navigation, profile, settings, stores, urlpreview, widget, widget-lifecycle, x509.
- custom-components.ts (@alpha, "Subject to change"): custom message renderers with render hints, including a hint documented as "Should the sender's profile be rendered for this event" (default true); room preview bar renderer. Overriding render means editing is unavailable.
- customisations.ts: `registerShouldShowComponent` only shows/hides listed UI components (invite, create room/space, directory, widgets, explore, room options).
- extras.ts (@alpha): space-panel item, room-header buttons.
- profile.ts: read-only profile of the logged-in user.
- No sender-name decorator or member-list hook found in the file listing; stores.ts, builtins.ts, client.ts not read in detail [U].
- Theme details (custom theme JSON/CSS variables): not read [U].
- Maintained-fork cost (merge pain, cadence of known forks): not gathered [U].
## Q3 Other clients and prior art
- Cinny (AGPL-3.0, pushed 2026-10-28): src/app/hooks/usePowerLevelTags.ts has DEFAULT_TAGS {9001 Goku, 150 Manager, 101 Founder, 100 Admin, 50 Moderator, 0 Member}, each with name+color; reads a state event (type in src/types/matrix/room.ts); src/app/hooks/useMemberPowerTag.ts resolves a member's tag from power level, with a room-creators tag, and applies `accessibleColor` per ThemeKind. This is the MSC3949 implementation.
- Sable (AGPL-3.0; repo now "Sable-Legacy", v2 at git.sable.moe): implements MSC4522 colours (SableClient/Sable#1526, per MSC text).
- Commet (AGPL-3.0, pushed 2026-10-29): room permissions settings page defines `MatrixRoomRoleEntry` {name, powerlevel} (commet/lib/ui/pages/settings/categories/room/permissions/matrix/matrix_room_permissions_page.dart). Timeline colour/badge display not checked [U].
- FluffyChat, Nheko, SchildiChat: issue searches returned nothing; no evidence either way [U].
- Rocket.Chat-on-Matrix, Conduit/Continuwuity communities, Polycule: not researched [U]. Continuwuity has an MSC4527 implementation PR (forgejo.ellis.link/continuwuation/continuwuity/pulls/2393).
- mautrix-discord (github.com/mautrix/discord): stores Discord roles in a DB `role` table (dc_guild_id, dcid, name, icon, mentionable, managed, hoist, color, position, permissions), synced from GuildRoleCreate/Update/Delete (user.go:684-688, 978+; database/role.go). ROADMAP.md lists "Power level" and "Role permissions" unchecked. portal.go:1734-1739 uses room power levels only to gate @room mentions. No evidence found of writing Discord roles into Matrix state or power levels.
## Q4 Body
- Chat map: F:\Projects\gamecult-ops\docs\gamecult-chat-map.md. Element Web at chat.gamecult.org is static `root /srv/element-web/current` (symlink to /srv/element-web/releases/<version>), 404 until campaign cut C5 (lines 71, 101); runbooks/gamecult-chat-host-yggdrasil.md:6 says Element Web's release belongs to C5. No deployed Element release found in the docs. Homeserver matrix.gamecult.org -> Synapse 127.0.0.1:8834; federation and admin paths 404; apex /.well-known/matrix/client base_url https://matrix.gamecult.org (lines 93-102).
- Synapse: ygg-verify probe ran matrixdotorg/synapse 1.163.0 on postgres:16 (chat map line 27, P5). Upstream latest Synapse v1.164.1 (2026-10-28), MAS v1.28.0 (2026-10-27). Deployed versions on Yggdrasil not checked (no ssh) [U].
- Idunn targets `synapse`, `matrix-auth`; C3 `synapse-mas-under-idunn` packages them.
- Bifrost origin/main docs/oidc-issuer.md: OpenIddict 7.7.1; scopes `openid profile`; claims only `sub`, `preferred_username` (never changes), `name`; "No e-mail, no Heimdall id, no GitHub id, and no membership detail leaves Bifrost". MAS is the OIDC client.
- Bifrost role code: only membership roles in its own DB (`MemberRole` StandardMember|Producer|Maintainer|LedgerReviewer|PlatformAdmin, RoleAssignment; src/Bifrost.Web/Data/BifrostDbContext.cs:113-114; docs/research/customer-onboarding-body-facts.md:16). Grep of origin/main docs and src for power_level / m.room.power found nothing. docs/bridge.md:199: Personas speak through `tools/persona-matrix-mouth.mjs` with per-persona Matrix access tokens (the only Matrix writer found).

63
docs/watchdog-notice.md Normal file
View file

@ -0,0 +1,63 @@
# Watchdog notice reader
`tools/watchdog-notice.mjs` is a one-shot reader. It turns Idunn's operator
incidents into Discord DMs to the owner. Run it from a timer.
```
node tools/watchdog-notice.mjs process --incident-store P --journal-store J --receipt-store R [--bridge-cli B]
node tools/watchdog-notice.mjs status --journal-store J
```
## Requirements
- **CultLib at `8cb3b728` or later.** The reader loads `@gamecult/cultcache-ts`,
the name CultLib gave the package in `8cb3b728` (2026-09-04), from
`$VOIDBOT_CULTLIB_ROOT` or the sibling `../CultLib`. An older CultLib fails at
import with `MODULE_NOT_FOUND`; the reader reports `cultlib-unavailable`. The
same floor applies to every Bifrost tool that loads CultCache
(`tools/persona-feedback.mjs` and the others). A deployment pairs a Bifrost
release with a CultLib commit at or after this one.
- `DISCORD_OWNER_ID` in the environment: the only source of the recipient. The
reader exits non-zero before writing the journal if it is missing.
- `BIFROST_DISCORD_BOT_TOKEN` in the environment, read only by
`bifrost-bridge.mjs`, which the reader spawns.
## Retry
A notice whose send failed (bridge exit 75) is never dropped. It is eligible
again once `min(2^(attempts-1), 60)` minutes have passed since its last attempt
in the journal: 1, 2, 4, 8, 16, 32, then every 60 minutes. A run that finds it
inside its delay skips it without spawning the bridge. An entry stamped ahead of the clock (a host clock stepped forward, then corrected) is eligible now, and its next attempt rewrites the stamp. Every attempt carries the
same nonce, so a Discord outage of any length costs a late notice, not a lost
one, and a retry that succeeds is the one post.
## Behaviour
- P is Idunn's `idunn.operator_incident.v1` store. The reader reads it without a
lock and never writes it. An absent P is a clean no-op.
- One notice when an incident opens and one when it closes, each at most once.
The closing notice is sent only after the opening notice completed.
- J is Bifrost's journal, `bifrost.watchdog_notice_execution.v1`, keyed
`<incident_key>#opened` or `<incident_key>#closed`. A `running` entry found at
start becomes `unknown` and is never sent again. A `failed` entry is retried on
later runs with the same nonce, never given up on (see Retry). The journal is never
pruned: what Bifrost has sent is Bifrost's memory, whatever P holds.
- The bridge (`bifrost-bridge.mjs discord-dm`) exits 75 for argument or token
errors, a failure opening the DM channel, or any non-2xx answer to the message
POST. A non-2xx is retried, not proven unsent: the retry carries the same nonce
and relies on Discord's `enforce_nonce` inside its window, so a 5xx that Discord
answered after creating the message can be delivered twice if the retry falls
outside that window. A 5xx stays retryable because parking it as `unknown`
would lose the notice on every routine Discord outage. The reader records
`failed` for exit 75 only. Any other non-success without a message id (another exit code,
a signal, the spawn timeout, unreadable output) is `unknown`: never sent again,
counted by the exit status.
- The exit status is non-zero while any entry is `unknown` or has failed three or
more times, or while P holds a record of this schema that breaks its contract.
systemd shows that; `status` lists the entries, with `attempts` and, for a
`failed` entry, `nextEligibleAt`. The non-zero status is the signal only: the
entry keeps being retried.
- Notice text is built from `condition`, `subject` and ISO UTC times only. The
subject follows Idunn's `require_id` (1-256 bytes of `[A-Za-z0-9-_.:/]`) and
is placed in inline code. A time outside the ECMAScript Date range refuses the
record.

View file

@ -11,7 +11,14 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
<!-- Direct pins lift transitive packages past their advisories; the lockfile holds the result. -->
<PackageReference Include="Npgsql" Version="8.0.3" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
<PackageReference Include="OpenIddict.Server.AspNetCore" Version="7.7.1" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="7.7.1" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,53 @@
namespace Bifrost.Web.Configuration;
public sealed class ConceptVoteOptions
{
public const string SectionName = "ConceptVotes";
// HMAC-SHA256 output is 32 bytes (RFC 2104 advises a key at least as long as the hash output), so a shorter
// secret makes the tag brute-forceable over the IPv4 space if the table leaks.
public const int MinNetworkTagSecretLength = 32;
// Keys the network tag. Required outside Development and Testing, at least MinNetworkTagSecretLength
// characters (StartupConfigurationValidator).
public string NetworkTagSecret { get; init; } = string.Empty;
// The research note (docs/research/anonymous-vote-stuffing-prior-art.md:57-61) finds no source for a
// coarser IPv4 partition than the address itself, so the default is the exact address.
public int IPv4PrefixLength { get; init; } = 32;
// /64 is bypassed by RFC 8981 privacy rotation and households can hold a /56 or /48; /56 is the practitioner
// default weighed in the note, not a standard. Tune on observed abuse.
public int IPv6PrefixLength { get; init; } = 56;
// Vote budgets (Features/ConceptVotes/ConceptVoteRateLimits). The research note
// (docs/research/anonymous-vote-stuffing-prior-art.md section 3) gives no numbers, so these are first values to tune on
// observed abuse. Cast: one visitor reading through the Mill can vote on a few dozen concepts in a sitting, so 30 per
// 10 minutes per account or network prefix does not trouble a person and caps a script at 180 an hour.
public int CastPermits { get; init; } = 30;
public TimeSpan CastWindow { get; init; } = TimeSpan.FromMinutes(10);
// Mint: a new device cookie is a new voter identity, the cheapest stuffing lever. Anonymous visitors share an
// address behind carrier, school and office NAT (the research note records it), so 30 new devices per hour per
// address (operator ruling concept-mill-votes:ruling:mint-budget-30-per-hour); per-device and per-prefix cast
// limits still bound stuffing.
public int MintPermits { get; init; } = 30;
public TimeSpan MintWindow { get; init; } = TimeSpan.FromHours(1);
// The most partitions each budget table holds at once (cast by account, cast by network prefix, mint). At the cap a
// partition not yet held evicts the least recently used one that still has permits left; a partition that has
// spent its permits is never evicted, so rotating sources can at most win back a forgotten partition's partial
// budget. 100000 partitions is a few tens of MB; a real audience is far below it.
public int MaxTrackedKeys { get; init; } = 100_000;
// The origins whose pages may read the public tally export from a browser (CORS). Each entry is exactly
// https://host[:non-default-port], lower case, no path; empty allows none. StartupConfigurationValidator
// refuses anything else outside Development and Testing, because "*" would allow every origin.
public string[] AllowedOrigins { get; init; } = [];
// The Mill page the vote page links back to; the concept's key is appended as the anchor. Production is the
// Aetheria site's page, so a deploy overrides nothing unless the page moves (ConceptVotes__MillPageUrl).
public string MillPageUrl { get; init; } = "https://aetheria.gamecult.org/Game-Design/Concept-Mill";
}

View file

@ -14,6 +14,8 @@ public sealed class HeimdallOptions
public string PatreonTierTitle { get; init; } = "Inner Sanctum";
public string AppSecret { get; init; } = string.Empty;
public string PatronSupportIntakeSecret { get; init; } = string.Empty;
public bool EnablePatronSupportIntake { get; init; } = true;
@ -24,11 +26,13 @@ public sealed class HeimdallOptions
public bool IsDiscordConfigured =>
IsConfigured &&
!string.IsNullOrWhiteSpace(AppSecret) &&
!string.IsNullOrWhiteSpace(DiscordGuildId) &&
DiscordAllowedRoleIds.Length > 0;
public bool IsPatreonConfigured =>
IsConfigured &&
!string.IsNullOrWhiteSpace(AppSecret) &&
!string.IsNullOrWhiteSpace(PatreonTierTitle);
public bool IsPatronSupportIntakeConfigured =>

View file

@ -0,0 +1,44 @@
namespace Bifrost.Web.Configuration;
/// <summary>
/// The OIDC provider surface (section <c>Bifrost:Oidc</c>). An absent section means the provider is not mapped at
/// all; a present section is complete or startup fails. Keys and the client secret are named by file path, never inlined.
/// </summary>
public sealed class OidcIssuerOptions
{
public const string SectionName = "Bifrost:Oidc";
/// <summary>PEM file holding the EC P-256 private key that signs tokens (ES256).</summary>
public string SigningKeyPath { get; init; } = string.Empty;
/// <summary>File holding the 32-byte symmetric token-encryption key as base64.</summary>
public string EncryptionKeyPath { get; init; } = string.Empty;
public OidcClientsOptions Clients { get; init; } = new();
/// <summary>Null when the section is absent.</summary>
public static OidcIssuerOptions? FromConfiguration(IConfiguration configuration)
{
var section = configuration.GetSection(SectionName);
return section.Exists() ? section.Get<OidcIssuerOptions>() ?? new OidcIssuerOptions() : null;
}
}
public sealed class OidcClientsOptions
{
/// <summary>Matrix Authentication Service, the one client of this provider.</summary>
public OidcClientOptions Mas { get; init; } = new();
}
public sealed class OidcClientOptions
{
public string ClientId { get; init; } = string.Empty;
/// <summary>File holding the client secret.</summary>
public string ClientSecretPath { get; init; } = string.Empty;
public string RedirectUri { get; init; } = string.Empty;
/// <summary>MAS's back-channel logout URL for this provider. Empty: no logout is sent and no session is ended.</summary>
public string BackchannelLogoutUri { get; init; } = string.Empty;
}

View file

@ -5,6 +5,10 @@ namespace Bifrost.Web.Data;
public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options) : DbContext(options)
{
// The OpenIddict tables are part of this model for every way a context is built: the host, the concepts
// command, tests and the design-time factory that generates migrations.
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseOpenIddict();
public DbSet<UserAccount> UserAccounts => Set<UserAccount>();
public DbSet<MemberProfile> MemberProfiles => Set<MemberProfile>();
@ -63,6 +67,10 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
public DbSet<AuditEvent> AuditEvents => Set<AuditEvent>();
public DbSet<ConceptSubject> ConceptSubjects => Set<ConceptSubject>();
public DbSet<ConceptAppraisal> ConceptAppraisals => Set<ConceptAppraisal>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<UserAccount>(entity =>
@ -523,5 +531,31 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
.HasForeignKey(x => x.ActorUserAccountId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<ConceptSubject>(entity =>
{
entity.Property(x => x.Collection).HasMaxLength(80);
entity.Property(x => x.Key).HasMaxLength(80);
entity.Property(x => x.Title).HasMaxLength(240);
entity.HasIndex(x => new { x.Collection, x.Key }).IsUnique();
});
modelBuilder.Entity<ConceptAppraisal>(entity =>
{
entity.Property(x => x.VoterKey).HasMaxLength(120);
entity.Property(x => x.NetworkTag).HasMaxLength(64);
entity.Property(x => x.Stance).HasConversion<string>().HasMaxLength(16);
entity.Property(x => x.VoterClass).HasConversion<string>().HasMaxLength(16);
entity.HasIndex(x => new { x.SubjectId, x.VoterKey }).IsUnique();
entity.HasIndex(x => new { x.NetworkTag, x.CastAtUtc });
entity.HasOne(x => x.Subject)
.WithMany()
.HasForeignKey(x => x.SubjectId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne<UserAccount>()
.WithMany()
.HasForeignKey(x => x.UserAccountId)
.OnDelete(DeleteBehavior.SetNull);
});
}
}

View file

@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@ -5,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Bifrost.Web.Data.Migrations
{
/// <inheritdoc />
[DbContext(typeof(BifrostDbContext))]
[Migration("20260608210000_AddHeimdallUserIdentity")]
public partial class AddHeimdallUserIdentity : Migration
{

View file

@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@ -5,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Bifrost.Web.Data.Migrations
{
/// <inheritdoc />
[DbContext(typeof(BifrostDbContext))]
[Migration("20260609143000_AddPatronSupportDerivation")]
public partial class AddPatronSupportDerivation : Migration
{

View file

@ -1,10 +1,12 @@
using Bifrost.Web.Domain;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
[DbContext(typeof(BifrostDbContext))]
[Migration("20260609160000_AddExternalPatronProviderIntake")]
public partial class AddExternalPatronProviderIntake : Migration
{

View file

@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@ -5,6 +6,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Bifrost.Web.Data.Migrations
{
/// <inheritdoc />
[DbContext(typeof(BifrostDbContext))]
[Migration("20260622142000_AddBridgeActionIdentityProvenance")]
public partial class AddBridgeActionIdentityProvenance : Migration
{
/// <inheritdoc />

View file

@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@ -5,6 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Bifrost.Web.Data.Migrations
{
/// <inheritdoc />
[DbContext(typeof(BifrostDbContext))]
[Migration("20260622165000_AddBifrostNativeIdentity")]
public partial class AddBifrostNativeIdentity : Migration
{

View file

@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@ -5,6 +6,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Bifrost.Web.Data.Migrations
{
/// <inheritdoc />
[DbContext(typeof(BifrostDbContext))]
[Migration("20260622202000_AddBridgeActionTargetSurfaceName")]
public partial class AddBridgeActionTargetSurfaceName : Migration
{
/// <inheritdoc />

View file

@ -1,4 +1,5 @@
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@ -6,6 +7,8 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Bifrost.Web.Data.Migrations
{
/// <inheritdoc />
[DbContext(typeof(BifrostDbContext))]
[Migration("20260705190000_AddPatronSupportProjectAttribution")]
public partial class AddPatronSupportProjectAttribution : Migration
{
/// <inheritdoc />

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,93 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
/// <inheritdoc />
public partial class AddConceptVotes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ConceptSubjects",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Collection = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
Key = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
Title = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
RegisteredAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RetiredAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ConceptSubjects", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ConceptAppraisals",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SubjectId = table.Column<Guid>(type: "uuid", nullable: false),
VoterKey = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
Stance = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
VoterClass = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
UserAccountId = table.Column<Guid>(type: "uuid", nullable: true),
NetworkTag = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
CastAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ConceptAppraisals", x => x.Id);
table.ForeignKey(
name: "FK_ConceptAppraisals_ConceptSubjects_SubjectId",
column: x => x.SubjectId,
principalTable: "ConceptSubjects",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ConceptAppraisals_UserAccounts_UserAccountId",
column: x => x.UserAccountId,
principalTable: "UserAccounts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_ConceptAppraisals_NetworkTag_CastAtUtc",
table: "ConceptAppraisals",
columns: new[] { "NetworkTag", "CastAtUtc" });
migrationBuilder.CreateIndex(
name: "IX_ConceptAppraisals_SubjectId_VoterKey",
table: "ConceptAppraisals",
columns: new[] { "SubjectId", "VoterKey" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ConceptAppraisals_UserAccountId",
table: "ConceptAppraisals",
column: "UserAccountId");
migrationBuilder.CreateIndex(
name: "IX_ConceptSubjects_Collection_Key",
table: "ConceptSubjects",
columns: new[] { "Collection", "Key" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ConceptAppraisals");
migrationBuilder.DropTable(
name: "ConceptSubjects");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,166 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Bifrost.Web.Data.Migrations
{
/// <inheritdoc />
public partial class AddOpenIddict : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "OpenIddictApplications",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
ApplicationType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
ClientId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
ClientSecret = table.Column<string>(type: "text", nullable: true),
ClientType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
ConcurrencyToken = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
ConsentType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
DisplayName = table.Column<string>(type: "text", nullable: true),
DisplayNames = table.Column<string>(type: "text", nullable: true),
JsonWebKeySet = table.Column<string>(type: "text", nullable: true),
Permissions = table.Column<string>(type: "text", nullable: true),
PostLogoutRedirectUris = table.Column<string>(type: "text", nullable: true),
Properties = table.Column<string>(type: "text", nullable: true),
RedirectUris = table.Column<string>(type: "text", nullable: true),
Requirements = table.Column<string>(type: "text", nullable: true),
Settings = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OpenIddictApplications", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OpenIddictScopes",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
ConcurrencyToken = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Description = table.Column<string>(type: "text", nullable: true),
Descriptions = table.Column<string>(type: "text", nullable: true),
DisplayName = table.Column<string>(type: "text", nullable: true),
DisplayNames = table.Column<string>(type: "text", nullable: true),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
Properties = table.Column<string>(type: "text", nullable: true),
Resources = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OpenIddictScopes", x => x.Id);
});
migrationBuilder.CreateTable(
name: "OpenIddictAuthorizations",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
ApplicationId = table.Column<string>(type: "text", nullable: true),
ConcurrencyToken = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
CreationDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Properties = table.Column<string>(type: "text", nullable: true),
Scopes = table.Column<string>(type: "text", nullable: true),
Status = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Subject = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: true),
Type = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OpenIddictAuthorizations", x => x.Id);
table.ForeignKey(
name: "FK_OpenIddictAuthorizations_OpenIddictApplications_Application~",
column: x => x.ApplicationId,
principalTable: "OpenIddictApplications",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "OpenIddictTokens",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
ApplicationId = table.Column<string>(type: "text", nullable: true),
AuthorizationId = table.Column<string>(type: "text", nullable: true),
ConcurrencyToken = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
CreationDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ExpirationDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Payload = table.Column<string>(type: "text", nullable: true),
Properties = table.Column<string>(type: "text", nullable: true),
RedemptionDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ReferenceId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
Status = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Subject = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: true),
Type = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_OpenIddictTokens", x => x.Id);
table.ForeignKey(
name: "FK_OpenIddictTokens_OpenIddictApplications_ApplicationId",
column: x => x.ApplicationId,
principalTable: "OpenIddictApplications",
principalColumn: "Id");
table.ForeignKey(
name: "FK_OpenIddictTokens_OpenIddictAuthorizations_AuthorizationId",
column: x => x.AuthorizationId,
principalTable: "OpenIddictAuthorizations",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_OpenIddictApplications_ClientId",
table: "OpenIddictApplications",
column: "ClientId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OpenIddictAuthorizations_ApplicationId_Status_Subject_Type",
table: "OpenIddictAuthorizations",
columns: new[] { "ApplicationId", "Status", "Subject", "Type" });
migrationBuilder.CreateIndex(
name: "IX_OpenIddictScopes_Name",
table: "OpenIddictScopes",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_OpenIddictTokens_ApplicationId_Status_Subject_Type",
table: "OpenIddictTokens",
columns: new[] { "ApplicationId", "Status", "Subject", "Type" });
migrationBuilder.CreateIndex(
name: "IX_OpenIddictTokens_AuthorizationId",
table: "OpenIddictTokens",
column: "AuthorizationId");
migrationBuilder.CreateIndex(
name: "IX_OpenIddictTokens_ReferenceId",
table: "OpenIddictTokens",
column: "ReferenceId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "OpenIddictScopes");
migrationBuilder.DropTable(
name: "OpenIddictTokens");
migrationBuilder.DropTable(
name: "OpenIddictAuthorizations");
migrationBuilder.DropTable(
name: "OpenIddictApplications");
}
}
}

View file

@ -17,7 +17,7 @@ namespace Bifrost.Web.Data.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.0")
.HasAnnotation("ProductVersion", "8.0.30")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@ -367,15 +367,15 @@ namespace Bifrost.Web.Data.Migrations
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("TargetSurface")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TargetSurfaceName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("TargetSurface")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
@ -412,6 +412,88 @@ namespace Bifrost.Web.Data.Migrations
b.ToTable("BridgeActions");
});
modelBuilder.Entity("Bifrost.Web.Domain.ConceptAppraisal", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CastAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("NetworkTag")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Stance")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("SubjectId")
.HasColumnType("uuid");
b.Property<Guid?>("UserAccountId")
.HasColumnType("uuid");
b.Property<string>("VoterClass")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("VoterKey")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.HasKey("Id");
b.HasIndex("UserAccountId");
b.HasIndex("NetworkTag", "CastAtUtc");
b.HasIndex("SubjectId", "VoterKey")
.IsUnique();
b.ToTable("ConceptAppraisals");
});
modelBuilder.Entity("Bifrost.Web.Domain.ConceptSubject", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Collection")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<DateTimeOffset>("RegisteredAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("RetiredAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.HasKey("Id");
b.HasIndex("Collection", "Key")
.IsUnique();
b.ToTable("ConceptSubjects");
});
modelBuilder.Entity("Bifrost.Web.Domain.DecayRun", b =>
{
b.Property<Guid>("Id")
@ -889,6 +971,9 @@ namespace Bifrost.Web.Data.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Provider")
.IsRequired()
.HasColumnType("text");
@ -908,9 +993,6 @@ namespace Bifrost.Web.Data.Migrations
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("RecordedAtUtc")
.HasColumnType("timestamp with time zone");
@ -922,12 +1004,12 @@ namespace Bifrost.Web.Data.Migrations
b.HasKey("Id");
b.HasIndex("ProjectId");
b.HasIndex("Provider", "ProviderEventId")
.IsUnique()
.HasFilter("\"ProviderEventId\" <> ''");
b.HasIndex("ProjectId");
b.HasIndex("UserAccountId", "Kind", "IsCurrentRecurringSupport");
b.ToTable("PatronSupportEvents");
@ -1534,6 +1616,214 @@ namespace Bifrost.Web.Data.Migrations
b.ToTable("WorkReviews");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<string>("ApplicationType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("ClientId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ClientSecret")
.HasColumnType("text");
b.Property<string>("ClientType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("ConsentType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<string>("DisplayNames")
.HasColumnType("text");
b.Property<string>("JsonWebKeySet")
.HasColumnType("text");
b.Property<string>("Permissions")
.HasColumnType("text");
b.Property<string>("PostLogoutRedirectUris")
.HasColumnType("text");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<string>("RedirectUris")
.HasColumnType("text");
b.Property<string>("Requirements")
.HasColumnType("text");
b.Property<string>("Settings")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ClientId")
.IsUnique();
b.ToTable("OpenIddictApplications", (string)null);
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<string>("ApplicationId")
.HasColumnType("text");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("CreationDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<string>("Scopes")
.HasColumnType("text");
b.Property<string>("Status")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Subject")
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("Type")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.HasKey("Id");
b.HasIndex("ApplicationId", "Status", "Subject", "Type");
b.ToTable("OpenIddictAuthorizations", (string)null);
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Descriptions")
.HasColumnType("text");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<string>("DisplayNames")
.HasColumnType("text");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<string>("Resources")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("OpenIddictScopes", (string)null);
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text");
b.Property<string>("ApplicationId")
.HasColumnType("text");
b.Property<string>("AuthorizationId")
.HasColumnType("text");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTime?>("CreationDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("ExpirationDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("Payload")
.HasColumnType("text");
b.Property<string>("Properties")
.HasColumnType("text");
b.Property<DateTime?>("RedemptionDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReferenceId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Status")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Subject")
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("Type")
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.HasKey("Id");
b.HasIndex("AuthorizationId");
b.HasIndex("ReferenceId")
.IsUnique();
b.HasIndex("ApplicationId", "Status", "Subject", "Type");
b.ToTable("OpenIddictTokens", (string)null);
});
modelBuilder.Entity("Bifrost.Web.Domain.AgentDispatchRun", b =>
{
b.HasOne("Bifrost.Web.Domain.UserAccount", "StartedByUserAccount")
@ -1615,6 +1905,22 @@ namespace Bifrost.Web.Data.Migrations
b.Navigation("WorkItem");
});
modelBuilder.Entity("Bifrost.Web.Domain.ConceptAppraisal", b =>
{
b.HasOne("Bifrost.Web.Domain.ConceptSubject", "Subject")
.WithMany()
.HasForeignKey("SubjectId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Bifrost.Web.Domain.UserAccount", null)
.WithMany()
.HasForeignKey("UserAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Subject");
});
modelBuilder.Entity("Bifrost.Web.Domain.GitHubIssueLink", b =>
{
b.HasOne("Bifrost.Web.Domain.WorkItem", "WorkItem")
@ -1992,6 +2298,30 @@ namespace Bifrost.Web.Data.Migrations
b.Navigation("WorkItem");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
{
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application")
.WithMany("Authorizations")
.HasForeignKey("ApplicationId");
b.Navigation("Application");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b =>
{
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application")
.WithMany("Tokens")
.HasForeignKey("ApplicationId");
b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization")
.WithMany("Tokens")
.HasForeignKey("AuthorizationId");
b.Navigation("Application");
b.Navigation("Authorization");
});
modelBuilder.Entity("Bifrost.Web.Domain.Membership", b =>
{
b.Navigation("RoleAssignments");
@ -2090,6 +2420,18 @@ namespace Bifrost.Web.Data.Migrations
b.Navigation("WorkReviews");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
{
b.Navigation("Authorizations");
b.Navigation("Tokens");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b =>
{
b.Navigation("Tokens");
});
#pragma warning restore 612, 618
}
}

View file

@ -0,0 +1,50 @@
namespace Bifrost.Web.Domain;
public enum ConceptStance
{
Up,
Down
}
public enum VoterClass
{
Anonymous,
Account,
Member
}
public sealed class ConceptSubject
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Collection { get; set; } = string.Empty;
public string Key { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public DateTimeOffset RegisteredAtUtc { get; set; }
public DateTimeOffset? RetiredAtUtc { get; set; }
}
public sealed class ConceptAppraisal
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid SubjectId { get; set; }
public string VoterKey { get; set; } = string.Empty;
public ConceptStance Stance { get; set; }
public VoterClass VoterClass { get; set; }
public Guid? UserAccountId { get; set; }
public string NetworkTag { get; set; } = string.Empty;
public DateTimeOffset CastAtUtc { get; set; }
public ConceptSubject? Subject { get; set; }
}

View file

@ -0,0 +1,29 @@
using Bifrost.Web.Configuration;
using Microsoft.AspNetCore.Http;
namespace Bifrost.Web.Features.ConceptVotes;
// The one place that decides whether a state-changing request to the vote page came from the site itself. The anonymous
// vote flow carries no antiforgery token and no antiforgery cookie, so a cross-site form post is refused here instead
// (OWASP Fetch Metadata): the Origin header must equal the configured site origin byte for byte, or, when the browser
// sent no Origin, Sec-Fetch-Site must be exactly same-origin. Same-site, cross-site, none, absent and a repeated header
// are all refused. Header values are compared, never echoed.
public static class CastRequestGuard
{
public static bool Allows(HttpRequest request, BifrostHostOptions host)
{
if (!Uri.TryCreate(host.PublicBaseUrl, UriKind.Absolute, out var site))
{
return false;
}
var origin = request.Headers.Origin;
if (request.Headers.ContainsKey("Origin"))
{
return origin.Count == 1 && string.Equals(origin[0], site.GetLeftPart(UriPartial.Authority), StringComparison.Ordinal);
}
var fetchSite = request.Headers["Sec-Fetch-Site"];
return fetchSite.Count == 1 && string.Equals(fetchSite[0], "same-origin", StringComparison.Ordinal);
}
}

View file

@ -0,0 +1,268 @@
using System.Net;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Npgsql;
namespace Bifrost.Web.Features.ConceptVotes;
public enum CastResult
{
Recorded,
Changed,
Unchanged,
UnknownSubject,
RetiredSubject,
NoVoter
}
public sealed record StanceCounts(int Up, int Down);
public sealed record ConceptTally(int Up, int Down, StanceCounts Members);
public sealed record ConceptSubjectView(
string Collection,
string Key,
string Title,
ConceptTally Tally,
ConceptStance? YourStance);
// The only writer of ConceptAppraisal rows. Tallies are derived from rows on every read.
public sealed class ConceptAppraisalService(
BifrostDbContext dbContext,
AuditTrailService auditTrail,
TimeProvider timeProvider,
IOptions<ConceptVoteOptions> options)
{
private const string UniqueViolation = "23505";
public async Task<CastResult> CastAsync(
string collection,
string key,
VoterIdentity? voter,
ConceptStance stance,
IPAddress? client,
CancellationToken cancellationToken)
{
if (voter is null)
{
return CastResult.NoVoter;
}
var subject = await FindSubjectAsync(collection, key, cancellationToken);
if (subject is null)
{
return CastResult.UnknownSubject;
}
if (subject.RetiredAtUtc is not null)
{
return CastResult.RetiredSubject;
}
var opts = options.Value;
var tag = client is null
? string.Empty
: NetworkPrefix.Tag(client, opts.IPv4PrefixLength, opts.IPv6PrefixLength, opts.NetworkTagSecret);
var existing = await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken);
if (existing is not null)
{
return await ApplyStanceAsync(existing, stance, tag, cancellationToken);
}
var row = new ConceptAppraisal
{
SubjectId = subject.Id,
VoterKey = voter.Key,
Stance = stance,
VoterClass = voter.Class,
UserAccountId = voter.UserAccountId,
NetworkTag = tag,
CastAtUtc = timeProvider.GetUtcNow()
};
dbContext.ConceptAppraisals.Add(row);
try
{
await dbContext.SaveChangesAsync(cancellationToken);
return CastResult.Recorded;
}
catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: UniqueViolation })
{
// A concurrent cast by the same voter won the insert: apply this stance to their row, once.
dbContext.Entry(row).State = EntityState.Detached;
var winner = await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken);
if (winner is null)
{
throw;
}
return await ApplyStanceAsync(winner, stance, tag, cancellationToken);
}
}
public async Task<bool> RetractAsync(
string collection,
string key,
VoterIdentity? voter,
CancellationToken cancellationToken)
{
if (voter is null)
{
return false;
}
var subject = await FindSubjectAsync(collection, key, cancellationToken);
if (subject is null)
{
return false;
}
var row = await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken);
if (row is null)
{
return false;
}
dbContext.ConceptAppraisals.Remove(row);
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
public async Task<int> PurgeAsync(
CurrentBifrostActor actor,
string networkTag,
DateTimeOffset fromUtc,
DateTimeOffset toUtc,
CancellationToken cancellationToken)
{
if (!actor.CanManageMembership || actor.UserAccount is null)
{
throw new UnauthorizedAccessException("Only a platform administrator can purge appraisals.");
}
if (string.IsNullOrEmpty(networkTag))
{
return 0;
}
var rows = await dbContext.ConceptAppraisals
.Where(x => x.NetworkTag == networkTag && x.CastAtUtc >= fromUtc && x.CastAtUtc < toUtc)
.ToListAsync(cancellationToken);
dbContext.ConceptAppraisals.RemoveRange(rows);
// The audit write saves the removals in the same SaveChanges. The member dashboard renders Detail, so it
// carries the window and count only: no network tag and no voter keys. The actor and window audit the purge.
await auditTrail.RecordAsync(
actor.UserAccount.Id,
nameof(ConceptAppraisal),
Guid.Empty,
"Purge",
$"from={fromUtc:O} to={toUtc:O} count={rows.Count}",
cancellationToken);
return rows.Count;
}
public async Task<IReadOnlyDictionary<string, ConceptTally>> GetTalliesAsync(
string collection,
CancellationToken cancellationToken)
{
var live = await dbContext.ConceptSubjects.AsNoTracking()
.Where(x => x.Collection == collection && x.RetiredAtUtc == null)
.Select(x => new { x.Id, x.Key })
.ToListAsync(cancellationToken);
var ids = live.Select(x => x.Id).ToList();
var groups = await dbContext.ConceptAppraisals.AsNoTracking()
.Where(x => ids.Contains(x.SubjectId))
.GroupBy(x => new { x.SubjectId, x.Stance, IsMember = x.VoterClass == VoterClass.Member })
.Select(g => new { g.Key.SubjectId, g.Key.Stance, g.Key.IsMember, Count = g.Count() })
.ToListAsync(cancellationToken);
return live.ToDictionary(
s => s.Key,
s => Tally(groups.Where(g => g.SubjectId == s.Id).Select(g => (g.Stance, g.IsMember, g.Count))),
StringComparer.Ordinal);
}
public async Task<ConceptSubjectView?> GetSubjectAsync(
string collection,
string key,
VoterIdentity? voter,
CancellationToken cancellationToken)
{
var subject = await FindSubjectAsync(collection, key, cancellationToken);
if (subject is null || subject.RetiredAtUtc is not null)
{
return null;
}
var groups = await dbContext.ConceptAppraisals.AsNoTracking()
.Where(x => x.SubjectId == subject.Id)
.GroupBy(x => new { x.Stance, IsMember = x.VoterClass == VoterClass.Member })
.Select(g => new { g.Key.Stance, g.Key.IsMember, Count = g.Count() })
.ToListAsync(cancellationToken);
var yours = voter is null
? null
: (await FindAppraisalAsync(subject.Id, voter.Key, cancellationToken))?.Stance;
return new ConceptSubjectView(
subject.Collection,
subject.Key,
subject.Title,
Tally(groups.Select(g => (g.Stance, g.IsMember, g.Count))),
yours);
}
private static ConceptTally Tally(IEnumerable<(ConceptStance Stance, bool IsMember, int Count)> groups)
{
int up = 0, down = 0, memberUp = 0, memberDown = 0;
foreach (var (stance, isMember, count) in groups)
{
if (stance == ConceptStance.Up)
{
up += count;
memberUp += isMember ? count : 0;
}
else
{
down += count;
memberDown += isMember ? count : 0;
}
}
return new ConceptTally(up, down, new StanceCounts(memberUp, memberDown));
}
private async Task<CastResult> ApplyStanceAsync(
ConceptAppraisal row,
ConceptStance stance,
string tag,
CancellationToken cancellationToken)
{
if (row.Stance == stance)
{
return CastResult.Unchanged;
}
row.Stance = stance;
row.NetworkTag = tag;
row.CastAtUtc = timeProvider.GetUtcNow();
await dbContext.SaveChangesAsync(cancellationToken);
return CastResult.Changed;
}
private Task<ConceptSubject?> FindSubjectAsync(string collection, string key, CancellationToken cancellationToken) =>
ConceptKey.IsValid(collection) && ConceptKey.IsValid(key)
? dbContext.ConceptSubjects
.SingleOrDefaultAsync(x => x.Collection == collection && x.Key == key, cancellationToken)
: Task.FromResult<ConceptSubject?>(null);
private Task<ConceptAppraisal?> FindAppraisalAsync(Guid subjectId, string voterKey, CancellationToken cancellationToken) =>
dbContext.ConceptAppraisals
.SingleOrDefaultAsync(x => x.SubjectId == subjectId && x.VoterKey == voterKey, cancellationToken);
}

View file

@ -0,0 +1,70 @@
using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Features.ConceptVotes;
// Host command: Bifrost.Web.dll concepts plan|apply <collection> <manifest.json> [--retire k1,k2]
public static class ConceptCommand
{
public const int Ok = 0;
public const int Refused = 1;
public const int Usage = 2;
// args are the words after "concepts".
public static async Task<int> RunAsync(string[] args, IServiceProvider services, TextWriter output)
{
var known = args.Length > 0 && args[0] is "plan" or "apply";
var hasRetire = args.Length == 5 && args[3] == "--retire";
if (!known || !(args.Length == 3 || hasRetire))
{
await output.WriteLineAsync("usage: concepts plan|apply <collection> <manifest.json> [--retire k1,k2]");
return Usage;
}
string[] retire = hasRetire ? args[4].Split(',', StringSplitOptions.RemoveEmptyEntries) : [];
try
{
var ships = ConceptSubjectRegistry.ParseManifest(await ReadManifestAsync(args[2]));
await using var scope = services.CreateAsyncScope();
var registry = scope.ServiceProvider.GetRequiredService<ConceptSubjectRegistry>();
var plan = args[0] == "apply"
? await registry.ApplyAsync(args[1], ships, retire, default)
: await registry.PlanAsync(args[1], ships, retire, default);
foreach (var entry in plan.Entries)
{
await output.WriteLineAsync($"{entry.Change.ToString().ToLowerInvariant()} {entry.Key}");
}
return Ok;
}
catch (ManifestRefusedException ex)
{
await output.WriteLineAsync($"refused {ex.Field} {ex.Code}");
return Refused;
}
}
// Reads at most one byte past the bound, so a wrong file is never read whole.
private static async Task<byte[]> ReadManifestAsync(string path)
{
try
{
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1, true);
var buffer = new byte[ConceptSubjectRegistry.MaxManifestBytes + 1];
var length = 0;
int read;
while (length < buffer.Length && (read = await stream.ReadAsync(buffer.AsMemory(length))) > 0)
{
length += read;
}
return buffer[..length];
}
// An empty or NUL path is an ArgumentException; a path the platform cannot name is NotSupported.
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException
or NotSupportedException or System.Security.SecurityException)
{
throw new ManifestRefusedException("manifest", "Unreadable");
}
}
}

View file

@ -0,0 +1,13 @@
using System.Text.RegularExpressions;
namespace Bifrost.Web.Features.ConceptVotes;
// The one grammar for a concept collection or key. \z, not $: $ admits a trailing line feed.
public static class ConceptKey
{
public const int MaxLength = 80;
private static readonly Regex Shape = new(@"^[a-z0-9-]{1,80}\z", RegexOptions.CultureInvariant);
public static bool IsValid(string? value) => value is not null && Shape.IsMatch(value);
}

View file

@ -0,0 +1,352 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
namespace Bifrost.Web.Features.ConceptVotes;
public enum SubjectChange
{
Add,
Rename,
Unchanged,
Revive,
Absent,
Retire,
Retired
}
public sealed record ManifestShip(string Key, string Title);
public sealed record SubjectPlanEntry(SubjectChange Change, string Key, string Title);
public sealed record SubjectPlan(string Collection, IReadOnlyList<SubjectPlanEntry> Entries);
// A refusal names the field and a code, never the offending value.
public sealed class ManifestRefusedException(string field, string code)
: Exception($"{field}: {code}")
{
public string Field { get; } = field;
public string Code { get; } = code;
}
// The only writer of ConceptSubject: decides which concept keys exist, their titles and their retirement.
public sealed class ConceptSubjectRegistry(
BifrostDbContext dbContext,
AuditTrailService auditTrail,
TimeProvider timeProvider)
{
public const int MaxTitleLength = 240;
// Defaulted bounds (a Mill manifest is tens of ships): the whole file is refused above either.
public const int MaxManifestBytes = 1024 * 1024;
public const int MaxShips = 1000;
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
// The Mill manifest is JSON from outside the Verse. Only ships[].id and ships[].name are read.
// This is the one input boundary: every malformed input leaves as a ManifestRefusedException.
public static IReadOnlyList<ManifestShip> ParseManifest(ReadOnlyMemory<byte> utf8)
{
using var document = ParseDocument(utf8);
var ships = ShipsOf(document.RootElement);
if (ships.ValueKind != JsonValueKind.Array)
{
throw new ManifestRefusedException("ships", "Missing");
}
if (ships.GetArrayLength() > MaxShips)
{
throw new ManifestRefusedException("ships", "TooMany");
}
var result = new List<ManifestShip>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var ship in ships.EnumerateArray())
{
var at = $"ships[{result.Count}]";
var key = ReadString(ship, "id", at);
var title = ReadString(ship, "name", at);
if (!ConceptKey.IsValid(key))
{
throw new ManifestRefusedException($"{at}.id", "InvalidKey");
}
if (!seen.Add(key))
{
throw new ManifestRefusedException($"{at}.id", "Duplicate");
}
if (!IsValidTitle(title))
{
throw new ManifestRefusedException($"{at}.name", "InvalidTitle");
}
if (title.Length > MaxTitleLength)
{
throw new ManifestRefusedException($"{at}.name", "TooLong");
}
result.Add(new ManifestShip(key, title));
}
return result;
}
private static JsonElement ShipsOf(JsonElement root)
{
if (root.ValueKind != JsonValueKind.Object)
{
return default;
}
return root.TryGetProperty("ships", out var ships) ? ships : default;
}
// A displayed title: no control, format (bidi, zero-width, BOM), line or paragraph separator
// character, no leading or trailing whitespace, and at least one letter or digit. This is the
// character-class rule of the mind schema's Title type, kept here as the one copy in Bifrost.
private static bool IsValidTitle(string title)
{
if (title.Length == 0 || title != title.Trim())
{
return false;
}
var hasLetterOrDigit = false;
foreach (var rune in title.EnumerateRunes())
{
if (Rune.GetUnicodeCategory(rune) is UnicodeCategory.Control or UnicodeCategory.Format
or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator)
{
return false;
}
hasLetterOrDigit |= Rune.IsLetterOrDigit(rune);
}
return hasLetterOrDigit;
}
private static JsonDocument ParseDocument(ReadOnlyMemory<byte> utf8)
{
if (utf8.Length > MaxManifestBytes)
{
throw new ManifestRefusedException("manifest", "TooLarge");
}
try
{
StrictUtf8.GetCharCount(utf8.Span);
}
catch (DecoderFallbackException)
{
throw new ManifestRefusedException("manifest", "NotUtf8");
}
try
{
RejectDuplicateProperties(utf8.Span);
return JsonDocument.Parse(utf8);
}
catch (JsonException)
{
throw new ManifestRefusedException("manifest", "NotJson");
}
catch (InvalidOperationException)
{
// A property name holding a lone surrogate escape: not text.
throw new ManifestRefusedException("manifest", "BadText");
}
}
// System.Text.Json (net8.0) keeps the last of two equal property names; a manifest that says
// one thing twice is refused instead.
private static void RejectDuplicateProperties(ReadOnlySpan<byte> utf8)
{
var reader = new Utf8JsonReader(utf8);
var scopes = new Stack<HashSet<string>?>();
while (reader.Read())
{
switch (reader.TokenType)
{
case JsonTokenType.StartObject:
scopes.Push(new HashSet<string>(StringComparer.Ordinal));
break;
case JsonTokenType.StartArray:
scopes.Push(null);
break;
case JsonTokenType.EndObject:
case JsonTokenType.EndArray:
scopes.Pop();
break;
case JsonTokenType.PropertyName:
if (!scopes.Peek()!.Add(reader.GetString()!))
{
throw new ManifestRefusedException("manifest", "DuplicateProperty");
}
break;
}
}
}
public async Task<SubjectPlan> PlanAsync(
string collection,
IReadOnlyList<ManifestShip> ships,
IReadOnlyCollection<string> retire,
CancellationToken cancellationToken)
{
if (!ConceptKey.IsValid(collection))
{
throw new ManifestRefusedException("collection", "InvalidKey");
}
var named = new HashSet<string>(StringComparer.Ordinal);
foreach (var key in retire)
{
if (!ConceptKey.IsValid(key))
{
throw new ManifestRefusedException("retire", "InvalidKey");
}
named.Add(key);
}
var registered = await dbContext.ConceptSubjects.AsNoTracking()
.Where(x => x.Collection == collection)
.ToDictionaryAsync(x => x.Key, StringComparer.Ordinal, cancellationToken);
var entries = new List<SubjectPlanEntry>();
foreach (var ship in ships)
{
var change = !registered.TryGetValue(ship.Key, out var row)
? SubjectChange.Add
: row.RetiredAtUtc is not null
? SubjectChange.Revive
: row.Title != ship.Title ? SubjectChange.Rename : SubjectChange.Unchanged;
entries.Add(new SubjectPlanEntry(change, ship.Key, ship.Title));
}
var inManifest = ships.Select(x => x.Key).ToHashSet(StringComparer.Ordinal);
var absent = registered.Values
.Where(x => x.RetiredAtUtc is null && !inManifest.Contains(x.Key))
.OrderBy(x => x.Key, StringComparer.Ordinal)
.ToList();
foreach (var subject in absent)
{
entries.Add(new SubjectPlanEntry(
named.Remove(subject.Key) ? SubjectChange.Retire : SubjectChange.Absent,
subject.Key,
subject.Title));
}
// A named key that an earlier run already retired is done: a rerun converges.
foreach (var key in named.OrderBy(x => x, StringComparer.Ordinal).ToList())
{
if (registered.TryGetValue(key, out var done) && done.RetiredAtUtc is not null && !inManifest.Contains(key))
{
entries.Add(new SubjectPlanEntry(SubjectChange.Retired, key, done.Title));
named.Remove(key);
}
}
// Whatever is still named is not a registered key missing from the manifest.
if (named.Count > 0)
{
throw new ManifestRefusedException("retire", "NotAbsent");
}
return new SubjectPlan(collection, entries);
}
public async Task<SubjectPlan> ApplyAsync(
string collection,
IReadOnlyList<ManifestShip> ships,
IReadOnlyCollection<string> retire,
CancellationToken cancellationToken)
{
var plan = await PlanAsync(collection, ships, retire, cancellationToken);
var rows = await dbContext.ConceptSubjects
.Where(x => x.Collection == collection)
.ToDictionaryAsync(x => x.Key, StringComparer.Ordinal, cancellationToken);
foreach (var entry in plan.Entries)
{
if (entry.Change is SubjectChange.Unchanged or SubjectChange.Absent or SubjectChange.Retired)
{
continue;
}
ConceptSubject subject;
if (entry.Change == SubjectChange.Add)
{
subject = new ConceptSubject
{
Collection = collection,
Key = entry.Key,
Title = entry.Title,
RegisteredAtUtc = timeProvider.GetUtcNow()
};
dbContext.ConceptSubjects.Add(subject);
}
else
{
subject = rows[entry.Key];
if (entry.Change == SubjectChange.Retire)
{
subject.RetiredAtUtc = timeProvider.GetUtcNow();
}
else
{
subject.Title = entry.Title;
subject.RetiredAtUtc = null;
}
}
// The audit write saves the subject change in the same SaveChanges.
await auditTrail.RecordAsync(
null,
nameof(ConceptSubject),
subject.Id,
entry.Change.ToString(),
$"actor=host-command collection={collection} key={entry.Key}",
cancellationToken);
}
return plan;
}
private static string ReadString(JsonElement ship, string name, string at)
{
if (ship.ValueKind != JsonValueKind.Object)
{
throw new ManifestRefusedException($"{at}.{name}", "Missing");
}
if (!ship.TryGetProperty(name, out var value))
{
throw new ManifestRefusedException($"{at}.{name}", "Missing");
}
if (value.ValueKind != JsonValueKind.String)
{
throw new ManifestRefusedException($"{at}.{name}", "NotString");
}
try
{
return value.GetString()!;
}
catch (InvalidOperationException)
{
// A lone surrogate escape: not text.
throw new ManifestRefusedException($"{at}.{name}", "BadText");
}
}
}

View file

@ -0,0 +1,68 @@
using Bifrost.Web.Configuration;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
namespace Bifrost.Web.Features.ConceptVotes;
// The public tally export and the one cross-origin policy in Bifrost. It owns nothing: counts are derived by
// ConceptAppraisalService, origins come from ConceptVoteOptions.
public static class ConceptTallyExport
{
public const string CorsPolicy = "ConceptTallies";
private const string RoutePrefix = "/public/concepts";
// GET only, no credentials, and required by the export route alone: every other route carries no CORS
// metadata and gets no CORS headers.
public static IServiceCollection AddConceptTallyCors(this IServiceCollection services)
{
services.AddCors();
services
.AddOptions<CorsOptions>()
.Configure<IOptions<ConceptVoteOptions>>((cors, conceptVotes) =>
cors.AddPolicy(
CorsPolicy,
policy => policy.WithOrigins(conceptVotes.Value.AllowedOrigins).WithMethods("GET")));
return services;
}
// Replaces app.UseCors(): the one place the origin decision is made. Every response under the export route
// (200, 404, 304, preflight, refusal) varies by Origin, appended to any Vary already present, because the CORS
// headers differ by request Origin and a URL-keyed cache must not serve one variant to another. ASP.NET Core
// adds Vary: Origin itself only for policies of more than one origin; this policy has one, and a repeated token is harmless.
public static IApplicationBuilder UseConceptTallyCors(this IApplicationBuilder app)
{
app.Use((context, next) =>
{
if (context.Request.Path.StartsWithSegments(RoutePrefix))
{
context.Response.OnStarting(() =>
{
context.Response.Headers.Append(HeaderNames.Vary, "Origin");
return Task.CompletedTask;
});
}
return next(context);
});
return app.UseCors();
}
// Counts only, per live subject: ConceptTally carries no voter key, account id or network tag.
public static IEndpointConventionBuilder MapConceptTallyExport(this IEndpointRouteBuilder app) =>
app.MapGet(RoutePrefix + "/{collection}/tallies", async (
string collection,
ConceptAppraisalService appraisals,
HttpResponse response,
CancellationToken cancellationToken) =>
{
var tallies = await appraisals.GetTalliesAsync(collection, cancellationToken);
if (tallies.Count == 0)
{
return Results.NotFound();
}
response.Headers.CacheControl = "public, max-age=60";
return Results.Json(tallies);
}).AllowAnonymous().RequireCors(CorsPolicy);
}

View file

@ -0,0 +1,29 @@
using System.Net;
using Bifrost.Web.Configuration;
using Bifrost.Web.Features.Shared;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.ConceptVotes;
// The one owner of the vote budgets. Cast is spent by every cast and retract; mint is spent additionally by a cast that
// is about to mint a device cookie. A denial says why and how long to wait, and writes nothing. The partition is the
// account when the voter is signed in, otherwise the network prefix (NetworkPrefix.Of) of the address the
// forwarded-headers middleware left on the connection. No header and no client input ever becomes a key. Accounts
// have a table of their own, so a flood of anonymous sources cannot crowd a signed-in voter out of it.
public sealed class ConceptVoteRateLimits(IOptions<ConceptVoteOptions> options, TimeProvider clock)
{
private readonly ConceptVoteOptions opts = options.Value;
private readonly FixedWindowBudget castAccounts = new(options.Value.CastPermits, options.Value.CastWindow, options.Value.MaxTrackedKeys, clock);
private readonly FixedWindowBudget castNetworks = new(options.Value.CastPermits, options.Value.CastWindow, options.Value.MaxTrackedKeys, clock);
private readonly FixedWindowBudget mint = new(options.Value.MintPermits, options.Value.MintWindow, options.Value.MaxTrackedKeys, clock);
public BudgetDenial? DenyCast(IPAddress? client, VoterIdentity? known) =>
known?.UserAccountId is { } account
? castAccounts.Spend(account.ToString("N"))
: castNetworks.Spend(NetKey(client));
public BudgetDenial? DenyMint(IPAddress? client) => mint.Spend(NetKey(client));
private string NetKey(IPAddress? client) =>
client is null ? "unknown" : NetworkPrefix.Of(client, opts.IPv4PrefixLength, opts.IPv6PrefixLength);
}

View file

@ -0,0 +1,45 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
namespace Bifrost.Web.Features.ConceptVotes;
public static class NetworkPrefix
{
public const int IPv4Bits = 32;
public const int IPv6Bits = 128;
public static bool IsValidLength(int length, int addressBits) => length >= 0 && length <= addressBits;
// The only prefix computation: IPv4-mapped IPv6 folds to IPv4, then the address is masked. Returns CIDR text.
public static string Of(IPAddress address, int ipv4Length, int ipv6Length)
{
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
var bytes = address.GetAddressBytes();
var length = bytes.Length == 4 ? ipv4Length : ipv6Length;
if (!IsValidLength(length, bytes.Length * 8))
{
throw new ArgumentOutOfRangeException(nameof(ipv4Length), "Prefix length is outside the address family.");
}
for (var i = 0; i < bytes.Length; i++)
{
var kept = Math.Clamp(length - i * 8, 0, 8);
bytes[i] &= (byte)(0xFF << (8 - kept));
}
return new IPAddress(bytes) + "/" + length;
}
// Lowercase hex HMAC-SHA256 over the prefix text. Never reversible to an address; audit and purge only.
public static string Tag(IPAddress address, int ipv4Length, int ipv6Length, string secret)
{
var prefix = Of(address, ipv4Length, ipv6Length);
var mac = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(prefix));
return Convert.ToHexString(mac).ToLowerInvariant();
}
}

View file

@ -0,0 +1,28 @@
using System.Text.RegularExpressions;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
namespace Bifrost.Web.Features.ConceptVotes;
public sealed record VoterIdentity(string Key, VoterClass Class, Guid? UserAccountId)
{
public const int MaxKeyLength = 120;
private static readonly Regex DeviceIdShape = new(@"^[A-Za-z0-9_-]{1,100}\z", RegexOptions.CultureInvariant);
// The one place a voter key and class are decided. Never reads vote weight.
public static VoterIdentity? For(CurrentBifrostActor actor, string? deviceId)
{
if (actor.IsAuthenticated && actor.UserAccount is { } account)
{
return new VoterIdentity(
"account:" + account.Id.ToString("N"),
actor.IsActiveMember ? VoterClass.Member : VoterClass.Account,
account.Id);
}
return deviceId is not null && DeviceIdShape.IsMatch(deviceId)
? new VoterIdentity("device:" + deviceId, VoterClass.Anonymous, null)
: null;
}
}

View file

@ -0,0 +1,55 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.DataProtection;
namespace Bifrost.Web.Features.ConceptVotes;
// The only reader and writer of the bifrost_voter cookie. It supplies a device id and nothing else: VoterIdentity.For
// decides the voter key and class. The cookie holds a protected random id, never a count, class or account.
public sealed class VoterKeyResolver(IDataProtectionProvider dataProtection)
{
public const string CookieName = "bifrost_voter";
public const string CookiePath = "/mill";
public static readonly TimeSpan CookieLifetime = TimeSpan.FromDays(365);
private const string Purpose = "Bifrost.ConceptVotes.DeviceToken.v1";
private readonly IDataProtector protector = dataProtection.CreateProtector(Purpose);
// The device id, or null when there is no cookie or it does not unprotect under this key ring and purpose
// (tampered, truncated, a bare id, or minted elsewhere). Nothing about a refused value is kept or reported.
public string? TryRead(HttpContext context)
{
if (!context.Request.Cookies.TryGetValue(CookieName, out var token) || string.IsNullOrEmpty(token))
{
return null;
}
try
{
return protector.Unprotect(token);
}
catch (CryptographicException)
{
return null;
}
}
// Sets a fresh device cookie on this response and returns its device id. Called only from a cast.
public string Mint(HttpContext context)
{
var deviceId = Guid.NewGuid().ToString("N");
context.Response.Cookies.Append(
CookieName,
protector.Protect(deviceId),
new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
Path = CookiePath,
MaxAge = CookieLifetime,
IsEssential = true
});
return deviceId;
}
}

View file

@ -1,27 +1,39 @@
using System.Net;
using System.Net.Http.Json;
using System.Collections.Concurrent;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Heimdall;
/// <summary>
/// Heimdall sign-in. Start issues an attempt that lives only in a Data Protection-protected cookie in the
/// browser that began it. The browser that Heimdall authenticated presents the completion code from its
/// URL fragment; Bifrost redeems it at Heimdall itself and signs in only when that browser holds the
/// attempt's cookie and the redeemed return target names the same attempt.
/// </summary>
public sealed class HeimdallAuthService(
HttpClient httpClient,
HeimdallAuthAttemptStore attemptStore,
HeimdallTokenVerifier tokenVerifier,
BifrostDbContext dbContext,
BifrostIdentityService bifrostIdentityService,
IOptions<HeimdallOptions> heimdallOptions,
IDataProtectionProvider dataProtectionProvider,
TimeProvider timeProvider)
{
public const string ErrorRedirect = "/?auth=heimdall-error";
public const string AppSecretHeader = "x-heimdall-app-secret";
private static readonly HashSet<string> AllowedProviders = new(StringComparer.OrdinalIgnoreCase)
{
"discord",
@ -29,16 +41,29 @@ public sealed class HeimdallAuthService(
"twitch"
};
private readonly IDataProtector _protector = dataProtectionProvider.CreateProtector("Bifrost.Heimdall.Attempt.v1");
/// <summary>Points the client at Heimdall and, when the app has a secret, identifies Bifrost as the app.</summary>
public static void ConfigureClient(HttpClient client, HeimdallOptions options)
{
client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/'));
if (!string.IsNullOrWhiteSpace(options.AppSecret))
{
client.DefaultRequestHeaders.Add(AppSecretHeader, options.AppSecret);
}
}
public async Task<HeimdallAuthStartResult> StartAsync(
string provider,
string callbackBaseUrl,
string returnUrl,
LocalReturnPath returnPath,
bool requireMemberAccess,
IEnumerable<KeyValuePair<string, string>> requestCookies,
CancellationToken cancellationToken)
{
if (!AllowedProviders.Contains(provider))
{
throw new HeimdallAuthException($"Unsupported Heimdall provider '{provider}'.");
throw new HeimdallAuthException("Unsupported Heimdall provider.");
}
var options = heimdallOptions.Value;
@ -50,14 +75,13 @@ public sealed class HeimdallAuthService(
var entitlementPolicy = requireMemberAccess
? BuildMemberAccessPolicy(provider, options)
: null;
var attemptId = Guid.NewGuid().ToString("N");
var callbackUrl = new Uri(new Uri(callbackBaseUrl.TrimEnd('/') + "/", UriKind.Absolute), "auth/heimdall/callback");
var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
var request = new HeimdallStartRequest(
options.AppSlug,
"sign_in",
ToAttemptReturnUrl(callbackBaseUrl, attemptId, returnUrl),
new HeimdallHandoff("backend_callback", attemptId, callbackUrl.ToString()),
ToAttemptReturnUrl(callbackBaseUrl, nonce),
new HeimdallHandoff("browser_completion"),
entitlementPolicy);
var response = await httpClient.PostAsJsonAsync(
@ -67,8 +91,7 @@ public sealed class HeimdallAuthService(
if (!response.IsSuccessStatusCode)
{
var detail = await response.Content.ReadAsStringAsync(cancellationToken);
throw new HeimdallAuthException($"Heimdall rejected {provider} auth start: {detail}");
throw new HeimdallAuthException("Heimdall rejected the auth start.");
}
var payload = await response.Content.ReadFromJsonAsync<HeimdallStartResponse>(cancellationToken);
@ -77,59 +100,174 @@ public sealed class HeimdallAuthService(
throw new HeimdallAuthException("Heimdall auth start returned no authorization URL.");
}
attemptStore.Create(attemptId, returnUrl);
attemptStore.SetRequiresMemberAccess(attemptId, requireMemberAccess);
return new HeimdallAuthStartResult(new Uri(payload.AuthorizationUrl, UriKind.Absolute), attemptId);
var attempt = new HeimdallAttempt(
nonce,
provider,
requireMemberAccess,
returnPath,
timeProvider.GetUtcNow() + HeimdallAttemptCookie.Lifetime);
return new HeimdallAuthStartResult(
new Uri(payload.AuthorizationUrl, UriKind.Absolute), ChooseSlot(requestCookies), Protect(attempt));
}
public void StoreBackendCallback(HeimdallBackendCallbackPayload payload)
/// <summary>
/// The slot the new attempt's cookie takes: the first slot that holds no live attempt, else the one whose
/// attempt expires first. Parallel starts carry the same request cookies, so they choose the same slot and
/// overwrite each other; the Cookie header holds at most <see cref="HeimdallAttemptCookie.SlotNames"/> attempt
/// cookies however many starts a browser makes.
/// </summary>
private string ChooseSlot(IEnumerable<KeyValuePair<string, string>> requestCookies)
{
if (!string.Equals(payload.AppSlug, heimdallOptions.Value.AppSlug, StringComparison.OrdinalIgnoreCase))
var cookies = requestCookies.ToLookup(x => x.Key, x => x.Value, StringComparer.Ordinal);
string? oldest = null;
var oldestExpiry = DateTimeOffset.MaxValue;
foreach (var name in HeimdallAttemptCookie.SlotNames)
{
throw new HeimdallAuthException("Heimdall callback targeted a different app.");
var live = Read(cookies[name].FirstOrDefault());
if (live is null)
{
return name;
}
if (live.ExpiresAt < oldestExpiry)
{
oldest = name;
oldestExpiry = live.ExpiresAt;
}
}
if (string.IsNullOrWhiteSpace(payload.AttemptId))
{
throw new HeimdallAuthException("Heimdall callback omitted attempt id.");
}
attemptStore.Complete(payload.AttemptId, payload);
return oldest!;
}
public async Task<HeimdallCompletedSignIn> CompleteAttemptAsync(
string attemptId,
/// <summary>
/// The slot cookie that holds the attempt for this nonce: its name and value, or null when the browser holds
/// none (never started here, expired, or overwritten by a later start).
/// </summary>
public (string Name, string Value)? FindAttemptCookie(IEnumerable<KeyValuePair<string, string>> requestCookies, string? nonce)
{
foreach (var (name, value) in requestCookies)
{
if (HeimdallAttemptCookie.SlotNames.Contains(name) && Unprotect(value, nonce) is not null)
{
return (name, value);
}
}
return null;
}
public async Task<HeimdallCompletedSignIn> CompleteAsync(
string callbackBaseUrl,
string? nonce,
string? cookieValue,
string? code,
CancellationToken cancellationToken)
{
var attempt = attemptStore.TryTake(attemptId)
?? throw new HeimdallAuthPendingException("Heimdall has not delivered this auth attempt yet.");
var payload = attempt.Payload
?? throw new HeimdallAuthPendingException("Heimdall has not delivered this auth attempt yet.");
if (!payload.IsSuccessful)
var attempt = Unprotect(cookieValue, nonce)
?? throw new HeimdallAuthException("Heimdall completion has no attempt for this browser.");
if (string.IsNullOrWhiteSpace(code))
{
throw new HeimdallAuthException(payload.ErrorDescription ?? payload.Error ?? "Heimdall auth did not succeed.");
throw new HeimdallAuthException("Heimdall completion carried no code.");
}
if (attempt.RequiresMemberAccess && !payload.HasMemberAccess)
var redeemed = await RedeemAsync(code, cancellationToken);
if (!string.Equals(redeemed.ReturnTo, ToAttemptReturnUrl(callbackBaseUrl, attempt.Nonce), StringComparison.Ordinal))
{
throw new HeimdallAuthException("Heimdall completion belongs to another attempt.");
}
if (string.IsNullOrWhiteSpace(redeemed.AccessToken))
{
throw new HeimdallAuthException("Heimdall completion carried no access token.");
}
var identity = await tokenVerifier.VerifyAsync(redeemed.AccessToken, redeemed.Account?.Id, cancellationToken);
if (attempt.RequiresMemberAccess && !identity.HasMemberAccess)
{
throw new HeimdallAuthException("Heimdall did not grant Bifrost member access.");
}
var userAccount = await UpsertUserAccountAsync(payload, attempt.RequiresMemberAccess, cancellationToken);
var userAccount = await UpsertUserAccountAsync(
identity,
attempt.Provider,
attempt.RequiresMemberAccess,
cancellationToken);
return new HeimdallCompletedSignIn(
bifrostIdentityService.BuildPrincipal(userAccount, payload.Provider ?? "heimdall"),
string.IsNullOrWhiteSpace(attempt.ReturnUrl) ? "/App" : attempt.ReturnUrl);
bifrostIdentityService.BuildPrincipal(userAccount, attempt.Provider),
attempt.ReturnPath);
}
private async Task<HeimdallRedeemResponse> RedeemAsync(string code, CancellationToken cancellationToken)
{
try
{
using var response = await httpClient.PostAsJsonAsync(
$"/v1/apps/{Uri.EscapeDataString(heimdallOptions.Value.AppSlug)}/auth-completions/redeem",
new HeimdallRedeemRequest(code),
cancellationToken);
if (response.StatusCode != HttpStatusCode.Created)
{
throw new HeimdallAuthException("Heimdall did not redeem the completion code.");
}
return await response.Content.ReadFromJsonAsync<HeimdallRedeemResponse>(cancellationToken)
?? throw new HeimdallAuthException("Heimdall redeem returned no body.");
}
catch (Exception error) when (error is not HeimdallAuthException && !cancellationToken.IsCancellationRequested)
{
throw new HeimdallAuthException("Heimdall redeem could not be read.");
}
}
private string Protect(HeimdallAttempt attempt) =>
_protector.Protect(JsonSerializer.Serialize(new AttemptWire(
attempt.Nonce, attempt.Provider, attempt.RequiresMemberAccess, attempt.ReturnPath.Value, attempt.ExpiresAt)));
/// <summary>The live attempt a cookie value protects when it belongs to this nonce, else null.</summary>
private HeimdallAttempt? Unprotect(string? cookieValue, string? nonce) =>
Read(cookieValue) is { } attempt && string.Equals(attempt.Nonce, nonce, StringComparison.Ordinal) ? attempt : null;
/// <summary>The live attempt a cookie value protects, else null.</summary>
private HeimdallAttempt? Read(string? cookieValue)
{
if (string.IsNullOrEmpty(cookieValue))
{
return null;
}
try
{
var wire = JsonSerializer.Deserialize<AttemptWire>(_protector.Unprotect(cookieValue));
if (wire is null || wire.ExpiresAt <= timeProvider.GetUtcNow())
{
return null;
}
return new HeimdallAttempt(
wire.Nonce, wire.Provider, wire.RequiresMemberAccess, LocalReturnPath.Parse(wire.ReturnPath), wire.ExpiresAt);
}
catch (Exception error) when (error is CryptographicException or JsonException or FormatException)
{
return null;
}
}
private sealed record AttemptWire(
string Nonce,
string Provider,
bool RequiresMemberAccess,
string ReturnPath,
DateTimeOffset ExpiresAt);
private async Task<UserAccount> UpsertUserAccountAsync(
HeimdallBackendCallbackPayload payload,
HeimdallVerifiedIdentity identity,
string provider,
bool grantMemberAccess,
CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
var accountId = payload.Account?.Id ?? throw new HeimdallAuthException("Heimdall response omitted account id.");
var displayName = payload.Account.DisplayName ?? accountId;
var accountId = identity.AccountId;
var displayName = identity.DisplayName ?? accountId;
var userAccount = await dbContext.UserAccounts
.Include(x => x.MemberProfile)
@ -150,7 +288,7 @@ public sealed class HeimdallAuthService(
MemberProfile = new MemberProfile
{
Nickname = displayName,
Headline = $"Verified through Heimdall {payload.Provider}",
Headline = $"Verified through Heimdall {provider}",
UpdatedAtUtc = now
},
Membership = new Bifrost.Web.Domain.Membership
@ -160,7 +298,7 @@ public sealed class HeimdallAuthService(
ApprovedAtUtc = grantMemberAccess ? now : null,
Notes = grantMemberAccess
? "Activated by Heimdall member access claim"
: $"Identity verified through Heimdall {payload.Provider}"
: $"Identity verified through Heimdall {provider}"
}
};
dbContext.UserAccounts.Add(userAccount);
@ -173,7 +311,7 @@ public sealed class HeimdallAuthService(
{
UserAccountId = userAccount.Id,
Nickname = displayName,
Headline = $"Verified through Heimdall {payload.Provider}",
Headline = $"Verified through Heimdall {provider}",
UpdatedAtUtc = now
};
userAccount.Membership ??= new Bifrost.Web.Domain.Membership
@ -248,61 +386,29 @@ public sealed class HeimdallAuthService(
return BuildPatreonPolicy(options);
}
throw new HeimdallAuthException($"Heimdall provider '{provider}' does not support Bifrost member access.");
throw new HeimdallAuthException("Heimdall provider does not support Bifrost member access.");
}
private static string ToAttemptReturnUrl(string callbackBaseUrl, string attemptId, string returnUrl)
private static string ToAttemptReturnUrl(string callbackBaseUrl, string nonce)
{
var baseUri = new Uri(callbackBaseUrl.TrimEnd('/') + "/", UriKind.Absolute);
var target = string.IsNullOrWhiteSpace(returnUrl) ? "/App" : returnUrl;
var waitUri = new Uri(baseUri, "auth/heimdall/wait");
return $"{waitUri}?attemptId={Uri.EscapeDataString(attemptId)}&returnTo={Uri.EscapeDataString(target)}";
return $"{new Uri(baseUri, "auth/heimdall/complete")}?attempt={Uri.EscapeDataString(nonce)}";
}
}
public class HeimdallAuthException(string message) : InvalidOperationException(message);
public sealed class HeimdallAuthPendingException(string message) : HeimdallAuthException(message);
public sealed record HeimdallAuthStartResult(Uri AuthorizationUrl, string CookieName, string CookieValue);
public sealed record HeimdallAuthStartResult(Uri AuthorizationUrl, string AttemptId);
public sealed record HeimdallCompletedSignIn(ClaimsPrincipal Principal, LocalReturnPath ReturnPath);
public sealed record HeimdallCompletedSignIn(ClaimsPrincipal Principal, string RedirectUri);
public sealed class HeimdallAuthAttemptStore
{
private readonly ConcurrentDictionary<string, HeimdallAuthAttempt> _attempts = new(StringComparer.Ordinal);
public void Create(string attemptId, string returnUrl)
{
_attempts[attemptId] = new HeimdallAuthAttempt(returnUrl, null);
}
public void SetRequiresMemberAccess(string attemptId, bool requiresMemberAccess)
{
_attempts.AddOrUpdate(
attemptId,
_ => new HeimdallAuthAttempt("/App", null, requiresMemberAccess),
(_, existing) => existing with { RequiresMemberAccess = requiresMemberAccess });
}
public void Complete(string attemptId, HeimdallBackendCallbackPayload payload)
{
_attempts.AddOrUpdate(
attemptId,
_ => new HeimdallAuthAttempt(payload.ReturnTo ?? "/App", payload),
(_, existing) => existing with { Payload = payload });
}
public HeimdallAuthAttempt? TryTake(string attemptId)
{
return _attempts.TryRemove(attemptId, out var attempt) ? attempt : null;
}
}
public sealed record HeimdallAuthAttempt(
string ReturnUrl,
HeimdallBackendCallbackPayload? Payload,
bool RequiresMemberAccess = false);
/// <summary>An attempt Start issued. It exists only inside its protected cookie.</summary>
public sealed record HeimdallAttempt(
string Nonce,
string Provider,
bool RequiresMemberAccess,
LocalReturnPath ReturnPath,
DateTimeOffset ExpiresAt);
public sealed record HeimdallStartRequest(
[property: JsonPropertyName("appSlug")] string AppSlug,
@ -313,11 +419,7 @@ public sealed record HeimdallStartRequest(
[property: JsonPropertyName("entitlementPolicy")] HeimdallEntitlementPolicy? EntitlementPolicy);
public sealed record HeimdallHandoff(
[property: JsonPropertyName("kind")] string Kind,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[property: JsonPropertyName("attemptId")] string? AttemptId = null,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[property: JsonPropertyName("callbackUrl")] string? CallbackUrl = null);
[property: JsonPropertyName("kind")] string Kind);
public sealed record HeimdallEntitlementPolicy(
[property: JsonPropertyName("kind")] string Kind,
@ -331,31 +433,45 @@ public sealed record HeimdallEntitlementPolicy(
public sealed record HeimdallStartResponse(
[property: JsonPropertyName("authorizationUrl")] string AuthorizationUrl);
public sealed record HeimdallBackendCallbackPayload(
[property: JsonPropertyName("attemptId")] string AttemptId,
[property: JsonPropertyName("appSlug")] string? AppSlug,
[property: JsonPropertyName("status")] string? Status,
[property: JsonPropertyName("provider")] string? Provider,
[property: JsonPropertyName("returnTo")] string? ReturnTo,
[property: JsonPropertyName("error")] string? Error,
[property: JsonPropertyName("errorDescription")] string? ErrorDescription,
[property: JsonPropertyName("account")] HeimdallAccount? Account,
[property: JsonPropertyName("sharedCapabilities")] string[]? SharedCapabilities,
[property: JsonPropertyName("entitlements")] HeimdallEntitlements? Entitlements)
{
public bool IsSuccessful => string.Equals(Status, "success", StringComparison.OrdinalIgnoreCase);
public sealed record HeimdallRedeemRequest(
[property: JsonPropertyName("completionCode")] string CompletionCode);
public bool HasMemberAccess =>
SharedCapabilities?.Any(capability =>
string.Equals(capability, "member_access", StringComparison.OrdinalIgnoreCase) ||
string.Equals(capability, "app_access", StringComparison.OrdinalIgnoreCase)) == true ||
Entitlements?.Facts?.Any(fact =>
string.Equals(fact, "entitlement.app_access", StringComparison.OrdinalIgnoreCase)) == true;
}
public sealed record HeimdallRedeemResponse(
[property: JsonPropertyName("returnTo")] string? ReturnTo,
[property: JsonPropertyName("accessToken")] string? AccessToken,
[property: JsonPropertyName("account")] HeimdallAccount? Account);
public sealed record HeimdallAccount(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("displayName")] string? DisplayName);
[property: JsonPropertyName("id")] string Id);
public sealed record HeimdallEntitlements(
[property: JsonPropertyName("facts")] string[]? Facts);
/// <summary>
/// The attempt cookies: a ring of <see cref="SlotNames"/> fixed names, so the Cookie header a browser sends to
/// /auth/heimdall is bounded by construction (a worst-case attempt is about 2.4 KB; nginx's line limit is 8 KB).
/// A start takes a free slot or overwrites the one expiring first; an overwritten attempt finds no cookie and
/// is refused. The attempt's nonce lives inside the protected value, never in the cookie name.
/// </summary>
public static class HeimdallAttemptCookie
{
public const string Prefix = "bifrost_heimdall_";
public const int SlotCount = 2;
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(10);
public static readonly IReadOnlyList<string> SlotNames =
Enumerable.Range(0, SlotCount).Select(x => Prefix + x).ToArray();
public static CookieOptions Options() => new()
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
Path = "/auth/heimdall",
MaxAge = Lifetime
};
/// <summary>
/// The options a deletion carries: only the attempt cookie's Path, which a cookie is matched on. It carries no
/// Max-Age, which would take precedence over the expiry Delete writes and leave the emptied cookie alive.
/// </summary>
public static CookieOptions DeleteOptions() => new() { Path = "/auth/heimdall" };
}

View file

@ -0,0 +1,46 @@
namespace Bifrost.Web.Features.Heimdall;
/// <summary>
/// The page Heimdall's "Return to the app" lands on. The completion code arrives in the URL fragment, which
/// the server never sees, so the page's script reads it, drops it from the address bar, and posts it with
/// the attempt to the same path. The page is static: it never contains a code.
/// </summary>
public static class HeimdallCompletePage
{
public const string Html = """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<title>Signing in - Bifrost</title>
</head>
<body>
<p>Finishing sign-in...</p>
<noscript><p>JavaScript is needed to finish sign-in.</p></noscript>
<form id="complete" method="post" action="/auth/heimdall/complete">
<input type="hidden" name="attempt" />
<input type="hidden" name="code" />
</form>
<script>
(function () {
var fragment = new URLSearchParams(location.hash.slice(1));
var attempt = new URLSearchParams(location.search).get("attempt");
var code = fragment.get("heimdall_completion_code");
var failed = fragment.get("heimdall_status") === "error";
history.replaceState(null, "", location.pathname + location.search);
if (failed || !code || !attempt) {
location.replace("/?auth=heimdall-error");
return;
}
var form = document.getElementById("complete");
form.elements.attempt.value = attempt;
form.elements.code.value = code;
form.submit();
})();
</script>
</body>
</html>
""";
}

View file

@ -0,0 +1,284 @@
using System.Text.Json;
using Bifrost.Web.Configuration;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Options;
using Org.BouncyCastle.Math.EC.Rfc8032;
namespace Bifrost.Web.Features.Heimdall;
/// <summary>
/// The identity a Heimdall-signed access token asserts. Everything here comes from verified claims.
/// </summary>
public sealed record HeimdallVerifiedIdentity(string AccountId, string? DisplayName, bool HasMemberAccess);
/// <summary>
/// Verifies Heimdall's access tokens (EdDSA) against Heimdall's published JWKS. Heimdall is the issuer
/// at <see cref="HeimdallOptions.BaseUrl"/>; the audience is this app's slug. Failures name a rule and
/// never echo the token or any of its contents. One instance serves the process: it owns the key cache.
/// </summary>
public sealed class HeimdallTokenVerifier(
IHttpClientFactory httpClientFactory,
IOptions<HeimdallOptions> heimdallOptions,
TimeProvider timeProvider)
{
public const string TokenType = "heimdall_access";
public const string ClientName = "heimdall-jwks";
internal static readonly TimeSpan ClockSkew = TimeSpan.FromSeconds(60);
internal static readonly TimeSpan KeyRefreshInterval = TimeSpan.FromSeconds(30);
internal static readonly TimeSpan KeyMaxAge = TimeSpan.FromHours(1);
private static readonly string[] MemberCapabilities = ["member_access", "app_access"];
private readonly SemaphoreSlim _fetchGate = new(1, 1);
private volatile KeySnapshot _keys = new(new Dictionary<string, byte[]>(StringComparer.Ordinal), DateTimeOffset.MinValue);
private DateTimeOffset _attemptedAt = DateTimeOffset.MinValue;
public async Task<HeimdallVerifiedIdentity> VerifyAsync(
string token,
string? payloadAccountId,
CancellationToken cancellationToken)
{
var parts = token.Split('.');
if (parts.Length != 3)
{
throw Refuse("token is not a compact JWS");
}
var headerBytes = Decode(parts[0]);
var payloadBytes = Decode(parts[1]);
var signature = Decode(parts[2]);
using var header = ParseJson(headerBytes, "token header is not JSON");
var kid = ReadKeyId(header.RootElement);
var publicKey = await FindKeyAsync(kid, cancellationToken);
if (publicKey is null)
{
throw Refuse("token key is not published");
}
var signingInput = System.Text.Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}");
if (signature.Length != Ed25519.SignatureSize ||
!Ed25519.Verify(signature, 0, publicKey, 0, signingInput, 0, signingInput.Length))
{
throw Refuse("token signature is invalid");
}
using var payload = ParseJson(payloadBytes, "token payload is not JSON");
return CheckClaims(payload.RootElement, heimdallOptions.Value, payloadAccountId);
}
private sealed record KeySnapshot(Dictionary<string, byte[]> Keys, DateTimeOffset FetchedAt);
private static byte[] Decode(string segment)
{
try
{
return WebEncoders.Base64UrlDecode(segment);
}
catch (FormatException)
{
throw Refuse("token is not base64url");
}
}
private static JsonDocument ParseJson(byte[] bytes, string rule)
{
try
{
return JsonDocument.Parse(bytes);
}
catch (JsonException)
{
throw Refuse(rule);
}
}
private static string ReadKeyId(JsonElement header)
{
if (header.ValueKind != JsonValueKind.Object || StringClaim(header, "alg") != "EdDSA")
{
throw Refuse("token algorithm is not EdDSA");
}
var kid = StringClaim(header, "kid");
if (string.IsNullOrEmpty(kid))
{
throw Refuse("token has no key id");
}
return kid;
}
private HeimdallVerifiedIdentity CheckClaims(JsonElement claims, HeimdallOptions options, string? payloadAccountId)
{
if (claims.ValueKind != JsonValueKind.Object)
{
throw Refuse("token payload is not an object");
}
if (StringClaim(claims, "iss") != options.BaseUrl.TrimEnd('/'))
{
throw Refuse("token issuer is not Heimdall");
}
if (StringClaim(claims, "aud") != options.AppSlug)
{
throw Refuse("token audience is not this app");
}
if (StringClaim(claims, "typ") != TokenType)
{
throw Refuse("token type is not an access token");
}
if (!claims.TryGetProperty("app", out var app) ||
app.ValueKind != JsonValueKind.Object ||
StringClaim(app, "slug") != options.AppSlug)
{
throw Refuse("token app is not this app");
}
var now = timeProvider.GetUtcNow();
if (NumericDate(claims, "nbf") is not { } notBefore || now < notBefore - ClockSkew)
{
throw Refuse("token is not yet valid");
}
if (NumericDate(claims, "exp") is not { } expires || now - ClockSkew >= expires)
{
throw Refuse("token has expired");
}
var subject = StringClaim(claims, "sub");
if (string.IsNullOrEmpty(subject) ||
subject != StringClaim(claims, "account_id") ||
subject != payloadAccountId)
{
throw Refuse("token subject does not match the account");
}
var capabilities = new List<string>();
if (claims.TryGetProperty("capabilities", out var list) && list.ValueKind == JsonValueKind.Array)
{
foreach (var capability in list.EnumerateArray())
{
if (capability.ValueKind == JsonValueKind.String)
{
capabilities.Add(capability.GetString()!);
}
}
}
return new HeimdallVerifiedIdentity(
subject,
StringClaim(claims, "display_name"),
capabilities.Any(capability => MemberCapabilities.Contains(capability, StringComparer.OrdinalIgnoreCase)));
}
/// <summary>The JWKS client waits a short, bounded time: a slow fetch must not hold sign-ins for long.</summary>
public static void ConfigureClient(HttpClient client, HeimdallOptions options)
{
client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/'));
client.Timeout = TimeSpan.FromSeconds(10);
}
private async Task<byte[]?> FindKeyAsync(string kid, CancellationToken cancellationToken)
{
if (TryFreshKey(kid, out var cached))
{
return cached;
}
await _fetchGate.WaitAsync(cancellationToken);
try
{
var now = timeProvider.GetUtcNow();
if (now - _attemptedAt >= KeyRefreshInterval)
{
_attemptedAt = now;
_keys = new KeySnapshot(await FetchKeysAsync(cancellationToken), now);
}
return _keys.Keys.GetValueOrDefault(kid);
}
finally
{
_fetchGate.Release();
}
}
private bool TryFreshKey(string kid, out byte[]? key)
{
var snapshot = _keys;
key = null;
return timeProvider.GetUtcNow() - snapshot.FetchedAt < KeyMaxAge && snapshot.Keys.TryGetValue(kid, out key);
}
private async Task<Dictionary<string, byte[]>> FetchKeysAsync(CancellationToken cancellationToken)
{
try
{
return await ReadKeysAsync(cancellationToken);
}
catch (Exception) when (!cancellationToken.IsCancellationRequested)
{
throw Refuse("Heimdall keys could not be fetched");
}
}
private async Task<Dictionary<string, byte[]>> ReadKeysAsync(CancellationToken cancellationToken)
{
var httpClient = httpClientFactory.CreateClient(ClientName);
using var response = await httpClient.GetAsync("/.well-known/jwks.json", cancellationToken);
response.EnsureSuccessStatusCode();
await using var body = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(body, cancellationToken: cancellationToken);
var keys = new Dictionary<string, byte[]>(StringComparer.Ordinal);
foreach (var jwk in document.RootElement.GetProperty("keys").EnumerateArray())
{
if (jwk.ValueKind != JsonValueKind.Object)
{
continue;
}
var id = StringClaim(jwk, "kid");
var x = StringClaim(jwk, "x");
if (StringClaim(jwk, "kty") != "OKP" ||
StringClaim(jwk, "crv") != "Ed25519" ||
string.IsNullOrEmpty(id) ||
x is null)
{
continue;
}
var key = WebEncoders.Base64UrlDecode(x);
if (key.Length == Ed25519.PublicKeySize)
{
keys[id] = key;
}
}
return keys;
}
private static string? StringClaim(JsonElement element, string name) =>
element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
private static DateTimeOffset? NumericDate(JsonElement element, string name)
{
if (!element.TryGetProperty(name, out var value) ||
value.ValueKind != JsonValueKind.Number ||
!value.TryGetInt64(out var seconds) ||
seconds is < 0 or > 253402300799)
{
return null;
}
return DateTimeOffset.FromUnixTimeSeconds(seconds);
}
private static HeimdallAuthException Refuse(string rule) => new($"Heimdall token refused: {rule}.");
}

View file

@ -0,0 +1,72 @@
using Bifrost.Web.Configuration;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace Bifrost.Web.Features.Oidc;
/// <summary>
/// Makes the provider's one client match configuration at startup, creating it or updating it in place, and removes
/// every other application (with its authorizations and tokens), so a client left by an older configuration cannot get codes.
/// </summary>
public sealed class OidcClientSeeder(
IServiceScopeFactory scopeFactory,
OidcIssuerOptions options,
OidcKeyMaterial material,
IOptions<OpenIddictServerOptions> serverOptions) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
// Reading the server options now runs their configuration, so a bad issuer stops startup rather than the first request.
_ = serverOptions.Value;
await using var scope = scopeFactory.CreateAsyncScope();
var manager = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
var mas = options.Clients.Mas;
var descriptor = new OpenIddictApplicationDescriptor
{
ClientId = mas.ClientId,
ClientSecret = material.ClientSecret,
ClientType = ClientTypes.Confidential,
DisplayName = "Matrix Authentication Service",
RedirectUris = { new Uri(mas.RedirectUri, UriKind.Absolute) },
Permissions =
{
Permissions.Endpoints.Authorization,
Permissions.Endpoints.Token,
Permissions.GrantTypes.AuthorizationCode,
Permissions.ResponseTypes.Code,
Permissions.Scopes.Profile,
Permissions.Prefixes.Scope + Scopes.OpenId
}
};
var existing = await manager.FindByClientIdAsync(mas.ClientId, cancellationToken);
if (existing is null)
{
await manager.CreateAsync(descriptor, cancellationToken);
}
else
{
await manager.UpdateAsync(existing, descriptor, cancellationToken);
}
var applications = new List<object>();
await foreach (var application in manager.ListAsync(cancellationToken: cancellationToken))
{
applications.Add(application);
}
foreach (var application in applications)
{
if (!string.Equals(await manager.GetClientIdAsync(application, cancellationToken), mas.ClientId, StringComparison.Ordinal))
{
await manager.DeleteAsync(application, cancellationToken);
}
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View file

@ -0,0 +1,142 @@
using System.Security.Claims;
using Microsoft.AspNetCore;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace Bifrost.Web.Features.Oidc;
/// <summary>
/// The decisions Bifrost owns at the OIDC provider: who the chat user is (sub = UserAccount.Id,
/// preferred_username = NormalizedBifrostIdentity) and whether they may get a session (membership Active).
/// OpenIddict does the protocol; these endpoints read the cookie session and the same actor facts the /App policy reads.
/// </summary>
public static class OidcEndpoints
{
public const string NotActiveDescription = "The account is not an active GameCult member.";
public const string NoIdentityDescription = "The account has no Bifrost identity.";
private const string OpenIddictScheme = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme;
private const string IdentityAuthenticationType = "Bifrost.Oidc";
public static void MapBifrostOidc(this IEndpointRouteBuilder app)
{
app.MapMethods(OidcServiceCollectionExtensions.AuthorizationPath, ["GET", "POST"], (Delegate)AuthorizeAsync).AllowAnonymous();
app.MapPost(OidcServiceCollectionExtensions.TokenPath, (Delegate)ExchangeAsync).AllowAnonymous();
app.MapMethods(OidcServiceCollectionExtensions.UserInfoPath, ["GET", "POST"], (Delegate)UserInfoAsync).AllowAnonymous();
}
private static async Task<IResult> AuthorizeAsync(HttpContext http, ICurrentBifrostActorAccessor actors)
{
var request = http.GetOpenIddictServerRequest()
?? throw new InvalidOperationException("The OpenIddict request could not be retrieved.");
if (http.User.Identity?.IsAuthenticated != true)
{
// OpenIddict has already replaced the request with a short request_uri, so this URL fits a sign-in
// return path (LocalReturnPath.MaxLength); a test pins that, because Parse would otherwise fall back to /App.
return Results.Challenge(
new AuthenticationProperties { RedirectUri = http.Request.PathBase + http.Request.Path + http.Request.QueryString },
[CookieAuthenticationDefaults.AuthenticationScheme]);
}
var actor = await actors.GetAsync(http.RequestAborted);
if (actor.UserAccount is null || !actor.IsActiveMember)
{
return Deny(Errors.AccessDenied, NotActiveDescription);
}
var account = actor.UserAccount;
if (string.IsNullOrWhiteSpace(account.NormalizedBifrostIdentity))
{
return Deny(Errors.AccessDenied, NoIdentityDescription);
}
var identity = new ClaimsIdentity(IdentityAuthenticationType, Claims.Name, Claims.Role);
identity.SetClaim(Claims.Subject, account.Id.ToString("D"));
identity.SetClaim(Claims.PreferredUsername, account.NormalizedBifrostIdentity);
identity.SetClaim(
Claims.Name,
string.IsNullOrWhiteSpace(account.DisplayName) ? account.BifrostIdentity : account.DisplayName);
var principal = new ClaimsPrincipal(identity);
principal.SetScopes(request.GetScopes());
principal.SetDestinations(claim => claim.Type switch
{
Claims.Subject => [Destinations.AccessToken, Destinations.IdentityToken],
Claims.PreferredUsername or Claims.Name when principal.HasScope(Scopes.Profile) =>
[Destinations.AccessToken, Destinations.IdentityToken],
_ => [Destinations.AccessToken]
});
return Results.SignIn(principal, properties: null, OpenIddictScheme);
}
private static async Task<IResult> ExchangeAsync(HttpContext http, BifrostDbContext db)
{
// Only the authorization code grant is enabled, so OpenIddict has refused every other grant already.
// The code's own principal carries the claims chosen at authorize time; membership is read again here,
// because the code outlives the authorize decision (a member suspended since gets no tokens).
var result = await http.AuthenticateAsync(OpenIddictScheme);
var subject = result.Principal?.GetClaim(Claims.Subject);
if (!await IsStillActiveMemberAsync(db, subject, http.RequestAborted))
{
return Deny(Errors.InvalidGrant, NotActiveDescription);
}
return Results.SignIn(result.Principal!, properties: null, OpenIddictScheme);
}
private static async Task<IResult> UserInfoAsync(HttpContext http, BifrostDbContext db)
{
var result = await http.AuthenticateAsync(OpenIddictScheme);
if (!result.Succeeded || result.Principal is null)
{
return Results.Challenge(authenticationSchemes: [OpenIddictScheme]);
}
// An access token outlives the authorize decision too: a member suspended since is no longer answered.
if (!await IsStillActiveMemberAsync(db, result.Principal.GetClaim(Claims.Subject), http.RequestAborted))
{
return Deny(Errors.InsufficientAccess, NotActiveDescription);
}
var claims = new Dictionary<string, object?> { [Claims.Subject] = result.Principal.GetClaim(Claims.Subject) };
if (result.Principal.HasScope(Scopes.Profile))
{
claims[Claims.PreferredUsername] = result.Principal.GetClaim(Claims.PreferredUsername);
claims[Claims.Name] = result.Principal.GetClaim(Claims.Name);
}
return Results.Json(claims);
}
/// <summary>The one reading of "this subject is still an Active member", shared by the token and userinfo endpoints and the logout reconciler.</summary>
internal static async Task<bool> IsStillActiveMemberAsync(BifrostDbContext db, string? subject, CancellationToken cancellationToken)
{
if (!Guid.TryParse(subject, out var accountId))
{
return false;
}
var account = await db.UserAccounts.AsNoTracking()
.Include(x => x.Membership)
.SingleOrDefaultAsync(x => x.Id == accountId, cancellationToken);
return new CurrentBifrostActor(true, account).IsActiveMember;
}
private static IResult Deny(string error, string description) =>
Results.Forbid(
new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = error,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = description
}),
[OpenIddictScheme]);
}

View file

@ -0,0 +1,140 @@
using System.Security.Cryptography;
using Bifrost.Web.Configuration;
using Microsoft.IdentityModel.Tokens;
namespace Bifrost.Web.Features.Oidc;
/// <summary>A configuration fault in the OIDC provider. The message names the setting, never its value.</summary>
public sealed class OidcConfigurationException(string message) : InvalidOperationException(message);
/// <summary>
/// The secrets the OIDC provider runs on, read once from the files <see cref="OidcIssuerOptions"/> names. Any
/// unreadable or wrong-shaped file stops startup; there is no fallback key.
/// </summary>
public sealed class OidcKeyMaterial : IDisposable
{
private const string NistP256Oid = "1.2.840.10045.3.1.7";
private const int EncryptionKeyBytes = 32;
private readonly ECDsa _signingEcdsa;
private OidcKeyMaterial(ECDsa signingEcdsa, SecurityKey signingKey, SecurityKey encryptionKey, string clientSecret)
{
_signingEcdsa = signingEcdsa;
SigningKey = signingKey;
EncryptionKey = encryptionKey;
ClientSecret = clientSecret;
}
public SecurityKey SigningKey { get; }
public SecurityKey EncryptionKey { get; }
public string ClientSecret { get; }
public static OidcKeyMaterial Load(OidcIssuerOptions options)
{
var mas = options.Clients.Mas;
Require(options.SigningKeyPath, "Bifrost:Oidc:SigningKeyPath");
Require(options.EncryptionKeyPath, "Bifrost:Oidc:EncryptionKeyPath");
Require(mas.ClientId, "Bifrost:Oidc:Clients:Mas:ClientId");
Require(mas.ClientSecretPath, "Bifrost:Oidc:Clients:Mas:ClientSecretPath");
Require(mas.RedirectUri, "Bifrost:Oidc:Clients:Mas:RedirectUri");
// A rooted path such as "/callback" parses as an absolute file URI on Linux, so the scheme is checked too.
if (!Uri.TryCreate(mas.RedirectUri, UriKind.Absolute, out var redirect) ||
redirect.Scheme is not ("https" or "http"))
{
throw new OidcConfigurationException("Bifrost:Oidc:Clients:Mas:RedirectUri must be an absolute http(s) URI.");
}
if (!string.IsNullOrWhiteSpace(mas.BackchannelLogoutUri) &&
(!Uri.TryCreate(mas.BackchannelLogoutUri, UriKind.Absolute, out var logout) ||
logout.Scheme is not ("https" or "http")))
{
throw new OidcConfigurationException("Bifrost:Oidc:Clients:Mas:BackchannelLogoutUri must be an absolute http(s) URI.");
}
var ecdsa = LoadSigningKey(options.SigningKeyPath);
try
{
var signingKey = new ECDsaSecurityKey(ecdsa)
{
KeyId = Base64UrlEncoder.Encode(SHA256.HashData(ecdsa.ExportSubjectPublicKeyInfo()))
};
return new OidcKeyMaterial(
ecdsa,
signingKey,
new SymmetricSecurityKey(LoadEncryptionKey(options.EncryptionKeyPath)),
ReadFile(mas.ClientSecretPath, "Bifrost:Oidc:Clients:Mas:ClientSecretPath").Trim());
}
catch
{
ecdsa.Dispose();
throw;
}
}
public void Dispose() => _signingEcdsa.Dispose();
private static ECDsa LoadSigningKey(string path)
{
const string field = "Bifrost:Oidc:SigningKeyPath";
var pem = ReadFile(path, field);
var ecdsa = ECDsa.Create();
try
{
ecdsa.ImportFromPem(pem);
if (ecdsa.ExportParameters(false).Curve.Oid.Value != NistP256Oid)
{
throw new CryptographicException();
}
// ImportFromPem accepts a public key; a signing key needs the private half.
ecdsa.ExportParameters(true);
return ecdsa;
}
catch (Exception error) when (error is CryptographicException or ArgumentException)
{
ecdsa.Dispose();
throw new OidcConfigurationException($"{field} must name a PEM file holding an EC P-256 private key.");
}
}
private static byte[] LoadEncryptionKey(string path)
{
const string field = "Bifrost:Oidc:EncryptionKeyPath";
try
{
var key = Convert.FromBase64String(ReadFile(path, field).Trim());
if (key.Length == EncryptionKeyBytes)
{
return key;
}
}
catch (FormatException)
{
}
throw new OidcConfigurationException($"{field} must name a file holding {EncryptionKeyBytes} bytes as base64.");
}
private static string ReadFile(string path, string field)
{
try
{
return File.ReadAllText(path);
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
{
throw new OidcConfigurationException($"{field} must name a readable file.");
}
}
private static void Require(string value, string field)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new OidcConfigurationException($"{field} is required when Bifrost:Oidc is configured.");
}
}
}

View file

@ -0,0 +1,220 @@
using System.Net;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace Bifrost.Web.Features.Oidc;
/// <summary>
/// Ends the chat sessions of a subject who is no longer an Active member. Each tick it lists the subjects holding valid
/// authorizations of the configured client; for each one the shared active-member reading refuses, it POSTs one signed
/// OIDC back-channel logout token to MAS and, only after a 2xx, revokes that subject's authorizations. The
/// authorizations table is the queue: nothing is stored, so a restart, a MAS outage or a later-set URI resumes from it.
/// Per-subject backoff lives in memory only. One subject's unexpected exception is logged and skipped, never aborting the
/// pass. It never writes membership.
/// </summary>
public sealed class OidcLogoutReconciler(
IServiceScopeFactory scopeFactory,
OidcIssuerOptions options,
OidcKeyMaterial material,
IOptions<OpenIddictServerOptions> serverOptions,
IHttpClientFactory httpClients,
TimeProvider time,
ILogger<OidcLogoutReconciler> logger) : BackgroundService
{
public const string HttpClientName = "bifrost-oidc-logout";
public const string LogoutEvent = "http://schemas.openid.net/event/backchannel-logout";
public const string LogoutTokenType = "logout+jwt";
public static readonly TimeSpan Interval = TimeSpan.FromSeconds(30);
public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10);
public static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(2);
public static readonly TimeSpan MaxBackoff = TimeSpan.FromMinutes(15);
private readonly Dictionary<string, Backoff> _backoff = new(StringComparer.Ordinal);
private sealed record Backoff(int Failures, DateTimeOffset NotBefore);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ReconcileOnceAsync(stoppingToken);
await Task.Delay(Interval, time, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception error)
{
// The type only: messages can carry connection strings or URLs.
logger.LogError("Back-channel logout reconcile failed: {ErrorType}", error.GetType().Name);
try
{
await Task.Delay(Interval, time, stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
}
}
}
/// <summary>One reconcile pass. Subjects are independent: one that cannot be delivered never blocks another.</summary>
public async Task ReconcileOnceAsync(CancellationToken cancellationToken)
{
var subjects = await ListSubjectsAsync(cancellationToken);
foreach (var gone in _backoff.Keys.Except(subjects).ToArray())
{
_backoff.Remove(gone);
}
foreach (var subject in subjects)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await ReconcileSubjectAsync(subject, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception error)
{
// One subject's fault never stops the pass: its authorizations stay valid and the next tick retries it.
// The subject id and the exception type only: messages can carry connection strings or URLs.
logger.LogError("Back-channel logout for subject {Subject} failed: {ErrorType}", subject, error.GetType().Name);
}
}
}
private async Task<HashSet<string>> ListSubjectsAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var applications = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();
var authorizations = scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>();
var subjects = new HashSet<string>(StringComparer.Ordinal);
var application = await applications.FindByClientIdAsync(options.Clients.Mas.ClientId, cancellationToken);
if (application is null || await applications.GetIdAsync(application, cancellationToken) is not { } applicationId)
{
return subjects;
}
await foreach (var authorization in authorizations.FindByApplicationIdAsync(applicationId, cancellationToken))
{
if (await authorizations.HasStatusAsync(authorization, Statuses.Valid, cancellationToken) &&
await authorizations.GetSubjectAsync(authorization, cancellationToken) is { Length: > 0 } subject)
{
subjects.Add(subject);
}
}
return subjects;
}
private async Task ReconcileSubjectAsync(string subject, CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
if (await OidcEndpoints.IsStillActiveMemberAsync(db, subject, cancellationToken))
{
_backoff.Remove(subject);
return;
}
// A subject that recovered is forgotten at once; one still owed a logout waits out its backoff.
_backoff.TryGetValue(subject, out var previous);
if (previous is not null && time.GetUtcNow() < previous.NotBefore)
{
return;
}
var failures = (previous?.Failures ?? 0) + 1;
var status = await SendLogoutAsync(subject, cancellationToken);
if (status is { } code && (int)code is >= 200 and < 300)
{
var authorizations = scope.ServiceProvider.GetRequiredService<IOpenIddictAuthorizationManager>();
await foreach (var authorization in authorizations.FindBySubjectAsync(subject, cancellationToken))
{
if (await authorizations.HasStatusAsync(authorization, Statuses.Valid, cancellationToken))
{
await authorizations.TryRevokeAsync(authorization, cancellationToken);
}
}
_backoff.Remove(subject);
return;
}
// 400 (token refused) and 404 (unknown provider) are configuration faults: retrying sooner cannot fix them.
var fault = status is HttpStatusCode.BadRequest or HttpStatusCode.NotFound;
var delay = fault ? MaxBackoff : BackoffFor(failures);
_backoff[subject] = new Backoff(failures, time.GetUtcNow() + delay);
logger.LogWarning(
"Back-channel logout to MAS failed with {Status}; retrying in {Delay}",
status is { } s ? (int)s : "no response",
delay);
}
internal static TimeSpan BackoffFor(int failures)
{
var seconds = Interval.TotalSeconds * Math.Pow(2, Math.Min(failures, 16) - 1);
return TimeSpan.FromSeconds(Math.Min(seconds, MaxBackoff.TotalSeconds));
}
/// <summary>The status MAS answered, or null on a network fault or timeout. The response body is never read.</summary>
private async Task<HttpStatusCode?> SendLogoutAsync(string subject, CancellationToken cancellationToken)
{
var token = BuildLogoutToken(subject);
using var request = new HttpRequestMessage(HttpMethod.Post, options.Clients.Mas.BackchannelLogoutUri)
{
Content = new FormUrlEncodedContent([new KeyValuePair<string, string>("logout_token", token)])
};
try
{
using var response = await httpClients.CreateClient(HttpClientName)
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
return response.StatusCode;
}
catch (Exception error) when (error is HttpRequestException or TaskCanceledException && !cancellationToken.IsCancellationRequested)
{
return null;
}
}
/// <summary>
/// The OIDC back-channel logout token MAS verifies like an id_token: iss and aud as the id_token carries them, exp
/// and iat both present, an events member, and neither nonce nor sid (MAS finds the session by sub).
/// </summary>
private string BuildLogoutToken(string subject)
{
var now = time.GetUtcNow();
var descriptor = new SecurityTokenDescriptor
{
TokenType = LogoutTokenType,
Issuer = serverOptions.Value.Issuer!.AbsoluteUri,
Audience = options.Clients.Mas.ClientId,
IssuedAt = now.UtcDateTime,
Expires = now.UtcDateTime + TokenLifetime,
SigningCredentials = new SigningCredentials(material.SigningKey, SecurityAlgorithms.EcdsaSha256),
Claims = new Dictionary<string, object>
{
[Claims.Subject] = Guid.TryParse(subject, out var id) ? id.ToString("D") : subject,
[Claims.JwtId] = Guid.NewGuid().ToString("D"),
["events"] = new Dictionary<string, object> { [LogoutEvent] = new Dictionary<string, object>() }
}
};
return new JsonWebTokenHandler().CreateToken(descriptor);
}
}

View file

@ -0,0 +1,81 @@
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
using OpenIddict.Server;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace Bifrost.Web.Features.Oidc;
public static class OidcServiceCollectionExtensions
{
public const string AuthorizationPath = "/connect/authorize";
public const string TokenPath = "/connect/token";
public const string UserInfoPath = "/connect/userinfo";
/// <summary>How long a parked authorize request (OpenIddict's request token) can wait for the user to sign in.</summary>
private static readonly TimeSpan ParkedRequestLifetime = TimeSpan.FromMinutes(10);
/// <summary>
/// Registers Bifrost as an OIDC provider whose issuer is <see cref="BifrostHostOptions.PublicBaseUrl"/>: authorization-code flow with
/// PKCE, ES256 id_tokens, scopes openid and profile, one configured client. Keys come from files, never from
/// generated development keys. The authorize request is parked by OpenIddict's own request caching, so the
/// sign-in return path carries only a short request_uri.
/// </summary>
public static IServiceCollection AddBifrostOidc(this IServiceCollection services, OidcIssuerOptions options)
{
var material = OidcKeyMaterial.Load(options);
services.AddSingleton(material);
services.AddSingleton(options);
services.AddOpenIddict()
.AddCore(core => core.UseEntityFrameworkCore().UseDbContext<BifrostDbContext>())
.AddServer(server =>
{
server.SetAuthorizationEndpointUris(AuthorizationPath)
.SetTokenEndpointUris(TokenPath)
.SetUserInfoEndpointUris(UserInfoPath);
server.AllowAuthorizationCodeFlow().RequireProofKeyForCodeExchange();
server.RegisterScopes(Scopes.OpenId, Scopes.Profile);
server.RegisterClaims(Claims.Subject, Claims.PreferredUsername, Claims.Name);
server.EnableAuthorizationRequestCaching();
server.Configure(opts =>
{
opts.ClientAuthenticationMethods.Clear();
opts.ClientAuthenticationMethods.Add(ClientAuthenticationMethods.ClientSecretBasic);
opts.RequestTokenLifetime = ParkedRequestLifetime;
});
server.AddSigningKey(material.SigningKey);
server.AddEncryptionKey(material.EncryptionKey);
server.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()
.EnableTokenEndpointPassthrough()
.EnableUserInfoEndpointPassthrough();
});
// The issuer is the public host, read from the bound options so it has one source.
services.AddOptions<OpenIddictServerOptions>().Configure<IOptions<BifrostHostOptions>>((server, host) =>
{
if (!Uri.TryCreate(host.Value.PublicBaseUrl, UriKind.Absolute, out var issuer))
{
throw new OidcConfigurationException("Host:PublicBaseUrl is required when Bifrost:Oidc is configured.");
}
server.Issuer = issuer;
});
services.AddHostedService<OidcClientSeeder>();
if (!string.IsNullOrWhiteSpace(options.Clients.Mas.BackchannelLogoutUri))
{
// MAS answers the logout POST itself: a redirect is a non-delivery, never followed to some other 2xx.
services.AddHttpClient(OidcLogoutReconciler.HttpClientName, client => client.Timeout = OidcLogoutReconciler.RequestTimeout)
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false });
services.AddHostedService<OidcLogoutReconciler>();
}
return services;
}
}

View file

@ -0,0 +1,131 @@
namespace Bifrost.Web.Features.Shared;
// Why a permit was refused and how long until the answer can change. TableFull is not the caller's own spending: the
// table holds only spent windows and has no room for a key it does not know.
public readonly record struct BudgetDenial(TimeSpan RetryAfter, bool TableFull);
// The one rate-limit mechanism: a fixed-window permit budget per caller-chosen partition key, timed on the injected
// clock's monotonic timestamps (never its wall time, which can step), so a clock adjustment cannot stretch or reset a
// window. Windows all have one length, so they end in the order they began: a list of entries in start order expires
// them from the front and the table never holds a key past its window.
// The table holds at most maxKeys keys. A new key arriving at the cap evicts the least recently used key that still
// has permits left (nginx limit_req_zone does the same); a key that has spent all its permits is never evicted, so a
// rotating caller can at most win back a forgotten key's partial budget and never a spent one, and nobody fresh is
// refused because the table is full. Only when every held key is spent is a new key refused, with the true wait.
// Every step is O(1): two linked lists, one by window start and one by last use among keys with permits left.
// System.Threading.RateLimiting is not used because its limiters time themselves on Stopwatch and timers, which a
// test cannot move.
public sealed class FixedWindowBudget
{
private readonly int permits;
private readonly TimeSpan window;
private readonly int maxKeys;
private readonly TimeProvider clock;
private readonly long origin;
private readonly Dictionary<string, Entry> entries = new(StringComparer.Ordinal);
private readonly LinkedList<Entry> byStart = new();
private readonly LinkedList<Entry> byUse = new();
private readonly object gate = new();
public FixedWindowBudget(int permits, TimeSpan window, int maxKeys, TimeProvider clock)
{
ArgumentOutOfRangeException.ThrowIfLessThan(permits, 1);
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(window, TimeSpan.Zero);
ArgumentOutOfRangeException.ThrowIfLessThan(maxKeys, 1);
this.permits = permits;
this.window = window;
this.maxKeys = maxKeys;
this.clock = clock;
origin = clock.GetTimestamp();
}
// How many keys are held now: the keys whose window is still open, never more than maxKeys.
public int TrackedKeys
{
get
{
lock (gate)
{
return entries.Count;
}
}
}
// Takes one permit for the key and returns null, or returns why it could not.
public BudgetDenial? Spend(string key)
{
lock (gate)
{
var now = clock.GetElapsedTime(origin);
while (byStart.First is { Value: var oldest } && oldest.Start + window <= now)
{
Remove(oldest);
}
if (entries.TryGetValue(key, out var entry))
{
if (entry.Count >= permits)
{
return new BudgetDenial(entry.Start + window - now, false);
}
entry.Count++;
if (entry.Count >= permits)
{
byUse.Remove(entry.UseNode!);
entry.UseNode = null;
}
else
{
byUse.Remove(entry.UseNode!);
byUse.AddLast(entry.UseNode!);
}
return null;
}
if (entries.Count >= maxKeys)
{
if (byUse.First is not { Value: var forgotten })
{
return new BudgetDenial(byStart.First!.Value.Start + window - now, true);
}
Remove(forgotten);
}
var added = new Entry(key, now);
entries[key] = added;
added.StartNode = byStart.AddLast(added);
if (added.Count < permits)
{
added.UseNode = byUse.AddLast(added);
}
return null;
}
}
private void Remove(Entry entry)
{
entries.Remove(entry.Key);
byStart.Remove(entry.StartNode!);
if (entry.UseNode is not null)
{
byUse.Remove(entry.UseNode);
}
}
private sealed class Entry(string key, TimeSpan start)
{
public string Key { get; } = key;
public TimeSpan Start { get; } = start;
public int Count { get; set; } = 1;
public LinkedListNode<Entry>? StartNode { get; set; }
public LinkedListNode<Entry>? UseNode { get; set; }
}
}

View file

@ -0,0 +1,34 @@
namespace Bifrost.Web.Features.Shared;
/// <summary>
/// A sign-in return target that stays on this site. The only way to make one is <see cref="Parse"/>, which
/// accepts a path of at most <see cref="MaxLength"/> characters that starts with a single slash and holds no
/// control character, and otherwise yields <see cref="Default"/>. Callers parse once at entry and carry the typed value from there.
/// </summary>
public sealed record LocalReturnPath
{
private LocalReturnPath(string value) => Value = value;
/// <summary>Bounds the path so the attempt cookie that carries it stays far below a browser's 4096-byte limit.</summary>
public const int MaxLength = 256;
public static LocalReturnPath Default { get; } = new("/App");
public string Value { get; }
public static LocalReturnPath Parse(string? candidate)
{
if (string.IsNullOrEmpty(candidate) ||
candidate.Length > MaxLength ||
candidate[0] != '/' ||
candidate.Any(char.IsControl) ||
(candidate.Length > 1 && (candidate[1] == '/' || candidate[1] == '\\')))
{
return Default;
}
return new LocalReturnPath(candidate);
}
public override string ToString() => Value;
}

View file

@ -1,4 +1,5 @@
using Bifrost.Web.Configuration;
using Bifrost.Web.Features.ConceptVotes;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Features.Shared;
@ -10,16 +11,45 @@ public sealed class StartupConfigurationValidator(
IOptions<GitHubAppOptions> gitHubAppOptions,
IOptions<HeimdallOptions> heimdallOptions,
IOptions<StripeOptions> stripeOptions,
IOptions<BifrostHostOptions> hostOptions)
IOptions<BifrostHostOptions> hostOptions,
IOptions<ConceptVoteOptions> conceptVoteOptions)
{
public void Validate()
{
if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing"))
var failures = new List<string>();
// Checked in every environment: a bad value here would otherwise pass startup and throw on every cast.
var conceptVotes = conceptVoteOptions.Value;
if (!NetworkPrefix.IsValidLength(conceptVotes.IPv4PrefixLength, NetworkPrefix.IPv4Bits))
{
return;
failures.Add($"ConceptVotes:IPv4PrefixLength must be between 0 and {NetworkPrefix.IPv4Bits}.");
}
var failures = new List<string>();
if (!NetworkPrefix.IsValidLength(conceptVotes.IPv6PrefixLength, NetworkPrefix.IPv6Bits))
{
failures.Add($"ConceptVotes:IPv6PrefixLength must be between 0 and {NetworkPrefix.IPv6Bits}.");
}
if (conceptVotes.CastPermits < 1 || conceptVotes.CastWindow <= TimeSpan.Zero)
{
failures.Add("ConceptVotes:CastPermits must be at least 1 and ConceptVotes:CastWindow must be positive.");
}
if (conceptVotes.MintPermits < 1 || conceptVotes.MintWindow <= TimeSpan.Zero)
{
failures.Add("ConceptVotes:MintPermits must be at least 1 and ConceptVotes:MintWindow must be positive.");
}
if (conceptVotes.MaxTrackedKeys < 1)
{
failures.Add("ConceptVotes:MaxTrackedKeys must be at least 1.");
}
if (hostEnvironment.IsDevelopment() || hostEnvironment.IsEnvironment("Testing"))
{
Refuse(failures);
return;
}
if (!gitHubOAuthOptions.Value.IsConfigured)
{
@ -66,10 +96,40 @@ public sealed class StartupConfigurationValidator(
failures.Add("Connection string 'Bifrost' must be configured.");
}
if (string.IsNullOrWhiteSpace(configuration["DataProtection:KeysPath"]))
{
failures.Add("DataProtection:KeysPath must be configured for production.");
}
if (string.IsNullOrWhiteSpace(conceptVotes.NetworkTagSecret) ||
conceptVotes.NetworkTagSecret.Length < ConceptVoteOptions.MinNetworkTagSecretLength)
{
failures.Add(
$"ConceptVotes:NetworkTagSecret must be configured with at least {ConceptVoteOptions.MinNetworkTagSecretLength} characters for production.");
}
if (!conceptVotes.AllowedOrigins.All(IsExactHttpsOrigin))
{
failures.Add("ConceptVotes:AllowedOrigins entries must each be exactly https://host with no path, wildcard or trailing slash.");
}
Refuse(failures);
}
private static void Refuse(List<string> failures)
{
if (failures.Count > 0)
{
throw new InvalidOperationException(
"Bifrost startup configuration is incomplete: " + string.Join(" ", failures));
}
}
// The authority form is the canonical spelling, so a wildcard, path, query, upper-case host or default port
// differs from it and is refused; user info is part of the authority, so it is refused on its own.
private static bool IsExactHttpsOrigin(string? origin) =>
Uri.TryCreate(origin, UriKind.Absolute, out var uri) &&
uri.Scheme == Uri.UriSchemeHttps &&
uri.UserInfo.Length == 0 &&
string.Equals(uri.GetLeftPart(UriPartial.Authority), origin, StringComparison.Ordinal);
}

View file

@ -0,0 +1,56 @@
@page "/mill/{collection}/{key}"
@model Bifrost.Web.Pages.Mill.VoteModel
@{
ViewData["Title"] = Model.Subject.Title;
var stance = Model.Subject.YourStance;
}
<section class="panel">
<a class="back-link" href="@Model.MillPageLink">Back to the Concept Mill</a>
<h1>@Model.Subject.Title</h1>
<dl class="mill-counts">
<dt>Everyone</dt>
<dd>@Model.Subject.Tally.Up up, @Model.Subject.Tally.Down down</dd>
<dt>Members</dt>
<dd>@Model.Subject.Tally.Members.Up up, @Model.Subject.Tally.Members.Down down</dd>
</dl>
<p>
@if (stance is null)
{
<text>You have not voted on this concept.</text>
}
else
{
<text>Your vote: @(stance == Bifrost.Web.Domain.ConceptStance.Up ? "up" : "down").</text>
}
</p>
<div class="mill-actions">
<form method="post" asp-page-handler="Up" asp-antiforgery="false" hx-boost="false">
<button class="button" type="submit">Up</button>
</form>
<form method="post" asp-page-handler="Down" asp-antiforgery="false" hx-boost="false">
<button class="button" type="submit">Down</button>
</form>
@if (stance is not null)
{
<form method="post" asp-page-handler="Retract" asp-antiforgery="false" hx-boost="false">
<button class="button ghost" type="submit">Retract</button>
</form>
}
</div>
@if (Model.SignedIn)
{
<p class="muted">Your vote is kept against your Bifrost account. No cookie is set.</p>
}
else
{
<p class="muted">
Voting saves one small cookie on this device. Its only purpose is to keep your one vote yours so you can
change or retract it; it lasts a year and carries nothing else.
</p>
}
</section>

View file

@ -0,0 +1,179 @@
using Bifrost.Web.Configuration;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Pages.Mill;
// The anonymous vote page. Counts and the visitor's own stance come from ConceptAppraisalService on every GET; the
// voter key is decided by VoterIdentity.For from the actor and the device id VoterKeyResolver reads.
[IgnoreAntiforgeryToken]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public sealed class VoteModel(
ICurrentBifrostActorAccessor actorAccessor,
ConceptAppraisalService appraisals,
VoterKeyResolver deviceCookie,
ConceptVoteRateLimits limits,
IOptions<ConceptVoteOptions> options,
IOptions<BifrostHostOptions> host) : PageModel
{
public ConceptSubjectView Subject { get; private set; } = null!;
public bool SignedIn { get; private set; }
public string MillPageLink => options.Value.MillPageUrl + "#" + Uri.EscapeDataString(Subject.Key);
// The single gate before any handler runs. A request that selects no handler (a POST with no or an unknown handler,
// any other method) is a client error, never a view over a null subject. A cast is accepted only from the site's own
// origin (CastRequestGuard); no antiforgery cookie or token exists on this flow.
public override async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next)
{
var method = context.HttpContext.Request.Method;
if (context.HandlerMethod is null)
{
context.Result = new StatusCodeResult(
HttpMethods.IsGet(method) || HttpMethods.IsHead(method) || HttpMethods.IsPost(method)
? StatusCodes.Status400BadRequest
: StatusCodes.Status405MethodNotAllowed);
return;
}
// The handler the request names must be the handler selected: GET ?handler=Up has no OnGetUp to run.
var named = context.HttpContext.Request.Query["handler"].ToString();
if (string.IsNullOrEmpty(named) != string.IsNullOrEmpty(context.HandlerMethod.Name))
{
context.Result = new StatusCodeResult(StatusCodes.Status400BadRequest);
return;
}
if (HttpMethods.IsPost(method) && !CastRequestGuard.Allows(context.HttpContext.Request, host.Value))
{
context.Result = new StatusCodeResult(StatusCodes.Status403Forbidden);
return;
}
await next();
}
public async Task<IActionResult> OnGetAsync(string collection, string key, CancellationToken cancellationToken)
{
var actor = await actorAccessor.GetAsync(cancellationToken);
var voter = ResolveVoter(actor);
SignedIn = voter?.UserAccountId is not null;
return await LoadAsync(collection, key, voter, cancellationToken) ? Page() : NotFound();
}
public Task<IActionResult> OnPostUpAsync(string collection, string key, CancellationToken cancellationToken) =>
CastAsync(collection, key, ConceptStance.Up, cancellationToken);
public Task<IActionResult> OnPostDownAsync(string collection, string key, CancellationToken cancellationToken) =>
CastAsync(collection, key, ConceptStance.Down, cancellationToken);
public async Task<IActionResult> OnPostRetractAsync(string collection, string key, CancellationToken cancellationToken)
{
var actor = await actorAccessor.GetAsync(cancellationToken);
var voter = ResolveVoter(actor);
if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null)
{
return NotFound();
}
if (Throttled(limits.DenyCast(HttpContext.Connection.RemoteIpAddress, voter), CastRefusal, voter) is { } throttled)
{
return throttled;
}
await appraisals.RetractAsync(collection, key, voter, cancellationToken);
return RedirectToPage(new { collection, key });
}
private async Task<IActionResult> CastAsync(
string collection,
string key,
ConceptStance stance,
CancellationToken cancellationToken)
{
var actor = await actorAccessor.GetAsync(cancellationToken);
var client = HttpContext.Connection.RemoteIpAddress;
var voter = ResolveVoter(actor);
if (await appraisals.GetSubjectAsync(collection, key, null, cancellationToken) is null)
{
return NotFound();
}
if (Throttled(limits.DenyCast(client, voter), CastRefusal, voter) is { } throttled)
{
return throttled;
}
if (voter is null)
{
if (Throttled(limits.DenyMint(client), MintRefusal, voter) is { } mintThrottled)
{
return mintThrottled;
}
voter = VoterIdentity.For(actor, deviceCookie.Mint(HttpContext));
}
var result = await appraisals.CastAsync(
collection, key, voter, stance, HttpContext.Connection.RemoteIpAddress, cancellationToken);
return result is CastResult.UnknownSubject or CastResult.RetiredSubject
? NotFound()
: RedirectToPage(new { collection, key });
}
// Accounts key to the account and never touch the cookie. An anonymous visitor keys to a device cookie that
// unprotects to a well-formed id; a cast without one mints it (CastAsync, after the mint budget), and no other path does.
private VoterIdentity? ResolveVoter(CurrentBifrostActor actor) =>
VoterIdentity.For(actor, null) ?? VoterIdentity.For(actor, deviceCookie.TryRead(HttpContext));
// A denied budget is a 429 that says when to retry, in words a voter can read; it writes no row, sets no cookie and
// repeats nothing of the request. The subject is checked before any budget is spent, so a request for a concept that
// does not exist costs the sender nothing and the table nothing. A refused new device is told it can sign in, which
// has a budget of its own. A table full of spent windows is not the visitor's doing and says so.
private const string CastRefusal = "You are voting too fast. Please wait a little before voting again.";
private const string MintRefusal =
"Too many new voters have voted from your network recently. You can sign in to vote now, or try again later.";
private const string BusyRefusal = "Voting is very busy right now. Please try again in a little while.";
// Accounts have their own table, so a visitor who is not signed in can still vote by signing in while a table is full.
private const string BusyRefusalSignIn =
"Voting is very busy right now. You can sign in to vote now, or try again in a little while.";
private IActionResult? Throttled(BudgetDenial? denial, string spentRefusal, VoterIdentity? voter)
{
if (denial is not { } refused)
{
return null;
}
var busy = voter?.UserAccountId is null ? BusyRefusalSignIn : BusyRefusal;
var refusal = refused.TableFull ? busy : spentRefusal;
Response.Headers.RetryAfter = ((int)Math.Ceiling(refused.RetryAfter.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture);
return new ContentResult
{
StatusCode = StatusCodes.Status429TooManyRequests,
ContentType = "text/plain; charset=utf-8",
Content = refusal
};
}
private async Task<bool> LoadAsync(string collection, string key, VoterIdentity? voter, CancellationToken cancellationToken)
{
var view = await appraisals.GetSubjectAsync(collection, key, voter, cancellationToken);
if (view is null)
{
return false;
}
Subject = view;
return true;
}
}

View file

@ -6,10 +6,12 @@ using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.Bridge;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.GitHub;
using Bifrost.Web.Features.Heimdall;
using Bifrost.Web.Features.Membership;
using Bifrost.Web.Features.Motions;
using Bifrost.Web.Features.Oidc;
using Bifrost.Web.Features.Patronage;
using Bifrost.Web.Features.Shared;
using Microsoft.AspNetCore.Authentication;
@ -17,9 +19,28 @@ using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OAuth;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.HttpLogging;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using System.Net;
// Host command: registers Mill concept subjects with configuration and the database only; no web app, no migrations.
if (args.Length > 0 && args[0] == "concepts")
{
var host = Host.CreateApplicationBuilder([]);
host.Services.AddDbContext<BifrostDbContext>(options => options.UseNpgsql(BifrostConnectionString(host.Configuration)));
host.Services.AddSingleton(TimeProvider.System);
host.Services.AddScoped<AuditTrailService>();
host.Services.AddScoped<ConceptSubjectRegistry>();
using var commandHost = host.Build();
Environment.ExitCode = await ConceptCommand.RunAsync(args[1..], commandHost.Services, Console.Out);
return;
}
static string BifrostConnectionString(IConfiguration configuration) =>
configuration.GetConnectionString("Bifrost")
?? "Host=127.0.0.1;Database=bifrost;Username=postgres;Password=postgres";
var builder = WebApplication.CreateBuilder(args);
@ -51,10 +72,15 @@ builder.Services
.AddOptions<BridgeOptions>()
.BindConfiguration(BridgeOptions.SectionName);
builder.Services
.AddOptions<ConceptVoteOptions>()
.BindConfiguration(ConceptVoteOptions.SectionName);
builder.Services.AddRazorPages(options =>
{
options.Conventions.AuthorizePage("/Membership/Status");
options.Conventions.AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName);
options.Conventions.AllowAnonymousToFolder("/Mill");
});
builder.Services.ConfigureHttpJsonOptions(options =>
{
@ -63,10 +89,7 @@ builder.Services.ConfigureHttpJsonOptions(options =>
builder.Services.AddDbContext<BifrostDbContext>(options =>
{
var connectionString = builder.Configuration.GetConnectionString("Bifrost")
?? "Host=127.0.0.1;Database=bifrost;Username=postgres;Password=postgres";
options.UseNpgsql(connectionString);
options.UseNpgsql(BifrostConnectionString(builder.Configuration));
});
builder.Services.AddHttpLogging(options =>
@ -95,29 +118,47 @@ builder.Services.AddScoped<MotionEveSurfaceService>();
builder.Services.AddScoped<PatronageService>();
builder.Services.AddScoped<StripeWebhookService>();
builder.Services.AddScoped<HeimdallPatronSupportIntakeService>();
builder.Services.AddScoped<ConceptAppraisalService>();
builder.Services.AddScoped<VoterKeyResolver>();
builder.Services.AddSingleton<ConceptVoteRateLimits>();
builder.Services.AddScoped<ConceptSubjectRegistry>();
builder.Services.AddScoped<ReadinessService>();
builder.Services.AddScoped<ApplicationBootstrapper>();
builder.Services.AddSingleton<StartupConfigurationValidator>();
builder.Services.AddScoped<IAuthorizationHandler, ActiveMemberAuthorizationHandler>();
builder.Services.AddSingleton<HeimdallAuthAttemptStore>();
builder.Services.AddHttpClient<HeimdallAuthService>((serviceProvider, client) =>
{
var options = serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl.TrimEnd('/'));
});
HeimdallAuthService.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value))
.RedactLoggedHeaders(header => string.Equals(header, HeimdallAuthService.AppSecretHeader, StringComparison.OrdinalIgnoreCase));
builder.Services.AddSingleton<HeimdallTokenVerifier>();
builder.Services.AddHttpClient(HeimdallTokenVerifier.ClientName, (serviceProvider, client) =>
HeimdallTokenVerifier.ConfigureClient(client, serviceProvider.GetRequiredService<IOptions<HeimdallOptions>>().Value));
builder.Services.AddHttpClient<StripeCheckoutService>(client =>
{
client.BaseAddress = new Uri("https://api.stripe.com/");
});
var dataProtectionBuilder = builder.Services
.AddDataProtection()
.SetApplicationName("Bifrost");
var dataProtectionKeysPath = builder.Configuration["DataProtection:KeysPath"];
if (!string.IsNullOrWhiteSpace(dataProtectionKeysPath))
{
builder.Services
.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath));
dataProtectionBuilder.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath));
}
// nginx on this host is the only proxy, and it connects from loopback. Trusting exactly
// those addresses keeps a direct client from choosing its own address with X-Forwarded-For.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
options.KnownProxies.Add(IPAddress.Loopback);
options.KnownProxies.Add(IPAddress.IPv6Loopback);
options.KnownProxies.Add(IPAddress.Loopback.MapToIPv6());
options.ForwardLimit = 1;
});
var configuredGitHubOAuthOptions = builder.Configuration
.GetSection(GitHubOAuthOptions.SectionName)
.Get<GitHubOAuthOptions>() ?? new GitHubOAuthOptions();
@ -183,6 +224,15 @@ builder.Services.AddAuthorization(options =>
});
});
builder.Services.AddConceptTallyCors();
// The OIDC provider is mapped only when Bifrost:Oidc is configured; a configured provider that cannot load its keys stops startup.
var oidcOptions = OidcIssuerOptions.FromConfiguration(builder.Configuration);
if (oidcOptions is not null)
{
builder.Services.AddBifrostOidc(oidcOptions);
}
var app = builder.Build();
var appJsonSerializerOptions = CreateAppJsonSerializerOptions();
@ -194,6 +244,8 @@ await using (var scope = app.Services.CreateAsyncScope())
.RunAsync(CancellationToken.None);
}
app.UseForwardedHeaders();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
@ -204,6 +256,7 @@ app.UseHttpLogging();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseConceptTallyCors();
app.UseAuthentication();
app.UseMiddleware<GitHubMembershipSynchronizationMiddleware>();
app.UseAuthorization();
@ -220,6 +273,13 @@ app.MapGet("/readyz", async (ReadinessService readinessService, CancellationToke
statusCode: StatusCodes.Status503ServiceUnavailable);
}).AllowAnonymous();
app.MapConceptTallyExport();
if (oidcOptions is not null)
{
app.MapBifrostOidc();
}
app.MapPost("/github/webhooks", async (
HttpRequest request,
GitHubWebhookService gitHubWebhookService,
@ -253,22 +313,18 @@ app.MapPost("/github/webhooks", async (
app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions<GitHubOAuthOptions> gitHubOptions) =>
{
var returnUrl = httpContext.Request.Query["returnUrl"].ToString();
if (string.IsNullOrWhiteSpace(returnUrl))
{
returnUrl = "/App";
}
var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString());
var provider = httpContext.Request.Query["provider"].ToString();
if (string.IsNullOrWhiteSpace(provider))
{
await WriteSignInChooserAsync(httpContext, returnUrl);
await WriteSignInChooserAsync(httpContext, returnPath.Value);
return;
}
if (!provider.Equals("github", StringComparison.OrdinalIgnoreCase))
{
httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnUrl)}");
httpContext.Response.Redirect($"/auth/heimdall/{Uri.EscapeDataString(provider)}?returnUrl={Uri.EscapeDataString(returnPath.Value)}");
return;
}
@ -280,7 +336,7 @@ app.MapGet("/auth/sign-in", async (HttpContext httpContext, IOptions<GitHubOAuth
await httpContext.ChallengeAsync(
GitHubAuthenticationDefaults.AuthenticationScheme,
new() { RedirectUri = returnUrl });
new() { RedirectUri = returnPath.Value });
}).AllowAnonymous();
app.MapPost("/auth/bifrost/register", async (
@ -320,16 +376,12 @@ app.MapGet("/auth/heimdall/{provider}", async (
HttpContext httpContext,
HeimdallAuthService heimdallAuthService,
IOptions<BifrostHostOptions> hostOptions,
ILogger<HeimdallAuthService> logger,
CancellationToken cancellationToken) =>
{
try
{
var returnUrl = httpContext.Request.Query["returnUrl"].ToString();
if (string.IsNullOrWhiteSpace(returnUrl))
{
returnUrl = "/App";
}
var returnPath = LocalReturnPath.Parse(httpContext.Request.Query["returnUrl"].ToString());
var requireMemberAccess = string.Equals(
httpContext.Request.Query["access"].ToString(),
"member",
@ -338,30 +390,18 @@ app.MapGet("/auth/heimdall/{provider}", async (
var start = await heimdallAuthService.StartAsync(
provider,
hostOptions.Value.PublicBaseUrl,
returnUrl,
returnPath,
requireMemberAccess,
httpContext.Request.Cookies,
cancellationToken);
httpContext.Response.Cookies.Append(start.CookieName, start.CookieValue, HeimdallAttemptCookie.Options());
return Results.Redirect(start.AuthorizationUrl.ToString());
}
catch (HeimdallAuthException error)
{
return Results.Redirect($"/?auth=heimdall-error&detail={Uri.EscapeDataString(error.Message)}");
}
}).AllowAnonymous();
app.MapPost("/auth/heimdall/callback", (
HeimdallBackendCallbackPayload payload,
HeimdallAuthService heimdallAuthService) =>
{
try
{
heimdallAuthService.StoreBackendCallback(payload);
return Results.Accepted();
}
catch (HeimdallAuthException error)
{
return Results.BadRequest(error.Message);
logger.LogWarning("Heimdall start refused: {Reason}", error.Message);
return Results.Redirect(HeimdallAuthService.ErrorRedirect);
}
}).AllowAnonymous();
@ -487,31 +527,44 @@ eveGovernance.MapGet("/surface", async (
"application/json");
});
app.MapGet("/auth/heimdall/wait", async (
app.MapGet("/auth/heimdall/complete", (HttpContext httpContext) =>
{
httpContext.Response.Headers.CacheControl = "no-store";
httpContext.Response.Headers["Referrer-Policy"] = "no-referrer";
return Results.Content(HeimdallCompletePage.Html, "text/html", System.Text.Encoding.UTF8);
}).AllowAnonymous();
app.MapPost("/auth/heimdall/complete", async (
HttpContext httpContext,
HeimdallAuthService heimdallAuthService,
IOptions<BifrostHostOptions> hostOptions,
ILogger<HeimdallAuthService> logger,
CancellationToken cancellationToken) =>
{
var attemptId = httpContext.Request.Query["attemptId"].ToString();
if (string.IsNullOrWhiteSpace(attemptId))
{
return Results.Redirect("/?auth=heimdall-error&detail=missing-attempt-id");
}
var form = await ReadFormOrNullAsync(httpContext.Request, cancellationToken);
var attempt = form?["attempt"].ToString();
var held = heimdallAuthService.FindAttemptCookie(httpContext.Request.Cookies, attempt);
try
{
var signIn = await heimdallAuthService.CompleteAttemptAsync(attemptId, cancellationToken);
var signIn = await heimdallAuthService.CompleteAsync(
hostOptions.Value.PublicBaseUrl,
attempt,
held?.Value,
form?["code"].ToString(),
cancellationToken);
await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, signIn.Principal);
return Results.Redirect(signIn.RedirectUri);
}
catch (HeimdallAuthPendingException)
{
httpContext.Response.Headers.Append("Refresh", "2");
return Results.Text("Waiting for Heimdall sign-in to complete.", "text/plain");
httpContext.Response.Cookies.Delete(held!.Value.Name, HeimdallAttemptCookie.DeleteOptions());
return Results.LocalRedirect(signIn.ReturnPath.Value);
}
catch (HeimdallAuthException error)
{
return Results.Redirect($"/?auth=heimdall-error&detail={Uri.EscapeDataString(error.Message)}");
logger.LogWarning("Heimdall completion refused: {Reason}", error.Message);
if (held is not null)
{
httpContext.Response.Cookies.Delete(held.Value.Name, HeimdallAttemptCookie.DeleteOptions());
}
return Results.Redirect(HeimdallAuthService.ErrorRedirect);
}
}).AllowAnonymous();
@ -531,6 +584,23 @@ static IResult JsonResult<T>(T value, JsonSerializerOptions options, int statusC
"application/json",
statusCode: statusCode);
static async Task<IFormCollection?> ReadFormOrNullAsync(HttpRequest request, CancellationToken cancellationToken)
{
if (!request.HasFormContentType)
{
return null;
}
try
{
return await request.ReadFormAsync(cancellationToken);
}
catch (Exception error) when (error is InvalidDataException or BadHttpRequestException)
{
return null;
}
}
static async Task WriteSignInChooserAsync(HttpContext httpContext, string returnUrl)
{
httpContext.Response.ContentType = "text/html; charset=utf-8";

View file

@ -27,6 +27,7 @@
"Heimdall": {
"BaseUrl": "https://heimdall.gamecult.org",
"AppSlug": "bifrost",
"AppSecret": "",
"DiscordGuildId": "",
"DiscordAllowedRoleIds": [],
"PatreonTierTitle": "Inner Sanctum",

View file

@ -0,0 +1,430 @@
{
"version": 1,
"dependencies": {
"net8.0": {
"BouncyCastle.Cryptography": {
"type": "Direct",
"requested": "[2.4.0, )",
"resolved": "2.4.0",
"contentHash": "SwXsAV3sMvAU/Nn31pbjhWurYSjJ+/giI/0n6tCrYoupEK34iIHCuk3STAd9fx8yudM85KkLSVdn951vTng/vQ=="
},
"Microsoft.EntityFrameworkCore.Design": {
"type": "Direct",
"requested": "[8.0.0, )",
"resolved": "8.0.0",
"contentHash": "94reKYu63jg4O75UI3LMJHwOSi8tQ6IfubiZhdnSsWcgtmAuF8OyLfjK/MIxuvaQRJZAF6E747FIuxjOtb8/og==",
"dependencies": {
"Humanizer.Core": "2.14.1",
"Microsoft.CodeAnalysis.CSharp.Workspaces": "4.5.0",
"Microsoft.EntityFrameworkCore.Relational": "8.0.0",
"Microsoft.Extensions.DependencyModel": "8.0.0",
"Mono.TextTemplating": "2.2.1"
}
},
"Microsoft.Extensions.Caching.Memory": {
"type": "Direct",
"requested": "[8.0.1, )",
"resolved": "8.0.1",
"contentHash": "HFDnhYLccngrzyGgHkjEDU5FMLn4MpOsr5ElgsBMC4yx6lJh4jeWO7fHS8+TXPq+dgxCmUa/Trl8svObmwW4QA==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "8.0.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2",
"Microsoft.Extensions.Logging.Abstractions": "8.0.2",
"Microsoft.Extensions.Options": "8.0.2",
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Npgsql": {
"type": "Direct",
"requested": "[8.0.3, )",
"resolved": "8.0.3",
"contentHash": "6WEmzsQJCZAlUG1pThKg/RmeF6V+I0DmBBBE/8YzpRtEzhyZzKcK7ulMANDm5CkxrALBEC8H+5plxHWtIL7xnA==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "8.0.0"
}
},
"Npgsql.EntityFrameworkCore.PostgreSQL": {
"type": "Direct",
"requested": "[8.0.0, )",
"resolved": "8.0.0",
"contentHash": "GDXiMS9peEdjSCU/rfgyHruio7q6tYuywGaktqEi6UPQ6ILechp3fVVX+dHXkIXt4nklCBzYVWkzFrSL9ubKUA==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "8.0.0",
"Microsoft.EntityFrameworkCore.Abstractions": "8.0.0",
"Microsoft.EntityFrameworkCore.Relational": "8.0.0",
"Npgsql": "8.0.0"
}
},
"OpenIddict.EntityFrameworkCore": {
"type": "Direct",
"requested": "[7.7.1, )",
"resolved": "7.7.1",
"contentHash": "U+Lq0DuEm9bolWOVur0Qy3LgwzCSjRQKs2Q8tUfFfpuAJsnhgK7j401eDm5PdHV1wnoaBYhXEMkkW+UKeADyaw==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Relational": "8.0.30",
"OpenIddict.Core": "7.7.1",
"OpenIddict.EntityFrameworkCore.Models": "7.7.1"
}
},
"OpenIddict.Server.AspNetCore": {
"type": "Direct",
"requested": "[7.7.1, )",
"resolved": "7.7.1",
"contentHash": "YQW64MJBVXsJ+H5kb+HFAQaSXsavOCEG+kDgD+DaYK7apLFtbOrgN233RhHOuztChWcY8SfjpjeN0ZXPwTFsbA==",
"dependencies": {
"OpenIddict.Server": "7.7.1"
}
},
"System.Text.Json": {
"type": "Direct",
"requested": "[8.0.5, )",
"resolved": "8.0.5",
"contentHash": "0f1B50Ss7rqxXiaBJyzUu9bWFOO2/zSlifZ/UNMdiIpDYe4cY4LQQicP4nirK1OS31I43rn062UIJ1Q9bpmHpg=="
},
"Humanizer.Core": {
"type": "Transitive",
"resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg=="
},
"Microsoft.Bcl.Cryptography": {
"type": "Transitive",
"resolved": "10.0.2",
"contentHash": "LG9Yll3B5aNpxv0+D47g6LiOiKBIlodhcHdQwcYzo8VeexFLGqx5ymetmA2aBRyo9cCcWsQWrFsdbsr8LvmWDw==",
"dependencies": {
"System.Formats.Asn1": "10.0.2"
}
},
"Microsoft.CodeAnalysis.Analyzers": {
"type": "Transitive",
"resolved": "3.3.3",
"contentHash": "j/rOZtLMVJjrfLRlAMckJLPW/1rze9MT1yfWqSIbUPGRu1m1P0fuo9PmqapwsmePfGB5PJrudQLvmUOAMF0DqQ=="
},
"Microsoft.CodeAnalysis.Common": {
"type": "Transitive",
"resolved": "4.5.0",
"contentHash": "lwAbIZNdnY0SUNoDmZHkVUwLO8UyNnyyh1t/4XsbFxi4Ounb3xszIYZaWhyj5ZjyfcwqwmtMbE7fUTVCqQEIdQ==",
"dependencies": {
"Microsoft.CodeAnalysis.Analyzers": "3.3.3",
"System.Collections.Immutable": "6.0.0",
"System.Reflection.Metadata": "6.0.1",
"System.Runtime.CompilerServices.Unsafe": "6.0.0",
"System.Text.Encoding.CodePages": "6.0.0"
}
},
"Microsoft.CodeAnalysis.CSharp": {
"type": "Transitive",
"resolved": "4.5.0",
"contentHash": "cM59oMKAOxvdv76bdmaKPy5hfj+oR+zxikWoueEB7CwTko7mt9sVKZI8Qxlov0C/LuKEG+WQwifepqL3vuTiBQ==",
"dependencies": {
"Microsoft.CodeAnalysis.Common": "[4.5.0]"
}
},
"Microsoft.CodeAnalysis.CSharp.Workspaces": {
"type": "Transitive",
"resolved": "4.5.0",
"contentHash": "h74wTpmGOp4yS4hj+EvNzEiPgg/KVs2wmSfTZ81upJZOtPkJsVkgfsgtxxqmAeapjT/vLKfmYV0bS8n5MNVP+g==",
"dependencies": {
"Humanizer.Core": "2.14.1",
"Microsoft.CodeAnalysis.CSharp": "[4.5.0]",
"Microsoft.CodeAnalysis.Common": "[4.5.0]",
"Microsoft.CodeAnalysis.Workspaces.Common": "[4.5.0]"
}
},
"Microsoft.CodeAnalysis.Workspaces.Common": {
"type": "Transitive",
"resolved": "4.5.0",
"contentHash": "l4dDRmGELXG72XZaonnOeORyD/T5RpEu5LGHOUIhnv+MmUWDY/m1kWXGwtcgQ5CJ5ynkFiRnIYzTKXYjUs7rbw==",
"dependencies": {
"Humanizer.Core": "2.14.1",
"Microsoft.Bcl.AsyncInterfaces": "6.0.0",
"Microsoft.CodeAnalysis.Common": "[4.5.0]",
"System.Composition": "6.0.0",
"System.IO.Pipelines": "6.0.3",
"System.Threading.Channels": "6.0.0"
}
},
"Microsoft.EntityFrameworkCore": {
"type": "Transitive",
"resolved": "8.0.30",
"contentHash": "Y6GP16iuqAUkcXSgMqjgao9w0UHSz8x04LRDadsi++sn7yBI9C/sy61xNLzPKM+6DZEbyPRHGq3M58f6DhB25g==",
"dependencies": {
"Microsoft.EntityFrameworkCore.Abstractions": "8.0.30",
"Microsoft.EntityFrameworkCore.Analyzers": "8.0.30",
"Microsoft.Extensions.Caching.Memory": "8.0.1",
"Microsoft.Extensions.Logging": "8.0.1"
}
},
"Microsoft.EntityFrameworkCore.Abstractions": {
"type": "Transitive",
"resolved": "8.0.30",
"contentHash": "JoVu3708PrMcA3BUeo1+gTcD4nRI8o3g3kwNXwgTPUDhe0dqN2CQmHmEPDD0uZlI1v2oq3F7tR46VycUHzP9ng=="
},
"Microsoft.EntityFrameworkCore.Analyzers": {
"type": "Transitive",
"resolved": "8.0.30",
"contentHash": "+ZVRBR3CzGu0AGWTbewOWTzV+UG//yDrA0gdvb9i1dTidSvwG9qRRggEI9fie0MPC0LAcS9Z8X5HRYWWxefutg=="
},
"Microsoft.EntityFrameworkCore.Relational": {
"type": "Transitive",
"resolved": "8.0.30",
"contentHash": "uGWv6fvDUvJDKAYXfVwPVLmit2i6WCmfdzwrHHuw4OJ30s9GDVLsvV62Gi8TIgFSUlTVgSVW/c+hAvDwmY1TDw==",
"dependencies": {
"Microsoft.EntityFrameworkCore": "8.0.30",
"Microsoft.Extensions.Configuration.Abstractions": "8.0.0"
}
},
"Microsoft.Extensions.Caching.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==",
"dependencies": {
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "BmANAnR5Xd4Oqw7yQ75xOAYODybZQRzdeNucg7kS5wWKd2PNnMdYtJ2Vciy0QLylRmv42DGl5+AFL9izA6F1Rw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "8.0.2",
"contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "NSmDw3K0ozNDgShSIpsZcbFIzBX4w28nDag+TfaQujkXGazBm+lid5onlWoCBy4VsLxqnnKjEBbGSJVWJMf43g==",
"dependencies": {
"System.Text.Encodings.Web": "8.0.0",
"System.Text.Json": "8.0.0"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "8.0.1",
"contentHash": "4x+pzsQEbqxhNf1QYRr5TDkLP9UsLT3A6MdRKDDEgrW7h1ljiEPgTNhKYUhNCCAaVpQECVQ+onA91PTPnIp6Lw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "8.0.1",
"Microsoft.Extensions.Logging.Abstractions": "8.0.2",
"Microsoft.Extensions.Options": "8.0.2"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "8.0.2",
"contentHash": "nroMDjS7hNBPtkZqVBbSiQaQjWRDxITI8Y7XnDs97rqG3EbzVTNLZQf7bIeUJcaHOV8bca47s1Uxq94+2oGdxA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "8.0.2",
"contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Primitives": "8.0.0"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g=="
},
"Microsoft.IdentityModel.Abstractions": {
"type": "Transitive",
"resolved": "8.19.2",
"contentHash": "HJbo/lnSfNHUfphPRT910poQc4T2/9+8svFLvzuaYHGAOJ2Tu+oEDqpX0BVP3BJ4OuUM1kylEKyaiX2fCAK3Cw=="
},
"Microsoft.IdentityModel.JsonWebTokens": {
"type": "Transitive",
"resolved": "8.19.2",
"contentHash": "ui3fuBT4fs8kdKfBthI4NzLYIBIneEVS8UrL1JVBzAn80UiKmngBBi0BEByE7n/9c+EElcfFlCMFFTqpkBSLNA==",
"dependencies": {
"Microsoft.IdentityModel.Tokens": "8.19.2"
}
},
"Microsoft.IdentityModel.Logging": {
"type": "Transitive",
"resolved": "8.19.2",
"contentHash": "r5YLDIxGOnkVJHrqXv/iD1FM1CgGrQOdriXuvuWvTPmKbnGANhEysq9XKmN6IHjf2a+9bAGEpnoRBsAQlvJU5w==",
"dependencies": {
"Microsoft.IdentityModel.Abstractions": "8.19.2"
}
},
"Microsoft.IdentityModel.Tokens": {
"type": "Transitive",
"resolved": "8.19.2",
"contentHash": "GtPC1S02uH1gOO4fQ+zRysIicKmEXaYFP8PIkdJYXqMyruYhopre4ozVHp0XiDSA0+GJvOZH9prxCPvgBMg4Ww==",
"dependencies": {
"Microsoft.Bcl.Cryptography": "10.0.2",
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
"Microsoft.IdentityModel.Logging": "8.19.2"
}
},
"Mono.TextTemplating": {
"type": "Transitive",
"resolved": "2.2.1",
"contentHash": "KZYeKBET/2Z0gY1WlTAK7+RHTl7GSbtvTLDXEZZojUdAPqpQNDL6tHv7VUpqfX5VEOh+uRGKaZXkuD253nEOBQ==",
"dependencies": {
"System.CodeDom": "4.4.0"
}
},
"OpenIddict.Abstractions": {
"type": "Transitive",
"resolved": "7.7.1",
"contentHash": "dgb+2dGHzwjVUs7Jc8Dt3PQurVCEllX8XW8+WPmdB1eV5IKRoAZyiJyp3mgFOR1sk/jvilPqnMzQYGkVNVf66Q==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2",
"Microsoft.Extensions.Primitives": "8.0.0",
"Microsoft.IdentityModel.Tokens": "8.19.2"
}
},
"OpenIddict.Core": {
"type": "Transitive",
"resolved": "7.7.1",
"contentHash": "TLk8u4zZ9IvTpTYV+a6nX1eBpnPR5E3seMPGcArXhfBK0VTImWWzPbcdx+GxiPxyq7+8iOg05uIJt7FsRwDpZg==",
"dependencies": {
"Microsoft.Extensions.Caching.Memory": "8.0.1",
"Microsoft.Extensions.Logging": "8.0.1",
"Microsoft.Extensions.Options": "8.0.2",
"OpenIddict.Abstractions": "7.7.1"
}
},
"OpenIddict.EntityFrameworkCore.Models": {
"type": "Transitive",
"resolved": "7.7.1",
"contentHash": "DjJ5/7TBV7q7zB43v7em73nQBIacid5hxtlZzLnuWBg7kzvlBlUvvmgEhW/asoMBT3sR2nEyufB0wKOnn1niXQ=="
},
"OpenIddict.Server": {
"type": "Transitive",
"resolved": "7.7.1",
"contentHash": "Se5+zNefCR9VpRPudmXOVJ6z79rSdEx+PvlPn1fGvKhLW6cqT5w8q8lPdRKe+zdiyAEXpSg+K5m6lTSpVqYOWA==",
"dependencies": {
"Microsoft.Extensions.Logging": "8.0.1",
"Microsoft.IdentityModel.JsonWebTokens": "8.19.2",
"OpenIddict.Abstractions": "7.7.1"
}
},
"System.CodeDom": {
"type": "Transitive",
"resolved": "4.4.0",
"contentHash": "2sCCb7doXEwtYAbqzbF/8UAeDRMNmPaQbU2q50Psg1J9KzumyVVCgKQY8s53WIPTufNT0DpSe9QRvVjOzfDWBA=="
},
"System.Collections.Immutable": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "l4zZJ1WU2hqpQQHXz1rvC3etVZN+2DLmQMO79FhOTZHMn8tDRr+WU287sbomD0BETlmKDn0ygUgVy9k5xkkJdA==",
"dependencies": {
"System.Runtime.CompilerServices.Unsafe": "6.0.0"
}
},
"System.Composition": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "d7wMuKQtfsxUa7S13tITC8n1cQzewuhD5iDjZtK2prwFfKVzdYtgrTHgjaV03Zq7feGQ5gkP85tJJntXwInsJA==",
"dependencies": {
"System.Composition.AttributedModel": "6.0.0",
"System.Composition.Convention": "6.0.0",
"System.Composition.Hosting": "6.0.0",
"System.Composition.Runtime": "6.0.0",
"System.Composition.TypedParts": "6.0.0"
}
},
"System.Composition.AttributedModel": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "WK1nSDLByK/4VoC7fkNiFuTVEiperuCN/Hyn+VN30R+W2ijO1d0Z2Qm0ScEl9xkSn1G2MyapJi8xpf4R8WRa/w=="
},
"System.Composition.Convention": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "XYi4lPRdu5bM4JVJ3/UIHAiG6V6lWWUlkhB9ab4IOq0FrRsp0F4wTyV4Dj+Ds+efoXJ3qbLqlvaUozDO7OLeXA==",
"dependencies": {
"System.Composition.AttributedModel": "6.0.0"
}
},
"System.Composition.Hosting": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "w/wXjj7kvxuHPLdzZ0PAUt++qJl03t7lENmb2Oev0n3zbxyNULbWBlnd5J5WUMMv15kg5o+/TCZFb6lSwfaUUQ==",
"dependencies": {
"System.Composition.Runtime": "6.0.0"
}
},
"System.Composition.Runtime": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "qkRH/YBaMPTnzxrS5RDk1juvqed4A6HOD/CwRcDGyPpYps1J27waBddiiq1y93jk2ZZ9wuA/kynM+NO0kb3PKg=="
},
"System.Composition.TypedParts": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "iUR1eHrL8Cwd82neQCJ00MpwNIBs4NZgXzrPqx8NJf/k4+mwBO0XCRmHYJT4OLSwDDqh5nBLJWkz5cROnrGhRA==",
"dependencies": {
"System.Composition.AttributedModel": "6.0.0",
"System.Composition.Hosting": "6.0.0",
"System.Composition.Runtime": "6.0.0"
}
},
"System.Formats.Asn1": {
"type": "Transitive",
"resolved": "10.0.2",
"contentHash": "9Hee6iQ74pDgdRNIL6lO8Vlx2onVWd5aDnqwk4Pvo0h5FklBd7QwcQDKgxeLuhiy0o3ibzUVO0KFQe0/hPXU+Q=="
},
"System.IO.Pipelines": {
"type": "Transitive",
"resolved": "6.0.3",
"contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw=="
},
"System.Reflection.Metadata": {
"type": "Transitive",
"resolved": "6.0.1",
"contentHash": "III/lNMSn0ZRBuM9m5Cgbiho5j81u0FAEagFX5ta2DKbljZ3T0IpD8j+BIiHQPeKqJppWS9bGEp6JnKnWKze0g==",
"dependencies": {
"System.Collections.Immutable": "6.0.0"
}
},
"System.Runtime.CompilerServices.Unsafe": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg=="
},
"System.Text.Encoding.CodePages": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "ZFCILZuOvtKPauZ/j/swhvw68ZRi9ATCfvGbk1QfydmcXBkIWecWKn/250UH7rahZ5OoDBaiAudJtPvLwzw85A==",
"dependencies": {
"System.Runtime.CompilerServices.Unsafe": "6.0.0"
}
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ=="
},
"System.Threading.Channels": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "TY8/9+tI0mNaUMgntOxxaq2ndTkdXqLSxvPmas7XEqOlv9lQtB7wLjYGd756lOaO7Dvb5r/WXhluM+0Xe87v5Q=="
}
}
}
}

View file

@ -20,6 +20,11 @@
<ProjectReference Include="..\..\src\Bifrost.Web\Bifrost.Web.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="Fixtures\*.cjs" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

View file

@ -0,0 +1,600 @@
using System.Net;
using System.Reflection;
using System.Text.Json;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Options;
using Npgsql;
namespace Bifrost.Web.Tests;
public sealed class ConceptAppraisalServiceTests
{
private const string Collection = "aetheria-concept-mill";
private static readonly IPAddress Client = IPAddress.Parse("203.0.113.7");
[Fact]
public async Task Anonymous_device_voter_can_cast_and_is_counted()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var result = await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up);
Assert.Equal(CastResult.Recorded, result);
var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"];
Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), tally);
}
[Fact]
public async Task Anonymous_without_device_id_gets_no_identity_and_cast_is_refused()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, null));
Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, "has:colon"));
Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, string.Empty));
Assert.Equal(CastResult.NoVoter, await f.Cast("ship-a", null, ConceptStance.Up));
Assert.Empty(f.Db.ConceptAppraisals);
}
[Theory]
[InlineData("abc\n")]
[InlineData("abc\r\n")]
[InlineData("abc ")]
[InlineData("abc\t")]
[InlineData("abc\0")]
public async Task A_device_id_with_trailing_whitespace_or_control_char_cannot_cast_beside_its_bare_form(string forged)
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
Assert.Equal(CastResult.Recorded, await f.Cast("ship-a", f.Anonymous("abc"), ConceptStance.Up));
Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, forged));
Assert.Equal(CastResult.NoVoter, await f.Cast("ship-a", VoterIdentity.For(CurrentBifrostActor.Anonymous, forged), ConceptStance.Down));
Assert.Single(f.Db.ConceptAppraisals);
}
[Fact]
public async Task A_changed_stance_is_saved_and_read_back_through_a_fresh_context()
{
var dbName = $"concept-{Guid.NewGuid():N}";
await using var first = new Fixture(dbName);
await first.AddSubject("ship-a");
Assert.Equal(CastResult.Recorded, await first.Cast("ship-a", first.Anonymous("device-one"), ConceptStance.Up));
var other = IPAddress.Parse("198.51.100.9");
// Production scopes one DbContext per request: the recast gets a context that has never tracked the row.
await using (var second = new Fixture(dbName))
{
second.Clock.Advance(TimeSpan.FromMinutes(5));
Assert.Equal(
CastResult.Changed,
await second.Cast("ship-a", second.Anonymous("device-one"), ConceptStance.Down, other));
}
await using var reader = new Fixture(dbName);
var row = Assert.Single(reader.Db.ConceptAppraisals);
Assert.Equal(ConceptStance.Down, row.Stance);
Assert.Equal(NetworkPrefix.Tag(other, 32, 56, Fixture.Secret), row.NetworkTag);
Assert.Equal(new DateTimeOffset(2026, 10, 10, 12, 5, 0, TimeSpan.Zero), row.CastAtUtc);
Assert.Equal(new ConceptTally(0, 1, new StanceCounts(0, 0)), (await reader.Service.GetTalliesAsync(Collection, default))["ship-a"]);
}
[Fact]
public async Task Voter_class_is_stamped_at_cast()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var account = f.Account(MembershipStatus.Authenticated);
var member = f.Account(MembershipStatus.Active);
await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(account, null), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Up);
var rows = f.Db.ConceptAppraisals.ToList();
Assert.Equal(VoterClass.Anonymous, rows.Single(x => x.VoterKey == "device:device-one").VoterClass);
Assert.Null(rows.Single(x => x.VoterKey == "device:device-one").UserAccountId);
var accountRow = rows.Single(x => x.UserAccountId == account.UserAccount!.Id);
var memberRow = rows.Single(x => x.UserAccountId == member.UserAccount!.Id);
Assert.Equal(VoterClass.Account, accountRow.VoterClass);
Assert.Equal(VoterClass.Member, memberRow.VoterClass);
// Membership ends; the same account votes again with the opposite stance. The row keeps its stamp.
member.UserAccount!.Membership!.Status = MembershipStatus.Authenticated;
var recast = await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Down);
Assert.Equal(CastResult.Changed, recast);
Assert.Equal(VoterClass.Member, f.Db.ConceptAppraisals.Single(x => x.Id == memberRow.Id).VoterClass);
}
[Fact]
public async Task Second_cast_by_the_same_key_changes_stance_without_a_new_row()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var voter = f.Anonymous("device-one");
Assert.Equal(CastResult.Recorded, await f.Cast("ship-a", voter, ConceptStance.Up));
Assert.Equal(CastResult.Changed, await f.Cast("ship-a", voter, ConceptStance.Down));
Assert.Equal(CastResult.Unchanged, await f.Cast("ship-a", voter, ConceptStance.Down));
var row = Assert.Single(f.Db.ConceptAppraisals);
Assert.Equal(ConceptStance.Down, row.Stance);
}
[Fact]
public async Task Member_weight_does_not_weight_counts()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var member = f.Account(MembershipStatus.Active, weight: 5m);
Assert.Equal(5m, member.EffectiveVotingWeight);
await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Up);
var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"];
Assert.Equal(1, tally.Up);
Assert.Equal(1, tally.Members.Up);
}
[Fact]
public async Task Member_tally_is_a_filter_over_the_same_rows()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up);
await f.Cast("ship-a", f.Anonymous("device-two"), ConceptStance.Down);
await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Authenticated), null), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Down);
var tally = (await f.Service.GetTalliesAsync(Collection, default))["ship-a"];
var rows = f.Db.ConceptAppraisals.ToList();
Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Up), tally.Up);
Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Down), tally.Down);
Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Up && x.VoterClass == VoterClass.Member), tally.Members.Up);
Assert.Equal(rows.Count(x => x.Stance == ConceptStance.Down && x.VoterClass == VoterClass.Member), tally.Members.Down);
Assert.Equal(new ConceptTally(3, 2, new StanceCounts(1, 1)), tally);
}
[Fact]
public async Task Tally_carries_no_voter_identity()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var account = f.Account(MembershipStatus.Authenticated);
var member = f.Account(MembershipStatus.Active);
await f.Cast("ship-a", f.Anonymous("device-secret-id"), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(account, null), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(member, null), ConceptStance.Down);
Assert.Equal(
new[] { "Down", "Members", "Up" },
typeof(ConceptTally).GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name).Order().ToArray());
Assert.Equal(
new[] { "Down", "Up" },
typeof(StanceCounts).GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name).Order().ToArray());
var json = JsonSerializer.Serialize(await f.Service.GetTalliesAsync(Collection, default));
var view = JsonSerializer.Serialize(
await f.Service.GetSubjectAsync(Collection, "ship-a", VoterIdentity.For(account, null), default));
foreach (var text in new[] { json, view })
{
Assert.DoesNotContain("device:", text);
Assert.DoesNotContain("account:", text);
Assert.DoesNotContain("device-secret-id", text);
foreach (var id in new[] { account.UserAccount!.Id, member.UserAccount!.Id })
{
Assert.DoesNotContain(id.ToString("D"), text);
Assert.DoesNotContain(id.ToString("N"), text);
}
foreach (var tag in f.Db.ConceptAppraisals.Select(x => x.NetworkTag).Distinct())
{
Assert.DoesNotContain(tag, text);
}
}
}
[Fact]
public async Task Votes_on_unknown_or_retired_keys_are_refused_and_create_nothing()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
await f.AddSubject("ship-old", retired: true);
var subjectsBefore = f.Db.ConceptSubjects.Count();
var voter = f.Anonymous("device-one");
Assert.Equal(CastResult.UnknownSubject, await f.Cast("ship-missing", voter, ConceptStance.Up));
Assert.Equal(CastResult.UnknownSubject, await f.Cast("Ship_A", voter, ConceptStance.Up));
Assert.Equal(CastResult.RetiredSubject, await f.Cast("ship-old", voter, ConceptStance.Up));
Assert.Equal(subjectsBefore, f.Db.ConceptSubjects.Count());
Assert.Empty(f.Db.ConceptAppraisals);
Assert.DoesNotContain("ship-old", (await f.Service.GetTalliesAsync(Collection, default)).Keys);
Assert.Contains("ship-a", (await f.Service.GetTalliesAsync(Collection, default)).Keys);
}
[Fact]
public async Task Retract_removes_the_row_and_the_count()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var voter = f.Anonymous("device-one");
await f.Cast("ship-a", voter, ConceptStance.Up);
await f.Cast("ship-a", f.Anonymous("device-two"), ConceptStance.Up);
Assert.True(await f.Service.RetractAsync(Collection, "ship-a", voter, default));
Assert.False(await f.Service.RetractAsync(Collection, "ship-a", voter, default));
Assert.Equal(1, (await f.Service.GetTalliesAsync(Collection, default))["ship-a"].Up);
Assert.Single(f.Db.ConceptAppraisals);
}
[Fact]
public async Task Network_tag_is_an_hmac_never_a_raw_address()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up);
var tag = f.Db.ConceptAppraisals.Single().NetworkTag;
Assert.Matches("^[0-9a-f]{64}$", tag);
Assert.Equal(NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret), tag);
}
[Fact]
public async Task Unique_violation_at_insert_becomes_a_change()
{
var dbName = $"race-{Guid.NewGuid():N}";
var racer = new RacingInterceptor(dbName);
await using var f = new Fixture(dbName, racer);
await f.AddSubject("ship-a");
racer.Subject = f.Db.ConceptSubjects.Single();
var result = await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Down);
Assert.Equal(CastResult.Changed, result);
Assert.Equal(1, racer.Fired);
var row = Assert.Single(f.Db.ConceptAppraisals);
Assert.Equal(ConceptStance.Down, row.Stance);
}
[Fact]
public void Unique_indexes_are_declared_in_model_and_migration()
{
var options = new DbContextOptionsBuilder<BifrostDbContext>().UseNpgsql("Host=unused").Options;
using var db = new BifrostDbContext(options);
Assert.Contains(
db.Model.FindEntityType(typeof(ConceptAppraisal))!.GetIndexes(),
i => i.IsUnique && i.Properties.Select(p => p.Name).SequenceEqual(["SubjectId", "VoterKey"]));
Assert.Contains(
db.Model.FindEntityType(typeof(ConceptSubject))!.GetIndexes(),
i => i.IsUnique && i.Properties.Select(p => p.Name).SequenceEqual(["Collection", "Key"]));
var script = db.Database.GenerateCreateScript();
Assert.Matches("CREATE UNIQUE INDEX \"IX_ConceptAppraisals_SubjectId_VoterKey\"", script);
Assert.Matches("CREATE UNIQUE INDEX \"IX_ConceptSubjects_Collection_Key\"", script);
}
[Fact]
public async Task Purge_removes_only_rows_in_tag_and_window_and_audits()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var admin = f.Admin();
var t0 = f.Clock.GetUtcNow();
var other = IPAddress.Parse("198.51.100.9");
await f.Cast("ship-a", f.Anonymous("device-in"), ConceptStance.Up);
await f.Cast("ship-a", f.Anonymous("device-other-net"), ConceptStance.Up, other);
f.Clock.Advance(TimeSpan.FromHours(2));
await f.Cast("ship-a", f.Anonymous("device-late"), ConceptStance.Up);
var tag = NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret);
var removed = await f.Service.PurgeAsync(admin, tag, t0, t0.AddHours(1), default);
Assert.Equal(1, removed);
Assert.Equal(
["device:device-late", "device:device-other-net"],
f.Db.ConceptAppraisals.Select(x => x.VoterKey).ToList().Order().ToArray());
var audit = Assert.Single(f.Db.AuditEvents);
Assert.Equal("ConceptAppraisal", audit.EntityType);
Assert.Equal("Purge", audit.Action);
Assert.DoesNotContain(tag, audit.Detail);
Assert.DoesNotContain("tag", audit.Detail);
Assert.Equal(admin.UserAccount!.Id, audit.ActorUserAccountId);
Assert.Contains("from=" + t0.ToString("O"), audit.Detail);
Assert.Contains("count=1", audit.Detail);
Assert.DoesNotContain("device", audit.Detail);
var refused = await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => f.Service.PurgeAsync(f.Account(MembershipStatus.Active), tag, t0, t0.AddDays(1), default));
Assert.DoesNotContain(tag, refused.Message);
Assert.Equal("Only a platform administrator can purge appraisals.", refused.Message);
Assert.Equal(2, f.Db.ConceptAppraisals.Count());
Assert.Single(f.Db.AuditEvents);
}
[Fact]
public async Task Unique_violation_with_no_surviving_row_is_not_swallowed()
{
var dbName = $"race-{Guid.NewGuid():N}";
var racer = new RacingInterceptor(dbName) { InsertRival = false };
await using var f = new Fixture(dbName, racer);
await f.AddSubject("ship-a");
racer.Subject = f.Db.ConceptSubjects.Single();
await Assert.ThrowsAsync<DbUpdateException>(
() => f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up));
}
[Fact]
public async Task A_changed_cast_restamps_tag_and_time()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var voter = f.Anonymous("device-one");
await f.Cast("ship-a", voter, ConceptStance.Up, IPAddress.Parse("198.51.100.9"));
var first = f.Db.ConceptAppraisals.Single().CastAtUtc;
f.Clock.Advance(TimeSpan.FromMinutes(5));
await f.Cast("ship-a", voter, ConceptStance.Down);
var row = f.Db.ConceptAppraisals.Single();
Assert.Equal(NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret), row.NetworkTag);
Assert.Equal(first.AddMinutes(5), row.CastAtUtc);
}
[Fact]
public async Task Unknown_client_address_leaves_no_tag_and_purge_never_matches_the_empty_tag()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
await f.Service.CastAsync(Collection, "ship-a", f.Anonymous("device-one"), ConceptStance.Up, null, default);
Assert.Equal(string.Empty, f.Db.ConceptAppraisals.Single().NetworkTag);
var t0 = f.Clock.GetUtcNow();
Assert.Equal(0, await f.Service.PurgeAsync(f.Admin(), string.Empty, t0.AddDays(-1), t0.AddDays(1), default));
Assert.Single(f.Db.ConceptAppraisals);
Assert.Empty(f.Db.AuditEvents);
}
[Fact]
public async Task Purge_window_excludes_its_end_and_includes_its_start()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var t0 = f.Clock.GetUtcNow();
await f.Cast("ship-a", f.Anonymous("device-one"), ConceptStance.Up);
var tag = NetworkPrefix.Tag(Client, 32, 56, Fixture.Secret);
Assert.Equal(0, await f.Service.PurgeAsync(f.Admin(), tag, t0.AddDays(-1), t0, default));
Assert.Equal(1, await f.Service.PurgeAsync(f.Admin(), tag, t0, t0.AddSeconds(1), default));
}
[Fact]
public async Task Retract_by_no_voter_or_on_unknown_subject_is_a_no_op()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
var voter = f.Anonymous("device-one");
await f.Cast("ship-a", voter, ConceptStance.Up);
Assert.False(await f.Service.RetractAsync(Collection, "ship-a", null, default));
Assert.False(await f.Service.RetractAsync(Collection, "ship-missing", voter, default));
Assert.False(await f.Service.RetractAsync("other-collection", "ship-a", voter, default));
Assert.Single(f.Db.ConceptAppraisals);
}
[Fact]
public async Task Subject_page_view_counts_only_its_own_subject_and_shows_the_voters_own_stance()
{
await using var f = new Fixture();
await f.AddSubject("ship-a");
await f.AddSubject("ship-b");
await f.AddSubject("ship-old", retired: true);
var mine = f.Anonymous("device-one");
await f.Cast("ship-a", mine, ConceptStance.Down);
await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Active), null), ConceptStance.Up);
await f.Cast("ship-a", VoterIdentity.For(f.Account(MembershipStatus.Authenticated), null), ConceptStance.Up);
await f.Cast("ship-b", f.Anonymous("device-two"), ConceptStance.Up);
var view = await f.Service.GetSubjectAsync(Collection, "ship-a", mine, default);
var stranger = await f.Service.GetSubjectAsync(Collection, "ship-a", f.Anonymous("device-nine"), default);
var anon = await f.Service.GetSubjectAsync(Collection, "ship-a", null, default);
Assert.Equal(new ConceptTally(2, 1, new StanceCounts(1, 0)), view!.Tally);
Assert.Equal(ConceptStance.Down, view.YourStance);
Assert.Null(stranger!.YourStance);
Assert.Null(anon!.YourStance);
Assert.Null(await f.Service.GetSubjectAsync(Collection, "ship-old", mine, default));
Assert.Null(await f.Service.GetSubjectAsync(Collection, "ship-missing", mine, default));
}
[Fact]
public async Task Collection_and_key_must_both_be_slugs_even_when_a_row_exists()
{
await using var f = new Fixture();
await f.AddSubject("Ship_A");
f.Db.ConceptSubjects.Add(new ConceptSubject
{
Collection = "Bad_Collection",
Key = "ship-a",
Title = "t",
RegisteredAtUtc = f.Clock.GetUtcNow()
});
await f.Db.SaveChangesAsync();
var voter = f.Anonymous("device-one");
Assert.Equal(CastResult.UnknownSubject, await f.Cast("Ship_A", voter, ConceptStance.Up));
Assert.Equal(
CastResult.UnknownSubject,
await f.Service.CastAsync("Bad_Collection", "ship-a", voter, ConceptStance.Up, Client, default));
Assert.Empty(f.Db.ConceptAppraisals);
}
[Fact]
public async Task Voter_keys_have_their_documented_shape()
{
await using var f = new Fixture();
var account = f.Account(MembershipStatus.Authenticated);
Assert.Equal("device:abc-DEF_123", VoterIdentity.For(CurrentBifrostActor.Anonymous, "abc-DEF_123")!.Key);
Assert.Equal("account:" + account.UserAccount!.Id.ToString("N"), VoterIdentity.For(account, "ignored")!.Key);
Assert.Null(VoterIdentity.For(CurrentBifrostActor.Anonymous, new string('a', 101)));
Assert.NotNull(VoterIdentity.For(CurrentBifrostActor.Anonymous, new string('a', 100)));
}
private sealed class RacingInterceptor(string dbName) : SaveChangesInterceptor
{
public bool InsertRival { get; init; } = true;
public ConceptSubject? Subject { get; set; }
public int Fired { get; private set; }
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
var adding = eventData.Context!.ChangeTracker.Entries<ConceptAppraisal>()
.Any(e => e.State == EntityState.Added);
if (Subject is null || !adding || Fired > 0)
{
return result;
}
Fired++;
if (!InsertRival)
{
throw new DbUpdateException("conflict", new PostgresException("duplicate", "ERROR", "ERROR", "23505"));
}
var options = new DbContextOptionsBuilder<BifrostDbContext>().UseInMemoryDatabase(dbName).Options;
await using (var other = new BifrostDbContext(options))
{
other.ConceptAppraisals.Add(new ConceptAppraisal
{
SubjectId = Subject.Id,
VoterKey = "device:device-one",
Stance = ConceptStance.Up,
VoterClass = VoterClass.Anonymous,
CastAtUtc = DateTimeOffset.UtcNow
});
await other.SaveChangesAsync(cancellationToken);
}
throw new DbUpdateException(
"conflict",
new PostgresException("duplicate", "ERROR", "ERROR", "23505"));
}
}
private sealed class Fixture : IAsyncDisposable
{
public const string Secret = "test-tag-secret";
public Fixture(string? dbName = null, IInterceptor? interceptor = null)
{
var builder = new DbContextOptionsBuilder<BifrostDbContext>()
.UseInMemoryDatabase(dbName ?? $"concept-{Guid.NewGuid():N}");
if (interceptor is not null)
{
builder.AddInterceptors(interceptor);
}
Db = new BifrostDbContext(builder.Options);
Clock = new ManualClock(new DateTimeOffset(2026, 10, 10, 12, 0, 0, TimeSpan.Zero));
Service = new ConceptAppraisalService(
Db,
new AuditTrailService(Db, Clock),
Clock,
Options.Create(new ConceptVoteOptions { NetworkTagSecret = Secret }));
}
public BifrostDbContext Db { get; }
public ManualClock Clock { get; }
public ConceptAppraisalService Service { get; }
public async Task AddSubject(string key, bool retired = false)
{
Db.ConceptSubjects.Add(new ConceptSubject
{
Collection = Collection,
Key = key,
Title = key,
RegisteredAtUtc = Clock.GetUtcNow(),
RetiredAtUtc = retired ? Clock.GetUtcNow() : null
});
await Db.SaveChangesAsync();
}
public Task<CastResult> Cast(string key, VoterIdentity? voter, ConceptStance stance, IPAddress? client = null) =>
Service.CastAsync(Collection, key, voter, stance, client ?? Client, default);
public VoterIdentity Anonymous(string deviceId) =>
VoterIdentity.For(CurrentBifrostActor.Anonymous, deviceId)!;
public CurrentBifrostActor Account(MembershipStatus status, decimal weight = 0m, bool admin = false)
{
var user = new UserAccount
{
DisplayName = "voter",
GitHubLogin = "voter",
NormalizedGitHubLogin = "voter",
CreatedAtUtc = Clock.GetUtcNow(),
LastSeenAtUtc = Clock.GetUtcNow()
};
user.Membership = new Membership
{
UserAccountId = user.Id,
Status = status,
IsPlatformAdmin = admin,
CreatedAtUtc = Clock.GetUtcNow()
};
if (weight > 0m)
{
user.Membership.TierSnapshots.Add(new TierSnapshot { Weight = weight, IsCurrent = true });
}
Db.UserAccounts.Add(user);
Db.SaveChanges();
return new CurrentBifrostActor(true, user);
}
public CurrentBifrostActor Admin()
{
return Account(MembershipStatus.Active, admin: true);
}
public ValueTask DisposeAsync() => Db.DisposeAsync();
}
private sealed class ManualClock(DateTimeOffset start) : TimeProvider
{
private DateTimeOffset _now = start;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now += by;
}
}

View file

@ -0,0 +1,673 @@
using System.Diagnostics;
using System.Net;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using Bifrost.Web.Configuration;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.Shared;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Tests;
public sealed class ConceptSubjectRegistryTests
{
private const string Collection = "aetheria-concept-mill";
private static readonly string[] None = [];
private static string FixturePath => Path.Combine(AppContext.BaseDirectory, "Fixtures", "mill-three-ships.json");
private static IReadOnlyList<ManifestShip> Parse(string json) =>
ConceptSubjectRegistry.ParseManifest(Encoding.UTF8.GetBytes(json));
private static IReadOnlyList<ManifestShip> Fixture() =>
ConceptSubjectRegistry.ParseManifest(File.ReadAllBytes(FixturePath));
[Fact]
public async Task Rerun_of_the_same_manifest_is_a_no_op()
{
using var world = new World();
var first = await world.Apply(Fixture());
Assert.All(first.Entries, e => Assert.Equal(SubjectChange.Add, e.Change));
Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.EntityType == nameof(ConceptSubject))));
var before = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync());
world.Clock.Advance(TimeSpan.FromHours(1));
var second = await world.Apply(Fixture());
Assert.All(second.Entries, e => Assert.Equal(SubjectChange.Unchanged, e.Change));
Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync()));
var after = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync());
Assert.Equal(before.Select(x => (x.Id, x.Title, x.RegisteredAtUtc, x.RetiredAtUtc)),
after.Select(x => (x.Id, x.Title, x.RegisteredAtUtc, x.RetiredAtUtc)));
}
[Fact]
public async Task Apply_writes_one_host_command_audit_event_per_change_and_none_for_a_plan()
{
using var world = new World();
var plan = await world.Plan(Fixture());
Assert.All(plan.Entries, e => Assert.Equal(SubjectChange.Add, e.Change));
Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync()));
Assert.Empty(await world.Fresh(db => db.AuditEvents.ToListAsync()));
await world.Apply(Fixture());
var events = await world.Fresh(db => db.AuditEvents.ToListAsync());
var subjectIds = (await world.Fresh(db => db.ConceptSubjects.ToListAsync())).Select(x => x.Id.ToString()).ToHashSet();
Assert.Equal(3, events.Count);
Assert.All(events, e =>
{
Assert.Equal(nameof(ConceptSubject), e.EntityType);
Assert.Equal("Add", e.Action);
Assert.Null(e.ActorUserAccountId);
Assert.Contains("actor=host-command", e.Detail);
Assert.Contains(e.EntityId, subjectIds);
});
}
[Fact]
public async Task Rename_changes_title_and_keeps_votes()
{
using var world = new World();
await world.Apply(Fixture());
await world.Cast("bison", ConceptStance.Up);
await world.Cast("kestrel", ConceptStance.Down);
var renamed = Fixture().Select(s => s.Key == "bison" ? s with { Title = "Bison Prime" } : s).ToList();
var plan = await world.Apply(renamed);
Assert.Equal(SubjectChange.Rename, plan.Entries.Single(e => e.Key == "bison").Change);
Assert.Equal(2, plan.Entries.Count(e => e.Change == SubjectChange.Unchanged));
var subject = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "bison"));
Assert.Equal("Bison Prime", subject.Title);
Assert.Null(subject.RetiredAtUtc);
Assert.Equal(2, await world.Fresh(db => db.ConceptAppraisals.CountAsync()));
var view = await world.Fresh(db => world.Appraisals(db).GetSubjectAsync(Collection, "bison", null, default));
Assert.Equal("Bison Prime", view!.Title);
Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), view.Tally);
Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Rename")));
}
[Fact]
public async Task Absent_key_is_retired_only_when_named()
{
using var world = new World();
await world.Apply(Fixture());
await world.Cast("kestrel", ConceptStance.Up);
var smaller = Fixture().Where(s => s.Key != "kestrel").ToList();
var unnamed = await world.Apply(smaller);
Assert.Equal(SubjectChange.Absent, unnamed.Entries.Single(e => e.Key == "kestrel").Change);
Assert.Null((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc);
Assert.Contains("kestrel", (await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default))).Keys);
var named = await world.Apply(smaller, ["kestrel"]);
Assert.Equal(SubjectChange.Retire, named.Entries.Single(e => e.Key == "kestrel").Change);
var retired = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"));
Assert.NotNull(retired.RetiredAtUtc);
Assert.Equal(1, await world.Fresh(db => db.ConceptAppraisals.CountAsync()));
Assert.DoesNotContain("kestrel", (await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default))).Keys);
Assert.Equal(CastResult.RetiredSubject, await world.Cast("kestrel", ConceptStance.Down));
Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Retire")));
}
[Theory]
[InlineData("bison")]
[InlineData("never-registered")]
public async Task Retire_naming_a_key_that_is_not_registered_live_and_absent_is_refused_whole(string named)
{
using var world = new World();
await world.Apply(Fixture());
var renamed = Fixture().Select(s => s.Key == "kestrel" ? s with { Title = "Kestrel II" } : s).ToList();
var refusal = await Assert.ThrowsAsync<ManifestRefusedException>(() => world.Apply(renamed, [named]));
Assert.Equal(("retire", "NotAbsent"), (refusal.Field, refusal.Code));
Assert.DoesNotContain(named, refusal.Message);
Assert.Equal("Kestrel", (await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).Title);
Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync()));
}
[Fact]
public async Task Rerunning_the_same_retire_list_converges_and_writes_nothing()
{
using var world = new World();
await world.Apply(Fixture());
var smaller = Fixture().Where(s => s.Key == "bison").ToList();
var first = await world.Apply(smaller, ["kestrel", "ibis-mk2"]);
Assert.Equal([SubjectChange.Unchanged, SubjectChange.Retire, SubjectChange.Retire], first.Entries.Select(e => e.Change));
var before = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync());
var audits = await world.Fresh(db => db.AuditEvents.CountAsync());
world.Clock.Advance(TimeSpan.FromHours(1));
var second = await world.Apply(smaller, ["kestrel", "ibis-mk2"]);
Assert.Equal(
[("bison", SubjectChange.Unchanged), ("ibis-mk2", SubjectChange.Retired), ("kestrel", SubjectChange.Retired)],
second.Entries.Select(e => (e.Key, e.Change)));
Assert.Equal(audits, await world.Fresh(db => db.AuditEvents.CountAsync()));
var after = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().OrderBy(x => x.Key).ToListAsync());
Assert.Equal(before.Select(x => (x.Id, x.Title, x.RetiredAtUtc)), after.Select(x => (x.Id, x.Title, x.RetiredAtUtc)));
}
[Fact]
public async Task Rerunning_a_retire_list_after_a_partial_apply_retires_the_rest()
{
using var world = new World();
await world.Apply(Fixture());
var smaller = Fixture().Where(s => s.Key == "bison").ToList();
await world.Apply(smaller, ["kestrel"]);
var plan = await world.Apply(smaller, ["kestrel", "ibis-mk2"]);
Assert.Equal(SubjectChange.Retired, plan.Entries.Single(e => e.Key == "kestrel").Change);
Assert.Equal(SubjectChange.Retire, plan.Entries.Single(e => e.Key == "ibis-mk2").Change);
var rows = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().ToListAsync());
Assert.Equal(["ibis-mk2", "kestrel"], rows.Where(r => r.RetiredAtUtc is not null).Select(r => r.Key).Order());
Assert.Equal(2, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Retire")));
}
[Fact]
public async Task Naming_a_retired_key_that_the_manifest_lists_again_is_still_refused()
{
using var world = new World();
await world.Apply(Fixture());
await world.Apply(Fixture().Where(s => s.Key != "kestrel").ToList(), ["kestrel"]);
var refusal = await Assert.ThrowsAsync<ManifestRefusedException>(() => world.Apply(Fixture(), ["kestrel"]));
Assert.Equal(("retire", "NotAbsent"), (refusal.Field, refusal.Code));
Assert.NotNull((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc);
}
[Fact]
public async Task Re_registering_a_retired_key_revives_it()
{
using var world = new World();
await world.Apply(Fixture());
await world.Cast("kestrel", ConceptStance.Up);
await world.Apply(Fixture().Where(s => s.Key != "kestrel").ToList(), ["kestrel"]);
var plan = await world.Apply(Fixture());
Assert.Equal(SubjectChange.Revive, plan.Entries.Single(e => e.Key == "kestrel").Change);
var subject = await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"));
Assert.Null(subject.RetiredAtUtc);
var tallies = await world.Fresh(db => world.Appraisals(db).GetTalliesAsync(Collection, default));
Assert.Equal(new ConceptTally(1, 0, new StanceCounts(0, 0)), tallies["kestrel"]);
Assert.Equal(1, await world.Fresh(db => db.AuditEvents.CountAsync(x => x.Action == "Revive")));
}
[Theory]
[InlineData("""{"ships":[{"id":"a","name":"A"},{"id":"a","name":"B"}]}""", "ships[1].id", "Duplicate")]
[InlineData("""{"ships":[{"id":"Upper","name":"A"}]}""", "ships[0].id", "InvalidKey")]
[InlineData("""{"ships":[{"id":"a/b","name":"A"}]}""", "ships[0].id", "InvalidKey")]
[InlineData("""{"ships":[{"id":"ship-a\n","name":"A"}]}""", "ships[0].id", "InvalidKey")]
[InlineData("""{"ships":[{"id":"","name":"A"}]}""", "ships[0].id", "InvalidKey")]
[InlineData("""{"ships":[{"id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","name":"A"}]}""", "ships[0].id", "InvalidKey")]
[InlineData("""{"ships":[{"id":"a"}]}""", "ships[0].name", "Missing")]
[InlineData("""{"ships":[{"id":"a","name":7}]}""", "ships[0].name", "NotString")]
[InlineData("""{"ships":[{"id":"a","name":" "}]}""", "ships[0].name", "InvalidTitle")]
[InlineData("""{"ships":[{"id":"a","name":"bad\ntitle"}]}""", "ships[0].name", "InvalidTitle")]
[InlineData("""{"ships":["a"]}""", "ships[0].id", "Missing")]
[InlineData("""{"fleet":[]}""", "ships", "Missing")]
[InlineData("[]", "ships", "Missing")]
[InlineData("""{"ships":5}""", "ships", "Missing")]
[InlineData("not json", "manifest", "NotJson")]
public async Task Malformed_manifest_is_refused_whole(string json, string field, string code)
{
using var world = new World();
var refusal = Assert.Throws<ManifestRefusedException>(() => Parse(json));
Assert.Equal((field, code), (refusal.Field, refusal.Code));
Assert.Equal($"{field}: {code}", refusal.Message);
Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync()));
}
[Fact]
public void A_title_of_exactly_the_column_width_is_accepted_and_one_more_is_refused()
{
var fits = new string('t', ConceptSubjectRegistry.MaxTitleLength);
Assert.Equal(fits, Parse($$"""{"ships":[{"id":"a","name":"{{fits}}"}]}""").Single().Title);
var refusal = Assert.Throws<ManifestRefusedException>(() =>
Parse($$"""{"ships":[{"id":"a","name":"{{fits}}x"}]}"""));
Assert.Equal(("ships[0].name", "TooLong"), (refusal.Field, refusal.Code));
}
private static readonly JsonSerializerOptions Raw = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
[Theory]
[InlineData("\u202Eevil")]
[InlineData("a\u202Ab")]
[InlineData("a\u2066b")]
[InlineData("a\u2069b")]
[InlineData("a\u061Cb")]
[InlineData("a\u200Eb")]
[InlineData("a\u200Fb")]
[InlineData("a\u2028b")]
[InlineData("a\u2029b")]
[InlineData("a\u200Bb")]
[InlineData("a\u200Cb")]
[InlineData("a\u200Db")]
[InlineData("a\u2060b")]
[InlineData("a\uFEFFb")]
[InlineData("a\u0085b")]
[InlineData("a\tb")]
[InlineData(" Ibis")]
[InlineData("Ibis ")]
[InlineData("\u00A0Ibis")]
[InlineData("Ibis\u00A0")]
[InlineData("\u3000Ibis")]
[InlineData("!!!")]
public void A_title_with_a_format_character_edge_whitespace_or_no_letter_is_refused(string title)
{
var json = """{"ships":[{"id":"a","name":""" + JsonSerializer.Serialize(title, Raw) + "}]}";
var refusal = Assert.Throws<ManifestRefusedException>(() => Parse(json));
Assert.Equal(("ships[0].name", "InvalidTitle"), (refusal.Field, refusal.Code));
}
[Theory]
[InlineData("Ibis Mk II")]
[InlineData("Ünïcode Ship")]
[InlineData("Ship ★ 7")]
[InlineData("7")]
[InlineData("Kestrel-2: the \"Bold\"")]
public void An_ordinary_title_is_accepted(string title)
{
var json = """{"ships":[{"id":"a","name":""" + JsonSerializer.Serialize(title, Raw) + "}]}";
Assert.Equal(title, Parse(json).Single().Title);
}
[Theory]
[InlineData("""{"ships":[{"id":"a","name":"x\ud800y"}]}""", "ships[0].name", "BadText")]
[InlineData("""{"ships":[{"id":"a\udc00","name":"x"}]}""", "ships[0].id", "BadText")]
[InlineData("""{"ships":[{"id":"a","name":"x","na\ud800me":"y"}]}""", "manifest", "BadText")]
public void A_lone_surrogate_is_refused_not_thrown(string json, string field, string code)
{
var refusal = Assert.Throws<ManifestRefusedException>(() => Parse(json));
Assert.Equal((field, code), (refusal.Field, refusal.Code));
}
[Fact]
public void Malformed_utf8_anywhere_in_the_file_is_refused()
{
byte[] inName = [.. "{\"ships\":[{\"id\":\"a\",\"name\":\"X"u8, 0xC3, 0x28, .. "\"}]}"u8];
byte[] inUnreadValue = [.. "{\"ships\":[],\"x\":\"X"u8, 0xFF, .. "\"}"u8];
foreach (var bytes in new[] { inName, inUnreadValue })
{
var refusal = Assert.Throws<ManifestRefusedException>(() => ConceptSubjectRegistry.ParseManifest(bytes));
Assert.Equal(("manifest", "NotUtf8"), (refusal.Field, refusal.Code));
}
}
[Theory]
[InlineData("""{"ships":[{"id":"a","id":"b","name":"A"}]}""")]
[InlineData("""{"ships":[{"id":"a","name":"A","name":"B"}]}""")]
[InlineData("""{"ships":[],"ships":[]}""")]
[InlineData("""{"ships":[{"id":"a","name":"A"}],"votes":{"x":1,"x":2}}""")]
[InlineData("""{"ships":[{"id":"a","name":"A","meta":{"k":[{"z":1,"z":1}]}}]}""")]
[InlineData("""{"ships":[{"id":"a","name":"A","id":"b"}]}""")]
public void A_duplicate_json_property_is_refused_wherever_it_sits(string json)
{
var refusal = Assert.Throws<ManifestRefusedException>(() => Parse(json));
Assert.Equal(("manifest", "DuplicateProperty"), (refusal.Field, refusal.Code));
}
[Fact]
public void The_same_property_name_in_sibling_and_nested_objects_is_not_a_duplicate()
{
var ships = Parse("""{"id":"top","ships":[{"id":"a","name":"A","meta":{"id":"x","name":"y"}},{"id":"b","name":"B"}]}""");
Assert.Equal(["a", "b"], ships.Select(s => s.Key));
}
[Fact]
public void A_manifest_of_exactly_the_byte_bound_is_accepted_and_one_more_byte_is_refused()
{
const string head = """{"ships":[{"id":"a","name":"A"}]}""";
var fits = Encoding.UTF8.GetBytes(head + new string(' ', ConceptSubjectRegistry.MaxManifestBytes - head.Length));
Assert.Equal(ConceptSubjectRegistry.MaxManifestBytes, fits.Length);
Assert.Single(ConceptSubjectRegistry.ParseManifest(fits));
var refusal = Assert.Throws<ManifestRefusedException>(() => ConceptSubjectRegistry.ParseManifest(fits.Append((byte)' ').ToArray()));
Assert.Equal(("manifest", "TooLarge"), (refusal.Field, refusal.Code));
}
[Fact]
public void A_manifest_of_exactly_the_ship_bound_is_accepted_and_one_more_ship_is_refused()
{
static string Fleet(int count) =>
"""{"ships":[""" + string.Join(",", Enumerable.Range(0, count).Select(i => $$"""{"id":"s{{i}}","name":"S{{i}}"}""")) + "]}";
Assert.Equal(ConceptSubjectRegistry.MaxShips, Parse(Fleet(ConceptSubjectRegistry.MaxShips)).Count);
var refusal = Assert.Throws<ManifestRefusedException>(() => Parse(Fleet(ConceptSubjectRegistry.MaxShips + 1)));
Assert.Equal(("ships", "TooMany"), (refusal.Field, refusal.Code));
}
[Fact]
public async Task Absent_keys_are_planned_in_key_order()
{
using var world = new World();
await world.Apply(Fixture());
var plan = await world.Plan([new ManifestShip("zzz-new", "New")]);
Assert.Equal(["zzz-new", "bison", "ibis-mk2", "kestrel"], plan.Entries.Select(e => e.Key));
}
[Fact]
public async Task A_bad_entry_after_good_ones_refuses_the_file_and_writes_no_row()
{
using var world = new World();
var json = """{"ships":[{"id":"good","name":"Good"},{"id":"ship-a\n","name":"Bad"}]}""";
Assert.Throws<ManifestRefusedException>(() => Parse(json));
Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync()));
}
[Fact]
public async Task Refusal_never_echoes_the_offending_value()
{
const string canary = "CANARY-secret/value";
var json = $$"""{"ships":[{"id":"{{canary}}","name":"A"}]}""";
var refusal = Assert.Throws<ManifestRefusedException>(() => Parse(json));
Assert.DoesNotContain("CANARY", refusal.Message);
using var world = new World();
var collection = await Assert.ThrowsAsync<ManifestRefusedException>(() => world.Registry(world.NewDb()).PlanAsync(canary, [], None, default));
Assert.DoesNotContain("CANARY", collection.Message);
Assert.Equal(("collection", "InvalidKey"), (collection.Field, collection.Code));
var retire = await Assert.ThrowsAsync<ManifestRefusedException>(() => world.Registry(world.NewDb()).PlanAsync(Collection, [], [canary], default));
Assert.DoesNotContain("CANARY", retire.Message);
Assert.Equal(("retire", "InvalidKey"), (retire.Field, retire.Code));
}
[Theory]
[InlineData("ship-a\n")]
[InlineData("ship-a\r\n")]
[InlineData("ship-a ")]
public async Task The_key_grammar_rejects_a_trailing_line_feed_wherever_it_is_reused(string forged)
{
using var world = new World();
await world.Apply(Fixture());
await world.Registry(world.NewDb()).ApplyAsync(Collection, [new ManifestShip("ship-a", "A")], None, default);
Assert.False(ConceptKey.IsValid(forged));
Assert.False(ConceptKey.IsValid("key\n"));
Assert.True(ConceptKey.IsValid("ship-a"));
Assert.Equal(CastResult.UnknownSubject, await world.Cast(forged, ConceptStance.Up));
Assert.Null(await world.Fresh(db => world.Appraisals(db).GetSubjectAsync(Collection, forged, null, default)));
await Assert.ThrowsAsync<ManifestRefusedException>(() =>
world.Registry(world.NewDb()).PlanAsync(Collection + "\n", [], None, default));
await Assert.ThrowsAsync<ManifestRefusedException>(() =>
world.Registry(world.NewDb()).PlanAsync(Collection, [], ["ship-a\n"], default));
Assert.Empty(await world.Fresh(db => db.ConceptAppraisals.ToListAsync()));
}
[Fact]
public async Task Host_command_plans_and_applies_with_exit_codes()
{
using var world = new World();
var services = world.Services();
var plan = new StringWriter();
Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["plan", Collection, FixturePath], services, plan));
Assert.Contains("add bison", plan.ToString());
Assert.Contains("add ibis-mk2", plan.ToString());
Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync()));
Assert.Empty(await world.Fresh(db => db.AuditEvents.ToListAsync()));
var apply = new StringWriter();
Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, apply));
Assert.Equal(3, await world.Fresh(db => db.ConceptSubjects.CountAsync()));
var again = new StringWriter();
Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, again));
Assert.Contains("unchanged bison", again.ToString());
Assert.Equal(3, await world.Fresh(db => db.AuditEvents.CountAsync()));
}
[Fact]
public async Task Host_command_retire_flag_retires_the_named_absent_key()
{
using var world = new World();
var services = world.Services();
await ConceptCommand.RunAsync(["apply", Collection, FixturePath], services, new StringWriter());
var smaller = Path.Combine(world.Dir, "two.json");
await File.WriteAllTextAsync(smaller, """{"ships":[{"id":"bison","name":"Bison"},{"id":"ibis-mk2","name":"Ibis Mk II"}]}""");
var output = new StringWriter();
Assert.Equal(ConceptCommand.Ok, await ConceptCommand.RunAsync(["apply", Collection, smaller, "--retire", "kestrel"], services, output));
Assert.Contains("retire kestrel", output.ToString());
Assert.NotNull((await world.Fresh(db => db.ConceptSubjects.AsNoTracking().SingleAsync(x => x.Key == "kestrel"))).RetiredAtUtc);
}
[Fact]
public async Task Host_command_exits_non_zero_on_refusal_and_never_echoes_input()
{
using var world = new World();
var services = world.Services();
var bad = Path.Combine(world.Dir, "bad.json");
await File.WriteAllTextAsync(bad, """{"ships":[{"id":"CANARY/Id","name":"A"}]}""");
var refused = new StringWriter();
Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["apply", Collection, bad], services, refused));
Assert.Contains("ships[0].id", refused.ToString());
Assert.DoesNotContain("CANARY", refused.ToString());
var missing = new StringWriter();
Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["apply", Collection, Path.Combine(world.Dir, "CANARY-missing.json")], services, missing));
Assert.DoesNotContain("CANARY", missing.ToString());
Assert.Equal("refused manifest Unreadable", missing.ToString().Trim());
Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync()));
}
[Theory]
[InlineData("frobnicate", "c", "m")]
[InlineData("plan", "c")]
[InlineData("apply", "c", "m", "--bogus", "x")]
[InlineData("apply", "c", "m", "--retire")]
public async Task Host_command_unknown_subcommand_or_shape_exits_2(params string[] args)
{
using var world = new World();
var output = new StringWriter();
Assert.Equal(ConceptCommand.Usage, await ConceptCommand.RunAsync(args, world.Services(), output));
Assert.StartsWith("usage:", output.ToString());
Assert.Empty(await world.Fresh(db => db.ConceptSubjects.ToListAsync()));
Assert.Equal(ConceptCommand.Usage, await ConceptCommand.RunAsync([], world.Services(), new StringWriter()));
}
[Theory]
[InlineData("")]
[InlineData("CANARY\0path.json")]
public async Task Host_command_refuses_an_empty_or_nul_path(string path)
{
using var world = new World();
var output = new StringWriter();
Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, path], world.Services(), output));
Assert.Equal("refused manifest Unreadable", output.ToString().Trim());
}
[Fact]
public async Task Host_command_refuses_a_directory_and_an_oversized_file()
{
using var world = new World();
var big = Path.Combine(world.Dir, "big.json");
await File.WriteAllBytesAsync(big, new byte[ConceptSubjectRegistry.MaxManifestBytes + 1]);
var dir = new StringWriter();
Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, world.Dir], world.Services(), dir));
Assert.Equal("refused manifest Unreadable", dir.ToString().Trim());
var large = new StringWriter();
Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, big], world.Services(), large));
Assert.Equal("refused manifest TooLarge", large.ToString().Trim());
}
[Fact]
public async Task Host_command_refuses_a_manifest_that_is_not_utf8_text()
{
using var world = new World();
var path = Path.Combine(world.Dir, "latin1.json");
await File.WriteAllBytesAsync(path, [.. "{\"ships\":[{\"id\":\"a\",\"name\":\"CANARY-"u8, 0xE9, .. "\"}]}"u8]);
var output = new StringWriter();
Assert.Equal(ConceptCommand.Refused, await ConceptCommand.RunAsync(["plan", Collection, path], world.Services(), output));
Assert.Equal("refused manifest NotUtf8", output.ToString().Trim());
}
[Fact]
public async Task Built_command_exits_with_its_documented_codes_and_echoes_no_input()
{
using var world = new World();
var big = Path.Combine(world.Dir, "CANARYBIG.json");
await File.WriteAllBytesAsync(big, new byte[ConceptSubjectRegistry.MaxManifestBytes + 1]);
var bidi = Path.Combine(world.Dir, "CANARYBIDI.json");
await File.WriteAllTextAsync(bidi, """{"ships":[{"id":"a","name":"CANARYTITLE\u202E"}]}""");
var dup = Path.Combine(world.Dir, "CANARYDUP.json");
await File.WriteAllTextAsync(dup, """{"ships":[{"id":"CANARYID","id":"b","name":"A"}]}""");
var surrogate = Path.Combine(world.Dir, "CANARYSURR.json");
await File.WriteAllTextAsync(surrogate, """{"ships":[{"id":"a","name":"CANARYNAME\ud800"}]}""");
var latin1 = Path.Combine(world.Dir, "CANARYLATIN.json");
await File.WriteAllBytesAsync(latin1, [.. "{\"ships\":[{\"id\":\"a\",\"name\":\"CANARYBYTES"u8, 0xE9, .. "\"}]}"u8]);
var cases = new (string[] Args, int Code)[]
{
(["plan", Collection, ""], ConceptCommand.Refused),
(["plan", Collection, Path.Combine(world.Dir, "CANARYMISSING.json")], ConceptCommand.Refused),
(["plan", Collection, world.Dir], ConceptCommand.Refused),
(["plan", Collection, big], ConceptCommand.Refused),
(["plan", Collection, bidi], ConceptCommand.Refused),
(["apply", Collection, dup], ConceptCommand.Refused),
(["apply", Collection, surrogate], ConceptCommand.Refused),
(["plan", Collection, latin1], ConceptCommand.Refused),
(["plan", "CANARYCOLLECTION", bidi], ConceptCommand.Refused),
(["frob", "CANARYARG", "x"], ConceptCommand.Usage),
(["plan"], ConceptCommand.Usage),
};
foreach (var (args, code) in cases)
{
var result = await RunBuiltCommandAsync(args);
Assert.True(code == result.ExitCode, $"case {args[0]}/{args.Length}: exit {result.ExitCode}, stdout {result.StdOut}, stderr {result.StdErr}");
Assert.DoesNotContain("CANARY", result.StdOut + result.StdErr);
Assert.DoesNotContain("Unhandled exception", result.StdOut + result.StdErr);
}
}
private static async Task<(int ExitCode, string StdOut, string StdErr)> RunBuiltCommandAsync(string[] args)
{
var dll = Path.Combine(AppContext.BaseDirectory, "Bifrost.Web.dll");
Assert.True(File.Exists(Path.ChangeExtension(dll, ".runtimeconfig.json")), "Bifrost.Web.runtimeconfig.json is not beside the test assembly");
var start = new ProcessStartInfo("dotnet")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
foreach (var part in new[] { dll, "concepts" }.Concat(args))
{
start.ArgumentList.Add(part);
}
// Every case refuses before the database is touched; the password is a canary for leaks.
start.Environment["ConnectionStrings__Bifrost"] = "Host=127.0.0.1;Port=1;Database=x;Username=u;Password=CANARYPW";
using var process = Process.Start(start)!;
var stdout = process.StandardOutput.ReadToEndAsync();
var stderr = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(120));
await process.WaitForExitAsync(timeout.Token);
return (process.ExitCode, await stdout, await stderr);
}
private sealed class World : IDisposable
{
private readonly string _dbName = $"registry-{Guid.NewGuid():N}";
public World()
{
Dir = Path.Combine(Path.GetTempPath(), $"registry-{Guid.NewGuid():N}");
Directory.CreateDirectory(Dir);
}
public string Dir { get; }
public ManualClock Clock { get; } = new(new DateTimeOffset(2026, 10, 10, 12, 0, 0, TimeSpan.Zero));
public BifrostDbContext NewDb() =>
new(new DbContextOptionsBuilder<BifrostDbContext>().UseInMemoryDatabase(_dbName).Options);
public ConceptSubjectRegistry Registry(BifrostDbContext db) =>
new(db, new AuditTrailService(db, Clock), Clock);
public ConceptAppraisalService Appraisals(BifrostDbContext db) =>
new(db, new AuditTrailService(db, Clock), Clock,
Options.Create(new ConceptVoteOptions { NetworkTagSecret = "test-tag-secret" }));
public async Task<SubjectPlan> Apply(IReadOnlyList<ManifestShip> ships, string[]? retire = null)
{
await using var db = NewDb();
return await Registry(db).ApplyAsync(Collection, ships, retire ?? None, default);
}
public async Task<SubjectPlan> Plan(IReadOnlyList<ManifestShip> ships)
{
await using var db = NewDb();
return await Registry(db).PlanAsync(Collection, ships, None, default);
}
public async Task<CastResult> Cast(string key, ConceptStance stance)
{
await using var db = NewDb();
var voter = VoterIdentity.For(CurrentBifrostActor.Anonymous, "device-" + stance)!;
return await Appraisals(db).CastAsync(Collection, key, voter, stance, IPAddress.Parse("203.0.113.7"), default);
}
public async Task<T> Fresh<T>(Func<BifrostDbContext, Task<T>> read)
{
await using var db = NewDb();
return await read(db);
}
public IServiceProvider Services()
{
var services = new ServiceCollection();
services.AddDbContext<BifrostDbContext>(o => o.UseInMemoryDatabase(_dbName));
services.AddSingleton<TimeProvider>(Clock);
services.AddScoped<AuditTrailService>();
services.AddScoped<ConceptSubjectRegistry>();
return services.BuildServiceProvider();
}
public void Dispose() => Directory.Delete(Dir, true);
}
private sealed class ManualClock(DateTimeOffset start) : TimeProvider
{
private DateTimeOffset _now = start;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now += by;
}
}

View file

@ -0,0 +1,383 @@
using System.Net;
using System.Text.Json;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Features.Shared;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Bifrost.Web.Tests;
public sealed class ConceptTallyExportTests : IClassFixture<ConceptTallyExportTests.ExportFactory>
{
private const string AllowedOrigin = "https://aetheria.gamecult.org";
private const string Client = "203.0.113.7";
private readonly ExportFactory _factory;
public ConceptTallyExportTests(ExportFactory factory)
{
_factory = factory;
}
[Fact]
public async Task Anonymous_request_gets_the_public_counts()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
await AddSubject(collection, "beta");
await AddSubject(collection, "untouched");
await Cast(collection, "alpha", DeviceVoter("dev-one"), ConceptStance.Up);
await Cast(collection, "alpha", DeviceVoter("dev-two"), ConceptStance.Up);
await Cast(collection, "alpha", DeviceVoter("dev-three"), ConceptStance.Down);
await Cast(collection, "alpha", await AccountVoter(MembershipStatus.Active), ConceptStance.Up);
await Cast(collection, "alpha", await AccountVoter(MembershipStatus.Authenticated), ConceptStance.Down);
await Cast(collection, "beta", DeviceVoter("dev-one"), ConceptStance.Down);
using var response = await Get(collection);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
Assert.Equal(["alpha", "beta", "untouched"], body.EnumerateObject().Select(p => p.Name).Order().ToArray());
AssertCounts(body.GetProperty("alpha"), up: 3, down: 2, memberUp: 1, memberDown: 0);
AssertCounts(body.GetProperty("beta"), up: 0, down: 1, memberUp: 0, memberDown: 0);
AssertCounts(body.GetProperty("untouched"), up: 0, down: 0, memberUp: 0, memberDown: 0);
}
[Fact]
public async Task Payload_carries_no_voter_identity()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
var account = await AccountVoter(MembershipStatus.Authenticated);
var member = await AccountVoter(MembershipStatus.Active);
await Cast(collection, "alpha", DeviceVoter("canary-device-id-9c2e"), ConceptStance.Up);
await Cast(collection, "alpha", account, ConceptStance.Down);
await Cast(collection, "alpha", member, ConceptStance.Up);
using var response = await Get(collection);
var text = await response.Content.ReadAsStringAsync();
var alpha = JsonDocument.Parse(text).RootElement.GetProperty("alpha");
Assert.Equal(["down", "members", "up"], alpha.EnumerateObject().Select(p => p.Name).Order().ToArray());
Assert.Equal(["down", "up"], alpha.GetProperty("members").EnumerateObject().Select(p => p.Name).Order().ToArray());
string[] tags;
using (var scope = _factory.Services.CreateScope())
{
tags = await scope.ServiceProvider.GetRequiredService<BifrostDbContext>()
.ConceptAppraisals.Select(x => x.NetworkTag).Distinct().ToArrayAsync();
}
Assert.All(tags, tag => Assert.NotEmpty(tag));
var forbidden = new List<string> { "canary-device-id-9c2e", "device:", "account:", "voterkey", "networktag", "useraccount" };
forbidden.AddRange(tags);
foreach (var voter in new[] { account, member })
{
forbidden.Add(voter.UserAccountId!.Value.ToString("N"));
forbidden.Add(voter.UserAccountId!.Value.ToString("D"));
}
foreach (var value in forbidden)
{
Assert.DoesNotContain(value, text, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public async Task Cors_allows_only_the_configured_origin_without_credentials()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
using var allowed = await Get(collection, AllowedOrigin);
Assert.Equal(HttpStatusCode.OK, allowed.StatusCode);
Assert.Equal([AllowedOrigin], allowed.Headers.GetValues("Access-Control-Allow-Origin").ToArray());
Assert.False(allowed.Headers.Contains("Access-Control-Allow-Credentials"));
using var otherHost = await Get(collection, "https://evil.example");
Assert.False(otherHost.Headers.Contains("Access-Control-Allow-Origin"));
Assert.False(otherHost.Headers.Contains("Access-Control-Allow-Credentials"));
using var preflightGet = await Preflight(collection, AllowedOrigin, "GET");
Assert.Equal([AllowedOrigin], preflightGet.Headers.GetValues("Access-Control-Allow-Origin").ToArray());
Assert.False(preflightGet.Headers.Contains("Access-Control-Allow-Credentials"));
using var preflightPost = await Preflight(collection, AllowedOrigin, "POST");
Assert.False(preflightPost.Headers.Contains("Access-Control-Allow-Origin"));
Assert.False(preflightPost.Headers.Contains("Access-Control-Allow-Methods"));
}
[Theory]
[InlineData("https://aetheria.gamecult.org.evil.example")]
[InlineData("https://aetheria.gamecult.orgx")]
[InlineData("https://evilaetheria.gamecult.org")]
[InlineData("https://www.aetheria.gamecult.org")]
[InlineData("HTTPS://AETHERIA.GAMECULT.ORG")]
[InlineData("https://Aetheria.gamecult.org")]
[InlineData("https://aetheria.gamecult.org/")]
[InlineData("https://aetheria.gamecult.org:8443")]
[InlineData("http://aetheria.gamecult.org")]
[InlineData("null")]
[InlineData("*")]
[InlineData("")]
public async Task Cors_refuses_origins_that_only_resemble_the_configured_one(string origin)
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
using var response = await Get(collection, origin);
using var preflight = await Preflight(collection, origin, "GET");
Assert.False(response.Headers.Contains("Access-Control-Allow-Origin"));
Assert.False(preflight.Headers.Contains("Access-Control-Allow-Origin"));
}
[Fact]
public async Task Every_export_response_varies_by_origin()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
using var allowed = await Get(collection, AllowedOrigin);
using var lookalike = await Get(collection, "https://aetheria.gamecult.org.evil.example");
using var noOrigin = await Get(collection);
using var unknownAllowed = await Get(NewCollection(), AllowedOrigin);
using var unknownNoOrigin = await Get(NewCollection());
using var preflightAllowed = await Preflight(collection, AllowedOrigin, "GET");
using var preflightLookalike = await Preflight(collection, "https://evil.example", "GET");
using var preflightPost = await Preflight(collection, AllowedOrigin, "POST");
Assert.Equal(HttpStatusCode.OK, allowed.StatusCode);
Assert.Equal(HttpStatusCode.OK, lookalike.StatusCode);
Assert.Equal(HttpStatusCode.OK, noOrigin.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, unknownAllowed.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, unknownNoOrigin.StatusCode);
foreach (var response in new[] { allowed, lookalike, noOrigin, unknownAllowed, unknownNoOrigin, preflightAllowed, preflightLookalike, preflightPost })
{
Assert.Contains("Origin", VaryTokens(response));
}
}
[Fact]
public async Task Origin_is_added_to_an_existing_vary()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
using var client = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, $"/public/concepts/{collection}/tallies");
request.Headers.Add("X-Test-Anonymous", "true");
request.Headers.Add("X-Test-Vary", "Accept-Encoding");
request.Headers.TryAddWithoutValidation("Origin", AllowedOrigin);
using var response = await client.SendAsync(request);
Assert.Equal(["Accept-Encoding", "Origin"], VaryTokens(response).Order().ToArray());
}
private static string[] VaryTokens(HttpResponseMessage response) =>
response.Headers.Vary.ToArray();
[Fact]
public async Task Other_routes_carry_no_cors_headers()
{
using var client = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, "/healthz");
request.Headers.Add("Origin", AllowedOrigin);
using var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.False(response.Headers.Contains("Access-Control-Allow-Origin"));
}
[Fact]
public async Task Retired_subject_leaves_the_export()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
await AddSubject(collection, "gone");
await Cast(collection, "gone", DeviceVoter("dev-one"), ConceptStance.Up);
await Retire(collection, "gone");
using var response = await Get(collection);
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync()).RootElement;
Assert.Equal(["alpha"], body.EnumerateObject().Select(p => p.Name).ToArray());
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
Assert.Single(db.ConceptAppraisals.Where(x => x.Subject!.Key == "gone" && x.Subject.Collection == collection));
}
[Fact]
public async Task Cache_control_is_public_sixty_seconds()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
using var response = await Get(collection);
var cache = response.Headers.CacheControl;
Assert.NotNull(cache);
Assert.True(cache!.Public);
Assert.Equal(TimeSpan.FromSeconds(60), cache.MaxAge);
}
[Fact]
public async Task Unknown_collection_is_not_found()
{
using var response = await Get(NewCollection());
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task The_export_only_reads()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
int rowsBefore, auditBefore;
using (var scope = _factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
rowsBefore = db.ConceptAppraisals.Count();
auditBefore = db.AuditEvents.Count();
}
using var response = await Get(collection);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.False(response.Headers.Contains("Set-Cookie"));
using var after = _factory.Services.CreateScope();
var dbAfter = after.ServiceProvider.GetRequiredService<BifrostDbContext>();
Assert.Equal(rowsBefore, dbAfter.ConceptAppraisals.Count());
Assert.Equal(auditBefore, dbAfter.AuditEvents.Count());
}
private async Task<HttpResponseMessage> Get(string collection, string? origin = null)
{
using var client = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Get, $"/public/concepts/{collection}/tallies");
request.Headers.Add("X-Test-Anonymous", "true");
if (origin is not null)
{
request.Headers.TryAddWithoutValidation("Origin", origin);
}
return await client.SendAsync(request);
}
private async Task<HttpResponseMessage> Preflight(string collection, string origin, string method)
{
using var client = _factory.CreateClient();
using var request = new HttpRequestMessage(HttpMethod.Options, $"/public/concepts/{collection}/tallies");
request.Headers.Add("X-Test-Anonymous", "true");
request.Headers.TryAddWithoutValidation("Origin", origin);
request.Headers.Add("Access-Control-Request-Method", method);
return await client.SendAsync(request);
}
private static void AssertCounts(JsonElement tally, int up, int down, int memberUp, int memberDown)
{
Assert.Equal(up, tally.GetProperty("up").GetInt32());
Assert.Equal(down, tally.GetProperty("down").GetInt32());
Assert.Equal(memberUp, tally.GetProperty("members").GetProperty("up").GetInt32());
Assert.Equal(memberDown, tally.GetProperty("members").GetProperty("down").GetInt32());
}
private static string NewCollection() => "c" + Guid.NewGuid().ToString("N");
private static VoterIdentity DeviceVoter(string deviceId) =>
VoterIdentity.For(CurrentBifrostActor.Anonymous, deviceId)!;
private async Task<VoterIdentity> AccountVoter(MembershipStatus status)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var now = DateTimeOffset.UtcNow;
var user = new UserAccount
{
DisplayName = "voter",
GitHubLogin = "voter",
NormalizedGitHubLogin = "voter",
CreatedAtUtc = now,
LastSeenAtUtc = now,
Membership = new Membership { Status = status, CreatedAtUtc = now }
};
user.Membership.UserAccountId = user.Id;
db.UserAccounts.Add(user);
await db.SaveChangesAsync();
return VoterIdentity.For(new CurrentBifrostActor(true, user), null)!;
}
private async Task AddSubject(string collection, string key)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
db.ConceptSubjects.Add(new ConceptSubject
{
Collection = collection,
Key = key,
Title = key,
RegisteredAtUtc = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync();
}
private async Task Retire(string collection, string key)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var subject = await db.ConceptSubjects.SingleAsync(x => x.Collection == collection && x.Key == key);
subject.RetiredAtUtc = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
}
private async Task Cast(string collection, string key, VoterIdentity voter, ConceptStance stance)
{
using var scope = _factory.Services.CreateScope();
var result = await scope.ServiceProvider.GetRequiredService<ConceptAppraisalService>()
.CastAsync(collection, key, voter, stance, IPAddress.Parse(Client), default);
Assert.Equal(CastResult.Recorded, result);
}
// Stands in for any earlier middleware that already set a Vary: a request header names the value to preset.
private sealed class PriorVaryFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) => app =>
{
app.Use((context, nextMiddleware) =>
{
if (context.Request.Headers.TryGetValue("X-Test-Vary", out var prior))
{
context.Response.Headers.Vary = prior;
}
return nextMiddleware(context);
});
next(app);
};
}
public sealed class ExportFactory : TestWebApplicationFactory
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureServices(services => services.AddTransient<IStartupFilter, PriorVaryFilter>());
builder.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConceptVotes:AllowedOrigins:0"] = AllowedOrigin,
["ConceptVotes:NetworkTagSecret"] = "export-test-tag-secret-0123456789-abcdef"
}));
}
}
}

View file

@ -0,0 +1,673 @@
using System.Net;
using System.Text.RegularExpressions;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Bifrost.Web.Tests;
public sealed partial class ConceptVotePageTests : IClassFixture<TestWebApplicationFactory>
{
private const string CookieName = "bifrost_voter";
private const string Purpose = "Bifrost.ConceptVotes.DeviceToken.v1";
private readonly TestWebApplicationFactory _factory;
public ConceptVotePageTests(TestWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task Anonymous_visitor_can_cast_change_and_retract()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
var visitor = NewVisitor(anonymous: true);
using var cast = await visitor.Post(collection, "alpha", "Up");
Assert.Equal(HttpStatusCode.Redirect, cast.StatusCode);
var setCookie = SetCookieHeader(cast, CookieName);
Assert.NotNull(setCookie);
var attributes = setCookie!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray();
Assert.Contains("httponly", attributes);
Assert.Contains("secure", attributes);
Assert.Contains("samesite=lax", attributes);
Assert.Contains("path=/mill", attributes);
Assert.Contains("max-age=31536000", attributes);
using (var page = await visitor.Get(collection, "alpha"))
{
var (everyone, members) = Counts(await page.Content.ReadAsStringAsync());
Assert.Equal("1 up, 0 down", everyone);
Assert.Equal("0 up, 0 down", members);
}
var firstRow = Assert.Single(await Rows(collection, "alpha"));
Assert.Equal(ConceptStance.Up, firstRow.Stance);
Assert.Equal(VoterClass.Anonymous, firstRow.VoterClass);
// The cookie from the first cast is the voter: casting again changes the row and mints nothing.
using var change = await visitor.Post(collection, "alpha", "Down");
Assert.Equal(HttpStatusCode.Redirect, change.StatusCode);
Assert.Null(SetCookieHeader(change, CookieName));
var changed = Assert.Single(await Rows(collection, "alpha"));
Assert.Equal(ConceptStance.Down, changed.Stance);
Assert.Equal(firstRow.VoterKey, changed.VoterKey);
using var retract = await visitor.Post(collection, "alpha", "Retract");
Assert.Equal(HttpStatusCode.Redirect, retract.StatusCode);
Assert.Null(SetCookieHeader(retract, CookieName));
Assert.Empty(await Rows(collection, "alpha"));
}
[Fact]
public async Task Get_never_sets_the_device_cookie()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
var visitor = NewVisitor(anonymous: true);
using (var first = await visitor.Get(collection, "alpha"))
{
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
Assert.Null(SetCookieHeader(first, CookieName));
}
using (var retract = await visitor.Post(collection, "alpha", "Retract"))
{
Assert.Equal(HttpStatusCode.Redirect, retract.StatusCode);
Assert.Null(SetCookieHeader(retract, CookieName));
}
Assert.False(visitor.Jar.ContainsKey(CookieName));
Assert.Empty(await Rows(collection, "alpha"));
using var cast = await visitor.Post(collection, "alpha", "Up");
Assert.NotNull(SetCookieHeader(cast, CookieName));
using var later = await visitor.Get(collection, "alpha");
Assert.Null(SetCookieHeader(later, CookieName));
}
[Fact]
public async Task Signed_in_vote_is_keyed_to_the_account()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
var visitor = NewVisitor(anonymous: false);
using var cast = await visitor.Post(collection, "alpha", "Up");
Assert.Equal(HttpStatusCode.Redirect, cast.StatusCode);
Assert.Null(SetCookieHeader(cast, CookieName));
var row = Assert.Single(await Rows(collection, "alpha"));
Assert.StartsWith("account:", row.VoterKey, StringComparison.Ordinal);
Assert.Equal(VoterClass.Member, row.VoterClass);
Assert.NotNull(row.UserAccountId);
using var page = await visitor.Get(collection, "alpha");
var (everyone, members) = Counts(await page.Content.ReadAsStringAsync());
Assert.Equal("1 up, 0 down", everyone);
Assert.Equal("1 up, 0 down", members);
Assert.Null(SetCookieHeader(page, CookieName));
}
private const string SiteOrigin = "https://localhost";
[Fact]
public async Task Cast_from_the_sites_own_origin_is_accepted_and_writes_one_row()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
var visitor = NewVisitor(anonymous: true);
using var byOrigin = await visitor.Post(collection, "alpha", "Up", origin: SiteOrigin);
Assert.Equal(HttpStatusCode.Redirect, byOrigin.StatusCode);
Assert.Single(await Rows(collection, "alpha"));
using var byFetchSite = await visitor.Post(collection, "alpha", "Down", origin: null, fetchSite: "same-origin");
Assert.Equal(HttpStatusCode.Redirect, byFetchSite.StatusCode);
Assert.Equal(ConceptStance.Down, Assert.Single(await Rows(collection, "alpha")).Stance);
}
[Theory]
[InlineData("https://localhost.evil.example")]
[InlineData("https://evil-localhost")]
[InlineData("https://sub.localhost")]
[InlineData("https://LOCALHOST")]
[InlineData("HTTPS://localhost")]
[InlineData("https://localhost:8443")]
[InlineData("http://localhost")]
[InlineData("null")]
[InlineData("https://localhost/")]
[InlineData("https://evil.example")]
public async Task Cast_from_any_other_origin_is_refused_even_when_fetch_site_says_same_origin(string origin)
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
var visitor = NewVisitor(anonymous: true);
foreach (var handler in new[] { "Up", "Down", "Retract" })
{
using var response = await visitor.Post(collection, "alpha", handler, origin: origin, fetchSite: "same-origin");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Null(SetCookieHeader(response, CookieName));
Assert.False(response.Headers.Contains("Set-Cookie"));
Assert.Equal(string.Empty, await response.Content.ReadAsStringAsync());
}
Assert.Empty(await Rows(collection, "alpha"));
}
[Fact]
public async Task Cast_with_the_origin_header_repeated_is_refused_even_when_every_value_is_the_sites()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
var visitor = NewVisitor(anonymous: true);
var request = new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/alpha?handler=Up")
{
Content = new FormUrlEncodedContent([])
};
request.Headers.TryAddWithoutValidation("Origin", [SiteOrigin, SiteOrigin]);
using var response = await visitor.Send(request);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Empty(await Rows(collection, "alpha"));
}
[Theory]
[InlineData(null)]
[InlineData("same-origin")]
public async Task Cast_with_an_empty_origin_header_is_refused_whatever_fetch_site_says(string? fetchSite)
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
// HttpClient drops a header whose value is empty, so the request is built on the server itself, where an
// Origin header with an empty value is present in the dictionary as a browser's would be.
var context = await _factory.Server.SendAsync(http =>
{
http.Request.Method = HttpMethods.Post;
http.Request.Scheme = "https";
http.Request.Host = new HostString("localhost");
http.Request.Path = $"/mill/{collection}/alpha";
http.Request.QueryString = new QueryString("?handler=Up");
http.Request.ContentType = "application/x-www-form-urlencoded";
http.Request.Headers["X-Test-Anonymous"] = "true";
http.Request.Headers["Origin"] = string.Empty;
if (fetchSite is not null)
{
http.Request.Headers["Sec-Fetch-Site"] = fetchSite;
}
});
Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode);
Assert.False(context.Response.Headers.ContainsKey("Set-Cookie"));
Assert.Empty(await Rows(collection, "alpha"));
}
[Theory]
[InlineData("same-origin", true)]
[InlineData("same-site", false)]
[InlineData("cross-site", false)]
[InlineData("none", false)]
[InlineData("Same-Origin", false)]
[InlineData("same-origin, cross-site", false)]
[InlineData(null, false)]
public async Task Cast_without_an_origin_header_needs_fetch_site_same_origin(string? fetchSite, bool accepted)
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
var visitor = NewVisitor(anonymous: true);
using var response = await visitor.Post(collection, "alpha", "Up", origin: null, fetchSite: fetchSite);
Assert.Equal(accepted ? HttpStatusCode.Redirect : HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(accepted ? 1 : 0, (await Rows(collection, "alpha")).Count);
}
[Fact]
public async Task Refusal_never_echoes_the_origin_or_fetch_site_values()
{
const string canary = "https://CANARY-origin-5e1c.example";
var logs = new CapturingLoggerProvider();
var factory = _factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs))));
var collection = NewCollection();
await AddSubject(collection, "alpha");
var visitor = NewVisitor(anonymous: true, factory);
using var byOrigin = await visitor.Post(collection, "alpha", "Up", origin: canary);
using var byFetchSite = await visitor.Post(collection, "alpha", "Up", origin: null, fetchSite: "CANARY-site-5e1c");
foreach (var response in new[] { byOrigin, byFetchSite })
{
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
var seen = await response.Content.ReadAsStringAsync()
+ string.Join("\n", response.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value)));
Assert.DoesNotContain("CANARY", seen, StringComparison.OrdinalIgnoreCase);
}
Assert.DoesNotContain(logs.Lines, line => line.Contains("CANARY", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task A_page_view_sets_no_cookie_at_all_and_a_cast_sets_only_the_device_cookie()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
var visitor = NewVisitor(anonymous: true);
using (var view = await visitor.Get(collection, "alpha"))
{
Assert.Equal(HttpStatusCode.OK, view.StatusCode);
Assert.False(view.Headers.Contains("Set-Cookie"));
var html = await view.Content.ReadAsStringAsync();
Assert.DoesNotContain("__RequestVerificationToken", html, StringComparison.Ordinal);
}
using var cast = await visitor.Post(collection, "alpha", "Up");
var cookies = cast.Headers.GetValues("Set-Cookie").ToArray();
Assert.Single(cookies);
Assert.StartsWith(CookieName + "=", cookies[0], StringComparison.Ordinal);
using var later = await visitor.Get(collection, "alpha");
Assert.False(later.Headers.Contains("Set-Cookie"));
}
[Fact]
public async Task A_request_that_selects_no_handler_is_a_client_error_and_never_a_500()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
var visitor = NewVisitor(anonymous: true);
using var noHandler = await visitor.Post(collection, "alpha", handler: null);
using var bogus = await visitor.Post(collection, "alpha", "CANARYQ9");
using var put = await visitor.Post(collection, "alpha", "Up", method: HttpMethod.Put);
using var delete = await visitor.Post(collection, "alpha", null, method: HttpMethod.Delete);
using var getBogus = await visitor.Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/alpha?handler=CANARYQ9"));
using var getUp = await visitor.Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/alpha?handler=Up"));
Assert.Equal(HttpStatusCode.BadRequest, noHandler.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, bogus.StatusCode);
Assert.Equal(HttpStatusCode.MethodNotAllowed, put.StatusCode);
Assert.Equal(HttpStatusCode.MethodNotAllowed, delete.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, getBogus.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, getUp.StatusCode);
foreach (var response in new[] { noHandler, bogus, put, delete, getBogus, getUp })
{
var status = (int)response.StatusCode;
Assert.InRange(status, 400, 499);
Assert.DoesNotContain("CANARYQ9", await response.Content.ReadAsStringAsync(), StringComparison.Ordinal);
Assert.False(response.Headers.Contains("Set-Cookie"));
}
Assert.Empty(await Rows(collection, "alpha"));
}
public enum Forgery
{
OneCharacterChanged,
BareDeviceId,
SameKeyRingOtherPurpose,
OtherKeyRingSamePurpose
}
[Theory]
[InlineData(Forgery.OneCharacterChanged)]
[InlineData(Forgery.BareDeviceId)]
[InlineData(Forgery.SameKeyRingOtherPurpose)]
[InlineData(Forgery.OtherKeyRingSamePurpose)]
public async Task Tampered_or_foreign_cookie_is_ignored_and_a_new_one_minted(Forgery kind)
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
var victim = NewVisitor(anonymous: true);
using (await victim.Post(collection, "alpha", "Up"))
{
}
var genuine = victim.Jar[CookieName];
var victimRow = Assert.Single(await Rows(collection, "alpha"));
var deviceId = victimRow.VoterKey["device:".Length..];
var forged = Forge(kind, genuine, deviceId);
Assert.NotEqual(genuine, forged);
if (kind is not Forgery.BareDeviceId)
{
Assert.Equal(genuine.Length, forged.Length);
Assert.Matches("^[A-Za-z0-9_-]+$", forged);
}
var attacker = NewVisitor(anonymous: true);
attacker.Jar[CookieName] = forged;
using var cast = await attacker.Post(collection, "alpha", "Down");
Assert.Equal(HttpStatusCode.Redirect, cast.StatusCode);
Assert.NotNull(SetCookieHeader(cast, CookieName));
Assert.NotEqual(forged, attacker.Jar[CookieName]);
Assert.NotEqual(genuine, attacker.Jar[CookieName]);
var rows = await Rows(collection, "alpha");
Assert.Equal(2, rows.Count);
var victimNow = Assert.Single(rows, x => x.VoterKey == victimRow.VoterKey);
Assert.Equal(ConceptStance.Up, victimNow.Stance);
var attackerRow = Assert.Single(rows, x => x.VoterKey != victimRow.VoterKey);
Assert.Equal(ConceptStance.Down, attackerRow.Stance);
Assert.Equal(VoterClass.Anonymous, attackerRow.VoterClass);
}
[Fact]
public async Task A_forged_cookie_value_is_echoed_by_no_response_and_no_log_line()
{
const string canary = "CANARY-forged-voter-cookie-7f3a";
var logs = new CapturingLoggerProvider();
var factory = _factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs))));
var collection = NewCollection();
await AddSubject(collection, "alpha");
var visitor = NewVisitor(anonymous: true, factory);
visitor.Jar[CookieName] = canary;
using var page = await visitor.Get(collection, "alpha");
using var cast = await visitor.Post(collection, "alpha", "Up");
foreach (var response in new[] { page, cast })
{
Assert.DoesNotContain(canary, await response.Content.ReadAsStringAsync(), StringComparison.Ordinal);
Assert.DoesNotContain(
canary,
string.Join("\n", response.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value))),
StringComparison.Ordinal);
}
Assert.NotEmpty(logs.Lines);
Assert.DoesNotContain(logs.Lines, line => line.Contains(canary, StringComparison.Ordinal));
}
[Fact]
public async Task Unknown_or_retired_key_is_404()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
await AddSubject(collection, "gone");
await Retire(collection, "gone");
var visitor = NewVisitor(anonymous: true);
foreach (var (c, k) in new[] { (collection, "gone"), (collection, "never-registered"), (NewCollection(), "alpha") })
{
using var get = await visitor.Get(c, k);
Assert.Equal(HttpStatusCode.NotFound, get.StatusCode);
foreach (var handler in new[] { "Up", "Down", "Retract" })
{
using var post = await visitor.Post(c, k, handler);
Assert.Equal(HttpStatusCode.NotFound, post.StatusCode);
Assert.Null(SetCookieHeader(post, CookieName));
}
}
Assert.Empty(await Rows(collection, "gone"));
Assert.Empty(await Rows(collection, "alpha"));
Assert.False(visitor.Jar.ContainsKey(CookieName));
}
[Fact]
public async Task Page_shows_counts_by_class_and_no_voter_identity()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
var memberId = Guid.NewGuid();
var accountId = Guid.NewGuid();
await AddRows(collection, "alpha",
Row("device:canary-device-id-4b1d", VoterClass.Anonymous, ConceptStance.Up, null, "canarytag-aaaa"),
Row("device:canary-device-id-5c2e", VoterClass.Anonymous, ConceptStance.Down, null, "canarytag-bbbb"),
Row("account:" + memberId.ToString("N"), VoterClass.Member, ConceptStance.Up, memberId, "canarytag-cccc"),
Row("account:" + accountId.ToString("N"), VoterClass.Account, ConceptStance.Down, accountId, "canarytag-dddd"));
var visitor = NewVisitor(anonymous: true);
using (await visitor.Post(collection, "alpha", "Up"))
{
}
using var page = await visitor.Get(collection, "alpha");
var html = await page.Content.ReadAsStringAsync();
var (everyone, members) = Counts(html);
Assert.Equal("3 up, 2 down", everyone);
Assert.Equal("1 up, 0 down", members);
Assert.Contains("Your vote: up.", html, StringComparison.Ordinal);
var visitorRow = Assert.Single(await Rows(collection, "alpha"), x => x.UserAccountId is null && !x.VoterKey.Contains("canary"));
var forbidden = new List<string>
{
"device:", "account:", "canary-device-id", "canarytag", visitorRow.VoterKey,
visitor.Jar[CookieName],
memberId.ToString("N"), memberId.ToString("D"), accountId.ToString("N"), accountId.ToString("D")
};
foreach (var value in forbidden)
{
Assert.DoesNotContain(value, html, StringComparison.OrdinalIgnoreCase);
}
Assert.True(page.Headers.CacheControl is { NoStore: true } or { NoCache: true });
var signedIn = NewVisitor(anonymous: false);
using var signedInPage = await signedIn.Get(collection, "alpha");
var signedInHtml = await signedInPage.Content.ReadAsStringAsync();
using var signedInCast = await signedIn.Post(collection, "alpha", "Down");
var accountRow = Assert.Single(
await Rows(collection, "alpha"),
x => x.UserAccountId is { } id && id != memberId && id != accountId);
using var afterCast = await signedIn.Get(collection, "alpha");
var afterHtml = await afterCast.Content.ReadAsStringAsync();
Assert.Contains("Your vote: down.", afterHtml, StringComparison.Ordinal);
foreach (var text in new[] { signedInHtml, afterHtml })
{
Assert.DoesNotContain("account:", text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(accountRow.UserAccountId!.Value.ToString("N"), text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(accountRow.UserAccountId!.Value.ToString("D"), text, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public async Task Cookie_notice_is_shown_to_anonymous_visitors_and_the_page_links_back_to_the_mill()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
using var anonymousPage = await NewVisitor(anonymous: true).Get(collection, "alpha");
var html = await anonymousPage.Content.ReadAsStringAsync();
using var signedInPage = await NewVisitor(anonymous: false).Get(collection, "alpha");
var signedInHtml = await signedInPage.Content.ReadAsStringAsync();
Assert.Contains("only purpose is to keep your one vote yours", html, StringComparison.Ordinal);
Assert.Contains("lasts a year", html, StringComparison.Ordinal);
Assert.Contains("carries nothing else", html, StringComparison.Ordinal);
Assert.DoesNotContain("only purpose is to keep your one vote yours", signedInHtml, StringComparison.Ordinal);
Assert.Contains(
"href=\"https://aetheria.gamecult.org/Game-Design/Concept-Mill#alpha\"",
html,
StringComparison.Ordinal);
}
[Fact]
public async Task Page_loads_no_script_and_posts_with_plain_forms()
{
var collection = NewCollection();
await AddSubject(collection, "alpha");
using var page = await NewVisitor(anonymous: true).Get(collection, "alpha");
var html = await page.Content.ReadAsStringAsync();
var main = html[html.IndexOf("<main", StringComparison.Ordinal)..html.IndexOf("</main>", StringComparison.Ordinal)];
Assert.DoesNotContain("<script", main, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("hx-post", main, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("hx-get", main, StringComparison.OrdinalIgnoreCase);
Assert.Contains("method=\"post\"", main, StringComparison.Ordinal);
}
private string Forge(Forgery kind, string genuine, string deviceId) => kind switch
{
Forgery.OneCharacterChanged => genuine[..(genuine.Length / 2)]
+ (genuine[genuine.Length / 2] == 'A' ? 'B' : 'A')
+ genuine[(genuine.Length / 2 + 1)..],
Forgery.BareDeviceId => deviceId,
Forgery.SameKeyRingOtherPurpose => _factory.Services.GetRequiredService<IDataProtectionProvider>()
.CreateProtector(Purpose + ".other").Protect(deviceId),
Forgery.OtherKeyRingSamePurpose => new EphemeralDataProtectionProvider()
.CreateProtector(Purpose).Protect(deviceId),
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
private static string NewCollection() => "c" + Guid.NewGuid().ToString("N");
private Visitor NewVisitor(bool anonymous, WebApplicationFactory<Program>? factory = null) =>
new((factory ?? _factory).CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
HandleCookies = false,
BaseAddress = new Uri("https://localhost")
}), anonymous);
private static string? SetCookieHeader(HttpResponseMessage response, string name) =>
response.Headers.TryGetValues("Set-Cookie", out var values)
? values.FirstOrDefault(x => x.StartsWith(name + "=", StringComparison.Ordinal))
: null;
private static (string Everyone, string Members) Counts(string html)
{
var cells = DefinitionCell().Matches(html).Select(m => m.Groups[1].Value.Trim()).ToArray();
Assert.Equal(2, cells.Length);
return (cells[0], cells[1]);
}
[GeneratedRegex("<dd>(.*?)</dd>", RegexOptions.Singleline)]
private static partial Regex DefinitionCell();
private async Task AddSubject(string collection, string key)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
db.ConceptSubjects.Add(new ConceptSubject
{
Collection = collection,
Key = key,
Title = key,
RegisteredAtUtc = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync();
}
private async Task Retire(string collection, string key)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var subject = await db.ConceptSubjects.SingleAsync(x => x.Collection == collection && x.Key == key);
subject.RetiredAtUtc = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
}
private static ConceptAppraisal Row(string key, VoterClass voterClass, ConceptStance stance, Guid? accountId, string tag) =>
new()
{
VoterKey = key,
VoterClass = voterClass,
Stance = stance,
UserAccountId = accountId,
NetworkTag = tag,
CastAtUtc = DateTimeOffset.UtcNow
};
private async Task AddRows(string collection, string key, params ConceptAppraisal[] rows)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
var subject = await db.ConceptSubjects.SingleAsync(x => x.Collection == collection && x.Key == key);
foreach (var row in rows)
{
row.SubjectId = subject.Id;
db.ConceptAppraisals.Add(row);
}
await db.SaveChangesAsync();
}
// A fresh DbContext per read: what a later request would see, not what this test's tracker remembers.
private async Task<List<ConceptAppraisal>> Rows(string collection, string key)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
return await db.ConceptAppraisals.AsNoTracking()
.Where(x => x.Subject!.Collection == collection && x.Subject.Key == key)
.ToListAsync();
}
private sealed class Visitor(HttpClient client, bool anonymous)
{
public Dictionary<string, string> Jar { get; } = new(StringComparer.Ordinal);
public Task<HttpResponseMessage> Get(string collection, string key) =>
Send(new HttpRequestMessage(HttpMethod.Get, $"/mill/{collection}/{key}"));
// A cookieless POST as a browser form would send it: Origin the site's own by default, null to omit it.
public Task<HttpResponseMessage> Post(
string collection,
string key,
string? handler,
string? origin = SiteOrigin,
string? fetchSite = null,
HttpMethod? method = null)
{
var request = new HttpRequestMessage(
method ?? HttpMethod.Post,
$"/mill/{collection}/{key}" + (handler is null ? string.Empty : "?handler=" + handler))
{
Content = new FormUrlEncodedContent([])
};
if (origin is not null)
{
request.Headers.TryAddWithoutValidation("Origin", origin);
}
if (fetchSite is not null)
{
request.Headers.TryAddWithoutValidation("Sec-Fetch-Site", fetchSite);
}
return Send(request);
}
public async Task<HttpResponseMessage> Send(HttpRequestMessage request)
{
if (anonymous)
{
request.Headers.Add("X-Test-Anonymous", "true");
}
if (Jar.Count > 0)
{
request.Headers.TryAddWithoutValidation(
"Cookie",
string.Join("; ", Jar.Select(x => x.Key + "=" + x.Value)));
}
var response = await client.SendAsync(request);
if (response.Headers.TryGetValues("Set-Cookie", out var values))
{
foreach (var header in values)
{
var pair = header.Split(';')[0];
var split = pair.IndexOf('=');
Jar[pair[..split]] = pair[(split + 1)..];
}
}
return response;
}
}
}

View file

@ -0,0 +1,621 @@
using System.Net;
using Bifrost.Web.Data;
using Bifrost.Web.Domain;
using Bifrost.Web.Features.ConceptVotes;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
namespace Bifrost.Web.Tests;
// The vote budgets through the full pipeline: a loopback peer (the proxy) forwards the client address in
// X-Forwarded-For, the clock is the test's own, and Cast is 3 per 10 minutes, Mint 2 per hour.
public sealed class ConceptVoteRateLimitTests : IClassFixture<ConceptVoteRateLimitTests.LimitedFactory>
{
private const string Site = "https://localhost";
private const string CookieName = "bifrost_voter";
private readonly LimitedFactory _factory;
public ConceptVoteRateLimitTests(LimitedFactory factory) => _factory = factory;
[Fact]
public async Task Burst_from_one_prefix_gets_429_while_another_prefix_is_unaffected()
{
var collection = await NewSubject();
var device = new Client(_factory, "203.0.113.10");
foreach (var handler in new[] { "Up", "Down", "Up" })
{
using var ok = await device.Post(collection, handler);
Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode);
}
using var denied = await device.Post(collection, "Down");
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
var other = new Client(_factory, "203.0.113.11");
using var unaffected = await other.Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, unaffected.StatusCode);
}
[Fact]
public async Task Retract_spends_the_cast_budget()
{
var collection = await NewSubject();
var device = new Client(_factory, "203.0.113.12");
await device.Post(collection, "Up");
await device.Post(collection, "Retract");
await device.Post(collection, "Retract");
using var denied = await device.Post(collection, "Retract");
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
}
[Fact]
public async Task Addresses_in_one_ipv6_prefix_share_a_bucket_and_another_prefix_does_not()
{
var collection = await NewSubject();
var first = new Client(_factory, "2001:db8:1:100::1");
var sameBlock = new Client(_factory, "2001:db8:1:1ff::2");
await first.Post(collection, "Up");
await first.Post(collection, "Down");
using var third = await sameBlock.Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, third.StatusCode);
using var denied = await sameBlock.Post(collection, "Down");
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
var elsewhere = new Client(_factory, "2001:db8:1:200::1");
using var unaffected = await elsewhere.Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, unaffected.StatusCode);
}
[Fact]
public async Task The_window_ends_exactly_at_its_length_and_retry_after_counts_down_to_it()
{
var collection = await NewSubject();
var device = new Client(_factory, "203.0.113.13");
for (var i = 0; i < 3; i++)
{
await device.Post(collection, "Up");
}
using (var first = await device.Post(collection, "Up"))
{
Assert.Equal(HttpStatusCode.TooManyRequests, first.StatusCode);
Assert.Equal("600", RetryAfter(first));
}
// 499.5 s remain: Retry-After rounds up, so a client told to wait never retries early.
_factory.Clock.Advance(TimeSpan.FromMilliseconds(100_500));
using (var later = await device.Post(collection, "Up"))
{
Assert.Equal(HttpStatusCode.TooManyRequests, later.StatusCode);
Assert.Equal("500", RetryAfter(later));
}
_factory.Clock.Advance(TimeSpan.FromMilliseconds(498_500));
using (var last = await device.Post(collection, "Up"))
{
Assert.Equal(HttpStatusCode.TooManyRequests, last.StatusCode);
Assert.Equal("1", RetryAfter(last));
}
_factory.Clock.Advance(TimeSpan.FromSeconds(1));
using var reopened = await device.Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, reopened.StatusCode);
}
[Fact]
public async Task Forged_forwarded_header_from_non_loopback_peer_does_not_move_the_bucket()
{
var collection = await NewSubject();
var device = new Client(_factory, "198.51.100.9") { Peer = "198.51.100.9" };
for (var i = 0; i < 3; i++)
{
using var ok = await device.Post(collection, "Up", forwardedFor: $"192.0.2.{i + 1}");
Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode);
}
using var denied = await device.Post(collection, "Up", forwardedFor: "192.0.2.200");
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
}
[Fact]
public async Task Signed_in_accounts_are_limited_per_account()
{
var collection = await NewSubject();
var alice = new Client(_factory, "203.0.113.20") { Account = ("7001", "alice-limits") };
var aliceElsewhere = new Client(_factory, "203.0.113.21") { Account = ("7001", "alice-limits") };
var bob = new Client(_factory, "203.0.113.20") { Account = ("7002", "bob-limits") };
var visitor = new Client(_factory, "203.0.113.20");
await alice.Post(collection, "Up");
await alice.Post(collection, "Down");
await aliceElsewhere.Post(collection, "Up");
using var denied = await aliceElsewhere.Post(collection, "Down");
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
var aliceAgain = new Client(_factory, "203.0.113.22") { Account = ("7001", "alice-limits") };
using var sameAccountNewPrefix = await aliceAgain.Post(collection, "Down");
Assert.Equal(HttpStatusCode.TooManyRequests, sameAccountNewPrefix.StatusCode);
using var bobOk = await bob.Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, bobOk.StatusCode);
using var visitorOk = await visitor.Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, visitorOk.StatusCode);
}
[Fact]
public async Task Retract_by_a_signed_in_account_spends_the_account_budget_from_any_prefix()
{
var collection = await NewSubject();
var home = new Client(_factory, "203.0.113.60") { Account = ("7003", "carol-limits") };
var away = new Client(_factory, "203.0.113.61") { Account = ("7003", "carol-limits") };
var elsewhere = new Client(_factory, "203.0.113.62") { Account = ("7003", "carol-limits") };
await home.Post(collection, "Up");
await home.Post(collection, "Down");
using var third = await away.Post(collection, "Retract");
Assert.Equal(HttpStatusCode.Redirect, third.StatusCode);
using var denied = await elsewhere.Post(collection, "Retract");
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
}
[Fact]
public async Task Mint_budget_is_spent_only_by_minting_casts()
{
var collection = await NewSubject();
const string prefix = "203.0.113.30";
var first = new Client(_factory, prefix);
using (var mint = await first.Post(collection, "Up"))
{
Assert.NotNull(SetCookie(mint));
}
// Recasts by the existing device spend cast permits only; they would exhaust a budget of 2 if they spent mint.
using (var recast = await first.Post(collection, "Down"))
{
Assert.Equal(HttpStatusCode.Redirect, recast.StatusCode);
Assert.Null(SetCookie(recast));
}
using (var recastAgain = await first.Post(collection, "Up"))
{
Assert.Equal(HttpStatusCode.Redirect, recastAgain.StatusCode);
}
_factory.Clock.Advance(TimeSpan.FromMinutes(10));
var second = new Client(_factory, prefix);
using (var secondMint = await second.Post(collection, "Up"))
{
Assert.Equal(HttpStatusCode.Redirect, secondMint.StatusCode);
Assert.NotNull(SetCookie(secondMint));
}
var rowsBefore = (await Rows(collection)).Count;
var third = new Client(_factory, prefix);
using var denied = await third.Post(collection, "Up");
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
Assert.Null(SetCookie(denied));
Assert.Equal(rowsBefore, (await Rows(collection)).Count);
Assert.StartsWith("text/plain", denied.Content.Headers.ContentType?.MediaType);
var refusal = await denied.Content.ReadAsStringAsync();
Assert.Contains("sign in", refusal, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("203.0.113", refusal);
// An hour after the first mint the budget is whole again.
_factory.Clock.Advance(TimeSpan.FromMinutes(50));
using var reopened = await third.Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, reopened.StatusCode);
}
[Fact]
public async Task Anonymous_cast_under_the_limit_succeeds()
{
var collection = await NewSubject();
var visitor = new Client(_factory, "203.0.113.40");
using var response = await visitor.Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
var row = Assert.Single(await Rows(collection));
Assert.Equal(VoterClass.Anonymous, row.VoterClass);
Assert.Equal(ConceptStance.Up, row.Stance);
}
[Fact]
public async Task Rejection_writes_nothing_says_when_to_retry_and_echoes_no_address()
{
var logs = new CapturingLoggerProvider();
var factory = _factory.WithWebHostBuilder(b => b.ConfigureServices(s => s.AddLogging(l => l.AddProvider(logs))));
var collection = await NewSubject();
var visitor = new Client(factory, "203.0.113.77");
for (var i = 0; i < 3; i++)
{
await visitor.Post(collection, i == 1 ? "Down" : "Up");
}
var before = Assert.Single(await Rows(collection));
using var denied = await visitor.Post(collection, "Down");
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
Assert.NotNull(RetryAfter(denied));
Assert.Null(SetCookie(denied));
var after = Assert.Single(await Rows(collection));
Assert.Equal(before.Stance, after.Stance);
var body = await denied.Content.ReadAsStringAsync();
Assert.Contains("too fast", body);
var seen = body
+ string.Join("\n", denied.Headers.Select(h => h.Key + ":" + string.Join(",", h.Value)));
Assert.DoesNotContain("203.0.113", seen);
Assert.DoesNotContain(logs.Lines, line => line.Contains("203.0.113", StringComparison.Ordinal));
}
[Fact]
public void Defaults_allow_thirty_new_devices_an_hour_per_address()
{
var defaults = new Bifrost.Web.Configuration.ConceptVoteOptions();
Assert.Equal(30, defaults.MintPermits);
Assert.Equal(TimeSpan.FromHours(1), defaults.MintWindow);
Assert.Equal(100_000, defaults.MaxTrackedKeys);
}
[Fact]
public void Every_table_holds_at_most_the_configured_keys_and_accounts_have_a_table_of_their_own()
{
var limits = new Bifrost.Web.Features.ConceptVotes.ConceptVoteRateLimits(
Microsoft.Extensions.Options.Options.Create(
new Bifrost.Web.Configuration.ConceptVoteOptions { MaxTrackedKeys = 1, CastPermits = 1, MintPermits = 1 }),
new TestClock());
var account = new VoterIdentity("account:test", VoterClass.Account, Guid.NewGuid());
Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.1"), null));
Assert.True(limits.DenyCast(IPAddress.Parse("198.51.100.2"), null)!.Value.TableFull);
Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.2"), account));
var otherAccount = new VoterIdentity("account:other", VoterClass.Account, Guid.NewGuid());
Assert.True(limits.DenyCast(IPAddress.Parse("198.51.100.2"), otherAccount)!.Value.TableFull);
Assert.Null(limits.DenyMint(IPAddress.Parse("198.51.100.1")));
Assert.True(limits.DenyMint(IPAddress.Parse("198.51.100.2"))!.Value.TableFull);
}
[Fact]
public async Task Filling_the_table_with_unspent_keys_locks_out_neither_a_fresh_visitor_nor_a_fresh_account()
{
var factory = Capped(4);
var collection = await NewSubject();
for (var i = 0; i < 4; i++)
{
using var filler = await new Client(factory, $"203.0.113.{80 + i}").Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, filler.StatusCode);
}
using var visitor = await new Client(factory, "203.0.113.90").Post(collection, "Up");
using var account = await new Client(factory, "203.0.113.91") { Account = ("7101", "fresh-account") }
.Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, visitor.StatusCode);
Assert.NotNull(SetCookie(visitor));
Assert.Equal(HttpStatusCode.Redirect, account.StatusCode);
}
[Fact]
public async Task A_spent_key_survives_eviction_pressure_and_stays_refused_while_rotating_callers_succeed()
{
var factory = Capped(4);
var collection = await NewSubject();
var spender = new Client(factory, "203.0.113.100");
for (var i = 0; i < 3; i++)
{
using var ok = await spender.Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode);
}
for (var i = 0; i < 12; i++)
{
using var rotating = await new Client(factory, $"203.0.114.{i + 1}").Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, rotating.StatusCode);
}
using var denied = await spender.Post(collection, "Up");
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
Assert.Contains("too fast", await denied.Content.ReadAsStringAsync());
}
[Fact]
public async Task A_rotating_caller_wins_back_only_a_forgotten_keys_unspent_budget()
{
var factory = Capped(2);
var collection = await NewSubject();
var forgotten = new Client(factory, "203.0.113.110");
await forgotten.Post(collection, "Up");
await forgotten.Post(collection, "Up");
var recent = new Client(factory, "203.0.113.111");
await recent.Post(collection, "Up");
await new Client(factory, "203.0.113.112").Post(collection, "Up");
// forgotten was least recently used and had one permit left: it was evicted, so it has all three again, no more.
for (var i = 0; i < 3; i++)
{
using var ok = await forgotten.Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode);
}
using var denied = await forgotten.Post(collection, "Up");
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
}
[Fact]
public async Task A_table_full_of_spent_keys_refuses_a_new_visitor_with_its_true_reason_and_leaves_accounts_alone()
{
var factory = Capped(4);
var collection = await NewSubject();
for (var i = 0; i < 4; i++)
{
var spender = new Client(factory, $"203.0.113.{120 + i}");
for (var cast = 0; cast < 3; cast++)
{
using var ok = await spender.Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode);
}
}
var rowsBefore = (await Rows(collection)).Count;
using var visitor = await new Client(factory, "203.0.113.130").Post(collection, "Up");
using var account = await new Client(factory, "203.0.113.131") { Account = ("7102", "spared-account") }
.Post(collection, "Up");
Assert.Equal(HttpStatusCode.TooManyRequests, visitor.StatusCode);
Assert.NotNull(RetryAfter(visitor));
Assert.Null(SetCookie(visitor));
var body = await visitor.Content.ReadAsStringAsync();
Assert.Contains("busy", body);
Assert.DoesNotContain("too fast", body);
Assert.Contains("sign in", body, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("203.0.113", body + string.Join(";", visitor.Headers.Select(h => string.Join(",", h.Value))));
Assert.Equal(HttpStatusCode.Redirect, account.StatusCode);
Assert.Equal(rowsBefore + 1, (await Rows(collection)).Count);
}
[Fact]
public async Task A_full_account_table_refuses_a_new_account_as_busy_without_offering_sign_in()
{
var factory = Capped(4);
var collection = await NewSubject();
for (var i = 0; i < 4; i++)
{
var spender = new Client(factory, $"203.0.113.{150 + i}") { Account = ($"72{i:00}", $"spent-account-{i}") };
for (var cast = 0; cast < 3; cast++)
{
using var ok = await spender.Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode);
}
}
using var fresh = await new Client(factory, "203.0.113.160") { Account = ("7299", "fresh-signed-in") }
.Post(collection, "Up");
Assert.Equal(HttpStatusCode.TooManyRequests, fresh.StatusCode);
var body = await fresh.Content.ReadAsStringAsync();
Assert.Contains("busy", body);
Assert.DoesNotContain("sign in", body, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Posts_to_a_missing_subject_spend_no_budget_and_mint_no_device()
{
var collection = await NewSubject();
var sender = new Client(_factory, "203.0.113.140");
foreach (var handler in new[] { "Up", "Down", "Retract" })
{
for (var i = 0; i < 4; i++)
{
using var missing = await sender.Post("c" + Guid.NewGuid().ToString("N"), handler);
Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
Assert.Null(SetCookie(missing));
}
}
for (var i = 0; i < 3; i++)
{
using var ok = await sender.Post(collection, "Up");
Assert.Equal(HttpStatusCode.Redirect, ok.StatusCode);
}
using var denied = await sender.Post(collection, "Up");
Assert.Equal(HttpStatusCode.TooManyRequests, denied.StatusCode);
}
[Fact]
public void A_request_with_no_client_address_shares_one_bucket_apart_from_every_address()
{
var limits = new Bifrost.Web.Features.ConceptVotes.ConceptVoteRateLimits(
Microsoft.Extensions.Options.Options.Create(new Bifrost.Web.Configuration.ConceptVoteOptions { CastPermits = 1 }),
new TestClock());
Assert.Null(limits.DenyCast(null, null));
Assert.NotNull(limits.DenyCast(null, null));
Assert.Null(limits.DenyCast(IPAddress.Parse("198.51.100.1"), null));
}
[Fact]
public async Task Reads_are_not_under_the_vote_budgets()
{
var collection = await NewSubject();
var visitor = new Client(_factory, "203.0.113.50");
for (var i = 0; i < 4; i++)
{
await visitor.Post(collection, "Up");
}
using var page = await visitor.Get($"/mill/{collection}/alpha");
using var export = await visitor.Get($"/public/concepts/{collection}/tallies");
Assert.Equal(HttpStatusCode.OK, page.StatusCode);
Assert.Equal(HttpStatusCode.OK, export.StatusCode);
}
private WebApplicationFactory<Program> Capped(int maxTrackedKeys) =>
_factory.WithWebHostBuilder(b => b.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConceptVotes:MaxTrackedKeys"] = maxTrackedKeys.ToString(System.Globalization.CultureInfo.InvariantCulture)
})));
private static string? RetryAfter(HttpResponseMessage response) =>
response.Headers.TryGetValues("Retry-After", out var values) ? values.Single() : null;
private static string? SetCookie(HttpResponseMessage response) =>
response.Headers.TryGetValues("Set-Cookie", out var values)
? values.FirstOrDefault(x => x.StartsWith(CookieName + "=", StringComparison.Ordinal))
: null;
private async Task<string> NewSubject()
{
var collection = "c" + Guid.NewGuid().ToString("N");
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
db.ConceptSubjects.Add(new ConceptSubject
{
Collection = collection,
Key = "alpha",
Title = "alpha",
RegisteredAtUtc = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync();
return collection;
}
private async Task<List<ConceptAppraisal>> Rows(string collection)
{
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BifrostDbContext>();
return await db.ConceptAppraisals.AsNoTracking()
.Where(x => x.Subject!.Collection == collection)
.ToListAsync();
}
// One browser: a client address that reaches the app through the proxy, a cookie jar, optionally an account.
private sealed class Client(WebApplicationFactory<Program> factory, string address)
{
private readonly HttpClient _http = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
HandleCookies = false,
BaseAddress = new Uri(Site)
});
private string? _cookie;
public string Peer { get; init; } = "127.0.0.1";
public (string Id, string Login)? Account { get; init; }
public Task<HttpResponseMessage> Post(string collection, string handler, string? forwardedFor = null) =>
Send(
new HttpRequestMessage(HttpMethod.Post, $"/mill/{collection}/alpha?handler={handler}")
{
Content = new FormUrlEncodedContent([])
},
forwardedFor);
public Task<HttpResponseMessage> Get(string path) => Send(new HttpRequestMessage(HttpMethod.Get, path), null);
private async Task<HttpResponseMessage> Send(HttpRequestMessage request, string? forwardedFor)
{
request.Headers.TryAddWithoutValidation("Origin", Site);
request.Headers.TryAddWithoutValidation("X-Forwarded-For", forwardedFor ?? address);
request.Headers.TryAddWithoutValidation("X-Test-Peer", Peer);
if (Account is { } account)
{
request.Headers.TryAddWithoutValidation("X-Test-GitHub-Id", account.Id);
request.Headers.TryAddWithoutValidation("X-Test-GitHub-Login", account.Login);
}
else
{
request.Headers.TryAddWithoutValidation("X-Test-Anonymous", "true");
}
if (_cookie is not null)
{
request.Headers.TryAddWithoutValidation("Cookie", _cookie);
}
var response = await _http.SendAsync(request);
if (SetCookie(response) is { } set)
{
_cookie = set.Split(';')[0];
}
return response;
}
}
public sealed class LimitedFactory : TestWebApplicationFactory
{
public TestClock Clock { get; } = new();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureAppConfiguration((_, configuration) =>
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConceptVotes:CastPermits"] = "3",
["ConceptVotes:CastWindow"] = "00:10:00",
["ConceptVotes:MintPermits"] = "2",
["ConceptVotes:MintWindow"] = "01:00:00",
["ConceptVotes:NetworkTagSecret"] = "rate-limit-test-tag-secret-0123456789"
}));
builder.ConfigureServices(services =>
{
services.RemoveAll<TimeProvider>();
services.AddSingleton<TimeProvider>(Clock);
services.AddTransient<IStartupFilter, PeerFilter>();
});
}
}
public sealed class TestClock : TimeProvider
{
private DateTimeOffset _now = DateTimeOffset.UtcNow;
private long _ticks = 5_000_000;
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
public override DateTimeOffset GetUtcNow() => _now;
public override long GetTimestamp() => _ticks;
public void Advance(TimeSpan by)
{
_now += by;
_ticks += by.Ticks;
}
}
// The proxy's connection: TestServer has no socket, so the peer address is set ahead of the app pipeline.
private sealed class PeerFilter : IStartupFilter
{
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) => app =>
{
app.Use((context, nextMiddleware) =>
{
context.Connection.RemoteIpAddress =
IPAddress.Parse(context.Request.Headers["X-Test-Peer"].FirstOrDefault() ?? "127.0.0.1");
return nextMiddleware(context);
});
next(app);
};
}
}

View file

@ -0,0 +1,299 @@
using Bifrost.Web.Features.Shared;
namespace Bifrost.Web.Tests;
public sealed class FixedWindowBudgetTests
{
private static readonly TimeSpan Window = TimeSpan.FromSeconds(60);
private const int Cap = 1000;
[Fact]
public void Permits_are_spent_per_key_and_the_next_is_denied_with_the_time_left()
{
var clock = new Clock();
var budget = new FixedWindowBudget(2, Window, Cap, clock);
Assert.True(Try(budget, "a", out _));
clock.Advance(TimeSpan.FromSeconds(10));
Assert.True(Try(budget, "a", out _));
Assert.False(Try(budget, "a", out var wait));
Assert.Equal(TimeSpan.FromSeconds(50), wait);
Assert.True(Try(budget, "b", out _));
}
[Fact]
public void A_window_opens_at_its_first_permit_and_closes_exactly_one_window_later()
{
var clock = new Clock();
var budget = new FixedWindowBudget(1, Window, Cap, clock);
Assert.True(Try(budget, "a", out _));
clock.Advance(Window - TimeSpan.FromTicks(1));
Assert.False(Try(budget, "a", out var wait));
Assert.Equal(TimeSpan.FromTicks(1), wait);
clock.Advance(TimeSpan.FromTicks(1));
Assert.True(Try(budget, "a", out _));
Assert.False(Try(budget, "a", out wait));
Assert.Equal(Window, wait);
}
[Fact]
public void A_denied_attempt_does_not_extend_the_window()
{
var clock = new Clock();
var budget = new FixedWindowBudget(1, Window, Cap, clock);
Try(budget, "a", out _);
for (var i = 0; i < 5; i++)
{
clock.Advance(TimeSpan.FromSeconds(10));
Assert.False(Try(budget, "a", out _));
}
clock.Advance(TimeSpan.FromSeconds(10));
Assert.True(Try(budget, "a", out _));
}
[Fact]
public void An_expired_window_is_dropped_and_a_live_one_keeps_counting_to_its_exact_end()
{
var clock = new Clock();
var budget = new FixedWindowBudget(1, Window, Cap, clock);
Try(budget, "a", out _);
clock.Advance(TimeSpan.FromSeconds(30));
Try(budget, "b", out _);
clock.Advance(TimeSpan.FromSeconds(30));
// At 60 s a has ended and b has 30 s left.
Try(budget, "c", out _);
Assert.Equal(2, budget.TrackedKeys);
Assert.False(Try(budget, "b", out var wait));
Assert.Equal(TimeSpan.FromSeconds(30), wait);
clock.Advance(TimeSpan.FromSeconds(30) - TimeSpan.FromTicks(1));
Assert.False(Try(budget, "b", out _));
clock.Advance(TimeSpan.FromTicks(1));
Assert.True(Try(budget, "b", out _));
}
[Fact]
public void A_wall_clock_step_back_does_not_extend_a_window_or_its_retry_after()
{
var clock = new Clock();
var budget = new FixedWindowBudget(1, Window, Cap, clock);
Try(budget, "a", out _);
clock.Advance(TimeSpan.FromSeconds(10));
clock.StepWall(-TimeSpan.FromHours(1));
Assert.False(Try(budget, "a", out var wait));
Assert.Equal(TimeSpan.FromSeconds(50), wait);
clock.Advance(TimeSpan.FromSeconds(50));
Assert.True(Try(budget, "a", out _));
Assert.Equal(1, budget.TrackedKeys);
}
[Fact]
public void A_wall_clock_step_forward_does_not_reset_a_window()
{
var clock = new Clock();
var budget = new FixedWindowBudget(1, Window, Cap, clock);
Try(budget, "a", out _);
clock.StepWall(TimeSpan.FromHours(1));
Assert.False(Try(budget, "a", out var wait));
Assert.Equal(Window, wait);
}
[Fact]
public void A_new_key_at_the_cap_evicts_the_least_recently_used_key_with_permits_left()
{
var clock = new Clock();
var budget = new FixedWindowBudget(3, Window, 2, clock);
Assert.True(Try(budget, "a", out _));
clock.Advance(TimeSpan.FromSeconds(1));
Assert.True(Try(budget, "b", out _));
clock.Advance(TimeSpan.FromSeconds(1));
Assert.True(Try(budget, "a", out _));
// b was used least recently, though a began first: c takes b's place and a keeps its count.
Assert.True(Try(budget, "c", out _));
Assert.Equal(2, budget.TrackedKeys);
Assert.True(Try(budget, "a", out _));
Assert.False(Try(budget, "a", out _));
}
[Fact]
public void A_forgotten_key_regains_only_what_it_had_not_spent_and_a_spent_key_regains_nothing()
{
var clock = new Clock();
var budget = new FixedWindowBudget(2, Window, 2, clock);
Assert.True(Try(budget, "spent", out _));
Assert.True(Try(budget, "spent", out _));
Assert.True(Try(budget, "partial", out _));
clock.Advance(TimeSpan.FromSeconds(10));
// Rotating callers evict partial, never spent.
for (var i = 0; i < 10; i++)
{
Assert.True(Try(budget, "rot" + i, out _));
}
Assert.False(Try(budget, "spent", out var wait));
Assert.Equal(TimeSpan.FromSeconds(50), wait);
Assert.Equal(2, budget.TrackedKeys);
// partial came back whole (2 permits), which is all a rotating caller can win: its own forgotten budget.
Assert.True(Try(budget, "partial", out _));
}
[Fact]
public void A_key_that_spends_its_last_permit_leaves_the_evictable_set()
{
var clock = new Clock();
var budget = new FixedWindowBudget(2, Window, 2, clock);
Assert.True(Try(budget, "a", out _));
Assert.True(Try(budget, "a", out _));
Assert.True(Try(budget, "b", out _));
// a is spent and b is not: the newcomer evicts b although a was used less recently.
Assert.True(Try(budget, "c", out _));
Assert.False(Try(budget, "a", out _));
Assert.True(Try(budget, "b", out _));
}
[Fact]
public void A_table_full_of_spent_keys_refuses_a_new_key_as_table_full_with_the_wait_for_the_oldest_window()
{
var clock = new Clock();
var budget = new FixedWindowBudget(1, Window, 2, clock);
Assert.True(Try(budget, "a", out _));
clock.Advance(TimeSpan.FromSeconds(20));
Assert.True(Try(budget, "b", out _));
clock.Advance(TimeSpan.FromSeconds(10));
var denial = budget.Spend("c");
Assert.Equal(new BudgetDenial(TimeSpan.FromSeconds(30), true), denial);
Assert.Equal(new BudgetDenial(TimeSpan.FromSeconds(30), false), budget.Spend("a"));
Assert.Equal(2, budget.TrackedKeys);
clock.Advance(TimeSpan.FromSeconds(30));
Assert.True(Try(budget, "c", out _));
Assert.Equal(2, budget.TrackedKeys);
Assert.False(Try(budget, "b", out var wait));
Assert.Equal(TimeSpan.FromSeconds(20), wait);
}
[Fact]
public void An_evicted_key_leaves_the_expiry_list_so_its_old_window_end_cannot_remove_the_key_that_took_its_name()
{
var clock = new Clock();
var budget = new FixedWindowBudget(2, Window, 2, clock);
Try(budget, "a", out _);
clock.Advance(TimeSpan.FromSeconds(1));
Try(budget, "b", out _);
clock.Advance(TimeSpan.FromSeconds(1));
Try(budget, "c", out _);
clock.Advance(TimeSpan.FromSeconds(1));
Try(budget, "a", out _);
// The first a began at 0 and ended at 60; the a that returned at 3 is still inside its window.
clock.Advance(TimeSpan.FromSeconds(57));
Try(budget, "c", out _);
Assert.Equal(2, budget.TrackedKeys);
}
[Fact]
public void A_held_key_under_its_permits_is_served_even_when_the_table_is_full()
{
var clock = new Clock();
var budget = new FixedWindowBudget(2, Window, 1, clock);
Assert.True(Try(budget, "a", out _));
Assert.True(Try(budget, "a", out _));
Assert.False(Try(budget, "a", out _));
}
[Fact]
public void Eviction_work_does_not_scan_the_table()
{
var clock = new Clock();
var budget = new FixedWindowBudget(2, Window, 50_000, clock);
for (var i = 0; i < 50_000; i++)
{
budget.Spend("k" + i);
}
var started = System.Diagnostics.Stopwatch.StartNew();
for (var i = 0; i < 200_000; i++)
{
budget.Spend("n" + i);
}
Assert.Equal(50_000, budget.TrackedKeys);
Assert.True(started.Elapsed < TimeSpan.FromSeconds(10), $"200000 evicting inserts took {started.Elapsed}");
}
[Fact]
public void Expired_windows_are_dropped_so_the_table_does_not_grow_with_every_key_ever_seen()
{
var clock = new Clock();
var budget = new FixedWindowBudget(1, Window, Cap, clock);
for (var i = 0; i < 100; i++)
{
Try(budget, "k" + i, out _);
}
Assert.Equal(100, budget.TrackedKeys);
clock.Advance(Window);
Try(budget, "fresh", out _);
Assert.Equal(1, budget.TrackedKeys);
}
[Theory]
[InlineData(0, 60)]
[InlineData(-1, 60)]
[InlineData(1, 0)]
[InlineData(1, -5)]
public void A_budget_that_cannot_be_enforced_is_refused(int permits, int seconds)
{
Assert.ThrowsAny<ArgumentException>(
() => new FixedWindowBudget(permits, TimeSpan.FromSeconds(seconds), Cap, new Clock()));
}
[Fact]
public void A_cap_below_one_is_refused()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new FixedWindowBudget(1, Window, 0, new Clock()));
}
private static bool Try(FixedWindowBudget budget, string key, out TimeSpan wait)
{
var denial = budget.Spend(key);
wait = denial?.RetryAfter ?? TimeSpan.Zero;
return denial is null;
}
// Time advances monotonic and wall together; StepWall moves only the wall clock, as an NTP step would.
private sealed class Clock : TimeProvider
{
private DateTimeOffset _wall = new(2026, 10, 11, 12, 0, 0, TimeSpan.Zero);
private long _ticks = 5_000_000;
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
public override DateTimeOffset GetUtcNow() => _wall;
public override long GetTimestamp() => _ticks;
public void Advance(TimeSpan by)
{
_wall += by;
_ticks += by.Ticks;
}
public void StepWall(TimeSpan by) => _wall += by;
}
}

View file

@ -0,0 +1,64 @@
// Runs the complete page's real inline script against a simulated browser, once per scenario, and prints
// what it did as JSON: the ordered events, and the address and form values at the moment of the submit.
// Usage: node complete-page-harness.cjs <page.html>
const fs = require("fs");
const vm = require("vm");
const html = fs.readFileSync(process.argv[2], "utf8");
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)];
if (scripts.length !== 1) {
throw new Error(`expected exactly one inline script, found ${scripts.length}`);
}
const script = scripts[0][1];
function run(name, search, hash) {
const events = [];
let submit = null;
const location = {
pathname: "/auth/heimdall/complete",
search,
hash,
get href() {
return this.pathname + this.search + this.hash;
},
replace(url) {
events.push(`replace:${url}`);
},
};
const history = {
replaceState(_state, _title, url) {
events.push(`replaceState:${url}`);
const next = new URL(url, "https://bifrost.test" + location.href);
location.pathname = next.pathname;
location.search = next.search;
location.hash = next.hash;
},
};
const form = {
elements: { attempt: { value: "" }, code: { value: "" } },
submit() {
events.push("submit");
submit = {
attempt: form.elements.attempt.value,
code: form.elements.code.value,
href: location.href,
hash: location.hash,
};
},
};
const document = { getElementById: (id) => (id === "complete" ? form : null) };
vm.runInNewContext(script, { URLSearchParams, location, history, document });
return { name, events, submit };
}
const complete = "&heimdall_completion_code=CODE123";
console.log(
JSON.stringify([
run("success", "?attempt=NONCE", "#heimdall_status=success" + complete),
run("success-with-decoy-query-code", "?attempt=NONCE&heimdall_completion_code=QCODE", "#heimdall_status=success" + complete),
run("error-status-with-code", "?attempt=NONCE", "#heimdall_status=error&heimdall_error=denied" + complete),
run("no-code", "?attempt=NONCE", "#heimdall_status=success"),
run("no-attempt", "", "#heimdall_status=success" + complete),
run("code-in-query-only", "?attempt=NONCE&heimdall_completion_code=QCODE", ""),
]),
);

View file

@ -0,0 +1,9 @@
{
"sections": [],
"votes": {},
"ships": [
{ "id": "bison", "name": "Bison", "code": "AU", "role": "Swarm escort" },
{ "id": "ibis-mk2", "name": "Ibis Mk II", "code": "AU" },
{ "id": "kestrel", "name": "Kestrel" }
]
}

View file

@ -0,0 +1,107 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Text.Json;
using Bifrost.Web.Tests.Support;
namespace Bifrost.Web.Tests;
/// <summary>
/// Runs the complete page's inline script, as the server serves it, under Node against a simulated browser
/// (Fixtures/complete-page-harness.cjs). Node is required: a missing Node fails these tests, it does not skip them.
/// </summary>
public sealed class HeimdallCompletePageScriptTests
{
private const string ErrorTarget = "replace:/?auth=heimdall-error";
private sealed record Run(string Name, string[] Events, Submit? Submit);
private sealed record Submit(string Attempt, string Code, string Href, string Hash);
private static async Task<Dictionary<string, Run>> RunScenariosAsync()
{
using var host = new HeimdallHost();
using var client = host.NewClient();
var page = await client.GetStringAsync("/auth/heimdall/complete");
var pagePath = Path.Combine(Path.GetTempPath(), $"bifrost-complete-{Guid.NewGuid():N}.html");
await File.WriteAllTextAsync(pagePath, page);
try
{
var harness = Path.Combine(AppContext.BaseDirectory, "Fixtures", "complete-page-harness.cjs");
var start = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true };
start.ArgumentList.Add(harness);
start.ArgumentList.Add(pagePath);
Process process;
try
{
process = Process.Start(start)!;
}
catch (Win32Exception error)
{
throw new InvalidOperationException("Node must be on PATH to run the complete page's script: " + error.Message, error);
}
using (process)
{
var output = process.StandardOutput.ReadToEndAsync();
var errors = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
await process.WaitForExitAsync(timeout.Token);
Assert.True(process.ExitCode == 0, await errors);
var runs = JsonSerializer.Deserialize<Run[]>(await output, new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
return runs.ToDictionary(x => x.Name);
}
}
finally
{
File.Delete(pagePath);
}
}
[Fact]
public async Task The_fragment_is_dropped_from_the_address_before_the_code_is_posted()
{
var runs = await RunScenariosAsync();
foreach (var name in new[] { "success", "success-with-decoy-query-code" })
{
var run = runs[name];
Assert.Equal(2, run.Events.Length);
Assert.StartsWith("replaceState:/auth/heimdall/complete?attempt=NONCE", run.Events[0]);
Assert.DoesNotContain("#", run.Events[0]);
Assert.DoesNotContain("CODE123", run.Events[0]);
Assert.Equal("submit", run.Events[1]);
Assert.Equal("", run.Submit!.Hash);
Assert.DoesNotContain("CODE123", run.Submit.Href);
Assert.DoesNotContain("#", run.Submit.Href);
}
}
[Fact]
public async Task The_code_is_read_only_from_the_fragment()
{
var runs = await RunScenariosAsync();
Assert.Equal(new Submit("NONCE", "CODE123", "/auth/heimdall/complete?attempt=NONCE", ""), runs["success"].Submit);
Assert.Equal("CODE123", runs["success-with-decoy-query-code"].Submit!.Code);
var queryOnly = runs["code-in-query-only"];
Assert.Null(queryOnly.Submit);
Assert.DoesNotContain("submit", queryOnly.Events);
Assert.Equal(ErrorTarget, queryOnly.Events[^1]);
}
[Theory]
[InlineData("error-status-with-code")]
[InlineData("no-code")]
[InlineData("no-attempt")]
public async Task A_failed_or_incomplete_handoff_never_submits_and_leaves_through_the_error_redirect(string scenario)
{
var run = (await RunScenariosAsync())[scenario];
Assert.Null(run.Submit);
Assert.DoesNotContain("submit", run.Events);
Assert.Equal(ErrorTarget, run.Events[^1]);
Assert.Contains(run.Events, x => x.StartsWith("replaceState:", StringComparison.Ordinal) && !x.Contains('#'));
Assert.DoesNotContain("CODE123", string.Join("\n", run.Events));
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,375 @@
using System.Net;
using Bifrost.Web.Configuration;
using Bifrost.Web.Features.Shared;
using Bifrost.Web.Tests.Support;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace Bifrost.Web.Tests;
public sealed class HostTrustTests : IClassFixture<TestWebApplicationFactory>
{
private readonly TestWebApplicationFactory _factory;
public HostTrustTests(TestWebApplicationFactory factory)
{
_factory = factory;
}
[Theory]
[InlineData("127.0.0.1")]
[InlineData("::1")]
[InlineData("::ffff:127.0.0.1")]
public async Task Forwarded_for_from_loopback_peer_becomes_remote_ip(string peer)
{
var context = await RunForwardedHeaders(peer, "203.0.113.7", "https");
Assert.Equal(IPAddress.Parse("203.0.113.7"), context.Connection.RemoteIpAddress);
Assert.Equal("https", context.Request.Scheme);
}
[Fact]
public async Task Forwarded_for_from_non_loopback_peer_is_ignored()
{
var context = await RunForwardedHeaders("198.51.100.9", "203.0.113.7", "https");
Assert.Equal(IPAddress.Parse("198.51.100.9"), context.Connection.RemoteIpAddress);
Assert.Equal("http", context.Request.Scheme);
}
[Fact]
public async Task Only_the_rightmost_forwarded_entry_is_used()
{
var context = await RunForwardedHeaders("127.0.0.1", "6.6.6.6, 203.0.113.7", null);
Assert.Equal(IPAddress.Parse("203.0.113.7"), context.Connection.RemoteIpAddress);
}
[Fact]
public async Task A_loopback_forwarded_entry_does_not_chain_to_earlier_entries()
{
var context = await RunForwardedHeaders("127.0.0.1", "6.6.6.6, 127.0.0.1", null);
Assert.Equal(IPAddress.Loopback, context.Connection.RemoteIpAddress);
}
[Fact]
public async Task Only_the_exact_loopback_addresses_are_trusted_not_the_loopback_range()
{
var context = await RunForwardedHeaders("127.0.0.2", "203.0.113.7", null);
Assert.Equal(IPAddress.Parse("127.0.0.2"), context.Connection.RemoteIpAddress);
}
[Fact]
public void Payload_protected_under_one_content_root_unprotects_under_another()
{
var root = NewRoot();
var keys = Path.Combine(root, "keys");
try
{
using var hostA = WithContentRoot(MakeRelease(root, "release-a"), keys);
using var hostB = WithContentRoot(MakeRelease(root, "release-b"), keys);
var sealedPayload = Protect(hostA, "payload");
Assert.Equal("payload", Unprotect(hostB, sealedPayload));
Assert.NotEmpty(Directory.GetFiles(keys, "key-*.xml"));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void A_different_keys_path_does_not_unprotect_the_payload()
{
var root = NewRoot();
try
{
using var hostA = WithContentRoot(MakeRelease(root, "release-a"), Path.Combine(root, "keys-a"));
using var hostB = WithContentRoot(MakeRelease(root, "release-b"), Path.Combine(root, "keys-b"));
var sealedPayload = Protect(hostA, "payload");
Assert.ThrowsAny<System.Security.Cryptography.CryptographicException>(
() => Unprotect(hostB, sealedPayload));
}
finally
{
Directory.Delete(root, recursive: true);
}
}
[Fact]
public void The_container_image_does_not_set_the_forwarded_headers_switch()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Dockerfile")))
{
directory = directory.Parent;
}
Assert.NotNull(directory);
Assert.DoesNotContain(
"ASPNETCORE_FORWARDEDHEADERS_ENABLED",
File.ReadAllText(Path.Combine(directory!.FullName, "Dockerfile")));
}
[Fact]
public void Production_without_keys_path_fails_startup_validation()
{
var withoutKey = Assert.Throws<InvalidOperationException>(() => Validate(keysPath: null));
Assert.Contains("DataProtection:KeysPath", withoutKey.Message);
Validate(keysPath: "/not/echoed/anywhere");
}
[Fact]
public void Startup_refusal_names_the_key_and_never_echoes_configured_values()
{
var canary = "canary-secret-4f1e";
var refusal = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: null, connectionString: canary));
Assert.Contains("DataProtection:KeysPath", refusal.Message);
Assert.DoesNotContain(canary, refusal.Message);
}
[Fact]
public void Production_without_network_tag_secret_fails_and_never_echoes_values()
{
var canary = "canary-keys-path-77aa";
var refusal = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: canary, tagSecret: " "));
Assert.Contains("ConceptVotes:NetworkTagSecret", refusal.Message);
Assert.DoesNotContain(canary, refusal.Message);
Assert.DoesNotContain("DataProtection:KeysPath", refusal.Message);
}
[Theory]
[InlineData("short-secret")]
[InlineData("0123456789012345678901234567890")]
public void Production_refuses_a_short_network_tag_secret_without_echoing_it(string secret)
{
var refusal = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: "/keys", tagSecret: secret));
Assert.Contains("ConceptVotes:NetworkTagSecret", refusal.Message);
Assert.DoesNotContain(secret, refusal.Message);
Validate(keysPath: "/keys", tagSecret: "01234567890123456789012345678901");
}
[Theory]
[InlineData(33, 56, "ConceptVotes:IPv4PrefixLength")]
[InlineData(-1, 56, "ConceptVotes:IPv4PrefixLength")]
[InlineData(32, 129, "ConceptVotes:IPv6PrefixLength")]
[InlineData(32, -1, "ConceptVotes:IPv6PrefixLength")]
public void Production_refuses_prefix_lengths_outside_the_address_family(int v4, int v6, string key)
{
var refusal = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: "/keys", ipv4Length: v4, ipv6Length: v6));
Assert.Contains(key, refusal.Message);
Validate(keysPath: "/keys", ipv4Length: 32, ipv6Length: 128);
Validate(keysPath: "/keys", ipv4Length: 0, ipv6Length: 0);
}
[Theory]
[InlineData("Development")]
[InlineData("Testing")]
[InlineData("Production")]
public void Every_environment_refuses_prefix_lengths_outside_the_address_family(string environment)
{
var v4 = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: "/keys", environment: environment, ipv4Length: 33));
Assert.Contains("ConceptVotes:IPv4PrefixLength", v4.Message);
Assert.DoesNotContain("IPv6PrefixLength", v4.Message);
var v6 = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: "/keys", environment: environment, ipv6Length: 129));
Assert.Contains("ConceptVotes:IPv6PrefixLength", v6.Message);
Assert.DoesNotContain("IPv4PrefixLength", v6.Message);
Validate(keysPath: "/keys", environment: environment, ipv4Length: 0, ipv6Length: 0);
}
[Theory]
[InlineData("Development", 0, 1, 5, 1, "ConceptVotes:CastPermits")]
[InlineData("Testing", 30, 0, 5, 1, "ConceptVotes:CastPermits")]
[InlineData("Production", 30, 1, 0, 1, "ConceptVotes:MintPermits")]
[InlineData("Production", 30, 1, 5, 0, "ConceptVotes:MintPermits")]
public void Every_environment_refuses_a_vote_budget_that_cannot_be_enforced(
string environment,
int castPermits,
int castMinutes,
int mintPermits,
int mintMinutes,
string key)
{
var refusal = Assert.Throws<InvalidOperationException>(() => Validate(
keysPath: "/keys",
environment: environment,
castPermits: castPermits,
castWindow: TimeSpan.FromMinutes(castMinutes),
mintPermits: mintPermits,
mintWindow: TimeSpan.FromMinutes(mintMinutes)));
Assert.Contains(key, refusal.Message);
Validate(keysPath: "/keys", environment: environment);
}
[Theory]
[InlineData("Development")]
[InlineData("Testing")]
[InlineData("Production")]
public void Every_environment_refuses_a_vote_table_cap_below_one(string environment)
{
var refusal = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: "/keys", environment: environment, maxTrackedKeys: 0));
Assert.Contains("ConceptVotes:MaxTrackedKeys", refusal.Message);
}
[Theory]
[InlineData("*")]
[InlineData("")]
[InlineData("null")]
[InlineData("https://canary-origin-5d1c.example/")]
[InlineData("https://canary-origin-5d1c.example/path")]
[InlineData("https://Canary-Origin-5d1c.example")]
[InlineData("http://canary-origin-5d1c.example")]
[InlineData("canary-origin-5d1c.example")]
[InlineData("https://canary-origin-5d1c.example:443")]
[InlineData("https://user@canary-origin-5d1c.example")]
public void Production_refuses_an_allowed_origin_that_is_not_an_exact_https_origin_without_echoing_it(string origin)
{
var refusal = Assert.Throws<InvalidOperationException>(
() => Validate(keysPath: "/keys", allowedOrigins: ["https://good.example", origin]));
Assert.Contains("ConceptVotes:AllowedOrigins", refusal.Message);
Assert.DoesNotContain("canary-origin-5d1c", refusal.Message, StringComparison.OrdinalIgnoreCase);
Validate(keysPath: "/keys", allowedOrigins: ["https://good.example", "https://other.example:8443"]);
Validate(keysPath: "/keys", allowedOrigins: []);
}
[Fact]
public void Development_does_not_require_keys_path_or_tag_secret()
{
Validate(keysPath: null, environment: Environments.Development, tagSecret: null);
}
private async Task<HttpContext> RunForwardedHeaders(string peer, string forwardedFor, string? forwardedProto)
{
var options = _factory.Services.GetRequiredService<IOptions<ForwardedHeadersOptions>>();
var middleware = new ForwardedHeadersMiddleware(
_ => Task.CompletedTask,
NullLoggerFactory.Instance,
options);
var context = new DefaultHttpContext();
context.Request.Scheme = "http";
context.Connection.RemoteIpAddress = IPAddress.Parse(peer);
context.Request.Headers["X-Forwarded-For"] = forwardedFor;
if (forwardedProto is not null)
{
context.Request.Headers["X-Forwarded-Proto"] = forwardedProto;
}
await middleware.Invoke(context);
return context;
}
private static string NewRoot() =>
Path.Combine(Path.GetTempPath(), "bifrost-dp-" + Guid.NewGuid().ToString("N"));
private static string MakeRelease(string root, string name) =>
Directory.CreateDirectory(Path.Combine(root, name)).FullName;
private static string Protect(TestWebApplicationFactory host, string value) =>
host.Services.GetRequiredService<IDataProtectionProvider>().CreateProtector("P").Protect(value);
private static string Unprotect(TestWebApplicationFactory host, string value) =>
host.Services.GetRequiredService<IDataProtectionProvider>().CreateProtector("P").Unprotect(value);
private TestWebApplicationFactory WithContentRoot(string contentRoot, string keysPath) =>
new KeyRingFactory(contentRoot, keysPath);
private sealed class KeyRingFactory(string contentRoot, string keysPath) : TestWebApplicationFactory
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.UseContentRoot(contentRoot);
builder.UseSetting("DataProtection:KeysPath", keysPath);
}
}
private const string ValidTagSecret = "tag-secret-0123456789-0123456789-abcdef";
private static void Validate(
string? keysPath,
string environment = "Production",
string? connectionString = "Host=db",
string? tagSecret = ValidTagSecret,
int ipv4Length = 32,
int ipv6Length = 56,
string[]? allowedOrigins = null,
int castPermits = 30,
TimeSpan? castWindow = null,
int mintPermits = 5,
TimeSpan? mintWindow = null,
int maxTrackedKeys = 100_000)
{
var values = new Dictionary<string, string?>
{
["AllowedHosts"] = "bifrost.example",
["ConnectionStrings:Bifrost"] = connectionString,
["DataProtection:KeysPath"] = keysPath,
};
var configuration = new ConfigurationBuilder().AddInMemoryCollection(values).Build();
var hostEnvironment = new StubHostEnvironment(environment);
new StartupConfigurationValidator(
configuration,
hostEnvironment,
Options.Create(new GitHubOAuthOptions { ClientId = "id", ClientSecret = "secret" }),
Options.Create(new GitHubAppOptions { EnableWebhookSync = false }),
Options.Create(new HeimdallOptions { EnablePatronSupportIntake = false }),
Options.Create(new StripeOptions()),
Options.Create(new BifrostHostOptions
{
PublicBaseUrl = "https://bifrost.example",
ExpectedHost = "bifrost.example"
}),
Options.Create(new ConceptVoteOptions
{
NetworkTagSecret = tagSecret ?? string.Empty,
IPv4PrefixLength = ipv4Length,
IPv6PrefixLength = ipv6Length,
AllowedOrigins = allowedOrigins ?? [],
CastPermits = castPermits,
CastWindow = castWindow ?? TimeSpan.FromMinutes(10),
MintPermits = mintPermits,
MintWindow = mintWindow ?? TimeSpan.FromHours(1),
MaxTrackedKeys = maxTrackedKeys
})).Validate();
}
private sealed class StubHostEnvironment(string environmentName) : IHostEnvironment
{
public string EnvironmentName { get; set; } = environmentName;
public string ApplicationName { get; set; } = "Bifrost.Web";
public string ContentRootPath { get; set; } = string.Empty;
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
}
}

Some files were not shown because too many files have changed in this diff Show more