$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] $createPath = '/genealogy/app/genealogies' $statusPath = '/genealogy/app/genealogy-bootstrap-operations/{operationKey}' $regionSearchPath = '/genealogy/app/region/search' $personPath = '/genealogy/app/genealogies/{genealogyId}/lineage/persons' $personDetailPath = '/genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}' $personParentsPath = '/genealogy/app/genealogies/{genealogyId}/lineage/persons/{personId}/parents' $minePath = '/genealogy/app/genealogies/mine' $overviewPath = '/genealogy/app/genealogies/{genealogyId}/overview' 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-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 } return $true } $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 ' | ')" } 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-ComposedPropertyDefinitions { param( [object]$Schema, [string]$Field, [hashtable]$Seen = @{} ) if (-not $Schema) { return @() } $definitions = New-Object System.Collections.Generic.List[object] $direct = if ($Schema.properties) { $Schema.properties.PSObject.Properties[$Field] } else { $null } if ($direct) { $definitions.Add($direct.Value) } if ($Schema.'$ref') { $name = ([string]$Schema.'$ref').Split('/')[-1] if (-not $Seen.ContainsKey($name)) { $Seen[$name] = $true $owner = $document.components.schemas.PSObject.Properties[$name] if (-not $owner) { Add-Issue "JSON missing composed schema owner: $name" } else { foreach ($definition in @(Get-ComposedPropertyDefinitions $owner.Value $Field $Seen)) { $definitions.Add($definition) } } } } foreach ($keyword in @('allOf', 'anyOf', 'oneOf')) { $branches = $Schema.PSObject.Properties[$keyword] if (-not $branches) { continue } foreach ($branch in @($branches.Value)) { foreach ($definition in @(Get-ComposedPropertyDefinitions $branch $Field $Seen)) { $definitions.Add($definition) } } } return @($definitions) } function Get-Operation { param([string]$Path, [string]$Method) $pathProperty = $document.paths.PSObject.Properties[$Path] $operation = if ($pathProperty) { $pathProperty.Value.PSObject.Properties[$Method] } else { $null } if (-not $operation) { Add-Issue "JSON missing $($Method.ToUpperInvariant()) $Path" return $null } return $operation.Value } function Get-EffectiveParameters { param([string]$Path, [object]$Operation) if (-not $Operation) { return @() } $pathProperty = $document.paths.PSObject.Properties[$Path] $pathParameters = if ($pathProperty) { @($pathProperty.Value.parameters) } else { @() } $parametersByIdentity = [ordered]@{} # OpenAPI lets an operation override a path-item parameter with the same in/name identity. # Reject duplicates inside each scope before applying the legal operation-level override. $scopes = @( [pscustomobject]@{ Name = 'path-item'; Parameters = @($pathParameters) }, [pscustomobject]@{ Name = 'operation'; Parameters = @($Operation.parameters) } ) foreach ($scope in $scopes) { $scopeIdentities = @{} foreach ($parameter in @($scope.Parameters)) { if (-not $parameter) { continue } $resolved = $parameter if ($parameter.'$ref') { $name = ([string]$parameter.'$ref').Split('/')[-1] $owner = $document.components.parameters.PSObject.Properties[$name] if (-not $owner) { Add-Issue "JSON missing parameter owner: $name" continue } $resolved = $owner.Value } $identity = (([string]$resolved.in) + ':' + ([string]$resolved.name)).ToLowerInvariant() if ($identity -eq ':') { Add-Issue "JSON $Path contains a parameter without in/name" continue } if ($scopeIdentities.ContainsKey($identity)) { Add-Issue "JSON $Path contains duplicate $($scope.Name) parameter: $identity" continue } $scopeIdentities[$identity] = $true $parametersByIdentity[$identity] = $resolved } } return @($parametersByIdentity.Values) } function Get-Parameter { param([object]$Operation, [string]$Name, [string]$In, [string]$Path = '') if (-not $Operation) { return $null } $parameters = if ($Path) { Get-EffectiveParameters $Path $Operation } else { @($Operation.parameters) } $matches = @($parameters | Where-Object { $_.name -eq $Name -and $_.in -eq $In }) if ($matches.Count -ne 1) { Add-Issue "JSON operation must define exactly one $In parameter named $Name" return $null } return $matches[0] } function Assert-SaTokenOnly { param([object]$Operation, [string]$Label) if (-not $Operation) { return } $requirements = if ($Operation.PSObject.Properties['security']) { @($Operation.security) } else { @($document.security) } $valid = $requirements.Count -eq 1 -and $requirements[0].PSObject.Properties.Count -eq 1 -and $requirements[0].PSObject.Properties.Name -contains 'SaToken' if (-not $valid) { Add-Issue "JSON $Label must require SaToken without an anonymous alternative" } } function Assert-RequiredClientId { param([object]$Operation, [string]$Label, [string]$Path) $header = Get-Parameter $Operation 'clientid' 'header' $Path if (-not $header) { return } if (-not (Test-IsJsonBoolean $header.required $true) -or $header.schema.type -ne 'string' -or [int]$header.schema.minLength -lt 1) { Add-Issue "JSON $Label clientid must be a required non-empty string header" } } function Assert-RequestBodyRef { param([object]$Operation, [string]$Label, [string]$ExpectedRef) if (-not $Operation) { return } $media = $Operation.requestBody.content.PSObject.Properties['application/json'] if (-not (Test-IsJsonBoolean $Operation.requestBody.required $true) -or -not $media) { Add-Issue "JSON $Label must require an application/json body" } else { [void](Test-IsPureSchemaRef $media.Value.schema $ExpectedRef "$Label request body") } } function Assert-ExactObject { param( [object]$Schema, [string]$Label, [string[]]$Fields, [string[]]$Required, [int]$MinProperties = -1, [int]$MaxProperties = -1 ) if (-not $Schema) { return } $actualFields = @($Schema.properties.PSObject.Properties.Name | Sort-Object) $expectedFields = @($Fields | Sort-Object) $actualRequired = @($Schema.required | Sort-Object) $expectedRequired = @($Required | Sort-Object) $requiredProperty = $Schema.PSObject.Properties['required'] $requiredShapeValid = if ($expectedRequired.Count -gt 0) { Test-IsJsonArray $Schema.required } elseif ($requiredProperty) { Test-IsJsonArray $Schema.required } else { $true } if ($Schema.type -ne 'object' -or -not (Test-IsNonNullable $Schema) -or -not (Test-IsJsonBoolean $Schema.additionalProperties $false) -or -not $requiredShapeValid -or ($actualFields -join ',') -ne ($expectedFields -join ',') -or ($actualRequired -join ',') -ne ($expectedRequired -join ',')) { Add-Issue "JSON $Label must be a closed object; fields=$($expectedFields -join ','); required=$($expectedRequired -join ',')" } if ($MinProperties -ge 0 -and [int]$Schema.minProperties -ne $MinProperties) { Add-Issue "JSON $Label minProperties must be $MinProperties" } if ($MaxProperties -ge 0 -and [int]$Schema.maxProperties -ne $MaxProperties) { Add-Issue "JSON $Label maxProperties must be $MaxProperties" } $allowedKeywords = @('type', 'properties', 'required', 'additionalProperties', 'nullable') if ($MinProperties -ge 0) { $allowedKeywords += 'minProperties' } if ($MaxProperties -ge 0) { $allowedKeywords += 'maxProperties' } Assert-AllowedSchemaKeywords $Schema $Label $allowedKeywords } 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.$Field must use $ExpectedRef"; return } [void](Test-IsPureSchemaRef $property.Value $ExpectedRef "$SchemaName.$Field") } function Assert-ErrorSchema { param([string]$Name, [int]$HttpStatus, [string[]]$BusinessCodes) $schema = Get-Schema $Name if (-not $schema) { return } Assert-ExactObject $schema $Name @('code', 'businessCode') @('code', 'businessCode') $actualCodes = @($schema.properties.businessCode.enum | Sort-Object) $expectedCodes = @($BusinessCodes | Sort-Object) if ($schema.properties.code.type -ne 'integer' -or -not (Test-IsJsonArray $schema.properties.code.enum) -or @($schema.properties.code.enum).Count -ne 1 -or @($schema.properties.code.enum)[0] -ne $HttpStatus -or $schema.properties.businessCode.type -ne 'string' -or -not (Test-IsJsonArray $schema.properties.businessCode.enum) -or ($actualCodes -join ',') -ne ($expectedCodes -join ',')) { Add-Issue "JSON $Name business-code contract drifted: $($actualCodes -join ',')" } } function Assert-StringField { param( [object]$Schema, [string]$SchemaName, [string]$Field, [int]$MinLength, [int]$MaxLength, [string]$Format = '' ) if (-not $Schema) { return } $property = $Schema.properties.PSObject.Properties[$Field] if (-not $property) { Add-Issue "JSON $SchemaName missing field: $Field" return } $value = $property.Value if ($value.type -ne 'string' -or [int]$value.minLength -ne $MinLength -or [int]$value.maxLength -ne $MaxLength) { Add-Issue "JSON $SchemaName.$Field must be a string with length $MinLength..$MaxLength" } if ($Format -and $value.format -ne $Format) { Add-Issue "JSON $SchemaName.$Field format must be $Format" } } 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 $Status response" return $null } $response = $property.Value if ($response.'$ref') { $name = ([string]$response.'$ref').Split('/')[-1] $owner = $document.components.responses.PSObject.Properties[$name] if (-not $owner) { Add-Issue "JSON missing response owner: $name" return $null } $response = $owner.Value } return $response } function Get-JsonResponseRef { param([object]$Response, [string]$Label, [string]$Status) if (-not $Response) { return '' } $media = $Response.content.PSObject.Properties['application/json'] if (-not $media) { Add-Issue "JSON $Label $Status must use application/json" return '' } return [string]$media.Value.schema.'$ref' } 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 = $property.Value if ($header.'$ref') { $name = ([string]$header.'$ref').Split('/')[-1] $owner = $document.components.headers.PSObject.Properties[$name] if ($owner) { $header = $owner.Value } } $evidence = ([string]$header.description) + ' ' + ([string]$header.example) + ' ' + ([string]$header.schema.example) if ($header.schema.type -ne 'string' -or $evidence -notmatch '(?i)(private.*no-store|no-store.*private)') { Add-Issue "JSON $Label $Status Cache-Control must specify private, no-store" } } function Assert-RetryAfter { param([object]$Response, [string]$Label, [string]$Status) $header = if ($Response -and $Response.headers) { $Response.headers.PSObject.Properties['Retry-After'] } else { $null } if (-not $header) { Add-Issue "JSON $Label $Status must document Retry-After" return } $value = $header.Value if ($value.'$ref') { $name = ([string]$value.'$ref').Split('/')[-1] $owner = $document.components.headers.PSObject.Properties[$name] if ($owner) { $value = $owner.Value } } if ($value.schema.type -notin @('integer', 'string')) { Add-Issue "JSON $Label $Status Retry-After must have an integer or string schema" } } function Assert-ExactResponseSet { param([object]$Operation, [string]$Label, [string[]]$Expected) if (-not $Operation) { return } $actual = @($Operation.responses.PSObject.Properties.Name | Sort-Object) $expectedSorted = @($Expected | Sort-Object) if (($actual -join ',') -ne ($expectedSorted -join ',')) { Add-Issue "JSON $Label response statuses must be exactly $($expectedSorted -join ','); actual: $($actual -join ',')" } } function Assert-AllDeclaredResponsesPrivateJson { param([object]$Operation, [string]$Label) if (-not $Operation) { return } foreach ($responseProperty in $Operation.responses.PSObject.Properties) { $status = [string]$responseProperty.Name $response = Get-Response $Operation $Label $status [void](Get-JsonResponseRef $response $Label $status) Assert-PrivateNoStore $response $Label $status } } $createOperation = Get-Operation $createPath 'post' $statusOperation = Get-Operation $statusPath 'get' $regionOperation = Get-Operation $regionSearchPath 'get' $personOperation = Get-Operation $personPath 'post' $personUpdateOperation = Get-Operation $personDetailPath 'put' $personDeleteOperation = Get-Operation $personDetailPath 'delete' $personParentsOperation = Get-Operation $personParentsPath 'post' $mineOperation = Get-Operation $minePath 'get' $overviewOperation = Get-Operation $overviewPath 'get' if ($document.paths.PSObject.Properties['/genealogy/region/search']) { Add-Issue 'JSON legacy /genealogy/region/search must be removed; APP consumers use the authenticated /genealogy/app/region/search owner' } foreach ($entry in @( [pscustomobject]@{ Operation = $createOperation; Label = "POST $createPath"; Path = $createPath }, [pscustomobject]@{ Operation = $statusOperation; Label = "GET $statusPath"; Path = $statusPath }, [pscustomobject]@{ Operation = $regionOperation; Label = "GET $regionSearchPath"; Path = $regionSearchPath }, [pscustomobject]@{ Operation = $personOperation; Label = "POST $personPath"; Path = $personPath }, [pscustomobject]@{ Operation = $personUpdateOperation; Label = "PUT $personDetailPath"; Path = $personDetailPath }, [pscustomobject]@{ Operation = $personDeleteOperation; Label = "DELETE $personDetailPath"; Path = $personDetailPath }, [pscustomobject]@{ Operation = $personParentsOperation; Label = "POST $personParentsPath"; Path = $personParentsPath } )) { Assert-SaTokenOnly $entry.Operation $entry.Label Assert-RequiredClientId $entry.Operation $entry.Label $entry.Path } Assert-RequestBodyRef $createOperation "POST $createPath" '#/components/schemas/AppGenealogyBootstrapBody' $idempotencyHeader = Get-Parameter $createOperation 'Idempotency-Key' 'header' $createPath if ($idempotencyHeader) { if (-not (Test-IsJsonBoolean $idempotencyHeader.required $true) -or [string]$idempotencyHeader.schema.'$ref' -ne '#/components/schemas/GenealogyBootstrapOperationKey') { Add-Issue 'JSON create Idempotency-Key must be required and reference GenealogyBootstrapOperationKey' } } $statusKey = Get-Parameter $statusOperation 'operationKey' 'path' $statusPath if ($statusKey) { if (-not (Test-IsJsonBoolean $statusKey.required $true) -or [string]$statusKey.schema.'$ref' -ne '#/components/schemas/GenealogyBootstrapOperationKey') { Add-Issue 'JSON bootstrap status operationKey must be required and reference GenealogyBootstrapOperationKey' } } if ($createOperation) { $semantics = [string]$createOperation.description foreach ($pattern in @( '(?i)before acceptUntil.*short transaction.*claim PENDING.*canonical digest' '(?i)unique.*account.*tenant.*client.*path.*key' '(?i)fencing lease.*stale worker.*cannot commit' '(?i)business transaction.*quota.*genealogy.*OWNER.*unique generation-one root.*READY.*SUCCEEDED receipt' '(?i)failure.*rolls back.*all.*FAILED_NO_COMMIT' '(?i)watchdog.*resolveBy.*compare-and-set.*FAILED_NO_COMMIT' '(?i)resolveBy.*no later than.*2 minutes' '(?i)expired absent key.*reject.*late POST' '(?i)same key.*same canonical.*same receipt' '(?i)same key.*different.*digest.*IDEMPOTENCY_KEY_REUSED' '(?i)200.*committed' '(?i)transaction.*revalidates.*region.*selectable' '(?i)duplicate name.*advisory.*not unique' '(?i)400.*401.*403.*before claim.*no operation record' '(?i)create limit.*FAILED_NO_COMMIT' '(?i)unprocessable.*FAILED_NO_COMMIT' '(?i)429.*same key.*retry' '(?i)500.*outcome unknown.*status' )) { if ($semantics -notmatch $pattern) { Add-Issue "JSON POST $createPath description is missing atomic/idempotent semantics: $pattern" } } } if ($statusOperation) { if ($statusOperation.PSObject.Properties['requestBody']) { Add-Issue "JSON GET $statusPath must not define a request body" } $actualParameters = @(Get-EffectiveParameters $statusPath $statusOperation | ForEach-Object { "$($_.in):$($_.name)" } | Sort-Object) $expectedParameters = @('header:clientid', 'path:operationKey') | Sort-Object if (($actualParameters -join ',') -ne ($expectedParameters -join ',')) { Add-Issue "JSON GET $statusPath parameters must be exactly clientid plus operationKey; actual: $($actualParameters -join ',')" } $semantics = [string]$statusOperation.description foreach ($pattern in @( '(?i)current account.*tenant.*client' '(?i)PENDING.*SUCCEEDED.*FAILED_NO_COMMIT' '(?i)SUCCEEDED.*same receipt' '(?i)read-only.*no side effect' '(?i)PENDING.*must.*terminal.*claimedAt.*2 minutes' '(?i)404.*before.*acceptUntil.*unknown.*must not.*new key' '(?i)after.*acceptUntil.*absent.*computed.*FAILED_NO_COMMIT' '(?i)expired.*POST.*permanently rejected' '(?i)no request payload.*personal data' '(?i)does not consume.*quota' '(?i)SUCCEEDED.*retained.*entity lifecycle' '(?i)FAILED_NO_COMMIT.*retained.*30 days' '(?i)cross-account.*tenant.*client.*404.*non-disclosing' )) { if ($semantics -notmatch $pattern) { Add-Issue "JSON GET $statusPath description is missing crash-recovery semantics: $pattern" } } } if ($personOperation) { $personSemantics = [string]$personOperation.description foreach ($pattern in @( '(?i)READY.*ordinary person.*not.*bootstrap.*root' '(?i)database.*bootstrap-root marker.*authoritative' '(?i)generation.*1.*cannot.*create.*replace.*bootstrap root' )) { if ($personSemantics -notmatch $pattern) { Add-Issue "JSON POST $personPath is missing root-protection semantics: $pattern" } } foreach ($status in @('409', '422')) { $response = Get-Response $personOperation "POST $personPath" $status $responseRef = Get-JsonResponseRef $response "POST $personPath" $status $expectedRef = if ($status -eq '409') { '#/components/schemas/RLineageRootConflict' } else { '#/components/schemas/RLineageRootUnprocessable' } if ($responseRef -ne $expectedRef) { Add-Issue "JSON POST $personPath $status must use $expectedRef; actual: $responseRef" } } } foreach ($mutation in @( [pscustomobject]@{ Operation = $personUpdateOperation Label = "PUT $personDetailPath" Patterns = @( '(?i)bootstrap root.*profile fields.*may.*edit' '(?i)root identity.*generation.*parentage.*immutable' '(?i)database.*bootstrap-root marker.*authoritative' ) }, [pscustomobject]@{ Operation = $personDeleteOperation Label = "DELETE $personDetailPath" Patterns = @( '(?i)bootstrap root.*cannot.*delete' '(?i)database.*bootstrap-root marker.*authoritative' ) }, [pscustomobject]@{ Operation = $personParentsOperation Label = "POST $personParentsPath" Patterns = @( '(?i)bootstrap root.*cannot.*add.*reassign.*parent' '(?i)database.*bootstrap-root marker.*authoritative' ) } )) { if (-not $mutation.Operation) { continue } $semantics = [string]$mutation.Operation.description foreach ($pattern in $mutation.Patterns) { if ($semantics -notmatch $pattern) { Add-Issue "JSON $($mutation.Label) is missing bootstrap-root guard: $pattern" } } foreach ($status in @('409', '422')) { $response = Get-Response $mutation.Operation $mutation.Label $status $actualRef = Get-JsonResponseRef $response $mutation.Label $status $expectedRef = if ($status -eq '409') { '#/components/schemas/RLineageRootConflict' } else { '#/components/schemas/RLineageRootUnprocessable' } if ($actualRef -ne $expectedRef) { Add-Issue "JSON $($mutation.Label) $status must use $expectedRef; actual: $actualRef" } } } if ($personUpdateOperation) { $rootEditableFields = $personUpdateOperation.'x-bootstrap-root-editable-fields' $actualRootEditableFields = @($rootEditableFields | Sort-Object) $expectedRootEditableFields = @('biography', 'birthDate', 'name', 'sex') | Sort-Object if (-not (Test-IsJsonArray $rootEditableFields) -or ($actualRootEditableFields -join ',') -ne ($expectedRootEditableFields -join ',') -or [string]$personUpdateOperation.'x-bootstrap-root-noneditable-policy' -ne 'REJECT_422_BOOTSTRAP_ROOT_IMMUTABLE') { Add-Issue 'JSON root PUT must machine-bind the exact name/sex/birthDate/biography editable allowlist and reject every other root field with typed 422' } } $bootstrapBody = Get-Schema 'AppGenealogyBootstrapBody' $rootPersonBody = Get-Schema 'AppGenealogyRootPersonBody' $operationKey = Get-Schema 'GenealogyBootstrapOperationKey' $regionCode = Get-Schema 'GenealogyRegionCode' $accessPreset = Get-Schema 'GenealogyAccessPreset' $bootstrapResult = Get-Schema 'GenealogyBootstrapResult' $operationStatus = Get-Schema 'GenealogyBootstrapOperationStatus' $pendingStatus = Get-Schema 'GenealogyBootstrapPendingStatus' $succeededStatus = Get-Schema 'GenealogyBootstrapSucceededStatus' $failedStatus = Get-Schema 'GenealogyBootstrapFailedStatus' $resultEnvelope = Get-Schema 'RAppGenealogyBootstrapResult' $statusEnvelope = Get-Schema 'RAppGenealogyBootstrapOperationStatus' $regionItem = Get-Schema 'RegionSelectVo' $regionEnvelope = Get-Schema 'RListRegionSelectVo' Assert-ExactObject $bootstrapBody 'AppGenealogyBootstrapBody' @( 'genealogyName', 'surname', 'ancestralHall', 'regionCode', 'accessPreset', 'rootPerson' ) @('genealogyName', 'surname', 'regionCode', 'accessPreset', 'rootPerson') Assert-PropertyRef $bootstrapBody 'AppGenealogyBootstrapBody' 'accessPreset' '#/components/schemas/GenealogyAccessPreset' Assert-PropertyRef $bootstrapBody 'AppGenealogyBootstrapBody' 'rootPerson' '#/components/schemas/AppGenealogyRootPersonBody' Assert-PropertyRef $bootstrapBody 'AppGenealogyBootstrapBody' 'regionCode' '#/components/schemas/GenealogyRegionCode' Assert-PropertyRef $bootstrapBody 'AppGenealogyBootstrapBody' 'genealogyName' '#/components/schemas/GenealogyName' Assert-StringField $bootstrapBody 'AppGenealogyBootstrapBody' 'surname' 1 4 Assert-StringField $bootstrapBody 'AppGenealogyBootstrapBody' 'ancestralHall' 1 12 Assert-ExactObject $rootPersonBody 'AppGenealogyRootPersonBody' @( 'name', 'sex', 'birthDate', 'biography' ) @('name', 'sex') Assert-StringField $rootPersonBody 'AppGenealogyRootPersonBody' 'name' 1 20 Assert-StringField $rootPersonBody 'AppGenealogyRootPersonBody' 'birthDate' 10 10 'date' Assert-StringField $rootPersonBody 'AppGenealogyRootPersonBody' 'biography' 1 200 if ($rootPersonBody) { $sexValues = @($rootPersonBody.properties.sex.enum | Sort-Object) $expectedSex = @('FEMALE', 'MALE', 'UNKNOWN') | Sort-Object if ($rootPersonBody.properties.sex.type -ne 'string' -or -not (Test-IsJsonArray $rootPersonBody.properties.sex.enum) -or ($sexValues -join ',') -ne ($expectedSex -join ',')) { Add-Issue 'JSON AppGenealogyRootPersonBody.sex must be MALE/FEMALE/UNKNOWN' } $description = [string]$rootPersonBody.description foreach ($pattern in @('(?i)server.*generation.*1', '(?i)no.*generationName', '(?i)Unicode code point.*NFC.*boundary whitespace')) { if ($description -notmatch $pattern) { Add-Issue "JSON root-person description is missing invariant: $pattern" } } } if ($operationKey) { if ($operationKey.type -ne 'string' -or [int]$operationKey.minLength -ne 40 -or [int]$operationKey.maxLength -ne 61 -or [string]$operationKey.pattern -ne '^gcb\.[0-9]{13}\.[A-Za-z0-9_-]{22,43}$') { Add-Issue 'JSON GenealogyBootstrapOperationKey must encode epoch milliseconds plus 128-bit-or-stronger CSPRNG material' } $keyDescription = [string]$operationKey.description foreach ($pattern in @( '(?i)issuedAt' '(?i)128-bit.*CSPRNG' '(?i)acceptUntil\s*=\s*issuedAt\s*\+\s*10 minutes' '(?i)server time.*future.*5 minutes.*OPERATION_KEY_INVALID' '(?i)existing operation.*replay.*after acceptUntil' )) { if ($keyDescription -notmatch $pattern) { Add-Issue "JSON operation-key description is missing invariant: $pattern" } } if ([int]$operationKey.'x-accept-window-seconds' -ne 600 -or [int]$operationKey.'x-max-future-skew-seconds' -ne 300 -or [string]$operationKey.'x-issued-at-source' -ne 'KEY_EPOCH_MILLISECONDS') { Add-Issue 'JSON GenealogyBootstrapOperationKey must machine-bind issuedAt, the 600-second accept window, and 300-second future skew' } } if ($regionCode) { if ($regionCode.type -ne 'string' -or [int]$regionCode.minLength -ne 1 -or [int]$regionCode.maxLength -ne 32 -or [string]$regionCode.pattern -ne '^[A-Za-z0-9][A-Za-z0-9._~-]{0,31}$') { Add-Issue 'JSON GenealogyRegionCode must be a bounded lexical identifier, not a JavaScript number' } } if ($accessPreset) { $values = @($accessPreset.enum | Sort-Object) $expected = @('MEMBER_ONLY', 'PUBLIC_APPLY') | Sort-Object $nullable = $accessPreset.PSObject.Properties['nullable'] if ($accessPreset.type -ne 'string' -or -not (Test-IsJsonArray $accessPreset.enum) -or ($values -join ',') -ne ($expected -join ',') -or ($nullable -and -not (Test-IsJsonBoolean $nullable.Value $false))) { Add-Issue 'JSON GenealogyAccessPreset must be non-null and contain only MEMBER_ONLY/PUBLIC_APPLY' } foreach ($keyword in @('not', 'allOf', 'anyOf', 'oneOf', 'const')) { if ($accessPreset.PSObject.Properties[$keyword]) { Add-Issue "JSON GenealogyAccessPreset must not define conflicting schema keyword: $keyword" } } Assert-AllowedSchemaKeywords $accessPreset 'GenealogyAccessPreset' @('type', 'enum', 'nullable') } foreach ($legacySchema in @('GenealogyCreateBody', 'AppGenealogyCreateBody', 'GenealogyUpdateBody', 'AppGenealogyUpdateBody')) { if ($document.components.schemas.PSObject.Properties[$legacySchema]) { Add-Issue "JSON legacy schema must be removed in the same migration: $legacySchema" } } $appGenealogy = $document.components.schemas.PSObject.Properties['AppGenealogyVo'] if (-not $appGenealogy) { Add-Issue 'JSON missing AppGenealogyVo for the shared read contract' } else { $view = $appGenealogy.Value Assert-PropertyRef $view 'AppGenealogyVo' 'accessPreset' '#/components/schemas/GenealogyAccessPreset' if ('accessPreset' -notin @($view.required)) { Add-Issue 'JSON AppGenealogyVo.accessPreset must be required for every APP read projection' } foreach ($legacyField in @('visibility', 'joinMode')) { if ($view.properties.PSObject.Properties[$legacyField]) { Add-Issue "JSON AppGenealogyVo must remove legacy access field: $legacyField" } } } # accessPreset owns the entire APP genealogy wire, including every AppGenealogy* model. foreach ($schemaProperty in $document.components.schemas.PSObject.Properties) { if ($schemaProperty.Name -notmatch '^AppGenealogy') { continue } foreach ($legacyField in @('visibility', 'joinMode')) { if (@(Get-ComposedPropertyDefinitions $schemaProperty.Value $legacyField @{}).Count -gt 0) { Add-Issue "JSON $($schemaProperty.Name) retains legacy APP access field: $legacyField" } } foreach ($presetDefinition in @(Get-ComposedPropertyDefinitions $schemaProperty.Value 'accessPreset' @{})) { [void](Test-IsPureSchemaRef $presetDefinition '#/components/schemas/GenealogyAccessPreset' "$($schemaProperty.Name).accessPreset") } } Assert-ExactObject $bootstrapResult 'GenealogyBootstrapResult' @( 'genealogyId', 'rootPersonId', 'setupState', 'roleType', 'canView' ) @('genealogyId', 'rootPersonId', 'setupState', 'roleType', 'canView') if ($bootstrapResult) { foreach ($idField in @('genealogyId', 'rootPersonId')) { $id = $bootstrapResult.properties.$idField if ($id.type -ne 'string' -or [int]$id.minLength -ne 1 -or [int]$id.maxLength -ne 128 -or [string]$id.pattern -ne '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$') { Add-Issue "JSON GenealogyBootstrapResult.$idField must be a 1..128 URL-safe lexical string" } } if ($bootstrapResult.properties.setupState.type -ne 'string' -or -not (Test-IsJsonArray $bootstrapResult.properties.setupState.enum) -or @($bootstrapResult.properties.setupState.enum).Count -ne 1 -or ($bootstrapResult.properties.setupState.enum -join ',') -ne 'READY') { Add-Issue 'JSON GenealogyBootstrapResult.setupState must be the single value READY' } if ($bootstrapResult.properties.roleType.type -ne 'string' -or -not (Test-IsJsonArray $bootstrapResult.properties.roleType.enum) -or @($bootstrapResult.properties.roleType.enum).Count -ne 1 -or ($bootstrapResult.properties.roleType.enum -join ',') -ne 'OWNER') { Add-Issue 'JSON GenealogyBootstrapResult.roleType must be the single value OWNER' } if ($bootstrapResult.properties.canView.type -ne 'boolean' -or -not (Test-IsJsonArray $bootstrapResult.properties.canView.enum) -or @($bootstrapResult.properties.canView.enum).Count -ne 1 -or -not (Test-IsJsonBoolean @($bootstrapResult.properties.canView.enum)[0] $true)) { Add-Issue 'JSON GenealogyBootstrapResult.canView must be the single boolean value true' } } if ($operationStatus) { $actualBranches = @($operationStatus.oneOf | ForEach-Object { [string]$_.'$ref' } | Sort-Object) $expectedBranches = @( '#/components/schemas/GenealogyBootstrapFailedStatus' '#/components/schemas/GenealogyBootstrapPendingStatus' '#/components/schemas/GenealogyBootstrapSucceededStatus' ) | Sort-Object $mapping = $operationStatus.discriminator.mapping $mappingPairs = if ($mapping) { @($mapping.PSObject.Properties | ForEach-Object { "$($_.Name)=$($_.Value)" } | Sort-Object) } else { @() } $expectedMappingPairs = @( 'FAILED_NO_COMMIT=#/components/schemas/GenealogyBootstrapFailedStatus' 'PENDING=#/components/schemas/GenealogyBootstrapPendingStatus' 'SUCCEEDED=#/components/schemas/GenealogyBootstrapSucceededStatus' ) | Sort-Object if (-not (Test-IsJsonArray $operationStatus.oneOf) -or ($actualBranches -join ',') -ne ($expectedBranches -join ',') -or $operationStatus.discriminator.propertyName -ne 'status' -or ($mappingPairs -join ',') -ne ($expectedMappingPairs -join ',')) { Add-Issue 'JSON GenealogyBootstrapOperationStatus must use three status-discriminated oneOf branches' } $transitions = $operationStatus.'x-state-transitions' $transitionNames = if ($transitions) { @($transitions.PSObject.Properties.Name | Sort-Object) } else { @() } $expectedTransitionNames = @('ABSENT', 'FAILED_NO_COMMIT', 'PENDING', 'SUCCEEDED') | Sort-Object $absentRaw = if ($transitions) { $transitions.ABSENT } else { $null } $pendingRaw = if ($transitions) { $transitions.PENDING } else { $null } $succeededRaw = if ($transitions) { $transitions.SUCCEEDED } else { $null } $failedRaw = if ($transitions) { $transitions.FAILED_NO_COMMIT } else { $null } $absentTargets = @($absentRaw) $pendingTargets = @($pendingRaw | Sort-Object) $succeededTargets = @($succeededRaw) $failedTargets = @($failedRaw) if (($transitionNames -join ',') -ne ($expectedTransitionNames -join ',') -or -not (Test-IsJsonArray $absentRaw) -or -not (Test-IsJsonArray $pendingRaw) -or -not (Test-IsJsonArray $succeededRaw) -or -not (Test-IsJsonArray $failedRaw) -or ($absentTargets -join ',') -ne 'PENDING' -or ($pendingTargets -join ',') -ne 'FAILED_NO_COMMIT,SUCCEEDED' -or $succeededTargets.Count -ne 0 -or $failedTargets.Count -ne 0 -or -not (Test-IsJsonBoolean $operationStatus.'x-terminal-immutable' $true)) { Add-Issue 'JSON bootstrap state machine must allow only ABSENT->PENDING->SUCCEEDED/FAILED_NO_COMMIT and make both terminal states immutable' } } Assert-ExactObject $pendingStatus 'GenealogyBootstrapPendingStatus' @( 'status', 'resolveBy', 'retryAfterSeconds' ) @('status', 'resolveBy', 'retryAfterSeconds') if ($pendingStatus) { if ($pendingStatus.properties.status.type -ne 'string' -or -not (Test-IsJsonArray $pendingStatus.properties.status.enum) -or @($pendingStatus.properties.status.enum).Count -ne 1 -or ($pendingStatus.properties.status.enum -join ',') -ne 'PENDING' -or $pendingStatus.properties.resolveBy.type -ne 'string' -or $pendingStatus.properties.resolveBy.format -ne 'date-time' -or $pendingStatus.properties.retryAfterSeconds.type -ne 'integer' -or [int]$pendingStatus.properties.retryAfterSeconds.minimum -ne 1 -or [int]$pendingStatus.properties.retryAfterSeconds.maximum -ne 30) { Add-Issue 'JSON GenealogyBootstrapPendingStatus must require PENDING, RFC3339 resolveBy, and retryAfterSeconds=1..30' } if ([string]$pendingStatus.description -notmatch '(?i)resolveBy\s*<=\s*claimedAt\s*\+\s*2 minutes') { Add-Issue 'JSON GenealogyBootstrapPendingStatus must bind resolveBy to a maximum two-minute claim SLA' } if ([int]$pendingStatus.'x-resolve-sla-seconds' -ne 120 -or [string]$pendingStatus.'x-resolve-from' -ne 'CLAIMED_AT') { Add-Issue 'JSON GenealogyBootstrapPendingStatus must machine-bind resolveBy to claimedAt plus at most 120 seconds' } } Assert-ExactObject $succeededStatus 'GenealogyBootstrapSucceededStatus' @('status', 'result') @('status', 'result') if ($succeededStatus) { if ($succeededStatus.properties.status.type -ne 'string' -or -not (Test-IsJsonArray $succeededStatus.properties.status.enum) -or @($succeededStatus.properties.status.enum).Count -ne 1 -or ($succeededStatus.properties.status.enum -join ',') -ne 'SUCCEEDED' -or [string]$succeededStatus.properties.result.'$ref' -ne '#/components/schemas/GenealogyBootstrapResult') { Add-Issue 'JSON GenealogyBootstrapSucceededStatus must require SUCCEEDED plus the strict receipt' } } Assert-ExactObject $failedStatus 'GenealogyBootstrapFailedStatus' @('status') @('status') if ($failedStatus -and ($failedStatus.properties.status.type -ne 'string' -or -not (Test-IsJsonArray $failedStatus.properties.status.enum) -or @($failedStatus.properties.status.enum).Count -ne 1 -or ($failedStatus.properties.status.enum -join ',') -ne 'FAILED_NO_COMMIT')) { Add-Issue 'JSON GenealogyBootstrapFailedStatus must contain only FAILED_NO_COMMIT' } if ($failedStatus -and ([string]$failedStatus.'x-domain-effects' -ne 'NONE' -or -not (Test-IsJsonBoolean $failedStatus.'x-quota-consumed' $false))) { Add-Issue 'JSON FAILED_NO_COMMIT must machine-guarantee zero domain effects and zero quota consumption' } foreach ($envelopeEntry in @( [pscustomobject]@{ Schema = $resultEnvelope; Name = 'RAppGenealogyBootstrapResult'; DataRef = '#/components/schemas/GenealogyBootstrapResult' }, [pscustomobject]@{ Schema = $statusEnvelope; Name = 'RAppGenealogyBootstrapOperationStatus'; DataRef = '#/components/schemas/GenealogyBootstrapOperationStatus' } )) { $schema = $envelopeEntry.Schema if (-not $schema) { continue } Assert-ExactObject $schema $envelopeEntry.Name @('code', 'data') @('code', 'data') if ($schema.properties.code.type -ne 'integer' -or -not (Test-IsJsonArray $schema.properties.code.enum) -or @($schema.properties.code.enum).Count -ne 1 -or @($schema.properties.code.enum)[0] -ne 200 -or [string]$schema.properties.data.'$ref' -ne $envelopeEntry.DataRef) { Add-Issue "JSON $($envelopeEntry.Name) must expose fixed code=200 and typed data only" } } Assert-ErrorSchema 'RGenealogyBootstrapBadRequest' 400 @('OPERATION_KEY_INVALID', 'REQUEST_MALFORMED') Assert-ErrorSchema 'RGenealogyBootstrapStatusBadRequest' 400 @('OPERATION_KEY_INVALID') Assert-ErrorSchema 'RGenealogyBootstrapUnauthorized' 401 @('AUTH_REQUIRED') Assert-ErrorSchema 'RGenealogyBootstrapForbidden' 403 @('GENEALOGY_CREATE_FORBIDDEN') Assert-ErrorSchema 'RGenealogyBootstrapConflict' 409 @( 'GENEALOGY_CREATE_LIMIT_REACHED', 'IDEMPOTENCY_KEY_REUSED', 'OPERATION_KEY_EXPIRED' ) Assert-ErrorSchema 'RGenealogyBootstrapUnprocessable' 422 @('ACCESS_PRESET_INVALID', 'REGION_NOT_SELECTABLE', 'ROOT_PERSON_INVALID') Assert-ErrorSchema 'RGenealogyBootstrapRateLimited' 429 @('RATE_LIMITED') Assert-ErrorSchema 'RGenealogyBootstrapOutcomeUnknown' 500 @('BOOTSTRAP_OUTCOME_UNKNOWN') Assert-ErrorSchema 'RGenealogyBootstrapStatusUnavailable' 500 @('BOOTSTRAP_STATUS_UNAVAILABLE') Assert-ErrorSchema 'RLineageRootConflict' 409 @('GENEALOGY_NOT_READY') Assert-ErrorSchema 'RLineageRootUnprocessable' 422 @('BOOTSTRAP_ROOT_IMMUTABLE') $operationNotFound = Get-Schema 'RGenealogyBootstrapOperationNotFound' Assert-ExactObject $operationNotFound 'RGenealogyBootstrapOperationNotFound' @( 'code', 'businessCode', 'acceptUntil' ) @('code', 'businessCode', 'acceptUntil') if ($operationNotFound) { if ($operationNotFound.properties.code.type -ne 'integer' -or -not (Test-IsJsonArray $operationNotFound.properties.code.enum) -or @($operationNotFound.properties.code.enum).Count -ne 1 -or @($operationNotFound.properties.code.enum)[0] -ne 404 -or $operationNotFound.properties.businessCode.type -ne 'string' -or -not (Test-IsJsonArray $operationNotFound.properties.businessCode.enum) -or @($operationNotFound.properties.businessCode.enum).Count -ne 1 -or ($operationNotFound.properties.businessCode.enum -join ',') -ne 'BOOTSTRAP_OPERATION_NOT_AVAILABLE' -or $operationNotFound.properties.acceptUntil.type -ne 'string' -or $operationNotFound.properties.acceptUntil.format -ne 'date-time') { Add-Issue 'JSON RGenealogyBootstrapOperationNotFound must expose the non-secret server acceptUntil boundary' } } if ($regionOperation) { $keyword = Get-Parameter $regionOperation 'keyword' 'query' $regionSearchPath if ($keyword -and (-not (Test-IsJsonBoolean $keyword.required $true) -or $keyword.schema.type -ne 'string' -or [int]$keyword.schema.minLength -ne 1 -or [int]$keyword.schema.maxLength -lt 1 -or [int]$keyword.schema.maxLength -gt 50)) { Add-Issue 'JSON region search keyword must be a bounded required string' } $region200 = Get-Response $regionOperation "GET $regionSearchPath" '200' if ((Get-JsonResponseRef $region200 "GET $regionSearchPath" '200') -ne '#/components/schemas/RListRegionSelectVo') { Add-Issue 'JSON region search 200 must return RListRegionSelectVo' } Assert-AllDeclaredResponsesPrivateJson $regionOperation "GET $regionSearchPath" } if ($mineOperation) { $mine200 = Get-Response $mineOperation "GET $minePath" '200' if ((Get-JsonResponseRef $mine200 "GET $minePath" '200') -ne '#/components/schemas/RListAppGenealogyVo') { Add-Issue 'JSON mine read must return RListAppGenealogyVo so accessPreset reaches the actual list consumer' } Assert-AllDeclaredResponsesPrivateJson $mineOperation "GET $minePath" } if ($overviewOperation) { $overview200 = Get-Response $overviewOperation "GET $overviewPath" '200' if ((Get-JsonResponseRef $overview200 "GET $overviewPath" '200') -ne '#/components/schemas/RAppGenealogyVo') { Add-Issue 'JSON overview read must return RAppGenealogyVo so accessPreset reaches the actual detail consumer' } Assert-AllDeclaredResponsesPrivateJson $overviewOperation "GET $overviewPath" } $listGenealogyEnvelope = $document.components.schemas.PSObject.Properties['RListAppGenealogyVo'] $objectGenealogyEnvelope = $document.components.schemas.PSObject.Properties['RAppGenealogyVo'] if (-not $listGenealogyEnvelope) { Add-Issue 'JSON missing RListAppGenealogyVo for the actual mine read' } elseif ( [string]$listGenealogyEnvelope.Value.properties.data.items.'$ref' -ne '#/components/schemas/AppGenealogyVo') { Add-Issue 'JSON RListAppGenealogyVo.data items must reference AppGenealogyVo' } if (-not $objectGenealogyEnvelope) { Add-Issue 'JSON missing RAppGenealogyVo for the actual overview read' } elseif ( [string]$objectGenealogyEnvelope.Value.properties.data.'$ref' -ne '#/components/schemas/AppGenealogyVo') { Add-Issue 'JSON RAppGenealogyVo.data must reference AppGenealogyVo' } if ($regionItem) { foreach ($required in @('regionCode', 'label', 'selectable')) { if ($required -notin @($regionItem.required)) { Add-Issue "JSON RegionSelectVo.required missing: $required" } } if ([string]$regionItem.properties.regionCode.'$ref' -ne '#/components/schemas/GenealogyRegionCode') { Add-Issue 'JSON RegionSelectVo.regionCode must use GenealogyRegionCode' } if ($regionItem.properties.label.type -ne 'string' -or [int]$regionItem.properties.label.minLength -lt 1) { Add-Issue 'JSON RegionSelectVo.label must be a non-empty string' } if ($regionItem.properties.selectable.type -ne 'boolean') { Add-Issue 'JSON RegionSelectVo.selectable must be boolean and must not be inferred from leaf' } } if ($regionEnvelope) { foreach ($required in @('code', 'data')) { if ($required -notin @($regionEnvelope.required)) { Add-Issue "JSON RListRegionSelectVo.required missing: $required" } } if ($regionEnvelope.properties.code.type -ne 'integer' -or $regionEnvelope.properties.data.type -ne 'array' -or [string]$regionEnvelope.properties.data.items.'$ref' -ne '#/components/schemas/RegionSelectVo') { Add-Issue 'JSON RListRegionSelectVo must expose integer code and RegionSelectVo[] data' } } $createResponses = @{} Assert-ExactResponseSet $createOperation "POST $createPath" @('200', '400', '401', '403', '409', '422', '429', '500') foreach ($status in @('200', '400', '401', '403', '409', '422', '429', '500')) { $createResponses[$status] = Get-Response $createOperation "POST $createPath" $status [void](Get-JsonResponseRef $createResponses[$status] "POST $createPath" $status) Assert-PrivateNoStore $createResponses[$status] "POST $createPath" $status } if ((Get-JsonResponseRef $createResponses['200'] "POST $createPath" '200') -ne '#/components/schemas/RAppGenealogyBootstrapResult') { Add-Issue 'JSON create 200 must return RAppGenealogyBootstrapResult' } $createErrorRefs = @{ '400' = '#/components/schemas/RGenealogyBootstrapBadRequest' '401' = '#/components/schemas/RGenealogyBootstrapUnauthorized' '403' = '#/components/schemas/RGenealogyBootstrapForbidden' '409' = '#/components/schemas/RGenealogyBootstrapConflict' '422' = '#/components/schemas/RGenealogyBootstrapUnprocessable' '429' = '#/components/schemas/RGenealogyBootstrapRateLimited' '500' = '#/components/schemas/RGenealogyBootstrapOutcomeUnknown' } foreach ($status in $createErrorRefs.Keys) { $actualRef = Get-JsonResponseRef $createResponses[$status] "POST $createPath" $status if ($actualRef -ne $createErrorRefs[$status]) { Add-Issue "JSON create $status must return $($createErrorRefs[$status]); actual: $actualRef" } } Assert-RetryAfter $createResponses['429'] "POST $createPath" '429' $statusResponses = @{} Assert-ExactResponseSet $statusOperation "GET $statusPath" @('200', '400', '401', '404', '429', '500') foreach ($status in @('200', '400', '401', '404', '429', '500')) { $statusResponses[$status] = Get-Response $statusOperation "GET $statusPath" $status [void](Get-JsonResponseRef $statusResponses[$status] "GET $statusPath" $status) Assert-PrivateNoStore $statusResponses[$status] "GET $statusPath" $status } if ((Get-JsonResponseRef $statusResponses['200'] "GET $statusPath" '200') -ne '#/components/schemas/RAppGenealogyBootstrapOperationStatus') { Add-Issue 'JSON bootstrap status 200 must return RAppGenealogyBootstrapOperationStatus' } $statusErrorRefs = @{ '400' = '#/components/schemas/RGenealogyBootstrapStatusBadRequest' '401' = '#/components/schemas/RGenealogyBootstrapUnauthorized' '404' = '#/components/schemas/RGenealogyBootstrapOperationNotFound' '429' = '#/components/schemas/RGenealogyBootstrapRateLimited' '500' = '#/components/schemas/RGenealogyBootstrapStatusUnavailable' } foreach ($status in $statusErrorRefs.Keys) { $actualRef = Get-JsonResponseRef $statusResponses[$status] "GET $statusPath" $status if ($actualRef -ne $statusErrorRefs[$status]) { Add-Issue "JSON bootstrap status $status must return $($statusErrorRefs[$status]); actual: $actualRef" } } Assert-RetryAfter $statusResponses['404'] "GET $statusPath" '404' Assert-RetryAfter $statusResponses['429'] "GET $statusPath" '429' Assert-AllDeclaredResponsesPrivateJson $personOperation "POST $personPath" Assert-AllDeclaredResponsesPrivateJson $personUpdateOperation "PUT $personDetailPath" Assert-AllDeclaredResponsesPrivateJson $personDeleteOperation "DELETE $personDetailPath" Assert-AllDeclaredResponsesPrivateJson $personParentsOperation "POST $personParentsPath" if ($issues.Count -gt 0) { $lines = New-Object System.Collections.Generic.List[string] $lines.Add('G03-BOOTSTRAP-OPENAPI-CONTRACT BLOCKED') foreach ($issue in $issues) { $lines.Add("- $issue") } $lines.Add('- Replace the existing create body with one closed atomic bootstrap body; the first in-page step performs no network write.') $lines.Add('- One transaction must create the genealogy, OWNER membership, unique generation-one root, READY state, and idempotency receipt, or roll everything back.') $lines.Add('- Use one GenealogyAccessPreset across APP read/create/update and remove visibility/joinMode in the same backend version; do not keep parallel mappings.') $lines.Add('- Persist only operationKey/sessionEpoch/startedAt on device; use the authenticated status operation after process death instead of storing ancestor PII or guessing from /mine.') $lines.Add('- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.') throw ($lines -join [Environment]::NewLine) } Write-Output 'G03-BOOTSTRAP-OPENAPI-CONTRACT PASS'