Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a716cfd7e | |||
| ae0037c159 | |||
|
|
96665a778b | ||
|
|
3b0a614ee9 | ||
|
|
d29f8014f9 | ||
|
|
25ce117f16 | ||
|
|
f04b20feed | ||
| e58e9531df | |||
|
|
1771f15789 | ||
|
|
1739d5552a | ||
| 920ecb1fbe | |||
|
|
1298e7db1e | ||
|
|
f9d0c24f66 | ||
| dc151e6cac | |||
|
|
1fbe16eb73 | ||
|
|
4c9937f881 | ||
|
|
95416653ba | ||
| d1d13992aa | |||
|
|
1998f0e6ae | ||
|
|
31ced94f2a | ||
| b687da954b | |||
|
|
87a1bb6b3b | ||
|
|
c085cead9d | ||
| fe0ff40864 | |||
|
|
280d843aeb | ||
|
|
25b9cc1683 | ||
|
|
95d2e8d262 | ||
| b80b27da0f | |||
| 420a4dbf85 | |||
| a1b173079d | |||
| afafff18bd | |||
| c2de290628 | |||
|
|
e639e13a8b | ||
|
|
46cf7d4492 | ||
|
|
a3bab9b8e5 | ||
|
|
beafbe18e2 | ||
|
|
8a3262b3e8 | ||
|
|
ee88bc709d | ||
|
|
abeb8a1d5f | ||
|
|
6c4c9c00f9 | ||
| b1725a106f | |||
| d26aa50041 | |||
| 4cb83971f9 | |||
| d4365a8364 | |||
| 0d820fdc00 | |||
| 7231cdb977 | |||
| f6a826b6e7 | |||
| 6acb4980b8 | |||
| e8e8fcc178 | |||
| 1da912d5e9 | |||
| e12b8e2298 | |||
| dc35c4a254 | |||
| ab89556101 | |||
| e8df60630e | |||
| 7813426e6d | |||
| 70a2038435 | |||
| 1571bf3e32 | |||
| 028bc2fdf6 | |||
| b2e0bb2514 | |||
| 09addd83d8 | |||
| 33be966b7d | |||
| fc8242f655 | |||
| e8722d0abf | |||
| 24f3deb88e | |||
| 5201508bdc | |||
| be44a09d52 | |||
| 1d0548313c | |||
| 4e86c33cd2 | |||
| 68dee25e3a | |||
| 781e6eabf6 | |||
| 05dcceb203 | |||
| 815304568f | |||
| 8385eca00f | |||
| 7e9ba5796b | |||
| 12a153f66f | |||
| 73c6f50440 | |||
| 752cd66686 | |||
| 1a3fa02373 | |||
| 55a97f2250 | |||
|
|
7e258988e5 | ||
|
|
62c1d4e0f1 | ||
| 39cc78cb4f | |||
| 5304a2a2a1 | |||
|
|
cf2c80e4e7 | ||
|
|
0a44bf88e5 | ||
|
|
5ba6571fa6 | ||
|
|
2c2cf15094 | ||
| 276f611ec8 | |||
|
|
8955279ef1 | ||
|
|
c23c8d1a88 | ||
| 800486a9a0 | |||
| 07554d7121 | |||
|
|
2aabaabc53 | ||
|
|
ee3a2f49f5 | ||
|
|
012d1cb926 | ||
| 416f11ad4f | |||
|
|
0d7c3ec078 | ||
|
|
1c9cb9937e | ||
|
|
8e6afc43ed | ||
| b42b86f816 | |||
|
|
55b7f79c13 | ||
|
|
8d6b45ae69 | ||
|
|
264233e08f | ||
|
|
96da1b7a8c | ||
|
|
d5a71eb639 | ||
| eb4aa3c493 | |||
| c049793eca | |||
|
|
7895d05a13 | ||
|
|
0ac9c956e1 | ||
| 0eb68e498e | |||
| 2dccadc8b5 | |||
| 1864fef9ee | |||
|
|
99128e9f90 | ||
| 9caca35e4e | |||
| 3e1bca76d5 | |||
| 55b74275b0 | |||
|
|
e576ba1d74 | ||
| 7c563130e8 | |||
|
|
8a95c99d15 | ||
|
|
58fa80bc5b | ||
|
|
2671b4bcb8 | ||
|
|
c97644e3ed | ||
|
|
7cfce18fcf | ||
| 244811cb49 | |||
| 94606d04a6 | |||
| 8938dbd739 | |||
| 111f7e6f1a | |||
| 8b7783dfb0 | |||
| b1b145ee39 | |||
| 5ad17d7c77 | |||
|
|
fd0a43e0e4 | ||
|
|
129b517f46 | ||
|
|
a85942e858 | ||
|
|
e23f7da2d5 | ||
|
|
02275ee17b | ||
|
|
c757acf525 | ||
|
|
8abd912917 | ||
|
|
d6c96fe1a1 | ||
| bf8ff62774 | |||
|
|
b6c1e9af73 | ||
|
|
527f96986a | ||
|
|
55253c48b4 | ||
| b3c4821a67 | |||
|
|
0f985c6003 | ||
| 10ffad3d21 | |||
|
|
081b0fc991 | ||
|
|
6e4f94ae27 | ||
| b95234bbef | |||
| a338c594be | |||
| 5900284742 | |||
|
|
5d352c6c20 | ||
|
|
9d032c2dff | ||
|
|
70e9cef3d5 | ||
|
|
dda8ee7c58 | ||
|
|
0d52eeaeee | ||
|
|
a23b6d94f1 | ||
|
|
a09746c8f7 | ||
|
|
2c37e8a0de | ||
|
|
f7770df147 | ||
|
|
6548ca8385 | ||
| e396305c19 | |||
| 7adc7315a3 | |||
|
|
2387cbc7f6 | ||
|
|
981164173f | ||
|
|
2dd4bd863e | ||
|
|
a76c70cc39 | ||
|
|
c29c5d1a4e | ||
|
|
98689b787d | ||
|
|
3bff0d779c | ||
|
|
b892c64f00 | ||
|
|
a7621f9eed | ||
|
|
ccb0e6b08c | ||
|
|
6cf24644bc | ||
|
|
cc76f0f2d0 | ||
|
|
18bbe6c312 | ||
|
|
1036849534 | ||
|
|
3f241c0fe4 | ||
|
|
eee86193c0 | ||
|
|
7b630312e1 | ||
|
|
1894f653d0 | ||
|
|
37c8fe1a72 | ||
|
|
8340ee0e92 | ||
|
|
ec66fba5ca | ||
|
|
51fe807b66 | ||
|
|
868b91fefb | ||
|
|
af64868326 | ||
|
|
c9e470bef4 | ||
|
|
69b670e3af | ||
|
|
404dafdd40 | ||
|
|
83daabe512 | ||
|
|
78d38d92e6 | ||
|
|
d7d4b21c4b | ||
|
|
a15648db04 | ||
|
|
b2110f53fa | ||
|
|
b7e3bdf28d | ||
|
|
d743e4f363 | ||
|
|
efb278a910 | ||
|
|
d463336206 | ||
|
|
b54e8d5dc8 | ||
|
|
053272a0f3 | ||
|
|
15f96c408c | ||
|
|
8186d6f8fa | ||
|
|
21f2c75c78 | ||
|
|
ec2f812b59 | ||
|
|
4a28cea1f9 | ||
|
|
79c29fe16e | ||
|
|
cf74019194 | ||
|
|
a9b9b29bf9 | ||
|
|
912d083894 | ||
|
|
436a02864f | ||
|
|
8f57dc89f0 | ||
|
|
a8c3ecf495 | ||
|
|
2c8384e44a | ||
|
|
9fcef927fd | ||
|
|
34f5dfa6f1 | ||
|
|
7f375ecf8f | ||
|
|
0f71a9f3ca | ||
|
|
fbf6e43955 | ||
|
|
cf4e5b4327 | ||
|
|
d695674359 | ||
|
|
f5432f7a42 | ||
|
|
ad06e2ffd5 | ||
|
|
713eff934a | ||
|
|
5626532571 | ||
|
|
bad2117ea8 | ||
|
|
1ab67da47b | ||
|
|
650bf6bcc6 | ||
|
|
e99eea8769 | ||
|
|
f3b8acbbc5 | ||
|
|
68214ced30 | ||
|
|
526102ec8a | ||
|
|
75d2cedaa2 | ||
|
|
d3a7611f2a | ||
|
|
30ba2af3c1 | ||
|
|
6554a1b017 | ||
|
|
59d5dbd068 | ||
|
|
e0b3b1a38a | ||
|
|
0c6f9216b6 | ||
|
|
72741198fa | ||
|
|
c9e0b7b0e1 | ||
|
|
747183078a | ||
|
|
9c4124dd9f | ||
|
|
0bb3b2f76c | ||
|
|
f7ff76daa4 | ||
|
|
e05e1bed81 | ||
|
|
f5a1f387ca | ||
|
|
af3b712b26 | ||
|
|
a35119362a | ||
|
|
11df901cb6 | ||
|
|
dcc3425520 | ||
|
|
464da6bd50 | ||
|
|
a43f90b48d | ||
|
|
46d92200e5 | ||
|
|
624cfd1567 | ||
|
|
cc00c64c9e | ||
|
|
34325e0efd | ||
|
|
d13b808471 | ||
|
|
b17436ce4f | ||
|
|
95ac1f84e6 | ||
|
|
bd06ef0c74 | ||
|
|
eca71f589c | ||
|
|
162d7b1f0d | ||
|
|
abb0986508 | ||
|
|
d171713ab1 | ||
|
|
214f555781 | ||
|
|
270ab95cc7 | ||
|
|
3c135b8e67 | ||
|
|
67072b2892 | ||
|
|
717e97bdcc | ||
|
|
bdff1a06a1 | ||
|
|
c259b91e69 | ||
|
|
da3e73ad4d | ||
|
|
5093ba81aa | ||
|
|
2e0de9601c | ||
|
|
837bc11a15 | ||
|
|
d4e6137537 | ||
|
|
d5ebd9b420 | ||
|
|
e77d6220de | ||
|
|
bf83a9321b | ||
|
|
20d5efa9c6 | ||
|
|
160fac20c9 | ||
|
|
9b28e9b27a | ||
|
|
fcf81ea564 | ||
|
|
0312e8bbac | ||
|
|
c468f05eaf | ||
|
|
69e12e3867 | ||
|
|
68fb44aeb1 | ||
|
|
ff087a554b | ||
|
|
1f001e11d2 | ||
|
|
7e92687716 | ||
|
|
ef867f3e28 | ||
|
|
417454c28f | ||
|
|
4152676ebb | ||
|
|
e84fe2cb05 | ||
|
|
9c954e7502 | ||
|
|
4619939e64 | ||
|
|
daedcc4f86 |
418 changed files with 75271 additions and 1050 deletions
55
.github/workflows/publish-container.yml
vendored
55
.github/workflows/publish-container.yml
vendored
|
|
@ -1,55 +0,0 @@
|
|||
name: Publish Container
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: gamecult/bifrost
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and publish Bifrost image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,prefix=sha-
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
## Locked Technical Defaults
|
||||
|
||||
- stack: ASP.NET Core 8 LTS
|
||||
- UI: Razor Pages + HTMX
|
||||
- UI: Razor Pages
|
||||
- database: PostgreSQL
|
||||
- hosting target: `bifrost.gamecult.org` on Yggdrasil
|
||||
- deployment pattern: nginx + systemd + localhost-only Postgres, matching `E:\Projects\gamecult-ops`
|
||||
|
|
|
|||
7
Directory.Build.props
Normal file
7
Directory.Build.props
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ It is closer to deployable now, but it is still not a finished internet-facing m
|
|||
## Chosen Stack
|
||||
|
||||
- ASP.NET Core 8 LTS
|
||||
- Razor Pages + HTMX
|
||||
- Razor Pages
|
||||
- PostgreSQL
|
||||
- Bifrost-native identity registration
|
||||
- GitHub OAuth for linked sign-in and repository integration
|
||||
|
|
@ -56,7 +56,7 @@ It is closer to deployable now, but it is still not a finished internet-facing m
|
|||
|
||||
- public app hostname: `bifrost.gamecult.org`
|
||||
- infrastructure context: `Yggdrasil`
|
||||
- deployment model: GHCR image + Docker Compose behind nginx, with the older systemd path kept as rollback until the container path is boring
|
||||
- deployment model: a release directory under `/srv/bifrost/app/releases`, switched by Idunn with a rollback point; no container images are published or pulled
|
||||
|
||||
## Next Build Target
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ For the full staged roadmap, including contribution points, revenue share, patro
|
|||
## Local Dev Notes
|
||||
|
||||
- set `GitHubOAuth:ClientId` and `GitHubOAuth:ClientSecret` before using sign-in
|
||||
- POST `/auth/bifrost/register` with `identity`, optional `displayName`, and optional `returnUrl` to create a Bifrost-native account without OAuth; this signs in as `Authenticated` but does not grant active member access
|
||||
- POST `/auth/bifrost/register` with `identity`, optional `displayName`, optional `returnUrl`, a solved proof-of-work `challenge` and `nonce` (from `GET /auth/bifrost/challenge`), and `acceptedNoticeVersion` (the `version` from `GET /auth/bifrost/notice`) to create a Bifrost-native account without OAuth; this signs in as `Authenticated` but does not grant active member access
|
||||
- set `Bootstrap:AdminGitHubLogins` with at least one GitHub login for the first active admin path
|
||||
- HTTP bridge ledger configuration (`BIFROST_BRIDGE_BASE_URL` / `BIFROST_BRIDGE_TOKEN`) is removed from live CLI transport. Request-lane, governance, dispatch, and bridge receipts converge on Bifrost-owned `bifrost.crossing_receipt.v1` documents in `.bifrost/bridge-receipts.cc`.
|
||||
- external bridge actions must carry typed CultMesh command provenance such as `BIFROST_CULTMESH_COMMAND_ID`; unreceipted and HTTP-ledger hatches fail closed.
|
||||
|
|
@ -89,7 +89,7 @@ For the full staged roadmap, including contribution points, revenue share, patro
|
|||
- set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production
|
||||
- default connection string points at local PostgreSQL
|
||||
- build with `dotnet build Bifrost.slnx`
|
||||
- test with `DOTNET_ROLL_FORWARD=Major dotnet test Bifrost.slnx` if the machine only has the .NET 10 runtime installed
|
||||
- test with `dotnet test Bifrost.slnx`; the suite needs a real .NET 8 runtime (`DOTNET_ROOT`, with a runsettings `DotNetHostPath` if the SDK is newer; .NET 10 roll-forward makes Mvc.Testing 8.0.0 return 500 on every JSON response) and a built CultLib TypeScript runtime (`dist/` and `node_modules`) at `$VOIDBOT_CULTLIB_ROOT`
|
||||
- dry-run a Reddit organizing thread with `node tools/bifrost-bridge.mjs reddit-post --title "Thread title" --persona-name Bifrost --content "Thread body" --dry-run true`
|
||||
- print the Eve provider advertisement with `node tools/provider-advertisement.mjs print`
|
||||
- export the Eve provider store with `node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc`
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ services:
|
|||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: ghcr.io/gamecult/bifrost:local
|
||||
image: bifrost-web:local
|
||||
container_name: bifrost-web
|
||||
depends_on:
|
||||
postgres:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
- Create Bifrost as a separate GameCult app repo and deploy it on Yggdrasil.
|
||||
- Build a member alpha, not a public launch.
|
||||
- Use ASP.NET Core 8 with Razor Pages + HTMX and PostgreSQL.
|
||||
- Use ASP.NET Core 8 with Razor Pages and PostgreSQL.
|
||||
- Support invite-only GitHub-linked members, GitHub-backed and internal work items, app-native governance, Reddit organizing threads, and internal ledgers.
|
||||
- Preserve the Labor Platform loop: user/member/patron demand raises work priority and reward pressure, contributors pick up valuable work, maintainers accept completion artifacts, and Bifrost credits the contributor plus any bounty/reward allocation.
|
||||
- Do not include Ethereum, wallets, DAO mechanics, or payout execution in v1.
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
## System Shape
|
||||
|
||||
- One ASP.NET web app process for v1.
|
||||
- Server-rendered UI with HTMX for progressive interactions.
|
||||
- Server-rendered UI; no client-side framework or third-party script.
|
||||
- PostgreSQL as the system of record.
|
||||
- Bifrost-native identity registration for login, with GitHub OAuth available as a linked external identity.
|
||||
- GitHub App plus webhooks for issue and pull request lifecycle sync.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
98
docs/charter-amendment-proposal.md
Normal file
98
docs/charter-amendment-proposal.md
Normal 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.
|
||||
129
docs/concept-mill-votes-target.md
Normal file
129
docs/concept-mill-votes-target.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# 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__*` (the app secret as `Heimdall__AppSecret`; `Heimdall__AppSecretFile` is the replacement), `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 no third-party script or stylesheet (HTMX was dropped; `NoThirdPartyAssetTests` fails on any cross-origin `<script src>` or `<link href>`), and no page uses `hx-` attributes. 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.
|
||||
- **Ranked read.** Beside the map, anonymous `GET /public/concepts/{collection}/ranked?sort=net|total|newest&limit=1..100&cursor=&unvoted=network` returns `{ items: [{ key, title, up, down, members }], next_cursor }`, largest first, ties by key. The cursor is opaque, keyset and bound to its sort and filter; any other query shape or cursor is a 400. `unvoted=network` omits the concepts the caller's own network tag has voted on (tag derived from the connection, never accepted or returned) and is never cached. Same CORS and Vary policy as the map; the map is unchanged.
|
||||
- **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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
254
docs/deploy-main-to-live.md
Normal file
254
docs/deploy-main-to-live.md
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
# Deploying main over the live release
|
||||
|
||||
Status: **plan, not executed.** Written 2026-11-09 by the heimdall Self run; revised the same day by the eureka Self run
|
||||
against main `55b74275`, from Libby's read of the first draft. Nothing here has been run. The deploy waits on a root
|
||||
window, a verified database backup and a rehearsed rollback (Preconditions).
|
||||
|
||||
## What moves
|
||||
|
||||
| | Live | Target |
|
||||
| --- | --- | --- |
|
||||
| Release | `/srv/bifrost/app/releases/20261010043032-67072b2` | a new `<UTC>-<sha7>` directory |
|
||||
| Revision | `67072b2` | `55b74275b0d86cdb0c5db01010380a90fbc2eb69` (main at revision; re-check with `git ls-remote`) |
|
||||
| Distance | | 147 commits; `67072b2` is an ancestor of main |
|
||||
| Schema | up to the last pre-`AddConceptVotes` migration | + `20261010043937_AddConceptVotes`, + `20261022163159_AddOpenIddict` |
|
||||
|
||||
Both migrations only create tables and indexes (`Up` has no `Drop*`, `AlterColumn` or `RenameColumn`). The previous
|
||||
binary therefore runs against the new schema, which is what makes the rollback below a symlink move, not a restore.
|
||||
|
||||
The deploy mechanism is `gamecult-ops/scripts/deploy-bifrost-alpha.sh <sha40>` and
|
||||
`runbooks/bifrost-alpha-deploy.md`. There is no Idunn recipe for Bifrost; this is a manual, root-window deploy.
|
||||
**Bifrost #11** (`__Host-` cookies) and #17 (CI publish workflow deleted) are in main; this page (#15) is docs only and
|
||||
changes no code. #11 renames every cookie Bifrost sets (`BifrostCookies.cs`: session, OAuth correlation, antiforgery,
|
||||
voter, Heimdall attempt), so **the deploy signs every member out once**, drops sign-ins in flight at the restart, and
|
||||
gives each returning anonymous voter a new device cookie. Say so to the members before the window. The data-protection
|
||||
key ring is unchanged, so this is the only forced sign-out; the live check below tests it.
|
||||
|
||||
## Configuration
|
||||
|
||||
Read against the live `/srv/bifrost/env/bifrost.env` before the window, not during it, by someone who can read it (mode
|
||||
600, owner `bifrost`; the eureka Self run's account cannot, so **no key below is marked present or absent here**: the
|
||||
column is filled in by the reader). Key names only; never paste values into a chat, a PR or a log. Name-only check:
|
||||
`sudo -n cut -d= -f1 /srv/bifrost/env/bifrost.env | sort`.
|
||||
|
||||
Outside Development and Testing, `StartupConfigurationValidator.Validate()` refuses to start (an `InvalidOperationException`
|
||||
naming every failure) unless all of these hold. Environment-variable spelling uses `__` for `:`.
|
||||
|
||||
| Key (configuration name) | Rule | Present? |
|
||||
| --- | --- | --- |
|
||||
| `GitHubOAuth:ClientId`, `GitHubOAuth:ClientSecret` | both non-empty | |
|
||||
| `Host:PublicBaseUrl`, `Host:ExpectedHost` | both non-empty | |
|
||||
| `AllowedHosts` | non-empty and no `*` (while `Host:RequireStrictHostValidation` is true, its default) | |
|
||||
| `ConnectionStrings:Bifrost` | non-empty | |
|
||||
| `DataProtection:KeysPath` | non-empty | |
|
||||
| **`ConceptVotes:NetworkTagSecret`** | non-empty and **at least 32 characters** (`ConceptVoteOptions.MinNetworkTagSecretLength`); a hard startup failure if missing or short (`StartupConfigurationValidator.cs:104`) | |
|
||||
| `ConceptVotes:AllowedOrigins` | each entry exactly `https://host`, no path, wildcard or trailing slash | |
|
||||
|
||||
Conditional, only if the feature is switched on: `GitHubApp` settings when `GitHubApp:EnableWebhookSync` is true;
|
||||
`Heimdall:PatronSupportIntakeSecret` while `Heimdall:EnablePatronSupportIntake` is true (its default); the Stripe keys
|
||||
when `Stripe:EnableCheckout` is true. Two of these checks are satisfied by defaults once the one secret is present: Stripe
|
||||
needs `SecretKey`, `SuccessUrl` and `CancelUrl`, and the last two default to `https://velvet.gamecult.org/...`
|
||||
(`StripeOptions.cs:15,17`); Heimdall intake needs `BaseUrl`, `AppSlug` and the intake secret, and the first two default to
|
||||
`https://heimdall.gamecult.org` and `bifrost` (`HeimdallOptions.cs:7,9`). Also checked at every start, in every environment: the `ConceptVotes` prefix lengths,
|
||||
permits, windows and `MaxTrackedKeys`.
|
||||
|
||||
New since the live release, and not read by the validator:
|
||||
|
||||
- **`Heimdall__AppSecretFile`**: the preferred way to give Bifrost the app secret below. A path to a file holding it (one
|
||||
trailing newlines are trimmed), for example `%d/heimdall-app-secret` under systemd `LoadCredential=`, so the secret never
|
||||
rides in the process environment. When set, the file wins over `Heimdall__AppSecret`; a missing, unreadable or empty file
|
||||
stops startup in every environment, with a message that names the option and path and never the value. Ops steps:
|
||||
`docs/heimdall-app-secret-file.md`.
|
||||
- **`Heimdall__AppSecret`** (deprecated; use `Heimdall__AppSecretFile`; startup logs a warning when it is set): required for
|
||||
Discord and Patreon sign-in. Outside Development and Testing startup fails when neither this nor
|
||||
`Heimdall__AppSecretFile` supplies a non-blank secret (the message names both options, never a value), because
|
||||
`HeimdallOptions.IsDiscordConfigured` and `IsPatreonConfigured` would otherwise be false and those sign-ins silently
|
||||
stop working (GitHub sign-in is unaffected). The value is Heimdall's app secret for slug `bifrost`
|
||||
(`GC_ACCESS_APP_BIFROST_SHARED_SECRET` on the Heimdall side). Bifrost sends it as `x-heimdall-app-secret`, redacted
|
||||
from logs.
|
||||
- **`Bifrost__Oidc__*`**: optional. Absent, the OIDC issuer is not mapped and discovery answers 404. Present, it must be
|
||||
complete and the key files must exist, or startup stops. Unless the window includes standing up chat sign-in, leave it
|
||||
unset. Keys and files: `docs/oidc-issuer.md`.
|
||||
- `DataProtection:KeysPath` keeps its intent. Data Protection has a fixed application name (`Bifrost`, `Program.cs`), so a
|
||||
new release directory does not log members out; #11's cookie rename does (above). The live env points the keys path at
|
||||
`/srv/bifrost/.aspnet/DataProtection-Keys` (read 2026-11-09); confirm that again in the window, and that the new release
|
||||
reads it.
|
||||
- `Bootstrap__ApplyMigrationsOnStartup=true` stays on, so **the first start applies both migrations**. That is the
|
||||
irreversible-looking step; the backup below is for it.
|
||||
|
||||
## Preconditions
|
||||
|
||||
All must hold before step 1. A missing one means the window is not used.
|
||||
|
||||
1. **Root window.** Someone with root on Yggdrasil is present for the whole deploy and the 30 minutes after (installing
|
||||
the unit, reading `journalctl`, restoring). The swarm brake is off for Bifrost, or the operator has said so.
|
||||
2. **Verified database backup.** `pg_dump -Fc` as in the runbook section 8, taken inside the window to
|
||||
`/srv/bifrost/backups/bifrost-pre-<sha12>-<UTC>.dump`, **root-owned, mode 0600**, then verified. The `postgres` user
|
||||
cannot read that file, so each check says who runs it:
|
||||
- `sudo -n pg_restore --list <dump> | head` succeeds, **as root** (it reads the file and touches no database).
|
||||
- The restore reads a **postgres-readable copy**, never the real dump: with `S=/var/tmp/bifrost-restoretest-<UTC>`, root runs
|
||||
`sudo -n install -d -m 0700 -o postgres -g postgres $S` and
|
||||
`sudo -n install -m 0400 -o postgres -g postgres <dump> $S/restore.dump`. The `bifrost` database role has no
|
||||
CREATEDB, so create and drop the scratch database as the superuser:
|
||||
`sudo -n -u postgres createdb -O bifrost bifrost_restoretest_<UTC>`, then
|
||||
`sudo -n -u postgres pg_restore --no-owner --role=bifrost -d bifrost_restoretest_<UTC> $S/restore.dump`. The scratch
|
||||
member count must equal the live count.
|
||||
- Cleanup, on success and on every stop: `sudo -n -u postgres dropdb bifrost_restoretest_<UTC>`, then
|
||||
`sudo -n sh -c "shred -u $S/restore.dump && rmdir $S"`; readback `test ! -e $S && echo gone` prints `gone`. The real
|
||||
dump stays root-only and is kept.
|
||||
|
||||
An unrestored dump is not a backup.
|
||||
3. **Env, secrets and key-ring backup.** The runbook's `bifrost-env-*.tar`, plus the live ring
|
||||
**`/srv/bifrost/.aspnet/DataProtection-Keys`** (read from the host 2026-11-09: `DataProtection__KeysPath` in
|
||||
`bifrost.env` names it; three key files at that reading, modes 600, owner `bifrost`, dated 2026-04-22, 2026-07-22 and
|
||||
2026-10-20). It lives outside `/srv/bifrost/env` and is not in that tar. `/srv/bifrost/data-protection-keys` holds one
|
||||
older key (2026-07-15) that no setting points at: it is not the ring, and is left alone. Losing the ring signs
|
||||
everyone out and breaks every protected cookie and token; `cp -a` it beside the env backup and record its file list.
|
||||
4. **Rollback rehearsal.** Before the real deploy, on the same host. There is no `dotnet ef` on the host and this plan
|
||||
does not use it: the target applies its own migrations at start. Rehearse by starting the target once, then the
|
||||
previous release, on the scratch database, from a separate env file that touches nothing live:
|
||||
- a copy of `bifrost.env` at `/srv/bifrost/rehearsal/bifrost-rehearsal.env` (directory and file owned by `bifrost`,
|
||||
modes 700 and 600; nothing under `/root`, which `bifrost` cannot read), with `ConnectionStrings__Bifrost` pointing at
|
||||
`bifrost_rehearsal`, `DataProtection__KeysPath` at a throwaway empty directory in the same place (not the live key
|
||||
ring, which the rehearsal must not write), and `ASPNETCORE_URLS` set to a port that is not 5080 (e.g.
|
||||
`http://127.0.0.1:5081`). Delete the file and the directories afterwards.
|
||||
- Strip from that copy every key that could act on or sign in to something real, and every key the scratch run does
|
||||
not need: all `Bifrost__Oidc__*`, `Bridge__LocalBridgeToken`, `Bootstrap__AdminGitHubLogins__*`, the GitHub App,
|
||||
Stripe and Heimdall intake and app-secret keys. Stripping a key is not enough: startup validation refuses to start
|
||||
when a feature flag is on and its key is missing (`StartupConfigurationValidator.cs:59-62`, `:69-73`, `:75-80`), so
|
||||
set `Heimdall__EnablePatronSupportIntake=false` (it defaults to true and then needs the intake secret),
|
||||
`GitHubApp__EnableWebhookSync=false` and `Stripe__EnableCheckout=false` in the rehearsal env. With them off the
|
||||
process starts without the secrets, and the rehearsal cannot reach Stripe, GitHub or Heimdall. The rehearsal checks that the process starts and migrates; **there is
|
||||
no sign-in on 5081**, because the GitHub OAuth app and Heimdall redirect to the public host.
|
||||
- start the target release from that file as `bifrost`, with the file loaded by systemd, not by a shell:
|
||||
`sudo -n systemd-run --wait --pty -p User=bifrost -p EnvironmentFile=/srv/bifrost/rehearsal/bifrost-rehearsal.env
|
||||
-p WorkingDirectory=<target-release> /usr/bin/dotnet <target-release>/Bifrost.Web.dll` (detached with `--unit
|
||||
bifrost-rehearsal` and no `--wait`/`--pty` if the check runs from another shell; stop it with
|
||||
`systemctl stop bifrost-rehearsal`). The scratch database now has both migrations. Check it listens and that
|
||||
`curl -s -o /dev/null -w '%{http_code}' -H 'Host: <Host:ExpectedHost of the live env>' http://127.0.0.1:5081/readyz`
|
||||
answers 200 (the app validates the host, so a bare request to `127.0.0.1` is refused), then stop it.
|
||||
- start the **previous** release (`/srv/bifrost/app/releases/20261010043032-67072b2`) the same way on another spare
|
||||
port (e.g. 5082) against the same, now migrated, scratch database, with the same `Host:` header; `/readyz` must answer
|
||||
200. This is the claim that the
|
||||
rollback is a symlink move; it is checked, not assumed.
|
||||
5. **Artifact.** Published per runbook section 3: `git merge-base --is-ancestor <sha> origin/main` exits 0, publish from a
|
||||
detached worktree, tar, upload. Record the tarball's SHA-256 on both ends. Build and test on Yggdrasil's verify path,
|
||||
not a workstation.
|
||||
6. **Env diff done.** Every row of the Configuration table is marked present by the reader, `ConceptVotes__NetworkTagSecret`
|
||||
included and at least 32 characters (a count, not the value), and the `Heimdall__AppSecret` line is in `bifrost.env`
|
||||
(mode 600, owner `bifrost`; a build with the empty-secret refusal does not start without it or `Heimdall__AppSecretFile`) with Heimdall holding the same value. Nothing in "Configuration" is left unresolved.
|
||||
7. **Quiet.** No motion closing and no known vote in flight inside the window. Check the motions page. Members told that
|
||||
everyone is signed out once (see "What moves").
|
||||
8. **Watchdog.** `bifrost-watchdog-notice.timer` fires one minute after boot and one minute after each run
|
||||
(`OnBootSec=1min`, `OnUnitActiveSec=1min`; read 2026-11-09), and the oneshot `bifrost-watchdog-notice.service`, run as
|
||||
`bifrost-notice`, DMs the owner Idunn's operator incidents (`systemctl cat bifrost-watchdog-notice.service`). A deliberate restart or rollback that Idunn counts as an
|
||||
incident can page the owner during the window; tell them first. Leave the timer running: it is not part of the
|
||||
deploy and must not be stopped or edited.
|
||||
|
||||
## Steps, in order
|
||||
|
||||
Record the time and the output line of each in the deploy record. Stop at the first failure and go to Rollback.
|
||||
|
||||
1. Note the live state: `readlink -f /srv/bifrost/app/current` (expect `.../20261010043032-67072b2`),
|
||||
`systemctl is-active bifrost.service`, the member count, the open motion count, and the **log baseline**: the service
|
||||
writes to files, not the journal (`StandardOutput=append:`), so `journalctl -u bifrost.service` shows only systemd's
|
||||
own lines. Record `wc -l /srv/bifrost/log/bifrost.stdout.log /srv/bifrost/log/bifrost.stderr.log` (at 2026-11-09
|
||||
11:55Z: 4838 and 44925 lines; stderr last written 2026-10-18, stdout 2026-11-06) so the live check counts only the
|
||||
lines written after the restart. This is the "before" the live check compares to.
|
||||
2. Take and verify the backups (Preconditions 2 and 3), then run the rollback rehearsal (Precondition 4).
|
||||
3. Make the env complete: add `Heimdall__AppSecret` and, if the Configuration table found it absent or shorter than 32
|
||||
characters, **`ConceptVotes__NetworkTagSecret`** (the target does not start without it) to
|
||||
`/srv/bifrost/env/bifrost.env`, after the timestamped backup, printing nothing. The secret is random, 32 or more
|
||||
characters, and is never echoed, logged or pasted into a chat. For example (`openssl rand -hex 32` gives 64 hex
|
||||
characters and nothing that needs quoting in a systemd `EnvironmentFile`):
|
||||
```
|
||||
sudo -n sh -c 'umask 077; cd /srv/bifrost/env || exit 1
|
||||
if grep -Eq "^[[:space:]]*ConceptVotes__NetworkTagSecret=[^[:space:]]" bifrost.env; then
|
||||
echo "ConceptVotes__NetworkTagSecret already set, left unchanged"
|
||||
elif grep -Eq "^[[:space:]]*ConceptVotes__NetworkTagSecret=" bifrost.env; then
|
||||
echo "ConceptVotes__NetworkTagSecret present with an empty value: fix by hand, nothing appended"; exit 1
|
||||
else
|
||||
v=$(openssl rand -hex 32) || { echo "openssl failed, nothing appended"; exit 1; }
|
||||
[ "${#v}" -ge 32 ] || { echo "generated value shorter than 32, nothing appended"; exit 1; }
|
||||
cp -a bifrost.env bifrost.env.pre-<UTC> && printf "\nConceptVotes__NetworkTagSecret=%s\n" "$v" >> bifrost.env
|
||||
fi && chown bifrost bifrost.env && chmod 600 bifrost.env'
|
||||
```
|
||||
The generated value is checked before anything is written: a failed `openssl`, or a value under 32 characters, appends
|
||||
nothing. The guard makes a re-run safe: an existing `ConceptVotes__NetworkTagSecret=` line, with or without leading
|
||||
spaces, is never overwritten or duplicated, and the skip message prints no value. A line with an empty (or
|
||||
whitespace-only) value counts as not set and is reported, not repaired: the command stops with a message and the
|
||||
operator fixes the line by hand (an empty value would otherwise sit beside a new one; systemd's `EnvironmentFile` takes
|
||||
the last duplicate, so do not leave two). A present line shorter than 32 characters is likewise not replaced by this
|
||||
command: fix it deliberately, with the rotation warning below in mind. A commented-out line (`#`) is not a key and does
|
||||
not count.
|
||||
(the same, with the value from the operator's own source, for `Heimdall__AppSecret`, which is not generated here).
|
||||
The file ends mode 600, owner `bifrost`, and the backup `bifrost.env.pre-<UTC>` beside it keeps the old content.
|
||||
Re-run the name-only check after, and check the length by count only:
|
||||
`sudo -n awk -F= '$1=="ConceptVotes__NetworkTagSecret"{print length($0)-length($1)-1}' /srv/bifrost/env/bifrost.env` is 32 or more.
|
||||
**Generate it once; do not rotate it casually.** It is the key of the audit `NetworkTag` that `ConceptAppraisal` stores
|
||||
per vote (`src/Bifrost.Web/Data/BifrostDbContext.cs:546`). A different secret does not touch votes, the
|
||||
`(SubjectId, VoterKey)` uniqueness or sign-in, but tags already stored no longer match tags computed for the same
|
||||
network, so `PurgeAsync` (`src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs:154`, equality on
|
||||
`NetworkTag`) misses the old rows; only new casts and re-stanced rows carry the new tag. On this first deploy no vote row
|
||||
has a tag from the new code, so nothing is orphaned; after it, treat a change as a purge-affecting event.
|
||||
4. Upload the tarball; compare its SHA-256 to the build's.
|
||||
5. `sudo -n bash /home/gamecultadmin/deploy-bifrost-alpha.sh <sha40>`. It unpacks into `<UTC>-<sha7>`, writes `REVISION`,
|
||||
repoints `current`, restarts. The restart applies both migrations.
|
||||
6. Watch the start in the real logs: `tail -n +<baseline+1> /srv/bifrost/log/bifrost.stdout.log` and the same for
|
||||
`bifrost.stderr.log`, until the host reports listening on `127.0.0.1:5080`; no `Failed executing DbCommand`, no
|
||||
`Bifrost startup configuration is incomplete`, no `Heimdall ... refused` at start. `journalctl -u bifrost.service -n 50
|
||||
--no-pager` shows only start, stop and restart events. A start that loops is a failure: roll back, do not wait.
|
||||
7. Live check (next section).
|
||||
8. Record the deploy in `gamecult-ops` `inventory.md` (Bifrost alpha live state): release directory, full sha, date.
|
||||
|
||||
## Live check
|
||||
|
||||
Done from outside nginx, with a real browser session, within the window. A 200 on `/` is not a check.
|
||||
|
||||
- `cat /srv/bifrost/app/current/REVISION` is the target sha; `systemctl is-active bifrost.service` is `active`.
|
||||
- **Cookies (#11).** After the first sign-in, the browser's cookie list shows the `__Host-bifrost_*` names, each Secure with
|
||||
Path `/` and no Domain, and no old `bifrost_*` name is set again. The old sessions are gone by design.
|
||||
- **Login.** Sign in with GitHub; then with Discord (exercises `Heimdall__AppSecret`: the start must reach Heimdall and
|
||||
return to `/auth/heimdall/callback`). A Discord or Patreon sign-in that comes back refused or loops is the new-secret
|
||||
failure. Data Protection having kept its keys shows as no `key ... was not found` line (name not verified) in the stderr log
|
||||
after the restart; the old sessions do not survive either way (see "What moves").
|
||||
- **Vote read.** The public concept-vote read endpoint answers with the existing tallies, and the tallies equal those
|
||||
noted in step 1 (the schema moved; the data must not).
|
||||
- **Persona picture.** Open a Persona's page and confirm its picture loads and comes from the host, not a stale cache. A broken
|
||||
image with a healthy `/` is the finding this check exists for. **Unknown: which app serves that picture.** `AvatarUrl` appears at
|
||||
`src/Bifrost.Web/Domain/BifrostModels.cs:261` and `src/Bifrost.Web/Program.cs:192`, so the picture is probably an
|
||||
external avatar URL; unverified. Name the serving app and the page before the window, or drop the check.
|
||||
- **Checkout page.** Load one Stripe checkout page (start a checkout and stop at the Stripe-hosted page) and confirm it
|
||||
renders with the expected product and amount. Do not pay: no card is entered and no live payment is made.
|
||||
- Member console, work board, motions and members pages load (runbook section 7).
|
||||
- The error rate over five minutes: the number of lines added to `bifrost.stderr.log` and `bifrost.stdout.log` since the
|
||||
baseline in step 1 is small and stops growing; read what was added, not just the count.
|
||||
|
||||
## Rollback
|
||||
|
||||
Rollback signs members out again (the old release reads the old cookie names); say so if it happens. Trigger: the start loops or migrates with errors, any live check fails, or the root window closes with a doubt open.
|
||||
|
||||
1. `sudo -n ln -sfn /srv/bifrost/app/releases/20261010043032-67072b2 /srv/bifrost/app/current && sudo -n chown -h
|
||||
bifrost:bifrost /srv/bifrost/app/current && sudo -n systemctl restart bifrost.service`.
|
||||
2. Restore the previous env file if step 3 changed it (`bifrost.env.pre-<UTC>`).
|
||||
3. Re-run the "before" checks from step 1: revision, `active`, counts equal.
|
||||
4. The two new migrations stay applied. They add tables the old release ignores, as rehearsed in Precondition 4.
|
||||
5. Only if the schema or data is damaged (not merely new): the runbook section 8 restore, from the verified dump. This is
|
||||
the last resort and needs the same root window. The `postgres` superuser cannot read the root-owned 0600 dump, so make a
|
||||
fresh postgres-readable copy as in Precondition 2 (`$S/restore.dump`, 0400, postgres-owned, new 0700 directory), run
|
||||
`pg_restore` from it, then `shred -u` the copy and `rmdir $S`; readback `test ! -e $S`. The dump itself stays root-only.
|
||||
6. Leave the failed release directory for diagnosis; do not delete it in the window.
|
||||
|
||||
## What this plan does not do
|
||||
|
||||
It does not deploy, does not add an Idunn recipe, and does not touch `bifrost-watchdog-notice.timer`. The
|
||||
Idunn recipe is the owner of making this repeatable; until then each deploy follows this page and the runbook.
|
||||
|
||||
**Host facts in this plan that no one re-verified in this revision:** the key-ring file count, modes and dates (lines
|
||||
~64, ~82-85), the stderr/stdout line counts and last-write dates (~135-136), the `bifrost-watchdog-notice.timer`
|
||||
cadence and unit names (~122-124), and that `bifrost` cannot read `/root` (~91) were read from the host on 2026-11-09 by
|
||||
an earlier pass or taken from the host's reports; this revision made no host read. The body also states these as fact
|
||||
without a host check in this revision, and they are unverified too: the `bifrost` database role has no CREATEDB (~77),
|
||||
there is no `dotnet ef` on the host (~87), logs go to files via `StandardOutput=append:` and the journal shows only
|
||||
systemd's own lines (~134-136, 148-149), the script path `/home/gamecultadmin/deploy-bifrost-alpha.sh` (~144), and that
|
||||
no Idunn recipe exists for Bifrost (~20). Treat each as unverified until checked in the window.
|
||||
117
docs/desk-feed-design.md
Normal file
117
docs/desk-feed-design.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Desk feed: per-Mind open forks for a signed-in member (design, no code)
|
||||
|
||||
Status: proposal, 2026-11-11, Heimdall (Imagination pass) against `origin/main` e8e8fcc. Docs only; nothing here is built, ruled or deployed.
|
||||
Labels: **FACT** = read at the cited place. **INFERENCE** = my reading. **UNREACHED** = I could not read it.
|
||||
Reads: `src/Bifrost.Web/Features/Desk/*` (main), `docs/ratatoskr-voting-auth.md` (PR #37, e172869), `/opt/commons/handoff/fork-desk-answer-binding-20261110.md`, Ratatoskr `fbec032` (`src/app/features/desk/*`), `tools/persona-matrix-mouth.mjs`, `docs/bridge.md`. UNREACHED: CultLib `fork-card.md` (no checkout here; I rely on the Desk code that pins it at de8a062a), Huginn's `desk_wire.rs` (PR #31 is merged; the list/read half of the door wire is not defined in anything I read).
|
||||
|
||||
## 1. What is asked, and the finding that shapes it
|
||||
|
||||
Ratatoskr's Desk row per Mind is empty "until Bifrost feeds it" (FACT `src/app/features/desk/source.ts`: `deskFor(mind): ForkList | undefined`, slice 2 "replaces this with Bifrost's list reader"). The page already decodes `gamecult.fork.list.v0` (`forkContract.ts`).
|
||||
|
||||
**Finding: the feed route is already built.** `GET /api/desk/{mind}/forks?status=open&limit=N` is on main (PR #40, hardened #42-#45): list, per-Mind socket door, `open_count`, `stamp`, notes stripped for non-deciders. It serves nothing today because its two ports have no production implementation (`DeskPorts.cs`: `IDeskCredential`, `IDeskDoor`; `MapDesk` fails startup if either is missing). So the smallest honest feed is **not a new endpoint. It is the existing list route, read-only, with its two ports filled and a token that carries only `desk.read`.** A second route would need a second contract, vectors and owner for no gain.
|
||||
|
||||
## 2. The endpoint
|
||||
|
||||
- `GET https://<bifrost>/api/desk/{mind}/forks?status=open&limit=50[&cursor=c<N>]`, one call per Mind (the client already asks per Mind). `limit=0` returns counts and stamp only (FACT binding file, route table), usable for a badge.
|
||||
- Response: `gamecult.fork.list.v0` (`ForkList`: `mind`, `stamp`, `open_count`, `recorded_count`, `forks[]` of `ForkEntry`, `next_cursor`). **JSON, and why:** Ratatoskr is a browser client, the xenos boundary; the contract (CultLib `fork-card.md`, Transport) fixes JSON at the HTTP edge with every key written, and the documents are typed C# records (`DeskDocuments.cs`) mirroring CultLib's. Bifrost to Huginn is the typed door, not JSON.
|
||||
- The row the UI needs is derived by the client from each entry: `card.fork_id`, `card.title`, age (section 5), and the count is `open_count`. The page "derives nothing" (FACT `forkContract.ts` header), so it takes `open_count` as the one count.
|
||||
- Not offered: any POST (`/api/desk/{mind}/answers` stays unreachable to this client because its token has no `desk.answer`, so it is refused 403 `insufficient_scope` at step 4 of the pipeline, before the body), any vote, any write.
|
||||
|
||||
## 3. Auth: what a signed-in member can call today, and what the feed reuses
|
||||
|
||||
FACT today: a signed-in member holds only the cookie `__Host-bifrost_session`; the default scheme is Cookie (`Program.cs:189-200`); no `AddValidation` exists, so no access token authenticates any `/api/*` route; the desk handler is `.AllowAnonymous()` and judges its own bearer (`DeskEndpoints.cs`, `MapDesk`), and the cookie scheme cannot authenticate `/api/desk/*`. **A signed-in member can call nothing on the desk today.** Chat.gamecult.org never holds the `__Host-` cookie, so "the Heimdall session" cannot be sent by the browser to Bifrost from chat.
|
||||
|
||||
So the session is used as PR #37 uses it: the **sign-in leg of an OIDC code+PKCE flow** (`/connect/authorize` resumes on the Heimdall-backed session, FACT `OidcEndpoints.cs:44-70`, which admits any account that is not Suspended and has an identity: membership is neither required nor written, so the sign-in leg does not prove Active. It works for `desk.read` only because the desk re-checks Active on every request) mints a short access token. Reuse, exactly:
|
||||
1. Token: PR #37's second public PKCE client (Ratatoskr) and OpenIddict validation as a non-default scheme (built as `OidcDeskCredential` on OpenIddict's fixed scheme name `OpenIddict.Validation.AspNetCore`; the label `BifrostBearer` in PR #37 section 5 names the role, not the scheme string; handler `IDeskCredential` production impl, the credential port; H4 in the desk cut map, a label used only there). Request scope `desk.read` only, audience `bifrost-desk`. No `desk.answer`, no `votes.*`.
|
||||
2. Judgment: unchanged `DeskEndpoints.HandleAsync`: audience, scope, then `OidcEndpoints.ActiveMemberIdAsync(db, sub)` re-read every request (non-Active gets 403 `forbidden`).
|
||||
3. CORS: existing `DeskCors` (exactly `Desk:AllowedOrigin`, GET and POST, no credentials).
|
||||
Rejected: a cookie-authenticated same-origin JSON route (works for a Bifrost page, not for the chat origin; a second auth path on the same data), and letting chat present its Matrix/MAS token (Bifrost does not validate it; INFERENCE per PR #37 section 1).
|
||||
|
||||
## 4. Where the data comes from (compare, recommend one)
|
||||
|
||||
Huginn is the truth. A fork is a question record with options, a derived status, deciders and a server-computed `options_hash` (FACT binding file, "Cards are a read-only projection"). Bifrost holds no fork store, no cache, no hash (FACT cut map authority map). Everything else is a cache or an unrelated plane. Options:
|
||||
|
||||
- **(a) A small Huginn endpoint Bifrost calls.** This is already the plan of record, not a new thing: `IDeskDoor.ListAsync(socket, context, request)` over the per-Mind peer-checked unix socket, `context` = {Mind, authority, subject, deciders} built by Bifrost alone (`DeskPorts.cs`). Huginn returns the list "as it projects it for this viewer".
|
||||
- **(b) A file the persona mouth tails or writes.** FACT the mouth holds one `cursor.cc` per Persona and it stores only the Matrix `/sync` position `next_batch` (`persona-matrix-mouth.mjs` ~:491, :906-912). `chat_pending` is Matrix deliveries to a Persona over loopback RUDP (credential-derived connection id), served *to* Huginn, which is the client; the mouth reads Matrix, not Huginn. It holds no fork, no question and no Huginn client. A file route means a new writer inside the Matrix actuator that copies private records out of the mind into the chat plane, a second owner of "what a member may see", a Node process rewriting Rust's projection, and staleness equal to its write period. The mouth would gain a job it was split away from.
|
||||
- **(c) Bifrost reads Huginn directly with a CultNet selection** (what `huginn open <campaign>` / `huginn blocked <spec>` do). Rejected: those return every in-force question and follow-up of a campaign (`epiphany.pipeline.question.v2`, `follow_up.v2`), most of which are not forks. Bifrost would need a general read of the mind and would decide fork-hood and visibility itself. Also Bifrost has no Huginn client at all (FACT: no CultNet/RUDP/unix-socket client in C#; `UnixDomainSocketEndPoint` appears nowhere).
|
||||
|
||||
**Recommend (a), as the existing desk door, list request only.** Reasons:
|
||||
- **Auth:** the door is peer-credential checked (the receiver's uid); Bifrost passes the verified subject; the member's token never reaches Huginn.
|
||||
- **Staleness:** none added. A live read, `Cache-Control: no-store` (FACT), with Huginn's `stamp` (`s<N>`, same stamp same state) as the change signal; a client polls, or `limit=0` for the cheap probe. Bifrost may cache by stamp later; it need not.
|
||||
- **Failure:** per Mind and per call. A dead socket, timeout or unknown outcome is 503 `unavailable` with `Retry-After` (FACT `WriteUnavailableAsync`); the other Minds' rows are unaffected because the client asks per Mind. The mouth being down is irrelevant. Nothing is served from a stale copy that could mislead.
|
||||
- **Mouth overlap:** none, which is the point. Chat plane (mouth) and fork plane (door) stay two owners.
|
||||
- **Not yet existing:** `UnixSocketDeskDoor` (the door port's production impl; H5 in the desk cut map) and the Huginn side of the list wire. Eureka owns that wire (binding file section 5, "The door wire is Eureka's"). I found no definition of the list/read request bytes; this note does not invent one.
|
||||
|
||||
## 5. Meaning of "open" and "age"
|
||||
|
||||
- **open** = the card's `status` is `open` as Huginn derives it (`ForkCard.status`; `?status=open` filter). Bifrost does not decide it. The count is `open_count` of the same response, so the number and the rows come from one stamp. `recorded_count` (closed with a ruling) is not part of the feed.
|
||||
- **age** = now minus when the question was raised. **The contract cannot give this today.** FACT `ForkCard` has `order` (a position), `raised_in` (a reference) and no timestamp; answers carry `answered_at`/`received_at` but an unanswered fork has none. Ruled 2026-11-11 by Libby (CultLib steward): the card gains `raised_at`, a REQUIRED key whose value is an RFC3339 string or nil (the card goes from 16 to 17 keys; its hex changes). Huginn emits the question's admission time, or nil when it has none. Bifrost's record gains one field that must accept string or nil. Age derives from `raised_at` and is computed by the client; Bifrost computes nothing; nil means age unknown and the row shows no age. Until the contract lands the feed ships without age (the fallback loses nothing else).
|
||||
|
||||
## 6. D1 (options_hash) and the feed
|
||||
|
||||
Two different "D1"s exist: the ruling cited for this feed is the fork-desk answer-binding D1 (2026-11-10: `options_hash` covers title, context, options with ids, recommendation and depends; `fork_id` is bound beside it). PR #37 also has a D1 (consent). This section is the first; section 7 the second.
|
||||
- Feed rows carry the card as Huginn returns it, `options_hash` included. Bifrost never computes or corrects a hash (FACT binding file). A row's identity is `(fork_id, options_hash)`: an edit to a shown title changes the hash, so the row changes with it.
|
||||
- The feed shows title, which the hash covers, so feed and detail cannot disagree about it.
|
||||
- **Age is not decision content.** Ruled by Libby (X2, my earlier "inside the hash" option is withdrawn): `raised_at` stays OUT of `options_hash`, which keeps exactly five members (title, context, options, recommendation, depends), so the hash digests and Void's `optionsHash.ts` vectors do not change. Only the card's own bytes change, by the added key.
|
||||
|
||||
## 7. PR #37 decisions, what this feed needs
|
||||
|
||||
| id | decision (brief) | for the feed |
|
||||
|---|---|---|
|
||||
| D1 | explicit consent for `votes.cast`, or first-party implicit | **not needed**: a `desk.read` grant casts and changes nothing; implicit read consent (PR #37 section 7) |
|
||||
| D2 | re-authentication at cast for high-stakes motions | **not needed**: no cast |
|
||||
| D3 | token lifetime | **decided** (PR #37 section 14 D3): no refresh token; `desk.read`-only requests get a 60-minute access token, so a polling feed needs no redirect per 10 minutes. Per-audience lifetime is not built; probe 7 is owed |
|
||||
| D4 | where a member lists and revokes grants | **not needed** while the grant is read-only; revisit with `desk.answer` |
|
||||
| D5 | runtime brake | **decided** (section 14 D5): the existing marker-file brake, held by the steward. It is NOT per-client and does not cover `/api/desk` until the guard's matching is extended; that and the desk-only deployment are the code PR's to settle |
|
||||
| D6 | rate limits | **decided** (section 14 D6): 120 requests/min per account for `desk.read`, in configuration. A 429 has no refusal code in the contract (Q3 of the desk cut map): not decided there |
|
||||
| D7 | order against the MAS rollout | **decided** (section 14 D7): independent; the Ratatoskr client is seeded only when configured. The feed still needs Bifrost OIDC live, which it is not (PR #37 section 1) |
|
||||
|
||||
Already ruled: the answer-binding D1 above, visibility ruling c (any `desk.read` holder sees cards and outcomes; notes only for deciders), read with Bifrost's requirement that the door returns only cards this member may see, D2/answer-binding (decider list authored in Bifrost admin, Mind read-only).
|
||||
|
||||
## 8. Privacy: what a member may see per Mind
|
||||
|
||||
- **Filter owner: Huginn.** The list and read calls carry the viewer's `subject` and that Mind's `deciders` (no `authority`; an answer carries it); Huginn projects "for this viewer". The policy is in Huginn's `docs/desk-feed-door.md` ("Listing", Huginn main 79d954c or later) and is not copied here: not listed unless a listing record is in force, deciders see every showable fork, a hidden fork reads as `fork_unknown`. That gate is not live on Huginn main yet (`cut-desk-feed-listing`), so no Mind that holds private questions goes behind a live receiver before it lands. Bifrost holds NO audience rule: no filter on which forks appear, no per-card visibility rule, no copy of the policy (two copies would drift). Bifrost's two new acts are the per-request Active check and the `desk.read` bearer (below), beside two already built: it nils every `note` for a non-decider (`DeskNotes.Strip`), and it resolves `{mind}` only against the configured list (unknown Mind and unknown fork return the same 404 bytes, so Minds are not probeable).
|
||||
- **Fields safe for a member:** `fork_id`, `title`, `options`, `recommendation`, `depends`, `status`, `order`, `options_hash`, counts, `stamp`, and `raised_at` (string or nil). **Not safe, and not in the feed:** `note` (stripped), the decider list beyond ids already on the card, anything from the mind that is not a fork card (follow-ups, findings, research, persona state, room text).
|
||||
- **Answerer ids (X3, closed by the steward in PR #37 section 14):** `ForkEntry.record.answers[]` carries each answerer's account id (a Bifrost `UserAccount.Id`) to any `desk.read` holder; ruling c permits cards and outcomes, but a member list of who answered what is more than a feed needs. The feed does not need `record` at all. SUPERSEDED by Eureka's ruling `eureka-substrate:ruling:desk-read-visibility-withheld-ids` (Huginn #56, 79d954c): the door, not Bifrost, withholds decider ids and answerer ids from a non-decider and returns counts. Bifrost masks nothing it cannot read (the card is opaque to it) and keeps no answerer mask; it still nils `note` for a non-decider. Until Huginn's cut and the CultLib change land, the door sends ids whole, so no receiver goes live on a Mind before then.
|
||||
- **Content privacy:** a fork's `context` and `title` are Mind-authored prose and may name people or private matter. The door is the only gate: Bifrost requires the list wire to carry only cards the member may see, passes them through; the door withholds answerer and decider ids. The audience policy is Huginn's door spec's, not here. The feed ships no free text Bifrost wrote.
|
||||
- **Consent and receipts:** reading creates no member record, so no receipt and no consent object; the member's act is the token grant (read-only, implicit). Answer receipts and the append-only record belong to the answer path and stay out. Audit: log client id and `sub`, not the response. The training-notice receipt gates the chat token only (Meta's ruling, 2026-11-11): a desk token issues for a member with no receipt.
|
||||
|
||||
## 9. Failure modes
|
||||
|
||||
Bearer unjudgeable: 503 `unavailable` (not a refusal of the caller). Not Active: 403. Unknown Mind: 404 `fork_unknown`. Oversize fork: 422 `fork_too_large` with a cursor to continue (FACT #42/#44; the client must handle it, not render nothing). Token expired: 401; the client restarts the redirect (D3). Door down: 503; the Desk row shows "unavailable, retry", never an empty list (an empty list must mean zero open forks). Huginn emits a decider list outside 1..64 for a configured Mind: 503 (config fault, FACT). Ratatoskr's source is synchronous; it needs a fetch-and-hold layer that keeps the last good `stamp` and marks it stale, which is Void's.
|
||||
|
||||
## 10. Tests that would prove it (consumer side)
|
||||
|
||||
1. Ratatoskr, against a stubbed list: a `ForkList` fixture from the vendored vectors renders the right rows and count; a 503 renders "unavailable", not an empty desk; `open_count` 0 with an empty page renders "none open".
|
||||
2. Bifrost, `DeskWebApplicationFactory` + `FakeDeskDoor`: a token with `desk.read` lists (200); no token 401; wrong audience 401; a `desk.read`-only token POSTing an answer is 403 `insufficient_scope` with the door never called; a non-Active subject is 403 with the door never called; a cookie alone gets 401.
|
||||
3. Privacy: a non-decider's response has every `note` nil (key present); Bifrost masks no `answerer` (Huginn's door withholds ids); a decider keeps notes; an unknown Mind and an unknown fork return identical bytes; Bifrost relays exactly the cards the door returns (a door double returning N cards yields N rows, none dropped or added) and applies no audience filter of its own.
|
||||
4. Hash: a row's `options_hash` equals the vector value, and Ratatoskr's `optionsHash.ts` recomputed over the rendered title/context/options matches it (pins D1 end to end).
|
||||
5. Probes owed by PR #37 section 11 (items 1, 3, 5): public client exchange, audience+scope enforced, bearer on `/App` still refused.
|
||||
6. Door contract: a `UnixSocketDeskDoor` against a Huginn double returns `Listed`, `NotListed`, `ForkTooLarge`, and an exception maps to 503.
|
||||
|
||||
## 11. Size estimate (lines, include tests)
|
||||
|
||||
| file | lines |
|
||||
|---|---|
|
||||
| Bifrost `Features/Desk/UnixSocketDeskDoor.cs` (list only; read/answer added by the answer cut) | ~180 |
|
||||
| Bifrost `Features/Oidc/*` bearer validation, `desk.read` scope, audience, second client seed (shared with PR #37) | ~150 |
|
||||
| Bifrost `IDeskCredential` production impl (validates the bearer, returns `DeskToken`) + wiring in `Program.cs`; no audience or visibility logic | ~60 |
|
||||
| Bifrost sweep revoking the Ratatoskr/desk client's authorizations for subjects failing the chat-tier test (PR #37 section 8; derived from membership, no outbound POST) | ~80 |
|
||||
| Bifrost `OidcGuard` match extended to `/api/desk` (D5 brake) and the desk-only deployment settlement | ~30 |
|
||||
| Bifrost per-audience token lifetime at authorize (D3: 60 minutes for `desk.read`-only; probe 7 first) | ~30 |
|
||||
| Bifrost `DeskNotes` extension: mask `answerer` for non-deciders (dropped: the door withholds ids) | 0 |
|
||||
| Bifrost re-vendor `tests/Bifrost.Web.Tests/Fixtures/fork-card-vectors.json` (17-key card, new CultLib commit) + `DeskVectors.cs:9,11` pin + `ForkCard` 17th member `RaisedAt`, string or nil (`DeskDocuments.cs:64-80`) + `DeskDocumentTests`/`DeskScenarioTests` updates | ~60 |
|
||||
| CultLib `fork-card.md` + schema + card vectors for `raised_at` (17-key card; `options_hash` vectors unchanged) | ~40 |
|
||||
| Huginn: list wire handler and `raised_at` (Eureka's) | not estimated |
|
||||
| Ratatoskr list reader + token redirect + stale holder (Void's) | ~250 |
|
||||
| Bifrost tests (items 2, 3, 6) | ~250 |
|
||||
| This doc and `docs/oidc-issuer.md` note | ~40 |
|
||||
|
||||
Subtraction: no new route, no store, no migration, no DTO type beyond one field. The two shared ports (door, bearer) are the cost, and the answer path needs them anyway.
|
||||
|
||||
## 12. Open decisions
|
||||
|
||||
- **X1** (CultLib, ruled 2026-11-11, Libby): `raised_at` is a required card key, RFC3339 string or nil; 16 to 17 keys. Huginn's emission of it is Eureka's, still to build.
|
||||
- **X2** (withdrawn): `raised_at` is out of `options_hash`, digests unchanged (Libby).
|
||||
- **Nothing in this note is open for Bifrost's own design.** Decided and recorded elsewhere: `raised_at` (Libby, CultLib ruling 2026-11-11, X1/X2 closed); answerer ids (X3: withheld by Huginn's door, ruling `desk-read-visibility-withheld-ids`, supersedes the Bifrost mask); PR #37 D1-D7 (section 14); card visibility (the Huginn door's policy, `docs/desk-feed-door.md` in Huginn; Bifrost holds no audience rule).
|
||||
- Owed by others: Eureka owns Huginn's emission of `raised_at`, the listing gate (`cut-desk-feed-listing`) and the ops root-run that binds the sockets; the door wire is on Huginn main and its golden fixtures are the contract for `UnixSocketDeskDoor`.
|
||||
370
docs/feedback-receiver.md
Normal file
370
docs/feedback-receiver.md
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
# Feedback receiver
|
||||
|
||||
Status: **design, nothing implemented.** Written for `heimdall-body:follow_up:chat-s3533-3533`, updated for
|
||||
`chat-s3549-3549`. It is the receiver half of Ratatoskr's Mind feedback pages. The page spec is Ratatoskr PR #24,
|
||||
`docs/mind-feedback-pages.md` at b146000d, which I take as final here; its section 7a names this doc. The rulings it
|
||||
builds on are Heimdall's drafts on guests and hiding (`/opt/commons/handoff/heimdall-feedback-pages-rulings.md`, cited as R4a to R6f).
|
||||
|
||||
Read at Bifrost main `b1725a1` (2026-11-12), line references re-read at `e58e953` (2026-11-12): `tools/persona-matrix-mouth.mjs`, `tools/persona-chat-documents.mjs`,
|
||||
`src/Bifrost.Web/Features/{Oidc,Desk,Registration,Shared}/*`, `Domain/BifrostModels.cs`, `Program.cs`, `docs/oidc-issuer.md`,
|
||||
`docs/ratatoskr-voting-auth.md`; the deployed trees under `/srv/bifrost` were last read at an earlier main (section 9). I could not read Eureka's receiver
|
||||
notes in #minds, and `huginn-feedback-kinds-proposal.md` was not in `/opt/commons/handoff` when I looked. Where
|
||||
PR #24 names a kind or a field it says the proposal wins, so I cite PR #24 and name the proposal's kinds only as PR #24 does.
|
||||
Where source disagrees with either, the section says so.
|
||||
|
||||
Rulings of Meta, 2026-11-12, now written into this doc: erase redacts the answer text in the receiver's store and keeps a text-free record
|
||||
(section 7a); the training notice covers any text entered into the app, bar a short exception list Eureka drafts (section 4a); the host may
|
||||
withhold an answer from its Mind in an emergency, with a logged receipt (section 6a); Minds authenticate to the receiver through an OIDC
|
||||
client with an audience, as the desk does (section 4b). Eureka's decisions on forks 2 to 5 of 2026-11-12 (`/opt/commons/handoff/bifrost-10-forks-2-5-recs-20261112.md`)
|
||||
are folded into sections 2, 4b, 6a, 7a and 9 and close those forks, except where section 9 says a point is still open.
|
||||
|
||||
## Rulings of the steward (Heimdall, 2026-11-12)
|
||||
|
||||
Two of this doc's forks are the Bifrost steward's. Meta's, Eureka's and Libby's items are not ruled here.
|
||||
|
||||
1. **Ratatoskr's client for people: reading 2a, with no third client.** The one public PKCE client the desk-credential cut seeds for Ratatoskr (only when `Bifrost:Oidc:Clients:Ratatoskr` is configured, so absent config changes nothing) also gets a scope `feedback.answer` that puts audience `bifrost-feedback-page` on the token. Authorization code with PKCE only, exact redirect URIs, no refresh token, the token only in an `Authorization` header and never in a URL, a query or a log. The audience is distinct from the Mind's `bifrost-feedback`, so a person's token never opens a Mind endpoint and a Mind's never opens the page (least privilege). Reading 2b, a Matrix token checked at the homeserver, is refused: it is a bearer for the person's whole Matrix account. The receiver writes a text-free receipt per accepted submission (receipts).
|
||||
2. **Token lifetimes are explicit, short, and set in the issuer change.** `feedback.answer` access token: 15 minutes, because the Bifrost session cookie re-issues it silently. The Mind's `client_credentials` token: 10 minutes, one call to renew. No refresh token for either. The desk's lifetime is its own decision and is not touched here. Until the issuer change lands the OpenIddict default applies; this doc no longer relies on it.
|
||||
|
||||
## 1. What the receiver owns
|
||||
|
||||
| Concern | Owner | Receiver's part |
|
||||
|---|---|---|
|
||||
| A question, its listing, status, hides, rulings | the owning Mind, in her own mind (Huginn) | reads them to build the projection; **never writes to Huginn** (an admission for another instance is refused `ForeignInstance`, PR #24 section 3) |
|
||||
| An answer's **text** | the receiver | stores it; it is never admitted into a mind |
|
||||
| The answer's **record** in the Mind | the owning Mind's Persona | she admits a text-free `feedback_answer` from the delivery, and acks after she has |
|
||||
| What `kind` an account is | Bifrost (`Membership`, later `Role`) | reads it at answer time and stamps it |
|
||||
| Delivery to the Mind | the receiver, at least once | keeps the record until the Persona acks |
|
||||
| Hiding, closing, ruling | the owning Mind | **never**; a hide only changes what the projection serves |
|
||||
| Erase | Bifrost's erase, executed here | redacts the text and name in the store, keeps a text-free record (section 7a) |
|
||||
| Withholding an answer from its Mind | the host (emergency only) | logs a text-free withhold record and skips delivery; changes nothing stored or ruled (section 6a) |
|
||||
| Who may answer | Bifrost: `Active`, a live training-notice receipt | checks both on every submission with text (section 4a) |
|
||||
|
||||
The page and the client write no record that carries identity (PR #24, section 3). A Matrix sender is a different identity
|
||||
authority and never becomes `answerer`.
|
||||
|
||||
## 2. The mouth's pending/ack path, read from source
|
||||
|
||||
Verified in `tools/persona-matrix-mouth.mjs` and `tools/persona-chat-documents.mjs`; line numbers re-read at main `e58e953` (2026-11-12), where the mouth has grown by about 100 lines since `b1725a1` and nothing else in this section changed:
|
||||
|
||||
- **Pending is Matrix mail only, held in memory.** `pending` (mouth l.156) is filled in one place, `syncOnce`
|
||||
(l.543; `pending.push(...made)` l.602), from Matrix `/sync` events. No function accepts a delivery from elsewhere.
|
||||
- **A request is non-consuming; an ack consumes.** The pending requests carry `afterSeq`; the mouth answers
|
||||
`pendingAfter(afterSeq)` (l.720) paged to 256 KiB (`pageOf`, l.1055-1084; `limits.replyBytes`, documents l.53). `chat_ack` carries `throughSeq`; `ack`
|
||||
(l.662-665) drops every delivery at or below it, releases stored images (`images?.acked`) and advances the Matrix cursor. An ack posts nothing to
|
||||
Matrix: her public `m.read` receipt follows a separate `chat_read` document (header l.10-13, `chat_read` branch l.1138). The endpoint serves a closed list of kinds
|
||||
(`served`, l.1103) and answers any other loudly with `chat_refusal` `UnknownKind` (l.1112); a request for another Persona gets `UnknownPersona`.
|
||||
- **At-least-once.** The Matrix cursor is persisted only when every delivery of a batch is acked (`commit`, l.619-627), so a
|
||||
restart before the ack re-delivers from Matrix. `seqCounter` (l.155) is persisted whenever a round made deliveries (l.614), so
|
||||
sequence numbers never repeat across a restart.
|
||||
- **Backpressure.** At 500 unacked (`MAX_PENDING`, l.63) `syncOnce` stops reading (l.550). A truncated round (pending cap l.578, reply-read cap l.579)
|
||||
leaves the cursor where it was (l.607).
|
||||
- **A delivery that will not encode is skipped, not held.** `pageOf` logs and omits it (l.1060-1062); the caller's
|
||||
next ack covers its seq, so it is dropped. Harmless for a chat line, wrong for a durable record.
|
||||
- **The endpoint is loopback RUDP.** `startChatEndpoint` (l.1091) refuses a non-loopback host (l.1092). It is another process
|
||||
from Bifrost.Web, which has no way to hand the mouth anything.
|
||||
- **The delivery tuple is closed and versioned.** `deliveryTuple` is twelve fields (the twelfth is an optional stored-image reference), the pending answer
|
||||
`gamecult.persona.chat_pending.v2` (`persona-chat-documents.mjs` l.216-224). v3 adds `basis` and `form` (14 fields) and a room tail (`chat_pending.v3`,
|
||||
asked for by `chat_pending_request.v2`); v4 adds `reply_to` (15 fields, `chat_pending_request.v3`, served only with `CHAT_PENDING_V4=1`, `replyLinkFromEnv` l.1185). No version has a source or
|
||||
kind field. A delivery cannot say it is not a Matrix message.
|
||||
- **A fetch kind exists, off by default.** `chat_fetch_request` is served only with `CHAT_FETCH=1` (`served`, l.1103; `fetchFromEnv`, l.1187): an optional served kind is a precedent for adding kinds
|
||||
to `served`, and the way a kind is withheld until its consumer is ready.
|
||||
- **The consumer is not in this repo.** The mouth's header (l.10-11) says the heartbeat polls with a non-consuming
|
||||
request and decides who runs. I could not confirm that from source.
|
||||
|
||||
What follows from it:
|
||||
|
||||
1. **Feedback cannot ride `pending` as it is.** There is no inlet, and the cursor logic ties acks to Matrix batches.
|
||||
2. **A framed text delivery is forgeable.** Any Matrix member can write a line that looks like a frame, and no tuple has a
|
||||
field to tell the Mind which one is real. The frame must be a typed difference in the wire, not a convention in `text`.
|
||||
3. **A record that is not in Matrix is not re-delivered by a restart.** Feedback needs its own durable store and its own
|
||||
delivery cursor.
|
||||
|
||||
PR #24 section 3 names the carrier: a new delivery document, `gamecult.persona.feedback_delivery.v1`, owned by Bifrost, through
|
||||
the mouth's pending/ack path. Source adds one constraint on how it rides:
|
||||
|
||||
- **It cannot be a variant inside `chat_pending`.** Each version is a closed positional tuple (twelve, fourteen or fifteen fields) encoded by
|
||||
`pageOf` (l.1060), so a different tuple in that page breaks every current consumer, and a text frame in `text` is forgeable by any Matrix member.
|
||||
- **Option B, kept in shape:** the delivery document has its own request, page and ack kinds (`feedback_pending_request`, `feedback_pending`, `feedback_ack`), its own seq space and
|
||||
a durable cursor over the receiver's store. The Matrix kinds, the Matrix cursor and `MAX_PENDING` are untouched. Per the decision below the kinds are served by the receiver over
|
||||
HTTPS, not added to the mouth's `served` list (l.1103). Confidence medium: the Persona-side consumer, which I cannot see, must learn them.
|
||||
- **Why main's changes reinforce B.** A chat `ack` releases stored images and advances the Matrix `/sync` cursor (l.662-665). A feedback ack must do neither, so it cannot
|
||||
share the `chat_ack` kind or its `ack` function. The chat pending answer is already versioned three times, so a new field in that tuple is a new
|
||||
document version for every consumer, which a feedback record must not force.
|
||||
- Rejected: injecting records into `pending` as synthetic Matrix deliveries (no inlet, forgeable, lost on restart);
|
||||
posting to a Matrix room as a bot (wrong identity authority, spam and forgery, and the Mind would read it as chat).
|
||||
- **Decided (Eureka, 2026-11-12): feedback does not ride the mouth.** The endpoint is loopback RUDP with no bearer and trusts the persona id in the document, so
|
||||
routing feedback through it would make the Matrix organ a bearer-less deputy holding every Mind's authority and would put the receiver's own seq space and durable
|
||||
cursor in a Matrix process. A Mind pulls and acks **directly over HTTPS from her own process to the receiver, with the section 4b bearer**; she admits the text-free
|
||||
record into her own mind afterwards. The three kinds `feedback_pending_request`, `feedback_pending`, `feedback_ack` are therefore HTTP endpoints of the receiver, not
|
||||
kinds added to `served`; Option B's "same endpoint" clause is withdrawn and the mouth is untouched. The delivery document and its seq space are as above. This is a
|
||||
Bifrost HTTP service, a xenos boundary for CultNet, so JSON/HTTP there is the tolerated kind. Pre-deploy test: section 9.
|
||||
|
||||
## 3. Records
|
||||
|
||||
One CultCache file, owned and written only by the receiver (section 8). Append-only through the API: nothing edits or
|
||||
deletes a record except the redaction of section 7a. Every record has a `recordId` and a receiver-assigned `seq`
|
||||
per owning Mind (the delivery sequence). PR #24 names the answer row `<question-label>.a<N>`, N the receiver's per-question
|
||||
sequence; I use that for answers and the same shape for the others.
|
||||
|
||||
| Record | Fields | Notes |
|
||||
|---|---|---|
|
||||
| `answer` | `recordId`, `questionId`, `owner`, `choice?` (an option label), `text?` (at most 2000 characters), `answerer`, `kind`, `weightPermille`, `nameAtAnswer`, `clientRequestId`, `time` | at least one of `choice`/`text`; an unknown option label is refused; the request id makes a retry idempotent, as `txnId = request_id` does in the mouth; `weightPermille` is 0 for a guest and 1000 otherwise, stamped, and the page sums nothing |
|
||||
| `close_request` | `recordId`, `questionId`, `requester`, `kind`, `time` | refused for a guest and for an account not `active` at that moment (R4c) |
|
||||
| `report` | `recordId`, `targetId`, `reporter`, `kind`, `reason`, `time` | delivered to the Mind as input; never shown, never counted (R6b) |
|
||||
| `withhold`, `withholdLifted` | `recordId`, `answerRecordId`, `owner`, `withheldBy` or `liftedBy`, `time`, `reasonCode` (withhold only) | text-free log of section 6a |
|
||||
| delivery state | per owning Mind: highest seq acked; per record: `delivered`, `failed`, `withheld` | a cursor, not a record the page reads |
|
||||
|
||||
Invariants: `answerer`, `kind`, `weightPermille`, `time` and `nameAtAnswer` come from the verified token and Bifrost state, never from the
|
||||
request body, and the body schema is closed: an unknown key refuses the submission. The latest record per account is that
|
||||
account's standing answer, derived at read time. A submission is refused if the question has no listing in force or is hidden.
|
||||
An answer to a question that has settled is kept and the page says so (PR #24 section 8). A refused submission leaves nothing behind.
|
||||
|
||||
## 4. Credential and claims check
|
||||
|
||||
PR #24 section 7a rules **route 2** for people: Ratatoskr presents an OIDC access token that Bifrost issued, and Bifrost verifies it itself. Meta
|
||||
ruled on 2026-11-12 that Minds authenticate to the receiver through an OIDC client with an audience, as the desk does (4b). This section records what
|
||||
Bifrost's source does on main `e58e953` (the Oidc, Desk and Registration files cited here are unchanged since `b1725a1`, bar `DeskEndpoints.cs:49`, which registers `UnixSocketDeskDoor`).
|
||||
|
||||
### What main does with tokens
|
||||
|
||||
| Token | Who may get it | Gate at issue | Gate on use |
|
||||
|---|---|---|---|
|
||||
| **Chat token** (scopes `openid`, `profile`; no audience) | an account that exists, has a Bifrost identity that is not reserved, and whose membership is not `Suspended`; membership is neither required nor written (`OidcEndpoints.cs:55-78`) | a live training-notice receipt: without one the member is redirected to the consent page and the parked request waits (`:104-111`) | the token and userinfo endpoints re-read it: `ChatRefusalAsync` refuses an absent or `Suspended` account and a chat token with no live receipt (`:164-180`, called at `:127` and `:144`) |
|
||||
| **Desk token** (every scope is `desk.read` or `desk.answer`, `DeskEndpoints.cs:21-26`) | the same sign-in, then only a client whose permissions allow the desk scope | **no receipt**: `IsDeskToken` is true only when every scope is a desk scope (`:118`), so a desk token skips the gate (`:104`, `:108`); `principal.SetResources(DeskEndpoints.Audience)` puts audience `bifrost-desk` on it (`:91-94`) | per request, in `DeskEndpoints.HandleAsync`: audience (`DeskEndpoints.cs:110`), scope (`:116-121`), then `Active` membership re-read through `OidcEndpoints.ActiveMemberIdAsync` (`:123-139`; `OidcEndpoints.cs:183-194`, which is `CurrentBifrostActor.IsActiveMember`, `Status == Active`, `CurrentBifrostActor.cs:17`) |
|
||||
|
||||
The token claims are unchanged: `sub` is `UserAccount.Id` in form `D`; `preferred_username` and `name` ride the token only with scope `profile`
|
||||
(`OidcEndpoints.cs:80-102`). No email, Heimdall id or GitHub id is in it. The access token is an encrypted OpenIddict token and its lifetime is OpenIddict's
|
||||
default, which I believe is one hour and have not verified (no lifetime override in `src`; `ratatoskr-voting-auth.md` section 14 D3 owes explicit values). There is no refresh token.
|
||||
|
||||
How a bearer is validated has changed: OpenIddict's validation handler is registered against the local server (`DeskEndpoints.cs:36-38`), reading the
|
||||
`Authorization` header only (`:40-44`), and `OidcDeskCredential` authenticates through it by its own scheme name and judges nothing else
|
||||
(`OidcDeskCredential.cs:14-27`; audience, scope and membership are the desk's, per its header comment). That path is registered only when `Bifrost:Oidc` is configured (`:30-33`).
|
||||
|
||||
**Only one client exists.** The seeder creates the MAS client and deletes every other application at startup (`OidcClientSeeder.cs:35-51`, `:63-75`). MAS's permissions
|
||||
carry no desk scope (`:42-50`), so **no desk token can be issued on main**: the desk's second client is owed (`ratatoskr-voting-auth.md` section 14 D7). `AllowAuthorizationCodeFlow` is the only
|
||||
grant and the token endpoint takes `client_secret_basic` only (`OidcServiceCollectionExtensions.cs:39`, `:45-46`).
|
||||
|
||||
### 4a. What the receiver checks on an answer from a person
|
||||
|
||||
In this order; a submission that fails any step leaves nothing behind.
|
||||
|
||||
1. **The token opens and has not expired**, and `sub` is a Guid naming an existing `UserAccount`.
|
||||
2. **The account is `Active` now** (`ActiveMemberIdAsync` as the desk does). A chat token is issued to non-`Active` accounts (`OidcEndpoints.cs:55-62`), so the token alone proves nothing about standing. Under PR #9 `IsActiveMember` would also require
|
||||
`Role == Member` and exclude guests, so the receiver reads `Status == Active` itself and not that expression.
|
||||
3. **A live training-notice receipt, read by the receiver on every submission that carries text** (Meta's ruling, 2026-11-12): `ConsentEndpoints.HasLiveReceiptAsync`
|
||||
(`Features/Registration/ConsentEndpoints.cs:146`), the one reading of "may speak": a receipt for the notice version in force, not withdrawn. The notice covers any
|
||||
text entered into the app except a short designated exception list, which Eureka drafts. The receiver checks the receipt itself because the token's
|
||||
gate does not cover it: a chat token's receipt was checked at issue and again only at the token and userinfo endpoints (`:127`, `:144`), never at the receiver, and a token whose audience is
|
||||
not a chat token skips the gate altogether (`:104`). A receipt withdrawn after issue must stop the next submission. The check applies to answers, close requests and reports alike. **Until Eureka's list exists the gate applies to every submission with text**; the list narrows it, and only by being written down.
|
||||
4. The body is closed-schema, an option label must exist, the question must be in force and not hidden (section 3).
|
||||
|
||||
A refusal for a missing receipt is typed and carries the consent page's path, so the page can send the person to agree; it does not store the text.
|
||||
|
||||
What the receiver refuses: a token that does not open or has expired; a `sub` that is not a Guid or names no account; an account that is not `Active`; a missing receipt (above);
|
||||
a body carrying `answerer`, `kind`, `name`, `weight` or any key the closed schema does not name; an unknown option label; a question with no listing in force, or hidden; a close request from a guest.
|
||||
|
||||
What source says route 2 still has to answer:
|
||||
|
||||
1. **No client reaches Ratatoskr.** MAS holds the only client, and it holds the access token; Ratatoskr gets a Matrix session from MAS, not a Bifrost token. The reading that fits what the desk now does is **2a**: Ratatoskr as a
|
||||
second, public, PKCE client with its own audience and scope, issued by the same code path as the desk's (`ratatoskr-voting-auth.md` sections 3-4). The audience machinery it needs now exists on main; the client does not. Reading **2b** (check the Matrix token against the homeserver) hands the receiver a bearer good for the person's whole Matrix account; I recommend against. I lean to 2a, confidence medium.
|
||||
2. **The logout reconciler covers only MAS** (`OidcLogoutReconciler.cs:108`, `:180`); a second client's token lives to expiry. The receiver's own `Active` and receipt reads at every submission cover that.
|
||||
3. A token with an audience of its own would not be accepted from any other client, which closes the weakness an unaudienced chat token had here.
|
||||
|
||||
### 4b. How a Mind authenticates (Meta's ruling, 2026-11-12; names are proposed)
|
||||
|
||||
A Mind authenticates to the receiver through an OIDC client with an audience, the shape the desk uses: a scope at issue puts an audience on the token
|
||||
(`OidcEndpoints.cs:91-94`), the validation handler opens it (`OidcDeskCredential.cs:14-27`), and the endpoint judges audience, then scope, then subject
|
||||
(`DeskEndpoints.cs:110-139`). The analogous parts:
|
||||
|
||||
| Part | Desk (main) | Receiver (**proposed**) |
|
||||
|---|---|---|
|
||||
| Audience | `bifrost-desk` (`DeskEndpoints.cs:21`) | **`bifrost-feedback`** |
|
||||
| Scopes | `desk.read`, `desk.answer` (`:22-23`) | **`feedback.read`** (pull deliveries and the Mind's own projection) and **`feedback.ack`** (ack a delivery; also the Mind's acts that pass through the receiver) |
|
||||
| Credential | `OidcDeskCredential`, OpenIddict validation, header only | the same port, a second audience |
|
||||
| Subject check | `ActiveMemberIdAsync` on `sub` (`DeskEndpoints.cs:127`) | **the `sub` must be the owning Mind's configured client**, matched against the question's `owner`; a Mind is not a `UserAccount` and has no membership to read |
|
||||
| Receipt | none for a desk token (`OidcEndpoints.cs:104`) | none: a Mind is not a person entering text |
|
||||
|
||||
**Decided (Eureka, 2026-11-12): OAuth 2.0 `client_credentials`, one confidential client per Mind.** `client_id = mind-<persona>`; scopes `feedback.read` and
|
||||
`feedback.ack` only; audience `bifrost-feedback`. The token's `sub` maps to the Mind's registered persona and is matched against the question's `owner`. The grant
|
||||
is RFC 6749 section 4.4 ("using only its client credentials"): short-lived, audience-bound, no refresh token, no cookie session. It is not extended to the desk in the
|
||||
same cut.
|
||||
|
||||
**The issuer today cannot issue it.** It enables one grant, `server.AllowAuthorizationCodeFlow().RequireProofKeyForCodeExchange()`
|
||||
(`OidcServiceCollectionExtensions.cs:39`); the token endpoint accepts `client_secret_basic` only (`:44-46`: `ClientAuthenticationMethods.Clear()`, then `ClientSecretBasic`);
|
||||
the seeder creates only the MAS client and deletes every other application at startup (`OidcClientSeeder.cs:35-51`, `:63-75`). **Enabling `client_credentials`
|
||||
(`AllowClientCredentialsFlow`), seeding the per-Mind clients and the audience/scope registration is an issuer change this design owes**, with a matching edit to
|
||||
`docs/oidc-issuer.md`. Until it lands no Mind token can be issued on main. The audience and scope names are accepted as proposed.
|
||||
|
||||
**Client authentication: `private_key_jwt` with the Mind's Ed25519 key is not available on the pinned stack; use a secret.** Finding, read against the pinned packages
|
||||
(`src/Bifrost.Web/packages.lock.json`: `OpenIddict.Server`, `.Validation` and the rest at 7.7.1):
|
||||
|
||||
- *Supported:* OpenIddict.Server 7.7.1 has `private_key_jwt` client authentication. Its assembly carries the literal `private_key_jwt`, the `client_assertion` and
|
||||
`client_assertion_type` parameters, `OpenIddictServerOptions.ClientAssertionTypes`, and the handlers `ValidateClientAssertion`, `...Issuer`, `...Audience` and `...WellknownClaims`
|
||||
(`OpenIddict.Server.xml`); each application can carry a `JsonWebKeySet` (`OpenIddictApplicationDescriptor.JsonWebKeySet`, `IOpenIddictApplicationManager.GetJsonWebKeySetAsync`, `OpenIddict.Abstractions.xml`).
|
||||
Bifrost does not enable it today: the method list is cleared to `ClientSecretBasic` (`:44-46`).
|
||||
- *Not supported with Ed25519:* the assertion is a JWT checked by `Microsoft.IdentityModel` 8.19.2, the version the lock file pulls in. Scanning
|
||||
`Microsoft.IdentityModel.Tokens.dll` for algorithm identifiers finds only `HS256/384/512`, `RS256/384/512`, `PS256/384/512` and `ES256/384/512`; there is no `EdDSA`, `Ed25519`
|
||||
or `OKP` anywhere in it or in `Microsoft.IdentityModel.JsonWebTokens.dll`, and none in `OpenIddict.Server.dll` or `OpenIddict.Abstractions.dll`. So a Mind's existing Ed25519 key
|
||||
(the one moderator-registered through `MindIntakeService.cs`) cannot sign a `client_assertion` this server would accept. (Evidence is a string scan of the shipped assemblies, not a
|
||||
runtime test; I did not run an assertion against a live issuer. Confidence high that it fails, since the algorithm names are absent.)
|
||||
- *Consequence:* `private_key_jwt` would need a second, ECDSA P-256 (or RSA) key per Mind registered as the client's key set, which is a new key to custody and no improvement on a secret.
|
||||
Decision: **`client_secret_basic` with one secret per Mind**, the method the token endpoint already takes. If the stack gains EdDSA the choice reopens, and `private_key_jwt`
|
||||
with the existing key is then preferred because it removes the per-Mind secret (RFC 7523 section 2.2).
|
||||
|
||||
**The client secret lives only in a file the Mind's process reads** (for example a systemd credential in her own home, mode 0400), never in an environment variable, a command
|
||||
line or a log. The seeder stores the hash Bifrost holds; the plaintext is handed to the Mind once, through the same custody path as her other credentials.
|
||||
|
||||
| Part | Desk (main) | Receiver (decided) |
|
||||
|---|---|---|
|
||||
| Audience | `bifrost-desk` (`DeskEndpoints.cs:21`) | `bifrost-feedback` |
|
||||
| Scopes | `desk.read`, `desk.answer` (`:22-23`) | `feedback.read` (pull deliveries, read the Mind's own projection) and `feedback.ack` (ack a delivery; also the Mind's acts that pass through the receiver) |
|
||||
| Credential port | `OidcDeskCredential`, OpenIddict validation, header only | the same port, a second audience |
|
||||
| Subject check | `ActiveMemberIdAsync` on `sub` (`DeskEndpoints.cs:127`) | `sub` is the owning Mind's client; a Mind is not a `UserAccount` and has no membership to read |
|
||||
| Receipt | none for a desk token (`OidcEndpoints.cs:104`) | none: a Mind is not a person entering text |
|
||||
|
||||
Rejected (Eureka): reusing the mouth's signed envelope per request. It is motion-bound (`verb` is commit/reveal, `MotionId` in the signed text) and would need a new verb set.
|
||||
Token lifetimes: ruled in Rulings of the steward (15 and 10 minutes, set explicitly in the issuer change).
|
||||
|
||||
## 5. Account-kind lookup
|
||||
|
||||
- The account is found by the token's `sub`, which is `UserAccount.Id` (section 4). **`answerer` is `UserAccount.Id`**, which survives role
|
||||
change and Heimdall unlink; the Heimdall account id is only an authenticator handle and is cleared on erase (guest-accounts design, PR #9).
|
||||
- On main there is only `Membership.Status` (`Authenticated | PendingApproval | Active | Suspended`) and no role
|
||||
(`BifrostModels.cs` l.5-10, l.335). The role field and `gamecult.bifrost.account.v1` are in PR #9 and unbuilt.
|
||||
Until then, `kind` can only be derived: `Active` is `member`, `Authenticated` and `PendingApproval` are `user`, and
|
||||
nobody is a `guest`. That is the PR #9 backfill, repeated here, and R4a-c have no guest to apply to until PR #9 lands.
|
||||
- **An answer needs `Status == Active`**, read at answer time, not `IsActiveMember` (section 4a). On main the chat token is issued to any account that is not
|
||||
suspended (`OidcEndpoints.cs:55-62`), so an `Authenticated` or `PendingApproval` account can hold a token and the receiver refuses it on its own read.
|
||||
- **`kind` is read from Bifrost's database**, never from the token: the session carries identity only.
|
||||
- **`mind` is not a Bifrost account** in main or PR #9. A Mind authenticates as an OIDC client with an audience (section 4b), not as an account; `kind` is stamped only on a person's answer.
|
||||
- **The display name must not be `CurrentBifrostActor.DisplayName`.** It falls back to `GitHubLogin`
|
||||
(`CurrentBifrostActor.cs:11-15`) and then to the literal "Guest". The receiver stores `nameAtAnswer` from
|
||||
`MemberProfile.Nickname` or `UserAccount.DisplayName` only, and "an account" when both are empty. Never a provider handle,
|
||||
GitHub login, Heimdall id or Matrix localpart.
|
||||
|
||||
## 6. Delivery as quoted data
|
||||
|
||||
- The receiver delivers each record as a `gamecult.persona.feedback_delivery.v1` document on the typed kinds of section 2. A feedback
|
||||
record is therefore never a `chat_pending` delivery and a Matrix sender cannot produce one.
|
||||
- The delivery carries its fields separately: `recordId`, `questionId`, `kind`, `nameAtAnswer`, `answerer`, `time`, `choice`, `text`. The
|
||||
**Persona's side renders the frame**, not the receiver, and puts `text` in a data position only: attributed, quoted, never in an instruction
|
||||
position, with the author's `kind` stated (R4d). The receiver does no keyword screening.
|
||||
- **She admits the text-free `feedback_answer` into her own mind and acks only after that** (PR #24 section 3). The text reaches her turn once, in the
|
||||
delivery, and is never admitted; she cites the record id, not the text. A restart redelivers the same `recordId`, which Huginn answers
|
||||
`AlreadyAdmitted`. So an ack means *admitted*, a stronger meaning than the mouth's chat ack, which means only *taken*.
|
||||
- **Untrusted by kind.** Text from `guest`, `user` and `patron` is untrusted input; so is `member` text, which differs only in weight. She records a
|
||||
ruling from an answer, never an action.
|
||||
- **A record that will not encode, or that she cannot admit, is marked `failed` and stays visible**, never acked past silently (the opposite of
|
||||
`pageOf`). The mouth's cap and backpressure do not apply: the receiver keeps accepting answers while a Mind has a backlog, because they are durable.
|
||||
- Close requests and reports are delivered the same way. A close request changes nothing until the Mind resolves the question; a report changes nothing.
|
||||
- **An erase notice is delivered the same way** (section 7a), and she answers it with a `feedback_hide` of reason `ErasedAuthor`.
|
||||
|
||||
## 6a. Withholding an answer from its Mind (Meta's ruling, 2026-11-12)
|
||||
|
||||
The host may withhold an answer from its Mind in an emergency, with a logged receipt. It changes **delivery only**.
|
||||
|
||||
- **The withhold record** is one more receiver record, append-only, text-free: `recordId` (its own), `answerRecordId` (the answer withheld), `owner` (the Mind),
|
||||
`withheldBy` (the host actor's `UserAccount.Id`, read from Bifrost state, never the body), `time`, `reasonCode` (closed, exactly `Emergency` (imminent harm), `PersonalData` (the answer contains someone's data) or `Legal` (counsel or a legal request); no free text, because free text is where content would get in; a fourth code needs a ruling) and, when it ends, a second record, `withholdLifted`, with `liftedBy` and `time`.
|
||||
**No answer text, no choice, no name of the answerer and no excerpt is in either.** It is the log the ruling asks for, readable by the host. **Decided (Eureka, 2026-11-12): the Mind and the member both see a text-free `withheld` marker carrying the code**, so silence is not read as "no answer"; the member sees the same marker on their feedback page. The marker carries no text, no choice and no withholder.
|
||||
- **What it changes:** the answer's delivery state becomes `withheld`, and the delivery cursor does not pass it silently. The receiver does not put it on the wire to the Mind
|
||||
while the withhold is in force. Lifting it makes the answer deliverable again, in its original seq order, unchanged.
|
||||
- **What it never changes:** the stored text (it is not redacted, edited or moved: only erase redacts, section 7a); the projection rules of section 7 (a withhold is not a hide);
|
||||
and the Mind's own authority over the question: she may still hide the answer, close the question or rule on it by her own acts, and a withhold takes none of that away. She cannot lift it; the host's act is the host's.
|
||||
- **Who may do it: `PlatformAdmin` only** (`CanManageMembership`, `CurrentBifrostActor.cs:30`, the check suspension uses; no new role). The act is refused to anyone else and to the Mind herself. **The withholder's identity (`withheldBy`, `liftedBy`) appears in the audit receipt only**, never on the marker the Mind or the member sees.
|
||||
Open: whether the member should be told in a safety case (for example a threat) is Meta's call (Eureka 60%); the default written here is that the member is told.
|
||||
- A withheld answer is not erased, and an erase of its author redacts the text as in 7a; the withhold record stays, because it carries none.
|
||||
|
||||
## 7. Projection read
|
||||
|
||||
The page polls the receiver, and the receiver reads the Mind's side through Huginn over RUDP and writes nothing to it
|
||||
(PR #24 section 3). The page does not talk to Huginn.
|
||||
|
||||
- Questions, their listing (`audience`, `close_grants`), status, rulings and hide acts come from the Mind's mind. Answer text and the name come from the receiver's store; an answer row is assembled from the receiver's record and the Mind's text-free `feedback_answer`.
|
||||
- **A hidden item's text is withheld on the server**, not collapsed in the client: author, kind, time and reason
|
||||
stay, the text does not leave the receiver (R6d). A hidden question's title, body, options and answers are withheld.
|
||||
- Questions and answers are filtered by the viewer's `kind` against the question's `audience` at read time (R4b).
|
||||
- The projection carries its own `as of`, so the page can say when it is stale.
|
||||
|
||||
## 7a. Erase
|
||||
|
||||
Bifrost's erase (PR #9) blanks the display name and keeps the account id as a tombstone. **Ruled by Meta, 2026-11-12: erase redacts the answer text in the receiver's store and keeps a text-free record.** The receiver's part, as PR #24 section 7 fixes it:
|
||||
|
||||
1. Bifrost's erase records its intent in Postgres first, then the receiver applies it idempotently. The two stores are not one transaction, so
|
||||
erase is crash-replayable, as `docs/bridge.md` describes for the persona-feedback pump.
|
||||
2. For every record the account authored the receiver **drops the text and the name** and keeps **record id, kind and time only**. `choice`,
|
||||
`weightPermille`, `answerer` and `clientRequestId` are dropped too. The row shows "erased account". **No content hash is kept.** Redaction is in the store itself, not a flag over retained text.
|
||||
3. The receiver delivers an erase notice, and the Persona admits a `feedback_hide` of reason `ErasedAuthor`: the Mind's acknowledgement, not a moderation call.
|
||||
4. **The honest residue:** a Mind's own memory or ruling may already quote an answer, and Huginn cannot redact it. That the Persona cites record ids and
|
||||
never text is a prompt, not an invariant. Erase does not reach what she already holds.
|
||||
5. **Backups: 56 days, restores replay erasures** (Eureka, 2026-11-12). "Append-only" is the receiver's API contract, not a property of the file, so erased text survives in
|
||||
backups of it. The stated purge is: **erased text can survive in backups at most 56 days**, the existing Raven tier (newest 14 daily plus the newest of each of 8 Monday-weeks;
|
||||
`gamecult-ops` `runbooks/gamecult-state-backup.md`, read at `origin/main`). No backup is changed. The receiver keeps an **erased-id list** (account id and record ids, no text,
|
||||
no hash) beside its store, and **any restore must re-apply it over `receiver.cc` before the receiver serves, never trusting the two files to be the same generation (section 8)**. The notice to people says it (R2 of the exceptions draft). If Meta wants shorter, put the
|
||||
store outside the weekly tier (14 days), a small backup-config edit.
|
||||
*Is the store under a backed-up path? Yes, by choice (Eureka, 2026-11-12):* `/var/lib/gamecult/bifrost/feedback-receiver/` (section 8), under `/var/lib/gamecult`, which the nightly
|
||||
snapshot takes whole bar Idunn caches and Ghostlight acceptance output. It avoids the excluded `/srv/bifrost/persona-feedback/runtime`, `app`, `log` and `backups`. A file with a sibling
|
||||
`<name>.lock` is re-copied under a shared lock, so `receiver.cc` and `erased.cc` each carry a `.lock` and travel in one directory in every snapshot, the erased-id list included. The 56 days is read from the runbook, not measured.
|
||||
|
||||
## 8. Where it lives
|
||||
|
||||
**CultCache files in one directory, written only by the receiver, which is C# inside the Bifrost app** (`bifrost.service`, `Bifrost.Web`), sharing its issuer and auth (Eureka, 2026-11-12). Not Node,
|
||||
not the mouth, and not the `bifrost-feedback` user of `bifrost-persona-feedback.service`.
|
||||
|
||||
- **Path:** `/var/lib/gamecult/bifrost/feedback-receiver/` holding `receiver.cc` (records and delivery state), `erased.cc` (the erased-id list of section 7a) and a `.lock` file for each.
|
||||
Directory `bifrost:bifrost`, mode 0750, **created by root at install**: `/var/lib/gamecult/bifrost` is `root:root` 0755 today, so the `bifrost` user cannot create the child itself (or a
|
||||
`StateDirectory=gamecult/bifrost/feedback-receiver` in the unit that writes it). `bifrost.service` has no sandbox directives, so it needs nothing more; if it is later hardened with `ProtectSystem=strict`,
|
||||
add `ReadWritePaths=/var/lib/gamecult/bifrost/feedback-receiver`. The backup reads as root, so 0750 does not block it.
|
||||
- **Nothing else opens the files.** There is no Node mouth reader and no Rust writer. The receiver is the only process that opens `receiver.cc` or `erased.cc`; its delivery cursor lives in the same
|
||||
store. The cross-runtime refusals are real and are not worked around: the Rust runtime refuses to write until it has pulled, and the C# runtime refuses to open a store whose records it cannot read
|
||||
(`CultStoreUnreadableException`). The CultCache contract would allow a lock-free stale read or a shared-lock read by another process; **this design takes neither: Mind-side readers get no file
|
||||
access at all, they pull over HTTPS** (section 2).
|
||||
- **Two stores, two locks, no pair consistency.** `receiver.cc` and `erased.cc` take independent locks (`receiver.cc.lock`, `erased.cc.lock`; `AcquireLock`, `CultCache.cs:5164-5185`, `FileShare.None`, retry 30 s),
|
||||
so a backup copying under each shared lock can capture them at different generations. The design never trusts the pair to be consistent: **restore re-applies `erased.cc` over `receiver.cc` by
|
||||
design** (section 7a, item 5), whichever generation each came from, and the receiver does not serve until it has.
|
||||
- **The C# runtime fit is "needs work" (Eyes).** `CultLib` C# (`src/GameCult.Caching` and `GameCult.Caching.MessagePack`, about 10.6k lines, netstandard2.1, MIT) fits net8.0, but:
|
||||
- **No published package exists**: nuget.org has none, the `cultlib-v` tags stop at 1.0.32, and packaging is a PowerShell local feed (`CultLib` `docs/nuget-packaging.md`). Bifrost has no `nuget.config` and no vendor directory.
|
||||
**The consumption route (checkout `ProjectReference`, local feed, or vendoring) is an open fork for Eureka and Libby.**
|
||||
- **Version conflict (predicted, not run):** `Caching.MessagePack` needs System.Text.Json 9.0.10; Bifrost's Direct pin is `[8.0.5, )` (`packages.lock.json:108-112`), so restore likely fails with NU1605.
|
||||
- **It drags CultMath** (MIT AND MPL-2.0 AND Apache-2.0, shaders) into a web service.
|
||||
- **Writer precedent:** the Node sidecar already writes `/srv/bifrost/persona-feedback/deliveries.cc` (`docs/bridge.md:294`), so the receiver is the second `.cc` writer in the app and the first in C#.
|
||||
- **Gaps owed to Libby (CultLib steward); no local workaround is built here:** (1) no consumable C# package; (2) the System.Text.Json pin conflict; (3) the CultMath transitive closure;
|
||||
(4) `src/GameCult.Caching/README.md:310` says single-writer, no competing writers, which contradicts the store-composition contract's lock; (5) no documented stale `.tmp` / crash procedure;
|
||||
(6) no documented backup-while-writing contract for a pair of stores; (7) no cross-runtime test pairing a Linux C# writer with a Rust reader.
|
||||
- Session check, account state and role stay in Postgres, where they already are.
|
||||
|
||||
I withdraw my earlier lean to Postgres rows: it contradicts the ruled store, and the `.cc` witness it would have owed is what the file is.
|
||||
|
||||
Naming: `persona-feedback` already means the Discord-to-Epiphany ingress (`docs/bridge.md`, "Organizational Persona Feedback Ingress"). This
|
||||
receiver's records and kinds use `feedback_page` and `feedback_delivery`, not `persona_feedback`.
|
||||
|
||||
## 9. Gaps and open forks, plainly
|
||||
|
||||
Rulings of 2026-11-12 closed these, and they are no longer forks: whether erase redacts the receiver's text (it does, section 7a); whether a person's answer to a Mind needs the training-notice receipt (it does, bar a designated exception list, section 4a); whether the host can withhold an answer (it can, in an emergency, section 6a); whether a Mind authenticates through the issuer (it does, with an audience, section 4b).
|
||||
|
||||
**Closed by Eureka's decisions (2026-11-12), folded above:** how a Mind gets a token (`client_credentials`, a confidential client per Mind, secret in a file; section 4b); the carrier (HTTPS with the bearer, not the mouth; section 2); who may withhold, the reason codes and who is told (section 6a); the backup purge number (56 days, with the erased-id list; section 7a). Their remaining work is listed below.
|
||||
|
||||
**Open forks:**
|
||||
|
||||
1. **The exception list is owed.** The notice covers any text entered into the app except a short designated list, which Eureka drafts. This doc names no entries; until the list exists the receipt gate applies to every submission with text (section 4a).
|
||||
2. **Route 2 for people has no client.** Only MAS is seeded (`OidcClientSeeder.cs:35-75`); a Ratatoskr client with its own audience is owed, as is the desk's (section 4a).
|
||||
3. **Telling the member of a withhold** in a safety case (section 6a): Meta's call.
|
||||
4. **Erase is bounded** by what a Mind already holds; Huginn cannot redact it (section 7a, item 4).
|
||||
|
||||
**Owed by this design, not forks:**
|
||||
|
||||
- The issuer change of section 4b (`client_credentials`, per-Mind clients, `docs/oidc-issuer.md`).
|
||||
- **Pre-deploy test: a Mind's process can reach Bifrost over HTTPS, and get a token.** Done 2026-11-12 from a Self run (not a Persona turn's sandbox): `https://bifrost.gamecult.org/healthz` returns 200 with a verified certificate. `/.well-known/openid-configuration` returns 404 because the live app (`67072b2`) predates the issuer, so the token path is untested. **Re-run when the issuer ships**, adding discovery, a `client_credentials` token request and one authorised receiver call; and run `curl -sI https://bifrost.gamecult.org/healthz` once from inside a Persona turn, which has not been tested. No Mind-to-Bifrost HTTP call exists in the repo except the signed intake POST (`Program.cs:768`).
|
||||
- **Install step:** root creates `/var/lib/gamecult/bifrost/feedback-receiver/` as `bifrost:bifrost` 0750 (section 8).
|
||||
- **Retention: 56 days**, stated in the notice.
|
||||
|
||||
**Dependencies and unbuilt things:**
|
||||
|
||||
- **`gamecult.bifrost.account.v1` and `Membership.Role` are unbuilt** (PR #9, design only at last read). Without them `kind` is derived from `Status`, and no guest exists.
|
||||
- **The Huginn kinds are Eureka's.** PR #24 names `feedback_listing`, `feedback_answer`, `feedback_close_request`, `feedback_hide`, owned by
|
||||
`huginn-feedback-kinds-proposal.md`, which I could not read. PR #24 also notes the proposal's `feedback_answer` lists a `content_hash` that must go.
|
||||
Their dependency order is Huginn readers, then the Persona organ, then this receiver, so nothing here can ship first.
|
||||
- **The Persona side must learn the three feedback kinds** and pull them over HTTPS with its bearer (section 2); the heartbeat that polls the mouth is outside this repo and is not the carrier.
|
||||
- **Deploy is behind main.** The deployed mouth is `/srv/bifrost/persona-chat/runtime/current`, release `e396305`, read at an earlier main; main has since added the image path, reply links, `CHAT_FETCH`
|
||||
and the v3/v4 pending answers. The deployed web app is `/srv/bifrost/app/current` at `67072b2`, which predates the desk, the chat-tier issuer change and the consent gate on main. I did not re-read `/srv/bifrost` for this pass. Anything in this design runs on a release that does not exist yet.
|
||||
- **Unread:** Eureka's receiver notes in #minds, `huginn-feedback-kinds-proposal.md`, MAS's side of the session, and Heimdall PR #6 (merged, `docs/answer-collector-consent.md`; it drops a sender with no linked member at l.197 and does not mention guests; the page's "guest answers are collected for nothing but delivery" is unchecked against it).
|
||||
- The receiver writes nothing to Matrix and nothing to Huginn.
|
||||
300
docs/guest-accounts.md
Normal file
300
docs/guest-accounts.md
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
# Guest accounts
|
||||
|
||||
Status: **design, nothing implemented.** Written for `eureka-body:follow_up:bifrost-guest-accounts` under
|
||||
`eureka-body:ruling:guest-arrival-bifrost` (Meta: guests need no Matrix account of their own; they get a GameCult
|
||||
account through Bifrost with a guest role, and move to member, user or patron on the same account seamlessly),
|
||||
building on `eureka-body:ruling:bifrost-owns-accounts-invite-only`. I could not `view` either ruling from the
|
||||
Heimdall mind, so their text here is the follow-up brief's wording. Eureka's review (PR comment, 2026-11-07) checked
|
||||
it against the rulings and found guest-arrival matches; the conflicts it found are folded in below. First guest:
|
||||
sletz (Faust developer). Aqua decides on `#aquasynth` access, so the first grant list is hers to name.
|
||||
|
||||
Read at Bifrost `bf8ff62` (`src/Bifrost.Web/Domain/BifrostModels.cs`, `Features/Membership`, `Features/Heimdall`,
|
||||
`Features/Oidc`, `Features/Motions`, `Features/ConceptVotes`, `Pages/App/Members`) and Heimdall `575be9c`
|
||||
(`src/app-profiles.ts`, `docs/app-profiles/bifrost.md`). File and line references re-read at Bifrost main `e58e953` and Heimdall main
|
||||
`afa1b74` on 2026-11-12; where main moved the subject, the text says so. Nothing in this design has landed on main.
|
||||
|
||||
## Rulings of the steward (Heimdall, 2026-11-12)
|
||||
|
||||
These four are the Bifrost steward's; they replace the recommendations further down. Eureka's, Aqua's and Meta's items are not ruled here.
|
||||
|
||||
1. **Name and shape: a stored `Membership.Role` (enum `AccountRole`), not a boolean.** One stored field is one reading, and an account with no role is denied by default (least privilege). `MemberRole` keeps its name and meaning; renaming it is not needed, and nothing existing is removed.
|
||||
2. **Invite: a single-use code redeemed from a form, never a token in a URL.** The issuer is shown the code once; the guest pastes it into `POST /invite/guest` (a body, not a path or query), so it reaches no log, referrer or browser history. Hashed at rest, seven days, one use, receipted on issue, accept and revoke. The issuer may bind `ExpectedProvider` (optional, narrows the invite). The sign-in return path carries only the Heimdall attempt, never the code.
|
||||
3. **Provider lists: Bifrost narrows to the intersection with Heimdall's `bifrost` profile, `discord, patreon`; no widening.** Twitch cannot start in Heimdall, so dropping it removes nothing live; it logs a deprecation warning for one release before the entry goes (deprecate before remove). Google stays link-only (PRs #81, #82) and becomes a sign-in provider only when an issued invite needs it, by a profile change in Heimdall first. Which provider sletz uses is still the issuer's to learn.
|
||||
4. **Collector: a guest has no `member_id` and none of their messages are collected.** A sender with no linked member is already dropped (`docs/answer-collector-consent.md` l.197 in Heimdall). A `member_id`, and a consent record under it, exists only on promotion to `member`.
|
||||
|
||||
## The shape in one paragraph
|
||||
|
||||
A guest is a `UserAccount` like any other: same `Id` (the OIDC `sub`), same `NormalizedBifrostIdentity` (the Matrix
|
||||
localpart, minted once). What is new is one field, `Membership.Role`, with four values: `guest`, `user`, `patron`,
|
||||
`member`. Role is the single place that says what an account may do. A role change rewrites that field and writes a
|
||||
receipt; it never creates a second account, never re-mints the identity, so chat history, grants and the Matrix
|
||||
user stay attached.
|
||||
|
||||
## What exists today
|
||||
|
||||
- `UserAccount.Id` is the OIDC subject; `NormalizedBifrostIdentity` is `preferred_username` and, through MAS, the
|
||||
Matrix localpart (`docs/oidc-issuer.md`). `HeimdallAccountId` is only an authenticator handle.
|
||||
- `Membership.Status` is `Authenticated | PendingApproval | Active | Suspended`. `CurrentBifrostActor.IsActiveMember`
|
||||
(`CurrentBifrostActor.cs:17`, `Status == Active`) is the one test behind `/App`, bridge transport (`BridgeActionService.cs:492`), motion votes
|
||||
(`Membership.EffectiveVotingWeight`, `BifrostModels.cs:361`, read at `MotionGovernanceService.cs:72`), concept votes (`VoterIdentity.For`,
|
||||
`VoterIdentity.cs:20`, member versus account class), Mind moderation (`MindIntakeService.cs:241`) and the fork desk's subject check
|
||||
(`OidcEndpoints.ActiveMemberIdAsync`, `OidcEndpoints.cs:183-194`). The OIDC chat tier is not behind it: main lets any account that is
|
||||
not `Suspended` and holds a live training-notice receipt sign in to chat (`OidcEndpoints.cs:55-65` and `ChatRefusalAsync`, `:164-180`,
|
||||
called at `:127` and `:144`; `docs/oidc-issuer.md` l.72-77), and the back-channel logout reconciler (`OidcLogoutReconciler.cs`, 30 s
|
||||
interval, l.34) ends sessions of suspended or deleted subjects, never of a chat-tier account for lacking membership.
|
||||
- `MemberRole` (`StandardMember`, `Producer`, `Maintainer`, `LedgerReviewer`, `PlatformAdmin`) are capability
|
||||
roles a member holds in addition. They are not the new field.
|
||||
- Invites are `MembershipInvitation`, keyed by GitHub login (`MembershipSynchronizationService.cs:39-41`,
|
||||
`Members/Index.cshtml.cs:69-71`). A guest may have no GitHub login at all.
|
||||
- Heimdall sign-in (`HeimdallAuthService.StartAsync`) with `requireMemberAccess = false` already creates an
|
||||
identity-only account at `Authenticated`; with member access it creates `Active`.
|
||||
- Nothing exists for room grants, leaving, erasing, or any "estate" in code (estates are rulings and research
|
||||
only, `docs/charter-amendment-proposal.md`).
|
||||
|
||||
## Decision: one role field, status stays lifecycle
|
||||
|
||||
Two authorities cannot both say "may this account vote". Today `Status == Active` says it. The cut:
|
||||
|
||||
- `Membership.Status` keeps its meaning as lifecycle of the account: may it hold a session. Add `Left` and
|
||||
`Erased` (see Leave and erase). `Active` means admitted and in good standing, for any role.
|
||||
- `Membership.Role` (new, non-null, enum `AccountRole`) decides rights. `IsActiveMember` becomes
|
||||
`Status == Active && Role == Member`, and is the only place that expression exists. Every current
|
||||
`IsActiveMember` caller keeps working unchanged and a guest fails all of them without a per-site edit.
|
||||
- OIDC needs no change for guests: main already admits any non-`Suspended` account with a live training-notice receipt to chat (see
|
||||
What exists today), so a guest, user or patron holds a chat session, and a member demoted to `Suspended` loses it. The change this
|
||||
design owes is the two new statuses: `Left` and `Erased` must be refused at the same three places (`OidcEndpoints.cs:62`,
|
||||
`ChatRefusalAsync` `:174`) and ended by the reconciler, which today looks for `Suspended` only. That is the line to review.
|
||||
- Backfill: `Active` becomes `member`; `Authenticated` and `PendingApproval` become `user`; an account with a
|
||||
current `Patron` tier snapshot and not `Active` becomes `patron`. Nobody starts as `guest`.
|
||||
|
||||
Naming risk: `MemberRole` already exists for capability roles. If the collision bites, call the new field
|
||||
`Class`; the published field below follows whichever name is chosen. I use `Role` because that is the ruling's word.
|
||||
|
||||
Alternative considered and not recommended: a boolean `IsGuest` on `Membership` with guests kept at `Active`.
|
||||
Smaller to land, but `user` and `patron` stay implicit in status and tier snapshots, so "the role field Personas
|
||||
read" would have to be derived in three places. One stored field is one reading.
|
||||
|
||||
## Invite shape
|
||||
|
||||
New table `GuestInvitation`, separate from `MembershipInvitation` (which is GitHub-login-keyed and leads to
|
||||
approval, not to a guest). Fields:
|
||||
|
||||
| field | rule |
|
||||
|---|---|
|
||||
| `Id` | Guid. |
|
||||
| `CodeHash` | SHA-256 of a 256-bit random code. The code itself is shown to the issuer once and never stored. |
|
||||
| `IntendedIdentity` | the localpart the issuer picks (for example `sletz`), validated by `BifrostIdentityService.NormalizeIdentity`, reserved while the invite is open. Minted once on accept; a collision gets the usual `-2` suffix. |
|
||||
| `Label` | free note for the issuer ("sletz, Faust developer"). |
|
||||
| `RoomGrants` | the rooms to grant on accept (below), as a list of grant requests. |
|
||||
| `IssuedByUserAccountId`, `IssuedAtUtc` | issuer must be a platform admin (`CanManageMembership`), as for member invites. |
|
||||
| `ExpiresAtUtc` | default seven days. |
|
||||
| `AcceptedByUserAccountId`, `AcceptedAtUtc`, `RevokedAtUtc` | one use; revoke before accept ends it. |
|
||||
|
||||
The code is a bearer: whoever redeems it first is the guest. That is the cost of not needing sletz's
|
||||
provider handle in advance, and it is why the code is single-use and short-lived and why the accept is shown
|
||||
to the issuer. Ruled over binding the invite to a provider handle (which needs a Discord or Google id the
|
||||
issuer may not have); the issuer may still set `ExpectedProvider` (see Rulings). It is carried in a form body, never in a URL.
|
||||
|
||||
## Sign-in via Heimdall
|
||||
|
||||
1. Guest opens `/invite/guest`, pastes the code, and posts it. Bifrost checks hash, expiry, unused, unrevoked, and stores the invite id in
|
||||
the existing Heimdall attempt (`HeimdallAuthService` protects attempt state with the data protector).
|
||||
2. Bifrost starts Heimdall sign-in with `requireMemberAccess = false` (identity only, no entitlement policy).
|
||||
Heimdall authenticates; it does not issue the identity (`docs/jurisdiction-map.md`).
|
||||
3. On the callback, in one transaction: resolve `HeimdallAccountId`.
|
||||
- No account: create `UserAccount` with `IntendedIdentity`, `Membership { Status = Active, Role = Guest }`, write
|
||||
the room grants, mark the invite accepted, write the receipt.
|
||||
- Account exists: the invite never lowers an account. A `member`, `patron` or `user` keeps their role; the grants
|
||||
are added and the invite is spent. If the existing account is `Left` or `Erased`, refuse.
|
||||
4. Bifrost issues its own cookie as for any account. From there the guest reaches chat through the one OIDC client.
|
||||
|
||||
Gap, drift found while reading, still true on main: Bifrost's `AllowedProviders` is `discord, patreon, twitch`
|
||||
(`HeimdallAuthService.cs:37-42`), but Heimdall's `bifrost` app profile allows `discord, patreon, google`
|
||||
(`app-profiles.ts:152`, `bifrostProfile`). Twitch cannot start in Heimdall and Google is refused by Bifrost as a sign-in provider.
|
||||
Main's only Google path is the explicit link by an already signed-in member (`HeimdallAttemptPurpose.LinkAwaitGoogle`,
|
||||
`HeimdallAuthService.cs:672`, PRs #81 and #82); it never signs anyone in, so it does not close the gap. A guest with
|
||||
neither Discord, Patreon nor a Google account cannot sign in, and GitHub is not a Heimdall sign-in provider for
|
||||
this app. Which provider sletz uses is unknown to me; align the two lists before the first invite.
|
||||
|
||||
## Guest rights
|
||||
|
||||
A guest may: sign in, hold a Bifrost cookie, hold a chat session through OIDC (after accepting the training notice, as any chat account does on main, `ConsentEndpoints.HasLiveReceiptAsync`, `ConsentEndpoints.cs:146`), see and edit their own profile
|
||||
(`MemberProfile`), be in the rooms granted to them, and leave or erase (below). A guest may not:
|
||||
|
||||
- vote on motions: `EffectiveVotingWeight` is `0` for any role but `member` (today it is `Max(1, ...)` for
|
||||
`Active` (`BifrostModels.cs:361-363`), which would give every guest weight 1; that line must read the role);
|
||||
- cast concept-mill appraisals: `VoterIdentity.For` returns no voter for a guest (not even `VoterClass.Account`);
|
||||
- appear on any estate roll or in any tally. The charter proposal snapshots each estate's roll at notice (A6);
|
||||
`guest` is on no roll, so a guest cannot be counted, cannot make a quorum, and reclassifying a guest after a
|
||||
snapshot changes nothing for that snapshot;
|
||||
- use bridge transport, claim or log work, earn points or ledger credit, or hold tier snapshots (those make them
|
||||
a patron or member by changing role, not by decorating a guest);
|
||||
- hold a `MemberRole` or any of the `CanManage*` flags;
|
||||
- invite anyone.
|
||||
|
||||
This list is enforced by role in one place (`CurrentBifrostActor`), not by a check at each use.
|
||||
|
||||
## Per-room grants
|
||||
|
||||
Rooms are Matrix rooms in chat, not Bifrost objects. Bifrost owns the decision, so a grant is Bifrost state:
|
||||
|
||||
`GuestRoomGrant { Id, UserAccountId, RoomRef, GrantedByUserAccountId, GrantedAtUtc, ExpiresAtUtc?, RevokedAtUtc? }`
|
||||
|
||||
`RoomRef` is a room id or alias as chat names it. Aqua's `#aquasynth` is the first one if she says yes.
|
||||
|
||||
**Scope (per `guest-room-moderation-ruled`, `guest-room-authority-ruled`).** Project rooms are public-join and
|
||||
hand-moderated by the steward, with Eureka as backstop, so a guest who can reach a public room needs no grant. A
|
||||
grant exists only for a room its steward chooses to keep closed, and its grantor is that room's steward, not a
|
||||
platform admin (the invite itself is still issued by a platform admin; `GrantedByUserAccountId` records the
|
||||
steward). Room entry for public rooms is not Bifrost's decision.
|
||||
|
||||
The grant has to reach Matrix to mean anything. Today Bifrost's only Matrix writer is the Persona mouth, with
|
||||
per-Persona access tokens (`docs/bridge.md`), and it holds no admin or invite right.
|
||||
|
||||
Gap (largest in this doc): something must perform "invite this Matrix user to that room" on a grant and "remove
|
||||
them" on revoke or expiry. Candidates: a Bifrost-owned bot user that is a member with invite power in the granted
|
||||
rooms (reuses the Matrix client API, no Synapse admin credential), or the Synapse admin API. I recommend the bot
|
||||
user: narrower credential, and the room's power levels remain the real gate. Until it exists, the grant is a
|
||||
record, a human invites the guest by hand, and the receipt says `delivered_by: manual`. Whether the closed rooms a
|
||||
steward keeps are invite-only in Synapse is a chat configuration fact I have not verified.
|
||||
|
||||
What Matrix takes from the Bifrost account is exactly this effector: invite or remove on a grant, and the power
|
||||
level the effector sets on invite. Role never enters OIDC, so Matrix reads no role from the issuer
|
||||
(`bifrost-owns-accounts-invite-only`: grants flow from the account through the effector, not through claims).
|
||||
|
||||
**Guest DMs (`guest-dms-ruled`) conflict, open.** The mouth declines invites from other servers and accepts
|
||||
same-server DM invites (`tools/persona-matrix-mouth.mjs` `answerInvites`). The ruling's decline-for-now assumed
|
||||
federated guests; a Bifrost guest is a local `@sletz:<server>`, so the mouth would accept their DM. Either the mouth
|
||||
declines DMs from role `guest` (using the account document below), or the ruling is revisited. That is Eureka's and
|
||||
the ruling's owner's call, not this doc's; until it is made, the mouth's behaviour is the ruling's opposite for local
|
||||
guests.
|
||||
|
||||
Second gap: a guest has a full Matrix account once MAS provisions the localpart, so Matrix itself will let them
|
||||
create rooms or DM unless Synapse's room-creation and invite settings (`room_list_publication_rules`,
|
||||
`allow_public_rooms_*`, a spam-checker or module) say otherwise. These are global in Synapse, not per user, so a
|
||||
guest limit may have to be power levels in every room they are in. I did not find a per-user switch.
|
||||
|
||||
## Role change on the same account id, with a receipt
|
||||
|
||||
`ChangeRole(accountId, to, actor, reason)` is the only writer of `Membership.Role`. It runs in one transaction:
|
||||
|
||||
1. refuse `Erased` and `Left`; refuse a no-op;
|
||||
2. write `Role`, and for `member` also set `Status = Active`, `ApprovedAtUtc`, and the default `StandardMember`
|
||||
capability role (the same effects as `OnPostApproveAsync`); for `patron`, nothing beyond the role (tier
|
||||
snapshots come from support events as before);
|
||||
3. write `AccountRoleReceipt { Id, UserAccountId, From, To, ChangedAtUtc, ChangedByUserAccountId, Reason }`;
|
||||
4. leave `UserAccount.Id`, `NormalizedBifrostIdentity`, `HeimdallAccountId`, grants and profile untouched.
|
||||
|
||||
Who may change: a platform admin (`CanManageMembership`). Promoting to `member` is the existing approve path;
|
||||
this design routes it through `ChangeRole` so one writer exists. Promoting to `member` also applies whatever the
|
||||
charter says about joining an estate (not decided; see Questions). Demotion (member to guest) is allowed and
|
||||
receipted; it also removes the `MemberRole`s and capability flags in the same transaction.
|
||||
|
||||
The receipt is a Postgres row today, like `AuditEvent`. Bifrost's rule is that durable state has a CultCache `.cc`
|
||||
witness (`docs/jurisdiction-map.md`, Design Rules); the witness is owed (Gaps).
|
||||
|
||||
Seamless means: the guest's session cookie, OIDC subject, Matrix id, display name and room memberships do not
|
||||
change on promotion. A cookie issued before promotion carries identity claims only, never the role, so it needs
|
||||
no refresh; the actor is read from the database each request (`CurrentBifrostActorAccessor`).
|
||||
|
||||
## Leave and erase
|
||||
|
||||
Neither exists in Bifrost today. Two different acts:
|
||||
|
||||
**Leave** (guest or any role, self-service): sets `Status = Left`, revokes every grant, and expels the account
|
||||
from granted rooms through the same effector as grants. Once the reconciler and the chat refusals name `Left` (see Decision),
|
||||
it ends the MAS session within its 30 s interval, and the token and userinfo endpoints refuse. The account row and
|
||||
`Id` stay, so a later invite can restore it (a `Left` account re-accepting is a role change from itself, receipted).
|
||||
Nothing else about the person is deleted by leaving.
|
||||
|
||||
**Erase** (self-service, or admin on request): irreversible. In one transaction: blank `DisplayName`, `AvatarUrl`,
|
||||
`MemberProfile`, `GitHubLogin`/`GitHubUserId`, clear `HeimdallAccountId` (the authenticator link), set
|
||||
`Status = Erased`, keep `Id` and `NormalizedBifrostIdentity` as a tombstone so the localpart is never reissued to
|
||||
someone else (OIDC says it is minted once), keep receipts but with names stripped. The reason to keep a tombstone
|
||||
row is that receipts and audit events point at the id.
|
||||
|
||||
An erasing guest has no votes, ledger entries or points, because guests cannot create them. A member or patron
|
||||
who erases does, and those are accounting records: their erase is a separate question (Questions). Guest erase
|
||||
is the case this design covers.
|
||||
|
||||
Outside Bifrost, erase is incomplete without two effectors:
|
||||
|
||||
- Matrix: Synapse's deactivate-with-erase admin call and MAS deactivation for the user. Federation is disabled in
|
||||
the chat map (`gamecult-ops/docs/gamecult-chat-map.md`: federation paths 404), which helps: messages are
|
||||
not copied to other servers. Other members' clients, and any export or training copy, are not reached by an
|
||||
erase. Whether chat content is training data is `heimdall-body`'s consent rulings (Heimdall PR #6), which already
|
||||
define exit by redaction; a guest's messages follow whatever that doc says, not this one.
|
||||
- Heimdall: I found no account-delete or unlink path in Heimdall `src`. Clearing `HeimdallAccountId` on the Bifrost
|
||||
side leaves the Heimdall account and its provider link alive. Heimdall needs an erase call keyed by the account id
|
||||
before "erased" is true end to end.
|
||||
|
||||
## What Eureka reads
|
||||
|
||||
Personas read the role, not the account. Do not put it in OIDC: the issuer promises "no membership detail leaves
|
||||
Bifrost" (`docs/oidc-issuer.md`) and chat does not need it. Publish a typed CultMesh document under the
|
||||
`gamecult.bifrost.governance` family (the nearest existing schema namespace, `verse-service-contract.md` l.60), id
|
||||
`gamecult.bifrost.account.v1`, key = account id:
|
||||
|
||||
| field | type | note |
|
||||
|---|---|---|
|
||||
| `account_id` | uuid | OIDC `sub`; never changes. |
|
||||
| `identity` | string | `NormalizedBifrostIdentity`; the Matrix localpart. |
|
||||
| `matrix_id` | string | the full mxid `@<identity>:<server>`, server name taken from MAS config at publish time so no reader assumes it. The lookup key for a Persona, which holds the Matrix sender id and not the account uuid. |
|
||||
| `display_name` | string | blank once erased. |
|
||||
| `role` | `guest` \| `user` \| `patron` \| `member` | the field Personas read. |
|
||||
| `status` | `authenticated` \| `pending_approval` \| `active` \| `suspended` \| `left` \| `erased` | |
|
||||
| `role_since` | UTC ms | from the latest `AccountRoleReceipt`. |
|
||||
| `rooms` | list of room refs | only for `guest`: the live grants. Others get rooms by estate. |
|
||||
|
||||
Contract the document carries, not left to each reader:
|
||||
|
||||
- **Lookup.** The document is queryable by `matrix_id`.
|
||||
- **Absence is `unknown`.** A sender with no document is not a Bifrost account (a bridge ghost or an unlinked user).
|
||||
Readers must treat it as `unknown`, never default it to `guest`, `user` or `member`. This reverses today's Persona
|
||||
reading (everyone unlisted is `member`), which narrows to the Bifrost `member` role.
|
||||
- **Status gates the role.** Only `status = active` yields a usable role; `suspended`, `left`, `erased`,
|
||||
`authenticated` and `pending_approval` yield none.
|
||||
- **Not Bifrost accounts.** Operator, the Persona's own id and Minds are typed by the reader's config, which takes
|
||||
precedence over the document. Minds are not Bifrost accounts and must not fall to `unknown`.
|
||||
- **Freshness.** A reader loads the document when it builds each turn, never once at start, so a role change
|
||||
applies from the next message; past messages keep the role they were read under. `role_since` lets a trace say
|
||||
which role was read. Publisher bound: a role or status change is published within 30 seconds, the same bound the
|
||||
OIDC reconciler already keeps for a non-`active` subject. No publisher exists, so this is the requirement it is
|
||||
built to, not a measurement.
|
||||
|
||||
Bifrost does not decide Persona behavior; it states the role.
|
||||
|
||||
No such publisher or witness exists; Bifrost has no `.cc` export for accounts today.
|
||||
|
||||
## Cut sequence (not specs)
|
||||
|
||||
1. `Membership.Role`, backfill, `IsActiveMember` and the OIDC tests moved to the new reading, vote and concept-vote
|
||||
exclusions, with tests that fail if a guest gets weight or a voter key.
|
||||
2. `GuestInvitation`, the accept path, the Heimdall provider list aligned.
|
||||
3. `AccountRoleReceipt` and `ChangeRole`, with `OnPostApproveAsync` routed through it.
|
||||
4. `GuestRoomGrant` and the Matrix effector.
|
||||
5. Leave, then erase (Bifrost side), then the Matrix and Heimdall effectors.
|
||||
6. The account document and its `.cc` witness.
|
||||
|
||||
## Gaps, plainly
|
||||
|
||||
- The Matrix effector for grants, leave and erase does not exist, and no credential for it is provisioned.
|
||||
- Synapse cannot cap what a guest does per user; room-creation and invite limits are global (unverified: I did not
|
||||
read the live Synapse config).
|
||||
- Heimdall has no erase path (re-checked at `afa1b74`: no account-delete or unlink route in `src`, and `docs/answer-collector-consent.md`, merged as Heimdall PR #6, redacts by exit and says nothing of guests). Bifrost and Heimdall disagree on provider lists today.
|
||||
- No `.cc` witness or CultMesh publisher for accounts or receipts. Bifrost's account state is Postgres only.
|
||||
- Matrix id shape (`@<identity>:<server>`) depends on MAS importing `preferred_username` as localpart; the OIDC doc
|
||||
says so, I did not check the deployed MAS.
|
||||
- Tie to the answer-collector consent doc (Heimdall PR #6, merged; `docs/answer-collector-consent.md`): a guest posting in a room may be posting to a sample
|
||||
collector. That doc keys on `member_id`, and says a sender with no linked member is dropped (l.197). Whether a guest has a
|
||||
`member_id`, and so whether their answers are collected at all, is not decided here. Default: not collected.
|
||||
- Erase for member and patron roles (ledger and vote records) is unmapped.
|
||||
|
||||
## Questions for the deciders
|
||||
|
||||
1. **Name and shape**: ruled (stored `Role`), see Rulings.
|
||||
2. **Bearer link or provider-bound invite?** Ruled (single-use code from a form), see Rulings.
|
||||
3. **Which Heimdall provider will sletz sign in with?** The lists are ruled (see Rulings); this is the issuer's to learn before the first invite.
|
||||
4. **`member_id` for the answer collector**: ruled (none for a guest), see Rulings.
|
||||
5. **Does promotion to member join the Members estate immediately**, or does the charter's notice and snapshot
|
||||
rule apply to the roll? The charter text is a proposal, not in force.
|
||||
18
docs/heimdall-app-secret-file.md
Normal file
18
docs/heimdall-app-secret-file.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Heimdall app secret from a file
|
||||
|
||||
`Heimdall:AppSecret` (`Heimdall__AppSecret`) is Bifrost's app secret for Heimdall slug `bifrost`; it is sent as the
|
||||
`x-heimdall-app-secret` header. Set through the environment it rides in the process environment. `Heimdall:AppSecretFile`
|
||||
(`Heimdall__AppSecretFile`) names a file holding it instead.
|
||||
|
||||
- File set: Bifrost reads it once at startup and trailing newlines are trimmed. The file wins over `Heimdall__AppSecret`.
|
||||
- File set but missing, unreadable, a directory, or empty: startup fails in every environment. The message names
|
||||
`Heimdall:AppSecretFile` and the path, never the secret.
|
||||
- Only `Heimdall__AppSecret` set: still works; startup logs a deprecation warning (without the value).
|
||||
- Neither set: startup fails outside Development and Testing, naming `Heimdall:AppSecretFile` and `Heimdall:AppSecret`
|
||||
(`StartupConfigurationValidator`). Development and Testing still start, with Discord and Patreon sign-in off.
|
||||
|
||||
Under systemd, pass the file with `LoadCredential=heimdall-app-secret:<root-owned file>` and
|
||||
`Environment=Heimdall__AppSecretFile=%d/heimdall-app-secret`.
|
||||
|
||||
Code: `HeimdallOptions.ResolveAppSecret` (`src/Bifrost.Web/Configuration/HeimdallOptions.cs`), wired in `Program.cs`, forced at
|
||||
startup by `StartupConfigurationValidator.Validate`. Tests: `tests/Bifrost.Web.Tests/HeimdallAppSecretFileTests.cs`.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
219
docs/oidc-issuer.md
Normal file
219
docs/oidc-issuer.md
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
# 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 (not suspended, and agreed to
|
||||
the training notice in force; see "Chat needs a receipt").
|
||||
|
||||
## 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:BrakePath` | Optional. While a file exists at this path, `/connect/authorize`, `/token` and `/userinfo` answer 503 `temporarily_unavailable` and nothing is issued; delete the file to release. Checked per request, so no restart. Unset: no marker brake. |
|
||||
| `Bifrost:Oidc:Limits:*` | `PerAddressPermits` (30), `Window` (1 min), `MaxTrackedKeys` (10000), `MaxTokenRows` (20000), `PruneInterval` (5 min). |
|
||||
| `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. A native account's verified address, once that is built, stays inside Bifrost as sign-in recovery and is never a claim (`registration-gates.md`, Email verification).
|
||||
|
||||
## 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 `Suspended`: redirect to the client's `redirect_uri` with `error=access_denied` and
|
||||
`error_description=The account is suspended.`
|
||||
- Signed in with no membership, `Authenticated` or `PendingApproval` (the chat tier): treated like an active member for
|
||||
sign-in to chat and nothing else. The token carries the same `openid profile` claims; the issuer writes no membership,
|
||||
role or vote, and every member or governance surface (`/App`, `/eve/governance`, `/api/sealed-motions`, the fork desk)
|
||||
still asks for `Active` itself, so neither the session nor the token opens them.
|
||||
- Signed in, with an account that can chat, but `NormalizedBifrostIdentity` empty: `error=access_denied` with
|
||||
`error_description=The account has no Bifrost identity.`
|
||||
- Signed in, with an account that can chat, but the identity is a reserved localpart (`BifrostIdentityService.IsReserved`: the 13 Synapse
|
||||
accounts and the system names, the same 28 as MAS `banned_usernames`): `error=access_denied` with
|
||||
`error_description=The account's Bifrost identity is reserved.` Creation refuses these names, but a row minted before
|
||||
that refusal may hold one and there is no rename path, so the issuer never vouches for a Mind's or a system's localpart. The refusal is at authorize only: a code or access token issued for such a row before this check deployed stays redeemable until it expires (minutes), and token and userinfo re-read membership, not the name. Whether any such row exists is not known from the code; one query of `UserAccounts` against the 28 names would say.
|
||||
- Signed in, not refused above, but no live consent receipt for the training notice version in force
|
||||
(`TrainingNotice.Version`; none ever, withdrawn, or for another version): **not an OIDC error.** A 302 to
|
||||
`/auth/bifrost/consent?returnUrl=<this request's own /connect/authorize?request_uri=... path>`. No code is issued and the
|
||||
client's `redirect_uri` is not called. The consent page shows the notice and an Agree button; agreeing is a 303 back to
|
||||
the `returnUrl`, which resumes the same parked request and issues the code. The parked request is single use and lives ten
|
||||
minutes (it is spent only by an OpenIddict response, which the redirect is not), so a member who agrees after it has expired
|
||||
gets MAS's error and starts the sign-in again, now with a receipt. This check comes last, so a suspended, identity-less or
|
||||
reserved account is refused as before and never invited to agree.
|
||||
- Otherwise: a code is issued.
|
||||
- Token endpoint and userinfo: suspension and the receipt are read again, on every call. An account suspended or deleted
|
||||
since, or without a live current receipt since (withdrawn, or the notice version bumped), gets `invalid_grant` (token) or
|
||||
403 `insufficient_access` (userinfo), with the description of the reason, and no claim. So an access token already in a
|
||||
chat client's hands stops answering userinfo the moment the receipt goes, and answers again if the member agrees again
|
||||
(this path does not revoke the token; the back-channel reconciler below does, when it is configured). There is no refresh token, so refresh needs no gate.
|
||||
The logout reconciler ends the sessions of those same accounts; a chat-tier account is never logged out for lacking membership.
|
||||
|
||||
## Chat needs a receipt
|
||||
|
||||
The issuer is the chat tier: every token it issues is a chat token. Ruled by Meta, 2026-11-11: speaking in chat needs agreement
|
||||
to the training notice, and nothing else does. A live `ConsentReceipt` for `TrainingNotice.Version` is required (the same
|
||||
reading as the consent page's "agreed", `ConsentEndpoints.HasLiveReceiptAsync`); account sign-in, the members page and votes need no
|
||||
receipt. A member with no receipt keeps all of those and loses only the chat token until they agree on the consent page.
|
||||
|
||||
Where they learn it: the refused authorize lands them on the consent page, which states what agreement is for ("to speak in a
|
||||
recorded room"), shows the notice, and returns them to chat sign-in when they agree. Nothing else prompts them: the
|
||||
members page and the sign-in form do not nag, and an existing member who never opens chat is never asked.
|
||||
|
||||
What a Persona or Eureka sees:
|
||||
|
||||
| Step | Receipt live and current | No receipt, withdrawn, or another version |
|
||||
| --- | --- | --- |
|
||||
| Authorize | 302 to the client with `code` | 302 to `/auth/bifrost/consent?returnUrl=...`; no `code`, no `error` |
|
||||
| Token | 200 with tokens | 400 `invalid_grant`, description "Agreement to the training notice in force is needed to speak in a recorded room." |
|
||||
| Userinfo | 200 with `sub`, `preferred_username`, `name` | 403 `insufficient_access`; no claim in the body |
|
||||
| Back-channel logout | none | Only when `BackchannelLogoutUri` is set (the reconciler runs): MAS is sent a logout for the subject and its authorizations are revoked, within one reconciler tick. Otherwise an already-open session is not ended; only new logins and userinfo calls are refused |
|
||||
|
||||
Deploy note: on the first reconciler tick, every subject holding a MAS authorization and no receipt (all existing chat users, admins included) is sent a back-channel logout and loses its authorizations. That is the ruling working as designed, not a fault; they return through the consent page.
|
||||
|
||||
The gate is on the token, so as built it covers every room the MAS login reaches. Which rooms are "recorded", and whether any
|
||||
room is not, is the chat side's to say; the issuer does not know.
|
||||
|
||||
### Userinfo smoke notes for Eureka
|
||||
|
||||
Run against a deployed instance with two throwaway accounts that hold a membership of any kind: A has agreed on its consent
|
||||
page, B has not (register without ticking the box, or click Withdraw). `$ISS` is the issuer, `$MAS` the registered client with
|
||||
its secret, sessions are the cookie jars of a browser signed in as A or as B. Use the MAS client's own redirect URI.
|
||||
|
||||
1. Sign in as A through chat (MAS), or by hand (the first authorize answer is a 302 to `?request_uri=...`; follow that hop, the next answer is the code redirect): `GET $ISS/connect/authorize?response_type=code&client_id=...&redirect_uri=...&scope=openid%20profile&state=s&nonce=n&code_challenge=...&code_challenge_method=S256` with A's cookie.
|
||||
Expect 302 to the redirect URI with `code` and `state`. Exchange it: `POST $ISS/connect/token` with `client_secret_basic`
|
||||
and `grant_type=authorization_code`, `code`, `redirect_uri`, `code_verifier`. Expect 200 with `access_token` and `id_token`.
|
||||
2. `GET $ISS/connect/userinfo` with `Authorization: Bearer <A's access_token>`. Expect 200 and exactly `sub`, `preferred_username`, `name`.
|
||||
3. Same authorize request with B's cookie. Expect 302 whose `Location` starts `/auth/bifrost/consent?returnUrl=%2Fconnect%2Fauthorize%3Frequest_uri`,
|
||||
and no `code` anywhere. (OpenIddict first parks the request and redirects to the same URL with a `request_uri`; follow
|
||||
that one hop, the consent redirect is the next answer. A missing receipt ignores `prompt=none`: still a 302 to the consent page, never `consent_required`.) `GET` that Location with B's cookie: expect 200 and a page that contains
|
||||
"to speak in a recorded room" and a form posting to `/auth/bifrost/consent/accept`.
|
||||
4. Withdraw A: on A's consent page click Withdraw (a form `POST /auth/bifrost/consent/withdraw`, 303 back). Repeat step 2 with
|
||||
A's old access token: expect 403, `WWW-Authenticate` with `error="insufficient_access"`, and no `sub` or `preferred_username`
|
||||
in the body. Repeat step 1's authorize for A: expect the consent redirect of step 3, not a code.
|
||||
5. A clicks Agree. Repeat step 1's authorize and token, then step 2: expect 302 with `code`, 200 tokens, 200 userinfo.
|
||||
6. A code obtained before step 4 and exchanged after it: expect 400 `invalid_grant` and no `access_token`.
|
||||
7. Control: with the receipt gone, B can still sign in to Bifrost, open `/App/Members` and vote; only chat is refused.
|
||||
|
||||
## 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 who may no longer chat (the same
|
||||
reading the token and userinfo endpoints use: membership `Suspended`, a deleted account, a subject that is not an account id, or
|
||||
no live receipt for the notice version in force; an account with no membership or a pending one that has agreed may chat and is left alone) 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 and receipts are never written by this path: any way of being suspended, deleted or withdrawn (admin
|
||||
action, sync, a database edit, the consent page) is covered because the work is derived from stored 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.
|
||||
|
||||
Without `BackchannelLogoutUri` none of this runs: suspension stops new chat tokens (the authorize, token and userinfo
|
||||
endpoints refuse a suspended account; there is no refresh token, and MAS does not call userinfo) but does not end a chat session that is already live, which runs to its natural expiry, and the issuer logs one warning at
|
||||
startup saying so. The other lever is MAS admin deactivation of the user (ops, not built here).
|
||||
|
||||
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.
|
||||
|
||||
## Limits, prune, brake
|
||||
|
||||
`OidcGuardMiddleware` sits ahead of OpenIddict and guards the three endpoints, matched by path segment so a trailing slash
|
||||
is no way around it. In order: the marker brake (503; a marker that cannot be read is treated as engaged); one per-address
|
||||
budget over the three endpoints together (429 with `Retry-After`; the address is the connection's after forwarded-headers
|
||||
handling that trusts only the local proxy, IPv6 keyed by /64); and a cap on token rows: a new authorize request (no
|
||||
`request_uri`) is refused with 503 while the table holds `MaxTokenRows`. Any failure inside the guard is a 503, never a pass.
|
||||
MAS's flow is a handful of requests per sign-in from one address, far under the defaults (30 a minute is about 15 sign-ins).
|
||||
|
||||
There is no per-client budget. The one client's id is public and what a caller names is unverified, so a budget keyed on it
|
||||
would let anyone spend the real client's permits and lock sign-in out. The bound on the anonymous endpoint is the address
|
||||
budget plus the row cap, and the cap is itself a switch an attacker with many addresses can flip: it refuses new sign-ins
|
||||
(never resumed ones) until the next prune frees rows, which is the bounded failure chosen over unbounded parked rows.
|
||||
Not parking state before the user authenticates (as pushed authorization requests avoid by authenticating the client first)
|
||||
would remove the switch, and OpenIddict's request caching does not offer that.
|
||||
|
||||
`OidcPruner` runs every `PruneInterval` and deletes tokens past expiry (parked authorize requests, codes, access tokens) or no
|
||||
longer valid, then authorizations no longer valid or ad hoc with no token left (younger than five minutes are left alone).
|
||||
Live rows are untouched. Plain EF queries rather than OpenIddict's own prune, which uses ExecuteDelete and cannot run on the
|
||||
in-memory provider the tests use.
|
||||
|
||||
`docs/oidc-reserved-accounts.sql` is the read-only query for existing accounts that hold one of the 28 reserved names.
|
||||
|
||||
## Not built
|
||||
|
||||
Checked against the Window B list (2026-11-11). Present and tested: discovery, JWKS, authorize/token/userinfo, PKCE S256,
|
||||
the one client, claims to a Matrix-legal localpart (`[a-z0-9._-]`, 3-80, lower case), reserved names at creation and now at
|
||||
issuance, back-channel logout. Not present:
|
||||
|
||||
- **MAS upstream block.** `gamecult-ops` `chat/matrix-auth/config.yaml` has no `upstream_oauth2` (provider ULID,
|
||||
`on_conflict: fail`, `on_backchannel_logout: logout_all`, the redirect URI above); that lives in the ops repo.
|
||||
|
||||
## 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.
|
||||
12
docs/oidc-reserved-accounts.sql
Normal file
12
docs/oidc-reserved-accounts.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
-- Read-only. Which existing UserAccounts hold one of the 28 reserved localparts
|
||||
-- (BifrostIdentityService.ReservedIdentities). Written for review; not run on live.
|
||||
-- Run it in a read-only transaction by whoever holds the database:
|
||||
-- psql -v ON_ERROR_STOP=1 -c 'BEGIN READ ONLY' -f docs/oidc-reserved-accounts.sql
|
||||
-- Keep the list in step with ReservedIdentities; the issuer refuses these at authorize (#49).
|
||||
SELECT a."Id", a."BifrostIdentity", a."NormalizedBifrostIdentity", a."CreatedAtUtc", a."LastSeenAtUtc"
|
||||
FROM "UserAccounts" a
|
||||
WHERE lower(a."NormalizedBifrostIdentity") IN (
|
||||
'admin', 'aqua', 'deru', 'eureka', 'heimdall', 'ivixey', 'kiko', 'libby', 'meta', 'nibu', 'unnie', 'void', 'weksa',
|
||||
'root', 'system', 'matrix', 'synapse', 'support', 'bot', 'mod', 'moderator', 'postmaster', 'gamecult',
|
||||
'bifrost', 'idunn', 'odin', 'huginn', 'ratatoskr')
|
||||
ORDER BY a."NormalizedBifrostIdentity";
|
||||
190
docs/ratatoskr-voting-auth.md
Normal file
190
docs/ratatoskr-voting-auth.md
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
# How a Ratatoskr voting module authenticates to Bifrost (design, no code)
|
||||
|
||||
Status: proposal, 2026-11-10, written by Heimdall (Self) against `origin/main` 276f611 (sections 1-13, citations as first read); section 14 and the corrections marked "e8e8fcc" in sections 1-13 are re-read against `origin/main` e8e8fcc. Decided 2026-11-11 by the steward of Bifrost for the read-only Desk feed (section 14); the cast scopes (`votes.cast`, `desk.answer`) stay gated by D1, D2 and D4. Docs only; nothing here is built or deployed.
|
||||
Labels: **FACT** = read at the cited `file:line` at the base named for that section or citation, 276f611 unless it says e8e8fcc (paths under `src/Bifrost.Web/` unless written otherwise). **INFERENCE** = my reading, not stated by code. **EXTERNAL** = OpenIddict/ASP.NET behaviour I know from its documentation and did not run here; each one needs a probe before the cut (section 11).
|
||||
Companion reads: `/opt/commons/handoff/bifrost-browser-voting-facts.md` (Eyes, at 416f11a; its Program.cs/OIDC lines still hold at 276f611, only `Features/Motions/MotionGovernanceService.cs` changed in between, in the close path), `heimdall-ratatoskr-sso-plan*.md`.
|
||||
|
||||
## 1. The problem in four facts
|
||||
|
||||
- FACT: a member's only Bifrost credential is the cookie `__Host-bifrost_session` (`Program.cs:189-200`; `Features/Shared/BifrostCookies.cs:10`). `__Host-` makes it host-only: chat.gamecult.org never holds it.
|
||||
- FACT: the member API carries no CORS: the single CORS policy is the anonymous tally GET (`Features/ConceptVotes/ConceptTallyExport.cs:8-27`), so a JSON POST from the chat origin is blocked at preflight.
|
||||
- FACT: Bifrost is an OIDC issuer with exactly one client, MAS, confidential, secret basic auth only, scopes `openid profile` (`Features/Oidc/OidcServiceCollectionExtensions.cs:39-46`; `Features/Oidc/OidcClientSeeder.cs:35-51` is the one MAS descriptor; startup deletes every other client, loop `:63-75`, all at e8e8fcc). No OpenIddict validation handler is registered (grep for `AddValidation` under `src` returns nothing), so no access token authenticates any `/api/*` route.
|
||||
- FACT: there is no JSON read or cast API for Standard motions (Razor form POST with 302 and no receipt: `Features/Motions/MotionGovernanceService.cs:47-86`, `Pages/App/Motions/Index.cshtml.cs`); sealed-ballot motions have JSON endpoints but no list (`Program.cs:718-763`).
|
||||
- INFERENCE: the access token chat holds today is Matrix's, minted by MAS after MAS authenticates the person at Bifrost (SSO plan section D). Bifrost's own access token goes to MAS, never to the browser. PR #24's "the OIDC access token Bifrost already issues to chat" is therefore not true as written: this design makes Bifrost issue a token to chat directly.
|
||||
- FACT (deployment): Bifrost has no `Bifrost__Oidc__*` variable live, MAS is disabled, Synapse is its own password authority (`heimdall-ratatoskr-sso-plan-live-read-20261110.md` section 1). Everything below is for after OIDC is enabled; `Bifrost:Oidc` absent means the provider surface is not mapped (`docs/oidc-issuer.md:37`).
|
||||
|
||||
## 2. Proposal in one paragraph
|
||||
|
||||
Ratatoskr's voting module is a second OIDC client of Bifrost: **public, PKCE (S256), no secret**, redirect URI exactly one URL on https://chat.gamecult.org. It runs authorization-code + PKCE in a top-level redirect to Bifrost (so the person's existing `__Host-` session cookie is used first-party; chat never sees it), receives an **access token for audience `bifrost-votes` and scopes `votes.read` / `votes.cast` (10 minutes for cast scopes; `desk.read`: see section 14)**, keeps it in memory only, and sends it as `Authorization: Bearer` to a **new `/api/votes` surface that accepts bearer tokens and refuses cookies**. CORS on exactly two route groups (`/connect/token`, `/api/votes`) allows exactly `https://chat.gamecult.org`, no credentials. Everything else in Bifrost keeps its current cookie-only behaviour. The Persona/Mind signed path is untouched.
|
||||
|
||||
## 3. Client registration
|
||||
|
||||
- A second client entry beside MAS, from configuration (`Bifrost:Oidc:Clients:Ratatoskr:{ClientId,RedirectUri}`), seeded by the same startup seeder. FACT the seeder today builds one descriptor and deletes all others (`OidcClientSeeder.cs:33-75` at e8e8fcc); it needs to build two and delete the rest.
|
||||
- `ClientType = Public`, no `ClientSecret`; permissions: authorization endpoint, token endpoint, authorization_code grant, response type `code`, scopes `profile`, `openid`, `votes.read`, `votes.cast`. PKCE is already required server-wide (`OidcServiceCollectionExtensions.cs:39`).
|
||||
- `RedirectUri` is one absolute URL, compared exactly (OpenIddict does exact match; EXTERNAL). No wildcard, no localhost entry in production configuration; a dev client is a separate config file never deployed.
|
||||
- FACT the token endpoint accepts only `client_secret_basic` (`OidcServiceCollectionExtensions.cs:45-46` clears and sets the list). EXTERNAL/needs probe: a public client authenticates by `client_id` alone and OpenIddict still accepts it with that list; if it does not, add `none` to the list for public clients only.
|
||||
- Not in scope: dynamic registration, implicit/hybrid flows, a client secret in the browser. The MAS client and its flow do not change.
|
||||
|
||||
## 4. Scopes, audience, what a token carries
|
||||
|
||||
| Scope | Grants | Never grants |
|
||||
|---|---|---|
|
||||
| `openid`, `profile` | `sub` = `UserAccount.Id`, `preferred_username`, `name` (FACT `OidcEndpoints.cs:79-84`, `docs/oidc-issuer.md:55-61`) | email, Heimdall id, membership detail |
|
||||
| `votes.read` | read motions the member may see, their own ballot status, results after close | other members' sealed ballots; weights of other members |
|
||||
| `votes.cast` | cast/change a Standard vote; sealed commit and reveal for the token's own `sub` | anything in the next list |
|
||||
|
||||
Audience: access tokens for these scopes carry audience `bifrost-votes` (INFERENCE: OpenIddict `SetResources`/audience per request; EXTERNAL detail). `/api/votes` requires the audience **and** a vote scope. This stops the MAS client's access token (same issuer, same signing key, scopes `openid profile`) from being accepted on `/api/votes`, and a Ratatoskr token from being accepted by anything else.
|
||||
|
||||
**A token may never:** grant or change a role (`RoleAssignments`); set, read-modify or choose a weight (weight is `Membership.EffectiveVotingWeight` computed server-side at cast, `Domain/BifrostModels.cs:360-364`; the API request has no weight field and the receipt does not echo it); open, close or edit a motion; register or revoke Mind keys (`Program.cs:754-763`, moderator, cookie only); reach `/App/*`, `/eve/*`, `/connect/userinfo` beyond the three profile claims; mint another token (no refresh token, `docs/oidc-issuer.md:26`; no token-exchange); act for a different account (the voter is always `sub`, never a body field). These are enforced by structure, not by a deny list: the vote endpoints take the account from the principal and expose no parameter for the rest.
|
||||
|
||||
## 5. The bearer surface
|
||||
|
||||
- Register OpenIddict validation (local server) as a **named, non-default** authentication scheme `BifrostBearer` (the desk's credential is built on OpenIddict's fixed scheme name `OpenIddict.Validation.AspNetCore`; keep that name, and read `BifrostBearer` as the role). FACT the default scheme is Cookie (`Program.cs:189-200`), so every existing endpoint stays cookie-only and a bearer token cannot reach `/App`, `/api/sealed-motions` or `/eve` (a test pins it).
|
||||
- New group `app.MapGroup("/api/votes")` with a policy `ActiveMember + scope + audience` whose `AuthenticationSchemes` is `BifrostBearer` only. A request that also carries the Bifrost session cookie is judged on the bearer alone, so the surface has no ambient credential: this is what removes CSRF from it (section 9).
|
||||
- **Actor resolution is a new job.** FACT `CurrentBifrostActorAccessor` builds the actor from cookie claims (`BifrostIdentity`, `HeimdallAccountId`, GitHub `NameIdentifier`) and returns Anonymous when none are present (`Features/Shared/CurrentBifrostActorAccessor.cs:33-46`). A bearer principal has only `sub`, so existing services would see an anonymous actor. FACT `OidcEndpoints.ActiveMemberIdAsync` already resolves by `sub` and re-reads membership (`OidcEndpoints.cs:170`, used by `Features/Desk/DeskEndpoints.cs:105`; e8e8fcc). Proposal: one accessor path "principal with `sub` and `BifrostBearer` → load `UserAccount` + `Membership` by id", reusing that reading, and the `/api/votes` handlers call the existing services (`MotionGovernanceService.CastVoteAsync`, `SealedBallotService.CommitAsync/RevealAsync`) with that actor. No second vote implementation.
|
||||
- Endpoints (names are a proposal, shapes belong to the cut spec): `GET /api/votes/motions`, `GET /api/votes/motions/{id}`, `PUT /api/votes/motions/{id}/ballot` `{choice, comment?}` (Standard), `POST …/commit` and `…/reveal` for sealed, `GET …/result`. FACT today's Standard cast returns silently when the motion is not open and throws on an unknown id (`MotionGovernanceService.cs:57-61`); the API wraps it with typed errors (404, 409 not open) rather than copying that.
|
||||
- Errors are JSON 401/403/404/409/429, never a 302 to a sign-in page (FACT the cookie challenge redirects, `Program.cs:197`; a `fetch` client must get a status it can act on, e.g. 401 with `WWW-Authenticate: Bearer error="invalid_token"`).
|
||||
- Persona/Mind path: FACT `POST /api/sealed-motions/{id}/mind` is anonymous, Ed25519-signed, rate-limited at 30/min/source and 4096 bytes (`Program.cs:767-809`). Unchanged. Minds never use this bearer flow; `/api/votes` has no Mind route, and a Persona's account is not a Ratatoskr login.
|
||||
|
||||
## 6. The flow (browser)
|
||||
|
||||
1. Ratatoskr voting module starts login: generates `state`, `nonce`, PKCE verifier/challenge (S256), stores them in `sessionStorage` for the one tab, navigates to `GET /connect/authorize` with `client_id`, exact `redirect_uri`, `scope=openid profile votes.read votes.cast`, `response_type=code`.
|
||||
2. FACT authorize without a session challenges to `/auth/sign-in` and resumes via a short `request_uri` (`OidcEndpoints.cs:44-51`); with a session it admits any account that is not Suspended and has an identity (chat tier; membership is neither required nor written), otherwise `access_denied` (`OidcEndpoints.cs:53-70`). So the sign-in leg does not prove Active: the `/api/votes` and `/api/desk` surfaces re-check Active on every request, which is what makes this safe. Heimdall sign-in and the Bifrost session happen on the Bifrost host exactly as for MAS.
|
||||
3. For a chat token without a live notice receipt (a desk or vote token needs none: the receipt gates the chat token only, Meta's ruling 2026-11-11), Bifrost first redirects to its training-notice consent page and resumes the parked request (`OidcEndpoints.cs:99-102`, e8e8fcc; not a grant to the client). Then, for cast scopes only, the per-client grant screen (section 7, D1); `desk.read` alone has none. Then 302 to `https://chat.gamecult.org/<redirect path>?code=…&state=…`.
|
||||
4. The module checks `state`, immediately removes `code` and `state` from the address bar (`history.replaceState`), POSTs `/connect/token` (form: `grant_type=authorization_code`, `code`, `code_verifier`, `client_id`, `redirect_uri`) cross-origin. FACT the token endpoint re-reads the account and refuses with `invalid_grant` when `ChatRefusalAsync` says so: account missing, Suspended, or no live notice receipt (`OidcEndpoints.cs:107-120`, `ChatRefusalAsync` `:151`). It does not test Active; the per-request Active check is the new surfaces' own (section 14 D3).
|
||||
5. The access token lives in a JS variable. Not `localStorage`, not `sessionStorage`, not IndexedDB. A page reload logs the module out of Bifrost votes (not out of chat) and the next vote action restarts step 1; with an existing Bifrost session it is two redirects and no typing.
|
||||
|
||||
INFERENCE on re-auth without refresh tokens: FACT none exist (`docs/oidc-issuer.md:26`). EXTERNAL: `prompt=none` in a hidden iframe is dead (third-party cookie rules), so renewal is a top-level redirect. That is the cost of a no-refresh-token design; see D3.
|
||||
|
||||
## 7. Consent screen: needed, because one hop is cheap and a vote is not
|
||||
|
||||
- FACT (e8e8fcc) there is no per-client grant consent: authorize redirects to `ConsentEndpoints` when the member has no live receipt (`OidcEndpoints.cs:99-102`, `Features/Registration/ConsentPage.cs`), but that is the training-notice receipt, a once-per-notice-version agreement independent of any client, not a grant to Ratatoskr. "Consent" below means the per-client grant unless it says notice. MAS needs no grant screen (it is the chat's own login service).
|
||||
- Reasoning (INFERENCE): `votes.cast` lets whoever holds the token change the member's recorded vote. The member is already redirected to Bifrost, so a consent step costs one click and makes the grant visible and revocable in Bifrost, which is the only place it can be. Recommend **explicit consent for `votes.cast`, implicit for `votes.read`** (OpenIddict `ConsentType` per client or per authorization; EXTERNAL).
|
||||
- Page content: client name ("Ratatoskr, chat.gamecult.org"), the account being used, the two powers in plain words ("see motions and your own ballots", "cast and change your votes"), Allow / Deny, a note that the grant lasts until revoked and tokens last 10 minutes (cast scopes). Served with `frame-ancestors 'none'` and `X-Frame-Options: DENY` (clickjacking). The consent wording for the chat side is `ratatoskr-consent-wording-draft-20261109.md` and is separate from this one.
|
||||
- A stored authorization (OpenIddict permanent authorization) lets the next login skip the consent page; it is the object revoked in section 8.
|
||||
|
||||
## 8. Token lifetime, revocation, receipts, rate limits
|
||||
|
||||
**Lifetime.** FACT the code sets no access-token lifetime (only `RequestTokenLifetime` 10 min, `OidcServiceCollectionExtensions.cs:47`); INFERENCE/EXTERNAL OpenIddict's defaults apply (about 1 hour for access tokens, 5 min for codes). Set explicitly: **access token 10 minutes for cast scopes (`desk.read`: 60 minutes, section 14 D3)**, authorization code 60 seconds, id_token short. No refresh token. Because every `/api/votes` call re-reads the account and membership (section 5), the lifetime bounds token theft, not demotion.
|
||||
|
||||
**Revocation.**
|
||||
- Demotion is immediate: each request re-checks Active (`ActiveMemberIdAsync`, `OidcEndpoints.cs:170`, e8e8fcc). A token held by a suspended member returns 403.
|
||||
- Authorization cleanup: FACT `OidcLogoutReconciler` finds subjects who fail the chat-tier test (Suspended, account gone, or no live notice receipt) and hold authorizations of the MAS client and tells MAS (`docs/oidc-issuer.md:146-164`); it is MAS-specific (it needs `Clients:Mas:BackchannelLogoutUri`, `OidcLogoutReconciler.cs:180` at e8e8fcc) and nothing in it serves another client today. To be built: a derived-from-membership sweep that revokes the Ratatoskr/desk client's authorizations for non-Active subjects (no outbound POST needed; the client is ours). Membership is never written by it.
|
||||
- Member-initiated revoke: needs a place to list and revoke grants. Not built; D4.
|
||||
- Operator kill: with the seeder extended to more than one client, removing a client from configuration deletes it and its tokens at next start (the delete-others loop, `OidcClientSeeder.cs:63-75` at e8e8fcc, today spares only the MAS client); a runtime brake is D5.
|
||||
|
||||
**Receipts.** Every cast returns a JSON receipt, since Standard votes return none today (facts file section 5): `{motionId, choice, revision, castAtUtc, receiptId}`; sealed commit/reveal keep their existing receipt (`SealedBallotReceipt`, `SealedBallotService.cs`). Receipt contains no weight. The audit trail already records casts (`MotionGovernanceService.cs:86`); the audit row gains the client id and `jti` so a vote can be traced to the grant that cast it (INFERENCE: this is what "receipts" is for after an XSS: the member can see which client cast it).
|
||||
|
||||
**Rate limits.** FACT none on the member API (facts file section 5); the primitives exist: `FixedWindowBudget` keyed by account (`Features/ConceptVotes/ConceptVoteRateLimits.cs:14-24`) and a 429 + Retry-After pattern (`Program.cs:~773-774`, e8e8fcc). Proposal: per-account budgets — casts 30 per 10 min (the Mill's number), reads 120 per minute; token endpoint per source address; 429 with `Retry-After`. Numbers are D6.
|
||||
|
||||
## 9. Threat list
|
||||
|
||||
| Threat | Why it matters here | Answer |
|
||||
|---|---|---|
|
||||
| **Token theft by XSS in chat** | Ratatoskr renders other people's Matrix messages; a script in the chat origin can read an in-memory token or call the module's own functions | Token memory-only, 10 min (cast scopes), scopes limited to votes, audience-bound, no refresh. Honest limit: an XSS can **cast during the page's life** whatever the token allows; shortening and scoping bound it, they do not stop it. Mitigations to require: strict CSP on chat.gamecult.org, message sanitisation, and an in-module confirmation step for `votes.cast` that is rendered by the module itself (Ratatoskr's cut). D2 asks whether casting should demand a fresh re-authentication (`prompt=login`, `max_age`) for high-stakes motions. Sealed motions are better off: the salt is generated and held client-side, but a compromised page holds it too. |
|
||||
| **CSRF** | A third-party page makes the member's browser vote | `/api/votes` accepts no cookie (section 5), so a forged cross-site request carries no credential. CORS has no `AllowCredentials`, so a credentialed cross-origin read is unreadable. The cookie surfaces keep their current protection (FACT: Razor antiforgery, `Index.cshtml:127,139`; the sealed API relies on SameSite and JSON binding, `Program.cs:718-763`) and are untouched. |
|
||||
| **Authorization-code interception / injection** | Code arrives in a URL on the chat origin | PKCE S256 required (FACT `:39`), exact redirect match, codes single-use and 60 s, `state` checked, URL cleaned at once, `Referrer-Policy: no-referrer` on the callback page. |
|
||||
| **Login CSRF / mix-up** | Attacker finishes a flow with their own code | `state` bound to the tab, `nonce` checked in the id_token, one issuer only. |
|
||||
| **Replay** | A captured request resent | Token expiry; codes single-use; Standard cast is `PUT` and idempotent by `(motion, sub)` (FACT one row per member per motion, `MotionGovernanceService.cs:64-85`); sealed commit/reveal already carry `revision` and phase checks; the commitment binds the voter id (`docs/sealed-ballot-motion.md:22-26`). Replaying the same cast cannot double a vote. |
|
||||
| **Token substitution** | MAS-bound token used at `/api/votes`, or a Ratatoskr token elsewhere | Audience + scope + named scheme (sections 4-5). |
|
||||
| **Open redirect / redirect abuse** | | One exact redirect URI; Bifrost local return paths unchanged (FACT `Features/Shared/LocalReturnPath.cs:13-31`). |
|
||||
| **Overreach by a demoted or compromised account** | Membership changes after issue | Per-request Active check; sweep revokes authorizations. |
|
||||
| **CORS misread as security** | CORS stops browsers only | Not relied on for authorisation; it is an availability measure. Tokens authenticate; non-browser callers need a token. |
|
||||
| **Consent phishing** | Look-alike client asks for `votes.cast` | One registered client; consent page names it and shows the redirect host. |
|
||||
| **Scraping / vote spam** | | Rate limits (section 8); no list of other members' ballots exists. |
|
||||
| **Clickjacking of consent/sign-in** | | `frame-ancestors 'none'` on Bifrost auth pages. |
|
||||
| **Logout gap** | The Bifrost session ends but a token lives on: 10 min for cast scopes; up to 60 min for `desk.read` (section 14 D3) | Accepted; bounded by lifetime and the per-request Active check. For `desk.read` the Bifrost session can end while the token keeps feeding `/api/desk` for up to 60 minutes; accepted because the feed is read-only and every request re-checks Active. |
|
||||
|
||||
## 10. CORS, exactly
|
||||
|
||||
- A **second named policy**, separate from `ConceptTallies` (FACT that policy reads `ConceptVotes:AllowedOrigins`, `ConceptTallyExport.cs:13-27`, so reusing it would couple two features' origin lists): `WithOrigins("https://chat.gamecult.org")`, methods `GET, POST, PUT, OPTIONS`, headers `Authorization, Content-Type`, **no `AllowCredentials`**, no exposed headers except `Retry-After`, preflight cache bounded. Origin comes from configuration as one exact value; no wildcard, no subdomain pattern, no `null`.
|
||||
- Applied by route metadata to `/connect/token` and `/api/votes/*` only. `/connect/authorize` is a top-level navigation and needs none; `/.well-known/*` is not fetched by the module (the client config is static). Every response under those routes carries `Vary: Origin` (the pattern the tally export already follows).
|
||||
- Bifrost still sends no CORS headers for `/api/sealed-motions/*`, `/App`, `/eve`, `/connect/userinfo` (the facts file's blocker 1 stays closed there on purpose).
|
||||
|
||||
## 11. Probes owed before the cut spec (each is an EXTERNAL claim above)
|
||||
|
||||
1. A public client with no secret completes code exchange when `ClientAuthenticationMethods` holds only `client_secret_basic`.
|
||||
2. Per-client explicit grant consent (D1) works with the request-caching path (`request_uri`) used at `OidcEndpoints.cs:44-51`, alongside the existing notice-receipt redirect (`:99-102`, e8e8fcc), which parks and resumes the same request.
|
||||
3. Audience and scope reach the access token and the validation handler enforces them; the MAS client's token is refused at `/api/votes`.
|
||||
4. Default token lifetimes before the explicit settings.
|
||||
5. OpenIddict validation as a non-default scheme leaves cookie-default endpoints unchanged (test: bearer on `/App` and `/api/sealed-motions` is 302/401, as now).
|
||||
6. CORS preflight on `/connect/token` through OpenIddict's passthrough (the endpoint is mapped as a minimal API at `OidcEndpoints.cs:35`).
|
||||
7. (owed by D3) Per-audience lifetimes: `principal.SetAccessTokenLifetime` / `SetAuthorizationCodeLifetime` set at authorize give a `desk.read`-only request a 60-minute access token and a cast-scope request 10 minutes, and the token's `exp` shows it.
|
||||
|
||||
## 12. Questions as raised (decided in section 14)
|
||||
|
||||
Decided 2026-11-11 by the steward of Bifrost: D1-D7 below are answered in section 14; the questions are kept as asked and are not open.
|
||||
|
||||
For **Meta**:
|
||||
- D1 Is explicit consent for `votes.cast` wanted (recommended) or is a first-party client implicit?
|
||||
- D2 Should a vote on a high-stakes motion demand re-authentication (`prompt=login`) at cast time? Recommend yes for sealed commit and for any motion whose result moves money or roles; no for ordinary reads.
|
||||
- D3 Accept that a 10-minute cast token with no refresh means a redirect round-trip to keep voting, or allow a refresh token (it widens theft impact; I recommend against).
|
||||
|
||||
For **Eureka** (platform/ops, MAS and deploy order):
|
||||
- D4 Where does a member list and revoke grants (a Bifrost `/App` page)? Needed before `votes.cast` ships if consent is explicit.
|
||||
- D5 Is a runtime brake wanted on the Ratatoskr client (disable without redeploy), and who holds it?
|
||||
- D6 Rate-limit numbers, and whether they belong in configuration (recommended) or code.
|
||||
- D7 Order against the MAS rollout: this design needs Bifrost OIDC live (it is not, section 1), and the two clients can ship separately; do voting tokens wait for MAS, or does voting go first as the first OIDC consumer? Recommend independent; the collision-and-import hazards in the SSO live read concern MAS only.
|
||||
|
||||
For **Ratatoskr (Void)**: the module owns the CSP, the in-module confirmation step, the callback page behaviour (section 6) and memory-only storage; this doc cannot enforce them from Bifrost's side.
|
||||
|
||||
## 13. What this deliberately does not do
|
||||
|
||||
No new identity: `sub` stays `UserAccount.Id`. No change to Heimdall, MAS, or the Mind key path. No weights or roles in tokens. No second vote implementation: the bearer surface is a thin projection of the existing services. No cookie sharing between chat and Bifrost, and no proxy on the chat origin pretending to hold one.
|
||||
|
||||
## 14. Decision record (steward, 2026-11-11)
|
||||
|
||||
Decided by the steward of Bifrost (the Persona), recorded by Heimdall (Self). Scope: the first consumer is the read-only Desk feed (scope `desk.read`, audience `bifrost-desk`, `docs/desk-feed-design.md`, draft PR #67), not votes. Each decision is the least-power, most-inspectable option that keeps exit (the member or operator can end it) and receipts (the act can be traced afterwards). Numbering: D1-D7 here are this document's (D1 consent, D2 re-auth, D3 token lifetime, D4 grant page, D5 brake, D6 rate limits, D7 ordering vs MAS); the "D1: `options_hash` covers everything shown" of the fork-desk answer-binding handoff is a different ruling and is not decided here.
|
||||
|
||||
**D1 consent (per-client grant).**
|
||||
- Decision: explicit per-client grant consent stays REQUIRED for any scope that can cast or answer (`votes.cast`, `desk.answer`), as recommended in section 7. `desk.read` (read-only, the member's own account) needs no per-client grant screen: implicit. Two senses of "consent" exist and are kept apart: the training-notice receipt (every authorize already redirects to `ConsentEndpoints` without a live receipt, `OidcEndpoints.cs:99-102`, `Features/Registration/ConsentPage.cs`, e8e8fcc), which applies to all clients and is unchanged; and the per-client grant, which does not exist yet.
|
||||
- Reasons: FACT (e8e8fcc) there is no per-client grant consent today: the only consent step is the notice receipt; a `desk.read` grant changes nothing, so a click would buy no protection; a cast changes a recorded vote or answer, so the member must see the grant.
|
||||
- Keeps: exit is the revocable stored authorization (D4); receipts are the audit row carrying client id and `jti` (section 8).
|
||||
- Probe owed: 2 (section 11), the grant screen beside the notice redirect on the parked-request path.
|
||||
- Holder: the steward of Bifrost.
|
||||
|
||||
**D2 re-authentication.**
|
||||
- Decision: yes (`prompt=login` at cast time) for sealed commit, and for answering or casting on any motion or fork whose result moves money or roles. Not needed for `desk.read`.
|
||||
- Reasons: a stolen 10-minute token must not be enough to move money or roles; a read-only feed has nothing to protect that a fresh login would add.
|
||||
- Keeps: exit, because the member re-proves the account each time; receipts, because the cast receipt follows a fresh authentication.
|
||||
- Holder: the steward of Bifrost.
|
||||
|
||||
**D3 token lifetime and refresh.**
|
||||
- Decision: NO refresh token, anywhere. Access token 10 minutes for the cast and answer scopes; 60 minutes for the read-only audience `bifrost-desk`. Authorization code 60 seconds. One client may request both audiences; the lifetime follows the requested scope set: a request containing any cast or answer scope gets 10 minutes, a `desk.read`-only request gets 60.
|
||||
- Reasons: FACT the code sets no access-token lifetime (only `RequestTokenLifetime` 10 minutes, `OidcServiceCollectionExtensions.cs:47`, e8e8fcc), so the explicit values replace OpenIddict defaults. Every `/api/desk` request re-checks Active membership (`ActiveMemberIdAsync`, `OidcEndpoints.cs:170`, used by `Features/Desk/DeskEndpoints.cs:105`), so lifetime bounds only theft of a read-only credential, not demotion; a 10-minute token would force a redirect round trip into a polling feed. Not to be confused with `ChatRefusalAsync` (`OidcEndpoints.cs:151`), which the token and userinfo endpoints use for chat subjects and which refuses a Suspended account or one without a live notice receipt, not a non-Active one. A refresh token would outlive the redirect and widen theft impact.
|
||||
- Per-audience lifetime is not built or proven: it would be set with `principal.SetAccessTokenLifetime` / `SetAuthorizationCodeLifetime` at authorize. Probe 7 (section 11) is owed before the cut.
|
||||
- Keeps: exit, because demotion is immediate and the sweep (to be built, section 8) revokes grants of non-Active subjects; no long-lived secret exists to leak. Accepted gap: the Bifrost session can end while a `desk.read` token feeds `/api/desk` for up to 60 minutes; acceptable for a read-only feed because every request re-checks Active.
|
||||
- Holder: the steward of Bifrost.
|
||||
|
||||
**D4 grant list and revoke page.**
|
||||
- Decision: a member-facing page in `/App` that lists and revokes grants is a BLOCKING condition for shipping any cast or answer scope. It is not needed for `desk.read`, which is ended by the derived-from-membership sweep and by the operator brake (D5). Nothing further is decided here.
|
||||
- Reasons: explicit consent (D1) without a place to withdraw it is half a control; a read-only grant ends with the sweep and the brake. Neither exists for a second client today: the sweep is to be built (`OidcLogoutReconciler` is MAS-specific, section 8), and the brake covers only the three `/connect` endpoints (D5).
|
||||
- Keeps: exit (the page itself) before any scope that can act.
|
||||
- Holder: the steward of Bifrost (the page is Bifrost code).
|
||||
|
||||
**D5 runtime brake.**
|
||||
- Decision: yes. Reuse the existing marker-file brake of PR #50 (commit 5201508; `OidcGuardMiddleware`, `OidcGuard.cs:32`, `BrakeEngaged` at `OidcGuard.cs:71`, option `Bifrost:Oidc:BrakePath`, `Configuration/OidcIssuerOptions.cs:26`, `docs/oidc-issuer.md` "Limits, prune, brake") rather than a new mechanism. It is held by the Bifrost steward (the Persona).
|
||||
- The brake is NOT per-client. FACT (e8e8fcc) the guard matches `/connect/authorize`, `/token`, `/userinfo` for all clients (`EndpointOf`, `OidcGuard.cs:64-68`), so engaging it also 503s MAS sign-in; it is registered only when `Bifrost:Oidc` is configured (`Program.cs:294-297`) and `BrakePath` lives under `Bifrost:Oidc`. The code PR must extend the guard's matching to `/api/desk` and must settle a desk-only deployment (a host running the desk without `Bifrost:Oidc` configured has no guard and no brake path; either the desk requires `Bifrost:Oidc`, which it does for its tokens, or the brake option moves). Not decided here: that settlement is the code PR's to bring back.
|
||||
- Reasons: FACT the guard is checked per request (no restart) and treats an unreadable marker as engaged; no second brake, no second owner.
|
||||
- Keeps: exit for the operator (delete the file to release; discovery and keys stay up, as the issuer test asserts); receipts in the 503 `temporarily_unavailable` answers and the guard's log line.
|
||||
- Holder: the Bifrost steward holds the brake. FLAG, not decided: where the marker file lives on the host is Eureka's (ops) to name.
|
||||
|
||||
**D6 rate limits.**
|
||||
- Decision: the numbers live in configuration, defaults: `desk.read` 120 requests per minute per account; casts 30 per 10 minutes per account (the Mill's number) when they exist. The token endpoint keeps the existing per-address budget (`OidcRateLimit`, `OidcGuard.cs:102`): that is ONE budget shared by authorize, token and userinfo together (`docs/oidc-issuer.md:187-188`), not a token-endpoint budget of its own.
|
||||
- Reasons: FACT the member API has none today; the primitive `FixedWindowBudget` keyed by account exists (`ConceptVoteRateLimits.cs:14-24`) and the 429 plus `Retry-After` pattern too (`Program.cs:~773-774`, e8e8fcc; `RefuseAsync` in the guard). Config keeps the numbers inspectable and changeable without a build. Per-client budgets stay out, as in `docs/oidc-issuer.md` (the client id is public).
|
||||
- Keeps: exit via the brake (D5); receipts via the 429 answers. The desk cut map's open point (a 429 has no refusal code in the contract, Q3 there) is not decided here.
|
||||
- Holder: the steward of Bifrost.
|
||||
|
||||
**D7 ordering against MAS.**
|
||||
- Decision: independent. A second client is seeded only when `Bifrost:Oidc:Clients:Ratatoskr` is configured. This is a code change, not existing behaviour: FACT (e8e8fcc) the seeder builds one MAS descriptor and its delete-others loop (`OidcClientSeeder.cs:63-75`) removes everything else. Constraint on the code PR: under the MAS-less or default configuration it adds nothing observable (no new client, route, scope or discovery change), so window B (cut stays ab89556) is not widened. `desk.read` is the first consumer. It needs Bifrost OIDC live, as MAS does.
|
||||
- Reasons: nothing in this client touches the MAS client, the Heimdall path or the Mind key path; "seeded only when configured, removed when no longer configured" is the behaviour to build, and the loop must be changed to spare configured clients rather than only MAS.
|
||||
- Keeps: exit, because removing the configuration section deletes the client and its tokens at next start (once built); no receipts needed beyond the audit rows above.
|
||||
- Holder: the steward of Bifrost.
|
||||
|
||||
**Decided with the feed.** The Desk feed never carries answerer account ids (the door withholds them from a non-decider, `eureka-substrate:ruling:desk-read-visibility-withheld-ids`; Bifrost masks nothing it cannot read, and this supersedes the Bifrost mask that closed X3 of `docs/desk-feed-design.md`).
|
||||
|
||||
**Flags to other stewards.**
|
||||
- Eureka: the Huginn door list and read wire and its audience policy are written in Huginn's `docs/desk-feed-door.md` (Huginn main 79d954c or later, with golden fixtures); the host path of the D5 marker is still open. The listing gate that implements the audience policy is NOT live on Huginn main (cut `cut-desk-feed-listing`): until it lands, the door returns every showable question to every reader, so no Mind that holds private questions goes behind a live receiver.
|
||||
- Libby (CultLib steward), ruled 2026-11-11: ForkCard gains `raised_at`, which stays OUT of `options_hash` (that hash keeps its five members: title, context, options, recommendation, depends, so digests are unchanged) and is a REQUIRED card key, an RFC3339 string or nil; the card goes from 16 to 17 keys and its hex changes. This closes X1 and X2 of `docs/desk-feed-design.md` (X2 withdrawn).
|
||||
|
||||
**Who sees which card: Huginn's door (Eureka), not Bifrost.** The policy is written in Huginn's `docs/desk-feed-door.md` ("Listing"), and this note holds no copy of it: a fork is shown to a reader who is not on the call's `deciders` only while a listing record for it is in force; a decider sees every showable fork; a hidden fork reads as `fork_unknown`, with the same bytes as a fork that does not exist. Bifrost applies no audience rule of its own: it relays exactly what the door returns. Reads carry `subject` and `deciders` (no `authority`; that is only on an answer), so anyone who must see unlisted forks is put on the Mind's decider list in Bifrost's admin. Bifrost adds three acts on the desk path: the per-request Active membership check, the `desk.read` bearer (audience `bifrost-desk`), and nilling notes for a non-decider; they sit beside the two already built (the note strip for non-deciders and the configured-Mind 404). Bifrost does NOT filter by audience, hold any per-card visibility rule, or keep a second copy of the policy (two copies would drift). The card is opaque to Bifrost (its `raised_at` key and order are being re-pinned in CultLib), so it is relayed as the door sent it.
|
||||
142
docs/registration-gates.md
Normal file
142
docs/registration-gates.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# Public registration gates
|
||||
|
||||
Meta ruled yes to public registration on 2026-11-10. The gates she required: self-hosted CAPTCHA, email verification,
|
||||
a training-notice consent receipt, rate limits and a brake, reserved names, and new accounts in Users with no vote.
|
||||
This page scopes the three that did not exist, says where each lives, and records what is built.
|
||||
|
||||
## What exists
|
||||
|
||||
| Gate | Where | State |
|
||||
| --- | --- | --- |
|
||||
| Rate limits and brake | `OidcGuardMiddleware` (`docs/oidc-issuer.md`) | Merged. Covers `/connect/*` only, not the register endpoint. |
|
||||
| Reserved names | `BifrostIdentityService.IsReserved`, 28 names | Merged, at creation and at authorize. |
|
||||
| Chat tier, no vote | `Authenticated` / `PendingApproval` accounts chat and open no member or governance surface | Merged. |
|
||||
| Suspension | `MembershipSuspensionService` | Merged. The reason is `Membership.SuspensionReason`, written with the status change and its receipt, cleared on unsuspend, projected only to admins on the members page; the receipt (`AuditEvent.Detail`) holds no reason text. Receipts written before the column existed still hold their reasons as history; the member dashboard keeps filtering suspension receipts out. A Suspended row with no stored reason (suspended before the column existed) shows admins the fixed text "Suspended before reasons were recorded"; the old text is never read out of the receipt. An unsuspend still requires a reason, but nothing stores it. |
|
||||
| CAPTCHA | `Features/Registration/RegistrationChallengeService` | Built here. |
|
||||
| Consent receipt | `ConsentReceipt`, `TrainingNotice` | Built here. |
|
||||
| Email verification | nowhere | Scoped below, not built. |
|
||||
|
||||
The hole these gates close: `POST /auth/bifrost/register` is anonymous, and since the chat tier merged, an
|
||||
`Authenticated` account it creates can sign in to chat. Before this cut it took an identity name and nothing else.
|
||||
|
||||
## CAPTCHA
|
||||
|
||||
Lives in Bifrost, because the register endpoint is Bifrost's. Heimdall authenticates people through providers and has no
|
||||
registration form.
|
||||
|
||||
Design: a hashcash proof of work. `GET /auth/bifrost/challenge` returns a Data Protection-protected token (random id and
|
||||
expiry, five minutes) and a difficulty in bits. The client finds a decimal `nonce` such that
|
||||
`SHA-256(challenge + ":" + nonce)` has at least that many leading zero bits, and posts both with the registration. The
|
||||
server verifies the protection, the expiry and the hash, and spends the id once.
|
||||
|
||||
- Self-hosted: no third-party script, request or cookie; nothing to track.
|
||||
- Accessible: there is nothing to see or hear and nothing to solve by hand. A screen-reader user, a keyboard user and a
|
||||
script all pass the same way, at the cost of CPU time on the client.
|
||||
- Cost: difficulty 18 is about 260,000 hashes on average, well under a second natively and a few seconds in browser
|
||||
JavaScript. It prices a bulk registration run in CPU; it does not stop a determined attacker with CPUs, and it is not a
|
||||
proof of humanity. The difficulty is embedded in the signed challenge, so a challenge verifies at the difficulty it was issued with.
|
||||
- Replay: the spent-id set is in memory and bounded (10,000, entries dropped at expiry); a restart forgets it, so a challenge
|
||||
can be replayed for at most its five-minute life. When the set is full the endpoint refuses rather than grows. One address
|
||||
cannot fill it alone: `GET /auth/bifrost/challenge` and `POST /auth/bifrost/register` share one per-address budget
|
||||
(`Bifrost:Registration:Limits`, 10 per minute by default, IPv6 by /64, over budget is 429 with Retry-After). An attacker
|
||||
with many addresses can still fill the set; that is the accepted bound, the same as oidc-issuer.md.
|
||||
|
||||
### The sign-up form and the no-JS fallback
|
||||
|
||||
`GET /auth/bifrost/register` renders the form: identity, optional display name, the notice in full beside an unchecked
|
||||
consent box (the box is the consent act), and a challenge. With JavaScript the page solves the proof of work in the
|
||||
background and reports progress in a polite live region. Without JavaScript the form still submits and succeeds, by a
|
||||
time-lock:
|
||||
|
||||
- The challenge carries its issue time. A submission that says it waited is accepted only once `FallbackWaitSeconds`
|
||||
(default 30) have passed since issue; earlier, the page comes back saying how many seconds remain and the challenge is
|
||||
not spent (a fresh challenge would restart the clock). The same holds for a refused identity (taken, reserved or
|
||||
malformed): those rules are checked before any permit or challenge is spent, and the still-valid challenge is kept. Every
|
||||
other failure re-renders with a fresh challenge, the consent box unchecked again (tick it again) and the identity kept.
|
||||
`FallbackWaitSeconds` is validated at 5 to under 300, since a challenge lives 300 seconds; `Captcha:Difficulty` at 1 to 24.
|
||||
- Fallback submissions have their own, stricter per-address budget (`Limits:FallbackPermits`, default 3 per
|
||||
`FallbackWindow`, default one hour; over it is 429 with Retry-After).
|
||||
- A submission names a nonce or says it waited, never both.
|
||||
- The page has a second submit button, always enabled, that abandons a solve in progress and submits on the time-lock, so
|
||||
a slow device is never stuck behind the proof of work.
|
||||
- `POST /auth/bifrost/register/form` refuses a request that did not come from the site itself (`RequestOriginGuard`: `Origin`
|
||||
equal to the site origin, else `Sec-Fetch-Site: same-origin`), 403, before it reads the body or spends anything, so a
|
||||
third-party page cannot register an account and replace a visitor's session. A request with neither header and no
|
||||
session cookie is allowed, because text browsers send neither and are the people the fallback is for. The body is capped
|
||||
at 16 KB (413) and the per-address budget is spent before it is read.
|
||||
- Whether a name is taken is answerable without a proof, bounded by the per-address budget. Names are public (they are
|
||||
chat localparts), so that leaks nothing.
|
||||
|
||||
Tradeoff, stated plainly. The time-lock is accessible to everyone who can use a browser: nothing to see, hear, read or
|
||||
solve, no script, no third party. It is also weaker than the proof of work. It costs an attacker wall-clock time, not CPU,
|
||||
and an attacker can start many challenges at once and wait once, so what bounds the fallback is the per-address budget
|
||||
and nothing else. An attacker with many addresses passes it at the price of the wait. We accept that because a
|
||||
registration buys little: the account is `Authenticated`, in Users, with no vote and no member surface, and the other
|
||||
gates (consent, verification once built, reserved names, suspension) still apply. If fallback registrations are abused,
|
||||
raise the wait or lower the budget; do not add a visual or audio puzzle, which excludes the people the fallback is for.
|
||||
|
||||
## Consent receipt
|
||||
|
||||
Lives in Bifrost: the account, the receipt and the registration are one transaction, and chat admission is Bifrost's
|
||||
decision (`admission-is-bifrost-membership`).
|
||||
|
||||
- Consent is optional at registration. Registration succeeds with or without it; a receipt exists only if the person
|
||||
agreed. It is not optional for chat: the OIDC chat sign-in needs a live receipt for the version in force
|
||||
(`oidc-issuer.md`, Who may sign in). See `training-notice.md` for why and for what the notice says.
|
||||
- Notice: `TrainingNotice.Version` (positive integer, no leading zeros, per the version rule in Heimdall's
|
||||
`answer-collector-consent.md`) and `TrainingNotice.Text`, served at `GET /auth/bifrost/notice`. Version 1 is in force, and its text is frozen (a test pins its hash).
|
||||
Changing the text is a version bump, never an edit in place. The text states that nothing from Discord is used.
|
||||
- Receipt: `ConsentReceipt` (account, notice version, `AcceptedAtUtc`, `WithdrawnAtUtc`). One live (not withdrawn)
|
||||
receipt per account and version; withdrawn rows stay as history. At registration an `acceptedNoticeVersion` equal to
|
||||
the version in force writes the receipt in the same save as the account; a different version is refused (400) and creates
|
||||
nothing; absent means no receipt.
|
||||
- Consent page: `GET /auth/bifrost/consent`, for any signed-in account (native, provider, existing). It shows the notice,
|
||||
the account's state (no agreement, agreed to the current version, agreed to an older one, withdrawn) and the buttons
|
||||
that apply (with a `returnUrl` query a refused chat sign-in sends, a local path only, Agree returns the member there):
|
||||
Withdraw whenever any receipt is live, Agree whenever none is live for the version in force (so an account
|
||||
on an older version sees both). `POST /auth/bifrost/consent/accept` and `POST /auth/bifrost/consent/withdraw` are form posts refused
|
||||
with 403 unless they came from the site itself (the strict `RequestOriginGuard`), before anything is read or written.
|
||||
Agree is idempotent; withdraw stamps `WithdrawnAtUtc` once and deletes nothing; agreeing again after a withdrawal writes
|
||||
a new live receipt. Withdrawal stops future use and cannot recall past use; the notice says so.
|
||||
- A version bump: receipts name the version agreed to, so after a bump no receipt matches and everyone is out of the set
|
||||
until they agree to the new version on the consent page, which tells them their agreement is to an older version. No
|
||||
one is prompted anywhere else; silence means no.
|
||||
- Consumer: a training export must include an account's data only while a live receipt for the notice version in force
|
||||
exists. No export exists in Bifrost; this page is the contract it will be built against, and the notice is not
|
||||
operative for real people until the export enforces it with a test that a withdrawn or never-agreed account's rows are
|
||||
absent.
|
||||
|
||||
Limits: accounts without a receipt (provider-created, existing, registered without agreeing) are excluded, which is the
|
||||
rule, and they opt in on the consent page. A native account that loses its session cannot reach the page (no credential
|
||||
until sign-in recovery exists), so it cannot withdraw.
|
||||
|
||||
## Email verification
|
||||
|
||||
Not built. Decided (steward, 2026-11-11): Bifrost keeps a verified address, as sign-in recovery.
|
||||
|
||||
Why: a native account has no credential and no sign-in route (`concept-mill-votes-target.md`, claim 12). A person who
|
||||
loses the session cookie loses the account, and the chat localpart minted from it is permanent. An address that was
|
||||
verified is the one way back, and the usual way (MAS, Matrix homeservers and most accounts hold one). Hashing it instead
|
||||
cannot send mail, so it recovers nothing.
|
||||
|
||||
Privacy handling, which the build must honour:
|
||||
|
||||
- Stored only for native accounts, one verified address per account, unique across accounts (a keyed hash of the
|
||||
normalized address is the lookup; the address itself is encrypted with Data Protection, since sending needs it).
|
||||
- Never in a token or claim (the issuer doc's claim table is unchanged), never in a log line, a dashboard, a Verse
|
||||
document, a training export, or Heimdall. The account page shows it only to its owner.
|
||||
- Used for two things: the verification mail and a single-use sign-in link (15 minutes, spent on use, per-address and
|
||||
per-account send budgets). No notices, no newsletters, no sharing.
|
||||
- The owner can remove it (then there is no recovery) or change it (a new verification first). Deleting the account
|
||||
deletes it.
|
||||
- Existing accounts and provider accounts are not asked.
|
||||
|
||||
The build cannot start until ops provides a transport: there is no mail sender anywhere in Bifrost, Heimdall or the chat
|
||||
stack. Bifrost's side is an `IEmailSender` port with the relay's settings as secret files; the relay, its sending domain
|
||||
and SPF/DKIM are ops infrastructure.
|
||||
|
||||
Shape: registration becomes two steps. Step one takes identity, address, consent and the CAPTCHA, and mails a single-use
|
||||
token; no account exists yet. Step two redeems the token and creates the account, the receipt and the stored address
|
||||
together. An unverified account then cannot exist, so it cannot chat.
|
||||
|
||||
Failure it prevents: accounts for addresses nobody holds, and unrecoverable accounts.
|
||||
330
docs/research/2026-10-29-estate-tally-game-theory/README.md
Normal file
330
docs/research/2026-10-29-estate-tally-game-theory/README.md
Normal 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).
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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']} |")
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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) + " |")
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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} |")
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.")
|
||||
|
|
@ -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 |
|
||||
82
docs/research/2026-10-29-estate-tally-game-theory/sims/mc.py
Normal file
82
docs/research/2026-10-29-estate-tally-game-theory/sims/mc.py
Normal 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} |")
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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) + " |")
|
||||
|
|
@ -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]
|
||||
110
docs/research/2026-10-29-estate-tally-game-theory/sims/power.py
Normal file
110
docs/research/2026-10-29-estate-tally-game-theory/sims/power.py
Normal 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])
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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))
|
||||
|
|
@ -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 |
|
||||
153
docs/research/2026-10-29-estate-tally-game-theory/sims/third.py
Normal file
153
docs/research/2026-10-29-estate-tally-game-theory/sims/third.py
Normal 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} |")
|
||||
|
|
@ -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 | - | - |
|
||||
|
|
@ -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} | - | - |")
|
||||
119
docs/research/2026-10-29-swarm-governance-prior-art.md
Normal file
119
docs/research/2026-10-29-swarm-governance-prior-art.md
Normal 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.
|
||||
1
docs/research/2026-11-05-estate-rule-sim/.gitignore
vendored
Normal file
1
docs/research/2026-11-05-estate-rule-sim/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
__pycache__/
|
||||
93
docs/research/2026-11-05-estate-rule-sim/REPORT.md
Normal file
93
docs/research/2026-11-05-estate-rule-sim/REPORT.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# 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.
|
||||
|
||||
## E7: contribution-weighted arm (SPEC section 5d)
|
||||
|
||||
Static weight analysis, not a motion simulation, and not a proposal: credit does not become vote weight here. Credit is linear in receipts (hours or credited changes), tallied per person, weight = min(credit, per-person cap), one function for a Meta-sized contributor and an ordinary member alike. Tally and half cap are E6's. Contributor share phi is the fraction of the Members estate's total raw credit; roll 15 and 150 ordinary members, 40 seeds, both estate-weight sets.
|
||||
|
||||
- **The half cap binds only when Members are the only live estate, and then it is a step at phi = .5.** All four estates live: 0 binding in every cell. Members + Minds live: 0 (the Members estate carries at most half the council, so the strict cap is never reached). Members alone: 0 for phi <= .4, and for phi >= .5 with no credit cap 1.000, because a contributor at exactly half of the credit has exactly half the estate (the cap is "strictly below"). The rate does not rise past phi = .5 because the cap binds as a yes/no, not by degree.
|
||||
- **A per-person credit cap moves that step, it does not remove it.** Members alone, phi >= .5, rate of binding at 100x / 20x / 5x the ordinary median credit: 0.438 / 0.075 / 0.000 (pooled over both rolls and weights, 320 trials each). The bound is the cap against the size of the ordinary roll: 5x the median holds a contributor under half of a 15-member estate, which says the cap is doing the work the half cap would do, at the price of flattening every member above 5x median.
|
||||
- **Splitting evades the half cap in every case it needed to act.** Members alone, over the whole sweep, k = 2, 10, 100: the joint share reaches .5 in 5,942 cases and in 5,942 no single identity binds (1.000). Uncapped linear credit makes splitting weight-neutral (multiplier 1.00) but still evades the per-identity half cap. With a per-person credit cap splitting also multiplies weight: at phi .9, k = 10, the multiplier is 3.58 at 100x, 9.66 at 20x, 10.00 at 5x (cap bites hardest on the single identity); k = 100: 6.50 / 21.58 / 59.10.
|
||||
- **Falsifiers (SPEC 5d).** (1) A credit cap contains phi .9 with half cap never binding: only at 5x median; false at 100x and 20x. (2) Credit cap survives splitting at multiplier <= 1.1 at k = 10: false in every capped row (1.23 at phi .25 with a 100x cap up to 10.00). (3) Half cap evaded under splitting: true in all 5,942.
|
||||
|
||||
Limits: Pareto credit curve and its scale, the multiples of median, the Members placement, and the rolls are placeholders. A person-level cap needs an identity oracle (as E6); credit tied to merged receipts could provide one, which this sim does not test. Receipts are not audited for farming or purchase (hours-farming is the E6 hours gap, still unanalysed). Ordinary members' own credit is capped too, which matters only at 5x.
|
||||
|
||||
## 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`.
|
||||
178
docs/research/2026-11-05-estate-rule-sim/SPEC.md
Normal file
178
docs/research/2026-11-05-estate-rule-sim/SPEC.md
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
# 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.
|
||||
|
||||
## 5d. Contribution credit (revision 6, written before its results)
|
||||
|
||||
Serves `heimdall-body:follow_up:chat-mind-20261106T134754647Z-t14`. A static weight analysis like 5c, for a contribution-weighted arm. It informs Meta's later call and decides nothing: credit does not become vote weight, here or in any rule above.
|
||||
|
||||
**Credit.** A person's credit is the sum of their receipts (hours, or credited changes counted in the same linear unit), tallied per person. Weight is `min(credit, cap)`: linear, one function for every person, so a Meta-sized contributor is the same row of the same table as an ordinary member, with no exemption and no separate schedule. The per-person cap is a multiple of the ordinary members' median credit, swept over none, 100x, 20x, 5x (assumptions, not rulings).
|
||||
|
||||
**Contributor share.** The contributor holds a fraction phi of the estate's total raw credit (before capping), swept over .05 to .9. This is the "Meta-sized" axis. Placement is the Members estate (where 5c's hours channel lives), a PLACEHOLDER.
|
||||
|
||||
**Electorate.** 15 (the harness roll) and 150 ordinary members with Pareto(1.16) credit from 10 (prior section 4's contribution curve; the scale is mine), 40 seeds each, estate weights and live-estate scenarios as 5c.
|
||||
|
||||
**Same tally, same half cap.** `council_share` and `influence_cap` of 5c, unchanged: binds iff council weight share x >= .5.
|
||||
|
||||
**Splitting.** The contributor's credit split evenly over k = 2, 10, 100 identities. Reported per identity (binds), jointly (joint share >= .5) and as evasion (joint >= .5 with no identity binding), plus the weight multiplier against k = 1. With no credit cap, linear credit makes splitting weight-neutral; with a per-person cap it is not.
|
||||
|
||||
**Falsifiers fixed in advance.** (1) Per-person credit cap "contains" a large contributor if the half cap never binds at phi .9 with a cap in force in the Members-alone scenario. (2) Credit cap "survives splitting" if the weight multiplier at k = 10 is at most 1.1 with a cap in force. (3) The half cap is evaded under splitting if joint x >= .5 with no identity binding.
|
||||
|
||||
## 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.
|
||||
- **E7 contribution credit:** section 5d. phi x credit cap x roll x weights x live set x seeds; splitting k.
|
||||
- **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.
|
||||
630
docs/research/2026-11-05-estate-rule-sim/estatesim.py
Normal file
630
docs/research/2026-11-05-estate-rule-sim/estatesim.py
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
"""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)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- contribution credit (SPEC section 5d)
|
||||
#
|
||||
# Credit is tallied per person from receipts (hours or credited changes, one linear unit each) and capped per
|
||||
# person. The tally and the half-influence cap are the ones above (council_share, influence_cap). Credit is
|
||||
# measured here as a candidate weight source only; this module never turns it into a vote.
|
||||
|
||||
CREDIT_SEED = 20261106
|
||||
CREDIT_ESTATE = "Members" # PLACEHOLDER: where contribution credit would weigh; the hours channel above lives here
|
||||
|
||||
|
||||
def credit_weight(credit, cap=None):
|
||||
"""One person's weight from credit: linear in receipts, min(credit, cap). One function for every person,
|
||||
a Meta-sized contributor included; there is no per-person exemption or schedule."""
|
||||
return credit if cap is None else min(credit, cap)
|
||||
|
||||
|
||||
def credit_ladder(n, s):
|
||||
"""Seed s's n ordinary members' credit (hours): Pareto(PARETO_ALPHA) from PARETO_XM (prior section 4's
|
||||
contribution curve; the scale is PLACEHOLDER)."""
|
||||
return pareto_ladder(n, random.Random(CREDIT_SEED + 1000 * n + s))
|
||||
|
||||
|
||||
def credit_cap_value(ladder, mult):
|
||||
"""Per-person cap as a multiple of the ordinary members' median credit (None = no cap)."""
|
||||
if mult is None:
|
||||
return None
|
||||
xs = sorted(ladder)
|
||||
return mult * xs[len(xs) // 2]
|
||||
|
||||
|
||||
def credit_run(phi, n, s, W, live, cap_mult=None, k=1):
|
||||
"""Half-cap result for a contributor holding fraction `phi` of the estate's TOTAL raw credit, against n
|
||||
ordinary members (seed s), the credit split evenly over k identities. Returns dict: the usual
|
||||
influence_cap fields for ONE identity (`one`), the joint share (`joint_x`), `evaded` iff the joint
|
||||
council share is >= HALF while no identity's is, and `w_total`."""
|
||||
e = ESTATES.index(CREDIT_ESTATE)
|
||||
lad = credit_ladder(n, s)
|
||||
cap = credit_cap_value(lad, cap_mult)
|
||||
rest = sum(credit_weight(x, cap) for x in lad)
|
||||
big = phi / (1 - phi) * sum(lad)
|
||||
wi = credit_weight(big / k, cap)
|
||||
tot = k * wi
|
||||
one = influence_cap(wi, rest + tot - wi, W, e, live)
|
||||
joint = council_share(tot, rest, W, e, live)[1]
|
||||
return dict(one=one, joint_x=joint, binds=one["binds"], joint_binds=joint >= HALF - EPS,
|
||||
evaded=joint >= HALF - EPS and not one["binds"], w_total=tot, rest=rest, cap=cap)
|
||||
958
docs/research/2026-11-05-estate-rule-sim/results.md
Normal file
958
docs/research/2026-11-05-estate-rule-sim/results.md
Normal file
|
|
@ -0,0 +1,958 @@
|
|||
# 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 |
|
||||
|
||||
## E7 contribution-weighted arm: linear credit, per-person cap, the half-influence cap (SPEC section 5d)
|
||||
|
||||
Static weight analysis. One contributor holds a fraction phi of the Members estate's total raw credit (receipts: hours or credited changes, one linear unit); 15 and 150 ordinary members hold Pareto(1.16) credit from 10, 40 seeds each. Weight = min(credit, per-person cap), the same function for the contributor and everyone else. The tally and the half cap are E6's: council share x = W x s over the live estates; the cap binds iff x >= .5. Credit is not vote weight anywhere in this module; this table only says how often the half cap would have to act.
|
||||
|
||||
**Half cap binds, all four estates: fraction of (weights x roll x seed) by contributor share phi (rows) and per-person credit cap (columns); one identity, k = 1**
|
||||
|
||||
| phi | none | 100x median | 20x median | 5x median |
|
||||
|---|---|---|---|---|
|
||||
| 0.05 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
| 0.1 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
| 0.25 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
| 0.4 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
| 0.5 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
| 0.6 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
| 0.75 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
| 0.9 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
|
||||
**Half cap binds, Members + Minds: fraction of (weights x roll x seed) by contributor share phi (rows) and per-person credit cap (columns); one identity, k = 1**
|
||||
|
||||
| phi | none | 100x median | 20x median | 5x median |
|
||||
|---|---|---|---|---|
|
||||
| 0.05 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
| 0.1 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
| 0.25 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
| 0.4 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
| 0.5 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
| 0.6 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
| 0.75 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
| 0.9 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
|
||||
**Half cap binds, Members alone: fraction of (weights x roll x seed) by contributor share phi (rows) and per-person credit cap (columns); one identity, k = 1**
|
||||
|
||||
| phi | none | 100x median | 20x median | 5x median |
|
||||
|---|---|---|---|---|
|
||||
| 0.05 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
| 0.1 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
| 0.25 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
| 0.4 | 0.000 | 0.000 | 0.000 | 0.000 |
|
||||
| 0.5 | 1.000 | 0.438 | 0.075 | 0.000 |
|
||||
| 0.6 | 1.000 | 0.438 | 0.075 | 0.000 |
|
||||
| 0.75 | 1.000 | 0.438 | 0.075 | 0.000 |
|
||||
| 0.9 | 1.000 | 0.438 | 0.075 | 0.000 |
|
||||
|
||||
**Splitting: the contributor's credit split evenly over k identities. Members alone, equal weights, roll 15, mean over seeds. binds = the half cap acts on an identity; joint >= .5 = the combined share would have needed cutting; evaded = joint >= .5 and no identity binds; weight x = total weight / weight at k = 1**
|
||||
|
||||
| phi | cap | k=1: binds / joint>=.5 / evaded / weight x | k=2: binds / joint>=.5 / evaded / weight x | k=10: binds / joint>=.5 / evaded / weight x | k=100: binds / joint>=.5 / evaded / weight x |
|
||||
|---|---|---|---|---|---|
|
||||
| 0.25 | none | 0.00 / 0.00 / 0.00 / 1.00 | 0.00 / 0.00 / 0.00 / 1.00 | 0.00 / 0.00 / 0.00 / 1.00 | 0.00 / 0.00 / 0.00 / 1.00 |
|
||||
| 0.25 | 100x median | 0.00 / 0.00 / 0.00 / 1.00 | 0.00 / 0.03 / 0.03 / 1.03 | 0.00 / 0.03 / 0.03 / 1.23 | 0.00 / 0.03 / 0.03 / 1.25 |
|
||||
| 0.25 | 20x median | 0.00 / 0.00 / 0.00 / 1.00 | 0.00 / 0.03 / 0.03 / 1.12 | 0.00 / 0.07 / 0.07 / 1.43 | 0.00 / 0.07 / 0.07 / 2.55 |
|
||||
| 0.25 | 5x median | 0.00 / 0.00 / 0.00 / 1.00 | 0.00 / 0.00 / 0.00 / 1.75 | 0.00 / 0.12 / 0.12 / 2.87 | 0.00 / 0.12 / 0.12 / 5.46 |
|
||||
| 0.5 | none | 1.00 / 1.00 / 0.00 / 1.00 | 0.00 / 1.00 / 1.00 / 1.00 | 0.00 / 1.00 / 1.00 / 1.00 | 0.00 / 1.00 / 1.00 / 1.00 |
|
||||
| 0.5 | 100x median | 0.88 / 0.88 / 0.00 / 1.00 | 0.00 / 1.00 / 1.00 / 1.08 | 0.00 / 1.00 / 1.00 / 1.31 | 0.00 / 1.00 / 1.00 / 1.88 |
|
||||
| 0.5 | 20x median | 0.15 / 0.15 / 0.00 / 1.00 | 0.00 / 0.80 / 0.80 / 1.50 | 0.00 / 1.00 / 1.00 / 2.35 | 0.00 / 1.00 / 1.00 / 4.73 |
|
||||
| 0.5 | 5x median | 0.00 / 0.00 / 0.00 / 1.00 | 0.00 / 0.00 / 0.00 / 2.00 | 0.00 / 1.00 / 1.00 / 6.41 | 0.00 / 1.00 / 1.00 / 11.38 |
|
||||
| 0.9 | none | 1.00 / 1.00 / 0.00 / 1.00 | 0.00 / 1.00 / 1.00 / 1.00 | 0.00 / 1.00 / 1.00 / 1.00 | 0.00 / 1.00 / 1.00 / 1.00 |
|
||||
| 0.9 | 100x median | 0.88 / 0.88 / 0.00 / 1.00 | 0.00 / 1.00 / 1.00 / 1.93 | 0.00 / 1.00 / 1.00 / 3.58 | 0.00 / 1.00 / 1.00 / 6.50 |
|
||||
| 0.9 | 20x median | 0.15 / 0.15 / 0.00 / 1.00 | 0.00 / 0.80 / 0.80 / 2.00 | 0.00 / 1.00 / 1.00 / 9.66 | 0.00 / 1.00 / 1.00 / 21.58 |
|
||||
| 0.9 | 5x median | 0.00 / 0.00 / 0.00 / 1.00 | 0.00 / 0.00 / 0.00 / 2.00 | 0.00 / 1.00 / 1.00 / 10.00 | 0.00 / 1.00 / 1.00 / 59.10 |
|
||||
|
||||
Evasion rate over the sweep (phi x cap x k in 2, 10, 100 x live set x weights x roll x seed), cases where the joint share needed cutting:
|
||||
|
||||
| live estates | joint share >= .5 (k > 1) | of which no identity binds (evaded) |
|
||||
|---|---|---|
|
||||
| all four estates | 0 | 0 |
|
||||
| Members + Minds | 0 | 0 |
|
||||
| Members alone | 5942 | 5942 (1.000) |
|
||||
|
||||
## 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
|
||||
|
||||
469
docs/research/2026-11-05-estate-rule-sim/run.py
Normal file
469
docs/research/2026-11-05-estate-rule-sim/run.py
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
"""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
|
||||
|
||||
|
||||
MEMBERS = E.ESTATES.index(E.CREDIT_ESTATE)
|
||||
CLIVE = {"all four estates": (0, 1, 2, 3), "Members + Minds": (0, MEMBERS), "Members alone": (MEMBERS,)}
|
||||
CREDIT_CAPS = (None, 100.0, 20.0, 5.0) # per-person credit cap, multiples of the ordinary members' median credit
|
||||
|
||||
|
||||
def _capname(m):
|
||||
return "none" if m is None else f"{m:g}x median"
|
||||
|
||||
|
||||
def credit_section(quick):
|
||||
"""E7 contribution-weighted arm (SPEC section 5d). Pure functions of fixed seeds; no wall clock."""
|
||||
seeds = 4 if quick else 40
|
||||
phis = (.1, .5, .9) if quick else (.05, .1, .25, .4, .5, .6, .75, .9)
|
||||
rolls = (E.ROLL["Members"],) if quick else (E.ROLL["Members"], 150)
|
||||
ks = (1, 2, 10, 100)
|
||||
L = ["## E7 contribution-weighted arm: linear credit, per-person cap, the half-influence cap (SPEC section 5d)", "",
|
||||
f"Static weight analysis. One contributor holds a fraction phi of the {E.CREDIT_ESTATE} estate's total raw credit (receipts: hours or credited "
|
||||
f"changes, one linear unit); {rolls[0]}" + (f" and {rolls[1]}" if len(rolls) > 1 else "") +
|
||||
f" ordinary members hold Pareto({E.PARETO_ALPHA}) credit from {E.PARETO_XM:.0f}, {seeds} seeds each. Weight = min(credit, per-person cap), the same function "
|
||||
"for the contributor and everyone else. The tally and the half cap are E6's: council share x = W x s over the live estates; the cap binds iff x >= .5. "
|
||||
"Credit is not vote weight anywhere in this module; this table only says how often the half cap would have to act.", ""]
|
||||
for ln, live in CLIVE.items():
|
||||
L += [f"**Half cap binds, {ln}: fraction of (weights x roll x seed) by contributor share phi (rows) and per-person credit cap (columns); one identity, k = 1**", "",
|
||||
"| phi | " + " | ".join(_capname(m) for m in CREDIT_CAPS) + " |", "|---|" + "---|" * len(CREDIT_CAPS)]
|
||||
for phi in phis:
|
||||
cols = []
|
||||
for m in CREDIT_CAPS:
|
||||
hit = tot = 0
|
||||
for W in E.WEIGHTS.values():
|
||||
for n in rolls:
|
||||
for s in range(seeds):
|
||||
hit += E.credit_run(phi, n, s, W, live, m)["binds"]
|
||||
tot += 1
|
||||
cols.append(f"{hit / tot:.3f}")
|
||||
L.append(f"| {phi:g} | " + " | ".join(cols) + " |")
|
||||
L.append("")
|
||||
L += ["**Splitting: the contributor's credit split evenly over k identities. Members alone, equal weights, roll "
|
||||
f"{rolls[0]}, mean over seeds. binds = the half cap acts on an identity; joint >= .5 = the combined share would have needed cutting; "
|
||||
"evaded = joint >= .5 and no identity binds; weight x = total weight / weight at k = 1**", "",
|
||||
"| phi | cap | " + " | ".join(f"k={k}: binds / joint>=.5 / evaded / weight x" for k in ks) + " |", "|---|---|" + "---|" * len(ks)]
|
||||
W = E.WEIGHTS["equal"]
|
||||
for phi in (.25, .5, .9):
|
||||
for m in CREDIT_CAPS:
|
||||
cols = []
|
||||
for k in ks:
|
||||
rs = [E.credit_run(phi, rolls[0], s, W, (MEMBERS,), m, k) for s in range(seeds)]
|
||||
base = [E.credit_run(phi, rolls[0], s, W, (MEMBERS,), m, 1)["w_total"] for s in range(seeds)]
|
||||
cols.append(f"{sum(r['binds'] for r in rs) / seeds:.2f} / {sum(r['joint_binds'] for r in rs) / seeds:.2f} / "
|
||||
f"{sum(r['evaded'] for r in rs) / seeds:.2f} / {sum(r['w_total'] / b for r, b in zip(rs, base)) / seeds:.2f}")
|
||||
L.append(f"| {phi:g} | {_capname(m)} | " + " | ".join(cols) + " |")
|
||||
L += ["", "Evasion rate over the sweep (phi x cap x k in 2, 10, 100 x live set x weights x roll x seed), cases where the joint share needed cutting:", "",
|
||||
"| live estates | joint share >= .5 (k > 1) | of which no identity binds (evaded) |", "|---|---|---|"]
|
||||
for ln, live in CLIVE.items():
|
||||
j = ev = 0
|
||||
for phi in phis:
|
||||
for m in CREDIT_CAPS:
|
||||
for k in ks[1:]:
|
||||
for Wt in E.WEIGHTS.values():
|
||||
for n in rolls:
|
||||
for s in range(seeds):
|
||||
r = E.credit_run(phi, n, s, Wt, live, m, k)
|
||||
j += r["joint_binds"]
|
||||
ev += r["evaded"]
|
||||
L.append(f"| {ln} | {j} | {ev} ({ev / j:.3f}) |" if j else f"| {ln} | 0 | 0 |")
|
||||
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 += credit_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()
|
||||
600
docs/research/2026-11-05-estate-rule-sim/test_estatesim.py
Normal file
600
docs/research/2026-11-05-estate-rule-sim/test_estatesim.py
Normal file
|
|
@ -0,0 +1,600 @@
|
|||
"""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))
|
||||
|
||||
|
||||
PINNED_CREDIT_BINDS = {None: 320, 100.0: 140, 20.0: 24, 5.0: 0} # of 320 (phi .5, .9 x roll 15, 150 x 40 seeds x 2 weights)
|
||||
|
||||
|
||||
class CreditTests(unittest.TestCase):
|
||||
"""SPEC section 5d: contribution credit, per-person cap, the half-influence cap."""
|
||||
M = E.ESTATES.index("Members")
|
||||
W = E.WEIGHTS["equal"]
|
||||
ALONE = (1,)
|
||||
|
||||
def test_one_standard_for_everyone(self):
|
||||
for c in (3.0, 40.0, 1e6): # a Meta-sized credit gets no schedule of its own
|
||||
self.assertEqual(E.credit_weight(c), c)
|
||||
self.assertEqual(E.credit_weight(c, 25.0), min(c, 25.0))
|
||||
|
||||
def test_cap_is_per_person_and_a_multiple_of_the_ordinary_median(self):
|
||||
lad = E.credit_ladder(15, 0)
|
||||
self.assertIsNone(E.credit_cap_value(lad, None))
|
||||
self.assertEqual(E.credit_cap_value(lad, 20.0), 20.0 * sorted(lad)[7])
|
||||
|
||||
def test_contributor_holds_phi_of_raw_credit(self):
|
||||
r = E.credit_run(.5, 15, 3, self.W, self.ALONE)
|
||||
self.assertAlmostEqual(r["w_total"], r["rest"]) # uncapped: phi .5 means equal to everyone else
|
||||
self.assertAlmostEqual(r["one"]["s"], .5)
|
||||
|
||||
def test_half_cap_binds_at_half_of_credit_in_a_lone_estate_not_below(self):
|
||||
self.assertTrue(E.credit_run(.5, 15, 0, self.W, self.ALONE)["binds"])
|
||||
self.assertFalse(E.credit_run(.49, 15, 0, self.W, self.ALONE)["binds"])
|
||||
|
||||
def test_other_estates_dilute_the_contributor_below_the_half_cap(self):
|
||||
self.assertFalse(E.credit_run(.9, 15, 0, self.W, (0, 1, 2, 3))["binds"])
|
||||
|
||||
def test_credit_cap_reduces_weight_and_can_release_the_half_cap(self):
|
||||
un = E.credit_run(.9, 15, 0, self.W, self.ALONE)
|
||||
cp = E.credit_run(.9, 15, 0, self.W, self.ALONE, cap_mult=5.0)
|
||||
self.assertTrue(un["binds"])
|
||||
self.assertFalse(cp["binds"])
|
||||
self.assertLess(cp["w_total"], un["w_total"])
|
||||
|
||||
def test_splitting_is_weight_neutral_without_a_cap_and_gains_with_one(self):
|
||||
base = E.credit_run(.9, 15, 0, self.W, self.ALONE)["w_total"]
|
||||
self.assertAlmostEqual(E.credit_run(.9, 15, 0, self.W, self.ALONE, k=10)["w_total"], base)
|
||||
c1 = E.credit_run(.9, 15, 0, self.W, self.ALONE, cap_mult=20.0)["w_total"]
|
||||
self.assertGreater(E.credit_run(.9, 15, 0, self.W, self.ALONE, cap_mult=20.0, k=10)["w_total"], c1)
|
||||
|
||||
def test_per_identity_half_cap_is_evaded_by_splitting_credit(self):
|
||||
r1 = E.credit_run(.9, 15, 0, self.W, self.ALONE, k=1)
|
||||
r2 = E.credit_run(.9, 15, 0, self.W, self.ALONE, k=2)
|
||||
self.assertTrue(r1["binds"] and not r1["evaded"])
|
||||
self.assertFalse(r2["binds"])
|
||||
self.assertTrue(r2["joint_binds"] and r2["evaded"])
|
||||
|
||||
def test_headline_numbers_are_pinned(self):
|
||||
hit = {m: sum(E.credit_run(phi, n, s, w, self.ALONE, m)["binds"] for phi in (.5, .9) for n in (15, 150)
|
||||
for s in range(40) for w in E.WEIGHTS.values()) for m in (None, 100.0, 20.0, 5.0)}
|
||||
self.assertEqual(hit, PINNED_CREDIT_BINDS)
|
||||
|
||||
def test_deterministic(self):
|
||||
a, b = E.credit_run(.5, 15, 2, self.W, self.ALONE, 20.0, 10), E.credit_run(.5, 15, 2, self.W, self.ALONE, 20.0, 10)
|
||||
self.assertEqual(a, b)
|
||||
|
||||
|
||||
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()
|
||||
|
|
@ -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
|
||||
|
98
docs/research/anonymous-vote-stuffing-prior-art.md
Normal file
98
docs/research/anonymous-vote-stuffing-prior-art.md
Normal 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.
|
||||
105
docs/research/customer-onboarding-body-facts.md
Normal file
105
docs/research/customer-onboarding-body-facts.md
Normal 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.
|
||||
56
docs/research/customer-onboarding-prior-art.md
Normal file
56
docs/research/customer-onboarding-prior-art.md
Normal 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.
|
||||
55
docs/research/email-mouth-prior-art.md
Normal file
55
docs/research/email-mouth-prior-art.md
Normal 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).
|
||||
59
docs/research/matrix-roles-prior-art.md
Normal file
59
docs/research/matrix-roles-prior-art.md
Normal 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).
|
||||
153
docs/sealed-ballot-motion.md
Normal file
153
docs/sealed-ballot-motion.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# Sealed-ballot motions
|
||||
|
||||
A blind choice among named candidates (first use: which Mind writes the swarm's story). Commit-reveal, one atomic
|
||||
publication at close, no mediator. Code: `Features/Motions/SealedBallotService.cs`. Ballot shape: the JSON record `SealedBallotDocument` carrying the string constant `gamecult.sealed_ballot.v0`.
|
||||
|
||||
## Protocol
|
||||
|
||||
1. **Open** (moderator only): title, candidates (2+), commit deadline, reveal deadline, optional `round` and `priorMotionId`.
|
||||
2. **Commit** until the commit deadline: the voter sends `SHA-256` of the canonical ballot document (below). Bifrost stores
|
||||
only that hash and returns a receipt (commitment, revision, time). A later commit replaces the earlier one (ballot change,
|
||||
`revision` counts it); every commit is an audit event carrying the hash.
|
||||
3. **Reveal** from the commit deadline until the reveal deadline: the voter posts the ballot document. Bifrost recomputes the
|
||||
hash and stores the content only if it matches the commitment. Revealed content is not readable through any endpoint,
|
||||
page, audit event or export until close.
|
||||
4. **Close** at the reveal deadline, mechanically: the first read of `/result` after the deadline, or the once-a-minute
|
||||
`SealedBallotCloser` sweep if nobody reads, writes status `Closed` and a digest of all commitments, then returns every revealed ballot (voter, candidate, one-sentence reason, salt, commitment) at
|
||||
once. The close freezes the reveal set: `Motion.RevealSeq` (bumped by every accepted reveal) and `Status` are concurrency
|
||||
tokens, so a reveal racing the close either lands before it (the close re-reads and its digest covers that reveal) or is
|
||||
refused with 409 ("the motion closed"). A conflict from another voter's reveal while the window is open is re-read and retried, up to five times (a retried reveal is judged for phase once, at the start of the request, but it lands in the frozen digest through `RevealSeq` like any other), then answered 409 "contended; send it again"; the same 409 "contended" is what `/result` answers if its close loses five races in a row; the digest covers `reveals:<RevealSeq>` plus every commitment's revealed/unrevealed state. A reveal after
|
||||
the deadline or after close is refused. Anyone can recompute each commitment from the published fields. Nobody can close early, extend, edit or break a tie.
|
||||
|
||||
Canonical document (`gamecult.sealed_ballot.v0`): `{schema, motionId, round, candidate, reason, salt}`. Commitment =
|
||||
`sha256(utf8("gamecult.sealed_ballot.v0\n{motionId}\n{voterUserAccountId}\n{round}\n{candidate}\n{reason}\n{salt}"))`, lowercase
|
||||
hex. `salt` is 32 to 128 hex characters (128 bits at least) the voter generates and keeps; without it a four-candidate hash is
|
||||
guessable. The voter's account id is in the hash, so a commitment cannot be replayed by another member. `gamecult.sealed_ballot.v0` is only a string
|
||||
constant on a JSON record (`SealedBallotDocument`); it is **not** a CultCache or CultNet schema, and nothing here registers
|
||||
one. A second Delphi round is modelled as another motion (`Round` and `PriorMotionId` columns) whose ballots have the same JSON
|
||||
shape. A real typed document schema, with CultCache/CultNet registration so a round-two ballot is the same registered type, is
|
||||
a follow-up (Bifrost issue linked in the PR).
|
||||
|
||||
## Decisions
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Cast surface | `POST /api/sealed-motions/{id}/commit` `{commitment}` and `POST .../reveal` (the document). `GET .../{id}` returns phase, times, candidates, your voter id, your own commitment status and an announcement line (open state and close time, no ballots). `GET .../result` refuses until close. All require an active member session. |
|
||||
| Who opens / closes | A moderator opens. Nobody closes: the deadline does. |
|
||||
| Deadlines | Set at open, immutable. Commit deadline < reveal deadline. |
|
||||
| Who may vote | Every active member at close, Minds included. Self-votes are allowed: a candidate is a name, not tied to the voter. One-sentence reason: required, non-empty, one line, at most 280 characters (length and line count are the check; "one sentence" is not parsed). |
|
||||
| Non-voters | No commitment, or a commitment never revealed: shown at close as abstentions (unrevealed ones with their commitment). Never counted for anyone. No quorum. |
|
||||
| Ballot change | Re-commit during the commit window; the latest commitment stands. No change once the window ends. |
|
||||
| Ties | Reported as `tie` with all leaders. Nobody breaks it; a runoff is a new motion over the tied candidates. |
|
||||
| Tally | Plurality of valid revealed ballots. |
|
||||
|
||||
## Why commit-reveal, not encryption to a close-time key
|
||||
|
||||
Encryption to a key released at close needs someone to hold that key until then, which is a mediator and trusts Bifrost with
|
||||
every plaintext from the first cast. Commit-reveal stores nothing but hashes during the commit window, so before the reveal
|
||||
step there is no ballot in Bifrost to read, and after it nobody can change what they committed. Price: a second step per
|
||||
voter, and a Mind that skips it is shown as an abstention. No custody opt-in is offered; offering one would mean Bifrost
|
||||
holding that Mind's plaintext from cast, which is the gap commit-reveal removes.
|
||||
|
||||
## Who can read the sealed store before close (the honest account)
|
||||
|
||||
- **Commit window:** nobody. The store holds hashes; the ballot exists only with its voter. Participation (who has committed,
|
||||
not what) is visible in the audit trail and to the voter's own receipt.
|
||||
- **Reveal window:** the **host and anyone with database access can read revealed ballots** that voters have already submitted.
|
||||
Application code does not expose them (enforced by tests), but a database reader sees columns `RevealedCandidate`,
|
||||
`RevealedReason`, `Salt`. **Backups are another reader:** any `pg_dump` taken in the reveal window contains the reveals
|
||||
already submitted. What this repo says about backups: `docs/deploy-main-to-live.md` (Precondition 2) takes a `pg_dump -Fc`
|
||||
inside a deploy window as in the gamecult-ops runbook section 8, and `docs/full-implementation-strategy.md` lists a
|
||||
backup/restore runbook as planned. This repo does not state a nightly schedule, where dumps are stored or who can read
|
||||
them; if a nightly dump exists it is defined in gamecult-ops, which I have not read, so I do not claim it or its reach.
|
||||
Dumps taken in the commit window hold hashes only. This is a gap and it is named: it is short, commitments are frozen so no one can change a ballot
|
||||
after seeing another, and the commitments published during the commit window let anyone detect an altered reveal. It is not
|
||||
closed. Closing it fully would need reveals encrypted to a threshold or time-lock key, which this change does not build.
|
||||
- A Mind that reveals late in the window exposes its ballot to the host for less time. A voter who never reveals exposes
|
||||
nothing, at the price of being an abstention.
|
||||
- The host can also withhold or alter the *result* at close (it computes the tally). The published commitment digest and the
|
||||
per-ballot recomputation make a forged ballot detectable by voters, not a withheld one.
|
||||
|
||||
## Where ballots must never travel
|
||||
|
||||
Ballots go to Bifrost as JSON documents over the endpoints above, never as room text (chat history is readable by the host
|
||||
and admins before close). A pinned motion announces only the `announcement` line: phase and times.
|
||||
|
||||
## Close is race-safe
|
||||
|
||||
`Motion.Status` is an EF concurrency token. Racing closers each compute the same result (it is a pure function of frozen
|
||||
data) but only one `SaveChanges` succeeds; the loser reloads the winner's row and writes no second `sealed.closed` audit
|
||||
event. Tests: a stale-copy conflict and four concurrent first reads each yield exactly one close; with the token removed the
|
||||
conflict test fails. In Postgres the token becomes `WHERE "Status" = <old>` on the update. Not exercised against Postgres.
|
||||
|
||||
## Can a Mind hold the member session these endpoints need?
|
||||
|
||||
Only through the existing member cookie, and awkwardly. Read from the code:
|
||||
|
||||
- Bifrost authenticates with one cookie scheme plus GitHub OAuth (`Program.cs:176`). There is no bearer or API-key scheme;
|
||||
the endpoints use the same `ActiveMember` policy as the console (`Program.cs:236`, `ActiveMemberAuthorizationHandler.cs:22`),
|
||||
which maps claims to an account by Bifrost identity, Heimdall account id or GitHub id (`CurrentBifrostActorAccessor.cs:33-38`).
|
||||
- A non-browser client can obtain a cookie: `POST /auth/bifrost/register` is anonymous and signs a cookie in
|
||||
(`Program.cs:358`), creating an account in status `Authenticated` (`BifrostIdentityService.cs:49`), not `Active`. It becomes
|
||||
able to vote only after membership approval.
|
||||
- The same route cannot sign an existing identity back in (`BifrostIdentityService.cs:26` refuses a registered identity), so a
|
||||
Mind that loses its cookie jar cannot re-authenticate natively. The other sign-in routes (`/auth/sign-in`,
|
||||
`/auth/heimdall/{provider}`, `Program.cs:390`) are browser redirect flows.
|
||||
- The bridge token (`BridgeOptions.cs:7`) is read by no server code path (the only matches in `src` are its own definition),
|
||||
so it opens nothing. `tools/*.mjs` bearer headers go to Reddit, Matrix and image hosts, not to Bifrost.
|
||||
|
||||
Answer: not cleanly. A Mind can hold a session if a human registers and approves it and the Mind keeps one cookie jar alive;
|
||||
Bifrost has no durable non-browser credential for a Mind. I invented no auth path. Whether to give Minds one (a Persona-signed
|
||||
or token intake like `tools/bifrost-signed-identity.mjs`) is a ruling for the stewards, not this PR.
|
||||
|
||||
## Signed Mind intake
|
||||
|
||||
A Mind casts through `POST /api/sealed-motions/{id}/mind` with a signed envelope. No session or cookie is involved; the
|
||||
signature is the credential, and a verified envelope runs the same `SealedBallotService` verbs as a member cast, as the
|
||||
persona's member account.
|
||||
|
||||
**Envelope:** `{persona, verb: "commit"|"reveal", motionId, nonce, issuedAt (unix seconds), commitment | (round, candidate, reason, salt), signature}`.
|
||||
`signature` is base64 Ed25519 over `MindIntakeService.SigningText`: `gamecult.sealed_intake.v0`, persona, verb, motion id,
|
||||
nonce, issued-at, commitment, round, candidate, reason, salt, one per line, unused fields empty. Ed25519 is not in .NET 8's built-ins;
|
||||
verification uses BouncyCastle, already a dependency (`HeimdallTokenVerifier.cs`).
|
||||
|
||||
**Order and refusals** (each tested; none carries a ballot). Stateless shape checks (persona, verb and nonce patterns, no
|
||||
newlines) answer 400. Then the signature is verified, and every refusal up to there costs one Ed25519 verification, a dummy one
|
||||
when the persona has no unrevoked key or the signature is not well-formed base64 of 64 bytes, so unknown persona, revoked key,
|
||||
wrong key, tampered field, persona not matching the signing key and a malformed signature are one 401 answer with the same work
|
||||
(`MindIntakeService.SignatureChecks` counts verifications for the test; a timing difference below that is not measured here).
|
||||
Only after a verified signature: a motion id different from the route: 400; `issuedAt` more than 10 minutes from the server
|
||||
clock: 400; a replayed nonce (stored per persona, spent only after the signature verifies): 409; then the verb itself (wrong
|
||||
phase 409, bad document 400, reveal not matching the persona-bound commitment 400). Nonces older than 20 minutes are pruned on
|
||||
each accepted envelope, in a separate best-effort write (a prune that fails, for example two requests deleting the same rows, never fails the request), which is safe because the clock window already refuses their envelopes. A database failure that is not
|
||||
a nonce collision is a server fault, not a replay.
|
||||
|
||||
**Limits.** Per-source (client IP after forwarded-header processing; an IPv6 /64 is not grouped) fixed-window budget on the
|
||||
shared `FixedWindowBudget`, 30 per minute by default, spent before the body is read: 429 with Retry-After. Body cap 4096 bytes:
|
||||
413. Both are `MindIntake:*` options. The budget is per process and in memory.
|
||||
|
||||
**Persona id in the commitment:** the commitment hash is `sha256(schema, motion, voter account, persona, round, candidate,
|
||||
reason, salt)`; member casts use an empty persona. A reveal through the intake must carry the signing persona, so a commitment
|
||||
made without it cannot be revealed as that persona.
|
||||
|
||||
**Keys and trust.** Bifrost stores only public keys (`MindKeys`). A moderator registers a key for a persona and its Active member
|
||||
account with `POST /api/sealed-motions/mind-keys {persona, userAccountId, publicKey}` and revokes with
|
||||
`DELETE /api/sealed-motions/mind-keys/{id}`; both are audited (`mindkey.registered`, `mindkey.revoked`). A persona stays bound
|
||||
to one member account and the account to that one persona (`MindPersonas`, unique on both columns, so a second persona
|
||||
cannot share the account and overwrite the first's ballot, and a registration race loses on the index). The trust is the moderator's: Bifrost cannot tell whether a registered public
|
||||
key really belongs to the Mind.
|
||||
|
||||
**What a Mind holds.** An Ed25519 private key (32-byte seed) at `/var/lib/mind-<persona>/sealed-motion.key`. The generator script writes it mode 0400 and the signer accepts 0400 or 0600.
|
||||
It is owned by `mind-<persona>`, the Persona user, which is also the signer; never in the mind store or a room. Eureka generates it on the host and prints only the public
|
||||
half. By rule, a moderator registers that public half only after the Persona shell, running as `mind-<persona>`, has signed a real `commit` envelope
|
||||
(production domain `gamecult.sealed_intake.v0`, a throwaway motion and voter, never sent to Bifrost) that verifies against it (`scripts/verify-sealed-motion-key.sh` in gamecult-ops, which must print `PASS`). **Whoever can read that file can vote as that Mind.** Root on the host can; Bifrost cannot.
|
||||
|
||||
**Signing.** `tools/sealed-motion-sign.mjs` (`pubkey`, `commit`, `reveal`) reads the key file, checks its type, mode and owner,
|
||||
and prints the envelope; it posts nothing. `commit` also prints the ballot (candidate, reason, salt), which the Mind keeps in
|
||||
private state to send `reveal` later; the persona turn posts the printed envelope with `curl`. The voter id `commit` needs is
|
||||
the persona's member account id (the `userAccountId` the moderator registered).
|
||||
|
||||
**Rotation.** Generate a new key, register it, revoke the old one. A ballot committed or revealed before revocation stays valid
|
||||
for its motion. A key revoked mid-motion can no longer reveal, so rotate between motions. A lost key is handled as rotation.
|
||||
|
||||
**Not covered:** the budget is per process, so several app instances multiply it; the moderator's identity is the member cookie, as for every moderator act.
|
||||
78
docs/training-notice.md
Normal file
78
docs/training-notice.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Training notice, version 1
|
||||
|
||||
The served text is `TrainingNotice.Text` and nothing else: this page records why it says what it says and what each gap in
|
||||
the receipt flow became, and does not repeat the wording. Contract for the receipt and the export is in
|
||||
`registration-gates.md`. What the receipt gates in chat is in `oidc-issuer.md`.
|
||||
|
||||
Version 1 is the first notice that will be live under that number, but the number has been used before: main carried a
|
||||
version 1 with different words from #54 onward, and #58 labelled its drafts version 2. Receipts therefore do not migrate.
|
||||
**The text is frozen as version 1**: a test pins its hash, so editing the words without bumping `TrainingNotice.Version`
|
||||
fails a test. A reworded notice is version 2, with its own pinned hash.
|
||||
|
||||
## Before deploy
|
||||
|
||||
Migration `20261111120000_ClearLegacyConsentReceipts` deletes every row from `ConsentReceipts` when it runs, because
|
||||
version 1 receipts were given to different words and must not count as agreement to these. Manual emptying is therefore only
|
||||
a belt-and-braces check: confirm the table is empty after the migrate on any database that ever ran main at or after
|
||||
`33be966`. Meta/ops must still confirm whether anything was deployed with receipts in it; if so, the people who agreed are
|
||||
asked again on their next chat sign-in.
|
||||
|
||||
## What the notice says and why
|
||||
|
||||
| The notice says | Why |
|
||||
| --- | --- |
|
||||
| What counts: the text of messages with time and room, tied to the account name. Not the email address, sign-in details or anything not posted. | Names the data instead of "messages and activity". |
|
||||
| "that GameCult builds and runs" | Whether a dataset or model may leave GameCult is Meta's call, still open (below); the notice claims nothing either way. |
|
||||
| "Nothing that came from Discord is used, in any form." | `gamecult-chat:ruling:discord-taints-training`. Whether any path carries Discord content into a chat room is for the chat steward to confirm (not established here), and any that exists must be excluded from every set for the sentence to stay true. |
|
||||
| "Agreement is needed to speak in a recorded room. You can have a GameCult account, see the members page and vote without agreeing." | Meta's ruling, 2026-11-11: the chat token needs a live receipt for the version in force; nothing else does. "to speak in a recorded room" is settled wording. |
|
||||
| "Lines you have already spoken stay: withdrawing deletes nothing." | Withdrawing ends future speaking and future training use; it recalls nothing. The sentence is on the page where the Agree button is (the consent page and the registration form both render the notice beside it), so nobody agrees believing they can unsay a line. |
|
||||
| Names the door: the consent page, while signed in. | "Withdraw at any time" with no door is no promise. |
|
||||
|
||||
## Rulings on the five gaps
|
||||
|
||||
1. **A native account with no session cannot withdraw.** True. A native account has no credential, so a visitor who loses
|
||||
the session cookie has no way back in, to the consent page or anywhere else. Nothing in the consent flow can fix that;
|
||||
sign-in recovery is the verified address (`registration-gates.md`, Email verification), not built. Until it is, such
|
||||
a person cannot withdraw and the notice cannot promise otherwise. The consequence is bounded: consent is optional, so
|
||||
the people affected are those who ticked it and then lost the session. This stays an open limit, listed under
|
||||
"What remains".
|
||||
2. **A version bump silently drops everyone.** True, and it stays the behavior because it is the honest one: a receipt
|
||||
records agreement to specific words, and new words are not agreed to. The export includes an account only while a live
|
||||
(not withdrawn) receipt for the version in force exists. After a bump, everyone is out until they agree again. The
|
||||
notice says so ("if the notice changes, its version changes and we ask again; until you agree to the new version, your
|
||||
messages are not used"), and the consent page shows the person that their agreement is to an older version and
|
||||
offers the new one. Since the chat gate, a bump also stops new chat tokens for everyone until they agree (their next chat sign-in lands on that page; a session already open runs to its natural expiry unless `BackchannelLogoutUri` is set, when the 30 s reconciler ends it). Nobody is emailed or prompted elsewhere; asking is the page's job, and silence means no.
|
||||
3. **Withdrawal had no CSRF check.** True. The session cookie's SameSite default is Lax, which already keeps a cross-site
|
||||
POST from carrying it, but nothing in the handler relied on that. Both state changes (agree, withdraw) are now
|
||||
form POSTs refused with 403 unless they came from the site itself (`RequestOriginGuard`, the strict form), before
|
||||
anything is read or written.
|
||||
4. **Provider-created and existing accounts have no receipt.** Correct, and now stated as the rule: no receipt, no use.
|
||||
Since the 2026-11-11 ruling it is also: no receipt, no chat token. They keep their account, the members page and their
|
||||
votes, and learn it where it bites: the refused chat sign-in lands them on the consent page (`oidc-issuer.md`, Chat needs a
|
||||
receipt). Nobody is emailed or prompted elsewhere.
|
||||
5. **No export exists.** True. Every promise about use is a promise about code not written. The notice must not be
|
||||
treated as operative for real people until the export enforces the contract with a test that a withdrawn or
|
||||
never-agreed account's rows are absent (`registration-gates.md`, Consent receipt, Consumer). Confirm (Before deploy) that no
|
||||
deploy has put an earlier version 1 in front of anyone.
|
||||
|
||||
## Consent is optional
|
||||
|
||||
Registration succeeds with or without agreement. A receipt exists only if the person agreed. The account is not
|
||||
conditioned on agreement, which removes the objection that agreement obtained as a condition of an account is not a free
|
||||
choice; what agreement buys is speaking in a recorded room, and the form says so beside the checkbox. This is the
|
||||
steward's ruling on Eureka's recommendation, and Meta's ruling of 2026-11-11 added the chat gate. One consequence: the
|
||||
registration form does not enforce the notice.
|
||||
|
||||
## Still open for Meta
|
||||
|
||||
- Purpose and scope: whether a dataset or model may leave GameCult, and whether a third-party model API processing
|
||||
messages to answer them is covered. The notice is silent on both.
|
||||
- Deletion of messages on request, who does it, and the contact words. The notice offers none.
|
||||
- Controller and contact, and minimum age. Nothing here addresses either.
|
||||
- Counsel's reading of consent under the law that applies to the people registering. Not researched here.
|
||||
|
||||
## What remains
|
||||
|
||||
- A signed-out native account cannot withdraw until sign-in recovery exists.
|
||||
- No training export, so no operative use.
|
||||
- Wording changes are a version bump with a new text and a new pinned hash in `TrainingNoticeFrozenTests`; never edit a version's text in place; the frozen-hash test fails if you do.
|
||||
63
docs/watchdog-notice.md
Normal file
63
docs/watchdog-notice.md
Normal 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.
|
||||
|
|
@ -7,11 +7,21 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MessagePack" Version="3.1.12" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
|
||||
<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.Validation.AspNetCore" Version="7.7.1" />
|
||||
<PackageReference Include="OpenIddict.Validation.ServerIntegration" Version="7.7.1" />
|
||||
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="7.7.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
53
src/Bifrost.Web/Configuration/ConceptVoteOptions.cs
Normal file
53
src/Bifrost.Web/Configuration/ConceptVoteOptions.cs
Normal 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";
|
||||
}
|
||||
68
src/Bifrost.Web/Configuration/DeskOptions.cs
Normal file
68
src/Bifrost.Web/Configuration/DeskOptions.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
namespace Bifrost.Web.Configuration;
|
||||
|
||||
// The fork desk's configuration (CultLib fork-card.md, Transport; M1 and M5). Minds is an ARRAY, never a map keyed
|
||||
// by Mind id: configuration keys are case-insensitive, so a keyed section would let GAMECULT-CHAT reach
|
||||
// gamecult-chat. Account ids are lowercase D-format Guids. An empty Minds list means the desk is not configured.
|
||||
public sealed class DeskOptions
|
||||
{
|
||||
public const string SectionName = "Desk";
|
||||
|
||||
public List<DeskMindOptions> Minds { get; init; } = [];
|
||||
|
||||
public DeskAttributionOptions Attribution { get; init; } = new();
|
||||
|
||||
// The one origin allowed to call /api/desk from a browser (exactly this, no credentials).
|
||||
public string AllowedOrigin { get; init; } = string.Empty;
|
||||
|
||||
// The cap on an answer body, in bytes, judged before it is parsed.
|
||||
public int MaxBodyBytes { get; init; } = 16 * 1024;
|
||||
|
||||
// What an `unavailable` document tells the client to wait.
|
||||
public int RetryAfterSeconds { get; init; } = 30;
|
||||
|
||||
public bool IsConfigured => Minds.Count > 0;
|
||||
}
|
||||
|
||||
public sealed class DeskMindOptions
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
// The full path of this Mind's socket on the peer-checked door. Bifrost only connects to it.
|
||||
public string Socket { get; init; } = string.Empty;
|
||||
|
||||
// Accounts that may answer this Mind's forks. Sent to Huginn with every call; no request sets or extends it.
|
||||
public List<string> Deciders { get; init; } = [];
|
||||
|
||||
// Huginn's bound on the list a call carries (fork-card.md: one to 64 ids).
|
||||
public const int MaxDeciders = 64;
|
||||
|
||||
// One to 64 ids, none repeated. The startup validator and the request path both ask this one question.
|
||||
public static bool DecidersWithinBounds(IReadOnlyCollection<string> deciders) =>
|
||||
deciders.Count is >= 1 and <= MaxDeciders && deciders.Distinct(StringComparer.Ordinal).Count() == deciders.Count;
|
||||
}
|
||||
|
||||
// The authority a stamped answer carries (fork-card.md: Operator, Steward or Member), from configuration because
|
||||
// Bifrost has no such roles: Operator is one account, Stewards a list, everyone else a Member.
|
||||
public sealed class DeskAttributionOptions
|
||||
{
|
||||
public string? Operator { get; init; }
|
||||
|
||||
public List<string> Stewards { get; init; } = [];
|
||||
|
||||
public string AuthorityOf(string accountId)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(Operator) && string.Equals(accountId, Operator, StringComparison.Ordinal))
|
||||
{
|
||||
return DeskAuthority.Operator;
|
||||
}
|
||||
|
||||
return Stewards.Contains(accountId, StringComparer.Ordinal) ? DeskAuthority.Steward : DeskAuthority.Member;
|
||||
}
|
||||
}
|
||||
|
||||
public static class DeskAuthority
|
||||
{
|
||||
public const string Operator = "Operator";
|
||||
public const string Steward = "Steward";
|
||||
public const string Member = "Member";
|
||||
}
|
||||
|
|
@ -14,6 +14,12 @@ public sealed class HeimdallOptions
|
|||
|
||||
public string PatreonTierTitle { get; init; } = "Inner Sanctum";
|
||||
|
||||
/// <summary>Deprecated: rides in the process environment. Use <see cref="AppSecretFile"/>; the file wins when both are set.</summary>
|
||||
public string AppSecret { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Path of a file holding the app secret (systemd <c>LoadCredential</c> style: <c>%d/heimdall-app-secret</c>).</summary>
|
||||
public string AppSecretFile { get; init; } = string.Empty;
|
||||
|
||||
public string PatronSupportIntakeSecret { get; init; } = string.Empty;
|
||||
|
||||
public bool EnablePatronSupportIntake { get; init; } = true;
|
||||
|
|
@ -24,15 +30,50 @@ 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 =>
|
||||
IsConfigured &&
|
||||
EnablePatronSupportIntake &&
|
||||
!string.IsNullOrWhiteSpace(PatronSupportIntakeSecret);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <see cref="AppSecret"/> once, when the options are built. A set <see cref="AppSecretFile"/> must be a
|
||||
/// readable, non-empty file, or startup fails; the message names the option and path, never a secret.
|
||||
/// </summary>
|
||||
public static void ResolveAppSecret(HeimdallOptions options, ILogger logger)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.AppSecret))
|
||||
{
|
||||
logger.LogWarning("Heimdall:AppSecret is deprecated: it rides in the process environment. Use Heimdall:AppSecretFile, which wins when both are set.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.AppSecretFile))
|
||||
{
|
||||
string secret;
|
||||
try
|
||||
{
|
||||
secret = File.ReadAllText(options.AppSecretFile).TrimEnd('\r', '\n');
|
||||
}
|
||||
catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Heimdall:AppSecretFile must name a readable file; '{options.AppSecretFile}' could not be read ({error.GetType().Name}).");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secret))
|
||||
{
|
||||
throw new InvalidOperationException($"Heimdall:AppSecretFile '{options.AppSecretFile}' is empty.");
|
||||
}
|
||||
|
||||
options.AppSecret = secret;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
72
src/Bifrost.Web/Configuration/OidcIssuerOptions.cs
Normal file
72
src/Bifrost.Web/Configuration/OidcIssuerOptions.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
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();
|
||||
|
||||
public OidcLimitsOptions Limits { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Optional path of the issuer's brake marker. While a file exists there, /connect/* answers 503 and nothing is issued;
|
||||
/// remove it to release. Checked on every request (one stat), so engaging and releasing need no restart.
|
||||
/// Unset: the issuer has no marker brake (removing this section and restarting is the other off switch).
|
||||
/// </summary>
|
||||
public string BrakePath { get; init; } = string.Empty;
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One budget on /connect/authorize, /connect/token and /connect/userinfo together, per source address in one fixed window (a refused caller gets 429 with Retry-After),
|
||||
/// and the bound on what the anonymous authorize endpoint can park. No per-client budget: the client id is public. The defaults suit one client (MAS) behind one proxy.
|
||||
/// </summary>
|
||||
public sealed class OidcLimitsOptions
|
||||
{
|
||||
public int PerAddressPermits { get; init; } = 30;
|
||||
|
||||
public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
public int MaxTrackedKeys { get; init; } = 10_000;
|
||||
|
||||
/// <summary>The most OpenIddict token rows (parked authorize requests, codes, access tokens) allowed to exist; a new authorize request beyond it is refused until the prune frees room.</summary>
|
||||
public int MaxTokenRows { get; init; } = 20_000;
|
||||
|
||||
/// <summary>How often expired and orphaned authorizations and tokens are deleted.</summary>
|
||||
public TimeSpan PruneInterval { get; init; } = TimeSpan.FromMinutes(5);
|
||||
}
|
||||
43
src/Bifrost.Web/Configuration/RegistrationOptions.cs
Normal file
43
src/Bifrost.Web/Configuration/RegistrationOptions.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
namespace Bifrost.Web.Configuration;
|
||||
|
||||
/// <summary>Gates on the anonymous native registration (section <c>Bifrost:Registration</c>).</summary>
|
||||
public sealed class RegistrationOptions
|
||||
{
|
||||
public const string SectionName = "Bifrost:Registration";
|
||||
|
||||
public const int MinDifficulty = 1;
|
||||
|
||||
public const int MaxDifficulty = 24;
|
||||
|
||||
public const int MinFallbackWaitSeconds = 5;
|
||||
|
||||
public const int MaxFallbackWaitSeconds = 300;
|
||||
|
||||
public RegistrationCaptchaOptions Captcha { get; init; } = new();
|
||||
|
||||
public RegistrationLimitsOptions Limits { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>One budget on GET /auth/bifrost/challenge and POST /auth/bifrost/register together, per source address in one fixed window.</summary>
|
||||
public sealed class RegistrationLimitsOptions
|
||||
{
|
||||
public int PerAddressPermits { get; init; } = 10;
|
||||
|
||||
public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
public int MaxTrackedKeys { get; init; } = 10_000;
|
||||
|
||||
/// <summary>Time-lock submissions (no script, no proof of work) per address per <see cref="FallbackWindow"/>; far tighter than <see cref="PerAddressPermits"/> because they cost the client nothing.</summary>
|
||||
public int FallbackPermits { get; init; } = 3;
|
||||
|
||||
public TimeSpan FallbackWindow { get; init; } = TimeSpan.FromHours(1);
|
||||
}
|
||||
|
||||
public sealed class RegistrationCaptchaOptions
|
||||
{
|
||||
/// <summary>Leading zero bits the SHA-256 of <c>challenge:nonce</c> must have. 18 is about 262k hashes, a second or two in a browser.</summary>
|
||||
public int Difficulty { get; init; } = 18;
|
||||
|
||||
/// <summary>Seconds after the challenge was issued before a <c>waited</c> (no proof of work) submission is accepted.</summary>
|
||||
public int FallbackWaitSeconds { get; init; } = 30;
|
||||
}
|
||||
|
|
@ -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>();
|
||||
|
|
@ -37,6 +41,15 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
|
||||
public DbSet<Vote> Votes => Set<Vote>();
|
||||
|
||||
public DbSet<SealedBallot> SealedBallots => Set<SealedBallot>();
|
||||
|
||||
public DbSet<ConsentReceipt> ConsentReceipts => Set<ConsentReceipt>();
|
||||
public DbSet<MindKey> MindKeys => Set<MindKey>();
|
||||
|
||||
public DbSet<MindNonce> MindNonces => Set<MindNonce>();
|
||||
|
||||
public DbSet<MindPersona> MindPersonas => Set<MindPersona>();
|
||||
|
||||
public DbSet<LedgerEntry> LedgerEntries => Set<LedgerEntry>();
|
||||
|
||||
public DbSet<PayoutProposalBatch> PayoutProposalBatches => Set<PayoutProposalBatch>();
|
||||
|
|
@ -63,6 +76,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 =>
|
||||
|
|
@ -267,6 +284,9 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
entity.Property(x => x.Category).HasConversion<string>();
|
||||
entity.Property(x => x.Status).HasConversion<string>();
|
||||
entity.Property(x => x.ApprovalThreshold).HasPrecision(6, 3);
|
||||
entity.Property(x => x.Kind).HasConversion<string>();
|
||||
entity.Property(x => x.RevealSeq).IsConcurrencyToken();
|
||||
entity.Property(x => x.Status).IsConcurrencyToken();
|
||||
entity.HasOne(x => x.Project)
|
||||
.WithMany(x => x.Motions)
|
||||
.HasForeignKey(x => x.ProjectId)
|
||||
|
|
@ -291,6 +311,44 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
|
|||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SealedBallot>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => new { x.MotionId, x.UserAccountId }).IsUnique();
|
||||
entity.HasOne(x => x.Motion)
|
||||
.WithMany(x => x.SealedBallots)
|
||||
.HasForeignKey(x => x.MotionId);
|
||||
entity.HasOne(x => x.UserAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.UserAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ConsentReceipt>(entity =>
|
||||
{
|
||||
// One live receipt per account and version; a withdrawn row stays as history and a later agreement is a new row.
|
||||
entity.HasIndex(x => new { x.UserAccountId, x.NoticeVersion }).IsUnique().HasFilter("\"WithdrawnAtUtc\" IS NULL");
|
||||
entity.HasOne(x => x.UserAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.UserAccountId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<MindKey>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => new { x.PersonaId, x.PublicKey }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<MindPersona>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => x.PersonaId).IsUnique();
|
||||
entity.HasIndex(x => x.UserAccountId).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<MindNonce>(entity =>
|
||||
{
|
||||
entity.HasIndex(x => new { x.PersonaId, x.Nonce }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<LedgerEntry>(entity =>
|
||||
{
|
||||
entity.Property(x => x.EntryType).HasConversion<string>();
|
||||
|
|
@ -523,5 +581,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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
2197
src/Bifrost.Web/Data/Migrations/20261010043937_AddConceptVotes.Designer.cs
generated
Normal file
2197
src/Bifrost.Web/Data/Migrations/20261010043937_AddConceptVotes.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
2441
src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.Designer.cs
generated
Normal file
2441
src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
166
src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.cs
Normal file
166
src/Bifrost.Web/Data/Migrations/20261022163159_AddOpenIddict.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
using System;
|
||||
using Bifrost.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bifrost.Web.Data.Migrations
|
||||
{
|
||||
// Hand-written: no dotnet-ef on the authoring host. The snapshot was edited to match; BuildTargetModel is left to the base no-op.
|
||||
[DbContext(typeof(BifrostDbContext))]
|
||||
[Migration("20261109200000_AddSealedBallotMotions")]
|
||||
public partial class AddSealedBallotMotions : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(name: "Candidates", table: "Motions", type: "text", nullable: false, defaultValue: "");
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(name: "CommitClosesAtUtc", table: "Motions", type: "timestamp with time zone", nullable: true);
|
||||
migrationBuilder.AddColumn<string>(name: "Kind", table: "Motions", type: "text", nullable: false, defaultValue: "Standard");
|
||||
migrationBuilder.AddColumn<Guid>(name: "PriorMotionId", table: "Motions", type: "uuid", nullable: true);
|
||||
migrationBuilder.AddColumn<string>(name: "ResultDigest", table: "Motions", type: "text", nullable: false, defaultValue: "");
|
||||
migrationBuilder.AddColumn<int>(name: "RevealSeq", table: "Motions", type: "integer", nullable: false, defaultValue: 0);
|
||||
migrationBuilder.AddColumn<int>(name: "Round", table: "Motions", type: "integer", nullable: false, defaultValue: 1);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SealedBallots",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MotionId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserAccountId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Commitment = table.Column<string>(type: "text", nullable: false),
|
||||
Revision = table.Column<int>(type: "integer", nullable: false),
|
||||
CommittedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
RevealedCandidate = table.Column<string>(type: "text", nullable: true),
|
||||
RevealedReason = table.Column<string>(type: "text", nullable: true),
|
||||
Salt = table.Column<string>(type: "text", nullable: true),
|
||||
RevealedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SealedBallots", x => x.Id);
|
||||
table.ForeignKey("FK_SealedBallots_Motions_MotionId", x => x.MotionId, "Motions", "Id", onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey("FK_SealedBallots_UserAccounts_UserAccountId", x => x.UserAccountId, "UserAccounts", "Id", onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(name: "IX_SealedBallots_MotionId_UserAccountId", table: "SealedBallots", columns: new[] { "MotionId", "UserAccountId" }, unique: true);
|
||||
migrationBuilder.CreateIndex(name: "IX_SealedBallots_UserAccountId", table: "SealedBallots", column: "UserAccountId");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(name: "SealedBallots");
|
||||
foreach (var column in new[] { "Candidates", "CommitClosesAtUtc", "Kind", "PriorMotionId", "ResultDigest", "RevealSeq", "Round" })
|
||||
{
|
||||
migrationBuilder.DropColumn(name: column, table: "Motions");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
using System;
|
||||
using Bifrost.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bifrost.Web.Data.Migrations
|
||||
{
|
||||
// Hand-written like AddSealedBallotMotions: no dotnet-ef on the authoring host; the snapshot was edited to match.
|
||||
[DbContext(typeof(BifrostDbContext))]
|
||||
[Migration("20261109210000_AddMindKeys")]
|
||||
public partial class AddMindKeys : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MindKeys",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
PersonaId = table.Column<string>(type: "text", nullable: false),
|
||||
UserAccountId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
PublicKey = table.Column<string>(type: "text", nullable: false),
|
||||
RegisteredByUserAccountId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
RegisteredAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
RevokedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
RevokedByUserAccountId = table.Column<Guid>(type: "uuid", nullable: true)
|
||||
},
|
||||
constraints: table => table.PrimaryKey("PK_MindKeys", x => x.Id));
|
||||
migrationBuilder.CreateIndex(name: "IX_MindKeys_PersonaId_PublicKey", table: "MindKeys", columns: new[] { "PersonaId", "PublicKey" }, unique: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MindPersonas",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
PersonaId = table.Column<string>(type: "text", nullable: false),
|
||||
UserAccountId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table => table.PrimaryKey("PK_MindPersonas", x => x.Id));
|
||||
migrationBuilder.CreateIndex(name: "IX_MindPersonas_PersonaId", table: "MindPersonas", column: "PersonaId", unique: true);
|
||||
migrationBuilder.CreateIndex(name: "IX_MindPersonas_UserAccountId", table: "MindPersonas", column: "UserAccountId", unique: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MindNonces",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
PersonaId = table.Column<string>(type: "text", nullable: false),
|
||||
Nonce = table.Column<string>(type: "text", nullable: false),
|
||||
SeenAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table => table.PrimaryKey("PK_MindNonces", x => x.Id));
|
||||
migrationBuilder.CreateIndex(name: "IX_MindNonces_PersonaId_Nonce", table: "MindNonces", columns: new[] { "PersonaId", "Nonce" }, unique: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(name: "MindNonces");
|
||||
migrationBuilder.DropTable(name: "MindPersonas");
|
||||
migrationBuilder.DropTable(name: "MindKeys");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
using System;
|
||||
using Bifrost.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bifrost.Web.Data.Migrations
|
||||
{
|
||||
// Hand-written like AddMindKeys: no dotnet-ef on the authoring host; the snapshot was edited to match.
|
||||
[DbContext(typeof(BifrostDbContext))]
|
||||
[Migration("20261111090000_AddConsentReceipts")]
|
||||
public partial class AddConsentReceipts : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ConsentReceipts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserAccountId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
NoticeVersion = table.Column<int>(type: "integer", nullable: false),
|
||||
AcceptedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
WithdrawnAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ConsentReceipts", x => x.Id);
|
||||
table.ForeignKey("FK_ConsentReceipts_UserAccounts_UserAccountId", x => x.UserAccountId, "UserAccounts", "Id", onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
migrationBuilder.CreateIndex(name: "IX_ConsentReceipts_UserAccountId_NoticeVersion", table: "ConsentReceipts", columns: new[] { "UserAccountId", "NoticeVersion" }, unique: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(name: "ConsentReceipts");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
using Bifrost.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bifrost.Web.Data.Migrations
|
||||
{
|
||||
// Hand-written like AddConsentReceipts: no dotnet-ef on the authoring host; the snapshot was edited to match.
|
||||
// A person may withdraw and agree again to the same version, so uniqueness narrows to the live receipt.
|
||||
[DbContext(typeof(BifrostDbContext))]
|
||||
[Migration("20261111100000_ConsentReceiptsOneLivePerVersion")]
|
||||
public partial class ConsentReceiptsOneLivePerVersion : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(name: "IX_ConsentReceipts_UserAccountId_NoticeVersion", table: "ConsentReceipts");
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ConsentReceipts_UserAccountId_NoticeVersion",
|
||||
table: "ConsentReceipts",
|
||||
columns: new[] { "UserAccountId", "NoticeVersion" },
|
||||
unique: true,
|
||||
filter: "\"WithdrawnAtUtc\" IS NULL");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(name: "IX_ConsentReceipts_UserAccountId_NoticeVersion", table: "ConsentReceipts");
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ConsentReceipts_UserAccountId_NoticeVersion",
|
||||
table: "ConsentReceipts",
|
||||
columns: new[] { "UserAccountId", "NoticeVersion" },
|
||||
unique: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
using Bifrost.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bifrost.Web.Data.Migrations
|
||||
{
|
||||
// Hand-written like ConsentReceiptsOneLivePerVersion: no dotnet-ef on the authoring host; the snapshot was edited to match.
|
||||
// Reasons already written to AuditEvent.Detail stay there as history; new ones live only in this admin-read column.
|
||||
[DbContext(typeof(BifrostDbContext))]
|
||||
[Migration("20261111110000_AddMembershipSuspensionReason")]
|
||||
public partial class AddMembershipSuspensionReason : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(name: "SuspensionReason", table: "Memberships", type: "text", nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(name: "SuspensionReason", table: "Memberships");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
using Bifrost.Web.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Bifrost.Web.Data.Migrations
|
||||
{
|
||||
// Hand-written like AddMembershipSuspensionReason: data only, so no schema change and the snapshot is untouched.
|
||||
// Notice v1 was reused for new words, so any receipt written against the earlier v1 text must not count as agreement
|
||||
// to these. Every row goes; people who agreed are asked again on their next chat sign-in.
|
||||
[DbContext(typeof(BifrostDbContext))]
|
||||
[Migration("20261111120000_ClearLegacyConsentReceipts")]
|
||||
public partial class ClearLegacyConsentReceipts : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("DELETE FROM \"ConsentReceipts\";");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Deleted agreements cannot be restored, and must not be.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
@ -739,6 +821,9 @@ namespace Bifrost.Web.Data.Migrations
|
|||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SuspensionReason")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
|
|
@ -814,15 +899,29 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.Property<DateTimeOffset>("ClosesAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Candidates")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("CommitClosesAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedByUserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("OpensAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("PriorMotionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
|
|
@ -833,11 +932,23 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.Property<DateTimeOffset?>("ResolvedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("RevealSeq")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ResultDigest")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Round")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Scope")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
|
|
@ -889,6 +1000,9 @@ namespace Bifrost.Web.Data.Migrations
|
|||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Provider")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
|
@ -908,9 +1022,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 +1033,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");
|
||||
|
|
@ -1332,6 +1443,163 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.ToTable("VolunteerClaims");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.ConsentReceipt", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("AcceptedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("NoticeVersion")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("UserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("WithdrawnAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserAccountId", "NoticeVersion")
|
||||
.IsUnique()
|
||||
.HasFilter("\"WithdrawnAtUtc\" IS NULL");
|
||||
|
||||
b.ToTable("ConsentReceipts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.MindKey", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PersonaId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PublicKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("RegisteredByUserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("RegisteredAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("RevokedByUserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PersonaId", "PublicKey")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MindKeys");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.MindPersona", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PersonaId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PersonaId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserAccountId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MindPersonas");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.MindNonce", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Nonce")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PersonaId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("SeenAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PersonaId", "Nonce")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MindNonces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.SealedBallot", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Commitment")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CommittedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("MotionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("RevealedCandidate")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RevealedReason")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevealedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Revision")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Salt")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("UserAccountId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserAccountId");
|
||||
|
||||
b.HasIndex("MotionId", "UserAccountId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SealedBallots");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.Vote", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
|
@ -1534,6 +1802,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 +2091,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")
|
||||
|
|
@ -1680,6 +2172,17 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.Navigation("WorkItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.ConsentReceipt", b =>
|
||||
{
|
||||
b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserAccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("UserAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.MemberProfile", b =>
|
||||
{
|
||||
b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount")
|
||||
|
|
@ -1903,6 +2406,25 @@ namespace Bifrost.Web.Data.Migrations
|
|||
b.Navigation("WorkItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.SealedBallot", b =>
|
||||
{
|
||||
b.HasOne("Bifrost.Web.Domain.Motion", "Motion")
|
||||
.WithMany("SealedBallots")
|
||||
.HasForeignKey("MotionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Bifrost.Web.Domain.UserAccount", "UserAccount")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Motion");
|
||||
|
||||
b.Navigation("UserAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.Vote", b =>
|
||||
{
|
||||
b.HasOne("Bifrost.Web.Domain.Motion", "Motion")
|
||||
|
|
@ -1992,6 +2514,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");
|
||||
|
|
@ -2001,6 +2547,8 @@ namespace Bifrost.Web.Data.Migrations
|
|||
|
||||
modelBuilder.Entity("Bifrost.Web.Domain.Motion", b =>
|
||||
{
|
||||
b.Navigation("SealedBallots");
|
||||
|
||||
b.Navigation("Votes");
|
||||
});
|
||||
|
||||
|
|
@ -2090,6 +2638,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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -334,6 +334,9 @@ public sealed class Membership
|
|||
|
||||
public MembershipStatus Status { get; set; } = MembershipStatus.Authenticated;
|
||||
|
||||
/// <summary>Why an admin suspended the account; set and cleared only by <c>MembershipSuspensionService</c> and projected only to admins.</summary>
|
||||
public string? SuspensionReason { get; set; }
|
||||
|
||||
public bool IsPlatformAdmin { get; set; }
|
||||
|
||||
public bool CanManageProjects { get; set; }
|
||||
|
|
@ -363,7 +366,8 @@ public sealed class Membership
|
|||
|
||||
public sealed class RoleAssignment
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
// Left empty until saved: a key preset on a row added to an already-saved membership makes EF treat it as existing and fail the save.
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid MembershipId { get; set; }
|
||||
|
||||
|
|
@ -692,11 +696,68 @@ public sealed class Motion
|
|||
|
||||
public string ResolutionNote { get; set; } = string.Empty;
|
||||
|
||||
public MotionKind Kind { get; set; } = MotionKind.Standard;
|
||||
|
||||
// Sealed-ballot motions only: commitments are accepted until this instant, reveals from then until ClosesAtUtc.
|
||||
public DateTimeOffset? CommitClosesAtUtc { get; set; }
|
||||
|
||||
// Sealed-ballot motions only: candidate names, one per line.
|
||||
public string Candidates { get; set; } = string.Empty;
|
||||
|
||||
// Sealed-ballot motions only: Delphi round number and the motion whose result this round answers.
|
||||
public int Round { get; set; } = 1;
|
||||
|
||||
public Guid? PriorMotionId { get; set; }
|
||||
|
||||
// Sealed-ballot motions only: bumped by every accepted reveal. A concurrency token, so a reveal and the close cannot both
|
||||
// succeed on the same row version: the close freezes exactly the reveals it counted.
|
||||
public int RevealSeq { get; set; }
|
||||
|
||||
// Sealed-ballot motions only: SHA-256 over the sorted commitments, written once at finalization.
|
||||
public string ResultDigest { get; set; } = string.Empty;
|
||||
|
||||
public Project? Project { get; set; }
|
||||
|
||||
public UserAccount CreatedByUserAccount { get; set; } = null!;
|
||||
|
||||
public ICollection<Vote> Votes { get; set; } = [];
|
||||
|
||||
public ICollection<SealedBallot> SealedBallots { get; set; } = [];
|
||||
}
|
||||
|
||||
public enum MotionKind
|
||||
{
|
||||
Standard,
|
||||
SealedBallot
|
||||
}
|
||||
|
||||
public sealed class SealedBallot
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid MotionId { get; set; }
|
||||
|
||||
public Guid UserAccountId { get; set; }
|
||||
|
||||
// Hex SHA-256 of the canonical ballot document; the only ballot-derived value stored before the reveal step.
|
||||
public string Commitment { get; set; } = string.Empty;
|
||||
|
||||
public int Revision { get; set; } = 1;
|
||||
|
||||
public DateTimeOffset CommittedAtUtc { get; set; }
|
||||
|
||||
// Reveal step. Held unread by every code path until the motion is Closed.
|
||||
public string? RevealedCandidate { get; set; }
|
||||
|
||||
public string? RevealedReason { get; set; }
|
||||
|
||||
public string? Salt { get; set; }
|
||||
|
||||
public DateTimeOffset? RevealedAtUtc { get; set; }
|
||||
|
||||
public Motion Motion { get; set; } = null!;
|
||||
|
||||
public UserAccount UserAccount { get; set; } = null!;
|
||||
}
|
||||
|
||||
public sealed class Vote
|
||||
|
|
@ -1096,3 +1157,66 @@ public sealed class AuditEvent
|
|||
|
||||
public UserAccount? ActorUserAccount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An account's consent to the training notice, recorded with the notice version it accepted. Withdrawal sets
|
||||
/// <see cref="WithdrawnAtUtc"/>; the row is never deleted, so what was agreed and when stays answerable.
|
||||
/// </summary>
|
||||
public sealed class ConsentReceipt
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid UserAccountId { get; set; }
|
||||
|
||||
public int NoticeVersion { get; set; }
|
||||
|
||||
public DateTimeOffset AcceptedAtUtc { get; set; }
|
||||
|
||||
public DateTimeOffset? WithdrawnAtUtc { get; set; }
|
||||
|
||||
public UserAccount? UserAccount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A Mind's registered Ed25519 public key. Bifrost never holds a private key.</summary>
|
||||
public sealed class MindKey
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public string PersonaId { get; set; } = string.Empty;
|
||||
|
||||
// The member account this persona votes as; fixed by the first registration for the persona.
|
||||
public Guid UserAccountId { get; set; }
|
||||
|
||||
// Base64 of the 32-byte public key.
|
||||
public string PublicKey { get; set; } = string.Empty;
|
||||
|
||||
public Guid RegisteredByUserAccountId { get; set; }
|
||||
|
||||
public DateTimeOffset RegisteredAtUtc { get; set; }
|
||||
|
||||
public DateTimeOffset? RevokedAtUtc { get; set; }
|
||||
|
||||
public Guid? RevokedByUserAccountId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A nonce already spent by a persona; a verified envelope with a seen nonce is refused as a replay.</summary>
|
||||
public sealed class MindNonce
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public string PersonaId { get; set; } = string.Empty;
|
||||
|
||||
public string Nonce { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset SeenAtUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The one member account a persona votes as. Unique on both columns: a persona has one account and an account one persona.</summary>
|
||||
public sealed class MindPersona
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public string PersonaId { get; set; } = string.Empty;
|
||||
|
||||
public Guid UserAccountId { get; set; }
|
||||
}
|
||||
|
|
|
|||
50
src/Bifrost.Web/Domain/ConceptVoteModels.cs
Normal file
50
src/Bifrost.Web/Domain/ConceptVoteModels.cs
Normal 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; }
|
||||
}
|
||||
348
src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs
Normal file
348
src/Bifrost.Web/Features/ConceptVotes/ConceptAppraisalService.cs
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
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 enum RankOrder
|
||||
{
|
||||
Net,
|
||||
Total,
|
||||
Newest
|
||||
}
|
||||
|
||||
// One row of the ranked read: counts and the subject's title only, never a voter key, account id or network tag.
|
||||
public sealed record RankedSubject(string Key, string Title, ConceptTally Tally);
|
||||
|
||||
public sealed record RankedPage(IReadOnlyList<RankedSubject> Items, string? NextCursor);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// The ranked read: live subjects of one collection ordered by net (up minus down), total (up plus down) or
|
||||
// registration time, largest first, ties by key ascending, so the order is total and a cursor names a position.
|
||||
// The cursor is keyset: the next page is whatever sorts after the last row's (value, key), so a vote landing
|
||||
// between pages can move rows but never makes a page repeat the cursor's own row. With unvoted and a client
|
||||
// address, subjects whose stored tag equals the client's are left out (no address filters nothing) ("not yet voted by this network"); the tag is only compared
|
||||
// inside the query and is never returned. Null when the collection has no live subject.
|
||||
public async Task<RankedPage?> GetRankedAsync(
|
||||
string collection,
|
||||
RankOrder order,
|
||||
int limit,
|
||||
RankCursor? after,
|
||||
bool unvoted,
|
||||
IPAddress? client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var live = await dbContext.ConceptSubjects.AsNoTracking()
|
||||
.Where(x => x.Collection == collection && x.RetiredAtUtc == null)
|
||||
.Select(x => new { x.Id, x.Key, x.Title, x.RegisteredAtUtc })
|
||||
.ToListAsync(cancellationToken);
|
||||
if (live.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
var voted = new HashSet<Guid>();
|
||||
if (unvoted && client is not null)
|
||||
{
|
||||
var opts = options.Value;
|
||||
var tag = NetworkPrefix.Tag(client, opts.IPv4PrefixLength, opts.IPv6PrefixLength, opts.NetworkTagSecret);
|
||||
voted = (await dbContext.ConceptAppraisals.AsNoTracking()
|
||||
.Where(x => ids.Contains(x.SubjectId) && x.NetworkTag == tag)
|
||||
.Select(x => x.SubjectId)
|
||||
.ToListAsync(cancellationToken)).ToHashSet();
|
||||
}
|
||||
|
||||
var rows = live
|
||||
.Where(s => !voted.Contains(s.Id))
|
||||
.Select(s =>
|
||||
{
|
||||
var tally = Tally(groups.Where(g => g.SubjectId == s.Id).Select(g => (g.Stance, g.IsMember, g.Count)));
|
||||
var value = order switch
|
||||
{
|
||||
RankOrder.Net => (long)tally.Up - tally.Down,
|
||||
RankOrder.Total => (long)tally.Up + tally.Down,
|
||||
_ => s.RegisteredAtUtc.UtcTicks
|
||||
};
|
||||
return (Value: value, s.Key, s.Title, Tally: tally);
|
||||
})
|
||||
.Where(r => after is null || r.Value < after.Value || (r.Value == after.Value && string.CompareOrdinal(r.Key, after.Key) > 0))
|
||||
.OrderByDescending(r => r.Value)
|
||||
.ThenBy(r => r.Key, StringComparer.Ordinal)
|
||||
.Take(limit + 1)
|
||||
.ToList();
|
||||
|
||||
var page = rows.Take(limit).ToList();
|
||||
var next = rows.Count > limit
|
||||
? new RankCursor(order, unvoted, page[^1].Value, page[^1].Key).Encode()
|
||||
: null;
|
||||
return new RankedPage(page.Select(r => new RankedSubject(r.Key, r.Title, r.Tally)).ToList(), next);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
70
src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs
Normal file
70
src/Bifrost.Web/Features/ConceptVotes/ConceptCommand.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/Bifrost.Web/Features/ConceptVotes/ConceptKey.cs
Normal file
13
src/Bifrost.Web/Features/ConceptVotes/ConceptKey.cs
Normal 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);
|
||||
}
|
||||
352
src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs
Normal file
352
src/Bifrost.Web/Features/ConceptVotes/ConceptSubjectRegistry.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
153
src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs
Normal file
153
src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
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);
|
||||
|
||||
public const int DefaultPageSize = 50;
|
||||
public const int MaxPageSize = 100;
|
||||
|
||||
// The ranked, paged read beside the map: GET /public/concepts/{collection}/ranked
|
||||
// ?sort=net|total|newest (default net) &limit=1..100 (default 50) &cursor=<opaque> &unvoted=network
|
||||
// returns { items: [{ key, title, up, down, members: { up, down } }], next_cursor }. The query is a closed
|
||||
// shape: an unknown or repeated key, an out-of-range value or a cursor that is not exactly one this route issued
|
||||
// for the same sort and filter is a 400. unvoted=network leaves out the concepts the caller's own network tag
|
||||
// has already voted on; the tag is derived from the connection and compared inside the query, never accepted
|
||||
// from the request and never returned. That answer depends on the caller, so it is never cached.
|
||||
public static IEndpointConventionBuilder MapConceptRankedExport(this IEndpointRouteBuilder app) =>
|
||||
app.MapGet(RoutePrefix + "/{collection}/ranked", async (
|
||||
string collection,
|
||||
HttpContext http,
|
||||
ConceptAppraisalService appraisals,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!ConceptKey.IsValid(collection))
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (!TryReadRankedQuery(http.Request.Query, out var order, out var limit, out var cursor, out var unvoted))
|
||||
{
|
||||
return JsonText(new { error = "request_invalid" }, StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
var page = await appraisals.GetRankedAsync(
|
||||
collection, order, limit, cursor, unvoted, http.Connection.RemoteIpAddress, cancellationToken);
|
||||
if (page is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
http.Response.Headers.CacheControl = unvoted ? "private, no-store" : "public, max-age=60";
|
||||
return JsonText(new
|
||||
{
|
||||
items = page.Items.Select(i => new
|
||||
{
|
||||
key = i.Key,
|
||||
title = i.Title,
|
||||
up = i.Tally.Up,
|
||||
down = i.Tally.Down,
|
||||
members = new { up = i.Tally.Members.Up, down = i.Tally.Members.Down }
|
||||
}),
|
||||
next_cursor = page.NextCursor
|
||||
}, StatusCodes.Status200OK);
|
||||
}).AllowAnonymous().RequireCors(CorsPolicy);
|
||||
|
||||
private static IResult JsonText(object body, int status) =>
|
||||
Results.Text(System.Text.Json.JsonSerializer.Serialize(body), "application/json", statusCode: status);
|
||||
|
||||
internal static bool TryReadRankedQuery(
|
||||
IQueryCollection query, out RankOrder order, out int limit, out RankCursor? cursor, out bool unvoted)
|
||||
{
|
||||
order = RankOrder.Net;
|
||||
limit = DefaultPageSize;
|
||||
cursor = null;
|
||||
unvoted = false;
|
||||
foreach (var (key, values) in query)
|
||||
{
|
||||
if (values.Count != 1 || values[0] is not { } value)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (key)
|
||||
{
|
||||
case "sort" when value == "net": order = RankOrder.Net; break;
|
||||
case "sort" when value == "total": order = RankOrder.Total; break;
|
||||
case "sort" when value == "newest": order = RankOrder.Newest; break;
|
||||
case "limit" when int.TryParse(value, System.Globalization.NumberStyles.None,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var n) &&
|
||||
n is >= 1 and <= MaxPageSize && value == n.ToString(System.Globalization.CultureInfo.InvariantCulture):
|
||||
limit = n;
|
||||
break;
|
||||
case "cursor" when RankCursor.TryDecode(value, out var decoded): cursor = decoded; break;
|
||||
case "unvoted" when value == "network": unvoted = true; break;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
return cursor is null || (cursor.Order == order && cursor.Unvoted == unvoted);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
45
src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs
Normal file
45
src/Bifrost.Web/Features/ConceptVotes/NetworkPrefix.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
59
src/Bifrost.Web/Features/ConceptVotes/RankCursor.cs
Normal file
59
src/Bifrost.Web/Features/ConceptVotes/RankCursor.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
|
||||
namespace Bifrost.Web.Features.ConceptVotes;
|
||||
|
||||
// The opaque cursor of the ranked read: the last row's sort value and key, plus the order and filter it was issued
|
||||
// for, so a cursor cannot be replayed under another order or filter. Text "1|order|u|value|key", base64url without
|
||||
// padding; the length cap only bounds decoding work. Not signed: it names a position in public data and grants nothing. Decoding is strict and canonical (a
|
||||
// cursor that does not re-encode to the same text is refused), and every field is re-validated, so no cursor text
|
||||
// reaches a query unparsed.
|
||||
public sealed record RankCursor(RankOrder Order, bool Unvoted, long Value, string Key)
|
||||
{
|
||||
public const int MaxLength = 200;
|
||||
|
||||
public string Encode() =>
|
||||
WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(
|
||||
string.Create(CultureInfo.InvariantCulture, $"1|{(int)Order}|{(Unvoted ? 1 : 0)}|{Value}|{Key}")));
|
||||
|
||||
public static bool TryDecode(string? text, out RankCursor cursor)
|
||||
{
|
||||
cursor = null!;
|
||||
if (string.IsNullOrEmpty(text) || text.Length > MaxLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string plain;
|
||||
try
|
||||
{
|
||||
var bytes = WebEncoders.Base64UrlDecode(text);
|
||||
plain = new UTF8Encoding(false, true).GetString(bytes);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or ArgumentException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = plain.Split('|');
|
||||
if (parts.Length != 5 || parts[0] != "1" ||
|
||||
!int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var order) ||
|
||||
!Enum.IsDefined((RankOrder)order) ||
|
||||
parts[2] is not ("0" or "1") ||
|
||||
!long.TryParse(parts[3], NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var value) ||
|
||||
!ConceptKey.IsValid(parts[4]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = new RankCursor((RankOrder)order, parts[2] == "1", value, parts[4]);
|
||||
if (candidate.Encode() != text)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
cursor = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
28
src/Bifrost.Web/Features/ConceptVotes/VoterIdentity.cs
Normal file
28
src/Bifrost.Web/Features/ConceptVotes/VoterIdentity.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
56
src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs
Normal file
56
src/Bifrost.Web/Features/ConceptVotes/VoterKeyResolver.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
using System.Security.Cryptography;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
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 = BifrostCookies.Voter;
|
||||
public const string CookiePath = BifrostCookies.Path;
|
||||
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;
|
||||
}
|
||||
}
|
||||
270
src/Bifrost.Web/Features/Desk/DeskBodyShape.cs
Normal file
270
src/Bifrost.Web/Features/Desk/DeskBodyShape.cs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Bifrost.Web.Features.Desk;
|
||||
|
||||
// The edge's judgment of an answer body (fork-card.md, answer_request, steps 1 and 2). It checks the shape only:
|
||||
// whether the answer is legal (a retraction's target, the choice, the fork) is Huginn's. A body that passes is
|
||||
// forwarded as the bytes received; the parsed request is returned for the door's context and for tests.
|
||||
public sealed record DeskBodyVerdict(AnswerRequest? Request, string? RefusalCode)
|
||||
{
|
||||
public static DeskBodyVerdict Pass(AnswerRequest request) => new(request, null);
|
||||
|
||||
public static DeskBodyVerdict Refuse(string code) => new(null, code);
|
||||
}
|
||||
|
||||
public static partial class DeskBodyShape
|
||||
{
|
||||
public const int MaxNoteBytes = 4000;
|
||||
|
||||
// Two keys the receiver alone stamps. Refused whole at any depth, never stripped.
|
||||
private static readonly string[] ForbiddenKeys = ["answerer", "authority"];
|
||||
|
||||
private static readonly string[] TopKeys =
|
||||
["schemaVersion", "fork_id", "options_hash", "answer", "retracted", "retracts", "answered_at", "request_id"];
|
||||
|
||||
private static readonly string[] AnswerKeys = ["choice", "note"];
|
||||
|
||||
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
|
||||
|
||||
private static readonly JsonDocumentOptions ParseOptions = new() { MaxDepth = 32 };
|
||||
|
||||
public static DeskBodyVerdict Judge(ReadOnlySpan<byte> body, int maxBodyBytes)
|
||||
{
|
||||
if (body.Length > maxBodyBytes)
|
||||
{
|
||||
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
|
||||
}
|
||||
|
||||
JsonDocument document;
|
||||
try
|
||||
{
|
||||
document = JsonDocument.Parse(body.ToArray(), ParseOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
|
||||
}
|
||||
|
||||
using (document)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JudgeParsed(document.RootElement);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// A string the parser accepted but cannot decode: invalid UTF-8 or a lone surrogate.
|
||||
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static DeskBodyVerdict JudgeParsed(JsonElement root)
|
||||
{
|
||||
if (HasForbiddenKey(root))
|
||||
{
|
||||
return DeskBodyVerdict.Refuse(DeskRefusals.BodyForbiddenField);
|
||||
}
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
|
||||
}
|
||||
|
||||
var top = Members(root);
|
||||
if (top is null)
|
||||
{
|
||||
return DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
|
||||
}
|
||||
|
||||
// Unknown keys, including inside the answer object, before any value is judged.
|
||||
if (top.Keys.Any(key => !TopKeys.Contains(key, StringComparer.Ordinal)))
|
||||
{
|
||||
return DeskBodyVerdict.Refuse(DeskRefusals.BodyUnknownField);
|
||||
}
|
||||
|
||||
Dictionary<string, JsonElement>? answerMembers = null;
|
||||
if (top.TryGetValue("answer", out var answerElement) && answerElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
answerMembers = Members(answerElement);
|
||||
if (answerMembers is not null &&
|
||||
answerMembers.Keys.Any(key => !AnswerKeys.Contains(key, StringComparer.Ordinal)))
|
||||
{
|
||||
return DeskBodyVerdict.Refuse(DeskRefusals.BodyUnknownField);
|
||||
}
|
||||
}
|
||||
|
||||
return Shape(top, answerMembers);
|
||||
}
|
||||
|
||||
private static DeskBodyVerdict Shape(Dictionary<string, JsonElement> top, Dictionary<string, JsonElement>? answerMembers)
|
||||
{
|
||||
var invalid = DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid);
|
||||
|
||||
// Map-mode: every key is written.
|
||||
if (TopKeys.Any(key => !top.ContainsKey(key)))
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
if (!IsString(top["schemaVersion"], out var schemaVersion) || schemaVersion != DeskSchemas.AnswerRequest ||
|
||||
!IsString(top["fork_id"], out var forkId) || forkId.Length == 0 ||
|
||||
!IsString(top["options_hash"], out var optionsHash) || !Hex64().IsMatch(optionsHash) ||
|
||||
!IsString(top["answered_at"], out var answeredAt) || !Instant().IsMatch(answeredAt) ||
|
||||
!IsString(top["request_id"], out var requestId) || !RequestId().IsMatch(requestId))
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
var retractedElement = top["retracted"];
|
||||
if (retractedElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
var retracted = retractedElement.ValueKind == JsonValueKind.True;
|
||||
|
||||
string? retracts = null;
|
||||
if (top["retracts"].ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
if (!IsString(top["retracts"], out var target) || !AnswerId().IsMatch(target))
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
retracts = target;
|
||||
}
|
||||
|
||||
AnswerObject? answer = null;
|
||||
if (top["answer"].ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
if (answerMembers is null || AnswerKeys.Any(key => !answerMembers.ContainsKey(key)))
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
string? choice = null;
|
||||
if (answerMembers["choice"].ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
if (!IsString(answerMembers["choice"], out var picked) || picked.Length == 0)
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
choice = picked;
|
||||
}
|
||||
|
||||
string? note = null;
|
||||
if (answerMembers["note"].ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
if (!IsString(answerMembers["note"], out var words) || !IsWords(words))
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
note = words;
|
||||
}
|
||||
|
||||
if (choice is null && note is null)
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
answer = new AnswerObject(choice, note);
|
||||
}
|
||||
|
||||
// No answer and no retraction is no request. The illegal retraction forms (a target-less retraction, a
|
||||
// retraction carrying an answer, a target on a plain answer) pass here: Huginn stores them as bad_retraction.
|
||||
if (answer is null && !retracted)
|
||||
{
|
||||
return invalid;
|
||||
}
|
||||
|
||||
return DeskBodyVerdict.Pass(new AnswerRequest(
|
||||
schemaVersion, forkId, optionsHash, answer, retracted, retracts, answeredAt, requestId));
|
||||
}
|
||||
|
||||
// A note is 1 to 4000 UTF-8 bytes and not whitespace only. Bytes, not characters or UTF-16 units.
|
||||
private static bool IsWords(string value)
|
||||
{
|
||||
int bytes;
|
||||
try
|
||||
{
|
||||
bytes = StrictUtf8.GetByteCount(value);
|
||||
}
|
||||
catch (EncoderFallbackException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return bytes is >= 1 and <= MaxNoteBytes && !value.All(IsSchemaSpace);
|
||||
}
|
||||
|
||||
// The whitespace set of the schema's `words` pattern.
|
||||
private static bool IsSchemaSpace(char c) =>
|
||||
c is (>= (char)0x09 and <= (char)0x0d) or (char)0x20 or (char)0x85 or (char)0xa0 or (char)0x1680
|
||||
or (>= (char)0x2000 and <= (char)0x200a) or (char)0x2028 or (char)0x2029 or (char)0x202f or (char)0x205f or (char)0x3000;
|
||||
|
||||
private static bool IsString(JsonElement element, out string value)
|
||||
{
|
||||
value = string.Empty;
|
||||
if (element.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = element.GetString() ?? string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Object members by unescaped name; null when a name repeats, because the forwarded bytes would then mean
|
||||
// different things to different parsers.
|
||||
private static Dictionary<string, JsonElement>? Members(JsonElement element)
|
||||
{
|
||||
var members = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (!members.TryAdd(property.Name, property.Value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return members;
|
||||
}
|
||||
|
||||
private static bool HasForbiddenKey(JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (ForbiddenKeys.Contains(property.Name, StringComparer.Ordinal) || HasForbiddenKey(property.Value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
case JsonValueKind.Array:
|
||||
return element.EnumerateArray().Any(HasForbiddenKey);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[0-9a-f]{64}\z")]
|
||||
private static partial Regex Hex64();
|
||||
|
||||
[GeneratedRegex(@"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,7})?Z\z")]
|
||||
private static partial Regex Instant();
|
||||
|
||||
[GeneratedRegex(@"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z")]
|
||||
private static partial Regex RequestId();
|
||||
|
||||
[GeneratedRegex(@"^n[1-9][0-9]*\z")]
|
||||
private static partial Regex AnswerId();
|
||||
}
|
||||
166
src/Bifrost.Web/Features/Desk/DeskDocuments.cs
Normal file
166
src/Bifrost.Web/Features/Desk/DeskDocuments.cs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Bifrost.Web.Features.Desk;
|
||||
|
||||
// The fork-desk documents Bifrost reads or writes, spelled as CultLib's contracts/cultnet/governance/fork-card.md
|
||||
// (commit d3025d3c) spells them: snake_case keys, every key written (nil where absent), declared in the
|
||||
// contract's key order. Bifrost authors only DeskRefusal and DeskUnavailable. AnswerRequest is the client's body;
|
||||
// DeskAnswerDocument, ForkList and AnswerResult are Huginn's documents, typed here so they pass through unchanged.
|
||||
// The card (gamecult.fork.card.v0) is deliberately NOT typed: the Huginn door owns what a reader's card holds (the
|
||||
// decider view carries ids, the non-decider view withholds them and keeps decider_count), and Bifrost neither reads
|
||||
// nor edits it, so it is an opaque JsonElement that keeps every key, key order and integer. The door refuses a float,
|
||||
// a binary or an extension value anywhere in a reply (the contract has none), so no number is ever rewritten.
|
||||
|
||||
public static class DeskSchemas
|
||||
{
|
||||
public const string AnswerRequest = "gamecult.fork.answer_request.v0";
|
||||
public const string AnswerResult = "gamecult.fork.answer_result.v0";
|
||||
public const string Card = "gamecult.fork.card.v0";
|
||||
public const string DeskAnswer = "gamecult.desk_answer.v0";
|
||||
public const string Refusal = "gamecult.fork.refusal.v0";
|
||||
public const string Unavailable = "gamecult.fork.unavailable.v0";
|
||||
}
|
||||
|
||||
public static class DeskJson
|
||||
{
|
||||
// Defaults write nulls, which map-mode needs ("every key written").
|
||||
public static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
// The documents are not HTML: keep apostrophes and non-ASCII text as written.
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record AnswerObject(
|
||||
[property: JsonPropertyName("choice")] string? Choice,
|
||||
[property: JsonPropertyName("note")] string? Note);
|
||||
|
||||
public sealed record AnswerRequest(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("fork_id")] string ForkId,
|
||||
[property: JsonPropertyName("options_hash")] string OptionsHash,
|
||||
[property: JsonPropertyName("answer")] AnswerObject? Answer,
|
||||
[property: JsonPropertyName("retracted")] bool Retracted,
|
||||
[property: JsonPropertyName("retracts")] string? Retracts,
|
||||
[property: JsonPropertyName("answered_at")] string AnsweredAt,
|
||||
[property: JsonPropertyName("request_id")] string RequestId);
|
||||
|
||||
public sealed record DeskRefusal(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("code")] string Code,
|
||||
[property: JsonPropertyName("error")] string Error);
|
||||
|
||||
public sealed record DeskUnavailable(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("code")] string Code,
|
||||
[property: JsonPropertyName("error")] string Error,
|
||||
[property: JsonPropertyName("retryAfterSeconds")] int? RetryAfterSeconds);
|
||||
|
||||
// One stored answer: Counted, Revision, Retraction or Refused, told apart by Outcome and Code.
|
||||
public sealed record DeskAnswerDocument(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("answer_id")] string AnswerId,
|
||||
[property: JsonPropertyName("fork_id")] string ForkId,
|
||||
[property: JsonPropertyName("outcome")] string Outcome,
|
||||
[property: JsonPropertyName("of")] string? Of,
|
||||
[property: JsonPropertyName("retracts")] string? Retracts,
|
||||
[property: JsonPropertyName("code")] string? Code,
|
||||
[property: JsonPropertyName("options_hash")] string OptionsHash,
|
||||
[property: JsonPropertyName("choice")] string? Choice,
|
||||
[property: JsonPropertyName("note")] string? Note,
|
||||
[property: JsonPropertyName("answered_at")] string AnsweredAt,
|
||||
[property: JsonPropertyName("received_at")] string ReceivedAt,
|
||||
[property: JsonPropertyName("claim_in_future")] bool ClaimInFuture,
|
||||
// Nil in a non-decider's view (Huginn withholds the ids). The key is required, and the door's wire walker (Closed in
|
||||
// UnixSocketDeskDoor) is the one owner of that: a missing key is a door fault, never a nil Bifrost invented.
|
||||
[property: JsonPropertyName("answerer")] string? Answerer,
|
||||
[property: JsonPropertyName("authority")] string Authority,
|
||||
[property: JsonPropertyName("request_id")] string RequestId);
|
||||
|
||||
public sealed record ForkRecord(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("fork_id")] string ForkId,
|
||||
[property: JsonPropertyName("options_hash")] string OptionsHash,
|
||||
[property: JsonPropertyName("counted_answer_id")] string? CountedAnswerId,
|
||||
[property: JsonPropertyName("answerer_count")] int AnswererCount,
|
||||
[property: JsonPropertyName("answers")] DeskAnswerDocument[] Answers);
|
||||
|
||||
public sealed record ForkEntry(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("card")] JsonElement Card,
|
||||
[property: JsonPropertyName("record")] ForkRecord Record);
|
||||
|
||||
public sealed record ForkList(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("mind")] string Mind,
|
||||
[property: JsonPropertyName("stamp")] string Stamp,
|
||||
[property: JsonPropertyName("open_count")] int OpenCount,
|
||||
[property: JsonPropertyName("recorded_count")] int RecordedCount,
|
||||
[property: JsonPropertyName("forks")] ForkEntry[] Forks,
|
||||
[property: JsonPropertyName("next_cursor")] string? NextCursor);
|
||||
|
||||
public sealed record AnswerResult(
|
||||
[property: JsonPropertyName("schemaVersion")] string SchemaVersion,
|
||||
[property: JsonPropertyName("answer")] DeskAnswerDocument Answer,
|
||||
[property: JsonPropertyName("card")] JsonElement? Card,
|
||||
[property: JsonPropertyName("counted_answer_id")] string? CountedAnswerId);
|
||||
|
||||
// The refusal codes Bifrost itself writes, each with its one status and message (vectors doorRefusals).
|
||||
public static class DeskRefusals
|
||||
{
|
||||
public const string Unauthenticated = "unauthenticated";
|
||||
public const string BodyForbiddenField = "body_forbidden_field";
|
||||
public const string BodyUnknownField = "body_unknown_field";
|
||||
public const string BodyInvalid = "body_invalid";
|
||||
public const string ForkUnknown = "fork_unknown";
|
||||
public const string InsufficientScope = "insufficient_scope";
|
||||
public const string Forbidden = "forbidden";
|
||||
public const string RequestInvalid = "request_invalid";
|
||||
public const string RequestIdConflict = "request_id_conflict";
|
||||
|
||||
private static readonly Dictionary<string, (int Status, string Error)> Table = new(StringComparer.Ordinal)
|
||||
{
|
||||
[Unauthenticated] = (401, "unauthenticated: a verified session is required"),
|
||||
[BodyForbiddenField] = (400, "body_forbidden_field: answerer and authority are set by the receiver"),
|
||||
[BodyUnknownField] = (400, "body_unknown_field: the body is a closed shape"),
|
||||
[BodyInvalid] = (400, "body_invalid: the body does not satisfy gamecult.fork.answer_request.v0"),
|
||||
[ForkUnknown] = (404, "fork_unknown: no such fork"),
|
||||
[InsufficientScope] = (403, "insufficient_scope: the token lacks the scope this route needs"),
|
||||
[Forbidden] = (403, "forbidden: only Active members may use the desk"),
|
||||
[RequestInvalid] = (400, "request_invalid: the request does not satisfy its document"),
|
||||
[RequestIdConflict] = (409, "request_id_conflict: this request_id was used for a different body"),
|
||||
};
|
||||
|
||||
// fork_too_large names its fork, so it is built here, not read from the table; a bare door code of that name is
|
||||
// never a refusal. The texts are the pinned vectors' (alone is past the response budget).
|
||||
public const string ForkTooLarge = "fork_too_large";
|
||||
|
||||
public static int ForkTooLargeStatus => 422;
|
||||
|
||||
// A list page whose next fork alone is past the budget: the client continues with cursor c<order>; at order 0
|
||||
// it has reached the end.
|
||||
public static DeskRefusal ForkTooLargeDocument(string forkId, int order) => new(
|
||||
DeskSchemas.Refusal, ForkTooLarge, $"fork_too_large: fork {forkId} (order {order}) alone is past the response budget");
|
||||
|
||||
// A read of such a fork: nothing to page, so no order.
|
||||
public static DeskRefusal ForkTooLargeDocument(string forkId) => new(
|
||||
DeskSchemas.Refusal, ForkTooLarge, $"fork_too_large: fork {forkId} alone is past the response budget");
|
||||
|
||||
public static bool IsKnown(string code) => Table.ContainsKey(code);
|
||||
|
||||
public static int StatusOf(string code) => Table[code].Status;
|
||||
|
||||
public static DeskRefusal Document(string code) => new(DeskSchemas.Refusal, code, Table[code].Error);
|
||||
}
|
||||
|
||||
public static class DeskUnavailables
|
||||
{
|
||||
public const int Status = 503;
|
||||
|
||||
public static DeskUnavailable Document(int? retryAfterSeconds) => new(
|
||||
DeskSchemas.Unavailable,
|
||||
"unavailable",
|
||||
"unavailable: the outcome is not confirmed, try again with the same request_id",
|
||||
retryAfterSeconds);
|
||||
}
|
||||
480
src/Bifrost.Web/Features/Desk/DeskEndpoints.cs
Normal file
480
src/Bifrost.Web/Features/Desk/DeskEndpoints.cs
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
using System.Text.Json;
|
||||
using Bifrost.Web.Configuration;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Features.Oidc;
|
||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Bifrost.Web.Features.Desk;
|
||||
|
||||
// The fork desk's HTTP edge (fork-card.md, Transport). One catch-all under /api/desk judges, in this order and no
|
||||
// other: the raw target, the bearer, its audience, its scope, Active membership (the body is unread), the body's
|
||||
// shape, the {mind} lookup, the door. Every refusal is a contract document. Bifrost holds no fork state: it passes
|
||||
// Huginn's outcome through, attaches the current card to a stale_fork answer, and says `unavailable` when the door
|
||||
// cannot confirm an outcome.
|
||||
public static class DeskEndpoints
|
||||
{
|
||||
public const string RoutePrefix = "/api/desk";
|
||||
public const string Audience = "bifrost-desk";
|
||||
public const string ReadScope = "desk.read";
|
||||
public const string AnswerScope = "desk.answer";
|
||||
public const string CorsPolicy = "DeskCors";
|
||||
|
||||
public static bool IsDeskScope(string scope) => scope is ReadScope or AnswerScope;
|
||||
|
||||
// The desk's credential is OpenIddict validation against the local server, so it exists only where Bifrost:Oidc is
|
||||
// configured. Without it nothing is registered here and startup fails in the validator, with its own message.
|
||||
public static IServiceCollection AddBifrostDesk(this IServiceCollection services, bool oidcConfigured)
|
||||
{
|
||||
if (oidcConfigured)
|
||||
{
|
||||
services.AddOpenIddict()
|
||||
.AddServer(server => server.RegisterScopes(ReadScope, AnswerScope))
|
||||
.AddValidation(validation =>
|
||||
{
|
||||
validation.UseLocalServer();
|
||||
// The bearer travels in the Authorization header only: a token in a URL lands in access logs.
|
||||
validation.UseAspNetCore().Configure(options =>
|
||||
{
|
||||
options.DisableAccessTokenExtractionFromQueryString = true;
|
||||
options.DisableAccessTokenExtractionFromBodyForm = true;
|
||||
});
|
||||
});
|
||||
services.TryAddSingleton<IDeskCredential, OidcDeskCredential>();
|
||||
}
|
||||
|
||||
services.TryAddSingleton<IDeskDoor>(_ => new UnixSocketDeskDoor());
|
||||
services.AddSingleton(provider => new DeskMindRoutes(provider.GetRequiredService<IOptions<DeskOptions>>().Value.Minds));
|
||||
services.AddCors();
|
||||
// Exactly the configured origin, no credentials, and only on the routes that require this policy.
|
||||
services
|
||||
.AddOptions<CorsOptions>()
|
||||
.Configure<IOptions<DeskOptions>>((cors, desk) =>
|
||||
cors.AddPolicy(
|
||||
CorsPolicy,
|
||||
policy => policy
|
||||
.WithOrigins(desk.Value.AllowedOrigin)
|
||||
.WithMethods("GET", "POST")
|
||||
.WithHeaders(HeaderNames.Authorization, HeaderNames.ContentType)));
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IEndpointConventionBuilder MapDesk(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// A configured desk with no credential or door port would serve nothing true: stop startup instead.
|
||||
app.ServiceProvider.GetRequiredService<IDeskCredential>();
|
||||
app.ServiceProvider.GetRequiredService<IDeskDoor>();
|
||||
|
||||
return app.MapMethods(RoutePrefix + "/{**rest}", ["GET", "POST"], HandleAsync)
|
||||
.RequireCors(CorsPolicy)
|
||||
.AllowAnonymous();
|
||||
}
|
||||
|
||||
private static async Task HandleAsync(
|
||||
HttpContext http,
|
||||
IDeskCredential credential,
|
||||
IDeskDoor door,
|
||||
DeskMindRoutes minds,
|
||||
IOptions<DeskOptions> options,
|
||||
BifrostDbContext db)
|
||||
{
|
||||
var cancellationToken = http.RequestAborted;
|
||||
var desk = options.Value;
|
||||
http.Response.Headers.Append(HeaderNames.Vary, "Origin");
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
|
||||
// 1. The raw target, never the decoded Request.Path.
|
||||
var rawTarget = http.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpRequestFeature>()?.RawTarget ?? string.Empty;
|
||||
var route = DeskRoute.Parse(http.Request.Method, rawTarget);
|
||||
if (route is null)
|
||||
{
|
||||
await WriteRefusalAsync(http, DeskRefusals.ForkUnknown);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2-4. Bearer, audience, scope.
|
||||
DeskToken? token;
|
||||
try
|
||||
{
|
||||
token = await credential.AuthenticateAsync(http, cancellationToken);
|
||||
}
|
||||
catch (Exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The bearer could not be judged: neither a pass nor a refusal of the caller.
|
||||
await WriteUnavailableAsync(http, desk);
|
||||
return;
|
||||
}
|
||||
|
||||
if (token is null || !string.Equals(token.Audience, Audience, StringComparison.Ordinal))
|
||||
{
|
||||
await WriteRefusalAsync(http, DeskRefusals.Unauthenticated);
|
||||
return;
|
||||
}
|
||||
|
||||
var scope = route.Kind == DeskRouteKind.Answer ? AnswerScope : ReadScope;
|
||||
if (!token.Scopes.Contains(scope, StringComparer.Ordinal))
|
||||
{
|
||||
await WriteRefusalAsync(http, DeskRefusals.InsufficientScope);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Active membership, re-read now; the body has not been touched.
|
||||
Guid? accountId;
|
||||
try
|
||||
{
|
||||
accountId = await OidcEndpoints.ActiveMemberIdAsync(db, token.Subject, cancellationToken);
|
||||
}
|
||||
catch (Exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await WriteUnavailableAsync(http, desk);
|
||||
return;
|
||||
}
|
||||
|
||||
if (accountId is null)
|
||||
{
|
||||
await WriteRefusalAsync(http, DeskRefusals.Forbidden);
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. The request's shape. An answer's bytes are the bytes forwarded; list and read judge their query.
|
||||
byte[] body = [];
|
||||
DeskListRequest? listRequest = null;
|
||||
var forkId = string.Empty;
|
||||
if (route.Kind == DeskRouteKind.Answer)
|
||||
{
|
||||
var read = await ReadBodyAsync(http.Request, desk.MaxBodyBytes, cancellationToken);
|
||||
var verdict = read is null
|
||||
? DeskBodyVerdict.Refuse(DeskRefusals.BodyInvalid)
|
||||
: DeskBodyShape.Judge(read, desk.MaxBodyBytes);
|
||||
if (verdict.RefusalCode is not null)
|
||||
{
|
||||
await WriteRefusalAsync(http, verdict.RefusalCode);
|
||||
return;
|
||||
}
|
||||
|
||||
body = read!;
|
||||
}
|
||||
else if (route.Kind == DeskRouteKind.Read
|
||||
? !DeskQuery.TryRead(route.RawQuery, route.RawFork!, out forkId)
|
||||
: !DeskQuery.TryList(route.RawQuery, string.Empty, out listRequest))
|
||||
{
|
||||
await WriteRefusalAsync(http, DeskRefusals.RequestInvalid);
|
||||
return;
|
||||
}
|
||||
|
||||
// 7. The {mind} lookup: a miss is the same document as an unknown fork.
|
||||
var mind = minds.Resolve(route.RawMind);
|
||||
if (mind is null)
|
||||
{
|
||||
await WriteRefusalAsync(http, DeskRefusals.ForkUnknown);
|
||||
return;
|
||||
}
|
||||
|
||||
// 8-10. The door. A decider list outside 1..64 unique ids is a configuration fault: nothing is sent.
|
||||
if (!DeskMindOptions.DecidersWithinBounds(mind.Deciders))
|
||||
{
|
||||
await WriteUnavailableAsync(http, desk);
|
||||
return;
|
||||
}
|
||||
|
||||
var subject = accountId.Value.ToString("D");
|
||||
var context = new DeskDoorContext(mind.Id, desk.Attribution.AuthorityOf(subject), subject, mind.Deciders.ToArray());
|
||||
try
|
||||
{
|
||||
switch (route.Kind)
|
||||
{
|
||||
case DeskRouteKind.List:
|
||||
var listed = await door.ListAsync(mind.Socket, context, listRequest! with { Mind = mind.Id }, cancellationToken);
|
||||
if (listed is DeskListOutcome.Listed(var list))
|
||||
{
|
||||
await WriteJsonAsync(http, 200, IsDecider(context) ? list : DeskRedaction.ForNonDecider(list));
|
||||
}
|
||||
else if (listed is DeskListOutcome.ForkTooLarge(var largeId, var largeOrder))
|
||||
{
|
||||
await WriteJsonAsync(http, DeskRefusals.ForkTooLargeStatus, DeskRefusals.ForkTooLargeDocument(largeId, largeOrder));
|
||||
}
|
||||
else if (listed is DeskListOutcome.NotListed(var listCode) && DoorMayRefuse(listCode) && listCode != DeskRefusals.RequestIdConflict)
|
||||
{
|
||||
await WriteRefusalAsync(http, ReadListCode(listCode));
|
||||
}
|
||||
else
|
||||
{
|
||||
await WriteUnavailableAsync(http, desk);
|
||||
}
|
||||
|
||||
return;
|
||||
case DeskRouteKind.Read:
|
||||
var found = await door.ReadAsync(mind.Socket, context, forkId, cancellationToken);
|
||||
if (found is DeskReadOutcome.Found(var entry))
|
||||
{
|
||||
await WriteJsonAsync(http, 200, IsDecider(context) ? entry : DeskRedaction.ForNonDecider(entry));
|
||||
}
|
||||
else if (found is DeskReadOutcome.ForkTooLarge(var largeFork))
|
||||
{
|
||||
await WriteJsonAsync(http, DeskRefusals.ForkTooLargeStatus, DeskRefusals.ForkTooLargeDocument(largeFork));
|
||||
}
|
||||
else if (found is DeskReadOutcome.NotFound(var readCode) && DoorMayRefuse(readCode) && readCode != DeskRefusals.RequestIdConflict)
|
||||
{
|
||||
await WriteRefusalAsync(http, ReadListCode(readCode));
|
||||
}
|
||||
else
|
||||
{
|
||||
await WriteUnavailableAsync(http, desk);
|
||||
}
|
||||
|
||||
return;
|
||||
default:
|
||||
await AnswerAsync(http, door, mind, context, body, desk);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await WriteUnavailableAsync(http, desk);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task AnswerAsync(
|
||||
HttpContext http, IDeskDoor door, DeskMindOptions mind, DeskDoorContext context, byte[] body, DeskOptions desk)
|
||||
{
|
||||
var cancellationToken = http.RequestAborted;
|
||||
var outcome = await door.AnswerAsync(mind.Socket, context, body, cancellationToken);
|
||||
switch (outcome)
|
||||
{
|
||||
case DeskAnswerOutcome.NotStored(var code) when DoorMayRefuse(code):
|
||||
await WriteRefusalAsync(http, code);
|
||||
return;
|
||||
case DeskAnswerOutcome.Answered(var result) when DeskStatus.Of(result) is { } status:
|
||||
// A stale_fork answer, fresh or replayed, carries the CURRENT card: nothing is stored for it.
|
||||
if (string.Equals(result.Answer.Code, DeskStatus.StaleFork, StringComparison.Ordinal))
|
||||
{
|
||||
var current = await door.ReadAsync(mind.Socket, context, result.Answer.ForkId, cancellationToken);
|
||||
if (current is DeskReadOutcome.ForkTooLarge(var largeFork))
|
||||
{
|
||||
// Nothing is recorded for a stale answer, and the card it would carry cannot be returned: permanent.
|
||||
await WriteJsonAsync(http, DeskRefusals.ForkTooLargeStatus, DeskRefusals.ForkTooLargeDocument(largeFork));
|
||||
return;
|
||||
}
|
||||
|
||||
if (current is not DeskReadOutcome.Found(var entry))
|
||||
{
|
||||
await WriteUnavailableAsync(http, desk);
|
||||
return;
|
||||
}
|
||||
|
||||
result = result with { Card = entry.Card };
|
||||
}
|
||||
|
||||
await WriteJsonAsync(http, status, result);
|
||||
return;
|
||||
default:
|
||||
await WriteUnavailableAsync(http, desk);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// A note is a decider's own words: only a caller on the admin-set list reads one. Bifrost strips notes itself; answerer
|
||||
// and decider ids are withheld by Huginn's door (desk-read-visibility-withheld-ids), not here.
|
||||
private static bool IsDecider(DeskDoorContext context) =>
|
||||
context.Deciders.Contains(context.Subject, StringComparer.Ordinal);
|
||||
|
||||
// Codes the door may hand back for nothing stored. The three the edge alone judges are never the door's.
|
||||
private static bool DoorMayRefuse(string code) =>
|
||||
code is not (DeskRefusals.Unauthenticated or DeskRefusals.InsufficientScope or DeskRefusals.Forbidden) &&
|
||||
DeskRefusals.IsKnown(code);
|
||||
|
||||
// request_id_conflict is the answer's alone (Libby): a read or list carries no request_id, so the door saying it
|
||||
// there is a door fault and the edge says `unavailable`.
|
||||
// A read or list sends no body. A door that refuses one for its body (an `authority` field among them) is
|
||||
// refusing the request, which is request_invalid; it is not an outage.
|
||||
private static string ReadListCode(string code) =>
|
||||
code is DeskRefusals.BodyForbiddenField or DeskRefusals.BodyUnknownField or DeskRefusals.BodyInvalid
|
||||
? DeskRefusals.RequestInvalid
|
||||
: code;
|
||||
|
||||
// At most `cap` bytes. Null when the body is longer, judged by Content-Length first and then while reading.
|
||||
private static async Task<byte[]?> ReadBodyAsync(HttpRequest request, int cap, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ContentLength > cap)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[4096];
|
||||
int count;
|
||||
while ((count = await request.Body.ReadAsync(chunk, cancellationToken)) > 0)
|
||||
{
|
||||
if (buffer.Length + count > cap)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
buffer.Write(chunk, 0, count);
|
||||
}
|
||||
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private static Task WriteRefusalAsync(HttpContext http, string code) =>
|
||||
WriteJsonAsync(http, DeskRefusals.StatusOf(code), DeskRefusals.Document(code));
|
||||
|
||||
private static async Task WriteUnavailableAsync(HttpContext http, DeskOptions desk)
|
||||
{
|
||||
http.Response.Headers.RetryAfter = desk.RetryAfterSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
await WriteJsonAsync(http, DeskUnavailables.Status, DeskUnavailables.Document(desk.RetryAfterSeconds));
|
||||
}
|
||||
|
||||
private static async Task WriteJsonAsync<T>(HttpContext http, int status, T document)
|
||||
{
|
||||
http.Response.StatusCode = status;
|
||||
http.Response.ContentType = "application/json; charset=utf-8";
|
||||
await JsonSerializer.SerializeAsync(http.Response.Body, document, DeskJson.Options, http.RequestAborted);
|
||||
}
|
||||
}
|
||||
|
||||
// The one place a stored outcome becomes an HTTP status. fork-card.md prose says 200 for any stored outcome; the
|
||||
// vectors (results[].httpStatus) say a Refused answer carries its refusal's status, and the vectors are the check.
|
||||
public static class DeskStatus
|
||||
{
|
||||
public const string StaleFork = "stale_fork";
|
||||
|
||||
private static readonly Dictionary<string, int> RefusedStatus = new(StringComparer.Ordinal)
|
||||
{
|
||||
[StaleFork] = 409,
|
||||
["not_a_decider"] = 403,
|
||||
["fork_closed"] = 409,
|
||||
["choice_invalid"] = 400,
|
||||
["bad_retraction"] = 400,
|
||||
["already_retracted"] = 409,
|
||||
};
|
||||
|
||||
// Null when the door sent an outcome or a refusal code this contract does not name: Bifrost then cannot vouch
|
||||
// for it and says `unavailable`.
|
||||
public static int? Of(AnswerResult result) => result.Answer.Outcome switch
|
||||
{
|
||||
"Counted" or "Revision" or "Retraction" => 200,
|
||||
"Refused" when result.Answer.Code is { } code && RefusedStatus.TryGetValue(code, out var status) => status,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
public enum DeskRouteKind
|
||||
{
|
||||
List,
|
||||
Read,
|
||||
Answer,
|
||||
}
|
||||
|
||||
public sealed record DeskRoute(DeskRouteKind Kind, string RawMind, string? RawFork, string RawQuery)
|
||||
{
|
||||
// The raw request target against the three routes. Segments stay percent-encoded: {mind} is decoded once, by
|
||||
// the resolver, and nothing here normalises a dot segment. Any other shape is not a desk route.
|
||||
public static DeskRoute? Parse(string method, string rawTarget)
|
||||
{
|
||||
var path = rawTarget;
|
||||
var query = string.Empty;
|
||||
var split = path.IndexOf('?');
|
||||
if (split >= 0)
|
||||
{
|
||||
query = path[(split + 1)..];
|
||||
path = path[..split];
|
||||
}
|
||||
|
||||
if (!path.StartsWith(DeskEndpoints.RoutePrefix + "/", StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var segments = path[(DeskEndpoints.RoutePrefix.Length + 1)..].Split('/');
|
||||
return (method, segments) switch
|
||||
{
|
||||
("GET", [var mind, "forks"]) => new DeskRoute(DeskRouteKind.List, mind, null, query),
|
||||
("GET", [var mind, "forks", var fork]) => new DeskRoute(DeskRouteKind.Read, mind, fork, query),
|
||||
("POST", [var mind, "answers"]) => new DeskRoute(DeskRouteKind.Answer, mind, null, query),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// The list and read requests the query string and path stand for (fork-card.md, List and Read). Nothing in a
|
||||
// query sets a decider, an answerer or a Mind; an unknown key, a repeated key or a bad value is request_invalid.
|
||||
public static class DeskQuery
|
||||
{
|
||||
public const int MaxLimit = 50;
|
||||
|
||||
public static bool TryList(string rawQuery, string mind, out DeskListRequest request)
|
||||
{
|
||||
request = new DeskListRequest(mind, null, null, MaxLimit);
|
||||
if (!TryPairs(rawQuery, out var pairs))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string? status = null, cursor = null;
|
||||
var limit = MaxLimit;
|
||||
foreach (var (key, value) in pairs)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case "status" when value is "open" or "recorded":
|
||||
status = value;
|
||||
break;
|
||||
case "status" when value.Length == 0:
|
||||
break;
|
||||
case "cursor" when IsCursor(value):
|
||||
cursor = value;
|
||||
break;
|
||||
case "cursor" when value.Length == 0:
|
||||
break;
|
||||
case "limit" when int.TryParse(value, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var n) && n <= MaxLimit:
|
||||
limit = n;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
request = new DeskListRequest(mind, status, cursor, limit);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The read request has no query at all.
|
||||
public static bool TryRead(string rawQuery, string rawFork, out string forkId)
|
||||
{
|
||||
forkId = string.Empty;
|
||||
return rawQuery.Length == 0 && DeskMindRoutes.TryDecode(rawFork, out forkId) && forkId.Length > 0;
|
||||
}
|
||||
|
||||
// c<N>, N a positive integer without a leading zero.
|
||||
private static bool IsCursor(string value) =>
|
||||
value.Length > 1 && value[0] == 'c' && value[1] is >= '1' and <= '9' && value.Skip(2).All(char.IsAsciiDigit);
|
||||
|
||||
private static bool TryPairs(string rawQuery, out List<(string Key, string Value)> pairs)
|
||||
{
|
||||
pairs = [];
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (rawQuery.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var part in rawQuery.Split('&'))
|
||||
{
|
||||
var eq = part.IndexOf('=');
|
||||
var rawKey = eq < 0 ? part : part[..eq];
|
||||
var rawValue = eq < 0 ? string.Empty : part[(eq + 1)..];
|
||||
if (!DeskMindRoutes.TryDecode(rawKey, out var key) || !DeskMindRoutes.TryDecode(rawValue, out var value) || !seen.Add(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
pairs.Add((key, value));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
84
src/Bifrost.Web/Features/Desk/DeskMindRoutes.cs
Normal file
84
src/Bifrost.Web/Features/Desk/DeskMindRoutes.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
using System.Text;
|
||||
using Bifrost.Web.Configuration;
|
||||
|
||||
namespace Bifrost.Web.Features.Desk;
|
||||
|
||||
// The {mind} segment of a desk route (fork-card.md M1). The raw segment is percent-decoded once and compared,
|
||||
// ordinally, with the configured ids: no case folding, trimming or normalisation, and a decoding failure is a miss.
|
||||
// The segment is never joined into a path, a socket name or a query, so a value off the list opens nothing.
|
||||
public sealed class DeskMindRoutes
|
||||
{
|
||||
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
|
||||
|
||||
private readonly Dictionary<string, DeskMindOptions> byId;
|
||||
|
||||
public DeskMindRoutes(IEnumerable<DeskMindOptions> minds)
|
||||
{
|
||||
byId = new Dictionary<string, DeskMindOptions>(StringComparer.Ordinal);
|
||||
foreach (var mind in minds)
|
||||
{
|
||||
byId.Add(mind.Id, mind);
|
||||
}
|
||||
}
|
||||
|
||||
public DeskMindOptions? Resolve(string rawSegment) =>
|
||||
TryDecode(rawSegment, out var id) && byId.TryGetValue(id, out var mind) ? mind : null;
|
||||
|
||||
// One pass of percent-decoding. Uri.UnescapeDataString is not used: it leaves a bad escape in place and turns
|
||||
// bytes that are not UTF-8 into U+FFFD instead of failing.
|
||||
internal static bool TryDecode(string raw, out string decoded)
|
||||
{
|
||||
decoded = string.Empty;
|
||||
var bytes = new List<byte>(raw.Length);
|
||||
for (var i = 0; i < raw.Length; i++)
|
||||
{
|
||||
var c = raw[i];
|
||||
if (c == '%')
|
||||
{
|
||||
if (i + 2 >= raw.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryHex(raw[i + 1], out var high) || !TryHex(raw[i + 2], out var low))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bytes.Add((byte)((high << 4) | low));
|
||||
i += 2;
|
||||
}
|
||||
else if (c > 0x7f)
|
||||
{
|
||||
// A raw request target is ASCII; anything else is not a segment this desk issues.
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
bytes.Add((byte)c);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
decoded = StrictUtf8.GetString(bytes.ToArray());
|
||||
return true;
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryHex(char c, out int value)
|
||||
{
|
||||
value = c switch
|
||||
{
|
||||
>= '0' and <= '9' => c - '0',
|
||||
>= 'a' and <= 'f' => c - 'a' + 10,
|
||||
>= 'A' and <= 'F' => c - 'A' + 10,
|
||||
_ => -1,
|
||||
};
|
||||
return value >= 0;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue