From e30ae032fead89aa7e720284c4c29be50fa41754 Mon Sep 17 00:00:00 2001 From: Jay Dreyer Date: Thu, 6 Aug 2026 12:23:28 -0500 Subject: [PATCH] Add trusted relationship provenance --- .github/workflows/quality.yml | 5 +- README.md | 16 +- docs/architecture.md | 22 +-- docs/decisions.md | 44 ++++++ docs/demo-scenario.md | 4 +- ...ilestone-7-enterprise-knowledge-catalog.md | 88 +++++++---- docs/remaining-work-inventory.md | 23 +-- .../versions/0014_relationship_provenance.py | 146 ++++++++++++++++++ src/changeops/db/models.py | 48 ++++++ src/changeops/schemas/catalog.py | 11 +- .../services/catalog_projection_service.py | 15 +- src/changeops/services/seed_service.py | 14 +- tests/integration/test_catalog_api.py | 39 +++-- tests/integration/test_seed_idempotency.py | 48 ++++++ tests/unit/test_catalog_schemas.py | 14 +- web/app/components/catalog-views.test.tsx | 33 +++- web/app/components/catalog-views.tsx | 41 +++-- web/app/globals.css | 5 + web/lib/types.ts | 11 +- 19 files changed, 521 insertions(+), 106 deletions(-) create mode 100644 migrations/versions/0014_relationship_provenance.py diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 5894d3d..06bb5ce 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -113,7 +113,7 @@ jobs: uses: actions/checkout@v6 - name: Build migration image - run: docker compose build migrate + run: docker compose build migrate seed - name: Start an empty PostgreSQL database run: | @@ -126,6 +126,9 @@ jobs: - name: Upgrade empty database to head run: docker compose run --rm --no-deps migrate alembic upgrade head + - name: Seed populated dependency rows + run: docker compose run --rm seed + - name: Downgrade head by one revision run: docker compose run --rm --no-deps migrate alembic downgrade -1 diff --git a/README.md b/README.md index ffcce0b..dd46eff 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,13 @@ ChangeOps analyzes operational and policy changes, identifies affected people an ## Current milestone -Milestone 7 PR B, Read-Only Confluence Document Identity. - -The catalog explorer can now associate the selected Manager Travel Approval Guide with one real, -read-only Confluence Cloud page. An explicit refresh validates the configured page identity, -imports only bounded page and space metadata into PostgreSQL, and exposes the canonical external -link without changing the stable ChangeOps document ID. Failed refreshes preserve last-known-good -metadata. Existing relationships, assessment behavior, comparison, approval, Learning System, and -Jira behavior remain unchanged. Relationship-origin provenance remains the separately deferred -Milestone 7 PR C. +Milestone 7 PR C, Trusted Relationship Provenance. + +The catalog explorer now records and exposes honest row-level origin for the 12 policy dependency +rows owned by the deterministic demonstration seed. Each relationship detail separates its seeded +source, owning authority, provenance timestamp, trust basis, and explicit absence of AI +involvement. The existing typed foreign keys, relationship identities, assessment behavior, +Confluence identity, comparison, approval, Learning System, and Jira behavior remain unchanged. Milestone 0 is complete and preserved by the `v0.0.1-milestone-0` tag. diff --git a/docs/architecture.md b/docs/architecture.md index 7efd7f1..0af99f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,12 +1,13 @@ # ChangeOps Architecture -This document describes the current implementation through Milestone 7 PR B: the completed +This document describes the current implementation through Milestone 7 PR C: the completed policy-analysis and approval lifecycles, the integrated Next.js journey and workbench, deterministic preparation and explicit execution of learning-assignment and Jira create-issue commands, and immutable deterministic comparison of two accepted international-travel policy rulesets and their immutable -enterprise impact assessments, plus the read-only Enterprise Knowledge Catalog Explorer and its -narrow persisted Confluence Cloud document-identity boundary. +enterprise impact assessments, plus the read-only Enterprise Knowledge Catalog Explorer, its +narrow persisted Confluence Cloud document-identity boundary, and trusted origin metadata for +deterministic policy dependencies. ## High-level architecture @@ -853,15 +854,18 @@ rule fields and leaves unknown codes visible without guessing. The projection preserves stable source and relationship IDs, deterministic row ordering, separate baseline and proposed dependency rows, foreign-key and semantic-uniqueness trust basis, and honest null owner/source/description fields. Dataset context is labeled as the seeded demonstration -catalog, while creator/import/curation/approval provenance is separately labeled not recorded. -Catalog reads never query assessment evidence or impact paths as current catalog authority and -perform no writes. Workers, teams, and customer commitments appear only as organization-scoped -assessment-context counts. +catalog. Each of the 12 seed-owned policy dependency rows also stores the closed provenance +category `seeded_demonstration`, the demonstration-seed authority and reference, and the timestamp +when that provenance metadata was recorded. These values describe row origin, not human approval +or the original relationship creation time. Catalog reads never query assessment evidence or +impact paths as current catalog authority and perform no writes. Workers, teams, and customer +commitments appear only as organization-scoped assessment-context counts. Next.js server-renders `/catalog`, supported object details, and policy-rule-reference details with uncached API reads. Native `details` elements keep raw IDs and integrity basis collapsed by default. -There is no search, administration, relationship editing, or graph view. PR A added no migration -and PR B does not change assessment behavior. +There is no search, administration, relationship editing, AI relationship generation, or graph +view. The UI presents provenance separately from the deterministic trust basis and states that AI +did not create or infer the seeded relationships. PR C does not change assessment behavior. ## Read-only Confluence document identity boundary diff --git a/docs/decisions.md b/docs/decisions.md index 769b607..027201c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -844,3 +844,47 @@ space. OAuth scope guidance for an equivalent app is limited to `read:page:confl silently rewriting external identity. - Relationship-origin provenance is still absent. PR C remains a separate schema and governance decision. + +# ADR-0021 — Store Seeded Relationship Origin on Existing Typed Dependencies + +## Status + +Accepted + +## Context + +The Enterprise Knowledge Catalog shows that policy dependencies are persisted typed rows consumed +deterministically by impact analysis, but PR A could not honestly say who created those rows or +whether AI inferred them. PR B added external Confluence document identity without changing that +gap. The current demonstration has exactly 12 dependency rows, all owned by the idempotent seed. +There is no relationship import, curation, AI proposal, or human decision workflow. + +## Decision + +Add the same four required origin fields to `policy_system_dependencies`, +`policy_document_dependencies`, and `policy_training_dependencies`: provenance category, owning +authority, stable source reference, and the timestamp when provenance metadata was recorded. +Preserve the three typed target foreign keys and existing stable relationship IDs. + +The initial closed category is only `seeded_demonstration`. The migration backfills only the 12 +canonical seed-owned IDs and fails if an unexpected dependency row exists rather than falsely +labeling it as seeded. The idempotent seed reapplies the same canonical values. The recorded +timestamp is explicitly the provenance-recording instant, not a claim about original relationship +creation time. + +The catalog response and UI keep origin separate from trust. `Trusted` means the row has typed +foreign-key integrity, semantic uniqueness, and deterministic analyzer use; it does not mean human +approval. The UI explicitly states that AI did not create or infer these relationships. No +`human-approved AI proposal` category exists until a real immutable proposal and human-decision +aggregate can support it. + +## Consequences + +- Reviewers can identify why an object is connected, where the row came from, and whether AI was + involved without reading code. +- Relationship reads remain read-only, and impact analysis consumes the same fields and rows as + before. +- The schema intentionally duplicates four bounded fields across three real dependency tables + instead of introducing a generic or polymorphic relationship layer. +- Imported, human-curated, or AI-backed relationship origins require separately reviewed lineage + and schema changes; they cannot be claimed through serializer labels alone. diff --git a/docs/demo-scenario.md b/docs/demo-scenario.md index a36d0bb..b15b7cd 100644 --- a/docs/demo-scenario.md +++ b/docs/demo-scenario.md @@ -351,8 +351,8 @@ not a persistent scenario-management feature: published status, version 2, missing owner, and separate baseline/proposed `instructs_manager` rows; - the baseline dependency follows to the policy-scoped `MANAGER_APPROVAL_REQUIRED` reference, - which links back to its connected typed catalog targets and distinguishes deterministic trust - basis from missing row-origin provenance; + which links back to its connected typed catalog targets and distinguishes seeded row origin, + deterministic trust basis, and the explicit absence of AI involvement; - the approval workbench opens the next pending item in persisted order, keeps evidence collapsed, and presents execution only after completed approval as a separate explicit operation; - the golden accepted extraction completes without clarification; diff --git a/docs/milestone-7-enterprise-knowledge-catalog.md b/docs/milestone-7-enterprise-knowledge-catalog.md index a0de244..2df1071 100644 --- a/docs/milestone-7-enterprise-knowledge-catalog.md +++ b/docs/milestone-7-enterprise-knowledge-catalog.md @@ -6,11 +6,17 @@ PR A is merged. PR B is implemented with fixture-backed verification and was liv against the bounded Acme Confluence knowledge base on August 4, 2026. Live credentials and page identifiers remain environment-only. The current local credential is broader than least privilege and is an explicitly accepted current-environment limitation, not a PR B blocker. PR C -relationship-origin provenance remains deferred. +relationship-origin provenance is implemented for the 12 canonical policy dependency rows owned +by the demonstration seed. -This document is based on repository `main` at commit `14cfb5d` and on the implementation, -migrations, seed/reset behavior, tests, and product-facing read models at that commit. The -implementation is the source of truth where older planning language differs. +This document is based on repository `main` at commit `345018d` plus the PR C implementation, +migrations, seed/reset behavior, tests, and product-facing read models. The implementation is the +source of truth where older planning language differs. + +Sections explicitly scoped to PR A or PR B preserve the historical design and acceptance record +from before relationship provenance existed. The current-state and PR C sections describe the +implemented behavior: seeded origin is stored for all 12 canonical dependency rows; imported, +human-curated, and AI-backed relationship lineage remains deferred. ## Product capability @@ -31,9 +37,8 @@ The approved delivery sequence is: 2. **PR B — narrow external document identity and read-only Confluence metadata:** persist and display selected page identities, imported metadata, and external links without changing relationships or assessment behavior. -3. **PR C — relationship-origin provenance:** add only the narrowly required provenance for - existing typed relationships after the PR A and PR B reviewer experiences show what is still - missing. +3. **PR C — relationship-origin provenance:** record and expose narrowly bounded provenance for + the existing seed-owned typed relationships without changing assessment behavior. PR A is the Codex-ready first vertical slice in this document. PR B is a named planned slice, not merely a deferred possibility. PR C must remain distinct from PR B: external document identity and @@ -105,9 +110,9 @@ MANAGER_APPROVAL_REQUIRED Used deterministically during impact assessment ``` -The current schema cannot honestly display `Owner: Travel Operations` or row-level relationship -origin. Those values belong in a separately reviewed later provenance migration, not in hard-coded -serializer logic. +The current schema still cannot honestly display `Owner: Travel Operations`. PR C now stores +row-level seeded relationship origin separately from object ownership; imported, human-curated, +and AI-backed relationship origin remains unavailable. ## Current catalog and domain model @@ -340,16 +345,15 @@ For `document-manager-travel-approval-guide`: 7. The persisted path is policy change → policy rule → enterprise document. 8. The proposed action remains a separate immutable assessment artifact. -The relationship is therefore trusted by the current analyzer because it is a persisted typed -mapping with referential integrity and the analyzer consumes it deterministically. That is a -trust basis, not row-origin provenance. +The relationship is trusted by the current analyzer because it is a persisted typed mapping with +referential integrity and the analyzer consumes it deterministically. PR C stores its seeded row +origin separately; neither fact is evidence of human approval. ## Current provenance and the gaps ### Provenance that already exists -The implementation already preserves several kinds of provenance, but none is relationship-origin -provenance: +The implementation preserves several kinds of provenance: - extraction attempts store model/provider/prompt/schema metadata and policy source-span provenance; @@ -359,7 +363,7 @@ provenance: - assessment paths store the ordered relationship names used for one historical conclusion; - reviews, approvals, commands, and results store human and execution lineage. -For dependency relationships themselves, the existing provenance-like fields are limited to: +For dependency relationships, PR C adds required origin fields to the existing typed facts: - stable dependency ID; - policy ID; @@ -368,15 +372,19 @@ For dependency relationships themselves, the existing provenance-like fields are - relationship type; - explanation; - document impact classification where applicable. +- provenance category `seeded_demonstration`; +- owning authority `ChangeOps demonstration seed`; +- stable reference `changeops.seed.relationships.v1`; +- the timestamp when provenance metadata was recorded. -There is no relationship creator, owning authority, creation time, import reference, curation -decision, approval record, or provenance category. +There is no individual relationship creator, original creation time, import lineage, curation +decision, approval record, or AI proposal/decision lineage. ### Missing information by requested source category | Category | What would be required for an honest claim | Current state | | --- | --- | --- | -| Seeded demonstration data | row-level origin or an explicitly bounded dataset declaration tied to the canonical seed | dataset origin is evident from code and demo context, but not stored per row | +| Seeded demonstration data | row-level origin tied to the canonical seed | stored on all 12 canonical dependency rows | | Imported authoritative metadata | source system type, external stable ID/URL, import batch or retrieval time, source version/fingerprint | absent | | Human-curated mapping | curator identity, authority/role, recorded time, rationale, lifecycle status | absent | | Human-approved AI proposal | immutable proposal, exact evidence, model metadata, reviewer decision, reviewer identity/time, accepted relationship lineage | entirely absent and intentionally deferred | @@ -840,7 +848,7 @@ For the Manager Travel Approval Guide, business-visible content should include: - `instructs_manager`; - `review_required`; - the persisted explanation; -- row-level provenance not recorded; +- seeded row-level provenance (added by PR C; historically unavailable in PR A); - deterministic assessment use. ### Rule-reference detail page @@ -905,9 +913,7 @@ applicable row. ### PR C relationship-origin representation -After PR A and PR B are understood, PR C should make current seeded and future -human-curated/imported relationship origin explicit only where required, without weakening typed -foreign keys. +PR C makes current seeded relationship origin explicit without weakening typed foreign keys. Minimum information under review: @@ -924,9 +930,10 @@ The closed product categories are: - human-curated mapping; - human-approved AI proposal. -Only categories supported by real persisted lineage may be returned. PR C may implement seeded, -imported, and human-curated relationship-origin metadata. `human-approved AI proposal` must remain -unavailable until a later proposal-and-decision aggregate exists. +Only categories supported by real persisted lineage may be returned. The implemented category is +`seeded demonstration data`. Imported, human-curated, and `human-approved AI proposal` categories +remain unavailable until separately reviewed lineage and, for AI, a proposal-and-decision +aggregate exist. ## Seed and reset implications @@ -1162,7 +1169,7 @@ Migration: none. Review gate: > Does the projection make the current enterprise-impact logic understandable while remaining -> honest about missing row provenance and owner metadata? +> honest about object-owner gaps and the row-provenance gap that existed at PR A review? ### PR B — Narrow external document identity and read-only Confluence metadata @@ -1175,10 +1182,10 @@ implementation plan and review. ### PR C — Narrow relationship-origin provenance -Recommended only if the validated PR A and Confluence experiences show that missing persisted -relationship origin still materially blocks the reviewer story. +Implemented after the validated PR A and Confluence experiences showed that missing persisted +relationship origin materially blocked the reviewer story. -Likely scope: +Implemented scope: - narrow provenance additions to the three typed policy dependency tables; - canonical seed values; @@ -1186,9 +1193,22 @@ Likely scope: - migration round-trip, constraint, idempotency, reset, API, and UI tests; - a new ADR for the accepted provenance persistence decision. -PR C must receive a concrete schema review before coding. It must not add AI proposals, Confluence -import behavior, external document identity changes, unrelated external imports, or a generic -catalog layer. +PR C received a concrete schema review before coding. It adds no AI proposals, Confluence import +behavior, external document identity changes, unrelated external imports, or generic catalog +layer. + +## Acceptance criteria for PR C + +- [x] Seeded relationship provenance is persisted on all 12 canonical dependency rows. +- [x] The Manager guide visibly shows relationship, policy scope, type, explanation, seeded + source, trusted level, and no AI involvement. +- [x] Baseline and proposed policy dependency rows remain distinct. +- [x] Trust basis remains separate from origin and human approval. +- [x] Migration backfill is limited to canonical seed-owned IDs and round-trips with populated + rows. +- [x] Seed reruns and demo reset preserve the same provenance. +- [x] Existing deterministic assessment behavior and golden counts remain unchanged. +- [x] Imported, human-curated, and AI-backed relationship provenance remains deferred. ## Acceptance criteria for PR A @@ -1209,7 +1229,7 @@ catalog layer. `MANAGER_APPROVAL_REQUIRED`. - [ ] The rule-reference view explains that the rule is policy-scoped and not a standalone stored rule entity. -- [ ] The relationship view distinguishes trust basis from missing row provenance. +- [x] The relationship view distinguishes trust basis from seeded row provenance. - [ ] The relationship view explains its deterministic assessment use. - [ ] All catalog endpoints are read-only. - [ ] Catalog access creates no workflow or assessment artifact. diff --git a/docs/remaining-work-inventory.md b/docs/remaining-work-inventory.md index 5ef8aa3..e3d5f68 100644 --- a/docs/remaining-work-inventory.md +++ b/docs/remaining-work-inventory.md @@ -79,7 +79,7 @@ Remaining work must add a distinct product, architecture, deployment, or present ## Recommended remaining work - [x] Narrow Confluence-backed document source integration and live Acme page validation -- [ ] Trusted relationship provenance and governance +- [x] Trusted relationship provenance for seeded deterministic relationships - [ ] Unified audit timeline - [ ] AWS deployment architecture decision - [ ] Terraform implementation and AWS deployment @@ -195,16 +195,17 @@ MANAGER_APPROVAL_REQUIRED - [x] Existing seeded documents, systems, and training courses are visible through a coherent catalog UI; workers, teams, and customer commitments remain compact assessment context. - [ ] Object ownership and source-system provenance are visible. -- [x] Persisted typed relationship trust basis can be inspected separately from the explicitly - missing row-origin provenance. +- [x] Persisted typed relationship trust basis can be inspected separately from seeded row-origin + provenance. - [x] A reviewer can explain why the Manager Travel Approval Guide is deterministic assessment input for both baseline and proposed policy scopes. - [x] Existing analysis behavior remains unchanged. - [x] Demo reset remains safe, repeatable, and preserves the catalog projection. -PR A intentionally leaves the ownership/source-provenance criterion incomplete where the typed -tables do not persist those facts. External document identity remains PR B; relationship-origin -provenance remains a separate, later PR C decision. +PR A intentionally left the ownership/source-provenance criterion incomplete where the typed +tables did not persist those facts. PR B added external document identity, and PR C now records +seeded relationship origin. Object ownership plus imported, curated, and AI-backed relationship +lineage remain separate future decisions. --- @@ -361,10 +362,10 @@ AI must never silently create authoritative relationships during policy analysis ### Completion criteria for the required slice -- [ ] Relationship provenance is visible. -- [ ] Seeded relationships are honestly labeled. -- [ ] The reviewer can distinguish enterprise facts from AI recommendations. -- [ ] Existing impact traversal continues to use only trusted relationships. +- [x] Relationship provenance is visible. +- [x] Seeded relationships are honestly labeled. +- [x] The reviewer can distinguish enterprise facts from AI recommendations. +- [x] Existing impact traversal continues to use only trusted relationships. ### Decision gate for AI proposals @@ -735,7 +736,7 @@ Jira and Confluence REST integrations alone do not automatically justify MCP. 4. [x] Implement the read-only catalog explorer. 5. [x] Create the bounded Acme Confluence content set using the documented manual setup. 6. [x] Add read-only Confluence metadata import and links. -7. [ ] Expose trusted relationship provenance. +7. [x] Expose trusted relationship provenance. 8. [ ] Decide whether AI-proposed relationship governance adds sufficient value. 9. [ ] Implement the unified audit timeline. 10. [ ] Produce the AWS architecture decision. diff --git a/migrations/versions/0014_relationship_provenance.py b/migrations/versions/0014_relationship_provenance.py new file mode 100644 index 0000000..c93d216 --- /dev/null +++ b/migrations/versions/0014_relationship_provenance.py @@ -0,0 +1,146 @@ +"""Add trusted relationship provenance. + +Revision ID: 0014_relationship_provenance +Revises: 0013_confluence_document_sources +Create Date: 2026-08-06 +""" + +from collections.abc import Sequence +from datetime import UTC, datetime + +import sqlalchemy as sa +from alembic import op + +revision: str = "0014_relationship_provenance" +down_revision: str | None = "0013_confluence_document_sources" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +PROVENANCE_RECORDED_AT = datetime(2026, 8, 6, tzinfo=UTC) +PROVENANCE_AUTHORITY = "ChangeOps demonstration seed" +PROVENANCE_REFERENCE = "changeops.seed.relationships.v1" + +CANONICAL_IDS = { + "policy_system_dependencies": ( + "dependency-policy-travel-request", + "dependency-policy-travel-request-proposed", + "dependency-policy-learning-management", + "dependency-policy-learning-management-proposed", + ), + "policy_document_dependencies": ( + "dependency-policy-primary-document", + "dependency-policy-primary-document-proposed", + "dependency-policy-booking-article", + "dependency-policy-booking-article-proposed", + "dependency-policy-manager-guide", + "dependency-policy-manager-guide-proposed", + ), + "policy_training_dependencies": ( + "dependency-policy-security-course", + "dependency-policy-security-course-proposed", + ), +} + + +def upgrade() -> None: + for table_name in CANONICAL_IDS: + op.add_column( + table_name, + sa.Column("provenance_category", sa.String(length=40), nullable=True), + ) + op.add_column( + table_name, + sa.Column("provenance_authority", sa.String(length=200), nullable=True), + ) + op.add_column( + table_name, + sa.Column("provenance_reference", sa.String(length=100), nullable=True), + ) + op.add_column( + table_name, + sa.Column("provenance_recorded_at", sa.DateTime(timezone=True), nullable=True), + ) + + for table_name, canonical_ids in CANONICAL_IDS.items(): + quoted_ids = ", ".join(f"'{item}'" for item in canonical_ids) + op.execute( + sa.text( + f""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM {table_name} + WHERE id NOT IN ({quoted_ids}) + ) THEN + RAISE EXCEPTION + 'Cannot assign seeded provenance to noncanonical rows in {table_name}'; + END IF; + END + $$; + """ + ) + ) + op.execute( + sa.text( + f""" + UPDATE {table_name} + SET provenance_category = 'seeded_demonstration', + provenance_authority = :authority, + provenance_reference = :reference, + provenance_recorded_at = :recorded_at + WHERE id IN ({quoted_ids}) + """ + ).bindparams( + authority=PROVENANCE_AUTHORITY, + reference=PROVENANCE_REFERENCE, + recorded_at=PROVENANCE_RECORDED_AT, + ) + ) + + for column_name in ( + "provenance_category", + "provenance_authority", + "provenance_reference", + "provenance_recorded_at", + ): + op.alter_column(table_name, column_name, nullable=False) + + op.create_check_constraint( + f"ck_{table_name}_provenance_category", + table_name, + "provenance_category = 'seeded_demonstration'", + ) + op.create_check_constraint( + f"ck_{table_name}_provenance_authority", + table_name, + "length(btrim(provenance_authority)) > 0", + ) + op.create_check_constraint( + f"ck_{table_name}_provenance_reference", + table_name, + "length(btrim(provenance_reference)) > 0", + ) + + +def downgrade() -> None: + for table_name in reversed(tuple(CANONICAL_IDS)): + op.drop_constraint( + f"ck_{table_name}_provenance_reference", + table_name, + type_="check", + ) + op.drop_constraint( + f"ck_{table_name}_provenance_authority", + table_name, + type_="check", + ) + op.drop_constraint( + f"ck_{table_name}_provenance_category", + table_name, + type_="check", + ) + op.drop_column(table_name, "provenance_recorded_at") + op.drop_column(table_name, "provenance_reference") + op.drop_column(table_name, "provenance_authority") + op.drop_column(table_name, "provenance_category") diff --git a/src/changeops/db/models.py b/src/changeops/db/models.py index cebc206..891d352 100644 --- a/src/changeops/db/models.py +++ b/src/changeops/db/models.py @@ -668,6 +668,18 @@ class PolicyChangeQuestion(Base): class PolicySystemDependency(Base): __tablename__ = "policy_system_dependencies" __table_args__ = ( + CheckConstraint( + "provenance_category = 'seeded_demonstration'", + name="ck_policy_system_dependencies_provenance_category", + ), + CheckConstraint( + "length(btrim(provenance_authority)) > 0", + name="ck_policy_system_dependencies_provenance_authority", + ), + CheckConstraint( + "length(btrim(provenance_reference)) > 0", + name="ck_policy_system_dependencies_provenance_reference", + ), UniqueConstraint( "policy_change_id", "rule_code", @@ -685,6 +697,10 @@ class PolicySystemDependency(Base): system_id: Mapped[str] = mapped_column(ForeignKey("enterprise_systems.id"), index=True) relationship_type: Mapped[str] = mapped_column(String(100)) explanation: Mapped[str] = mapped_column(Text) + provenance_category: Mapped[str] = mapped_column(String(40)) + provenance_authority: Mapped[str] = mapped_column(String(200)) + provenance_reference: Mapped[str] = mapped_column(String(100)) + provenance_recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) class PolicyDocumentDependency(Base): @@ -694,6 +710,18 @@ class PolicyDocumentDependency(Base): "impact_classification IN ('review_required', 'update_required')", name="ck_policy_document_dependencies_classification", ), + CheckConstraint( + "provenance_category = 'seeded_demonstration'", + name="ck_policy_document_dependencies_provenance_category", + ), + CheckConstraint( + "length(btrim(provenance_authority)) > 0", + name="ck_policy_document_dependencies_provenance_authority", + ), + CheckConstraint( + "length(btrim(provenance_reference)) > 0", + name="ck_policy_document_dependencies_provenance_reference", + ), UniqueConstraint( "policy_change_id", "rule_code", @@ -715,11 +743,27 @@ class PolicyDocumentDependency(Base): relationship_type: Mapped[str] = mapped_column(String(100)) impact_classification: Mapped[str] = mapped_column(String(30)) explanation: Mapped[str] = mapped_column(Text) + provenance_category: Mapped[str] = mapped_column(String(40)) + provenance_authority: Mapped[str] = mapped_column(String(200)) + provenance_reference: Mapped[str] = mapped_column(String(100)) + provenance_recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) class PolicyTrainingDependency(Base): __tablename__ = "policy_training_dependencies" __table_args__ = ( + CheckConstraint( + "provenance_category = 'seeded_demonstration'", + name="ck_policy_training_dependencies_provenance_category", + ), + CheckConstraint( + "length(btrim(provenance_authority)) > 0", + name="ck_policy_training_dependencies_provenance_authority", + ), + CheckConstraint( + "length(btrim(provenance_reference)) > 0", + name="ck_policy_training_dependencies_provenance_reference", + ), UniqueConstraint( "policy_change_id", "rule_code", @@ -737,6 +781,10 @@ class PolicyTrainingDependency(Base): course_id: Mapped[str] = mapped_column(ForeignKey("training_courses.id"), index=True) relationship_type: Mapped[str] = mapped_column(String(100)) explanation: Mapped[str] = mapped_column(Text) + provenance_category: Mapped[str] = mapped_column(String(40)) + provenance_authority: Mapped[str] = mapped_column(String(200)) + provenance_reference: Mapped[str] = mapped_column(String(100)) + provenance_recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) class CustomerCommitment(Base): diff --git a/src/changeops/schemas/catalog.py b/src/changeops/schemas/catalog.py index 6b79ff8..15c971b 100644 --- a/src/changeops/schemas/catalog.py +++ b/src/changeops/schemas/catalog.py @@ -106,7 +106,9 @@ class RelationshipEndpointResponse(StrictModel): class RelationshipTrustResponse(StrictModel): + level: Literal["trusted"] state: Literal["persisted_typed_relationship"] + basis: Literal["deterministic_enterprise_relationship"] integrity: list[ Literal[ "policy_change_foreign_key", @@ -120,11 +122,14 @@ class RelationshipTrustResponse(StrictModel): class RelationshipProvenanceResponse(StrictModel): - classification: Literal["not_recorded"] + classification: Literal["seeded_demonstration"] + source_label: Literal["Seeded demonstration data"] creator: None - owning_authority: None - recorded_at: None + owning_authority: str + reference: str + recorded_at: datetime approval: None + ai_involvement: Literal["none"] class CatalogRelationshipResponse(StrictModel): diff --git a/src/changeops/services/catalog_projection_service.py b/src/changeops/services/catalog_projection_service.py index 6ea77e1..26f89f1 100644 --- a/src/changeops/services/catalog_projection_service.py +++ b/src/changeops/services/catalog_projection_service.py @@ -413,7 +413,9 @@ def _relationships_for_object( href=_frontend_object_href(object_type, record.id), ), trust=RelationshipTrustResponse( + level="trusted", state="persisted_typed_relationship", + basis="deterministic_enterprise_relationship", integrity=[ "policy_change_foreign_key", spec.target_integrity, @@ -421,7 +423,7 @@ def _relationships_for_object( ], used_deterministically=True, ), - provenance=_missing_relationship_provenance(), + provenance=_relationship_provenance(dependency), ) ) return relationships @@ -551,13 +553,16 @@ def _catalog_context() -> CatalogContextResponse: ) -def _missing_relationship_provenance() -> RelationshipProvenanceResponse: +def _relationship_provenance(dependency) -> RelationshipProvenanceResponse: return RelationshipProvenanceResponse( - classification="not_recorded", + classification=dependency.provenance_category, + source_label="Seeded demonstration data", creator=None, - owning_authority=None, - recorded_at=None, + owning_authority=dependency.provenance_authority, + reference=dependency.provenance_reference, + recorded_at=dependency.provenance_recorded_at, approval=None, + ai_involvement="none", ) diff --git a/src/changeops/services/seed_service.py b/src/changeops/services/seed_service.py index f2c485d..5d2bdee 100644 --- a/src/changeops/services/seed_service.py +++ b/src/changeops/services/seed_service.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import UTC, date, datetime from sqlalchemy.orm import Session @@ -25,6 +25,12 @@ POLICY_CHANGE_ID = "policy-international-travel-2026-09" PROPOSED_POLICY_CHANGE_ID = "policy-international-travel-proposed-2026-10" COURSE_IDENTIFIER = "international-travel-security" +RELATIONSHIP_PROVENANCE = { + "provenance_category": "seeded_demonstration", + "provenance_authority": "ChangeOps demonstration seed", + "provenance_reference": "changeops.seed.relationships.v1", + "provenance_recorded_at": datetime(2026, 8, 6, tzinfo=UTC), +} POLICY_TEXT = """Effective September 1, 2026, U.S.-based employees and contractors traveling internationally for business must complete the International Travel Security course before @@ -549,12 +555,14 @@ def seed_database(session: Session) -> None: session.merge( PolicySystemDependency( policy_change_id=POLICY_CHANGE_ID, + **RELATIONSHIP_PROVENANCE, **dependency, ) ) session.merge( PolicySystemDependency( policy_change_id=PROPOSED_POLICY_CHANGE_ID, + **RELATIONSHIP_PROVENANCE, **{**dependency, "id": f"{dependency['id']}-proposed"}, ) ) @@ -562,12 +570,14 @@ def seed_database(session: Session) -> None: session.merge( PolicyDocumentDependency( policy_change_id=POLICY_CHANGE_ID, + **RELATIONSHIP_PROVENANCE, **dependency, ) ) session.merge( PolicyDocumentDependency( policy_change_id=PROPOSED_POLICY_CHANGE_ID, + **RELATIONSHIP_PROVENANCE, **{**dependency, "id": f"{dependency['id']}-proposed"}, ) ) @@ -579,6 +589,7 @@ def seed_database(session: Session) -> None: course_id=COURSE_IDENTIFIER, relationship_type="requires_course", explanation="The policy requires the International Travel Security course.", + **RELATIONSHIP_PROVENANCE, ) ) session.merge( @@ -589,6 +600,7 @@ def seed_database(session: Session) -> None: course_id=COURSE_IDENTIFIER, relationship_type="requires_course", explanation="The policy requires the International Travel Security course.", + **RELATIONSHIP_PROVENANCE, ) ) for assignment in COMMITMENT_ASSIGNMENTS: diff --git a/tests/integration/test_catalog_api.py b/tests/integration/test_catalog_api.py index 3c98253..c7d028e 100644 --- a/tests/integration/test_catalog_api.py +++ b/tests/integration/test_catalog_api.py @@ -16,7 +16,11 @@ ) from changeops.db.session import SessionLocal from changeops.services.demo_reset_service import reset_demo_workflows -from changeops.services.seed_service import ORGANIZATION_ID, POLICY_CHANGE_ID +from changeops.services.seed_service import ( + ORGANIZATION_ID, + POLICY_CHANGE_ID, + RELATIONSHIP_PROVENANCE, +) CATALOG_URL = f"/api/v1/catalog-objects?organization_id={ORGANIZATION_ID}" MANAGER_GUIDE_URL = ( @@ -107,7 +111,9 @@ def test_document_detail_preserves_flagship_metadata_and_policy_lineage(client) "policy-international-travel-proposed-2026-10:MANAGER_APPROVAL_REQUIRED" ) assert baseline["trust"] == { + "level": "trusted", "state": "persisted_typed_relationship", + "basis": "deterministic_enterprise_relationship", "integrity": [ "policy_change_foreign_key", "enterprise_document_foreign_key", @@ -116,23 +122,28 @@ def test_document_detail_preserves_flagship_metadata_and_policy_lineage(client) "used_deterministically": True, } assert baseline["provenance"] == { - "classification": "not_recorded", + "classification": "seeded_demonstration", + "source_label": "Seeded demonstration data", "creator": None, - "owning_authority": None, - "recorded_at": None, + "owning_authority": "ChangeOps demonstration seed", + "reference": "changeops.seed.relationships.v1", + "recorded_at": "2026-08-06T00:00:00Z", "approval": None, + "ai_involvement": "none", } assert body["assessment_usage"]["changes_assessment_behavior"] is False assert "deterministic enterprise analyzer" in body["assessment_usage"]["statement"] def test_system_and_training_details_normalize_status_without_inventing_metadata(client) -> None: - system = client.get("/api/v1/catalog-objects/enterprise_system/system-travel-request").json()[ - "object" - ] - course = client.get( + system_detail = client.get( + "/api/v1/catalog-objects/enterprise_system/system-travel-request" + ).json() + course_detail = client.get( "/api/v1/catalog-objects/training_course/international-travel-security" - ).json()["object"] + ).json() + system = system_detail["object"] + course = course_detail["object"] assert system["status"] == {"value": "active", "basis": "normalized"} assert system["description"] == "Collects travel requests and manager approvals." @@ -148,6 +159,14 @@ def test_system_and_training_details_normalize_status_without_inventing_metadata "completion_record_count": 6, } assert system["relationship_count"] == 2 + assert { + relationship["provenance"]["classification"] + for relationship in system_detail["relationships"] + course_detail["relationships"] + } == {"seeded_demonstration"} + assert { + relationship["provenance"]["ai_involvement"] + for relationship in system_detail["relationships"] + course_detail["relationships"] + } == {"none"} assert course["relationship_count"] == 2 @@ -184,6 +203,7 @@ def test_unknown_persisted_rule_code_stays_visible_without_guessing(client) -> N system_id="system-expense-management", relationship_type="future_mapping", explanation="A controlled fixture for an unknown mapping.", + **RELATIONSHIP_PROVENANCE, ) ) @@ -267,6 +287,7 @@ def test_catalog_fails_closed_on_cross_organization_relationship_endpoint(client system_id="system-controlled-integrity-fixture", relationship_type="fixture_relationship", explanation="Exists only to verify fail-closed organization resolution.", + **RELATIONSHIP_PROVENANCE, ) ) diff --git a/tests/integration/test_seed_idempotency.py b/tests/integration/test_seed_idempotency.py index dd7e08a..12b5319 100644 --- a/tests/integration/test_seed_idempotency.py +++ b/tests/integration/test_seed_idempotency.py @@ -1,4 +1,6 @@ +import pytest from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from changeops.db.models import ( @@ -87,6 +89,52 @@ def test_seed_is_repeatable_without_duplicates() -> None: "commitment_assignments": 2, } ) + with SessionLocal() as session: + dependency_rows = [ + *session.scalars(select(PolicySystemDependency)), + *session.scalars(select(PolicyDocumentDependency)), + *session.scalars(select(PolicyTrainingDependency)), + ] + assert len(dependency_rows) == 12 + assert {row.provenance_category for row in dependency_rows} == {"seeded_demonstration"} + assert {row.provenance_authority for row in dependency_rows} == { + "ChangeOps demonstration seed" + } + assert {row.provenance_reference for row in dependency_rows} == { + "changeops.seed.relationships.v1" + } + assert {row.provenance_recorded_at.isoformat() for row in dependency_rows} == { + "2026-08-06T00:00:00+00:00" + } + + +@pytest.mark.parametrize( + ("model", "field_name", "invalid_value"), + [ + (PolicySystemDependency, "provenance_category", "human_approved_ai"), + (PolicySystemDependency, "provenance_authority", " "), + (PolicySystemDependency, "provenance_reference", ""), + (PolicySystemDependency, "provenance_recorded_at", None), + (PolicyDocumentDependency, "provenance_category", "human_curated"), + (PolicyDocumentDependency, "provenance_authority", ""), + (PolicyDocumentDependency, "provenance_reference", " "), + (PolicyDocumentDependency, "provenance_recorded_at", None), + (PolicyTrainingDependency, "provenance_category", "imported_authoritative"), + (PolicyTrainingDependency, "provenance_authority", " "), + (PolicyTrainingDependency, "provenance_reference", ""), + (PolicyTrainingDependency, "provenance_recorded_at", None), + ], +) +def test_dependency_provenance_rejects_unsupported_or_incomplete_lineage( + model, + field_name: str, + invalid_value, +) -> None: + with pytest.raises(IntegrityError), SessionLocal.begin() as session: + dependency = session.scalars(select(model).order_by(model.id)).first() + assert dependency is not None + setattr(dependency, field_name, invalid_value) + session.flush() def test_provider_free_demo_assessment_seed_is_stable_and_idempotent() -> None: diff --git a/tests/unit/test_catalog_schemas.py b/tests/unit/test_catalog_schemas.py index abb6bea..f7607d0 100644 --- a/tests/unit/test_catalog_schemas.py +++ b/tests/unit/test_catalog_schemas.py @@ -52,7 +52,9 @@ def catalog_detail_payload() -> dict: "href": "/catalog/enterprise_document/document-guide", }, "trust": { + "level": "trusted", "state": "persisted_typed_relationship", + "basis": "deterministic_enterprise_relationship", "integrity": [ "policy_change_foreign_key", "enterprise_document_foreign_key", @@ -61,11 +63,14 @@ def catalog_detail_payload() -> dict: "used_deterministically": True, }, "provenance": { - "classification": "not_recorded", + "classification": "seeded_demonstration", + "source_label": "Seeded demonstration data", "creator": None, - "owning_authority": None, - "recorded_at": None, + "owning_authority": "ChangeOps demonstration seed", + "reference": "changeops.seed.relationships.v1", + "recorded_at": "2026-08-06T00:00:00Z", "approval": None, + "ai_involvement": "none", }, } ], @@ -82,7 +87,10 @@ def catalog_detail_payload() -> dict: (("object", "object_type"), "worker"), (("object", "record_provenance", "classification"), "seeded"), (("relationships", 0, "trust", "state"), "human_approved"), + (("relationships", 0, "trust", "level"), "human_approved"), + (("relationships", 0, "trust", "basis"), "human_approval"), (("relationships", 0, "provenance", "classification"), "human_curated"), + (("relationships", 0, "provenance", "ai_involvement"), "generated"), ], ) def test_catalog_contract_rejects_unsupported_authority_claims(path, value) -> None: diff --git a/web/app/components/catalog-views.test.tsx b/web/app/components/catalog-views.test.tsx index 9c22f97..e55efbd 100644 --- a/web/app/components/catalog-views.test.tsx +++ b/web/app/components/catalog-views.test.tsx @@ -81,7 +81,9 @@ const relationship = { href: "/catalog/enterprise_document/document-manager-travel-approval-guide", }, trust: { + level: "trusted" as const, state: "persisted_typed_relationship" as const, + basis: "deterministic_enterprise_relationship" as const, integrity: [ "policy_change_foreign_key", "enterprise_document_foreign_key", @@ -90,11 +92,14 @@ const relationship = { used_deterministically: true as const, }, provenance: { - classification: "not_recorded" as const, + classification: "seeded_demonstration" as const, + source_label: "Seeded demonstration data" as const, creator: null, - owning_authority: null, - recorded_at: null, + owning_authority: "ChangeOps demonstration seed", + reference: "changeops.seed.relationships.v1", + recorded_at: "2026-08-06T00:00:00Z", approval: null, + ai_involvement: "none" as const, }, }; @@ -140,6 +145,11 @@ describe("enterprise knowledge catalog views", () => { screen.getByRole("heading", { name: "What enterprise knowledge does ChangeOps use?" }), ).toBeInTheDocument(); expect(screen.getByText("Seeded demonstration catalog")).toBeInTheDocument(); + expect( + screen.getByText( + "This is fictional demonstration data. Object-level origin is not recorded; seeded relationship origin is available on each object detail.", + ), + ).toBeInTheDocument(); expect(screen.getByRole("navigation", { name: "Catalog categories" })).toBeInTheDocument(); expect( screen.getByRole("link", { name: "Open Manager Travel Approval Guide details" }), @@ -159,10 +169,21 @@ describe("enterprise knowledge catalog views", () => { render(); expect(screen.getByRole("heading", { name: "Manager Travel Approval Guide" })).toBeInTheDocument(); - expect(screen.getAllByText("Not recorded").length).toBeGreaterThan(1); + expect(screen.getByText("Not recorded")).toBeInTheDocument(); expect(screen.getByText("International Business Travel Approval and Security Training · 1")).toBeInTheDocument(); expect(screen.getByText("Proposed International Business Travel Revision · proposed-draft")).toBeInTheDocument(); expect(screen.getAllByText("Instructs Manager")).toHaveLength(2); + expect(screen.getAllByRole("heading", { name: "Relationship Provenance" })).toHaveLength(2); + expect(screen.getAllByText("Seeded demonstration data")).toHaveLength(2); + expect(screen.getAllByText("Trusted")).toHaveLength(2); + expect( + screen.getAllByText("None — AI did not create or infer this relationship."), + ).toHaveLength(2); + expect( + screen.getAllByText( + "Manager Travel Approval Guide → MANAGER_APPROVAL_REQUIRED", + ), + ).toHaveLength(2); expect(screen.getByText("Confluence metadata has not been imported.")).toBeInTheDocument(); expect(screen.getAllByRole("link", { name: "Explain this policy rule" })[0]).toHaveAttribute( "href", @@ -171,7 +192,9 @@ describe("enterprise knowledge catalog views", () => { for (const summary of screen.getAllByText(/Technical relationship details|Technical object identity/)) { expect(summary.closest("details")).not.toHaveAttribute("open"); } - expect(screen.queryByRole("button", { name: /edit|import|approve|refresh|search/i })).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /edit|import|approve|refresh|search/i }), + ).not.toBeInTheDocument(); }); it("shows validated read-only Confluence identity and an external link", () => { diff --git a/web/app/components/catalog-views.tsx b/web/app/components/catalog-views.tsx index 56b2f7c..9ef64e9 100644 --- a/web/app/components/catalog-views.tsx +++ b/web/app/components/catalog-views.tsx @@ -44,8 +44,8 @@ export function CatalogBrowseView({ catalog }: { catalog: CatalogBrowse }) { @@ -162,7 +162,7 @@ export function CatalogObjectDetailView({ detail }: { detail: CatalogObjectDetai
Catalog context: Seeded demonstration catalog

- This dataset context is known from the bounded demo. Row-level creator, import, + This dataset context is known from the bounded demo. Object-level creator, import, curation, and approval provenance are not recorded.

@@ -182,8 +182,8 @@ export function CatalogObjectDetailView({ detail }: { detail: CatalogObjectDetai

Each connection is trusted as deterministic analyzer input because it is a persisted row - in a typed dependency table with enforced foreign keys. That is not evidence of human - approval or row origin. + in a typed dependency table with enforced foreign keys. Relationship origin is recorded + separately and does not imply human approval.

{detail.relationships.map((relationship) => { @@ -191,18 +191,34 @@ export function CatalogObjectDetailView({ detail }: { detail: CatalogObjectDetai return (
- Persisted typed relationship + Trusted enterprise relationship {relationship.impact_classification && ( {humanize(relationship.impact_classification)} )}
-

{humanize(relationship.relationship_type)}

-

{relationship.explanation}

+

Relationship Provenance

- - - + + + + + + +
+

+ Recorded by {relationship.provenance.owning_authority} on{" "} + {formatDateTime(relationship.provenance.recorded_at)}. +

Explain this policy rule @@ -212,6 +228,9 @@ export function CatalogObjectDetailView({ detail }: { detail: CatalogObjectDetai + + + diff --git a/web/app/globals.css b/web/app/globals.css index a488534..c951faf 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -2316,6 +2316,11 @@ dd { margin-top: 1rem; } +.catalog-provenance-note { + color: var(--color-muted); + font-size: 0.88rem; +} + .catalog-usage, .assessment-context, .catalog-external-source, diff --git a/web/lib/types.ts b/web/lib/types.ts index 96a7c4c..7a8214d 100644 --- a/web/lib/types.ts +++ b/web/lib/types.ts @@ -720,16 +720,21 @@ export interface CatalogRelationship { href: string; }; trust: { + level: "trusted"; state: "persisted_typed_relationship"; + basis: "deterministic_enterprise_relationship"; integrity: string[]; used_deterministically: true; }; provenance: { - classification: "not_recorded"; + classification: "seeded_demonstration"; + source_label: "Seeded demonstration data"; creator: null; - owning_authority: null; - recorded_at: null; + owning_authority: string; + reference: string; + recorded_at: string; approval: null; + ai_involvement: "none"; }; }