270 lines
9.6 KiB
Markdown
270 lines
9.6 KiB
Markdown
# Topdata JSON Validation Tightening Design
|
|
|
|
**Date:** 2026-06-25
|
|
**Status:** Implemented and reviewed
|
|
**Scope:** `sow-tools`
|
|
|
|
## Problem
|
|
|
|
Topdata JSON validation checks many required properties and value types, but it
|
|
often ignores unknown structural properties. This allows plausible typos to
|
|
pass validation and then be ignored by the builder.
|
|
|
|
The immediate failure occurred in
|
|
`topdata/data/classes/feats/global.json`: an injection used
|
|
`when_present` even though the implemented condition is `require_present`.
|
|
Validation succeeded, the builder ignored `when_present`, and the injection was
|
|
therefore applied without the intended condition.
|
|
|
|
This is a fail-open authoring contract. A misspelled build-control property must
|
|
not silently change generated game data.
|
|
|
|
## Goals
|
|
|
|
- Reject unsupported structural properties in canonical topdata JSON files.
|
|
- Add an `any_present` global injection condition.
|
|
- Keep standalone validation and direct native builds consistent.
|
|
- Preserve dataset-specific row flexibility.
|
|
- Tighten only established canonical formats; do not turn this work into a
|
|
repository-wide schema rewrite.
|
|
- Produce diagnostics that identify the file, object, and unsupported property.
|
|
|
|
## Non-goals
|
|
|
|
- No schema migration or formatting change to authored topdata.
|
|
- No generic JSON Schema framework or new validation dependency.
|
|
- No blanket closure of every specialized JSON dialect.
|
|
- No change to generated feat family, registry, spellbook, TLK, or other
|
|
specialized formats unless they use the canonical containers covered here.
|
|
- No restriction of row values beyond existing column, metadata, inheritance,
|
|
reference, TLK, and authoring-sugar contracts.
|
|
- No compatibility alias for `when_present`; it is invalid syntax.
|
|
|
|
## Design
|
|
|
|
### Validation boundary
|
|
|
|
Structural objects use closed property sets. Dataset payload objects remain
|
|
dataset-aware.
|
|
|
|
Closed structural objects include:
|
|
|
|
| Object | Supported properties |
|
|
| --- | --- |
|
|
| Base or plain rows-file root | `output`, `key`, `columns`, `rows` |
|
|
| Canonical module root | `output`, `key`, `columns`, `entries`, `overrides`, `rows` |
|
|
| `global.json` root | `columns`, `entries`, `overrides`, `defaults`, `position`, `injections` |
|
|
| Global injection | `row`, `require_present`, `any_present`, `unless_present` |
|
|
| Global condition | `field`, `id` |
|
|
| Global default rule | `match`, `values` |
|
|
| Global default match object | `source` |
|
|
| Global default format object | `format` |
|
|
|
|
The allowed root set is selected from the file's existing canonical context;
|
|
properties from unrelated formats are not accepted merely because another
|
|
topdata file type supports them.
|
|
|
|
When a canonical root contains more than one supported container, each present
|
|
container must be validated. For example, a module containing both `entries`
|
|
and `overrides` must not stop validation after recognizing `entries`.
|
|
|
|
The following remain payload objects rather than global allowlists:
|
|
|
|
- objects inside `rows`;
|
|
- values inside `entries`;
|
|
- row-like objects inside `overrides`;
|
|
- an injection's `row`;
|
|
- a default rule's `values`.
|
|
|
|
Those objects continue to accept declared dataset columns plus documented row
|
|
control and metadata fields. The builder already rejects unknown row and entry
|
|
columns when it canonicalizes a dataset. This work must reuse or align with
|
|
that knowledge where practical, but must not invent a single fixed row schema
|
|
across all datasets.
|
|
|
|
### Moderate strictness
|
|
|
|
This change closes only canonical containers whose grammar is already
|
|
established by the parser and current authored data.
|
|
|
|
Before adding a closed root check, implementation must inventory active
|
|
`sow-topdata` files and existing `sow-tools` fixtures for legitimate property
|
|
combinations. A property used by an established canonical format must be added
|
|
to that format's explicit set rather than removed from authored data merely to
|
|
satisfy the validator.
|
|
|
|
Specialized parsers remain responsible for their own object shapes. Extending
|
|
closed-property checks to those formats is separate work.
|
|
|
|
### Global injection conditions
|
|
|
|
Global injections support three condition groups:
|
|
|
|
- `require_present`: every listed condition must match a current row.
|
|
- `any_present`: at least one listed condition must match a current row.
|
|
- `unless_present`: no listed condition may match a current row.
|
|
|
|
When more than one group is present, every group must pass. Conditions continue
|
|
to use the existing shape:
|
|
|
|
```json
|
|
{
|
|
"field": "FeatIndex",
|
|
"id": "feat:example"
|
|
}
|
|
```
|
|
|
|
Example:
|
|
|
|
```json
|
|
{
|
|
"row": {
|
|
"FeatIndex": {
|
|
"id": "feat:example"
|
|
}
|
|
},
|
|
"require_present": [
|
|
{
|
|
"field": "FeatIndex",
|
|
"id": "feat:base_requirement"
|
|
}
|
|
],
|
|
"any_present": [
|
|
{
|
|
"field": "FeatIndex",
|
|
"id": "masterfeats:metamagic"
|
|
},
|
|
{
|
|
"field": "FeatIndex",
|
|
"id": "masterfeats:combat"
|
|
}
|
|
],
|
|
"unless_present": [
|
|
{
|
|
"field": "FeatIndex",
|
|
"id": "feat:example"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
This injection applies only when the required feat exists, either listed
|
|
masterfeat exists, and the injected feat does not already exist.
|
|
|
|
`any_present` must contain at least one condition. An empty list is an
|
|
authoring error because it cannot express a useful successful condition.
|
|
Existing empty-list behavior for `require_present` and `unless_present` is not
|
|
changed by this work.
|
|
|
|
Conditions are evaluated against the same current-row view and in the same
|
|
injection order used today. This preserves the existing behavior where an
|
|
earlier injection can affect a later injection's conditions.
|
|
|
|
### Validation and build consistency
|
|
|
|
`ValidateProject` must report unsupported structural properties before a build.
|
|
The native builder must also fail closed when invoked directly with the same
|
|
invalid control syntax.
|
|
|
|
The implementation should centralize:
|
|
|
|
- deterministic unsupported-property checks;
|
|
- global condition parsing and validation;
|
|
- condition group evaluation.
|
|
|
|
Validation may collect multiple diagnostics while building returns the first
|
|
blocking error. Both paths must accept and reject the same property names and
|
|
condition shapes.
|
|
|
|
### Diagnostics
|
|
|
|
Diagnostics must name the containing object and unsupported property. Property
|
|
lists must be deterministic.
|
|
|
|
For the original error, an acceptable diagnostic is:
|
|
|
|
```text
|
|
global injection 10 contains unsupported key "when_present";
|
|
supported keys are row, require_present, any_present, and unless_present
|
|
```
|
|
|
|
Equivalent concise wording is acceptable if tests assert the stable semantic
|
|
parts rather than the entire sentence.
|
|
|
|
## Compatibility
|
|
|
|
Current canonical `sow-topdata` roots fit the property sets above. The
|
|
implementation must rerun the inventory immediately before tightening checks
|
|
and run validation against the active `sow-topdata` checkout.
|
|
|
|
Unknown properties are not retained as compatibility behavior. They currently
|
|
have no defined effect, and silently accepting them is the bug being fixed.
|
|
Existing documented properties and dataset-specific row fields remain valid.
|
|
|
|
`require_present` and `unless_present` retain their current semantics.
|
|
`any_present` is additive.
|
|
|
|
## Testing
|
|
|
|
Tests must follow red-green development and cover validation and direct-build
|
|
paths.
|
|
|
|
### Unsupported properties
|
|
|
|
- Reject an unknown `global.json` root property.
|
|
- Reject `when_present` and other unknown injection properties.
|
|
- Reject an unknown condition property.
|
|
- Reject unknown properties in default rules, match objects, and format
|
|
objects.
|
|
- Reject unknown canonical base/plain rows-file root properties.
|
|
- Reject unknown canonical module root properties.
|
|
- Continue accepting legitimate combinations such as:
|
|
- a columns-only module;
|
|
- entries-only and overrides-only modules;
|
|
- a module containing both entries and overrides;
|
|
- current base and plain rows-file roots.
|
|
- Continue accepting dataset columns and documented row control fields inside
|
|
rows, entries, overrides, and injection rows.
|
|
|
|
### `any_present`
|
|
|
|
- Validation accepts a non-empty `any_present` condition list.
|
|
- Validation rejects a non-array or empty `any_present`.
|
|
- A build injects when the first alternative is present.
|
|
- A build injects when a later alternative is present.
|
|
- A build skips the injection when no alternative is present.
|
|
- `any_present` combines correctly with `require_present`.
|
|
- `any_present` combines correctly with `unless_present`.
|
|
- Existing `require_present` and `unless_present` behavior remains unchanged.
|
|
- Direct building rejects unsupported condition syntax even when project
|
|
validation was not called first.
|
|
|
|
### Integration
|
|
|
|
- Run focused `internal/topdata` tests.
|
|
- Run `nix develop --command make check`.
|
|
- Validate the active `sow-topdata` checkout with the changed Crucible.
|
|
- Build topdata far enough to exercise global injections and confirm no
|
|
generated files are tracked.
|
|
|
|
## Documentation
|
|
|
|
Update the class feat global injection contract to document `require_present`,
|
|
`any_present`, and `unless_present`, including their all/any/none semantics and
|
|
how multiple groups combine.
|
|
|
|
No broad validation guide is added unless implementation reveals an existing
|
|
operator document that would otherwise become stale.
|
|
|
|
## Acceptance criteria
|
|
|
|
- `when_present` in a global injection fails validation and direct building.
|
|
- `any_present` is valid only as a non-empty list of valid conditions.
|
|
- `any_present` applies an injection when one or more alternatives exist and
|
|
skips it when none exist.
|
|
- Unknown canonical structural properties fail with actionable diagnostics.
|
|
- Dataset-specific row fields remain valid when declared by the dataset.
|
|
- Existing active `sow-topdata` validates and builds after replacing invalid
|
|
syntax with the documented grammar.
|
|
- No specialized topdata format is tightened outside the stated scope.
|