Imagination pass that admitted cut specs 0a through 7. Facts re-probed at
Bifrost bf83a93 and on the host; the from-memory prior-art list is replaced
by a citation of docs/research/anonymous-vote-stuffing-prior-art.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
24 KiB
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
- 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 arebifrost.governance.topicandbifrost.governance.topic-comment(:48-111). The commands areopen,comment,approve,promote,list,showanddigest(:113-155,:582). - A topic is an agent-dispatch work thread. It has
jurisdictionRepoName(required),jurisdictionAgentIdentity, a status lifecycleopen → consensus_ready → approved → dispatched → closed(:52), andpromote, which renders the topic into an agent update request (:324-405). - Support and objection are comment stances, not votes. The valid set is
comment, proposal, support, objection, question, approval, summary, receipt(:53). A comment hasauthorKind(default"agent") andauthorId, a free string from--author(:232-241). Nothing enforces one stance per author: each call writes a newcomment_<uuid>(:233). Retraction is not possible.authorIdis not tied to a BifrostUserAccount. - The web app has no topic type, page or store. It knows topics only as
GovernanceActivityReceiptrows in Postgres, with kindsTopicOpened/Commented/Approved/Promoted(src/Bifrost.Web/Domain/BifrostModels.cs:235-240,:1050-1066;Features/Bridge/GovernanceActivityReceiptService.cs). - No topic store exists anywhere looked.
.bifrost\holds onlybridge-receipts.cc. A read-onlyfindon Yggdrasil under/srv /home /var/libfound nogovernance-threads.cc(ran undersudo -nwith errors silenced, so likely but not proven).
How motions and votes differ
- Motions and votes are EF Core entities in Postgres:
Motion(BifrostModels.cs:665-700) andVote(:702-721, withVoteChoice For/Against/Abstainat:110-115). - 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). - Votes are weighted:
Weight = actor.EffectiveVotingWeight(MotionGovernanceService.cs:72,80), 0 unless the membership isActive, otherwisemax(1, sum of current tier snapshot weights)(BifrostModels.cs:357-361). - 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
- Every member surface is behind
ActiveMember:AuthorizeFolder("/App", ActiveMemberRequirement.PolicyName)(src/Bifrost.Web/Program.cs:57). The handler succeeds only whenMembership.Status == Active(Features/Membership/ActiveMemberAuthorizationHandler.cs,Features/Shared/CurrentBifrostActor.cs:17). Admin-only page handlers additionally checkActor.CanManageMembershipand returnForbid()(Pages/App/Members/Index.cshtml.cs:37-39,95-97). - An account becomes
Activeby 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 inPendingApproval, without one inAuthenticated(MembershipSynchronizationService.cs:61). - Anyone can register natively, and doing so proves nothing:
POST /auth/bifrost/registeris anonymous (Program.cs:286-316), takes an identity name and display name, creates anAuthenticatedaccount 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. - 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 requiresActiveMember(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
- Live release
/srv/bifrost/app/releases/20260723-daedcc4(readlink -f /srv/bifrost/app/current, 2026-10-10 02:58Z),bifrost.serviceactive 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 on127.0.0.1:5080. Earlier releases are bare UTC timestamps (20260610073418…20260618144854); the-daedcc4suffix was added by hand, the deploy script does not write it (fact 16). daedcc4("Fix EF migration discovery metadata", 2026-07-15) is not on main:git branch -a --contains daedcc4lists onlyorigin/codex/bifrost-migration-metadata, which isbd3a5a9(an ancestor of main) plusdaedcc4alone.git merge-tree --write-tree bf83a93 origin/codex/bifrost-migration-metadatais clean. Main hascde3a78, which deleted the HTTP bridge routes/bridge/actions/*,/dispatch/runs/*,/transport/receipts,/governance/receiptsandPOST /eve/governance/commands(369 lines ofProgram.cs); main'sPlatformEndpointsTests.Removed_http_bridge_routes_are_not_mappedpins their absence.- Deploys are manual:
gamecult-ops/scripts/deploy-bifrost-alpha.sh(33 lines, gamecult-ops1e95674; the deploy files are unchanged since44889b1) unpacks/srv/bifrost/bifrost-publish.tarintoreleases/$(date -u +%Y%m%d%H%M%S), repointscurrent, restarts. It takes no revision and records none.runbooks/bifrost-alpha-deploy.mdstep 3 publishes withdotnet publishfrom the operator's checkoutF:\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. - No caller of the routes
cde3a78deleted, 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/receiptsor/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 or127.0.0.1:5080exceptbifrost.serviceitself, and no non-nginx connection to:5080was 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
- The live env file sets no
DataProtection__KeysPath(key names only:AllowedHosts,Bootstrap__*,ConnectionStrings__Bifrost,GitHubApp__EnableWebhookSync,GitHubOAuth__*,Heimdall__*,Host__*,Stripe__*). SoProgram.cs:113-119never callsPersistKeysToFileSystem, and ASP.NET Core's default applies: the service runs asbifrostwithHOME=/srv/bifrost, and the key ring is/srv/bifrost/.aspnet/DataProtection-Keyswith two keys (created 2026-04-22 and 2026-07-22, the 90-day roll; prior-art notedocs/research/anonymous-vote-stuffing-prior-art.md:65-69). A third, unused directory/srv/bifrost/data-protection-keysholds one key from 2026-07-15. Keys therefore persist across restarts today by accident ofHOME, not by configuration. - 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.SetApplicationNameis not called anywhere (git grep SetApplicationName bf83a93is empty).
Proxy and client address
- nginx sends
X-Real-IP,X-Forwarded-For($proxy_add_x_forwarded_for),X-Forwarded-ProtoandX-Forwarded-Host(live/etc/nginx/sites-enabled/bifrost*:12-19), and proxies to127.0.0.1:5080.Program.csnever callsUseForwardedHeaders(fact 13 grep), soRemoteIpAddressis127.0.0.1for every request and any per-client limit puts every visitor in one bucket.
Code and test substrate
- Main lacks EF migration discovery metadata on seven migrations:
20260622142000_AddBridgeActionIdentityProvenance,20260622202000_AddBridgeActionTargetSurfaceNameand20260705190000_AddPatronSupportProjectAttributionhave neither[DbContext]nor[Migration];20260608210000_AddHeimdallUserIdentity,20260609143000_AddPatronSupportDerivation,20260609160000_AddExternalPatronProviderIntakeand20260622165000_AddBifrostNativeIdentityhave[Migration]only; none of the seven has a.Designer.cs. EF discovers a migration only with both attributes, soMigrateAsyncfrom main would skip them on a fresh database.daedcc4adds exactly those attributes (17 lines). The other five migrations have Designer files and are discovered. Migrations run at startup whenBootstrap:ApplyMigrationsOnStartupis true (Features/Shared/ApplicationBootstrapper.cs:17-21); the live env sets that key. - 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.TestAuthHandleroffers two actors only: the bootstrap admintest-admin, or anonymous via headerX-Test-Anonymous(Support/TestAuthHandler.cs:17-37).CurrentBifrostActoris a public record with a public constructor (Features/Shared/CurrentBifrostActor.cs:5), so service tests can build account and member actors directly.Programispublic partial(Program.cs:582). - 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). - The shared layout loads HTMX from
https://unpkg.com/htmx.org@1.9.12(Pages/Shared/_Layout.cshtml:65); no page posts withhx-posttoday. Admin and vote handlers use plain Razor page handlers withRedirectToPage()(Pages/App/Motions/Index.cshtml.cs:56-61). - Verification on Yggdrasil:
ygg-verify.sh'sdotnetalias ismcr.microsoft.com/dotnet/sdk:10.0, which has no .NET 8 runtime for thisnet8.0test project; passing the image namemcr.microsoft.com/dotnet/sdk:8.0works (any other value is used as an image name). Atbf83a93,dotnet test tests/Bifrost.Web.Tests/Bifrost.Web.Tests.csproj -c Release --filter "FullyQualifiedName!~BridgeCliTests&FullyQualifiedName!~ProviderAdvertisementTests"passes 29 of 29 (scratch logbifrost-base-test-2.log; that run's filter excluded onlyBridgeCliTests, and the twoProviderAdvertisementTestsfailed 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
- The Mill is committed and merged: AetheriaLore
origin/main6980087("Merge concept-mill-spa").Aetheria/Game Design/Concept Mill.mdis now 10 lines of front matter and prose; the viewer is the Quartz componentsite/quartz/components/AetheriaConceptMill.tsxwithscripts/aetheriaConceptMill.inline.ts(417 lines) andstyles/aetheriaConceptMill.scss. Its data source isscripts/concept-mill/ships.yaml(581 lines);node scripts/concept-mill/build.mjswrites the manifestsite/quartz/components/data/conceptMill.json(embedded in the page) and render derivatives, skipping derivatives that already exist unless--force(build.mjs:51-60), and loadsjs-yamlandsharpfrom the siblingF:\Projects\GameCult-Quartzcheckout (:22-25). - Concept identity is the ship
idinships.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#shipor#ship/variant(aetheriaConceptMill.inline.ts:3,178-192). The 2026-10-08 "19 card slugs" are gone. - The vote slot already exists and is switched off.
ships.yaml:22-27holdsvotes: { enabled: false, endpoint: null, bifrostUrl: null }, copied into the manifest (conceptMill.json:16-19). When enabled, the script fetchesendpointonce withcredentials: "omit", readstallies[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. - The Aetheria site's origin is
https://aetheria.gamecult.org(site/quartz.config.ts:66baseUrl: "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 toUserAccountin 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, shipid(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, plusTitle,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 aVoterKeyResolverthat only reads or mints the device cookie and calls it:- signed in: key
account:<UserAccountId>, classMemberifCurrentBifrostActor.IsActiveMemberat cast time, elseAccount; - otherwise: key
device:<id>, classAnonymous, where the id is a random GUID protected by Data Protection in an HttpOnly, Secure, SameSite=Lax, first-party cookie onbifrost.gamecult.org. The resolver does not own membership; it reads it. When the cookie is minted is the open questionconcept-mill-votes:question:device-cookie-consent.
- signed in: key
- Votes. Owner:
ConceptAppraisalrows:SubjectId,VoterKey((SubjectId, VoterKey)unique),Stance(Up/Down),VoterClassstamped at cast,UserAccountIdnullable,NetworkTagandCastAtUtc. 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 auditNetworkTag(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}/talliesreturning{ key: { up, down, members: { up, down } } }for every live subject; no voter keys, account ids or tags; CORS for the configured origins only (https://aetheria.gamecult.org),GET, no credentials;Cache-Control: public, max-age=60. It owns nothing. - The Mill page. Read only. Its existing vote slot (fact 28) fills from the export and links to
https://bifrost.gamecult.org/mill/aetheria-concept-mill/<ship id>. Voting happens on Bifrost's own origin, which keeps the device cookie first-party. - Client address.
UseForwardedHeaderstrusting loopback only makesRemoteIpAddressthe 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.Weightand 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 configuredDataProtection:KeysPathis (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:
- 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 questiondevice-cookie-consent(note:74-77,97); the recommended option mints it only when a visitor casts a vote, with a one-line notice. - Network rate limits. Kept, in-box ASP.NET Core rate limiter, partitioned by
NetworkPrefixfor 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 fromRemoteIpAddressafter loopback-only forwarded headers or from an account id, never from raw client input (note:45). - 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. - 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-consentfor 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.