$snapshot * @return array */ private function scope(array $snapshot, string $gate, bool $extended = true, bool $franchiseFinance = true): array { $p = $snapshot['payload'] ?? []; $common = ['opportunity_category', 'promoter_id', 'promoter_type', 'sector_id', 'subsector_id', 'economic_activity_id', 'demand_statement', 'executive_summary']; $fields = match ($gate) { 'property_review' => ['real_estate.property_type', 'real_estate.land_area', 'real_estate.built_area', 'real_estate.area_unit', 'real_estate.units', 'real_estate.occupancy_status', 'real_estate.price', 'real_estate.rent', 'real_estate.rent_periodicity', 'real_estate.valuation', 'real_estate.utilities'], 'registry_urban_review' => ['real_estate.transaction_type', 'real_estate.title_type', 'real_estate.registration_number', 'real_estate.encumbrances', 'real_estate.land_area', 'real_estate.area_unit'], 'building_compliance_review' => ['real_estate.construction_status', 'real_estate.property_condition', 'real_estate.built_area', 'real_estate.floors', 'real_estate.planning_permit_document_id', 'real_estate.use_license_document_id'], 'cadastral_screening' => ['land_rights.parcels', 'land_rights.overlaps_conflicts'], 'land_legal_review' => ['land_rights.operation_type', 'land_rights.duat_status', 'land_rights.duat_number', 'land_rights.holder_id', 'land_rights.holder_name', 'land_rights.issuing_authority_id', 'land_rights.issue_date', 'land_rights.expiry_date', 'land_rights.authorized_purpose', 'land_rights.improvements', 'land_rights.consideration'], 'community_environment_review' => ['land_rights.communities', 'land_rights.community_consultation', 'environmental_social'], 'competent_authority_clearance' => ['land_rights.required_authorizations', 'land_rights.exploitation_plan_document_id', 'land_rights.authorized_purpose'], 'corporate_screening' => ['business_transaction.legal_entity_id', 'business_transaction.legal_form', 'business_transaction.beneficial_owners', 'business_transaction.percentage_offered'], 'financial_review' => ['business_transaction.asking_price', 'business_transaction.valuation', 'business_transaction.financial_periods', 'business_transaction.assets_liabilities', 'business_transaction.employees', 'business_transaction.partner_contribution'], 'legal_tax_due_diligence' => ['business_transaction.transaction_type', 'business_transaction.percentage_offered', 'business_transaction.contracts_licenses', 'business_transaction.litigation_contingencies', 'business_transaction.governance_rights', 'business_transaction.franchise_terms', 'business_transaction.tax_labor_compliance'], 'partnership_governance_review' => ['business_transaction.percentage_offered', 'business_transaction.partner_contribution', 'business_transaction.governance_rights'], 'brand_license_review' => ['business_transaction.franchise_terms', 'business_transaction.contracts_licenses'], 'franchise_legal_review' => ['business_transaction.franchise_terms', 'business_transaction.contracts_licenses', 'business_transaction.litigation_contingencies'], 'confidentiality_clearance' => ['visibility', 'publish_contact', 'public_contact_id', 'business_transaction.confidentiality_level', 'business_transaction.teaser', 'business_transaction.nda_document_id', 'business_transaction.data_room_index_document_id'], 'technical_review' => ['demand_statement', 'opportunity_type', 'maturity_stage', 'investment_modalities', 'investor_profiles', 'products', 'assets', 'inputs', 'markets', 'competitive_advantage', 'employment', 'impact_indicators', 'total_investment', 'currency', 'investment_breakdown', 'secured_financing', 'funding_sought', 'financial_indicators', 'milestones', 'risks'], 'legal_territorial_review' => ['licenses', 'environmental_social', 'relations', 'truth_declaration'], // Experimental modules have no approved scope contract: never carry their decisions. default => array_keys($p), }; if ($extended) { $fields = [...$fields, ...match ($gate) { 'land_legal_review' => ['land_rights.holder_document_id', 'land_rights.area_unit'], 'community_environment_review' => ['land_rights.communities_affected'], 'competent_authority_clearance' => ['land_rights.exploitation_plan_required'], 'corporate_screening' => ['business_transaction.entity_registration'], 'financial_review' => ['business_transaction.available_financial_years', 'business_transaction.history_status', 'business_transaction.history_declaration', 'business_transaction.history_declaration_document_id', 'business_transaction.projections'], 'franchise_legal_review' => ['business_transaction.legal_entity_id', 'business_transaction.legal_form', 'business_transaction.beneficial_owners', 'business_transaction.entity_registration', 'business_transaction.governance_rights', 'business_transaction.tax_labor_compliance'], 'confidentiality_clearance' => ['internal_contact_id'], default => [], }]; } if ($franchiseFinance && $gate === 'financial_review' && ($p['opportunity_category'] ?? '') === 'franchise') { // Existing canonical monetary obligations affect the existing financial review. // No rates, calculations, reviewers or external decisions are introduced here. $fields = [...$fields, 'business_transaction.franchise_terms.initial_fee', 'business_transaction.franchise_terms.royalties']; } $data = []; foreach ([...$common, ...$fields] as $field) { if (! is_string($field)) { throw new \UnexpectedValueException('Canonical review fields must be named fields.'); } $data[$field] = Arr::get($p, $field); } // Explicitly unclassified specialised fields conservatively reopen reviews. // They cannot silently become immaterial when a schema gains a new field. $classified = match ($p['opportunity_category'] ?? '') { 'real_estate' => ['transaction_type', 'property_type', 'construction_status', 'property_condition', 'land_area', 'built_area', 'area_unit', 'units', 'rooms', 'floors', 'amenities', 'utilities', 'occupancy_status', 'title_type', 'registration_number', 'encumbrances', 'price', 'rent', 'rent_periodicity', 'deposit', 'valuation', 'viewing', 'planning_permit_document_id', 'use_license_document_id', 'title_document_id', 'title_holder_name', 'issuing_authority_id', 'expected_completion_date', 'occupancy_details', 'occupancy_document_id', 'amenities_other', 'urban_limit_document_id', 'max_built_area'], default => [], }; if ($classified !== []) { $data['unclassified_module_fields'] = array_diff_key($p['real_estate'] ?? [], array_flip($classified)); } if ($gate === 'property_review') { foreach (['rooms', 'amenities', 'deposit', 'occupancy_details', 'occupancy_document_id', 'amenities_other'] as $key) { $data['real_estate.'.$key] = Arr::get($p, 'real_estate.'.$key); } } if ($gate === 'registry_urban_review') { foreach (['title_document_id', 'title_holder_name', 'issuing_authority_id', 'urban_limit_document_id', 'max_built_area'] as $key) { $data['real_estate.'.$key] = Arr::get($p, 'real_estate.'.$key); } } if ($gate === 'building_compliance_review') { $data['real_estate.expected_completion_date'] = Arr::get($p, 'real_estate.expected_completion_date'); } // A changed evidence file always invalidates a decision relying on it. // Unrelated documents/images and editorial timestamps are not review evidence. return $data; } /** @param array $snapshot * @param array $review */ public function hash(array $snapshot, string $gate, array $review = [], bool $legacy = false, ?string $rule = null): string { $effectiveRule = $rule ?? self::VERSION; $extended = ! $legacy && $effectiveRule !== self::PREVIOUS_VERSION; $franchiseFinance = ! $legacy && ! in_array($effectiveRule, [self::PREVIOUS_VERSION, self::BEFORE_FRANCHISE_FINANCE_VERSION], true); $data = $this->scope($snapshot, $gate, $extended, $franchiseFinance); $locationGates = ['registry_urban_review', 'cadastral_screening', 'legal_territorial_review', 'property_review']; if (! $legacy) { $locationGates = [...$locationGates, 'land_legal_review', 'community_environment_review', 'competent_authority_clearance']; } if (in_array($gate, $locationGates, true)) { $data['locations'] = array_map(fn ($r) => $r['payload'], $snapshot['resources']['locations'] ?? []); } foreach ($review['evidence_document_ids'] ?? [] as $id) { $r = $snapshot['resources']['documents'][$id] ?? null; $data['evidence'][$id] = $r ? ['sha256' => $r['sha256'] ?? null, 'payload' => $r['payload'] ?? [], 'deleted_at' => $r['deleted_at'] ?? null] : null; } if ($extended) { // Nested references are part of the decision even when the reviewer // selected a different supporting file. Preserve the original bytes. $referenced = function (array $node) use (&$referenced, &$data, $snapshot): void { foreach ($node as $key => $value) { if (is_array($value)) { $referenced($value); } elseif (is_string($key) && is_string($value) && $value !== '' && (str_ends_with($key, 'document_id') || $key === 'image_id')) { $kind = $key === 'image_id' ? 'images' : 'documents'; $resource = $snapshot['resources'][$kind][$value] ?? null; $data['referenced_resources'][$kind][$value] = $resource ? ['sha256' => $resource['sha256'] ?? null, 'payload' => $resource['payload'] ?? [], 'deleted_at' => $resource['deleted_at'] ?? null] : null; } } }; $referenced($data); } return $legacy ? hash('sha256', json_encode(Arr::sortRecursive($data), JSON_THROW_ON_ERROR)) : OpportunityCanonicalValue::hash($data); } /** @param array $snapshot * @param array $review */ public function matches(array $snapshot, string $gate, array $review): bool { if (! isset($review['scope_hash'])) { return true; // Caller must independently verify the original signed submission hash. } $rule = $review['scope_rule'] ?? ''; $legacy = ! in_array($rule, [self::VERSION, self::PREVIOUS_VERSION, self::BEFORE_FRANCHISE_FINANCE_VERSION], true); $expected = $this->hash($snapshot, $gate, $review, $legacy, $rule); return hash_equals($review['scope_hash'], $expected); } /** @param array $before * @param array $after * @param array $reviews * @param list $gates * @return array */ public function reconcile(array $before, array $after, array $reviews, array $gates, bool $force = false): array { $kept = []; $reopened = []; foreach ($gates as $gate) { $review = $reviews[$gate] ?? []; // Compare the signed baseline and new content with the new scope; // separately validate old scope signatures under their original rule. $priorHash = $this->hash($before, $gate, $review); $currentHash = $this->hash($after, $gate, $review); if (! $force && $before !== [] && ($review['decision'] ?? '') === 'approved' && $this->matches($before, $gate, $review) && hash_equals($priorHash, $currentHash)) { $kept[$gate] = [...$review, 'scope_hash' => $priorHash, 'scope_rule' => self::VERSION, 'accepted_submission_hash' => $after['hash'], 'carried_forward' => ['rule' => self::VERSION, 'from_scope_hash' => $review['scope_hash'] ?? null, 'from_scope_rule' => $review['scope_rule'] ?? 'legacy_unspecified', 'from_submission_hash' => $before['hash'] ?? null, 'at' => now()->toIso8601String()]]; } else { $reopened[] = $gate; } } return ['rule' => self::VERSION, 'reviews' => $kept, 'reopened' => $reopened, 'retained' => array_keys($kept), 'reason' => $force ? 'Suspensão, rejeição ou recurso exige nova decisão; motivo anterior preservado.' : 'Comparação dos campos e evidências aplicáveis a cada revisão (§28.1).', 'final_approval' => 'Nova assinatura independente da versão; nenhum parecer ou decisão externa inventado.']; } }