From 58788b0cb427266e8bb3f4d014ab12d2498646ba Mon Sep 17 00:00:00 2001 From: Wonderforge <74642251+Wonderforge-Lab@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:43:26 +0100 Subject: [PATCH] Close registry boundary validation gaps --- .github/CODEOWNERS | 12 ++++ AI_ENTRYPOINT.md | 8 +++ docs/registry/FIELD_DECISION_MATRIX.md | 2 +- docs/registry/REGISTRY_CONTRACT_V1.md | 6 +- .../SCHEMA_AND_VALIDATOR_DESIGN_V1.md | 8 +-- ...602-assistant-session-a-example-topic.json | 1 + ...-assistant-session-b-example-critique.json | 1 + registry/packet_registry.csv | 2 +- registry/response_registry.csv | 2 +- registry/schemas/packet.schema.json | 2 +- registry/schemas/response.schema.json | 2 +- scripts/check_tag_promotion.py | 2 +- scripts/generate_registry_views.py | 4 +- scripts/validate_repo.py | 68 ++++++++++++++++++- templates/packet_record.json | 1 + templates/response_record.json | 1 + tests/fixtures/invalid/derivative/record.json | 1 + tests/fixtures/invalid/duplicate/record.json | 1 + .../invalid/filename/not-the-packet-id.json | 1 + tests/fixtures/invalid/packet/record.json | 2 +- tests/fixtures/invalid/path/record.json | 1 + tests/fixtures/invalid/reference/record.json | 1 + tests/fixtures/invalid/response/record.json | 1 + .../invalid/tag-reference/record.json | 1 + .../invalid/unknown-origin/record.json | 22 ++++++ tests/fixtures/valid/packet/record.json | 1 + tests/fixtures/valid/response/record.json | 1 + tests/test_tag_promotion.py | 13 ++++ tests/test_validator_smoke.py | 62 ++++++++++++++++- 29 files changed, 211 insertions(+), 19 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 tests/fixtures/invalid/unknown-origin/record.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..1e23f84 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,12 @@ +# Control-plane review paths. These assignments request review on pull requests; +# direct default-branch deposits remain governed by AI_ENTRYPOINT.md. +/AI_ENTRYPOINT.md @Wonderforge-Lab +/lobby/ @Wonderforge-Lab +/docs/ @Wonderforge-Lab +/.github/ @Wonderforge-Lab +/registry/schemas/ @Wonderforge-Lab +/scripts/ @Wonderforge-Lab +/templates/ @Wonderforge-Lab +/bridge_config.json @Wonderforge-Lab +/config/ @Wonderforge-Lab +/registry/tags/accepted/ @Wonderforge-Lab diff --git a/AI_ENTRYPOINT.md b/AI_ENTRYPOINT.md index 9d155b1..cbd5f20 100644 --- a/AI_ENTRYPOINT.md +++ b/AI_ENTRYPOINT.md @@ -52,6 +52,14 @@ Do not create task branches for ordinary deposits. Use branch + PR for procedure, policy, code, structure, cleanup, risky/bulky imports, many existing-file edits, or explicit review. +## Direct-deposit boundary + +Direct routine deposits are content-plane work only: their artifacts and canonical records may be written under `datadrops/`, `responses/`, `messages/`, `notifications/`, `registry/packets/`, `registry/responses/`, `registry/messages/`, `registry/notifications/`, `registry/visits/`, `registry/visitors/`, and `registry/tags/proposed/`. + +Use branch + PR for any change to the control plane or its enforcement, including `AI_ENTRYPOINT.md`, `lobby/`, `docs/`, `.github/`, `registry/schemas/`, `scripts/`, `templates/`, `bridge_config.json`, `config/`, generated-view machinery, and `registry/tags/accepted/`. + +Post-push validation detects direct-write violations after they land; it cannot revoke a commit. Do not make CI auto-revert changes. A write credential is therefore part of the trust perimeter. + For ordinary deposits, use `lobby/ROUTINE_DEPOSIT_QUICKSTART.md`. ## Reading Order diff --git a/docs/registry/FIELD_DECISION_MATRIX.md b/docs/registry/FIELD_DECISION_MATRIX.md index 6a65f2a..3dddbed 100644 --- a/docs/registry/FIELD_DECISION_MATRIX.md +++ b/docs/registry/FIELD_DECISION_MATRIX.md @@ -17,6 +17,6 @@ This matrix records the approved v1 decisions. “Artifact” means Markdown pac - `created_at` is the canonical RFC 3339 creation timestamp. - A CSV `date` is derived, not a competing canonical field. - Applicable packet, response, and message records carry tags as defined by the contract. -- Packets and responses carry provenance: creator, depositor, origin, source references, derivation, and coverage as applicable. +- Packets and responses carry provenance: creator, depositor, origin, source references, an uncertainty note when origin is unknown, derivation, and coverage as applicable. - Notifications inherit provenance through their required message link; visits do not carry provenance by default. - Legacy field names are migration inputs only. diff --git a/docs/registry/REGISTRY_CONTRACT_V1.md b/docs/registry/REGISTRY_CONTRACT_V1.md index 1350918..ae84805 100644 --- a/docs/registry/REGISTRY_CONTRACT_V1.md +++ b/docs/registry/REGISTRY_CONTRACT_V1.md @@ -114,13 +114,13 @@ Tag slugs are lowercase hyphenated slugs. IDs are validated as complete values; ## Tags, provenance, and derivatives -Packets and responses carry `created_by`, `deposited_by`, `content_origin`, `source_refs`, `derivative_of`, and `provenance_coverage` as applicable. +Packets and responses carry `created_by`, `deposited_by`, `content_origin`, `source_refs`, `source_note`, `derivative_of`, and `provenance_coverage` as applicable. `content_origin` is a closed enum: `operator_authored`, `third_party`, `web`, `model_generated`, `mixed`, or `unknown`. -Third-party, web, and mixed material requires source references. Derivative/summarising material requires upstream references and coverage. Routine routing summaries require record-level provenance; evidence or decision synthesis requires claim-level support where practical and must preserve uncertainty. +Third-party, web, and mixed material requires source references. An `unknown` origin must include a non-empty `source_note` explaining the uncertainty; it must not be used as a substitute for available source references. Derivative/summarising material requires upstream references and coverage. Routine routing summaries require record-level provenance; evidence or decision synthesis requires claim-level support where practical and must preserve uncertainty. -AI/session-created tags begin proposed and cannot become accepted in the same change set. Operator-supplied tags may be accepted directly with `acceptance_basis: operator_supplied`. Promotion of a proposal uses `acceptance_basis: operator_approved_promotion`. Pull-request validation rejects a same-change-set promotion; push validation detects the equivalent direct-write violation for remediation, but cannot undo a commit already accepted by the remote. +AI/session-created tags begin proposed and cannot become accepted in the same change set. Operator-supplied tags may enter `accepted` directly with `acceptance_basis: operator_supplied`; this is a control-plane change and uses branch + PR. Promotion of a proposal uses `acceptance_basis: operator_approved_promotion` and also uses branch + PR. Pull-request validation rejects a same-change-set promotion; push validation detects the equivalent direct-write violation for remediation, but cannot undo a commit already accepted by the remote. ## Compatibility diff --git a/docs/registry/SCHEMA_AND_VALIDATOR_DESIGN_V1.md b/docs/registry/SCHEMA_AND_VALIDATOR_DESIGN_V1.md index f47e293..40e602e 100644 --- a/docs/registry/SCHEMA_AND_VALIDATOR_DESIGN_V1.md +++ b/docs/registry/SCHEMA_AND_VALIDATOR_DESIGN_V1.md @@ -35,9 +35,9 @@ IDs are validated as complete date-led values, then checked against filename and ## Conditional provenance -Packets and responses require `created_by`, `deposited_by`, `content_origin`, `source_refs`, `derivative_of`, and `provenance_coverage` according to the contract. +Packets and responses require `created_by`, `deposited_by`, `content_origin`, `source_refs`, `source_note`, `derivative_of`, and `provenance_coverage` according to the contract. -For `third_party`, `web`, and `mixed`, `source_refs` must be non-empty. For a derivative/summarising artifact, `derivative_of` and coverage must be non-empty. The schema checks field presence/shape; the validator resolves `derivative_of` entries to packet or response records in the validation set. Visitor identifiers remain routing handles and do not require a corresponding visitor-registration record. +For `third_party`, `web`, and `mixed`, `source_refs` must be non-empty. For `unknown`, `source_note` must state why origin cannot be determined. For a derivative/summarising artifact, `derivative_of` and coverage must be non-empty. The schema checks field presence/shape; the validator resolves `derivative_of` entries to packet or response records in the validation set. Visitor identifiers remain routing handles and do not require a corresponding visitor-registration record. Messages may carry provenance when relaying source-bearing content. Notifications inherit through their required `message_id`. Visits do not require provenance. @@ -47,10 +47,10 @@ Messages may carry provenance when relaying source-bearing content. Notification 2. Validate JSON Schema. 3. Assert ID equals filename stem. 4. Assert identifier-date/`created_at` agreement and, where a canonical registry record is filed under a four-digit year directory, year-directory agreement. -5. Assert path safety, existence and artifact/registry bucket agreement. +5. Assert path safety, existence, record-type artifact-prefix and artifact/registry bucket agreement. 6. Assert uniqueness of IDs by namespace. 7. Resolve packet, response, message, notification, tag and derivative references; visitor handles remain intentionally unregistered-capable. -8. Check tag status/path agreement and proposed/accepted/deprecated lifecycle rules. +8. Check tag status/direct-child path agreement and proposed/accepted/deprecated lifecycle rules. 9. Compare a PR base/head when available to reject illegal state transitions, including AI-proposed tags accepted in the same change set. Push validation detects the same condition after a direct write; repository review rules are required if prevention is required before acceptance. 10. Validate examples as isolated fixtures. 11. Check canonical Markdown links. diff --git a/examples/contract_v1/packets/20260602-assistant-session-a-example-topic.json b/examples/contract_v1/packets/20260602-assistant-session-a-example-topic.json index 1abb2b7..4bc84fe 100644 --- a/examples/contract_v1/packets/20260602-assistant-session-a-example-topic.json +++ b/examples/contract_v1/packets/20260602-assistant-session-a-example-topic.json @@ -7,6 +7,7 @@ "deposited_by": "example-operator", "content_origin": "operator_authored", "source_refs": [], + "source_note": "", "derivative_of": [], "provenance_coverage": "record_level", "source_session": "assistant-session-a", diff --git a/examples/contract_v1/responses/20260602-assistant-session-b-example-critique.json b/examples/contract_v1/responses/20260602-assistant-session-b-example-critique.json index 6769fb2..c04e6bf 100644 --- a/examples/contract_v1/responses/20260602-assistant-session-b-example-critique.json +++ b/examples/contract_v1/responses/20260602-assistant-session-b-example-critique.json @@ -7,6 +7,7 @@ "deposited_by": "example-operator", "content_origin": "model_generated", "source_refs": [], + "source_note": "", "derivative_of": [ "20260602-assistant-session-a-example-topic" ], diff --git a/registry/packet_registry.csv b/registry/packet_registry.csv index 03d233d..590becb 100644 --- a/registry/packet_registry.csv +++ b/registry/packet_registry.csv @@ -1 +1 @@ -packet_id,date,created_at,created_by,deposited_by,content_origin,source_session,target_session,topic,status,path,response_expected,response_packet_id,tags,notes +packet_id,date,created_at,created_by,deposited_by,content_origin,source_note,source_session,target_session,topic,status,path,response_expected,response_packet_id,tags,notes diff --git a/registry/response_registry.csv b/registry/response_registry.csv index 67dce2e..fc26f10 100644 --- a/registry/response_registry.csv +++ b/registry/response_registry.csv @@ -1 +1 @@ -response_id,date,created_at,created_by,deposited_by,content_origin,responding_session,source_packet_id,status,path,accepted_by,decision_at,tags,notes +response_id,date,created_at,created_by,deposited_by,content_origin,source_note,responding_session,source_packet_id,status,path,accepted_by,decision_at,tags,notes diff --git a/registry/schemas/packet.schema.json b/registry/schemas/packet.schema.json index 3799c2b..1ad1fea 100644 --- a/registry/schemas/packet.schema.json +++ b/registry/schemas/packet.schema.json @@ -1 +1 @@ -{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"packet.schema.json","type":"object","additionalProperties":false,"required":["record_type","schema_version","packet_id","created_at","created_by","deposited_by","content_origin","source_refs","derivative_of","provenance_coverage","source_session","target_session","topic","status","path","response_expected","response_packet_id","tags","notes"],"properties":{"record_type":{"const":"packet"},"schema_version":{"$ref":"common.schema.json#/$defs/schema_version"},"packet_id":{"$ref":"common.schema.json#/$defs/artifact_id"},"created_at":{"$ref":"common.schema.json#/$defs/timestamp"},"created_by":{"type":"string","minLength":1},"deposited_by":{"type":"string","minLength":1},"content_origin":{"$ref":"common.schema.json#/$defs/origin"},"source_refs":{"type":"array","items":{"type":"string","minLength":1}},"derivative_of":{"type":"array","items":{"type":"string","minLength":1}},"provenance_coverage":{"$ref":"common.schema.json#/$defs/coverage"},"source_session":{"type":"string","minLength":1},"target_session":{"type":"string","minLength":1},"topic":{"$ref":"common.schema.json#/$defs/slug"},"status":{"enum":["new","in_review","answered","superseded","archived"]},"path":{"$ref":"common.schema.json#/$defs/safe_path"},"response_expected":{"type":"boolean"},"response_packet_id":{"type":["string","null"]},"tags":{"type":"array","items":{"$ref":"common.schema.json#/$defs/slug"},"uniqueItems":true},"notes":{"type":"string"}},"allOf":[{"if":{"properties":{"content_origin":{"enum":["third_party","web","mixed"]}}},"then":{"properties":{"source_refs":{"minItems":1}}}},{"if":{"properties":{"content_origin":{"const":"model_generated"}}},"then":{"properties":{"derivative_of":{"minItems":1}}}}]} \ No newline at end of file +{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"packet.schema.json","type":"object","additionalProperties":false,"required":["record_type","schema_version","packet_id","created_at","created_by","deposited_by","content_origin","source_refs","source_note","derivative_of","provenance_coverage","source_session","target_session","topic","status","path","response_expected","response_packet_id","tags","notes"],"properties":{"record_type":{"const":"packet"},"schema_version":{"$ref":"common.schema.json#/$defs/schema_version"},"packet_id":{"$ref":"common.schema.json#/$defs/artifact_id"},"created_at":{"$ref":"common.schema.json#/$defs/timestamp"},"created_by":{"type":"string","minLength":1},"deposited_by":{"type":"string","minLength":1},"content_origin":{"$ref":"common.schema.json#/$defs/origin"},"source_refs":{"type":"array","items":{"type":"string","minLength":1}},"source_note":{"type":"string"},"derivative_of":{"type":"array","items":{"type":"string","minLength":1}},"provenance_coverage":{"$ref":"common.schema.json#/$defs/coverage"},"source_session":{"type":"string","minLength":1},"target_session":{"type":"string","minLength":1},"topic":{"$ref":"common.schema.json#/$defs/slug"},"status":{"enum":["new","in_review","answered","superseded","archived"]},"path":{"$ref":"common.schema.json#/$defs/safe_path"},"response_expected":{"type":"boolean"},"response_packet_id":{"type":["string","null"]},"tags":{"type":"array","items":{"$ref":"common.schema.json#/$defs/slug"},"uniqueItems":true},"notes":{"type":"string"}},"allOf":[{"if":{"properties":{"content_origin":{"enum":["third_party","web","mixed"]}}},"then":{"properties":{"source_refs":{"minItems":1}}}},{"if":{"properties":{"content_origin":{"const":"unknown"}}},"then":{"properties":{"source_note":{"minLength":1}}}},{"if":{"properties":{"content_origin":{"const":"model_generated"}}},"then":{"properties":{"derivative_of":{"minItems":1}}}}]} diff --git a/registry/schemas/response.schema.json b/registry/schemas/response.schema.json index ccd9b53..a6469cf 100644 --- a/registry/schemas/response.schema.json +++ b/registry/schemas/response.schema.json @@ -1 +1 @@ -{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"response.schema.json","type":"object","additionalProperties":false,"required":["record_type","schema_version","response_id","created_at","created_by","deposited_by","content_origin","source_refs","derivative_of","provenance_coverage","responding_session","source_packet_id","status","path","accepted_by","decision_at","tags","notes"],"properties":{"record_type":{"const":"response"},"schema_version":{"$ref":"common.schema.json#/$defs/schema_version"},"response_id":{"$ref":"common.schema.json#/$defs/artifact_id"},"created_at":{"$ref":"common.schema.json#/$defs/timestamp"},"created_by":{"type":"string","minLength":1},"deposited_by":{"type":"string","minLength":1},"content_origin":{"$ref":"common.schema.json#/$defs/origin"},"source_refs":{"type":"array","items":{"type":"string","minLength":1}},"derivative_of":{"type":"array","items":{"type":"string","minLength":1}},"provenance_coverage":{"$ref":"common.schema.json#/$defs/coverage"},"responding_session":{"type":"string","minLength":1},"source_packet_id":{"type":"string","minLength":1},"status":{"enum":["pending_review","accepted","rejected","archived"]},"path":{"$ref":"common.schema.json#/$defs/safe_path"},"accepted_by":{"type":["string","null"]},"decision_at":{"type":["string","null"],"format":"date-time"},"tags":{"type":"array","items":{"$ref":"common.schema.json#/$defs/slug"},"uniqueItems":true},"notes":{"type":"string"}},"allOf":[{"if":{"properties":{"content_origin":{"enum":["third_party","web","mixed"]}}},"then":{"properties":{"source_refs":{"minItems":1}}}},{"if":{"properties":{"content_origin":{"const":"model_generated"}}},"then":{"properties":{"derivative_of":{"minItems":1}}}}]} \ No newline at end of file +{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"response.schema.json","type":"object","additionalProperties":false,"required":["record_type","schema_version","response_id","created_at","created_by","deposited_by","content_origin","source_refs","source_note","derivative_of","provenance_coverage","responding_session","source_packet_id","status","path","accepted_by","decision_at","tags","notes"],"properties":{"record_type":{"const":"response"},"schema_version":{"$ref":"common.schema.json#/$defs/schema_version"},"response_id":{"$ref":"common.schema.json#/$defs/artifact_id"},"created_at":{"$ref":"common.schema.json#/$defs/timestamp"},"created_by":{"type":"string","minLength":1},"deposited_by":{"type":"string","minLength":1},"content_origin":{"$ref":"common.schema.json#/$defs/origin"},"source_refs":{"type":"array","items":{"type":"string","minLength":1}},"source_note":{"type":"string"},"derivative_of":{"type":"array","items":{"type":"string","minLength":1}},"provenance_coverage":{"$ref":"common.schema.json#/$defs/coverage"},"responding_session":{"type":"string","minLength":1},"source_packet_id":{"type":"string","minLength":1},"status":{"enum":["pending_review","accepted","rejected","archived"]},"path":{"$ref":"common.schema.json#/$defs/safe_path"},"accepted_by":{"type":["string","null"]},"decision_at":{"type":["string","null"],"format":"date-time"},"tags":{"type":"array","items":{"$ref":"common.schema.json#/$defs/slug"},"uniqueItems":true},"notes":{"type":"string"}},"allOf":[{"if":{"properties":{"content_origin":{"enum":["third_party","web","mixed"]}}},"then":{"properties":{"source_refs":{"minItems":1}}}},{"if":{"properties":{"content_origin":{"const":"unknown"}}},"then":{"properties":{"source_note":{"minLength":1}}}},{"if":{"properties":{"content_origin":{"const":"model_generated"}}},"then":{"properties":{"derivative_of":{"minItems":1}}}}]} diff --git a/scripts/check_tag_promotion.py b/scripts/check_tag_promotion.py index 4cc77ba..6a29a3a 100644 --- a/scripts/check_tag_promotion.py +++ b/scripts/check_tag_promotion.py @@ -24,7 +24,7 @@ def tag_slugs(paths: list[str], bucket: str) -> set[str]: return { PurePosixPath(path).stem for path in paths - if PurePosixPath(path).parent == prefix and path.endswith(".json") + if path.endswith(".json") and PurePosixPath(path).is_relative_to(prefix) } diff --git a/scripts/generate_registry_views.py b/scripts/generate_registry_views.py index 160eabb..6c8ef04 100644 --- a/scripts/generate_registry_views.py +++ b/scripts/generate_registry_views.py @@ -20,8 +20,8 @@ "tag": REGISTRY / "tags", } CSV_VIEWS = { - "packet": ("packet_registry.csv", ["packet_id", "date", "created_at", "created_by", "deposited_by", "content_origin", "source_session", "target_session", "topic", "status", "path", "response_expected", "response_packet_id", "tags", "notes"]), - "response": ("response_registry.csv", ["response_id", "date", "created_at", "created_by", "deposited_by", "content_origin", "responding_session", "source_packet_id", "status", "path", "accepted_by", "decision_at", "tags", "notes"]), + "packet": ("packet_registry.csv", ["packet_id", "date", "created_at", "created_by", "deposited_by", "content_origin", "source_note", "source_session", "target_session", "topic", "status", "path", "response_expected", "response_packet_id", "tags", "notes"]), + "response": ("response_registry.csv", ["response_id", "date", "created_at", "created_by", "deposited_by", "content_origin", "source_note", "responding_session", "source_packet_id", "status", "path", "accepted_by", "decision_at", "tags", "notes"]), "message": ("message_registry.csv", ["message_id", "date", "created_at", "from_visitor_id", "to_visitor_id", "to_group", "status", "path", "reply_to", "reply_expected", "response_message_id", "needs_human_relay", "related_packet", "related_response", "summary", "tags", "notes"]), "notification": ("notification_registry.csv", ["notification_id", "date", "created_at", "from_visitor_id", "to_visitor_id", "message_id", "status", "path", "needs_human_action", "summary", "notes"]), "visit": ("visit_registry.csv", ["visit_id", "date", "created_at", "visitor_id", "session_family", "checked_messages", "answered_messages", "created_messages", "relay_needed", "signoff_path", "notes"]), diff --git a/scripts/validate_repo.py b/scripts/validate_repo.py index 6f94acb..26349db 100644 --- a/scripts/validate_repo.py +++ b/scripts/validate_repo.py @@ -6,7 +6,7 @@ import json import re from functools import lru_cache -from pathlib import Path +from pathlib import Path, PurePosixPath from jsonschema import Draft202012Validator, FormatChecker from referencing import Registry, Resource @@ -27,6 +27,22 @@ CONTRACT_EXAMPLES = ROOT / "examples" / "contract_v1" CANONICAL_RECORD_ROOTS = (REGISTRY_ROOT, CONTRACT_EXAMPLES) PATH_FIELDS = ("path", "signoff_path", "profile_path") +ARTIFACT_PATH_PREFIXES = { + "packet": ("path", PurePosixPath("datadrops")), + "response": ("path", PurePosixPath("responses")), + "visit": ("signoff_path", PurePosixPath("responses") / "signoffs"), +} +GENERATED_REGISTRY_VIEWS = frozenset( + { + "INDEX.md", + "packet_registry.csv", + "response_registry.csv", + "message_registry.csv", + "notification_registry.csv", + "visit_registry.csv", + "visitor_registry.csv", + } +) ID_FIELDS = { "packet": "packet_id", "response": "response_id", @@ -208,6 +224,23 @@ def validate_path_fields(record: dict) -> list[str]: return errors +def validate_artifact_path_prefix(record: dict, location: Path) -> list[str]: + """Keep canonical artifact records out of control-plane and unrelated paths.""" + if not is_under(location, REGISTRY_ROOT): + return [] + constraint = ARTIFACT_PATH_PREFIXES.get(record.get("record_type")) + if constraint is None: + return [] + field, prefix = constraint + value = record.get(field) + if value is None or not isinstance(value, str): + return [] + path = PurePosixPath(value) + if not path.is_relative_to(prefix): + return [f"{field}: {record['record_type']} artifacts must be under {prefix}/"] + return [] + + def validate_lifecycle_path(record: dict, location: Path, enforce: bool) -> list[str]: if not (enforce or is_under(location, REGISTRY_ROOT)): return [] @@ -234,7 +267,12 @@ def validate_lifecycle_path(record: dict, location: Path, enforce: bool) -> list errors: list[str] = [] try: relative_location = location.resolve().relative_to(ROOT.resolve()) - if not relative_location.is_relative_to(record_prefix): + if record_type == "tag" and relative_location.parent != record_prefix: + errors.append( + f"lifecycle: tag status {status!r} requires a direct record under " + f"{record_prefix}/" + ) + elif not relative_location.is_relative_to(record_prefix): errors.append( f"lifecycle: status {status!r} requires record location under {record_prefix}/" ) @@ -279,6 +317,10 @@ def validate_document( f"{path}: {message}" for message in validate_identifier_date(record, path) ) messages.extend(f"{path}: {message}" for message in validate_path_fields(record)) + messages.extend( + f"{path}: {message}" + for message in validate_artifact_path_prefix(record, path) + ) messages.extend( f"{path}: {message}" for message in validate_lifecycle_path(record, path, check_lifecycle) @@ -369,6 +411,26 @@ def registry_paths() -> list[Path]: ) +def validate_registry_inventory( + record_paths: list[Path], registry_root: Path = REGISTRY_ROOT, schemas: Path = SCHEMAS +) -> list[str]: + """Reject registry files that are neither contract support nor validated records.""" + known_records = {path.resolve() for path in record_paths} + errors: list[str] = [] + for path in sorted(candidate for candidate in registry_root.rglob("*") if candidate.is_file()): + relative = path.resolve().relative_to(registry_root.resolve()) + if path.name == ".gitkeep": + continue + if is_under(path, schemas) and path.name.endswith(".schema.json"): + continue + if relative.parent == Path(".") and path.name in GENERATED_REGISTRY_VIEWS | {"README.md"}: + continue + if path.resolve() in known_records: + continue + errors.append(f"{path}: registry inventory contains an unvalidated or unsupported file") + return errors + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("paths", nargs="*", type=Path) @@ -411,6 +473,8 @@ def main() -> int: errors.extend(validate_tags(valid_records)) if check_unique_ids: errors.extend(validate_unique_ids(valid_records)) + if args.registry: + errors.extend(validate_registry_inventory(paths)) print("\n".join(errors) if errors else f"validated {len(paths)} record(s)") return 1 if errors else 0 diff --git a/templates/packet_record.json b/templates/packet_record.json index 0b9ccb2..d1d7bba 100644 --- a/templates/packet_record.json +++ b/templates/packet_record.json @@ -7,6 +7,7 @@ "deposited_by": "example-visitor", "content_origin": "operator_authored", "source_refs": [], + "source_note": "", "derivative_of": [], "provenance_coverage": "record_level", "source_session": "example-session", diff --git a/templates/response_record.json b/templates/response_record.json index 8a14ba2..837a7d4 100644 --- a/templates/response_record.json +++ b/templates/response_record.json @@ -7,6 +7,7 @@ "deposited_by": "example-visitor", "content_origin": "model_generated", "source_refs": [], + "source_note": "", "derivative_of": [ "YYYYMMDD-example-session-short-topic" ], diff --git a/tests/fixtures/invalid/derivative/record.json b/tests/fixtures/invalid/derivative/record.json index 76bcd74..b8d7a77 100644 --- a/tests/fixtures/invalid/derivative/record.json +++ b/tests/fixtures/invalid/derivative/record.json @@ -7,6 +7,7 @@ "deposited_by": "review-session", "content_origin": "model_generated", "source_refs": [], + "source_note": "", "derivative_of": [ "20260902-unknown-upstream" ], diff --git a/tests/fixtures/invalid/duplicate/record.json b/tests/fixtures/invalid/duplicate/record.json index f881423..2a57275 100644 --- a/tests/fixtures/invalid/duplicate/record.json +++ b/tests/fixtures/invalid/duplicate/record.json @@ -7,6 +7,7 @@ "deposited_by": "example-session", "content_origin": "operator_authored", "source_refs": [], + "source_note": "", "derivative_of": [], "provenance_coverage": "record_level", "source_session": "example-session", diff --git a/tests/fixtures/invalid/filename/not-the-packet-id.json b/tests/fixtures/invalid/filename/not-the-packet-id.json index 4bb3bb5..1918408 100644 --- a/tests/fixtures/invalid/filename/not-the-packet-id.json +++ b/tests/fixtures/invalid/filename/not-the-packet-id.json @@ -7,6 +7,7 @@ "deposited_by": "example-session", "content_origin": "operator_authored", "source_refs": [], + "source_note": "", "derivative_of": [], "provenance_coverage": "record_level", "source_session": "example-session", diff --git a/tests/fixtures/invalid/packet/record.json b/tests/fixtures/invalid/packet/record.json index e046a08..fbc474b 100644 --- a/tests/fixtures/invalid/packet/record.json +++ b/tests/fixtures/invalid/packet/record.json @@ -1 +1 @@ -{"record_type":"packet","schema_version":1,"packet_id":"20260902-example-session-invalid","created_at":"2026-09-02T12:00:00Z","created_by":"example-session","deposited_by":"example-session","content_origin":"web","source_refs":[],"derivative_of":[],"provenance_coverage":"record_level","source_session":"example-session","target_session":"review-session","topic":"fixture","status":"new","path":"tests/fixtures/invalid/packet/artifact.md","response_expected":false,"response_packet_id":null,"tags":[],"notes":"Deliberately invalid: web origin lacks source refs."} \ No newline at end of file +{"record_type":"packet","schema_version":1,"packet_id":"20260902-example-session-invalid","created_at":"2026-09-02T12:00:00Z","created_by":"example-session","deposited_by":"example-session","content_origin":"web","source_refs":[],"source_note":"","derivative_of":[],"provenance_coverage":"record_level","source_session":"example-session","target_session":"review-session","topic":"fixture","status":"new","path":"tests/fixtures/invalid/packet/artifact.md","response_expected":false,"response_packet_id":null,"tags":[],"notes":"Deliberately invalid: web origin lacks source refs."} diff --git a/tests/fixtures/invalid/path/record.json b/tests/fixtures/invalid/path/record.json index 0358330..25a3398 100644 --- a/tests/fixtures/invalid/path/record.json +++ b/tests/fixtures/invalid/path/record.json @@ -7,6 +7,7 @@ "deposited_by": "example-session", "content_origin": "operator_authored", "source_refs": [], + "source_note": "", "derivative_of": [], "provenance_coverage": "record_level", "source_session": "example-session", diff --git a/tests/fixtures/invalid/reference/record.json b/tests/fixtures/invalid/reference/record.json index 0f9e197..99e49b8 100644 --- a/tests/fixtures/invalid/reference/record.json +++ b/tests/fixtures/invalid/reference/record.json @@ -7,6 +7,7 @@ "deposited_by": "review-session", "content_origin": "operator_authored", "source_refs": [], + "source_note": "", "derivative_of": [], "provenance_coverage": "record_level", "responding_session": "review-session", diff --git a/tests/fixtures/invalid/response/record.json b/tests/fixtures/invalid/response/record.json index c546771..a95f3fc 100644 --- a/tests/fixtures/invalid/response/record.json +++ b/tests/fixtures/invalid/response/record.json @@ -7,6 +7,7 @@ "deposited_by": "review-session", "content_origin": "model_generated", "source_refs": [], + "source_note": "", "derivative_of": [], "provenance_coverage": "record_level", "responding_session": "review-session", diff --git a/tests/fixtures/invalid/tag-reference/record.json b/tests/fixtures/invalid/tag-reference/record.json index 75473f8..f94911d 100644 --- a/tests/fixtures/invalid/tag-reference/record.json +++ b/tests/fixtures/invalid/tag-reference/record.json @@ -7,6 +7,7 @@ "deposited_by": "example-session", "content_origin": "operator_authored", "source_refs": [], + "source_note": "", "derivative_of": [], "provenance_coverage": "record_level", "source_session": "example-session", diff --git a/tests/fixtures/invalid/unknown-origin/record.json b/tests/fixtures/invalid/unknown-origin/record.json new file mode 100644 index 0000000..8db3b62 --- /dev/null +++ b/tests/fixtures/invalid/unknown-origin/record.json @@ -0,0 +1,22 @@ +{ + "record_type": "packet", + "schema_version": 1, + "packet_id": "20260902-example-session-unknown-origin", + "created_at": "2026-09-02T12:00:00Z", + "created_by": "example-session", + "deposited_by": "example-session", + "content_origin": "unknown", + "source_refs": [], + "source_note": "", + "derivative_of": [], + "provenance_coverage": "partial", + "source_session": "example-session", + "target_session": "review-session", + "topic": "unknown-origin", + "status": "new", + "path": "tests/fixtures/valid/packet/artifact.md", + "response_expected": false, + "response_packet_id": null, + "tags": [], + "notes": "Invalid: unknown origin does not explain the uncertainty." +} diff --git a/tests/fixtures/valid/packet/record.json b/tests/fixtures/valid/packet/record.json index b65c2a5..bc4f39e 100644 --- a/tests/fixtures/valid/packet/record.json +++ b/tests/fixtures/valid/packet/record.json @@ -7,6 +7,7 @@ "deposited_by": "example-session", "content_origin": "operator_authored", "source_refs": [], + "source_note": "", "derivative_of": [], "provenance_coverage": "record_level", "source_session": "example-session", diff --git a/tests/fixtures/valid/response/record.json b/tests/fixtures/valid/response/record.json index a732cb7..55a2f9d 100644 --- a/tests/fixtures/valid/response/record.json +++ b/tests/fixtures/valid/response/record.json @@ -7,6 +7,7 @@ "deposited_by": "review-session", "content_origin": "model_generated", "source_refs": [], + "source_note": "", "derivative_of": [ "20260902-example-session-fixture" ], diff --git a/tests/test_tag_promotion.py b/tests/test_tag_promotion.py index 1d94e83..8f5e6f9 100644 --- a/tests/test_tag_promotion.py +++ b/tests/test_tag_promotion.py @@ -58,6 +58,19 @@ def main() -> int: if result.returncode == 0: raise AssertionError("same-change-set tag rename was accepted") + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) + base = init_repository(repository) + accepted = repository / "registry" / "tags" / "accepted" / "sub" + accepted.mkdir(parents=True) + git(repository, "rm", "registry/tags/proposed/ai-suggested.json") + (accepted / "ai-suggested.json").write_text("{}\n", encoding="utf-8") + git(repository, "add", ".") + git(repository, "commit", "--quiet", "-m", "accept tag in nested path") + result = run_guard(repository, base, git(repository, "rev-parse", "HEAD")) + if result.returncode == 0: + raise AssertionError("same-change-set nested tag move was accepted") + with tempfile.TemporaryDirectory() as temporary: repository = Path(temporary) base = init_repository(repository) diff --git a/tests/test_validator_smoke.py b/tests/test_validator_smoke.py index e305e2c..eed4658 100644 --- a/tests/test_validator_smoke.py +++ b/tests/test_validator_smoke.py @@ -4,19 +4,27 @@ import subprocess import sys +import tempfile from pathlib import Path ROOT = Path(__file__).resolve().parents[1] VALIDATOR = ROOT / "scripts" / "validate_repo.py" sys.path.insert(0, str(ROOT / "scripts")) -from validate_repo import validate_identifier_date, validate_record_type_location +from validate_repo import ( + validate_artifact_path_prefix, + validate_identifier_date, + validate_lifecycle_path, + validate_record_type_location, + validate_registry_inventory, +) INVALID_FIXTURES = { "shared schema cannot be selected as a record": ( "tests/fixtures/invalid/schema/common.json", ), "packet source provenance": ("tests/fixtures/invalid/packet/record.json",), + "unknown origin disclosure": ("tests/fixtures/invalid/unknown-origin/record.json",), "response derivation provenance": ("tests/fixtures/invalid/response/record.json",), "message recipient routing": ("tests/fixtures/invalid/message/record.json",), "notification status vocabulary": ("tests/fixtures/invalid/notification/record.json",), @@ -106,6 +114,58 @@ def assert_direct_validator_invariants() -> None: if not wrong_year: raise AssertionError("identifier/year-directory mismatch was accepted") + wrong_artifact_path = validate_artifact_path_prefix( + {"record_type": "packet", "path": "AI_ENTRYPOINT.md"}, + ROOT / "registry" / "packets" / "2026" / "example.json", + ) + if not wrong_artifact_path: + raise AssertionError("packet artifact path outside datadrops/ was accepted") + + wrong_response_path = validate_artifact_path_prefix( + {"record_type": "response", "path": "AI_ENTRYPOINT.md"}, + ROOT / "registry" / "responses" / "2026" / "example.json", + ) + if not wrong_response_path: + raise AssertionError("response artifact path outside responses/ was accepted") + + wrong_signoff_path = validate_artifact_path_prefix( + {"record_type": "visit", "signoff_path": "responses/example.md"}, + ROOT / "registry" / "visits" / "2026" / "example.json", + ) + if not wrong_signoff_path: + raise AssertionError("visit signoff path outside responses/signoffs/ was accepted") + + nested_tag = validate_lifecycle_path( + {"record_type": "tag", "status": "accepted"}, + ROOT / "registry" / "tags" / "accepted" / "sub" / "example.json", + enforce=True, + ) + if not nested_tag: + raise AssertionError("nested tag record was accepted") + + with tempfile.TemporaryDirectory() as temporary: + registry_root = Path(temporary) / "registry" + schemas = registry_root / "schemas" + schemas.mkdir(parents=True) + (schemas / "common.schema.json").write_text("{}\n", encoding="utf-8") + (registry_root / "README.md").write_text("# Registry\n", encoding="utf-8") + (registry_root / "INDEX.md").write_text("# Index\n", encoding="utf-8") + record = registry_root / "packets" / "2026" / "canonical.json" + record.parent.mkdir(parents=True) + record.write_text("{}\n", encoding="utf-8") + (registry_root / "packets" / "2026" / "shadow.JSON").write_text( + "{}\n", encoding="utf-8" + ) + stray = registry_root / "inbox" / "untracked.json" + stray.parent.mkdir() + stray.write_text("{}\n", encoding="utf-8") + inventory_errors = validate_registry_inventory([record], registry_root, schemas) + if len(inventory_errors) != 2: + raise AssertionError( + "registry inventory did not reject uppercase and out-of-layout JSON: " + + "\n".join(inventory_errors) + ) + def main() -> int: assert_direct_validator_invariants()