diff --git a/CHANGELOG.md b/CHANGELOG.md
index 605b79a1c..48d394858 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,9 @@ All notable changes to this project are documented here. Format follows
- All OpenAI-compatible chat-completion consumers now validate the shared
response envelope before parsing it, preventing malformed provider bodies
from escaping as raw `KeyError` or response-shape details.
+- The post-detail dialog focus trap now excludes descendants of `aria-hidden`
+ content as well as collapsed `details`, keeping keyboard focus inside the
+ visible modal controls.
## [2.12.6] - 2026-08-20
diff --git a/backend/app/main.py b/backend/app/main.py
index 8a71dcab6..d86fac121 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -179,7 +179,10 @@
persist_post_summary,
require_summary_source_body,
)
-from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
+from backend.app.post_eligibility import (
+ SOURCE_POST_ELIGIBILITY_SQL,
+ SOURCE_POST_VISIBILITY_SQL,
+)
from backend.app.demo_scope import (
fetch_demo_corporate_entity_ids,
has_real_source_context,
@@ -550,8 +553,7 @@ async def _post_filter_options(
left join common_lookup_value lookup
on lookup.lookup_category = 'post_visibility'
and lookup.lookup_code = post.visibility_code
- where (post.visibility_code = 'public'
- or post.corporate_entity_id::text = any($1::text[]))
+ where {SOURCE_POST_VISIBILITY_SQL.format(alias='post', authorized_entity_ids='$1')}
and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}
order by display_order, code
"""
@@ -563,8 +565,7 @@ async def _post_filter_options(
left join common_lookup_value lookup
on lookup.lookup_category = 'voc_type'
and lookup.lookup_code = post.voc_type_code
- where (post.visibility_code = 'public'
- or post.corporate_entity_id::text = any($1::text[]))
+ where {SOURCE_POST_VISIBILITY_SQL.format(alias='post', authorized_entity_ids='$1')}
and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}
order by display_order, code
"""
@@ -740,7 +741,7 @@ async def read_customer_master(
from source_post
where (nullif(btrim(source_customer_code), '') is not null
or nullif(btrim(source_customer_name), '') is not null)
- and (visibility_code = 'public' or corporate_entity_id = any($1::uuid[]))
+ and {SOURCE_POST_VISIBILITY_SQL.format(alias='source_post', authorized_entity_ids='$1')}
and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}
), ranked as (
select scoped.*,
@@ -806,7 +807,7 @@ async def read_customer_master(
join user_account author on author.user_account_id = post.author_account_id
where post.source_author_code is not null
and btrim(post.source_author_code) <> ''
- and (post.visibility_code = 'public' or post.corporate_entity_id = any($1::uuid[]))
+ and {SOURCE_POST_VISIBILITY_SQL.format(alias='post', authorized_entity_ids='$1')}
and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}
), ranked as (
select scoped.*,
@@ -924,16 +925,77 @@ async def read_customer_master(
""",
list(account.corporate_entity_ids),
)
- entity_rows = await conn.fetch(
+ authorized_entity_rows = await conn.fetch(
"""
- select corporate_entity_id, corporate_entity_code, entity_name,
- entity_level_code, parent_entity_id
- from corporate_entity
- where corporate_entity_id = any($1::uuid[])
- order by entity_name
+ select entity.corporate_entity_id, entity.corporate_entity_code,
+ entity.entity_name, entity.entity_level_code,
+ entity.parent_entity_id,
+ coalesce(
+ array_agg(distinct affiliation.affiliation_scope_code)
+ filter (where affiliation.affiliation_scope_code is not null),
+ array['scope_unclassified']::text[]
+ ) as affiliation_scope_codes
+ from corporate_entity entity
+ left join account_affiliation affiliation
+ on affiliation.corporate_entity_id = entity.corporate_entity_id
+ and affiliation.user_account_id = $2
+ where entity.corporate_entity_id = any($1::uuid[])
+ group by entity.corporate_entity_id, entity.corporate_entity_code,
+ entity.entity_name, entity.entity_level_code,
+ entity.parent_entity_id
+ order by entity.entity_name
+ """,
+ list(account.corporate_entity_ids),
+ account.user_account_id,
+ )
+ # Resolved organization mentions enrich navigation only when the source
+ # post is already visible to this account. They never grant access to a
+ # private post and unresolved counterparty names remain hints below.
+ # Safe SQL: the visibility and eligibility predicates are closed schema text; ids are bound.
+ observed_entity_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli
+ f"""
+ select distinct entity.corporate_entity_id, entity.corporate_entity_code,
+ entity.entity_name, entity.entity_level_code,
+ entity.parent_entity_id
+ from post_organization_mention mention
+ join source_post post on post.post_id = mention.post_id
+ join corporate_entity entity
+ on entity.corporate_entity_id = mention.corporate_entity_id
+ where {SOURCE_POST_VISIBILITY_SQL.format(alias='post', authorized_entity_ids='$1')}
+ and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')}
+ order by entity.entity_name
""",
list(account.corporate_entity_ids),
)
+
+ scope_facet_by_code = {
+ "scope_own_entity": "authorized_own",
+ "scope_granted_entity": "authorized_granted",
+ "scope_unclassified": "scope_unclassified",
+ }
+ entity_by_id: dict[str, dict[str, Any]] = {}
+
+ def add_entity(row: asyncpg.Record | dict[str, Any], facets: list[str]) -> None:
+ entity_id = str(row["corporate_entity_id"])
+ current = entity_by_id.get(entity_id)
+ if current is None:
+ current = dict(row)
+ current["scope_facets"] = set()
+ entity_by_id[entity_id] = current
+ current["scope_facets"].update(facets)
+
+ for row in authorized_entity_rows:
+ add_entity(
+ row,
+ [
+ scope_facet_by_code.get(scope_code, "scope_unclassified")
+ for scope_code in (row["affiliation_scope_codes"] or ["scope_unclassified"])
+ ],
+ )
+ observed_entity_ids = {str(row["corporate_entity_id"]) for row in observed_entity_rows}
+ for row in observed_entity_rows:
+ add_entity(row, ["observed_organization"])
+ entity_rows = sorted(entity_by_id.values(), key=lambda row: row["entity_name"])
has_source_context = bool(source_customer_rows or source_author_rows)
if not has_source_context:
has_source_context = await has_real_source_context(
@@ -946,11 +1008,29 @@ async def read_customer_master(
for row in entity_rows
if str(row["corporate_entity_id"]) not in synthetic_only_entity_ids
]
- entity_ids = [row["corporate_entity_id"] for row in entity_rows]
+ surviving_entity_by_id = {
+ str(row["corporate_entity_id"]): row for row in entity_rows
+ }
+ for entity_id in observed_entity_ids:
+ entity = surviving_entity_by_id.get(entity_id)
+ if entity is None:
+ continue
+ parent_id = entity["parent_entity_id"]
+ parent = surviving_entity_by_id.get(str(parent_id)) if parent_id is not None else None
+ if parent is None:
+ continue
+ entity["scope_facets"].add("observed_hierarchy")
+ parent["scope_facets"].add("observed_hierarchy")
+ authorized_entity_id_set = {str(entity_id) for entity_id in account.corporate_entity_ids}
+ authorized_entity_ids = [
+ str(row["corporate_entity_id"])
+ for row in entity_rows
+ if str(row["corporate_entity_id"]) in authorized_entity_id_set
+ ]
source_author_affiliations = await _load_account_affiliation_hints(
conn,
[str(row["author_account_id"]) for row in source_author_rows],
- [str(entity_id) for entity_id in entity_ids],
+ authorized_entity_ids,
)
keyman_rows = await conn.fetch(
"""
@@ -967,11 +1047,11 @@ async def read_customer_master(
where affiliation.affiliated_corporate_entity_id = any($1::uuid[])
order by person.person_name, affiliation.affiliated_organization_name
""",
- entity_ids,
+ authorized_entity_ids,
)
side_labels = await labels_for_codes(conn, [row["person_side_code"] for row in keyman_rows])
entity_level_labels = await labels_for_codes(conn, [row["entity_level_code"] for row in entity_rows])
- relationship_network = await fetch_relationship_network(conn, entity_ids)
+ relationship_network = await fetch_relationship_network(conn, authorized_entity_ids)
keymen_by_id: dict[str, dict[str, Any]] = {}
for row in keyman_rows:
@@ -1010,6 +1090,7 @@ async def read_customer_master(
"entity_level_label": entity_level_labels.get(
row["entity_level_code"], row["entity_level_code"]
),
+ "scope_facets": sorted(row["scope_facets"]),
"parent_entity_id": (
str(row["parent_entity_id"]) if row["parent_entity_id"] is not None else None
),
@@ -1216,8 +1297,7 @@ async def list_posts(
end as search_priority,
count(*) over() as total_count
from source_post post
- where (post.visibility_code = 'public'
- or post.corporate_entity_id::text = any($2::text[]))
+ where {SOURCE_POST_VISIBILITY_SQL.format(alias='post', authorized_entity_ids='$2')}
and {SOURCE_POST_ELIGIBILITY_SQL.format(alias="post")}
and (
$1::text is null
diff --git a/backend/app/post_eligibility.py b/backend/app/post_eligibility.py
index 48e3da9db..9136961f0 100644
--- a/backend/app/post_eligibility.py
+++ b/backend/app/post_eligibility.py
@@ -15,6 +15,14 @@
"source_project_name",
)
+# The SQL projection of the fixed ABAC rule in ``main._can_see_post``. Keep
+# the authorized-id placeholder explicit so every reader query shares the
+# same public-or-affiliated visibility boundary.
+SOURCE_POST_VISIBILITY_SQL = (
+ "({alias}.visibility_code = 'public' "
+ "or {alias}.corporate_entity_id = any({authorized_entity_ids}::uuid[]))"
+)
+
def source_context_present_sql(alias: str) -> str:
return " or ".join(
diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index c50a34ae2..2aea6264c 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -123,6 +123,11 @@
/ "migrations"
/ "0104_two_word_database_identifiers.sql"
)
+_CUSTOMER_MASTER_SCOPE_MIGRATION = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "0105_customer_master_scope_facets.sql"
+)
def _postgres_available() -> bool:
@@ -238,6 +243,7 @@ def seeded_db(demo_analyst_token):
cur.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text())
cur.execute(_TENANT_SETTINGS_MIGRATION.read_text())
cur.execute(_IDENTIFIER_MIGRATION.read_text())
+ cur.execute(_CUSTOMER_MASTER_SCOPE_MIGRATION.read_text())
cur.execute(
"insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values "
"('corporate_entity_level', 'group', 'Group'), "
@@ -294,6 +300,16 @@ def seeded_db(demo_analyst_token):
"values ('OTHER-CORP', 'Other Corp', 'group') returning corporate_entity_id"
)
other_corp_id = cur.fetchone()[0]
+ cur.execute(
+ "insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) "
+ "values ('GRANTED-CORP', 'Granted Corp', 'company') returning corporate_entity_id"
+ )
+ granted_corp_id = cur.fetchone()[0]
+ cur.execute(
+ "insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) "
+ "values ('HIDDEN-CORP', 'Hidden Corp', 'company') returning corporate_entity_id"
+ )
+ hidden_corp_id = cur.fetchone()[0]
cur.execute(
"insert into user_account (external_subject_id, display_name, email_address) "
@@ -302,8 +318,10 @@ def seeded_db(demo_analyst_token):
)
account_id = cur.fetchone()[0]
cur.execute(
- "insert into account_affiliation (user_account_id, corporate_entity_id) values (%s, %s)",
- (account_id, own_corp_id),
+ "insert into account_affiliation "
+ "(user_account_id, corporate_entity_id, affiliation_scope_code) "
+ "values (%s, %s, 'scope_own_entity'), (%s, %s, 'scope_granted_entity')",
+ (account_id, own_corp_id, account_id, granted_corp_id),
)
cur.execute(
"insert into access_role (role_code, role_name) values ('viewer', 'Viewer') returning access_role_id"
@@ -503,6 +521,12 @@ def _insert_post(
"values (%s, 'Northridge Grid'), (%s, 'Northridge Holdings')",
(counterpart_person_id, counterpart_person_id),
)
+ cur.execute(
+ "insert into person_affiliation "
+ "(person_id, affiliated_organization_name, affiliated_corporate_entity_id) "
+ "values (%s, 'Other Corp Only', %s)",
+ (hidden_person_id, other_corp_id),
+ )
cur.execute(
"insert into post_person_mention (post_id, person_id) values "
@@ -560,6 +584,8 @@ def _insert_post(
"own_group_id": str(own_group_id),
"own_corp_id": str(own_corp_id),
"other_corp_id": str(other_corp_id),
+ "granted_corp_id": str(granted_corp_id),
+ "hidden_corp_id": str(hidden_corp_id),
"own_private_post_id": own_private_post_id,
"late_own_private_post_id": late_own_private_post_id,
"edited_own_post_id": edited_own_post_id,
@@ -1229,7 +1255,20 @@ def test_settings_patch_requires_post_admin(client, demo_analyst_token, seeded_d
assert confirm.json() == {"brandName": "LineageWeave Demo"}
-def test_customer_master_returns_authorized_catalog_contract(client, demo_analyst_token, seeded_db) -> None:
+def test_customer_master_returns_authorized_catalog_contract(
+ client, demo_analyst_token, seeded_db, monkeypatch
+) -> None:
+ subject = jwt.decode(demo_analyst_token, options={"verify_signature": False})["sub"]
+ from backend.app import main as main_module
+
+ relationship_entity_ids: list[str] = []
+ original_relationship_network = main_module.fetch_relationship_network
+
+ async def capture_relationship_entity_ids(conn, corporate_entity_ids):
+ relationship_entity_ids.extend(corporate_entity_ids)
+ return await original_relationship_network(conn, corporate_entity_ids)
+
+ monkeypatch.setattr(main_module, "fetch_relationship_network", capture_relationship_entity_ids)
admin_conn = psycopg2.connect(seeded_db["dsn"])
try:
with admin_conn.cursor() as cur:
@@ -1266,6 +1305,48 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys
seeded_db["our_person_id"],
),
)
+ cur.execute(
+ "insert into corporate_entity "
+ "(parent_entity_id, corporate_entity_code, entity_name, entity_level_code) "
+ "values (%s, 'DEMO-GRANTED-CHILD', 'Demo Granted Child', 'company') "
+ "returning corporate_entity_id",
+ (seeded_db["granted_corp_id"],),
+ )
+ demo_child_id = str(cur.fetchone()[0])
+ cur.execute(
+ "insert into account_affiliation "
+ "(user_account_id, corporate_entity_id, affiliation_scope_code) "
+ "select user_account_id, %s, 'scope_granted_entity' "
+ "from user_account where external_subject_id = %s",
+ (demo_child_id, subject),
+ )
+ cur.execute(
+ "insert into post_organization_mention (post_id, corporate_entity_id) "
+ "values (%s, %s), (%s, %s), (%s, %s), (%s, %s)",
+ (
+ seeded_db["public_post_id"],
+ seeded_db["other_corp_id"],
+ seeded_db["other_private_post_id"],
+ seeded_db["hidden_corp_id"],
+ seeded_db["public_post_id"],
+ seeded_db["own_corp_id"],
+ seeded_db["public_post_id"],
+ demo_child_id,
+ ),
+ )
+ cur.execute(
+ "insert into post_counterparty_entity "
+ "(post_id, counterparty_entity_name, relationship_type_code, verification_status_code) "
+ "values (%s, 'Private Other Corp', 'rel_voc', 'verify_pending')",
+ (seeded_db["other_private_post_id"],),
+ )
+ cur.execute(
+ "insert into account_affiliation "
+ "(user_account_id, corporate_entity_id, affiliation_scope_code) "
+ "select user_account_id, %s, 'scope_granted_entity' "
+ "from user_account where external_subject_id = %s",
+ (seeded_db["own_group_id"], subject),
+ )
# A real counterparty can hold more than one role over its
# lifetime -- one post classifies "Northridge Grid" as a
# customer, a different visible post classifies the same
@@ -1283,6 +1364,20 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys
seeded_db["public_post_id"],
),
)
+ # Once imported source context exists, this affiliated child is
+ # synthetic-only and must be removed consistently from the tree,
+ # stale observed hierarchy facets, Keymen, and account hints.
+ cur.execute(
+ "insert into cataloged_person (person_name, person_side_code) "
+ "values ('Demo Grant Only', 'our_side') returning person_id",
+ )
+ demo_person_id = cur.fetchone()[0]
+ cur.execute(
+ "insert into person_affiliation "
+ "(person_id, affiliated_organization_name, affiliated_corporate_entity_id) "
+ "values (%s, 'Demo Granted Child', %s)",
+ (demo_person_id, demo_child_id),
+ )
admin_conn.commit()
finally:
admin_conn.close()
@@ -1306,7 +1401,18 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys
# confirm this is a real common_lookup_value label, not the code echoed back.
assert entity["entity_level_code"] == "company"
assert entity["entity_level_label"] not in ("", "company")
+ assert entity["scope_facets"] == ["authorized_own", "observed_hierarchy", "observed_organization"]
+ parent = next(item for item in body["corporate_entities"] if item["entity_name"] == "Test Group")
+ assert parent["scope_facets"] == ["authorized_granted", "observed_hierarchy"]
+ granted = next(item for item in body["corporate_entities"] if item["entity_name"] == "Granted Corp")
+ assert granted["scope_facets"] == ["authorized_granted"]
+ assert not any(item["entity_name"] == "Demo Granted Child" for item in body["corporate_entities"])
+ observed = next(item for item in body["corporate_entities"] if item["entity_name"] == "Other Corp")
+ assert observed["scope_facets"] == ["observed_organization"]
+ assert not any(item["entity_name"] == "Hidden Corp" for item in body["corporate_entities"])
assert isinstance(body["keymen"], list)
+ assert not any(item["person_name"] == "Other Corp Only" for item in body["keymen"])
+ assert not any(item["person_name"] == "Demo Grant Only" for item in body["keymen"])
ada_west = next(item for item in body["keymen"] if item["person_name"] == "Ada West")
assert ada_west["person_side_code"] == "our_side"
# Live UI finding (2026-08-19): the Customer Master Keymen list falls
@@ -1316,6 +1422,8 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys
assert ada_west["person_side_label"] not in ("", "our_side")
network = {row["counterparty_entity_name"]: row for row in body["relationship_network"]}
+ assert demo_child_id not in relationship_entity_ids
+ assert "Private Other Corp" not in network
northridge = network["Northridge Grid"]
assert northridge["multi_role"] is True
assert {rel["relationship_type_code"] for rel in northridge["relationships"]} == {"rel_voc", "rel_voco"}
@@ -1370,6 +1478,14 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys
affiliation["entity_name"] == "Test Corp"
for affiliation in author_hint[0]["account_affiliations"]
)
+ assert any(
+ affiliation["entity_name"] == "Granted Corp"
+ for affiliation in author_hint[0]["account_affiliations"]
+ )
+ assert not any(
+ affiliation["entity_name"] == "Demo Granted Child"
+ for affiliation in author_hint[0]["account_affiliations"]
+ )
assert "account_affiliation.corporate_entity_id" in author_hint[0]["provenance"]
diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh
index 3a16458d4..88f168a43 100644
--- a/docker/postgres-init/migrate.sh
+++ b/docker/postgres-init/migrate.sh
@@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do
migration_name=${migration##*/}
case "$migration_name" in
0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;;
- 0060_*|0100_*|0101_*|0102_*|0103_*|0104_*) ;;
+ 0060_*|0100_*|0101_*|0102_*|0103_*|0104_*|0105_*) ;;
*) continue ;;
esac
printf 'Applying %s\n' "$migration_name"
diff --git a/docs/adr/0125-customer-master-scope-facets.md b/docs/adr/0125-customer-master-scope-facets.md
new file mode 100644
index 000000000..3099448be
--- /dev/null
+++ b/docs/adr/0125-customer-master-scope-facets.md
@@ -0,0 +1,106 @@
+# ADR 0125 — Customer Master separates authorization scope from observed relationship facets
+
+**Decision status:** Proposed
+**Date:** 2026-08-21
+
+## Context
+
+`/api/customer-master` currently reads its `corporate_entities` list only from
+`account_affiliation`. That is a valid authorization boundary, but it is not a
+customer hierarchy: verified organization mentions and counterparty entities
+created by ADR 0010 are not necessarily account affiliations. The resulting UI
+can show an authorized employer while hiding an observed customer or affiliate,
+and it cannot distinguish an account's own company from an explicitly granted
+company because the current affiliation row has no such attribute.
+
+The fix must preserve the existing ABAC decision. A visible relationship in a
+public or authorized post is evidence for navigation, not permission to read a
+private post. An unresolved counterparty name is a hint, not a catalog entity.
+
+## Decision
+
+1. Keep `account_affiliation` as the authorization source. Every post and
+ entity returned by Customer Master remains subject to the existing
+ per-request visibility and source-post eligibility predicates.
+2. Add an explicit, nullable `affiliation_scope_code` to
+ `account_affiliation`, backed by `common_lookup_value`, with the controlled
+ values `scope_own_entity`, `scope_granted_entity`, and
+ `scope_unclassified`. Existing rows are migrated to `scope_unclassified`;
+ no own/customer identity is inferred from a login token, a PU, a post title,
+ or a corporate name. Authentication continues to authorize every existing
+ affiliation row regardless of this display facet.
+3. Extend the Customer Master entity contract with repeatable, provenance-bearing
+ `scope_facets`: `authorized_own`, `authorized_granted`,
+ `scope_unclassified`, `observed_organization`, and `observed_hierarchy`.
+ Multiple facets are
+ allowed because one organization may be both an authorized entity and an
+ observed counterparty in different evidence.
+4. Build `observed_organization` only from a visible, eligible post's resolved
+ `post_organization_mention` (or an equivalently persisted, verified catalog
+ binding). `post_counterparty_entity` names that remain unresolved or
+ uncorroborated stay in the existing `source_customer_hints` / relationship
+ evidence surfaces and do not become tree nodes.
+5. Traverse `parent_entity_id` only across entities already admitted by an
+ authorization or visible evidence path. If a parent is not admitted, render
+ the admitted child as a root; never widen access merely to complete a tree.
+6. The UI's own-company/customer filters consume these facets and expose
+ `scope_unclassified` as an honest third state. No confirmation dialog or
+ guessed label is introduced. The API remains the single place that applies
+ authorization and provenance rules.
+
+## Implementation sequence
+
+The implementation is intentionally split so an ABAC regression cannot hide in
+a large customer-tree change:
+
+1. Add the lookup values and nullable affiliation column with a migration and
+ update provisioning paths to write an explicit value.
+2. Add an API integration test with own, granted, unclassified, visible
+ organization-mention, and private-post cases. Assert that private evidence
+ never adds a node and unresolved names remain hints.
+3. Add the API contract and frontend filter/tree tests, then implement the
+ query projection and UI facets.
+4. Backfill only from an authoritative account-scope source. Until that source
+ exists, retain `scope_unclassified`; do not infer it from the corpus.
+
+## Consequences
+
+- The tree can become useful without weakening ABAC: observed nodes are bounded
+ by visible evidence and do not authorize unrelated reads.
+- Existing deployments will initially show an explicit unknown scope instead of
+ a misleading own/customer label. This is preferable to a silent false fact.
+- `account_affiliation` remains a normalized authorization relation; the facet
+ is an attribute of that relation, not a second account-to-entity authority
+ table. A later need for time-bounded grants requires a separate ADR rather
+ than overloading this column.
+- The entity query needs an entity-first index on persisted organization
+ mentions if live corpus size requires it. Add that index with the same
+ migration after measuring the query plan; do not pre-emptively shard a small
+ table.
+
+## Related decisions
+
+- [ADR 0004](0004-knowledge-graph-ontology.md) — ontology and semantic layer.
+- [ADR 0010](0010-corporate-hierarchy-auto-creation.md) — verified counterparty
+ hierarchy creation.
+- [ADR 0041](0041-source-context-vs-authorization-scope.md) — source context
+ must not be confused with authorization scope.
+- [ADR 0042](0042-source-hints-before-customer-binding.md) — unresolved source
+ customer values remain hints.
+- [ADR 0052](0052-plain-orchestrator-semantic-evidence.md) — semantic evidence
+ must retain provenance and uncertainty.
+
+## References (APA 7th)
+
+Hu, V. C., Ferraiolo, D., Kuhn, R., Schnitzer, A., Sandlin, K., Miller, R., &
+ Scarfone, K. (2019). *Guide to attribute based access control (ABAC)
+ definition and considerations* (NIST Special Publication 800-162, updated
+ 2019). National Institute of Standards and Technology.
+ https://doi.org/10.6028/NIST.SP.800-162
+
+Rose, S., Borchert, O., Mitchell, S., & Connelly, S. (2020). *Zero trust
+ architecture* (NIST Special Publication 800-207). National Institute of
+ Standards and Technology. https://doi.org/10.6028/NIST.SP.800-207
+
+World Wide Web Consortium. (2009). *SKOS simple knowledge organization system
+ reference*. https://www.w3.org/TR/skos-reference/
diff --git a/docs/doctoring/CUSTOMER_MASTER_SCOPE_REFERENCES.md b/docs/doctoring/CUSTOMER_MASTER_SCOPE_REFERENCES.md
new file mode 100644
index 000000000..5e5607975
--- /dev/null
+++ b/docs/doctoring/CUSTOMER_MASTER_SCOPE_REFERENCES.md
@@ -0,0 +1,29 @@
+# Customer Master scope references
+
+This register grounds the proposed Customer Master scope-facet decision in
+authoritative access-control and semantic-hierarchy sources. Keep the
+authorization boundary, observed evidence, and catalog identity separate when
+implementing ADR 0125.
+
+## Evidence mapping
+
+| Source | Product decision |
+| --- | --- |
+| NIST SP 800-162 | Treat account, resource, action, and environment attributes as inputs to an ABAC decision; do not turn a display classification into a permission grant. |
+| NIST SP 800-207 | Re-evaluate access at the resource boundary and minimize implicit trust; visible relationship evidence cannot widen private-post access. |
+| W3C SKOS | Represent the corporate hierarchy as a broader/narrower concept relation while retaining the evidence and authorization facets separately. |
+
+## APA 7th references
+
+Hu, V. C., Ferraiolo, D., Kuhn, R., Schnitzer, A., Sandlin, K., Miller, R., &
+Scarfone, K. (2019). *Guide to attribute based access control (ABAC)
+definition and considerations* (NIST Special Publication 800-162, updated
+2019). National Institute of Standards and Technology.
+https://doi.org/10.6028/NIST.SP.800-162
+
+Rose, S., Borchert, O., Mitchell, S., & Connelly, S. (2020). *Zero trust
+architecture* (NIST Special Publication 800-207). National Institute of
+Standards and Technology. https://doi.org/10.6028/NIST.SP.800-207
+
+World Wide Web Consortium. (2009). *SKOS simple knowledge organization system
+reference*. https://www.w3.org/TR/skos-reference/
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index cb68bb2e1..5bfc76566 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -1,20 +1,24 @@
# Product & Technical Gap Baseline
-> Audit scope: the current `fix/uiux-standard-guide-v3-postmerge` worktree,
-> compared with `origin/main`, the UI/UX Standard Guide v3.0 supplied for this
+> Audit scope: the current `feat/customer-master-scope-facets` worktree and
+> open PR #366, compared with its exact base `feat/lineage-dag-regression`, the UI/UX Standard Guide v3.0 supplied for this
> product, ADR 0118, the accepted TEPP PRD/contracts, and the
> contextual-orchestrator architecture. Real source identifiers are deliberately
> replaced with case labels; they must not enter repository artifacts.
## 1. Exact-head evidence
-Audit anchor: the exact source state carried by this commit at 2026-08-21;
-record the final PR head with `git rev-parse HEAD` during acceptance.
+Audit anchor: the exact source state carried by this documentation commit at
+2026-08-21; record the final PR head with `git rev-parse HEAD` during
+acceptance.
-Current source/test exact head observed before this documentation update:
-`0e63ba0a2e23949630f8997cbe001b6e13b2d274`, the squash merge of PR #350.
-This documentation update will create the next exact head and therefore
-requires the protected checks to rerun.
+Current implementation source/test exact head observed before this
+documentation update: `a5aa0daa`, the
+fully-contained popup keyboard-navigation repair including nested
+`aria-hidden` descendants, on top of the customer-scope facets and
+closed-details focus fixes, with PR #366 based on
+`8bed77e7e7b91b633bb92d3a82d0187c387206af`. The documentation commit creates
+the next exact head; protected checks must rerun for the resulting PR head.
- **Implemented in source:** PostgreSQL-backed API boundaries, Keyverse/OIDC
identity boundary, workspace navigation, post popup, ABAC/RBAC surfaces, Korean
@@ -33,15 +37,83 @@ requires the protected checks to rerun.
- **Figma reference:** ADR 0118 records file `1Su3lDRmiZdcUs47t1QwIX`; the
inspected Event Lineage frames are desktop `5:14` and mobile `5:15`.
- **Local quality evidence at the source/test head:** backend `uv run pytest -q`
- passed `786` tests with `16` skips; frontend Vitest passed `174` tests in `19`
+ passed `788` tests with `17` skips; frontend Vitest passed `177` tests in `19`
files, frontend lint/build passed, and Storybook build completed. These are
local checks, not hosted protected-gate or independent-review evidence.
- **Current PR gate:** PR #350 merged at
`0e63ba0a2e23949630f8997cbe001b6e13b2d274` after its source head
`819ef876270212305c89743e5443b3ce0b871e66` was reviewed and squashed into
- `feat/lineage-dag-regression`. This docs-only follow-up requires its own
- protected checks and independent approval; neither is claimed yet. The
- prior PR #347 merged at `ef6f5a5ffcb467bd935dc1e53acc0029669b0bd7`.
+ `feat/lineage-dag-regression`; PR #364 then merged the evidence-only
+ baseline at `8bed77e7e7b91b633bb92d3a82d0187c387206af`. This ADR/docs
+ follow-up requires its own protected checks and independent approval;
+ neither is claimed yet. PR #366 remains open at code head
+ `a5aa0daa`; its hosted Tests run is queued,
+ Devin Review is pending, and no independent approval or merge is claimed.
+ The prior PR #347 merged at
+ `ef6f5a5ffcb467bd935dc1e53acc0029669b0bd7`.
+
+- **Adjacent current parsing PR:** PR #367 remains open and unmerged at exact
+ head `5194d267b90430d7a27a9752a49d73617cb5756c`, based on
+ `docs/customer-master-scope-adr` at `f66991699506ef14607de5946da1efcfd20ae6da`.
+ It addresses the supplied numbered-footnote and empty-table-cell cases,
+ removes a dead footnote-reference branch, and rejects short non-footnote IDs.
+ Its focused parser gate is `47 passed`, with `compileall` and
+ `git diff --check` passing. GitHub Tests run `32474961105` is queued,
+ Devin Review is successful, CodeRabbit is successful, and no unresolved
+ review thread, independent approval, or merge is claimed.
+
+- **Latest #366 review repair before this documentation commit:** code head
+ `e2ebd156c89fb810de44207c25a57a92372ab5d2` removes the discarded pre-filter
+ hierarchy pass and centralizes the public-or-authorized-corporate-entity SQL
+ projection used by post lists, filter options, source hints, and observed
+ organization enrichment. The focused PostgreSQL/API regression set passed
+ `3 passed` and the shared SQL contract test passed; full API-file execution
+ remains unclaimed because its external integration setup did not terminate.
+
+- **#366 latest review repair:** code head `09b47ccb` excludes descendants of
+ closed `details` elements from the post-popup focus-trap candidate set, so
+ keyboard focus cannot land on collapsed related-post controls. The same
+ change removes the now-dead observed-hierarchy discard loop after scope
+ filtering. The focused frontend keyboard regression passed (`1 passed`, 86
+ deselected), the customer-master/API regression set passed (`3 passed`), and
+ `compileall` plus `git diff --check` passed. The final documentation commit
+ records the resulting PR head below; hosted Checks and review state remain
+ external evidence.
+
+- **#366 current-head frontend gate:** at `fbffec488a7db4029682b19bf22f4818ae17037e`,
+ `pnpm run lint`, `pnpm run test` (`177 passed` in 19 files), `pnpm run build`,
+ and `pnpm run build-storybook` all passed. Storybook emitted only the existing
+ post-build bundle-size advisory; no build failure was observed.
+
+- **#366 current-head backend gate:** at `e6d94c23ae74c39007f97fbe0afddcfe8e27451c`,
+ `uv run pytest -q` completed with `788 passed, 17 skipped, 4 warnings` in
+ 117.45 seconds. The warnings are existing Starlette deprecations and the
+ synthetic forged-token test key-length warning; no test failure was hidden.
+
+- **#366 latest accessibility repair:** code head
+ `a5aa0daa` always intercepts popup Tab events and advances only through the
+ filtered visible focus set, so native browser tabbing cannot land on an
+ `aria-hidden` control, a nested descendant of an `aria-hidden` container, or
+ a control inside closed `details`. The synthetic direct and nested
+ `aria-hidden` tab-stop regressions passed, and the frontend whole gate at
+ the preceding code head passed: 177 tests in 19 files, lint,
+ build, and Storybook. The existing Storybook bundle-size advisory is not a
+ failure. Customer-scope filtering intentionally re-roots an authorized child
+ when its parent is absent; the backend observed-hierarchy contract preserves
+ the hierarchy facet without reintroducing an unauthorized parent.
+
+- **Canonical ontology explorer PR:** PR #349 remains open and unmerged at
+ exact head `e88f3862215e76d0702204f29aba75ddc902d19f`, based on `main` at
+ `ef6f5a5ffcb467bd935dc1e53acc0029669b0bd7`. Its latest stabilization fixes
+ final-depth endpoint visibility, bounded stale-summary diagnostics, node
+ evidence after node-bound trimming, replay-safe tenant settings migration,
+ login return-path sanitization, OWL-Time citation accuracy, cursor-page
+ graph merging, and the intentional ontology direction contract. Its current
+ local gate is `812 passed, 17 skipped, 4 warnings` in the backend and 168
+ frontend tests in 20 files with lint, build, and Storybook passing. The
+ baseline's historical case references are sanitized to non-identifying case
+ labels. Hosted Tests, Security, SAST, PROV-O, and Devin Review remain
+ pending/queued, so no approval or merge is claimed.
## 2. UI/UX Standard Guide v3.0 comparison
@@ -64,6 +136,12 @@ requires the protected checks to rerun.
- **Event Lineage Figma parity — fixed in this worktree:** the DAG now includes
lineage-evidence context, legend, horizontal overflow on phones, inference
boundary, direction markers, and an evidence trail table/cards treatment.
+- **Post detail modal keyboard access — fixed in this worktree:** the existing
+ 50% backdrop now exposes a named modal dialog with `aria-modal`, moves focus
+ into the panel, closes on Escape, contains Tab focus, and restores focus to
+ the opener. Native interactive controls also share the token-based
+ `:focus-visible` ring. The behavior is covered by the authenticated React
+ and CSS tests; fresh browser evidence remains open.
- **Approved CI/BI asset — open:** the header/footer currently render the
tenant brand name as text. Do not invent or alter a corporate logo; add the
approved asset only after the tenant CI/BI source and usage permission are
@@ -181,10 +259,10 @@ adapter, fixture, or HTTP-shaped test double never upgrades a row to
| Approved CI/BI logo asset | Tenant text is present; approved asset and permission are absent | open |
| User/logout/language/global search header actions | `App.tsx`, `i18n.ts`, handled/pending search-focus tests | source + unit |
| Site map / utility menu | `SiteMapUtility`, accessible toggle/region, Escape and destination-close behavior, responsive CSS contract, locale coverage | source + unit; authenticated browser evidence open |
-| Noto Sans, palette, table/form/button conventions, modal 50% mask | ADR 0118, token CSS, component tests | source + unit |
+| Noto Sans, palette, table/form/button conventions, modal 50% mask and keyboard semantics | ADR 0118, token CSS, popup dialog implementation, frontend tests | source + unit |
| Keyverse/OIDC login with real account | `auth.py`, OIDC discovery/JWKS boundary, local redirect check | source + local-integration; Keyverse open |
| Authenticated corp/PU attributes | `/api/me` returns DB-backed codes; backend integration test covers `TEST-CORP`/`TEST-PU` and header displays them | source + local-integration |
-| RBAC/ABAC, public/private visibility, tenant isolation | `_can_see_post`, API authorization tests, aggregate-only runtime checks | source + local-integration |
+| RBAC/ABAC, public/private visibility, tenant isolation | `_can_see_post`, shared `SOURCE_POST_VISIBILITY_SQL`, API authorization tests, aggregate-only runtime checks | source + unit + local-integration |
| React product surface and PostgreSQL boundary | React routes/components, asyncpg API, Compose stack | source + local-integration |
| Authorized PostgreSQL export import mapping | `scripts/import_postgresql_posts.py`, ADR 0121, hash-verified RFC 2557 MHTML resolver, and synthetic preflight/import tests; authorized relation has artifact-path metadata but no body/content/HTML field | source + unit + local-integration partial; operator artifact files and authorized live import open |
| Bounded large-body search migration | `0035_body_search_prefix.sql`, `0036_normalized_body_search.sql`; live replay completed after bounded rendered-text indexing | source + local-integration |
@@ -194,13 +272,13 @@ adapter, fixture, or HTTP-shaped test double never upgrades a row to
| Keyman on both sides, titles, affiliations, related KG nodes | Keyman/affiliate-tree/related-node routes and popup | source + unit; live extraction open |
| Ontology, semantic layer, provenance, W3C PROV-O projection | normalized schema, SKOS operational vocabulary concepts, `ontology_annotations` label fallback, ADR 0124, provenance modules, ADRs, evidence UI | source + unit; corpus verification open |
| Branching Event Lineage DAG with evidence trail | `LineageDag.tsx`, Storybook story, Figma frames, accessible node-kind names for screen readers/tooltips, frontend tests; runtime cases include both a rendered DAG and honest empty states, while current corpus coverage remains sparse | source + unit + local-integration partial |
-| Customer master and hierarchy tree | `/api/customer-master`, affiliate tree, catalog migrations | source + unit; scoped to `account_affiliation` only, no own-company/customer distinction — see §5 |
+| Customer master and hierarchy tree | `/api/customer-master`, `scope_facets`, visible `post_organization_mention` enrichment, affiliate tree, migration `0105`, scope filter | source + unit + local-integration partial; authorized own/granted/unclassified facets, visible observed organizations, and admitted observed hierarchy facets are implemented, while authoritative scope backfill and broader hierarchy traversal remain open |
| VOC/VOM/VOP/VOCC/VOCO/VOS role classification | common lookup values and relationship APIs | source + unit; live classification open |
| Evidence-grounded chat and source navigation | `/chat`, `/ask`, citation/evidence UI | source + unit; synthetic orchestrator judge route verified, corpus chat/runtime evidence open |
| PU/team/project weekly/monthly reports | report API/UI and grouping controls | source + unit; TEPP-backed live report open |
| TEPP calibrated measurement, dichotomous items, multilevel/MMM/time model | published import/REST boundary and TEPP ADR/PRD references | boundary-only; live-external open |
| contextual-orchestrator routing, VISION, embedding, schema repair | clients and provenance/session boundary; synthetic authenticated route returned a judge score of `0.98`, OCR succeeded, and region location returned five regions | source + local-integration partial; corpus backfill, capability/readiness evidence, and schema-repair workflow open |
-| HTML semantic units, tables, indentation, footnotes, formulas | parser modules and synthetic tests; 11-case authenticated popup sweep had no popup errors and rendered the supplied footnote/table cases; bounded metric superscript/subscript normalization has backend/frontend focused coverage | source + unit + local-integration partial; arbitrary formula/semantic correctness open |
+| HTML semantic units, tables, indentation, footnotes, formulas | parser modules and synthetic tests; adjacent open PR #367 at exact head `b628722cb000717b0198e4337d12306d4306922d` adds numbered-footnote, leading-empty-cell, and short-ID regressions; 11-case authenticated popup sweep had no popup errors and rendered the supplied footnote/table cases; bounded metric superscript/subscript normalization has backend/frontend focused coverage | source + unit + local-integration partial; PR #367 protected checks, arbitrary formula/semantic correctness, and corpus re-backfill remain open |
| Base64/file image regions and multimodal evidence | image-region schema and VISION client boundary; live aggregate has 12,823 images, 22 described images/regions, and 422 failed images; current synthetic VISION route returned five regions | source + local-integration partial; supplied image-table case re-backfill and complete corpus coverage open |
| Abbreviation/multilingual alias/entity disambiguation | catalog hints and resolver boundary | source; live corroboration open |
| SearXNG/internal relation fact check | verification endpoint and unavailable handling; local SearXNG health and JSON query both returned HTTP 200, while some upstream engines reported rate-limit/CAPTCHA results | source + local-integration partial; corroboration policy and reliable external coverage open |
@@ -213,7 +291,7 @@ adapter, fixture, or HTTP-shaped test double never upgrades a row to
| External email/project lineage package boundary | PR #343 publishes strict v1.0.0 bounded request/result types, available-time cutoff handling, observed/inferred/proposed truth states, pair-budget enforcement, and no source/provider access | source + focused unit; exact-head hosted gates, independent review, and immutable release open |
| Naruon calendar projection boundary | PR #337 is closed as superseded; draft PR #355 carries the strict read projection contract without making LineageWeave a CalDAV provider | source + focused unit; Naruon endpoint, runtime wiring, restack, and review open |
| Hourly PR review/repair/merge loop | Central `ContextualWisdomLab/.github` scheduler owns `*/15 * * * *` sweep and `0 * * * *` heartbeat; no duplicate repo-local scheduler is required | boundary accepted; current-head runtime open |
-| 100% coverage/docstrings/edge-case/release gates | current checks and coverage evidence are not complete on PR #350 | open |
+| 100% coverage/docstrings/edge-case/release gates | current local checks pass, but repository-wide coverage/docstring reports, hosted checks, independent review, and release evidence are not complete on PR #366 | open |
## 4. Supplied parsing and semantic cases
@@ -241,32 +319,32 @@ or an explicit unavailable result.
## 5. Product and technical gaps
-- **Customer master "customer tree" — scope gap, evidence-backed (2026-08-21):**
- `/api/customer-master`'s `corporate_entities` list (`backend/app/main.py`
- `read_customer_master`, `entity_rows` query) is scoped to
- `account.corporate_entity_ids`, which comes only from `account_affiliation`
- rows (`backend/app/auth.py` `get_current_account`) — the account's own
- employer plus any explicitly granted entities. A live query against the
- seeded stack confirms the Demo Corp account's `account_affiliation` grants
- exactly one entity (Demo Corp itself) with zero `source_customer_code`/
- `source_customer_name` hints on its posts, so `buildCustomerEntityTree`
- (`frontend/src/App.tsx`) renders a single un-nested node, not the "Harbor
- Group -> Harbor Devices Korea" customer-affiliate tree ADR 0004 and ADR 0010
- describe as a standing requirement. Counterparty `corporate_entity` rows
- that ADR 0010's `get_or_create_corporate_entity` auto-creates are never
- linked via `account_affiliation`, so they cannot reach this endpoint no
- matter how well-populated the corpus becomes — the tree needs to traverse
- observed post/VOC/affiliate-tree evidence, not `account_affiliation` alone.
- Separately, there is no schema signal distinguishing "own company" from
- "granted customer entity" inside `account_affiliation`: both use the same
- `process_unit_id`-bearing row shape (the Demo Analyst account's grants into
- "Source company H504"/"H904" carry `process_unit_id` exactly like Demo
- Corp's own grant does), so a same-screen filter separating 자사(own
- company) attributes from customer attributes has no field to filter on
- yet. Needs an ADR before implementation: either an explicit
- `account_affiliation`/`corporate_entity` scope flag, or a customer-tree
- query redesign: guessing at either without a reviewed decision risks an
- ABAC-adjacent regression.
+- **Customer master "customer tree" — scope facet slice implemented, hierarchy gap open (2026-08-21):**
+ ADR 0125 defines `account_affiliation` as the authorization source and adds
+ the normalized `affiliation_scope_code` lookup-backed attribute. Migration
+ `0105_customer_master_scope_facets.sql` defaults existing rows to
+ `scope_unclassified`; it does not infer own-company or granted scope from a
+ token, PU, title, or corporate name. `/api/customer-master` now returns
+ repeatable `scope_facets` (`authorized_own`, `authorized_granted`,
+ `scope_unclassified`, `observed_organization`, and admitted
+ `observed_hierarchy`) and the React Customer Master panel can filter those
+ server-provided facets. `observed_hierarchy` is emitted only when a visible
+ observed child and its already-admitted parent are both in the response.
+ Resolved
+ `post_organization_mention` rows enrich navigation only when their source
+ post is public or already authorized and eligible; they never widen ABAC,
+ and unresolved counterparty names remain hints. Synthetic schema/API/UI
+ coverage is present at implementation head `1eef321b`; the follow-up also
+ separates observed navigation IDs from authorized IDs for Keyman and
+ relationship-network queries, with regression coverage preventing observed
+ non-authorized organizations from widening ABAC. Hierarchy facets are
+ recomputed after synthetic filtering so removed children cannot leave stale
+ parent facets.
+ The remaining product gap is authoritative scope backfill for existing live
+ affiliations, persisted parent/hierarchy evidence, and a customer tree that
+ can safely traverse admitted observed hierarchy nodes. Until that evidence
+ exists, the UI must retain `scope_unclassified` rather than inventing a
+ customer label.
- **Entity and abbreviation resolution — open:** canonical names, aliases,
multilingual labels, team-vs-organization typing, title-aware person
disambiguation, and SearXNG/internal corroboration need end-to-end evidence.
@@ -326,8 +404,8 @@ or an explicit unavailable result.
## 6. Next acceptance loop
-1. Re-fetch the exact PR head and required reviews/checks for the workspace UI stack,
- PR #343, and the superseding calendar contract PR #355.
+1. Re-fetch PR #366's exact head and required reviews/checks, then separately
+ audit PR #343 and the superseding calendar contract PR #355.
2. Run frontend lint, tests, build, Storybook, backend tests, and authenticated
browser checks when the local stack is available.
3. Reproduce each case label using synthetic fixtures or authorized runtime
diff --git a/frontend/src/App.css b/frontend/src/App.css
index d188b03e9..0d0f52596 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -297,6 +297,15 @@
}
/* Button Standards (§4.3) */
+button:focus-visible,
+select:focus-visible,
+input:focus-visible,
+textarea:focus-visible,
+a:focus-visible {
+ outline: 2px solid var(--color-focus-border);
+ outline-offset: 2px;
+}
+
.btn-primary {
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
@@ -626,6 +635,29 @@
margin: 1.25rem 0 0;
}
+.customer-master-scope-filter {
+ display: grid;
+ grid-template-columns: auto minmax(12rem, 1fr);
+ align-items: center;
+ gap: 0.65rem;
+ margin-top: 1.25rem;
+}
+
+.customer-master-scope-filter label {
+ color: var(--text-muted);
+ font-size: 0.85rem;
+ font-weight: 700;
+}
+
+.customer-master-scope-filter select {
+ min-height: 2.75rem;
+ padding: 0.45rem 0.65rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-control);
+ background: var(--surface);
+ color: var(--text-h);
+}
+
.customer-master-tree-children {
margin-top: 0.55rem;
padding-left: 1rem;
@@ -658,6 +690,22 @@
font-size: 0.8rem;
}
+.customer-entity-meta {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 0.3rem 0.5rem;
+}
+
+.customer-scope-chip {
+ padding: 0.15rem 0.4rem;
+ border-radius: 999px;
+ background: var(--color-accent-background);
+ color: var(--text-h) !important;
+ font-size: 0.72rem !important;
+ font-weight: 700;
+}
+
.customer-related-posts {
margin: 0.45rem 0 0.75rem;
padding: 0.75rem;
@@ -1782,6 +1830,19 @@
grid-column: auto;
}
+ .customer-master-scope-filter {
+ grid-template-columns: 1fr;
+ }
+
+ .customer-entity-button {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .customer-entity-meta {
+ justify-content: flex-start;
+ }
+
.post-list-item {
grid-template-columns: 1fr;
gap: 0.8rem;
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 69d7ca008..d45abc403 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -1597,6 +1597,7 @@ describe("App, authenticated", () => {
entity_level_code: "group",
entity_level_label: "Group",
parent_entity_id: null,
+ scope_facets: ["scope_unclassified"],
},
{
corporate_entity_id: "corp-demo",
@@ -1605,6 +1606,7 @@ describe("App, authenticated", () => {
entity_level_code: "company",
entity_level_label: "Company",
parent_entity_id: "corp-group",
+ scope_facets: ["authorized_own", "observed_hierarchy"],
},
]
: [
@@ -1752,6 +1754,24 @@ describe("App, authenticated", () => {
expect(parentRow?.contains(subsidiaryRow)).toBe(true);
});
+ it("filters the Customer Master tree by the server-provided scope facet", async () => {
+ stubBackend({ customerEntityHierarchy: true });
+ render();
+ expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Customer master" }));
+
+ const filter = screen.getByLabelText("Filter customer entities");
+ await userEvent.selectOptions(filter, "authorized_own");
+
+ expect(screen.getByText("Demo Corp")).toBeInTheDocument();
+ expect(screen.queryByText("Demo Group")).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /Demo Corp.*Own company/ })).toBeInTheDocument();
+
+ await userEvent.selectOptions(filter, "observed_hierarchy");
+ expect(screen.getByText("Demo Corp")).toBeInTheDocument();
+ expect(screen.queryByText("Demo Group")).not.toBeInTheDocument();
+ });
+
it("shows every observed relationship role for a counterparty, flagging multi-role names", async () => {
// Feature request (2026-08-19): a real counterparty is not limited
// to one role -- a customer in one post can be a competitor,
@@ -2035,6 +2055,9 @@ describe("App, authenticated", () => {
expect(screen.queryByText("Related to Priya Nair")).not.toBeInTheDocument();
const popup = document.querySelector(".popup-panel");
expect(popup).not.toBeNull();
+ expect(screen.getByRole("dialog", { name: "Public post" })).toBe(popup);
+ expect(popup).toHaveAttribute("aria-modal", "true");
+ expect(popup).toHaveAttribute("aria-labelledby", "post-detail-title");
const evaluation = within(popup as HTMLElement).getByRole("heading", {
name: "Post quality (IRT)",
});
@@ -2052,6 +2075,45 @@ describe("App, authenticated", () => {
expect(keyman.compareDocumentPosition(ask) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
});
+ it("keeps the post popup keyboard-contained and restores the opener after Escape", async () => {
+ const user = userEvent.setup();
+ stubBackend();
+ render();
+
+ const opener = await screen.findByRole("button", { name: "View post: Public post" });
+ await user.click(opener);
+ const dialog = await screen.findByRole("dialog", { name: "Public post" });
+ await waitFor(() => expect(dialog).toHaveFocus());
+
+ await user.keyboard("{Tab}");
+ expect(screen.getByRole("button", { name: "Close" })).toHaveFocus();
+ const evidenceSummary = screen.getByText("Evidence provenance");
+ for (let step = 0; step < 40 && document.activeElement !== evidenceSummary; step += 1) {
+ await user.keyboard("{Tab}");
+ }
+ expect(evidenceSummary).toHaveFocus();
+ const ariaHiddenTabStop = document.createElement("button");
+ ariaHiddenTabStop.setAttribute("aria-hidden", "true");
+ dialog.insertBefore(ariaHiddenTabStop, screen.getByRole("button", { name: "Close" }).nextSibling);
+ await user.keyboard("{Tab}");
+ expect(ariaHiddenTabStop).not.toHaveFocus();
+ const ariaHiddenGroup = document.createElement("div");
+ ariaHiddenGroup.setAttribute("aria-hidden", "true");
+ const nestedAriaHiddenTabStop = document.createElement("button");
+ ariaHiddenGroup.append(nestedAriaHiddenTabStop);
+ dialog.insertBefore(ariaHiddenGroup, screen.getByRole("button", { name: "Close" }).nextSibling);
+ await user.keyboard("{Tab}");
+ expect(nestedAriaHiddenTabStop).not.toHaveFocus();
+ expect(dialog).toContainElement(document.activeElement as HTMLElement);
+ await user.keyboard("{Shift>}{Tab}{/Shift}");
+ expect(dialog).toContainElement(document.activeElement as HTMLElement);
+ expect((document.activeElement as HTMLElement).closest("details:not([open])")).toBeNull();
+ await user.keyboard("{Escape}");
+
+ await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
+ expect(opener).toHaveFocus();
+ });
+
it("labels a stale summary and retries the semantic refresh on request", async () => {
const fetchMock = stubBackend({ staleSummary: true });
render();
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 40a8be40c..ba6888fef 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -58,6 +58,7 @@ import {
type CorporateEntityRef,
type CustomerMasterEntity,
type CustomerMasterResponse,
+ type CustomerMasterScopeFacet,
type Counterparty,
type EvaluationResponse,
type IssueTicket,
@@ -1744,6 +1745,57 @@ function PostDetailPopup({
const [focusEntity, setFocusEntity] = useState<{ entityId: string; entityName: string } | null>(null);
const [focusTeam, setFocusTeam] = useState<{ teamId: string; teamName: string } | null>(null);
const contentReloadRef = useRef<() => void>(() => undefined);
+ const popupPanelRef = useRef(null);
+ const onCloseRef = useRef(onClose);
+
+ useEffect(() => {
+ onCloseRef.current = onClose;
+ }, [onClose]);
+
+ useEffect(() => {
+ const panel = popupPanelRef.current;
+ const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ if (!panel) return;
+ panel.focus({ preventScroll: true });
+
+ const focusableSelector =
+ 'a[href], area[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex="-1"])';
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ onCloseRef.current();
+ return;
+ }
+ if (event.key !== "Tab") return;
+ const focusable = Array.from(panel.querySelectorAll(focusableSelector)).filter(
+ (element) =>
+ !element.hidden &&
+ !element.closest('[aria-hidden="true"]') &&
+ (!element.closest("details:not([open])") || element.matches("summary")),
+ );
+ if (focusable.length === 0) {
+ event.preventDefault();
+ panel.focus();
+ return;
+ }
+ const currentIndex = focusable.indexOf(document.activeElement as HTMLElement);
+ const nextIndex = event.shiftKey
+ ? currentIndex <= 0
+ ? focusable.length - 1
+ : currentIndex - 1
+ : currentIndex < 0 || currentIndex === focusable.length - 1
+ ? 0
+ : currentIndex + 1;
+ event.preventDefault();
+ focusable[nextIndex].focus();
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ if (previouslyFocused && document.contains(previouslyFocused)) previouslyFocused.focus();
+ };
+ }, []);
function reloadKeymen() {
fetchPostKeymen(accessToken, postId)
@@ -1919,13 +1971,22 @@ function PostDetailPopup({
return (