From 484d9468c4bac941c4f827575d844a359b1bdaa0 Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sun, 30 Aug 2026 07:27:17 +0200 Subject: [PATCH 1/6] Add durable schema migrations and continuity gates --- .github/workflows/ci.yml | 9 + README.md | 4 + backend/.env.example | 11 +- backend/README.md | 27 +- backend/app/database.py | 120 ++++-- backend/app/main.py | 11 +- backend/app/models.py | 4 +- backend/app/schema_cli.py | 59 +++ backend/app/schema_migrations/__init__.py | 17 + backend/app/schema_migrations/runner.py | 114 ++++++ .../schema_migrations/versions/__init__.py | 1 + .../versions/v20260830_0001.py | 373 ++++++++++++++++++ backend/tests/test_data_safety_contract.py | 131 +++++- backend/tests/test_database.py | 200 +++++++++- backend/tests/test_endpoints.py | 10 + backend/tests/test_provenance_contract.py | 9 + contracts/data-safety/v1/data-safety.json | 143 +++++++ .../data-safety/v1/release-test-matrix.json | 30 +- contracts/provenance/v1/traceability.json | 10 + docs/BIGCHAINDB_ASSESSMENT.md | 45 +++ docs/DURABLE_DATA_FOUNDATION.md | 69 +++- docs/ECOSYSTEM_CONTINUITY.md | 115 ++++++ docs/PRODUCT_ECOSYSTEM_BOUNDARY.md | 89 +++++ docs/XRPL_TRANSACTION_LINKING.md | 6 + docs/public/data-safety.md | 8 + release-check.sh | 11 + 26 files changed, 1560 insertions(+), 66 deletions(-) create mode 100644 backend/app/schema_cli.py create mode 100644 backend/app/schema_migrations/__init__.py create mode 100644 backend/app/schema_migrations/runner.py create mode 100644 backend/app/schema_migrations/versions/__init__.py create mode 100644 backend/app/schema_migrations/versions/v20260830_0001.py create mode 100644 docs/BIGCHAINDB_ASSESSMENT.md create mode 100644 docs/ECOSYSTEM_CONTINUITY.md create mode 100644 docs/PRODUCT_ECOSYSTEM_BOUNDARY.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 193f9e4..eccf4fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,6 +153,15 @@ jobs: - name: Compile backend Python run: python -m compileall -q backend/app + - name: Verify forward-only schema migration and readiness + working-directory: backend + env: + CALORIEAPP_ENV: test + DATABASE_URL: sqlite:///${{ runner.temp }}/calorie-schema-smoke.sqlite + run: | + python -m app.schema_cli upgrade + python -m app.schema_cli check + frontend-checks: name: Frontend lint and build (Node.js 20) runs-on: ubuntu-latest diff --git a/README.md b/README.md index ee3a468..bfde322 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ python -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 ``` Backend health endpoint: http://127.0.0.1:8000/health +Backend database readiness endpoint: http://127.0.0.1:8000/ready Optional backend startup helper (PowerShell): @@ -201,6 +202,9 @@ gate can additionally run the local developer health check. - Public roadmap: docs/public/roadmap.md - Public deployment guide: docs/public/deployment.md - Durable data and privacy foundation: docs/DURABLE_DATA_FOUNDATION.md +- BigchainDB decision record: docs/BIGCHAINDB_ASSESSMENT.md +- Ecosystem continuity foundation: docs/ECOSYSTEM_CONTINUITY.md +- Official product and separate ecosystem boundary: docs/PRODUCT_ECOSYSTEM_BOUNDARY.md - Voluntary XRPL transaction-linking architecture: docs/XRPL_TRANSACTION_LINKING.md - Public data-safety direction: docs/public/data-safety.md - Public XRPL reference direction: docs/public/xrpl-linking.md diff --git a/backend/.env.example b/backend/.env.example index 6db6f52..d3b668b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,11 +9,10 @@ # Non-localhost origins must use HTTPS because API requests include session cookies. CORS_ORIGINS=http://localhost:3000 -# DATABASE_URL: SQLite database connection string -# Local development: Not needed (defaults to backend/calorieapp.db) -# Cloud examples: sqlite:///tmp/calorieapp.db or other persistent path -# For production, consider a managed database. -# DATABASE_URL=sqlite:////tmp/calorieapp.db +# DATABASE_URL: provider-neutral database connection string +# Local/test: not needed (defaults to backend/calorieapp.db) +# Staging/production: PostgreSQL is mandatory; SQLite fails closed. +# DATABASE_URL=postgresql://USER:PASSWORD@HOST/DATABASE # PORT: Server port (read by deployment wrapper, not directly by FastAPI) # Local development: 8000 @@ -51,7 +50,7 @@ WORDPRESS_BRIDGE_SECRET=CHANGE_ME_TO_A_RANDOM_SECRET CALORIEAPP_CLIENT_ID=calorieapp-backend # Deployment environment selector used for startup guardrails. -# Allowed local-only value: local +# Allowed values: local, test, staging, production CALORIEAPP_ENV=local # Where the frontend should land after successful callback finalization. diff --git a/backend/README.md b/backend/README.md index 300673b..07192ed 100644 --- a/backend/README.md +++ b/backend/README.md @@ -11,6 +11,7 @@ This service is the CalorieApp V1 data layer only. ## Endpoints - GET /health +- GET /ready - GET /search-food?q= - POST /log-food - GET /logs @@ -20,7 +21,8 @@ This service is the CalorieApp V1 data layer only. 1. python -m venv .venv 2. Activate your environment 3. pip install -r requirements.txt -4. python -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 +4. python -m app.schema_cli upgrade +5. python -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8000 Canonical backend command: @@ -52,11 +54,32 @@ If you see inconsistent API responses: 1. Stop old backend processes. 2. Start backend using start-backend.ps1. 3. Confirm http://127.0.0.1:8000/health returns status ok. -4. Retry requests from frontend on localhost:3000. +4. Confirm http://127.0.0.1:8000/ready reports the expected database revision. +5. Retry requests from frontend on localhost:3000. + +## Schema migrations + +Schema changes are forward-only, versioned and provider-neutral: + +```bash +python -m app.schema_cli current +python -m app.schema_cli upgrade +python -m app.schema_cli check +``` + +Local and test startup may apply known migrations automatically. Staging and +production never migrate on application startup. Their approved pipeline must +run `upgrade --approval-reference ` before starting the new app +version. Downgrades are deliberately unsupported; use a tested corrective +migration or verified restore. ## Notes - Data storage uses local SQLite via SQLModel for development and tests only. - Public user onboarding remains blocked until the durable PostgreSQL, migration, persistence, export, erasure and recovery gates pass. +- The core ecosystem remains free to users. Database and Web3 schema functions + rely on open application code and standard PostgreSQL capabilities, not paid + add-ons. Separately reviewed value-added developer services may be offered + later without paywalling identity or personal-data rights. - Open Food Facts is consumed only by backend service endpoints. diff --git a/backend/app/database.py b/backend/app/database.py index 0d88da9..ce95198 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -1,12 +1,17 @@ -""" -Database setup for CalorieApp backend. -Uses SQLModel with local SQLite by default and PostgreSQL in hosted environments. -""" +"""Database configuration, startup guards and readiness for CalorieApp.""" import os from pathlib import Path -from sqlalchemy import inspect, text -from sqlmodel import Session, SQLModel, create_engine +from sqlalchemy import text +from sqlalchemy.engine import Engine, make_url +from sqlmodel import Session, create_engine + +from .schema_migrations import ( + SCHEMA_HEAD, + assert_database_at_head, + current_revision, + upgrade_database, +) def _normalize_database_url(database_url: str) -> str: @@ -18,14 +23,15 @@ def _normalize_database_url(database_url: str) -> str: return database_url -# Read DATABASE_URL from environment; default to local SQLite file. -# Format: sqlite:///path/to/db.sqlite or sqlite+pysqlite:///path -if DATABASE_URL := os.getenv("DATABASE_URL"): - pass # Use environment configuration +_DATABASE_URL_FROM_ENV = os.getenv("DATABASE_URL") +if _DATABASE_URL_FROM_ENV: + DATABASE_URL = _DATABASE_URL_FROM_ENV + _DATABASE_URL_WAS_EXPLICIT = True else: # Local development default: SQLite file one directory above this file (backend/calorieapp.db). _DB_PATH = Path(__file__).parent.parent / "calorieapp.db" DATABASE_URL = f"sqlite:///{_DB_PATH}" + _DATABASE_URL_WAS_EXPLICIT = False DATABASE_URL = _normalize_database_url(DATABASE_URL) @@ -33,38 +39,74 @@ def _normalize_database_url(database_url: str) -> str: _ENGINE_OPTIONS = {"connect_args": {"check_same_thread": False}} if _IS_SQLITE else {"pool_pre_ping": True} engine = create_engine(DATABASE_URL, **_ENGINE_OPTIONS) -_OPTIONAL_LOG_COLUMNS: dict[str, tuple[str, str]] = { - "owner_id": ("TEXT", "TEXT"), - "portion_percentage": ("REAL", "DOUBLE PRECISION"), - "barcode": ("TEXT", "TEXT"), - "image_url": ("TEXT", "TEXT"), - "brand": ("TEXT", "TEXT"), - "serving_size": ("TEXT", "TEXT"), - "nutri_score": ("TEXT", "TEXT"), -} - - -def _ensure_food_log_optional_columns() -> None: - """Add known nullable columns without resetting existing SQLite/PostgreSQL data.""" - with engine.begin() as connection: - inspector = inspect(connection) - if not inspector.has_table("food_log"): - return - existing = {str(column["name"]) for column in inspector.get_columns("food_log")} - dialect_index = 0 if connection.dialect.name == "sqlite" else 1 - quote = connection.dialect.identifier_preparer.quote - - for column_name, column_types in _OPTIONAL_LOG_COLUMNS.items(): - if column_name in existing: - continue - column_type = column_types[dialect_index] - connection.execute(text(f"ALTER TABLE {quote('food_log')} ADD COLUMN {quote(column_name)} {column_type}")) +_ALLOWED_ENVIRONMENTS = {"local", "test", "staging", "production"} + + +def validate_database_environment( + database_url: str, + environment: str | None, + *, + database_url_was_explicit: bool = True, +) -> str: + """Validate the environment/database pairing and return the resolved environment.""" + normalized_environment = environment.strip().lower() if environment and environment.strip() else None + if normalized_environment is None: + if database_url_was_explicit: + raise RuntimeError( + "CALORIEAPP_ENV must be set when DATABASE_URL is explicitly configured" + ) + normalized_environment = "local" + + if normalized_environment not in _ALLOWED_ENVIRONMENTS: + allowed = ", ".join(sorted(_ALLOWED_ENVIRONMENTS)) + raise RuntimeError(f"CALORIEAPP_ENV must be one of: {allowed}") + + backend_name = make_url(_normalize_database_url(database_url)).get_backend_name() + if backend_name not in {"sqlite", "postgresql"}: + raise RuntimeError("DATABASE_URL must use SQLite or PostgreSQL") + if backend_name == "sqlite" and normalized_environment not in {"local", "test"}: + raise RuntimeError( + "SQLite is only allowed when CALORIEAPP_ENV is local or test; " + f"current environment is {normalized_environment}" + ) + if normalized_environment in {"staging", "production"} and backend_name != "postgresql": + raise RuntimeError( + f"{normalized_environment} requires a PostgreSQL DATABASE_URL" + ) + return normalized_environment + + +def _configured_environment() -> str: + return validate_database_environment( + str(engine.url), + os.getenv("CALORIEAPP_ENV"), + database_url_was_explicit=_DATABASE_URL_WAS_EXPLICIT, + ) def init_db() -> None: - """Create all SQLModel tables if they do not already exist.""" - SQLModel.metadata.create_all(engine) - _ensure_food_log_optional_columns() + """Upgrade local/test databases and require pre-approved migrations elsewhere.""" + environment = _configured_environment() + if environment in {"local", "test"}: + upgrade_database(engine) + else: + assert_database_at_head(engine) + + +def database_readiness(target_engine: Engine | None = None) -> dict[str, str]: + """Perform a read-only connectivity and migration-head check.""" + selected_engine = target_engine or engine + validate_database_environment( + str(selected_engine.url), + os.getenv("CALORIEAPP_ENV"), + database_url_was_explicit=( + _DATABASE_URL_WAS_EXPLICIT if target_engine is None else False + ), + ) + with selected_engine.connect() as connection: + connection.execute(text("SELECT 1")).scalar_one() + assert_database_at_head(selected_engine) + return {"status": "ready", "database_revision": current_revision(selected_engine) or SCHEMA_HEAD} def get_session(): diff --git a/backend/app/main.py b/backend/app/main.py index 90cd623..e2e31cc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -19,7 +19,7 @@ from sqlalchemy.exc import IntegrityError from sqlmodel import Session, select -from .database import get_session, init_db +from .database import database_readiness, get_session, init_db from .locales import resolve_locale from .models import AuthSessionDB, BridgeAuthNonceDB, CalorieAppUserDB, FoodLogDB from .schemas import ( @@ -677,6 +677,15 @@ def health() -> dict[str, str]: return {"status": "ok", "service": "calorieapp-backend"} +@app.get("/ready") +def ready() -> dict[str, str]: + """Confirm that the database is reachable and exactly at schema head.""" + return { + **database_readiness(), + "service": "calorieapp-backend", + } + + # ========================================================================= # Identity Endpoints # ========================================================================= diff --git a/backend/app/models.py b/backend/app/models.py index 2ffa1fb..880b731 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,6 +1,6 @@ """ SQLModel table definitions for CalorieApp backend. -FoodLogDB maps to the food_log table in calorieapp.db. +FoodLogDB maps to the provider-neutral food_log table. Also includes identity tables: CalorieAppUser, ExternalIdentity, AuthorizationCode. """ from datetime import UTC, datetime @@ -17,7 +17,7 @@ def utc_now() -> datetime: class FoodLogDB(SQLModel, table=True): - """Persistent food log entry stored in SQLite.""" + """Persistent food log entry owned by one internal CalorieApp user.""" __tablename__ = "food_log" diff --git a/backend/app/schema_cli.py b/backend/app/schema_cli.py new file mode 100644 index 0000000..723230a --- /dev/null +++ b/backend/app/schema_cli.py @@ -0,0 +1,59 @@ +"""Command-line entry point for approved schema migration operations.""" + +from __future__ import annotations + +import argparse +import os + +from .database import ( + _DATABASE_URL_WAS_EXPLICIT, + database_readiness, + engine, + validate_database_environment, +) +from .schema_migrations import SCHEMA_HEAD, current_revision, upgrade_database + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="CalorieApp schema migration control") + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("current", help="Print the recorded database revision") + subparsers.add_parser("check", help="Check connectivity, schema and migration head") + upgrade = subparsers.add_parser("upgrade", help="Apply forward migrations to schema head") + upgrade.add_argument( + "--approval-reference", + help="Required change/review reference for staging and production", + ) + return parser + + +def main() -> int: + args = _parser().parse_args() + environment = validate_database_environment( + str(engine.url), + os.getenv("CALORIEAPP_ENV"), + database_url_was_explicit=_DATABASE_URL_WAS_EXPLICIT, + ) + + if args.command == "current": + print(current_revision(engine) or "unversioned") + return 0 + if args.command == "check": + result = database_readiness() + print(f"{result['status']} revision={result['database_revision']}") + return 0 + + approval_reference = args.approval_reference.strip() if args.approval_reference else None + if environment in {"staging", "production"} and not approval_reference: + raise SystemExit( + "--approval-reference is required for staging and production migrations" + ) + revision = upgrade_database(engine, approval_reference=approval_reference) + if revision != SCHEMA_HEAD: + raise SystemExit("Migration finished without reaching schema head") + print(f"upgraded revision={revision}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/app/schema_migrations/__init__.py b/backend/app/schema_migrations/__init__.py new file mode 100644 index 0000000..4ba5f2b --- /dev/null +++ b/backend/app/schema_migrations/__init__.py @@ -0,0 +1,17 @@ +"""Versioned CalorieApp schema migrations without a provider-specific service.""" + +from .runner import ( + SCHEMA_HEAD, + MigrationError, + assert_database_at_head, + current_revision, + upgrade_database, +) + +__all__ = [ + "SCHEMA_HEAD", + "MigrationError", + "assert_database_at_head", + "current_revision", + "upgrade_database", +] diff --git a/backend/app/schema_migrations/runner.py b/backend/app/schema_migrations/runner.py new file mode 100644 index 0000000..827a747 --- /dev/null +++ b/backend/app/schema_migrations/runner.py @@ -0,0 +1,114 @@ +"""Small, deterministic forward-only migration runner for SQLite and PostgreSQL.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Callable + +import sqlalchemy as sa +from sqlalchemy.engine import Connection, Engine + +from .versions import v20260830_0001 + + +class MigrationError(RuntimeError): + """Raised when migration history or schema state is unsafe.""" + + +@dataclass(frozen=True) +class Migration: + revision: str + down_revision: str | None + upgrade: Callable[[Connection], None] + validate: Callable[[Connection], None] + + +MIGRATIONS = ( + Migration( + revision=v20260830_0001.revision, + down_revision=v20260830_0001.down_revision, + upgrade=v20260830_0001.upgrade, + validate=v20260830_0001.validate, + ), +) +SCHEMA_HEAD = MIGRATIONS[-1].revision + +_history_metadata = sa.MetaData() +_history = sa.Table( + "calorie_schema_revision", + _history_metadata, + sa.Column("revision", sa.String(64), primary_key=True), + sa.Column("down_revision", sa.String(64), nullable=True), + sa.Column("applied_at", sa.DateTime(), nullable=False), + sa.Column("approval_reference", sa.String(120), nullable=True), +) + + +def _applied_revisions(connection: Connection) -> list[str]: + if not sa.inspect(connection).has_table(_history.name): + return [] + statement = sa.select(_history.c.revision).order_by( + _history.c.applied_at, + _history.c.revision, + ) + return list(connection.execute(statement).scalars()) + + +def _validate_history(applied: list[str]) -> None: + expected_prefix = [migration.revision for migration in MIGRATIONS[: len(applied)]] + if applied != expected_prefix: + raise MigrationError( + "Database migration history is unknown, duplicated, or not a valid prefix" + ) + + +def current_revision(engine: Engine) -> str | None: + with engine.connect() as connection: + applied = _applied_revisions(connection) + _validate_history(applied) + return applied[-1] if applied else None + + +def upgrade_database(engine: Engine, *, approval_reference: str | None = None) -> str: + """Apply each unapplied forward migration in one ordered pass.""" + with engine.begin() as connection: + _history_metadata.create_all(connection, checkfirst=True) + applied = _applied_revisions(connection) + _validate_history(applied) + + for migration in MIGRATIONS[len(applied) :]: + expected_parent = applied[-1] if applied else None + if migration.down_revision != expected_parent: + raise MigrationError( + f"Migration {migration.revision} does not follow {expected_parent}" + ) + migration.upgrade(connection) + migration.validate(connection) + connection.execute( + _history.insert().values( + revision=migration.revision, + down_revision=migration.down_revision, + applied_at=datetime.now(UTC).replace(tzinfo=None), + approval_reference=approval_reference, + ) + ) + applied.append(migration.revision) + + for migration in MIGRATIONS: + migration.validate(connection) + return applied[-1] + + +def assert_database_at_head(engine: Engine) -> None: + """Fail closed unless all known migrations are recorded and the schema matches.""" + with engine.connect() as connection: + applied = _applied_revisions(connection) + _validate_history(applied) + if not applied or applied[-1] != SCHEMA_HEAD: + current = applied[-1] if applied else "unversioned" + raise MigrationError( + f"Database revision {current} is not at required head {SCHEMA_HEAD}" + ) + for migration in MIGRATIONS: + migration.validate(connection) diff --git a/backend/app/schema_migrations/versions/__init__.py b/backend/app/schema_migrations/versions/__init__.py new file mode 100644 index 0000000..49f648a --- /dev/null +++ b/backend/app/schema_migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Ordered, immutable CalorieApp database revisions.""" diff --git a/backend/app/schema_migrations/versions/v20260830_0001.py b/backend/app/schema_migrations/versions/v20260830_0001.py new file mode 100644 index 0000000..bade8ea --- /dev/null +++ b/backend/app/schema_migrations/versions/v20260830_0001.py @@ -0,0 +1,373 @@ +"""Baseline the complete pre-public CalorieApp schema. + +Revision: 20260830_0001 +Parent: none +""" + +from __future__ import annotations + +import sqlalchemy as sa +from sqlalchemy.engine import Connection + + +revision = "20260830_0001" +down_revision = None + +metadata = sa.MetaData() + +calorieappuser = sa.Table( + "calorieappuser", + metadata, + sa.Column("id", sa.String(), primary_key=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("status", sa.String(), nullable=False), +) + +food_log = sa.Table( + "food_log", + metadata, + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("product_name", sa.String(120), nullable=False), + sa.Column("calories", sa.Float(), nullable=False), + sa.Column("protein", sa.Float(), nullable=False), + sa.Column("fat", sa.Float(), nullable=False), + sa.Column("carbohydrates", sa.Float(), nullable=False), + sa.Column("portion_percentage", sa.Float(), nullable=True), + sa.Column("barcode", sa.String(64), nullable=True), + sa.Column("image_url", sa.String(500), nullable=True), + sa.Column("brand", sa.String(160), nullable=True), + sa.Column("serving_size", sa.String(80), nullable=True), + sa.Column("nutri_score", sa.String(2), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column( + "owner_id", + sa.String(), + sa.ForeignKey("calorieappuser.id", name="fk_food_log_owner_id_calorieappuser"), + nullable=True, + ), +) + +externalidentity = sa.Table( + "externalidentity", + metadata, + sa.Column("id", sa.String(), primary_key=True), + sa.Column( + "calorieapp_user_id", + sa.String(), + sa.ForeignKey("calorieappuser.id", name="fk_externalidentity_user"), + nullable=False, + ), + sa.Column("provider", sa.String(50), nullable=False), + sa.Column("external_subject", sa.String(255), nullable=False), + sa.Column("xrpl_address", sa.String(34), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("last_verified_at", sa.DateTime(), nullable=False), + sa.UniqueConstraint( + "provider", + "external_subject", + name="uq_externalidentity_provider_subject", + ), +) +sa.Index("ix_externalidentity_provider", externalidentity.c.provider) +sa.Index("ix_externalidentity_external_subject", externalidentity.c.external_subject) + +authorizationcode = sa.Table( + "authorizationcode", + metadata, + sa.Column("id", sa.String(), primary_key=True), + sa.Column("code_hash", sa.String(255), nullable=False, unique=True), + sa.Column("external_subject", sa.String(255), nullable=False), + sa.Column("xrpl_address", sa.String(34), nullable=True), + sa.Column("state", sa.String(255), nullable=False), + sa.Column("login_session_id", sa.String(255), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("used_at", sa.DateTime(), nullable=True), + sa.Column("used_by_ip", sa.String(45), nullable=True), +) + +pendingloginstate = sa.Table( + "pendingloginstate", + metadata, + sa.Column("id", sa.String(), primary_key=True), + sa.Column("state_hash", sa.String(64), nullable=False), + sa.Column("status", sa.String(20), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("consumed_at", sa.DateTime(), nullable=True), + sa.Column("post_login_redirect", sa.String(255), nullable=True), +) +sa.Index("ix_pendingloginstate_state_hash", pendingloginstate.c.state_hash, unique=True) +sa.Index("ix_pendingloginstate_status", pendingloginstate.c.status) + +pendingloginlocale = sa.Table( + "pendingloginlocale", + metadata, + sa.Column("id", sa.String(), primary_key=True), + sa.Column("state_hash", sa.String(64), nullable=False), + sa.Column("locale", sa.String(16), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=False), +) +sa.Index("ix_pendingloginlocale_state_hash", pendingloginlocale.c.state_hash, unique=True) +sa.Index("ix_pendingloginlocale_expires_at", pendingloginlocale.c.expires_at) + +originloginhandoff = sa.Table( + "originloginhandoff", + metadata, + sa.Column("id", sa.String(), primary_key=True), + sa.Column("state_hash", sa.String(64), nullable=False), + sa.Column("handoff_token_hash", sa.String(64), nullable=False), + sa.Column("status", sa.String(20), nullable=False), + sa.Column( + "calorieapp_user_id", + sa.String(), + sa.ForeignKey("calorieappuser.id", name="fk_originloginhandoff_user"), + nullable=True, + ), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("completed_at", sa.DateTime(), nullable=True), + sa.Column("claimed_at", sa.DateTime(), nullable=True), + sa.Column("failure_code", sa.String(40), nullable=True), +) +sa.Index("ix_originloginhandoff_state_hash", originloginhandoff.c.state_hash, unique=True) +sa.Index("ix_originloginhandoff_handoff_token_hash", originloginhandoff.c.handoff_token_hash) +sa.Index("ix_originloginhandoff_status", originloginhandoff.c.status) +sa.Index("ix_originloginhandoff_calorieapp_user_id", originloginhandoff.c.calorieapp_user_id) +sa.Index("ix_originloginhandoff_expires_at", originloginhandoff.c.expires_at) + +authsession = sa.Table( + "authsession", + metadata, + sa.Column("id", sa.String(), primary_key=True), + sa.Column("session_token_hash", sa.String(64), nullable=False), + sa.Column( + "calorieapp_user_id", + sa.String(), + sa.ForeignKey("calorieappuser.id", name="fk_authsession_user"), + nullable=False, + ), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("last_seen_at", sa.DateTime(), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("revoked_at", sa.DateTime(), nullable=True), + sa.Column( + "replaced_by_session_id", + sa.String(), + sa.ForeignKey("authsession.id", name="fk_authsession_replacement"), + nullable=True, + ), +) +sa.Index("ix_authsession_session_token_hash", authsession.c.session_token_hash, unique=True) +sa.Index("ix_authsession_calorieapp_user_id", authsession.c.calorieapp_user_id) +sa.Index("ix_authsession_last_seen_at", authsession.c.last_seen_at) +sa.Index("ix_authsession_expires_at", authsession.c.expires_at) +sa.Index("ix_authsession_revoked_at", authsession.c.revoked_at) + +bridgeauthnonce = sa.Table( + "bridgeauthnonce", + metadata, + sa.Column("id", sa.String(), primary_key=True), + sa.Column("client_id", sa.String(120), nullable=False), + sa.Column("nonce_hash", sa.String(64), nullable=False), + sa.Column("context", sa.String(60), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.UniqueConstraint( + "client_id", + "nonce_hash", + "context", + name="uq_bridgeauthnonce_context_nonce", + ), +) +sa.Index("ix_bridgeauthnonce_client_id", bridgeauthnonce.c.client_id) +sa.Index("ix_bridgeauthnonce_nonce_hash", bridgeauthnonce.c.nonce_hash) +sa.Index("ix_bridgeauthnonce_context", bridgeauthnonce.c.context) +sa.Index("ix_bridgeauthnonce_expires_at", bridgeauthnonce.c.expires_at) + +_required_food_log_columns = { + "id", + "product_name", + "calories", + "protein", + "fat", + "carbohydrates", + "created_at", +} + + +def _food_log_has_owner_foreign_key(connection: Connection) -> bool: + return any( + foreign_key.get("constrained_columns") == ["owner_id"] + and foreign_key.get("referred_table") == "calorieappuser" + and foreign_key.get("referred_columns") == ["id"] + for foreign_key in sa.inspect(connection).get_foreign_keys("food_log") + ) + + +def _validate_legacy_owner_links( + connection: Connection, + existing_columns: set[str] | None = None, +) -> None: + if existing_columns is not None and "owner_id" not in existing_columns: + return + invalid_owner = connection.execute( + sa.text( + "SELECT food_log.owner_id FROM food_log " + "LEFT JOIN calorieappuser ON calorieappuser.id = food_log.owner_id " + "WHERE food_log.owner_id IS NOT NULL AND calorieappuser.id IS NULL LIMIT 1" + ) + ).first() + if invalid_owner is not None: + raise RuntimeError( + "food_log contains an owner_id without a matching calorieappuser; " + "migration stopped without inventing ownership" + ) + + +def _rebuild_sqlite_food_log(connection: Connection, existing_columns: set[str]) -> None: + _validate_legacy_owner_links(connection, existing_columns) + temporary_name = "food_log_migration_20260830_0001" + quote = connection.dialect.identifier_preparer.quote + temp_metadata = sa.MetaData() + sa.Table("calorieappuser", temp_metadata, sa.Column("id", sa.String(), primary_key=True)) + temporary = food_log.to_metadata(temp_metadata, name=temporary_name) + temporary.create(connection, checkfirst=False) + + copied_columns = [column.name for column in food_log.columns if column.name in existing_columns] + quoted_columns = ", ".join(quote(column) for column in copied_columns) + connection.execute( + sa.text( + f"INSERT INTO {quote(temporary_name)} ({quoted_columns}) " + f"SELECT {quoted_columns} FROM {quote('food_log')}" + ) + ) + connection.execute(sa.text(f"DROP TABLE {quote('food_log')}")) + connection.execute( + sa.text(f"ALTER TABLE {quote(temporary_name)} RENAME TO {quote('food_log')}") + ) + + +def _upgrade_existing_food_log(connection: Connection) -> None: + inspector = sa.inspect(connection) + existing_columns = {str(column["name"]) for column in inspector.get_columns("food_log")} + missing_required = _required_food_log_columns - existing_columns + if missing_required: + missing = ", ".join(sorted(missing_required)) + raise RuntimeError(f"Legacy food_log is missing required columns: {missing}") + + expected_columns = {column.name for column in food_log.columns} + needs_upgrade = existing_columns != expected_columns or not _food_log_has_owner_foreign_key(connection) + if not needs_upgrade: + return + + if connection.dialect.name == "sqlite": + _rebuild_sqlite_food_log(connection, existing_columns) + return + + quote = connection.dialect.identifier_preparer.quote + postgresql_types = { + "portion_percentage": "DOUBLE PRECISION", + "barcode": "VARCHAR(64)", + "image_url": "VARCHAR(500)", + "brand": "VARCHAR(160)", + "serving_size": "VARCHAR(80)", + "nutri_score": "VARCHAR(2)", + "owner_id": "VARCHAR", + } + for column_name in sorted(expected_columns - existing_columns): + connection.execute( + sa.text( + f"ALTER TABLE {quote('food_log')} ADD COLUMN {quote(column_name)} " + f"{postgresql_types[column_name]}" + ) + ) + if not _food_log_has_owner_foreign_key(connection): + _validate_legacy_owner_links(connection) + connection.execute( + sa.text( + "ALTER TABLE food_log ADD CONSTRAINT fk_food_log_owner_id_calorieappuser " + "FOREIGN KEY (owner_id) REFERENCES calorieappuser (id)" + ) + ) + + +def _ensure_declared_indexes(connection: Connection) -> None: + inspector = sa.inspect(connection) + for table in metadata.tables.values(): + existing = {index["name"] for index in inspector.get_indexes(table.name)} + for index in table.indexes: + if index.name not in existing: + index.create(connection, checkfirst=True) + + +def upgrade(connection: Connection) -> None: + """Create the baseline or safely adopt the one supported legacy table shape.""" + food_log_existed = sa.inspect(connection).has_table("food_log") + metadata.create_all(connection, checkfirst=True) + if food_log_existed: + _upgrade_existing_food_log(connection) + _ensure_declared_indexes(connection) + + +def _required_unique_column_sets(table: sa.Table) -> set[tuple[str, ...]]: + required: set[tuple[str, ...]] = set() + for constraint in table.constraints: + if isinstance(constraint, sa.UniqueConstraint): + required.add(tuple(column.name for column in constraint.columns)) + for index in table.indexes: + if index.unique: + required.add(tuple(column.name for column in index.columns)) + return required + + +def validate(connection: Connection) -> None: + """Detect missing, extra or constraint-drifted objects for this revision.""" + inspector = sa.inspect(connection) + for table in metadata.sorted_tables: + if not inspector.has_table(table.name): + raise RuntimeError(f"Required table is missing: {table.name}") + + actual_columns = {str(column["name"]) for column in inspector.get_columns(table.name)} + expected_columns = {column.name for column in table.columns} + if actual_columns != expected_columns: + raise RuntimeError(f"Schema column drift detected for table {table.name}") + + actual_indexes = {index["name"] for index in inspector.get_indexes(table.name)} + expected_indexes = {index.name for index in table.indexes} + if not expected_indexes.issubset(actual_indexes): + raise RuntimeError(f"Schema index drift detected for table {table.name}") + + actual_unique_sets = { + tuple(item["column_names"]) + for item in inspector.get_unique_constraints(table.name) + if item.get("column_names") + } + actual_unique_sets.update( + tuple(item["column_names"]) + for item in inspector.get_indexes(table.name) + if item.get("unique") and item.get("column_names") + ) + if not _required_unique_column_sets(table).issubset(actual_unique_sets): + raise RuntimeError(f"Schema uniqueness drift detected for table {table.name}") + + expected_foreign_keys = { + ( + tuple(constraint.column_keys), + constraint.referred_table.name, + tuple(element.column.name for element in constraint.elements), + ) + for constraint in table.foreign_key_constraints + } + actual_foreign_keys = { + ( + tuple(item["constrained_columns"]), + item["referred_table"], + tuple(item["referred_columns"]), + ) + for item in inspector.get_foreign_keys(table.name) + } + if not expected_foreign_keys.issubset(actual_foreign_keys): + raise RuntimeError(f"Schema foreign-key drift detected for table {table.name}") diff --git a/backend/tests/test_data_safety_contract.py b/backend/tests/test_data_safety_contract.py index 36deef2..1cf3286 100644 --- a/backend/tests/test_data_safety_contract.py +++ b/backend/tests/test_data_safety_contract.py @@ -119,6 +119,130 @@ def test_platform_budget_prevents_duplicate_core_services() -> None: assert platforms["roles"]["optional_ledger_reference"] == "XRPL only" +def test_core_stays_free_while_separate_value_added_services_remain_possible() -> None: + contract = _load_json("data-safety.json") + cost = contract["cost_sustainability"] + access = contract["free_core_and_optional_services"] + web3 = contract["web3_cost_boundary"] + + assert cost["core_ecosystem_end_user_price"] == "free" + assert cost["additional_recurring_app_hosting_subscription_allowed"] is False + assert cost["additional_recurring_database_hosting_subscription_allowed"] is False + assert cost["automatic_infrastructure_paid_upgrade_allowed"] is False + assert cost["paid_database_capability_required_for_core"] is False + assert cost["paid_web3_capability_required_for_core"] is False + assert cost["third_party_free_tier_permanence_claim_allowed"] is False + assert cost["new_onboarding_must_pause_before_data_safety_or_quota_failure"] is True + assert cost["existing_user_history_may_be_deleted_to_stay_free"] is False + assert cost["no_additional_cost_exit_plan_required_before_public_onboarding"] is True + assert access["optional_value_added_services_may_be_paid"] is True + assert access["core_data_rights_may_be_paywalled"] is False + assert access["identity_access_may_be_paywalled"] is False + assert access["premium_feature_may_enable_automatic_financial_action"] is False + assert web3["bigchaindb_selected"] is False + assert web3["automatic_fee_bearing_action_allowed"] is False + + +def test_external_developer_access_is_brokered_scoped_and_disabled() -> None: + access = _load_json("data-safety.json")["ecosystem_developer_access"] + + assert access["status"] == "future-candidate-disabled-by-default" + assert access["official_identity_bridge_operator"] == ( + "Pieter Hendrikse and CalorieToken" + ) + assert ( + access["identity_bridge_foundation_control_remains_with_current_operator"] + is True + ) + assert access["reviewed_ecosystem_linking_interface_allowed"] is True + assert ( + access["ecosystem_participant_may_administer_identity_bridge_foundation"] + is False + ) + assert ( + access["open_specs_contracts_and_local_conformance_tools_must_remain_free"] + is True + ) + assert access["registered_and_reviewed_client_required"] is True + assert access["explicit_user_consent_required_per_purpose"] is True + assert access["least_privilege_scopes_required"] is True + assert access["pairwise_pseudonymous_subject_required"] is True + assert access["short_lived_audience_restricted_tokens_required"] is True + assert access["direct_identity_database_access_allowed"] is False + assert access["direct_session_store_access_allowed"] is False + assert access["password_or_identity_bridge_session_disclosure_allowed"] is False + assert access["donation_or_food_history_scope_enabled_by_default"] is False + assert access["payment_may_grant_broader_personal_data_scope"] is False + + +def test_official_products_and_separate_ecosystem_have_a_reuse_boundary() -> None: + boundary = _load_json("data-safety.json")["product_ecosystem_boundary"] + + assert boundary["official_product_operator"] == ( + "Pieter Hendrikse with the designated Gallery Token development team" + ) + assert "gallery-token-website-and-official-wordpress-presentation" in boundary[ + "official_product_layer" + ] + assert ( + "official-calorieapp-identity-bridge-service-and-production-configuration" + in boundary["official_product_layer"] + ) + assert "approved-extension-interfaces" in boundary["separate_ecosystem_layer"] + assert boundary["ecosystem_is_part_of_official_product_layer"] is False + assert boundary["ecosystem_participation_grants_official_product_control"] is False + assert boundary["public_source_visibility_is_reuse_permission"] is False + assert boundary["identity_bridge_component_declared_licence"] == "GPL-2.0-or-later" + assert boundary["identity_bridge_code_licence_grants_official_service_access"] is False + assert ( + boundary["identity_bridge_code_licence_grants_brand_or_official_status"] + is False + ) + assert ( + boundary["official_identity_bridge_release_and_service_control_remains_with_operator"] + is True + ) + assert boundary["legal_ownership_or_third_party_rights_adjudicated_by_this_contract"] is False + + +def test_official_app_control_and_parallel_ecosystem_are_separate() -> None: + continuity = _load_json("data-safety.json")["ecosystem_continuity"] + + assert continuity["official_calorieapp_active_operator"] == ( + "Pieter Hendrikse and CalorieToken" + ) + assert continuity["official_app_management_remains_with_current_operator"] is True + assert continuity["official_brand_and_release_authority_open_by_default"] is False + assert continuity["parallel_open_ecosystem_layer_required"] is True + assert continuity["open_ecosystem_scope"] == [ + "schemas", + "contracts", + "data-formats", + "verification-specifications", + "extension-interfaces", + ] + assert continuity["external_contribution_auto_accepted_into_official_app"] is False + assert continuity["official_integration_requires_operator_review"] is True + assert continuity["emergency_continuity_is_active_control_transfer"] is False + assert continuity["fork_may_claim_official_calorieapp_or_calorietoken_brand"] is False + assert ( + continuity[ + "open_or_published_ecosystem_layer_overrides_component_licensing" + ] + is False + ) + assert continuity["single_person_operational_dependency_allowed_for_public_release"] is False + assert continuity["open_schema_and_contracts_required"] is True + assert continuity["reproducible_build_and_provider_neutral_deployment_required"] is True + assert continuity["versioned_export_and_import_required"] is True + assert continuity["user_controlled_encrypted_backup_required_before_continuity_claim"] is True + assert continuity["public_xrpl_anchors_remain_independently_verifiable"] is True + assert continuity["confidential_operator_succession_runbook_required"] is True + assert continuity["secrets_or_personal_data_in_public_runbook_allowed"] is False + assert continuity["automatic_dead_man_switch_allowed"] is False + assert continuity["automatic_credential_or_asset_transfer_allowed"] is False + + def test_responsible_automation_keeps_human_release_and_privacy_gates() -> None: automation = _load_json("data-safety.json")["responsible_automation"] @@ -141,6 +265,8 @@ def test_all_required_durable_data_release_gates_are_explicit_and_blocking() -> "provider_neutral_postgresql_configuration", "production_sqlite_fail_closed", "formal_schema_migrations", + "zero_additional_cost_capacity_and_exit_plan", + "ecosystem_operator_succession_and_handover", "owner_isolation", "restart_persistence", "redeploy_persistence", @@ -156,7 +282,10 @@ def test_all_required_durable_data_release_gates_are_explicit_and_blocking() -> assert all(gate["release_blocking"] is True for gate in gates.values()) assert all(gate["status"] in matrix["statuses"] for gate in gates.values()) assert gates["owner_isolation"]["status"] == "verified" - assert gates["production_sqlite_fail_closed"]["status"] == "not_started" + assert gates["production_sqlite_fail_closed"]["status"] == "verified" + assert gates["formal_schema_migrations"]["status"] == "verified" + assert gates["zero_additional_cost_capacity_and_exit_plan"]["status"] == "partial" + assert gates["ecosystem_operator_succession_and_handover"]["status"] == "partial" assert gates["retention_policy"]["status"] == "decision_required" assert matrix["release_state"] == "blocked" diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py index f3aee23..b719963 100644 --- a/backend/tests/test_database.py +++ b/backend/tests/test_database.py @@ -1,4 +1,25 @@ -from app.database import _normalize_database_url +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest +from sqlalchemy import inspect +from sqlmodel import SQLModel, create_engine +from sqlmodel.pool import StaticPool + +from app import models # noqa: F401 +from app.database import ( + _normalize_database_url, + database_readiness, + validate_database_environment, +) +from app.schema_migrations import ( + SCHEMA_HEAD, + MigrationError, + assert_database_at_head, + current_revision, + upgrade_database, +) def test_normalize_render_postgresql_url_uses_psycopg_v3() -> None: @@ -18,3 +39,180 @@ def test_normalize_legacy_postgres_url_uses_psycopg_v3() -> None: def test_normalize_database_url_preserves_explicit_driver_and_sqlite() -> None: assert _normalize_database_url("postgresql+psycopg://example.test/db") == "postgresql+psycopg://example.test/db" assert _normalize_database_url("sqlite:///calorieapp.db") == "sqlite:///calorieapp.db" + + +@pytest.mark.parametrize("environment", ["local", "test"]) +def test_sqlite_is_allowed_only_for_explicit_local_or_test(environment: str) -> None: + assert ( + validate_database_environment( + "sqlite:///calorieapp.db", + environment, + ) + == environment + ) + + +@pytest.mark.parametrize("environment", ["staging", "production"]) +def test_sqlite_fails_closed_outside_local_and_test(environment: str) -> None: + with pytest.raises(RuntimeError, match="SQLite is only allowed"): + validate_database_environment("sqlite:///calorieapp.db", environment) + + +def test_explicit_database_url_requires_explicit_environment() -> None: + with pytest.raises(RuntimeError, match="CALORIEAPP_ENV must be set"): + validate_database_environment( + "postgresql://user:password@example.test/calorieapp", + None, + ) + + +def test_postgresql_is_accepted_for_staging_and_production() -> None: + for environment in ("staging", "production"): + assert ( + validate_database_environment( + "postgresql://user:password@example.test/calorieapp", + environment, + ) + == environment + ) + + +def _memory_engine(): + return create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + + +def _schema_signature(target_engine) -> dict[str, dict[str, object]]: + inspector = inspect(target_engine) + ignored_tables = {"calorie_schema_revision"} + signature: dict[str, dict[str, object]] = {} + for table_name in sorted(set(inspector.get_table_names()) - ignored_tables): + unique_sets = { + tuple(item["column_names"]) + for item in inspector.get_unique_constraints(table_name) + if item.get("column_names") + } + unique_sets.update( + tuple(item["column_names"]) + for item in inspector.get_indexes(table_name) + if item.get("unique") and item.get("column_names") + ) + signature[table_name] = { + "columns": tuple(column["name"] for column in inspector.get_columns(table_name)), + "foreign_keys": { + ( + tuple(item["constrained_columns"]), + item["referred_table"], + tuple(item["referred_columns"]), + ) + for item in inspector.get_foreign_keys(table_name) + }, + "indexes": { + (item["name"], tuple(item["column_names"]), bool(item["unique"])) + for item in inspector.get_indexes(table_name) + }, + "unique_sets": unique_sets, + } + return signature + + +def test_versioned_baseline_matches_current_sqlmodel_schema() -> None: + model_engine = _memory_engine() + migration_engine = _memory_engine() + try: + SQLModel.metadata.create_all(model_engine) + assert upgrade_database(migration_engine) == SCHEMA_HEAD + assert _schema_signature(migration_engine) == _schema_signature(model_engine) + finally: + model_engine.dispose() + migration_engine.dispose() + + +def test_migration_is_idempotent_and_records_one_revision() -> None: + test_engine = _memory_engine() + try: + assert upgrade_database(test_engine) == SCHEMA_HEAD + assert upgrade_database(test_engine) == SCHEMA_HEAD + assert current_revision(test_engine) == SCHEMA_HEAD + with test_engine.connect() as connection: + count = connection.exec_driver_sql( + "SELECT COUNT(*) FROM calorie_schema_revision" + ).scalar_one() + assert count == 1 + finally: + test_engine.dispose() + + +def test_legacy_food_log_is_preserved_and_receives_owner_foreign_key() -> None: + test_engine = _memory_engine() + try: + with test_engine.begin() as connection: + connection.exec_driver_sql( + """ + CREATE TABLE food_log ( + id INTEGER PRIMARY KEY, + product_name VARCHAR(120) NOT NULL, + calories FLOAT NOT NULL, + protein FLOAT NOT NULL, + fat FLOAT NOT NULL, + carbohydrates FLOAT NOT NULL, + created_at DATETIME NOT NULL + ) + """ + ) + connection.exec_driver_sql( + """ + INSERT INTO food_log + (id, product_name, calories, protein, fat, carbohydrates, created_at) + VALUES + (1, 'Legacy Preserved', 123, 4, 5, 6, '2026-01-01 00:00:00') + """ + ) + + upgrade_database(test_engine) + + with test_engine.connect() as connection: + row = connection.exec_driver_sql( + "SELECT id, product_name, owner_id FROM food_log WHERE id = 1" + ).one() + assert tuple(row) == (1, "Legacy Preserved", None) + owner_foreign_keys = [ + item + for item in inspect(test_engine).get_foreign_keys("food_log") + if item["constrained_columns"] == ["owner_id"] + ] + assert len(owner_foreign_keys) == 1 + assert owner_foreign_keys[0]["referred_table"] == "calorieappuser" + finally: + test_engine.dispose() + + +def test_readiness_is_read_only_and_requires_schema_head() -> None: + test_engine = _memory_engine() + try: + with pytest.raises(MigrationError, match="not at required head"): + assert_database_at_head(test_engine) + upgrade_database(test_engine) + assert database_readiness(test_engine) == { + "status": "ready", + "database_revision": SCHEMA_HEAD, + } + finally: + test_engine.dispose() + + +def test_migration_history_stores_approved_reference_without_secret_data() -> None: + test_engine = _memory_engine() + try: + upgrade_database(test_engine, approval_reference="CHANGE-2026-001") + with test_engine.connect() as connection: + applied_at, reference = connection.exec_driver_sql( + "SELECT applied_at, approval_reference FROM calorie_schema_revision" + ).one() + assert datetime.fromisoformat(str(applied_at)).replace(tzinfo=UTC).year == 2026 + assert reference == "CHANGE-2026-001" + finally: + test_engine.dispose() diff --git a/backend/tests/test_endpoints.py b/backend/tests/test_endpoints.py index 2bac8cd..0ed384c 100644 --- a/backend/tests/test_endpoints.py +++ b/backend/tests/test_endpoints.py @@ -58,6 +58,16 @@ def test_health_response_schema(client: TestClient) -> None: assert data["service"] == "calorieapp-backend" +def test_readiness_checks_database_revision(client: TestClient) -> None: + response = client.get("/ready") + assert response.status_code == 200 + assert response.json() == { + "status": "ready", + "database_revision": "20260830_0001", + "service": "calorieapp-backend", + } + + def test_health_is_not_marked_as_private_session_data(client: TestClient) -> None: response = client.get("/health") assert response.headers.get("cache-control") != "no-store" diff --git a/backend/tests/test_provenance_contract.py b/backend/tests/test_provenance_contract.py index 3942f13..62dd5eb 100644 --- a/backend/tests/test_provenance_contract.py +++ b/backend/tests/test_provenance_contract.py @@ -27,6 +27,15 @@ def test_provenance_is_future_ready_without_becoming_a_launch_feature() -> None: assert platform["separate_graph_database_required"] is False assert platform["additional_blockchain_required"] is False assert platform["ipfs_required"] is False + assert platform["bigchaindb_required"] is False + + cost = _contract()["cost_boundary"] + assert cost["paid_database_feature_required_for_core"] is False + assert cost["paid_web3_feature_required_for_core"] is False + assert cost["existing_transaction_verification_creates_new_ledger_fee"] is False + assert cost["new_fee_bearing_transaction_requires_explicit_user_authorization"] is True + assert cost["automatic_fee_bearing_action_allowed"] is False + assert cost["optional_separately_reviewed_value_added_services_may_be_paid"] is True def test_provenance_automation_is_scoped_idempotent_and_disabled_by_default() -> None: diff --git a/contracts/data-safety/v1/data-safety.json b/contracts/data-safety/v1/data-safety.json index 9d8d00e..ca9b3cb 100644 --- a/contracts/data-safety/v1/data-safety.json +++ b/contracts/data-safety/v1/data-safety.json @@ -183,6 +183,149 @@ "new_provider_requires_architecture_record": true, "backup_separation_exception": "allowed only when required for recoverability and documented before use" }, + "cost_sustainability": { + "core_ecosystem_end_user_price": "free", + "additional_recurring_app_hosting_subscription_allowed": false, + "additional_recurring_database_hosting_subscription_allowed": false, + "automatic_infrastructure_paid_upgrade_allowed": false, + "paid_database_capability_required_for_core": false, + "paid_web3_capability_required_for_core": false, + "existing_paid_wordpress_and_development_tools_outside_boundary": true, + "third_party_free_tier_permanence_claim_allowed": false, + "unbounded_usage_at_zero_infrastructure_cost_claim_allowed": false, + "provider_neutral_schema_and_export_required": true, + "capacity_monitoring_required": true, + "new_onboarding_must_pause_before_data_safety_or_quota_failure": true, + "existing_user_history_may_be_deleted_to_stay_free": false, + "free_tier_expiry_may_define_retention": false, + "no_additional_cost_exit_plan_required_before_public_onboarding": true + }, + "free_core_and_optional_services": { + "free_core": [ + "identity-bridge-account-access", + "basic-food-search-and-logging", + "personal-history-access", + "personal-data-export-correction-and-erasure", + "core-localization-and-accessibility", + "basic-provenance-view", + "voluntary-existing-transaction-hash-verification" + ], + "optional_value_added_services_may_be_paid": true, + "examples_requiring_separate_future_approval": [ + "business-bulk-api", + "custom-integrations", + "advanced-business-analytics", + "professional-support" + ], + "core_data_rights_may_be_paywalled": false, + "identity_access_may_be_paywalled": false, + "premium_feature_may_enable_automatic_financial_action": false, + "pricing_and_entitlement_implementation_status": "not-started-separate-review" + }, + "ecosystem_developer_access": { + "status": "future-candidate-disabled-by-default", + "official_identity_bridge_operator": "Pieter Hendrikse and CalorieToken", + "identity_bridge_role": "operator-controlled gateway and trust boundary between the official CalorieApp and the parallel ecosystem", + "identity_bridge_foundation_control_remains_with_current_operator": true, + "reviewed_ecosystem_linking_interface_allowed": true, + "ecosystem_participant_may_administer_identity_bridge_foundation": false, + "open_specs_contracts_and_local_conformance_tools_must_remain_free": true, + "registered_and_reviewed_client_required": true, + "explicit_user_consent_required_per_purpose": true, + "least_privilege_scopes_required": true, + "pairwise_pseudonymous_subject_required": true, + "short_lived_audience_restricted_tokens_required": true, + "redirect_allowlist_and_client_revocation_required": true, + "direct_identity_database_access_allowed": false, + "direct_session_store_access_allowed": false, + "password_or_identity_bridge_session_disclosure_allowed": false, + "donation_or_food_history_scope_enabled_by_default": false, + "premium_managed_services_may_include": [ + "managed-sandbox", + "higher-rate-limits", + "integration-review", + "verified-client-status", + "professional-support" + ], + "payment_may_grant_broader_personal_data_scope": false, + "separate_product_privacy_security_and_legal_review_required_before_enablement": true + }, + "product_ecosystem_boundary": { + "status": "governance-baseline-reuse-not-generally-granted", + "official_product_operator": "Pieter Hendrikse with the designated Gallery Token development team", + "repository_rights_administration": "ICTHendrikse subject to the repository rights notices and third-party rights", + "official_product_layer": [ + "gallery-token-website-and-official-wordpress-presentation", + "official-gallery-token-and-calorieapp-web-applications", + "official-calorieapp-identity-bridge-service-and-production-configuration", + "official-application-databases-and-private-user-records", + "official-domains-releases-brands-and-historical-visual-identity" + ], + "separate_ecosystem_layer": [ + "published-interoperability-contracts", + "documented-data-formats", + "verification-and-provenance-specifications", + "approved-extension-interfaces", + "independently-operated-applications-that-do-not-claim-official-status" + ], + "ecosystem_is_part_of_official_product_layer": false, + "ecosystem_participation_grants_official_product_control": false, + "public_source_visibility_is_reuse_permission": false, + "default_repository_reuse_permission": "none-unless-an-explicit-component-licence-or-written-permission-applies", + "technology_reuse_requires": [ + "explicit-component-designation", + "applicable-licence-or-written-permission", + "security-and-conformance-review-for-managed-connections", + "separate-branding-without-official-status-claims" + ], + "identity_bridge_component_declared_licence": "GPL-2.0-or-later", + "identity_bridge_code_licence_grants_official_service_access": false, + "identity_bridge_code_licence_grants_brand_or_official_status": false, + "official_identity_bridge_release_and_service_control_remains_with_operator": true, + "open_or_published_interface_overrides_component_licensing": false, + "legal_ownership_or_third_party_rights_adjudicated_by_this_contract": false, + "detailed_document": "docs/PRODUCT_ECOSYSTEM_BOUNDARY.md" + }, + "web3_cost_boundary": { + "database_hashing_and_provenance_graph": "open-application-code-on-postgresql", + "existing_xrpl_transaction_read_and_verification": "no-new-ledger-transaction-required", + "new_xrpl_transaction_or_memo": "optional-user-authorized-and-subject-to-xrpl-network-fee", + "automatic_fee_bearing_action_allowed": false, + "bigchaindb_selected": false, + "bigchaindb_reason": "adds MongoDB, Tendermint and multi-node operations while duplicating an XRPL trust layer and weakening personal-data erasure" + }, + "ecosystem_continuity": { + "goal": "keep the official CalorieApp under its current operator while a parallel open ecosystem can grow and the technical foundation remains preservable if the operator becomes unavailable", + "official_calorieapp_active_operator": "Pieter Hendrikse and CalorieToken", + "official_app_management_remains_with_current_operator": true, + "official_brand_and_release_authority_open_by_default": false, + "parallel_open_ecosystem_layer_required": true, + "open_ecosystem_scope": [ + "schemas", + "contracts", + "data-formats", + "verification-specifications", + "extension-interfaces" + ], + "external_contribution_auto_accepted_into_official_app": false, + "official_integration_requires_operator_review": true, + "emergency_continuity_is_active_control_transfer": false, + "fork_may_claim_official_calorieapp_or_calorietoken_brand": false, + "open_or_published_ecosystem_layer_overrides_component_licensing": false, + "single_person_operational_dependency_allowed_for_public_release": false, + "open_schema_and_contracts_required": true, + "reproducible_build_and_provider_neutral_deployment_required": true, + "versioned_export_and_import_required": true, + "user_controlled_encrypted_backup_required_before_continuity_claim": true, + "public_xrpl_anchors_remain_independently_verifiable": true, + "confidential_operator_succession_runbook_required": true, + "domain_repository_database_and_key_recovery_roles_required": true, + "secrets_or_personal_data_in_public_runbook_allowed": false, + "automatic_dead_man_switch_allowed": false, + "automatic_credential_or_asset_transfer_allowed": false, + "legal_trademark_and_data_controller_succession_requires_separate_review": true, + "continuity_claim_status": "blocked-until-tested-handover-and-restore" + }, "responsible_automation": { "principle": "automate repeatable evidence and operations; require explicit approval for irreversible, privacy-sensitive, financial or public actions", "automated_by_default": [ diff --git a/contracts/data-safety/v1/release-test-matrix.json b/contracts/data-safety/v1/release-test-matrix.json index 86f49ca..63c239d 100644 --- a/contracts/data-safety/v1/release-test-matrix.json +++ b/contracts/data-safety/v1/release-test-matrix.json @@ -12,15 +12,37 @@ }, { "id": "production_sqlite_fail_closed", - "status": "not_started", + "status": "verified", "release_blocking": true, - "evidence": [] + "evidence": ["backend/app/database.py", "backend/tests/test_database.py"] }, { "id": "formal_schema_migrations", - "status": "not_started", + "status": "verified", "release_blocking": true, - "evidence": [] + "evidence": [ + "backend/app/schema_migrations/runner.py", + "backend/app/schema_migrations/versions/v20260830_0001.py", + "backend/tests/test_database.py" + ] + }, + { + "id": "zero_additional_cost_capacity_and_exit_plan", + "status": "partial", + "release_blocking": true, + "evidence": [ + "contracts/data-safety/v1/data-safety.json", + "docs/DURABLE_DATA_FOUNDATION.md" + ] + }, + { + "id": "ecosystem_operator_succession_and_handover", + "status": "partial", + "release_blocking": true, + "evidence": [ + "contracts/data-safety/v1/data-safety.json", + "docs/ECOSYSTEM_CONTINUITY.md" + ] }, { "id": "owner_isolation", diff --git a/contracts/provenance/v1/traceability.json b/contracts/provenance/v1/traceability.json index 4039ba8..7fb1c23 100644 --- a/contracts/provenance/v1/traceability.json +++ b/contracts/provenance/v1/traceability.json @@ -18,8 +18,18 @@ "separate_graph_database_required": false, "additional_blockchain_required": false, "ipfs_required": false, + "bigchaindb_required": false, "xrpl_role": "optional validated transaction anchor only" }, + "cost_boundary": { + "paid_database_feature_required_for_core": false, + "paid_web3_feature_required_for_core": false, + "hashing_and_graph_storage": "application code and primary PostgreSQL", + "existing_transaction_verification_creates_new_ledger_fee": false, + "new_fee_bearing_transaction_requires_explicit_user_authorization": true, + "automatic_fee_bearing_action_allowed": false, + "optional_separately_reviewed_value_added_services_may_be_paid": true + }, "automation_boundary": { "feature_flag_default": "disabled", "explicit_purpose_scoped_link_request_required": true, diff --git a/docs/BIGCHAINDB_ASSESSMENT.md b/docs/BIGCHAINDB_ASSESSMENT.md new file mode 100644 index 0000000..e9a3bb5 --- /dev/null +++ b/docs/BIGCHAINDB_ASSESSMENT.md @@ -0,0 +1,45 @@ +# BigchainDB assessment + +Assessment date: 2026-08-30. Decision: not selected for CalorieApp's primary or +provenance database. + +## Why it was considered + +BigchainDB presents an asset-oriented, signed and immutable data model that is +closer to Web3 terminology than a relational database. That makes it an +understandable candidate for food provenance and hash-linked records. + +## Why it is not selected + +- The official deployment consists of BigchainDB Server, MongoDB and + Tendermint on every node. +- Meaningful decentralization requires a governed consortium and several + independently operated nodes; one project-controlled node is still a central + service. +- The latest official GitHub release is v2.2.2 from 2020 and the latest commit + on the main repository is from 2022. This is not an acceptable maintenance + posture for new personal production data. +- Replication multiplies hosting, monitoring, recovery and operator work. Open + source licensing does not make those operational resources permanently free. +- Immutable personal records complicate purpose limitation, correction and + erasure. CalorieApp needs private history to remain exportable and deletable. +- XRPL already supplies the project's public consensus and transaction hashes. + Adding BigchainDB would duplicate a trust layer rather than improve the + initial user workflow. + +## Selected Web3-compatible path + +Private and mutable records use provider-neutral PostgreSQL. Provenance events +are append-only and content-addressed in application code, forming a directed +acyclic graph in the same database. A future voluntary link can anchor an event +or record to an existing validated XRPL transaction hash without publishing the +private contents. New fee-bearing transactions remain optional and require +explicit user authorization. + +Primary references: + +- [BigchainDB node software](https://docs.bigchaindb.com/en/latest/installation/node-setup/set-up-node-software.html) +- [BigchainDB consortium model](https://docs.bigchaindb.com/projects/server/en/latest/networks.html) +- [BigchainDB v2.2.2 release](https://github.com/bigchaindb/bigchaindb/releases/tag/v2.2.2) +- [BigchainDB repository](https://github.com/bigchaindb/bigchaindb) +- [XRPL transaction cost](https://xrpl.org/docs/concepts/transactions/transaction-cost) diff --git a/docs/DURABLE_DATA_FOUNDATION.md b/docs/DURABLE_DATA_FOUNDATION.md index 5ce1db9..b86d0ff 100644 --- a/docs/DURABLE_DATA_FOUNDATION.md +++ b/docs/DURABLE_DATA_FOUNDATION.md @@ -36,6 +36,28 @@ until the durable-data and privacy gates pass. - XRPL memos may contain only a one-time opaque challenge; comparable private CalorieDB records use keyed fingerprints or salted commitments, never a plain public hash of personal data. +- The core Calorie ecosystem remains free to users. App/database hosting and + core database/Web3 capabilities may not require an additional subscription. +- Separately reviewed value-added services may later be paid, but identity, + personal history access, export, correction and erasure may not be paywalled. +- Free capacity must never be protected by silently deleting existing history. + New onboarding pauses before a quota or provider change threatens durability. +- Pieter Hendrikse and CalorieToken retain management, release and brand control + over the official CalorieApp while a parallel open ecosystem can build on + documented contracts and extension interfaces. +- Emergency continuity keeps the technical foundation preservable if the + current operator becomes unavailable; it is not an automatic transfer of + active control or official branding. +- Future ecosystem developers may receive reviewed, revocable and scoped client + access, never direct Identity Bridge or session-store access. Ecosystem specs + expressly designated for use stay free to access under their stated rights; + optional managed developer services may be paid without buying broader + personal-data access. +- The official website, web applications, Identity Bridge service, databases, + domains, releases, brands and historical visual identity remain in the + operator-controlled product layer. The ecosystem is a separate + interoperability layer; technology crosses that boundary only under an + explicit component licence or written permission. ## Current assessment @@ -44,8 +66,10 @@ until the durable-data and privacy gates pass. | Identity ownership | Internal user id is bound to food logs | Preserve and test on PostgreSQL | | Cross-user access | Automated SQLite tests cover reads and deletion | Repeat against PostgreSQL staging | | PostgreSQL support | Driver and URL normalization exist | Partial, not production-ready | -| Schema changes | `create_all` plus ad-hoc optional-column changes | Replace with formal migrations | -| Production SQLite guard | Missing | Add a startup fail-closed check | +| Schema changes | Versioned forward-only baseline with model-drift tests | Verified locally; prove on PostgreSQL staging next | +| Production SQLite guard | SQLite rejected outside local/test | Verified locally | +| Zero-additional-cost operation | Hard requirement; provider not selected | Verify capacity alerts, backup and exit plan | +| Operator succession | Open technical contracts exist; handover is incomplete | Test restore, import and confidential role transfer | | Durable-host tests | Missing | Automate restart and redeploy probes | | Back-up and restore | Missing | Select mechanism and prove restoration | | User export | Missing | Add authenticated portable export | @@ -54,14 +78,14 @@ until the durable-data and privacy gates pass. ## DS-2 implementation order -1. Introduce a formal migration baseline for the exact current schema. -2. Add environment validation and reject SQLite in staging/production. -3. Add a database readiness probe that performs a safe query. -4. Run the complete identity and ownership suite against PostgreSQL. -5. Add restart and redeploy persistence tests using synthetic records. -6. Implement authenticated data export. -7. Complete account erasure, including identity links and active sessions. -8. Select an encrypted backup method and perform a documented staging restore. +1. Run the complete migration, identity and ownership suite against PostgreSQL. +2. Select a zero-additional-subscription provider and define quota alerts. +3. Prove the no-additional-cost exit path with a synthetic database copy. +4. Add restart and redeploy persistence tests using synthetic records. +5. Implement authenticated data export and versioned import. +6. Complete account erasure, including identity links and active sessions. +7. Select an encrypted backup method and perform a documented staging restore. +8. Test the confidential operator-succession runbook without exposing secrets. 9. Approve retention, backup deletion and privacy-notice wording. 10. Only after the core gates pass, implement the disabled XRPL reference tables and verification flow described in `XRPL_TRANSACTION_LINKING.md`. @@ -107,3 +131,28 @@ can store the future provenance graph. A new provider requires a short architecture record explaining why an existing role cannot safely provide the capability. Independent backup storage is the only expected exception, and only when a documented recovery design requires it. + +## Free core and sustainable optional services + +The user-facing core remains free. The app runtime, primary database and core +database/Web3 feature set must not add a recurring subscription beyond the +already accepted WordPress and development-tool costs. The implementation uses +standard PostgreSQL, open application code and provider-neutral exports. It may +not depend on a paid graph, blockchain-database, identity or Web3 add-on. + +Value-added work with independent value—such as a business bulk API, custom +integration, advanced business analysis or professional support—may be priced +later after a separate product, privacy and legal review. Such services cannot +paywall identity, basic food logging, personal history access or a user's rights +to export, correct and erase their data. + +No external provider can credibly promise an unchanged free tier forever. +Therefore the release gate is operational rather than promotional: monitor +capacity, prohibit automatic paid upgrades, keep a tested export/import and +restore path, and pause new onboarding before a quota can endanger existing +records. Existing history may never be deleted merely to remain under a limit. + +BigchainDB is not selected. Its server combines MongoDB and Tendermint and a +meaningfully decentralized deployment requires multiple independently operated +nodes. That adds duplicated infrastructure beside XRPL and makes private-data +erasure harder without removing the underlying hosting cost. diff --git a/docs/ECOSYSTEM_CONTINUITY.md b/docs/ECOSYSTEM_CONTINUITY.md new file mode 100644 index 0000000..9ef643a --- /dev/null +++ b/docs/ECOSYSTEM_CONTINUITY.md @@ -0,0 +1,115 @@ +# Calorie ecosystem continuity foundation + +Status: pre-release and incomplete. This document defines the technical +continuity target; it does not transfer legal ownership, credentials, personal +data or trade mark rights. + +## Official management and parallel ecosystem + +Pieter Hendrikse and CalorieToken remain the active operator of the official +CalorieApp. Official release decisions, infrastructure administration and use +of the CalorieApp and CalorieToken brands remain under that operator's control. +Open source or open contracts do not make an external implementation official. +Contributions and integrations enter the official app only after operator +review and approval. + +A parallel Calorie ecosystem may grow through published schemas, contracts, +data formats, verification specifications and documented extension interfaces. +This layer enables independent experimentation and interoperability without +creating shared control over the official product. Publication or public source +visibility is not by itself reuse permission: every component remains governed +by its explicit licence or written permission. A permitted fork may not present +itself as the official CalorieApp or use protected CalorieApp or CalorieToken +branding without authorization. + +The continuity provisions below are emergency preservation and recovery +measures. They do not pre-authorize a takeover, credential transfer, release or +brand transfer while the current operator is active. + +## Continuity goal + +The open technical foundation should remain understandable, verifiable, +deployable and forkable if Pieter Hendrikse, CalorieToken as the current project +operator or the current development team becomes unavailable. No public release +may claim this resilience until a synthetic handover and restore have succeeded. + +## Public continuity layer + +- Source, schemas, migration history and machine-readable contracts remain in + version control under the repository's approved licences. +- Builds and validation are deterministic and documented. +- The database schema remains provider-neutral and can be recreated without a + paid proprietary database feature. +- Export and import formats are versioned so authorized data can move to a + successor deployment. +- Public XRPL transaction anchors remain independently verifiable even when the + CalorieApp service is offline. +- Public documentation contains roles and procedures, never credentials, + recovery codes, personal data or private operational endpoints. + +## External developer boundary + +Future ecosystem developers may integrate through a reviewed, revocable client +interface, not through direct access to the Identity Bridge database, password +store, session store or private user records. Every client must use narrowly +defined scopes, an allowlisted redirect destination, short-lived +audience-restricted tokens and explicit user consent for each purpose. A +pairwise pseudonymous subject prevents different ecosystem apps from silently +combining the same user's activity. + +The Identity Bridge may therefore operate as the managed connection tool and +trust boundary between the official CalorieApp and the parallel ecosystem. Its +foundation, security policy, client approvals, releases and revocations remain +under Pieter Hendrikse and CalorieToken. Participating in the ecosystem does not +grant authority to administer or alter that foundation. + +Specifications, contracts and local conformance tools explicitly designated +for ecosystem use must remain free to access; their exact reuse rights must be +stated per component. +Separately reviewed premium developer services may later cover a managed +sandbox, higher rate limits, integration review, verified-client status or +professional support. Payment must never buy broader access to personal data. +Food history and donation details remain unavailable by default, and any future +scope needs its own product, privacy, security and legal review before it can be +enabled. + +## Confidential operator layer + +A separate access-controlled runbook must identify recovery and successor roles +for the domain, WordPress, GitHub organization, app runtime, database, encrypted +backups, signing material and relevant XRPL administration. It must document +credential rotation, loss recovery, incident contact and lawful data-controller +handover without placing any secret in the repository. + +At least two authorized recovery paths are required for every release-critical +service. That does not mean publishing or casually sharing keys. Access follows +least privilege and is tested with synthetic credentials and data before public +onboarding. + +## User continuity + +Users need an authenticated, portable export and a later user-controlled +encrypted backup. If the central service disappears, those artifacts preserve +the user's own history without exposing it publicly. A successor service may +import it only after authentication, format validation and explicit user action. + +## Prohibited shortcuts + +- No automatic dead-man switch. +- No automatic credential, treasury, token or domain transfer. +- No public recovery secrets or personal data. +- No claim that open source alone guarantees continued hosting. +- No combining previously separate identity, donation, food-history or wallet + purposes during a handover. + +## Release evidence still required + +1. Rebuild the services from a clean checkout using only documented inputs. +2. Export, restore and import a synthetic user and food history. +3. Restore an encrypted synthetic backup into a clean PostgreSQL instance. +4. Complete a role-based operator handover without using the founder's active + browser session or personal device. +5. Confirm that loss of the app does not prevent independent verification of a + public XRPL anchor. +6. Obtain separate legal review for trade mark, company, data-controller and + other non-technical succession questions. diff --git a/docs/PRODUCT_ECOSYSTEM_BOUNDARY.md b/docs/PRODUCT_ECOSYSTEM_BOUNDARY.md new file mode 100644 index 0000000..7a758a2 --- /dev/null +++ b/docs/PRODUCT_ECOSYSTEM_BOUNDARY.md @@ -0,0 +1,89 @@ +# Official product and separate ecosystem boundary + +Status: governance baseline. This document separates operational control and +technology reuse; it does not adjudicate authorship, contributor rights, +third-party rights or legal ownership. + +## 1. Official Gallery Token product layer + +Pieter Hendrikse and the designated Gallery Token development team operate the +official product layer. ICTHendrikse administers repository rights subject to +the repository notices and every applicable third-party or contributor right. + +The official product layer includes: + +- the Gallery Token website and its official WordPress presentation; +- official Gallery Token and CalorieApp web applications; +- the official CalorieApp Identity Bridge service, production configuration, + client registry and security policy; +- official application databases and private user records; +- official domains, deployments, release decisions and support channels; and +- Gallery Token and CalorieToken names, marks, historical imagery, trade dress + and other official visual identity, subject to the recorded rights position. + +These components do not become ecosystem-governed merely because an interface +connects to them or source code is publicly visible. + +## 2. Separate ecosystem layer + +The ecosystem is a separate interoperability environment, not another name for +the official product portfolio. It may contain: + +- published interoperability contracts and documented data formats; +- verification and provenance specifications; +- explicitly approved extension interfaces and conformance tools; and +- independently operated applications that do not claim official status. + +An ecosystem participant does not receive authority over official releases, +production infrastructure, private data, the Identity Bridge foundation or +branding. Its implementation must remain operationally and visually distinct +unless the operator grants specific written permission. + +## 3. Technology crossing the boundary + +No component crosses into ecosystem reuse by implication. Reuse requires all +of the following that apply: + +1. The component is explicitly designated for reuse. +2. An explicit component licence or written permission grants the intended use. +3. A connection to an official service passes security, privacy and conformance + review and can be revoked. +4. The external implementation uses separate branding and does not claim to be + an official Gallery Token or CalorieToken product. + +The repository-level notice grants no general licence. Public visibility, +documentation and the word “open” do not override that position. + +The separately packaged CalorieApp Identity Bridge declares +GPL-2.0-or-later. That component licence governs copying, modification and +distribution of that component. It does not grant access to the official +Identity Bridge service, its client registry, sessions, production +configuration, user data, domains, trade marks or official status. Independent +modified copies are not official releases. + +## 4. Identity Bridge as managed ecosystem tool + +The official Identity Bridge may serve as the managed gateway and trust +boundary between official products and approved ecosystem applications. Pieter +Hendrikse and the designated team retain control over its official foundation, +security rules, production deployment, client approvals, scopes, releases and +revocations. + +Approved ecosystem applications receive only short-lived, audience-restricted +and least-privilege access after explicit user consent. They never receive +direct access to passwords, the session store, the identity database or private +application databases. Participation in the ecosystem does not confer a right +to administer the bridge. + +## 5. Free interoperability and optional managed services + +Specifications, contracts and local conformance tools expressly designated for +ecosystem use remain free to access. Their exact reuse licence must still be +stated explicitly. + +Optional paid developer services may later include a managed sandbox, higher +rate limits, integration review, verified-client status or professional +support. Payment cannot purchase broader personal-data scopes, official product +control, branding rights or exemption from security and consent requirements. + +No external ecosystem client or premium tier is enabled by this document. diff --git a/docs/XRPL_TRANSACTION_LINKING.md b/docs/XRPL_TRANSACTION_LINKING.md index 2b5e436..bedf111 100644 --- a/docs/XRPL_TRANSACTION_LINKING.md +++ b/docs/XRPL_TRANSACTION_LINKING.md @@ -96,6 +96,12 @@ the same provider-neutral PostgreSQL database as the rest of CalorieApp. XRPL is only the optional ledger anchor; IPFS, Filecoin and another blockchain database are not dependencies of this design. +The hash relation, provenance graph and verification state use open application +code and ordinary PostgreSQL features. Reading and validating an existing XRPL +transaction hash does not create a new ledger transaction. Any future action +that writes a transaction or memo carries the XRPL network fee and therefore +requires separate, explicit user authorization; it can never run automatically. + When this feature is eventually approved, a worker may automatically verify and ingest the one transaction the user or authorized business has requested. The network and transaction hash form its idempotency key, so retries cannot create diff --git a/docs/public/data-safety.md b/docs/public/data-safety.md index d375f71..946c623 100644 --- a/docs/public/data-safety.md +++ b/docs/public/data-safety.md @@ -34,5 +34,13 @@ changes, new identity or ledger purposes, financial actions and public publication retain explicit approval gates. Automated work must be retry-safe, observable and avoid secrets or unnecessary personal data in logs. +The Calorie ecosystem core is intended to remain free for users and independent +of paid database or Web3 add-ons. Free-provider capacity is monitored; +automatic paid upgrades are forbidden. If capacity becomes unsafe, new +onboarding pauses while existing history remains protected and portable. +Separately reviewed business services may be offered later, but identity and +personal-data access, export, correction and deletion remain part of the free +core. + Passing technical checks does not by itself constitute legal, privacy or security certification. diff --git a/release-check.sh b/release-check.sh index fcdbcbb..4e7ed43 100644 --- a/release-check.sh +++ b/release-check.sh @@ -29,6 +29,17 @@ step "Backend tests" step "Backend Python compilation" "$python_bin" -m compileall -q "$repo_root/backend/app" +step "Schema migration smoke test" +( + migration_db="$(mktemp)" + trap 'rm -f -- "$migration_db"' EXIT + cd "$repo_root/backend" + CALORIEAPP_ENV=test DATABASE_URL="sqlite:///$migration_db" \ + "$python_bin" -m app.schema_cli upgrade + CALORIEAPP_ENV=test DATABASE_URL="sqlite:///$migration_db" \ + "$python_bin" -m app.schema_cli check +) + step "Frontend lint" ( cd "$repo_root/frontend" From d427005b85aac349b8c07a12e183daf1f0daa45d Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sun, 30 Aug 2026 07:37:34 +0200 Subject: [PATCH 2/6] Gate Identity Bridge releases on code provenance --- .../workflows/wordpress-plugin-release.yml | 3 +- README.md | 1 + THIRD_PARTY_NOTICES.md | 8 +- backend/tests/test_data_safety_contract.py | 7 + contracts/data-safety/v1/data-safety.json | 3 + .../identity-bridge/v1/code-provenance.json | 173 ++++++++++++++++++ docs/IDENTITY_BRIDGE_CODE_PROVENANCE.md | 81 ++++++++ release-check.sh | 6 + tools/build_wordpress_plugin_release.py | 73 +++++++- tools/check_legal_boundaries.py | 14 ++ .../test_build_wordpress_plugin_release.py | 26 +++ .../THIRD_PARTY_NOTICES.md | 57 ++++++ 12 files changed, 446 insertions(+), 6 deletions(-) create mode 100644 contracts/identity-bridge/v1/code-provenance.json create mode 100644 docs/IDENTITY_BRIDGE_CODE_PROVENANCE.md create mode 100644 wordpress-plugins/calorieapp-identity-bridge/THIRD_PARTY_NOTICES.md diff --git a/.github/workflows/wordpress-plugin-release.yml b/.github/workflows/wordpress-plugin-release.yml index 2b5b4d3..cf52b0e 100644 --- a/.github/workflows/wordpress-plugin-release.yml +++ b/.github/workflows/wordpress-plugin-release.yml @@ -53,7 +53,8 @@ jobs: - name: Build deterministic archive and checksum run: | python tools/build_wordpress_plugin_release.py \ - --expected-version "${{ steps.version.outputs.value }}" + --expected-version "${{ steps.version.outputs.value }}" \ + --require-cleared-provenance - name: Upload workflow artifact uses: actions/upload-artifact@v6 diff --git a/README.md b/README.md index bfde322..3e79c2a 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,7 @@ gate can additionally run the local developer health check. - BigchainDB decision record: docs/BIGCHAINDB_ASSESSMENT.md - Ecosystem continuity foundation: docs/ECOSYSTEM_CONTINUITY.md - Official product and separate ecosystem boundary: docs/PRODUCT_ECOSYSTEM_BOUNDARY.md +- Identity Bridge code-provenance review: docs/IDENTITY_BRIDGE_CODE_PROVENANCE.md - Voluntary XRPL transaction-linking architecture: docs/XRPL_TRANSACTION_LINKING.md - Public data-safety direction: docs/public/data-safety.md - Public XRPL reference direction: docs/public/xrpl-linking.md diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 460f34d..1c1cb1a 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -19,5 +19,9 @@ sponsorship. No dependency establishes that the project's product concept is novel, exclusive, or patent-clear. The WordPress Identity Bridge is a separately licensed GPL-2.0-or-later -component. See its `LICENSE` file. Open Food Facts data is addressed separately -in `DATA_LICENSING.md`. +component. Its release-file inventory, known external interfaces and unresolved +source-clearance work are recorded in +`contracts/identity-bridge/v1/code-provenance.json` and +`docs/IDENTITY_BRIDGE_CODE_PROVENANCE.md`. See its `LICENSE` and bundled +`THIRD_PARTY_NOTICES.md` files. Open Food Facts data is addressed separately in +`DATA_LICENSING.md`. diff --git a/backend/tests/test_data_safety_contract.py b/backend/tests/test_data_safety_contract.py index 1cf3286..28c2acf 100644 --- a/backend/tests/test_data_safety_contract.py +++ b/backend/tests/test_data_safety_contract.py @@ -193,6 +193,13 @@ def test_official_products_and_separate_ecosystem_have_a_reuse_boundary() -> Non assert boundary["ecosystem_participation_grants_official_product_control"] is False assert boundary["public_source_visibility_is_reuse_permission"] is False assert boundary["identity_bridge_component_declared_licence"] == "GPL-2.0-or-later" + assert boundary["identity_bridge_code_provenance_contract"] == ( + "contracts/identity-bridge/v1/code-provenance.json" + ) + assert boundary["identity_bridge_public_distribution_clearance_status"] == ( + "blocked-pending-source-clearance" + ) + assert boundary["identity_bridge_ecosystem_reuse_expansion_allowed"] is False assert boundary["identity_bridge_code_licence_grants_official_service_access"] is False assert ( boundary["identity_bridge_code_licence_grants_brand_or_official_status"] diff --git a/contracts/data-safety/v1/data-safety.json b/contracts/data-safety/v1/data-safety.json index ca9b3cb..9a564e5 100644 --- a/contracts/data-safety/v1/data-safety.json +++ b/contracts/data-safety/v1/data-safety.json @@ -279,6 +279,9 @@ "separate-branding-without-official-status-claims" ], "identity_bridge_component_declared_licence": "GPL-2.0-or-later", + "identity_bridge_code_provenance_contract": "contracts/identity-bridge/v1/code-provenance.json", + "identity_bridge_public_distribution_clearance_status": "blocked-pending-source-clearance", + "identity_bridge_ecosystem_reuse_expansion_allowed": false, "identity_bridge_code_licence_grants_official_service_access": false, "identity_bridge_code_licence_grants_brand_or_official_status": false, "official_identity_bridge_release_and_service_control_remains_with_operator": true, diff --git a/contracts/identity-bridge/v1/code-provenance.json b/contracts/identity-bridge/v1/code-provenance.json new file mode 100644 index 0000000..cee0baa --- /dev/null +++ b/contracts/identity-bridge/v1/code-provenance.json @@ -0,0 +1,173 @@ +{ + "contract_id": "calorieapp.identity-bridge.code-provenance", + "contract_version": "1.0.0", + "review_date": "2026-08-30", + "plugin_version_reviewed": "0.3.0", + "distribution_clearance_status": "blocked-pending-source-clearance", + "release_expansion_allowed": false, + "local_build_and_test_allowed": true, + "claims": { + "git_history_proves_legal_authorship": false, + "public_repository_visibility_proves_reuse_permission": false, + "current_review_is_a_legal_clearance_conclusion": false, + "bundled_composer_npm_vendor_or_xaman_sdk_detected": false, + "xumm_login_source_file_detected_in_release_archive": false, + "absence_of_detected_source_proves_no_code_was_adapted": false + }, + "known_external_interfaces": [ + { + "id": "wordpress-core", + "relationship": "runtime-platform-api-not-bundled", + "recorded_licence": "GPL-2.0-or-later", + "source": "https://wordpress.org/about/license/", + "review_status": "identified" + }, + { + "id": "xaman-platform-api", + "relationship": "server-side-http-api-no-sdk-bundled", + "source": "https://docs.xaman.dev/concepts/authorization", + "review_status": "technical-interface-identified-terms-review-required" + }, + { + "id": "installed-xumm-login-plugin", + "relationship": "reads-xummlogin-api-key-secret-and-create-user-options", + "source": "exact-installed-package-not-yet-preserved-in-repository", + "review_status": "source-version-licence-and-similarity-review-required" + } + ], + "repository_contributors_observed": [ + "xrpbanks", + "Codex" + ], + "release_files": [ + { + "path": "CONFIGURATION.md", + "origin_class": "project-repository-material", + "repository_first_add_commit": "2c5a98ec289decadb244274fdf5763e2afde62cf", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "contributor-and-source-declaration-pending" + }, + { + "path": "LICENSE", + "origin_class": "upstream-licence-text", + "repository_first_add_commit": "31b523d693756479153e56fd9de78bb6bff6df55", + "declared_distribution_licence": "GPL-2.0-only-text-applied-as-GPL-2.0-or-later-by-plugin-header", + "clearance_status": "identified", + "source": "https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt" + }, + { + "path": "README.md", + "origin_class": "project-repository-material", + "repository_first_add_commit": "2c5a98ec289decadb244274fdf5763e2afde62cf", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "contributor-and-source-declaration-pending" + }, + { + "path": "SECURITY.md", + "origin_class": "project-repository-material", + "repository_first_add_commit": "2c5a98ec289decadb244274fdf5763e2afde62cf", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "contributor-and-source-declaration-pending" + }, + { + "path": "THIRD_PARTY_NOTICES.md", + "origin_class": "project-provenance-documentation", + "repository_first_add_commit": "pending-current-change", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "identified" + }, + { + "path": "assets/calorieapp-embed.css", + "origin_class": "project-repository-material", + "repository_first_add_commit": "c2b1dac881ca03212c26c37512017d2e8caa50bb", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "contributor-source-and-similarity-review-pending" + }, + { + "path": "assets/calorieapp-embed.js", + "origin_class": "project-repository-material", + "repository_first_add_commit": "c2b1dac881ca03212c26c37512017d2e8caa50bb", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "contributor-source-and-similarity-review-pending" + }, + { + "path": "calorieapp-identity-bridge.php", + "origin_class": "project-repository-material", + "repository_first_add_commit": "2c5a98ec289decadb244274fdf5763e2afde62cf", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "contributor-and-source-declaration-pending" + }, + { + "path": "config/locales.json", + "origin_class": "generated-project-contract-copy", + "repository_first_add_commit": "afce5b7d233dc44cb8599c50d670217c4d117391", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "source-contract-identified", + "source": "contracts/identity-bridge/v1/locales.json" + }, + { + "path": "includes/class-calorieapp-identity-bridge-admin.php", + "origin_class": "project-repository-material", + "repository_first_add_commit": "2c5a98ec289decadb244274fdf5763e2afde62cf", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "contributor-source-and-similarity-review-pending" + }, + { + "path": "includes/class-calorieapp-identity-bridge-browser-authorize.php", + "origin_class": "project-repository-material", + "repository_first_add_commit": "2c5a98ec289decadb244274fdf5763e2afde62cf", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "contributor-source-and-similarity-review-pending" + }, + { + "path": "includes/class-calorieapp-identity-bridge-integrated-login.php", + "origin_class": "project-repository-material", + "repository_first_add_commit": "c2b1dac881ca03212c26c37512017d2e8caa50bb", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "xumm-source-and-similarity-review-pending" + }, + { + "path": "includes/class-calorieapp-identity-bridge-legal-footer-compatibility.php", + "origin_class": "project-repository-material", + "repository_first_add_commit": "3e4645f58e74e2c5d69f8849cec68695d0c092cf", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "contributor-source-and-similarity-review-pending" + }, + { + "path": "includes/class-calorieapp-identity-bridge-locale-registry.php", + "origin_class": "project-repository-material", + "repository_first_add_commit": "afce5b7d233dc44cb8599c50d670217c4d117391", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "contributor-and-source-declaration-pending" + }, + { + "path": "includes/class-calorieapp-identity-bridge-rest.php", + "origin_class": "project-repository-material", + "repository_first_add_commit": "2c5a98ec289decadb244274fdf5763e2afde62cf", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "contributor-source-and-similarity-review-pending" + }, + { + "path": "includes/class-calorieapp-identity-bridge-storage.php", + "origin_class": "project-repository-material", + "repository_first_add_commit": "2c5a98ec289decadb244274fdf5763e2afde62cf", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "contributor-source-and-similarity-review-pending" + }, + { + "path": "includes/class-calorieapp-identity-bridge.php", + "origin_class": "project-repository-material", + "repository_first_add_commit": "2c5a98ec289decadb244274fdf5763e2afde62cf", + "declared_distribution_licence": "GPL-2.0-or-later", + "clearance_status": "contributor-source-and-similarity-review-pending" + } + ], + "blocking_actions": [ + "preserve-exact-installed-xumm-login-package-version-source-licence-and-notices", + "run-reproducible-code-similarity-scan-and-human-review-against-that-package", + "obtain-contributor-source-and-ai-assistance-declarations", + "review-xaman-api-service-terms-for-official-and-ecosystem-use", + "decide-and-test-bridge-owned-xaman-credential-migration", + "record-rights-administrator-and-where-appropriate-independent-legal-approval" + ] +} diff --git a/docs/IDENTITY_BRIDGE_CODE_PROVENANCE.md b/docs/IDENTITY_BRIDGE_CODE_PROVENANCE.md new file mode 100644 index 0000000..4763164 --- /dev/null +++ b/docs/IDENTITY_BRIDGE_CODE_PROVENANCE.md @@ -0,0 +1,81 @@ +# Identity Bridge code-provenance review + +Status: public distribution and ecosystem reuse expansion blocked pending +source clearance. Local builds and tests remain allowed. + +This review addresses code and documentation used to create the official +CalorieApp Identity Bridge. It is an engineering inventory, not a legal opinion +or a finding of exclusive authorship. + +## Current evidence + +- The release archive contains no Composer, npm, `vendor` or bundled SDK tree. +- Runtime code calls WordPress core APIs and the Xaman Platform API directly. +- The bridge reads three `xummlogin_*` WordPress options from the installed XUMM + Login integration, but no XUMM Login source file is present in the archive. +- Repository history attributes plugin-path commits to the `xrpbanks` account + and Codex. Commit attribution does not prove who authored every expression or + whether a fragment was adapted from another source. +- The plugin declares GPL-2.0-or-later. That declaration cannot erase an + incompatible third-party right or cure missing permission for copied code. + +The machine-readable inventory at +`contracts/identity-bridge/v1/code-provenance.json` lists every file permitted +in the deterministic plugin archive. The release builder fails when the archive +allowlist and provenance inventory differ. + +## Known external boundaries + +### WordPress + +The plugin depends on WordPress core APIs. WordPress states that its software is +GPLv2 or later and describes plugins and themes as derivative works in its +licensing position. The bridge's GPL declaration is compatible with that stated +platform boundary, but this is not a conclusion about every individual source +fragment. + +### Xaman + +The bridge uses documented server-side HTTP headers and payload endpoints. No +Xaman SDK is bundled. Xaman's developer documentation says backend API secrets +belong only in the backend; the current implementation follows that security +boundary. API/service terms and trade marks are separate from source-code +licensing and still need a recorded review. + +### XUMM Login + +The current implementation directly reads option names owned operationally by +another installed plugin. That may be a compatibility interface rather than +copied code, but the repository does not contain the exact upstream package +needed to verify the distinction. Depending on undocumented internal option +names is also a maintenance and security risk. + +The recommended target is bridge-owned Xaman application credentials and an +explicit, documented migration from the existing XUMM Login configuration. No +credential value may be copied into source control or exposed to a browser. +Until that migration is designed and tested, the current login path must not be +silently changed. + +## Clearance work before another public release + +1. Export the exact installed XUMM Login plugin package without credentials. +2. Record its name, version, source URL, licence and required notices. +3. Compare its PHP, JavaScript and CSS against every Identity Bridge release + file using a reproducible similarity/provenance scan plus human review. +4. Obtain source and AI-assistance declarations for the initial plugin import + and later material changes; record any external snippets and permissions. +5. Review Xaman API/service terms for the intended official and ecosystem use. +6. Decide and test whether the bridge will migrate to its own Xaman credentials + and user-provisioning setting. +7. Have the rights administrator and, where appropriate, independent counsel + approve the result before changing the machine-readable status to cleared. + +## Automated gate + +Ordinary CI may build a local inspection archive whose manifest records the +blocked provenance status. The tag-release workflow adds +`--require-cleared-provenance`; therefore it cannot publish a new plugin release +while the contract remains blocked. Adding any file to the archive also fails +until that file receives an inventory entry. + +This gate does not merge, deploy, publish or change the live WordPress plugin. diff --git a/release-check.sh b/release-check.sh index 4e7ed43..d1ad455 100644 --- a/release-check.sh +++ b/release-check.sh @@ -29,6 +29,12 @@ step "Backend tests" step "Backend Python compilation" "$python_bin" -m compileall -q "$repo_root/backend/app" +step "Identity Bridge contracts, provenance and release builder" +"$python_bin" "$repo_root/tools/sync_identity_contracts.py" --check +"$python_bin" -m unittest \ + tools.tests.test_identity_contracts \ + tools.tests.test_build_wordpress_plugin_release + step "Schema migration smoke test" ( migration_db="$(mktemp)" diff --git a/tools/build_wordpress_plugin_release.py b/tools/build_wordpress_plugin_release.py index 6e7a845..4d79821 100644 --- a/tools/build_wordpress_plugin_release.py +++ b/tools/build_wordpress_plugin_release.py @@ -17,11 +17,15 @@ PLUGIN_SLUG = "calorieapp-identity-bridge" PLUGIN_DIR = ROOT / "wordpress-plugins" / PLUGIN_SLUG MAIN_FILE = PLUGIN_DIR / f"{PLUGIN_SLUG}.php" +PROVENANCE_CONTRACT = ( + ROOT / "contracts" / "identity-bridge" / "v1" / "code-provenance.json" +) RELEASE_FILES = ( "CONFIGURATION.md", "LICENSE", "README.md", "SECURITY.md", + "THIRD_PARTY_NOTICES.md", f"{PLUGIN_SLUG}.php", ) RELEASE_GLOBS = ("includes/*.php", "assets/*.css", "assets/*.js", "config/*.json") @@ -88,7 +92,46 @@ def archive_name(version: str) -> str: return f"{PLUGIN_SLUG}-{version}.zip" -def build(output_dir: Path, expected_version: str | None = None) -> tuple[Path, Path, Path]: +def code_provenance(release_files: list[Path]) -> dict: + contract = json.loads(PROVENANCE_CONTRACT.read_text(encoding="utf-8")) + if contract.get("contract_id") != "calorieapp.identity-bridge.code-provenance": + raise ValueError("Identity Bridge code-provenance contract id is invalid") + + entries = contract.get("release_files") + if not isinstance(entries, list): + raise ValueError("Identity Bridge code-provenance release_files must be a list") + documented = { + str(entry.get("path")): entry + for entry in entries + if isinstance(entry, dict) and entry.get("path") + } + expected = {path.relative_to(PLUGIN_DIR).as_posix() for path in release_files} + if set(documented) != expected: + missing = sorted(expected - set(documented)) + extra = sorted(set(documented) - expected) + raise ValueError( + "Identity Bridge code-provenance inventory differs from the release " + f"allowlist; missing={missing}, extra={extra}" + ) + for path, entry in documented.items(): + required = { + "origin_class", + "repository_first_add_commit", + "declared_distribution_licence", + "clearance_status", + } + absent = sorted(required - set(entry)) + if absent: + raise ValueError(f"Code-provenance entry {path} is missing {absent}") + return contract + + +def build( + output_dir: Path, + expected_version: str | None = None, + *, + require_cleared_provenance: bool = False, +) -> tuple[Path, Path, Path]: version = plugin_version() if expected_version and expected_version != version: raise ValueError( @@ -101,6 +144,13 @@ def build(output_dir: Path, expected_version: str | None = None) -> tuple[Path, manifest = archive.with_suffix(".zip.manifest.json") files = release_paths() + provenance = code_provenance(files) + provenance_status = str(provenance.get("distribution_clearance_status", "")) + if require_cleared_provenance and provenance_status != "cleared": + raise ValueError( + "Identity Bridge code provenance clearance is required for public " + f"distribution; current status is {provenance_status or 'missing'}" + ) with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as bundle: for source in files: relative = source.relative_to(PLUGIN_DIR).as_posix() @@ -120,6 +170,10 @@ def build(output_dir: Path, expected_version: str | None = None) -> tuple[Path, "version": version, "archive": archive.name, "sha256": digest, + "code_provenance_contract": str( + PROVENANCE_CONTRACT.relative_to(ROOT).as_posix() + ), + "code_provenance_status": provenance_status, "files": [ f"{PLUGIN_SLUG}/{path.relative_to(PLUGIN_DIR).as_posix()}" for path in files @@ -164,14 +218,27 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output-dir", type=Path, default=ROOT / "dist") parser.add_argument("--expected-version") + parser.add_argument( + "--require-cleared-provenance", + action="store_true", + help="Refuse public distribution while code provenance has blockers.", + ) args = parser.parse_args() try: - artifacts = build(args.output_dir, args.expected_version) + artifacts = build( + args.output_dir, + args.expected_version, + require_cleared_provenance=args.require_cleared_provenance, + ) except (OSError, ValueError, zipfile.BadZipFile) as exc: print(f"release build failed: {exc}", file=sys.stderr) return 1 for artifact in artifacts: - print(artifact.relative_to(ROOT)) + try: + display_path = artifact.relative_to(ROOT) + except ValueError: + display_path = artifact + print(display_path) return 0 diff --git a/tools/check_legal_boundaries.py b/tools/check_legal_boundaries.py index eb9e516..9c76285 100644 --- a/tools/check_legal_boundaries.py +++ b/tools/check_legal_boundaries.py @@ -28,6 +28,9 @@ def main() -> None: "THIRD_PARTY_NOTICES.md", "ASSET_PROVENANCE.md", "IP_CLEARANCE.md", + "contracts/identity-bridge/v1/code-provenance.json", + "docs/IDENTITY_BRIDGE_CODE_PROVENANCE.md", + "wordpress-plugins/calorieapp-identity-bridge/THIRD_PARTY_NOTICES.md", ) missing = [path for path in required_files if not (ROOT / path).is_file()] if missing: @@ -46,6 +49,17 @@ def main() -> None: "wordpress-plugins/calorieapp-identity-bridge/calorieapp-identity-bridge.php", ("License: GPL-2.0-or-later",), ) + provenance = json.loads( + ( + ROOT / "contracts" / "identity-bridge" / "v1" / "code-provenance.json" + ).read_text(encoding="utf-8") + ) + if provenance.get("distribution_clearance_status") != "blocked-pending-source-clearance": + raise SystemExit( + "Identity Bridge provenance status may change only through its reviewed clearance workflow" + ) + if provenance.get("release_expansion_allowed") is not False: + raise SystemExit("Identity Bridge release expansion must remain blocked") package = json.loads((ROOT / "frontend/package.json").read_text(encoding="utf-8")) lock = json.loads((ROOT / "frontend/package-lock.json").read_text(encoding="utf-8")) diff --git a/tools/tests/test_build_wordpress_plugin_release.py b/tools/tests/test_build_wordpress_plugin_release.py index f9e4eba..ec60169 100644 --- a/tools/tests/test_build_wordpress_plugin_release.py +++ b/tools/tests/test_build_wordpress_plugin_release.py @@ -29,6 +29,10 @@ def test_build_is_reproducible_and_safe(self) -> None: f"{digest} {first_archive.name}\n", ) self.assertIn(f'"sha256": "{digest}"', first_manifest.read_text(encoding="utf-8")) + self.assertIn( + '"code_provenance_status": "blocked-pending-source-clearance"', + first_manifest.read_text(encoding="utf-8"), + ) with zipfile.ZipFile(first_archive) as bundle: names = bundle.namelist() @@ -37,8 +41,30 @@ def test_build_is_reproducible_and_safe(self) -> None: self.assertFalse(any("tests/" in name for name in names)) self.assertFalse(any(name.endswith(".zip") for name in names)) self.assertIn(f"{release.PLUGIN_SLUG}/LICENSE", names) + self.assertIn(f"{release.PLUGIN_SLUG}/THIRD_PARTY_NOTICES.md", names) self.assertIn(f"{release.PLUGIN_SLUG}/config/locales.json", names) + def test_release_allowlist_has_exact_code_provenance_inventory(self) -> None: + files = release.release_paths() + contract = release.code_provenance(files) + + self.assertEqual( + contract["distribution_clearance_status"], + "blocked-pending-source-clearance", + ) + self.assertFalse(contract["claims"]["git_history_proves_legal_authorship"]) + self.assertFalse( + contract["claims"]["current_review_is_a_legal_clearance_conclusion"] + ) + + def test_public_distribution_requires_cleared_code_provenance(self) -> None: + with tempfile.TemporaryDirectory() as output: + with self.assertRaisesRegex(ValueError, "code provenance clearance"): + release.build( + Path(output), + require_cleared_provenance=True, + ) + def test_expected_version_must_match(self) -> None: with tempfile.TemporaryDirectory() as output: with self.assertRaisesRegex(ValueError, "plugin header declares"): diff --git a/wordpress-plugins/calorieapp-identity-bridge/THIRD_PARTY_NOTICES.md b/wordpress-plugins/calorieapp-identity-bridge/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..23e3d07 --- /dev/null +++ b/wordpress-plugins/calorieapp-identity-bridge/THIRD_PARTY_NOTICES.md @@ -0,0 +1,57 @@ +# CalorieApp Identity Bridge third-party notices + +This file identifies known external technology boundaries. It is not a legal +clearance opinion and does not prove that the source history is complete. + +## WordPress + +The plugin runs on WordPress and calls WordPress core APIs. WordPress states +that its software is GPLv2 or later and that plugins and themes are derivative +works in its licensing position. No WordPress core source file is intentionally +bundled in this plugin archive. WordPress names and marks remain with their +respective owners. + +Source: https://wordpress.org/about/license/ + +## Xaman platform + +The integrated sign-in flow sends server-side HTTP requests to the Xaman +Platform API. The plugin does not bundle the Xaman JavaScript, TypeScript or PHP +SDK. Xaman documentation requires backend API keys and secrets to remain in a +backend environment; the bridge keeps them server-side. + +Sources: + +- https://docs.xaman.dev/concepts/authorization +- https://docs.xaman.dev/environments/backend-sdk-api + +API access terms, service availability, names and marks remain external to this +plugin licence and require review before any expanded ecosystem offering. + +## Existing XUMM Login plugin compatibility + +The current bridge reads the WordPress option names `xummlogin_api_key`, +`xummlogin_api_secret` and `xummlogin_create_user` to interoperate with the +installed XUMM Login plugin. No XUMM Login source file or package has been +identified in this release archive. That observation does not prove that no +fragment was adapted during development. + +Before another public Identity Bridge release or ecosystem reuse expansion, the +exact installed XUMM Login package, version, source, licence and notices must be +preserved and compared with the bridge. The project must then either document a +permitted compatibility boundary or migrate to bridge-owned Xaman credentials +and an approved user-provisioning setting. + +## Project-generated locale registry + +`config/locales.json` is a generated deployment copy of +`contracts/identity-bridge/v1/locales.json` in the CalorieApp repository. Its +presence must stay synchronized by the contract tooling. + +## Source and contribution limitation + +Repository history currently contains commits attributed to the `xrpbanks` +account and to Codex. Commit metadata is technical evidence, not proof of legal +authorship, assignment or independent creation. AI assistance, external +snippets, employer or contractor rights and every adapted source must be +declared and reviewed under the repository contribution policy. From c0b4ccf528cbef518356d66eacfaf1af82949680 Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:46:12 +0200 Subject: [PATCH 3/6] Correct CalorieToken naming and future-proof migration timestamp test --- backend/tests/test_data_safety_contract.py | 4 ++-- backend/tests/test_database.py | 4 +++- contracts/data-safety/v1/data-safety.json | 6 +++--- docs/PRODUCT_ECOSYSTEM_BOUNDARY.md | 12 ++++++------ 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/backend/tests/test_data_safety_contract.py b/backend/tests/test_data_safety_contract.py index 2938db2..203d333 100644 --- a/backend/tests/test_data_safety_contract.py +++ b/backend/tests/test_data_safety_contract.py @@ -180,9 +180,9 @@ def test_official_products_and_separate_ecosystem_have_a_reuse_boundary() -> Non boundary = _load_json("data-safety.json")["product_ecosystem_boundary"] assert boundary["official_product_operator"] == ( - "Pieter Hendrikse with the designated Gallery Token development team" + "Pieter Hendrikse with the designated CalorieToken development team" ) - assert "gallery-token-website-and-official-wordpress-presentation" in boundary[ + assert "calorietoken-website-and-official-wordpress-presentation" in boundary[ "official_product_layer" ] assert ( diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py index b719963..bae8bb5 100644 --- a/backend/tests/test_database.py +++ b/backend/tests/test_database.py @@ -212,7 +212,9 @@ def test_migration_history_stores_approved_reference_without_secret_data() -> No applied_at, reference = connection.exec_driver_sql( "SELECT applied_at, approval_reference FROM calorie_schema_revision" ).one() - assert datetime.fromisoformat(str(applied_at)).replace(tzinfo=UTC).year == 2026 + applied_at_utc = datetime.fromisoformat(str(applied_at)).replace(tzinfo=UTC) + age_seconds = (datetime.now(UTC) - applied_at_utc).total_seconds() + assert 0 <= age_seconds < 5 assert reference == "CHANGE-2026-001" finally: test_engine.dispose() diff --git a/contracts/data-safety/v1/data-safety.json b/contracts/data-safety/v1/data-safety.json index 9a564e5..ad2e9e5 100644 --- a/contracts/data-safety/v1/data-safety.json +++ b/contracts/data-safety/v1/data-safety.json @@ -252,11 +252,11 @@ }, "product_ecosystem_boundary": { "status": "governance-baseline-reuse-not-generally-granted", - "official_product_operator": "Pieter Hendrikse with the designated Gallery Token development team", + "official_product_operator": "Pieter Hendrikse with the designated CalorieToken development team", "repository_rights_administration": "ICTHendrikse subject to the repository rights notices and third-party rights", "official_product_layer": [ - "gallery-token-website-and-official-wordpress-presentation", - "official-gallery-token-and-calorieapp-web-applications", + "calorietoken-website-and-official-wordpress-presentation", + "official-calorietoken-and-calorieapp-web-applications", "official-calorieapp-identity-bridge-service-and-production-configuration", "official-application-databases-and-private-user-records", "official-domains-releases-brands-and-historical-visual-identity" diff --git a/docs/PRODUCT_ECOSYSTEM_BOUNDARY.md b/docs/PRODUCT_ECOSYSTEM_BOUNDARY.md index 7a758a2..e97a0a5 100644 --- a/docs/PRODUCT_ECOSYSTEM_BOUNDARY.md +++ b/docs/PRODUCT_ECOSYSTEM_BOUNDARY.md @@ -4,21 +4,21 @@ Status: governance baseline. This document separates operational control and technology reuse; it does not adjudicate authorship, contributor rights, third-party rights or legal ownership. -## 1. Official Gallery Token product layer +## 1. Official CalorieToken product layer -Pieter Hendrikse and the designated Gallery Token development team operate the +Pieter Hendrikse and the designated CalorieToken development team operate the official product layer. ICTHendrikse administers repository rights subject to the repository notices and every applicable third-party or contributor right. The official product layer includes: -- the Gallery Token website and its official WordPress presentation; -- official Gallery Token and CalorieApp web applications; +- the CalorieToken website and its official WordPress presentation; +- official CalorieToken and CalorieApp web applications; - the official CalorieApp Identity Bridge service, production configuration, client registry and security policy; - official application databases and private user records; - official domains, deployments, release decisions and support channels; and -- Gallery Token and CalorieToken names, marks, historical imagery, trade dress +- CalorieToken and CalorieApp names, marks, historical imagery, trade dress and other official visual identity, subject to the recorded rights position. These components do not become ecosystem-governed merely because an interface @@ -49,7 +49,7 @@ of the following that apply: 3. A connection to an official service passes security, privacy and conformance review and can be revoked. 4. The external implementation uses separate branding and does not claim to be - an official Gallery Token or CalorieToken product. + an official CalorieToken or CalorieApp product. The repository-level notice grants no general licence. Public visibility, documentation and the word “open” do not override that position. From a9e2b9c17c8f7d80a415fd9c78c579402b15f3fa Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:48:01 +0200 Subject: [PATCH 4/6] Trigger exact-head CI after retarget to main From fc723ad13d38ef96cf1a41ae8482b6cb14373255 Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:03:38 +0200 Subject: [PATCH 5/6] Fail closed on unknown legacy food log columns --- .../versions/v20260830_0001.py | 8 +++ backend/tests/test_database.py | 56 ++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/backend/app/schema_migrations/versions/v20260830_0001.py b/backend/app/schema_migrations/versions/v20260830_0001.py index bade8ea..08aa518 100644 --- a/backend/app/schema_migrations/versions/v20260830_0001.py +++ b/backend/app/schema_migrations/versions/v20260830_0001.py @@ -259,6 +259,14 @@ def _upgrade_existing_food_log(connection: Connection) -> None: raise RuntimeError(f"Legacy food_log is missing required columns: {missing}") expected_columns = {column.name for column in food_log.columns} + unexpected_columns = existing_columns - expected_columns + if unexpected_columns: + unexpected = ", ".join(sorted(unexpected_columns)) + raise RuntimeError( + "Legacy food_log has unsupported columns; migration stopped to " + f"prevent data loss: {unexpected}" + ) + needs_upgrade = existing_columns != expected_columns or not _food_log_has_owner_foreign_key(connection) if not needs_upgrade: return diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py index bae8bb5..d6c4d5d 100644 --- a/backend/tests/test_database.py +++ b/backend/tests/test_database.py @@ -101,7 +101,14 @@ def _schema_signature(target_engine) -> dict[str, dict[str, object]]: if item.get("unique") and item.get("column_names") ) signature[table_name] = { - "columns": tuple(column["name"] for column in inspector.get_columns(table_name)), + "columns": tuple( + ( + column["name"], + str(column["type"]), + bool(column["nullable"]), + ) + for column in inspector.get_columns(table_name) + ), "foreign_keys": { ( tuple(item["constrained_columns"]), @@ -190,6 +197,53 @@ def test_legacy_food_log_is_preserved_and_receives_owner_foreign_key() -> None: test_engine.dispose() +def test_legacy_food_log_with_unknown_column_fails_closed_without_data_loss() -> None: + test_engine = _memory_engine() + try: + with test_engine.begin() as connection: + connection.exec_driver_sql( + """ + CREATE TABLE food_log ( + id INTEGER PRIMARY KEY, + product_name VARCHAR(120) NOT NULL, + calories FLOAT NOT NULL, + protein FLOAT NOT NULL, + fat FLOAT NOT NULL, + carbohydrates FLOAT NOT NULL, + created_at DATETIME NOT NULL, + legacy_note TEXT + ) + """ + ) + connection.exec_driver_sql( + """ + INSERT INTO food_log + (id, product_name, calories, protein, fat, carbohydrates, + created_at, legacy_note) + VALUES + (1, 'Legacy Preserved', 123, 4, 5, 6, + '2026-01-01 00:00:00', 'must-not-disappear') + """ + ) + + with pytest.raises(RuntimeError, match="unsupported columns.*legacy_note"): + upgrade_database(test_engine) + + with test_engine.connect() as connection: + columns = { + str(column["name"]) + for column in inspect(connection).get_columns("food_log") + } + row = connection.exec_driver_sql( + "SELECT id, product_name, legacy_note FROM food_log WHERE id = 1" + ).one() + assert "legacy_note" in columns + assert tuple(row) == (1, "Legacy Preserved", "must-not-disappear") + assert current_revision(test_engine) is None + finally: + test_engine.dispose() + + def test_readiness_is_read_only_and_requires_schema_head() -> None: test_engine = _memory_engine() try: From 6bb9f5e6e9370a39321c6cab8981d286e40121ad Mon Sep 17 00:00:00 2001 From: xrpbanks <126300068+xrpbanks@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:34:53 +0200 Subject: [PATCH 6/6] Test PostgreSQL migrations in CI --- .github/workflows/ci.yml | 20 +++ backend/tests/test_postgresql_integration.py | 121 +++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 backend/tests/test_postgresql_integration.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eccf4fd..06c62b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,6 +123,20 @@ jobs: name: Backend tests (Python 3.11) runs-on: ubuntu-latest timeout-minutes: 15 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: calorieapp_ci + POSTGRES_PASSWORD: synthetic_ci_only + POSTGRES_DB: calorieapp_ci_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U calorieapp_ci -d calorieapp_ci_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 steps: - name: Check out repository @@ -162,6 +176,12 @@ jobs: python -m app.schema_cli upgrade python -m app.schema_cli check + - name: Verify PostgreSQL migration compatibility + working-directory: backend + env: + CALORIEAPP_POSTGRES_TEST_DATABASE_URL: postgresql+psycopg://calorieapp_ci:synthetic_ci_only@127.0.0.1:5432/calorieapp_ci_test + run: python -m pytest tests/test_postgresql_integration.py -q -W error::DeprecationWarning + frontend-checks: name: Frontend lint and build (Node.js 20) runs-on: ubuntu-latest diff --git a/backend/tests/test_postgresql_integration.py b/backend/tests/test_postgresql_integration.py new file mode 100644 index 0000000..51b5e87 --- /dev/null +++ b/backend/tests/test_postgresql_integration.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import os + +import pytest +from sqlalchemy import inspect +from sqlalchemy.engine import Engine, make_url +from sqlmodel import create_engine + +from app.database import database_readiness +from app.schema_migrations import SCHEMA_HEAD, current_revision, upgrade_database +from app.schema_migrations.versions.v20260830_0001 import food_log as migration_food_log + + +POSTGRES_TEST_URL_ENV = "CALORIEAPP_POSTGRES_TEST_DATABASE_URL" + + +def _required_postgresql_test_url() -> str: + raw_url = os.getenv(POSTGRES_TEST_URL_ENV, "").strip() + if not raw_url: + pytest.skip(f"{POSTGRES_TEST_URL_ENV} is not configured") + + parsed = make_url(raw_url) + if parsed.get_backend_name() != "postgresql": + pytest.fail(f"{POSTGRES_TEST_URL_ENV} must use PostgreSQL") + if parsed.host not in {"127.0.0.1", "localhost", "::1"}: + pytest.fail(f"{POSTGRES_TEST_URL_ENV} must target a loopback-only test server") + if parsed.database != "calorieapp_ci_test": + pytest.fail(f"{POSTGRES_TEST_URL_ENV} must target calorieapp_ci_test") + return raw_url + + +def _reset_synthetic_database(engine: Engine) -> None: + """Reset only the hard-coded loopback CI database guarded above.""" + with engine.begin() as connection: + connection.exec_driver_sql("DROP SCHEMA IF EXISTS public CASCADE") + connection.exec_driver_sql("CREATE SCHEMA public") + + +@pytest.fixture() +def postgres_engine() -> Engine: + raw_url = _required_postgresql_test_url() + engine = create_engine(raw_url, pool_pre_ping=True) + _reset_synthetic_database(engine) + try: + yield engine + finally: + engine.dispose() + cleanup_engine = create_engine(raw_url, pool_pre_ping=True) + try: + _reset_synthetic_database(cleanup_engine) + finally: + cleanup_engine.dispose() + + +def test_postgresql_empty_database_migrates_and_is_ready( + postgres_engine: Engine, +) -> None: + assert ( + upgrade_database( + postgres_engine, + approval_reference="CI-POSTGRES-EMPTY-DATABASE", + ) + == SCHEMA_HEAD + ) + assert current_revision(postgres_engine) == SCHEMA_HEAD + assert database_readiness(postgres_engine) == { + "status": "ready", + "database_revision": SCHEMA_HEAD, + } + assert "food_log" in inspect(postgres_engine).get_table_names() + + +def test_postgresql_legacy_food_log_is_preserved( + postgres_engine: Engine, +) -> None: + with postgres_engine.begin() as connection: + connection.exec_driver_sql( + """ + CREATE TABLE food_log ( + id INTEGER PRIMARY KEY, + product_name VARCHAR(120) NOT NULL, + calories DOUBLE PRECISION NOT NULL, + protein DOUBLE PRECISION NOT NULL, + fat DOUBLE PRECISION NOT NULL, + carbohydrates DOUBLE PRECISION NOT NULL, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL + ) + """ + ) + connection.exec_driver_sql( + """ + INSERT INTO food_log + (id, product_name, calories, protein, fat, carbohydrates, created_at) + VALUES + (1, 'Synthetic Legacy Record', 123, 4, 5, 6, '2026-01-01 00:00:00') + """ + ) + + upgrade_database( + postgres_engine, + approval_reference="CI-POSTGRES-LEGACY-DATABASE", + ) + + inspector = inspect(postgres_engine) + actual_columns = { + str(column["name"]) for column in inspector.get_columns("food_log") + } + expected_columns = {column.name for column in migration_food_log.columns} + assert actual_columns == expected_columns + + with postgres_engine.connect() as connection: + row = connection.exec_driver_sql( + "SELECT id, product_name, owner_id FROM food_log WHERE id = 1" + ).one() + assert tuple(row) == (1, "Synthetic Legacy Record", None) + assert any( + foreign_key["constrained_columns"] == ["owner_id"] + and foreign_key["referred_table"] == "calorieappuser" + for foreign_key in inspector.get_foreign_keys("food_log") + )