diff --git a/config/callers.user-engine.draft.json b/config/callers.user-engine.draft.json new file mode 100644 index 0000000..bfb6bb1 --- /dev/null +++ b/config/callers.user-engine.draft.json @@ -0,0 +1,29 @@ +{ + "schema_version": 1, + "callers": [ + { + "caller_id": "user-engine", + "source": "user-engine", + "token_file": "/var/run/secrets/email-connect-callers/user-engine", + "bindings": [ + ["tenant:coulomb", "coulomb-social"] + ], + "operations": [ + "send", + "delivery-status", + "status", + "verify", + "consume", + "cancel" + ], + "templates": [ + "family_member.invited", + "family_invitation.resent" + ], + "rate_per_minute": 10, + "burst": 5, + "max_payload_bytes": 8192, + "adopt_legacy_verifications": true + } + ] +} diff --git a/docs/caller-policy-draft-2026-10-08.md b/docs/caller-policy-draft-2026-10-08.md new file mode 100644 index 0000000..01f49f5 --- /dev/null +++ b/docs/caller-policy-draft-2026-10-08.md @@ -0,0 +1,42 @@ +# User-engine caller policy draft — 2026-10-08 + +This file proposes the only caller that exists today. It is not mounted, +`EMAIL_CONNECT_CALLERS_FILE` is not set, and no token file was created. +EMAIL-WP-0008-T03 stays waiting until the bindings, quotas and token custody +are approved. + +Source: `config/callers.user-engine.draft.json`. + +## What the live caller does + +user-engine sends invitation mail to `/v1/send` with source `user-engine` and +templates `family_member.invited` or `family_invitation.resent`. It reads +`/v1/status` and `/v1/delivery-status`. Registration uses +`/v1/registration-verifications`, consume and cancel. The live registration +allow-list is tenant `tenant:coulomb` and client `coulomb-social` +(`USER_ENGINE_REGISTRATION_TENANTS` / `USER_ENGINE_REGISTRATION_CLIENTS` in +`net-kingdom/sso-mfa/k8s/user-engine/runtime.yaml`). Mail and verification +share the current `user-engine-delivery` `mail-token`. Suppression is not a +user-engine operation and is omitted. + +## Proposed limits + +`rate_per_minute` 10 matches the live registration limit of 10 requests per +60 seconds. Burst 5 and an 8 KiB payload cap are below the receiver ceilings +(6000/minute, 600 burst, 128 KiB). They are proposals, not an approved quota. +`adopt_legacy_verifications` is proposed so this original caller can still +consume verification rows written before caller ids existed. + +## Gap that blocks activation + +Current invitation envelopes carry `tenant` and do not carry `client_id`. +Scoped send accepts a binding only when both fields are present and admitted, +or when both are absent and `legacy_invitation_binding` is set. A tenant +without a client id is denied. Family invitations also use tenants other than +`tenant:coulomb`. This draft therefore does not claim to carry current +invitation traffic. Activation needs a user-engine change that sends an +admitted tenant/client pair, or a separate reviewed contract change. + +The token path in the draft does not exist. Selecting this file on the +running receiver would fail startup before serving traffic. Do not copy the +shared ingest token into that path as part of this draft. diff --git a/tests/test_caller_policy_draft.py b/tests/test_caller_policy_draft.py new file mode 100644 index 0000000..02d0507 --- /dev/null +++ b/tests/test_caller_policy_draft.py @@ -0,0 +1,107 @@ +"""The user-engine draft parses and matches today's call set. It is not deployed.""" + +import io +import json +from pathlib import Path + +from email_connect.callers import ScopedTransactionalApplication, load_callers +from email_connect.transactional import SQLiteDeliveryStore + +ROOT = Path(__file__).resolve().parents[1] +DRAFT = ROOT / "config" / "callers.user-engine.draft.json" +TOKEN = "synthetic-user-engine-token" + + +class Provider: + def __init__(self): + self.calls = [] + + def send(self, *args): + self.calls.append(args) + return "synthetic-reference" + + def check(self): + return None + + +def load_draft(tmp_path): + token = tmp_path / "token" + token.write_text(TOKEN + "\n") + document = json.loads(DRAFT.read_text(encoding="utf-8")) + document["callers"][0]["token_file"] = str(token) + path = tmp_path / "policy.json" + path.write_text(json.dumps(document)) + return load_callers(path) + + +def invoke(app, payload, path): + raw = json.dumps(payload).encode() + response = {} + body = b"".join( + app( + { + "PATH_INFO": path, + "REQUEST_METHOD": "POST", + "CONTENT_LENGTH": str(len(raw)), + "wsgi.input": io.BytesIO(raw), + "HTTP_AUTHORIZATION": "Bearer " + TOKEN, + "HTTP_IDEMPOTENCY_KEY": payload.get("id", ""), + }, + lambda status, headers: response.update(status=int(status.split()[0])), + ) + ) + return response["status"], json.loads(body) + + +def test_draft_admits_only_the_live_registration_pair(tmp_path): + policies = load_draft(tmp_path) + assert len(policies) == 1 + policy = policies[0] + assert policy.caller_id == "user-engine" + assert policy.source == "user-engine" + assert policy.bindings == frozenset({("tenant:coulomb", "coulomb-social")}) + assert policy.operations == frozenset( + {"send", "delivery-status", "status", "verify", "consume", "cancel"} + ) + assert "suppress" not in policy.operations + assert policy.templates == frozenset({"family_member.invited", "family_invitation.resent"}) + assert (policy.rate_per_minute, policy.burst, policy.max_payload_bytes) == (10, 5, 8192) + assert policy.adopt_legacy_verifications is True + assert policy.legacy_invitation_binding is None + assert policy.token == TOKEN + assert TOKEN not in DRAFT.read_text(encoding="utf-8") + assert "token" not in json.loads(DRAFT.read_text(encoding="utf-8"))["callers"][0] + + +def test_current_invitation_shape_is_denied_and_registration_pair_is_allowed(tmp_path): + app = ScopedTransactionalApplication( + SQLiteDeliveryStore(str(tmp_path / "ledger.db")), + Provider(), + "https://issuer.example.test", + load_draft(tmp_path), + ) + invitation = { + "id": "evt-1", + "type": "family_member.invited", + "source": "user-engine", + "tenant": "tenant:family", + "data": {"primary_email": "member@example.test", "invitation_id": "invite-1"}, + } + status, body = invoke(app, invitation, "/v1/send") + assert status == 400 and body["error"] == "caller_scope_denied" + assert app.apps["user-engine"].provider.calls == [] + + verification = { + "registration_id": "reg-1", + "normalized_email": "member@example.test", + "preferred_username": "member", + "client_id": "coulomb-social", + "tenant": "tenant:coulomb", + "correlation_id": "trace-1", + } + status, body = invoke(app, verification, "/v1/registration-verifications") + assert status == 202 and body["accepted"] is True + assert len(app.apps["user-engine"].provider.calls) == 1 + + status, body = invoke(app, {"recipient": "member@example.test"}, "/v1/suppressions") + assert status == 403 and body["error"] == "operation_not_allowed" diff --git a/workplans/EMAIL-WP-0007-ledger-backup-recovery.md b/workplans/EMAIL-WP-0007-ledger-backup-recovery.md index 2263f04..e817804 100644 --- a/workplans/EMAIL-WP-0007-ledger-backup-recovery.md +++ b/workplans/EMAIL-WP-0007-ledger-backup-recovery.md @@ -123,3 +123,12 @@ with every existing row preserved, UID10001/network-none and no send. See docs/caller-deployment-2026-10-08.md. This is image/schema alignment only: T03/T04 archive custody, first admitted offsite run/retrieval/application recovery and monitoring stay waiting. CSOC-WP-0015-T03 is the product acceptance follow-up. + +## Pair-custody preview — 2026-10-08 + +Founder asked to start platform custody and stop before keys. railiance-platform +`scripts/application_backup_pair_custody.py` previews CCR-2026-0037: one future +CAS=0 write of both AWS fields, writer policy `backup-pair-writer` not admitted, +no OpenBao call and no Scaleway key. The delivery reader remains unable to +create the pair. T03/T04 stay wait. No receiver pause, upload, primary.env or +timer. Review: railiance-platform/docs/credential-lane-designs/application-backup-pair-custody-2026-10-08.md. diff --git a/workplans/EMAIL-WP-0008-scoped-transactional-callers.md b/workplans/EMAIL-WP-0008-scoped-transactional-callers.md index 18c527d..4c2424d 100644 --- a/workplans/EMAIL-WP-0008-scoped-transactional-callers.md +++ b/workplans/EMAIL-WP-0008-scoped-transactional-callers.md @@ -102,3 +102,15 @@ CSOC-WP-0015; EMAIL-WP-0008-T03, EMAIL-WP-0007 and RPF-WP-0053 retain their canonical runtime/custody tasks. Existing personal recovery acceptance and the receiver revision7 deployment remain evidenced. No pending owner requirement is waived or marked satisfied by the original workplans' administrative closure. + +## User-engine policy draft — 2026-10-08 + +Founder asked for a user-engine-only draft and no activation. Draft: +config/callers.user-engine.draft.json. Review: docs/caller-policy-draft-2026-10-08.md. +It names caller/source user-engine, binding tenant:coulomb/coulomb-social, +send, delivery-status, status, verify, consume and cancel, both family +templates, 10/minute, burst 5, 8192-byte cap, and legacy verification adoption. +No suppress operation and no token value. Current invitation envelopes omit +client_id, so this draft does not admit them. The token path is not created +and EMAIL_CONNECT_CALLERS_FILE is not set. T03 stays wait for approval of +these bindings, quotas and separate token custody.