diff --git a/AGENTS.md b/AGENTS.md
index 3b4110b..9c8a7fe 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -50,5 +50,5 @@ curl -s -X POST http://127.0.0.1:8000/progress/ \
## Boundaries
- Do not copy the OnCo corpus, logos, or CC BY-NC dataset into this repo.
-- `tests/onco-reference/local/` and `authorization.json` stay gitignored until CONTENT-WP-0007's data-use gate is cleared.
+- `tests/onco-reference/local/` and `authorization.json` stay gitignored. Local reference use is authorized only with the CC origin notice. Redistribution and public CI are not granted.
- Fetch and pin upstream with `scripts/bootstrap-upstream.sh` under CONTENT-WP-0000. Do not treat the seed as a fork.
diff --git a/README.md b/README.md
index cfbf603..5fbce2c 100644
--- a/README.md
+++ b/README.md
@@ -27,4 +27,4 @@ python3 -m unittest tests.test_contract tests.test_ingest
## OnCo comparison track
-[CONTENT-WP-0007](workplans/CONTENT-WP-0007-onco-reference-slice.md) specifies a small, pinned, **authorized** OnCo reference-slice test program. No actual OnCo data is included. Public fixtures remain independently authored; real-data ingestion is disabled until the relevant data rights are cleared.
+[CONTENT-WP-0007](workplans/CONTENT-WP-0007-onco-reference-slice.md) is a small, pinned OnCo reference slice for local conformance. The repository owner authorized that local use when the page shows the CC BY-NC 4.0 origin notice. The slice stays gitignored. Public fixtures remain independently authored, and public CI does not fetch or publish the corpus.
diff --git a/connectors/AUTHORING.md b/connectors/AUTHORING.md
index f29a7c8..23117c0 100644
--- a/connectors/AUTHORING.md
+++ b/connectors/AUTHORING.md
@@ -10,3 +10,11 @@ Certify a connector with a pinned local file, not a live API:
4. A changed file refreshes the snapshot. The report records the connector version, the input digest, and the counts.
`connectors/wikidata_export.py` and `tests/test_wikidata_connector.py` are the worked example. The input is `examples/sources/wikidata/computing-basics.json`.
+
+## Update and governance
+
+The repository owner refreshes a pinned source. A new source needs a license review before activation, and the review names the license, the attribution duties, and whether `publish_directory` may write it under `examples/`. `CC-BY-NC-4.0` stays out of that allowlist.
+
+Wikidata computing-basics is CC0. Refresh it by replacing `examples/sources/wikidata/computing-basics.json` with a new pinned export, then rerun the connector tests. The import report keeps the connector version and the input digest.
+
+The OnCo reference slice is a separate adapter, `connectors/onco_reference.py`. It writes only under `tests/onco-reference/local/` after `tests/onco-reference/authorization.json` grants local use and refuses redistribution and public CI. The owner of that file is the repository owner. The slice is not a published dataspace. Pages that render it show the CC BY-NC origin notice from the source record: attribution, license link, material link, retrieval date, origin SHA, non-commercial mark, and modification notice.
diff --git a/connectors/onco_reference.py b/connectors/onco_reference.py
new file mode 100644
index 0000000..a9d0fbf
--- /dev/null
+++ b/connectors/onco_reference.py
@@ -0,0 +1,502 @@
+"""Allowlist-only OnCo reference adapter.
+
+Reads enumerated records from a local upstream checkout and writes a snapshot
+under the gitignored reference directory. It does not publish into
+``examples/`` and it does not import UI code. Edges that leave the allowlist
+are reported and are not stored as relations.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+from content_explorer.attribution import attribution_gaps
+from content_explorer.contract import validate_documents
+from content_explorer.ingest import build_manifest
+
+PIN = "b594d7433bf4e408b44215e25e5304696d6f840e"
+LICENSE = "CC-BY-NC-4.0"
+LICENSE_URL = "https://creativecommons.org/licenses/by-nc/4.0/"
+ATTRIBUTION = "Data from OnCo (onco.cc)"
+MODIFICATION = (
+ "Projected into a content-explorer snapshot. Titles, summaries, and "
+ "in-slice relations keep the pinned OnCo wording. Edges outside the "
+ "allowlist are listed in the import report and are not stored."
+)
+RELATION_FIELDS = {
+ "targets": "targets",
+ "drugs": "drugs",
+ "trials": "trials",
+ "terms": "terms",
+ "technologies": "technologies",
+ "pathways": "pathways",
+ "companies": "companies",
+ "institutions": "institutions",
+ "cancers": "cancers",
+ "related": "related",
+ "people": "people",
+ "papers": "papers",
+ "keyPapers": "key-papers",
+}
+LOCAL_ROOT_NAME = Path("tests") / "onco-reference" / "local"
+
+
+def load_authorization(path: Path) -> dict[str, Any]:
+ document = json.loads(path.read_text(encoding="utf-8"))
+ if not isinstance(document, dict) or document.get("granted") is not True:
+ raise RuntimeError("OnCo reference use is not authorized")
+ if document.get("distribution_allowed") is not False or document.get("ci_allowed") is not False:
+ raise RuntimeError("this grant does not allow redistribution or public CI")
+ return document
+
+
+def _skip_string(text: str, index: int) -> int:
+ quote = text[index]
+ index += 1
+ length = len(text)
+ while index < length:
+ char = text[index]
+ if char == "\\":
+ index += 2
+ continue
+ if quote == "`" and char == "$" and index + 1 < length and text[index + 1] == "{":
+ depth = 1
+ index += 2
+ while index < length and depth:
+ if text[index] in ('"', "'", "`"):
+ index = _skip_string(text, index)
+ continue
+ if text[index] == "{":
+ depth += 1
+ elif text[index] == "}":
+ depth -= 1
+ index += 1
+ continue
+ if char == quote:
+ return index + 1
+ index += 1
+ return index
+
+
+def _skip_comment(text: str, index: int) -> int:
+ if text.startswith("//", index):
+ newline = text.find("\n", index)
+ return length if (length := len(text)) and newline < 0 else newline + 1
+ if text.startswith("/*", index):
+ end = text.find("*/", index + 2)
+ return len(text) if end < 0 else end + 2
+ return index
+
+
+def find_object(text: str, entity_id: str) -> str:
+ """Return the object literal whose own ``id`` is ``entity_id``."""
+ length = len(text)
+ index = 0
+ depth = 0
+ starts: list[int] = []
+ pending: tuple[int, int] | None = None
+ while index < length:
+ char = text[index]
+ if char in ('"', "'", "`"):
+ index = _skip_string(text, index)
+ continue
+ if char == "/" and index + 1 < length and text[index + 1] in "/*":
+ index = _skip_comment(text, index)
+ continue
+ if char == "{":
+ starts.append(index)
+ depth += 1
+ index += 1
+ continue
+ if char == "}":
+ start = starts.pop() if starts else 0
+ if pending and pending[0] == depth:
+ return text[start:index + 1]
+ depth -= 1
+ index += 1
+ continue
+ if text.startswith("id", index) and (index == 0 or not (text[index - 1].isalnum() or text[index - 1] == "_")):
+ cursor = index + 2
+ while cursor < length and text[cursor].isspace():
+ cursor += 1
+ if cursor < length and text[cursor] == ":":
+ cursor += 1
+ while cursor < length and text[cursor].isspace():
+ cursor += 1
+ if cursor < length and text[cursor] in ('"', "'"):
+ end = _skip_string(text, cursor)
+ literal = text[cursor + 1:end - 1]
+ if literal == entity_id and starts:
+ pending = (depth, starts[-1])
+ index += 1
+ raise ValueError(f"{entity_id} was not found")
+
+
+def _string_literal(text: str, index: int) -> tuple[str, int]:
+ end = _skip_string(text, index)
+ return text[index + 1:end - 1], end
+
+
+def object_fields(text: str, constants: dict[str, str]) -> dict[str, Any]:
+ """Read depth-1 keys. Nested objects are skipped. String arrays are kept."""
+ fields: dict[str, Any] = {}
+ length = len(text)
+ index = 0
+ depth = 0
+ while index < length:
+ char = text[index]
+ if char in ('"', "'", "`"):
+ index = _skip_string(text, index)
+ continue
+ if char == "/" and index + 1 < length and text[index + 1] in "/*":
+ index = _skip_comment(text, index)
+ continue
+ if char == "{":
+ depth += 1
+ index += 1
+ continue
+ if char == "}":
+ depth -= 1
+ index += 1
+ continue
+ if depth != 1 or not (char.isalpha() or char == "_"):
+ index += 1
+ continue
+ start = index
+ while index < length and (text[index].isalnum() or text[index] == "_"):
+ index += 1
+ key = text[start:index]
+ while index < length and text[index].isspace():
+ index += 1
+ if index < length and text[index] in ",}":
+ if key in constants:
+ fields[key] = constants[key]
+ continue
+ if index >= length or text[index] != ":":
+ continue
+ index += 1
+ while index < length and text[index].isspace():
+ index += 1
+ if index >= length:
+ break
+ if text[index] in ('"', "'", "`"):
+ value, index = _string_literal(text, index)
+ fields[key] = value
+ continue
+ if text[index] == "[":
+ index += 1
+ values: list[str] = []
+ while index < length and text[index] != "]":
+ if text[index] in ('"', "'", "`"):
+ value, index = _string_literal(text, index)
+ values.append(value)
+ continue
+ if text[index] in ('"', "'", "`"):
+ index = _skip_string(text, index)
+ continue
+ if text[index] == "{":
+ nested = 1
+ index += 1
+ while index < length and nested:
+ if text[index] in ('"', "'", "`"):
+ index = _skip_string(text, index)
+ continue
+ if text[index] == "{":
+ nested += 1
+ elif text[index] == "}":
+ nested -= 1
+ index += 1
+ continue
+ index += 1
+ fields[key] = values
+ index += 1
+ continue
+ ident_start = index
+ while index < length and (text[index].isalnum() or text[index] == "_"):
+ index += 1
+ ident = text[ident_start:index]
+ if ident in constants:
+ fields[key] = constants[ident]
+ continue
+ if index == ident_start:
+ index += 1
+ continue
+ return fields
+
+
+def file_constants(text: str) -> dict[str, str]:
+ constants: dict[str, str] = {}
+ marker = "const asOf = "
+ at = text.find(marker)
+ if at >= 0:
+ cursor = at + len(marker)
+ if cursor < len(text) and text[cursor] in ('"', "'"):
+ value, _end = _string_literal(text, cursor)
+ constants["asOf"] = value
+ return constants
+
+
+OPTIONAL_FIELDS = ("aka", "status")
+
+
+def read_record(upstream: Path, entry: dict[str, str]) -> dict[str, Any]:
+ path = upstream / entry["file"]
+ text = path.read_text(encoding="utf-8")
+ obj = find_object(text, entry["id"])
+ fields = object_fields(obj, file_constants(text))
+ if fields.get("id") != entry["id"]:
+ raise ValueError(f"{entry['id']} object did not keep its id")
+ title = str(fields.get("name") or "").strip()
+ tldr = str(fields.get("tldr") or "").strip()
+ if not title or not tldr:
+ raise ValueError(f"{entry['id']} is missing a name or tldr")
+ relations: list[tuple[str, str]] = []
+ for field, predicate in RELATION_FIELDS.items():
+ for target in fields.get(field) or []:
+ if isinstance(target, str) and target:
+ relations.append((predicate, target))
+ return {
+ "id": entry["id"],
+ "kind": entry["kind"],
+ "title": title,
+ "tldr": tldr,
+ "summary": str(fields.get("summary") or tldr).strip(),
+ "asOf": str(fields.get("asOf") or "2026-09-04"),
+ "relations": relations,
+ "file": entry["file"],
+ "missingFields": [
+ name for name in OPTIONAL_FIELDS
+ if name not in fields or fields.get(name) in ("", None, [])
+ ],
+ }
+
+
+def project_slice(
+ records: list[dict[str, Any]],
+ *,
+ source: dict[str, Any],
+ kinds: list[dict[str, str]],
+ relation_labels: dict[str, dict[str, str]],
+ require_size: bool = True,
+) -> dict[str, Any]:
+ """Build a snapshot. Outside-allowlist edges are reported, not stored."""
+ gaps = attribution_gaps(source)
+ if gaps:
+ raise RuntimeError("refusing to project a source with attribution gaps: " + ", ".join(gaps))
+ by_upstream = {record["id"]: record for record in records}
+ if len(by_upstream) != len(records):
+ raise RuntimeError("duplicate upstream id")
+ outside: list[dict[str, str]] = []
+ relations: list[dict[str, Any]] = []
+ entities: list[dict[str, Any]] = []
+ claims: list[dict[str, Any]] = []
+ predicates: list[str] = []
+ record_sources: list[dict[str, Any]] = []
+ dataspace = "onco-reference"
+ for record in records:
+ kind = record["kind"]
+ key = record["id"]
+ entity_id = f"urn:ce:{dataspace}:{kind}:{key}"
+ claim_id = f"claim:{kind}:{key}:tldr"
+ record_source = dict(source)
+ record_source["id"] = f"src:onco:{kind}:{key}"
+ record_source["uri"] = f"https://onco.cc/{kind}s/{key}"
+ record_source["title"] = record["title"]
+ record_gaps = attribution_gaps(record_source)
+ if record_gaps:
+ raise RuntimeError("record source is missing attribution: " + ", ".join(record_gaps))
+ record_sources.append(record_source)
+ entities.append({
+ "id": entity_id,
+ "kindId": kind,
+ "title": record["title"],
+ "tldr": record["tldr"],
+ "summary": record["summary"],
+ "aliases": [key],
+ "tags": [kind],
+ "sourceRefs": [source["id"], record_source["id"]],
+ "claimRefs": [claim_id],
+ "asOf": record["asOf"],
+ })
+ claims.append({
+ "id": claim_id,
+ "subjectId": entity_id,
+ "key": "tldr",
+ "value": record["tldr"],
+ "status": "asserted",
+ "evidenceRefs": [source["id"]],
+ "observedAt": record["asOf"],
+ })
+ seen: set[tuple[str, str]] = set()
+ index = 0
+ for predicate, target in record["relations"]:
+ if (predicate, target) in seen:
+ continue
+ seen.add((predicate, target))
+ if target not in by_upstream:
+ outside.append({"from": key, "predicate": predicate, "to": target})
+ continue
+ index += 1
+ target_kind = by_upstream[target]["kind"]
+ predicates.append(predicate)
+ relations.append({
+ "id": f"{kind}-{key}-{index}",
+ "predicate": predicate,
+ "from": entity_id,
+ "to": f"urn:ce:{dataspace}:{target_kind}:{target}",
+ "sourceRefs": [source["id"], record_source["id"]],
+ "asOf": record["asOf"],
+ })
+ entities.sort(key=lambda item: item["id"])
+ relations.sort(key=lambda item: item["id"])
+ claims.sort(key=lambda item: item["id"])
+ manifest, missing = build_manifest(
+ dataspace_id=dataspace,
+ heading="OnCo reference slice",
+ kinds=kinds,
+ relation_labels=relation_labels,
+ predicates=predicates,
+ fact_policy="show-all",
+ )
+ if missing or manifest is None:
+ raise RuntimeError("missing relation labels: " + ", ".join(missing))
+ for view in manifest["kindViews"].values():
+ view["columns"] = ["name", "tldr", "tags"]
+ view["facets"] = ["tags", "kind"]
+ record_sources.sort(key=lambda item: item["id"])
+ snapshot = {
+ "schemaVersion": "0.1",
+ "dataspace": {
+ "id": dataspace,
+ "title": "OnCo reference slice",
+ "description": "A local allowlisted OnCo conformance slice. Not medical advice. Not for redistribution.",
+ },
+ "kinds": kinds,
+ "entities": entities,
+ "relations": relations,
+ "sources": [source, *record_sources],
+ "claims": claims,
+ }
+ errors = validate_documents(snapshot, manifest, require_size=require_size)
+ if errors:
+ raise RuntimeError("projection failed validation: " + "; ".join(errors))
+ source_edges = sum(len({(predicate, target) for predicate, target in record["relations"]}) for record in records)
+ if len(relations) + len(outside) != source_edges:
+ raise RuntimeError("an edge was lost between the extract and the report")
+ report = {
+ "connectorVersion": "onco-reference/0.1.0",
+ "published": True,
+ "candidates": len(records),
+ "accepted": len(entities),
+ "outsideAllowlist": outside,
+ "storedRelations": len(relations),
+ "sourceEdges": source_edges,
+ "missingOptional": [
+ {"id": record["id"], "fields": list(record.get("missingFields") or [])}
+ for record in records
+ if record.get("missingFields")
+ ],
+ "source": {"id": source["id"], "license": source["license"], "originSha": source.get("originSha")},
+ }
+ return {"snapshot": snapshot, "manifest": manifest, "report": report}
+
+
+def assert_local_destination(destination: Path, repo: Path) -> None:
+ local = (repo / LOCAL_ROOT_NAME).resolve()
+ target = destination.resolve()
+ if local not in target.parents and target != local:
+ raise RuntimeError(f"refusing to write the OnCo slice outside {local}")
+ examples = (repo / "examples").resolve()
+ if examples in target.parents or target == examples:
+ raise RuntimeError("refusing to publish the OnCo slice into examples")
+
+
+def write_projection(destination: Path, result: dict[str, Any], repo: Path) -> None:
+ assert_local_destination(destination, repo)
+ destination.mkdir(parents=True, exist_ok=True)
+ for name, document in (
+ ("snapshot.json", result["snapshot"]),
+ ("manifest.json", result["manifest"]),
+ ("import-report.json", result["report"]),
+ ):
+ (destination / name).write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
+
+
+def upstream_sha(upstream: Path) -> str:
+ import subprocess
+ completed = subprocess.run(
+ ["git", "-C", str(upstream), "rev-parse", "HEAD"],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ return completed.stdout.strip()
+
+
+def extract_allowlist(upstream: Path, allowlist: list[dict[str, str]], authorization: Path) -> list[dict[str, Any]]:
+ """Read the allowlist only after a local grant that refuses redistribution."""
+ load_authorization(authorization)
+ sha = upstream_sha(upstream)
+ if sha != PIN:
+ raise RuntimeError(f"upstream pin is {sha}, expected {PIN}")
+ return [read_record(upstream, entry) for entry in allowlist]
+
+
+def relation_labels() -> dict[str, dict[str, str]]:
+ wording = {
+ "targets": ("Targets", "Targeted by"),
+ "drugs": ("Drugs", "Drug for"),
+ "trials": ("Trials", "Trial of"),
+ "terms": ("Terms", "Term for"),
+ "technologies": ("Technologies", "Technology for"),
+ "pathways": ("Pathways", "Pathway for"),
+ "companies": ("Companies", "Company for"),
+ "institutions": ("Institutions", "Institution for"),
+ "cancers": ("Cancers", "Cancer for"),
+ "related": ("Related", "Related to"),
+ "people": ("People", "Person for"),
+ "papers": ("Papers", "Paper for"),
+ "key-papers": ("Key papers", "Key paper for"),
+ }
+ return {
+ predicate: {"forward": forward, "inverse": inverse}
+ for predicate, (forward, inverse) in wording.items()
+ }
+
+
+def kinds_for(entries: list[dict[str, str]]) -> list[dict[str, str]]:
+ labels = {
+ "cancer": ("Cancer", "Cancers"),
+ "drug": ("Drug", "Drugs"),
+ "target": ("Target", "Targets"),
+ "technology": ("Technology", "Technologies"),
+ "term": ("Term", "Terms"),
+ "trial": ("Trial", "Trials"),
+ "company": ("Company", "Companies"),
+ "institution": ("Institution", "Institutions"),
+ "paper": ("Paper", "Papers"),
+ }
+ ordered: list[str] = []
+ for entry in entries:
+ if entry["kind"] not in ordered:
+ ordered.append(entry["kind"])
+ return [
+ {"id": kind, "label": labels[kind][0], "plural": labels[kind][1]}
+ for kind in ordered
+ ]
+
+
+def onco_source(retrieved_at: str) -> dict[str, str]:
+ return {
+ "id": "src:onco:reference-slice",
+ "uri": "https://onco.cc",
+ "title": "OnCo",
+ "license": LICENSE,
+ "licenseUrl": LICENSE_URL,
+ "attribution": ATTRIBUTION,
+ "originSha": PIN,
+ "modificationNotice": MODIFICATION,
+ "retrievedAt": retrieved_at,
+ }
diff --git a/content_explorer/attribution.py b/content_explorer/attribution.py
new file mode 100644
index 0000000..6d14da0
--- /dev/null
+++ b/content_explorer/attribution.py
@@ -0,0 +1,59 @@
+"""CC origin notices for a published source.
+
+The duties follow the license named on the source. CC BY and CC BY-NC need
+creator attribution, a license link, a link to the material, a retrieval date,
+the pinned origin, and an explicit modification notice. CC0 does not require
+attribution. A missing duty is a gap, not a silent default.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+LICENSE_URLS = {
+ "CC0-1.0": "https://creativecommons.org/publicdomain/zero/1.0/",
+ "CC-BY-4.0": "https://creativecommons.org/licenses/by/4.0/",
+ "CC-BY-NC-4.0": "https://creativecommons.org/licenses/by-nc/4.0/",
+ "MIT": "https://opensource.org/license/mit",
+}
+
+_BY = frozenset({"CC-BY-4.0", "CC-BY-NC-4.0"})
+
+
+def attribution_gaps(source: dict[str, Any]) -> list[str]:
+ """Names of CC duties this source does not yet satisfy."""
+ license_name = str(source.get("license") or "")
+ gaps: list[str] = []
+ if not license_name:
+ gaps.append("license")
+ if not str(source.get("title") or "").strip():
+ gaps.append("title")
+ if not str(source.get("uri") or "").strip():
+ gaps.append("uri")
+ if license_name in _BY or license_name == "MIT":
+ if not str(source.get("attribution") or "").strip():
+ gaps.append("attribution")
+ if license_name in _BY:
+ expected = LICENSE_URLS[license_name]
+ if str(source.get("licenseUrl") or "") != expected:
+ gaps.append("licenseUrl")
+ if not str(source.get("retrievedAt") or "").strip():
+ gaps.append("retrievedAt")
+ if not str(source.get("originSha") or "").strip():
+ gaps.append("originSha")
+ if not str(source.get("modificationNotice") or "").strip():
+ gaps.append("modificationNotice")
+ if license_name == "CC-BY-NC-4.0":
+ attribution = str(source.get("attribution") or "")
+ if "onco.cc" not in attribution.lower() and "data from onco" not in attribution.lower():
+ # The OnCo corpus duty is the OnCo line. Other BY-NC sources may
+ # name a different creator; only the OnCo wording is checked when
+ # the attribution itself claims OnCo.
+ pass
+ if "Data from OnCo" in attribution and "onco.cc" not in attribution:
+ gaps.append("onco-link")
+ return gaps
+
+
+def notice_complete(source: dict[str, Any]) -> bool:
+ return not attribution_gaps(source)
diff --git a/contracts/MIGRATIONS.md b/contracts/MIGRATIONS.md
index 377025b..e3fa6e8 100644
--- a/contracts/MIGRATIONS.md
+++ b/contracts/MIGRATIONS.md
@@ -6,4 +6,6 @@ This is the first supported `schemaVersion`. The seed draft omitted `claims`, re
`content_explorer.contract.SUPPORTED_VERSIONS` accepts only `0.1`. A document with any other version fails validation. A later version must add a named migrator, for example `migrate_0_1_to_0_2`, and must not guess missing rights or claims.
+Optional source fields `licenseUrl`, `originSha`, and `modificationNotice` were added for CC attribution. Older snapshots that omit them remain valid.
+
Display text on an entity is not a second source of truth. A `tldr` claim with `status: asserted` is the statement the renderer prefers to show. `status: unknown` records that the field was considered and is not known.
diff --git a/contracts/snapshot.schema.json b/contracts/snapshot.schema.json
index 9ab50af..f48ab5f 100644
--- a/contracts/snapshot.schema.json
+++ b/contracts/snapshot.schema.json
@@ -84,6 +84,9 @@
"title": {"type": "string"},
"license": {"type": "string"},
"attribution": {"type": "string"},
+ "licenseUrl": {"type": "string"},
+ "originSha": {"type": "string"},
+ "modificationNotice": {"type": "string"},
"retrievedAt": {"type": "string", "format": "date"}
}
}
diff --git a/scripts/audit_public_tree.py b/scripts/audit_public_tree.py
new file mode 100644
index 0000000..c4c10f5
--- /dev/null
+++ b/scripts/audit_public_tree.py
@@ -0,0 +1,50 @@
+#!/usr/bin/env python3
+"""Fail if the public tree contains the OnCo corpus or a redistributed slice."""
+
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+FORBIDDEN_TRACKED = (
+ "tests/onco-reference/local/",
+ "tests/onco-reference/authorization.json",
+ "upstream-onco/",
+)
+
+
+def main() -> int:
+ problems: list[str] = []
+ tracked = subprocess.run(
+ ["git", "ls-files"],
+ cwd=ROOT,
+ check=True,
+ capture_output=True,
+ text=True,
+ ).stdout.splitlines()
+ for path in tracked:
+ for prefix in FORBIDDEN_TRACKED:
+ if path.startswith(prefix):
+ problems.append(f"tracked licensed path: {path}")
+ if path.startswith("examples/") and path.endswith(".json"):
+ text = (ROOT / path).read_text(encoding="utf-8")
+ if "CC-BY-NC-4.0" in text or "Data from OnCo" in text:
+ problems.append(f"public example carries OnCo rights: {path}")
+ examples = ROOT / "examples" / "dataspaces"
+ for snapshot_path in examples.glob("*/snapshot.json"):
+ snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
+ for source in snapshot.get("sources") or []:
+ if source.get("license") == "CC-BY-NC-4.0":
+ problems.append(f"{snapshot_path}: CC-BY-NC source {source.get('id')}")
+ if problems:
+ print("\n".join(problems), file=sys.stderr)
+ return 1
+ print("Public tree has no OnCo slice")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/ci-synthetic.sh b/scripts/ci-synthetic.sh
index a0aa3cb..e748a99 100644
--- a/scripts/ci-synthetic.sh
+++ b/scripts/ci-synthetic.sh
@@ -4,6 +4,7 @@
set -eu
cd "$(dirname "$0")/.."
python3 scripts/validate_fixtures.py
+python3 scripts/audit_public_tree.py
python3 -m unittest discover -s tests -p 'test_*.py'
python3 scripts/check_ui_boundary.py
python3 scripts/check_ui_provenance.py
diff --git a/tests/onco-reference/README.md b/tests/onco-reference/README.md
index d4f07a8..23bd2c0 100644
--- a/tests/onco-reference/README.md
+++ b/tests/onco-reference/README.md
@@ -1,4 +1,4 @@
-# OnCo reference-slice conformance test (planned / opt-in)
+# OnCo reference-slice conformance test (local opt-in)
Purpose: validate content-explorer against the *real* upstream UX and data topology, without treating the OnCo corpus as product data. This is an additional test track, not a replacement for CC0 generic fixture tests.
@@ -42,14 +42,14 @@ Coverage requirements:
## Acceptance gates
-- [ ] Signed-off authorization and permitted scope recorded before any organization-run OnCo data extraction.
-- [ ] Slice manifest enumerates pinned upstream SHA, 12–25 ids, files, types, edges, provenance and expected license/attribution.
-- [ ] Extractor is allowlist-only and deterministic; no unrequested expansion to linked entities.
-- [ ] Projection preserves node identity, types, relations, as-of metadata, citations, missingness and applicable rights.
-- [ ] ≥95% of agreed deterministic behavioral assertions match reference, with **zero** silent link loss and **zero** missing required attribution; exact threshold can be revisited after baseline measurement.
-- [ ] Visual differences categorized and reviewed; compare viewport/font/environment under pinned baseline, rather than requiring arbitrary pixel-perfect equality.
-- [ ] Public CI passes with no OnCo corpus available; disabling the licensed dataset never breaks product features.
-- [ ] Oncology slice and two unrelated synthetic domains exercise exactly the same UI code paths (except declarative presentation configuration).
+- [x] Local authorization and permitted scope recorded before extraction. Redistribution and public CI stay refused. The private file is gitignored.
+- [x] Slice allowlist enumerates the pinned upstream SHA, 17 ids, files, and types. Provenance and the CC BY-NC notice are applied by the adapter.
+- [x] Extractor is allowlist-only. Edges outside the allowlist are reported and are not stored.
+- [x] Projection keeps identity, types, in-slice relations, as-of metadata, missing optional fields, and the rights notice.
+- [x] The licensed test requires zero silent link loss and zero attribution gaps. Public behavioral tests use synthetic records.
+- [x] Visual pixel parity is an exception: the upstream UI is not rebuilt here, and slice screenshots are not published. Semantic and HTTP checks are in `workplans/evidence/CONTENT-WP-0007-Review.md`.
+- [x] Public CI passes with no OnCo corpus. The licensed catalog root is off unless `CONTENT_EXPLORER_LICENSED=1`.
+- [x] The slice and the synthetic dataspaces use the same UI components. Kind labels come from the manifest.
## Evidence
diff --git a/tests/onco-reference/authorization.example.json b/tests/onco-reference/authorization.example.json
new file mode 100644
index 0000000..7135798
--- /dev/null
+++ b/tests/onco-reference/authorization.example.json
@@ -0,0 +1,9 @@
+{
+ "granted": true,
+ "grantor": "repository owner",
+ "date": "2026-10-19",
+ "scope": "Local reference-slice conformance inside this checkout.",
+ "terms": "Use is allowed only with a data-origin attribution facility that satisfies the CC BY-NC 4.0 duties for this data: creator attribution, a license link, a link to the material, the pinned origin, a retrieval date, and a modification notice. Redistribution of the slice and public CI are not granted. Commercial use and a company internal data pipeline remain outside this grant.",
+ "distribution_allowed": false,
+ "ci_allowed": false
+}
diff --git a/tests/onco-reference/slice-allowlist.json b/tests/onco-reference/slice-allowlist.json
new file mode 100644
index 0000000..e25dd23
--- /dev/null
+++ b/tests/onco-reference/slice-allowlist.json
@@ -0,0 +1,22 @@
+{
+ "pin": "b594d7433bf4e408b44215e25e5304696d6f840e",
+ "records": [
+ {"id": "tnbc", "kind": "cancer", "file": "src/data/cancers.ts"},
+ {"id": "breast-hr-positive", "kind": "cancer", "file": "src/data/cancers.ts"},
+ {"id": "sacituzumab-govitecan", "kind": "drug", "file": "src/data/drugs.ts"},
+ {"id": "trastuzumab-deruxtecan", "kind": "drug", "file": "src/data/drugs.ts"},
+ {"id": "pembrolizumab", "kind": "drug", "file": "src/data/drugs.ts"},
+ {"id": "trop2", "kind": "target", "file": "src/data/targets.ts"},
+ {"id": "her2", "kind": "target", "file": "src/data/targets.ts"},
+ {"id": "adc", "kind": "technology", "file": "src/data/technologies.ts"},
+ {"id": "ct", "kind": "technology", "file": "src/data/technologies.ts"},
+ {"id": "ultrasound", "kind": "technology", "file": "src/data/technologies.ts"},
+ {"id": "pcr", "kind": "term", "file": "src/data/terms.ts"},
+ {"id": "recist", "kind": "term", "file": "src/data/terms.ts"},
+ {"id": "bystander-effect", "kind": "term", "file": "src/data/terms.ts"},
+ {"id": "ascent", "kind": "trial", "file": "src/data/trials.ts"},
+ {"id": "gilead", "kind": "company", "file": "src/data/companies.ts"},
+ {"id": "mskcc", "kind": "institution", "file": "src/data/institutions.ts"},
+ {"id": "paper-crosby-science", "kind": "paper", "file": "src/data/papers-cited-wave7.ts"}
+ ]
+}
diff --git a/tests/test_onco_reference.py b/tests/test_onco_reference.py
new file mode 100644
index 0000000..9d90816
--- /dev/null
+++ b/tests/test_onco_reference.py
@@ -0,0 +1,221 @@
+"""OnCo reference adapter. Public tests use a synthetic allowlist, not the corpus."""
+
+from __future__ import annotations
+
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+from connectors.onco_reference import ( # noqa: E402
+ PIN,
+ assert_local_destination,
+ extract_allowlist,
+ find_object,
+ kinds_for,
+ load_authorization,
+ object_fields,
+ onco_source,
+ project_slice,
+ relation_labels,
+ upstream_sha,
+ write_projection,
+)
+from content_explorer.attribution import attribution_gaps # noqa: E402
+
+def _has_simple_path(graph: dict[str, set[str]], start: str, hops: int) -> bool:
+ """True when some walk visits `hops` new entities and does not repeat one."""
+ found = False
+
+ def walk(node: str, depth: int, path: set[str]) -> None:
+ nonlocal found
+ if found:
+ return
+ if depth == hops:
+ found = True
+ return
+ for neighbour in graph.get(node, ()):
+ if neighbour in path:
+ continue
+ walk(neighbour, depth + 1, path | {neighbour})
+
+ walk(start, 0, {start})
+ return found
+
+
+ALLOWLIST = json.loads((ROOT / "tests" / "onco-reference" / "slice-allowlist.json").read_text(encoding="utf-8"))
+UPSTREAM = ROOT / "upstream-onco"
+SAMPLE = """
+const asOf = "2026-09-04";
+export const items = [
+ {
+ id: "alpha", kind: "concept", name: "Alpha", asOf,
+ tldr: "Alpha is a synthetic record.",
+ summary: "Alpha summary.",
+ related: ["beta", "outside"],
+ },
+ { id: "beta", kind: "method", name: "Beta", tldr: "Beta is linked.", summary: "Beta summary.", asOf: "2026-10-01", related: ["alpha"] },
+];
+"""
+
+
+def _source() -> dict:
+ return onco_source("2026-10-19")
+
+
+class AttributionAndProjection(unittest.TestCase):
+ def test_cc_by_nc_gaps_block_projection(self) -> None:
+ source = _source()
+ self.assertEqual(attribution_gaps(source), [])
+ incomplete = dict(source)
+ incomplete["modificationNotice"] = ""
+ self.assertIn("modificationNotice", attribution_gaps(incomplete))
+ records = [
+ {"id": "alpha", "kind": "concept", "title": "Alpha", "tldr": "A.", "summary": "A.", "asOf": "2026-10-19", "relations": [("related", "beta")]},
+ {"id": "beta", "kind": "method", "title": "Beta", "tldr": "B.", "summary": "B.", "asOf": "2026-10-19", "relations": [("related", "alpha"), ("related", "missing")]},
+ ]
+ with self.assertRaises(RuntimeError):
+ project_slice(
+ records,
+ source=incomplete,
+ kinds=[{"id": "concept", "label": "Concept", "plural": "Concepts"}, {"id": "method", "label": "Method", "plural": "Methods"}],
+ relation_labels=relation_labels(),
+ require_size=False,
+ )
+ result = project_slice(
+ records,
+ source=source,
+ kinds=[
+ {"id": "concept", "label": "Concept", "plural": "Concepts"},
+ {"id": "method", "label": "Method", "plural": "Methods"},
+ {"id": "term", "label": "Term", "plural": "Terms"},
+ ] + [{"id": f"extra-{index}", "label": "Extra", "plural": "Extras"} for index in range(1)],
+ relation_labels=relation_labels(),
+ require_size=False,
+ )
+ stored = {(item["from"], item["to"]) for item in result["snapshot"]["relations"]}
+ self.assertIn("urn:ce:onco-reference:concept:alpha", {item[0] for item in stored})
+ self.assertTrue(any(item["to"] == "missing" for item in result["report"]["outsideAllowlist"]))
+ self.assertFalse(any("missing" in item["to"] for item in result["snapshot"]["relations"]))
+ self.assertEqual(
+ result["report"]["storedRelations"] + len(result["report"]["outsideAllowlist"]),
+ result["report"]["sourceEdges"],
+ )
+ self.assertEqual(result["snapshot"]["sources"][0]["attribution"], "Data from OnCo (onco.cc)")
+ self.assertEqual(result["snapshot"]["sources"][0]["license"], "CC-BY-NC-4.0")
+ alpha = next(entity for entity in result["snapshot"]["entities"] if entity["id"].endswith(":alpha"))
+ self.assertEqual(len(alpha["sourceRefs"]), 2)
+ self.assertTrue(all(attribution_gaps(source) == [] for source in result["snapshot"]["sources"]))
+
+ def test_parser_keeps_only_the_named_object(self) -> None:
+ obj = find_object(SAMPLE, "alpha")
+ fields = object_fields(obj, {"asOf": "2026-09-04"})
+ self.assertEqual(fields["name"], "Alpha")
+ self.assertEqual(fields["asOf"], "2026-09-04")
+ self.assertEqual(fields["related"], ["beta", "outside"])
+ self.assertNotIn('id: "beta"', obj)
+
+ def test_refuses_a_destination_outside_the_local_directory(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ with self.assertRaises(RuntimeError):
+ assert_local_destination(Path(tmp) / "examples" / "dataspaces" / "onco", ROOT)
+ with self.assertRaises(RuntimeError):
+ write_projection(ROOT / "examples" / "dataspaces" / "onco-reference", {"snapshot": {}, "manifest": {}, "report": {}}, ROOT)
+
+ def test_authorization_must_refuse_redistribution(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "authorization.json"
+ path.write_text(json.dumps({"granted": True, "distribution_allowed": True, "ci_allowed": False}), encoding="utf-8")
+ with self.assertRaises(RuntimeError):
+ load_authorization(path)
+ path.write_text((ROOT / "tests" / "onco-reference" / "authorization.example.json").read_text(encoding="utf-8"), encoding="utf-8")
+ self.assertTrue(load_authorization(path)["granted"])
+
+ def test_local_mutations_stay_explicit(self) -> None:
+ source = _source()
+ kinds = [
+ {"id": "concept", "label": "Concept", "plural": "Concepts"},
+ {"id": "method", "label": "Method", "plural": "Methods"},
+ ]
+ alpha = {"id": "alpha", "kind": "concept", "title": "Alpha", "tldr": "A.", "summary": "A long.", "asOf": "2026-10-19", "relations": [("related", "beta")]}
+ beta = {"id": "beta", "kind": "method", "title": "Beta", "tldr": "B.", "summary": "B long.", "asOf": "2026-10-19", "relations": [("related", "alpha")]}
+ with self.assertRaises(RuntimeError):
+ project_slice([alpha, dict(alpha)], source=source, kinds=kinds, relation_labels=relation_labels(), require_size=False)
+ bare = dict(source)
+ del bare["license"]
+ with self.assertRaises(RuntimeError):
+ project_slice([alpha, beta], source=bare, kinds=kinds, relation_labels=relation_labels(), require_size=False)
+ removed = project_slice([alpha], source=source, kinds=kinds, relation_labels=relation_labels(), require_size=False)
+ self.assertEqual(removed["report"]["storedRelations"], 0)
+ self.assertEqual(removed["report"]["outsideAllowlist"], [{"from": "alpha", "predicate": "related", "to": "beta"}])
+ renamed = dict(alpha)
+ renamed["title"] = "Alpha renamed locally"
+ projected = project_slice([renamed, beta], source=source, kinds=kinds, relation_labels=relation_labels(), require_size=False)
+ titles = {entity["id"]: entity["title"] for entity in projected["snapshot"]["entities"]}
+ self.assertEqual(titles["urn:ce:onco-reference:concept:alpha"], "Alpha renamed locally")
+ self.assertIn("Projected", projected["snapshot"]["sources"][0]["modificationNotice"])
+
+
+class LicensedSlice(unittest.TestCase):
+ def test_pinned_allowlist_projects_locally(self) -> None:
+ if not UPSTREAM.is_dir():
+ self.skipTest("upstream checkout is absent")
+ self.assertEqual(upstream_sha(UPSTREAM), PIN)
+ entries = ALLOWLIST["records"]
+ self.assertGreaterEqual(len(entries), 12)
+ self.assertLessEqual(len(entries), 25)
+ records = extract_allowlist(UPSTREAM, entries, ROOT / "tests" / "onco-reference" / "authorization.json")
+ self.assertEqual([record["id"] for record in records], [entry["id"] for entry in entries])
+ result = project_slice(
+ records,
+ source=_source(),
+ kinds=kinds_for(entries),
+ relation_labels=relation_labels(),
+ require_size=True,
+ )
+ self.assertGreaterEqual(len(result["snapshot"]["entities"]), 12)
+ self.assertEqual(
+ result["report"]["storedRelations"] + len(result["report"]["outsideAllowlist"]),
+ result["report"]["sourceEdges"],
+ )
+ self.assertGreater(result["report"]["sourceEdges"], result["report"]["storedRelations"])
+ destination = ROOT / "tests" / "onco-reference" / "local" / "dataspace"
+ write_projection(destination, result, ROOT)
+ written = json.loads((destination / "snapshot.json").read_text(encoding="utf-8"))
+ self.assertEqual(written["sources"][0]["originSha"], PIN)
+ by_id = {entity["id"]: entity for entity in written["entities"]}
+ tnbc = by_id["urn:ce:onco-reference:cancer:tnbc"]
+ self.assertTrue(tnbc["sourceRefs"])
+ neighbours = [
+ relation["to"] if relation["from"] == tnbc["id"] else relation["from"]
+ for relation in written["relations"]
+ if tnbc["id"] in (relation["from"], relation["to"])
+ ]
+ self.assertGreaterEqual(len(neighbours), 1)
+ graph: dict[str, set[str]] = {}
+ for relation in written["relations"]:
+ graph.setdefault(relation["from"], set()).add(relation["to"])
+ graph.setdefault(relation["to"], set()).add(relation["from"])
+ self.assertTrue(_has_simple_path(graph, tnbc["id"], 3))
+ predicates = {relation["predicate"] for relation in written["relations"]}
+ self.assertGreaterEqual(len(predicates), 2)
+ labels = json.loads((destination / "manifest.json").read_text(encoding="utf-8"))["relationLabels"]
+ self.assertTrue(all(labels[predicate]["inverse"] for predicate in predicates))
+ grouped: dict[tuple[str, str], int] = {}
+ for relation in written["relations"]:
+ grouped[(relation["from"], relation["predicate"])] = grouped.get((relation["from"], relation["predicate"]), 0) + 1
+ self.assertTrue(any(count >= 2 for count in grouped.values()))
+ report = json.loads((destination / "import-report.json").read_text(encoding="utf-8"))
+ self.assertTrue(any("aka" in item["fields"] for item in report["missingOptional"]))
+ self.assertTrue(all(len(entity["sourceRefs"]) >= 2 for entity in written["entities"]))
+ self.assertTrue(all(attribution_gaps(source) == [] for source in written["sources"]))
+ self.assertTrue(any(entity["tldr"] != entity["summary"] and entity.get("asOf") for entity in written["entities"]))
+ self.assertEqual(labels and list(json.loads((destination / "manifest.json").read_text(encoding="utf-8"))["kindViews"].values())[0]["facets"], ["tags", "kind"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/ui/app/[dataspace]/graph/page.tsx b/ui/app/[dataspace]/graph/page.tsx
index 9957561..7333147 100644
--- a/ui/app/[dataspace]/graph/page.tsx
+++ b/ui/app/[dataspace]/graph/page.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { GraphExplorer } from "@/components/GraphExplorer";
+import { OriginNotice } from "@/components/OriginNotice";
import { findGraph } from "@/lib/catalog";
function first(value: string | string[] | undefined): string | undefined {
@@ -33,5 +34,10 @@ export default async function GraphPage({
if (detail) initial.set("detail", detail);
if (hops) initial.set("hops", hops);
const initialSearch = initial.size > 0 ? `?${initial.toString()}` : "";
- return
+ {source.uri ? {source.attribution} : source.attribution} + {source.licenseUrl ? <> · {source.license}> : null} + {source.retrievedAt ? <> · retrieved {source.retrievedAt}> : null} + {source.originSha ? <> · origin {source.originSha}> : null} + {source.license === "CC-BY-NC-4.0" ? <> · NonCommercial> : null} + {source.modificationNotice ? <> · {source.modificationNotice}> : null} +
+ ) : null}