SecaniDocumentation
OSCAL

Validator

How the Secani OSCAL validator proves source-verified validation beyond the Java reference implementation – completeness closure, differential receipts, and honest limits.

Private preparation: The validator described here ships inside the secani/oscal toolkit, which remains private while it is prepared for open source. All counts, quotes, and locators below are taken from the machine-readable registry in the toolkit's validation/ directory and are re-verifiable via pnpm verify.

Summary

Secani's OSCAL toolkit validates NIST OSCAL documents against what the pinned NIST sources actually state – not against what any reference implementation happens to do. It reaches full capability parity with the Java OSCAL CLI 3.2.0 surface, and then goes past it in four ways the Java stack does not attempt:

  1. A machine-checked completeness proof: every one of the 348 constraint occurrences discoverable in the eight OSCAL 1.2.2 root Metaschemas is individually reconciled – proven enforced, proven schema-covered, or explicitly excluded with a written rationale. Zero are unaccounted for.
  2. Native OSCAL 1.2.2 semantics with multi-release support (1.1.2, 1.1.3, 1.2.1, 1.2.2), where Java CLI 3.2.0 embeds OSCAL 1.2.1 bindings and can process 1.2.2 documents only on a compatibility basis.
  3. Cross-document workflow validation – resolving and checking the reference graph across SSPs, assessment plans, assessment results, and POA&Ms – which is outside the Java CLI's validation surface entirely.
  4. One TypeScript codebase that runs everywhere the documents live: the same precompiled validators execute in the browser, in serverless API routes, in the CLI, and in the backend – no JVM anywhere.

The process also surfaced defects in the NIST sources themselves (see Finding bugs – in both directions), which is what you would expect from – and the strongest evidence for – occurrence-level verification.

The authority model: sources over implementations

The project's binding scope rule:

NIST OSCAL source is the authority. Java is a compatibility comparator and implementation evidence, never the authority.

Concretely, every semantic claim is pinned to bytes:

  • Authority: usnistgov/OSCAL at tag v1.2.2, commit 21403b4ad2f162ef1201e5ee70e8b93f254f51ac – the eight root Metaschema modules (catalog, profile, component-definition, SSP, assessment-plan, assessment-results, POA&M, mapping) plus their common imports (metadata, control-common, implementation-common, assessment-common, mapping-common) and external constraint entities. All are cached byte-exact with SHA-256 pins.
  • Profile resolution: the NIST draft specification src/specifications/profile-resolution/profile-resolution-specml.xml. Because it is draft/WIP, its requirements are implemented behind an explicit capability and never silently folded into ordinary validation.
  • Comparator: Java OSCAL CLI 3.2.0 with liboscal-java 7.2.0, which embeds OSCAL 1.2.1 bindings. Comparator evidence is recorded with occurrence-exact locators; references without cached bytes and exact locators are labeled unqualified source inspection and cannot close a provenance gate.

This is the inversion that matters: most tooling treats the reference implementation as ground truth. We treat the standard's sources as ground truth and treat the reference implementation as a witness to be cross-examined.

Example – what a locator-pinned claim looks like. The registry doesn't say "we support responsible-party role checks." It says: constraint oscal-metadata-responsible-party-role-ids, source oscal_metadata_metaschema.xml@L400, source file SHA-256 pinned, enforced by rule META-002.a, proven by a named test whose AST hash is recorded. If NIST changes that line, the pin breaks and the claim must be re-qualified.

The completeness closure: accounting for every constraint

The Metaschema sources define constraints (allowed-values, is-unique, matches, index, index-has-key, has-cardinality, expect) scattered across thousands of lines of XML. A validator can claim to "support OSCAL constraints" without anyone being able to check what that means. We made it checkable.

The completeness closure lexically inventories 348 constraint occurrences across the pinned module graph – including 17 anonymous occurrences and 8 occurrences retained inside source comments, because deciding they don't count is itself a reviewable decision. Occurrence identity includes source path and line, so repeated constraint IDs never collapse.

Every occurrence must land in exactly one closed evidence bucket:

BucketCountMeaning
assertion303Proven enforced at runtime by an occurrence-exact test
schema-enforced1Proven covered by the release JSON Schema
excluded-with-rationale44Explicitly excluded, each naming its concrete reason
unresolved0

Example – what "occurrence-exact evidence" means in code. Each proven occurrence has a declaration like this in the test suite:

closureEvidenceTest({
  assertionIds: ["META-012.b"],
  name: "allowed-values assessment-common oscal-assessment-objective-types@L65 occurrence",
  releases: [],
  subject: "evaluateBuiltinAllowedValues",
}, () => {
  const evaluation = evaluateBuiltinAllowedValues({
    "assessment-results": {
      "local-definitions": {
        "objectives-and-methods": [
          { "parts": [{ "name": "assessment" }] }
        ]
      }
    },
  }, "assessment-results");
  expect(evaluation).toMatchObject({
    evaluatedInventoryIds: expect.arrayContaining([
      "oscal_assessment-common_metaschema.xml#oscal-assessment-objective-types@L65",
    ]),
  });
});

The test drives the real evaluator over a minimal document and asserts that the exact source occurrence – file, constraint id, line – was evaluated. The evidence format is deliberately hard to fake: the declaration's TypeScript AST is verified (the declared subject must execute exactly once; its result must flow into exactly one top-level expect), and the declaration, its file, and the SHA-256 of its transitive import closure are pinned. Change the evaluator and every dependent evidence hash goes stale until mechanically re-derived. A renamed test, a no-op body, or a mutated helper invalidates the evidence automatically.

The 44 exclusions are not hand-waving: 5 occurrences sit inside XML comments in the NIST sources (dead text), 2 are upstream defects (see below), 3 are documented model-boundary items, and 34 are active constraints that are enforced at runtime by default but fall outside the research registry's locked 93-assertion inventory – each exclusion names the generated evaluator that enforces it.

With unresolved = 0, the artifact flips exhaustiveRequirementClaimAllowed: true – a flag the generator computes from the bucket counts, not a sentence a human writes.

Requirements registry: 40 of 42, with honest holds

Above the occurrence level sits a registry of 42 research requirements (93 atomic assertions) spanning schema routing, metadata semantics, catalog/profile rules, profile resolution, cross-document resolution, and workflow validation. 40 are implemented; each flip required a named runtime path plus real positive/negative tests. The two open items are deliberate, documented holds:

  • PRES-004 (import identifier mappings): the draft spec says mapping has five optional subsections but only defines one concretely (mapping[].controls[{from,to}]), and the pinned 1.2.2 profile Metaschema defines no mapping on import at all. We implement the example-backed shape and fail closed on everything else:

    $ oscal-cli resolve-profile --to JSON profile-with-param-mapping.json
    oscal-cli: unsupported import mapping subsection 'params' in pinned draft

    Claiming full mapping support would mean inventing semantics the source does not contain.

  • META-013 (equivalent alternate resource representations): the source requires multiple rlink/base64 representations of a resource to "contain equivalent information" without operationally defining equivalence (byte equality? semantic equality after format conversion?). Enforcement stays off pending a definition, rather than inventing one.

What the validator does that Java CLI 3.2.0 does not

Native 1.2.2 semantics, multi-release routing

Java CLI 3.2.0 embeds liboscal-java 7.2.0 with OSCAL 1.2.1 bindings; an unchanged run over a 1.2.2 document is observational compatibility, not 1.2.2 validation. Secani ships version-aware schema routing and precompiled validators for 1.1.2, 1.1.3, 1.2.1, and 1.2.2, with 1.2.2 as the semantic implementation target – and fails closed on releases it has not qualified instead of silently validating against the wrong generation.

The generated semantic constraint layer

The ordinary (default-on) semantic layer evaluates, per document, the pinned constraint inventory: all 200 active allowed-values occurrences (including descendant-axis targets and flag-addressed targets like action/@type), all 27 scoped is-unique occurrences, 24 of 25 active matches occurrences (the 25th is enforced by an owning metadata rule), the generated expect, has-cardinality, and file-local index/index-has-key evaluators, and the metadata/catalog/profile/SSP semantic rule families. Every diagnostic carries its NIST provenance. This is the verbatim runtime output for a catalog whose metadata action declares "type": "invented-type":

{
  "ruleId": "oscal-metadata-action-type-values",
  "sourceId": "https://raw.githubusercontent.com/usnistgov/OSCAL/21403b4ad2f162ef1201e5ee70e8b93f254f51ac/src/metaschema/oscal_metadata_metaschema.xml#L877",
  "sourceType": "oscal-constraint",
  "authority": "nist-metaschema",
  "severity": "error",
  "phase": "semantic",
  "path": "/catalog/metadata/actions/0/type",
  "message": "value \"invented-type\" is not one of the allowed values: \"approval\", \"request-changes\"",
  "keyword": "allowed-values"
}

The sourceId is not a label – it is a clickable, commit-pinned pointer to the exact Metaschema line that mandates the rule. (This particular diagnostic is also a live differential: Java CLI 3.2.0 accepts this exact document – in JSON and XML – even though the constraint sits at L877 of the 1.2.1 metadata Metaschema its own bindings embed; see receipt R4 below.)

Full profile resolution per the draft specification

resolve-profile implements the NIST draft resolution pipeline end to end – and fails closed where the draft is undefined rather than guessing:

$ oscal-cli resolve-profile --to JSON baseline-profile.json resolved-catalog.json
$ oscal-cli resolve-profile --to JSON legacy-combine.json
oscal-cli: deprecated combine method 'merge' has undefined resolution behavior

$ oscal-cli resolve-profile --to JSON mixed-major.json
oscal-cli: major OSCAL version mismatch between '1.2.2' and '2.0.0' at 'file:///…/legacy.json'

Covered: import acquisition with cycle/depth/byte budgets, internal #uuid imports including base64-embedded documents, include/exclude selection with with-child-controls expansion, keep/use-first combine semantics, as-is/custom/flat structuring, deterministic parameter and alteration application, and back-matter merge with the draft's exact ordering rules (later duplicates replace at the later position; a keep=always resource blocks unmarked replacement).

Output finalization is where several subtle guarantees live: the declared oscal-version of the result is clamped to the highest supported same-major release; keys are emitted in canonical OSCAL order; and the resolved catalog is re-validated – schema and semantic constraints – before serialization, so an invalid resolution result is a refusal, not an output. The Java CLI does not perform that check on its own output: its resolver will write a catalog that its own validator then rejects (receipt R6 below).

Cross-document workflow validation (not in the Java surface)

This is the differentiator. OSCAL compliance artifacts form a graph – an assessment result imports an assessment plan, which imports an SSP, which imports a profile, which resolves against catalogs – and the model documentation states relationships no JSON Schema can check. The document-graph validator resolves those edges and verifies them.

Example. An SSP claims to implement control ac-99, but the baseline it imports never supplies that control. With the interpretive rules enabled, the graph validator resolves the baseline through the full profile-resolution pipeline down to its catalogs, computes the selected control set, and emits (verbatim issue shape):

{
  "ruleId": "SSP-003.b",
  "sourceId": "secani:oscal-ssp-import-profile-interpretation:v1",
  "sourceType": "validation-dispatch",
  "authority": "secani-policy",
  "severity": "warning",
  "phase": "resolution",
  "path": "/system-security-plan/control-implementation/implemented-requirements/1/control-id",
  "message": "implemented control 'ac-99' is not supplied by the resolved import-profile 'file:///…/baseline.json'",
  "keyword": "implemented-control-supplied"
}

Note the provenance discipline even here: the authority is secani-policy under a versioned interpretation source – not nist-metaschema – because this check is our documented interpretation, not NIST's machine constraint. From the CLI, the checks are reachable via oscal-cli validate --resolve-imports --interpretive SSP-003.b ssp.json (repeatable, or --interpretive all; --warnings-as-errors promotes them to a failing exit).

The full rule family, each pinned to its anchor in the sources:

RuleWhat it checksAnchor
SSP-003Every SSP implemented-requirement/control-id is supplied by the resolved import-profileresolved via the profile-resolution pipeline
XDOC-001AP import-ssp → an SSP; AR import-ap → an AP; POA&M import-ssp → an SSP; release gatingimport fields in the AP/AR/POA&M Metaschemas
ASMT-002related-observation/associated-risk/related-finding UUIDs resolve in their documented local scopeoscal_assessment-common_metaschema.xml @L863/@L877; oscal_poam_metaschema.xml @L135
ASMT-003Assessment tool component references resolve against assessment assets, including AR→AP asset visibilityorigin-actor @L1025-1039
ASMT-001Finding targets resolve through the AR→AP→SSP→profile→catalog chain, with target/@type agreementfinding-target @L735-755 + catalog part vocabulary @L316-341
POAM-001The documented system-context predicate – exactly as writtensee below

Example – implementing prose exactly. The POA&M Metaschema remark reads:

"Either an OSCAL-based SSP must be imported, or a unique system-id must be specified. Both may be present." – oscal_poam_metaschema.xml @L58-60

POAM-001.a warns only when neither is present. A test pins that supplying both stays silent – because inventing an exclusive-or the source does not state is exactly the kind of over-validation this project refuses to do.

Two design decisions matter as much as the checks:

  • Interpretive honesty. These relationships are documented in prose, not as machine constraints – revealingly, the assessment plan's own index-has-key for these UUIDs exists in the NIST source only as a commented-out "bogus example". So the rules are off by default, emit warnings, never flip a document to invalid, and run only when a caller names them explicitly.
  • Scope pinned before code. Each rule's registry note records, per reference field, the documented resolution scope and locator – and what was excluded by name. subject-uuid, for instance, is documented as resolving across "every file imported directly or indirectly" (@L652-654) – transitively cross-document – so it is explicitly out of scope rather than half-implemented.

CLI surface: parity plus

All 22 OSCAL-facing Java CLI capabilities are covered with observable acceptance contracts (command, options, input, success/failure output, exit codes): validate/convert across JSON/XML/YAML, resolve-profile, list-allowed-values, deterministic SARIF (--sarif-include-pass, --sarif-timing), --threads worker partitioning with canonical result order, metapath/xpath/jsonpointer path rendering, model command aliases, shell completion. On top: --prune, --interpretive, version-pinned schema exports, and the programmatic diagnostics API.

Why TypeScript

  1. One artifact, every runtime. The validators are Ajv standalone precompiled modules – plain generated JavaScript, no runtime schema compilation, no reflection, no Metaschema interpreter. The identical code path runs in the browser, in serverless API routes, in Node (CLI), and inside the backend. The Java stack would require a JVM at each of those locations, or a lossy port.

  2. The platform is where the documents live. Secani's product is a web platform; its validation API, authoring workbench, and MCP server consume the toolkit as an in-process, typed library:

    import { createDiagnosticsOscalProcessor } from "@secani/oscal/diagnostics";
    
    const processor = createDiagnosticsOscalProcessor({ maxIssues: 50 });
    const result = processor.validate(document);
    // OscalValidationResult:
    // { ok, complete, modelType, oscalVersion, layers,
    //   errors, issues, totalIssueCount, truncated }

    No subprocess IPC against a Java binary, no sidecar container, no cold-start JVM.

  3. Determinism as a feature. Precompiled validators + generated evaluators + canonical key ordering make outputs byte-stable – which is what allows the evidence system to pin hashes over behavior in the first place.

  4. Distribution. A typed package with dedicated entry points (., ./browser, ./versioned, ./diagnostics) reaches the ecosystem where compliance UIs are actually built.

  5. Type-safety against the model. Generated model maps make "this constraint targets a flag, not an element" a statically checkable property – with a guard test asserting no flag/element name collision exists across all 1,011 node shapes of the pinned model.

What TypeScript did not buy us is exemption from proof: the completeness closure exists precisely so the language choice is backed by occurrence-level evidence, not by claims about developer experience.

Finding bugs – in both directions

Occurrence-level verification found real defects on both sides, which is the strongest argument that the methodology works.

In our own evaluator (found by the closure gate). Two metadata constraints target flags (attributes) rather than child elements – the allowed values of action/@system and action/@type (oscal_metadata_metaschema.xml @L874, @L877). They compiled into the supported inventory but never fired: the evaluator's path walker resolved child steps against model elements only, so both constraints silently evaluated nothing. The closure gate refused to accept evidence for them, which is how the bug surfaced. Fixed with proper flag-step resolution; a static walk over the definition graph proved exactly those two occurrences were affected.

In our own evaluator (found by the live differential run – both fixed the same day). First, the index evaluator treated a missing key-field value as an error, which rejected any catalog containing a prop without a uuid – including NIST's own SP 800-53 rev5 catalog (20 reported errors; Java 3.2.0 accepts it). The Metaschema specification is explicit that a missing key-field value yields a null index key, not a violation; 800-53 now validates clean. Second, the OSCAL action-type vocabulary stayed bound even when action/@system declared a custom URI – contradicting NIST's remarks that @system "provides a means to segment the value space for the type" per organization. Both fixes shipped with regression tests and re-derived evidence hashes.

In the NIST sources:

  • An orphaned constraint. oscal_mapping-common_metaschema.xml @L657 constrains a flag defined at @L652 – which is never referenced by any flag ref. No model node carries it; the constraint is unsatisfiable for any document that can exist.
  • An impossible predicate. oscal_implementation-common_metaschema.xml @L567 constrains prop names under a @type predicate on inventory-item – but inventory-item declares no type flag (its sibling asset-id flag is itself commented out at @L456-461). The predicate can never match.
  • Constraints living inside comments. Five occurrences – including a with-child-controls value vocabulary in the profile Metaschema – exist only inside comment blocks: text a lexical reader finds but the compiled schema never enforces.

Each is recorded as excluded-with-rationale with its exact locator – auditable, and reported upstream: usnistgov/OSCAL#2254 (orphaned flag), #2255 (impossible predicate), #2256 (constraints in comments). The flag-enforcement gap in the reference tooling is reported as metaschema-framework/oscal-cli#279.

The live differential run: receipts, both directions

On 2026-07-18 we closed the gap between source-inspected comparator claims and executed evidence: Java OSCAL CLI 3.2.0 was downloaded from Maven Central, byte-verified against the registry-pinned SHA-256/SHA-512, and run over a crafted fixture family whose baseline documents validate cleanly on both toolchains – so every differential is attributable to a single injected change. Its embedded OSCAL commit (26df0501) is the 1.2.1 patch-release commit, confirming the bindings gap at runtime. Full transcripts, fixtures, and exit codes are recorded in the differential receipts alongside the registry.

ReceiptSingle change vs. baselinePer NIST 1.2.2 sourcesJava 3.2.0Secani
R1SSP component link rel="validation", dangling #hrefinvalid (ssp L628)valid, exit 0invalid, exit 1
R2SSP component link rel="validated-by", dangling #hrefvalid (constraint removed in 1.2.2)invalid, exit 1valid, exit 0
R3responsible-role without optional party-uuidvalid (1.2.2 added [party-uuid], L736)invalid, exit 1Key reference [null] not foundvalid, exit 0
R4metadata/action/@type = "invented-type"invalid (L877 – in 1.2.1 and 1.2.2 sources)valid, exit 0 (JSON and XML)invalid, exit 1
R5SSP implements ac-99, absent from resolved baselineinterpretive prosevalid, exit 0opt-in warning SSP-003.b
R6Profile alter adds prop status="operational" to resolved controlresolved output invalid (L289)resolve exit 0 – then rejects its own output on validaterefuses to write the output
R7NIST SP 800-53 rev5 catalog, unmodifiedvalidvalid, exit 0was invalid – our bug, fixed same day; now valid

Three patterns, honestly separated: R1–R3 are pure stale-bindings differentials – 1.2.2 changed exactly two constraints beyond version bumps, both in the SSP model, and Java is on the wrong side of all three observable behaviors (one false negative, two false positives, one of them erroring on the absence of an optional field). R4 is an enforcement gap independent of version skew: the constraint text is byte-identical in the sources Java embeds – and the affected class is fully mapped: a mechanical enumeration over the pinned inventory shows exactly 2 of 200 active allowed-values occurrences target flags; the only error-grade member is the one live-tested, so no untested remainder exists. R5 is a capability gap, not a defect – and our check stays an opt-in warning because the source is prose, not a machine constraint. R6 and R7 cut our way first and were fixed the same day; they stay in the table because a differential harness that only ever finds the other side's bugs isn't a harness.

What we deliberately do not claim

  • The profile-resolution specification is draft; PRES-family behavior is feature-gated, and NIST WARNING-level constraints stay warnings.
  • The workflow rules are interpretations of documented relationships, marked as such, off by default.
  • Registry comparator evidence remains labeled source-inspected; the live differential run supplements it with executed, hash-pinned receipts but does not silently upgrade the registry's evidence tier – folding the transcripts into the formal qualification gate is tracked separately.
  • 34 runtime-enforced constraints await a scope review before they gain first-class registry assertions; their runtime enforcement is unaffected.
  • The hosted validation API currently exposes the schema level; the semantic constraint layer described here ships with a later API release.

Appendix: pinned references

  • OSCAL sources: usnistgov/OSCAL @ 21403b4ad2f162ef1201e5ee70e8b93f254f51ac (v1.2.2) – Metaschemas, JSON Schemas, profile-resolution draft spec
  • OSCAL releases supported: 1.1.2, 1.1.3, 1.2.1, 1.2.2 (semantic target: 1.2.2)
  • Model documentation: https://pages.nist.gov/OSCAL/
  • Comparator: Java OSCAL CLI 3.2.0 / liboscal-java 7.2.0 (OSCAL 1.2.1 bindings)
  • Machine-readable acceptance: validation/requirements.json (42 requirements / 93 assertions), validation/capabilities.json (22 capability contracts), validation/completeness-closure.json (348 occurrences), validation/scope-lock.json
  • Verification: 1,000+ tests; pnpm verify (typecheck → tests → 13 generated-artifact gates → build → package checks → smoke)