TenantAtlas/specs/405-json-to-jsonb-data-layer-hardening/implementation-report.md
ahmido 686947d26c feat: harden json to jsonb data layer for trust payloads (#476)
Automated PR provided by Codex via Gitea API.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #476
2026-06-23 21:36:35 +00:00

27 KiB

Spec 405 Implementation Report - JSON-to-JSONB Data-layer Hardening

1. Candidate Gate Result

Current result: PASS WITH CONDITIONS.

Local PostgreSQL migration/type proof, rollback/down-up proof, schema-attribute proof, and focused model/query regression tests pass. The remaining condition is external staging/Dokploy validation; it is not accessible from this agent session, so the gate cannot honestly be stronger than PASS WITH CONDITIONS.

2. Scope Confirmation

In scope:

  • Inventory every live PostgreSQL json and jsonb column.
  • Classify every live json column.
  • Convert only reviewed CONVERT columns to jsonb.
  • Preserve current payload semantics, casts, scoping, and query behavior.
  • Add local PostgreSQL tests and focused regression proof.

Out of scope and not changed:

  • UI surfaces, routes, navigation, Filament resources, panels, actions, forms, tables, widgets, or customer output.
  • Authorization model, roles, capabilities, provider semantics, lifecycle semantics, normalized replacement tables, and new product concepts.
  • Completed historical specs and their validation/task/smoke/browser history.
  • Speculative JSONB indexes.

Historical context reviewed as read-only:

  • Spec 400: no implementation-report.md present; spec/plan/tasks were treated as historical audit context only.
  • Spec 401: backup confirmation and provider residual context reviewed; left unchanged.
  • Spec 402: provider action residual closure context reviewed; left unchanged.
  • Spec 403: evidence/currentness runtime closure context reviewed; left unchanged.
  • Spec 404: management report PDF staging validation and PASS WITH CONDITIONS staging caveat reviewed; left unchanged.

3. Dirty State

Initial repository state:

  • Branch: 405-json-to-jsonb-data-layer-hardening
  • HEAD: 8918b357 feat: finish management report PDF staging validation (#475)
  • Initial dirty state: untracked specs/405-json-to-jsonb-data-layer-hardening/
  • Session branch not created because the active spec package was already untracked in the working tree; work continued cautiously on the current feature branch.
  • Initial git diff --check: passed.

Final git status --short --untracked-files=all:

 M apps/platform/app/Models/BackupItem.php
?? apps/platform/database/migrations/2026_06_23_000405_convert_trust_payload_json_columns_to_jsonb.php
?? apps/platform/tests/Feature/Database/JsonbDataLayerHardeningTest.php
?? specs/405-json-to-jsonb-data-layer-hardening/checklists/requirements.md
?? specs/405-json-to-jsonb-data-layer-hardening/implementation-report.md
?? specs/405-json-to-jsonb-data-layer-hardening/plan.md
?? specs/405-json-to-jsonb-data-layer-hardening/spec.md
?? specs/405-json-to-jsonb-data-layer-hardening/tasks.md

The spec package was untracked at session start. This implementation added the migration, database test, implementation report, task completion updates, and the BackupItem runtime adjustment.

4. JSON/JSONB Inventory Matrix

Inventory source: PostgreSQL information_schema.columns, row/null counts from the live local database, 2026-06-23. Converted-column migration decision is direct ALTER COLUMN ... TYPE jsonb USING ...::jsonb, grouped per table when more than one column is converted on the same table. Local converted tables have small row counts and no JSON-column index rebuild requirement; staging must still validate actual lock duration and migration runtime before production promotion.

Column Type Rows Nulls Nullable Default Indexes Classification Decision
alert_deliveries.payload json 0 0 YES none none CONVERT P2 Alert delivery payload; direct conversion.
alert_rules.tenant_allowlist json 0 0 YES none none CONVERT P2 Alert scoping list; direct conversion.
audit_logs.metadata json 1320 0 YES none none CONVERT P1 Audit context queried by metadata key; direct conversion.
backup_items.payload json 246 0 NO none none CONVERT P1 Critical backup snapshot payload; direct conversion.
backup_items.metadata json 246 0 YES none none CONVERT P1 Backup metadata and warnings; direct conversion.
backup_items.assignments json 246 146 YES none none CONVERT P1 Assignment proof payload; direct conversion and query function update.
backup_schedules.days_of_week json 0 0 YES none none CONVERT P2 Schedule structured config; direct conversion.
backup_schedules.policy_types json 0 0 NO none none CONVERT P2 Schedule policy-type filter; direct conversion.
backup_sets.metadata json 25 0 YES none none CONVERT P1 Backup-set provenance metadata; direct conversion.
baseline_profiles.scope_jsonb jsonb 4 0 NO none none ALREADY_JSONB No migration.
baseline_snapshot_items.meta_jsonb jsonb 102 0 YES none none ALREADY_JSONB No migration.
baseline_snapshots.summary_jsonb jsonb 4 0 YES none none ALREADY_JSONB No migration.
baseline_snapshots.completion_meta_jsonb jsonb 4 0 YES none none ALREADY_JSONB No migration.
baseline_tenant_assignments.override_scope_jsonb jsonb 3 3 YES none none ALREADY_JSONB No migration.
entra_groups.group_types jsonb 574 0 YES none none ALREADY_JSONB No migration.
environment_review_sections.summary_payload jsonb 153 0 NO {} none ALREADY_JSONB No migration.
environment_review_sections.render_payload jsonb 153 0 NO {} none ALREADY_JSONB No migration.
environment_reviews.summary jsonb 28 0 NO {} none ALREADY_JSONB No migration.
evidence_snapshot_items.summary_payload jsonb 98 0 NO {} evidence_snapshot_items_payload_gin ALREADY_JSONB Existing justified GIN retained.
evidence_snapshots.summary jsonb 24 0 NO {} none ALREADY_JSONB No migration.
finding_exception_decisions.metadata jsonb 7 0 NO {} finding_exception_decisions_metadata_gin ALREADY_JSONB Existing justified GIN retained.
finding_exception_evidence_references.summary_payload jsonb 0 0 NO {} finding_exception_evidence_refs_payload_gin ALREADY_JSONB Existing justified GIN retained.
finding_exceptions.evidence_summary jsonb 9 0 NO {} finding_exceptions_evidence_summary_gin ALREADY_JSONB Existing justified GIN retained.
findings.evidence_jsonb jsonb 254 0 YES none none ALREADY_JSONB No migration.
inventory_items.meta_jsonb jsonb 229 0 YES none none ALREADY_JSONB No migration.
inventory_links.metadata jsonb 251 0 YES none none ALREADY_JSONB No migration.
managed_environment_onboarding_sessions.state json 2 0 YES none none CONVERT P1 Onboarding state payload; direct conversion.
managed_environment_permissions.details json 135 0 YES none none CONVERT P1 Provider permission details; direct conversion.
managed_environment_triage_reviews.review_snapshot jsonb 0 0 NO {} none ALREADY_JSONB No migration.
managed_environments.metadata json 54 2 YES none none CONVERT P1 Provider/environment metadata; direct conversion.
managed_environments.rbac_canary_results json 54 54 YES none none CONVERT P1 RBAC readiness proof; direct conversion.
managed_environments.rbac_last_warnings json 54 54 YES none none CONVERT P1 RBAC warning proof; direct conversion.
notifications.data jsonb 311 0 NO none none ALREADY_JSONB No migration.
operation_runs.summary_counts jsonb 96 0 NO {} none ALREADY_JSONB No migration.
operation_runs.failure_summary jsonb 96 0 NO [] none ALREADY_JSONB No migration.
operation_runs.context jsonb 96 0 NO {} none ALREADY_JSONB No migration.
platform_users.capabilities jsonb 1 0 NO [] none ALREADY_JSONB No migration.
policies.metadata json 208 3 YES none none CONVERT P1 Policy inventory metadata; direct conversion.
policy_versions.snapshot json 422 0 NO none none CONVERT P1 Immutable policy snapshot; direct conversion.
policy_versions.metadata json 422 0 YES none none CONVERT P1 Version metadata; direct conversion.
policy_versions.assignments json 422 216 YES none none CONVERT P1 Version assignment snapshot; direct conversion.
policy_versions.scope_tags json 422 14 YES none none CONVERT P1 Scope tag snapshot; direct conversion.
policy_versions.secret_fingerprints json 422 2 YES none none CONVERT P1 Redaction integrity metadata; direct conversion.
product_usage_events.metadata jsonb 100 0 NO {} none ALREADY_JSONB No migration.
provider_connections.scopes_granted jsonb 16 0 NO {} none ALREADY_JSONB No migration.
provider_connections.metadata jsonb 16 0 NO {} none ALREADY_JSONB No migration.
restore_runs.requested_items json 4 2 YES none none CONVERT P1 Restore request payload; direct conversion.
restore_runs.preview json 4 1 YES none none CONVERT P1 Restore preview payload; direct conversion.
restore_runs.results json 4 0 YES none none CONVERT P1 Restore execution result payload; direct conversion.
restore_runs.metadata json 4 0 YES none none CONVERT P1 Restore metadata; direct conversion.
restore_runs.group_mapping json 4 4 YES none none CONVERT P1 Restore group mapping; direct conversion.
review_packs.summary jsonb 23 0 NO {} none ALREADY_JSONB No migration.
review_packs.options jsonb 23 0 NO {} none ALREADY_JSONB No migration.
review_publication_resolution_cases.summary jsonb 3 0 NO {} review_publication_resolution_cases_summary_gin ALREADY_JSONB Existing justified GIN retained.
review_publication_resolution_cases.metadata jsonb 3 0 NO {} none ALREADY_JSONB No migration.
review_publication_resolution_steps.summary jsonb 17 0 NO {} review_publication_resolution_steps_summary_gin ALREADY_JSONB Existing justified GIN retained.
review_publication_resolution_steps.metadata jsonb 17 0 NO {} none ALREADY_JSONB No migration.
settings_catalog_definitions.raw jsonb 0 0 NO none idx_settings_catalog_definitions_raw_gin ALREADY_JSONB Existing justified GIN retained.
stored_reports.payload jsonb 35 0 NO none stored_reports_payload_gin ALREADY_JSONB Existing justified GIN retained.
support_requests.context_envelope jsonb 0 0 NO {} none ALREADY_JSONB No migration.
tenant_settings.value json 0 0 NO none none CONVERT P1 Tenant-scoped setting value; direct conversion.
workspace_settings.value json 0 0 NO none none CONVERT P1 Workspace-scoped setting value; direct conversion.

No live json column was left as KEEP_JSON, DEPRECATED, or DECISION_REQUIRED: every live json column had current model ownership, bounded row counts, existing cast/read semantics, and a trust-layer storage reason to align with the existing jsonb baseline.

Inventory ownership, cast, and query continuation for converted columns:

Column Model / owner Existing cast or accessor Existing query/rendered usage Constraint/FK context
alert_deliveries.payload App\Models\AlertDelivery array Alert delivery rendering/notification payload; no JSON predicate. Workspace/environment/rule/destination FKs unchanged.
alert_rules.tenant_allowlist App\Models\AlertRule array Alert tenant scoping list; no JSON predicate. Workspace FK unchanged.
audit_logs.metadata App\Models\AuditLog / AuditRecorder array metadata ->> '_dedupe_key', metadata->... audit filters/tests. Workspace/environment/operation FKs and audit scope check unchanged.
backup_items.payload App\Models\BackupItem array Backup set detail, restore readiness, included item rendering. Workspace/environment/backup/policy FKs unchanged.
backup_items.metadata App\Models\BackupItem array Backup quality/source/warning helpers and rendered backup detail. Workspace/environment/backup/policy FKs unchanged.
backup_items.assignments App\Models\BackupItem array scopeWithAssignments(); assignment count/group helper rendering. Workspace/environment/backup/policy FKs unchanged.
backup_schedules.days_of_week App\Models\BackupSchedule array Schedule form/detail semantics; no JSON predicate. Workspace/environment FKs and frequency check unchanged.
backup_schedules.policy_types App\Models\BackupSchedule array Schedule policy-type selection; no JSON predicate. Workspace/environment FKs and frequency check unchanged.
backup_sets.metadata App\Models\BackupSet array Backup provenance/source helpers; metadata->source test path. Workspace/environment FK unchanged.
managed_environment_onboarding_sessions.state App\Models\ManagedEnvironmentOnboardingSession array with allowed-key mutator Onboarding resume/state rendering and historical migration reads. Workspace/environment/user FKs and lifecycle constraints unchanged.
managed_environment_permissions.details App\Models\ManagedEnvironmentPermission array Provider permission/readiness detail rendering; no JSON predicate. Workspace/environment FK and permission unique key unchanged.
managed_environments.metadata App\Models\ManagedEnvironment array Environment/provider metadata accessors and rendered environment context. Workspace FK and tenant lifecycle constraints unchanged.
managed_environments.rbac_canary_results App\Models\ManagedEnvironment JSON decode accessor RBAC readiness proof rendering. Workspace FK and tenant lifecycle constraints unchanged.
managed_environments.rbac_last_warnings App\Models\ManagedEnvironment JSON decode accessor RBAC warning rendering. Workspace FK and tenant lifecycle constraints unchanged.
policies.metadata App\Models\Policy array Policy inventory metadata rendering and helper access. Workspace/environment FK unchanged.
policy_versions.snapshot App\Models\PolicyVersion array Immutable version snapshot rendering/diff/backup provenance. Workspace/environment/policy FKs unchanged.
policy_versions.metadata App\Models\PolicyVersion array Version source/warning/integrity helpers. Workspace/environment/policy FKs unchanged.
policy_versions.assignments App\Models\PolicyVersion array Version assignment snapshot rendering; sibling hash index unchanged. Workspace/environment/policy FKs unchanged.
policy_versions.scope_tags App\Models\PolicyVersion array Scope tag snapshot rendering; sibling hash index unchanged. Workspace/environment/policy FKs unchanged.
policy_versions.secret_fingerprints App\Models\PolicyVersion array Redaction integrity helper. Workspace/environment/policy FKs unchanged.
restore_runs.requested_items App\Models\RestoreRun array Restore request/preview detail rendering. Workspace/environment/backup FKs unchanged.
restore_runs.preview App\Models\RestoreRun array Restore preview wizard/detail rendering. Workspace/environment/backup FKs unchanged.
restore_runs.results App\Models\RestoreRun array Restore result proof/detail rendering. Workspace/environment/backup FKs unchanged.
restore_runs.metadata App\Models\RestoreRun array Restore result/safety metadata and reconciliation helpers; no direct JSON predicate on this table. Workspace/environment/backup FKs unchanged.
restore_runs.group_mapping App\Models\RestoreRun array Restore group-mapping preview/detail rendering. Workspace/environment/backup FKs unchanged.
tenant_settings.value App\Models\TenantSetting array Tenant settings read/write; no JSON predicate. Workspace/environment/user FKs unchanged.
workspace_settings.value App\Models\WorkspaceSetting array Workspace settings read/write; no JSON predicate. Workspace/user FKs unchanged.

Already-JSONB ownership groups were also reviewed and left unchanged:

  • Baseline/inventory/finding/evidence/review rows: existing *_jsonb, summary, summary_payload, render_payload, meta_jsonb, and evidence payload columns with current model/service ownership; existing GIN indexes retained where already present.
  • OperationRun/provider/notification/support rows: existing operation_runs.context, summary/failure payloads, provider connection metadata/scopes, notification data, product usage metadata, and support request context already use jsonb.
  • Stored report and review publication rows: stored_reports.payload, review pack summaries/options, and review publication resolution summaries/metadata already use jsonb; browser proof confirms rendered output remains stable.

5. Migrations Added

Added apps/platform/database/migrations/2026_06_23_000405_convert_trust_payload_json_columns_to_jsonb.php.

  • Converts the 27 reviewed CONVERT columns with ALTER TABLE ... ALTER COLUMN ... TYPE jsonb USING column::jsonb.
  • Groups multiple converted columns on the same table into a single ALTER TABLE statement to avoid repeated per-column table rewrites/lock windows where PostgreSQL can satisfy the changes together.
  • Runs only when DB::getDriverName() === 'pgsql'.
  • Preserves nullability, defaults, constraints, and existing non-JSON indexes by altering only the column type.
  • Rollback converts the same columns back to json with USING column::json.
  • Rollback limitation: jsonb canonicalizes object key ordering and duplicate JSON object keys. Semantic content is preserved, but byte-for-byte textual JSON representation is not guaranteed.
  • Local rollback/down-up proof passed in the PostgreSQL lane, including BackupItem::withAssignments() while the column is temporarily json.

6. Index Justification

No new JSONB indexes were added.

Usage scan found one conversion-affected raw JSON function path, BackupItem::scopeWithAssignments(), which filters by array length but does not justify a GIN or expression index at current row counts. Existing hash/sibling indexes and existing GIN indexes on already-JSONB tables remain unchanged.

The new PostgreSQL test asserts no speculative GIN index exists on converted-column tables.

7. Runtime Changes Made

Changed apps/platform/app/Models/BackupItem.php:

  • scopeWithAssignments() now uses jsonb_array_length(assignments::jsonb) on PostgreSQL.
  • The explicit cast keeps the scope compatible during pre-migration deploy windows and after a database-only rollback to json.
  • Non-PostgreSQL test lanes keep json_array_length(assignments) to avoid breaking SQLite-style JSON test support.

No model casts required changes. Laravel array casts continue to read/write jsonb columns correctly.

8. Tests Added or Updated

Added apps/platform/tests/Feature/Database/JsonbDataLayerHardeningTest.php with PostgreSQL-only coverage:

  • All reviewed legacy json columns are jsonb after migrations.
  • The 405 migration can run down() to json and back up() to jsonb while preserving representative existing rows.
  • Nullability, defaults, table indexes, and constraints remain unchanged across the 405 down/up cycle.
  • No remaining live public-schema json columns remain after the conversion migration.
  • No speculative GIN index was introduced for converted columns.
  • Representative read/write/cast behavior for alert, audit, backup, restore, policy, provider/environment, settings, and onboarding payload categories.
  • Audit metadata key query using metadata ->> '_dedupe_key'.
  • BackupItem::withAssignments() query path against both rollback-state json and migrated jsonb.

Latest result: 5 tests passed, 260 assertions.

Existing test run:

  • apps/platform/tests/Unit/BackupItemTest.php still passes after the driver-aware scope change.
  • Latest result: 20 tests passed, 31 assertions.

9. Data Validation

Local PostgreSQL validation:

  • The migration/type test ran migrations on the PostgreSQL testing database and asserted every converted column is jsonb.
  • The new down/up test temporarily rolled the 405 migration back to json, inserted representative rows across every converted column category, verified BackupItem::withAssignments() works in the rollback-state schema, migrated forward to jsonb, and compared decoded payload semantics.
  • The same down/up test asserted nullability, defaults, table index definitions, and table constraint definitions remain unchanged after conversion.
  • The same test asserted there are no remaining public-schema columns with data_type = 'json'.
  • Representative non-sensitive samples were written and read back through Eloquent casts and direct JSONB key predicates.
  • Row/null counts were recorded before conversion from the local development database; converted tables are small enough for direct ALTER COLUMN locally. Staging/Dokploy must still validate real runtime and lock behavior.

Not performed in this session:

  • Production-sized timing.
  • Staging/Dokploy migration runtime.
  • Full byte-level dump comparison. This is not required because jsonb canonicalizes textual representation; semantic preservation was tested instead.

10. Browser Proof

Focused browser proof passed using existing payload-backed smoke tests. No UI files were changed; this proof is regression-only.

Command:

cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec403EvidenceCurrentnessRuntimeClosureSmokeTest.php tests/Browser/Spec394ProviderFreshnessPermissionSmokeTest.php tests/Browser/Spec371BackupSetProductizationSmokeTest.php tests/Browser/Spec335RestoreRunDetailProductizationSmokeTest.php tests/Browser/Spec379ManagementReportPdfSmokeTest.php

Result:

5 browser tests passed, 176 assertions, 19.87s

Covered surfaces:

  • Evidence Overview and OperationRun proof currentness: Spec403EvidenceCurrentnessRuntimeClosureSmokeTest.php
  • Provider freshness and required-permission readiness: Spec394ProviderFreshnessPermissionSmokeTest.php
  • Backup set list/detail decision hierarchy and included backup items: Spec371BackupSetProductizationSmokeTest.php
  • Restore run detail post-execution proof/results rendering: Spec335RestoreRunDetailProductizationSmokeTest.php
  • Review Pack management PDF / Stored Report output state: Spec379ManagementReportPdfSmokeTest.php

Browser tests asserted no JavaScript errors and no unexpected console logs on the tested surfaces.

11. Regression Proof

Completed:

  • PostgreSQL schema/type/index/model/query proof passed.
  • PostgreSQL rollback/down-up preservation proof passed.
  • Existing BackupItem unit regression suite passed.
  • Focused browser proof passed across evidence, operations, provider, backup, restore, review pack, stored report, and management PDF surfaces.
  • No UI/runtime surface files were edited.
  • No authorization, global search, provider registration, destructive/high-impact action, or asset behavior was changed.

12. Staging Validation

Staging/Dokploy validation is not accessible from this agent session.

Required staging release checks before production:

  • Run the migration on staging PostgreSQL.
  • Confirm app boot.
  • Run the focused PostgreSQL Spec405 test or equivalent staging validation.
  • Smoke the representative payload-backed surfaces.
  • Confirm no secrets or raw provider credential payloads appear in logs/screenshots.

Because staging is unavailable here, final readiness remains PASS WITH CONDITIONS.

13. Remaining Findings

  • No P0 findings.
  • No unresolved json column classification.
  • No in-scope schema or application regression currently known after fixing rollback-compatible query handling and grouped table conversion.
  • Remaining P1 condition: staging/Dokploy validation unavailable in this session.
  • Browser proof passed locally.

14. Deferred Items

  • Staging/Dokploy validation and migration runtime observation.
  • Optional future online migration strategy if a production-sized table proves too large for direct ALTER COLUMN.
  • Full browser/runtime audit remains out of scope.
  • Governance artifact lifecycle and retention remain separate follow-up scope.

15. Validation Commands

Passed:

git diff --check
cd apps/platform && ./vendor/bin/sail php vendor/bin/pest -c phpunit.pgsql.xml tests/Feature/Database/JsonbDataLayerHardeningTest.php
cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Unit/BackupItemTest.php
cd apps/platform && ./vendor/bin/sail pint app/Models/BackupItem.php database/migrations/2026_06_23_000405_convert_trust_payload_json_columns_to_jsonb.php tests/Feature/Database/JsonbDataLayerHardeningTest.php
cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec403EvidenceCurrentnessRuntimeClosureSmokeTest.php tests/Browser/Spec394ProviderFreshnessPermissionSmokeTest.php tests/Browser/Spec371BackupSetProductizationSmokeTest.php tests/Browser/Spec335RestoreRunDetailProductizationSmokeTest.php tests/Browser/Spec379ManagementReportPdfSmokeTest.php
  • Application implementation status: implemented locally with focused PostgreSQL rollback/down-up, unit, formatter, diff, and browser proof passing; staging validation still conditions the gate.
  • Livewire v4 compliance: Livewire 4.1.4 confirmed by Laravel Boost; no Livewire code changed.
  • Provider registration location: Laravel 12 panel providers remain in apps/platform/bootstrap/providers.php; no provider registration changed.
  • Global search posture: unchanged. No globally searchable resource was added or modified.
  • Destructive/high-impact actions: none added or changed; no confirmation/authorization behavior changed.
  • Asset strategy: no frontend assets added, no FilamentAsset registration, no new filament:assets deploy requirement beyond the existing deployment baseline.
  • Product Surface Impact: no runtime UI surface changed.
  • UI Surface Impact: none; focused browser proof is regression-only.
  • No-legacy posture: canonical data-layer conversion; no compatibility shim or dual-write path introduced.
  • Page archetype / surface budgets / Technical Annex / deep-link demotion / canonical status vocabulary: N/A for changed code; existing surfaces remain unchanged.
  • Product Surface exceptions: none.
  • Focused browser proof: passed across evidence/currentness, operations, provider readiness, backup, restore, review pack, stored report, and management PDF surfaces.
  • Human Product Sanity: visible complexity unchanged; no raw payload exposure added; current/released/failed/partial semantics unchanged.
  • Implementation-report fields: Livewire v4, provider registration, global search, destructive/high-impact actions, asset strategy, deployment impact, tests/browser result, and visible complexity are recorded here.
  • Deployment impact: database migration only. No env vars, queues, scheduler, storage, routes, provider scopes, panels, assets, or worker changes. Staging validation is required before production promotion.
  • No completed-spec rewrite assertion: Specs 400-404 were read-only context; their files were not rewritten.
  • Recommended next step: run staging PostgreSQL migration validation before production promotion.