#!/usr/bin/env python3 """Validate a public FindMate owner profile without network access.""" from __future__ import annotations import argparse import hashlib import json import re import sys from datetime import date, datetime, timezone from pathlib import Path from urllib.parse import urlparse SCHEMA_VERSION = "1.0" SCHEMA_URL = ( "https://raw.githubusercontent.com/merc1305/findMate/main/" "schemas/findmate-owner-profile-v1.schema.json" ) STAGES = ( "zero_to_one", "one_to_ten", "ten_to_hundred", ) FUNCTIONS = ( "problem_discovery", "product", "engineering", "design", "go_to_market", "operations", "people_leadership", "capital_partnerships", ) DIMENSIONS = STAGES + FUNCTIONS LEVELS = ("unknown", "observed", "practiced", "strong", "standout") CONFIDENCE_LEVELS = ("none", "low", "medium", "high") ROOT_KEYS = { "schema_version", "profile_type", "alias", "summary", "generated_at", "expires_on", "stage_contributions", "functional_contributions", "preferences", "seeking", "public_evidence", "contact", "consent", "interpretation", } SENSITIVE_PATTERNS = { "email address": re.compile( r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE ), "phone-like number": re.compile(r"(? dict: try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ValidationError(f"Cannot load {path}: {exc}") from exc if not isinstance(value, dict): raise ValidationError("profile must be a JSON object") return value def exact_object(value: object, field: str, keys: set[str]) -> dict: if not isinstance(value, dict): raise ValidationError(f"{field} must be an object") actual = set(value) missing = sorted(keys - actual) extra = sorted(actual - keys) if missing: raise ValidationError(f"{field} is missing: {', '.join(missing)}") if extra: raise ValidationError(f"{field} has unsupported fields: {', '.join(extra)}") return value def text(value: object, field: str, *, maximum: int) -> str: if not isinstance(value, str) or not value.strip(): raise ValidationError(f"{field} must be a non-empty string") clean = value.strip() if clean != value: raise ValidationError(f"{field} must not have surrounding whitespace") if len(clean) > maximum: raise ValidationError(f"{field} exceeds {maximum} characters") return clean def public_text(value: object, field: str, *, maximum: int) -> str: clean = text(value, field, maximum=maximum) for label, pattern in SENSITIVE_PATTERNS.items(): if pattern.search(clean): raise ValidationError(f"{field} appears to contain a {label}") return clean def alias(value: object, field: str = "alias") -> str: clean = public_text(value, field, maximum=50) if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{1,49}", clean): raise ValidationError( f"{field} must be a 2-50 character pseudonym using letters, " "digits, _ or -" ) return clean def evidence_identifier(value: object, field: str) -> str: clean = public_text(value, field, maximum=60) if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,59}", clean): raise ValidationError( f"{field} must use letters, digits, _ or - and begin with " "a letter or digit" ) return clean def iso_date(value: object, field: str) -> date: raw = text(value, field, maximum=10) try: parsed = date.fromisoformat(raw) except ValueError as exc: raise ValidationError(f"{field} must use YYYY-MM-DD") from exc if parsed.isoformat() != raw: raise ValidationError(f"{field} must use canonical YYYY-MM-DD") return parsed def iso_datetime(value: object, field: str) -> datetime: raw = text(value, field, maximum=35) normalized = raw[:-1] + "+00:00" if raw.endswith("Z") else raw try: parsed = datetime.fromisoformat(normalized) except ValueError as exc: raise ValidationError(f"{field} must be an ISO 8601 timestamp") from exc if parsed.tzinfo is None or parsed.utcoffset() is None: raise ValidationError(f"{field} must include a UTC offset") return parsed.astimezone(timezone.utc) def https_url( value: object, field: str, *, github_contact_type: str | None = None, ) -> str: raw = public_text(value, field, maximum=500) parsed = urlparse(raw) if ( parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment ): raise ValidationError( f"{field} must be a credential-free HTTPS URL without query or fragment" ) if github_contact_type: if parsed.hostname.lower() != "github.com": raise ValidationError(f"{field} must use github.com") parts = [part for part in parsed.path.split("/") if part] expected = "issues" if github_contact_type == "github_issues" else "discussions" if len(parts) < 3 or parts[2] != expected: raise ValidationError(f"{field} must point to a GitHub {expected} page") return raw def unique_enum_list( value: object, field: str, allowed: tuple[str, ...], *, required: bool = False, ) -> list[str]: if not isinstance(value, list): raise ValidationError(f"{field} must be a list") if required and not value: raise ValidationError(f"{field} must not be empty") if len(value) != len(set(value)): raise ValidationError(f"{field} must not contain duplicates") for item in value: if item not in allowed: raise ValidationError(f"{field} contains unsupported value {item!r}") return value def public_string_list( value: object, field: str, *, limit: int, ) -> list[str]: if not isinstance(value, list) or len(value) > limit: raise ValidationError(f"{field} must be a list with at most {limit} items") output: list[str] = [] for index, item in enumerate(value): output.append(public_text(item, f"{field}[{index}]", maximum=80)) if len(output) != len(set(output)): raise ValidationError(f"{field} must not contain duplicates") return output def score_level(score: int) -> str: if score == 0: return "unknown" if score < 25: return "observed" if score < 50: return "practiced" if score < 75: return "strong" return "standout" def contribution_vectors( value: object, field: str, dimensions: tuple[str, ...], ) -> None: vectors = exact_object(value, field, set(dimensions)) vector_keys = {"score", "level", "confidence", "evidence_count"} for dimension in dimensions: prefix = f"{field}.{dimension}" item = exact_object(vectors[dimension], prefix, vector_keys) score = item["score"] count = item["evidence_count"] if isinstance(score, bool) or not isinstance(score, int) or not 0 <= score <= 100: raise ValidationError(f"{prefix}.score must be an integer from 0 to 100") if ( isinstance(count, bool) or not isinstance(count, int) or not 0 <= count <= 50 ): raise ValidationError( f"{prefix}.evidence_count must be an integer from 0 to 50" ) if item["level"] not in LEVELS: raise ValidationError(f"{prefix}.level is unsupported") expected_level = score_level(score) if item["level"] != expected_level: raise ValidationError( f"{prefix}.level must be {expected_level!r} for score {score}" ) if item["confidence"] not in CONFIDENCE_LEVELS: raise ValidationError(f"{prefix}.confidence is unsupported") if count == 0 and (score != 0 or item["confidence"] != "none"): raise ValidationError( f"{prefix} with no evidence must have score 0 and confidence none" ) if count > 0 and item["confidence"] == "none": raise ValidationError( f"{prefix} with evidence must not use confidence none" ) def validate_profile(profile: dict) -> dict: exact_object(profile, "profile", ROOT_KEYS) if profile["schema_version"] != SCHEMA_VERSION: raise ValidationError(f"schema_version must be {SCHEMA_VERSION}") if profile["profile_type"] != "founder-collaboration": raise ValidationError("profile_type must be founder-collaboration") profile_alias = alias(profile["alias"]) public_text(profile["summary"], "summary", maximum=280) generated_at = iso_datetime(profile["generated_at"], "generated_at") expires_on = iso_date(profile["expires_on"], "expires_on") today = datetime.now(timezone.utc).date() if expires_on < today: raise ValidationError(f"profile expired on {expires_on.isoformat()}") if generated_at.date() > expires_on: raise ValidationError("generated_at must not be after expires_on") contribution_vectors( profile["stage_contributions"], "stage_contributions", STAGES, ) contribution_vectors( profile["functional_contributions"], "functional_contributions", FUNCTIONS, ) preferences = exact_object( profile["preferences"], "preferences", {"stages", "functions"}, ) unique_enum_list(preferences["stages"], "preferences.stages", STAGES) unique_enum_list(preferences["functions"], "preferences.functions", FUNCTIONS) seeking = exact_object( profile["seeking"], "seeking", { "stages", "functions", "project_themes", "collaboration_modes", "shared_principles", }, ) sought_stages = unique_enum_list(seeking["stages"], "seeking.stages", STAGES) sought_functions = unique_enum_list( seeking["functions"], "seeking.functions", FUNCTIONS, ) if not sought_stages and not sought_functions: raise ValidationError( "seeking must name at least one stage or functional capability" ) public_string_list( seeking["project_themes"], "seeking.project_themes", limit=10, ) public_string_list( seeking["collaboration_modes"], "seeking.collaboration_modes", limit=5, ) public_string_list( seeking["shared_principles"], "seeking.shared_principles", limit=10, ) evidence = profile["public_evidence"] if not isinstance(evidence, list) or len(evidence) > 50: raise ValidationError("public_evidence must be a list with at most 50 items") evidence_ids: set[str] = set() for index, raw in enumerate(evidence): prefix = f"public_evidence[{index}]" item = exact_object(raw, prefix, {"id", "claim", "proof", "supports"}) evidence_id = evidence_identifier(item["id"], f"{prefix}.id") if evidence_id in evidence_ids: raise ValidationError(f"duplicate public evidence id: {evidence_id}") evidence_ids.add(evidence_id) public_text(item["claim"], f"{prefix}.claim", maximum=180) https_url(item["proof"], f"{prefix}.proof") unique_enum_list( item["supports"], f"{prefix}.supports", DIMENSIONS, required=True, ) contact = exact_object(profile["contact"], "contact", {"type", "url"}) if contact["type"] not in {"github_issues", "github_discussions"}: raise ValidationError("contact.type is unsupported") https_url( contact["url"], "contact.url", github_contact_type=contact["type"], ) consent = exact_object( profile["consent"], "consent", {"state", "approved_at", "expires_on", "scope"}, ) if consent["state"] != "public_profile_approved": raise ValidationError("consent.state must be public_profile_approved") approved_at = iso_date(consent["approved_at"], "consent.approved_at") consent_expires = iso_date(consent["expires_on"], "consent.expires_on") if approved_at > today: raise ValidationError("consent.approved_at must not be in the future") if approved_at > generated_at.date(): raise ValidationError( "consent.approved_at must not be after generated_at" ) if consent_expires != expires_on: raise ValidationError("consent.expires_on must equal profile expires_on") if consent_expires < approved_at: raise ValidationError("consent.expires_on must not precede approved_at") if (consent_expires - approved_at).days > 180: raise ValidationError("public profile consent may cover at most 180 days") public_text(consent["scope"], "consent.scope", maximum=180) interpretation = exact_object( profile["interpretation"], "interpretation", {"status", "not_for"}, ) if interpretation["status"] != "owner-approved collaboration hypothesis": raise ValidationError("interpretation.status is unsupported") required_exclusions = { "employment screening", "psychometric diagnosis", "sensitive-trait inference", } excluded = unique_enum_list( interpretation["not_for"], "interpretation.not_for", tuple(sorted(required_exclusions)), required=True, ) if set(excluded) != required_exclusions: raise ValidationError( "interpretation.not_for must contain all protocol exclusions" ) canonical = json.dumps( profile, sort_keys=True, separators=(",", ":"), ensure_ascii=False, ).encode("utf-8") return { "valid": True, "schema": SCHEMA_URL, "schema_version": SCHEMA_VERSION, "alias": profile_alias, "expires_on": expires_on.isoformat(), "canonical_sha256": hashlib.sha256(canonical).hexdigest(), "network_access": False, } def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=( "Validate a privacy-minimized FindMate owner profile and print its " "canonical SHA-256. Performs no network access." ) ) parser.add_argument("profile", type=Path) return parser.parse_args() def main() -> int: args = parse_args() try: result = validate_profile(load_json(args.profile)) except ValidationError as exc: print(f"error: {exc}", file=sys.stderr) return 2 json.dump(result, sys.stdout, indent=2, ensure_ascii=False, sort_keys=True) sys.stdout.write("\n") return 0 if __name__ == "__main__": raise SystemExit(main())