nodii-grpc-interfaces 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ node_modules/
2
+ dist/
3
+ build/
4
+ coverage/
5
+ .DS_Store
6
+ *.log
7
+ .env
8
+ .env.local
9
+ .env.*.local
10
+ .turbo/
11
+ .bun/
12
+
13
+ # AI review loop artifacts (scripts/review/ai-review.ts)
14
+ .claude-pre-review.md
15
+ .claude-pre-review.json
16
+ .claude/pending-patches/
17
+
18
+ # Agent session state (not committed; per-session)
19
+ .claude/endpoints-snapshot.json
20
+ .agent-todo.md
21
+
22
+ # Python (uv)
23
+ .venv/
24
+ __pycache__/
25
+ *.pyc
26
+ *.pyo
27
+ *.egg-info/
28
+ .mypy_cache/
29
+ .ruff_cache/
30
+ .pytest_cache/
31
+ htmlcov/
32
+ .coverage
33
+ .coverage.*
34
+ coverage.xml
35
+
36
+ # Go
37
+ *.test
38
+ *.out
39
+ go.work.sum
40
+
41
+ # Generated proto artifacts (regenerate via `bun proto:gen`)
42
+ ts/_generated/
43
+ python/_generated/
44
+ go/_generated/
45
+
46
+ # Test-stack volumes (docker-compose persistent data)
47
+ infra/test-stack/data/
48
+ # synthetic consumer go binaries
49
+ synthetic-consumers/telemetry/go/nodii-synthetic-telemetry
50
+ synthetic-consumers/pii/go/nodii-synthetic-pii
51
+ synthetic-consumers/auth-sdk/go/nodii-synthetic-auth-sdk
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: nodii-grpc-interfaces
3
+ Version: 0.1.0
4
+ Summary: Canonical gRPC interface contracts for the Nodii stack. Ships the vendored .proto bytes + runtime-compiled grpcio stubs for nodii.dsar.v1.DataLifecycleService and nodii.tenant.v1.TenantSnapshotService so consumer services can add_*_to_server(...) without running buf codegen.
5
+ Project-URL: Homepage, https://github.com/cognion-nucleus/nodii-libs
6
+ Project-URL: Repository, https://github.com/cognion-nucleus/nodii-libs
7
+ Author-email: Nodii <ops@nodii.co>
8
+ License: MIT
9
+ Keywords: dsar,grpc,interfaces,nodii,proto,tenant-snapshot
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: grpcio-tools>=1.60
16
+ Requires-Dist: grpcio>=1.60
17
+ Requires-Dist: protobuf>=4.25
@@ -0,0 +1,19 @@
1
+ # proto/ — vendored canonical gRPC contracts
2
+
3
+ These `.proto` files are **vendored byte-for-byte** from the nodii-libs
4
+ canonical proto mirror at `proto/nodii/**` (sourced from the planning hub).
5
+ They ship inside the `@nodii/grpc-interfaces` npm artifact (see `files` in
6
+ `package.json`) so the library is self-contained and consumers can load the
7
+ ServiceDefinitions at runtime via `@grpc/proto-loader` — no buf-remote
8
+ pipeline required.
9
+
10
+ | File | Package | Service |
11
+ |---|---|---|
12
+ | `nodii/dsar/v1/dsar.proto` | `nodii.dsar.v1` | `DataLifecycleService` (9 RPCs; D108 — 9th RPC is `ListActiveHolds`) |
13
+ | `nodii/tenant/v1/tenant_snapshot.proto` | `nodii.tenant.v1` | `TenantSnapshotService` (5 streaming bootstrap RPCs) |
14
+
15
+ ## Parity contract
16
+
17
+ The vendored copies MUST stay byte-equal to the repo-root `proto/nodii/**`
18
+ canonical files. The `tests/proto-parity.test.ts` fence asserts this on every
19
+ run. To update, re-copy from the canonical mirror — never hand-edit here.
@@ -0,0 +1,176 @@
1
+ syntax = "proto3";
2
+
3
+ // DataLifecycleService — ticketing's DSAR Pattern A gRPC surface over its OWN
4
+ // data subjects (DCO ticket rows + ticket-side PII), called by
5
+ // nodii-tenant-service.DsarOrchestratorService (compliance-dsar-pattern-a /
6
+ // global 11-dsar § 6 / D109). Wired via @nodii/dsar.bindPatternA — the lib's
7
+ // DsarServiceImpl handler map is mounted onto this service definition.
8
+ //
9
+ // D108 / drift 7a24d546: the 9th RPC is ListActiveHolds (NOT the legacy
10
+ // Acknowledge). @nodii/dsar@0.3.2's DsarServiceImpl implements ListActiveHolds;
11
+ // ticketing AUTHORS this contract per D190 (producer authors its own gRPC
12
+ // contract) because the canonical proto vendored in @nodii/role-catalog@0.1.5 is
13
+ // the pre-D108 (Acknowledge) artifact — see drift 33066346 / request 39e64d25
14
+ // (nodii-libs proto fix in flight). RPCs 1-8 + their messages are transcribed
15
+ // verbatim from that proto (which is correct for those); only the 9th is
16
+ // corrected to ListActiveHolds per the live decision.
17
+ //
18
+ // Uniform envelope: every request carries (request_id, tenant_id, subject_kind,
19
+ // subject_id, …); the 8 lifecycle RPCs return LifecycleResponse{status, …};
20
+ // ListActiveHolds returns ListActiveHoldsResponse{holds}.
21
+
22
+ package nodii.dsar.v1;
23
+
24
+ import "google/protobuf/timestamp.proto";
25
+
26
+ service DataLifecycleService {
27
+ // GDPR subject rights.
28
+ rpc AccessData(AccessDataRequest) returns (LifecycleResponse);
29
+ rpc EraseData(EraseDataRequest) returns (LifecycleResponse);
30
+ rpc RectifyData(RectifyDataRequest) returns (LifecycleResponse);
31
+ rpc ExportPortableData(ExportPortableDataRequest) returns (LifecycleResponse);
32
+ rpc RestrictProcessing(RestrictProcessingRequest) returns (LifecycleResponse);
33
+
34
+ // Retention hold pair.
35
+ rpc PlaceRetentionHold(PlaceRetentionHoldRequest) returns (LifecycleResponse);
36
+ rpc LiftRetentionHold(LiftRetentionHoldRequest) returns (LifecycleResponse);
37
+
38
+ // Visibility.
39
+ rpc GetLifecycleRequestStatus(GetLifecycleRequestStatusRequest)
40
+ returns (LifecycleResponse);
41
+ // D108 (drift 7a24d546): ListActiveHolds replaces the legacy Acknowledge.
42
+ rpc ListActiveHolds(ListActiveHoldsRequest) returns (ListActiveHoldsResponse);
43
+ }
44
+
45
+ enum SubjectKind {
46
+ SUBJECT_KIND_UNSPECIFIED = 0;
47
+ SUBJECT_KIND_USER = 1;
48
+ SUBJECT_KIND_EMPLOYEE = 2;
49
+ SUBJECT_KIND_CUSTOMER = 3;
50
+ SUBJECT_KIND_PLATFORM_ADMIN = 4;
51
+ SUBJECT_KIND_TENANT = 5; // synthetic — tenant-level holds + per-tenant erase
52
+ }
53
+
54
+ enum Status {
55
+ STATUS_UNSPECIFIED = 0;
56
+ STATUS_ACCEPTED = 1;
57
+ STATUS_COMPLETED = 2;
58
+ STATUS_NO_DATA = 3;
59
+ STATUS_PARTIAL = 4;
60
+ STATUS_FAILED = 5;
61
+ STATUS_HELD = 6;
62
+ }
63
+
64
+ // ---- Request messages (RPCs 1-8 — verbatim from the canonical proto) -------
65
+
66
+ message AccessDataRequest {
67
+ string request_id = 1; // UUIDv7
68
+ string tenant_id = 2;
69
+ SubjectKind subject_kind = 3;
70
+ string subject_id = 4;
71
+ string reason = 5;
72
+ string requested_by = 6;
73
+ }
74
+
75
+ message EraseDataRequest {
76
+ string request_id = 1;
77
+ string tenant_id = 2;
78
+ SubjectKind subject_kind = 3;
79
+ string subject_id = 4;
80
+ string reason = 5;
81
+ string requested_by = 6;
82
+ string scope = 7; // "subject" | "tenant"
83
+ }
84
+
85
+ message RectifyDataRequest {
86
+ string request_id = 1;
87
+ string tenant_id = 2;
88
+ SubjectKind subject_kind = 3;
89
+ string subject_id = 4;
90
+ string reason = 5;
91
+ string requested_by = 6;
92
+ map<string, string> patches = 7;
93
+ }
94
+
95
+ message ExportPortableDataRequest {
96
+ string request_id = 1;
97
+ string tenant_id = 2;
98
+ SubjectKind subject_kind = 3;
99
+ string subject_id = 4;
100
+ string reason = 5;
101
+ string requested_by = 6;
102
+ string region = 7; // for S3 artifact bucket selection
103
+ }
104
+
105
+ message RestrictProcessingRequest {
106
+ string request_id = 1;
107
+ string tenant_id = 2;
108
+ SubjectKind subject_kind = 3;
109
+ string subject_id = 4;
110
+ string reason = 5;
111
+ string requested_by = 6;
112
+ string restriction_kind = 7; // free-form
113
+ }
114
+
115
+ message PlaceRetentionHoldRequest {
116
+ string request_id = 1;
117
+ string tenant_id = 2;
118
+ SubjectKind subject_kind = 3;
119
+ string subject_id = 4;
120
+ string statute_citation = 5;
121
+ google.protobuf.Timestamp expires_at = 6;
122
+ string requested_by = 7;
123
+ }
124
+
125
+ message LiftRetentionHoldRequest {
126
+ string request_id = 1;
127
+ string tenant_id = 2;
128
+ SubjectKind subject_kind = 3;
129
+ string subject_id = 4;
130
+ string justification = 5;
131
+ string requested_by = 6;
132
+ }
133
+
134
+ message GetLifecycleRequestStatusRequest {
135
+ string request_id = 1;
136
+ string rpc_method = 2;
137
+ string tenant_id = 3;
138
+ }
139
+
140
+ // ---- ListActiveHolds (D108 9th RPC — replaces Acknowledge) -----------------
141
+
142
+ message ListActiveHoldsRequest {
143
+ string request_id = 1;
144
+ string tenant_id = 2;
145
+ SubjectKind subject_kind = 3; // optional narrowing filter (UNSPECIFIED = any)
146
+ string subject_id = 4; // optional narrowing filter ("" = any)
147
+ string hold_kind = 5; // optional "erase" | "restrict" ("" = any)
148
+ string requested_by = 6;
149
+ }
150
+
151
+ message ActiveHold {
152
+ string id = 1;
153
+ string tenant_id = 2;
154
+ string subject_id = 3; // "" / unset for tenant-level holds
155
+ string hold_kind = 4; // "erase" | "restrict"
156
+ string statute_citation = 5;
157
+ string placed_by = 6;
158
+ google.protobuf.Timestamp placed_at = 7;
159
+ google.protobuf.Timestamp expires_at = 8; // unset = no expiry
160
+ }
161
+
162
+ message ListActiveHoldsResponse {
163
+ repeated ActiveHold holds = 1;
164
+ }
165
+
166
+ // ---- Uniform lifecycle response (8 RPCs) -----------------------------------
167
+
168
+ message LifecycleResponse {
169
+ string request_id = 1;
170
+ Status status = 2;
171
+ string reason = 3;
172
+ string result_artifact_uri = 4;
173
+ google.protobuf.Timestamp expires_at = 5;
174
+ string statute_citation = 6;
175
+ }
176
+
@@ -0,0 +1,93 @@
1
+ syntax = "proto3";
2
+
3
+ // TenantSnapshotService — tenant-service's cold-start bootstrap surface for
4
+ // every sibling service maintaining a tenant-aggregate replica, called by
5
+ // `@nodii/replica-consumer`'s snapshotRpc client per [`09-replication-doctrine
6
+ // § 7`](../../../../../../../global/09-replication-doctrine.md).
7
+ //
8
+ // Locked + shipped per drift 2b30f6c5 adjudication (R0: 09-replication § 7.6
9
+ // wins over the feature_doc's 11-typed-message draft). Authored by
10
+ // nodii-tenant-service per D190 (producer-authored gRPC contracts); ingested
11
+ // to canonical mirror via contract-lock-notice 4e683dea.
12
+ //
13
+ // Phase 1 (this lock): 5 of 11 RPCs — for the 5 aggregates that have tables +
14
+ // emitters today (Tenant + UserMembership + CustomerMembership + AuthPolicy +
15
+ // TenantSubscription per 09-replication § 2.2a lines 109-117).
16
+ //
17
+ // Deferred to subsequent locks (R1 — no RPCs over non-existent tables):
18
+ // - BootstrapAccessTokenSnapshot ← access-control-personal-access-tokens
19
+ // - BootstrapOAuthAppSnapshot ← access-control-oauth-apps
20
+ // - BootstrapOAuthAppInstallationSnapshot ← access-control-oauth-apps
21
+ // - BootstrapCustomDomainSnapshot ← custom-domains
22
+ // - BootstrapChildSubdomainSnapshot ← custom-domains
23
+ //
24
+ // Removed from § 2.2a as STALE (per drift 2b30f6c5 audit confirmed by
25
+ // tenant-service in 4e683dea):
26
+ // - TenantModuleSubscription — no owning table; tenant_subscriptions IS the
27
+ // TenantSubscription aggregate. Hub follow-up: doctrine PR removing this
28
+ // row from § 2.2a.
29
+ //
30
+ // HWM (stream_high_water_mark) emission is DEFERRED to the outbox→Redis Streams
31
+ // dispatcher landing per § 9.2 + D209 SET-NX-EX lease. Until the dispatcher
32
+ // ships, the server streams data chunks then closes the gRPC stream without
33
+ // a terminal HWM chunk. Consumers bootstrap via LWW(entity_version) monotonicity;
34
+ // the HWM stitch arrives with the dispatcher.
35
+
36
+ package nodii.tenant.v1;
37
+
38
+ service TenantSnapshotService {
39
+ // S2S scope: tenant:snapshot:read.
40
+ // Each RPC streams the full set of rows for its aggregate (no filtering —
41
+ // bootstrap is full-or-none per § 7.6). Pagination is implicit via gRPC
42
+ // backpressure on the streaming response.
43
+
44
+ rpc BootstrapTenantSnapshot(BootstrapSnapshotRequest)
45
+ returns (stream TenantSnapshotChunk);
46
+
47
+ rpc BootstrapUserMembershipSnapshot(BootstrapSnapshotRequest)
48
+ returns (stream TenantSnapshotChunk);
49
+
50
+ rpc BootstrapCustomerMembershipSnapshot(BootstrapSnapshotRequest)
51
+ returns (stream TenantSnapshotChunk);
52
+
53
+ rpc BootstrapAuthPolicySnapshot(BootstrapSnapshotRequest)
54
+ returns (stream TenantSnapshotChunk);
55
+
56
+ rpc BootstrapTenantSubscriptionSnapshot(BootstrapSnapshotRequest)
57
+ returns (stream TenantSnapshotChunk);
58
+ }
59
+
60
+ // No filtering — bootstrap is full or none per § 7.6.
61
+ message BootstrapSnapshotRequest {}
62
+
63
+ // The single canonical chunk message per § 7.6 (NOT 11 typed
64
+ // `<Aggregate>Snapshot`/`<Aggregate>Payload` messages — that draft was
65
+ // rejected per drift 2b30f6c5).
66
+ message TenantSnapshotChunk {
67
+ // Singular PascalCase aggregate name, byte-for-byte equal to the
68
+ // 09-replication § 2.2a enum value. Consumer's `@nodii/replica-consumer`
69
+ // `defineReplica({ aggregateKind: ... })` must match.
70
+ string aggregate_kind = 1;
71
+
72
+ // Aggregate root's primary key.
73
+ string aggregate_id = 2;
74
+
75
+ // Current monotonic version — the ordering primitive (NOT
76
+ // `google.protobuf.Timestamp`; that was rejected per drift 2b30f6c5).
77
+ int64 entity_version = 3;
78
+
79
+ // Identical byte-equal payload to the equivalent live event's payload
80
+ // (the `*.created.v1` payload for the aggregate). Status / lifecycle
81
+ // changes are NOT included — those arrive via the live stream's
82
+ // `*.status_changed.v1`, `*.closed.v1`, etc. events.
83
+ // Consumer requirement: apply `lead_id` (and other "absence-means-unknown"
84
+ // fields) set-if-absent on snapshot apply — do NOT LWW-null a non-null
85
+ // replicated lead_id from a snapshot.
86
+ string payload_json = 4;
87
+
88
+ // Populated ONLY on the final chunk of the stream (the HWM "I'm caught up
89
+ // to here on the live stream" marker). Empty string until the
90
+ // outbox→Redis Streams dispatcher lands per § 9.2 + D209; consumers
91
+ // bootstrap via LWW(entity_version) monotonicity in the interim.
92
+ string stream_high_water_mark = 5;
93
+ }
@@ -0,0 +1,85 @@
1
+ [project]
2
+ name = "nodii-grpc-interfaces"
3
+ version = "0.1.0"
4
+ description = "Canonical gRPC interface contracts for the Nodii stack. Ships the vendored .proto bytes + runtime-compiled grpcio stubs for nodii.dsar.v1.DataLifecycleService and nodii.tenant.v1.TenantSnapshotService so consumer services can add_*_to_server(...) without running buf codegen."
5
+ requires-python = ">=3.11"
6
+ license = { text = "MIT" }
7
+ authors = [
8
+ { name = "Nodii", email = "ops@nodii.co" },
9
+ ]
10
+ keywords = ["nodii", "grpc", "proto", "dsar", "tenant-snapshot", "interfaces"]
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "Programming Language :: Python :: 3.11",
14
+ "Programming Language :: Python :: 3.12",
15
+ "License :: OSI Approved :: MIT License",
16
+ ]
17
+ dependencies = [
18
+ "grpcio>=1.60",
19
+ "grpcio-tools>=1.60",
20
+ "protobuf>=4.25",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/cognion-nucleus/nodii-libs"
25
+ Repository = "https://github.com/cognion-nucleus/nodii-libs"
26
+
27
+ [build-system]
28
+ requires = ["hatchling"]
29
+ build-backend = "hatchling.build"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/nodii_grpc_interfaces"]
33
+
34
+ [tool.hatch.build.targets.sdist]
35
+ include = ["src/", "tests/", "proto/", "README.md", "LICENSE", "pyproject.toml"]
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "ruff>=0.7",
40
+ "mypy>=1.13",
41
+ "pytest>=8.3",
42
+ "pytest-asyncio>=0.24",
43
+ "pytest-cov>=6.0",
44
+ "types-protobuf>=4.25",
45
+ ]
46
+
47
+ [tool.ruff]
48
+ line-length = 100
49
+ target-version = "py311"
50
+
51
+ [tool.ruff.lint]
52
+ select = [
53
+ "E", "W", "F", "I", "B", "C4", "UP", "N", "ASYNC", "S", "PT", "RET", "SIM",
54
+ ]
55
+ ignore = [
56
+ "S101", # use of assert detected — fine in tests
57
+ ]
58
+
59
+ [tool.ruff.lint.per-file-ignores]
60
+ "tests/**/*.py" = ["S101", "S105", "S106", "B017", "PT011"]
61
+
62
+ [tool.ruff.format]
63
+ quote-style = "double"
64
+ indent-style = "space"
65
+
66
+ [tool.mypy]
67
+ strict = true
68
+ python_version = "3.11"
69
+ warn_redundant_casts = true
70
+ warn_unused_ignores = true
71
+ warn_unreachable = true
72
+ disallow_untyped_decorators = true
73
+ # grpcio + protobuf-generated stubs lack type info; allow them through.
74
+ [[tool.mypy.overrides]]
75
+ module = ["grpc", "grpc.*", "grpc_tools", "grpc_tools.*", "google.protobuf", "google.protobuf.*"]
76
+ ignore_missing_imports = true
77
+
78
+ [tool.pytest.ini_options]
79
+ testpaths = ["tests"]
80
+ asyncio_mode = "auto"
81
+ addopts = [
82
+ "-ra",
83
+ "--strict-markers",
84
+ "--strict-config",
85
+ ]
@@ -0,0 +1,42 @@
1
+ """nodii_grpc_interfaces — canonical gRPC interface contracts for the Nodii stack.
2
+
3
+ Ships the vendored .proto bytes (under proto/) + runtime-compiled grpcio stubs
4
+ for the two canonical services, so a consumer service can register its own
5
+ handler implementation without running buf codegen.
6
+
7
+ Resolves hub requests:
8
+ - daa248c0 — dsar.proto (nodii.dsar.v1.DataLifecycleService)
9
+ - d7cd41dc — tenant_snapshot.proto (nodii.tenant.v1.TenantSnapshotService)
10
+ - a0919cfa — DataLifecycleService ServiceDefinition codegen for consumers
11
+
12
+ Public API (parity with @nodii/grpc-interfaces):
13
+ - load_dsar_stubs / load_tenant_snapshot_stubs — (pb2, pb2_grpc) tuples.
14
+ - data_lifecycle_servicer_base / tenant_snapshot_servicer_base — grpcio
15
+ ServicerBase classes for the consumer to subclass.
16
+ - SERVICE_NAMES — fully-qualified service names.
17
+ """
18
+
19
+ from nodii_grpc_interfaces.proto_loader import (
20
+ data_lifecycle_servicer_base,
21
+ load_dsar_stubs,
22
+ load_tenant_snapshot_stubs,
23
+ tenant_snapshot_servicer_base,
24
+ )
25
+
26
+ __version__ = "0.1.0"
27
+ LIB_NAME = "grpc-interfaces"
28
+
29
+ SERVICE_NAMES = {
30
+ "DataLifecycleService": "nodii.dsar.v1.DataLifecycleService",
31
+ "TenantSnapshotService": "nodii.tenant.v1.TenantSnapshotService",
32
+ }
33
+
34
+ __all__ = [
35
+ "LIB_NAME",
36
+ "SERVICE_NAMES",
37
+ "__version__",
38
+ "data_lifecycle_servicer_base",
39
+ "load_dsar_stubs",
40
+ "load_tenant_snapshot_stubs",
41
+ "tenant_snapshot_servicer_base",
42
+ ]
@@ -0,0 +1,163 @@
1
+ """Dynamic gRPC stub loader for the canonical Nodii interface contracts.
2
+
3
+ We compile the vendored protos at import time using grpcio_tools.protoc into a
4
+ temporary import path. This keeps the library a single self-contained package
5
+ (no buf-codegen dependency) and matches what the TS lib does with
6
+ @grpc/proto-loader.
7
+
8
+ Two services are shipped:
9
+ - nodii.dsar.v1.DataLifecycleService (request daa248c0 / a0919cfa; D108)
10
+ - nodii.tenant.v1.TenantSnapshotService (request d7cd41dc)
11
+
12
+ Usage by a consumer service:
13
+
14
+ from nodii_grpc_interfaces.proto_loader import load_dsar_stubs
15
+
16
+ pb2, pb2_grpc = load_dsar_stubs()
17
+ pb2_grpc.add_DataLifecycleServiceServicer_to_server(MyServicer(), server)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import importlib
23
+ import os
24
+ import sys
25
+ import tempfile
26
+ import threading
27
+ from pathlib import Path
28
+ from types import ModuleType
29
+ from typing import Any
30
+
31
+ _lock = threading.Lock()
32
+ _compiled: dict[str, tuple[ModuleType, ModuleType]] = {}
33
+
34
+ # (proto path relative to the proto root, pb2 module name, grpc module name).
35
+ _DSAR_PROTO = ("nodii/dsar/v1/dsar.proto",
36
+ "nodii.dsar.v1.dsar_pb2",
37
+ "nodii.dsar.v1.dsar_pb2_grpc")
38
+ _TENANT_PROTO = ("nodii/tenant/v1/tenant_snapshot.proto",
39
+ "nodii.tenant.v1.tenant_snapshot_pb2",
40
+ "nodii.tenant.v1.tenant_snapshot_pb2_grpc")
41
+
42
+ # Persistent gen dir (survives the call); subsequent imports reuse the cached
43
+ # sys.modules entries. Overridable for wheel-install layouts where proto/ is
44
+ # not co-located with the package source.
45
+ _GEN_DIR_ENV = "NODII_GRPC_INTERFACES_GEN_DIR"
46
+ _PROTO_ROOT_ENV = "NODII_GRPC_INTERFACES_PROTO_ROOT"
47
+
48
+
49
+ def _find_proto_root() -> Path:
50
+ here = Path(__file__).resolve().parent
51
+ candidates: list[Path] = []
52
+ env_override = os.environ.get(_PROTO_ROOT_ENV)
53
+ if env_override:
54
+ candidates.append(Path(env_override))
55
+ candidates.extend(
56
+ [
57
+ here / "proto", # bundled with wheel (preferred)
58
+ here.parent.parent / "proto", # python/grpc-interfaces/proto (dev)
59
+ here.parent / "proto",
60
+ ]
61
+ )
62
+ for c in candidates:
63
+ if (c / "nodii" / "dsar" / "v1" / "dsar.proto").is_file():
64
+ return c
65
+ raise FileNotFoundError(
66
+ f"nodii_grpc_interfaces: cannot locate the vendored protos. Tried: {candidates}. "
67
+ f"Set {_PROTO_ROOT_ENV} to point at the proto root, or install the wheel "
68
+ "with bundled protos."
69
+ )
70
+
71
+
72
+ def _compile_all_protos() -> Path:
73
+ """Compile every vendored proto into a persistent gen dir; return that dir.
74
+
75
+ Both protos are compiled in a single protoc invocation so cross-file +
76
+ well-known-type imports resolve consistently.
77
+ """
78
+ try:
79
+ from grpc_tools import protoc as _protoc # type: ignore[import-not-found,import-untyped,unused-ignore] # noqa: WPS433, I001
80
+ except ImportError as e: # pragma: no cover
81
+ raise RuntimeError(
82
+ "nodii_grpc_interfaces requires grpcio-tools to compile its protos. "
83
+ "Install grpcio-tools."
84
+ ) from e
85
+
86
+ proto_root = _find_proto_root()
87
+ gen_dir = Path(
88
+ os.environ.get(
89
+ _GEN_DIR_ENV,
90
+ Path(tempfile.gettempdir()) / "nodii_grpc_interfaces_gen",
91
+ )
92
+ )
93
+ gen_dir.mkdir(parents=True, exist_ok=True)
94
+
95
+ grpc_tools_mod = importlib.import_module("grpc_tools")
96
+ grpc_tools_file = grpc_tools_mod.__file__
97
+ if grpc_tools_file is None:
98
+ raise RuntimeError("grpc_tools module has no __file__ — broken install?")
99
+ wkt_include = Path(grpc_tools_file).parent / "_proto"
100
+
101
+ args = [
102
+ "protoc",
103
+ f"-I{proto_root}",
104
+ f"-I{wkt_include}",
105
+ f"--python_out={gen_dir}",
106
+ f"--grpc_python_out={gen_dir}",
107
+ str(proto_root / _DSAR_PROTO[0]),
108
+ str(proto_root / _TENANT_PROTO[0]),
109
+ ]
110
+ rc = _protoc.main(args)
111
+ if rc != 0:
112
+ raise RuntimeError(f"nodii_grpc_interfaces: grpc_tools.protoc exited with code {rc}")
113
+
114
+ # Ensure every parent dir is an importable package.
115
+ for sub in ("", "nodii", "nodii/dsar", "nodii/dsar/v1",
116
+ "nodii/tenant", "nodii/tenant/v1"):
117
+ init = gen_dir / sub / "__init__.py"
118
+ init.parent.mkdir(parents=True, exist_ok=True)
119
+ if not init.exists():
120
+ init.write_text("")
121
+
122
+ if str(gen_dir) not in sys.path:
123
+ sys.path.insert(0, str(gen_dir))
124
+ return gen_dir
125
+
126
+
127
+ def _load(pb2_name: str, grpc_name: str) -> tuple[ModuleType, ModuleType]:
128
+ with _lock:
129
+ cached = _compiled.get(pb2_name)
130
+ if cached is not None:
131
+ return cached
132
+ _compile_all_protos()
133
+ pb2 = importlib.import_module(pb2_name)
134
+ grpc_mod = importlib.import_module(grpc_name)
135
+ result = (pb2, grpc_mod)
136
+ _compiled[pb2_name] = result
137
+ return result
138
+
139
+
140
+ def load_dsar_stubs() -> tuple[ModuleType, ModuleType]:
141
+ """Returns (pb2, pb2_grpc) for nodii.dsar.v1.DataLifecycleService."""
142
+ return _load(_DSAR_PROTO[1], _DSAR_PROTO[2])
143
+
144
+
145
+ def load_tenant_snapshot_stubs() -> tuple[ModuleType, ModuleType]:
146
+ """Returns (pb2, pb2_grpc) for nodii.tenant.v1.TenantSnapshotService."""
147
+ return _load(_TENANT_PROTO[1], _TENANT_PROTO[2])
148
+
149
+
150
+ def data_lifecycle_servicer_base() -> Any:
151
+ """The grpcio ServicerBase for DataLifecycleService.
152
+
153
+ A consumer subclasses this and implements the 9 RPC methods, then calls
154
+ `add_DataLifecycleServiceServicer_to_server(impl, server)`.
155
+ """
156
+ _pb2, grpc_mod = load_dsar_stubs()
157
+ return grpc_mod.DataLifecycleServiceServicer
158
+
159
+
160
+ def tenant_snapshot_servicer_base() -> Any:
161
+ """The grpcio ServicerBase for TenantSnapshotService."""
162
+ _pb2, grpc_mod = load_tenant_snapshot_stubs()
163
+ return grpc_mod.TenantSnapshotServiceServicer
File without changes
@@ -0,0 +1,91 @@
1
+ """Asserts the runtime-compiled stubs load and expose the expected RPCs.
2
+
3
+ The grpcio-generated ``<Service>Servicer`` base class carries one method per
4
+ RPC (named with the proto-declared PascalCase RPC name), and the
5
+ ``add_<Service>Servicer_to_server`` registrar is what a consumer calls — so
6
+ introspecting the servicer base is the consumer-facing contract surface.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from nodii_grpc_interfaces import SERVICE_NAMES
12
+ from nodii_grpc_interfaces.proto_loader import (
13
+ load_dsar_stubs,
14
+ load_tenant_snapshot_stubs,
15
+ )
16
+
17
+ # The 9 canonical DataLifecycleService RPCs (D108 — ListActiveHolds, NOT Acknowledge).
18
+ DSAR_RPCS = {
19
+ "AccessData",
20
+ "EraseData",
21
+ "RectifyData",
22
+ "ExportPortableData",
23
+ "RestrictProcessing",
24
+ "PlaceRetentionHold",
25
+ "LiftRetentionHold",
26
+ "GetLifecycleRequestStatus",
27
+ "ListActiveHolds",
28
+ }
29
+
30
+ # The 5 phase-1 TenantSnapshotService streaming bootstrap RPCs.
31
+ TENANT_RPCS = {
32
+ "BootstrapTenantSnapshot",
33
+ "BootstrapUserMembershipSnapshot",
34
+ "BootstrapCustomerMembershipSnapshot",
35
+ "BootstrapAuthPolicySnapshot",
36
+ "BootstrapTenantSubscriptionSnapshot",
37
+ }
38
+
39
+
40
+ def _servicer_rpc_names(servicer_cls: type) -> set[str]:
41
+ # grpcio servicer bases define one public method per RPC.
42
+ return {
43
+ name
44
+ for name in dir(servicer_cls)
45
+ if not name.startswith("_") and callable(getattr(servicer_cls, name))
46
+ }
47
+
48
+
49
+ def test_dsar_stubs_load_and_expose_nine_rpcs() -> None:
50
+ pb2, pb2_grpc = load_dsar_stubs()
51
+ assert hasattr(pb2_grpc, "DataLifecycleServiceServicer")
52
+ assert hasattr(pb2_grpc, "DataLifecycleServiceStub")
53
+ assert hasattr(pb2_grpc, "add_DataLifecycleServiceServicer_to_server")
54
+
55
+ rpcs = _servicer_rpc_names(pb2_grpc.DataLifecycleServiceServicer)
56
+ assert rpcs == DSAR_RPCS
57
+ assert len(rpcs) == 9
58
+
59
+
60
+ def test_dsar_ninth_rpc_is_list_active_holds_not_acknowledge() -> None:
61
+ _pb2, pb2_grpc = load_dsar_stubs()
62
+ rpcs = _servicer_rpc_names(pb2_grpc.DataLifecycleServiceServicer)
63
+ assert "ListActiveHolds" in rpcs
64
+ assert "Acknowledge" not in rpcs
65
+
66
+
67
+ def test_dsar_list_active_holds_wire_messages_present() -> None:
68
+ pb2, _pb2_grpc = load_dsar_stubs()
69
+ # The D108 wire messages exist; the retired Acknowledge message is gone.
70
+ assert hasattr(pb2, "ListActiveHoldsRequest")
71
+ assert hasattr(pb2, "ListActiveHoldsResponse")
72
+ assert hasattr(pb2, "ActiveHold")
73
+ assert not hasattr(pb2, "AcknowledgeRequest")
74
+
75
+
76
+ def test_tenant_snapshot_stubs_load_and_expose_five_rpcs() -> None:
77
+ pb2, pb2_grpc = load_tenant_snapshot_stubs()
78
+ assert hasattr(pb2_grpc, "TenantSnapshotServiceServicer")
79
+ assert hasattr(pb2_grpc, "TenantSnapshotServiceStub")
80
+ assert hasattr(pb2_grpc, "add_TenantSnapshotServiceServicer_to_server")
81
+ assert hasattr(pb2, "TenantSnapshotChunk")
82
+ assert hasattr(pb2, "BootstrapSnapshotRequest")
83
+
84
+ rpcs = _servicer_rpc_names(pb2_grpc.TenantSnapshotServiceServicer)
85
+ assert rpcs == TENANT_RPCS
86
+ assert len(rpcs) == 5
87
+
88
+
89
+ def test_service_names_are_canonical() -> None:
90
+ assert SERVICE_NAMES["DataLifecycleService"] == "nodii.dsar.v1.DataLifecycleService"
91
+ assert SERVICE_NAMES["TenantSnapshotService"] == "nodii.tenant.v1.TenantSnapshotService"
@@ -0,0 +1,51 @@
1
+ """Contract fence: the vendored proto bytes carry the canonical wire contract.
2
+
3
+ These protos are vendored byte-equal from the planning-hub grpc-interfaces
4
+ canonical mirror. nodii-grpc-interfaces IS the canonical proto/stub vendor
5
+ surface (daa248c0/d7cd41dc/D190); it is NOT bound to the repo-root proto/ tree
6
+ (which feeds dsar/role-catalog codegen and still carries the pre-D108 dsar proto
7
+ pending drift 39e64d25). So we assert the vendored bytes carry the canonical
8
+ contract markers (incl. D108 ListActiveHolds, NOT the legacy Acknowledge) rather
9
+ than byte-comparing to the stale root. A failure means the vendored contract
10
+ drifted — an R0 violation.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ _HERE = Path(__file__).resolve().parent
18
+ _VENDORED_ROOT = _HERE.parent / "proto"
19
+
20
+
21
+ def _read(rel: str) -> str:
22
+ return (_VENDORED_ROOT / rel).read_text(encoding="utf-8")
23
+
24
+
25
+ def test_dsar_proto_is_canonical_d108() -> None:
26
+ src = _read("nodii/dsar/v1/dsar.proto")
27
+ assert "package nodii.dsar.v1;" in src
28
+ assert "service DataLifecycleService" in src
29
+ # D108: 9th RPC is ListActiveHolds, NOT the legacy Acknowledge.
30
+ assert "rpc ListActiveHolds" in src
31
+ assert "ListActiveHoldsRequest" in src
32
+ assert "ListActiveHoldsResponse" in src
33
+ assert "rpc Acknowledge" not in src
34
+ for rpc in (
35
+ "AccessData",
36
+ "EraseData",
37
+ "RectifyData",
38
+ "ExportPortableData",
39
+ "RestrictProcessing",
40
+ "PlaceRetentionHold",
41
+ "LiftRetentionHold",
42
+ "GetLifecycleRequestStatus",
43
+ "ListActiveHolds",
44
+ ):
45
+ assert f"rpc {rpc}" in src, f"missing rpc {rpc}"
46
+
47
+
48
+ def test_tenant_snapshot_proto_is_canonical() -> None:
49
+ src = _read("nodii/tenant/v1/tenant_snapshot.proto")
50
+ assert "package nodii.tenant.v1;" in src
51
+ assert "service TenantSnapshotService" in src