完成50%

This commit is contained in:
2026-07-23 08:23:59 +08:00
parent 9b0ad62df4
commit f1edc6b533
218 changed files with 24318 additions and 5514 deletions
+988
View File
@@ -0,0 +1,988 @@
$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}'
$settingsPath = '/genealogy/app/genealogies/{genealogyId}'
$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
}
$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"
} elseif ([string]$media.Value.schema.'$ref' -ne $ExpectedRef) {
Add-Issue "JSON $Label request body must use $ExpectedRef"
}
}
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-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"
}
}
function Assert-PropertyRef {
param([object]$Schema, [string]$SchemaName, [string]$Field, [string]$ExpectedRef)
if (-not $Schema) { return }
$property = $Schema.properties.PSObject.Properties[$Field]
$actual = if ($property) { [string]$property.Value.'$ref' } else { '' }
if ($actual -ne $ExpectedRef) {
Add-Issue "JSON $SchemaName.$Field must use $ExpectedRef; actual: $actual"
}
}
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'
$settingsOperation = Get-Operation $settingsPath 'put'
$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 = $settingsOperation; Label = "PUT $settingsPath"; Path = $settingsPath },
[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'
Assert-RequestBodyRef $settingsOperation "PUT $settingsPath" '#/components/schemas/AppGenealogySettingsUpdateBody'
$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'
$settingsBody = Get-Schema 'AppGenealogySettingsUpdateBody'
$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-StringField $bootstrapBody 'AppGenealogyBootstrapBody' 'genealogyName' 1 24
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
if ($accessPreset.type -ne 'string' -or -not (Test-IsJsonArray $accessPreset.enum) -or
($values -join ',') -ne ($expected -join ',')) {
Add-Issue 'JSON GenealogyAccessPreset must contain only MEMBER_ONLY/PUBLIC_APPLY'
}
}
Assert-ExactObject $settingsBody 'AppGenealogySettingsUpdateBody' @(
'genealogyName', 'intro', 'accessPreset'
) @() 1 3
Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'accessPreset' '#/components/schemas/GenealogyAccessPreset'
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' @{})) {
if ([string]$presetDefinition.'$ref' -ne '#/components/schemas/GenealogyAccessPreset') {
Add-Issue "JSON $($schemaProperty.Name).accessPreset must reference GenealogyAccessPreset"
}
}
}
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 $settingsOperation "PUT $settingsPath"
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'