From 397ccfd90136fa32a9f7b6ba804c01968fca100a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 26 Apr 2026 05:18:35 +0100 Subject: [PATCH] Extract GameCult access planning repo --- .gitignore | 3 + AGENTS.md | 119 +++++++ README.md | 31 ++ docs/app-profiles/repixelizer.md | 96 ++++++ docs/app-profiles/streampixels.md | 58 ++++ docs/architecture.md | 330 ++++++++++++++++++++ docs/implementation-plan.md | 101 ++++++ notes/fresh-workspace-handoff.md | 69 ++++ state/branches.json | 17 + state/evidence.jsonl | 3 + state/map.yaml | 81 +++++ state/scratch.md | 7 + tools/gamecult_access_prepare_compaction.py | 328 +++++++++++++++++++ tools/gamecult_access_state.py | 179 +++++++++++ 14 files changed, 1422 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 docs/app-profiles/repixelizer.md create mode 100644 docs/app-profiles/streampixels.md create mode 100644 docs/architecture.md create mode 100644 docs/implementation-plan.md create mode 100644 notes/fresh-workspace-handoff.md create mode 100644 state/branches.json create mode 100644 state/evidence.jsonl create mode 100644 state/map.yaml create mode 100644 state/scratch.md create mode 100644 tools/gamecult_access_prepare_compaction.py create mode 100644 tools/gamecult_access_state.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77ac754 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.venv/ +__pycache__/ +*.pyc diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e12bc8b --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3e66db --- /dev/null +++ b/README.md @@ -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. diff --git a/docs/app-profiles/repixelizer.md b/docs/app-profiles/repixelizer.md new file mode 100644 index 0000000..19e22db --- /dev/null +++ b/docs/app-profiles/repixelizer.md @@ -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 diff --git a/docs/app-profiles/streampixels.md b/docs/app-profiles/streampixels.md new file mode 100644 index 0000000..f440609 --- /dev/null +++ b/docs/app-profiles/streampixels.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..c2fb602 --- /dev/null +++ b/docs/architecture.md @@ -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 diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..7904a15 --- /dev/null +++ b/docs/implementation-plan.md @@ -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 diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md new file mode 100644 index 0000000..5c798ba --- /dev/null +++ b/notes/fresh-workspace-handoff.md @@ -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. diff --git a/state/branches.json b/state/branches.json new file mode 100644 index 0000000..2785196 --- /dev/null +++ b/state/branches.json @@ -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." + } + ] +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl new file mode 100644 index 0000000..d4cd442 --- /dev/null +++ b/state/evidence.jsonl @@ -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."} diff --git a/state/map.yaml b/state/map.yaml new file mode 100644 index 0000000..68d7482 --- /dev/null +++ b/state/map.yaml @@ -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. diff --git a/state/scratch.md b/state/scratch.md new file mode 100644 index 0000000..3f84d3b --- /dev/null +++ b/state/scratch.md @@ -0,0 +1,7 @@ +# Scratch + +This file is disposable working memory for the current bounded subgoal. + +## Current Subgoal + +No active scratch subgoal. diff --git a/tools/gamecult_access_prepare_compaction.py b/tools/gamecult_access_prepare_compaction.py new file mode 100644 index 0000000..b262ea1 --- /dev/null +++ b/tools/gamecult_access_prepare_compaction.py @@ -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()) diff --git a/tools/gamecult_access_state.py b/tools/gamecult_access_state.py new file mode 100644 index 0000000..1cd8933 --- /dev/null +++ b/tools/gamecult_access_state.py @@ -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())