Extract GameCult access planning repo

This commit is contained in:
Meta Vens 2026-04-26 05:18:35 +01:00
commit 397ccfd901
14 changed files with 1422 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.venv/
__pycache__/
*.pyc

119
AGENTS.md Normal file
View file

@ -0,0 +1,119 @@
# GameCult Access Instructions
## Project Purpose
GameCult Access exists to hold the shared auth and access-control architecture
for GameCult-hosted experiments without letting that shared layer metastasize
into a giant mixed-data pit.
The planned reusable backbone is:
- provider OAuth and linking
- signed local sessions
- entitlement refresh
- capability evaluation
- app-facing access claims
The planned non-shared-by-default layer is:
- app-domain user data
- audience data
- creator data
- queue/job payloads
- product-specific state
## Canonical State
- Treat `state/map.yaml` as the canonical current project map.
- Treat `state/scratch.md` as disposable working memory for one bounded subgoal.
- Treat `state/evidence.jsonl` as the distilled durable ledger of decisions,
verified boundaries, and rejected paths.
- Treat `state/branches.json` as hypothesis tracking, not phase/status prose.
- Treat `notes/fresh-workspace-handoff.md` as the compact re-entry packet.
- Treat `docs/architecture.md` as the source-grounded shared architecture note.
- Treat `docs/implementation-plan.md` as the forward plan.
- Treat `docs/app-profiles/repixelizer.md` and
`docs/app-profiles/streampixels.md` as thin app bindings, not the whole
shared architecture.
## Important Paths
- Project root: `E:\Projects\gamecult-access`
- Shared architecture: `E:\Projects\gamecult-access\docs\architecture.md`
- Implementation plan: `E:\Projects\gamecult-access\docs\implementation-plan.md`
- Repixelizer profile: `E:\Projects\gamecult-access\docs\app-profiles\repixelizer.md`
- StreamPixels profile: `E:\Projects\gamecult-access\docs\app-profiles\streampixels.md`
- Handoff summary: `E:\Projects\gamecult-access\notes\fresh-workspace-handoff.md`
- State CLI: `E:\Projects\gamecult-access\tools\gamecult_access_state.py`
- Pre-compaction helper: `E:\Projects\gamecult-access\tools\gamecult_access_prepare_compaction.py`
## Useful Commands
This repo does not carry a dedicated virtualenv yet. Use any working Python
3.11+ interpreter. On this workstation, `E:\Projects\repixelizer\.venv\Scripts\python.exe`
is known-good.
Preferred here:
```powershell
E:\Projects\repixelizer\.venv\Scripts\python.exe .\tools\gamecult_access_state.py status
E:\Projects\repixelizer\.venv\Scripts\python.exe .\tools\gamecult_access_state.py add-evidence --type design --status accepted --note "..."
E:\Projects\repixelizer\.venv\Scripts\python.exe .\tools\gamecult_access_prepare_compaction.py
```
Fallback if a normal `python` command exists:
```powershell
python .\tools\gamecult_access_state.py status
python .\tools\gamecult_access_state.py add-evidence --type design --status accepted --note "..."
python .\tools\gamecult_access_prepare_compaction.py
```
## Session Bootstrap And Re-entry Protocol
On fresh session load:
1. read:
- `state/map.yaml`
- `notes/fresh-workspace-handoff.md`
- `docs/architecture.md`
- `docs/implementation-plan.md`
2. run:
- `python .\tools\gamecult_access_state.py status`
- `git status --short --branch`
- `git log --oneline -5`
3. restate the current next action from persisted state before editing
After compaction or suspicious continuity loss:
1. rerun `gamecult_access_state.py status`
2. reread `state/map.yaml` and `notes/fresh-workspace-handoff.md`
3. treat the persisted next action as authoritative unless fresh evidence
contradicts it
When the user says to prepare for imminent compaction:
1. run `tools/gamecult_access_prepare_compaction.py`
2. update only the state that actually changed
3. rerun the helper
4. commit the completed persistence pass unless the work is deliberately
mid-surgery
## Operating Discipline
- Before substantial edits, restate the current mechanism and intended change.
- Keep the shared backbone separate from app-domain data unless there is a very
deliberate, source-grounded reason to share something.
- Prefer embedded shared-package mode before inventing a dedicated auth service.
- Do not let app profiles turn into duplicate copies of the shared architecture.
- If the diff grows while the boundary gets blurrier, stop and simplify.
- Before handoff or compaction, sync `state/map.yaml`, refresh
`notes/fresh-workspace-handoff.md`, add distilled evidence when future belief
changed, and make the next action explicit.
## External Context
- For StreamPixels architecture/auth context, prefer the indexed `StreamPixels`
repo through `voidbot` before filesystem spelunking.
- For GameCult deployment and host conventions, check
`E:\Projects\gamecult-ops` before improvising infra claims.

31
README.md Normal file
View file

@ -0,0 +1,31 @@
# GameCult Access
GameCult Access is the planned shared auth and access-control backbone for
GameCult-hosted experiments.
The point is painfully simple:
- stop rebuilding Discord/Patreon login glue in every app
- keep signed local sessions, linked identities, and capability checks reusable
- keep app-domain data separate so "shared auth" does not become "shared swamp"
## Current Shape
This repo is planning and extraction work, not a landed auth service yet.
Canonical docs:
- `docs/architecture.md`
- `docs/implementation-plan.md`
- `docs/app-profiles/repixelizer.md`
- `docs/app-profiles/streampixels.md`
- `state/map.yaml`
- `notes/fresh-workspace-handoff.md`
## Working Loop
1. Rehydrate from canonical state.
2. Keep the shared backbone separate from app-domain data.
3. Prefer embedded shared-package mode before inventing a central auth service.
4. Move only one organ at a time.
5. Commit finished passes before the notes start breeding.

View file

@ -0,0 +1,96 @@
# Repixelizer Access Profile
## What this file is
This file binds Repixelizer onto the shared design in `docs/architecture.md`.
It is future design, not a claim that the hosted demo already has auth.
## Current repo boundary
Current live hosted-demo behavior is still just:
- hosted-demo runtime limits and UI flags in `E:\Projects\repixelizer\src\repixelizer\gui.py`
- the local single-process queue in that same module
- frontend runtime-config consumption in `E:\Projects\repixelizer\frontend\src\app.ts`
No Discord login, Patreon login, linked identities, local sessions, or access
gates are landed yet.
## App identity
- `app_slug`: `repixelizer`
- primary hostname: `repixelizer.gamecult.org`
- runtime shape: one hosted GUI plus one queue-backed worker loop inside the app
## Capabilities
- `app_access`
- may load the protected hosted GUI
- `queue_submit`
- may create a repixelizer job
- `job_read_own`
- may read own job state, event stream, and final output
- `job_cancel_own`
- may cancel own queued or running job
- `admin_access`
- may inspect operational or grant/admin surfaces later
First-cut rule shape:
```text
queue_submit = app_access
job_read_own = app_access + resource ownership check
job_cancel_own = app_access + resource ownership check
```
## Access policy
```text
app_access =
discord.allowed_role
|| patreon.allowed_tier
|| grant.global_member
|| grant.app_access
queue_submit = app_access
admin_access =
grant.operator
|| grant.admin_access
```
## Runtime binding
Suggested queue/job additions:
- `jobs.account_id`
- `jobs.session_id`
- `jobs.access_revision`
- `jobs.entitlement_checked_at`
Route policy:
- public:
- `/api/health`
- `/api/config`
- public or capability-gated by policy choice:
- `/api/queue`
- requires `queue_submit`:
- `POST /api/jobs`
- requires ownership:
- `GET /api/jobs/{job_id}`
- `GET /api/jobs/{job_id}/events`
- `POST /api/jobs/{job_id}/heartbeat`
- `DELETE /api/jobs/{job_id}`
Important invariant:
- job ownership resolves from the local session/account, not from Discord or
Patreon ids
## Config mapping
- keep runtime/queue/limit env vars under `REPIXELIZER_*`
- move shared access machinery under `GC_ACCESS_*`
- keep per-app role/tier/capability bindings small and app-scoped

View file

@ -0,0 +1,58 @@
# StreamPixels Access Profile
## What this file is
This file records how StreamPixels should fit onto the shared access backbone in
`docs/architecture.md` without turning streamer-audience data into shared auth
sludge.
It is a boundary note, not a command to rewrite StreamPixels around Repixelizer.
## Current repo boundary
The current StreamPixels architecture already makes useful splits:
- the web app owns the remembered viewer session
- the service owns provider OAuth exchange and identity resolution
- the overlay remains auth-free
- creator memberships and operator roles are separate from general viewer state
That is already compatible with the shared backbone idea.
## What StreamPixels can reuse
- signed local session mechanics
- linked-identity and signed OAuth-state patterns
- provider adapter structure
- creator/operator capability evaluation
- grant/invite primitives where they fit
## What StreamPixels should keep separate
- viewer audience profiles
- creator memberships tied to streamer spaces
- creator connector credentials
- overlay/runtime state
- audience-facing app data in general
In plain language:
- the same backbone can authenticate and authorize the control plane
- StreamPixels audience data stays StreamPixels data
## Capability direction
Likely capability families:
- `viewer_access`
- `creator_access`
- `creator_admin`
- `operator_access`
These should bind onto the existing StreamPixels creator/operator separation
instead of flattening it into one GameCult-member gate.
## Invariant
If a future shared access store exists, it must not become the canonical home
for streamer-audience identity and product data. That stays with StreamPixels.

330
docs/architecture.md Normal file
View file

@ -0,0 +1,330 @@
# GameCult Hosted Access Architecture
## What this file is
This file is the shared architecture note for GameCult-hosted experiments that
need the same basic promise:
- let people sign in with Discord and/or Patreon
- decide access from GameCult/community entitlements
- keep app runtimes tied to local sessions and local accounts, not provider ids
It is not a map of landed auth code yet.
## Problem
If every experiment rebuilds:
- provider OAuth
- identity linking
- entitlement refresh
- session cookies
- queue/job ownership
- "Discord role OR Patreon tier" logic
then we get five near-identical auth pits and deserve what happens next.
The reusable move is to build one GameCult hosted-access layer and let each app
bind its own policy and runtime seams onto it.
## Design goals
- one reusable local account/session model across hosted experiments
- provider login and linking owned by one shared access layer
- per-app capability policies instead of hard-coded one-off gate checks
- host runtimes consume signed local session claims, not raw provider tokens
- keep app-domain data separate unless there is a deliberate reason to share it
- start as an embedded shared package before pretending we need a central auth
service
## Non-goals
- becoming a general public identity provider
- building multi-tenant SaaS auth machinery
- inventing global billing analytics because we got bored
- forcing cross-app single sign-on on day one before a second app exists
## Core concepts
- `Account`
one GameCult-local person record independent of external providers
- `LinkedIdentity`
one external provider identity attached to exactly one local account
- `EntitlementSnapshot`
cached result from a provider or grant source saying whether some claim is
currently true
- `CapabilityGrant`
a reusable local grant such as `global_member`, `app_access`, or
`admin_access`, scoped either globally or to one app
- `AppAccessProfile`
the per-app binding that names capabilities, allowed provider sources, and
policy rules
- `Session`
signed local app/browser session; the browser gets this, not provider tokens
- `AuditEvent`
durable record for login, link, unlink, refresh, denial, and admin overrides
## System shape
```mermaid
flowchart TD
A["Browser / app web UI"] --> B["Hosted experiment web surface"]
B --> C["GameCult access layer"]
C --> D["Discord OAuth + guild role checks"]
C --> E["Patreon OAuth + campaign/tier checks"]
C --> F["Local account/session/grant store"]
C --> G["Signed access claims"]
G --> H["Hosted experiment runtime"]
```
The important split:
- the access layer owns provider OAuth, identity linking, entitlement refresh,
and signed local sessions
- the host experiment owns its workload, queue, editor, render path, or other
product-specific machinery
Do not smear provider logic directly through every host runtime.
## Backbone reuse is not data merging
Using the same auth backbone does **not** mean all hosted experiments should
pour their user data into one communal bucket.
The reusable thing is:
- provider OAuth plumbing
- signed local session mechanics
- linked-identity primitives
- entitlement refresh
- capability evaluation
The non-reusable-by-default thing is:
- app-domain user data
- creator data
- audience data
- queue/job payloads
- inventories, scenes, outputs, or other product state
Recommended default:
- shared auth/access **code**
- separate app databases or at least separate app-owned schemas/tables for
domain data
- if a shared access store exists at all, keep it limited to auth/control-plane
truth such as identities, grants, entitlement snapshots, sessions, and audit
events
Do not let "same auth backbone" quietly mutate into "same user-data swamp."
## Reusable data model
Suggested durable core:
- `accounts`
- `id`
- `created_at`
- `last_seen_at`
- `display_name`
- `primary_email` nullable
- `linked_identities`
- `id`
- `account_id`
- `provider`
- `provider_user_id`
- `username`
- `access_token_encrypted`
- `refresh_token_encrypted`
- `token_expires_at`
- `profile_json`
- unique on `(provider, provider_user_id)`
- `entitlement_snapshots`
- `id`
- `account_id`
- `provider`
- `scope`
- `evaluated_at`
- `is_allowed`
- `reason_code`
- `reason_detail`
- `raw_summary_json`
- `capability_grants`
- `id`
- `account_id`
- `scope_type` enum: `global`, `app`
- `scope_id` nullable app slug
- `capability`
- `source` enum: `manual`, `invite`, `migration`, `operator`
- `status`
- `expires_at` nullable
- `note`
- `sessions`
- `id`
- `account_id`
- `app_slug`
- `created_at`
- `last_seen_at`
- `expires_at`
- `claims_json`
- `access_revision`
- `audit_events`
- `id`
- `account_id` nullable
- `session_id` nullable
- `app_slug` nullable
- `event_type`
- `event_payload_json`
- `created_at`
Important invariants:
- one provider identity belongs to one local account only
- provider tokens stay server-side only
- session cookies represent local sessions, not provider trust directly
- app-specific capability checks read local claims, not Discord or Patreon on
every route
## Provider adapters
Shared provider/gate adapters should be boring and reusable:
- `DiscordRoleEntitlementProvider`
- login via Discord OAuth
- entitlement check via guild membership and role ids, ideally through the
GameCult bot token server-side
- `PatreonMembershipEntitlementProvider`
- login via Patreon OAuth
- entitlement check via campaign membership and tier ids
- `ManualGrantProvider`
- local override for staff, migrations, experiments, or emergency access
- `InviteGrantProvider`
- reusable invite-issued app or role grants where that pattern makes sense
## Policy model
The reusable core should not hard-code one app's access rules. It should
evaluate capabilities against an app profile.
Suggested app-profile shape:
```text
AppAccessProfile {
appSlug: string
displayName: string
capabilities: string[]
policyRules: CapabilityRule[]
publicRoutes: string[]
explanationStrings: map[string]string
}
```
Example rule style:
```text
app_access = discord.allowed_role || patreon.allowed_tier || grant.global_member || grant.app_access
queue_submit = app_access
admin_access = grant.operator || grant.admin_access
```
## Auth and linking flow
The generic flow should be the same across hosted experiments:
1. user starts OAuth from the host app UI
2. shared access layer signs OAuth `state`
3. provider callback resolves the provider identity
4. shared layer finds or creates the local account
5. if the user was already signed in locally, the identity may link onto the
existing account instead of creating a new one
6. shared layer refreshes entitlements and capability claims
7. shared layer issues or updates the local session cookie
The browser should never be the custodian of provider tokens.
## App integration contract
Every hosted experiment should integrate through the same seams:
- define an `app_slug`
- define the app capability profile
- mark which routes are public, authenticated, or capability-gated
- attach `account_id`, `session_id`, and `access_revision` to owned resources
such as jobs, drafts, uploads, or queue entries
- require resource-owner checks on private read/update/delete paths
If an app has a queue or job system:
- queue entries belong to local account/session ids, not provider ids
- entitlement refresh should block new submissions after access loss
- active-job cancellation policy is app-specific, but the recommended first cut
is still "let the active job finish, block new work"
## Deployment modes
### Mode 1: Embedded shared package
Recommended first cut.
- one shared `gamecult_access` package/module
- each hosted experiment embeds it
- each app keeps its own runtime and route integration
- sessions may still be app-local
- each app keeps its own domain-data store
Why this is the right first cut:
- reusable without forcing a separate always-on dependency
- small blast radius
- easy to evolve while only one or two apps use it
### Mode 2: Same-host shared access service
Use this when multiple apps on the same server want one shared access store and
session issuer, but full centralization still feels premature.
### Mode 3: Dedicated GameCult access service
Only do this when multiple independently deployed apps genuinely need:
- shared cross-app sessions
- central grant administration
- one callback origin for all providers
- enough auth churn that duplicated embedded deployments become the bigger pain
Do not build this first because it sounds important.
Even in this mode, the dedicated service should own auth/control-plane data, not
absorb every app's audience or product data.
## Configuration split
Suggested generic env surface:
- `GC_ACCESS_ENABLED=1`
- `GC_ACCESS_SESSION_SECRET=...`
- `GC_ACCESS_BASE_URL=https://access.gamecult.org` or host-app local equivalent
- `GC_ACCESS_DISCORD_CLIENT_ID=...`
- `GC_ACCESS_DISCORD_CLIENT_SECRET=...`
- `GC_ACCESS_DISCORD_BOT_TOKEN=...`
- `GC_ACCESS_DISCORD_GUILD_ID=...`
- `GC_ACCESS_PATREON_CLIENT_ID=...`
- `GC_ACCESS_PATREON_CLIENT_SECRET=...`
- `GC_ACCESS_PATREON_CAMPAIGN_ID=...`
- `GC_ACCESS_ENTITLEMENT_CACHE_TTL_SECONDS=900`
- `GC_ACCESS_PROVIDER_FAILURE_GRACE_SECONDS=3600`
Per-app binding should live in code or profile config, not in cloned provider
env namespaces.
## Audit Result
The correct reusable seam is:
- shared provider/session/entitlement/capability machinery
- thin per-app access profiles and runtime bindings
The incorrect reusable seam is:
- cloning one app’s auth blob into every future experiment
- or turning shared auth into shared domain-data soup

101
docs/implementation-plan.md Normal file
View file

@ -0,0 +1,101 @@
# GameCult Access Implementation Plan
## What this file is
This file is the forward plan and active hypothesis ledger for GameCult Access.
It is not the canonical shared architecture note. That lives in
`docs/architecture.md`.
If the code or the architecture note disagrees with this plan, trust the code
and the architecture note first, then fix this file.
## Current machine
There is no landed auth machine here yet.
What exists right now is:
- the extracted shared architecture note
- app-binding notes for Repixelizer and StreamPixels
- explicit state/doctrine scaffolding so the plan does not immediately turn into
soup
## Primary objective
Build a reusable `gamecult_access` layer that can:
- own provider OAuth and identity linking
- issue signed local sessions
- refresh Discord and Patreon entitlements
- evaluate per-app capability rules
- stay separate from app-domain data
## Current priorities
### 1. Lock the shared boundary
Goals:
- keep the shared layer scoped to auth/control-plane machinery
- keep app-domain data in app-owned stores
- keep app profiles thin and explicit
### 2. Prefer embedded-package mode first
Goals:
- define the reusable package/module seam
- avoid a dedicated shared service until a second real app proves the need
- keep initial blast radius small
### 3. Make Repixelizer the first binding
Goals:
- gate hosted access and queue submission through local sessions
- attach queue ownership to `account_id` and `session_id`
- preserve the existing hosted GUI and queue runtime
### 4. Keep StreamPixels separate where it matters
Goals:
- let StreamPixels reuse auth/control-plane machinery
- keep viewer profiles, creator memberships, connector creds, and audience data
in StreamPixels
## Proposed phases
### Phase 1: Shared package scaffold
- create package/module boundaries for:
- provider adapters
- signed session issuance/verification
- linked identity persistence interfaces
- entitlement refresh/evaluation
- capability rule evaluation
### Phase 2: Repixelizer binding
- integrate the shared layer into the hosted demo
- gate `POST /api/jobs`
- gate per-job read/cancel/event routes by local session ownership
### Phase 3: StreamPixels control-plane fit check
- map the shared access layer onto StreamPixels creator/operator auth seams
- explicitly reject audience-data centralization
### Phase 4: Shared-store decision
- only after two real bindings exist, decide whether auth/control-plane state
should stay embedded or move into a shared service/store
## Guardrails
- do not let auth reuse become data mixing
- do not build a central service first just because it sounds grand
- do not duplicate the shared architecture note into app profiles
- keep one clear hypothesis per pass
- commit completed work before the planning notes start breeding

View file

@ -0,0 +1,69 @@
# Fresh Workspace Handoff
This is the re-entry packet for `E:\Projects\gamecult-access`.
It is intentionally short. Historical proof belongs in git history and the
distilled `state/evidence.jsonl` ledger; the shared architecture belongs in
`docs/architecture.md`; the forward build order belongs in
`docs/implementation-plan.md`.
## Rehydrate
From the repo root:
```powershell
# This repo has no dedicated venv yet. Use any working Python 3.11+.
E:\Projects\repixelizer\.venv\Scripts\python.exe .\tools\gamecult_access_state.py status
Get-Content '.\state\map.yaml'
Get-Content '.\notes\fresh-workspace-handoff.md'
Get-Content '.\docs\architecture.md'
Get-Content '.\docs\implementation-plan.md'
git status --short --branch
git log --oneline -5
Get-Content '.\state\evidence.jsonl' -Tail 8
```
Do not trust this file for the exact live HEAD. Always check git.
## Current Orientation
- this repo is planning and extraction work, not a landed auth service yet
- the canonical shared architecture lives in `docs/architecture.md`
- app profiles live under `docs/app-profiles/`
- the key boundary is explicit:
- shared auth/control-plane mechanics are reusable
- app-domain data stays app-owned by default
- Repixelizer is the first binding target
- StreamPixels is the boundary case that proves backbone reuse must not imply
audience-data centralization
- the recommended first implementation mode is an embedded shared package, not a
dedicated auth service
## Critical Doctrine
- Persistent state is the agent's mind.
- Cut stale architecture notes as ruthlessly as stale code.
- Do not mistake "shared auth" for "shared database."
- If compaction hits before boundary findings are persisted, that work is gone.
Re-gather it instead of pretending continuity happened.
## Next Real Move
Do not continue implementation automatically from a rehydrate-only request.
If the user asks to continue, the current next move is to define the embedded
`gamecult_access` package seam:
- storage interfaces
- provider adapter interfaces
- signed-session claims
- capability rule evaluation boundaries
without central-service theatrics and without smearing app-domain data into the
shared layer.
## Immediate Re-entry Instruction
After compaction, first rehydrate and reorient from the listed files and git
state. Wait for the user's next instruction unless they explicitly say to
continue.

17
state/branches.json Normal file
View file

@ -0,0 +1,17 @@
{
"branches": [
{
"id": "main",
"hypothesis": "A shared GameCult access backbone can be reused across hosted experiments if OAuth/session/capability machinery is centralized while app-domain data stays app-owned.",
"status": "active",
"artifacts": [
"state/map.yaml",
"state/scratch.md",
"state/evidence.jsonl",
"docs/architecture.md",
"docs/implementation-plan.md"
],
"notes": "Current mainline. Repixelizer is the first binding target; StreamPixels is the boundary stress test."
}
]
}

3
state/evidence.jsonl Normal file
View file

@ -0,0 +1,3 @@
{"ts":"2026-04-26T00:00:00Z","type":"design","status":"accepted","note":"The shared hosted-access plan was extracted from Repixelizer into its own repo so the backbone can evolve separately from app-specific notes."}
{"ts":"2026-04-26T00:00:00Z","type":"design","status":"accepted","note":"The recommended first implementation mode is an embedded shared gamecult_access package, not a dedicated auth service."}
{"ts":"2026-04-26T00:00:00Z","type":"boundary","status":"accepted","note":"StreamPixels can reuse the same auth/control-plane backbone, but streamer-audience data, creator memberships, connector credentials, and runtime state should remain StreamPixels-owned."}

81
state/map.yaml Normal file
View file

@ -0,0 +1,81 @@
project:
name: GameCult Access
status: active
phase: architecture_extraction
premise: >
Build one reusable GameCult-hosted access backbone for OAuth, linked
identities, local sessions, and capability checks without mixing in
app-domain audience or product data.
objective:
primary: >
Create a reusable shared access layer that Repixelizer, StreamPixels, and
future GameCult-hosted experiments can bind onto.
current: >
Lock the shared architecture, extraction path, and app-boundary rules before
implementation starts breeding myths.
constraints:
- Shared auth/access code is reusable; app-domain data is separate by default.
- Prefer an embedded shared package before a dedicated auth service.
- App profiles stay thin; the shared architecture note owns the main model.
- StreamPixels audience and creator data must not be absorbed into a shared auth store.
invariants:
- The map is canonical and slow-changing.
- Scratch is temporary and disposable.
- Evidence is durable and distilled; routine activity logs belong in git history and commit messages.
- The shared access layer owns OAuth, local sessions, entitlement refresh, capability evaluation, and audit surfaces.
- App-domain data stays with the host app unless there is a deliberate, source-grounded reason to share it.
- Embedded shared-package mode is the default first implementation strategy.
- StreamPixels can reuse control-plane auth mechanics without centralizing streamer-audience data.
- Persistent state cruft is the same failure class as architecture cruft.
data_flow:
observe:
- task request
- shared architecture note
- app profile bindings
- current app/runtime seams in Repixelizer and StreamPixels
process:
- rehydrate from canonical state
- choose one bounded architecture or implementation slice
- keep shared code and app-domain data boundaries explicit
- record only belief-changing evidence
- commit or backtrack
cut:
- reject shared-data creep
- reject duplicated auth blobs per app
- reject central-service theatrics before two real bindings justify them
active_subgoals:
- Keep the shared architecture explicit and separate from app profiles.
- Preserve the default boundary that app-domain data remains app-owned.
- Make Repixelizer the first binding and StreamPixels the boundary stress test.
- Define the extraction seam for an embedded `gamecult_access` package.
accepted_design:
- `docs/architecture.md` is the canonical shared architecture note.
- `docs/app-profiles/*.md` are thin app bindings, not duplicate brains.
- The reusable backbone owns provider OAuth, identity linking, signed local sessions, entitlement refresh, capability evaluation, and audit events.
- Host apps own their domain data, runtime state, and product-specific resources.
- A future shared access store, if it exists, should hold auth/control-plane truth only.
- Repixelizer is the first hosted-app binding.
- StreamPixels is the boundary case that proves backbone reuse must not imply audience-data centralization.
landed_baseline:
- Shared architecture extracted into its own repo.
- Repixelizer and StreamPixels app-profile notes created.
- Repo-local state, handoff, and compaction scaffolding created.
rejected_paths:
- name: per_app_auth_copy_paste
reason: duplicates the same provider/session sludge across every experiment
- name: central_auth_service_first
reason: adds deployment weight before two real bindings justify it
- name: shared_user_data_swamp
reason: reusing auth mechanics does not justify centralizing app-domain audience or product data
current_status:
summary: The shared hosted-access planning work has been extracted into its own repo with a canonical architecture note, a forward implementation plan, and thin app profiles for Repixelizer and StreamPixels. The key boundary is explicit: reusable auth/control-plane code is shared, app-domain data remains app-owned by default. The recommended first implementation mode is an embedded shared package rather than a dedicated auth service.
next_action: Start the first implementation-oriented pass by defining the embedded `gamecult_access` package seam: storage interfaces, provider adapter interfaces, signed-session claims, and capability rule evaluation boundaries, while keeping Repixelizer as the first binding target.

7
state/scratch.md Normal file
View file

@ -0,0 +1,7 @@
# Scratch
This file is disposable working memory for the current bounded subgoal.
## Current Subgoal
No active scratch subgoal.

View file

@ -0,0 +1,328 @@
from __future__ import annotations
import argparse
import json
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
STATE_DIR = ROOT / "state"
NOTES_DIR = ROOT / "notes"
DOCS_DIR = ROOT / "docs"
MAP_PATH = STATE_DIR / "map.yaml"
SCRATCH_PATH = STATE_DIR / "scratch.md"
BRANCHES_PATH = STATE_DIR / "branches.json"
EVIDENCE_PATH = STATE_DIR / "evidence.jsonl"
HANDOFF_PATH = NOTES_DIR / "fresh-workspace-handoff.md"
ARCH_PATH = DOCS_DIR / "architecture.md"
PLAN_PATH = DOCS_DIR / "implementation-plan.md"
AGENTS_PATH = ROOT / "AGENTS.md"
@dataclass
class Finding:
level: str
message: str
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def run_git(args: list[str]) -> str:
completed = subprocess.run(
["git", *args],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
return completed.stdout.strip()
def extract_map_field(name: str) -> str | None:
prefix = f" {name}:"
for line in read_text(MAP_PATH).splitlines():
if line.startswith(prefix):
return line.split(":", 1)[1].strip()
return None
def extract_active_subgoals() -> list[str]:
lines = read_text(MAP_PATH).splitlines()
results: list[str] = []
in_section = False
for line in lines:
if not in_section:
if line.strip() == "active_subgoals:":
in_section = True
continue
if line.startswith(" - "):
results.append(line[4:].strip())
continue
if line and not line.startswith(" "):
break
return results
def current_scratch_subgoal() -> str | None:
lines = read_text(SCRATCH_PATH).splitlines()
for index, line in enumerate(lines):
if line.strip() != "## Current Subgoal":
continue
for candidate in lines[index + 1 :]:
stripped = candidate.strip()
if stripped:
return stripped
return None
def load_evidence() -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
for line_number, line in enumerate(read_text(EVIDENCE_PATH).splitlines(), start=1):
if not line.strip():
continue
try:
record = json.loads(line)
except json.JSONDecodeError as exc:
raise ValueError(f"evidence line {line_number} is invalid JSON: {exc}") from exc
if not isinstance(record, dict):
raise ValueError(f"evidence line {line_number} is not a JSON object")
records.append(record)
return records
def load_branches() -> dict[str, Any]:
data = json.loads(read_text(BRANCHES_PATH))
if not isinstance(data, dict):
raise ValueError("branches.json must be a JSON object")
branches = data.get("branches")
if not isinstance(branches, list):
raise ValueError("branches.json must contain a branches list")
return data
def add_required_file_checks(findings: list[Finding]) -> None:
for path in [
MAP_PATH,
SCRATCH_PATH,
BRANCHES_PATH,
EVIDENCE_PATH,
HANDOFF_PATH,
ARCH_PATH,
PLAN_PATH,
AGENTS_PATH,
]:
if path.exists():
findings.append(Finding("ok", f"found {path.relative_to(ROOT)}"))
else:
findings.append(Finding("error", f"missing {path.relative_to(ROOT)}"))
def add_content_checks(findings: list[Finding]) -> tuple[list[dict[str, Any]], dict[str, Any]]:
evidence: list[dict[str, Any]] = []
branches: dict[str, Any] = {}
summary = extract_map_field("summary")
next_action = extract_map_field("next_action")
if summary:
findings.append(Finding("ok", "state/map.yaml has current_status.summary"))
else:
findings.append(Finding("error", "state/map.yaml is missing current_status.summary"))
if next_action:
findings.append(Finding("ok", "state/map.yaml has current_status.next_action"))
else:
findings.append(Finding("error", "state/map.yaml is missing current_status.next_action"))
subgoals = extract_active_subgoals()
if subgoals:
findings.append(Finding("ok", f"state/map.yaml has {len(subgoals)} active subgoal(s)"))
else:
findings.append(Finding("warn", "state/map.yaml has no active_subgoals entries"))
scratch_subgoal = current_scratch_subgoal()
if scratch_subgoal == "No active scratch subgoal.":
findings.append(Finding("ok", "state/scratch.md has no stale active scratch subgoal"))
elif scratch_subgoal:
findings.append(Finding("warn", f"state/scratch.md has active scratch subgoal: {scratch_subgoal}"))
else:
findings.append(Finding("warn", "state/scratch.md has no Current Subgoal value"))
try:
evidence = load_evidence()
findings.append(Finding("ok", f"state/evidence.jsonl parses ({len(evidence)} record(s))"))
except ValueError as exc:
findings.append(Finding("error", str(exc)))
if EVIDENCE_PATH.exists() and EVIDENCE_PATH.stat().st_size > 25_000:
findings.append(Finding("warn", "state/evidence.jsonl is larger than 25 KB; consider distillation"))
try:
branches = load_branches()
active = [
branch
for branch in branches.get("branches", [])
if isinstance(branch, dict) and branch.get("status") == "active"
]
findings.append(Finding("ok", f"state/branches.json parses ({len(active)} active branch(es))"))
except (json.JSONDecodeError, ValueError) as exc:
findings.append(Finding("error", f"branches.json parse failed: {exc}"))
return evidence, branches
def add_handoff_checks(findings: list[Finding]) -> None:
text = read_text(HANDOFF_PATH)
stale_branch = re.search(r"Current branch before .*ahead \d+", text)
stale_head = re.search(r"Current HEAD before .*\b[0-9a-f]{7,40}\b", text)
if stale_branch or stale_head:
findings.append(Finding("error", "handoff embeds an exact branch or HEAD snapshot; use git commands instead"))
else:
findings.append(Finding("ok", "handoff avoids exact branch/HEAD snapshots"))
required_phrases = [
"Do not continue implementation automatically from a rehydrate-only request.",
"Do not trust this file for the exact live HEAD.",
"Immediate Re-entry Instruction",
]
for phrase in required_phrases:
if phrase in text:
findings.append(Finding("ok", f"handoff contains: {phrase}"))
else:
findings.append(Finding("warn", f"handoff missing: {phrase}"))
def add_agents_checks(findings: list[Finding]) -> None:
text = read_text(AGENTS_PATH)
if "gamecult_access_prepare_compaction.py" in text:
findings.append(Finding("ok", "AGENTS.md tells agents to use the compaction helper"))
else:
findings.append(Finding("error", "AGENTS.md does not mention tools/gamecult_access_prepare_compaction.py"))
if "prepare for imminent compaction" in text.lower():
findings.append(Finding("ok", "AGENTS.md names the imminent-compaction trigger"))
else:
findings.append(Finding("warn", "AGENTS.md does not name the imminent-compaction trigger phrase"))
def add_git_checks(findings: list[Finding]) -> tuple[str, str]:
status = run_git(["status", "--short", "--branch"])
log = run_git(["log", "--oneline", "-5"])
dirty_lines = [line for line in status.splitlines()[1:] if line.strip()]
if dirty_lines:
findings.append(Finding("warn", "git worktree has uncommitted changes; commit or explain before compaction"))
else:
findings.append(Finding("ok", "git worktree is clean"))
return status, log
def render_report(findings: list[Finding], status: str, log: str, evidence: list[dict[str, Any]]) -> str:
summary = extract_map_field("summary") or "(missing)"
next_action = extract_map_field("next_action") or "(missing)"
subgoals = extract_active_subgoals()
counts = {
"ok": sum(1 for finding in findings if finding.level == "ok"),
"warn": sum(1 for finding in findings if finding.level == "warn"),
"error": sum(1 for finding in findings if finding.level == "error"),
}
lines = [
"GameCult Access pre-compaction persistence check",
f"Workspace: {ROOT}",
f"Findings: {counts['ok']} ok, {counts['warn']} warn, {counts['error']} error",
"",
"Git status:",
status,
"",
"Recent commits:",
log,
"",
f"Summary: {summary}",
f"Next action: {next_action}",
]
if subgoals:
lines.append("Active subgoals:")
lines.extend(f"- {item}" for item in subgoals)
if evidence:
latest = evidence[-1]
lines.extend(
[
"",
"Latest distilled evidence:",
f"- {latest.get('ts', '(missing ts)')} {latest.get('type', '(missing type)')}/{latest.get('status', '(missing status)')}: {latest.get('note', '(missing note)')}",
]
)
lines.append("")
lines.append("Findings:")
for finding in findings:
lines.append(f"[{finding.level.upper()}] {finding.message}")
lines.extend(
[
"",
"Pre-compaction checklist:",
"- Update state/map.yaml only if current understanding changed.",
"- Refresh notes/fresh-workspace-handoff.md if re-entry instructions changed.",
"- Add distilled evidence only for a belief-changing lesson, verification, rejected path, or scar.",
"- Keep exact branch/HEAD out of handoff prose; git commands own volatile truth.",
"- Commit completed persistence changes, or state why the worktree must stay dirty.",
"- Re-run this helper after edits before yielding to compaction.",
]
)
return "\n".join(lines)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Audit GameCult Access persistent state before imminent compaction."
)
parser.add_argument(
"--strict",
action="store_true",
help="Exit nonzero on warnings as well as errors.",
)
return parser
def main() -> int:
args = build_parser().parse_args()
findings: list[Finding] = []
evidence: list[dict[str, Any]] = []
add_required_file_checks(findings)
try:
evidence, _branches = add_content_checks(findings)
except OSError as exc:
findings.append(Finding("error", f"state content check failed: {exc}"))
try:
add_handoff_checks(findings)
except OSError as exc:
findings.append(Finding("error", f"handoff check failed: {exc}"))
try:
add_agents_checks(findings)
except OSError as exc:
findings.append(Finding("error", f"AGENTS check failed: {exc}"))
try:
status, log = add_git_checks(findings)
except (OSError, subprocess.CalledProcessError) as exc:
findings.append(Finding("error", f"git check failed: {exc}"))
status = "(git status unavailable)"
log = "(git log unavailable)"
print(render_report(findings, status, log, evidence))
has_error = any(finding.level == "error" for finding in findings)
has_warning = any(finding.level == "warn" for finding in findings)
if has_error or (args.strict and has_warning):
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,179 @@
from __future__ import annotations
import argparse
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
STATE_DIR = ROOT / "state"
MAP_PATH = STATE_DIR / "map.yaml"
BRANCHES_PATH = STATE_DIR / "branches.json"
EVIDENCE_PATH = STATE_DIR / "evidence.jsonl"
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def write_text(path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8")
def load_branches() -> dict[str, Any]:
return json.loads(read_text(BRANCHES_PATH))
def save_branches(data: dict[str, Any]) -> None:
write_text(BRANCHES_PATH, json.dumps(data, indent=2) + "\n")
def append_evidence(record: dict[str, Any]) -> None:
with EVIDENCE_PATH.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record, ensure_ascii=True) + "\n")
def utc_stamp() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def extract_map_field(name: str) -> str | None:
prefix = f" {name}:"
for line in read_text(MAP_PATH).splitlines():
if line.startswith(prefix):
return line.split(":", 1)[1].strip()
return None
def extract_active_subgoals() -> list[str]:
lines = read_text(MAP_PATH).splitlines()
results: list[str] = []
in_section = False
for line in lines:
if not in_section:
if line.strip() == "active_subgoals:":
in_section = True
continue
if line.startswith(" - "):
results.append(line[4:].strip())
continue
if line and not line.startswith(" "):
break
return results
def cmd_status(_: argparse.Namespace) -> int:
branches = load_branches().get("branches", [])
active = [branch for branch in branches if branch.get("status") == "active"]
summary = extract_map_field("summary") or "(missing)"
next_action = extract_map_field("next_action") or "(missing)"
subgoals = extract_active_subgoals()
print(f"Workspace: {ROOT}")
print(f"Summary: {summary}")
print(f"Next action: {next_action}")
print(f"Active branches: {len(active)} / {len(branches)}")
if subgoals:
print("Active subgoals:")
for item in subgoals:
print(f"- {item}")
return 0
def cmd_add_evidence(args: argparse.Namespace) -> int:
record: dict[str, Any] = {
"ts": utc_stamp(),
"type": args.type,
"status": args.status,
"note": args.note,
}
if args.branch:
record["branch"] = args.branch
append_evidence(record)
print("Appended evidence record.")
return 0
def cmd_add_branch(args: argparse.Namespace) -> int:
data = load_branches()
branches = data.setdefault("branches", [])
if any(branch.get("id") == args.id for branch in branches):
raise SystemExit(f"Branch '{args.id}' already exists.")
branches.append(
{
"id": args.id,
"hypothesis": args.hypothesis,
"status": "active",
"artifacts": args.artifact or [],
"notes": args.note or "",
}
)
save_branches(data)
print(f"Added branch '{args.id}'.")
return 0
def cmd_close_branch(args: argparse.Namespace) -> int:
data = load_branches()
for branch in data.get("branches", []):
if branch.get("id") != args.id:
continue
branch["status"] = args.status
if args.note:
branch["notes"] = args.note
save_branches(data)
print(f"Updated branch '{args.id}' to status '{args.status}'.")
return 0
raise SystemExit(f"Branch '{args.id}' was not found.")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Inspect and update GameCult Access persistent state files."
)
subparsers = parser.add_subparsers(dest="command", required=True)
status_parser = subparsers.add_parser("status", help="Show a compact state summary.")
status_parser.set_defaults(func=cmd_status)
evidence_parser = subparsers.add_parser(
"add-evidence", help="Append one distilled JSONL evidence record."
)
evidence_parser.add_argument("--type", required=True)
evidence_parser.add_argument("--status", required=True)
evidence_parser.add_argument("--note", required=True)
evidence_parser.add_argument("--branch")
evidence_parser.set_defaults(func=cmd_add_evidence)
add_branch_parser = subparsers.add_parser(
"add-branch", help="Create a new active branch entry."
)
add_branch_parser.add_argument("--id", required=True)
add_branch_parser.add_argument("--hypothesis", required=True)
add_branch_parser.add_argument("--artifact", action="append")
add_branch_parser.add_argument("--note")
add_branch_parser.set_defaults(func=cmd_add_branch)
close_branch_parser = subparsers.add_parser(
"close-branch", help="Close an existing branch."
)
close_branch_parser.add_argument("--id", required=True)
close_branch_parser.add_argument(
"--status", required=True, choices=["accepted", "rejected", "archived"]
)
close_branch_parser.add_argument("--note")
close_branch_parser.set_defaults(func=cmd_close_branch)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())