diff --git a/AGENTS.md b/AGENTS.md
index 3af4169bc..963d0d1f2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -422,3 +422,25 @@ columns). Do not silently rewrite either historical form. The SHACL
shapes graph (`docs/ontology/lineageweave-kg-shapes.ttl`) is the
closed-world data-validation boundary for DB-to-RDF projections and is
published beside the ontology.
+
+## Verified maintenance practices
+
+- Bind authorization-sensitive asynchronous UI results to the request lifecycle,
+ not token equality alone. Exercise deferred success and failure across A → B → A
+ and unmounts; retired requests must not update data, errors, loading state, or
+ trigger follow-up reads. Use behavioral tests rather than source-text guards.
+- A test deadline failure is not a causal RED assertion. Record timeouts separately
+ and diagnose the driver or runtime before claiming a regression is reproduced.
+- Validate local login from the documented `http://localhost:15173` origin. An IP
+ alias can fail the issuer redirect allowlist even when the canonical origin works;
+ do not broaden redirect permissions to accommodate a test-only alias.
+- Release eligible changes through the existing protected owner workflow and its
+ GitHub Secrets. Verify the revision, checks, approval, published version, and
+ runtime outcome separately. Inspect secret metadata only; never expose values.
+ Preserve reusable lessons here as work proceeds, without transient logs or real
+ source records.
+
+- Keyboard acceptance must send keyboard input to focused controls; a pointer-click
+ test with a keyboard title proves neither Enter nor Space activation. Keep both
+ native-button and custom SVG-control activation covered. Programmatic focus in
+ a unit test does not establish browser Tab-order acceptance.
diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md
index b5d31877b..542e47a22 100644
--- a/docs/product-technical-gap-baseline.md
+++ b/docs/product-technical-gap-baseline.md
@@ -1,5 +1,209 @@
# Product & Technical Gap Baseline
+## Review repair audit — 2026-09-06 19:20 KST
+
+This update supersedes queue and acceptance claims in the earlier audit below.
+Remote `main` remains `83eba56149eb802cd63642c507c324c9976ec78e`.
+The fully paginated inventory has **121 open PRs (114 draft, 7 ready)** and
+**16 open issues**. The six previously ready heads listed below are unchanged;
+PR #959 entered review at `3db3a477bebd15ca335ec29a0eff65664db613c9`.
+REST exact-head checks were re-read for all seven ready PRs. Queued and
+cancelled checks are not success. GraphQL CLI quota was exhausted; review
+thread lookup through the connector found one unresolved #959 documentation
+thread. No new independent approval or protected merge is claimed.
+The existing six squash auto-merge settings remain enabled.
+
+### Selected user-visible repair and evidence boundary
+
+PR #959's supplied-data path still retained a denied neighborhood, and changing
+only its status to ready made the same records exportable again. Two new
+rerender regressions reproduced this defect before repair. The component now
+discards loaded data and selection/search state, remembers denied object
+identities weakly, and requires a replacement projection before showing data
+again. The immediate denied-prop render also omits the old projection. No
+source record, Voice, inference, API, schema, release version, or owner-engine
+contract changes. Existing controls, notices, tokens and Storybook scenes are
+reused; fresh projections do not restore a previously selected evidence drawer.
+
+Implementation commit: `c6b272ae7542c1f686a5178259463252c9d7f243`.
+Lint, TypeScript/Vite build, and Storybook build passed. Chromium rendering at
+1440px and 390px confirmed disabled CSV/JSON-LD exports, zero old evidence
+tables/drawers, and no horizontal overflow; both screenshots were inspected:
+[desktop](screenshots/ontology-denial-repair-desktop-20260906.png) and
+[mobile](screenshots/ontology-denial-repair-mobile-20260906.png).
+The new status-recovery regressions failed twice before repair and passed in
+the bounded full-file attempt; that attempt also had six existing UI failures.
+Subsequent isolated execution hit test-process startup timeouts under observed
+host load averages around 28–36. A separate layout file passed nine tests, but
+the combined run had one process-startup error. These are not a green full
+suite. The final state-transition regression uses synchronous click/change
+events instead of per-keystroke simulation; its two cases pass in isolation
+with every assertion and the original timeout retained. Lint and production
+build evidence concerns the unchanged production component. Full-suite
+acceptance still requires a clean run. No timeout, warning filter, test
+assertion, or CI gate was weakened.
+
+At remote repair head `25688661c05e48f2b95470ce23add24b8827ef30`,
+GitHub's authenticated browser confirmed normal squash auto-merge enabled,
+the documentation thread resolved, and independent approval still required.
+Fourteen exact-head Checks were queued and two were skipped; none of those
+states is terminal success. CLI/connector GraphQL mutations had failed on
+quota, so the standard web auto-merge control was used with bypass unchecked.
+No merge SHA is claimed. The following test/documentation-only update
+requires fresh Checks on its own head; predecessor results do not transfer.
+
+The current LineageWeave PRD, ADR 0184 and ADR 0256, and the upstream
+contextual-orchestrator product/architecture authority were read before this
+repair. Canonical remote names were rechecked: `ContextualWisdomLab/LineageWeave`,
+`RankWeave`, `ThreadWeave`, `TEPP`, `contextual-orchestrator`, and lowercase
+`ContextualWisdomLab/disksage`. No research-derived policy is added. Context7
+returned a quota error and DeepWiki had no indexed LineageWeave documentation;
+neither is presented as verified authority. React's official reference supplied
+the effect/ref lifecycle contract.
+
+All **121 current PR file lists** were retrieved. The eight distinct ADR-number
+collisions listed below remain present; no distinct added migration filenames
+share a numeric prefix. This is filename/ownership evidence, not complete
+cross-branch SQL or API compatibility. Existing Voice/export PRs remain the
+owners of their respective changes, and no child was retargeted before its
+parent's protected merge. No in-progress repository Actions run was returned,
+so no runner job was cancelled.
+
+The earlier runtime counts and k6 probe below are dated evidence from the
+previous candidate, not measurements of this repair. Authenticated PostgreSQL
+API/rendered UI acceptance, full asynchronous end-to-end load and saturation,
+and all Voice truth/cutoff/PROV-O acceptance remain **unverified**. Synthetic
+rendering and local regression tests must not promote those conditions to done.
+
+## Current audit — 2026-09-06
+
+This section supersedes the dated queue snapshots below. Audited protected
+`main`: `83eba56149eb802cd63642c507c324c9976ec78e`.
+The pre-change remote inventory contains 120 open PRs (114 Draft, 6 Ready)
+and 16 open issues. No Ready PR has a qualifying exact-head approval.
+All six retain normal squash auto-merge. Their first 100 review threads were
+fully paginated (no additional page) and contain zero unresolved threads.
+Required workflows remain mandatory even where ordinary local tests pass.
+
+| Ready PR | Exact head | Current-head approvals / unresolved threads | Auto-merge | Non-green check names |
+| --- | --- | --- | --- | --- |
+| #780 | `1d8fa267b059289e77301a09985dfac70a439814` | 0 / 0 | enabled | Admit current pull request head, CodeQL compatibility analysis (actions), CodeQL compatibility analysis (javascript-typescript), CodeQL compatibility analysis (python), Detect changed scope, admit-current-head, cancel-superseded-opencode-review-runs, cancel-superseded-pr-runs, required-workflow-bootstrap |
+| #802 | `32f1cda10a2a1a6cabd64a3ae6f59bd6f0b20fd6` | 0 / 0 | enabled | CodeQL compatibility analysis (actions), CodeQL compatibility analysis (javascript-typescript), CodeQL compatibility analysis (python), dependency-review, noema-review, opencode-review, strix |
+| #907 | `847a15e73e69bfc768d517a83fa8706aecfafe7e` | 0 / 0 | enabled | CodeQL compatibility analysis (actions), CodeQL compatibility analysis (javascript-typescript), CodeQL compatibility analysis (python), noema-review, opencode-review, publish-manual-pr-evidence-status, strix |
+| #911 | `5d40eed35a0b6e0d182397f8d02b29c38e9bdd17` | 0 / 0 | enabled | CodeQL compatibility analysis (actions), CodeQL compatibility analysis (javascript-typescript), CodeQL compatibility analysis (python), dependency-review, noema-review, opencode-review, strix |
+| #914 | `61ed3a3712d252e3c179a71d297c52f05e1bac20` | 0 / 0 | enabled | CodeQL compatibility analysis (actions), CodeQL compatibility analysis (javascript-typescript), CodeQL compatibility analysis (python), noema-review, opencode-review |
+| #929 | `2a8ed5d02f4a3082b346d923d754c1ff37ebff52` | 0 / 0 | enabled | CodeQL compatibility analysis (actions), CodeQL compatibility analysis (javascript-typescript), CodeQL compatibility analysis (python), dependency-review, noema-review, opencode-review |
+
+Current branch rules require one independent approval, dismissal of stale
+reviews, resolved review threads, and the central workflow suite. Last-push
+approval is currently false; do not replace live rules with historical notes.
+No self-approval, force push, admin bypass, stack retarget, or merge occurred
+in this audit. No in-progress repository run was returned during the initial
+runner inventory, so no run was cancelled. Cancelled checks above remain
+non-passing evidence and do not authorize cancelling a current open-PR run.
+
+### Authority, implementation, and acceptance are separate
+
+- **Authority read:** current LineageWeave `docs/product-requirements.md`,
+ ADR 0184, ADR 0246 and the actual main ADR 0251; current upstream TEPP
+ `docs/product/prd-v0.4-approved.md`, contextual-orchestrator
+ `docs/product_planning.md` and `docs/architecture.md`. Main's ADR 0251 is
+ the FJA/I-O taxonomy, while Voice composition is ADR 0256; an ADR number
+ alone does not resolve the competing historical identities.
+- **Canonical remote names verified:** `ContextualWisdomLab/LineageWeave`,
+ `RankWeave`, `ThreadWeave`, `TEPP`, and `ContextualWisdomLab/disksage`.
+ The mixed-case DiskSage row in the historical authority register is not
+ the canonical repository name.
+- **Research boundary:** no new scientific claim, weight, inference policy,
+ model selector, or arithmetic was introduced. Existing ADR literature is
+ supporting authority for its stated scope, not proof that implementation
+ or runtime acceptance is complete.
+- **Current implementation gap selected:** after an authenticated continuation
+ returned 403/404, the ontology graph disappeared but previously loaded
+ CSV/JSON-LD remained downloadable and a selected record drawer survived.
+ This directly violated ADR 0184's same-authorized-content export contract.
+ Three new regressions failed before the fix. The minimal repair clears
+ denied response data/selection and prevents every visible projection from
+ using data in the denied state, including supplied Storybook payloads.
+ Ordinary 500 continuation retries retain the earlier authorized pages.
+- **Local candidate evidence:** 36 focused and 533 total frontend tests pass;
+ lint, TypeScript/Vite build and Storybook build pass. `DeniedAfterLoaded`
+ reuses the existing controls, copy, layout and tokens. Inspected 1440px and
+ 390px screenshots show disabled CSV/JSON-LD controls, no old evidence table
+ or drawer, and no horizontal document overflow. Existing build chunk-size
+ warnings were not suppressed.
+- **Current aggregate only:** official Compose project `lineageweave` has
+ 43,189 source Posts and a Voice-assignment store. These counts disclose no
+ record identity and are not a probability sample or population inference.
+ The inspected worker, PostgreSQL and orchestrator containers report healthy;
+ this is not exact-candidate deployment evidence.
+- **Still unaccepted:** full Voice API/UI truth/cutoff/PROV-O parity,
+ authenticated rendering of this candidate against PostgreSQL, and full
+ asynchronous Ask end-to-end saturation/capacity. The current stack is a
+ private-data runtime; its existence does not authorize presenting synthetic
+ Storybook or a missing-focus probe as full real-runtime acceptance.
+
+### Bounded authenticated denial probe
+
+Synthetic `demo.analyst` OIDC authentication succeeded without printing its
+credential or token. PostgreSQL confirmed the synthetic focus identifier had
+zero rows. A 4-VU, 10-second k6 probe against the official authenticated
+ontology route returned the expected 404 on all 18,939 focus reads (0 failed
+checks); explicit expected-status classification produced 0% HTTP error rate.
+Including one setup token request, observed throughput was 1,757.53 requests/s,
+mean latency 2.04ms and p95 2.60ms. This intentionally bounded observation is
+not an SLO, probability sample, full Ask workflow, or saturation test.
+Point-in-time observations showed one active diagnostic PostgreSQL connection
+and six idle clients; sampled CPU percentages for PostgreSQL, Ask processing,
+Valkey, and the gateway were 0.00%, 0.14%, 0.23%, and 0.02%. These samples do
+not measure peak saturation. No performance bottleneck or numerical repair is
+claimed. The first probe classified expected 404s as HTTP errors; the reported
+probe corrected only expected-response classification, without hiding failed
+checks or changing the application.
+
+The UI design contract is unchanged in layout: evidence reviewers retain the
+existing next-action notice and navigation; denial removes prior record
+content and disables export controls at desktop and mobile widths. Existing
+tokens and Storybook scenes are the visual authority. No new layout, color,
+spacing, or decorative component was introduced. Screenshots are synthetic:
+[desktop](screenshots/ontology-denial-desktop-20260906.png) and
+[mobile](screenshots/ontology-denial-mobile-20260906.png).
+
+### PR overlap and identity collisions
+
+All 120 open PR file lists were retrieved successfully. Added ADR filenames
+reuse eight numeric identities for distinct decisions:
+
+- `docs/adr/0279`: #888 `docs/adr/0279-global-ask-exact-semantic-index.md`; #811 `docs/adr/0279-leftover-map-segment-expected.md`.
+- `docs/adr/0289`: #821 `docs/adr/0289-leftover-map-compare-coverage.md`; #820 `docs/adr/0289-leftover-map-plot-singular.md`.
+- `docs/adr/0290`: #823 `docs/adr/0290-leftover-map-axis-singular.md`; #822 `docs/adr/0290-leftover-map-compare-item-coverage.md`.
+- `docs/adr/0300`: #899 `docs/adr/0300-contextual-orchestrator-owner-boundary.md`; #837 `docs/adr/0300-leftover-map-compare-expected.md`.
+- `docs/adr/0301`: #902 `docs/adr/0301-dichotomous-measurement-policy.md`; #838 `docs/adr/0301-leftover-map-compare-rank.md`.
+- `docs/adr/0305`: #844 `docs/adr/0305-leftover-map-compare-plot-axis-share.md`; #843 `docs/adr/0305-leftover-map-compare-rank-payload.md`.
+- `docs/adr/0335`: #877 `docs/adr/0335-leftover-map-compare-plot-tick-origin-badge.md`; #876 `docs/adr/0335-leftover-map-plot-criterion-coordinates.md`.
+- `docs/adr/0355`: #920 `docs/adr/0355-leftover-map-plot-origin-badge.md`; #915 `docs/adr/0355-dynamic-evaluation-lineage.md`.
+
+No distinct added migration filenames reused a number in that inventory.
+This filename check does not prove SQL/API compatibility across all stacked
+branches. Release-title duplicates remain candidates requiring actual version
+reconciliation: v2.92.0 (#877/#876), v2.62.0 (#844/#843), v2.61.0 (#842/#841),
+v2.50.0 (#828/#826), v2.47.0 (#823/#822), v2.46.0 (#821/#820).
+Do not merge these child branches onto an unprotected parent; protect the
+parent first, retarget, and gather new head/base/review/check evidence.
+
+Existing owner PRs remain intact: #780 carries Voice export authority;
+PR #934 singleton JSON-LD page relations; #935 filtered export parity;
+PR #936 correction history; #937 evidence visibility/write admission; #929
+versioned translations. The denial fix changes `OntologyExplorer.tsx` and its
+regressions only; shared Storybook/inventory/baseline edits need reconciliation
+when those PRs reach main. API, schema, ADR identity, and release number are
+unchanged by this slice.
+
+## Historical audit snapshots
+
+The remaining dated entries are historical supporting evidence, not a current
+queue, deployment, or acceptance claim.
+
> Exact-head loop overlay: 2026-08-29 13:20 KST. Protected `main` is
> `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map
> explained leftover share, #775). Open ready PRs still lack independent
@@ -932,3 +1136,78 @@ The ONET rows stacked into base branches (#743/#745/#746/#740/#732) reached
`main` together through the #759 promotion; their per-base merge records are
historical evidence only. The job-architecture artifact ship originally via
#749 is now re-verified on `main` from the promotion.
+
+
+### Ontology external scope retirement (2026-09-07, PR #959)
+
+Changing the credential, cutoff, or external focus while a continuation is pending
+left the prior node visible. All four behavioral cases failed the absence assertion
+on the prior implementation; earlier deadline-only failures were not counted as
+causal reproduction. A guarded input-state reset now discards the loaded projection,
+selection, and cursor before committing the changed scope, while preserving the
+existing denial bookkeeping. Deferred prior responses remain inadmissible.
+
+The implementation follows the existing ADR 0124 cursor-scope and ADR 0184
+authorized-projection contracts. React's guarded same-component state adjustment
+avoids a stale committed child render without remounting and losing denial state.
+Reference: React. (n.d.). *You might not need an Effect*.
+https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
+
+Automatic eligible deployment and continuous AGENTS.md maintenance are now part
+of the existing hourly loop. Protected main currently exposes ontology Pages
+publication; this Draft UI repair is not a released artifact. The fast-mlsirm
+publication workflow consumes `PIPY_TOKEN`; its last observed failed run stopped
+at control-plane provenance verification before any package publication. Secret
+availability was not established by the repository metadata query, and the
+environment metadata endpoint returned 404; neither result establishes missing
+organization credentials. No secret value was accessed.
+
+Validation: all four new scope cases passed after the repair. The complete two-file
+Ontology suite included 35 tests: 33 passed and two existing keyboard/Voice cases
+exceeded their unchanged five-second deadlines. This is not a green suite or
+authenticated runtime acceptance. TypeScript and the production Vite build passed;
+the existing 500 kB chunk warning remains. No package or runtime was deployed.
+
+
+### Keyboard acceptance and publication eligibility follow-up (2026-09-07)
+
+The ontology test named keyboard acceptance used pointer clicks only. It now
+exercises both Enter and Space on focused SVG node/edge controls and their native
+evidence buttons, preserving the evidence destination assertions. This proves
+activation, not real-browser Tab ordering. The complete two-file suite included
+36 cases: 35 passed, including both keyboard cases and the previously timed-out
+Voice case; one credential-transition case hit the unchanged five-second limit.
+The suite therefore remains non-green.
+
+A separate diagnostic CPU-profile run of that credential case passed in 1.15 s
+(test body; 4.13 s total). Of 4082.7 ms sampled, 475.8 ms was idle, 451.5 ms
+readFileUtf8, 423.3 ms module wrapSafe, and 111.8 ms garbage collection. These
+process-wide import/runtime samples do not establish the cause of earlier
+intermittent timeouts or prove the authenticated page-latency target. No timeout,
+assertion, or required test was relaxed.
+
+Colima still has 14 running containers; the three previously retired duplicate
+services have not reappeared. No new stack or service was started.
+
+The fast-mlsirm v0.9.1 release tag still resolves to
+`09f762ded35786dd1078222a4577ff09d649816f` and its GitHub release has no assets.
+Publication run 32966867962 stopped because its control-plane revision had moved.
+Fresh source checks also contain failed Rust study shards 3, 5, 7, 8, and 9.
+Existing release work includes PR #1471 (v0.9.2, Draft) and PR #1692 (SBOM and
+provenance); do not create a competing release path or re-dispatch v0.9.1 as
+verified merely because core CI passed. No publication was dispatched.
+
+
+### Ontology request re-entry verification (2026-09-07)
+
+Deferred success and 403 denial from the retired first A request cannot replace
+or hide the current authorized projection after A → B → A. Two behavioral
+cases now assert the current graph and both exports survive, without another
+request. Existing per-request effect cleanup already satisfies this contract;
+no second lifecycle mechanism was added.
+
+The latest complete OntologyExplorer plus stabilization suite passed 38/38
+cases in 16.66 s, with all existing deadlines unchanged. This supersedes the
+previous non-green focused-suite result, while preserving its timeout history.
+It does not establish a green full frontend suite, browser Tab order, current-head
+hosted CI, or authenticated all-page performance.
diff --git a/docs/screenshots/ontology-denial-desktop-20260906.png b/docs/screenshots/ontology-denial-desktop-20260906.png
new file mode 100644
index 000000000..66d5b85ba
Binary files /dev/null and b/docs/screenshots/ontology-denial-desktop-20260906.png differ
diff --git a/docs/screenshots/ontology-denial-mobile-20260906.png b/docs/screenshots/ontology-denial-mobile-20260906.png
new file mode 100644
index 000000000..5c89af3ee
Binary files /dev/null and b/docs/screenshots/ontology-denial-mobile-20260906.png differ
diff --git a/docs/screenshots/ontology-denial-repair-desktop-20260906.png b/docs/screenshots/ontology-denial-repair-desktop-20260906.png
new file mode 100644
index 000000000..66d5b85ba
Binary files /dev/null and b/docs/screenshots/ontology-denial-repair-desktop-20260906.png differ
diff --git a/docs/screenshots/ontology-denial-repair-mobile-20260906.png b/docs/screenshots/ontology-denial-repair-mobile-20260906.png
new file mode 100644
index 000000000..5c89af3ee
Binary files /dev/null and b/docs/screenshots/ontology-denial-repair-mobile-20260906.png differ
diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md
index f426285a6..1abbfa6c0 100644
--- a/docs/storybook-inventory.md
+++ b/docs/storybook-inventory.md
@@ -13,7 +13,7 @@ operator-facing control you can click before changing product CSS.
| `Post/Connect perspective` | Choose one unassigned Voice and an explicit evidence state, then record the open post as its evidence. `Ready`, `Completed`, and `NarrowViewport` cover untouched, successful, and mobile states. | `VoiceAssignmentForm`, `admin-form`, `btn-primary` |
| `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` |
| `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` |
-| `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation, while `CombinedVoiceEvidence` covers primary-plus-additional Voice assignments and focuses the evidence action distinct from the carrying-Post action. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222/0251 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` |
+| `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation, while `CombinedVoiceEvidence` covers primary-plus-additional Voice assignments and focuses the evidence action distinct from the carrying-Post action. `DeniedAfterLoaded` proves previously loaded evidence cannot be exported or opened after denial. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222/0251 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` |
| `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project/Work-evidence shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The populated scene includes one assertion-backed occupational construct without a person-trait promotion. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222/0255 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` |
| `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` |
| `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` |
diff --git a/frontend/src/components/OntologyExplorer.stories.tsx b/frontend/src/components/OntologyExplorer.stories.tsx
index 5e192ff0f..acaf8eca8 100644
--- a/frontend/src/components/OntologyExplorer.stories.tsx
+++ b/frontend/src/components/OntologyExplorer.stories.tsx
@@ -430,6 +430,24 @@ export const Denied: Story = {
},
};
+export const DeniedAfterLoaded: Story = {
+ args: {
+ neighborhood: demoNeighborhood,
+ status: "denied",
+ },
+ play: ({ canvasElement }) => {
+ const buttons = Array.from(canvasElement.querySelectorAll("button"));
+ for (const label of ["Export CSV", "Export JSON-LD"]) {
+ if (!buttons.find((button) => button.textContent === label)?.disabled) {
+ throw new Error("A denied neighborhood must disable evidence exports");
+ }
+ }
+ if (canvasElement.querySelector(".ontology-exact-values, .ontology-drawer")) {
+ throw new Error("A denied neighborhood must omit prior evidence");
+ }
+ },
+};
+
export const StaleCutoff: Story = {
args: {
knowledgeCutoff: "2026-01-15T12:00:00Z",
diff --git a/frontend/src/components/OntologyExplorer.test.tsx b/frontend/src/components/OntologyExplorer.test.tsx
index 7820d383c..d6e1edee6 100644
--- a/frontend/src/components/OntologyExplorer.test.tsx
+++ b/frontend/src/components/OntologyExplorer.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, waitFor } from "@testing-library/react";
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { BackendError, fetchOntologyNeighborhood } from "../api";
@@ -161,8 +161,85 @@ describe("OntologyExplorer", () => {
expect(container.querySelector('polygon[points="0,-16 20,0 0,16 -20,0"]')).not.toBeNull();
});
+ it.each([
+ ["credential", { accessToken: "token-b" }],
+ ["cutoff", { knowledgeCutoff: "2026-01-01T00:00:00Z" }],
+ ["focus", { focusNodeId: EVIDENCE_POST_ID }],
+ ["focus type", { focusNodeType: "node_person" }],
+ ] as const)("retires old pages before a changed %s loads", async (_name, change) => {
+ const fetchNeighborhood = vi.mocked(fetchOntologyNeighborhood).mockReset();
+ let releaseOld!: (value: OntologyNeighborhoodPayload) => void;
+ let releaseCurrent!: (value: OntologyNeighborhoodPayload) => void;
+ fetchNeighborhood
+ .mockResolvedValueOnce(neighborhood({ truncated: true, next_cursor: "page-2" }))
+ .mockImplementationOnce(() => new Promise((resolve) => { releaseOld = resolve; }))
+ .mockImplementationOnce(() => new Promise((resolve) => { releaseCurrent = resolve; }));
+ const initial = {
+ accessToken: "token-a", focusNodeType: "node_post", focusNodeId: POST_ID,
+ knowledgeCutoff: undefined,
+ };
+ const { rerender } = render();
+ fireEvent.click(await screen.findByRole("button", { name: "Select node: Post Demo public post" }));
+ fireEvent.click(screen.getByRole("button", { name: "Load next relation page" }));
+ expect(fetchNeighborhood).toHaveBeenCalledTimes(2);
+
+ const current = { ...initial, ...change };
+ rerender();
+ expect(screen.queryByRole("button", { name: "Select node: Post Demo public post" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("heading", { name: "Demo public post" })).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Export CSV" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Export JSON-LD" })).toBeDisabled();
+ expect(fetchNeighborhood).toHaveBeenCalledTimes(3);
+ expect(fetchNeighborhood).toHaveBeenNthCalledWith(3, current.accessToken, {
+ focusNodeType: current.focusNodeType, focusNodeId: current.focusNodeId,
+ knowledgeCutoff: current.knowledgeCutoff, cursor: undefined,
+ });
+
+ await act(async () => { releaseOld(neighborhood()); });
+ expect(screen.queryByRole("button", { name: "Select node: Post Demo public post" })).not.toBeInTheDocument();
+ await act(async () => { releaseCurrent(neighborhood({
+ focus_node_id: current.focusNodeId, focus_node_type_code: current.focusNodeType,
+ nodes: [{ ...neighborhood().nodes[0], node_id: current.focusNodeId,
+ node_type_code: current.focusNodeType, display_label: "Current scope record" }],
+ edges: [], exact_value_rows: [],
+ })); });
+ expect(screen.getByRole("button", { name: /Select node: .*Current scope record/ })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Export CSV" })).toBeEnabled();
+ });
+
+ it.each(["success", "denial"])("ignores retired %s after credential re-entry", async (outcome) => {
+ const fetchNeighborhood = vi.mocked(fetchOntologyNeighborhood).mockReset();
+ let resolveRetired!: (value: OntologyNeighborhoodPayload) => void;
+ let rejectRetired!: (error: BackendError) => void;
+ fetchNeighborhood
+ .mockImplementationOnce(() => new Promise((resolve, reject) => {
+ resolveRetired = resolve;
+ rejectRetired = reject;
+ }))
+ .mockImplementationOnce(() => new Promise(() => {}))
+ .mockResolvedValueOnce(neighborhood());
+ const props = { focusNodeType: "node_post", focusNodeId: POST_ID };
+ const { rerender } = render();
+ rerender();
+ rerender();
+ expect(await screen.findByRole("button", { name: "Select node: Post Demo public post" })).toBeInTheDocument();
+ expect(fetchNeighborhood).toHaveBeenCalledTimes(3);
+
+ await act(async () => {
+ if (outcome === "denial") {
+ rejectRetired(new BackendError("/api/ontology/neighborhood", 403));
+ } else {
+ resolveRetired(neighborhood({ nodes: [], edges: [], exact_value_rows: [] }));
+ }
+ });
+ expect(screen.getByRole("button", { name: "Select node: Post Demo public post" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Export CSV" })).toBeEnabled();
+ expect(screen.getByRole("button", { name: "Export JSON-LD" })).toBeEnabled();
+ expect(fetchNeighborhood).toHaveBeenCalledTimes(3);
+ });
+
it("keeps loaded pages visible when a continuation page fails", async () => {
- const fetchNeighborhood = vi.mocked(fetchOntologyNeighborhood);
+ const fetchNeighborhood = vi.mocked(fetchOntologyNeighborhood).mockReset();
let rejectContinuation!: (error: BackendError) => void;
fetchNeighborhood
.mockResolvedValueOnce(neighborhood({ truncated: true, next_cursor: "page-2" }))
@@ -246,7 +323,92 @@ describe("OntologyExplorer", () => {
).toBeInTheDocument();
});
- it("lets keyboard users open node and edge evidence", async () => {
+ it.each([403, 404])("discards prior evidence and exports after continuation denial (%s)", async (status) => {
+ const fetchNeighborhood = vi.mocked(fetchOntologyNeighborhood);
+ fetchNeighborhood.mockReset();
+ fetchNeighborhood
+ .mockResolvedValueOnce(neighborhood({ truncated: true, next_cursor: "page-2" }))
+ .mockRejectedValueOnce(new BackendError("/api/ontology/neighborhood", status));
+ render(
+ ,
+ );
+ await userEvent.click(await screen.findByRole("button", { name: "Select node: Post Demo public post" }));
+ await userEvent.click(screen.getByRole("button", { name: "Load next relation page" }));
+ expect(await screen.findByText("Related information is unavailable for this record. Open a visible post next.")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Export CSV" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Export JSON-LD" })).toBeDisabled();
+ expect(screen.queryByRole("region", { name: "Exact values" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("complementary", { name: "Node evidence" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Load next relation page" })).not.toBeInTheDocument();
+ expect(screen.queryByText("Demo public post")).not.toBeInTheDocument();
+ });
+
+ it.each([403, 404])("restarts at the first page after continuation denial and credential refresh (%s)", async (status) => {
+ const fetchNeighborhood = vi.mocked(fetchOntologyNeighborhood);
+ fetchNeighborhood.mockReset();
+ fetchNeighborhood
+ .mockResolvedValueOnce(neighborhood({ truncated: true, next_cursor: "page-2" }))
+ .mockRejectedValueOnce(new BackendError("/api/ontology/neighborhood", status))
+ .mockResolvedValueOnce(neighborhood({ next_cursor: null }));
+
+ const { rerender } = render(
+ ,
+ );
+
+ await userEvent.click(await screen.findByRole("button", { name: "Load next relation page" }));
+ expect(
+ await screen.findByText("Related information is unavailable for this record. Open a visible post next."),
+ ).toBeInTheDocument();
+ await waitFor(() => expect(fetchNeighborhood).toHaveBeenCalledTimes(2));
+
+ rerender(
+ ,
+ );
+ await waitFor(() => expect(fetchNeighborhood).toHaveBeenCalledTimes(3));
+ expect(fetchNeighborhood).toHaveBeenNthCalledWith(
+ 3,
+ "synthetic-token-b",
+ expect.objectContaining({ cursor: undefined }),
+ );
+ expect(await screen.findByRole("button", { name: "Select node: Post Demo public post" })).toBeInTheDocument();
+ });
+
+ it.each(["ready", undefined] as const)("does not restore a denied supplied payload on status-only recovery (%s)", (recoveredStatus) => {
+ const payload = neighborhood();
+ const { rerender } = render(
+ ,
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Select node: Post Demo public post" }));
+ fireEvent.change(screen.getByLabelText("Search within this neighborhood"), { target: { value: "Demo" } });
+ rerender(
+ ,
+ );
+ expect(screen.queryByRole("complementary", { name: "Node evidence" })).not.toBeInTheDocument();
+ rerender(
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Export CSV" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Export JSON-LD" })).toBeDisabled();
+ expect(screen.queryByText("Demo public post")).not.toBeInTheDocument();
+ rerender(
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Export CSV" })).toBeEnabled();
+ expect(screen.queryByRole("complementary", { name: "Node evidence" })).not.toBeInTheDocument();
+ expect(screen.getByLabelText("Search within this neighborhood")).toHaveValue("");
+ });
+
+ it("denies exports when initially supplied a denied payload", () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Export CSV" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Export JSON-LD" })).toBeDisabled();
+ expect(screen.queryByText("Demo public post")).not.toBeInTheDocument();
+ });
+
+ it.each(["{Enter}", " "])("opens node and edge evidence with %s", async (activationKey) => {
+ const user = userEvent.setup();
const onSelectPost = vi.fn();
const onOpenEvidence = vi.fn();
render(
@@ -264,14 +426,22 @@ describe("OntologyExplorer", () => {
expect(screen.getByRole("columnheader", { name: "Valid from" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Valid to" })).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Evidence" })).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "Select node: Post Demo public post" }));
+ const nodeButton = screen.getByRole("button", { name: "Select node: Post Demo public post" });
+ nodeButton.focus();
+ expect(nodeButton).toHaveFocus();
+ await user.keyboard(activationKey);
expect(screen.getByRole("heading", { name: "Demo public post" })).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "Open evidence post" }));
+ screen.getByRole("button", { name: "Open evidence post" }).focus();
+ await user.keyboard(activationKey);
expect(onSelectPost).toHaveBeenCalledWith(POST_ID);
expect(onOpenEvidence).not.toHaveBeenCalled();
- await userEvent.click(screen.getByRole("button", { name: /Select edge: mentions from/ }));
+ const edgeButton = screen.getByRole("button", { name: /Select edge: mentions from/ });
+ edgeButton.focus();
+ expect(edgeButton).toHaveFocus();
+ await user.keyboard(activationKey);
expect(screen.getByText(/Property IRI/)).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: `Open evidence: ${POST_ID}` }));
+ screen.getByRole("button", { name: `Open evidence: ${POST_ID}` }).focus();
+ await user.keyboard(activationKey);
expect(onOpenEvidence).toHaveBeenCalledWith(POST_ID);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
diff --git a/frontend/src/components/OntologyExplorer.tsx b/frontend/src/components/OntologyExplorer.tsx
index fb05b0144..bb98b1df8 100644
--- a/frontend/src/components/OntologyExplorer.tsx
+++ b/frontend/src/components/OntologyExplorer.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import {
BackendError,
fetchOntologyNeighborhood,
@@ -103,6 +103,14 @@ export function OntologyExplorer({
const [cursor, setCursor] = useState(undefined);
const [pageRetry, setPageRetry] = useState(0);
const [liveFocus, setLiveFocus] = useState(false);
+ const [inputScope, setInputScope] = useState({ accessToken, focusNodeType, focusNodeId, knowledgeCutoff });
+ const deniedPayloads = useRef(new WeakSet());
+ const deniedLiveRequest = useRef<{
+ accessToken: string;
+ focusNodeType: string;
+ focusNodeId: string;
+ knowledgeCutoff?: string;
+ } | null>(null);
function clearSelection() {
setSelectedNodeKey(null);
@@ -110,18 +118,32 @@ export function OntologyExplorer({
setQuery("");
}
- useEffect(() => {
+ if (
+ inputScope.accessToken !== accessToken || inputScope.focusNodeType !== focusNodeType ||
+ inputScope.focusNodeId !== focusNodeId || inputScope.knowledgeCutoff !== knowledgeCutoff
+ ) {
+ setInputScope({ accessToken, focusNodeType, focusNodeId, knowledgeCutoff });
+ setLoaded(null);
+ setStatus("loading");
setFocusType(focusNodeType);
setFocusId(focusNodeId);
setCursor(undefined);
setPageRetry(0);
setLiveFocus(false);
clearSelection();
- }, [focusNodeType, focusNodeId]);
+ }
useEffect(() => {
const useProvided = Boolean(provided) && !liveFocus;
if (useProvided && provided) {
+ if (providedStatus === "denied") deniedPayloads.current.add(provided);
+ // A status change is not a newly authorized projection.
+ if (deniedPayloads.current.has(provided)) {
+ setLoaded(null);
+ clearSelection();
+ setStatus("denied");
+ return;
+ }
setLoaded(provided);
setStatus(providedStatus ?? statusFromPayload(provided, knowledgeCutoff));
return;
@@ -133,6 +155,17 @@ export function OntologyExplorer({
setStatus(providedStatus ?? "empty");
return;
}
+ const deniedRequest = deniedLiveRequest.current;
+ if (
+ deniedRequest?.accessToken === accessToken &&
+ deniedRequest.focusNodeType === focusType &&
+ deniedRequest.focusNodeId === focusId &&
+ deniedRequest.knowledgeCutoff === knowledgeCutoff
+ ) {
+ setLoaded(null);
+ setStatus("denied");
+ return;
+ }
let cancelled = false;
setStatus("loading");
fetchOntologyNeighborhood(accessToken, {
@@ -143,6 +176,7 @@ export function OntologyExplorer({
})
.then((payload) => {
if (cancelled) return;
+ deniedLiveRequest.current = null;
setLoaded((current) =>
cursor && current ? accumulateNeighborhoodPages(current, payload) : payload,
);
@@ -152,6 +186,15 @@ export function OntologyExplorer({
if (cancelled) return;
if (!cursor) setLoaded(null);
if (error instanceof BackendError && (error.status === 403 || error.status === 404)) {
+ deniedLiveRequest.current = {
+ accessToken,
+ focusNodeType: focusType,
+ focusNodeId: focusId,
+ knowledgeCutoff,
+ };
+ setCursor(undefined);
+ setLoaded(null);
+ clearSelection();
setStatus("denied");
return;
}
@@ -162,11 +205,15 @@ export function OntologyExplorer({
};
}, [accessToken, focusType, focusId, knowledgeCutoff, cursor, pageRetry, provided, providedStatus, liveFocus]);
- const visible = useMemo(() => filterNeighborhood(loaded, query), [loaded, query]);
+ const visible = useMemo(
+ () => status === "denied" || (!liveFocus && providedStatus === "denied")
+ ? null : filterNeighborhood(loaded, query),
+ [loaded, query, status, liveFocus, providedStatus],
+ );
const layout = useMemo(() => (visible ? layoutOntologyNeighborhood(visible) : null), [visible]);
const selectedNode = visible?.nodes.find((node) => nodeKey(node) === selectedNodeKey) ?? null;
const selectedEdge = visible?.edges.find((edge) => edge.edge_id === selectedEdgeId) ?? null;
- const canLoadNextPage = Boolean(loaded?.next_cursor && accessToken && !provided);
+ const canLoadNextPage = Boolean(status !== "denied" && loaded?.next_cursor && accessToken && !provided);
function resetFocus() {
setFocusType(focusNodeType);