From 44a8a0e0dd7550100897fb8f47afcd0e1727b921 Mon Sep 17 00:00:00 2001 From: Konstantin Serebryakov <97249355+KonstantinSerebryakov@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:04:16 +0300 Subject: [PATCH] feat: expose stored client identity in normalized SDK models --- README.md | 10 +++++ src/Model/ClientIdentity.php | 69 ++++++++++++++++++++++++++++++++++ src/Model/Identification.php | 3 ++ test-data-client-identity.json | 48 +++++++++++++++++++++++ tests/ClientIdentityTest.php | 31 +++++++++++++++ 5 files changed, 161 insertions(+) create mode 100644 src/Model/ClientIdentity.php create mode 100644 test-data-client-identity.json create mode 100644 tests/ClientIdentityTest.php diff --git a/README.md b/README.md index ac3c58b..06cc251 100644 --- a/README.md +++ b/README.md @@ -461,3 +461,13 @@ docker run --rm -v "$PWD":/app -w /app composer:2 sh -c "composer install && com ## License [MIT](LICENSE) + +### Bot / Agent attribution + +The optional client identity field preserves the stored ingest decision from both +History and webhook data. Claims are unverified names; each verified entry proves +only its named subject through referenced evidence. Provider proof does not +confirm the agent name, service or AI mode. Missing data stays absent, and unknown +string values remain open. This field does not change risk evaluation or flags. +The legacy `search_bot` scoring flag may include allowlisted UA self-identification; +it is not a general cryptographic verification flag. diff --git a/src/Model/ClientIdentity.php b/src/Model/ClientIdentity.php new file mode 100644 index 0000000..c678379 --- /dev/null +++ b/src/Model/ClientIdentity.php @@ -0,0 +1,69 @@ +> $claims + * @param list> $verified Each subject/value has its own evidence_ids. + * @param list> $assessments + * @param list> $evidence + * @param array $raw Original object, including future fields. + */ + private function __construct( + public readonly string $schema_version, + public readonly int $classification_revision, + public readonly string $availability, + public readonly string $registry_revision, + public readonly string $observed_at, + public readonly array $claims, + public readonly array $verified, + public readonly array $assessments, + public readonly array $evidence, + public readonly array $raw, + ) {} + + public static function fromValue(mixed $value): ?self + { + if (!\is_array($value)) { + return null; + } + foreach (['schema_version', 'availability', 'registry_revision', 'observed_at'] as $key) { + if (!isset($value[$key]) || !\is_string($value[$key])) { + return null; + } + } + if (!isset($value['classification_revision']) || !\is_int($value['classification_revision']) || $value['classification_revision'] < 1) { + return null; + } + foreach (['claims', 'verified', 'assessments', 'evidence'] as $key) { + if (!isset($value[$key]) || !\is_array($value[$key]) || !array_is_list($value[$key])) { + return null; + } + foreach ($value[$key] as $entry) { + if (!\is_array($entry)) { + return null; + } + } + } + /** @var list> $claims */ + $claims = $value['claims']; + /** @var list> $verified */ + $verified = $value['verified']; + /** @var list> $assessments */ + $assessments = $value['assessments']; + /** @var list> $evidence */ + $evidence = $value['evidence']; + return new self($value['schema_version'], $value['classification_revision'], $value['availability'], $value['registry_revision'], $value['observed_at'], $claims, $verified, $assessments, $evidence, $value); + } + + /** @return array */ + public function jsonSerialize(): array + { + return $this->raw; + } +} diff --git a/src/Model/Identification.php b/src/Model/Identification.php index f0feb0d..4b47b7d 100644 --- a/src/Model/Identification.php +++ b/src/Model/Identification.php @@ -57,6 +57,7 @@ public function __construct( public readonly ?\DateTimeImmutable $observed_at, public readonly string $source, public readonly array $raw = [], + public readonly ?ClientIdentity $client_identity = null, ) {} /** @@ -132,6 +133,7 @@ public function toArray(): array 'detection_flags' => $this->detection_flags->toArray(), 'observed_at' => Time::format($this->observed_at), 'source' => $this->source, + ...($this->client_identity !== null ? ['client_identity' => $this->client_identity->jsonSerialize()] : []), ]; } @@ -193,6 +195,7 @@ private static function fromNormalized(array $normalized, array $raw): self observed_at: $normalized['observed_at'], source: $normalized['source'], raw: $raw, + client_identity: ClientIdentity::fromValue($raw['client_identity'] ?? null), ); } } diff --git a/test-data-client-identity.json b/test-data-client-identity.json new file mode 100644 index 0000000..a46fadb --- /dev/null +++ b/test-data-client-identity.json @@ -0,0 +1,48 @@ +{ + "schema_version": "1", + "classification_revision": 1, + "classifier_version": "192.1", + "registry_revision": "2026-10-06.1", + "availability": "available", + "observed_at": "2026-10-06T12:00:00Z", + "decided_at": "2026-10-06T12:00:00Z", + "claims": [ + { + "profile_id": "gptbot", + "provider_id": "openai", + "provider_name": "OpenAI", + "agent_name": "GPTBot", + "client_kind": "crawler", + "purpose": "training", + "source": "http_ua" + } + ], + "verified": [ + { + "subject": "provider", + "value_id": "openai", + "evidence_ids": [ + "ip:fixture" + ] + } + ], + "assessments": [ + { + "status": "unverified", + "reason": "ua_only" + } + ], + "evidence": [ + { + "id": "ip:fixture", + "method": "published_ip", + "source_id": "https://openai.com/gptbot.json", + "source_revision": "fixture-digest", + "checked_at": "2026-10-06T12:00:00Z", + "evaluated_at": "2026-10-06T12:00:00Z", + "covered_attributes": [ + "provider" + ] + } + ] +} diff --git a/tests/ClientIdentityTest.php b/tests/ClientIdentityTest.php new file mode 100644 index 0000000..d6501e2 --- /dev/null +++ b/tests/ClientIdentityTest.php @@ -0,0 +1,31 @@ + 70, 'client_identity' => $fixture]); + $webhook = Identification::fromWebhookData(['risk_score' => 70, 'client_identity' => $fixture]); + self::assertEquals($history->client_identity, $webhook->client_identity); + self::assertSame('provider', $history->client_identity->verified[0]['subject']); + self::assertSame('GPTBot', $history->client_identity->claims[0]['agent_name']); + self::assertSame(70, $history->risk_score); + self::assertSame($fixture, $history->toArray()['client_identity']); + $fixture['availability'] = 'future_state'; + $fixture['extra'] = 'kept'; + self::assertSame($fixture, Identification::fromHistoryRow(['client_identity' => $fixture])->toArray()['client_identity']); + foreach ([null, 'bad', []] as $value) { + $id = Identification::fromHistoryRow(['client_identity' => $value]); + self::assertNull($id->client_identity); + self::assertArrayNotHasKey('client_identity', $id->toArray()); + } + } +}