Skip to content

feat: bind corroborated SKOS org aliases to one catalog row - #480

Merged
seonghobae merged 15 commits into
mainfrom
feat/skos-organization-alias-catalog-binding
Aug 24, 2026
Merged

feat: bind corroborated SKOS org aliases to one catalog row#480
seonghobae merged 15 commits into
mainfrom
feat/skos-organization-alias-catalog-binding

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • bind search-corroborated SKOS alternate/preferred labels to one corporate catalog row
  • preserve tie-as-tie semantics and reuse the alias set during the post-lock exact recheck
  • stack the non-identifying gap-baseline and authenticated frontend repair from docs: restore non-identifying gap baseline #479 so this head no longer inherits main failures

Validation

  • focused backend and documentation suite: 45 passed
  • frontend lint: passed
  • frontend tests: 140 passed
  • frontend production build: passed
  • git diff --check: passed

Stack

Includes #479 commits until that protected predecessor merges; no force push was used.

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

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 54 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: cdb16a17-bae3-43d1-8a6e-6c3e0dcf5d37

📥 Commits

Reviewing files that changed from the base of the PR and between b4911f8 and 002e11e.

📒 Files selected for processing (16)
  • ARCHITECTURE.md
  • CHANGELOG.d/skos-organization-alias-binding.md
  • CHANGELOG.md
  • backend/app/corporate_entity_ingestion.py
  • backend/app/keyman_ingestion.py
  • backend/app/organization_name_resolution_ingestion.py
  • backend/app/post_summary_ingestion.py
  • docs/adr/0008-organization-abbreviation-resolution.md
  • docs/adr/0012-corporate-entity-creation-lock.md
  • docs/adr/0160-skos-organization-alias-catalog-binding.md
  • lineageweave/corporate_hierarchy_resolution.py
  • tests/test_corporate_hierarchy_resolution.py
  • tests/test_ingestion_transaction_contracts.py
  • tests/test_organization_name_resolution_ingestion.py
  • tests/test_skos_organization_alias_binding.py
  • tests/test_tied_organization_no_create.py

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.

Copy link
Copy Markdown
Contributor Author

Opened for independent review. I will not self-approve. Synthetic fixtures only (AGP / Aurora Grid Power). Do not merge the docs-only “mark entity-resolution resolved” PRs instead of this code change. Independent exact-head APPROVE required.

devin-ai-integration[bot]

This comment was marked as resolved.

# Conflicts:
#	docs/product-technical-gap-baseline.md
devin-ai-integration[bot]

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor Author

Cycle retry (2026-08-23T19:01 KST): independent review only — no self-approve. Exact-head squash-merge remains gated on independent APPROVE + required checks.

Copy link
Copy Markdown
Contributor Author

Independent review still required for protected merge: current reviews are Devin COMMENTED only, not a non-author APPROVE. Required checks on d7d830f succeeded (Full test suite, Frontend, strix, Semgrep, Scorecard, Trivy, OSV, coverage-evidence, CodeQL). Author will not self-approve. Please APPROVE at exact head d7d830f9faa0f4b2acde4fa2f08310d05461c4f5 if the SKOS alias catalog binding is ready.

Copy link
Copy Markdown
Contributor Author

Cycle write probe 2026-08-23T10:15Z: confirming REST comment access on exact head d7d830f. No review from this actor.

Copy link
Copy Markdown
Contributor Author

Cycle retry 2026-08-23T19:14 KST: mergeable_state remains blocked — no independent APPROVE on exact head d7d830f9. Author will not self-approve. Copilot review requested. Independent of this catalog-binding stack, the next buyer-visible slice (corroborated SKOS companion captions on org chips) will land as a separate PR from main so it does not mix into this head.

devin-ai-integration[bot]

This comment was marked as resolved.

@github-actions
github-actions Bot disabled auto-merge August 23, 2026 13:35
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

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).
@seonghobae
seonghobae enabled auto-merge (squash) August 23, 2026 14:17
devin-ai-integration[bot]

This comment was marked as resolved.

This branch was behind main (like #547/#405 earlier this session).
Resolved 2 conflicts:

- CHANGELOG.md: both sides added independent [Unreleased] Added/Fixed
  entries; unioned them and merged the resulting duplicate "### Fixed"
  headers into one section.
- docs/product-technical-gap-baseline.md: took main's current
  restructured snapshot wholesale, same reasoning as #405.

frontend/src/App.tsx merged with no conflict and already carries the
accessToken-guarded AdminPanel render (`destination === "admin" &&
accessToken`), confirming this branch doesn't need the recurring
AdminPanel/oidcReturnUrl fix applied elsewhere this session.

Verified: frontend build/lint clean. This PR's own new logic (33 tests
across test_skos_organization_alias_binding.py,
test_corporate_hierarchy_resolution.py, test_tied_organization_no_create.py
-- pure-Python, no DB fixture) passes in 1.36s. Could not complete a
full-suite run this cycle: the shared local Postgres is under the same
sustained ~20-concurrent-session contention documented on #530/#547/#405
earlier this session (one run exceeded 30 minutes with no sign of
finishing). Deferring full-suite confirmation to this PR's isolated
GitHub Actions run, unaffected by local contention.
seonghobae added a commit that referenced this pull request Aug 24, 2026
Downstream of #480's SKOS org-alias catalog work; also behind main.
Resolved 7 conflicts:

- pyproject.toml, frontend/package.json, uv.lock: this PR's own version
  (2.14.0) doesn't collide with main's current tip (2.12.18), kept it.
- CHANGELOG.md: HEAD's Added entry sat under [Unreleased] even though the
  version files claimed 2.14.0 -- promoted it to its own dated "## [2.14.0]"
  section (matching the convention every other version bump in this file
  already follows) and placed main's [Unreleased] additions/fixes above it.
- docs/product-technical-gap-baseline.md: took main's current snapshot
  wholesale, same reasoning as #405/#480.
- docs/storybook-inventory.md, frontend/src/App.test.tsx: independent
  additions on each side (new story row; new stub-config flags); unioned.

frontend/src/App.tsx merged with no conflict and already carries the
accessToken-guarded AdminPanel render.

Verified: frontend build/lint clean. This PR's own new logic (37 tests
across test_organization_alias.py, test_affiliate_tree.py,
test_knowledge_graph.py, test_organization_name_resolution_ingestion.py)
passes in 4.94s. Full-suite run hit the same sustained local-Postgres
contention documented on #530/#547/#405/#480 this session (15+ min with
no sign of finishing); deferring to this PR's isolated GitHub Actions
run as the authoritative check, same precedent as those four.
@seonghobae
seonghobae enabled auto-merge August 24, 2026 06:46
seonghobae added a commit that referenced this pull request Aug 24, 2026
… reconciled with ADR-0145 weight estimation

Genuine dual-feature conflict, not a mechanical one: this PR's own
rebuild_lineage() rewrite (adjudication/embedding client wiring, session
advisory lock, bounded snapshot-changed retry, ADR 0195 channel_scores
persistence) landed at the same call site main independently modified
for ADR 0145 persisted channel-weight loading. Resolved by threading
all three concerns through one function:

- lineage_edge_specs() / reconstruct() already had all three keyword
  params (llm, embedding, weights) merge cleanly with no conflict --
  confirming the two features are compositionally compatible.
- Rebuilt rebuild_lineage() to: compute the active channel set (adding
  "llm" only when adjudication_client is real, matching how
  load_estimated_channel_weights decides whether a persisted vector
  applies), load weights once, then run this PR's lock+retry+recheck
  structure with weights threaded through the lineage_edge_specs call.
- backend/app/main.py, docker/postgres-init/migrate.sh (took main's ADR
  0166 generalized wildcard replay pattern -- already covers this PR's
  own migration 0195), CHANGELOG.md (promoted this PR's [Unreleased]
  entry to a dated ## [2.24.0] section, matching the file's own
  convention, ahead of main's [2.12.18]), pyproject.toml/uv.lock
  (kept this PR's 2.24.0, no collision with main's 2.12.18 tip),
  frontend/src/App.test.tsx (recurring OIDC-return-url pattern, took
  main's superset assertion): mechanical, low-risk.

tests/test_lineage_ingestion.py needed real fixes, not just marker
removal, to keep passing against the merged rebuild_lineage():
- _RebuildConnection (this PR's own fake asyncpg connection, used by
  7 of its own tests) predates ADR 0145 and had no fetchval -- added
  one returning False (no persisted weight table), matching every
  other fake connection's default in this file.
- 5 monkeypatched lineage_edge_specs stand-ins across this PR's own
  tests only accepted (records, *, llm, embedding) -- rebuild_lineage
  now always passes weights= too. Added weights=None to each stub;
  without this every adjudication/embedding-wiring test would have
  failed with "unexpected keyword argument 'weights'" the moment this
  PR's own branch met main.
- Concatenated main's two lineage_graphs_for_posts tests after this
  PR's ten rebuild_lineage tests -- different function, no overlap.

Verified: this module's own 26 tests pass, 32 more in directly-adjacent
modules (reconstruct, embedding_client, adjudication_client, channel
weight estimation) pass, `python -c "import backend.app.main"` succeeds,
frontend build/lint clean, the one touched App.test.tsx assertion passes
standalone. Could not complete the full ~800-test suite locally --
severe sustained Postgres contention from ~20 concurrent peer sessions,
same as #530/#547/#405/#480/#482 this session (20+ min, no sign of
finishing). Deferring full-suite confirmation to this PR's isolated
GitHub Actions run.
seonghobae added a commit to seonghobae/LineageWeave that referenced this pull request Aug 24, 2026
…d, bump version

A peer session independently merged origin/main into this branch while
I was mid-merge on the same PR (functionally identical resolution to
mine, already pushed) -- rebased onto their tip rather than force-push
a redundant duplicate.

One genuine gap remained in both resolutions: CHANGELOG.d/2.13.2-otel-
server-diagnostics.md existed in this PR from the start but was never
promoted into CHANGELOG.md, and pyproject.toml/__init__.py/uv.lock
never bumped past main's 2.12.18 -- same version-bump gap already
fixed on ContextualWisdomLab#480/ContextualWisdomLab#482/ContextualWisdomLab#434/this-branch's-first-pass this session.

Verified: full suite 940 passed, 16 skipped, zero failures.
@seonghobae
seonghobae merged commit ca919c5 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>
seonghobae added a commit that referenced this pull request Aug 25, 2026
* feat: trace post processing and Valkey sessions (#345)

* feat: trace post processing and valkey sessions

* docs: align orchestrator runtime pin

* docs: codify telemetry boundary

* fix: normalize OTLP trace endpoint

* chore: pin orchestrator telemetry fix

* fix: keep provider details out of ingestion ledger

* fix: propagate W3C trace context to gateway

* fix: classify server failures in OTel telemetry

* fix: redact worker failure details

* fix: flush telemetry and label TEPP transport

* fix: harden OpenTelemetry failure boundaries

* fix: ignore unsupported telemetry attribute keys

* test: enforce printable telemetry session ids

* fix(otel): retain endpoint operation diagnostics

* fix(frontend): keep admin controls behind authentication

* fix(oidc): retain validated login return URLs

* fix(otel): suppress automatic exception values in spans

* fix: classify worker provider failures

* fix(otel): classify type errors as internal

* fix(otel): suppress idle spans and mark HTTP errors

* fix(otel): pass exporter endpoint to orchestrator

* fix(otel): trace Valkey read failures

* fix: retry transient broker outages

* fix(docs,tests): rename buyer-safe to reader-safe per ADR 0119

ADR 0119 retired "buyer" project-wide the day before this PR's ADR
0122 and tests/test_server_diagnostics.py introduced it again in a
non-exempt new ADR and new source identifiers. Rename to
"reader-safe" to keep `grep -ri buyer` clean outside the ADR
0119-exempted historical ADRs, CHANGELOG*, and fixture/test domain
content.

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

* fix(otel): correlate Error spans with TraceId and SpanId

Record classified Ask and post-chat failures inside the API span so
structured logs share the same W3C TraceId and SpanId. Inject
traceparent on GET as well as POST, export OTLP logs when an endpoint
is set, and keep a blank Compose interpolation from wiping orchestrator
env_file telemetry.

* fix(otel): sort imports and wrap ruff line-length in Ask HTTP paths

Keep backend.app.main and the HTTP client import-sorted and wrap the
over-length lines in the classified Ask/chat and GET tracing paths so
the diagnostics change is ruff-clean on the files it touches.

* test(otel): require Valkey xadd to run under a child span

Assert the activity XADD span id differs from the parent so removing
the traced() wrapper cannot pass by inheriting the parent span.

* fix(otel): label GET peers and keep session headers on orchestrator

OIDC, Searxng, and CalDAV GETs now set a bounded service.peer.name
instead of inheriting contextual-orchestrator. The post session header
stays on orchestrator and TEPP only; W3C traceparent still propagates.

* docs: bind OTel control evidence to exact upstream head

* docs(changelog): promote CHANGELOG.d/2.13.2-otel-server-diagnostics.md, bump version

A peer session independently merged origin/main into this branch while
I was mid-merge on the same PR (functionally identical resolution to
mine, already pushed) -- rebased onto their tip rather than force-push
a redundant duplicate.

One genuine gap remained in both resolutions: CHANGELOG.d/2.13.2-otel-
server-diagnostics.md existed in this PR from the start but was never
promoted into CHANGELOG.md, and pyproject.toml/__init__.py/uv.lock
never bumped past main's 2.12.18 -- same version-bump gap already
fixed on #480/#482/#434/this-branch's-first-pass this session.

Verified: full suite 940 passed, 16 skipped, zero failures.

* fix(otel): instrument queued Ask failures

* merge: rebase onto main; union OTel spans with per-run refusal handling

The branch's diagnostic spans around the Valkey xread path now wrap
main's fail-closed per-run AnalysisRunCreateError refusal handling, so a
refused run is logged and skipped without ending the worker while the
batch keeps its traced span. Also drops conflict markers that an earlier
branch-side merge committed and unifies on _worker_logger.

* fix(api): keep Ask configuration details reader-safe

* docs(changelog): restore descending release order

* fix(telemetry): bound session metadata to service peers

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: seonghobae <seonghobae@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