diff --git a/CHANGELOG.md b/CHANGELOG.md
index b8a476d..e39b92b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Fixed
+
+- A complexContent restriction may no longer relax a required base attribute (XSD 1.0 `cos-ct-derived-ok` / `derivation-ok-restriction.2`, #170). When a restriction redeclares an attribute that the base type marks `use="required"`, the redeclaration may not make it optional or prohibited; such schemas were accepted. Only attributes the restriction explicitly redeclares that also exist in the base are checked (an omitted attribute is inherited unchanged); the base reference must resolve to the schema's own target namespace, and the check stands down for a schema that composes other documents. XSTS: invalid schemas accepted 394 to 390, no other bucket raised. Disclosed under-rejection: the matching fixed-value clause (a base attribute fixed to a value must keep that value) is not enforced, because it requires comparing fixed values in the attribute type's value space rather than lexically.
+
## [0.1.0] - 2026-06-15
First tagged pre-release. A pure-Swift XML parser/emitter with an XSD 1.0 schema
diff --git a/Sources/Schema/SchemaAttributeRestriction.swift b/Sources/Schema/SchemaAttributeRestriction.swift
new file mode 100644
index 0000000..c702d98
--- /dev/null
+++ b/Sources/Schema/SchemaAttributeRestriction.swift
@@ -0,0 +1,52 @@
+private typealias AttrRestrictNode = PureXML.Schema.XSDNode
+
+extension PureXML.Schema.XSDParser {
+ /// XSD 1.0 Derivation Valid (Restriction, Complex) for attribute uses
+ /// (`cos-ct-derived-ok` / `derivation-ok-restriction.2`): when a complex type
+ /// restricts a base, an attribute the restriction redeclares may not relax the
+ /// base's corresponding use. A base `required` attribute must stay required: it
+ /// may not become optional or prohibited. Such schemas were accepted.
+ ///
+ /// Only attributes the restriction explicitly declares that also exist in the
+ /// base are checked; an attribute the restriction omits is inherited unchanged
+ /// (valid), and an attribute with no base counterpart is a separate rule, left
+ /// alone. Checked for a self-contained schema (no `import`/`include`/`redefine`),
+ /// where the base resolves locally and attribute names are unambiguous; the base
+ /// reference must resolve to this schema's own target namespace.
+ ///
+ /// Disclosed under-rejection: the matching fixed-value clause (a base attribute
+ /// fixed to a value must keep that value) is not enforced here. It requires
+ /// comparing the two fixed values in the attribute type's value space, not
+ /// lexically (e.g. a list type's `"1 2 3"` and `"1 2 3"` are the same value),
+ /// which a string comparison gets wrong; that clause is left for a later change.
+ static func attributeRestrictionErrors(_ schema: XSDTree, _ context: PureXML.Schema.XSDContext, _ types: [String: PureXML.Schema.ElementType]) -> [String] {
+ guard !hasExternalReference(schema) else { return [] }
+ let bindings = context.namespaceBindings
+ var errors: [String] = []
+ for content in descendants(schema, named: "complexContent") {
+ guard let restriction = AttrRestrictNode.firstChild(content, named: "restriction"),
+ let base = AttrRestrictNode.attribute(restriction, "base"),
+ AttrRestrictNode.referenceNamespace(base, bindings) == context.targetNamespace,
+ case let .complex(complex)? = types[AttrRestrictNode.stripPrefix(base)]
+ else { continue }
+ for derived in attributeUses(under: restriction, context) {
+ if let message = attributeRestrictionViolation(derived, complex.attributes) {
+ errors.append(message)
+ }
+ }
+ }
+ return errors
+ }
+
+ /// The way `derived` (an attribute the restriction redeclares) illegally relaxes
+ /// its base counterpart, or nil when it is a valid restriction of it (or has no
+ /// base counterpart). A base `required` attribute may not be made optional or
+ /// prohibited (a prohibited use has `required == false`).
+ private static func attributeRestrictionViolation(_ derived: PureXML.Schema.AttributeUse, _ baseAttributes: [PureXML.Schema.AttributeUse]) -> String? {
+ guard let base = baseAttributes.first(where: { $0.name == derived.name }) else { return nil }
+ if base.required, !derived.required {
+ return "attribute '\(derived.name.localName)' is required in the base type and a restriction may not make it optional or prohibited"
+ }
+ return nil
+ }
+}
diff --git a/Sources/Schema/SchemaIDAttribute.swift b/Sources/Schema/SchemaIDAttribute.swift
index 9362b7b..26ef0c3 100644
--- a/Sources/Schema/SchemaIDAttribute.swift
+++ b/Sources/Schema/SchemaIDAttribute.swift
@@ -72,5 +72,6 @@ extension PureXML.Schema.XSDParser {
+ substitutionTypeErrors(schema, derivation, typeMaps.named)
+ userTypeValueConstraintErrors(schema, context, typeMaps.named)
+ extensionAllGroupErrors(schema, context, typeMaps.named)
+ + attributeRestrictionErrors(schema, context, typeMaps.named)
}
}
diff --git a/Tests/SchemaAttributeRestrictionTests.swift b/Tests/SchemaAttributeRestrictionTests.swift
new file mode 100644
index 0000000..8086503
--- /dev/null
+++ b/Tests/SchemaAttributeRestrictionTests.swift
@@ -0,0 +1,60 @@
+@testable import PureXML
+import Testing
+
+@Suite("XSD attribute-use restriction (cos-ct-derived-ok)")
+struct SchemaAttributeRestrictionTests {
+ private let xsd = "xmlns:xs=\"http://www.w3.org/2001/XMLSchema\""
+
+ private func compiles(_ body: String) -> Bool {
+ (try? PureXML.Schema.Document("\(body)")) != nil
+ }
+
+ private func derive(base: String, restricted: String) -> String {
+ "\(base)"
+ + ""
+ + "\(restricted)"
+ }
+
+ @Test("A restriction may not relax a required attribute")
+ func test_requiredRelaxationRejected() {
+ let req = ""
+ // required -> optional (explicit)
+ #expect(!compiles(derive(base: req, restricted: "")))
+ // required -> optional (default use)
+ #expect(!compiles(derive(base: req, restricted: "")))
+ // required -> prohibited
+ #expect(!compiles(derive(base: req, restricted: "")))
+ }
+
+ @Test("A faithful or tightening attribute restriction compiles")
+ func test_validRestrictionsAccepted() {
+ // required kept required
+ let req = ""
+ #expect(compiles(derive(base: req, restricted: req)))
+ // fixed kept identical
+ let fixed = ""
+ #expect(compiles(derive(base: fixed, restricted: "")))
+ // optional base may be made prohibited or stay optional
+ let opt = ""
+ #expect(compiles(derive(base: opt, restricted: "")))
+ // an attribute the restriction does not redeclare is inherited unchanged
+ #expect(compiles(derive(base: req, restricted: "")))
+ // an optional base attribute that is also fixed may be prohibited (the
+ // prohibited use removes it, so the fixed clause does not apply).
+ #expect(compiles(derive(base: fixed, restricted: "")))
+ }
+
+ /// The fixed-value clause is a disclosed under-rejection (it needs value-space,
+ /// not lexical, comparison). A list-typed fixed value that differs only in
+ /// whitespace is the same value and must compile; and, for now, even a genuinely
+ /// changed fixed value is accepted rather than risk a lexical false positive.
+ @Test("Fixed-value differences on a restricted attribute are not (yet) rejected")
+ func test_fixedClauseDeferred() {
+ let listBase = ""
+ + ""
+ let derived = ""
+ + ""
+ // Same list value, different whitespace: valid, must compile.
+ #expect(compiles(listBase + derived))
+ }
+}
diff --git a/Tests/XSTSSuiteTests.swift b/Tests/XSTSSuiteTests.swift
index 8e58387..2769056 100644
--- a/Tests/XSTSSuiteTests.swift
+++ b/Tests/XSTSSuiteTests.swift
@@ -29,7 +29,7 @@ struct XSTSSuiteTests {
/// XSTS` (debug is far slower and the corpus is large). Per-case deviations
/// are written to /tmp/xsts-failures.txt for the burn-down.
private let knownSchemaValidRejected = 1
- private let knownSchemaInvalidAccepted = 394
+ private let knownSchemaInvalidAccepted = 390
private let knownInstanceValidRejected = 180
private let knownInstanceInvalidAccepted = 158
diff --git a/docs/schema-validity-burndown.md b/docs/schema-validity-burndown.md
index 70bd6cc..2bdeb55 100644
--- a/docs/schema-validity-burndown.md
+++ b/docs/schema-validity-burndown.md
@@ -10,7 +10,7 @@ irreducible tail. Do not deviate to new features while this is open.
| Bucket | Count | Reading |
|---|---|---|
| valid-schemas-rejected | **72 -> 4** | production target is 0: rejecting a correct schema is the most user-hostile defect (#148). Particle-restriction over-rejection fixed: pointless `maxOccurs=0` particles removed, content-free restriction of an EMPTY base, empty-derived emptiable-base, and (#163) the pairwise check over the normalized content model with occurrence subsumption applied only to same-kind pairings (cross-kind element/group, group/element, group/wildcard match occurrence inside, by effective total range / member). #164 resolved element-ref namespaces by prefix binding (the `particlesJj/Jk/Jm/Jn/Js`, `particlesQ/R` cluster, -28). Content-free (`maxOccurs=0`) derived groups and substitution-group declaration order closed `particlesW006`/`elemZ027_a`; the W3C MapAndSum rule (Sequence:Choice, #165) closed `particlesV003`. Remaining 1 (`particlesZ001`): a documented spec ambiguity (RecurseAsIfGroup; the reference rejects it), not a defect. |
-| **invalid-schemas-accepted** | **2461 -> 394** | we rarely catch a bad one (facet, default/fixed value, particle-restriction name+type, id, structural content-model, UPA determinism, content order, identity-constraint XPath-subset, wildcard-namespace, final/block, simpleType content, group/wildcard and group/element cardinality, element-ref namespace, sequence/choice MapAndSum, circular type derivation, circular group/attributeGroup/substitution references, attribute-use uniqueness and single-ID, type-excludes-inline-type, ID-typed value-constraint exclusion, all-group reference placement, substitution-member type derivation, default/fixed mutual exclusion landed). |
+| **invalid-schemas-accepted** | **2461 -> 390** | we rarely catch a bad one (facet, default/fixed value, particle-restriction name+type, id, structural content-model, UPA determinism, content order, identity-constraint XPath-subset, wildcard-namespace, final/block, simpleType content, group/wildcard and group/element cardinality, element-ref namespace, sequence/choice MapAndSum, circular type derivation, circular group/attributeGroup/substitution references, attribute-use uniqueness and single-ID, type-excludes-inline-type, ID-typed value-constraint exclusion, all-group reference placement, substitution-member type derivation, default/fixed mutual exclusion landed). |
| valid-instances-rejected | **233 -> 180** | (instance side; #164's element-ref namespace fix is shared with instance validation, -34) |
| invalid-instances-accepted | **160 -> 158** | (instance side; resolving an inline `` restriction base preserves list/union variety, so a `length` facet counts items and two more invalid instances are caught) |