Skip to content

fix(ui-ux): grow citation chip to a 24px touch target - #556

Merged
seonghobae merged 5 commits into
mainfrom
fix/uiux-touch_interaction-citation-chip-min-height
Aug 24, 2026
Merged

fix(ui-ux): grow citation chip to a 24px touch target#556
seonghobae merged 5 commits into
mainfrom
fix/uiux-touch_interaction-citation-chip-min-height

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Gap

Dimension: touch_interaction
Severity: medium

.citation-chip (the Ask/Chat evidence citation button rendered by
ChatCitations / CitationChip.tsx) renders ~19px tall on mobile, below
the 24px minimum touch target.

Evidence (frontend/src/App.css):

.citation-chip {
  border: 1px solid var(--color-chip-border);
  border-radius: var(--radius-chip);
  padding: var(--space-chip-block) var(--space-chip-inline);
  margin-right: var(--space-chip-gap);
  background: none;
  cursor: pointer;
  font-family: var(--font-family-chip);
  color: var(--text);
}

No min-height/min-width, and --space-chip-block is 0.1rem
(frontend/src/styles/tokens.css:83) — unlike .language-switcher select
and .lineage-entity-picker select, which already add
min-height: var(--size-control-min).

Fix

Added to .citation-chip:

display: inline-flex;
align-items: center;
min-height: var(--size-control-min);

min-height mirrors the existing convention (11 other rules in
App.css already use var(--size-control-min), e.g. .language-switcher select, .lineage-entity-picker select). display: inline-flex; align-items: center; is needed because .citation-chip is a bare <button>, so its text
would otherwise sit at the top of the taller box once min-height applies.

No hardcoded sizes/colors — reused the existing --size-control-min token.
Did not bump --space-chip-gap (mentioned as optional in the gap report,
not required to close the core gap — YAGNI).

Test

Added a regression test in frontend/src/styles/tokens.test.ts (extends
the file's existing pattern of reading App.css as text and asserting on
CSS rule content, since jsdom doesn't apply real layout/CSS in this repo's
Vitest config) that extracts the .citation-chip rule and asserts
min-height: var(--size-control-min), display: inline-flex, and
align-items: center are present. Confirmed red before the fix / green
after.

Checks (run from frontend/)

  • corepack pnpm exec vitest run src/styles/tokens.test.ts src/components/CitationChip.test.tsx5 passed (2 files)
  • corepack pnpm exec oxlint src/App.css src/styles/tokens.test.ts src/components/CitationChip.tsx src/components/CitationChip.test.tsx → clean, no output
  • corepack pnpm exec tsc -b → 2 pre-existing errors in src/App.tsx (unused import at line 104, AdminPanel accessToken type mismatch at line 4623), unrelated to this change and unrelated to App.css/tokens.test.ts. Confirmed via git diff that App.tsx is untouched by this PR, and these errors reproduce identically against a pristine origin/main checkout.

🤖 Generated with Claude Code

https://claude.ai/code/session_011jWJzKUd82yy97esEBfJbt


Open in Devin Review

.citation-chip rendered ~19px tall on mobile (0.1rem block padding via
--space-chip-block, no min-height), below the 24px minimum touch target.
Add min-height: var(--size-control-min), matching the convention already
used by .language-switcher select and .lineage-entity-picker select.
display: inline-flex + align-items: center keep the button's text
vertically centered in the taller box, since (unlike the label-based
board-option rules) this is a bare <button>.

Pins the fix with a regression test in tokens.test.ts that extracts the
.citation-chip rule from App.css and asserts min-height/inline-flex are
present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jWJzKUd82yy97esEBfJbt
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 53 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2043f994-8609-4965-9f21-5cc561e2ff07

📥 Commits

Reviewing files that changed from the base of the PR and between b4911f8 and 23cfb1f.

📒 Files selected for processing (2)
  • frontend/src/App.css
  • frontend/src/styles/tokens.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@seonghobae
seonghobae enabled auto-merge August 24, 2026 01:23
- The unauthenticated Log in handler now calls returnUrlFromLocation()
  then rememberOidcReturnUrl() before signinRedirect, per ADR 0109, so a
  shared /?post= link still opens that post after enterprise SSO. The
  previously unused oidcReturnUrl import is now load-bearing.
- AdminPanel renders only when accessToken is a string; the OIDC access
  token is string | undefined before isAuthenticated narrowing.
devin-ai-integration[bot]

This comment was marked as resolved.

@seonghobae
seonghobae merged commit 9e53b8e into main Aug 24, 2026
29 of 30 checks passed
seonghobae added a commit that referenced this pull request Aug 24, 2026
…ADR 0200 point 5, no activation) (#586)

* fix: detach provider parse error context (#393)

* fix: detach provider parse error context

* fix(frontend): restore authenticated admin boundary

* fix(frontend): use safe OIDC return URL

* fix: close provider transport error boundary

* fix: hide raw TEPP transport failures

* fix: restore exception chaining at HTTP transport/parse boundary

ADR 0123 keeps the original exception as an in-process chained cause
for operator logging while the buyer-facing message stays generic.
The transport_error/contentless-raise idiom in _request,
_decode_json, and configured_tepp_client's transport() dropped both
__cause__ and __context__ on every failure mode (TLS errors, DNS
failures, timeouts, malformed JSON), erasing the diagnostic trail an
operator needs from server-side logs. Restore `raise ... from exc` at
all three sites; the exposed message text is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5M79L945DMyMs3sg5yJ14

* docs: remove nonexistent provider-boundary ADR citations

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>

* feat: persist leftover-map axis share on period reports (v2.12.16) (#519)

Gabriel inertia of residual SVD axes 1 and 2 (σ²/Σσ²) is a report-level
3NF slice next to leftover pairs (ADR 0148). Rank-0 residuals emit two
zero-share axes. Do not invent a leftover score.

Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>

* feat(ask): move Global Ask behind a durable Valkey job queue (#572)

* feat(ask): move Global Ask behind a durable Valkey job queue

A live Ask answer is a multi-minute orchestrator LLM round-trip under
shared-gateway load (158 s measured); serving it inside one blocking
HTTP request pinned connections and timed out every client. POST
/api/ask now persists a global_ask_job row (migration 0165), wakes an
in-process worker over the global_ask_request_stream Valkey stream
(the post_content_queue idiom: durable row is truth, stream entry is a
wake-up, queued rows republish after 60 s), and returns 202 with a job
id. GET /api/ask/jobs/{id} is owner-scoped (404 hides existence) and
returns the settled answer payload. The worker reloads the account's
ABAC visibility at processing time so revocations between submit and
processing are honored. The frontend submits then polls with an
unchanged askAgent signature; e2e deadlines now match the async
reality and the temporal e2e question targets the seeded 2026-01
window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* docs: docstring for the worker-side ABAC predicate

The repo's docstring-coverage gate (tests/test_public_docstrings.py)
covers nested defs too; the can_see predicate in
compute_global_ask_answer was the one uncovered definition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* fix(ask): bounded-concurrency worker, per-job deadline, orphan recovery

The live e2e run surfaced two queue defects the unit tests could not:
the consumer awaited each job inline, so one slow answer head-of-line
blocked every question behind it (observed: a 17-minute job starving a
queued one), and a hung orchestrator round-trip kept a job running
indefinitely with no recovery for rows orphaned by a mid-job crash.

The worker now dispatches jobs as concurrent tasks behind a
4-slot semaphore (the queued->running claim already makes duplicate
wake-ups no-ops, so at-least-once semantics hold), every job runs under
a 600 s asyncio.wait_for deadline that settles it as failed, and the
recovery sweep re-queues running rows older than deadline+300 s —
which a live worker can never legitimately produce. e2e deadlines are
sized to the measured contended settle (480 s) plus setup slack for a
cold, loaded host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* fix(ask): size the answer client timeout to the job deadline

The orchestrator was observed completing an answer and hitting
BrokenPipe writing it back: the chat client's 180 s default socket
timeout hung up first, discarding an answer already paid for and
failing the job. The Ask client now uses
ORCHESTRATOR_ANSWER_TIMEOUT_SECONDS (default 570 s) — just under the
worker's 600 s job deadline, so the client ends a slow call before the
reaper does and a generated answer has the whole deadline to arrive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* test(e2e): size the answer deadline to the 570 s client timeout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* fix(ask): restate the resolved relative-time window in the prompt

Retrieval already scopes sources to the resolved window ('7개월 전' →
2026-01-01..2026-01-31), but the numbered sources carry no dates, so
the model answered the temporal e2e question with 'no date information'
and cited nothing (observed live: 4 in-window sources, zero citations).
The worker now appends the resolved window to the question and states
that every provided source falls inside it, grounding the answer in the
evidence it was given.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* feat(seed): warm post-content ingestion for seeded posts

Seeded posts are inserted straight into Postgres, but post-content
extraction (units, embeddings, embedded images) only enqueues when a
post's /content endpoint is first served — so a fresh stack had an
empty image pipeline and image citation had nothing to cite until a
human happened to open the right post. The seed now opens each seeded
post once through the API with the demo reader account, replaying the
exact production enqueue path instead of duplicating its SQL.
Verified live: the seeded TIFF was extracted and vision-captioned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* fix(ask): anchor today's date when restating a resolved time window

The first grounding clause named only the resolved window, and the
model read that window as the reference point ('now') — re-subtracting
the offset and answering that '7개월 전' meant mid-2025. The clause now
states today's date and equates the relative expression to the window
outright, then directs the answer at the provided in-window sources.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* test(e2e): ask questions the seeded corpus can answer decisively

'What happened between these events?' has no referent and 'Which
project?' names nothing — a careful model correctly declines both and
cites nothing, so the suite depended on a bluffing model to pass. The
questions now target seeded content directly (the Westfield Power
specification thread, the synthetic raster evidence post, Ada West's
initial site visit), so a model answering from the sources must cite
them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* fix(ask): settle a job on any failure, not a named few

An exception outside the settlement handler's tuple (first exercised
live when citation assembly ran against real image rows) killed the
worker task silently and stranded the row running until orphan
recovery re-queued it 15 minutes later. Settlement is fail-closed now:
any exception settles the job failed; CancelledError still propagates
so shutdown leaves the row for recovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* chore(ask): single-literal SQL statements for the Semgrep asyncpg rule

The asyncpg-sqli rule flags adjacent-literal SQL concatenation as
non-literal string building even with fully parameterized values (two
blocking findings on the recovery sweep). Every statement in the module
is now one triple-quoted literal, so there is no concatenation for the
rule to misread and nothing to suppress.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* chore(ask): express recovery windows without make_interval

The Semgrep asyncpg-sqli rule still flagged the recovery statements
after the single-literal rewrite; the trigger is the
make_interval(secs => $n) call syntax, which its matcher reads as
non-literal string building. interval '1 second' * $n is the same
parameterized arithmetic without the construct the rule misparses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* chore(ask): suppress the asyncpg-sqli false positive on recovery SQL

Both recovery statements are fully parameterized; the rule misreads
the literal-plus-constant-arguments shape regardless of literal style
or interval syntax (verified across three rewrites). Suppressed with
the same annotation the repo already carries in report_ingestion.py
and customer_hint_ingestion.py for this exact rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* fix(ask): apply PR-572 review batch across queue, config, seed, e2e

- Bound the wake-up stream (maxlen 1000, approximate) at both XADD
  sites and swallow a failed wake-up publish in enqueue: the committed
  row is truth and the sweep republishes within a minute, so the caller
  keeps its pollable job id instead of a 500.
- Share the 600 s job deadline through config
  (GLOBAL_ASK_JOB_DEADLINE_SECONDS) and validate
  ORCHESTRATOR_ANSWER_TIMEOUT_SECONDS as finite and strictly inside
  (0, deadline) so the client-hangs-up-first ordering cannot be
  misconfigured away.
- Tighten orphan recovery to deadline+60 s so a crashed worker's job
  returns to the queue while a reader is still polling; raise the
  frontend poll ceiling to 15 min to cover queue wait plus deadline.
- Resolve one Seoul reference date per job and use it for both
  retrieval and the grounded prompt (midnight-boundary consistency);
  gather_global_chat_sources accepts the pinned date.
- Give only the Ask worker the long answer timeout; the synchronous
  per-post chat endpoint keeps the interactive client default.
- Harden the worker loop: one failed consume/recovery round logs and
  retries instead of silently ending Ask consumption.
- e2e computes the months-ago offset from the real clock so the
  temporal question keeps resolving onto the seeded 2026-01 window in
  any month.
- Seed waits for backend /healthz before warming, and closes its
  psycopg2 connection explicitly.
- Drop imports left dead by the Ask logic move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

---------

Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* chore: remove one-shot patch scripts left from PR #347 development (#575)

add_translations.py, patch_api.py, patch_app_fetch.py,
patch_app_order.py, and patch_main.py were single-use edit scripts
that already applied their changes; nothing in the Makefile, CI, or
runtime references them, and leaving dead top-level scripts invites
someone to run them twice.


Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix: bootstrap repo-root sys.path in the two operator scripts missing it (#570)

backfill_post_keymen.py and requeue_failed_post_content.py were the only
scripts/ entries without the repo-root sys.path insert their siblings use,
so `python scripts/<name>.py` failed with ModuleNotFoundError: backend
unless a caller manually exported PYTHONPATH=. first. Match the existing
pattern instead of documenting the workaround.

Found while re-running backfill_post_keymen.py to refresh a stale
reason_no_live_client role row now that the local orchestrator/searxng
services are reachable.

* fix(ui-ux): size secondary details/summary toggles to --size-control-min (#560)

* fix(ui-ux): size secondary details/summary toggles to --size-control-min

Several disclosure toggles (advanced review tools, evidence-operation
actions, semantic provenance, source-author hint context) rendered with
a browser-default <summary> well under the project's 24px
--size-control-min touch target, the same convention already used by
.language-switcher select and .lineage-entity-picker select.

- Add a shared App.css rule giving .advanced-review-tools,
  .semantic-provenance, .operator-action-tools, .keyman-source-context,
  and the new .hint-disclosure class an inline-flex summary with
  min-height: var(--size-control-min) and horizontal padding, so the
  hit target isn't text-width-only.
- .operator-action-tools and .keyman-source-context previously had zero
  CSS rules at all -- not just a missing min-height.
- The three bare <details> "Related posts"/"Hint only" expanders in
  CustomerMasterPanel had no className, so there was no selector able
  to reach them; give them className="hint-disclosure".

Tests: frontend/src/styles/tokens.test.ts pins the new selector list
and asserts the rule uses var(--size-control-min) plus horizontal
padding, not a bespoke value. frontend/src/App.test.tsx adds a render
test asserting the three previously-bare <details> carry the
.hint-disclosure class. Both were confirmed to fail before this change
and pass after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jWJzKUd82yy97esEBfJbt

* fix(ui-ux): keep disclosure markers on secondary summary toggles

display: inline-flex replaced the summary list-item box, which stops
browsers from rendering the built-in expand/collapse marker. Reach the
--size-control-min touch target with min-height and padding instead so
the native affordance stays visible (devin review thread).

* fix(frontend): wire ADR 0109 return-url capture and narrow admin token

- The unauthenticated Log in handler now calls returnUrlFromLocation()
  then rememberOidcReturnUrl() before signinRedirect, per ADR 0109, so a
  shared /?post= link still opens that post after enterprise SSO. The
  previously unused oidcReturnUrl import is now load-bearing.
- AdminPanel renders only when accessToken is a string; the OIDC access
  token is string | undefined before isAuthenticated narrowing.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>

* fix(ui-ux): expose bare loading text as live regions (#558)

* fix(a11y): expose bare loading text as live regions

Async loading placeholders in App.tsx and FiveW1H.tsx rendered as
plain <p> text with no role/aria-live, so assistive tech never
announced the transition from "Loading..." to the resolved content.
This included the app-root auth gate every session passes through,
and the post-detail popup's loading paragraph.

Add role="status" to every bare loading <p> (19 in App.tsx, 1 in
FiveW1H.tsx), matching the role="status" pattern already used
elsewhere in the same files (e.g. "Loading posts...",
SourceResearchPanel). No behavior change beyond the attribute.

Tests: add an RTL assertion that the auth-loading gate and the
post-detail popup's loading state are exposed via getByRole("status")
rather than only getByText, extending stubBackend with a
deferPostOne option to deterministically observe the popup's
pre-resolution state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jWJzKUd82yy97esEBfJbt

* fix(frontend): wire ADR 0109 return-url capture and narrow admin token

- The unauthenticated Log in handler now calls returnUrlFromLocation()
  then rememberOidcReturnUrl() before signinRedirect, per ADR 0109, so a
  shared /?post= link still opens that post after enterprise SSO. The
  previously unused oidcReturnUrl import is now load-bearing.
- AdminPanel renders only when accessToken is a string; the OIDC access
  token is string | undefined before isAuthenticated narrowing.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>

* fix(ui-ux): grow citation chip to a 24px touch target (#556)

* fix(ui-ux): grow citation chip to a 24px touch target

.citation-chip rendered ~19px tall on mobile (0.1rem block padding via
--space-chip-block, no min-height), below the 24px minimum touch target.
Add min-height: var(--size-control-min), matching the convention already
used by .language-switcher select and .lineage-entity-picker select.
display: inline-flex + align-items: center keep the button's text
vertically centered in the taller box, since (unlike the label-based
board-option rules) this is a bare <button>.

Pins the fix with a regression test in tokens.test.ts that extracts the
.citation-chip rule from App.css and asserts min-height/inline-flex are
present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jWJzKUd82yy97esEBfJbt

* fix(frontend): wire ADR 0109 return-url capture and narrow admin token

- The unauthenticated Log in handler now calls returnUrlFromLocation()
  then rememberOidcReturnUrl() before signinRedirect, per ADR 0109, so a
  shared /?post= link still opens that post after enterprise SSO. The
  previously unused oidcReturnUrl import is now load-bearing.
- AdminPanel renders only when accessToken is a string; the OIDC access
  token is string | undefined before isAuthenticated narrowing.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>

* fix: shorten the orchestrator healthcheck's retry budget (#547)

* fix: shorten the orchestrator healthcheck's retry budget

10 retries at a 5s interval matches every other service's healthcheck
(postgres, valkey, searxng all use retries: 10). The orchestrator was
the one outlier at 20 retries -- a 100s startup grace period for a
liveness-only check (does no real work beyond confirming the process is
up) versus 50s everywhere else, with no documented reason for the gap.

* fix(compose): give the orchestrator healthcheck a 50s warm-up window

retries: 10 alone halves cold-boot grace to ~50s, which could fail the
backend depends_on gate on slow hosts. start_period: 50s keeps failures
during warm-up outside the retry budget: a booting orchestrator still
gets ~100s total while a genuinely dead one trips the gate in ~50s
(devin review thread).

* docs(compose): correct the healthcheck warm-up timing comment

start_period: 50s + retries: 10 x 5s means a dead service trips at
~100s total (not ~50s): identical to the old retries: 20 budget. The
actual win is that a booting orchestrator no longer consumes retry
budget during warm-up (devin review thread).

---------

Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>

* feat: name leftover-map unexplained leftover on leftover pairs (v2.12.26) (#535)

* feat: name leftover-map unexplained leftover on leftover pairs (v2.12.26)

Persist unexplained leftover U = R − R̂ after two-axis Gabriel reconstruction
so a leftover residual is not read as the leftover the map does not reconstruct.
R̂ stays internal and is not persisted. Rank-0 maps store U = 0; fallback pairs
omit U. After make seed, closest and farthest leftover pairs sit above the
member list with U next to leftover-map distance d; click opens that post.

* fix(frontend): remember OIDC return URL and drop unreachable AdminPanel render

Login screen built returnUrl via raw string concatenation instead of
returnUrlFromLocation()/rememberOidcReturnUrl(), leaving both imports
unused (TS6192) and rendering an AdminPanel with accessToken: undefined
(TS2322) on a branch guarded by !auth.isAuthenticated.

---------

Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>

* feat: bind corroborated SKOS org aliases to one catalog row (#480)

* docs: restore non-identifying gap baseline

* fix(frontend): restore authenticated admin boundary

* feat: bind corroborated SKOS org aliases to one catalog row

Expand corporate_entity candidates with search-verified alt/pref labels
so synthetic AGP and Aurora Grid Power mentions reuse one row (ADR 0120).
Ties and uncorroborated pairs stay unbound. No real organization names.

* docs: correct live Strix failure count

* fix: preserve raw organization ties before aliases

* docs: clarify alias resolution order

* fix: recheck organization evidence under lock

* fix: require exact SKOS alias matches

* fix: exclude full inferred ancestor path

* docs: align ancestor exclusion contract

* docs(adr): renumber SKOS alias-binding decision to avoid 0120 collision

PR #490 already claims ADR 0120 for an unrelated decision
(two-word database identifiers). Renumber this PR's ADR to 0158,
which is currently free across origin/main and the open PRs checked,
and update all cross-references (ARCHITECTURE.md, CHANGELOG.md,
CHANGELOG.d, ADR 0008, ADR 0012, and the two backend docstrings).

* docs: resolve SKOS ADR number collision

* feat(estimation): queued llm pair judging via durable batch routing (ADR 0200 point 5, no activation)

The llm channel joins the estimate without a single bulk synchronous
provider call: 'submit' samples pairs exactly as the deterministic
estimator does, takes a bounded deterministic stride subsample, submits
ONE contextual-orchestrator batch routing job (one request per pair,
caller-supplied custom_id=pair-<ordinal> -- the id round-trip landed
upstream as contextual-orchestrator #832; #829's Valkey registry makes
the job survive orchestrator restarts) and persists the run plus every
pair's deterministic scores into migration 0201's ledger, never
waiting. 'collect' polls once, maps returned scores to pairs by
custom_id only (never result order), persists each llm score durably
as it lands -- a killed collect loses nothing -- and fits the
4-channel expected-information estimate only over a complete run,
persisting it as channel_set_with_llm with full provenance. A refused
fit marks the run run_failed and writes no weights.

The adjudication prompt and confidence parsing move to shared helpers
(judge_prompt / parse_confidence) so the live client and the queued
scorer ask the byte-identical question. sample_pair_scores returns
pair labels again and the deterministic stride subsample returns for
the bounded llm pass. Migration 0201 live-validated (replay-idempotent,
judged-at/llm-score consistency check enforced, rollback round-trip).

Activation remains zero: the loader's authorized anchor set is empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015HgzNGsCxqiTaT4YuJEb5J

* feat(operability): structured server diagnostics behind the generic 503 (#577)

* feat(operability): structured server diagnostics behind the generic 503

Global Ask hid every failure behind a stable 503 (correct customer
boundary) but the cause never reached structured telemetry, and the
f-string leaked raw exception text to callers.

Split the ask handler into three classified paths, all returning the
same generic 503:
- HttpClientError/OSError -> known provider/transport fault; warning
  event orchestrator_provider_unavailable with operation code,
  correlation id, exception class; message deliberately not logged.
- KeyError/ValueError -> evidence-object contract break; error event
  orchestrator_internal_fault with stack trace attached.
- broad Exception -> unexpected defect; same internal-fault diagnostic
  so a programming regression cannot degrade into an opaque
  availability incident. Chaining is preserved on every path.

backend/app/operability.py documents the forbidden-field contract (no
prompt text, model output, bearer tokens, provider keys, tenant PII, or
post bodies in any record); alerting keys on event_type so pager load
separates provider-down from our-bug (issue #361). Unit tests cover
both event shapes, uniqueness of correlation ids, and the forbidden-
field guarantee without needing a live stack.

* feat(ontology): deterministic legacy-namespace migration tooling

ADR 0157 keeps the lowercase ontology namespace canonical and demoted
the repository-case spelling to deprecated compatibility status, but
rows written before the decision can still carry legacy IRIs in
post_project_mention.ontology_iri -- and RDF consumers treat the two
spellings as different resources.

scripts/migrate_legacy_namespace.py scans, prints every planned
rewrite, refuses unrecognized namespaces (fail closed rather than
bulk-mangle a third spelling), and only writes under --apply inside
one transaction guarded by the exact old IRI so a concurrent edit
aborts instead of double-applying. Provenance columns (extraction
method, confidence, evidence) are never touched per ADR 0157's
do-not-silently-rewrite rule.

Dry run is the default. 8 unit tests cover canonicalize mapping,
dry-run reporting, selective apply, fail-closed behavior, and the
clean-database no-op.

* fix(operability): redact exception messages from internal-fault tracebacks

Python renders a traceback's final line as 'ExceptionType: message', so
exc_info=exc violated the module's own forbidden-field contract: parsing
exceptions from orchestrator responses can embed provider payload or
prompt fragments, and those landed in the log. Re-emit through a
_MessageRedacted carrier that keeps the original __traceback__ (the
raise-site frames stay diagnosable) while its text is a fixed redaction
notice; the real class name is retained in the structured
exception_class field.

Also unify the three /api/ask 503 detail strings into one generic
message so callers cannot probe which internal classifier fired; the
provider-vs-contract-vs-defect distinction lives only in server-side
event_type (devin/coderabbit review threads on PR #577). Drop the
docstring's nonexistent include_message option.

---------

Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>

* feat: add bounded ontology and provenance explorer (v2.13.0) (#349)

* feat: add bounded ontology and provenance explorer

GET /api/ontology/neighborhood returns a typed Post/Person/CorporateEntity/Team
neighborhood with SKOS broader distinct from OWL subclass, truth-status
vocabulary, knowledge-cutoff binding, and no hidden-count side channels.
Buyers inspect it from the Keyman panel, not a second GNB destination
(ADR 0119 / #341).

* fix: keep ontology JSON-LD exports filtered

* fix: validate ontology neighborhood request bounds

* fix: escape ontology CSV formulas

* fix: make ontology layout ordering deterministic

* fix: make ontology explorer controls accessible

* fix: translate ontology post node labels

* fix: deduplicate ontology node evidence

* fix: drop unlabeled ontology edges

* fix: harden typed ontology neighborhood boundaries

* fix: bound ontology fact loading by requested edge limit

* fix: complete ontology explorer verification coverage

* fix: hide unauthorized ontology parents

* test: reproduce missing Storybook viewport addon

* fix: declare Storybook viewport addon

* fix: register Storybook viewport addon

* fix: use Storybook 10 built-in viewport feature

* fix: keep Storybook 10 essentials zero-config

* fix: declare Storybook built-in viewport options

* test: lock Storybook 10 built-in viewport contract

* fix: bind narrow ontology story to mobile viewport

* test: reproduce cutoff and uppercase focus defects

* fix: apply ontology cutoff before SQL limit

* test: require localized ontology stabilization copy

* feat: localize ontology paging and provenance guidance

* test: reproduce ontology paging and provenance UX defects

* fix: stabilize ontology paging and provenance UX

* test: reproduce bounded window and disconnected first page

* fix: preserve proximity and source truncation truth

* fix: expose bounded ontology source windows

* fix: keep ontology loader test doubles compatible

* test: reproduce duplicate ontology focus rule

* test: reproduce endpoint N+1 and shallow evidence expansion

* test: require a next action for hard ontology bounds

* test: require localized hard-bound guidance

* feat: localize hard ontology query-bound guidance

* fix: validate provider chat response envelopes

* fix: close remaining provider error leaks

* fix: normalize summary enrichment failures

* fix: normalize unexpected provider failures

* fix: keep TEPP and worker errors provider-safe

* fix: bound and batch ontology neighborhood evidence

* fix: preserve ontology visibility query compatibility

* fix: restore ontology explorer next actions

* fix: honor ontology cutoff and canonical focus UUIDs

* fix: read ontology visibility records safely

* fix: preserve provider parse failures and truncation

* fix: preserve ontology explorer session and parent visibility

Clear a loaded neighborhood when the session token is removed, hide live
refocus on static catalog snapshots, look up SKOS parent visibility
independently of the child, and fail closed on dangling exact-value
endpoints. OWL-Time is cited as a W3C Candidate Recommendation Draft.

* fix: authorize final ontology endpoints

* fix: preserve ontology evidence and stale diagnostics

* fix: make tenant settings migration replayable

* fix: harden ontology explorer paging and login return

* docs: sanitize baseline and record ontology gates

* fix: align provider changelog release

* test: assert ontology fact query limit argument

* feat: continue ontology neighborhoods with an opaque source cursor (v2.14.0) (#369)

* feat: continue ontology neighborhoods with an opaque source cursor

Add a versioned HMAC source-window cursor so authorized relations beyond
the bounded SQL window can be paged with keyset continuation, not OFFSET.
Tamper, scope, version, expiry, and snapshot drift fail closed. A missing
process secret keeps truncated-without-cursor. The explorer accumulates
pages without losing selected evidence (ADR 0124 / #363).

* fix: retain paged ontology relations

* fix: reuse expanded ontology window

* fix: seal ontology continuation windows

* docs: record ontology continuation safeguards

* test: assert continuation cursor anchor

* fix: deduplicate ontology source-window edges

* fix: preserve ontology source cursor ordering

* Fix ontology cursor after derived edge overflow

* fix(ontology): remove unused subclass IRI constant

* fix(frontend): preserve ontology pages on continuation failure

* test(frontend): retain ontology selection after page failure

* test(frontend): align rejected ontology fixture rows

* fix: make ontology page retries effective

* fix(security): replace custom source cursor cipher

* fix(security): keep aggregate SQL immutable

* test(security): preserve reviewed SQL contract

* fix(ontology): keep paging safe and stable

* fix: conceal ontology error details

* fix: distinguish full ontology page from truncation

* test: use one ontology ingestion import style

* fix(ontology-neighborhood): trim by BFS distance, not key string

assemble_ontology_neighborhood() trimmed nodes over maximum_nodes by
sorting the "node_type_code:node_id" key lexicographically instead of
by BFS/source-hop distance from the focus. Since "node_corporate_entity"
sorts before "node_post", farther nodes of an alphabetically-earlier
type survived while closer nodes of a later type were dropped, even
though truncated stayed true and evidence_count was still correct for
the wrong node set.

Sort the trim candidates by the same `reached` distance already
computed during BFS (and via source_hop_depth in the source-window
path), using the node key only as a tiebreaker. Adds a regression test
covering two node types straddling the maximum_nodes boundary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5M79L945DMyMs3sg5yJ14

* fix: allocate migration 0175 for ontology truth

* fix: restore ontology snapshot on reset

* fix: resolve silent ADR-0119 number collision from main merge

main independently claimed ADR 0119 for the leftover-map two-axis
distance change while this PR's own ADR 0119 (ontology/provenance
explorer) merged in cleanly as a separate file, since git diffs by
path and both files kept distinct names. Renumber this PR's ADR to
0168 (next free number after main's 0167) and update every
cross-reference (CHANGELOG, AGENTS.md, ARCHITECTURE.md, the 0175
migration comment, ontology_neighborhood.py/test/ingestion
docstrings, ADR 0124's predecessor link, and the ADR/storybook
README index). Main's own ADR 0119 file and its references are
untouched.

* fix: document the source-window page sort key closure

tests/test_public_docstrings.py's repository-wide public-docstring
contract walks every non-underscore-prefixed def, including nested
closures (matching flush/transport/stale_fallback elsewhere in this
codebase). source_page_sort_key was missing its docstring; add one
describing the cursor-key-first, hop/property/node-tuple-fallback
ordering it implements.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>

* fix: strip Keycloak OIDC callback params from the post share link (#576)

* fix: strip Keycloak OIDC callback params from the post share link

PostDetailPopup built its permanent link from window.location.href
verbatim, so sharing right after (or during) a Keycloak sign-in
redirect copied the raw code/state/session_state/iss query params
along with the post link instead of a clean permalink.

Add stripOidcCallbackParams (oidcReturnUrl.ts) and use it before
appending ?post=<id>, with unit tests covering both the strip and
the no-op case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GUcvhvimNVGqjFuD9f1fUa

* fix(oidc): strip callback params from restored return URLs too

stripOidcCallbackParams only cleaned share links, but restoreOidcReturnUrl
falls back to returnUrlFromLocation() on the post-redirect location --
which still carries code/state/session_state/iss. Strip them at build
time so no consumer of this module re-mints a URL with a one-time
authorization code in it; regression test covers the redirect-shaped
search string (devin review thread).

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>

* fix(ui-ux): give Event Lineage DAG node marks a 24x24px hit target (#554)

* fix(ui-ux): give Event Lineage DAG node marks a 24x24px hit target

The visible node mark in LineageDag.tsx is a 7px-radius (14px) SVG
circle with no separate hit area, well under the WCAG 2.2 SC 2.5.8 AA
minimum of 24x24 CSS px and this codebase's own --size-control-min
token (styles/tokens.css). Add a transparent, pointer-events:all
circle (r=12, matching --size-control-min at this DAG's ~1
user-unit-per-px scale) as the first child inside the existing
role="button" <g>, ahead of the visible mark, so it enlarges the
click/tap area without changing appearance. ROW_H is a 52px row
pitch (lineageLayout.ts), so a 24px-diameter hit circle leaves 28px
of clearance between adjacent rows -- no overlap.

Adds LineageDag.test.tsx asserting the hit circle is present, sized
>=24px diameter, transparent, and painted before the visible mark,
plus a click-still-works regression check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jWJzKUd82yy97esEBfJbt

* fix(ui-ux): keep the DAG hit circle invisible under the author CSS fill rule

The .lineage-dag-node circle rule paints every child circle with
--surface-muted plus a border stroke, and author CSS overrides the
fill="transparent" presentation attribute, so the enlarged hit target
rendered as a second opaque disc. Scope an explicit transparent/no-stroke
override to .lineage-dag-hit (devin review thread).

* fix(frontend): wire ADR 0109 return-url capture and narrow admin token

- The unauthenticated Log in handler now calls returnUrlFromLocation()
  then rememberOidcReturnUrl() before signinRedirect, per ADR 0109, so a
  shared /?post= link still opens that post after enterprise SSO. The
  previously unused oidcReturnUrl import is now load-bearing.
- AdminPanel renders only when accessToken is a string; the OIDC access
  token is string | undefined before isAuthenticated narrowing.

* fix(ui-ux): keep focus/hover/current rings off the DAG hit target

The aria-current ring rule (0,2,1) outranked the hit-circle override
(0,2,0) and painted a large hollow ring around the current node's mark.
Exclude .lineage-dag-hit from the focus/hover/current selectors via
:not(), which never matches the hit circle at all, and lift the
override to (0,3,0) so future circle-scoped emphasis rules lose by
specificity rather than source order (devin review thread).

* fix(ui-ux): exclude the DAG hit target from focus/hover/current rings

Equal-specificity source-order was still letting the aria-current ring
paint over the hit circle. :not(.lineage-dag-hit) on the emphasis
selectors never matches the hit target, closing the ring regression
for good.

* fix(ui-ux): pin the DAG svg to its user-unit width

width="100%" plus a viewBox let the container scale the coordinate
system, shrinking the effective 24px hit target below WCAG 2.5.8 on
wide containers. Render at the layout's own user-unit width (height
already behaves this way) so one SVG unit stays ~1 CSS px and the hit
radius keeps its intended size; horizontal overflow is handled by the
panel's existing scroll container.

* test(red): require scrollable lineage DAG viewport

* test(red): require mobile lineage scroll guidance

* fix(ui-ux): keep lineage DAG reachable in narrow viewports

* fix(ui-ux): style keyboard-scrollable lineage viewport

* test(storybook): cover mobile lineage scrolling

* docs(storybook): record mobile lineage scroll state

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>

* fix(review): connection-free fit in collect; errored judgments stay unjudged

Devin findings on #586: (1) _collect held its database connection open
across the model fit -- the exact idle-reap failure class #571 fixed --
now every phase (run lookup, ledger update, fit, persist) opens its own
short-lived connection and the fit runs with none. (2) An empty or
non-numeric batch answer parsed as a confident 0.0; the new
parse_confidence_or_none keeps the live client's semantics while the
queued scorer omits unparseable answers so an errored request stays
unjudged (pure helper judgment_updates_from_results, tested). Also:
collect gains --run-id for stale earlier runs, and the rebase onto the
updated ADR branch adopts main's chat_completion_content safe extractor
inside the shared-helper judge().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015HgzNGsCxqiTaT4YuJEb5J

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>
seonghobae added a commit that referenced this pull request Aug 24, 2026
… two active lines (#574)

* docs(adr): propose ADR 0200 reconciling channel-weight measurement across lines

The two active lines carry ADR 0145 with opposite decisions (estimation
active + constants deleted vs. rejection + constants retained), on
divergent lineage_channel_weight schemas. ADR 0200 keeps the operator
directive (no hand-picked weight anywhere), upgrades the estimator to
expected-information weighting over the fitted latent distribution
(answering the theta-conditionality critique), adds an anchor-honesty
label plus a TEPP criterion-validity gate (answering the
criterion-validity critique, amending ADR 0003 explicitly), merges the
two schemas as a union, and moves llm pair scoring to the durable
Valkey queue idiom per the operator's no-bulk-synchronous-LLM
directive and issue #289.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015HgzNGsCxqiTaT4YuJEb5J

* feat(estimation): expected-information channel weights (ADR 0200 point 2, no activation)

Replaces main's unconditional-refusal estimation stub with the full
estimator ADR 0200 specifies: MLS2PLM over dichotomized channel scores
with multilevel cluster intercepts, weights = normalized EXPECTED item
information over the fitted latent distribution, computed on the fitted
person parameters with the package's own predict_proba -- answering the
theta-conditionality critique in main's ADR 0145 rejection instead of
ignoring it. Non-converged fits are rejected outright
(convergence_status must be 'converged'). Method code:
mls2plm_expected_information.

NOTHING ACTIVATES: the product loader's authorized anchor set remains
empty, so persisted-weight activation still fails closed exactly as
main's ADR 0145 requires -- this stage lands the estimator and its
parameter-recovery evidence only. The demo generative design's follow
probabilities are re-declared (0.80/0.72/0.66) so the fixture estimate
preserves the designed A-100 demo fork under expected-information
weighting; the estimate over that design converges and recovers the
declared ordering.

Supersedes main's test_unanchored_channel_scores_never_run_a_fit by
design: running the fit is now permitted, activation is not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015HgzNGsCxqiTaT4YuJEb5J

* feat(estimation): schema union + provenance persistence (ADR 0200 point 4, no activation)

Migration 0200 unions the two lines' lineage_channel_weight schemas:
primary key (channel_set_code, channel_code) -- one persisted set per
active-channel combination -- carrying main's full per-run provenance
contract and integrity constraints. Validated live against BOTH
predecessor shapes (main's 0135 provenance table and the
customer-master line's 0135+0136 set table), replay-idempotent per the
ADR 0166 window, with a rollback that restores main's single-set
shape. Pre-provenance rows are deleted, not backfilled with invented
provenance -- the loader refuses them either way and re-estimation is
the operator's next action.

The loader becomes set-aware (exact active-channel match of exactly
one set) while keeping every provenance-integrity gate; a pre-0200
schema is probed via the catalog (never a failing statement that would
abort the caller's transaction) and read as the single implicit
deterministic set. ACTIVATION IS UNCHANGED: the authorized anchor set
stays empty, so every loaded vector is still refused.

The operator script becomes the full estimator front-end: fetch on one
short-lived connection, no connection held while fitting, persist with
full provenance (fresh run uuid, installed estimator version, honest
anchor_method_code=unanchored_internal_structure, reproducible
source-snapshot digest over the ordered sampled rows, knowledge cutoff
= max sampled created_at) on a fresh connection. Its report names
activation as blocked until an anchor is authorized. The llm channel
is deliberately absent -- bulk synchronous provider calls are banned;
llm scoring arrives with the queued worker (ADR 0200 point 5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015HgzNGsCxqiTaT4YuJEb5J

* fix(estimation): raise mmle max_iter so the CPU fallback actually converges

fast-mlsirm's FitConfig default (max_iter=1000) is tuned against its
GPU/f32 kernel; the f64 CPU fallback -- the only path available on any
CI runner, since none expose a GPU -- needs materially more EM
iterations to reach the same optimum at full precision (observed up to
~1850 on this module's own recovery fixture). Confirmed locally by
forcing rust_device="cpu": both channel-weight recovery tests reliably
hit max_iter_reached at the old default and reliably converge at 3000.

* feat(estimation): queued llm pair judging via durable batch routing (ADR 0200 point 5, no activation) (#586)

* fix: detach provider parse error context (#393)

* fix: detach provider parse error context

* fix(frontend): restore authenticated admin boundary

* fix(frontend): use safe OIDC return URL

* fix: close provider transport error boundary

* fix: hide raw TEPP transport failures

* fix: restore exception chaining at HTTP transport/parse boundary

ADR 0123 keeps the original exception as an in-process chained cause
for operator logging while the buyer-facing message stays generic.
The transport_error/contentless-raise idiom in _request,
_decode_json, and configured_tepp_client's transport() dropped both
__cause__ and __context__ on every failure mode (TLS errors, DNS
failures, timeouts, malformed JSON), erasing the diagnostic trail an
operator needs from server-side logs. Restore `raise ... from exc` at
all three sites; the exposed message text is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5M79L945DMyMs3sg5yJ14

* docs: remove nonexistent provider-boundary ADR citations

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>

* feat: persist leftover-map axis share on period reports (v2.12.16) (#519)

Gabriel inertia of residual SVD axes 1 and 2 (σ²/Σσ²) is a report-level
3NF slice next to leftover pairs (ADR 0148). Rank-0 residuals emit two
zero-share axes. Do not invent a leftover score.

Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>

* feat(ask): move Global Ask behind a durable Valkey job queue (#572)

* feat(ask): move Global Ask behind a durable Valkey job queue

A live Ask answer is a multi-minute orchestrator LLM round-trip under
shared-gateway load (158 s measured); serving it inside one blocking
HTTP request pinned connections and timed out every client. POST
/api/ask now persists a global_ask_job row (migration 0165), wakes an
in-process worker over the global_ask_request_stream Valkey stream
(the post_content_queue idiom: durable row is truth, stream entry is a
wake-up, queued rows republish after 60 s), and returns 202 with a job
id. GET /api/ask/jobs/{id} is owner-scoped (404 hides existence) and
returns the settled answer payload. The worker reloads the account's
ABAC visibility at processing time so revocations between submit and
processing are honored. The frontend submits then polls with an
unchanged askAgent signature; e2e deadlines now match the async
reality and the temporal e2e question targets the seeded 2026-01
window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* docs: docstring for the worker-side ABAC predicate

The repo's docstring-coverage gate (tests/test_public_docstrings.py)
covers nested defs too; the can_see predicate in
compute_global_ask_answer was the one uncovered definition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* fix(ask): bounded-concurrency worker, per-job deadline, orphan recovery

The live e2e run surfaced two queue defects the unit tests could not:
the consumer awaited each job inline, so one slow answer head-of-line
blocked every question behind it (observed: a 17-minute job starving a
queued one), and a hung orchestrator round-trip kept a job running
indefinitely with no recovery for rows orphaned by a mid-job crash.

The worker now dispatches jobs as concurrent tasks behind a
4-slot semaphore (the queued->running claim already makes duplicate
wake-ups no-ops, so at-least-once semantics hold), every job runs under
a 600 s asyncio.wait_for deadline that settles it as failed, and the
recovery sweep re-queues running rows older than deadline+300 s —
which a live worker can never legitimately produce. e2e deadlines are
sized to the measured contended settle (480 s) plus setup slack for a
cold, loaded host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* fix(ask): size the answer client timeout to the job deadline

The orchestrator was observed completing an answer and hitting
BrokenPipe writing it back: the chat client's 180 s default socket
timeout hung up first, discarding an answer already paid for and
failing the job. The Ask client now uses
ORCHESTRATOR_ANSWER_TIMEOUT_SECONDS (default 570 s) — just under the
worker's 600 s job deadline, so the client ends a slow call before the
reaper does and a generated answer has the whole deadline to arrive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* test(e2e): size the answer deadline to the 570 s client timeout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* fix(ask): restate the resolved relative-time window in the prompt

Retrieval already scopes sources to the resolved window ('7개월 전' →
2026-01-01..2026-01-31), but the numbered sources carry no dates, so
the model answered the temporal e2e question with 'no date information'
and cited nothing (observed live: 4 in-window sources, zero citations).
The worker now appends the resolved window to the question and states
that every provided source falls inside it, grounding the answer in the
evidence it was given.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* feat(seed): warm post-content ingestion for seeded posts

Seeded posts are inserted straight into Postgres, but post-content
extraction (units, embeddings, embedded images) only enqueues when a
post's /content endpoint is first served — so a fresh stack had an
empty image pipeline and image citation had nothing to cite until a
human happened to open the right post. The seed now opens each seeded
post once through the API with the demo reader account, replaying the
exact production enqueue path instead of duplicating its SQL.
Verified live: the seeded TIFF was extracted and vision-captioned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* fix(ask): anchor today's date when restating a resolved time window

The first grounding clause named only the resolved window, and the
model read that window as the reference point ('now') — re-subtracting
the offset and answering that '7개월 전' meant mid-2025. The clause now
states today's date and equates the relative expression to the window
outright, then directs the answer at the provided in-window sources.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* test(e2e): ask questions the seeded corpus can answer decisively

'What happened between these events?' has no referent and 'Which
project?' names nothing — a careful model correctly declines both and
cites nothing, so the suite depended on a bluffing model to pass. The
questions now target seeded content directly (the Westfield Power
specification thread, the synthetic raster evidence post, Ada West's
initial site visit), so a model answering from the sources must cite
them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* fix(ask): settle a job on any failure, not a named few

An exception outside the settlement handler's tuple (first exercised
live when citation assembly ran against real image rows) killed the
worker task silently and stranded the row running until orphan
recovery re-queued it 15 minutes later. Settlement is fail-closed now:
any exception settles the job failed; CancelledError still propagates
so shutdown leaves the row for recovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* chore(ask): single-literal SQL statements for the Semgrep asyncpg rule

The asyncpg-sqli rule flags adjacent-literal SQL concatenation as
non-literal string building even with fully parameterized values (two
blocking findings on the recovery sweep). Every statement in the module
is now one triple-quoted literal, so there is no concatenation for the
rule to misread and nothing to suppress.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* chore(ask): express recovery windows without make_interval

The Semgrep asyncpg-sqli rule still flagged the recovery statements
after the single-literal rewrite; the trigger is the
make_interval(secs => $n) call syntax, which its matcher reads as
non-literal string building. interval '1 second' * $n is the same
parameterized arithmetic without the construct the rule misparses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* chore(ask): suppress the asyncpg-sqli false positive on recovery SQL

Both recovery statements are fully parameterized; the rule misreads
the literal-plus-constant-arguments shape regardless of literal style
or interval syntax (verified across three rewrites). Suppressed with
the same annotation the repo already carries in report_ingestion.py
and customer_hint_ingestion.py for this exact rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

* fix(ask): apply PR-572 review batch across queue, config, seed, e2e

- Bound the wake-up stream (maxlen 1000, approximate) at both XADD
  sites and swallow a failed wake-up publish in enqueue: the committed
  row is truth and the sweep republishes within a minute, so the caller
  keeps its pollable job id instead of a 500.
- Share the 600 s job deadline through config
  (GLOBAL_ASK_JOB_DEADLINE_SECONDS) and validate
  ORCHESTRATOR_ANSWER_TIMEOUT_SECONDS as finite and strictly inside
  (0, deadline) so the client-hangs-up-first ordering cannot be
  misconfigured away.
- Tighten orphan recovery to deadline+60 s so a crashed worker's job
  returns to the queue while a reader is still polling; raise the
  frontend poll ceiling to 15 min to cover queue wait plus deadline.
- Resolve one Seoul reference date per job and use it for both
  retrieval and the grounded prompt (midnight-boundary consistency);
  gather_global_chat_sources accepts the pinned date.
- Give only the Ask worker the long answer timeout; the synchronous
  per-post chat endpoint keeps the interactive client default.
- Harden the worker loop: one failed consume/recovery round logs and
  retries instead of silently ending Ask consumption.
- e2e computes the months-ago offset from the real clock so the
  temporal question keeps resolving onto the seeded 2026-01 window in
  any month.
- Seed waits for backend /healthz before warming, and closes its
  psycopg2 connection explicitly.
- Drop imports left dead by the Ask logic move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

---------

Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* chore: remove one-shot patch scripts left from PR #347 development (#575)

add_translations.py, patch_api.py, patch_app_fetch.py,
patch_app_order.py, and patch_main.py were single-use edit scripts
that already applied their changes; nothing in the Makefile, CI, or
runtime references them, and leaving dead top-level scripts invites
someone to run them twice.


Claude-Session: https://claude.ai/code/session_01VENX71RtEntaUq6nkAWZho

Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix: bootstrap repo-root sys.path in the two operator scripts missing it (#570)

backfill_post_keymen.py and requeue_failed_post_content.py were the only
scripts/ entries without the repo-root sys.path insert their siblings use,
so `python scripts/<name>.py` failed with ModuleNotFoundError: backend
unless a caller manually exported PYTHONPATH=. first. Match the existing
pattern instead of documenting the workaround.

Found while re-running backfill_post_keymen.py to refresh a stale
reason_no_live_client role row now that the local orchestrator/searxng
services are reachable.

* fix(ui-ux): size secondary details/summary toggles to --size-control-min (#560)

* fix(ui-ux): size secondary details/summary toggles to --size-control-min

Several disclosure toggles (advanced review tools, evidence-operation
actions, semantic provenance, source-author hint context) rendered with
a browser-default <summary> well under the project's 24px
--size-control-min touch target, the same convention already used by
.language-switcher select and .lineage-entity-picker select.

- Add a shared App.css rule giving .advanced-review-tools,
  .semantic-provenance, .operator-action-tools, .keyman-source-context,
  and the new .hint-disclosure class an inline-flex summary with
  min-height: var(--size-control-min) and horizontal padding, so the
  hit target isn't text-width-only.
- .operator-action-tools and .keyman-source-context previously had zero
  CSS rules at all -- not just a missing min-height.
- The three bare <details> "Related posts"/"Hint only" expanders in
  CustomerMasterPanel had no className, so there was no selector able
  to reach them; give them className="hint-disclosure".

Tests: frontend/src/styles/tokens.test.ts pins the new selector list
and asserts the rule uses var(--size-control-min) plus horizontal
padding, not a bespoke value. frontend/src/App.test.tsx adds a render
test asserting the three previously-bare <details> carry the
.hint-disclosure class. Both were confirmed to fail before this change
and pass after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jWJzKUd82yy97esEBfJbt

* fix(ui-ux): keep disclosure markers on secondary summary toggles

display: inline-flex replaced the summary list-item box, which stops
browsers from rendering the built-in expand/collapse marker. Reach the
--size-control-min touch target with min-height and padding instead so
the native affordance stays visible (devin review thread).

* fix(frontend): wire ADR 0109 return-url capture and narrow admin token

- The unauthenticated Log in handler now calls returnUrlFromLocation()
  then rememberOidcReturnUrl() before signinRedirect, per ADR 0109, so a
  shared /?post= link still opens that post after enterprise SSO. The
  previously unused oidcReturnUrl import is now load-bearing.
- AdminPanel renders only when accessToken is a string; the OIDC access
  token is string | undefined before isAuthenticated narrowing.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>

* fix(ui-ux): expose bare loading text as live regions (#558)

* fix(a11y): expose bare loading text as live regions

Async loading placeholders in App.tsx and FiveW1H.tsx rendered as
plain <p> text with no role/aria-live, so assistive tech never
announced the transition from "Loading..." to the resolved content.
This included the app-root auth gate every session passes through,
and the post-detail popup's loading paragraph.

Add role="status" to every bare loading <p> (19 in App.tsx, 1 in
FiveW1H.tsx), matching the role="status" pattern already used
elsewhere in the same files (e.g. "Loading posts...",
SourceResearchPanel). No behavior change beyond the attribute.

Tests: add an RTL assertion that the auth-loading gate and the
post-detail popup's loading state are exposed via getByRole("status")
rather than only getByText, extending stubBackend with a
deferPostOne option to deterministically observe the popup's
pre-resolution state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jWJzKUd82yy97esEBfJbt

* fix(frontend): wire ADR 0109 return-url capture and narrow admin token

- The unauthenticated Log in handler now calls returnUrlFromLocation()
  then rememberOidcReturnUrl() before signinRedirect, per ADR 0109, so a
  shared /?post= link still opens that post after enterprise SSO. The
  previously unused oidcReturnUrl import is now load-bearing.
- AdminPanel renders only when accessToken is a string; the OIDC access
  token is string | undefined before isAuthenticated narrowing.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>

* fix(ui-ux): grow citation chip to a 24px touch target (#556)

* fix(ui-ux): grow citation chip to a 24px touch target

.citation-chip rendered ~19px tall on mobile (0.1rem block padding via
--space-chip-block, no min-height), below the 24px minimum touch target.
Add min-height: var(--size-control-min), matching the convention already
used by .language-switcher select and .lineage-entity-picker select.
display: inline-flex + align-items: center keep the button's text
vertically centered in the taller box, since (unlike the label-based
board-option rules) this is a bare <button>.

Pins the fix with a regression test in tokens.test.ts that extracts the
.citation-chip rule from App.css and asserts min-height/inline-flex are
present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jWJzKUd82yy97esEBfJbt

* fix(frontend): wire ADR 0109 return-url capture and narrow admin token

- The unauthenticated Log in handler now calls returnUrlFromLocation()
  then rememberOidcReturnUrl() before signinRedirect, per ADR 0109, so a
  shared /?post= link still opens that post after enterprise SSO. The
  previously unused oidcReturnUrl import is now load-bearing.
- AdminPanel renders only when accessToken is a string; the OIDC access
  token is string | undefined before isAuthenticated narrowing.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>

* fix: shorten the orchestrator healthcheck's retry budget (#547)

* fix: shorten the orchestrator healthcheck's retry budget

10 retries at a 5s interval matches every other service's healthcheck
(postgres, valkey, searxng all use retries: 10). The orchestrator was
the one outlier at 20 retries -- a 100s startup grace period for a
liveness-only check (does no real work beyond confirming the process is
up) versus 50s everywhere else, with no documented reason for the gap.

* fix(compose): give the orchestrator healthcheck a 50s warm-up window

retries: 10 alone halves cold-boot grace to ~50s, which could fail the
backend depends_on gate on slow hosts. start_period: 50s keeps failures
during warm-up outside the retry budget: a booting orchestrator still
gets ~100s total while a genuinely dead one trips the gate in ~50s
(devin review thread).

* docs(compose): correct the healthcheck warm-up timing comment

start_period: 50s + retries: 10 x 5s means a dead service trips at
~100s total (not ~50s): identical to the old retries: 20 budget. The
actual win is that a booting orchestrator no longer consumes retry
budget during warm-up (devin review thread).

---------

Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>

* feat: name leftover-map unexplained leftover on leftover pairs (v2.12.26) (#535)

* feat: name leftover-map unexplained leftover on leftover pairs (v2.12.26)

Persist unexplained leftover U = R − R̂ after two-axis Gabriel reconstruction
so a leftover residual is not read as the leftover the map does not reconstruct.
R̂ stays internal and is not persisted. Rank-0 maps store U = 0; fallback pairs
omit U. After make seed, closest and farthest leftover pairs sit above the
member list with U next to leftover-map distance d; click opens that post.

* fix(frontend): remember OIDC return URL and drop unreachable AdminPanel render

Login screen built returnUrl via raw string concatenation instead of
returnUrlFromLocation()/rememberOidcReturnUrl(), leaving both imports
unused (TS6192) and rendering an AdminPanel with accessToken: undefined
(TS2322) on a branch guarded by !auth.isAuthenticated.

---------

Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>

* feat: bind corroborated SKOS org aliases to one catalog row (#480)

* docs: restore non-identifying gap baseline

* fix(frontend): restore authenticated admin boundary

* feat: bind corroborated SKOS org aliases to one catalog row

Expand corporate_entity candidates with search-verified alt/pref labels
so synthetic AGP and Aurora Grid Power mentions reuse one row (ADR 0120).
Ties and uncorroborated pairs stay unbound. No real organization names.

* docs: correct live Strix failure count

* fix: preserve raw organization ties before aliases

* docs: clarify alias resolution order

* fix: recheck organization evidence under lock

* fix: require exact SKOS alias matches

* fix: exclude full inferred ancestor path

* docs: align ancestor exclusion contract

* docs(adr): renumber SKOS alias-binding decision to avoid 0120 collision

PR #490 already claims ADR 0120 for an unrelated decision
(two-word database identifiers). Renumber this PR's ADR to 0158,
which is currently free across origin/main and the open PRs checked,
and update all cross-references (ARCHITECTURE.md, CHANGELOG.md,
CHANGELOG.d, ADR 0008, ADR 0012, and the two backend docstrings).

* docs: resolve SKOS ADR number collision

* feat(estimation): queued llm pair judging via durable batch routing (ADR 0200 point 5, no activation)

The llm channel joins the estimate without a single bulk synchronous
provider call: 'submit' samples pairs exactly as the deterministic
estimator does, takes a bounded deterministic stride subsample, submits
ONE contextual-orchestrator batch routing job (one request per pair,
caller-supplied custom_id=pair-<ordinal> -- the id round-trip landed
upstream as contextual-orchestrator #832; #829's Valkey registry makes
the job survive orchestrator restarts) and persists the run plus every
pair's deterministic scores into migration 0201's ledger, never
waiting. 'collect' polls once, maps returned scores to pairs by
custom_id only (never result order), persists each llm score durably
as it lands -- a killed collect loses nothing -- and fits the
4-channel expected-information estimate only over a complete run,
persisting it as channel_set_with_llm with full provenance. A refused
fit marks the run run_failed and writes no weights.

The adjudication prompt and confidence parsing move to shared helpers
(judge_prompt / parse_confidence) so the live client and the queued
scorer ask the byte-identical question. sample_pair_scores returns
pair labels again and the deterministic stride subsample returns for
the bounded llm pass. Migration 0201 live-validated (replay-idempotent,
judged-at/llm-score consistency check enforced, rollback round-trip).

Activation remains zero: the loader's authorized anchor set is empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015HgzNGsCxqiTaT4YuJEb5J

* feat(operability): structured server diagnostics behind the generic 503 (#577)

* feat(operability): structured server diagnostics behind the generic 503

Global Ask hid every failure behind a stable 503 (correct customer
boundary) but the cause never reached structured telemetry, and the
f-string leaked raw exception text to callers.

Split the ask handler into three classified paths, all returning the
same generic 503:
- HttpClientError/OSError -> known provider/transport fault; warning
  event orchestrator_provider_unavailable with operation code,
  correlation id, exception class; message deliberately not logged.
- KeyError/ValueError -> evidence-object contract break; error event
  orchestrator_internal_fault with stack trace attached.
- broad Exception -> unexpected defect; same internal-fault diagnostic
  so a programming regression cannot degrade into an opaque
  availability incident. Chaining is preserved on every path.

backend/app/operability.py documents the forbidden-field contract (no
prompt text, model output, bearer tokens, provider keys, tenant PII, or
post bodies in any record); alerting keys on event_type so pager load
separates provider-down from our-bug (issue #361). Unit tests cover
both event shapes, uniqueness of correlation ids, and the forbidden-
field guarantee without needing a live stack.

* feat(ontology): deterministic legacy-namespace migration tooling

ADR 0157 keeps the lowercase ontology namespace canonical and demoted
the repository-case spelling to deprecated compatibility status, but
rows written before the decision can still carry legacy IRIs in
post_project_mention.ontology_iri -- and RDF consumers treat the two
spellings as different resources.

scripts/migrate_legacy_namespace.py scans, prints every planned
rewrite, refuses unrecognized namespaces (fail closed rather than
bulk-mangle a third spelling), and only writes under --apply inside
one transaction guarded by the exact old IRI so a concurrent edit
aborts instead of double-applying. Provenance columns (extraction
method, confidence, evidence) are never touched per ADR 0157's
do-not-silently-rewrite rule.

Dry run is the default. 8 unit tests cover canonicalize mapping,
dry-run reporting, selective apply, fail-closed behavior, and the
clean-database no-op.

* fix(operability): redact exception messages from internal-fault tracebacks

Python renders a traceback's final line as 'ExceptionType: message', so
exc_info=exc violated the module's own forbidden-field contract: parsing
exceptions from orchestrator responses can embed provider payload or
prompt fragments, and those landed in the log. Re-emit through a
_MessageRedacted carrier that keeps the original __traceback__ (the
raise-site frames stay diagnosable) while its text is a fixed redaction
notice; the real class name is retained in the structured
exception_class field.

Also unify the three /api/ask 503 detail strings into one generic
message so callers cannot probe which internal classifier fired; the
provider-vs-contract-vs-defect distinction lives only in server-side
event_type (devin/coderabbit review threads on PR #577). Drop the
docstring's nonexistent include_message option.

---------

Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>

* feat: add bounded ontology and provenance explorer (v2.13.0) (#349)

* feat: add bounded ontology and provenance explorer

GET /api/ontology/neighborhood returns a typed Post/Person/CorporateEntity/Team
neighborhood with SKOS broader distinct from OWL subclass, truth-status
vocabulary, knowledge-cutoff binding, and no hidden-count side channels.
Buyers inspect it from the Keyman panel, not a second GNB destination
(ADR 0119 / #341).

* fix: keep ontology JSON-LD exports filtered

* fix: validate ontology neighborhood request bounds

* fix: escape ontology CSV formulas

* fix: make ontology layout ordering deterministic

* fix: make ontology explorer controls accessible

* fix: translate ontology post node labels

* fix: deduplicate ontology node evidence

* fix: drop unlabeled ontology edges

* fix: harden typed ontology neighborhood boundaries

* fix: bound ontology fact loading by requested edge limit

* fix: complete ontology explorer verification coverage

* fix: hide unauthorized ontology parents

* test: reproduce missing Storybook viewport addon

* fix: declare Storybook viewport addon

* fix: register Storybook viewport addon

* fix: use Storybook 10 built-in viewport feature

* fix: keep Storybook 10 essentials zero-config

* fix: declare Storybook built-in viewport options

* test: lock Storybook 10 built-in viewport contract

* fix: bind narrow ontology story to mobile viewport

* test: reproduce cutoff and uppercase focus defects

* fix: apply ontology cutoff before SQL limit

* test: require localized ontology stabilization copy

* feat: localize ontology paging and provenance guidance

* test: reproduce ontology paging and provenance UX defects

* fix: stabilize ontology paging and provenance UX

* test: reproduce bounded window and disconnected first page

* fix: preserve proximity and source truncation truth

* fix: expose bounded ontology source windows

* fix: keep ontology loader test doubles compatible

* test: reproduce duplicate ontology focus rule

* test: reproduce endpoint N+1 and shallow evidence expansion

* test: require a next action for hard ontology bounds

* test: require localized hard-bound guidance

* feat: localize hard ontology query-bound guidance

* fix: validate provider chat response envelopes

* fix: close remaining provider error leaks

* fix: normalize summary enrichment failures

* fix: normalize unexpected provider failures

* fix: keep TEPP and worker errors provider-safe

* fix: bound and batch ontology neighborhood evidence

* fix: preserve ontology visibility query compatibility

* fix: restore ontology explorer next actions

* fix: honor ontology cutoff and canonical focus UUIDs

* fix: read ontology visibility records safely

* fix: preserve provider parse failures and truncation

* fix: preserve ontology explorer session and parent visibility

Clear a loaded neighborhood when the session token is removed, hide live
refocus on static catalog snapshots, look up SKOS parent visibility
independently of the child, and fail closed on dangling exact-value
endpoints. OWL-Time is cited as a W3C Candidate Recommendation Draft.

* fix: authorize final ontology endpoints

* fix: preserve ontology evidence and stale diagnostics

* fix: make tenant settings migration replayable

* fix: harden ontology explorer paging and login return

* docs: sanitize baseline and record ontology gates

* fix: align provider changelog release

* test: assert ontology fact query limit argument

* feat: continue ontology neighborhoods with an opaque source cursor (v2.14.0) (#369)

* feat: continue ontology neighborhoods with an opaque source cursor

Add a versioned HMAC source-window cursor so authorized relations beyond
the bounded SQL window can be paged with keyset continuation, not OFFSET.
Tamper, scope, version, expiry, and snapshot drift fail closed. A missing
process secret keeps truncated-without-cursor. The explorer accumulates
pages without losing selected evidence (ADR 0124 / #363).

* fix: retain paged ontology relations

* fix: reuse expanded ontology window

* fix: seal ontology continuation windows

* docs: record ontology continuation safeguards

* test: assert continuation cursor anchor

* fix: deduplicate ontology source-window edges

* fix: preserve ontology source cursor ordering

* Fix ontology cursor after derived edge overflow

* fix(ontology): remove unused subclass IRI constant

* fix(frontend): preserve ontology pages on continuation failure

* test(frontend): retain ontology selection after page failure

* test(frontend): align rejected ontology fixture rows

* fix: make ontology page retries effective

* fix(security): replace custom source cursor cipher

* fix(security): keep aggregate SQL immutable

* test(security): preserve reviewed SQL contract

* fix(ontology): keep paging safe and stable

* fix: conceal ontology error details

* fix: distinguish full ontology page from truncation

* test: use one ontology ingestion import style

* fix(ontology-neighborhood): trim by BFS distance, not key string

assemble_ontology_neighborhood() trimmed nodes over maximum_nodes by
sorting the "node_type_code:node_id" key lexicographically instead of
by BFS/source-hop distance from the focus. Since "node_corporate_entity"
sorts before "node_post", farther nodes of an alphabetically-earlier
type survived while closer nodes of a later type were dropped, even
though truncated stayed true and evidence_count was still correct for
the wrong node set.

Sort the trim candidates by the same `reached` distance already
computed during BFS (and via source_hop_depth in the source-window
path), using the node key only as a tiebreaker. Adds a regression test
covering two node types straddling the maximum_nodes boundary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5M79L945DMyMs3sg5yJ14

* fix: allocate migration 0175 for ontology truth

* fix: restore ontology snapshot on reset

* fix: resolve silent ADR-0119 number collision from main merge

main independently claimed ADR 0119 for the leftover-map two-axis
distance change while this PR's own ADR 0119 (ontology/provenance
explorer) merged in cleanly as a separate file, since git diffs by
path and both files kept distinct names. Renumber this PR's ADR to
0168 (next free number after main's 0167) and update every
cross-reference (CHANGELOG, AGENTS.md, ARCHITECTURE.md, the 0175
migration comment, ontology_neighborhood.py/test/ingestion
docstrings, ADR 0124's predecessor link, and the ADR/storybook
README index). Main's own ADR 0119 file and its references are
untouched.

* fix: document the source-window page sort key closure

tests/test_public_docstrings.py's repository-wide public-docstring
contract walks every non-underscore-prefixed def, including nested
closures (matching flush/transport/stale_fallback elsewhere in this
codebase). source_page_sort_key was missing its docstring; add one
describing the cursor-key-first, hop/property/node-tuple-fallback
ordering it implements.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>

* fix: strip Keycloak OIDC callback params from the post share link (#576)

* fix: strip Keycloak OIDC callback params from the post share link

PostDetailPopup built its permanent link from window.location.href
verbatim, so sharing right after (or during) a Keycloak sign-in
redirect copied the raw code/state/session_state/iss query params
along with the post link instead of a clean permalink.

Add stripOidcCallbackParams (oidcReturnUrl.ts) and use it before
appending ?post=<id>, with unit tests covering both the strip and
the no-op case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GUcvhvimNVGqjFuD9f1fUa

* fix(oidc): strip callback params from restored return URLs too

stripOidcCallbackParams only cleaned share links, but restoreOidcReturnUrl
falls back to returnUrlFromLocation() on the post-redirect location --
which still carries code/state/session_state/iss. Strip them at build
time so no consumer of this module re-mints a URL with a one-time
authorization code in it; regression test covers the redirect-shaped
search string (devin review thread).

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>

* fix(ui-ux): give Event Lineage DAG node marks a 24x24px hit target (#554)

* fix(ui-ux): give Event Lineage DAG node marks a 24x24px hit target

The visible node mark in LineageDag.tsx is a 7px-radius (14px) SVG
circle with no separate hit area, well under the WCAG 2.2 SC 2.5.8 AA
minimum of 24x24 CSS px and this codebase's own --size-control-min
token (styles/tokens.css). Add a transparent, pointer-events:all
circle (r=12, matching --size-control-min at this DAG's ~1
user-unit-per-px scale) as the first child inside the existing
role="button" <g>, ahead of the visible mark, so it enlarges the
click/tap area without changing appearance. ROW_H is a 52px row
pitch (lineageLayout.ts), so a 24px-diameter hit circle leaves 28px
of clearance between adjacent rows -- no overlap.

Adds LineageDag.test.tsx asserting the hit circle is present, sized
>=24px diameter, transparent, and painted before the visible mark,
plus a click-still-works regression check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jWJzKUd82yy97esEBfJbt

* fix(ui-ux): keep the DAG hit circle invisible under the author CSS fill rule

The .lineage-dag-node circle rule paints every child circle with
--surface-muted plus a border stroke, and author CSS overrides the
fill="transparent" presentation attribute, so the enlarged hit target
rendered as a second opaque disc. Scope an explicit transparent/no-stroke
override to .lineage-dag-hit (devin review thread).

* fix(frontend): wire ADR 0109 return-url capture and narrow admin token

- The unauthenticated Log in handler now calls returnUrlFromLocation()
  then rememberOidcReturnUrl() before signinRedirect, per ADR 0109, so a
  shared /?post= link still opens that post after enterprise SSO. The
  previously unused oidcReturnUrl import is now load-bearing.
- AdminPanel renders only when accessToken is a string; the OIDC access
  token is string | undefined before isAuthenticated narrowing.

* fix(ui-ux): keep focus/hover/current rings off the DAG hit target

The aria-current ring rule (0,2,1) outranked the hit-circle override
(0,2,0) and painted a large hollow ring around the current node's mark.
Exclude .lineage-dag-hit from the focus/hover/current selectors via
:not(), which never matches the hit circle at all, and lift the
override to (0,3,0) so future circle-scoped emphasis rules lose by
specificity rather than source order (devin review thread).

* fix(ui-ux): exclude the DAG hit target from focus/hover/current rings

Equal-specificity source-order was still letting the aria-current ring
paint over the hit circle. :not(.lineage-dag-hit) on the emphasis
selectors never matches the hit target, closing the ring regression
for good.

* fix(ui-ux): pin the DAG svg to its user-unit width

width="100%" plus a viewBox let the container scale the coordinate
system, shrinking the effective 24px hit target below WCAG 2.5.8 on
wide containers. Render at the layout's own user-unit width (height
already behaves this way) so one SVG unit stays ~1 CSS px and the hit
radius keeps its intended size; horizontal overflow is handled by the
panel's existing scroll container.

* test(red): require scrollable lineage DAG viewport

* test(red): require mobile lineage scroll guidance

* fix(ui-ux): keep lineage DAG reachable in narrow viewports

* fix(ui-ux): style keyboard-scrollable lineage viewport

* test(storybook): cover mobile lineage scrolling

* docs(storybook): record mobile lineage scroll state

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>

* fix(review): connection-free fit in collect; errored judgments stay unjudged

Devin findings on #586: (1) _collect held its database connection open
across the model fit -- the exact idle-reap failure class #571 fixed --
now every phase (run lookup, ledger update, fit, persist) opens its own
short-lived connection and the fit runs with none. (2) An empty or
non-numeric batch answer parsed as a confident 0.0; the new
parse_confidence_or_none keeps the live client's semantics while the
queued scorer omits unparseable answers so an errored request stays
unjudged (pure helper judgment_updates_from_results, tested). Also:
collect gains --run-id for stale earlier runs, and the rebase onto the
updated ADR branch adopts main's chat_completion_content safe extractor
inside the shared-helper judge().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015HgzNGsCxqiTaT4YuJEb5J

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>

---------

Co-authored-by: seonghobae <seonghobae@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant