diff --git a/Makefile b/Makefile index 5d76852..c17d103 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help sync migrate run test lint shell check test-usecases test-browser setup-usecases check-communication +.PHONY: help sync migrate run test lint shell check test-usecases test-browser setup-usecases check-communication check-owner-communication .DEFAULT_GOAL := help @@ -39,3 +39,6 @@ check: lint ## Full lint/regression gate, including every established browser jo check-communication: ## Send-free owner retry/privacy checks; set EMAIL_CONNECT_ROOT uv run python scripts/check-communication.py --owner "$(EMAIL_CONNECT_ROOT)" + +check-owner-communication: ## Real owner-source browser journey; set both owner roots and a fresh OWNER_BROWSER_RECEIPT + uv run python scripts/check-owner-communication.py --user-engine "$(USER_ENGINE_ROOT)" --email-connect "$(EMAIL_CONNECT_ROOT)" --receipt "$(OWNER_BROWSER_RECEIPT)" diff --git a/docs/release/owner-communication-browser-2026-10-30.json b/docs/release/owner-communication-browser-2026-10-30.json new file mode 100644 index 0000000..8c32394 --- /dev/null +++ b/docs/release/owner-communication-browser-2026-10-30.json @@ -0,0 +1,35 @@ +{ + "schema": "coulomb.owner-communication-browser.v1", + "passed": true, + "browser_node": "scripts/owner_communication/test_browser.py::test_real_owner_registration_and_invitation", + "source_manifest": "owner-communication-browser-2026-10-30.json.sources.txt", + "source_manifest_sha256": "fda78bda6ff6ae10f749596367e835b0bae34fb3313137a834f994a69b497fb5", + "source_file_count": 60, + "runner_sha256": "ff5a75eed45b3c313487041b7f01e9a98a4cf18c67beb72ead6b83804c942b5d", + "python_lock_sha256": "e8ebb6fc4b5abd1ac80f1c9012f1983642d0528a8a213cfcc394261606fad887", + "package_lock_sha256": "47802f261f4006a57a66dc29dac4896c81a5fd68857df36f82319d3255748346", + "checks": { + "passed": true, + "browser_requests_outside_fixture": 0, + "registration_verify_cancel_expire_replay": true, + "mailbox_not_authorization": true, + "tenant_client_caller_denials": true, + "real_invitation_and_resend": true, + "duplicate_delivery_and_suppression": true, + "provider_calls": 5 + }, + "production_access": false, + "smtp_provider_sends": false, + "synthetic_session_and_directory": true, + "productive_acceptance": false, + "native_identity_reset_verified": false, + "full_gate": { + "command": "make check", + "tests": 344, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + "runner_safety_tests_sha256": "5747cbf3616c0aa0c1a602a152c72f28b276ed4e2a2bcab055775995e9920d49", + "synthetic_authorization_adapter": true +} diff --git a/docs/release/owner-communication-browser-2026-10-30.json.sources.txt b/docs/release/owner-communication-browser-2026-10-30.json.sources.txt new file mode 100644 index 0000000..b40f465 --- /dev/null +++ b/docs/release/owner-communication-browser-2026-10-30.json.sources.txt @@ -0,0 +1,60 @@ +scripts/owner_communication/browser.mjs: c49d91cf3f4d106b6e9f5770f8b3b261e4aad1e6a26c8d1f9590dbd20f4972c6 +scripts/owner_communication/test_browser.py: 4895483849feb84ea29f91eb5671004d052b142f5bdc4f7ed4c0eea6637e9d51 +src/email_connect/__init__.py: 0f1ae5e9251f80b0bd5220b1d7bf91735d6ea273e07aa26cb7852a7950be7087 +src/email_connect/adapter_contract.py: 5059b2eaaac2408f4e247bb468a97960eb3f823e63c3eeb151a1c2250e1f3bbf +src/email_connect/callers.py: 06fe961b9030dba2c0b26b92f8097e8fbd24e205d5fa79642aa9af60382886dc +src/email_connect/cli.py: 6ac6ae8acd091ba2d2102589be3b75fe3a48392309e40e61f8383deb3329ad42 +src/email_connect/config.py: 6ca89fb7c3e1f35bc4faedfba8c3313d4366b2cb7830ef88ed3f7d2313f7867e +src/email_connect/evidence.py: 42cca03234a79dc78033ec1fbaf0b1785e6089ef3f3128e2e9d666ed239a3582 +src/email_connect/mailbox.py: 7f0fe7e19ce1f814269609c4236152042f0f49bf3b517114b3b3f6f482471362 +src/email_connect/models.py: 7f8136646b663a097280286dccce76a90abb7190ea0712fff7da32d47526521c +src/email_connect/parser.py: 786b291256ddfac5a8622c743622508cdd9f45f06156d0f5487ce399fad1287c +src/email_connect/recipients.py: 68792f44f0d2ba204649542aa8bd6b0f209d89c675baa4fc0767f1250a29045f +src/email_connect/reporting.py: 8f8ce5f98e4347b778893d08a9e58f2bab3b44b80c159fcf52d0b5772429737c +src/email_connect/scanner.py: 6266cbad2dbc610c1f6df1c051ded2ee55b43d7ff30af6eab38721924931c1ed +src/email_connect/storage.py: 57fbd6d3b2e7541c0bf52d87a4f7361a4ff4a03511620d2a1c3789632b73afeb +src/email_connect/transactional.py: 36fdfd8cb58f10148cd491a7e34d7aea798e4289fce760af70a48776bede204d +src/user_engine/__init__.py: 2da5f289343f1f5832fa2f5f26da8185b9828d306c81e89f0724b7875494abe8 +src/user_engine/adapters/__init__.py: 5b8e63f648f228fdbd76f8c78c3b848ebd03cf0590521c8cf3a66dd85bdad461 +src/user_engine/adapters/application_access.py: adf1fac707446f41b5859ba5102e820cd88f60af5daf20a42f13dfa2067d5481 +src/user_engine/adapters/claims.py: e8944dbb1aee87e0ede28dc69500c5478cf866acba178777bd6ad08ea17213e0 +src/user_engine/adapters/delivery.py: 5011bb77d191e0626f5f2986fe65404c319e363bc0ea90eeaf2072e67a24b11c +src/user_engine/adapters/flex_auth.py: 73d645b7b37904ebfe0df6f7ed397b74b4c47f505cd64a30c39e60717ce71455 +src/user_engine/adapters/local.py: 3d7a5c7716edd0f2227a5d5bcb2760273a3ceb247f3bc9439397fbf829943888 +src/user_engine/adapters/postgres.py: 528d75431d1f3a7e55423ffa55d34722ac343582e513985db74ba3add36e6da6 +src/user_engine/adapters/provisioning.py: a033a891d8e0e2595d177877ac8615814190fe2c97b67059c7e3b63a87e25b51 +src/user_engine/adapters/registration_verification.py: 88fa298f60ff374d7ddf84ce21d0c43fce6d301475ac95b293e8e1324e905a58 +src/user_engine/adapters/tenant_management.py: 1ccaf275d102278b7081dd104d82292d3bf6bec0287d5943f739119294bc4de3 +src/user_engine/application_access.py: 798f180474d98e1c6abacc7b8bfba3184d40ecb77d9ca90f6526cd4f774cf363 +src/user_engine/authentication_policy.py: 50e13972a549e54aff97f5939c640b2ff3b4c6b4f072d7b430cfbfb50f3d079c +src/user_engine/custody.py: d37aaf292cbde24967f8e3546532fd05998eced1b6ac5a7e9a8a91bd9fc86357 +src/user_engine/custody_bootstrap.py: 1af42f51ecf6909092ffbdd69dbb91b7b7ad61f34ade3178063cfaef06db2432 +src/user_engine/custody_canon/__init__.py: f0629b13759b26256fd0e30fa5184a9e834005f49f1e54ec6f0b548d214a31c7 +src/user_engine/custody_canon/custody-record_v0.1.schema.json: bb6cd6da5dc48eddcf118ae78c2cacfafd5f860098fd8a8368fe6bc845776d07 +src/user_engine/custody_canon/policy.py: a1ba5a6f586a7d64e406aebcde783ab033db010be31c132593f07fb0a93a7bc4 +src/user_engine/custody_canon/provenance.json: c39d53e119abfa208b2d098275a0d365d6bebee2764793f8f8fd5a8ca507c9d3 +src/user_engine/custody_runtime.py: ceb689505424145df98865aeb8ce1229eb1abcf4a7a2019e632c23913feab190 +src/user_engine/domain/__init__.py: 12fd0e760491e6e689372f3ea0e4d0b4b4db367554d1ed2e9bc0fe00e4b2bbd5 +src/user_engine/domain/models.py: 8c9cb8051a930fa05a1657c8b539510588f1788812d68510df55712d2f4df050 +src/user_engine/errors.py: 5724a7346e3680af7c1d6321704b21c5afcc3ede460a983d80d2aac9ee8e8746 +src/user_engine/evidence.py: 973344ddbe4200c27b3f0fe809962bbe39804abc1b025fb1caf4895341f8f221 +src/user_engine/factor_recovery.py: adbe69866e96de06b7cc8390607453138772e178d0b49fe81d0470e4d15c26f1 +src/user_engine/identity_state.py: 3718254c6c12e7c5ec19312bf50a0b8f9c09f6859f5b6d4bd62032b5b890fe16 +src/user_engine/layer_yaml.py: bd77be9a116dd61dfbe0e83efcf9e960bd1d2ef9059e831f2a6ba029448d94c7 +src/user_engine/mail_bindings.py: 1afb0eab96fae41e4054a97c82f15a0e9c756acb4305da4bac6898465c30b786 +src/user_engine/migrations.py: d957581bebedcdc6555411f8c9b00665a36e42c554bb623524bdfae7951608d4 +src/user_engine/oidc.py: eeed38e75ca2948a4ffc9bce24f98a1395d9a2cc8152460b392b323afb0bc276 +src/user_engine/operations_status.py: 21cb6feeca99371f08ae876b6a56ad0e2a50ff7023d62fda2c56dc4e00a14cbc +src/user_engine/pep_stance.py: 57045ddd161980fee269bb0e0f3ce5fe0f347b2526d57dfe72e44bf360562841 +src/user_engine/ports.py: 5234d9c49bf1c51157fdfd3da3cfc967d05a8b463b617ace1a8b9033640ce322 +src/user_engine/projections.py: f896833720026ae3b68437616936a37c5e6a3b6478b97bf76bebaa556283146c +src/user_engine/runtime.py: 536e77c1f0e578109dc6495015de742bac53333cf3560d686d67490d809d4dde +src/user_engine/service.py: 1c60b69e08c36eb3f678893a4a945eeeb9c73d5246c6566857cd8c134f75446d +src/user_engine/store_records.py: 3cec4575434e82e29932a3e9cc7659f3dd8fa7e5be81d5690487033796bbfc16 +src/user_engine/testing/__init__.py: 2c9e372a16381edd0625774ef60f887fb0a4fb3f6b880fd347e72683aa99c9db +src/user_engine/testing/fixtures.py: b78b1663e2a00cf42a26b2bb57d2630ea84bf0760154881f536b70055b4e2a0b +src/user_engine/testing/postgres_provider.py: f846bcecdbd76e336e9d58644e88c3ce217498578ae4d2445d04699587f81873 +src/user_engine/testing/scenarios.py: ef94c2d75c8d0f1d78d6b630bd478278e045e1594e5c29be434c305346fecee5 +src/user_engine/testing/store_conformance.py: 7cb66cbd85df88839daedca18c18c450634e9573bf8831d71f2ee2d23d5daba9 +src/user_engine/ui.py: 0e4202ac11b821e87e1ca7d03ae3715ea0101a51b23ab7dac76e7af1afe93034 +src/user_engine/web.py: ae5fd2f41f643c804310d58c5a47d19ab0bc721634197110f684de72b94adeca diff --git a/docs/usecases/platform-communication.md b/docs/usecases/platform-communication.md index f11e94a..8f7f5d8 100644 --- a/docs/usecases/platform-communication.md +++ b/docs/usecases/platform-communication.md @@ -114,3 +114,68 @@ Actual owner-spanning browser integration, platform sender/inbound/reply/bounce operations and productive UC-010/011 verdicts remain WP-0015-T02/T04. UC-009 personal recovery acceptance is preserved; UC-012 remains deferred. Details: ../release/acceptance-audit-2026-10-27.md. + +## Owner source browser integration 2026-10-30 + +UC-010 and UC-011 now have an additional explicit owner-browser gate: + +```sh +make check-owner-communication \ + USER_ENGINE_ROOT=/path/to/user-engine \ + EMAIL_CONNECT_ROOT=/path/to/email-connect \ + OWNER_BROWSER_RECEIPT=/private/fresh-owner-browser-receipt.json +``` + +The exact required node is +`scripts/owner_communication/test_browser.py::test_real_owner_registration_and_invitation`. +It is linked by `owner_browser_tests` in the candidate use-case records. This +separate command needs the two owner checkouts; `make check` continues to protect +the established product journeys and tests the owner runner's isolation/failure +boundaries. The new check does not mark a candidate established or accepted. + +The runner copies and hashes only the owners' Python sources and packaged public +custody schemas into a fresh private tree. It strips inherited configuration and +credentials, disables pytest plugin autoload, and confirms both imported packages +come from that snapshot. Existing owner configuration, databases and tests are +not loaded. Locked development dependencies include user-engine's jsonschema +dependency. The receipt binds the runner, Python lock, Playwright package lock, +owner source and both browser harness files. +Per-file hashes live in the adjacent `.sources.txt` filename/digest manifest, +whose hash is bound in the JSON receipt. Retain both files together. This is the +receive scanner's approved content-digest shape; it adds no scanner exception. + +Chromium submits user-engine's actual registration/confirmation forms. The real +registration HTTP adapter calls email-connect's actual scoped receiver and SQLite +ledger. A synthetic local mail sink exposes the verification/cancellation links; +the directory and preauthenticated administrative session are fixtures. The +authorization adapter is explicitly the owner's standalone test double, not a +production PDP. The password setup handoff is shown but not followed. No native +Authelia reset, directory password change, app sign-in or MFA ceremony is proved +by this node; UC-009 retains its separate personal acceptance and isolated-double +test. + +The browser verifies that mailbox delivery alone creates no applicant User or +directory identity; correct confirmation creates one identity. A second caller +cannot consume a fresh valid handle that the correct caller can subsequently +consume. Reused, cancelled and expired handles refuse, as do wrong tenant/client +bindings and an invalid caller token. The administrative form refuses another +tenant. Real invitation forms and the actual outbox HTTP adapter prove one local +provider call per invitation event, a distinct resend, repeated delivery without +another send, and recipient suppression. The event sink asserts that recipient +addresses are removed from the activity envelope. + +Python connections are restricted to the exact admitted loopback service ports, +with external-connect and DNS refusal probes. Browser routing permits only the +three fixture origins, blocks service workers and rejects other origins. Browser +API requests also validate their origin and disable redirects. The mail provider +is an injected in-memory sink; no SMTP provider or live mailbox is contacted. +Failed check diagnostics stay in a private temporary file. Receipts require the +exact unskipped node and contain hashes/counts/verdicts, not usable handles or +message bodies. Safety tests prove inherited credentials, runtime databases and +`.env` files are excluded, and skipped/wrong-node reports and symlinked sources +cannot pass. + +Evidence: `../release/owner-communication-browser-2026-10-30.json`. Productive +UC-010/011 verdicts and platform sender/reply/bounce operations remain WP-0015. +UC-012 remains deferred pending a selected template/caller; UC-008 manual mail +distribution is unchanged. diff --git a/docs/usecases/registry.json b/docs/usecases/registry.json index 391af11..e2e3421 100644 --- a/docs/usecases/registry.json +++ b/docs/usecases/registry.json @@ -85,8 +85,12 @@ }, "owner_regression_contract": "docs/usecases/communication-regressions.json", "manual_review": [ - "Loopback registration sink is bound; real verification admission remains open" - ] + "Owner-source portal/HTTP adapters/scoped receiver browser gate passes; synthetic session, authorization and directory adapters remain explicit. Productive per-flow verdict remains required." + ], + "owner_browser_tests": [ + "scripts/owner_communication/test_browser.py::test_real_owner_registration_and_invitation" + ], + "owner_browser_command": "make check-owner-communication" }, { "id": "CSOC-UC-011", @@ -107,8 +111,12 @@ }, "owner_regression_contract": "docs/usecases/communication-regressions.json", "manual_review": [ - "Loopback invitation double is bound; real caller admission remains open" - ] + "Owner-source portal/HTTP adapters/scoped receiver browser gate passes; synthetic session, authorization and directory adapters remain explicit. Productive per-flow verdict remains required." + ], + "owner_browser_tests": [ + "scripts/owner_communication/test_browser.py::test_real_owner_registration_and_invitation" + ], + "owner_browser_command": "make check-owner-communication" }, { "id": "CSOC-UC-012", diff --git a/pyproject.toml b/pyproject.toml index 01a39bb..1a6a919 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dev = [ "pytest-django>=4.8", "pytest-cov>=5.0", "ruff>=0.4", + "jsonschema>=4.10,<5", ] [tool.pytest.ini_options] diff --git a/scripts/check-owner-communication.py b/scripts/check-owner-communication.py new file mode 100644 index 0000000..55abeb0 --- /dev/null +++ b/scripts/check-owner-communication.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Browser-check exact owner source snapshots without production configs or sends.""" + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +NODE = "scripts/owner_communication/test_browser.py::test_real_owner_registration_and_invitation" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--user-engine", type=Path, required=True) + parser.add_argument("--email-connect", type=Path, required=True) + parser.add_argument("--receipt", type=Path, required=True) + args = parser.parse_args() + manifest_path = args.receipt.with_name(args.receipt.name + ".sources.txt") + if any(p.exists() or p.is_symlink() for p in (args.receipt, manifest_path)): + raise ValueError("Fresh receipt required") + hashes = {} + with tempfile.TemporaryDirectory(prefix="csoc-owner-browser-") as temporary: + work = Path(temporary) + for owner, package in [(args.user_engine, "user_engine"), + (args.email_connect, "email_connect")]: + source = owner.resolve(strict=True) / "src" / package + if (not source.is_dir() or source.is_symlink() + or not (source / "__init__.py").is_file()): + raise ValueError("Real owner source directory required") + paths = sorted(source.rglob("*.py")) + if package == "user_engine": + paths += sorted((source / "custody_canon").glob("*.json")) + for path in paths: + if not path.is_file() or any(p.is_symlink() for p in [path, *path.parents]): + raise ValueError("Owner source symlink refused") + name = "src/" + path.relative_to(source.parent).as_posix() + data = path.read_bytes() + hashes[name] = hashlib.sha256(data).hexdigest() + target = work / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + for name in ["scripts/owner_communication/test_browser.py", + "scripts/owner_communication/browser.mjs"]: + data = (ROOT / name).read_bytes() + hashes[name] = hashlib.sha256(data).hexdigest() + target = work / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + config = work / "pytest.ini" + config.write_text("[pytest]\nmarkers =\n browser: real browser\n" + " usecase(id): candidate contract\n") + report, checks = work / "result.xml", work / "checks.json" + result = subprocess.run( + [sys.executable, "-m", "pytest", "-q", "-c", str(config), NODE, + "-p", "no:cacheprovider", "--junitxml", str(report)], + cwd=work, capture_output=True, timeout=200, + env={"PATH": os.defpath, "PYTHONPATH": str(work / "src"), + "PYTHONDONTWRITEBYTECODE": "1", "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "OWNER_NODE": shutil.which("node") or "node", + "OWNER_BROWSER_SCRIPT": str(work / "scripts/owner_communication/browser.mjs"), + "OWNER_PLAYWRIGHT_MODULE": str(ROOT / "scripts/node_modules/playwright/index.mjs"), + "OWNER_CHECKS_RESULT": str(checks)}) + cases = ET.parse(report).findall(".//testcase") if report.exists() else [] + passed = (result.returncode == 0 and len(cases) == 1 and checks.exists() + and cases[0].attrib.get("name") == NODE.split("::")[-1] + and not any(c.findall("failure") or c.findall("error") or c.findall("skipped") + for c in cases)) + manifest = "".join(f"{name}: {digest}\n" + for name, digest in sorted(hashes.items())).encode() + proof = {"schema": "coulomb.owner-communication-browser.v1", "passed": passed, + "browser_node": NODE, "source_manifest": manifest_path.name, + "source_manifest_sha256": hashlib.sha256(manifest).hexdigest(), + "source_file_count": len(hashes), + "runner_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), + "python_lock_sha256": hashlib.sha256((ROOT / "uv.lock").read_bytes()).hexdigest(), + "package_lock_sha256": hashlib.sha256( + (ROOT / "scripts/package-lock.json").read_bytes()).hexdigest(), + "checks": json.loads(checks.read_bytes()) if passed else {}, + "production_access": False, "smtp_provider_sends": False, + "synthetic_session_and_directory": True, + "productive_acceptance": False, "native_identity_reset_verified": False} + if not passed: + fd, diagnostic = tempfile.mkstemp(prefix="csoc-owner-browser-private-diagnostics-") + with os.fdopen(fd, "wb") as stream: + stream.write(result.stdout + result.stderr) + proof["private_diagnostics"] = diagnostic + fd = os.open(manifest_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "wb") as stream: + stream.write(manifest) + fd = os.open(args.receipt, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as stream: + json.dump(proof, stream, indent=2) + stream.write("\n") + public = {"passed": passed, "browser_node": NODE} + if not passed: + public["private_diagnostics"] = proof["private_diagnostics"] + print(json.dumps(public)) + return 0 if passed else 1 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError, KeyError, subprocess.SubprocessError, ET.ParseError): + raise SystemExit("Owner browser check refused; inspect private inputs/diagnostics locally.") diff --git a/scripts/owner_communication/browser.mjs b/scripts/owner_communication/browser.mjs new file mode 100644 index 0000000..28fd1ea --- /dev/null +++ b/scripts/owner_communication/browser.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; + +const [portal, fixtures, receiver, modulePath] = process.argv.slice(2); +const allowed = new Set([portal, fixtures, receiver]); +for (const base of allowed) { + const url = new URL(base); + assert.equal(url.hostname, '127.0.0.1'); + assert.equal(url.protocol, 'http:'); +} +const { chromium } = await import(pathToFileURL(modulePath).href); +const browser = await chromium.launch({ headless: true }); +let outside = 0; +const context = await browser.newContext({ serviceWorkers: 'block' }); +await context.route('**/*', route => { + if (!allowed.has(new URL(route.request().url()).origin)) { + outside += 1; + return route.abort(); + } + return route.continue(); +}); +const page = await context.newPage(); +page.setDefaultTimeout(15000); +const request = async (method, url, options = {}) => { + assert(allowed.has(new URL(url).origin)); + return context.request[method](url, { ...options, maxRedirects: 0 }); +}; +const state = async () => (await request('get', fixtures + '/state')).json(); +const control = async (path, data = {}) => { + const response = await request('post', fixtures + path, { data }); + assert.equal(response.status(), 200); +}; +async function register(name) { + await page.goto(portal + '/register'); + await page.getByLabel('Username', { exact: true }).fill(name); + await page.getByLabel('Email', { exact: true }).fill(name + '@example.test'); + await page.getByRole('button', { name: 'Send verification email' }).click(); + await page.getByText('Check your email', { exact: false }).first().waitFor(); + await page.goto(fixtures + '/mailbox'); + const body = await page.locator(`article[data-recipient="${name}@example.test"]`).innerText(); + const url = body.match(/http:\/\/127\.0\.0\.1:\d+\/registration\/verify\?handle=[A-Za-z0-9_-]+/)[0]; + assert.equal(new URL(url).origin, portal); + return url; +} +async function consume(url, button, accepted) { + assert.equal(new URL(url).origin, portal); + await page.goto(url); + const response = page.waitForResponse(r => r.url().startsWith(portal + '/registration/') + && r.request().method() === 'POST'); + await page.getByRole('button', { name: button, exact: true }).click(); + const result = await response; + assert.equal(result.status() < 400, accepted); +} +try { + const verified = await register('verified.person'); + assert.equal((await state()).provisions, 0); // mail receipt alone cannot provision + assert.equal((await state()).users, 1); // only the synthetic inviter exists + const wrongCaller = await request('post', receiver + '/v1/registration-verifications/consume', + { headers: { Authorization: 'Bearer synthetic-other-caller-token' }, + data: { handle: new URL(verified).searchParams.get('handle') } }); + assert.equal(wrongCaller.status(), 400); + await consume(verified, 'Verify and continue', true); + const passwordLink = page.getByRole('link', { name: 'Create password', exact: true }); + assert.equal(await passwordLink.count(), 1, await page.locator('body').innerText()); + assert.equal((await state()).provisions, 1); + assert.equal((await state()).users, 2); + await consume(verified, 'Verify and continue', false); + + const canceled = await register('canceled.person'); + await consume(canceled.replace('/verify?', '/cancel?'), 'Cancel registration', true); + await consume(canceled, 'Verify and continue', false); + const expired = await register('expired.person'); + await control('/expire', { email: 'expired.person@example.test' }); + await consume(expired, 'Verify and continue', false); + assert.equal((await state()).provisions, 1); + + const headers = { Authorization: 'Bearer synthetic-user-engine-mail-token' }; + const challenge = { registration_id: 'reg-wrong-binding', + normalized_email: 'denied@example.test', preferred_username: 'denied.person', + client_id: 'coulomb-social-test', tenant: 'tenant:communication-test', correlation_id: 'test' }; + for (const bad of [{ tenant: 'tenant:other' }, { client_id: 'client-other' }]) { + const response = await request('post', receiver + '/v1/registration-verifications', + { headers, data: { ...challenge, ...bad } }); + assert.equal(response.status(), 400); + } + const missingCaller = await request('post', receiver + '/v1/registration-verifications', + { headers: { Authorization: 'Bearer invalid-caller-token' }, data: challenge }); + assert.equal(missingCaller.status(), 401); + assert.equal((await state()).messages, 3); + + // Synthetic preauthenticated MFA session; real portal form and role/CSRF checks. + await context.addCookies([{ name: 'ue_session', value: 'synthetic-admin-session', + url: portal, httpOnly: true, sameSite: 'Lax' }]); + assert.equal((await request('get', portal + '/admin/tenant%3Aother')).status(), 403); + const admin = portal + '/admin/tenant%3Acommunication-test'; + await page.goto(admin); + const form = page.locator('form[action$="/invitations"]'); + await form.getByLabel('Email', { exact: true }).fill('invited@example.test'); + await form.getByRole('button', { name: 'Send invitation', exact: true }).click(); + await control('/deliver'); + assert.equal((await state()).messages, 4); + await control('/deliver'); + assert.equal((await state()).messages, 4); + await page.reload(); + await page.getByRole('button', { name: 'Resend', exact: true }).click(); + await control('/deliver'); + assert.equal((await state()).messages, 5); + + await control('/suppress', { email: 'suppressed@example.test' }); + await page.goto(admin); + await form.getByLabel('Email', { exact: true }).fill('suppressed@example.test'); + await form.getByRole('button', { name: 'Send invitation', exact: true }).click(); + await control('/deliver'); + assert.equal((await state()).messages, 5); + assert.equal((await state()).provisions, 1); + assert.equal(outside, 0); + console.log(JSON.stringify({ passed: true, browser_requests_outside_fixture: outside, + registration_verify_cancel_expire_replay: true, mailbox_not_authorization: true, + tenant_client_caller_denials: true, real_invitation_and_resend: true, + duplicate_delivery_and_suppression: true, provider_calls: 5 })); +} finally { + await browser.close(); +} diff --git a/scripts/owner_communication/test_browser.py b/scripts/owner_communication/test_browser.py new file mode 100644 index 0000000..eaed706 --- /dev/null +++ b/scripts/owner_communication/test_browser.py @@ -0,0 +1,199 @@ +"""Real owner portal/adapters/receiver with synthetic session, directory and mail sink.""" + +import html +import json +import os +import socket +import subprocess +import threading +import time +from contextlib import contextmanager +from pathlib import Path +from socketserver import ThreadingMixIn +from wsgiref.simple_server import WSGIRequestHandler, WSGIServer, make_server + +import pytest + +TENANT = "tenant:communication-test" +CLIENT = "coulomb-social-test" +TOKEN = "synthetic-user-engine-mail-token" + + +class QuietHandler(WSGIRequestHandler): + def log_message(self, *args): + pass + + +class FixtureServer(ThreadingMixIn, WSGIServer): + daemon_threads = True + + +@contextmanager +def server(app, ports): + httpd = make_server("127.0.0.1", 0, app, server_class=FixtureServer, + handler_class=QuietHandler) + ports.add(httpd.server_port) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{httpd.server_port}" + finally: + httpd.shutdown() + httpd.server_close() + thread.join(timeout=5) + + +@pytest.mark.browser +@pytest.mark.usecase("CSOC-UC-010") +@pytest.mark.usecase("CSOC-UC-011") +def test_real_owner_registration_and_invitation(tmp_path, monkeypatch): + ports = set() + connect, resolve = socket.socket.connect, socket.getaddrinfo + + def guarded_connect(sock, address): + if (not isinstance(address, tuple) or address[0] != "127.0.0.1" + or address[1] not in ports): + raise RuntimeError("Owner harness refuses non-fixture connections") + return connect(sock, address) + + def guarded_resolve(host, port, *args, **kwargs): + if host != "127.0.0.1" or int(port) not in ports: + raise RuntimeError("Owner harness refuses DNS/non-fixture connections") + return resolve(host, port, *args, **kwargs) + + monkeypatch.setattr(socket.socket, "connect", guarded_connect) + monkeypatch.setattr(socket.socket, "connect_ex", guarded_connect) + monkeypatch.setattr(socket, "getaddrinfo", guarded_resolve) + monkeypatch.setattr(socket, "getfqdn", lambda *args: "localhost") + with pytest.raises(RuntimeError), socket.socket() as denied: + denied.connect(("203.0.113.1", 443)) + with pytest.raises(RuntimeError): + socket.getaddrinfo("provider.example.test", 443) + + import email_connect + import user_engine + from email_connect.callers import CallerPolicy, ScopedTransactionalApplication + from email_connect.transactional import SQLiteDeliveryStore + from user_engine.adapters.delivery import HTTPOutboxDeliveryAdapter + from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort + from user_engine.adapters.registration_verification import HTTPRegistrationVerificationAdapter + from user_engine.oidc import BrowserSession, OIDCClient + from user_engine.ports import ProvisioningResult + from user_engine.service import UserEngineService + from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims + from user_engine.web import PortalApplication + + snapshot = Path(os.environ["PYTHONPATH"]).resolve() + assert all(Path(package.__file__).resolve().is_relative_to(snapshot) + for package in (email_connect, user_engine)) + + messages, provisions, events = [], [], [] + store = InMemoryUserEngineStore() + store.migrate() + service = UserEngineService(store=store, identity_adapter=FixtureIdentityClaimsAdapter(), + authorization=LocalAuthorizationCheckPort()) + ledger = SQLiteDeliveryStore(str(tmp_path / "mail.db")) + + class MailSink: + def send(self, recipient, subject, body): + assert recipient.endswith("@example.test") + messages.append({"recipient": recipient, "subject": subject, "body": body}) + return "synthetic-provider-reference-" + str(len(messages)) + + def check(self): + pass + + class Directory: + def provision(self, request): + provisions.append(request) + return ProvisioningResult("synthetic-directory", request.preferred_username, + "password_setup_required", + password_setup_url="https://directory.example.test/setup") + + oidc = OIDCClient(issuer="https://issuer.example.test", client_id=CLIENT, + redirect_uri="https://portal.example.test/callback", audience=CLIENT) + claims = human_actor_claims(tenant=TENANT, subject="synthetic-inviter") + claims["roles"] = ["tenant-admin"] + service.me(claims) + oidc.sessions["synthetic-admin-session"] = BrowserSession( + claims=claims, expires_at=time.time() + 3600, csrf_token="synthetic-admin-csrf") + operator = service.identity_adapter.normalize(human_actor_claims( + tenant="platform:root", subject="synthetic-delivery-operator")) + portal = PortalApplication(service, trusted_proxy_secret="synthetic-proxy-secret-long-enough", + login_url="https://issuer.example.test/login", oidc_client=oidc, + provisioning=Directory(), registration_clients=(CLIENT,), + registration_tenants=(TENANT,), + registration_oidc_issuer="https://issuer.example.test", + registration_password_setup_origins=("https://directory.example.test",)) + + def response(start, payload, content_type="application/json", status="200 OK"): + data = payload.encode() if isinstance(payload, str) else json.dumps(payload).encode() + start(status, [("Content-Type", content_type), ("Content-Length", str(len(data)))]) + return [data] + + def fixtures(env, start): + path = env["PATH_INFO"] + if path == "/mailbox": + body = "

Synthetic mailbox

" + "".join( + f'
'
+                + html.escape(m["body"]) + "
" for m in messages) + return response(start, body, "text/html") + if path == "/state": + return response(start, {"messages": len(messages), "provisions": len(provisions), + "events": len(events), + "users": service.operability_snapshot().metrics["users"]}) + if env["REQUEST_METHOD"] != "POST": + return response(start, {}, status="404 Not Found") + raw = env["wsgi.input"].read(int(env.get("CONTENT_LENGTH") or 0)) + data = json.loads(raw or b"{}") + if path == "/events": + assert env.get("HTTP_AUTHORIZATION") == "Bearer synthetic-event-token" + assert "primary_email" not in data["data"] + events.append(data) + elif path == "/deliver": + service.deliver_outbox(operator, portal.outbox_delivery, worker_id="fixture-worker") + elif path == "/expire": + with ledger.lock, ledger.db: + ledger.db.execute("UPDATE verifications SET expires_at=? WHERE email=?", + ("2000-01-01T00:00:00+00:00", data["email"])) + elif path == "/suppress": + ledger.suppress(data["email"]) + else: + return response(start, {}, status="404 Not Found") + return response(start, {"ok": True}) + + with server(fixtures, ports) as fixture_url, server(portal, ports) as portal_url: + policies = [CallerPolicy( + caller_id=name, + token=TOKEN if name == "user-engine" else "synthetic-other-caller-token", + source="user-engine", bindings=frozenset({(TENANT, CLIENT)}), + operations=frozenset({"send", "status", "delivery-status", + "verify", "consume", "cancel"}), + templates=frozenset({"family_member.invited", "family_invitation.resent"}), + rate_per_minute=120, burst=100, max_payload_bytes=32768, + ) for name in ("user-engine", "other-caller")] + receiver = ScopedTransactionalApplication(ledger, MailSink(), portal_url, policies) + with server(receiver, ports) as mail_url: + portal.registration_verification = HTTPRegistrationVerificationAdapter( + base_url=mail_url, bearer_token=TOKEN) + portal.outbox_delivery = HTTPOutboxDeliveryAdapter( + event_url=fixture_url + "/events", event_bearer_token="synthetic-event-token", + mail_url=mail_url + "/v1/send", mail_bearer_token=TOKEN, + mail_client_bindings=json.dumps([[TENANT, CLIENT]])) + result = subprocess.run( + [os.environ["OWNER_NODE"], os.environ["OWNER_BROWSER_SCRIPT"], + portal_url, fixture_url, mail_url, os.environ["OWNER_PLAYWRIGHT_MODULE"]], + capture_output=True, text=True, timeout=150) + assert result.returncode == 0, result.stdout + result.stderr + checks = json.loads(result.stdout) + assert len(provisions) == 1 + assert provisions[0].preferred_username == "verified.person" + assert all(p.primary_email.endswith("@example.test") for p in provisions) + with ledger.lock: + assert ledger.db.execute("SELECT count(*) FROM verifications WHERE consumed_at IS NOT NULL" + ).fetchone()[0] == 1 + assert ledger.db.execute("SELECT count(*) FROM verifications WHERE canceled_at IS NOT NULL" + ).fetchone()[0] == 1 + ledger.db.close() + assert checks["passed"] and checks["browser_requests_outside_fixture"] == 0 + Path(os.environ["OWNER_CHECKS_RESULT"]).write_text(json.dumps(checks)) diff --git a/tests/test_owner_communication_gate.py b/tests/test_owner_communication_gate.py new file mode 100644 index 0000000..cff6151 --- /dev/null +++ b/tests/test_owner_communication_gate.py @@ -0,0 +1,79 @@ +"""The explicit owner-browser runner must isolate configuration and refuse weak reports.""" + +import importlib.util +import json +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +def runner(): + spec = importlib.util.spec_from_file_location("owner_browser_gate", ROOT / "scripts" + / "check-owner-communication.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize("report_kind", ["passed", "skipped", "wrong_node"]) +def test_snapshot_strips_credentials_runtime_data_and_requires_exact_unskipped_node( + tmp_path, monkeypatch, report_kind): + module = runner() + owners = [] + for package in ("user_engine", "email_connect"): + owner = tmp_path / package + source = owner / "src" / package + source.mkdir(parents=True) + (source / "__init__.py").write_text("# synthetic owner source\n") + (owner / ".env").write_text("SMTP_PASSWORD=synthetic-not-to-inherit") + (source / "runtime.db").write_bytes(b"synthetic-private-database") + owners.append(owner) + output = tmp_path / "receipt.json" + monkeypatch.setattr(sys, "argv", ["gate", "--user-engine", str(owners[0]), + "--email-connect", str(owners[1]), + "--receipt", str(output)]) + monkeypatch.setenv("SMTP_PASSWORD", "synthetic-not-to-inherit") + monkeypatch.setenv("USER_ENGINE_FLEX_AUTH_TOKEN_FILE", "/synthetic/production-config") + + def execute(argv, *, cwd, env, **kwargs): + assert "SMTP_PASSWORD" not in env and "USER_ENGINE_FLEX_AUTH_TOKEN_FILE" not in env + assert not list(cwd.rglob(".env")) and not list(cwd.rglob("*.db")) + assert argv[argv.index("-c") + 1] == str(cwd / "pytest.ini") + assert module.NODE in argv + node = module.NODE.split("::")[-1] if report_kind != "wrong_node" else "another_test" + skipped = "" if report_kind == "skipped" else "" + (cwd / "result.xml").write_text( + f'{skipped}' + '') + Path(env["OWNER_CHECKS_RESULT"]).write_text(json.dumps({"passed": True})) + return SimpleNamespace(returncode=0, stdout=b"", stderr=b"") + + monkeypatch.setattr(module.subprocess, "run", execute) + assert module.main() == (0 if report_kind == "passed" else 1) + proof = json.loads(output.read_bytes()) + assert proof["passed"] == (report_kind == "passed") + assert not proof["production_access"] and not proof["productive_acceptance"] + assert output.stat().st_mode & 0o777 == 0o600 + manifest = output.with_name(proof["source_manifest"]).read_text() + assert ".env:" not in manifest and ".db:" not in manifest + with pytest.raises(ValueError, match="Fresh receipt"): + module.main() + + +def test_symlinked_owner_source_is_refused_before_any_execution(tmp_path, monkeypatch): + module = runner() + source = tmp_path / "owner/src/user_engine" + source.mkdir(parents=True) + (source / "__init__.py").write_text("# synthetic package") + (tmp_path / "private.py").write_text("# not owner source") + (source / "linked.py").symlink_to(tmp_path / "private.py") + monkeypatch.setattr(sys, "argv", ["gate", "--user-engine", str(tmp_path / "owner"), + "--email-connect", str(tmp_path / "missing"), + "--receipt", str(tmp_path / "receipt.json")]) + monkeypatch.setattr(module.subprocess, "run", lambda *a, **kw: pytest.fail("must not execute")) + with pytest.raises(ValueError, match="symlink refused"): + module.main() diff --git a/uv.lock b/uv.lock index 9a3fee5..05b373c 100644 --- a/uv.lock +++ b/uv.lock @@ -23,6 +23,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478 }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, +] + [[package]] name = "authlib" version = "1.7.2" @@ -170,6 +179,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "jsonschema" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-django" }, @@ -192,6 +202,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "jsonschema", specifier = ">=4.10,<5" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-cov", specifier = ">=5.0" }, { name = "pytest-django", specifier = ">=4.8" }, @@ -452,6 +463,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/bf/249dcd99b3376375910b7fa922383b57792975c8758f50d44612e749226c/joserfc-1.7.4-py3-none-any.whl", hash = "sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463", size = 71000 }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, +] + [[package]] name = "markdown" version = "3.10.3" @@ -606,6 +644,128 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/d4/9193206c4563ec771faf2ccf54815ca7918529fe81f6adb22ee6d0e06622/python_decouple-3.8-py3-none-any.whl", hash = "sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66", size = 9947 }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, +] + +[[package]] +name = "rpds-py" +version = "2026.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/68/3bd46b8a5e01d3c2ebdf9c5e9497912e3fe0cde02bac21a7130ca866e403/rpds_py-2026.9.1.tar.gz", hash = "sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12", size = 63948 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/34/a828586ea3329fbb50895b50e9cf98ca3d924a9f41a0b26d443bff1b3794/rpds_py-2026.9.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:50906f5aea24b5a865cbd0a589698288631d9f3a54c3a937c83aefa95a0d14af", size = 346822 }, + { url = "https://files.pythonhosted.org/packages/90/81/ac6a0d064982251856ce009c9e1dd51a34110b3c055aa7d1aad18b4899a3/rpds_py-2026.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e21c1429e205828ea886a2293a4a2c8e01f4c25d9893ca330e97a6cf73f52e7b", size = 341380 }, + { url = "https://files.pythonhosted.org/packages/42/ff/bf7d54f362748fd6a49277b9d6531c394074110fedf50ad791ec59133fbb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693b2728bbcc48d09a981a356954b0c47c53ff25b545856f28a889ea619f69a", size = 373053 }, + { url = "https://files.pythonhosted.org/packages/60/de/74b0ccdbbd28687b9b5fdb34c1cabed88352182facaced9d6f5486b8b9ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8601470267d938bcb7f3ab1a336100af51a4fd5b6ed030ef52461bb3ef5e7e07", size = 377742 }, + { url = "https://files.pythonhosted.org/packages/c2/6d/b979775a3057b2a5c26d75ecbff60a20a24ef081db6dd76836fca3f20247/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3890a6aa36e6baa53d5258a2a25d3ef8b37ad165a6ab27a892d7c3e3a432cd69", size = 487289 }, + { url = "https://files.pythonhosted.org/packages/1d/5d/7c34734ce3ece943d9d6ee0122a74a21bbc75b47acb5b7053c83f6efb1ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b5b393eda5ea42cca1c1a6665f2a4882b4fd5d1777e41ce0545a107fb008c9d", size = 390710 }, + { url = "https://files.pythonhosted.org/packages/1d/6d/b26eb1e75395925b3a142ed351cbed2ec8212f5c9d937aee3df32c701baa/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:addeda51556dac7c1a2f14cda62db8b621cd12afba3091d03a96c72932387eab", size = 371261 }, + { url = "https://files.pythonhosted.org/packages/7c/98/b2fdfe10301a9e27af21e634337fbba7baac0bc17fe44619a17c26bba2cb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d9edf30457d74eebfd76b045535e36f1cd89062566a128a0db2145ca042d787e", size = 376075 }, + { url = "https://files.pythonhosted.org/packages/c5/b1/c4b8d954e49c3c69cf8063c0c9e0919c99f3d2cecc46311606a1cbe835dc/rpds_py-2026.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815d26356930846a40c7bc1366e7b1b0320ab8a063e66c11298a208bed0fd237", size = 399358 }, + { url = "https://files.pythonhosted.org/packages/b3/59/9559a7293c97dff0cbb293efffbd36644103883ecc27741af8ef90c84ca8/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b5a6f40f0a1486b4b36c888123afc67acdbd9f33235927acf5ff295429a0ba3", size = 550267 }, + { url = "https://files.pythonhosted.org/packages/6c/9d/6dc60e49511de4c8b00e74f9c1d43aed4d27f712cdb1ade92f2c199cbf64/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5b8b0753718d258fd454283fbd57e14545d3b40583fa672e27cb4f987626bcc", size = 614018 }, + { url = "https://files.pythonhosted.org/packages/fb/bc/92a4ecf27301b886232a413360f6b348a81d55ffef654f2ebda5970ebc3d/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:46d80bc76b51a6c24f9944368c28d38b8bcbcea1da4f2f8d3ebc31a67e8c6ec6", size = 578509 }, + { url = "https://files.pythonhosted.org/packages/08/53/efb97f2e6589b7ab8394385a73870e0b2ab2a195281c117b21fd6d7d4855/rpds_py-2026.9.1-cp312-cp312-win32.whl", hash = "sha256:befc2d6a953e563f8a7bfd87a42c22ebf8a3e980dcb7b6a4d17b70b0e914e8a3", size = 205401 }, + { url = "https://files.pythonhosted.org/packages/1c/84/1700cc748d0eaa747486e4e82882d2575224e6e9a3148df583058850c629/rpds_py-2026.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:5ce8943f79c2210f7abcc28e86367b03b28d95027fd01c46d2472373ae70c86f", size = 222923 }, + { url = "https://files.pythonhosted.org/packages/c3/89/0302215373c2f8b4408b0fdfee955368cc378a98ff59508d47a7e0dc2dbf/rpds_py-2026.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:501909f2e4a1e2dee528ef766fe3c469060ebc17e54a8383d404ba07a81a6f02", size = 218415 }, + { url = "https://files.pythonhosted.org/packages/83/ea/ee88fd9e756ff93fb6b1182a47ec09504a242620e33ce1d20679efefe841/rpds_py-2026.9.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a36b70596407634ca82d4b989a3729074a008537a0522e4c8046a67c729103e9", size = 346229 }, + { url = "https://files.pythonhosted.org/packages/57/71/a097d6552f837500fc36e6b23d09cfb9890c3cc47531f9ca64e149799615/rpds_py-2026.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eba5d173f7d5708b22a93815017a4611873ed54db9f268077c0dd1ed99cfc858", size = 340514 }, + { url = "https://files.pythonhosted.org/packages/bd/b7/497e85768bf4e0d8ddbaa096a4cac31d1509251dee2728a8490aa367e0b5/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063", size = 372611 }, + { url = "https://files.pythonhosted.org/packages/52/4b/74ab4108916250b6e198e0d3af05bc6835eb046315f22f7a0ceb49667c5a/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a3a52a3ba86436ab3aef510fbe21512abc2ddd1993005dfe50514bd2284ef025", size = 377712 }, + { url = "https://files.pythonhosted.org/packages/0c/8e/067e77d9d7b3cc793c9d909b7e97e7aadbbb1fb094093b6876902cc96d38/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7841166b7fa64c9c56404617ae4341448847482d45933b13135d26c130519e5", size = 488106 }, + { url = "https://files.pythonhosted.org/packages/3c/b4/c5aae6c2dde269bf955f6b7d35065c655a57e47750d9668052ad67e74dda/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:926bdd3e3b5998ddf70cc64bc8cf57209571f9044542913afb673799fec77dd0", size = 390795 }, + { url = "https://files.pythonhosted.org/packages/a0/36/76fab39973ee11e7f9f357c55138197bb01c86f6502cb76487e3b4f42db0/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b", size = 370794 }, + { url = "https://files.pythonhosted.org/packages/3d/fe/cd2a80e6d7b871937a60e935c5d507aa390d143f4ff3636f640b9733d5df/rpds_py-2026.9.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3cd182d7291d29b92c521a0069d9c01ba6193628a9a105531d11b40a6d731a33", size = 375673 }, + { url = "https://files.pythonhosted.org/packages/6c/18/7464a9953724e55a3b3206062fa0ffdeaa519584c6aabf65d3956d94f131/rpds_py-2026.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e6ea1cda8d8c688278430e4268a42f5e5da3bdd74578dfadc0820c3f1766ce83", size = 398758 }, + { url = "https://files.pythonhosted.org/packages/c0/86/1534b436700fd49ff411063b7c4d7e938adfabf90895b6cf1622d5a7d1f6/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5943980471829f6de242a20b109de3111ba6b77e3af0ffc587028ac854b05e6c", size = 550370 }, + { url = "https://files.pythonhosted.org/packages/57/1c/e1fa82a8a01e3c5820f3ba98a8b2673f642128eb368fa88871b01dd2c909/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:76d3af9732d2dab69f28179b40ba2d87e2f1d5824b4a694780aa787d685e8f36", size = 613106 }, + { url = "https://files.pythonhosted.org/packages/29/55/b20b8c4c3dde8755bfcd5b08492a02d0cd2e26929aedf6199ca2a377d42a/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:78326f4cb4427a56ba4996c0762b63be45f06b85f086526420d2b3a66e40f84d", size = 578235 }, + { url = "https://files.pythonhosted.org/packages/55/42/df3f7bbc3f7ab37a8a9db8d6c2ff2c985422899f1f7926afbf7ec3c0b8b4/rpds_py-2026.9.1-cp313-cp313-win32.whl", hash = "sha256:172e47169583f46ce118cbec68e6795d0da0f4606b488b6434f8276bca0a058c", size = 205293 }, + { url = "https://files.pythonhosted.org/packages/31/9c/ba5a9569d719bfdd6ce863df4133ac6a1658cf1b07cc3534c31db729fbbc/rpds_py-2026.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:3e93b2cd69a9830be33e03945cd7cda940a0a8bfcfbff41d6144f0cb0d3d8bd9", size = 222409 }, + { url = "https://files.pythonhosted.org/packages/35/72/f28ca566f6c23c35bbf7445f65eb0364577b25a026305995e24f78b83d94/rpds_py-2026.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:d151e148117294133bf8af7eeace085e7e87432db15ab6adf640330298a47f6f", size = 217681 }, + { url = "https://files.pythonhosted.org/packages/8a/f2/67b94be1532767803415c1c5a1fd88ea487643d74a673cec1ba140af77bb/rpds_py-2026.9.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c9d1aca01f49170fdcf5c92761b1fafe97f554b721ca4570c5949fff778f0d4b", size = 347178 }, + { url = "https://files.pythonhosted.org/packages/04/37/b751de2b59b0197a1d92a5dd491de88e8a5e928c2e6562581974f1e85263/rpds_py-2026.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3f0e9ac28fc067d4d34b88ae43c48e9489455c97fee9633d851f7eeed5a05d35", size = 341878 }, + { url = "https://files.pythonhosted.org/packages/72/e2/5873bc4643c250db9e05d48dc0c93763d4aa68bc3b81164cb1af3b45b284/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0", size = 373963 }, + { url = "https://files.pythonhosted.org/packages/51/03/5acf7632158247f3f6386ff0af3a1ee48167d575037e8d0920594b76d92b/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:821b2755db9194409254012f429c56643416fb96ef9be090be82ec8826b7f477", size = 377975 }, + { url = "https://files.pythonhosted.org/packages/e6/00/63fda451b8bffa5808fc8bb311ee7c073b340974b09f273c7b2a145d3d62/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c91c210ae7645626c608400e3519b4a642f837cce09ca830db3beb2e9f274d4", size = 490347 }, + { url = "https://files.pythonhosted.org/packages/a1/ae/c093ffd070ba0fb02f76c565d06fecc65ad6e4afdbae78f7031076d3cdac/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54ac2158a6f96cfbabff0b2eedaf94b90c5ec7ca8317fcadc61e1c2b2e0ff6ef", size = 392962 }, + { url = "https://files.pythonhosted.org/packages/22/9d/d08a1128ab199b2f0cf25bfeb0639bd05119fff4b7c47bec24ef9a8ec23f/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd", size = 371623 }, + { url = "https://files.pythonhosted.org/packages/53/c7/4758ddcbb75609414bbccfcb11d612436f9b3ee821bd2f33f0f1604ee648/rpds_py-2026.9.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:8aa5dda18d39b6143eb24809d158f9252c88f402749b6f1b62a506cc7d96cc35", size = 376997 }, + { url = "https://files.pythonhosted.org/packages/87/e4/947bd7f608ff60faf46dc9d389c3dffd0e3d767d78a0be19978448ef0ce7/rpds_py-2026.9.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5c90e7fa02e8f5de0d10c17595c568ada48c5302e749462c0ea1a4c362111a86", size = 400169 }, + { url = "https://files.pythonhosted.org/packages/4b/35/fe93e020a0543b5670472c18d7e6af3197c08da571240ce1965c84f85c0f/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6d198bad4e49dd6732fbd636e2fc5c082f45c8cad0b4acb756b00c82c76072e", size = 551615 }, + { url = "https://files.pythonhosted.org/packages/0d/4f/5d2a0136bb03b2a56a39dc6ff92d58a6e3e53a2e17079238b86228882f16/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:96beca19ec79de272e8668585380ff9092c47077c1d7a1e098e00bbd921f4785", size = 614655 }, + { url = "https://files.pythonhosted.org/packages/09/1c/3f1025aaf70d9bf7272cc41f8b64ee76b48bf01726248138430e16f23b38/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5cf77eb04f20b720be95265a3e00eb2a14814074255cc27069c551b2db53118", size = 579602 }, + { url = "https://files.pythonhosted.org/packages/53/0d/5c72e6204f76610608706da32b6b7e11ef7e10317558a7bb15a122e008dc/rpds_py-2026.9.1-cp314-cp314-win32.whl", hash = "sha256:a03d57b86d2a51d0a66c92177e2be154ad015f357791d306e714569999cdb4cc", size = 206315 }, + { url = "https://files.pythonhosted.org/packages/a4/0b/489d48abbcc7d70cf3fbf662d9d22abf1f4650761c0a9ae05260800800d3/rpds_py-2026.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:837c6b305e26fe0f75b15c92cf3b2ba29e0ae19dc40b1c557b026cb426347d0c", size = 222852 }, + { url = "https://files.pythonhosted.org/packages/91/16/bbb05a7e6a10cf79ba639be7f799d770ee15f64175cc61d081b218dd402a/rpds_py-2026.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:fce4b85234a0cbad67bf8e6e1201ee815d172c9aebad75f25645bc4d834f8e31", size = 218709 }, + { url = "https://files.pythonhosted.org/packages/22/ac/ac507a0a4ec478ca470440a09583db4be5259ba7670aeba0620822f1e57a/rpds_py-2026.9.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a72c11530d71abfb66c8d7696a2f86c43e63fca8b948f1a784ac490f4ec688e", size = 349596 }, + { url = "https://files.pythonhosted.org/packages/e9/f2/817a46b658d5070f477f722c298ee9a24525b0e4017347964146ef5fdd0e/rpds_py-2026.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:068c37bba854ec2fe42f7365c640af11dd9895890ccbf2df5070d0c059bd7f96", size = 343114 }, + { url = "https://files.pythonhosted.org/packages/6c/42/6ade976b13ac1b4cb3bf2eb603f1be2fe74df19a29988d4c2b386be59d6f/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fca4eb6df565e2a928f1c7dad92d27db8f9df0f449e76423ed5d7e713ed445", size = 374197 }, + { url = "https://files.pythonhosted.org/packages/d9/70/77cdf1d3f1a07faabe936016ae623aec7981f73108a8fe7a203ed2e21998/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c933c6678c6f116ff8af47a4c6db0868b8ace74af0343016c0ef00f00272ea69", size = 378025 }, + { url = "https://files.pythonhosted.org/packages/3f/6b/18a44a3beaa9b7931acb04af7bd9539836477c630a794452d4826d6185d4/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028ad274ea951dac64491b5d1e65712a4aeabfdbdb9fccf797b57bd899b0c495", size = 491969 }, + { url = "https://files.pythonhosted.org/packages/73/27/fb39cfd6bddaf741b024f813890374578ff8ac1f1adc473659c048b03b05/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:740d0a99cf9de0b17a3943388e9294a59becf75e7c43421f387bd3c7a9901f7c", size = 393905 }, + { url = "https://files.pythonhosted.org/packages/ed/71/0fa7bb77b57af0d710273964180d11b503f62b8a5c358eb2d8c3f62feff6/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0da298fb372dc192610a4b9ecbc68a0cd8b675bbbd1fc519d01b41cfd658333e", size = 374569 }, + { url = "https://files.pythonhosted.org/packages/54/22/f41cfac269af3b449513ef1bc3d7f32fde52abbbd2d01c7e76b47743acd9/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eb61be926bb81567c1f48bdc8aa22b9855048dc2efd53871f9f7e6e9a5632346", size = 377482 }, + { url = "https://files.pythonhosted.org/packages/b4/fc/312b49006e7f8f9ca5f96647577b8aa6f3df30519c46bd448f5c425af0b2/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42e75466f83cd43f6026c81eab74246efb2bdadafb307b85700632d06c68f299", size = 400040 }, + { url = "https://files.pythonhosted.org/packages/cf/a6/18cca7a878dc7fa95165a83343fd4d7b65643fd22e54e47340a451121d5c/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:617f59cde379b4f648a09797b7f683d04b90a46344cddab85639da5aff0f5531", size = 551849 }, + { url = "https://files.pythonhosted.org/packages/e4/6d/1f5685e20f39604691bdc3c05aaa6b8bd2f954e9e996477adf2376768e33/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3edae8c5ddfdb6985d49ae9d150516e5076888879022f91a26c2de9276ce0bdb", size = 614842 }, + { url = "https://files.pythonhosted.org/packages/c6/25/98652109fd9f7e10268dd4571aa52b81987001b806f37ef1a18260de714a/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0f045bb053c9057720d72c56dffe30dffdc05997b2897a827b9325f0ab6623fa", size = 582606 }, + { url = "https://files.pythonhosted.org/packages/19/03/11ca09099bab5f53373a80a334c424ec917c13d050760a59499d2af5171e/rpds_py-2026.9.1-cp314-cp314t-win32.whl", hash = "sha256:bf35d0568abda97233239ce32896d3ad53fccc537832c104e30c94aa5fb93569", size = 203341 }, + { url = "https://files.pythonhosted.org/packages/6f/8a/88909e3ffd9f47f5b58211473875d8c3c09079f0058c46fb72c55a702a26/rpds_py-2026.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:1e8d4d79d828299bf44a55db22a9388ab967b49d17132c88eab0f4360b48da8e", size = 222843 }, + { url = "https://files.pythonhosted.org/packages/f6/b7/a662f367d4896dd0a10cef2fc91f10b7f08af1c10858e287e019563f338d/rpds_py-2026.9.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:1d77b649e6f7cdf12ca5c2a98dad0ad37f9ea9b6f960408a92f0cb12bb3d04d9", size = 347463 }, + { url = "https://files.pythonhosted.org/packages/ae/3f/ad45d03df4f84ebae5439577ee81f3999d182711e82235c8037b6528890e/rpds_py-2026.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00ba2d8c7dd4ee537978ddf4b3fbd712bef2d8751603f7f3146b3f4287768e25", size = 342051 }, + { url = "https://files.pythonhosted.org/packages/7e/31/3dcd68c13d4bcc59c1f7eb33ac8e80698f06f3eb0d8a1e06419836071c20/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec450527cbf485e13c8d3602a54f428ab0432fdade0ede75efd74b735421c871", size = 374143 }, + { url = "https://files.pythonhosted.org/packages/cf/0d/68c1f058a250fbd1380ebda9fc227cbf50117adf8ffe8161ef383ea79f68/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:306ee1850d8105b5baf977e78d45fcadd12c1a54678d614c9baf217708446e91", size = 378604 }, + { url = "https://files.pythonhosted.org/packages/d7/d6/2d4c59b85397cb4800594fadf692688ccf5ce556adc930e7a5bf21061a5e/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef6b65b03247c54692ad4fd9ee97cb772781927db72e3cb05e70b3db6d1ff14f", size = 490790 }, + { url = "https://files.pythonhosted.org/packages/da/04/7e05dc3aebaf52f4e026766bd668fdd09a9d0e23f64a14686b36b3501892/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a575404ebc9cf2e91edd32eaf570ec1430eb900d4f56724ba7dd4bc1fc9c176d", size = 393146 }, + { url = "https://files.pythonhosted.org/packages/57/ca/e2e9a0a46a74ed51a0498ba1fe10f4ea6b2d9a155f372a2f91e51f18cf10/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c16ab111bc27c646ba8aa005d0527754edc538ebb636f0b1bf8e244b48d1945", size = 372022 }, + { url = "https://files.pythonhosted.org/packages/ba/cb/8f8774df5134e23424372838bcc5c7ed4127d723e1ff52f7bebd4dcb2563/rpds_py-2026.9.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:7664419f27db41d4f1c43a78dccda7dd6e8ef2428df3ee01d0c2a07a6b071297", size = 377092 }, + { url = "https://files.pythonhosted.org/packages/90/02/8d7095d73bf9114219be40230baa5df00611e0a82ed9517779ff9c19f82b/rpds_py-2026.9.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b26b03d9d2658ee2fa234f8f4f19f38a09773fe5261028025032e26d4d35af0", size = 400830 }, + { url = "https://files.pythonhosted.org/packages/ec/02/8206856f8f363cd042a8315dc86b3912f5dcb6d3c66bbb24b69c2bfb0775/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:be3e47e2d91aa3942ff9bf4077a505226005abfc39b6f7554a91c1b9393986b9", size = 551478 }, + { url = "https://files.pythonhosted.org/packages/41/6b/36211f1bb1f0b0313f496d92f5905b74ea107f27cb16fa3355a82f04575e/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:6307a0da524939decb8ca4a3933b8ab62525794411d6984fca6726e732804af6", size = 615173 }, + { url = "https://files.pythonhosted.org/packages/35/77/cda0c4a6f055446b692f0ed5692f73707cafd3dff82672ede31b1a9b59de/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:159a7aab5c5e8b112c8830f54717ce56da1252ebdbb526f5be2df2309280b9e7", size = 579653 }, + { url = "https://files.pythonhosted.org/packages/74/ec/d8385f446240aed643b9e92a5055cff3015cc04a13c61f73b2883c478ed5/rpds_py-2026.9.1-cp315-cp315-win32.whl", hash = "sha256:dbc2673f9223d420c91145599b3ba45a8a50c207d1976908e5fb5ddb0c9b9429", size = 206498 }, + { url = "https://files.pythonhosted.org/packages/6d/a5/71b5cd00e0521e3b6b81828baea368c62b6b700ebdd9554cd7d41ddf12fa/rpds_py-2026.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:75c38c50ab9aca840225d9a9a3810bf11d04bd5c1f186cabbb8aee56db3e9b15", size = 223121 }, + { url = "https://files.pythonhosted.org/packages/33/58/dba857c3bc8221b31b62eb170a3080f4f191de79f047200389b7ed1b06a7/rpds_py-2026.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:a431156bb41865fc14cd5d79bb9d7bbed83110b0159e34e62ae30951f96c0009", size = 218741 }, + { url = "https://files.pythonhosted.org/packages/5b/d0/320ab28ccc1415eeb509d68682b0014fb74690cd49f1c2d29a232475af50/rpds_py-2026.9.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ef0d8c843e2827d6c120ab4687e9423fb1d893db1df27b7c1506615bcb9734a0", size = 349719 }, + { url = "https://files.pythonhosted.org/packages/56/88/f5b12f1358f443c08b7ce3cc8391d82d335f4872580e2e097847fd36087a/rpds_py-2026.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:45bc6bccf78b20fd834237d18db64965d7ee68ba7f60440a26c7ab71e7b8d51a", size = 343267 }, + { url = "https://files.pythonhosted.org/packages/f7/0c/c765b0059d532acb3b9c45d781ccc22f15a96dbe443d00903f643ba9df10/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d55198263bb51f557550c6ed2e6d1cb6a6fed6eb5c9120b741c5926bef8a45d", size = 374555 }, + { url = "https://files.pythonhosted.org/packages/6b/8a/cafddfda77564a10cd21184640c3bffda6a2b8d20972fb5dedd5e0166328/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8763f20692da7df39b0afdd1ba3042b004c50a45994f76c2d9a25641f7673db", size = 378346 }, + { url = "https://files.pythonhosted.org/packages/b9/01/5e626016eff72c183bf6c96539240cace15d402468647a453ec08415b2fc/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e43d4a1f673e8a1cbd8533e809e02b4bf9d4f2280269bb640436556312121250", size = 492373 }, + { url = "https://files.pythonhosted.org/packages/3b/9c/15a2469e9389242f46896b3f0a01d68caea8a5a35c011fcb05ef333aae73/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea394a937f17a54c51239348bdbe2e3518124c8d4a8951ba04a311d3095bd18f", size = 394371 }, + { url = "https://files.pythonhosted.org/packages/63/f5/c100ff77e1e6366e947c75969c258fdfc4b7bc5bbfe7351e62ffbf2a1228/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdeaa99ce822dca76cfb1b993e9120c5ea212f2eb66d48950ad63c349668a018", size = 375132 }, + { url = "https://files.pythonhosted.org/packages/dd/f4/fe0269c9de253e99c81cabc12b8971a5feaa083debdaff1221e06264d9e3/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:b4f062343e7ad3fa94f2c66e5ae667dee47ee74dd41a9057c4fbe163236a123d", size = 377642 }, + { url = "https://files.pythonhosted.org/packages/05/65/b34a7b257baccff8f4a24a722933166d4941d5ebdc9c3f4bc4ffcd5ce4f4/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22ffd29a63d71fb1b81552c21f2c2b734949b7ac751a9be70675a939a900839b", size = 400855 }, + { url = "https://files.pythonhosted.org/packages/98/32/844e54176b6071b90b38a564e6940bc6eb8f97b2890dc709c19db9dec0f4/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:08dae4a4095150a7c4545a1fb40b98e1ab1744fbc2770d92c977b9dadaa49ab6", size = 552573 }, + { url = "https://files.pythonhosted.org/packages/17/73/6041d20729dffbfdf155c02d65be58bc225a1c1fb548fd87c23ef306138f/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9a0460d43603d1fd9ef59c30278531e15d78581721ddb538fa560aa7817ea4ad", size = 615840 }, + { url = "https://files.pythonhosted.org/packages/af/9e/418094adaee6b056ce199051b255448ed872829051e341b2294c80da0977/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2d1f6da5128eabf34e963d7163a818846075a52568250d006c4c953b40f903", size = 582764 }, + { url = "https://files.pythonhosted.org/packages/3d/9b/1698ebf6b840ddfe8b472198abbed6ace35c6e398faeecd6c47dae742a4e/rpds_py-2026.9.1-cp315-cp315t-win32.whl", hash = "sha256:5c6ee90dee3e85e055ddfd502d611643d9b0fd94c818220bda84ec3dacd9b27b", size = 203795 }, + { url = "https://files.pythonhosted.org/packages/8a/e2/91f70d804c61f8eac39a417e82aa1024f655ab9e8bdd7393b196242bc41b/rpds_py-2026.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567", size = 223037 }, + { url = "https://files.pythonhosted.org/packages/a3/90/a9ba81408369d34c1aca73319c15adc91ed45c1d21e9e8ee4832ffb7fe0b/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cdc537c8633d7fd92a82e2e0d2ab74320a3f63d5e59fb9cf08711e08fe151c4", size = 381109 }, + { url = "https://files.pythonhosted.org/packages/b3/44/5192a0bed94cec86bd2a5e1cc5a1bb8f7eec3aec907eacd2deb13e87e326/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10e208f2425d973938afcd56e28a7c4be32e27b6a60b5d381f49fb9d8acf9759", size = 388222 }, + { url = "https://files.pythonhosted.org/packages/92/cd/5356549711448f18b52f7a11ffbe90f1228774996fbf3c6cd1b18d22abaf/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c0dbbcc19735fe5f8b0a54c07659d154a9e69f47e15d0a6ab7299215daf62cb", size = 500384 }, + { url = "https://files.pythonhosted.org/packages/7a/88/ddda9d28adfe33119c75b2e733d4ba7e326f41d7e1b1093951771768ca48/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684fd492fff4fead00587544e059be2bbcb6f93454f21fa2a91b66fc7508be82", size = 401661 }, + { url = "https://files.pythonhosted.org/packages/32/c3/bb59ba16a6b4a57995d15b8c04c00f28df3b938c4dcdde48fbe0f73edb0c/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1028417bb44037eb3069c1009bd7b7277212876cda22fbe565b0bca9fab6d2c", size = 380748 }, + { url = "https://files.pythonhosted.org/packages/f5/b8/0580faa1c5a32ddc160130dd7ae54d2d007272a89a40a6b5db3e50f9f585/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:492e5e428cbe126221611f47e068f01660352feec4ad18bc0f5ea9b2ae88fb14", size = 385731 }, + { url = "https://files.pythonhosted.org/packages/bf/70/f73564642bbe3322c7eeef2c2f258cf040b71c41a430b532e59d04a1b838/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:88b5268892fde430d5531f95bc560b6efbbd67c929662c586afd729a96e7461c", size = 412131 }, + { url = "https://files.pythonhosted.org/packages/8c/5d/17fff2e1f8f68721a2afb5cb48f7e442c751bb6b4883157c5abaf618cd08/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:01445c8d194aa032a08e944f16567672da1c62dbdbefd8b6d0693032e290cf68", size = 558193 }, + { url = "https://files.pythonhosted.org/packages/2a/d0/869ab6fb08531f97aae4a780b4d61b4190d8aef48fe054ee28bcf7114466/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_i686.whl", hash = "sha256:eef6a03b0b6d08d0835ccfa8ec8d1bc70525e3801387567137b50c557695e6da", size = 622448 }, + { url = "https://files.pythonhosted.org/packages/ba/a5/9672e532fe02cd3b92c78cfd8743abca939d96853006c63835009a1ecb6f/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6b9bf3135b4ad5981df9a73d71a35272d650a2985ae9c2746357b24d59de2448", size = 591126 }, + { url = "https://files.pythonhosted.org/packages/80/ef/3a9f8e4279c7920af561deed4cb7ebebed2794a8f608cf404d5eef14a105/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_10_12_x86_64.whl", hash = "sha256:56c6952a9b15047466d0c2347c446a761d4527f89976156341e68f0ce5cc08b0", size = 357108 }, + { url = "https://files.pythonhosted.org/packages/b7/55/4b2fa381a583760aea5c92841e4e928a358e1c6511b129219e9c2826a226/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:b242c27c8f836305a4a72df9cdd564386ac57b807bd252a063223331c9316b37", size = 346877 }, +] + [[package]] name = "ruff" version = "0.16.2" diff --git a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md index cdaeea9..8ef8934 100644 --- a/workplans/CSOC-WP-0015-communication-runtime-acceptance.md +++ b/workplans/CSOC-WP-0015-communication-runtime-acceptance.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: coulomb-social created: "2026-10-08" -updated: "2026-10-27" +updated: "2026-10-30" origin: residual origin_ref: CSOC-WP-0010 state_hub_workstream_id: "38208f72-c285-56fa-be44-de9b59a4e031" @@ -77,6 +77,21 @@ Use isolated issuer/directory/local mail sink/app for UC-009/010/011. Bind exact 2026-10-11: the checkout has a loopback issuer, directory and mailbox double. The browser node is bound on UC-009/010/011 and still `established: false`. It covers reset-to-local-mailbox, sign-in, callback replay, expired and reused handles, tenant and client refusal, registration verify/cancel, and caller/suppression invitation counts. No SMTP, production account, or live issuer is contacted. Real user-engine/email-connect admission, DNS and productive per-flow verdicts remain open. UC-012 stays deferred. Gate for this slice: `make check` 200 passed. +2026-10-30: added an explicit real-owner-source browser gate for UC-010/011. +Private snapshots run the actual user-engine portal, registration/outbox HTTP +adapters and email-connect scoped receiver/SQLite ledger, with synthetic session, +authorization, directory, activity sink and mail provider. Actual forms cover +verify/cancel/expiry/replay, fresh-handle cross-caller denial, tenant/client/token +denials, cross-tenant admin refusal, invitation/resend, duplicate delivery and +suppression. Python only permits fixture ports; browser routes/API redirects are +bounded to fixture origins. No production configuration, account, SMTP provider, +mailbox scan or original note body is used. Exact node is linked in the candidate +registry and the invocation is in docs/usecases/platform-communication.md. +Proof: docs/release/owner-communication-browser-2026-10-30.json. This completes +the owner-source UC-010/011 browser integration slice, not native issuer/directory/ +app-login coverage or productive per-flow verdicts. T04 remains progress; T02 +sender/inbound/bounce ownership and UC-012 nomination remain separate gates. + ## Completion Finish when every task above has its stated evidence and acceptance, or hand off any newly discovered residual to a live work record first. Cross-owner dependencies remain their existing canonical records.