Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api/subject-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ Array of Relation objects, each pointing to another subject.
| Field | Type | Required | Description |
|-------|------|-------------|-------------|
| `id` | string | Yes | Unique identifier for this relation |
| `target` | string | Yes | Subject ID of the target subject, in either id form (bare means local) |
| `target` | string | Yes | Subject ID of the target subject, in either id form (bare means local). Must reference a resolvable Source: a target whose Source is not registered on this wiki is reported at write and validation time, and rejected when `$wgNeoWikiEnforceValidation` is enabled (v1 guard, [ADR 23](../adr/023-subject-sources.md)). |
| `properties` | object | No | Key-value pairs of relation properties. Only included when non-empty. |

Relation properties example:
Expand Down
24 changes: 23 additions & 1 deletion docs/api/validation-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,26 @@ Note the create dry-run endpoint (`POST /subject/validate`) instead returns `404
the update dry-run (`POST /subject/{id}/validate`) and both write endpoints surface the violation.
Reconciling that asymmetry is left to the enforcement tier (ADR 21).

### `relation-target-source-unresolvable`

Emitted by `SubjectValidator`, for every relation-typed statement in the payload — including
statements whose property is not declared on the current Schema or whose recorded type mismatches it.
`args`: `[sourceKey]` — the unresolvable Source key taken from the offending target's id.
`valuePartIndex`: never set.
`propertyName`: the property whose statement carries the offending target (which need not be declared
on the Schema).

A Relation targets a Subject whose id names a Source that is not registered on this wiki. The v1 guard
of [ADR 23](../adr/023-subject-sources.md) ("Relations across Sources") requires a relation target to
reference a resolvable Source, so a target with an unregistered Source key is rejected at write and
validation time (blocking under `$wgNeoWikiEnforceValidation`). Schema membership says nothing about
whether a target's Source resolves, so the out-of-schema tolerance described under Known limitations
does not exempt relation targets from this check. Bare targets, and ids explicitly qualified with the
local Source (which canonicalize to bare), carry no Source key and always pass; targets whose Source
is registered pass too. This is a write/validate-time guard only: persisted Subjects are never
revalidated on read, so an already-stored foreign target is never rejected and cross-source relations
can be opened up later.

## Known limitations (Foundation round)

The PHP `SubjectValidator` performs two Subject-level checks:
Expand All @@ -213,7 +233,9 @@ The PHP `SubjectValidator` performs two Subject-level checks:
may have been removed from the Schema while Subjects still carry old Statements. ADR 8 says
one Schema per Subject, not that every Statement on that Subject must reference an extant
Property. If surfacing these as violations becomes useful (e.g. for migration UIs), it can be
added as a separate code without changing the current behavior.
added as a separate code without changing the current behavior. The one exception is
[`relation-target-source-unresolvable`](#relation-target-source-unresolvable), which fires for
relation-typed statements regardless of schema membership.

## Adding a new validation code

Expand Down
1 change: 1 addition & 0 deletions extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@
"neowiki-field-schema-not-found",
"neowiki-field-unregistered-type",
"neowiki-field-label-required",
"neowiki-field-relation-target-source-unresolvable",
"neowiki-select-placeholder",
"neowiki-select-no-results",
"neowiki-select-unknown-option",
Expand Down
1 change: 1 addition & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
"neowiki-field-schema-not-found": "The schema \"$1\" is missing. Restore it before saving.",
"neowiki-field-unregistered-type": "The type \"$1\" is not available on this wiki.",
"neowiki-field-label-required": "Please provide a label for the subject.",
"neowiki-field-relation-target-source-unresolvable": "The Source \"$1\" is not registered on this wiki, so it cannot be a relation target.",
"neowiki-select-placeholder": "Select an option",
"neowiki-select-no-results": "No matching options.",
"neowiki-select-unknown-option": "(unknown option)",
Expand Down
1 change: 1 addition & 0 deletions i18n/qqq.json
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@
"neowiki-field-min-length": "Validation error shown when a text value is shorter than the property's minimum length. $1 is the minimum number of characters.",
"neowiki-field-max-length": "Validation error shown when a text value is longer than the property's maximum length. $1 is the maximum number of characters.",
"neowiki-field-label-required": "Form-level error shown when the backend reports the Subject is missing its required display label.",
"neowiki-field-relation-target-source-unresolvable": "Validation error shown when a relation points to a target Subject whose Source is not registered on this wiki. $1 is the unresolvable source key. This is the v1 guard of ADR 23 (Relations across Sources); bare and local-qualified targets always pass.",
"neowiki-select-placeholder": "Placeholder text shown in the select dropdown before any option is chosen.",
"neowiki-select-no-results": "Message shown in the multi-select dropdown when the typed text does not match any available option.",
"neowiki-field-invalid-datetime": "Validation error shown when the entered date and time value cannot be parsed. DateTime values must be strict ISO 8601 / xsd:dateTime strings with an explicit timezone offset or 'Z' (for example, '2025-06-15T12:00:00Z' or '2025-06-15T12:00:00+02:00'). Partial values such as year-only or date-only are rejected, and min/max bounds are inclusive.",
Expand Down
29 changes: 29 additions & 0 deletions resources/ext.neowiki/tests/components/Value/RelationInput.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,35 @@ describe( 'RelationInput', () => {
expect( field.props( 'status' ) ).toBe( 'error' );
} );

it( 'surfaces the unresolvable-source violation with the offending source key', () => {
// The server owns the Source registry, so the client never hard-rejects a foreign
// target; it only renders the backend's violation. The message must carry the source
// key ($1) so the user learns which Source is unresolvable.
setupMwMock( {
functions: [ 'message' ],
messages: {
'neowiki-field-relation-target-source-unresolvable':
( source: string ): string => `Unregistered source: ${ source }`,
},
} );

const wrapper = newWrapper( {
property: newRelationProperty( { name: 'Owner', targetSchema: 'Company' } ),
serverViolations: [
{
propertyName: 'Owner',
code: 'relation-target-source-unresolvable',
args: [ 'otherwiki' ],
valuePartIndex: null,
},
],
} );

const field = wrapper.findComponent( CdxField );
expect( field.props( 'messages' ) ).toEqual( { error: 'Unregistered source: otherwiki' } );
expect( field.props( 'status' ) ).toBe( 'error' );
} );

it( 'keeps a server violation suppressed while the lookup reports unmatched text', async () => {
const wrapper = newWrapper( {
property: newRelationProperty( { name: 'Owner', targetSchema: 'Company' } ),
Expand Down
42 changes: 42 additions & 0 deletions src/Application/Validation/SubjectValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,18 @@
use ProfessionalWiki\NeoWiki\Domain\Schema\PropertyDefinition;
use ProfessionalWiki\NeoWiki\Domain\Schema\PropertyName;
use ProfessionalWiki\NeoWiki\Domain\Schema\Schema;
use ProfessionalWiki\NeoWiki\Domain\Source\SourceRegistry;
use ProfessionalWiki\NeoWiki\Domain\Statement;
use ProfessionalWiki\NeoWiki\Domain\Subject\StatementList;
use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectLabel;
use ProfessionalWiki\NeoWiki\Domain\Validation\Violation;
use ProfessionalWiki\NeoWiki\Domain\Value\RelationValue;

readonly class SubjectValidator {

public function __construct(
private PropertyTypeLookup $propertyTypeLookup,
private SourceRegistry $sourceRegistry,
) {
}

Expand All @@ -37,6 +40,8 @@ public function validate( SubjectLabel $label, StatementList $statements, Schema
$this->validateStatement( $statement, $schema->getProperty( $statement->getPropertyName() ) )
);
}

$violations = array_merge( $violations, $this->validateRelationTargetSources( $statement ) );
}

return array_merge( $violations, $this->validateRequiredProperties( $statements, $schema ) );
Expand Down Expand Up @@ -78,6 +83,43 @@ private function validateStatement( Statement $statement, PropertyDefinition $de
return $violations;
}

/**
* The v1 guard of ADR 23 ("Relations across Sources"): a Relation may only target a Subject whose
* Source resolves through the registry. Runs against every statement in the payload, not only
* schema-declared ones: schema membership says nothing about whether a target's Source resolves,
* and scoping the check to the schema would let an out-of-schema or type-mismatched relation
* statement carry an unresolvable target past enforcement. Bare and local-qualified targets carry
* no source key (SubjectIdParser canonicalizes the local-qualified form to bare) and pass
* untouched. This runs only on the validate and write paths; persisted Subjects are never
* revalidated on read, so an already-stored foreign target is never rejected and cross-source
* relations can open up later.
*
* @return Violation[]
*/
private function validateRelationTargetSources( Statement $statement ): array {
$value = $statement->getValue();

if ( !$value instanceof RelationValue ) {
return [];
}

$violations = [];

foreach ( $value->relations as $relation ) {
$sourceKey = $relation->targetId->getSource();

if ( $sourceKey !== null && $this->sourceRegistry->getSource( $sourceKey ) === null ) {
$violations[] = new Violation(
propertyName: $statement->getPropertyName(),
code: 'relation-target-source-unresolvable',
args: [ $sourceKey ],
);
}
}

return $violations;
}

/**
* Catch required-but-missing: the Schema declares the property as required, but no
* Statement for it is present on the Subject. This also covers the "empty Value
Expand Down
1 change: 1 addition & 0 deletions src/NeoWikiExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,7 @@ private function isValidationEnforced(): bool {
public function getSubjectValidator(): SubjectValidator {
return new SubjectValidator(
propertyTypeLookup: $this->getPropertyTypeLookup(),
sourceRegistry: $this->getSourceRegistry(),
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use ProfessionalWiki\NeoWiki\Domain\Schema\PropertyName;
use ProfessionalWiki\NeoWiki\Domain\Schema\Schema;
use ProfessionalWiki\NeoWiki\Domain\Schema\SchemaName;
use ProfessionalWiki\NeoWiki\Domain\Source\SourceRegistry;
use ProfessionalWiki\NeoWiki\Domain\Subject\StatementList;
use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId;
use ProfessionalWiki\NeoWiki\Domain\Value\RelationValue;
Expand Down Expand Up @@ -76,7 +77,10 @@ private function newCreateSubjectAction( bool $validationEnforced = false ): Cre
new SelectStatementResolver( new SelectValueResolver() ),
new ProposedSubjectValidator(
schemaLookup: $this->schemaLookup,
subjectValidator: new SubjectValidator( propertyTypeLookup: $registry ),
subjectValidator: new SubjectValidator(
propertyTypeLookup: $registry,
sourceRegistry: new SourceRegistry( TestData::LOCAL_SOURCE_KEY ),
),
),
$validationEnforced,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use ProfessionalWiki\NeoWiki\Domain\Schema\PropertyName;
use ProfessionalWiki\NeoWiki\Domain\Schema\Schema;
use ProfessionalWiki\NeoWiki\Domain\Schema\SchemaName;
use ProfessionalWiki\NeoWiki\Domain\Source\SourceRegistry;
use ProfessionalWiki\NeoWiki\Domain\Subject\StatementList;
use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectId;
use ProfessionalWiki\NeoWiki\Domain\Subject\SubjectLabel;
Expand Down Expand Up @@ -76,7 +77,10 @@ private function newAction(
selectStatementResolver: new SelectStatementResolver( new SelectValueResolver() ),
proposedSubjectValidator: new ProposedSubjectValidator(
schemaLookup: $this->schemaLookup,
subjectValidator: new SubjectValidator( propertyTypeLookup: $registry ),
subjectValidator: new SubjectValidator(
propertyTypeLookup: $registry,
sourceRegistry: new SourceRegistry( TestData::LOCAL_SOURCE_KEY ),
),
),
presenter: $this->presenterSpy,
validationEnforced: $validationEnforced,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
use ProfessionalWiki\NeoWiki\Domain\Schema\PropertyDefinitions;
use ProfessionalWiki\NeoWiki\Domain\Schema\Schema;
use ProfessionalWiki\NeoWiki\Domain\Schema\SchemaName;
use ProfessionalWiki\NeoWiki\Domain\Source\SourceRegistry;
use ProfessionalWiki\NeoWiki\Domain\Subject\Subject;
use ProfessionalWiki\NeoWiki\Tests\Data\TestData;
use ProfessionalWiki\NeoWiki\Tests\Data\TestSubject;
use ProfessionalWiki\NeoWiki\Tests\TestDoubles\InMemorySchemaLookup;

Expand All @@ -33,7 +35,10 @@ protected function setUp(): void {
private function newValidator(): ProposedSubjectValidator {
return new ProposedSubjectValidator(
schemaLookup: $this->schemaLookup,
subjectValidator: new SubjectValidator( propertyTypeLookup: PropertyTypeRegistry::withCoreTypes() ),
subjectValidator: new SubjectValidator(
propertyTypeLookup: PropertyTypeRegistry::withCoreTypes(),
sourceRegistry: new SourceRegistry( TestData::LOCAL_SOURCE_KEY ),
),
);
}

Expand Down
Loading
Loading