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.
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.