$ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot $jsonPath = Join-Path $root 'APP.openapi.json' $document = Get-Content -Raw -Encoding UTF8 -LiteralPath $jsonPath | ConvertFrom-Json $issues = New-Object System.Collections.Generic.List[string] $settingsPath = '/genealogy/app/genealogies/{genealogyId}' $overviewPath = '/genealogy/app/genealogies/{genealogyId}/overview' $identifierPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$' $genealogyNamePattern = '^[^\s\u0000-\u001F\u007F-\u009F](?:[^\r\n\u0000-\u001F\u007F-\u009F\u2028\u2029]*[^\s\u0000-\u001F\u007F-\u009F])?$' $genealogyIntroPattern = '^[^\s\u0000-\u001F\u007F-\u009F](?:[^\r\u0000-\u0009\u000B-\u001F\u007F-\u009F\u2028\u2029]*[^\s\u0000-\u001F\u007F-\u009F])?$' function Add-Issue { param([string]$Message) $script:issues.Add($Message) } function Test-IsJsonArray { param([object]$Value) return $null -ne $Value -and $Value.GetType().IsArray } function Test-IsJsonBoolean { param([object]$Value, [bool]$Expected) return $Value -is [System.Boolean] -and $Value -eq $Expected } function Test-IsNonNullable { param([object]$Schema) if (-not $Schema) { return $false } $nullable = $Schema.PSObject.Properties['nullable'] return -not $nullable -or (Test-IsJsonBoolean $nullable.Value $false) } function Assert-NoConflictingSchemaKeywords { param( [object]$Schema, [string]$Label, [string[]]$Allowed = @() ) if (-not $Schema) { return } foreach ($keyword in @('not', 'allOf', 'anyOf', 'oneOf', 'const', 'enum')) { if ($keyword -notin $Allowed -and $Schema.PSObject.Properties[$keyword]) { Add-Issue "JSON $Label must not define conflicting schema keyword: $keyword" } } } function Assert-AllowedSchemaKeywords { param([object]$Schema, [string]$Label, [string[]]$Allowed) if (-not $Schema) { return } $annotations = @('title', 'description', 'example', 'examples', 'deprecated') foreach ($property in @($Schema.PSObject.Properties)) { if ($property.Name -like 'x-*' -or $property.Name -in $annotations -or $property.Name -in $Allowed) { continue } Add-Issue "JSON $Label contains an unowned schema keyword: $($property.Name)" } } function Test-IsPureSchemaRef { param([object]$Schema, [string]$ExpectedRef, [string]$Label) if (-not $Schema) { return $false } $properties = @($Schema.PSObject.Properties.Name) $actualRef = [string]$Schema.'$ref' if ($properties.Count -ne 1 -or $properties[0] -cne '$ref' -or $actualRef -cne $ExpectedRef) { Add-Issue "JSON $Label must be the sole exact local schema ref $ExpectedRef; actual: $actualRef" return $false } [void](Get-LocalComponentName $actualRef 'schemas' $Label) return $true } function Get-LocalComponentName { param([string]$Ref, [string]$Section, [string]$Label) $pattern = '^#/components/' + [regex]::Escape($Section) + '/(?[^/]+)$' $match = [regex]::Match($Ref, $pattern) if (-not $match.Success) { Add-Issue "JSON $Label must use an exact local #/components/$Section/... ref; actual: $Ref" return '' } return $match.Groups['name'].Value } function Get-Schema { param([string]$Name) $property = $document.components.schemas.PSObject.Properties[$Name] if (-not $property) { Add-Issue "JSON missing schema owner: $Name" return $null } return $property.Value } function Get-Operation { param([string]$Path, [string]$Method) $pathProperty = $document.paths.PSObject.Properties[$Path] if (-not $pathProperty) { Add-Issue "JSON missing path: $Path" return $null } $operationProperty = $pathProperty.Value.PSObject.Properties[$Method] if (-not $operationProperty) { Add-Issue "JSON missing operation: $($Method.ToUpper()) $Path" return $null } return $operationProperty.Value } function Resolve-Parameter { param([object]$Parameter, [string]$Label) if (-not $Parameter) { return $null } if (-not $Parameter.'$ref') { return $Parameter } $parameterRefSiblings = @($Parameter.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') }) if ($parameterRefSiblings.Count -gt 0) { Add-Issue "JSON $Label parameter ref contains semantic sibling keywords: $($parameterRefSiblings -join ',')" } $name = Get-LocalComponentName ([string]$Parameter.'$ref') 'parameters' $Label if (-not $name) { return $null } $owner = $document.components.parameters.PSObject.Properties[$name] if (-not $owner) { Add-Issue "JSON $Label references missing parameter owner: $name" return $null } return $owner.Value } function Resolve-Response { param([object]$Response, [string]$Label) if (-not $Response) { return $null } if (-not $Response.'$ref') { return $Response } $responseRefSiblings = @($Response.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') }) if ($responseRefSiblings.Count -gt 0) { Add-Issue "JSON $Label response ref contains semantic sibling keywords: $($responseRefSiblings -join ',')" } $name = Get-LocalComponentName ([string]$Response.'$ref') 'responses' $Label if (-not $name) { return $null } $owner = $document.components.responses.PSObject.Properties[$name] if (-not $owner) { Add-Issue "JSON $Label references missing response owner: $name" return $null } return $owner.Value } function Resolve-Header { param([object]$Header, [string]$Label) if (-not $Header) { return $null } if (-not $Header.'$ref') { return $Header } $headerRefSiblings = @($Header.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') }) if ($headerRefSiblings.Count -gt 0) { Add-Issue "JSON $Label header ref contains semantic sibling keywords: $($headerRefSiblings -join ',')" } $name = Get-LocalComponentName ([string]$Header.'$ref') 'headers' $Label if (-not $name) { return $null } $owner = $document.components.headers.PSObject.Properties[$name] if (-not $owner) { Add-Issue "JSON $Label references missing header owner: $name" return $null } return $owner.Value } function Get-Response { param([object]$Operation, [string]$Label, [string]$Status) if (-not $Operation) { return $null } $property = $Operation.responses.PSObject.Properties[$Status] if (-not $property) { Add-Issue "JSON $Label missing response: $Status" return $null } return Resolve-Response $property.Value "$Label $Status" } function Get-JsonResponseRef { param([object]$Response, [string]$Label, [string]$Status) if (-not $Response) { return '' } $media = @($Response.content.PSObject.Properties) if ($media.Count -ne 1 -or $media[0].Name -ne 'application/json') { Add-Issue "JSON $Label $Status must expose only application/json" return '' } $schema = $media[0].Value.schema $ref = [string]$schema.'$ref' if (-not $ref) { Add-Issue "JSON $Label $Status must use a component schema ref" } else { [void](Get-LocalComponentName $ref 'schemas' "$Label $Status response schema") $schemaKeywords = @($schema.PSObject.Properties.Name) if ($schemaKeywords.Count -ne 1 -or $schemaKeywords[0] -cne '$ref') { Add-Issue "JSON $Label $Status response schema must contain only its exact local schema ref" } } return $ref } function Assert-ExactResponseSet { param([object]$Operation, [string]$Label, [string[]]$Expected) if (-not $Operation) { return } $actual = @($Operation.responses.PSObject.Properties.Name | Sort-Object) $wanted = @($Expected | Sort-Object) if (($actual -join ',') -ne ($wanted -join ',')) { Add-Issue "JSON $Label responses must be exactly $($wanted -join ','); actual: $($actual -join ',')" } } function Assert-PrivateNoStore { param([object]$Response, [string]$Label, [string]$Status) if (-not $Response) { return } $property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null } if (-not $property) { Add-Issue "JSON $Label $Status must document Cache-Control: private, no-store" return } $header = Resolve-Header $property.Value "$Label $Status Cache-Control" if (-not $header) { return } $values = @($header.schema.enum) if ($header.schema.type -ne 'string' -or -not (Test-IsNonNullable $header.schema) -or -not (Test-IsJsonArray $header.schema.enum) -or $values.Count -ne 1 -or [string]$values[0] -cne 'private, no-store') { Add-Issue "JSON $Label $Status Cache-Control must be fixed by a single enum value: private, no-store" } Assert-NoConflictingSchemaKeywords $header.schema "$Label $Status Cache-Control schema" @('enum') Assert-AllowedSchemaKeywords $header.schema "$Label $Status Cache-Control schema" @('type', 'enum', 'nullable') } function Assert-RequiredResponseHeaders { param([object]$Response, [string]$Label, [string]$Status) if (-not $Response) { return } $headerNames = if ($Response.headers) { @($Response.headers.PSObject.Properties.Name) } else { @() } if ('Cache-Control' -notin $headerNames) { Add-Issue "JSON $Label $Status response headers must include Cache-Control" } if ($Status -eq '429' -and 'Retry-After' -notin $headerNames) { Add-Issue "JSON $Label $Status response headers must include Retry-After" } if (@($headerNames | Where-Object { $_ -ieq 'ETag' }).Count -gt 0) { Add-Issue "JSON $Label $Status must not publish ETag; settings concurrency has one If-Match/settingsVersion owner" } $allowedHeaders = @('Cache-Control', 'traceparent', 'tracestate', 'x-request-id', 'x-correlation-id') if ($Status -eq '429') { $allowedHeaders += 'Retry-After' } $unexpectedHeaders = @($headerNames | Where-Object { $_ -notin $allowedHeaders }) if ($unexpectedHeaders.Count -gt 0) { Add-Issue "JSON $Label $Status response headers may add only traceparent/tracestate/x-request-id/x-correlation-id tracing headers; unexpected: $($unexpectedHeaders -join ',')" } foreach ($traceName in @('traceparent', 'tracestate', 'x-request-id', 'x-correlation-id')) { $property = if ($Response.headers) { $Response.headers.PSObject.Properties[$traceName] } else { $null } if (-not $property) { continue } $header = Resolve-Header $property.Value "$Label $Status $traceName" if (-not $header) { continue } foreach ($headerProperty in @($header.PSObject.Properties)) { if ($headerProperty.Name -like 'x-*' -or $headerProperty.Name -in @('description', 'deprecated', 'schema')) { continue } Add-Issue "JSON $Label $Status $traceName contains an unowned Header Object keyword: $($headerProperty.Name)" } if (-not $header.schema -or $header.schema.type -ne 'string' -or -not (Test-IsNonNullable $header.schema)) { Add-Issue "JSON $Label $Status $traceName must resolve to a Header Object with a non-null string schema" continue } Assert-NoConflictingSchemaKeywords $header.schema "$Label $Status $traceName schema" Assert-AllowedSchemaKeywords $header.schema "$Label $Status $traceName schema" @('type', 'nullable') } } function Assert-RetryAfter { param([object]$Response, [string]$Label) if (-not $Response) { return } $property = if ($Response.headers) { $Response.headers.PSObject.Properties['Retry-After'] } else { $null } if (-not $property) { Add-Issue "JSON $Label must document Retry-After" return } $header = Resolve-Header $property.Value "$Label Retry-After" if (-not $header) { return } if ($header.schema.type -ne 'integer' -or -not (Test-IsNonNullable $header.schema) -or [int]$header.schema.minimum -ne 1 -or [int]$header.schema.maximum -lt 1 -or [int]$header.schema.maximum -gt 300) { Add-Issue "JSON $Label Retry-After must be an integer in a bounded 1..300 second range" } Assert-NoConflictingSchemaKeywords $header.schema "$Label Retry-After schema" Assert-AllowedSchemaKeywords $header.schema "$Label Retry-After schema" @('type', 'minimum', 'maximum', 'nullable') } function Assert-SaToken { param([object]$Operation, [string]$Label) if (-not $Operation) { return } $requirements = @($Operation.security) if ($requirements.Count -ne 1) { Add-Issue "JSON $Label must have exactly one SaToken security requirement" return } $names = @($requirements[0].PSObject.Properties.Name) if ($names.Count -ne 1 -or $names[0] -ne 'SaToken') { Add-Issue "JSON $Label must require only SaToken" } } function Get-OperationParameters { param([string]$Path, [object]$Operation, [string]$Label) $parameters = @() $pathProperty = $document.paths.PSObject.Properties[$Path] if ($pathProperty -and $pathProperty.Value.parameters) { foreach ($parameter in @($pathProperty.Value.parameters)) { $resolved = Resolve-Parameter $parameter "$Label path parameter" if ($resolved) { $parameters += $resolved } } } if ($Operation -and $Operation.parameters) { foreach ($parameter in @($Operation.parameters)) { $resolved = Resolve-Parameter $parameter "$Label operation parameter" if ($resolved) { $parameters += $resolved } } } return $parameters } function Get-Parameter { param([object[]]$Parameters, [string]$Name, [string]$In, [string]$Label) $matches = @($Parameters | Where-Object { $_.name -eq $Name -and $_.in -eq $In }) if ($matches.Count -ne 1) { Add-Issue "JSON $Label must declare exactly one $In parameter: $Name" return $null } return $matches[0] } function Assert-ExactParameters { param([object[]]$Parameters, [string]$Label, [string[]]$Expected) $actual = @($Parameters | ForEach-Object { "$($_.in):$($_.name)" } | Sort-Object) $wanted = @($Expected | Sort-Object) if (($actual -join ',') -ne ($wanted -join ',')) { Add-Issue "JSON $Label parameters must be exactly $($wanted -join ','); actual: $($actual -join ',')" } } function Assert-ExactObject { param( [object]$Schema, [string]$Name, [string[]]$Properties, [string[]]$Required, [int]$MinProperties = -1, [int]$MaxProperties = -1 ) if (-not $Schema) { return } $actualProperties = @($Schema.properties.PSObject.Properties.Name | Sort-Object) $expectedProperties = @($Properties | Sort-Object) $actualRequired = @($Schema.required | Sort-Object) $expectedRequired = @($Required | Sort-Object) if ($Schema.type -ne 'object' -or -not (Test-IsNonNullable $Schema) -or -not (Test-IsJsonBoolean $Schema.additionalProperties $false) -or ($actualProperties -join ',') -ne ($expectedProperties -join ',') -or ($actualRequired -join ',') -ne ($expectedRequired -join ',')) { Add-Issue "JSON $Name must be a closed object with properties [$($expectedProperties -join ',')] and required [$($expectedRequired -join ',')]" } Assert-NoConflictingSchemaKeywords $Schema $Name $allowedObjectKeywords = @('type', 'properties', 'required', 'additionalProperties', 'nullable') if ($MinProperties -ge 0) { $allowedObjectKeywords += 'minProperties' } if ($MaxProperties -ge 0) { $allowedObjectKeywords += 'maxProperties' } Assert-AllowedSchemaKeywords $Schema $Name $allowedObjectKeywords if ($MinProperties -ge 0 -and [int]$Schema.minProperties -ne $MinProperties) { Add-Issue "JSON $Name.minProperties must be $MinProperties" } if ($MaxProperties -ge 0 -and [int]$Schema.maxProperties -ne $MaxProperties) { Add-Issue "JSON $Name.maxProperties must be $MaxProperties" } } function Assert-PropertyRef { param([object]$Schema, [string]$SchemaName, [string]$Field, [string]$ExpectedRef) if (-not $Schema) { return } $property = $Schema.properties.PSObject.Properties[$Field] if (-not $property) { Add-Issue "JSON $SchemaName missing property: $Field" return } [void](Test-IsPureSchemaRef $property.Value $ExpectedRef "$SchemaName.$Field") } function Assert-FixedError { param( [object]$Schema, [string]$Name, [int]$Status, [string]$BusinessCode, [switch]$Current ) $properties = @('code', 'businessCode') if ($Current) { $properties += 'current' } Assert-ExactObject $Schema $Name $properties $properties if (-not $Schema) { return } if ($Schema.properties.code.type -ne 'integer' -or -not (Test-IsNonNullable $Schema.properties.code) -or -not (Test-IsJsonArray $Schema.properties.code.enum) -or @($Schema.properties.code.enum).Count -ne 1 -or @($Schema.properties.code.enum)[0] -ne $Status -or $Schema.properties.businessCode.type -ne 'string' -or -not (Test-IsNonNullable $Schema.properties.businessCode) -or -not (Test-IsJsonArray $Schema.properties.businessCode.enum) -or @($Schema.properties.businessCode.enum).Count -ne 1 -or @($Schema.properties.businessCode.enum)[0] -ne $BusinessCode) { Add-Issue "JSON $Name must fix code=$Status and businessCode=$BusinessCode" } Assert-NoConflictingSchemaKeywords $Schema.properties.code "$Name.code" @('enum') Assert-NoConflictingSchemaKeywords $Schema.properties.businessCode "$Name.businessCode" @('enum') Assert-AllowedSchemaKeywords $Schema.properties.code "$Name.code" @('type', 'enum', 'nullable') Assert-AllowedSchemaKeywords $Schema.properties.businessCode "$Name.businessCode" @('type', 'enum', 'nullable') if ($Current) { [void](Test-IsPureSchemaRef $Schema.properties.current '#/components/schemas/AppGenealogyVo' "$Name.current") } } function Get-RequestBodySchemas { param([object]$Operation, [string]$Label) if (-not $Operation -or -not $Operation.requestBody) { return @() } $requestBody = $Operation.requestBody if ($requestBody.'$ref') { $name = Get-LocalComponentName ([string]$requestBody.'$ref') 'requestBodies' "$Label request body" if (-not $name) { return @() } $owner = $document.components.requestBodies.PSObject.Properties[$name] if (-not $owner) { Add-Issue "JSON $Label references missing request body owner: $name" return @() } $requestBody = $owner.Value } if (-not $requestBody.content) { return @() } return @($requestBody.content.PSObject.Properties | ForEach-Object { $_.Value.schema } | Where-Object { $_ }) } function Test-SchemaContainsSettingsContract { param( [object]$Schema, [hashtable]$Seen, [string]$Label, [bool]$AllowInlineIntro, [bool]$AtRequestRoot = $true ) if (-not $Schema) { return $false } $ref = [string]$Schema.'$ref' if ($ref) { $leaf = @($ref -split '/')[-1] if ($leaf -ceq 'AppGenealogySettingsUpdateBody') { if ($ref -cne '#/components/schemas/AppGenealogySettingsUpdateBody') { [void](Get-LocalComponentName $ref 'schemas' $Label) } return $true } $name = Get-LocalComponentName $ref 'schemas' $Label if (-not $name) { return $false } if ($Seen.ContainsKey($name)) { return $false } $Seen[$name] = $true $owner = $document.components.schemas.PSObject.Properties[$name] if (-not $owner) { Add-Issue "JSON $Label references missing schema owner: $name" return $false } return Test-SchemaContainsSettingsContract $owner.Value $Seen "$Label -> $name" $AllowInlineIntro $AtRequestRoot } if ($Schema.properties) { $sharedOwners = @{ genealogyName = '#/components/schemas/GenealogyName' intro = '#/components/schemas/GenealogyIntro' accessPreset = '#/components/schemas/GenealogyAccessPreset' } foreach ($field in $sharedOwners.Keys) { $property = $Schema.properties.PSObject.Properties[$field] if (-not $property) { continue } $propertyKeywords = @($property.Value.PSObject.Properties.Name) if ($propertyKeywords.Count -eq 1 -and $propertyKeywords[0] -ceq '$ref' -and [string]$property.Value.'$ref' -ceq $sharedOwners[$field]) { return $true } if ($AtRequestRoot -and ($field -ne 'intro' -or $AllowInlineIntro)) { return $true } } } foreach ($keyword in @('allOf', 'anyOf', 'oneOf')) { foreach ($branch in @($Schema.$keyword)) { if (Test-SchemaContainsSettingsContract $branch $Seen "$Label $keyword" $AllowInlineIntro $AtRequestRoot) { return $true } } } if ($Schema.items -and (Test-SchemaContainsSettingsContract $Schema.items $Seen "$Label items" $AllowInlineIntro $false)) { return $true } if ($Schema.properties) { foreach ($property in @($Schema.properties.PSObject.Properties)) { if (Test-SchemaContainsSettingsContract $property.Value $Seen "$Label.$($property.Name)" $AllowInlineIntro $false) { return $true } } } return $false } function Assert-GlobalSettingsWriteOwner { $methods = @('get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace') $writeMethods = @('post', 'put', 'patch', 'delete') $settingsOwners = New-Object System.Collections.Generic.List[string] $operationIdOwners = New-Object System.Collections.Generic.List[string] foreach ($pathProperty in @($document.paths.PSObject.Properties)) { foreach ($methodProperty in @($pathProperty.Value.PSObject.Properties | Where-Object { $_.Name -in $methods })) { $method = [string]$methodProperty.Name $operation = $methodProperty.Value $label = "$($method.ToUpperInvariant()) $($pathProperty.Name)" if ([string]$operation.operationId -ceq 'appUpdateGenealogySettings') { $operationIdOwners.Add($label) } if ($method -notin $writeMethods) { continue } $isSettingsOwner = $method -eq 'put' -and $pathProperty.Name -ceq $settingsPath $settingsSemanticPath = $pathProperty.Name -match '(?i)/genealogies(?:/\{[^}]+\})?/settings(?:[-_/]|$)' if ($settingsSemanticPath -or [string]$operation.operationId -ceq 'appUpdateGenealogySettings') { $isSettingsOwner = $true } $isGenealogyCreate = $method -eq 'post' -and $pathProperty.Name -ceq '/genealogy/app/genealogies' if (-not $isGenealogyCreate) { if ($operation.requestBody -and $operation.requestBody.'$ref') { $requestBodyLeaf = @(([string]$operation.requestBody.'$ref') -split '/')[-1] if ($requestBodyLeaf -ceq 'AppGenealogySettingsUpdateBody') { $isSettingsOwner = $true } } $allowInlineIntro = $settingsSemanticPath -or $pathProperty.Name -match '^/genealogy/app/genealogies(?:/|$)' foreach ($schema in @(Get-RequestBodySchemas $operation $label)) { if (Test-SchemaContainsSettingsContract $schema @{} "$label request schema" $allowInlineIntro $true) { $isSettingsOwner = $true break } } } if ($isSettingsOwner) { $settingsOwners.Add("$method $($pathProperty.Name)") } } } $uniqueSettingsOwners = @($settingsOwners | Sort-Object -Unique) $expected = "put $settingsPath" if ($uniqueSettingsOwners.Count -ne 1 -or $uniqueSettingsOwners[0] -cne $expected) { Add-Issue "JSON settings write contract must have exactly one global owner ($expected); actual: $($uniqueSettingsOwners -join ',')" } if ($operationIdOwners.Count -ne 1 -or $operationIdOwners[0] -cne "PUT $settingsPath") { Add-Issue "JSON operationId appUpdateGenealogySettings must be globally unique on PUT $settingsPath; actual: $($operationIdOwners -join ',')" } } $parityScript = Join-Path $PSScriptRoot 'openapi-yaml-json-parity-runtime-smoke.js' $parityOutput = @(& node $parityScript 2>&1) if ($LASTEXITCODE -ne 0 -or 'OPENAPI-YAML-JSON-PARITY PASS' -notin $parityOutput) { Add-Issue "protected JSON/YAML semantic parity failed: $($parityOutput -join ' | ')" } $workspaceScript = Join-Path $PSScriptRoot 'genealogy-workspace-openapi-contract.ps1' $previousErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = 'Continue' $workspaceOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $workspaceScript 2>&1) $workspaceExitCode = $LASTEXITCODE $ErrorActionPreference = $previousErrorActionPreference $workspaceLines = @($workspaceOutput | ForEach-Object { [string]$_ }) if ($workspaceExitCode -ne 0 -or 'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT PASS' -notin $workspaceLines) { Add-Issue 'canonical /mine and /overview workspace prerequisite must pass its sole owner gate before G11 can pass' } Assert-GlobalSettingsWriteOwner $settingsPathProperty = $document.paths.PSObject.Properties[$settingsPath] $settingsPathItem = if ($settingsPathProperty) { $settingsPathProperty.Value } else { $null } $settingsOperation = Get-Operation $settingsPath 'put' if ($settingsPathItem -and $settingsPathItem.patch) { Add-Issue "JSON $settingsPath must not publish a second PATCH settings owner" } if ($settingsOperation) { if ($settingsOperation.operationId -ne 'appUpdateGenealogySettings') { Add-Issue "JSON PUT $settingsPath operationId must be appUpdateGenealogySettings" } Assert-SaToken $settingsOperation "PUT $settingsPath" $settingsParameters = @(Get-OperationParameters $settingsPath $settingsOperation "PUT $settingsPath") Assert-ExactParameters $settingsParameters "PUT $settingsPath" @( 'path:genealogyId', 'header:clientid', 'header:If-Match' ) $genealogyId = Get-Parameter $settingsParameters 'genealogyId' 'path' "PUT $settingsPath" if ($genealogyId) { if (-not (Test-IsJsonBoolean $genealogyId.required $true)) { Add-Issue "JSON PUT $settingsPath genealogyId must be required" } [void](Test-IsPureSchemaRef $genealogyId.schema '#/components/schemas/GenealogyId' "PUT $settingsPath genealogyId") } $clientid = Get-Parameter $settingsParameters 'clientid' 'header' "PUT $settingsPath" if ($clientid -and (-not (Test-IsJsonBoolean $clientid.required $true) -or $clientid.schema.type -ne 'string' -or -not (Test-IsNonNullable $clientid.schema) -or [int]$clientid.schema.minLength -ne 1 -or [int]$clientid.schema.maxLength -ne 128)) { Add-Issue "JSON PUT $settingsPath clientid must be a required non-null bounded non-empty string" } if ($clientid) { Assert-NoConflictingSchemaKeywords $clientid.schema "PUT $settingsPath clientid" } if ($clientid) { Assert-AllowedSchemaKeywords $clientid.schema "PUT $settingsPath clientid" @('type', 'minLength', 'maxLength', 'nullable') } $ifMatch = Get-Parameter $settingsParameters 'If-Match' 'header' "PUT $settingsPath" if ($ifMatch) { if (-not (Test-IsJsonBoolean $ifMatch.required $true)) { Add-Issue "JSON PUT $settingsPath If-Match must be required" } [void](Test-IsPureSchemaRef $ifMatch.schema '#/components/schemas/GenealogySettingsVersion' "PUT $settingsPath If-Match") } $requestContent = if ($settingsOperation.requestBody) { @($settingsOperation.requestBody.content.PSObject.Properties) } else { @() } if (-not $settingsOperation.requestBody -or -not (Test-IsJsonBoolean $settingsOperation.requestBody.required $true) -or $requestContent.Count -ne 1 -or $requestContent[0].Name -ne 'application/json') { Add-Issue "JSON PUT $settingsPath must require only application/json AppGenealogySettingsUpdateBody" } elseif ($requestContent.Count -eq 1) { [void](Test-IsPureSchemaRef $requestContent[0].Value.schema '#/components/schemas/AppGenealogySettingsUpdateBody' "PUT $settingsPath request schema") } if ([string]$settingsOperation.'x-update-semantics' -ne 'ATOMIC_DIRTY_ONLY_MERGE' -or [string]$settingsOperation.'x-omitted-fields' -ne 'UNCHANGED' -or [string]$settingsOperation.'x-version-precondition' -ne 'IF_MATCH_SETTINGS_VERSION_CAS' -or [string]$settingsOperation.'x-canonical-noop-policy' -ne 'RETURN_200_KEEP_VERSION_NO_DOMAIN_SIDE_EFFECTS') { Add-Issue "JSON PUT $settingsPath must machine-bind dirty-only merge, omission, version CAS, and canonical no-op semantics" } $precedence = @($settingsOperation.'x-conflict-precedence') $expectedPrecedence = @( 'GENEALOGY_SETTINGS_VERSION_CHANGED', 'GENEALOGY_NOT_READY', 'ACTIVE_PENDING_APPLICATIONS' ) if (-not (Test-IsJsonArray $settingsOperation.'x-conflict-precedence') -or ($precedence -join ',') -ne ($expectedPrecedence -join ',')) { Add-Issue "JSON PUT $settingsPath must order version, READY, then active-pending conflicts" } if ([string]$settingsOperation.'x-active-pending-policy' -ne 'BLOCK_ONLY_PUBLIC_APPLY_TO_MEMBER_ONLY' -or [string]$settingsOperation.'x-public-apply-coordination' -ne 'ATOMIC_SINGLE_WINNER' -or -not (Test-IsJsonBoolean $settingsOperation.'x-authorization-revalidated-in-transaction' $true) -or -not (Test-IsJsonBoolean $settingsOperation.'x-conflict-disclosure-requires-current-permission' $true)) { Add-Issue "JSON PUT $settingsPath must bind permission revalidation and atomic single-winner PUBLIC_APPLY-to-MEMBER_ONLY pending protection" } } $statuses = @('200', '400', '401', '403', '404', '409', '422', '429', '500') Assert-ExactResponseSet $settingsOperation "PUT $settingsPath" $statuses $responseRefs = @{ '200' = '#/components/schemas/RAppGenealogyVo' '400' = '#/components/schemas/RGenealogySettingsBadRequest' '401' = '#/components/schemas/RGenealogySettingsUnauthorized' '403' = '#/components/schemas/RGenealogySettingsForbidden' '404' = '#/components/schemas/RGenealogySettingsNotFound' '409' = '#/components/schemas/RGenealogySettingsConflict' '422' = '#/components/schemas/RGenealogySettingsValidationError' '429' = '#/components/schemas/RGenealogySettingsRateLimited' '500' = '#/components/schemas/RGenealogySettingsOutcomeUnknown' } $settingsResponses = @{} foreach ($status in $statuses) { $response = Get-Response $settingsOperation "PUT $settingsPath" $status $settingsResponses[$status] = $response $actualRef = Get-JsonResponseRef $response "PUT $settingsPath" $status if ($actualRef -ne $responseRefs[$status]) { Add-Issue "JSON PUT $settingsPath $status must return $($responseRefs[$status]); actual: $actualRef" } Assert-RequiredResponseHeaders $response "PUT $settingsPath" $status Assert-PrivateNoStore $response "PUT $settingsPath" $status } Assert-RetryAfter $settingsResponses['429'] "PUT $settingsPath 429" $nameOwner = Get-Schema 'GenealogyName' if ($nameOwner) { if ($nameOwner.type -ne 'string' -or [int]$nameOwner.minLength -ne 1 -or [int]$nameOwner.maxLength -ne 24 -or [string]$nameOwner.pattern -cne $genealogyNamePattern -or -not (Test-IsNonNullable $nameOwner) -or [string]$nameOwner.'x-unicode-normalization' -ne 'NFC' -or [string]$nameOwner.'x-length-unit' -ne 'UNICODE_CODE_POINT') { Add-Issue 'JSON GenealogyName must be non-null NFC, 1..24 Unicode code points, without boundary whitespace, line breaks, or control characters' } Assert-NoConflictingSchemaKeywords $nameOwner 'GenealogyName' Assert-AllowedSchemaKeywords $nameOwner 'GenealogyName' @('type', 'minLength', 'maxLength', 'pattern', 'nullable') } $introOwner = Get-Schema 'GenealogyIntro' if ($introOwner) { $branches = @($introOwner.oneOf) $clear = @($branches | Where-Object { $_.type -eq 'string' -and (Test-IsNonNullable $_) -and (Test-IsJsonArray $_.enum) -and @($_.enum).Count -eq 1 -and [string]$_.enum[0] -eq '' }) $value = @($branches | Where-Object { $_.type -eq 'string' -and (Test-IsNonNullable $_) -and [int]$_.minLength -eq 1 }) if (-not (Test-IsJsonArray $introOwner.oneOf) -or $branches.Count -ne 2 -or $clear.Count -ne 1 -or $value.Count -ne 1 -or -not (Test-IsNonNullable $introOwner) -or [int]$value[0].maxLength -ne 80 -or [string]$value[0].pattern -cne $genealogyIntroPattern -or [string]$introOwner.'x-unicode-normalization' -ne 'NFC' -or [string]$introOwner.'x-length-unit' -ne 'UNICODE_CODE_POINT' -or [string]$introOwner.'x-line-ending-normalization' -ne 'LF') { Add-Issue 'JSON GenealogyIntro must be non-null, allow exact empty clear or boundary-trimmed NFC 1..80 code points, permit internal LF only, and reject other controls' } Assert-NoConflictingSchemaKeywords $introOwner 'GenealogyIntro' @('oneOf') Assert-AllowedSchemaKeywords $introOwner 'GenealogyIntro' @('oneOf', 'nullable') foreach ($branch in $clear) { Assert-NoConflictingSchemaKeywords $branch 'GenealogyIntro empty branch' @('enum') Assert-AllowedSchemaKeywords $branch 'GenealogyIntro empty branch' @('type', 'enum', 'nullable') } foreach ($branch in $value) { Assert-NoConflictingSchemaKeywords $branch 'GenealogyIntro value branch' Assert-AllowedSchemaKeywords $branch 'GenealogyIntro value branch' @('type', 'minLength', 'maxLength', 'pattern', 'nullable') } } $versionOwner = Get-Schema 'GenealogySettingsVersion' if ($versionOwner) { if ($versionOwner.type -ne 'string' -or [int]$versionOwner.minLength -ne 1 -or [int]$versionOwner.maxLength -ne 128 -or [string]$versionOwner.pattern -ne $identifierPattern -or -not (Test-IsNonNullable $versionOwner) -or [string]$versionOwner.'x-semantics' -ne 'OPAQUE_SETTINGS_CAS_VERSION' -or -not (Test-IsJsonArray $versionOwner.'x-version-scope-fields') -or (@($versionOwner.'x-version-scope-fields') -join ',') -cne 'genealogyName,intro,accessPreset' -or [string]$versionOwner.'x-version-change-policy' -ne 'CANONICAL_SETTINGS_CHANGE_ONLY') { Add-Issue 'JSON GenealogySettingsVersion must be a non-null 1..128 URL-safe opaque CAS token that changes only when canonical genealogyName/intro/accessPreset changes' } Assert-NoConflictingSchemaKeywords $versionOwner 'GenealogySettingsVersion' Assert-AllowedSchemaKeywords $versionOwner 'GenealogySettingsVersion' @('type', 'minLength', 'maxLength', 'pattern', 'nullable') } $settingsBody = Get-Schema 'AppGenealogySettingsUpdateBody' Assert-ExactObject $settingsBody 'AppGenealogySettingsUpdateBody' @( 'genealogyName', 'intro', 'accessPreset' ) @() 1 3 Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'genealogyName' '#/components/schemas/GenealogyName' Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'intro' '#/components/schemas/GenealogyIntro' Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'accessPreset' '#/components/schemas/GenealogyAccessPreset' $appGenealogy = Get-Schema 'AppGenealogyVo' if ($appGenealogy) { if (-not (Test-IsNonNullable $appGenealogy)) { Add-Issue 'JSON AppGenealogyVo must reject nullable=true' } foreach ($field in @('genealogyId', 'genealogyName', 'intro', 'accessPreset', 'settingsVersion', 'canManage')) { if ($field -notin @($appGenealogy.required)) { Add-Issue "JSON AppGenealogyVo.required missing settings baseline field: $field" } } Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'genealogyId' '#/components/schemas/GenealogyId' Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'genealogyName' '#/components/schemas/GenealogyName' Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'intro' '#/components/schemas/GenealogyIntro' Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'accessPreset' '#/components/schemas/GenealogyAccessPreset' Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'settingsVersion' '#/components/schemas/GenealogySettingsVersion' if ($appGenealogy.properties.canManage.type -ne 'boolean' -or -not (Test-IsNonNullable $appGenealogy.properties.canManage)) { Add-Issue 'JSON AppGenealogyVo.canManage must be a non-null boolean and is only an entry capability, not write authorization proof' } Assert-NoConflictingSchemaKeywords $appGenealogy.properties.canManage 'AppGenealogyVo.canManage' Assert-AllowedSchemaKeywords $appGenealogy 'AppGenealogyVo' @('type', 'properties', 'required', 'additionalProperties', 'nullable') Assert-AllowedSchemaKeywords $appGenealogy.properties.canManage 'AppGenealogyVo.canManage' @('type', 'nullable') } $successEnvelope = Get-Schema 'RAppGenealogyVo' if ($successEnvelope) { Assert-ExactObject $successEnvelope 'RAppGenealogyVo' @('code', 'data') @('code', 'data') if ($successEnvelope.properties.code.type -ne 'integer' -or -not (Test-IsNonNullable $successEnvelope.properties.code) -or -not (Test-IsJsonArray $successEnvelope.properties.code.enum) -or @($successEnvelope.properties.code.enum).Count -ne 1 -or @($successEnvelope.properties.code.enum)[0] -ne 200) { Add-Issue 'JSON RAppGenealogyVo must expose fixed code=200 and canonical AppGenealogyVo data' } Assert-NoConflictingSchemaKeywords $successEnvelope.properties.code 'RAppGenealogyVo.code' @('enum') Assert-AllowedSchemaKeywords $successEnvelope.properties.code 'RAppGenealogyVo.code' @('type', 'enum', 'nullable') [void](Test-IsPureSchemaRef $successEnvelope.properties.data '#/components/schemas/AppGenealogyVo' 'RAppGenealogyVo.data') } $badRequest = Get-Schema 'RGenealogySettingsBadRequest' $unauthorized = Get-Schema 'RGenealogySettingsUnauthorized' $forbidden = Get-Schema 'RGenealogySettingsForbidden' $notFound = Get-Schema 'RGenealogySettingsNotFound' $versionChanged = Get-Schema 'RGenealogySettingsVersionChanged' $notReady = Get-Schema 'RGenealogySettingsNotReady' $pendingApplications = Get-Schema 'RGenealogySettingsPendingApplications' $conflict = Get-Schema 'RGenealogySettingsConflict' $validation = Get-Schema 'RGenealogySettingsValidationError' $rateLimited = Get-Schema 'RGenealogySettingsRateLimited' $outcomeUnknown = Get-Schema 'RGenealogySettingsOutcomeUnknown' Assert-FixedError $badRequest 'RGenealogySettingsBadRequest' 400 'GENEALOGY_SETTINGS_REQUEST_INVALID' Assert-FixedError $unauthorized 'RGenealogySettingsUnauthorized' 401 'AUTH_REQUIRED' Assert-FixedError $forbidden 'RGenealogySettingsForbidden' 403 'GENEALOGY_SETTINGS_FORBIDDEN' Assert-FixedError $notFound 'RGenealogySettingsNotFound' 404 'GENEALOGY_NOT_AVAILABLE' Assert-FixedError $versionChanged 'RGenealogySettingsVersionChanged' 409 'GENEALOGY_SETTINGS_VERSION_CHANGED' -Current Assert-FixedError $notReady 'RGenealogySettingsNotReady' 409 'GENEALOGY_NOT_READY' Assert-FixedError $pendingApplications 'RGenealogySettingsPendingApplications' 409 'ACTIVE_PENDING_APPLICATIONS' Assert-FixedError $rateLimited 'RGenealogySettingsRateLimited' 429 'RATE_LIMITED' Assert-FixedError $outcomeUnknown 'RGenealogySettingsOutcomeUnknown' 500 'GENEALOGY_SETTINGS_OUTCOME_UNKNOWN' if ($conflict) { $actualRefs = @($conflict.oneOf | ForEach-Object { [string]$_.'$ref' } | Sort-Object) $expectedRefs = @( '#/components/schemas/RGenealogySettingsVersionChanged', '#/components/schemas/RGenealogySettingsNotReady', '#/components/schemas/RGenealogySettingsPendingApplications' ) | Sort-Object $mapping = $conflict.discriminator.mapping $actualMapping = if ($mapping) { @($mapping.PSObject.Properties | ForEach-Object { "$($_.Name)=$($_.Value)" } | Sort-Object) } else { @() } $expectedMapping = @( 'GENEALOGY_SETTINGS_VERSION_CHANGED=#/components/schemas/RGenealogySettingsVersionChanged', 'GENEALOGY_NOT_READY=#/components/schemas/RGenealogySettingsNotReady', 'ACTIVE_PENDING_APPLICATIONS=#/components/schemas/RGenealogySettingsPendingApplications' ) | Sort-Object $conflictKeywords = @($conflict.PSObject.Properties.Name | Sort-Object) $discriminatorKeywords = if ($conflict.discriminator) { @($conflict.discriminator.PSObject.Properties.Name | Sort-Object) } else { @() } $pureBranches = @($conflict.oneOf | Where-Object { (@($_.PSObject.Properties.Name) -join ',') -ceq '$ref' -and [string]$_.'$ref' -match '^#/components/schemas/[^/]+$' }) if (($conflictKeywords -join ',') -cne 'discriminator,oneOf' -or ($discriminatorKeywords -join ',') -cne 'mapping,propertyName' -or -not (Test-IsJsonArray $conflict.oneOf) -or $pureBranches.Count -ne 3 -or ($actualRefs -join ',') -ne ($expectedRefs -join ',') -or $conflict.discriminator.propertyName -ne 'businessCode' -or ($actualMapping -join ',') -ne ($expectedMapping -join ',')) { Add-Issue 'JSON RGenealogySettingsConflict must contain only an exact businessCode discriminator and three local-ref oneOf branches for version, READY, and active-pending conflicts' } } Assert-ExactObject $validation 'RGenealogySettingsValidationError' @( 'code', 'businessCode', 'fieldErrors' ) @('code', 'businessCode', 'fieldErrors') if ($validation) { if ($validation.properties.code.type -ne 'integer' -or -not (Test-IsNonNullable $validation.properties.code) -or -not (Test-IsJsonArray $validation.properties.code.enum) -or @($validation.properties.code.enum).Count -ne 1 -or @($validation.properties.code.enum)[0] -ne 422 -or $validation.properties.businessCode.type -ne 'string' -or -not (Test-IsNonNullable $validation.properties.businessCode) -or -not (Test-IsJsonArray $validation.properties.businessCode.enum) -or @($validation.properties.businessCode.enum).Count -ne 1 -or @($validation.properties.businessCode.enum)[0] -ne 'GENEALOGY_SETTINGS_INVALID') { Add-Issue 'JSON RGenealogySettingsValidationError must fix code=422 and businessCode=GENEALOGY_SETTINGS_INVALID' } Assert-NoConflictingSchemaKeywords $validation.properties.code 'RGenealogySettingsValidationError.code' @('enum') Assert-NoConflictingSchemaKeywords $validation.properties.businessCode 'RGenealogySettingsValidationError.businessCode' @('enum') Assert-AllowedSchemaKeywords $validation.properties.code 'RGenealogySettingsValidationError.code' @('type', 'enum', 'nullable') Assert-AllowedSchemaKeywords $validation.properties.businessCode 'RGenealogySettingsValidationError.businessCode' @('type', 'enum', 'nullable') $fieldErrors = $validation.properties.fieldErrors Assert-ExactObject $fieldErrors 'RGenealogySettingsValidationError.fieldErrors' @( 'genealogyName', 'intro', 'accessPreset' ) @() 1 3 if ($fieldErrors) { foreach ($field in @('genealogyName', 'intro', 'accessPreset')) { $property = $fieldErrors.properties.PSObject.Properties[$field] if (-not $property -or $property.Value.type -ne 'string' -or -not (Test-IsNonNullable $property.Value) -or [int]$property.Value.minLength -ne 1 -or [int]$property.Value.maxLength -ne 200) { Add-Issue "JSON RGenealogySettingsValidationError.fieldErrors.$field must be an optional non-null bounded non-empty string" } if ($property) { Assert-NoConflictingSchemaKeywords $property.Value "RGenealogySettingsValidationError.fieldErrors.$field" Assert-AllowedSchemaKeywords $property.Value "RGenealogySettingsValidationError.fieldErrors.$field" @('type', 'minLength', 'maxLength', 'nullable') } } } } if ($issues.Count -gt 0) { Write-Output 'G11-SETTINGS-OPENAPI-CONTRACT BLOCKED' Write-Output "Issues: $($issues.Count)" $issues | ForEach-Object { Write-Output "- $_" } Write-Output '- Keep PUT as the only settings owner and /overview as the only single-genealogy read owner; do not add PATCH or revive the generic GET.' Write-Output '- Use one atomic dirty-only merge with GenealogySettingsVersion in AppGenealogyVo, required If-Match, typed 409 conflicts, and no version field in the body.' Write-Output '- Block only PUBLIC_APPLY-to-MEMBER_ONLY while active PENDING applications exist, and serialize that transition with new application admission.' Write-Output '- Timeout, cancellation, 5xx, or malformed success is outcome-unknown; reconcile with a fresh /overview and never auto-repeat PUT or claim this request succeeded.' Write-Output '- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.' exit 1 } Write-Output 'G11-SETTINGS-OPENAPI-CONTRACT PASS'