Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3106,6 +3106,7 @@ public function resultPagePdf(Request $request, string $id): Response|JsonRespon
'X-Pdf-Surface' => (string) ($generated['surface'] ?? ReportPdfDocumentService::MBTI_RESULT_PAGE_SNAPSHOT_SURFACE),
'X-Pdf-Surface-Version' => (string) ($generated['surface_version'] ?? ReportPdfDocumentService::MBTI_RESULT_PAGE_SNAPSHOT_SURFACE_VERSION),
'X-Pdf-Render-Version' => (string) ($generated['render_version'] ?? ReportPdfDocumentService::MBTI_RESULT_PAGE_SNAPSHOT_RENDER_VERSION),
'X-Pdf-Print-Asset-Hash' => (string) ($generated['print_asset_hash'] ?? ReportPdfDocumentService::mbtiResultPageSnapshotPrintAssetHash()),
'X-Pdf-Artifact-Cache' => ((bool) ($generated['cached'] ?? false)) ? 'HIT' : 'MISS',
'X-Legacy-Mpdf-Fallback' => 'false',
'X-Gotenberg-Trace' => (string) ($generated['trace_id'] ?? ''),
Expand All @@ -3125,6 +3126,7 @@ private function resultPagePdfFailureHeaders(Request $request, string $traceId):
'X-Pdf-Surface' => ReportPdfDocumentService::MBTI_RESULT_PAGE_SNAPSHOT_SURFACE,
'X-Pdf-Surface-Version' => ReportPdfDocumentService::MBTI_RESULT_PAGE_SNAPSHOT_SURFACE_VERSION,
'X-Pdf-Render-Version' => ReportPdfDocumentService::MBTI_RESULT_PAGE_SNAPSHOT_RENDER_VERSION,
'X-Pdf-Print-Asset-Hash' => ReportPdfDocumentService::mbtiResultPageSnapshotPrintAssetHash(),
'X-Gotenberg-Trace' => $traceId,
'X-Legacy-Mpdf-Fallback' => 'false',
'X-Pdf-Error-Stage' => 'gotenberg.convert_url',
Expand All @@ -3149,6 +3151,7 @@ private function resultPagePdfCorsHeaders(Request $request): array
'X-Pdf-Surface',
'X-Pdf-Surface-Version',
'X-Pdf-Render-Version',
'X-Pdf-Print-Asset-Hash',
'X-Legacy-Mpdf-Fallback',
'X-Gotenberg-Trace',
'X-Pdf-Error-Stage',
Expand Down
17 changes: 17 additions & 0 deletions backend/app/Services/Report/Pdf/ReportPdfDocumentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ final class ReportPdfDocumentService

public const MBTI_RESULT_PAGE_SNAPSHOT_RENDER_VERSION = 'mbti.snapshot.print_layout.v1';

public const MBTI_RESULT_PAGE_SNAPSHOT_PRINT_ASSET_HASH = 'sha256:f8b8f8a162f469777924fb60966fac19c29ea4fdad1323b5f9ae1a19286a7614';

public const RESULT_PAGE_EXPORT_ENGINE = 'gotenberg_chromium';

private const MBTI_LEGACY_RESULT_PAGE_EXPORT_SURFACE_VERSION = 'mbti.result_page_export.v2';
Expand Down Expand Up @@ -405,6 +407,7 @@ public function getOrGenerateMbtiResultPageExport(Attempt $attempt, array $gate,
'surface' => self::MBTI_RESULT_PAGE_SNAPSHOT_SURFACE,
'surface_version' => self::MBTI_RESULT_PAGE_SNAPSHOT_SURFACE_VERSION,
'render_version' => self::MBTI_RESULT_PAGE_SNAPSHOT_RENDER_VERSION,
'print_asset_hash' => self::mbtiResultPageSnapshotPrintAssetHash(),
'trace_id' => $traceId,
];
}
Expand All @@ -423,6 +426,7 @@ public function getOrGenerateMbtiResultPageExport(Attempt $attempt, array $gate,
'surface' => self::MBTI_RESULT_PAGE_SNAPSHOT_SURFACE,
'surface_version' => self::MBTI_RESULT_PAGE_SNAPSHOT_SURFACE_VERSION,
'render_version' => self::MBTI_RESULT_PAGE_SNAPSHOT_RENDER_VERSION,
'print_asset_hash' => self::mbtiResultPageSnapshotPrintAssetHash(),
'trace_id' => $traceId,
];
}
Expand All @@ -442,13 +446,26 @@ private function resolveResultPageExportManifestHash(Attempt $attempt, array $ga
$baseHash,
self::MBTI_RESULT_PAGE_SNAPSHOT_SURFACE_VERSION,
self::MBTI_RESULT_PAGE_SNAPSHOT_RENDER_VERSION,
$this->sanitizeManifestSegment(self::mbtiResultPageSnapshotPrintAssetHash()),
self::RESULT_PAGE_EXPORT_ENGINE,
preg_replace('/[^a-z0-9_.-]+/i', '_', $locale) ?: 'locale',
$entitlement,
$variant,
]);
}

public static function mbtiResultPageSnapshotPrintAssetHash(): string
{
$configured = trim((string) config('gotenberg.result_print_asset_hash', ''));

return $configured !== '' ? $configured : self::MBTI_RESULT_PAGE_SNAPSHOT_PRINT_ASSET_HASH;
}

private function sanitizeManifestSegment(string $value): string
{
return preg_replace('/[^a-z0-9_.-]+/i', '_', trim($value)) ?: 'unset';
}

private function baseContentManifestHash(Attempt $attempt, ?Result $result = null): string
{
$summary = is_array($attempt->answers_summary_json ?? null) ? $attempt->answers_summary_json : [];
Expand Down
4 changes: 4 additions & 0 deletions backend/config/gotenberg.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
'/{locale}/result/{attempt_id}/print'
),
'result_print_token_secret' => env('GOTENBERG_RESULT_PRINT_TOKEN_SECRET', ''),
'result_print_asset_hash' => env(
'GOTENBERG_RESULT_PRINT_ASSET_HASH',
'sha256:f8b8f8a162f469777924fb60966fac19c29ea4fdad1323b5f9ae1a19286a7614'
),
'timeout_seconds' => (int) env('GOTENBERG_TIMEOUT_SECONDS', 60),
'connect_timeout_seconds' => (int) env('GOTENBERG_CONNECT_TIMEOUT_SECONDS', 5),
'allow_single_label_hosts' => (bool) env('GOTENBERG_ALLOW_SINGLE_LABEL_HOSTS', true),
Expand Down
51 changes: 48 additions & 3 deletions backend/tests/Feature/V0_3/AttemptPublicReportPdfParityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ public function test_public_mbti_result_page_pdf_uses_strict_gotenberg_surface()
$pdf->assertHeader('X-Pdf-Surface', 'mbti_result_page_snapshot');
$pdf->assertHeader('X-Pdf-Surface-Version', 'mbti.result_page_snapshot.v4');
$pdf->assertHeader('X-Pdf-Render-Version', 'mbti.snapshot.print_layout.v1');
$pdf->assertHeader('X-Pdf-Print-Asset-Hash', 'sha256:f8b8f8a162f469777924fb60966fac19c29ea4fdad1323b5f9ae1a19286a7614');
$pdf->assertHeader('X-Pdf-Artifact-Cache', 'MISS');
$pdf->assertHeader('X-Legacy-Mpdf-Fallback', 'false');
$this->assertNotSame('', (string) $pdf->headers->get('X-Gotenberg-Trace'));
Expand Down Expand Up @@ -353,7 +354,7 @@ public function test_public_mbti_result_page_pdf_uses_strict_gotenberg_surface()
$this->assertArrayNotHasKey('failOnConsoleExceptions', $payload);

Storage::disk('local')->assertExists(
"artifacts/pdf/MBTI/{$attemptId}/nohash-mbti.result_page_snapshot.v4-mbti.snapshot.print_layout.v1-gotenberg_chromium-zh-locked-free/report_free.pdf"
"artifacts/pdf/MBTI/{$attemptId}/nohash-mbti.result_page_snapshot.v4-mbti.snapshot.print_layout.v1-sha256_f8b8f8a162f469777924fb60966fac19c29ea4fdad1323b5f9ae1a19286a7614-gotenberg_chromium-zh-locked-free/report_free.pdf"
);
}

Expand Down Expand Up @@ -528,6 +529,7 @@ public function test_public_mbti_result_page_pdf_does_not_fallback_to_mpdf_when_
$pdf->assertHeader('X-Pdf-Surface', 'mbti_result_page_snapshot');
$pdf->assertHeader('X-Pdf-Surface-Version', 'mbti.result_page_snapshot.v4');
$pdf->assertHeader('X-Pdf-Render-Version', 'mbti.snapshot.print_layout.v1');
$pdf->assertHeader('X-Pdf-Print-Asset-Hash', 'sha256:f8b8f8a162f469777924fb60966fac19c29ea4fdad1323b5f9ae1a19286a7614');
$pdf->assertHeader('X-Legacy-Mpdf-Fallback', 'false');
$pdf->assertHeader('X-Pdf-Error-Stage', 'gotenberg.convert_url');
$this->assertStringContainsString('X-Gotenberg-Trace', (string) $pdf->headers->get('Access-Control-Expose-Headers'));
Expand All @@ -541,7 +543,7 @@ public function test_public_mbti_result_page_pdf_does_not_fallback_to_mpdf_when_
$pdf->assertJsonPath('request_id', 'pdf-export-request-1');
$this->assertSame($pdf->headers->get('X-Gotenberg-Trace'), $pdf->json('trace'));
Storage::disk('local')->assertMissing(
"artifacts/pdf/MBTI/{$attemptId}/nohash-mbti.result_page_snapshot.v4-mbti.snapshot.print_layout.v1-gotenberg_chromium-zh-locked-free/report_free.pdf"
"artifacts/pdf/MBTI/{$attemptId}/nohash-mbti.result_page_snapshot.v4-mbti.snapshot.print_layout.v1-sha256_f8b8f8a162f469777924fb60966fac19c29ea4fdad1323b5f9ae1a19286a7614-gotenberg_chromium-zh-locked-free/report_free.pdf"
);
}

Expand Down Expand Up @@ -618,7 +620,7 @@ public function test_public_mbti_result_page_pdf_cache_hit_preserves_engine_head
$this->createResult($attemptId);

Storage::disk('local')->put(
"artifacts/pdf/MBTI/{$attemptId}/nohash-mbti.result_page_snapshot.v4-mbti.snapshot.print_layout.v1-gotenberg_chromium-zh-locked-free/report_free.pdf",
"artifacts/pdf/MBTI/{$attemptId}/nohash-mbti.result_page_snapshot.v4-mbti.snapshot.print_layout.v1-sha256_f8b8f8a162f469777924fb60966fac19c29ea4fdad1323b5f9ae1a19286a7614-gotenberg_chromium-zh-locked-free/report_free.pdf",
'%PDF-1.4 cached chromium export'
);

Expand All @@ -634,9 +636,52 @@ public function test_public_mbti_result_page_pdf_cache_hit_preserves_engine_head
$pdf->assertHeader('X-Pdf-Surface', 'mbti_result_page_snapshot');
$pdf->assertHeader('X-Pdf-Surface-Version', 'mbti.result_page_snapshot.v4');
$pdf->assertHeader('X-Pdf-Render-Version', 'mbti.snapshot.print_layout.v1');
$pdf->assertHeader('X-Pdf-Print-Asset-Hash', 'sha256:f8b8f8a162f469777924fb60966fac19c29ea4fdad1323b5f9ae1a19286a7614');
$pdf->assertHeader('X-Pdf-Artifact-Cache', 'HIT');
$pdf->assertHeader('X-Legacy-Mpdf-Fallback', 'false');
$this->assertStringContainsString('cached chromium export', (string) $pdf->getContent());
Http::assertNothingSent();
}

public function test_public_mbti_result_page_pdf_print_asset_hash_change_misses_old_artifact(): void
{
$this->seedScales();
config()->set('fap.features.report_snapshot_strict_v2', false);
config()->set('gotenberg.enabled', true);
config()->set('gotenberg.base_url', 'http://gotenberg:3000');
config()->set('gotenberg.result_print_base_url', 'http://frontend:3000');
config()->set('gotenberg.result_print_asset_hash', 'sha256:1111111111111111111111111111111111111111111111111111111111111111');
Storage::fake('local');

$attemptId = (string) Str::uuid();
$anonId = 'anon_mbti_result_page_pdf_asset_hash';
$token = $this->issueAnonToken($anonId);
$this->createAttempt($attemptId, 'MBTI', $anonId);
$this->createResult($attemptId);

Storage::disk('local')->put(
"artifacts/pdf/MBTI/{$attemptId}/nohash-mbti.result_page_snapshot.v4-mbti.snapshot.print_layout.v1-sha256_f8b8f8a162f469777924fb60966fac19c29ea4fdad1323b5f9ae1a19286a7614-gotenberg_chromium-zh-locked-free/report_free.pdf",
'%PDF-1.4 cached old print asset hash export'
);

Http::fake([
'gotenberg:3000/forms/chromium/convert/url' => Http::response('%PDF-1.4 new print asset hash export', 200, [
'Content-Type' => 'application/pdf',
]),
]);

$pdf = $this->withHeaders([
'X-Anon-Id' => $anonId,
'Authorization' => 'Bearer '.$token,
])->get("/api/v0.3/attempts/{$attemptId}/result-page.pdf");

$pdf->assertStatus(200);
$pdf->assertHeader('X-Pdf-Print-Asset-Hash', 'sha256:1111111111111111111111111111111111111111111111111111111111111111');
$pdf->assertHeader('X-Pdf-Artifact-Cache', 'MISS');
$this->assertStringContainsString('new print asset hash export', (string) $pdf->getContent());
$this->assertStringNotContainsString('cached old print asset hash export', (string) $pdf->getContent());
Storage::disk('local')->assertExists(
"artifacts/pdf/MBTI/{$attemptId}/nohash-mbti.result_page_snapshot.v4-mbti.snapshot.print_layout.v1-sha256_1111111111111111111111111111111111111111111111111111111111111111-gotenberg_chromium-zh-locked-free/report_free.pdf"
);
}
}
83 changes: 83 additions & 0 deletions docs/codex/pr-train-state.json
Original file line number Diff line number Diff line change
Expand Up @@ -66323,5 +66323,88 @@
"summary": "Re-audited all zh-CN/en desktop clone locked/visible content; initialized local en owner rows via existing public personality baseline importer; zh-CN and en desktop clone dry-runs both pass."
}
]
},
"MBTI-PDF-SNAPSHOT-SYNC-GUARD-H1": {
"repo": "fap-api",
"base": "main",
"branch": "codex/mbti-pdf-snapshot-sync-guard-h1",
"title": "MBTI-PDF-SNAPSHOT-SYNC-GUARD-H1: add PDF snapshot render-source cache guard",
"train_scope": "mbti_pdf_snapshot_sync_guard_h1_companion",
"depends_on": [],
"status": "pr_open",
"commit_sha": null,
"pr_url": "https://github.com/fermatmind/fap-api/pull/2579",
"merged_at": null,
"remote_branch_deleted": false,
"local_cleanup_executed": false,
"failure_reason": null,
"checks": {
"authorization": {
"status": "pass",
"evidence": "User provided /goal execution request with explicit H1 backend companion scope, validation, merge, and cleanup rules."
},
"worktree_isolation": {
"status": "pass",
"evidence": "Created branch codex/mbti-pdf-snapshot-sync-guard-h1 in isolated /private/tmp fap-api worktree from origin/main because the main branch is owned by another local worktree."
},
"phpunit_result_page_pdf": {
"status": "pass",
"command": "cd backend && php artisan test --filter=AttemptPublicReportPdfParityTest",
"evidence": "11 tests completed with exit code 0; warnings are existing deprecation/file_get_contents notices."
},
"pint_dirty": {
"status": "pass",
"command": "cd backend && ./vendor/bin/pint --dirty",
"evidence": "4 changed PHP files formatted."
},
"manifest_parse": {
"status": "pass",
"command": "ruby -e \"require 'yaml'; YAML.load_file('docs/codex/pr-train.yaml')\"",
"evidence": "YAML parsed successfully."
},
"state_parse": {
"status": "pass",
"command": "python3 -m json.tool docs/codex/pr-train-state.json >/dev/null",
"evidence": "JSON parsed successfully."
},
"diff_check": {
"status": "pass",
"command": "git diff --check",
"evidence": "No whitespace errors."
},
"autoload_isolation": {
"status": "pass",
"evidence": "Temporary backend worktree uses copied vendor plus composer dump-autoload so tests load current branch sources rather than the original checkout."
}
},
"scope_validation": {
"allowed_files": [
"backend/config/gotenberg.php",
"backend/app/Services/Report/Pdf/ReportPdfDocumentService.php",
"backend/app/Http/Controllers/API/V0_3/AttemptReadController.php",
"backend/tests/Feature/V0_3/AttemptPublicReportPdfParityTest.php",
"docs/codex/pr-train.yaml",
"docs/codex/pr-train-state.json",
"generated/pr-train-sidecar-issues/**"
],
"outside_scope_files": []
},
"events": [
{
"at": "2026-07-01T14:30:04.421Z",
"status": "in_progress",
"notes": "Initialized H1 backend companion. Scope is print asset hash header/cache-key guard only; no deploy, server mutation, content import, Gotenberg route/timeout, visual layout, CMS/DB, sitemap/llms/SEO runtime changes."
},
{
"at": "2026-07-01T14:41:15.290294Z",
"status": "local_checks_passed",
"notes": "fap-api companion H1 local checks passed; no deploy, DB/CMS write, or production import performed."
},
{
"at": "2026-07-01T14:44:12.039300Z",
"status": "pr_open",
"notes": "Opened fap-api companion PR #2579 for H1."
}
]
}
}
39 changes: 39 additions & 0 deletions docs/codex/pr-train.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30680,3 +30680,42 @@ prs:
publish_allowed: false
deploy_allowed: false
content_authority: backend

- id: MBTI-PDF-SNAPSHOT-SYNC-GUARD-H1
repo: fap-api
depends_on: []
branch: codex/mbti-pdf-snapshot-sync-guard-h1
title: "MBTI-PDF-SNAPSHOT-SYNC-GUARD-H1: add PDF snapshot render-source cache guard"
train_scope: mbti_pdf_snapshot_sync_guard_h1_companion
status: in_progress
scope:
- Add `X-Pdf-Print-Asset-Hash` observability for MBTI result-page snapshot PDF responses.
- Include the print asset hash in the result-page PDF artifact key so print-impact frontend asset changes can miss old artifacts when the hash is updated.
- Keep `MBTI_RESULT_PAGE_SNAPSHOT_RENDER_VERSION` as the manual fallback escape hatch.
- Add focused cache tests proving changed print asset hash does not hit the old artifact.
allowed_paths:
- backend/config/gotenberg.php
- backend/app/Services/Report/Pdf/ReportPdfDocumentService.php
- backend/app/Http/Controllers/API/V0_3/AttemptReadController.php
- backend/tests/Feature/V0_3/AttemptPublicReportPdfParityTest.php
- docs/codex/pr-train.yaml
- docs/codex/pr-train-state.json
- generated/pr-train-sidecar-issues/**
do_not:
- Modify fap-web in this repository.
- Deploy, reload, restart, clear cache, write DB/CMS/search/indexing/queue, mutate server env, or run production import.
- Change Gotenberg route, timeout, PDF rendering chain, content injection, visual layout, content assets, mPDF behavior, sitemap, llms, canonical, noindex, JSON-LD, or SEO runtime.
validation:
- cd backend && php artisan test --filter=AttemptPublicReportPdfParityTest
- cd backend && ./vendor/bin/pint --dirty
- ruby -e "require 'yaml'; YAML.load_file('docs/codex/pr-train.yaml')"
- python3 -m json.tool docs/codex/pr-train-state.json >/dev/null
- git diff --check
merge_policy: { github_checks_required: true, squash: true, auto_merge: true }
production_write_execution: false
database_mutation: false
cms_mutation: false
api_runtime_change: true
publish_allowed: false
deploy_allowed: false
content_authority: backend