Skip to content

Commit 95e2c9e

Browse files
committed
feat(error-layer): introduce deterministic error serialization kernel
- Introduced ErrorContext immutable VO - Introduced NormalizedError immutable VO with defensive meta snapshot - Introduced ErrorResponseModel immutable response abstraction - Introduced ThrowableToErrorInterface (deterministic mapping contract) - Implemented DefaultThrowableToError with strict fallback lock - Introduced FormatterInterface (pure deterministic formatting contract) - Implemented JsonErrorFormatter (stable JSON envelope) - Implemented ProblemDetailsFormatter (RFC7807 compliant) - Introduced ErrorSerializer readonly orchestrator - Added determinism stress tests (JSON + RFC) - Added immutability validation tests - Added fallback safety tests (no external message leakage) - Extended API_FREEZE_POLICY with Determinism Boundary section Compliance: - Preserves 1.x API freeze guarantees - Maintains strict determinism (no randomness, no timestamps) - No public signature modifications - No JSON envelope breaking changes - Fully backward compatible Phase: Error Layer – Determinism Lock Complete
1 parent 067d84b commit 95e2c9e

16 files changed

Lines changed: 902 additions & 0 deletions

docs/API_FREEZE_POLICY.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,43 @@ Breaking determinism = breaking change.
175175

176176
---
177177

178+
## 7.1 Determinism Boundary & Extension Responsibility
179+
180+
The library guarantees deterministic behavior for all **shipped components**, including:
181+
182+
* DefaultThrowableToError
183+
* JsonErrorFormatter
184+
* ProblemDetailsFormatter
185+
* ErrorSerializer
186+
* All Value Objects
187+
188+
Determinism means:
189+
190+
* Same Throwable + Same Context → identical output
191+
* No randomness
192+
* No timestamps
193+
* No environment-based branching
194+
* No stack trace exposure
195+
196+
However, the following are **outside the deterministic guarantee of the library**:
197+
198+
* Custom implementations of `ThrowableToErrorInterface`
199+
* Custom implementations of `FormatterInterface`
200+
* Application-provided `meta` data
201+
202+
The library guarantees deterministic **transformation of input**.
203+
It does not guarantee deterministic **input provided by the application**.
204+
205+
Custom extensions must preserve:
206+
207+
* Safety
208+
* Determinism
209+
* JSON contract stability
210+
211+
Failure to do so constitutes misuse of the extension surface, not a defect in the kernel.
212+
213+
---
214+
178215
# 8️⃣ Extension Rules
179216

180217
All extension mechanisms must:
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Maatify\Exceptions\Application\Error;
6+
7+
use Maatify\Exceptions\Exception\MaatifyException;
8+
use Throwable;
9+
10+
final class DefaultThrowableToError implements ThrowableToErrorInterface
11+
{
12+
public function map(Throwable $t): NormalizedError
13+
{
14+
if ($t instanceof MaatifyException) {
15+
return new NormalizedError(
16+
$t->getErrorCode()->getValue(),
17+
$t->getMessage(),
18+
$t->getHttpStatus(),
19+
$t->getCategory()->getValue(),
20+
$t->isRetryable(),
21+
$t->isSafe(),
22+
$t->getMeta()
23+
);
24+
}
25+
26+
// Fallback for external exceptions
27+
return new NormalizedError(
28+
'INTERNAL_ERROR',
29+
'An unexpected error occurred.',
30+
500,
31+
'internal',
32+
false,
33+
true,
34+
[]
35+
);
36+
}
37+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Maatify\Exceptions\Application\Error;
6+
7+
/**
8+
* Immutable Value Object representing the error context.
9+
*
10+
* @psalm-immutable
11+
*/
12+
final readonly class ErrorContext
13+
{
14+
15+
public function __construct(
16+
private ?string $traceId = null,
17+
private ?string $instance = null,
18+
private bool $debug = false
19+
) {
20+
}
21+
22+
public function getTraceId(): ?string
23+
{
24+
return $this->traceId;
25+
}
26+
27+
public function getInstance(): ?string
28+
{
29+
return $this->instance;
30+
}
31+
32+
public function isDebug(): bool
33+
{
34+
return $this->debug;
35+
}
36+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Maatify\Exceptions\Application\Error;
6+
7+
/**
8+
* Immutable Value Object representing the final error response.
9+
*
10+
* @psalm-immutable
11+
*/
12+
final class ErrorResponseModel
13+
{
14+
/** @var array<string, string> */
15+
private array $headers;
16+
/** @var array<mixed> */
17+
private array $body;
18+
19+
/**
20+
* @param int $status
21+
* @param array<string, string> $headers
22+
* @param string $contentType
23+
* @param array<mixed> $body
24+
*/
25+
public function __construct(
26+
private readonly int $status,
27+
array $headers,
28+
private readonly string $contentType,
29+
array $body
30+
) {
31+
$this->headers = [...$headers];
32+
$this->body = [...$body];
33+
}
34+
35+
public function getStatus(): int
36+
{
37+
return $this->status;
38+
}
39+
40+
/**
41+
* @return array<string, string>
42+
*/
43+
public function getHeaders(): array
44+
{
45+
return [...$this->headers];
46+
}
47+
48+
public function getContentType(): string
49+
{
50+
return $this->contentType;
51+
}
52+
53+
/**
54+
* @return array<mixed>
55+
*/
56+
public function getBody(): array
57+
{
58+
return [...$this->body];
59+
}
60+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Maatify\Exceptions\Application\Error;
6+
7+
use Maatify\Exceptions\Application\Format\FormatterInterface;
8+
use Throwable;
9+
10+
final readonly class ErrorSerializer
11+
{
12+
public function __construct(
13+
private ThrowableToErrorInterface $mapper,
14+
private FormatterInterface $formatter
15+
) {
16+
}
17+
18+
public function serialize(Throwable $t, ?ErrorContext $context = null): ErrorResponseModel
19+
{
20+
$context = $context ?? new ErrorContext();
21+
$normalized = $this->mapper->map($t);
22+
23+
return $this->formatter->format($normalized, $context);
24+
}
25+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Maatify\Exceptions\Application\Error;
6+
7+
/**
8+
* Immutable Value Object representing a normalized error.
9+
*
10+
* @psalm-immutable
11+
*/
12+
final class NormalizedError
13+
{
14+
15+
/**
16+
* @param string $code
17+
* @param string $message
18+
* @param int $status
19+
* @param string $category
20+
* @param bool $retryable
21+
* @param bool $safe
22+
* @param array<mixed> $meta
23+
*/
24+
public function __construct(
25+
private readonly string $code,
26+
private readonly string $message,
27+
private readonly int $status,
28+
private readonly string $category,
29+
private readonly bool $retryable,
30+
private readonly bool $safe,
31+
private array $meta
32+
) {
33+
$this->meta = [...$meta];
34+
}
35+
36+
public function getCode(): string
37+
{
38+
return $this->code;
39+
}
40+
41+
public function getMessage(): string
42+
{
43+
return $this->message;
44+
}
45+
46+
public function getStatus(): int
47+
{
48+
return $this->status;
49+
}
50+
51+
public function getCategory(): string
52+
{
53+
return $this->category;
54+
}
55+
56+
public function isRetryable(): bool
57+
{
58+
return $this->retryable;
59+
}
60+
61+
public function isSafe(): bool
62+
{
63+
return $this->safe;
64+
}
65+
66+
/**
67+
* @return array<mixed>
68+
*/
69+
public function getMeta(): array
70+
{
71+
return $this->meta;
72+
}
73+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Maatify\Exceptions\Application\Error;
6+
7+
use Throwable;
8+
9+
interface ThrowableToErrorInterface
10+
{
11+
/**
12+
* Maps any Throwable to a NormalizedError VO.
13+
* Must be deterministic.
14+
*/
15+
public function map(Throwable $t): NormalizedError;
16+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Maatify\Exceptions\Application\Format;
6+
7+
use Maatify\Exceptions\Application\Error\ErrorContext;
8+
use Maatify\Exceptions\Application\Error\ErrorResponseModel;
9+
use Maatify\Exceptions\Application\Error\NormalizedError;
10+
11+
interface FormatterInterface
12+
{
13+
/**
14+
* Formats a normalized error into a response model.
15+
* Must be deterministic.
16+
*/
17+
public function format(NormalizedError $error, ErrorContext $context): ErrorResponseModel;
18+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Maatify\Exceptions\Application\Format;
6+
7+
use Maatify\Exceptions\Application\Error\ErrorContext;
8+
use Maatify\Exceptions\Application\Error\ErrorResponseModel;
9+
use Maatify\Exceptions\Application\Error\NormalizedError;
10+
11+
final class JsonErrorFormatter implements FormatterInterface
12+
{
13+
public function format(NormalizedError $error, ErrorContext $context): ErrorResponseModel
14+
{
15+
$body = [
16+
'error' => [
17+
'code' => $error->getCode(),
18+
'message' => $error->getMessage(),
19+
'status' => $error->getStatus(),
20+
'category' => $error->getCategory(),
21+
'retryable' => $error->isRetryable(),
22+
'safe' => $error->isSafe(),
23+
'meta' => $error->getMeta(),
24+
],
25+
];
26+
27+
if ($context->getTraceId() !== null) {
28+
$body['trace_id'] = $context->getTraceId();
29+
}
30+
31+
return new ErrorResponseModel(
32+
$error->getStatus(),
33+
[],
34+
'application/json; charset=utf-8',
35+
$body
36+
);
37+
}
38+
}

0 commit comments

Comments
 (0)