diff --git a/Directory.Build.props b/Directory.Build.props deleted file mode 100644 index 1bb3c83..0000000 --- a/Directory.Build.props +++ /dev/null @@ -1,7 +0,0 @@ - - - - true - - - diff --git a/Dockerfile b/Dockerfile index 45072e5..b83befa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,19 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src -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 src/Bifrost.Web/Bifrost.Web.csproj src/Bifrost.Web/ +RUN dotnet restore src/Bifrost.Web/Bifrost.Web.csproj COPY . . RUN dotnet publish src/Bifrost.Web/Bifrost.Web.csproj \ --configuration Release \ --output /app/publish \ - /p:UseAppHost=false /p:RestoreLockedMode=true + /p:UseAppHost=false 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 diff --git a/docs/bridge.md b/docs/bridge.md index d971d0c..683d7d2 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -196,7 +196,15 @@ The command is a dry-run shape probe for public surfaces that are real enough to Bifrost's native Discord gateway is the conversation observer. A direct mention of the Bifrost bot, or a reply to one of its messages, becomes an immutable typed `bifrost.discord.persona_conversation_event.v0` only when the guild and channel have one exact Persona target binding. Bot-authored, ambient, empty, over-1200-character, unbound, and ambiguously bound messages do not enter the conversation path. The 1200-character cap is the shared signed alpha boundary with Epiphany. The observation store is Bifrost-private working state, never an actuator queue, and insert collisions must contain exactly equal content. VoidBot does not observe, schedule, or answer for the target Persona. -Persona speech goes out through Matrix, not Discord. `tools/persona-matrix-mouth.mjs` is the one process for every Persona (`PERSONAS`, a list of slugs): it holds each Persona's Matrix access token as its own systemd credential (`matrix-token-`) and nobody else holds any. Per Persona it keeps one `/sync` loop over the configured rooms and the account's direct rooms (a DM invitation from an account on `MATRIX_SERVER_NAME` is accepted while that Persona's brake is released, and one from any other server is declined), one cursor and one delivery sequence. A delivery carries the sender's Matrix id, the event's `m.mentions` user ids and whether the room is a DM. Reading wakes nothing and acks nothing: the heartbeat polls with the non-consuming `gamecult.persona.chat_pending_request.v1`, and only `chat_ack` advances a position. The mouth speaks on a `gamecult.persona.chat_send_request.v1` and joins rooms only while that Persona's brake (`/etc/gamecult/minds//control.cc`) is released. One loopback CultNet RUDP endpoint serves all of them; a request naming an unlisted Persona gets a typed `chat_refusal` (`UnknownPersona`). Document shapes: `tools/persona-chat-documents.mjs`; golden bytes: `tests/fixtures/persona-chat-v1/`. The Discord posting actuator, its permit and its RUDP listener are removed. +Persona replies cross back through two explicit CultMesh surfaces. Epiphany is +the sole writer of `epiphany.persona_discord_delivery_request.v0`; Bifrost +reads that surface, authenticates the dedicated Persona-mouth signer, journals +external execution before touching Discord, and is the sole writer of +`bifrost.persona_discord_delivery_receipt.v0`. Epiphany reads receipts but +never receives the Discord token or write access to Bifrost private state. +`unknown` is terminal for automatic transport retry: an external post may have +happened, so restart recovery must not manufacture a duplicate. Failed and +unknown outcomes remain Persona pressure for explicit resolution. Bifrost admits an observation only when guild, channel, target runtime, canonical repository, Persona, producer, and producer runtime match a preconfigured `bifrost.persona_feedback.target_binding.v0` document in Bifrost's private provider store. That binding also owns source visibility, data classification, and the delivery-store target. Bifrost never infers public visibility from Discord identifiers. It classifies the author using its own `bifrost.discord.actor_link.v0` state: a match yields `linked_governance_feedback`; absence yields `unlinked_social_feedback`. Neither class grants adoption, work, release, or deployment authority. diff --git a/docs/concept-mill-votes-target.md b/docs/concept-mill-votes-target.md index 91c92e6..b2b9272 100644 --- a/docs/concept-mill-votes-target.md +++ b/docs/concept-mill-votes-target.md @@ -1,128 +1,217 @@ # 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. +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; its invariants, rulings and cut specs are typed state in the eureka mind, and 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. +Short answer: Bifrost has two voting-like things, and neither fits the Mill as built. Governance topics take support and objection only as free-text comment stances. Those live in a command-line CultCache file the web app never reads, and they are 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. It also needs an anti-stuffing defence, a public read endpoint and a vote page. The live deploy is off-main, and that has to be 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`). +Bifrost repo head: `160fac2` (2026-10-01). Paths are relative to `F:\Projects\Bifrost` unless named. ### 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_` (`: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). +4. The web app has no topic type, no topic page and no topic 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 I looked. `F:\Projects\Bifrost\.bifrost\` holds only `bridge-receipts.cc`. A read-only `find` on Yggdrasil under `/srv /home /var/lib` found no `governance-threads.cc`. That `find` ran under `sudo -n` with errors silenced, so the absence there is 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`). +7. A vote is one per account per motion: there is a unique index on `(MotionId, UserAccountId)` (`Data/BifrostDbContext.cs:280-283`). Recasting a vote upserts it (`Features/Motions/MotionGovernanceService.cs:63-82`). +8. Votes are weighted. `Weight = actor.EffectiveVotingWeight` (`MotionGovernanceService.cs:72,80`). That weight is 0 unless the membership is `Active`, and otherwise `max(1, sum of current tier snapshot weights)` (`BifrostModels.cs:357-361`). Patron tier is the voting weight. 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). +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`). The motions page and its vote handler are at `Pages/App/Motions/Index.cshtml.cs:56`. +11. An account becomes `Active` in one of three ways: + - bootstrap admin GitHub logins (`Features/Shared/ApplicationBootstrapper.cs:44`, `MembershipSynchronizationService.cs:133`); + - admin approval on the Members page (`Pages/App/Members/Index.cshtml.cs:92-105`); + - a Heimdall member-access claim, meaning the Discord cult-member role or an active Patreon tier (`Features/Heimdall/HeimdallAuthService.cs:158,186`). + + GitHub sign-in with an invitation lands in `PendingApproval`, and 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`). It takes an identity name and a display name, creates an `Authenticated` account and sets the cookie (`Features/Membership/BifrostIdentityService.cs:15-58`). It asks for no credential, and there is no native sign-in route. A native account is a single cookie: as a voter it is indistinguishable from anonymous, and it is lost when the cookie expires. +13. No public read path exists: + - Anonymous routes are only health, readiness, auth, webhooks, the patron intake and the patronage checkout (`Program.cs:211-522`). + - The Eve governance surface requires `ActiveMember` (`Program.cs:473-488`). + - No CORS policy is configured anywhere in `src/Bifrost.Web` (grep for `Cors` returns nothing). ### Deployed versus repo -14. Live release `/srv/bifrost/app/releases/20260723-daedcc4` (`readlink -f /srv/bifrost/app/current`, 2026-10-10 02:58Z), `bifrost.service` active since 2026-08-21 10:51 UTC, `ExecStart=/usr/bin/dotnet /srv/bifrost/app/current/Bifrost.Web.dll`, `EnvironmentFiles=/srv/bifrost/env/bifrost.env`, `ASPNETCORE_ENVIRONMENT=Production`, listening on `127.0.0.1:5080`. Earlier releases are bare UTC timestamps (`20260610073418` … `20260618144854`); the `-daedcc4` suffix was added by hand, the deploy script does not write it (fact 16). -15. `daedcc4` ("Fix EF migration discovery metadata", 2026-07-15) is not on main: `git branch -a --contains daedcc4` lists only `origin/codex/bifrost-migration-metadata`, which is `bd3a5a9` (an ancestor of main) plus `daedcc4` alone. `git merge-tree --write-tree bf83a93 origin/codex/bifrost-migration-metadata` is clean. Main has `cde3a78`, which deleted the HTTP bridge routes `/bridge/actions/*`, `/dispatch/runs/*`, `/transport/receipts`, `/governance/receipts` and `POST /eve/governance/commands` (369 lines of `Program.cs`); main's `PlatformEndpointsTests.Removed_http_bridge_routes_are_not_mapped` pins their absence. -16. Deploys are manual: `gamecult-ops/scripts/deploy-bifrost-alpha.sh` (33 lines, gamecult-ops `1e95674`; the deploy files are unchanged since `44889b1`) unpacks `/srv/bifrost/bifrost-publish.tar` into `releases/$(date -u +%Y%m%d%H%M%S)`, repoints `current`, restarts. It takes no revision and records none. `runbooks/bifrost-alpha-deploy.md` step 3 publishes with `dotnet publish` from the operator's checkout `F:\Projects\Bifrost` (`:101-115`), step 2 lists the env keys (`:63-89`). Idunn does not manage Bifrost (`:162-164`). The inventory's "Bifrost alpha live state" block (`gamecult-ops/inventory.md:923-933`) records no revision. -17. No caller of the routes `cde3a78` deleted, over the whole retained nginx access log (2026-09-26 00:46 to 2026-10-10 02:58, 473,581 lines, all vhosts): zero requests to `/bridge/actions`, `/dispatch/runs`, `/transport/receipts`, `/governance/receipts` or `/eve/governance/commands`. The only `/governance/*` hits were ten scanner GETs on 2026-10-07 (`/governance/providers`, `/governance/budgets`, …, all 400). No systemd unit, timer or crontab on Yggdrasil names the bridge tools or `127.0.0.1:5080` except `bifrost.service` itself, and no non-nginx connection to `:5080` was established at probe time. The bifrost journal holds 1 line in 30 days, so the app's own logs cannot witness traffic. - -### Data Protection on the live host - -18. The live env file sets no `DataProtection__KeysPath` (key names only: `AllowedHosts`, `Bootstrap__*`, `ConnectionStrings__Bifrost`, `GitHubApp__EnableWebhookSync`, `GitHubOAuth__*`, `Heimdall__*`, `Host__*`, `Stripe__*`). So `Program.cs:113-119` never calls `PersistKeysToFileSystem`, and ASP.NET Core's default applies: the service runs as `bifrost` with `HOME=/srv/bifrost`, and the key ring is `/srv/bifrost/.aspnet/DataProtection-Keys` with two keys (created 2026-04-22 and 2026-07-22, the 90-day roll; prior-art note `docs/research/anonymous-vote-stuffing-prior-art.md:65-69`). A third, unused directory `/srv/bifrost/data-protection-keys` holds one key from 2026-07-15. Keys therefore persist across restarts today by accident of `HOME`, not by configuration. -19. The service's working directory resolves to the release directory: `readlink /proc//cwd` = `/srv/bifrost/app/releases/20260723-daedcc4`. Data Protection's default application discriminator is the content-root path (prior-art note `:67`), so every deploy into a new release directory isolates payloads protected by the previous release: auth cookies, antiforgery tokens and any future device token stop decrypting after each deploy. Mechanism from source docs plus this probe; not observed as logged-out users. `SetApplicationName` is not called anywhere (`git grep SetApplicationName bf83a93` is empty). - -### Proxy and client address - -20. nginx sends `X-Real-IP`, `X-Forwarded-For` (`$proxy_add_x_forwarded_for`), `X-Forwarded-Proto` and `X-Forwarded-Host` (live `/etc/nginx/sites-enabled/bifrost*:12-19`), and proxies to `127.0.0.1:5080`. `Program.cs` never calls `UseForwardedHeaders` (fact 13 grep), so `RemoteIpAddress` is `127.0.0.1` for every request and any per-client limit puts every visitor in one bucket. - -### Code and test substrate - -21. Main lacks EF migration discovery metadata on seven migrations: `20260622142000_AddBridgeActionIdentityProvenance`, `20260622202000_AddBridgeActionTargetSurfaceName` and `20260705190000_AddPatronSupportProjectAttribution` have neither `[DbContext]` nor `[Migration]`; `20260608210000_AddHeimdallUserIdentity`, `20260609143000_AddPatronSupportDerivation`, `20260609160000_AddExternalPatronProviderIntake` and `20260622165000_AddBifrostNativeIdentity` have `[Migration]` only; none of the seven has a `.Designer.cs`. EF discovers a migration only with both attributes, so `MigrateAsync` from main would skip them on a fresh database. `daedcc4` adds exactly those attributes (17 lines). The other five migrations have Designer files and are discovered. Migrations run at startup when `Bootstrap:ApplyMigrationsOnStartup` is true (`Features/Shared/ApplicationBootstrapper.cs:17-21`); the live env sets that key. -22. Tests use EF InMemory (`tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs:52-58`), which enforces no unique index; a uniqueness rule must be proven in service code and in the model/migration metadata, not by an InMemory insert failing. `TestAuthHandler` offers two actors only: the bootstrap admin `test-admin`, or anonymous via header `X-Test-Anonymous` (`Support/TestAuthHandler.cs:17-37`). `CurrentBifrostActor` is a public record with a public constructor (`Features/Shared/CurrentBifrostActor.cs:5`), so service tests can build account and member actors directly. `Program` is `public partial` (`Program.cs:582`). -23. Startup configuration rules live in one owner, `Features/Shared/StartupConfigurationValidator.cs`, which skips Development and Testing and throws on a missing production key (`:15-74`). -24. The shared layout loads HTMX from `https://unpkg.com/htmx.org@1.9.12` (`Pages/Shared/_Layout.cshtml:65`); no page posts with `hx-post` today. Admin and vote handlers use plain Razor page handlers with `RedirectToPage()` (`Pages/App/Motions/Index.cshtml.cs:56-61`). -25. Verification on Yggdrasil: `ygg-verify.sh`'s `dotnet` alias is `mcr.microsoft.com/dotnet/sdk:10.0`, which has no .NET 8 runtime for this `net8.0` test project; passing the image name `mcr.microsoft.com/dotnet/sdk:8.0` works (any other value is used as an image name). At `bf83a93`, `dotnet test tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj -c Release --filter "FullyQualifiedName!~BridgeCliTests&FullyQualifiedName!~ProviderAdvertisementTests"` passes 29 of 29 (scratch log `bifrost-base-test-2.log`; that run's filter excluded only `BridgeCliTests`, and the two `ProviderAdvertisementTests` failed for want of Node). The other 37 tests shell out to Node: with no Node they fail to start the process; with Debian's Node 18 they fail on newer Node APIs (`bifrost-base-test-3.log`, 30 pass, 36 fail). Starfire has Node 24 and SDK 10.0.202 only. None of the cuts touch the Node tools. +14. The live release is `/srv/bifrost/app/releases/20260723-daedcc4`, and `bifrost.service` is active. I checked both read-only over SSH alias `yggdrasil` on 2026-10-08. `https://bifrost.gamecult.org/healthz` and `/readyz` both return 200. +15. `daedcc4` ("Fix EF migration discovery metadata", 2026-07-15) is not on `main`. It exists only on `origin/codex/bifrost-migration-metadata`, which branched from `bd3a5a9`. Compared with `daedcc4`, `main` has `cde3a78`, which deletes 369 lines of HTTP bridge endpoints from `Program.cs`. `main` also lacks the migration-metadata attribute lines `daedcc4` added to seven migrations. Production therefore runs code that `main` has deleted, and `main` may still carry the EF migration-discovery bug that `daedcc4` fixed. +16. Deploys are manual. `gamecult-ops/scripts/deploy-bifrost-alpha.sh` creates a timestamped release directory, and Idunn does not manage Bifrost (`gamecult-ops/runbooks/bifrost-alpha-deploy.md:162-164`). The inventory entry (`gamecult-ops/inventory.md:941-952`) records no revision. ### 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"`). +17. The Mill is being built uncommitted in worktree `F:\Projects\AetheriaLore-wt-concept-mill`. It consists of `Aetheria/Game Design/Concept Mill.md` (397 lines, hand-authored HTML in Markdown), `Aetheria/media/concept-mill/` (41 files) and a `site/quartz/styles/custom.scss` change. +18. Each card is `
`, and there are 19 today (`Concept Mill.md:12` for the first, `bison`). The slug is already independent of the display name: for example, `vasuki2` is shown as "Vasuki". The ship roll links to `#`. +19. The site has a pattern for client script. A Quartz component sets `afterDOMLoaded` (`site/quartz/components/AetheriaEpubReader.tsx:110`) and re-runs on the `nav` event (`site/quartz/components/scripts/aetheriaEpubReader.inline.ts:228`). Deploys go through `.github/workflows/deploy-quartz.yml` to GitHub Pages. + +### Anonymous-voter substrate + +20. Bifrost has no anonymous actor. Every write path resolves a `CurrentBifrostActor` with a `UserAccount` and throws without one (`MotionGovernanceService.cs:18,54`). Anonymous votes therefore need a voter identity that is not a `UserAccount`. +21. Bifrost sees no client IP. nginx sends `X-Real-IP` and `X-Forwarded-For` (`gamecult-ops/nginx/bifrost.gamecult.org.conf:15-16`), but `Program.cs` never calls `UseForwardedHeaders`, so `RemoteIpAddress` is the loopback proxy for every request. Until that is fixed, any per-IP limit puts every visitor in one bucket. +22. Data Protection keys can be persisted (`Program.cs:113-117`, `DataProtection:KeysPath`). A device token signed with them would stay valid across restarts and releases, provided the live env sets the path. Cut 0 checks this. ## 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). +- **Who may vote:** anyone, including anonymous visitors. There is no membership gate. +- **Votes are public:** up and down counts are shown, so downvotes are visible. +- **Members are a filter, not a separate system.** Each vote records the voter's class at cast time (anonymous, account, or Active member). Member-only views are a filter over the same rows. -Derived from them, not asked separately: +Derived from these rulings, 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. +- **Concept art stays out of governance.** Mill votes are their own record, not governance topics or motions. Topics are agent-dispatch threads, and motions are tier-weighted, deadline-closing decisions (facts 2, 8, 9). Neither can admit an anonymous voter (fact 20). +- **Substrate is Postgres beside `Vote`,** to keep the optional foreign key to `UserAccount` in one transaction. "Bifrost web state is off CultCache" remains the standing doctrine debt it already is. This feature does not pay it down. + +Interpretation for Self to confirm, not a fork: "votes are public" is read as public counts, split by voter class. The export carries no per-voter identities. Anonymous voters have none to show, and publishing account names was not asked for. ## 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:`, class `Member` if `CurrentBifrostActor.IsActiveMember` at cast time, else `Account`; - - otherwise: key `device:`, class `Anonymous`, where the id is a random GUID protected by Data Protection in an HttpOnly, Secure, SameSite=Lax, first-party cookie on `bifrost.gamecult.org`. - The resolver does not own membership; it reads it. When the cookie is minted is the open question `concept-mill-votes:question:device-cookie-consent`. -- **Votes.** Owner: `ConceptAppraisal` rows: `SubjectId`, `VoterKey` (`(SubjectId, VoterKey)` unique), `Stance` (`Up`/`Down`), `VoterClass` stamped at cast, `UserAccountId` nullable, `NetworkTag` and `CastAtUtc`. One writer, `ConceptAppraisalService` (Cast, Retract, Purge, plus read-only Tally and subject view). Rate limiting stands in front of it; uniqueness is enforced inside it and by the unique index, with a unique-violation on insert retried as a stance change. -- **Network prefix.** Owner: one function, `NetworkPrefix`, mapping a client address to its configured IPv4/IPv6 prefix (IPv4-mapped IPv6 folded to IPv4). The audit `NetworkTag` (HMAC-SHA256 of the prefix under a configured secret) and the rate-limit partition both read it; no second prefix computation exists. -- **Tallies.** Derived only: per live subject, per stance, all and members, computed from rows and never stored as counters. -- **Public tally export.** The JSON xenos boundary: anonymous `GET /public/concepts/{collection}/tallies` returning `{ key: { up, down, members: { up, down } } }` for every live subject; no voter keys, account ids or tags; CORS for the configured origins only (`https://aetheria.gamecult.org`), `GET`, no credentials; `Cache-Control: public, max-age=60`. It owns nothing. -- **The Mill page.** Read only. Its existing vote slot (fact 28) fills from the export and links to `https://bifrost.gamecult.org/mill/aetheria-concept-mill/`. 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. +- **Concept identity.** Owner: the Mill page in AetheriaLore. The card `id` slug is the key (fact 18). Bifrost stores keys and never invents one. Renaming a ship changes its title, never its key. +- **Concept subjects.** Owner: a Bifrost `ConceptSubject` record: `(collection, key)` unique, plus `title`, `registeredAt` and `retiredAt`. Writer: one idempotent admin registration command, fed from the Mill's card list and run by the render-round agent. The vote path cannot create subjects, because otherwise anonymous visitors could mint keys. +- **Voter identity.** Owner: a Bifrost `VoterKey` resolver, the one place that answers "who is casting this?": + - signed in: `account:`; + - otherwise: `device:`, where the id comes from a signed, HttpOnly, first-party device-token cookie that Bifrost issues on the vote page. + + The `VoterKey` resolver is not an owner of membership status. It reads `CurrentBifrostActor.IsActiveMember` at cast time. +- **Votes.** Owner: a `ConceptAppraisal` record with these fields: + - `subjectId` and `voterKey`, with `(subjectId, voterKey)` unique; + - `stance` (`Up` or `Down`) and `castAt`; + - `voterClass` (`Anonymous`, `Account` or `Member`), stamped at cast; + - `userAccountId`, nullable; + - `networkTag`, an HMAC of the client's /24 (IPv4) or /64 (IPv6) prefix, stored for audit and purge. + + The one writer is `ConceptAppraisalService` (Cast, Change, Retract, and a Purge for admins). Rate limiting happens before the service, and uniqueness is enforced inside it and by the index. +- **Tallies.** Derived only. Per subject, per stance, per voter class, they are computed from rows and never stored as counters. The "all" and "members" views are filters over the same rows. +- **Public tally export.** This is the JSON xenos boundary. It is one anonymous `GET /public/concepts/{collection}/tallies` returning `{ key: { up, down, members: { up, down } } }` for every live subject: + - no voter keys, account ids or network tags; + - CORS allows exactly `https://aetheria.gamecult.org`, `GET` only, without credentials; + - `Cache-Control` is about 60 s. + + The export owns nothing. +- **The Mill page.** Read only. One fetch fills each card's counts, and a "Vote on Bifrost" link goes to `https://bifrost.gamecult.org/mill/aetheria-concept-mill/`. Voting happens on Bifrost's own origin, which keeps the device cookie first-party. That means no `SameSite=None`, no cross-site POST and no exposure to browser third-party-cookie blocking. - **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). + - The client IP is not an identity: it is a rate-limit bucket and an audit tag, never a vote key. ## 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: +Prior art on lightweight anonymous voting, from memory and not freshly searched: -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). +- Strawpoll-style polls offer duplicate checks by IP, by browser cookie, or by sign-in. IP-only checks wrongly block shared networks, and cookie-only checks are reset by a private window. +- Proof-of-work challenges (Hashcash; today ALTCHA, mCaptcha, Friendly Captcha) make each vote cost CPU without any third party. They slow bulk scripts and barely touch a determined attacker with hardware. +- Third-party challenges (Cloudflare Turnstile, reCAPTCHA) are strong. They also send visitor data to an outside service and add an external dependency to the vote path. +- Large sites rely on after-the-fact detection more than on gates: they look for clusters by network, timing and new devices, then discount or purge. -Vote fuzzing and voter-history weighting (note `:98`) are not adopted: raw public counts are the ruling. +Recommendation: a layered defence that lives entirely inside Bifrost, with proof-of-work held back as an escalation. -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. +1. **Device token: one vote per device per concept.** On the first vote-page visit, Bifrost mints a random device id, signs it with Data Protection, and sets it as `bifrost_voter`: HttpOnly, Secure, SameSite=Lax, valid for one year. The vote key is `device:`. Clearing cookies resets it, and that is accepted and bounded by layer 2. +2. **Network rate limits.** Configure `UseForwardedHeaders`, trusting only loopback (fact 21 makes this a prerequisite). Then put the in-box ASP.NET Core rate limiter (`Microsoft.AspNetCore.RateLimiting`) on two paths, partitioned by network prefix: + - token minting: a small number of new devices per prefix per hour; + - vote casts: a modest number per prefix per minute. -## Cut order and rationale + Signed-in accounts are partitioned by account, not by network. The limits are configuration values, and the first values are Hands' to propose and Soul's to probe. +3. **Audit and purge.** Every anonymous row carries `networkTag` and `castAt`. An admin view lists subjects whose recent votes cluster on one tag. A Purge command removes rows by tag and time window, leaving an audit event. Public counts can then be corrected after the fact, without a gate that blocks honest shared networks. +4. **Escalation, not built now:** a self-hosted proof-of-work challenge on token minting, using an ALTCHA-style library served from Bifrost. It is switched on by configuration only if layer 3 shows stuffing. It needs no third party, so votes stay Bifrost-owned. Turnstile and reCAPTCHA are refused, because they add an outside owner to the vote path. -The cut specs are typed state (`concept-mill-votes:cut_spec:cut-