fix: harden DGR-003 identity trust boundary
This commit is contained in:
@@ -32,7 +32,7 @@ import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
# Layout of the identity block this tracker reads (meshnet_node.runtime_recipe).
|
||||
RECIPE_IDENTITY_SCHEMA_VERSION = 1
|
||||
@@ -42,6 +42,7 @@ RECIPE_IDENTITY_SCHEMA_VERSION = 1
|
||||
# without changing it there silently partitions every route.
|
||||
ARTIFACT_DIGEST_DOMAIN = "meshnet.model-artifact.v1"
|
||||
RECIPE_DIGEST_DOMAIN = "meshnet.runtime-recipe.v1"
|
||||
SHARD_BINDING_DIGEST_DOMAIN = "meshnet.shard-binding.v1"
|
||||
|
||||
# The axes a recipe digest commits to. Order is irrelevant (the canonical JSON
|
||||
# sorts keys); membership is not — an axis missing here is an axis the tracker
|
||||
@@ -62,16 +63,6 @@ RECIPE_AXES: tuple[str, ...] = (
|
||||
|
||||
_INT_AXES = frozenset({"boundary_schema_version", "protocol_schema_version"})
|
||||
|
||||
MISMATCH_ARTIFACT = "artifact"
|
||||
MISMATCH_TOKENIZER = "tokenizer"
|
||||
MISMATCH_ARCHITECTURE = "architecture"
|
||||
MISMATCH_RANGE = "range"
|
||||
MISMATCH_BOUNDARY_SCHEMA = "boundary-schema"
|
||||
MISMATCH_ACTIVATION_RECIPE = "activation-recipe"
|
||||
MISMATCH_CACHE_LAYOUT = "cache-layout"
|
||||
MISMATCH_FINGERPRINT = "fingerprint"
|
||||
MISMATCH_UNCERTIFIED = "uncertified"
|
||||
|
||||
STATUS_UNKNOWN = "unknown"
|
||||
STATUS_DARK = "dark"
|
||||
STATUS_CERTIFIED = "certified"
|
||||
@@ -183,6 +174,8 @@ class PresentedIdentity:
|
||||
artifact_id: str
|
||||
revision: str
|
||||
source_digest: str
|
||||
content_digest: str
|
||||
derivative_range: tuple[int, int] | None
|
||||
architecture: str
|
||||
architecture_digest: str
|
||||
layer_count: int
|
||||
@@ -211,6 +204,36 @@ class PresentedIdentity:
|
||||
def key(self) -> tuple[str, str]:
|
||||
return (self.model_artifact_digest, self.runtime_recipe_digest)
|
||||
|
||||
@property
|
||||
def shard_binding_digest(self) -> str:
|
||||
"""This participant's own bytes and exact range, bound to the source.
|
||||
|
||||
The route fingerprint (:attr:`key`) is deliberately range-independent —
|
||||
Shards on one route own different ranges, so a range-sensitive digest
|
||||
would stop any two of them from ever agreeing. The consequence is that
|
||||
the fingerprint alone cannot tell two *different splits of the same
|
||||
source* apart: a derivative can assert the right source and recipe and
|
||||
inherit certification earned by bytes it does not hold.
|
||||
|
||||
This digest is what closes that. It commits to the derivative's own
|
||||
content hash and its exact end-exclusive range, so certification can be
|
||||
recipe-wide while every serving node is still separately pinned to the
|
||||
blob it was admitted on (`TrackerServer.certify_recipe`).
|
||||
"""
|
||||
return _digest(
|
||||
SHARD_BINDING_DIGEST_DOMAIN,
|
||||
{
|
||||
"source_digest": self.source_digest,
|
||||
"content_digest": self.content_digest,
|
||||
"architecture_digest": self.architecture_digest,
|
||||
"derivative_range": list(self.derivative_range)
|
||||
if self.derivative_range is not None
|
||||
else None,
|
||||
"shard_start": self.shard_start,
|
||||
"shard_end": self.shard_end,
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
def tokenizer_revision(self) -> str:
|
||||
return str(self.axes["tokenizer_revision"])
|
||||
@@ -315,6 +338,8 @@ def parse_identity(data: Any) -> PresentedIdentity:
|
||||
artifact_id=_text(artifact.get("artifact_id"), "artifact.artifact_id"),
|
||||
revision=_pin(artifact.get("revision"), "artifact.revision"),
|
||||
source_digest=source_digest,
|
||||
content_digest=content_digest,
|
||||
derivative_range=binding,
|
||||
architecture=_text(artifact.get("architecture"), "artifact.architecture"),
|
||||
architecture_digest=_hex64(
|
||||
artifact.get("architecture_digest"), "artifact.architecture_digest"
|
||||
@@ -349,78 +374,6 @@ def parse_identity(data: Any) -> PresentedIdentity:
|
||||
return identity
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteMismatch:
|
||||
reason: str
|
||||
detail: str
|
||||
|
||||
def describe(self) -> str:
|
||||
return f"{self.reason}: {self.detail}"
|
||||
|
||||
|
||||
def compare(
|
||||
route: PresentedIdentity, candidate: PresentedIdentity
|
||||
) -> tuple[RouteMismatch, ...]:
|
||||
"""Why `candidate` may not join a route already running `route`."""
|
||||
if route.key == candidate.key:
|
||||
return ()
|
||||
|
||||
reasons: list[RouteMismatch] = []
|
||||
if route.source_digest != candidate.source_digest:
|
||||
reasons.append(
|
||||
RouteMismatch(
|
||||
MISMATCH_ARTIFACT,
|
||||
f"serves Model Artifact {candidate.source_digest[:12]}…, the route "
|
||||
f"runs {route.source_digest[:12]}…",
|
||||
)
|
||||
)
|
||||
if route.architecture_digest != candidate.architecture_digest:
|
||||
reasons.append(
|
||||
RouteMismatch(
|
||||
MISMATCH_ARCHITECTURE,
|
||||
"architecture/config snapshot differs from the route's",
|
||||
)
|
||||
)
|
||||
if route.layer_count != candidate.layer_count:
|
||||
reasons.append(
|
||||
RouteMismatch(
|
||||
MISMATCH_ARTIFACT,
|
||||
f"artifact has {candidate.layer_count} layers, the route's has "
|
||||
f"{route.layer_count}",
|
||||
)
|
||||
)
|
||||
|
||||
axis_reason = {
|
||||
"tokenizer_revision": MISMATCH_TOKENIZER,
|
||||
"architecture_adapter": MISMATCH_ARCHITECTURE,
|
||||
"activation_dtype": MISMATCH_ACTIVATION_RECIPE,
|
||||
"compute_dtype": MISMATCH_ACTIVATION_RECIPE,
|
||||
"kv_dtype": MISMATCH_CACHE_LAYOUT,
|
||||
"kv_layout": MISMATCH_CACHE_LAYOUT,
|
||||
"boundary_schema_version": MISMATCH_BOUNDARY_SCHEMA,
|
||||
"protocol_schema_version": MISMATCH_BOUNDARY_SCHEMA,
|
||||
}
|
||||
for axis in RECIPE_AXES:
|
||||
mine = route.axes.get(axis)
|
||||
theirs = candidate.axes.get(axis)
|
||||
if mine != theirs:
|
||||
reasons.append(
|
||||
RouteMismatch(
|
||||
axis_reason.get(axis, MISMATCH_FINGERPRINT),
|
||||
f"{axis} is {theirs!r}, the route runs {mine!r}",
|
||||
)
|
||||
)
|
||||
|
||||
if not reasons:
|
||||
reasons.append(
|
||||
RouteMismatch(
|
||||
MISMATCH_FINGERPRINT,
|
||||
"fingerprints differ on a field this comparison does not cover",
|
||||
)
|
||||
)
|
||||
return tuple(reasons)
|
||||
|
||||
|
||||
def coverage_gap(
|
||||
ranges: Iterable[tuple[int, int]], layer_count: int
|
||||
) -> str | None:
|
||||
@@ -462,6 +415,7 @@ class DistributedForwardEvidence:
|
||||
tokens_generated: int
|
||||
layer_count: int
|
||||
fingerprint: tuple[str, str]
|
||||
participants: tuple[PresentedIdentity, ...]
|
||||
synthetic: bool = False
|
||||
certified_at: float = field(default_factory=time.time)
|
||||
|
||||
@@ -471,6 +425,19 @@ class DistributedForwardEvidence:
|
||||
"evidence comes from a synthetic worker; only a real distributed "
|
||||
"forward certifies a recipe"
|
||||
)
|
||||
if len(self.participants) != len(self.node_ids):
|
||||
return "participant identities do not match the certifying node list"
|
||||
if len(self.shard_ranges) != len(self.participants):
|
||||
return "participant identities do not match the recorded effective ranges"
|
||||
for participant, shard_range in zip(
|
||||
self.participants, self.shard_ranges, strict=True
|
||||
):
|
||||
if participant.key != self.fingerprint:
|
||||
return "a participant fingerprint differs from the certifying route"
|
||||
if participant.layer_count != self.layer_count:
|
||||
return "a participant artifact layer count differs from the certifying route"
|
||||
if (participant.shard_start, participant.shard_end) != shard_range:
|
||||
return "a participant identity does not match its recorded effective range"
|
||||
distinct = set(self.node_ids)
|
||||
if len(distinct) < MIN_CERTIFYING_NODES:
|
||||
return (
|
||||
@@ -540,11 +507,11 @@ class CertificationLedger:
|
||||
|
||||
def certify(
|
||||
self,
|
||||
identity: PresentedIdentity | tuple[str, str],
|
||||
identity: PresentedIdentity,
|
||||
evidence: DistributedForwardEvidence,
|
||||
) -> RecipeStatus:
|
||||
"""Promote a recipe out of the dark, or raise saying why the evidence is short."""
|
||||
key = identity if isinstance(identity, tuple) else identity.key
|
||||
key = identity.key
|
||||
if key not in self._dark and key not in self._certified:
|
||||
raise RecipeIdentityError(
|
||||
"this fingerprint is not registered; unknown recipes cannot be certified"
|
||||
@@ -553,6 +520,10 @@ class CertificationLedger:
|
||||
raise RecipeIdentityError(
|
||||
"certification evidence fingerprint does not match the recipe being promoted"
|
||||
)
|
||||
if evidence.layer_count != identity.layer_count:
|
||||
raise RecipeIdentityError(
|
||||
"certification evidence layer count does not match the artifact being promoted"
|
||||
)
|
||||
rejection = evidence.rejection()
|
||||
if rejection is not None:
|
||||
raise RecipeIdentityError(f"this evidence does not certify: {rejection}")
|
||||
@@ -578,38 +549,8 @@ class CertificationLedger:
|
||||
}
|
||||
|
||||
|
||||
def admit_route(
|
||||
identities: Sequence[PresentedIdentity],
|
||||
*,
|
||||
ledger: CertificationLedger | None = None,
|
||||
for_certification: bool = False,
|
||||
) -> tuple[RouteMismatch, ...]:
|
||||
"""Every reason these Shards may not form one Inference Route; empty when they may.
|
||||
|
||||
Fail-closed by construction: an empty route, a hole in the coverage, one
|
||||
differing dtype, and an uncertified recipe each produce a reason, so a caller
|
||||
that proceeds only on an empty result cannot admit any of them.
|
||||
"""
|
||||
if not identities:
|
||||
return (RouteMismatch(MISMATCH_RANGE, "a route needs at least one Shard"),)
|
||||
|
||||
head = identities[0]
|
||||
reasons: list[RouteMismatch] = []
|
||||
for candidate in identities[1:]:
|
||||
reasons.extend(compare(head, candidate))
|
||||
|
||||
gap = coverage_gap(
|
||||
[(i.shard_start, i.shard_end) for i in identities], head.layer_count
|
||||
)
|
||||
if gap is not None:
|
||||
reasons.append(RouteMismatch(MISMATCH_RANGE, gap))
|
||||
|
||||
if ledger is not None:
|
||||
status = ledger.status(head)
|
||||
allowed = status.may_certify if for_certification else status.may_serve
|
||||
if not allowed:
|
||||
reasons.append(
|
||||
RouteMismatch(MISMATCH_UNCERTIFIED, f"{status.status}: {status.detail}")
|
||||
)
|
||||
|
||||
return tuple(reasons)
|
||||
# Route formation itself lives in `server.py` (`_select_route`, `_enumerate_routes`,
|
||||
# `_find_pinned_route`): candidates are partitioned by the exact fingerprint this
|
||||
# module derives, so a route can only ever be assembled inside one identity. A
|
||||
# second route gate here would be a second copy of that policy to keep in step —
|
||||
# the same reason the node module holds no certification authority.
|
||||
|
||||
Reference in New Issue
Block a user