diff --git a/notations/examples/relations/reports-to/README.md b/notations/examples/relations/reports-to/README.md index 6bdbba77..105ff8b0 100644 --- a/notations/examples/relations/reports-to/README.md +++ b/notations/examples/relations/reports-to/README.md @@ -4,12 +4,96 @@ This catalogue illustrates [`reports_to`](../../../elements/17-relations.md#34-r Every person, admission and relationship is synthetic. Consumers can load the six YAML records under `canon/` without reconstructing a model from prose. The oracle below is also the expected-result contract for consumer tests. -It specifies behavior; it does **not** claim an available reporting query CLI, -released validator support, or that the current generic listing and trace -implementations enforce this contract. The existing `7.0.0` manifest pin is -unchanged; use the vocabulary at the specification revision containing this -example. An older artifact at that pin can reject the added kind. Record the -exact specification revision and consumer version when testing conformance. +The source-checkout command below implements the reporting oracle. This does +not claim released or installed support in other validators, generic listing, +or trace consumers. The existing `7.0.0` manifest pin is unchanged; older +artifacts at that pin can reject the added kind. Record the exact source +revision and consumer version when testing conformance. + +## Run the reporting check and direct queries + +From a source checkout, use Python 3.12 with PyYAML 6.0.1 (the same dependency +used by the repository's Python validation tools): + +```sh +python3 -m pip install PyYAML==6.0.1 +python3 tools/reporting.py --root notations/examples/relations/reports-to +python3 tools/reporting.py --root notations/examples/relations/reports-to --as-at 2026-06-15 --actor ACTOR-SYN-A-1 +python3 tools/reporting.py --root notations/examples/relations/reports-to --as-at 2026-06-15 --actor ACTOR-SYN-B-1 --direction reports +python3 tools/tests/test_reporting.py +``` + +`--root` must identify one declared catalogue with `transitrix.yaml` and both +`canon/elements/` and `canon/relations/` directories (an existing empty directory +is valid). The command recursively reads whole-file YAML mappings (`.yaml` or +`.yml`) in those two directories, including full admission and lifecycle data. +It supports single-catalogue local inputs, not holding-wide aggregation, +remote imports, Markdown front matter, or view-defined elements. Symlinks, +unreadable directories/files, malformed YAML, duplicate YAML keys, unsupported +files, and nested manifests in the scanned input are explicit incomplete +results. Only empty regular `.gitkeep` files are exempt. Other zones and view +files are outside this command's population; keep the repository linter for +their integrity checks. Consumers must supply the complete local population, +not an export of selected relations. + +Every invocation checks file shapes, admission, lifecycle, resolved endpoints, +and **all-history** reporting cycles. With no date it also scans all-history +multi-manager health intervals. `--as-at` limits health warnings and returned +pairs to that explicit date; it does not hide a historical validation error. +`--actor` requires a date. `--direction managers` (default) selects outgoing +pairs; `--direction reports` selects incoming pairs. Without an actor, a dated +invocation returns every direct pair. No inferred or transitive edges are added. + +Output is JSON: `complete`, `valid`, `as_at`, resolved `actor`, `direction`, +`pairs`, and `findings`. Each pair contains `from_actor`, `to_actor`, and every +active contributing REL ID in `relations`. Identities and evidence are sorted. +A valid empty `pairs` array means no relationship recorded at that date; it is +only definitive within the declared input scope. Any error or incomplete input +suppresses **all** pairs. Warnings alone preserve valid matrix results. Exit 0 +means complete and valid (possibly with warnings), 1 means validation errors, +and 2 means incomplete input; argument-usage errors also exit 2. + +Identity lookup uses live IDs then `former_ids` migration bridges; collisions +are errors. Human-readable `aliases` are matching hints and never endpoint IDs. +The command checks endpoint TYPE and subtype for `reports_to`, `employment`, and +`unit_parent`, preserving their different meanings. Other known REL kinds are +recognized but their kind-specific semantics remain with their existing +validators. They never produce reporting edges. `extensions` pass through +without reporting authority. This is a bounded reporting checker, not full +schema certification of every notation or a replacement for `tools/lint.py`. +The separate `scripts/release-obligations.mjs` interface is unchanged. + +### Diagnostic ownership + +`tools/reporting.py` enforces the following rules for this supported input; +repository graph checks require its complete catalogue load. Existing linter +checks remain in place. Findings carry record/field context where applicable; +cycle findings include an ordered actor path, incident edges with REL IDs, +all contributing evidence, and an inclusive interval/witness date. Health +findings carry the actor, distinct manager identities, evidence and interval. +Intervals are split whenever active evidence changes. + +| Code | Severity | Meaning / enforcement layer | +|---|---|---| +| `SCHEMA_INVALID` | error | File field/identity/notation shape, duplicate ID, missing `valid_to`, or invalid query argument. Duplicate IDs also make the population incomplete. | +| `ADMIT-001`–`ADMIT-004` | error | Unknown state, inconsistent proposal metadata, incomplete active admission, or missing rejection metadata; file admission checks. Valid drafts are excluded. | +| `LIFECYCLE-001` | error | Missing/invalid quoted start date; file check. | +| `LIFECYCLE-002` | error | Non-null end is not a valid quoted calendar date; file check. | +| `LIFECYCLE-003` | error | End before start; file check. | +| `REL-001` | error | Missing/unknown relation kind; file check using the shipped vocabulary. | +| `REL-002` | error | Missing/malformed, unresolved/unadmitted or wrong TYPE/subtype endpoint; catalogue check. An endpoint with invalid lifecycle cannot contribute. Also used for an invalid query person. | +| `REL-003` | error | Relation window not contained in either resolved endpoint; catalogue check, never clipping. | +| `REL-004` | error | Canonical inline `manager`/`reports_to` field on ACTOR; author a REL instead. | +| `ELEM-FORMER-ID-001` | error | Migration identity collision; catalogue incomplete. | +| `REL-011` | error | Resolved reporting self-link; catalogue identity check. | +| `REL-012` | error | Simultaneously effective reporting cycle; repository graph check. | +| `REL-013` | warning | Multiple distinct effective managers; repository health check. | +| `CAT-001` | warning | No declared catalogue manifest; result is incomplete and exits 2. | +| `CAT-002` | error | Nested catalogue boundary; result is incomplete. | + +`CATALOGUE_INCOMPLETE` is an error status for command input/load failures, +including unsupported files; the result is incomplete. It is command status, +not a new normative validator rule. ## Records and dated oracle @@ -55,7 +139,7 @@ Apply each mutation independently to the base catalogue, retaining full admission and lifecycle records unless the vector explicitly changes them. Diagnostics retain field/endpoint context; graph failures retain incident records. These are normative expectations for dependent mechanical tests, -not results from a runtime validator supplied by this example. +exercised by the source-checkout command and its mechanical tests. | Vector | Expected result | |---|---| diff --git a/tools/reporting.py b/tools/reporting.py new file mode 100644 index 00000000..690a97f9 --- /dev/null +++ b/tools/reporting.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +"""Validate and query direct person reporting in a declared YAML catalogue.""" +import argparse +from datetime import date, timedelta +import json +from pathlib import Path +import re +import stat + +import yaml + + +class UniqueLoader(yaml.SafeLoader): + """Reject duplicate keys instead of silently dropping authored evidence.""" + + +def unique_map(loader, node): + result = {} + for key_node, value_node in node.value: + key = loader.construct_object(key_node) + if not isinstance(key, str) or key in result: + raise ValueError('Mapping keys must be unique strings') + result[key] = loader.construct_object(value_node) + return result + + +UniqueLoader.add_constructor('tag:yaml.org,2002:map', unique_map) +ID = re.compile(r'^[A-Z][A-Z0-9_]*(?:-[A-Z0-9_]+)*-[0-9]+$') +VOCAB = Path(__file__).resolve().parents[1] / 'notations' / 'vocabulary.yaml' + + +def day(value): + if not isinstance(value, str) or not re.fullmatch(r'\d{4}-\d{2}-\d{2}', value): + return False + try: + date.fromisoformat(value) + return True + except ValueError: + return False + + +def finding(code, message, record=None, field=None, severity='error', **context): + return dict(code=code, severity=severity, message=message, + **({'record': record} if record else {}), + **({'field': field} if field else {}), **context) + + +def load_catalogue(root): + """One explicit root, whole-file YAML in canon/elements and canon/relations.""" + root = Path(root).absolute() + records, findings = [], [] + complete = True + + def incomplete(message, path, code='CATALOGUE_INCOMPLETE'): + nonlocal complete + complete = False + findings.append(finding(code, message, severity='warning' if code == 'CAT-001' else 'error', path=str(path))) + + def read(path): + try: + value = yaml.load(path.read_text(encoding='utf-8'), Loader=UniqueLoader) + if not isinstance(value, dict): + raise ValueError('Expected one YAML mapping') + return value + except (OSError, UnicodeError, yaml.YAMLError, ValueError, RecursionError): + incomplete('Unreadable or malformed YAML mapping', path.relative_to(root)) + return None + + def inspect(path, missing_code='CATALOGUE_INCOMPLETE'): + try: + info = path.lstat() + except FileNotFoundError: + incomplete('Required catalogue input is missing', path.relative_to(root), missing_code) + return None + except OSError: + incomplete('Cannot inspect catalogue input', path.relative_to(root)) + return None + if stat.S_ISLNK(info.st_mode): + incomplete('Symlinks are outside the supported catalogue scope', path.relative_to(root)) + return None + return info + + def directory(path): + info = inspect(path) + if info is None: + return False + if not stat.S_ISDIR(info.st_mode): + incomplete('Expected a catalogue directory', path.relative_to(root)) + return False + return True + + if not directory(root): + return dict(records=records, findings=findings, complete=complete) + manifest = root / 'transitrix.yaml' + info = inspect(manifest, missing_code='CAT-001') + if info is not None: + if stat.S_ISREG(info.st_mode): + read(manifest) + else: + incomplete('Expected a regular YAML manifest', 'transitrix.yaml') + for ancestor in root.parents: + try: + nested = (ancestor / 'transitrix.yaml').exists() + except OSError: + incomplete('Cannot inspect enclosing catalogue boundary', 'transitrix.yaml') + break + if nested: + incomplete('Root is inside another declared catalogue', 'transitrix.yaml', 'CAT-002') + break + + def walk(folder): + if not directory(folder): + return + try: + entries = sorted(folder.iterdir()) + except OSError: + incomplete('Cannot enumerate catalogue directory', folder.relative_to(root)) + return + for path in entries: + rel = path.relative_to(root) + info = inspect(path) + if info is None: + continue + if path.name == 'transitrix.yaml': + incomplete('Nested catalogue manifest', rel, 'CAT-002') + elif stat.S_ISDIR(info.st_mode): + walk(path) + elif not stat.S_ISREG(info.st_mode): + incomplete('Expected a regular catalogue file', rel) + elif path.suffix in ('.yaml', '.yml'): + value = read(path) + if value is not None: + value['_path'] = str(rel) + records.append(value) + elif path.name != '.gitkeep' or info.st_size != 0: + incomplete('Unsupported file in catalogue input', rel) + + # Both directories are required: absence cannot establish an empty population. + if directory(root / 'canon'): + for name in ('elements', 'relations'): + walk(root / 'canon' / name) + return dict(records=records, findings=findings, complete=complete) + + +def admitted(r): + gates = r.get('gate_checks') + return (r.get('admission_state', 'active') == 'active' and r.get('zone') == 'canon' + and day(r.get('admitted_at')) and isinstance(r.get('admitted_by'), str) + and bool(r['admitted_by'].strip()) and isinstance(gates, dict) + and all(gates.get(k) == 'pass' for k in ('uniqueness', 'consistency', 'completeness')) + and all(v == 'pass' for v in gates.values())) + + +def in_effect(r, when): + return r['valid_from'] <= when and (r['valid_to'] is None or when <= r['valid_to']) + + +def analyse(catalogue, as_at=None, actor=None, direction='managers'): + findings = list(catalogue['findings']) + complete = catalogue['complete'] + records = catalogue['records'] + if as_at is not None and not day(as_at): + return dict(complete=complete, valid=False, as_at=as_at, actor=actor, direction=direction, + pairs=[], findings=findings + [finding( + 'SCHEMA_INVALID', 'Query date must be YYYY-MM-DD', field='as_at')]) + elements, aliases, relations, valid_windows = {}, {}, [], set() + vocabulary = yaml.safe_load(VOCAB.read_text(encoding='utf-8')) + kinds = vocabulary['relation_types'] + kinds = {**kinds, **{k: kinds[v['replaced_by']] + for k, v in vocabulary['deprecated_relation_types'].items()}} + + def emit(code, message, r, field=None, **ctx): + findings.append(finding(code, message, r.get('id'), field, **ctx)) + + for r in records: + identity = r.get('id') + if not isinstance(identity, str) or not ID.fullmatch(identity): + emit('SCHEMA_INVALID', 'Expected a canonical ID', r, 'id') + complete = False + continue + if identity in elements: + emit('SCHEMA_INVALID', 'Duplicate canonical ID; catalogue is ambiguous', r, 'id') + complete = False + continue + elements[identity] = r + expected_notation = 'relation' if identity.startswith('REL-') else ('actor' if identity.startswith('ACTOR-') else None) + if expected_notation and r.get('notation') != expected_notation: + emit('SCHEMA_INVALID', 'Notation does not match canonical TYPE', r, 'notation') + if identity.startswith('REL-') or r.get('notation') == 'relation': + relations.append(r) + state = r.get('admission_state', 'active') + if state not in ('active', 'proposed', 'rejected'): + emit('ADMIT-001', 'Unknown admission state', r, 'admission_state') + elif state == 'active' and not admitted(r): + emit('ADMIT-003', 'Incomplete canon admission or gate checks', r, 'admitted_at') + elif state == 'proposed' and (r.get('admitted_at') or r.get('admitted_by') + or not r.get('proposed_at') or not r.get('proposed_by')): + emit('ADMIT-002', 'Proposed record must retain proposal, not admission metadata', r) + elif state == 'rejected' and (not r.get('rejected_at') or not r.get('rejected_by')): + emit('ADMIT-004', 'Rejected record needs rejection metadata', r) + start, end = r.get('valid_from'), r.get('valid_to') + good = True + if not day(start): + emit('LIFECYCLE-001', 'Expected quoted ISO calendar date', r, 'valid_from') + good = False + if 'valid_to' not in r: + emit('SCHEMA_INVALID', 'Required field is missing', r, 'valid_to') + good = False + elif end is not None and not day(end): + emit('LIFECYCLE-002', 'Expected quoted ISO calendar date or null', r, 'valid_to') + good = False + if good and end is not None and end < start: + emit('LIFECYCLE-003', 'End precedes start', r, 'valid_to') + good = False + if good: + valid_windows.add(identity) + if identity.startswith('ACTOR-'): + for field in ('manager', 'reports_to'): + if field in r: + emit('REL-004', 'Author reporting as a first-class REL', r, field) + + for identity, r in elements.items(): + former = r.get('former_ids', []) + if not isinstance(former, list) or any(not isinstance(x, str) or not ID.fullmatch(x) for x in former): + emit('SCHEMA_INVALID', 'Expected a list of retired canonical IDs', r, 'former_ids') + complete = False + continue + for old in former: + if old in elements or (old in aliases and aliases[old] != identity): + emit('ELEM-FORMER-ID-001', 'Retired identity collides with another identity', r, 'former_ids', reference=old) + complete = False + aliases[old] = identity + + def resolve(reference): + if not isinstance(reference, str): + return None + return elements.get(reference) or elements.get(aliases.get(reference)) + + edges = [] + for r in relations: + before = len(findings) + kind = r.get('type') + if not isinstance(kind, str) or kind not in kinds: + emit('REL-001', 'Missing or unknown relation kind', r, 'type') + continue + # Other known relation kinds retain their own validators and semantics. + if kind not in ('reports_to', 'employment', 'unit_parent'): + continue + endpoints = {} + for field in ('from', 'to'): + ref = r.get(field) + endpoint = resolve(ref) + expected = kinds[kind][field + '_subtype'][0] + if not isinstance(ref, str) or not ID.fullmatch(ref): + emit('REL-002', 'Expected one canonical endpoint ID', r, field) + if not isinstance(ref, str): + emit('SCHEMA_INVALID', 'Endpoint must be a scalar string', r, field) + elif endpoint is None or not admitted(endpoint): + emit('REL-002', 'Unresolved or unadmitted endpoint', r, field, reference=ref) + elif (not endpoint['id'].startswith('ACTOR-') or endpoint.get('notation') != 'actor' + or endpoint.get('type') != expected): + emit('REL-002', 'Resolved endpoint has wrong TYPE or subtype', r, field, + reference=ref, expected='ACTOR(' + expected + ')', + actual=endpoint['id'].split('-')[0] + '(' + str(endpoint.get('type')) + ')') + else: + endpoints[field] = endpoint + if r['id'] in valid_windows and endpoint['id'] in valid_windows: + if (r['valid_from'] < endpoint['valid_from'] or + (endpoint['valid_to'] is not None and + (r['valid_to'] is None or r['valid_to'] > endpoint['valid_to']))): + emit('REL-003', 'Relation window exceeds endpoint lifecycle', r, field, + reference=ref, interval=[r['valid_from'], r['valid_to']], + endpoint_interval=[endpoint['valid_from'], endpoint['valid_to']]) + elif endpoint['id'] not in valid_windows: + emit('REL-002', 'Endpoint lifecycle is invalid', r, field, reference=ref) + if (kind == 'reports_to' and len(endpoints) == 2 and + endpoints['from']['id'] == endpoints['to']['id']): + emit('REL-011', 'Reporting endpoints resolve to the same person', r, + actor=endpoints['from']['id']) + if (kind == 'reports_to' and len(findings) == before and admitted(r) + and r['id'] in valid_windows and len(endpoints) == 2): + edges.append(dict(r, **{f: e['id'] for f, e in endpoints.items()})) + + edges.sort(key=lambda r: r['id']) + # Change-point slices preserve inclusive boundaries and all evidence changes. + points = set() + for r in edges: + points.add(r['valid_from']) + if r['valid_to'] and r['valid_to'] < '9999-12-31': + points.add((date.fromisoformat(r['valid_to']) + timedelta(days=1)).isoformat()) + points = sorted(points) + for i, start in enumerate(points): + end = (date.fromisoformat(points[i + 1]) - timedelta(days=1)).isoformat() if i + 1 < len(points) else None + live = [r for r in edges if in_effect(r, start)] + if live and all(r['valid_to'] is not None for r in live): + finite_end = max(r['valid_to'] for r in live) + end = min(end, finite_end) if end else finite_end + graph = {} + for r in live: + graph.setdefault(r['from'], {}).setdefault(r['to'], []).append(r['id']) + # Enumerate each simple directed cycle once, beginning at its least ID. + for origin in sorted(graph): + stack = [(origin, [origin], [])] + while stack: + current, path, evidence = stack.pop() + for target, ids in sorted(graph.get(current, {}).items()): + if target == origin: + findings.append(finding('REL-012', 'Simultaneously effective reporting cycle', + actor_path=path + [origin], + relations=sorted(evidence + ids), + incident_edges=[dict(from_actor=a, to_actor=b, relations=graph[a][b]) + for a, b in zip(path, path[1:] + [origin])], + interval=[start, end], witness=start)) + elif target > origin and target not in path: + stack.append((target, path + [target], evidence + ids)) + managers = graph[origin] + if len(managers) > 1 and (as_at is None or start <= as_at and (end is None or as_at <= end)): + findings.append(finding('REL-013', 'Multiple distinct concurrently effective managers', + severity='warning', actor=origin, managers=sorted(managers), + relations=sorted(x for ids in managers.values() for x in ids), + interval=[start, end], witness=as_at or start)) + + subject = resolve(actor) if actor is not None else None + if actor is not None and (subject is None or not admitted(subject) or + not subject['id'].startswith('ACTOR-') or subject.get('type') != 'person'): + findings.append(finding('REL-002', 'Query subject must resolve to an admitted person', field='actor')) + if actor is not None and as_at is None: + findings.append(finding('SCHEMA_INVALID', 'Direct queries require an explicit date', field='as_at')) + valid = not any(f['severity'] == 'error' for f in findings) + pairs = {} + if valid and complete and as_at is not None: + for r in edges: + if in_effect(r, as_at) and (subject is None or + r['from' if direction == 'managers' else 'to'] == subject['id']): + pairs.setdefault((r['from'], r['to']), []).append(r['id']) + return dict(complete=complete, valid=valid, as_at=as_at, + actor=subject['id'] if subject else actor, direction=direction, + pairs=[dict(from_actor=a, to_actor=b, relations=sorted(ids)) + for (a, b), ids in sorted(pairs.items())], findings=findings) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--root', required=True, type=Path) + parser.add_argument('--as-at') + parser.add_argument('--actor') + parser.add_argument('--direction', choices=('managers', 'reports'), default='managers') + args = parser.parse_args() + result = analyse(load_catalogue(args.root), args.as_at, args.actor, args.direction) + print(json.dumps(result, indent=2, ensure_ascii=True)) + return 2 if not result['complete'] else (0 if result['valid'] else 1) + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/tests/test_reporting.py b/tools/tests/test_reporting.py new file mode 100644 index 00000000..3dddc608 --- /dev/null +++ b/tools/tests/test_reporting.py @@ -0,0 +1,332 @@ +"""Consumer oracle and negative controls for the reporting command.""" +import copy +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +import yaml + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from reporting import analyse, load_catalogue + +ROOT = Path(__file__).resolve().parents[2] +EXAMPLE = ROOT / 'notations/examples/relations/reports-to' +A, B, C = ('ACTOR-SYN-' + x + '-1' for x in 'ABC') +R1, R2, R3 = ('REL-SYN-REPORTING-' + str(x) for x in (1, 2, 3)) + + +class ReportingTests(unittest.TestCase): + def setUp(self): + self.cat = load_catalogue(EXAMPLE) + self.records = {r['id']: r for r in self.cat['records']} + + def result(self, when='2026-06-15', actor=A, direction='managers'): + return analyse(self.cat, when, actor, direction) + + def assert_code(self, code, field=None): + result = self.result() + hits = [f for f in result['findings'] if f['code'] == code and + (field is None or f.get('field') == field)] + self.assertTrue(hits, result) + if hits[0]['severity'] == 'error': + self.assertFalse(result['valid']) + self.assertEqual(result['pairs'], []) + return hits + + def add(self, source, identity, **fields): + r = dict(copy.deepcopy(self.records[source]), id=identity, **fields) + self.cat['records'].append(r) + self.records[identity] = r + return r + + def test_full_dated_oracle_and_reverse_evidence(self): + rows = [('2026-05-31', [(B, R1)], False), + ('2026-06-01', [(B, R1), (C, R2)], True), + ('2026-06-15', [(B, R1), (C, R2)], True), + ('2026-06-30', [(B, R1), (C, R2)], True), + ('2026-07-01', [(C, R2)], False), + ('2026-08-01', [(B, R3), (C, R2)], True)] + for when, expected, warning in rows: + with self.subTest(when=when): + result = self.result(when) + self.assertTrue(result['complete'] and result['valid'], result) + self.assertEqual(result['pairs'], [dict(from_actor=A, to_actor=b, relations=[r]) for b, r in expected]) + self.assertEqual(any(f['code'] == 'REL-013' for f in result['findings']), warning) + for manager in (B, C): + reverse = self.result(when, manager, 'reports') + self.assertEqual(reverse['pairs'], [p for p in result['pairs'] if p['to_actor'] == manager]) + self.assertEqual(self.result(when, manager)['pairs'], []) + self.assertEqual(self.result(when, A, 'reports')['pairs'], []) + scan = analyse(self.cat) + self.assertEqual([f['interval'] for f in scan['findings']], + [['2026-06-01', '2026-06-30'], ['2026-08-01', None]]) + + def test_endpoint_matrix_both_sides(self): + baseline = copy.deepcopy(self.cat) + for side in ('from', 'to'): + for subtype in ('business_unit', 'system', 'ROLE', 'unknown', 'unadmitted'): + with self.subTest(side=side, subtype=subtype): + self.cat = copy.deepcopy(baseline) + records = {r['id']: r for r in self.cat['records']} + target = records[records[R1][side]] + if subtype == 'unknown': + records[R1][side] = 'ACTOR-MISSING-1' + elif subtype == 'unadmitted': + target['admission_state'] = 'proposed' + elif subtype == 'ROLE': + target['id'] = 'ROLE-SYN-1' + records[R1][side] = target['id'] + target['notation'] = 'role' + else: + target['type'] = subtype + self.assert_code('REL-002', side) + + def test_endpoint_shapes_and_unknown_kind(self): + for side in ('from', 'to'): + for value in (None, [], [A, B], {}, 42, '', 'invalid'): + with self.subTest(side=side, value=value): + self.setUp() + self.records[R1][side] = value + self.assert_code('REL-002', side) + self.setUp() + del self.records[R1][side] + self.assert_code('REL-002', side) + for value in (None, 'invented', []): + self.setUp() + self.records[R1]['type'] = value + self.assert_code('REL-001') + + def test_identity_alias_self_duplicate_and_collisions(self): + self.records[A]['former_ids'] = ['ACTOR-OLD-1'] + self.records[R1]['to'] = 'ACTOR-OLD-1' + self.assert_code('REL-011') + self.records[R1]['to'] = A + self.assert_code('REL-011') + self.setUp() + self.records[B]['former_ids'] = ['ACTOR-OLD-1'] + self.add(R1, 'REL-SYN-REPORTING-4', to='ACTOR-OLD-1') + result = self.result('2026-05-31') + self.assertEqual(result['pairs'][0]['relations'], [R1, 'REL-SYN-REPORTING-4']) + self.assertEqual(result['findings'], []) + result = self.result() + warning = [f for f in result['findings'] if f['code'] == 'REL-013'][0] + self.assertEqual(warning['managers'], [B, C]) + self.assertEqual(len(warning['relations']), 3) + self.assertEqual(self.result('2026-08-01')['pairs'][0]['relations'], [R3]) + self.records[C]['former_ids'] = ['ACTOR-OLD-1'] + self.assert_code('ELEM-FORMER-ID-001') + self.assertFalse(self.result()['complete']) + + def test_temporal_two_and_three_person_cycles_and_matrix_dag(self): + self.add(R1, 'REL-REVERSE-1', **{'from': B, 'to': A, 'valid_from': '2026-06-30'}) + hit = self.assert_code('REL-012')[0] + self.assertEqual(hit['actor_path'], [A, B, A]) + self.assertEqual(hit['interval'], ['2026-06-30', '2026-06-30']) + self.assertEqual(hit['relations'], ['REL-REVERSE-1', R1]) + self.setUp() + self.add(R1, 'REL-BC-1', **{'from': B, 'to': C}) + self.assertTrue(self.result()['valid'], 'Matrix DAG must remain valid') + self.add(R1, 'REL-CA-1', **{'from': C, 'to': A, 'valid_from': '2026-06-01'}) + cycles = self.assert_code('REL-012') + self.assertTrue(any(f['actor_path'] == [A, B, C, A] for f in cycles)) + self.assertTrue(all(f['incident_edges'] and f['witness'] == '2026-06-01' for f in cycles)) + # Whole-history validation is independent of a query's date. + self.assertFalse(self.result('2030-01-01')['valid']) + self.setUp() + self.cat['records'] = [r for r in self.cat['records'] if r['id'] not in (R2, R3)] + self.add(R1, 'REL-REVERSE-1', **{'from': B, 'to': A, 'valid_from': '2026-07-01', 'valid_to': None}) + self.assertTrue(self.result()['valid']) + + def test_lifecycle_one_day_future_null_expired_and_bad_dates(self): + self.cat['records'] = [r for r in self.cat['records'] if r['id'] not in (R2, R3)] + self.records[R1].update(valid_from='2026-06-15', valid_to='2026-06-15') + for when, count in [('2026-06-14', 0), ('2026-06-15', 1), ('2026-06-16', 0)]: + self.assertEqual(len(self.result(when)['pairs']), count) + self.records[R1]['valid_to'] = None + self.assertEqual(self.result('2026-06-14')['pairs'], []) + self.assertEqual(len(self.result('2030-01-01')['pairs']), 1) + for field, value, code in [('valid_from', None, 'LIFECYCLE-001'), + ('valid_from', '2026-02-30', 'LIFECYCLE-001'), + ('valid_to', '2026-13-01', 'LIFECYCLE-002'), + ('valid_to', '2025-01-01', 'LIFECYCLE-003')]: + self.setUp() + self.records[R1][field] = value + self.assert_code(code, field) + for field, code in [('valid_from', 'LIFECYCLE-001'), ('valid_to', 'SCHEMA_INVALID')]: + self.setUp() + del self.records[R1][field] + self.assert_code(code, field) + + def test_containment_each_endpoint_no_clipping(self): + for side in ('from', 'to'): + for start, end in [('2026-01-02', None), ('2026-01-01', '2026-06-29')]: + self.setUp() + self.records[self.records[R1][side]].update(valid_from=start, valid_to=end) + self.assert_code('REL-003', side) + self.setUp() + self.records[self.records[R2][side]]['valid_to'] = '2026-12-31' + self.assert_code('REL-003', side) + self.setUp() + self.cat['records'] = [r for r in self.cat['records'] if r['id'] not in (R2, R3)] + for identity in (A, B): + self.records[identity]['valid_to'] = '2026-06-30' + for when in ('2026-01-01', '2026-06-30'): + self.assertTrue(self.result(when)['valid']) + self.assertEqual(len(self.result(when)['pairs']), 1) + self.assertEqual(self.result('2026-07-01')['pairs'], []) + + def test_draft_relation_independent_admission_inline_and_extensions(self): + for r in self.cat['records']: + if r['id'].startswith('REL-'): + r.update(admission_state='proposed', proposed_at='2026-01-01', proposed_by='synthetic') + del r['admitted_at'], r['admitted_by'] + r['gate_checks']['completeness'] = 'pending_review' + self.assertEqual(self.result()['pairs'], []) + self.assertTrue(self.result()['valid']) + self.setUp() + self.records[R1]['gate_checks']['consistency'] = 'fail' + self.assert_code('ADMIT-003') + self.setUp() + self.records[A]['extensions'] = {'reports_to': B, 'manager': B, 'arbitrary': {'anything': True}} + self.assertTrue(self.result()['valid']) + for field in ('manager', 'reports_to'): + self.records[A][field] = B + self.assert_code('REL-004', field) + del self.records[A][field] + + def test_employment_unit_parent_no_inferred_reporting(self): + unit = self.add(B, 'ACTOR-UNIT-1', type='business_unit') + self.add(B, 'ACTOR-UNIT-2', type='business_unit') + self.cat['records'] = [r for r in self.cat['records'] if not r['id'].startswith('REL-')] + employment = self.add(R1, 'REL-EMPLOYMENT-1', type='employment', to=unit['id'], roles=['ROLE-SYN-1']) + parent = self.add(R1, 'REL-UNIT-1', type='unit_parent', **{'from': unit['id'], 'to': 'ACTOR-UNIT-2'}) + self.assertTrue(self.result()['valid']) + self.assertEqual(self.result()['pairs'], []) + employment['to'] = B + self.assert_code('REL-002', 'to') + employment['to'] = unit['id'] + parent['from'] = A + self.assert_code('REL-002', 'from') + + def test_incomplete_population_and_parse_failures(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.assertFalse(analyse(load_catalogue(root))['complete']) + (root / 'transitrix.yaml').write_text('transitrix: 1\n') + (root / 'canon/elements').mkdir(parents=True) + (root / 'canon/relations').mkdir() + for text in ('id: [', 'id: ACTOR-A-1\nid: ACTOR-B-1', '- scalar', 'x: 1\n---\nx: 2'): + (root / 'canon/elements/broken.yaml').write_text(text) + result = analyse(load_catalogue(root)) + self.assertFalse(result['complete']) + self.assertEqual(result['pairs'], []) + with patch.object(Path, 'read_text', side_effect=PermissionError): + self.assertFalse(load_catalogue(root)['complete']) + (root / 'canon/elements/broken.yaml').unlink() + (root / 'canon/elements/transitrix.yaml').write_text('transitrix: 1\n') + self.assertFalse(load_catalogue(root)['complete']) + + def test_fail_closed_identity_header_and_unreadable_directory(self): + self.add(B, B) + self.assert_code('SCHEMA_INVALID', 'id') + self.assertFalse(self.result()['complete']) + self.setUp() + self.records[R1]['notation'] = 'actor' + self.assert_code('SCHEMA_INVALID', 'notation') + self.setUp() + self.records[B]['aliases'] = ['Former manager'] + self.records[R1]['to'] = 'Former manager' + self.assert_code('REL-002', 'to') + with patch.object(Path, 'iterdir', side_effect=PermissionError): + result = analyse(load_catalogue(EXAMPLE)) + self.assertFalse(result['complete']) + self.assertEqual(result['pairs'], []) + self.setUp() + self.records[B]['former_ids'] = [A] + self.assert_code('ELEM-FORMER-ID-001') + + def test_symlinked_catalogue_boundaries_are_incomplete(self): + for relative in ('.', 'transitrix.yaml', 'canon', 'canon/elements', + 'canon/relations', 'canon/relations/' + R1 + '.yaml'): + with self.subTest(path=relative), tempfile.TemporaryDirectory() as directory: + root = Path(directory) / 'catalogue' + shutil.copytree(EXAMPLE, root) + path = root / relative + target = Path(directory) / 'target' + path.rename(target) + path.symlink_to(target, target_is_directory=target.is_dir()) + result = analyse(load_catalogue(root), '2026-06-15', A) + self.assertFalse(result['complete'], result) + self.assertEqual(result['pairs'], []) + self.assertTrue(any(f['code'] == 'CATALOGUE_INCOMPLETE' + for f in result['findings'])) + + def test_metadata_errors_are_incomplete(self): + original = Path.lstat + for relative in ('transitrix.yaml', 'canon/relations/' + R1 + '.yaml'): + with self.subTest(path=relative): + def unreadable(path, *args, **kwargs): + if path == EXAMPLE / relative: + raise PermissionError('Cannot inspect file') + return original(path, *args, **kwargs) + with patch.object(Path, 'lstat', unreadable): + result = analyse(load_catalogue(EXAMPLE), '2026-06-15', A) + self.assertFalse(result['complete']) + self.assertEqual(result['pairs'], []) + self.assertTrue(any(f.get('path') == relative for f in result['findings'])) + + @unittest.skipUnless(hasattr(os, 'mkfifo'), 'Named pipes require POSIX') + def test_nonregular_yaml_is_rejected_without_reading(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / 'catalogue' + shutil.copytree(EXAMPLE, root) + os.mkfifo(root / 'canon/relations/stream.yaml') + result = analyse(load_catalogue(root), '2026-06-15', A) + self.assertFalse(result['complete']) + self.assertEqual(result['pairs'], []) + self.assertTrue(any(f.get('path') == 'canon/relations/stream.yaml' + for f in result['findings'])) + + def test_deterministic_order_and_cli_error_status(self): + expected = self.result() + self.cat['records'].reverse() + self.assertEqual(self.result()['pairs'], expected['pairs']) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + command = [sys.executable, str(ROOT / 'tools/reporting.py'), '--root', str(root)] + result = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(result.returncode, 2) + self.assertFalse(json.loads(result.stdout)['complete']) + (root / 'transitrix.yaml').write_text('transitrix: 1\n') + (root / 'canon/elements').mkdir(parents=True) + (root / 'canon/relations').mkdir() + for r in self.cat['records']: + folder = 'relations' if r['id'].startswith('REL-') else 'elements' + value = {k: v for k, v in r.items() if k != '_path'} + if r['id'] == R1: + value['to'] = A + (root / 'canon' / folder / (r['id'] + '.yaml')).write_text(yaml.safe_dump(value)) + result = subprocess.run(command, capture_output=True, text=True) + self.assertEqual(result.returncode, 1, result.stderr) + output = json.loads(result.stdout) + self.assertTrue(output['complete']) + self.assertTrue(any(f['code'] == 'REL-011' for f in output['findings'])) + + def test_cli_and_date_requirement(self): + result = subprocess.run([sys.executable, str(ROOT / 'tools/reporting.py'), '--root', str(EXAMPLE), + '--as-at', '2026-06-15', '--actor', A], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len(json.loads(result.stdout)['pairs']), 2) + self.assertFalse(analyse(self.cat, actor=A)['valid']) + self.assertFalse(self.result('2026-02-30')['valid']) + self.assertFalse(self.result(actor='ACTOR-MISSING-1')['valid']) + + +if __name__ == '__main__': + unittest.main()