From d35d5b79d830333cee972c88861d9c855e4ae990 Mon Sep 17 00:00:00 2001 From: zircote Date: Thu, 25 Jun 2026 13:15:32 -0400 Subject: [PATCH 1/3] feat(validation): temporal-consistency check + first-class scalar properties okf_validate.py: flag derivation edges (derived-from/supersedes/cites) whose target is created after the deriving concept. WARN by default (non-blocking); --strict-temporal promotes to a hard error for known-clean corpora in CI. mif_convert.py + schema: add a first-class scalar 'properties' field (literal key/value pairs with no concept target), wired into both passthrough lists and FRONTMATTER_ORDER so all conversions stay lossless. tests: temporal WARN/ERROR/no-false-positive, properties roundtrip + schema accept/reject, and a no-clobber guarantee across MIF -> OKF -> MIF -> JSON* -> MIF. --- schema/mif.schema.json | 7 ++ scripts/mif_convert.py | 3 + scripts/okf_validate.py | 90 +++++++++++++- scripts/test_temporal_and_properties.py | 157 ++++++++++++++++++++++++ test/properties/concept.md | 16 +++ test/temporal/bad/observation.md | 17 +++ test/temporal/bad/session.md | 9 ++ test/temporal/good/observation.md | 17 +++ test/temporal/good/session.md | 9 ++ 9 files changed, 319 insertions(+), 6 deletions(-) create mode 100644 scripts/test_temporal_and_properties.py create mode 100644 test/properties/concept.md create mode 100644 test/temporal/bad/observation.md create mode 100644 test/temporal/bad/session.md create mode 100644 test/temporal/good/observation.md create mode 100644 test/temporal/good/session.md diff --git a/schema/mif.schema.json b/schema/mif.schema.json index f5a1f98..6bdb376 100644 --- a/schema/mif.schema.json +++ b/schema/mif.schema.json @@ -120,6 +120,13 @@ "maxLength": 500, "description": "Compressed content summary (Level 3, max 500 chars)" }, + "properties": { + "type": "object", + "additionalProperties": { + "type": ["string", "number", "boolean", "null"] + }, + "description": "First-class scalar properties: literal key/value pairs (e.g. from a knowledge-graph literal-object triple) that have no concept target and therefore cannot be a relationship. Values MUST be scalar (string, number, boolean, or null); nested objects/arrays are out of scope at Level 1." + }, "compressedAt": { "type": "string", "format": "date-time", diff --git a/scripts/mif_convert.py b/scripts/mif_convert.py index 5feef35..deb962c 100644 --- a/scripts/mif_convert.py +++ b/scripts/mif_convert.py @@ -51,6 +51,7 @@ "namespace", "title", "summary", + "properties", "tags", "aliases", "temporal", @@ -131,6 +132,7 @@ def md_to_jsonld(frontmatter: dict, body: str) -> dict: "namespace", "title", "summary", + "properties", "tags", "aliases", "temporal", @@ -171,6 +173,7 @@ def jsonld_to_md(jsonld: dict) -> tuple[dict, str]: "namespace", "title", "summary", + "properties", "tags", "aliases", "temporal", diff --git a/scripts/okf_validate.py b/scripts/okf_validate.py index 46e185a..6010f89 100644 --- a/scripts/okf_validate.py +++ b/scripts/okf_validate.py @@ -13,13 +13,17 @@ 4. broken bundle-relative links are tolerated -- reported as warnings, never failures (OKF tolerates broken links); 5. the ``markdown -> json-ld -> markdown`` projection round-trips losslessly - (delegated to ``mif_convert.roundtrip_file``). + (delegated to ``mif_convert.roundtrip_file``); +6. derivation edges are temporally consistent -- a ``derived-from`` / ``supersedes`` + / ``cites`` target must not be ``created`` after the concept that derives from + it. Reported as a warning by default (non-blocking); ``--strict-temporal`` + promotes it to a hard error so a known-clean corpus can enforce it in CI. Exit code 0 means every concept in every bundle conforms. Usage:: - python okf_validate.py [bundle-dir ...] + python okf_validate.py [bundle-dir ...] [--strict-temporal] python okf_validate.py # defaults to examples/ + profiles/*/examples/ """ @@ -27,12 +31,18 @@ import re import sys +from datetime import datetime, timezone from pathlib import Path import mif_convert # local module (same scripts/ directory) REPO_ROOT = Path(__file__).resolve().parent.parent +# Relationship types whose target is a *prior* concept the source is built on: +# the target must not be created AFTER the source. (Q-a: derived-from + supersedes +# + cites.) Stored kebab-normalized to match _kebab() output. +DERIVATION_TYPES = {"derived-from", "supersedes", "cites"} + # A body relationship line: "- [Text](/path/to/target.md)". REL_LINE_RE = re.compile(r"^-\s+([a-z0-9][a-z0-9-]*)\s+\[[^\]]+\]\(([^)]+)\)\s*$") # Any markdown link, for broken-link scanning. @@ -86,8 +96,65 @@ def _resolve(target: str, md_path: Path, bundle: Path) -> Path | None: return (md_path.parent / target).resolve() -def validate_bundle(bundle: Path) -> tuple[list[str], list[str], int]: - """Validate one bundle. Returns (errors, warnings, concept_count).""" +def _parse_created(value: object) -> datetime | None: + """Parse an ISO-8601 ``created`` value to an aware datetime (UTC if naive).""" + if not isinstance(value, str) or not value: + return None + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc) + + +def _temporal_findings( + frontmatter: dict, md_path: Path, bundle: Path, rel_name: object +) -> list[str]: + """Findings where a derivation target is created AFTER the deriving concept. + + A ``derived-from`` / ``supersedes`` / ``cites`` edge asserts the target predates + the source; a target with a later ``created`` is a logical impossibility the + schema/round-trip checks cannot see. Targets that don't resolve to an existing + in-bundle concept, or where either ``created`` is missing/unparseable, are + skipped (no false positives). + """ + findings: list[str] = [] + source_created = _parse_created(frontmatter.get("created")) + if source_created is None: + return findings + for rel_type, target in _frontmatter_relationships(frontmatter): + if rel_type not in DERIVATION_TYPES: + continue + resolved = _resolve(target, md_path, bundle) + if resolved is None or resolved.suffix != ".md" or not resolved.exists(): + continue + if resolved.resolve() == md_path.resolve(): + continue + try: + tgt_fm, _ = mif_convert.parse_markdown(resolved.read_text()) + except (ValueError, OSError): + continue + target_created = _parse_created(tgt_fm.get("created")) + if target_created is None: + continue + if target_created > source_created: + findings.append( + f"{rel_name}: temporal inconsistency -> '{rel_type}' target " + f"{target} is created {tgt_fm.get('created')}, after concept " + f"created {frontmatter.get('created')}" + ) + return findings + + +def validate_bundle( + bundle: Path, strict_temporal: bool = False +) -> tuple[list[str], list[str], int]: + """Validate one bundle. Returns (errors, warnings, concept_count). + + ``strict_temporal`` promotes temporal-inconsistency findings from warnings to + hard errors (exit 1). Default is WARN so the check never blocks real-world use + until a corpus is known clean. + """ errors: list[str] = [] warnings: list[str] = [] count = 0 @@ -132,6 +199,10 @@ def validate_bundle(bundle: Path) -> tuple[list[str], list[str], int]: if rt_err: errors.append(f"{rel_name}: {rt_err}") + # (6) temporal consistency of derivation edges (WARN unless --strict-temporal). + temporal = _temporal_findings(frontmatter, md_path, bundle, rel_name) + (errors if strict_temporal else warnings).extend(temporal) + # (2) reserved-filename misuse is structural; rglob to catch any. for reserved in mif_convert.RESERVED_FILENAMES: for hit in bundle.rglob(reserved): @@ -154,7 +225,14 @@ def default_bundles() -> list[Path]: def main() -> None: args = sys.argv[1:] - bundles = [Path(a) for a in args] if args else default_bundles() + strict_temporal = False + positional: list[str] = [] + for arg in args: + if arg in ("--strict-temporal", "--temporal-strict"): + strict_temporal = True + else: + positional.append(arg) + bundles = [Path(a) for a in positional] if positional else default_bundles() if not bundles: print("No bundles found to validate.", file=sys.stderr) sys.exit(1) @@ -167,7 +245,7 @@ def main() -> None: if not bundle.exists(): all_errors.append(f"{bundle}: bundle directory not found") continue - errors, warnings, count = validate_bundle(bundle) + errors, warnings, count = validate_bundle(bundle, strict_temporal=strict_temporal) total += count all_errors.extend(errors) all_warnings.extend(warnings) diff --git a/scripts/test_temporal_and_properties.py b/scripts/test_temporal_and_properties.py new file mode 100644 index 0000000..abe364c --- /dev/null +++ b/scripts/test_temporal_and_properties.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Tests for the temporal-consistency check, the first-class scalar ``properties`` +construct, and the no-field-clobber guarantee across the full conversion chain. + +Run: ``python -m pytest scripts/test_temporal_and_properties.py -q`` from the repo root. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import jsonschema +import pytest + +import mif_convert +import okf_validate + +ROOT = Path(__file__).resolve().parent.parent +TEMPORAL = ROOT / "test" / "temporal" +PROPERTIES = ROOT / "test" / "properties" + + +# --------------------------------------------------------------------------- # +# Temporal consistency (Q-a: derived-from + supersedes + cites; Q-b: WARN, # +# promotable to ERROR via --strict-temporal). # +# --------------------------------------------------------------------------- # +def test_temporal_violation_is_warning_by_default(): + errors, warnings, count = okf_validate.validate_bundle(TEMPORAL / "bad") + assert count == 2 + assert errors == [] # non-blocking by default — must not fail the run + temporal = [w for w in warnings if "temporal inconsistency" in w] + assert len(temporal) == 1 + assert "derived-from" in temporal[0] + + +def test_temporal_violation_promotes_to_error_in_strict_mode(): + errors, warnings, _ = okf_validate.validate_bundle( + TEMPORAL / "bad", strict_temporal=True + ) + temporal = [e for e in errors if "temporal inconsistency" in e] + assert len(temporal) == 1 + assert [w for w in warnings if "temporal inconsistency" in w] == [] + + +def test_temporal_consistent_derivation_has_no_finding(): + errors, warnings, count = okf_validate.validate_bundle(TEMPORAL / "good") + assert count == 2 + assert errors == [] + assert [w for w in warnings if "temporal inconsistency" in w] == [] + + +def test_non_derivation_edge_is_not_temporally_checked(): + # A "relates-to" edge to a newer target is fine — only derivation edges order time. + fm = { + "created": "2025-01-01T00:00:00Z", + "relationships": [{"type": "relates-to", "target": "/session.md"}], + } + findings = okf_validate._temporal_findings( + fm, TEMPORAL / "bad" / "observation.md", TEMPORAL / "bad", "x" + ) + assert findings == [] + + +def test_missing_created_skips_check_no_false_positive(): + fm = {"relationships": [{"type": "derived-from", "target": "/session.md"}]} + findings = okf_validate._temporal_findings( + fm, TEMPORAL / "bad" / "observation.md", TEMPORAL / "bad", "x" + ) + assert findings == [] + + +# --------------------------------------------------------------------------- # +# First-class scalar ``properties``. # +# --------------------------------------------------------------------------- # +def test_properties_roundtrips_losslessly(): + assert mif_convert.roundtrip_file(PROPERTIES / "concept.md") is None + + +def test_properties_survive_md_to_jsonld_and_back(): + fm, body = mif_convert.parse_markdown((PROPERTIES / "concept.md").read_text()) + jsonld = json.loads(json.dumps(mif_convert.md_to_jsonld(fm, body))) + assert jsonld["properties"] == { + "status": "active", + "priority": 1, + "archived": False, + "retired_on": None, + } + fm2, _ = mif_convert.jsonld_to_md(jsonld) + assert fm2["properties"] == fm["properties"] + + +def _properties_subschema() -> dict: + schema = json.loads((ROOT / "schema" / "mif.schema.json").read_text()) + return schema["properties"]["properties"] + + +def test_schema_accepts_scalar_properties(): + jsonschema.Draft202012Validator(_properties_subschema()).validate( + {"status": "active", "priority": 1, "archived": False, "retired_on": None} + ) + + +@pytest.mark.parametrize("bad", [{"nested": {"a": 1}}, {"list": [1, 2]}]) +def test_schema_rejects_non_scalar_properties(bad): + with pytest.raises(jsonschema.ValidationError): + jsonschema.Draft202012Validator(_properties_subschema()).validate(bad) + + +# --------------------------------------------------------------------------- # +# No MIF field is clobbered across MIF -> OKF(JSON-LD) -> MIF -> JSON* -> MIF. # +# --------------------------------------------------------------------------- # +FULL_FRONTMATTER = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "type": "semantic", + "created": "2026-01-15T10:30:00Z", + "modified": "2026-02-01T08:00:00Z", + "namespace": "_semantic/observations", + "title": "Every-field concept", + "summary": "A concept exercising every top-level frontmatter field.", + "properties": {"status": "active", "priority": 1, "archived": False, "retired_on": None}, + "tags": ["a", "b"], + "aliases": ["alt-name"], + "temporal": {"validFrom": "2026-01-01T00:00:00Z", "validUntil": None}, + "provenance": {"sourceType": "agent_inferred", "confidence": 0.9}, + "embedding": {"model": "text-embed", "dimensions": 8}, + "relationships": [{"type": "derived-from", "target": "/other.md", "strength": 0.8}], + "citations": [{"title": "Ref", "url": "https://example.com/x"}], + "entities": [{"name": "Alice"}], + "ontology": {"id": "mif-base", "version": "1.0.0"}, + "entity": {"name": "Alice Chen", "entity_type": "person"}, + "blocks": {"b1": "block text"}, + "extensions": {"vendor": {"k": "v"}}, +} + + +def test_no_field_clobbered_across_full_conversion_chain(): + body = "Body content for the every-field concept." + # MIF -> OKF (JSON-LD projection) + jsonld = mif_convert.md_to_jsonld(FULL_FRONTMATTER, body) + # Every MIF source field must be carried into the OKF projection (no drop/clobber). + for key, value in FULL_FRONTMATTER.items(): + if key == "id": + assert jsonld["@id"] == f"urn:mif:{value}" + elif key == "type": + assert jsonld["conceptType"] == value + else: + assert jsonld[key] == value, f"OKF projection clobbered {key!r}" + # OKF -> MIF + fm1, body1 = mif_convert.jsonld_to_md(jsonld) + # MIF -> JSON* (serialize) -> MIF (the on-disk markdown round) + md = mif_convert.serialize_markdown(fm1, body1) + fm2, body2 = mif_convert.parse_markdown(md) + # Final MIF frontmatter must equal the original, field for field. + assert fm2 == FULL_FRONTMATTER + for key in FULL_FRONTMATTER: + assert fm2[key] == FULL_FRONTMATTER[key], f"chain clobbered {key!r}" + assert body2.strip() == body.strip() diff --git a/test/properties/concept.md b/test/properties/concept.md new file mode 100644 index 0000000..dd5757f --- /dev/null +++ b/test/properties/concept.md @@ -0,0 +1,16 @@ +--- +id: 1f2e3d4c-5b6a-5e7f-8a9b-0c1d2e3f4a5b +type: semantic +created: '2026-02-01T09:00:00Z' +namespace: _semantic/observations +title: Project Phoenix facts +properties: + status: active + priority: 1 + archived: false + retired_on: null +--- + +Literal-object knowledge-graph triples (predicate -> scalar value) map to +first-class `properties`, not to relationships, because there is no concept +`target` to point at. diff --git a/test/temporal/bad/observation.md b/test/temporal/bad/observation.md new file mode 100644 index 0000000..14750a3 --- /dev/null +++ b/test/temporal/bad/observation.md @@ -0,0 +1,17 @@ +--- +id: 897fbf98-a6cd-5e0d-b719-18095c15ab5c +type: semantic +created: '2025-03-04T12:00:00Z' +namespace: _semantic/observations +title: Alice Chen works on Project Phoenix +relationships: +- type: derived-from + target: /session.md +--- + +Reproduces PR #65: this observation is `derived-from` a session that is +`created` ten months in its future, which is logically impossible. + +## Relationships + +- derived-from [Phoenix Rate-Spike Debug Session](/session.md) diff --git a/test/temporal/bad/session.md b/test/temporal/bad/session.md new file mode 100644 index 0000000..0a4bda1 --- /dev/null +++ b/test/temporal/bad/session.md @@ -0,0 +1,9 @@ +--- +id: 0c523a47-28b3-559f-94ce-692cf88c2d9f +type: episodic +created: '2026-01-15T10:30:00Z' +namespace: _episodic/sessions +title: Phoenix Rate-Spike Debug Session +--- + +A debug session created after the observation that claims to derive from it. diff --git a/test/temporal/good/observation.md b/test/temporal/good/observation.md new file mode 100644 index 0000000..01b4bca --- /dev/null +++ b/test/temporal/good/observation.md @@ -0,0 +1,17 @@ +--- +id: 897fbf98-a6cd-5e0d-b719-18095c15ab5c +type: semantic +created: '2026-02-01T12:00:00Z' +namespace: _semantic/observations +title: Alice Chen works on Project Phoenix +relationships: +- type: derived-from + target: /session.md +--- + +Same shape as the bad fixture, but `created` is after its source session, so +the derivation is temporally consistent and must produce no finding. + +## Relationships + +- derived-from [Phoenix Rate-Spike Debug Session](/session.md) diff --git a/test/temporal/good/session.md b/test/temporal/good/session.md new file mode 100644 index 0000000..bf706e4 --- /dev/null +++ b/test/temporal/good/session.md @@ -0,0 +1,9 @@ +--- +id: 0c523a47-28b3-559f-94ce-692cf88c2d9f +type: episodic +created: '2026-01-15T10:30:00Z' +namespace: _episodic/sessions +title: Phoenix Rate-Spike Debug Session +--- + +The source session, created before the observation that derives from it. From 4db9a7d5ac012dacc9e8ef99443044145e079f06 Mon Sep 17 00:00:00 2001 From: zircote Date: Thu, 25 Jun 2026 13:46:45 -0400 Subject: [PATCH 2/3] fix(validation): address independent review findings - okf_validate: catch yaml.YAMLError so a malformed-YAML derivation target is skipped, not a crash (reserved index.md/log.md targets are only ever parsed here); compare date-only 'created' at date granularity to avoid a same-day false positive under --strict-temporal. - mif_convert: add compressedAt and memoryType to FRONTMATTER_ORDER and both passthrough lists; they were silently dropped on round-trip (lossless gap). - tests: exercise the JSON serialization leg (json.dumps/loads); enumerate all schema source fields (non-circular); cover supersedes/cites, equal-timestamp boundary, naive datetime, target-missing-created, multi-violation. 11 -> 22. --- scripts/mif_convert.py | 6 + scripts/okf_validate.py | 30 ++++- scripts/test_temporal_and_properties.py | 144 +++++++++++++++++++++++- 3 files changed, 173 insertions(+), 7 deletions(-) diff --git a/scripts/mif_convert.py b/scripts/mif_convert.py index deb962c..ecfdcba 100644 --- a/scripts/mif_convert.py +++ b/scripts/mif_convert.py @@ -46,12 +46,14 @@ FRONTMATTER_ORDER = [ "id", "type", + "memoryType", "created", "modified", "namespace", "title", "summary", "properties", + "compressedAt", "tags", "aliases", "temporal", @@ -127,12 +129,14 @@ def md_to_jsonld(frontmatter: dict, body: str) -> dict: jsonld["conceptType"] = fm["type"] passthrough = [ + "memoryType", "created", "modified", "namespace", "title", "summary", "properties", + "compressedAt", "tags", "aliases", "temporal", @@ -168,12 +172,14 @@ def jsonld_to_md(jsonld: dict) -> tuple[dict, str]: fm["type"] = jsonld["conceptType"] passthrough = [ + "memoryType", "created", "modified", "namespace", "title", "summary", "properties", + "compressedAt", "tags", "aliases", "temporal", diff --git a/scripts/okf_validate.py b/scripts/okf_validate.py index 6010f89..0df4eb0 100644 --- a/scripts/okf_validate.py +++ b/scripts/okf_validate.py @@ -34,6 +34,8 @@ from datetime import datetime, timezone from pathlib import Path +import yaml # PyYAML; parse_markdown's safe_load can raise yaml.YAMLError + import mif_convert # local module (same scripts/ directory) REPO_ROOT = Path(__file__).resolve().parent.parent @@ -107,6 +109,11 @@ def _parse_created(value: object) -> datetime | None: return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc) +def _is_date_only(value: object) -> bool: + """True when a ``created`` value carries no time-of-day (e.g. ``2024-06-01``).""" + return isinstance(value, str) and "T" not in value and " " not in value.strip() + + def _temporal_findings( frontmatter: dict, md_path: Path, bundle: Path, rel_name: object ) -> list[str]: @@ -119,7 +126,8 @@ def _temporal_findings( skipped (no false positives). """ findings: list[str] = [] - source_created = _parse_created(frontmatter.get("created")) + source_raw = frontmatter.get("created") + source_created = _parse_created(source_raw) if source_created is None: return findings for rel_type, target in _frontmatter_relationships(frontmatter): @@ -132,16 +140,28 @@ def _temporal_findings( continue try: tgt_fm, _ = mif_convert.parse_markdown(resolved.read_text()) - except (ValueError, OSError): + except (ValueError, OSError, yaml.YAMLError): continue - target_created = _parse_created(tgt_fm.get("created")) + target_raw = tgt_fm.get("created") + target_created = _parse_created(target_raw) if target_created is None: continue + # If either side is date-only, a finer-grained other side would otherwise + # be compared against a manufactured midnight; downgrade both to date + # granularity so a same-day derivation is not a false positive. + if _is_date_only(source_raw) or _is_date_only(target_raw): + if target_created.date() > source_created.date(): + findings.append( + f"{rel_name}: temporal inconsistency -> '{rel_type}' target " + f"{target} is created {target_raw}, after concept " + f"created {source_raw}" + ) + continue if target_created > source_created: findings.append( f"{rel_name}: temporal inconsistency -> '{rel_type}' target " - f"{target} is created {tgt_fm.get('created')}, after concept " - f"created {frontmatter.get('created')}" + f"{target} is created {target_raw}, after concept " + f"created {source_raw}" ) return findings diff --git a/scripts/test_temporal_and_properties.py b/scripts/test_temporal_and_properties.py index abe364c..a7bf7ed 100644 --- a/scripts/test_temporal_and_properties.py +++ b/scripts/test_temporal_and_properties.py @@ -39,6 +39,8 @@ def test_temporal_violation_promotes_to_error_in_strict_mode(): ) temporal = [e for e in errors if "temporal inconsistency" in e] assert len(temporal) == 1 + # No unrelated errors should appear in the bad bundle (scope the assertion). + assert [e for e in errors if "temporal inconsistency" not in e] == [] assert [w for w in warnings if "temporal inconsistency" in w] == [] @@ -69,6 +71,97 @@ def test_missing_created_skips_check_no_false_positive(): assert findings == [] +def _bundle_with_target(tmp_path, target_created): + """A bundle whose /target.md carries ``target_created`` (or no created if None).""" + fm = "" if target_created is None else f"created: {target_created}\n" + (tmp_path / "target.md").write_text( + f"---\nid: t\ntype: semantic\n{fm}---\nbody\n" + ) + return tmp_path + + +def _findings_for(tmp_path, rel_type, source_created, target_created): + bundle = _bundle_with_target(tmp_path, target_created) + fm = { + "created": source_created, + "relationships": [{"type": rel_type, "target": "/target.md"}], + } + return okf_validate._temporal_findings(fm, bundle / "src.md", bundle, "src") + + +@pytest.mark.parametrize("rel_type", ["derived-from", "supersedes", "cites"]) +def test_all_derivation_types_flag_future_target(tmp_path, rel_type): + # Every member of DERIVATION_TYPES must be temporally checked, not just derived-from. + findings = _findings_for( + tmp_path, rel_type, "2025-01-01T00:00:00Z", "2026-01-01T00:00:00Z" + ) + assert len(findings) == 1 + assert rel_type in findings[0] + + +def test_equal_timestamps_is_not_a_violation(tmp_path): + # Boundary: target created exactly at source time is "not after" -> no finding. + ts = "2026-01-01T00:00:00Z" + assert _findings_for(tmp_path, "derived-from", ts, ts) == [] + + +def test_date_only_same_day_is_not_a_false_positive(tmp_path): + # Date-only source vs same-day finer-grained target must not flag (no midnight bias). + findings = _findings_for( + tmp_path, "derived-from", "2024-06-01", "2024-06-01T09:00:00Z" + ) + assert findings == [] + + +def test_date_only_next_day_target_is_flagged(tmp_path): + findings = _findings_for( + tmp_path, "derived-from", "2024-06-01", "2024-06-02T00:00:00Z" + ) + assert len(findings) == 1 + + +def test_naive_source_datetime_does_not_raise(tmp_path): + # Naive (no tz) source is normalized to UTC; aware-vs-naive comparison must not raise. + findings = _findings_for( + tmp_path, "derived-from", "2025-01-15T10:30:00", "2026-01-01T00:00:00Z" + ) + assert len(findings) == 1 + + +def test_target_missing_created_skips_no_false_positive(tmp_path): + assert _findings_for(tmp_path, "derived-from", "2025-01-01T00:00:00Z", None) == [] + + +def test_malformed_yaml_target_is_skipped_not_crash(tmp_path): + # A derivation target with malformed YAML frontmatter must be skipped, never crash + # the run (the function's documented "unparseable -> skip" contract). + (tmp_path / "target.md").write_text("---\n bad: : : yaml\n :::\n---\nbody\n") + fm = { + "created": "2025-01-01T00:00:00Z", + "relationships": [{"type": "derived-from", "target": "/target.md"}], + } + assert okf_validate._temporal_findings(fm, tmp_path / "src.md", tmp_path, "x") == [] + + +def test_multiple_future_targets_all_reported(tmp_path): + # Two future-target derivation edges -> two findings (guards an early-break regression). + (tmp_path / "a.md").write_text( + "---\nid: a\ntype: semantic\ncreated: 2026-01-01T00:00:00Z\n---\nbody\n" + ) + (tmp_path / "b.md").write_text( + "---\nid: b\ntype: semantic\ncreated: 2026-06-01T00:00:00Z\n---\nbody\n" + ) + fm = { + "created": "2025-01-01T00:00:00Z", + "relationships": [ + {"type": "derived-from", "target": "/a.md"}, + {"type": "supersedes", "target": "/b.md"}, + ], + } + findings = okf_validate._temporal_findings(fm, tmp_path / "src.md", tmp_path, "x") + assert len(findings) == 2 + + # --------------------------------------------------------------------------- # # First-class scalar ``properties``. # # --------------------------------------------------------------------------- # @@ -112,12 +205,25 @@ def test_schema_rejects_non_scalar_properties(bad): FULL_FRONTMATTER = { "id": "550e8400-e29b-41d4-a716-446655440000", "type": "semantic", + "memoryType": "semantic", "created": "2026-01-15T10:30:00Z", "modified": "2026-02-01T08:00:00Z", "namespace": "_semantic/observations", "title": "Every-field concept", "summary": "A concept exercising every top-level frontmatter field.", - "properties": {"status": "active", "priority": 1, "archived": False, "retired_on": None}, + # Scalar property values that YAML would re-type on reparse if left unquoted + # ("true"/"null"/date-/number-like strings) — pins the quoting guarantee. + "properties": { + "status": "active", + "priority": 1, + "archived": False, + "retired_on": None, + "flag_str": "true", + "void_str": "null", + "when_str": "2026-01-01", + "ratio_str": "1.5", + }, + "compressedAt": "2026-02-01T08:00:00Z", "tags": ["a", "b"], "aliases": ["alt-name"], "temporal": {"validFrom": "2026-01-01T00:00:00Z", "validUntil": None}, @@ -132,6 +238,18 @@ def test_schema_rejects_non_scalar_properties(bad): "extensions": {"vendor": {"k": "v"}}, } +# JSON-LD keys produced by the projection itself (not 1:1 source frontmatter +# fields); excluded when asserting that every schema-defined source field survives. +_PROJECTION_ONLY_KEYS = { + "@context", + "@type", + "@id", + "conceptType", + "timestamp", + "description", + "content", +} + def test_no_field_clobbered_across_full_conversion_chain(): body = "Body content for the every-field concept." @@ -145,9 +263,11 @@ def test_no_field_clobbered_across_full_conversion_chain(): assert jsonld["conceptType"] == value else: assert jsonld[key] == value, f"OKF projection clobbered {key!r}" + # MIF -> JSON* : actually serialize through JSON, as an on-disk projection would. + jsonld = json.loads(json.dumps(jsonld)) # OKF -> MIF fm1, body1 = mif_convert.jsonld_to_md(jsonld) - # MIF -> JSON* (serialize) -> MIF (the on-disk markdown round) + # MIF -> serialized markdown -> MIF (the on-disk markdown round) md = mif_convert.serialize_markdown(fm1, body1) fm2, body2 = mif_convert.parse_markdown(md) # Final MIF frontmatter must equal the original, field for field. @@ -155,3 +275,23 @@ def test_no_field_clobbered_across_full_conversion_chain(): for key in FULL_FRONTMATTER: assert fm2[key] == FULL_FRONTMATTER[key], f"chain clobbered {key!r}" assert body2.strip() == body.strip() + + +def test_every_schema_source_field_survives_round_trip(): + """Regression guard: every top-level schema property that is a source field + (not a projection-only key) must be present in FULL_FRONTMATTER and survive + the full chain. A new schema field that is forgotten in the converter's + passthrough lists fails here instead of silently dropping.""" + schema = json.loads((ROOT / "schema" / "mif.schema.json").read_text()) + source_fields = set(schema["properties"]) - _PROJECTION_ONLY_KEYS + # memoryType maps to the same `type`/conceptType slot; one stands in for both. + covered = set(FULL_FRONTMATTER) + missing = source_fields - covered + assert not missing, f"schema source field(s) not exercised by the chain: {missing}" + jsonld = json.loads(json.dumps(mif_convert.md_to_jsonld(FULL_FRONTMATTER, "b"))) + fm2, _ = mif_convert.jsonld_to_md(jsonld) + for field in source_fields: + if field == "type": # surfaced as conceptType, recovered as type + assert fm2["type"] == FULL_FRONTMATTER["type"] + else: + assert fm2.get(field) == FULL_FRONTMATTER[field], f"dropped {field!r}" From 01a4cabc8fe3340a45c87555f7d30796751382ef Mon Sep 17 00:00:00 2001 From: zircote Date: Thu, 25 Jun 2026 14:10:24 -0400 Subject: [PATCH 3/3] fix(validation): address Copilot review on #79 - okf_validate: restrict temporal-check target reads to within the bundle; a target escaping via ../ must not cause reads of arbitrary files outside the bundle (matches the function's documented 'in-bundle' contract). - tests: add explicit sys.path for scripts/ so imports also work under importmode=importlib / direct execution (already passed under pytest's prepend mode); correct an inaccurate comment claiming memoryType merges into the conceptType slot (it is a standalone passthrough key); add a bundle-escape regression test. --- scripts/okf_validate.py | 4 ++++ scripts/test_temporal_and_properties.py | 30 ++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/scripts/okf_validate.py b/scripts/okf_validate.py index 0df4eb0..f2df1f3 100644 --- a/scripts/okf_validate.py +++ b/scripts/okf_validate.py @@ -136,6 +136,10 @@ def _temporal_findings( resolved = _resolve(target, md_path, bundle) if resolved is None or resolved.suffix != ".md" or not resolved.exists(): continue + # Stay inside the bundle: a target that escapes (e.g. ``../other.md``) + # must not cause us to read an arbitrary file outside the bundle tree. + if not resolved.resolve().is_relative_to(bundle.resolve()): + continue if resolved.resolve() == md_path.resolve(): continue try: diff --git a/scripts/test_temporal_and_properties.py b/scripts/test_temporal_and_properties.py index a7bf7ed..db5bc7d 100644 --- a/scripts/test_temporal_and_properties.py +++ b/scripts/test_temporal_and_properties.py @@ -7,13 +7,19 @@ from __future__ import annotations import json +import sys from pathlib import Path import jsonschema import pytest -import mif_convert -import okf_validate +# ``mif_convert``/``okf_validate`` live in this scripts/ dir. pytest's default +# (prepend) import mode already puts it on sys.path, but make it explicit so the +# imports also work under importmode=importlib or direct execution. +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import mif_convert # noqa: E402 +import okf_validate # noqa: E402 ROOT = Path(__file__).resolve().parent.parent TEMPORAL = ROOT / "test" / "temporal" @@ -120,6 +126,22 @@ def test_date_only_next_day_target_is_flagged(tmp_path): assert len(findings) == 1 +def test_target_escaping_the_bundle_is_skipped(tmp_path): + # A target that resolves outside the bundle must NOT be read (no arbitrary + # file reads), so it produces no finding even with a future created. + bundle = tmp_path / "bundle" + bundle.mkdir() + (tmp_path / "outside.md").write_text( + "---\nid: o\ntype: semantic\ncreated: 2026-01-01T00:00:00Z\n---\nbody\n" + ) + fm = { + "created": "2025-01-01T00:00:00Z", + "relationships": [{"type": "derived-from", "target": "../outside.md"}], + } + findings = okf_validate._temporal_findings(fm, bundle / "src.md", bundle, "src") + assert findings == [] + + def test_naive_source_datetime_does_not_raise(tmp_path): # Naive (no tz) source is normalized to UTC; aware-vs-naive comparison must not raise. findings = _findings_for( @@ -284,7 +306,9 @@ def test_every_schema_source_field_survives_round_trip(): passthrough lists fails here instead of silently dropping.""" schema = json.loads((ROOT / "schema" / "mif.schema.json").read_text()) source_fields = set(schema["properties"]) - _PROJECTION_ONLY_KEYS - # memoryType maps to the same `type`/conceptType slot; one stands in for both. + # `type` is recovered from conceptType; every other source field (including + # memoryType, which is its own standalone passthrough key) must be present in + # FULL_FRONTMATTER and round-trip as itself. covered = set(FULL_FRONTMATTER) missing = source_fields - covered assert not missing, f"schema source field(s) not exercised by the chain: {missing}"