SPB Git forge

spb/doc-api

Public
2commits 1branches 0releases
15.7 MBsize
maindefault branch
13 days agolast push
Python 88.3% TypeScript 7.6% Shell 4.1%
38.6 KB · 533 lines python
Raw Blame History
1#!/usr/bin/env python32"""Build the Anthropic Compliance API + AWS IAM fragments from downloaded docs (offline).34Outputs:5  generated/fragments/endpoints/anthropic-compliance.json6  generated/fragments/parameters/anthropic-compliance.json7  generated/fragments/errors/anthropic-compliance.json8  generated/fragments/compatibility/anthropic-iam-actions.json9  tmp/platform-anthropic/objects-compliance.json10"""11from __future__ import annotations12import json, os, re, sys13from pathlib import Path1415ROOT = Path(__file__).resolve().parents[2]16sys.path.insert(0, str(ROOT / "tmp/platform-anthropic"))17import extract_ref as X  # noqa: E4021819PAGES = ROOT / "sources/anthropic/pages"20RETRIEVED = "2026-09-18"21GUIDE = "https://platform.claude.com/docs/en/manage-claude/"2223# ---------------------------------------------------------------- parsing (fixed Returns handling)2425def parse_page(p: Path) -> dict | None:26    rec = X.parse_page(p)27    if not rec:28        return None29    text = p.read_text()30    if text.startswith("---"):31        _, _, text = text.split("---", 2)32    secs = X.sections(text)33    if "Returns" in secs:34        roots = X.parse_items(secs["Returns"])35        if len(roots) == 1:36            r0 = roots[0]37            rec["returns"] = {"type": r0["type"], "fields": [X.simplify(c) for c in r0["children"]],38                              "description": " ".join(r0["desc"]).strip(), "name": r0.get("name")}39        elif len(roots) > 1:40            rec["returns"] = {"type": "object", "fields": [X.simplify(r) for r in roots], "description": ""}41        # scalar-only returns (e.g. file downloads) -> keep description text42        ret_txt = "\n".join(secs["Returns"]).strip()43        if not roots and ret_txt:44            rec["returns"] = {"type": "binary", "fields": [], "description": ret_txt[:400]}45    return rec464748def leaf_pages(d: Path) -> list[Path]:49    out = []50    for f in sorted(d.rglob("*.md")):51        if not f.with_suffix("").is_dir():52            out.append(f)53    return out545556# ---------------------------------------------------------------- endpoint metadata5758def group_of(path: str) -> str:59    if path.startswith("/v1/compliance/activities"):60        return "activities"61    if path.startswith("/v1/compliance/apps/artifacts"):62        return "apps/artifacts"63    if path.startswith("/v1/compliance/apps/chats/files"):64        return "apps/chats/files"65    if path.startswith("/v1/compliance/apps/chats/generated-files"):66        return "apps/chats/generated_files"67    if "/messages" in path and path.startswith("/v1/compliance/apps/chats"):68        return "apps/chats/messages"69    if path.startswith("/v1/compliance/apps/chats"):70        return "apps/chats"71    if path.startswith("/v1/compliance/apps/projects/documents"):72        return "apps/projects/documents"73    if "/attachments" in path:74        return "apps/projects/attachments"75    if "/collaborators" in path:76        return "apps/projects/collaborators"77    if path.startswith("/v1/compliance/apps/projects"):78        return "apps/projects"79    if path.startswith("/v1/compliance/apps/sessions/local"):80        return "apps/sessions/local"81    if path.startswith("/v1/compliance/apps/sessions/remote"):82        return "apps/sessions/remote"83    if path.startswith("/v1/compliance/apps/code/artifacts"):84        return "code/artifacts"85    if path.startswith("/v1/compliance/groups") and "/members" in path:86        return "groups/members"87    if path.startswith("/v1/compliance/groups"):88        return "groups"89    if "/roles/" in path and "/permissions" in path:90        return "organizations/roles/permissions"91    if "/roles" in path:92        return "organizations/roles"93    if path.endswith("/settings"):94        return "organizations/settings"95    if path.endswith("/users"):96        return "organizations/users"97    if path.startswith("/v1/compliance/organizations"):98        return "organizations"99    if path.startswith("/v1/organizations/compliance_settings"):100        return "admin/compliance_settings"101    return "other"102103104ORG_DATA_GROUPS = {"organizations", "organizations/roles", "organizations/roles/permissions",105                   "organizations/settings", "groups"}106USER_DATA_GROUPS = {"apps/artifacts", "apps/chats", "apps/chats/files", "apps/chats/generated_files",107                    "apps/chats/messages", "apps/projects", "apps/projects/attachments", "apps/projects/collaborators",108                    "apps/projects/documents", "apps/sessions/local", "apps/sessions/remote", "code/artifacts",109                    "organizations/users", "groups/members"}110111112def auth_for(method: str, group: str) -> dict:113    base = {"scheme": "api_key", "header": "x-api-key", "base_url": "https://api.anthropic.com",114            "version_header": "anthropic-version: 2023-06-01"}115    if group == "admin/compliance_settings":116        base.update(key_type="admin_api_key (sk-ant-admin01-…)", scopes=None,117                    note="Admin API (beta). Reference example sends X-Api-Key; SDK surfaces address it as "118                         "/v1/organizations/compliance_settings?beta=true. Not available on Claude Platform on AWS.")119        return base120    if group == "activities":121        base.update(key_type="compliance_access_key (sk-ant-api01-…) OR admin_api_key (sk-ant-admin01-…)",122                    scopes=["read:compliance_activities", "read:org_audit"],123                    note="Only Compliance endpoint an Admin API key can call (key must have been created while the "124                         "Compliance API was enabled). On Claude Platform on AWS: IAM action "125                         "aws-external-anthropic:ListComplianceActivities (SigV4).")126        return base127    scopes: list[str]128    if method == "DELETE":129        scopes = ["delete:compliance_user_data"]130        note = "Compliance Access Key only; scope 'Needed' (not 'one of'). Destructive, permanent, immediate."131    elif group in ORG_DATA_GROUPS:132        scopes = ["read:compliance_org_data", "read:org_audit"]133        note = "Compliance Access Key only (Enterprise). read:compliance_org_settings retired 2026-06-30."134    elif group in USER_DATA_GROUPS:135        scopes = ["read:compliance_user_data", "read:org_audit"]136        note = "Compliance Access Key only (Claude Enterprise tenant); Admin API keys get 403."137        if group in ("apps/projects/collaborators", "code/artifacts"):138            note += " Scope inferred from resource family (reference page does not state it explicitly)."139    else:140        scopes = ["unknown"]141        note = "scope not stated"142    base.update(key_type="compliance_access_key (sk-ant-api01-…)", scopes=scopes, note=note)143    return base144145146def pagination_for(rec: dict) -> dict | None:147    q = {p["name"] for p in rec.get("query_params", [])}148    if "after_id" in q:149        return {"style": "cursor", "request_params": ["limit", "after_id", "before_id"],150                "response_fields": ["data", "has_more", "first_id", "last_id"],151                "note": "Opaque cursors; only one of after_id/before_id per request."}152    if "page" in q:153        sess = "/sessions/" in rec["path"]154        return {"style": "page_token", "request_params": ["limit", "page"],155                "response_fields": ["data", "next_page"] + ([] if sess else ["has_more"]),156                "note": "Pass next_page back as page; stop when next_page is null" +157                        (" (session endpoints return no has_more; local-session page tokens expire 24 h after the walk starts)." if sess else " / has_more is false.")}158    if "limit" in q:159        return {"style": "limit_only", "request_params": ["limit"], "response_fields": ["data"],160                "note": "Whole result set returned when limit omitted."}161    return None162163164def response_for(rec: dict) -> dict:165    ret = rec.get("returns") or {}166    fields = [f.get("name") or f.get("type") for f in ret.get("fields", [])]167    content_type = "application/json"168    if ret.get("type") == "binary" or rec["path"].endswith("/content") or "/versions/" in rec["path"]:169        content_type = "application/octet-stream (chunked; Content-Disposition, Content-Type, Content-MD5)"170        if "/artifacts/" in rec["path"] and rec["path"].startswith("/v1/compliance/apps/artifacts"):171            content_type = "application/json ({content, title, artifact_type, …})"172    return {"status": 200, "content_type": content_type,173            "schema_ref": (ret.get("type") or None) if ret else None,174            "fields": fields[:60] if len(fields) <= 60 else fields[:20] + [f"… {len(fields)} union variants"],175            "example": json.dumps(rec["example_response"])[:1500] if rec.get("example_response") else None}176177178def sdk_for(group: str) -> dict:179    if group == "admin/compliance_settings":180        return {"python": "client.beta.organization.compliance_settings.retrieve() / .update(state=…)",181                "node": "client.beta.organization.complianceSettings.retrieve() / .update({state})"}182    return {"python": None, "node": None,183            "note": "No compliance methods in sources/anthropic/openapi/{python,node}-sdk-api.md (grep 'compliance' → only beta.organization.compliance_settings)."}184185186def endpoint_record(rec: dict) -> dict:187    group = group_of(rec["path"])188    family = "admin" if group == "admin/compliance_settings" else "compliance"189    status = ["DOCUMENTED", "ACCOUNT_RESTRICTED"]190    if family == "admin":191        status = ["DOCUMENTED", "BETA", "ACCOUNT_RESTRICTED"]192    sources = [{"url": rec["url"], "retrieved_at": RETRIEVED}]193    guide_map = {194        "activities": "compliance-activity-feed", "apps/sessions/local": "compliance-sessions",195        "apps/sessions/remote": "compliance-sessions", "organizations": "compliance-org-data",196        "organizations/roles": "compliance-org-data", "organizations/roles/permissions": "compliance-org-data",197        "organizations/users": "compliance-org-data", "organizations/settings": "compliance-org-data",198        "groups": "compliance-org-data", "groups/members": "compliance-org-data",199        "admin/compliance_settings": "compliance-api-access",200    }201    sources.append({"url": GUIDE + guide_map.get(group, "compliance-content-data"), "retrieved_at": RETRIEVED})202    body_ct = rec.get("body_content_type")203    body_ref = None204    if rec.get("body_params"):205        body_ref = ", ".join(f'{p["name"]}: {p["type"]}' for p in rec["body_params"])206    idem = "safe (read-only)" if rec["method"] == "GET" else (207        "not idempotent in effect: first call deletes, repeat returns 404" if rec["method"] == "DELETE" else208        "idempotent (setting state to its current value succeeds unchanged)")209    return {210        "provider": "anthropic", "api_family": family, "resource_group": group,211        "method": rec["method"], "path": rec["path"], "name": rec["title"],212        "description": rec["description"][:1200], "status": status,213        "auth": auth_for(rec["method"], group),214        "beta_header": None if family == "compliance" else "unknown (reference page lists no anthropic-beta header; SDK marks the resource as beta via `?beta=true`)",215        "request": {"content_type": body_ct, "body_ref": body_ref,216                    "path_params": [p["name"] for p in rec.get("path_params", [])],217                    "query_params": [p["name"] + ("[]" if p["type"].startswith("array") else "") for p in rec.get("query_params", [])]},218        "response": response_for(rec),219        "streaming": {"supported": False, "events_ref": None,220                      "note": "Content endpoints stream a chunked HTTP body (not SSE)." if "octet" in response_for(rec)["content_type"] else None},221        "pagination": pagination_for(rec),222        "idempotency": idem,223        "destructive": rec["method"] == "DELETE",224        "sdk": sdk_for(group),225        "verification": {"method": "docs_only", "verified_at": RETRIEVED, "result": "restricted", "http_status": None,226                         "request_note": "Not called. The Compliance API requires a Claude Enterprise tenant with the Compliance API enabled and a Compliance Access Key (sk-ant-api01-) or entitled Admin API key; this account has neither. Delete endpoints must never be exercised."227                         if family == "compliance" else228                         "Not called. Admin API (beta) requires an Admin API key with org-admin role; not available to this account. Update is a state-changing org setting and was not exercised."},229        "sources": sources,230    }231232233# ---------------------------------------------------------------- parameters234235def to_num(v):236    try:237        return int(v) if v is not None and re.fullmatch(r"-?\d+", str(v)) else (float(v) if v is not None else None)238    except Exception:239        return None240241242def param_records(rec: dict) -> list[dict]:243    out = []244    ep = f'{rec["method"]} {rec["path"]}'245246    def emit(node: dict, loc: str, prefix: str = "", top_is_array: bool = False):247        name = node.get("name")248        if name is None:  # enum value / union variant line249            return250        typ = node.get("type") or "unknown"251        full = prefix + name252        if loc == "query" and typ.startswith("array") and not prefix:253            full = name + "[]"254        c = node.get("constraints", {})255        rec_p = {256            "provider": "anthropic", "endpoint": ep, "parameter": full, "location": loc, "type": typ,257            "required": bool(node.get("required")) if node.get("required") is not None else None,258            "default": c.get("default"), "minimum": to_num(c.get("minimum")), "maximum": to_num(c.get("maximum")),259            "enum": node.get("enum") or None,260            "description": (node.get("description") or "")[:800],261            "compatible_models": None, "beta_header": None, "status": ["DOCUMENTED"], "source": rec["url"],262        }263        if c.get("format"):264            rec_p["format"] = c["format"]265        if rec_p["enum"] and len(rec_p["enum"]) > 60:266            rec_p["enum_count"] = len(rec_p["enum"])267        out.append(rec_p)268        for ch in node.get("children", []):269            if ch.get("name") is None:270                # union variant object: descend into its children with same prefix271                for gc in ch.get("children", []):272                    emit(gc, loc, full + ".")273            else:274                emit(ch, loc, full + ".")275276    for p in rec.get("path_params", []):277        emit(p, "path")278    for p in rec.get("query_params", []):279        emit(p, "query")280    for p in rec.get("body_params", []):281        emit(p, "body")282    return out283284285# ---------------------------------------------------------------- objects286287def field_summary(node: dict, depth: int) -> dict:288    d = {"name": node.get("name"), "type": (node.get("type") or "")[:160], "required": node.get("required")}289    if node.get("description"):290        d["description"] = node["description"][:300]291    if node.get("enum"):292        d["enum"] = node["enum"] if len(node["enum"]) <= 40 else node["enum"][:40] + [f"… {len(node['enum'])} total"]293    if node.get("constraints"):294        d["constraints"] = node["constraints"]295    if depth < 2 and node.get("children"):296        kids = []297        for c in node["children"]:298            if c.get("name") is None:  # union variant299                kids.append({"variant": c["type"], "fields": [field_summary(g, depth + 1) for g in c.get("children", [])][:40]})300            else:301                kids.append(field_summary(c, depth + 1))302        d["children"] = kids[:40]303    return d304305306def object_records(recs: list[dict]) -> list[dict]:307    objs: dict[str, dict] = {}308309    def add(name: str, official: str | None, desc: str, fields: list[dict], url: str, union_variants=None):310        key = name311        if key in objs:312            objs[key]["sources"].append({"url": url, "retrieved_at": RETRIEVED})313            return314        o = {"provider": "anthropic", "api_family": "compliance", "name": name, "official_type_name": official,315             "description": desc[:600], "fields": [field_summary(f, 0) for f in fields][:80],316             "status": ["DOCUMENTED", "ACCOUNT_RESTRICTED"], "sources": [{"url": url, "retrieved_at": RETRIEVED}]}317        if union_variants:318            o["union_variants"] = union_variants319        objs[key] = o320321    name_map = {322        "GET /v1/compliance/apps/chats": ("ComplianceChat", "chat list item"),323        "GET /v1/compliance/apps/chats/{claude_chat_id}/messages": ("ComplianceChatMessages", "chat with chat_messages[]"),324        "GET /v1/compliance/apps/chats/files/{claude_file_id}": ("ComplianceFileMetadata", None),325        "GET /v1/compliance/apps/chats/generated-files/{claude_gen_file_id}": ("ComplianceGeneratedFileMetadata", None),326        "GET /v1/compliance/apps/artifacts/{artifact_version_id}": ("ComplianceArtifactVersionMetadata", None),327        "GET /v1/compliance/apps/projects": ("ComplianceProject", None),328        "GET /v1/compliance/apps/projects/{project_id}": ("ComplianceProjectDetails", None),329        "GET /v1/compliance/apps/projects/{project_id}/attachments": ("ComplianceProjectAttachment", None),330        "GET /v1/compliance/apps/projects/{project_id}/collaborators": ("ComplianceProjectCollaborator", None),331        "GET /v1/compliance/apps/projects/documents/{document_id}": ("ComplianceProjectDocument", None),332        "GET /v1/compliance/apps/projects/documents/{document_id}/metadata": ("ComplianceProjectDocumentMetadata", None),333        "GET /v1/compliance/apps/sessions/local": ("ComplianceLocalSession", "compliance_local_session"),334        "GET /v1/compliance/apps/sessions/local/{local_session_id}/messages": ("ComplianceLocalSessionMessage", None),335        "GET /v1/compliance/apps/sessions/remote": ("ComplianceRemoteSession", None),336        "GET /v1/compliance/apps/sessions/remote/{claude_remote_session_id}/messages": ("ComplianceRemoteSessionMessage", None),337        "GET /v1/compliance/apps/code/artifacts": ("ComplianceCodeArtifact", None),338        "GET /v1/compliance/groups": ("ComplianceGroup", None),339        "GET /v1/compliance/groups/{group_id}/members": ("ComplianceGroupMember", None),340        "GET /v1/compliance/organizations": ("ComplianceOrganization", None),341        "GET /v1/compliance/organizations/{org_uuid}/roles": ("ComplianceRole", None),342        "GET /v1/compliance/organizations/{org_uuid}/roles/{role_id}/permissions": ("ComplianceRolePermission", None),343        "GET /v1/compliance/organizations/{org_uuid}/users": ("ComplianceOrganizationUser", None),344        "GET /v1/compliance/organizations/{organization_id}/settings": ("EffectiveOrganizationSettings", "effective_organization_settings"),345        "GET /v1/organizations/compliance_settings": ("BetaComplianceSettings", "BetaComplianceSettings"),346    }347    for rec in recs:348        ep = f'{rec["method"]} {rec["path"]}'349        ret = rec.get("returns")350        if not ret or not ret.get("fields"):351            continue352        fields = ret["fields"]353        if fields and fields[0].get("name") == "data" and fields[0].get("children"):354            fields = fields[0]["children"]  # unwrap list envelope355        if ep == "GET /v1/compliance/activities":356            variants = fields357            actor = next(c for c in variants[0]["children"] if c["name"] == "actor")358            common = [c for c in variants[0]["children"] if c["name"] in ("id", "created_at", "organization_id", "organization_uuid", "actor", "type")]359            add("Activity", "Activity (discriminated union on `type`)",360                "One Activity Feed event. Common fields id/created_at/organization_id/organization_uuid/actor/type; each `type` adds type-specific fields (e.g. claude_chat_id, filename).",361                common, rec["url"], union_variants=[v["type"].replace(" object", "") for v in variants])362            add("Actor", "APIActor | UserActor | UnauthenticatedUserActor | AnthropicActor | SystemActor | AdminAPIKeyActor | ServiceAccountActor | ScimDirectorySyncActor | FederatedIdentityActor | FederatedActor | AttestedDeviceActor",363                "Who performed the activity; discriminated union on actor.type.", [actor], rec["url"])364            env = [f for f in ret["fields"] if f.get("name") in ("data", "has_more", "first_id", "last_id")]365            add("ActivityListResponse", None, "Envelope: data[] Activity, has_more, first_id, last_id.",366                [{**f, "children": []} if f.get("name") == "data" else f for f in env], rec["url"])367            continue368        name, official = name_map.get(ep, (None, None))369        if not name:370            continue371        # list envelopes: data is `array of object` root with children372        if ret.get("type", "").startswith("array") or (fields and fields[0].get("name") == "data"):373            pass374        if fields and fields[0].get("name") is None and len(fields) > 1:375            # union of variants376            add(name, official or " | ".join(f["type"].replace(" object", "") for f in fields),377                rec["description"], [], rec["url"],378                union_variants=[{"variant": f["type"], "fields": [field_summary(g, 1) for g in f.get("children", [])]} for f in fields])379            continue380        add(name, official or ret.get("type"), rec["description"], fields, rec["url"])381    return list(objs.values())382383384# ---------------------------------------------------------------- errors (from compliance-errors.md, hand-curated)385386ERR_URL = GUIDE + "compliance-errors"387388389def error_records() -> list[dict]:390    def E(status, typ, code, sem, retry, action, cat, backoff=None, example=None):391        return {"provider": "anthropic", "api_family": "compliance", "http_status": status, "type": typ, "code": code,392                "message_semantics": sem, "retryable": retry, "recommended_action": action, "backoff": backoff,393                "category": cat, "observed_live": False, "example": example, "source": ERR_URL}394    return [395        E(400, "invalid_request_error", "compliance_api_not_enabled", "`Compliance API is not enabled for this organization` — key valid but API not enabled (or turned off) for the org/parent; every endpoint returns it.", False, "Enable the Compliance API (claude.ai > Organization settings > API for Enterprise; Console > Settings > Security toggle for standalone Console org), then resend.", "invalid_request"),396        E(400, "invalid_request_error", "unknown_query_parameter", "`Unknown query parameter: 'created_at[gte]'. Did you mean 'created_at.gte'?` — unrecognized params are rejected, not ignored.", False, "Use dot notation for ranges (created_at.gte), `[]` suffix for arrays (activity_types[]), and after_id/before_id/page per endpoint.", "invalid_request"),397        E(400, "invalid_request_error", "invalid_parameter_value", "Message starts with the parameter name then the failed constraint, e.g. `limit: Input should be less than or equal to 1000`, `created_at.gte: Input should be a valid datetime…`, `activity_types[].0: Input is not one of the permitted values.`, `created_at.gte: Input should have timezone info`, `created_at.lt must be strictly after created_at.gte.`; tool_use_input_max_bytes/tool_result_max_bytes accept positive int or -1.", False, "Correct the named parameter; respect per-endpoint limit maxima; RFC 3339 timestamps with explicit UTC offset.", "invalid_request"),398        E(400, "invalid_request_error", "invalid_pagination_cursor", "`Invalid activity_id format: '…'` (activities) / `Invalid pagination cursor for 'after_id'` (chats) / `The page parameter is not a valid cursor for this request.` (local sessions, cursor bound to session+order) / `The page cursor has expired. Restart the walk without a page parameter…` (local session messages, 24 h).", False, "Treat cursors as opaque; copy first_id/last_id/next_page unchanged; on expiry restart without page.", "invalid_request"),399        E(401, "authentication_error", "api_key_invalid", "`API key is invalid.` — value does not match a usable Compliance Access Key / Admin API key (truncated/altered).", False, "Compare stored secret; create a new key if the copy is wrong.", "authentication"),400        E(401, "authentication_error", "api_key_deactivated", "`API key has been deactivated.` — key disabled or deleted.", False, "Re-enable if only disabled; otherwise create a new key and rotate.", "authentication"),401        E(401, "authentication_error", "api_key_expired", "`API key has expired.` — Admin API key past its expiration (Compliance Access Keys have no expiry).", False, "Create a new key and update the integration.", "authentication"),402        E(403, "permission_error", "insufficient_scope_activities", "`Missing required scopes. Got: [...] Needed one of: ['read:compliance_activities', 'read:org_audit']` on GET /v1/compliance/activities.", False, "Create a Compliance Access Key with read:compliance_activities, or use an Admin API key created while the Compliance API was enabled.", "permission"),403        E(403, "permission_error", "insufficient_scope_org_data", "`… Needed one of: ['read:compliance_org_data', 'read:org_audit']` on organizations/roles/groups/settings endpoints; Admin API keys cannot read org metadata.", False, "Create a new Compliance Access Key with read:compliance_org_data.", "permission"),404        E(403, "permission_error", "retired_scope_org_settings", "`Got: ['read:compliance_org_settings'] Needed one of: ['read:compliance_org_data', 'read:org_audit']` — scope retired 2026-06-30; settings endpoint now needs read:compliance_org_data.", False, "Create a new key with read:compliance_org_data, migrate, delete the old key.", "permission"),405        E(403, "permission_error", "insufficient_scope_user_data", "`… Needed one of: ['read:compliance_user_data', 'read:org_audit']` on chats/messages/files/projects/sessions/users/group-members; Admin API keys can never hold this scope.", False, "Use a Compliance Access Key created in claude.ai with read:compliance_user_data.", "permission"),406        E(403, "permission_error", "insufficient_scope_delete", "`… Needed: ['delete:compliance_user_data']` on DELETE chats/files/projects/documents.", False, "Create a separate key carrying delete:compliance_user_data (keep read and delete keys separate).", "permission"),407        E(404, "not_found_error", "request_not_authenticated", "Bare `Not found` — no key, or a key type the Compliance API does not accept (e.g. sk-ant-api03- Claude API key); same body as a non-existent path; any endpoint incl. lists. Exception: organization settings endpoint returns 401 instead.", False, "Send an sk-ant-api01- or sk-ant-admin01- key in x-api-key; check the path against the reference.", "not_found"),408        E(404, "not_found_error", "chat_not_found", "`Chat conversation not found: '<claude_chat_id>'` — hard-deleted, retention-expired, or outside key scope. User-deleted chats are NOT 404 (listed with deleted_at).", False, "Reconcile against claude_chat_created / claude_chat_viewed activities; drop the ID from the queue.", "not_found"),409        E(404, "not_found_error", "file_not_found", "`File not found: <uuid>` — file missing/deleted (deleting a chat deletes its files); message uses the underlying UUID; applies to metadata, content and delete endpoints, chat files and project files.", False, "Reconcile against claude_file_uploaded / claude_file_deleted / claude_chat_deleted activities.", "not_found"),410        E(404, "not_found_error", "generated_file_or_artifact_not_found", "`Generated file not found: '…'` (metadata) / `Generated file content not found: '…'` (content) / `Artifact version not found: '…'` (both artifact endpoints) — deleted with their chat.", False, "Look up the chat via Get chat messages; if deleted_at set, remove from queue.", "not_found"),411        E(404, "not_found_error", "project_not_found", "`No project is found with the provided id.` (detail/attachments/collaborators) / `No project found with provided id, or it has already been deleted.` (DELETE).", False, "Reconcile against claude_project_created / claude_project_deleted activities.", "not_found"),412        E(404, "not_found_error", "project_document_not_found", "`No project document found with the provided id.` / `…, or it has already been deleted.` (DELETE) — text project documents (claude_proj_doc_) only.", False, "List current attachments via GET /v1/compliance/apps/projects/{project_id}/attachments.", "not_found"),413        E(404, "not_found_error", "local_session_not_found", "`Local session not found.` — not readable (other parent org), never existed, ZDR in effect, or fully aged out of retention; no transient form. Malformed non-`clls_` ID → 400.", False, "Confirm via the local session list; if absent, transcript is not retrievable.", "not_found"),414        E(404, "not_found_error", "local_sessions_not_available", "`Local sessions are not available.` — returned on EVERY local-session call incl. the list while the endpoints are unavailable to the parent org; independent of session ID; can be temporary.", True, "Keep queued IDs; retry on the next scheduled run; if persistent contact Anthropic with request-id.", "not_found"),415        E(404, "not_found_error", "remote_session_not_found", "`Remote session not found.` — `cse_` ID missing/deleted, outside scope, or session still `pending` (no transcript yet). Malformed ID → 400.", "conditional", "Check status via the remote session list; retry after it leaves pending; deleted sessions are gone.", "not_found"),416        E(404, "not_found_error", "organization_role_or_group_not_found", "`The \"<org_uuid>\" organization does not exist or the requester is not authorized to access it.` / `Role not found.` / `Group not found.`", False, "Verify the ID against the corresponding list endpoint.", "not_found"),417        E(404, "not_found_error", "organization_settings_not_available", "`organization `<uuid>` not found in this organization's hierarchy` — org not a linked child, invalid UUID, or settings endpoint not yet enabled for the parent (same body on purpose).", False, "Verify against List organizations; if a known-good ID still 404s, contact your Anthropic representative.", "not_found"),418        E(409, "invalid_request_error", "project_has_attached_chats", "`The \"<claude_proj_id>\" project cannot be deleted as it has chats attached to it. Delete or detach all chats, and try deleting the project again.` (type is invalid_request_error — distinguish by 409).", False, "List chats with user_ids[] + project_ids[], delete or detach each, retry the project delete.", "invalid_request"),419        E(429, "rate_limit_error", "compliance_rate_limit_exceeded", "`Compliance API rate limit of 600 requests per minute per parent organization has been exceeded…` — shared budget across all keys/linked orgs/endpoints; remote-session endpoints carry a second budget (its 429 has retry-after: 1 always).", True, "Wait `retry-after` seconds (fallback exponential backoff 1 s → 60 s); do NOT advance the cursor. Headers: anthropic-ratelimit-requests-limit/-remaining/-reset.", "rate_limit", backoff="retry-after header; else exponential 1s doubling to 60s"),420        E(500, "api_error", None, "Deterministic failure when `x-should-retry: false` header present; otherwise transient.", "conditional", "Honor x-should-retry; if absent retry with exponential backoff (1 s → 60 s).", "server_error", backoff="exponential 1s→60s unless x-should-retry: false"),421        E("502/503/504/529", None, None, "Transient upstream/overload errors.", True, "Retry with exponential backoff; check status.anthropic.com. Exception: some local-session 503s are not transient (see overloaded_error).", "capacity", backoff="exponential 1s→60s"),422        E(503, "overloaded_error", "local_sessions_index_unavailable", "`The local-sessions index is temporarily unavailable. Try again shortly.` — transient.", True, "Retry with backoff; do not advance page cursor.", "overload", backoff="exponential"),423        E(503, "overloaded_error", "local_sessions_captured_content_unavailable", "`Captured content is temporarily unavailable. Try again shortly.` — usually transient; persistent for CMEK orgs whose key is disabled/revoked/unreachable (never reported as not_captured).", "conditional", "Retry with backoff; if it keeps recurring for a CMEK org, check the key in your KMS and stop walking that org's transcripts.", "overload", backoff="exponential"),424        E(503, "overloaded_error", "local_sessions_retention_overrides_unavailable", "`The local-sessions index cannot currently evaluate retention overrides for this page/session. Try again later.` — depends on the org's data/settings, can persist.", "conditional", "Do not hold the walk open: narrow created_at window or skip the session and retry on a later run (restart without page).", "overload", backoff="retry on a later run"),425    ]426427428# ---------------------------------------------------------------- IAM actions429430IAM_URL = "https://platform.claude.com/docs/en/api/claude-platform-on-aws-iam-actions"431ACCOUNT_SCOPED = {"CreateWorkspace", "ListWorkspaces", "ListComplianceActivities", "RegisterKey", "GetKey", "ListKeys",432                  "UpdateKey", "DisableKey"}433ROUTELESS = {"CallWithBearerToken", "AssumeConsole"}434MANAGED = {435    "AnthropicFullAccess": lambda a: True,436    "AnthropicReadOnlyAccess": lambda a: a.startswith(("Get", "List")) or a == "CallWithBearerToken",437    "AnthropicInferenceAccess": lambda a: a.startswith(("Get", "List")) or a in {"CreateInference", "CreateBatchInference", "CancelBatchInference", "DeleteBatchInference", "CountTokens", "CallWithBearerToken"},438    "AnthropicSelfHostedEnvironmentAccess": lambda a: a in {"GetEnvironment", "ProcessEnvironmentWork", "GetSession", "UpdateSession", "GetSkill", "CallWithBearerToken"},439}440CMA_GROUPS = {"Agents", "Sessions", "Environments", "Vaults", "Memory stores", "Webhooks"}441442443def iam_records() -> list[dict]:444    text = (PAGES / "api/claude-platform-on-aws-iam-actions.md").read_text()445    # route -> cloudtrail type446    ct = {}447    for m in re.finditer(r"^\| `(GET|POST|DELETE)`\s*\| `([^`]+)`\s*\| `(\w+)`\s*\| (Data|Management)\s*\|", text, re.M):448        ct[m.group(3)] = m.group(4)449    actions_sec = text.split("## Actions", 1)[1].split("## Route-to-action mapping", 1)[0]450    out = []451    for gm in re.finditer(r"### (.+?)\n(.*?)(?=\n### |\Z)", actions_sec, re.S):452        group, body = gm.group(1).strip(), gm.group(2)453        notes = " ".join(s.strip() for s in re.findall(r"<Note>(.*?)</Note>|<Warning>(.*?)</Warning>", body, re.S) for s in s if s)454        notes = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", notes)455        notes = re.sub(r"\s+", " ", notes).strip()456        for row in re.finditer(r"^\| `(\w+)`\s*\| (.+?) \|$", body, re.M):457            action, routes_raw = row.group(1), row.group(2).strip()458            routes = re.findall(r"`([^`]+)`", routes_raw)459            routeless = action in ROUTELESS460            scope = "route-less (authentication/console permission; Resource \"*\")" if routeless else (461                "account-scoped (workspace ARN has no effect; use Resource \"*\")" if action in ACCOUNT_SCOPED else "workspace")462            desc = {463                "CallWithBearerToken": "Authorizes a principal to authenticate with an API key (bearer token) instead of SigV4. Maps to no route; grant alongside route-mapped actions.",464                "AssumeConsole": "Authorizes opening the Claude Console for a Claude Platform on AWS workspace via AWS Console federation. Maps to no route; not included in any managed policy except AnthropicFullAccess.",465            }.get(action, f"Authorizes {'; '.join(routes)}" if routes else "")466            policies = [p for p, f in MANAGED.items() if f(action)]467            if action != "AssumeConsole" and (MANAGED["AnthropicInferenceAccess"](action) or group in CMA_GROUPS):468                policies.append("AnthropicLimitedAccess")469            out.append({470                "provider": "anthropic", "platform": "claude_platform_on_aws", "service_prefix": "aws-external-anthropic",471                "action": f"aws-external-anthropic:{action}", "action_short": action, "group": group,472                "resource_type": "workspace" if scope == "workspace" else None, "resource_scope": scope,473                "resource_arn_format": "arn:aws:aws-external-anthropic:{region}:{account-id}:workspace/{workspace-id}" if scope == "workspace" else "*",474                "routes_authorized": routes, "cloudtrail_event_type": ct.get(action),475                "description": desc, "group_notes": notes[:1200] or None,476                "condition_keys": [], "condition_keys_note": "No service-specific condition keys are documented on the reference page.",477                "managed_policies_including": sorted(set(policies)),478                "status": ["DOCUMENTED"], "source": IAM_URL, "retrieved_at": RETRIEVED,479            })480    return out481482483# ---------------------------------------------------------------- main484485def main():486    files = leaf_pages(PAGES / "api/compliance") + [487        PAGES / "api/beta/organization/compliance_settings/retrieve.md",488        PAGES / "api/beta/organization/compliance_settings/update.md",489    ]490    recs = [r for r in (parse_page(f) for f in files) if r]491    # persist fixed parses for inspection492    outdir = ROOT / "tmp/platform-anthropic/ref-compliance"493    outdir.mkdir(exist_ok=True)494    for r in recs:495        slug = r["source_file"].replace("sources/anthropic/pages/", "").replace("/", "__").replace(".md", "")496        (outdir / f"{slug}.json").write_text(json.dumps(r, indent=1, ensure_ascii=False))497498    endpoints = [endpoint_record(r) for r in recs]499    params = []500    seen: dict[tuple, dict] = {}501    for r in recs:502        for p in param_records(r):503            k = (p["endpoint"], p["parameter"], p["location"])504            if k in seen:  # union variants of the same field: merge enum-like types505                prev = seen[k]506                if p["type"] != prev["type"]:507                    prev["type"] = f'{prev["type"]} | {p["type"]}' if " | " not in prev["type"] else prev["type"] + f' | {p["type"]}'508                    vals = [t.strip().strip('"') for t in prev["type"].split("|")]509                    if all(v.replace("_", "").isalnum() for v in vals):510                        prev["enum"] = vals511                continue512            seen[k] = p513            params.append(p)514    objects = object_records(recs)515    errors = error_records()516    iam = iam_records()517518    (ROOT / "generated/fragments/compatibility").mkdir(parents=True, exist_ok=True)519    (ROOT / "generated/fragments/errors").mkdir(parents=True, exist_ok=True)520    (ROOT / "generated/fragments/endpoints/anthropic-compliance.json").write_text(json.dumps(endpoints, indent=1, ensure_ascii=False))521    (ROOT / "generated/fragments/parameters/anthropic-compliance.json").write_text(json.dumps(params, indent=1, ensure_ascii=False))522    (ROOT / "generated/fragments/errors/anthropic-compliance.json").write_text(json.dumps(errors, indent=1, ensure_ascii=False))523    (ROOT / "generated/fragments/compatibility/anthropic-iam-actions.json").write_text(json.dumps(iam, indent=1, ensure_ascii=False))524    (ROOT / "tmp/platform-anthropic/objects-compliance.json").write_text(json.dumps(objects, indent=1, ensure_ascii=False))525526    from collections import Counter527    print("endpoints", len(endpoints), Counter(e["resource_group"] for e in endpoints))528    print("params", len(params), "objects", len(objects), "errors", len(errors), "iam", len(iam), Counter(i["group"] for i in iam))529530531if __name__ == "__main__":532    main()533