Skip to content

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

Merged
seonghobae merged 2 commits into
mainfrom
feat/leftover-map-axis-share-v21216
Aug 24, 2026
Merged

feat: persist leftover-map axis share on period reports (v2.12.16)#519
seonghobae merged 2 commits into
mainfrom
feat/leftover-map-axis-share-v21216

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Persist leftover-map axis share (Gabriel inertia of residual SVD axes 1 and 2, σ_k² / Σ_j σ_j²) as report-level 3NF next to leftover pairs (ADR 0148). Rank-0 residuals emit two zero-share axes so seed can name leftover-map structure without inventing a leftover score. Missing cells stay out of the factorization.

After make seed, leftover-axis badges sit with the leftover pairs. The caption tells the reader to open a leftover pair to read the post–criterion cell. Axis share is aggregate and non-identifying: ABAC that hides individual leftover pairs does not hide the report-level share, and reports with no visible members remain absent.

This is independent of leftover-map coordinates (#481), leftover-criterion landing (#485), and leftover residual disclosure (#502).

The current head also preserves the inherited OIDC return-path/build repair and localizes every new axis badge, accessible label, and scientific caption through the existing five-locale boundary.

Reader next action

Open a leftover pair. The shares do not invent a leftover score.

Schema

report_leftover_map_axis is created only by idempotent migration 0169_report_leftover_map_axis.sql and reaches fresh and existing Compose volumes through the migration service replay window. It is deliberately not duplicated in shipped migration 0001. Axes 1 and 2 only. Cascade with report_period_score. leftover_singular_value >= 0; leftover_share is in [0, 1].

Merge contract

Independent exact-head APPROVE is required. This identity will not self-approve or bypass protected checks. Do not merge until that approval exists on this exact head.

Test plan

  • exact scientific/schema/API partition: 130 passed, 6 integration skips
  • exact App/i18n partition: 96 passed
  • frontend lint and production build (449.10 kB)
  • ShellCheck, sh -n, and git diff --check
  • hosted full and frontend suites on exact head
  • independent exact-head approval and remaining protected checks

Open in Devin Review

Copy link
Copy Markdown
Contributor Author

Exact head f808ffc3. Independent exact-head APPROVE is required; this identity will not self-approve or squash-merge. Do not mix leftover-map coordinates (#481), leftover-criterion landing (#485), or leftover residual disclosure (#502) into this PR. Issues #79 and #87 stay open. Issue #96 is already closed.

@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: 74149128-b3c9-4d1f-83ef-2e6e2551b328

📥 Commits

Reviewing files that changed from the base of the PR and between b4911f8 and 8a82c54.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (26)
  • AGENTS.md
  • ARCHITECTURE.md
  • CHANGELOG.d/2.12.19-leftover-map-axis-share.md
  • CHANGELOG.md
  • CLAUDE.md
  • backend/app/main.py
  • backend/app/report_ingestion.py
  • backend/tests/test_api.py
  • docs/adr/0003-fast-mlsirm-report-integration.md
  • docs/adr/0148-leftover-map-axis-share.md
  • frontend/package.json
  • frontend/src/App.test.tsx
  • frontend/src/App.tsx
  • frontend/src/api.ts
  • frontend/src/i18n.test.ts
  • frontend/src/i18n.ts
  • lineageweave/leftover_pairs.py
  • lineageweave/period_report.py
  • migrations/0169_report_leftover_map_axis.sql
  • migrations/rollback/0169_report_leftover_map_axis.sql
  • pyproject.toml
  • scripts/seed_demo_data.py
  • tests/test_leftover_pairs.py
  • tests/test_migration_replay.py
  • tests/test_period_report.py
  • tests/test_schema.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.

@seonghobae seonghobae left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

requesting copilot

Copy link
Copy Markdown
Contributor Author

Clarification: review 5003118980 is an accidental COMMENT from routing Copilot (requesting copilot). It is not APPROVE. Independent exact-head APPROVE is still required on f808ffc3. This identity will not self-approve or squash-merge.

devin-ai-integration[bot]

This comment was marked as resolved.

github-code-quality[bot]

This comment was marked as resolved.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Integration repair at e0549d730628: migration 0106 is already independently allocated by three current open stacks (account_affiliation_scope_facet, analysis_run_tepp_accepted_receipt, and this branch). ADR 0148 itself is unique, so I left the decision untouched and moved only this idempotent migration/rollback plus replay references to unused 0169. Exact checks: 33 migration/leftover/period/schema tests passed; ShellCheck, sh -n, and git diff --check passed.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Closed the remaining reader-facing locale gap at exact head 29bff927: leftover-axis badges, the accessible label, and the scientific caption now use the existing t/tf boundary across English, Korean, Chinese, Japanese, and Vietnamese. No measurement or persistence behavior changed.

Validation: 96 focused App/i18n tests, 130 scientific/schema/API tests with 6 honest integration skips, frontend lint, 449.10 kB production build, ShellCheck, sh -n, and git diff --check.

devin-ai-integration[bot]

This comment was marked as resolved.

@github-actions
github-actions Bot disabled auto-merge August 23, 2026 19:36
seonghobae added a commit that referenced this pull request Aug 23, 2026
* feat: persist leftover observed Y and expected E (v2.12.20)

After seed, leftover closest/farthest pairs sit above the member list
with observed Y and expected E[Y|θ, item] next to leftover-map distance
d. Click opens that post. Residual stays R = Y − E (Jeon et al., 2021
eq. 3; Gabriel 1971; ADR 0170). Never invent a leftover score or theta.

Independent of leftover persist-map (#481), criterion landing (#485),
complete-case coverage (#518), leftover-map axis share (#519),
comparison-strip leftover pairs (#521), and two-axis distance (#522).
Issues #79 and #87 stay open.

* Repair inherited login build boundary

* Move leftover evidence contract to ADR 0177

* fix: show leftover Y and E after stacking onto the workspace branch

Merge #490 without force-push. Keep ADR 0049 next-action copy and name
observed Y and expected E on leftover pair buttons. Replay migration
0177 on existing volumes.

* fix(leftover-observed-expected): wire missing migration and translations

Two genuine gaps in this PR's own diff, unrelated to any merge conflict:

1. backend/tests/test_api.py and tests/test_schema.py never applied
   migration 0177_report_leftover_observed_expected.sql in their test
   DB fixtures, even though report_ingestion.py already inserts into
   the observed_response/expected_response columns it adds. Every
   period-report test that touches leftover pairs failed with
   UndefinedColumnError.

2. frontend/src/i18n.ts never got the ko/zh/ja/vi translations for
   the two leftover-map-rank action strings that
   frontend/src/leftoverMapRank.ts (added downstream, in the PR that
   bases on this one) references — t()/tf() fall back to the raw
   English key for every locale, failing the i18n test suite in the
   whole downstream chain.

Verified: 1035 Python tests pass, 362 frontend tests pass, build/lint
clean.
seonghobae added a commit that referenced this pull request Aug 24, 2026
* feat: persist leftover observed Y and expected E (v2.12.20)

After seed, leftover closest/farthest pairs sit above the member list
with observed Y and expected E[Y|θ, item] next to leftover-map distance
d. Click opens that post. Residual stays R = Y − E (Jeon et al., 2021
eq. 3; Gabriel 1971; ADR 0170). Never invent a leftover score or theta.

Independent of leftover persist-map (#481), criterion landing (#485),
complete-case coverage (#518), leftover-map axis share (#519),
comparison-strip leftover pairs (#521), and two-axis distance (#522).
Issues #79 and #87 stay open.

* Repair inherited login build boundary

* Move leftover evidence contract to ADR 0177

* feat: name leftover-map rank on leftover pairs (v2.12.21)

After seed, leftover closest/farthest pairs sit above the member list
with leftover-map rank next to leftover-map distance d. Click opens
that post. Rank is the count of Gabriel singular values above the
floor (Jeon et al., 2021 eq. 3; Gabriel 1971; ADR 0171). Rank 0 names
no leftover structure. Never invent a leftover score or theta.

Independent of leftover persist-map (#481), criterion landing (#485),
complete-case coverage (#518), leftover-map axis share (#519),
comparison-strip leftover pairs (#521), two-axis distance (#522), and
observed Y / expected E (#527). Issues #79 and #87 stay open.

* Move leftover-map rank contract to ADR 0172

ADR 0171 is already used by analysis-run status same clock (#524).
Keep leftover-map rank independent: ADR 0172 and migration 0172.
Shipped 0001 / 0012 stay untouched.

* fix: restore frontend build on leftover-map PR

Drop unused OIDC import and unauthenticated AdminPanel that fail tsc
(TS6192 / TS2322). AdminPanel stays authenticated-only.

* fix: show leftover Y and E after stacking onto the workspace branch

Merge #490 without force-push. Keep ADR 0049 next-action copy and name
observed Y and expected E on leftover pair buttons. Replay migration
0177 on existing volumes.

* fix: keep leftover-map rank after stacking onto leftover Y/E

Merge stacked #527 without force-push. Persist leftover_map_rank with
observed Y and expected E, and name rank on leftover pair buttons.
seonghobae added a commit that referenced this pull request Aug 24, 2026
)

* feat: persist leftover observed Y and expected E (v2.12.20)

After seed, leftover closest/farthest pairs sit above the member list
with observed Y and expected E[Y|θ, item] next to leftover-map distance
d. Click opens that post. Residual stays R = Y − E (Jeon et al., 2021
eq. 3; Gabriel 1971; ADR 0170). Never invent a leftover score or theta.

Independent of leftover persist-map (#481), criterion landing (#485),
complete-case coverage (#518), leftover-map axis share (#519),
comparison-strip leftover pairs (#521), and two-axis distance (#522).
Issues #79 and #87 stay open.

* Repair inherited login build boundary

* Move leftover evidence contract to ADR 0177

* feat: name leftover-map rank on leftover pairs (v2.12.21)

After seed, leftover closest/farthest pairs sit above the member list
with leftover-map rank next to leftover-map distance d. Click opens
that post. Rank is the count of Gabriel singular values above the
floor (Jeon et al., 2021 eq. 3; Gabriel 1971; ADR 0171). Rank 0 names
no leftover structure. Never invent a leftover score or theta.

Independent of leftover persist-map (#481), criterion landing (#485),
complete-case coverage (#518), leftover-map axis share (#519),
comparison-strip leftover pairs (#521), two-axis distance (#522), and
observed Y / expected E (#527). Issues #79 and #87 stay open.

* Move leftover-map rank contract to ADR 0172

ADR 0171 is already used by analysis-run status same clock (#524).
Keep leftover-map rank independent: ADR 0172 and migration 0172.
Shipped 0001 / 0012 stay untouched.

* feat: name leftover residual on period-report pair rows (v2.12.22)

After seed, leftover closest/farthest pairs sit above the member list
with signed leftover residual R next to leftover-map distance d. Click
opens that post. Residual is R = Y − E[Y|θ, item] (Jeon et al., 2021
eq. 3; Gabriel 1971; ADR 0178). A non-finite residual is an em dash.
Never invent a leftover score or theta.

Independent of leftover persist-map (#481), criterion landing (#485),
complete-case coverage (#518), leftover-map axis share (#519),
comparison-strip leftover pairs (#521), two-axis distance (#522),
observed Y / expected E (#527), and leftover-map rank (#529). Issues
#79 and #87 stay open.

* fix: restore frontend build on leftover residual disclosure

Drop unused OIDC import and unauthenticated AdminPanel that fail tsc
(TS6192 / TS2322). Login matches the workspace branch: AdminPanel is
authenticated-only.

* fix: restore frontend build on leftover-map PR

Drop unused OIDC import and unauthenticated AdminPanel that fail tsc
(TS6192 / TS2322). AdminPanel stays authenticated-only.

* fix: show leftover Y and E after stacking onto the workspace branch

Merge #490 without force-push. Keep ADR 0049 next-action copy and name
observed Y and expected E on leftover pair buttons. Replay migration
0177 on existing volumes.

* fix: keep leftover-map rank after stacking onto leftover Y/E

Merge stacked #527 without force-push. Persist leftover_map_rank with
observed Y and expected E, and name rank on leftover pair buttons.
@seonghobae
seonghobae enabled auto-merge August 24, 2026 01:24
@opencode-agent
opencode-agent Bot disabled auto-merge August 24, 2026 04:40

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 3 new potential issues.

Open in Devin Review

Comment on lines 151 to +158
)
closest = min(candidates, key=lambda row: (row[0], row[1], row[2]))
farthest = max(candidates, key=lambda row: (row[0], row[1], row[2]))
return (
pairs = (
_pair_from_candidate(PAIR_KIND_CLOSEST, closest, leftover_map_rank),
_pair_from_candidate(PAIR_KIND_FARTHEST, farthest, leftover_map_rank),
)
return pairs, axes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Rank-0 pair fallback keeps non-zero axis shares

In leftover_map_from_residual, axis shares are computed from the singular values (leftover_pairs.py:115) but the pair leftover_map_rank is reset to 0 in the no-candidate fallback (leftover_pairs.py:136-137). If a complete-case rectangle yields non-empty singulars yet all map distances are non-finite, pairs report rank 0 while axes report non-zero share. Only reachable via the monkeypatched non-finite coordinates in test_nonfinite_map_distance_falls_back_to_centered_residual; real SVD coordinates are finite, so this does not occur in practice.

(Refers to this code)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +463 to +478
for axis in report.leftover_map_axes:
await conn.execute(
"""
insert into report_leftover_map_axis (
grouping_kind, grouping_key, period_code, rubric_version,
axis_index, leftover_singular_value, leftover_share
) values ($1,$2,$3,$4,$5,$6,$7)
""",
grouping_kind,
grouping_key,
period_code,
RUBRIC_VERSION,
axis.axis_index,
axis.leftover_singular_value,
axis.leftover_share,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Axis-share replace relies on cascade delete

Neither persist_period_report nor the seed's _persist_seed_period_report explicitly deletes report_leftover_map_axis rows before re-inserting; replacement depends on ON DELETE CASCADE from the leading report_period_score delete (migration 0169 FK). This mirrors the existing report_leftover_pair pattern and is correct as long as that period-score delete always precedes the inserts.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +214 to +230
values = np.asarray(singular, dtype=np.float64).reshape(-1)
kept = values[np.isfinite(values) & (values > _LEFTOVER_SINGULAR_FLOOR)]
total = float(np.sum(kept * kept)) if kept.size else 0.0
axes: list[LeftoverMapAxis] = []
for index in (1, 2):
value = float(kept[index - 1]) if kept.size >= index else 0.0
if not np.isfinite(value) or value < 0.0:
value = 0.0
share = (value * value / total) if total > 0.0 else 0.0
axes.append(
LeftoverMapAxis(
axis_index=index,
leftover_singular_value=value,
leftover_share=max(share, 0.0),
)
)
return tuple(axes)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Axis-share denominator excludes below-floor singulars

leftover_map_axes_from_singular builds the inertia denominator from singular values above the floor only (leftover_pairs.py:214-216), so axis 1 and 2 shares are relative to surviving inertia. For rank>2 residuals the two shares deliberately sum to less than 1, and the frontend badges will not total 100%. Consistent with ADR 0148's definition.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@seonghobae
seonghobae enabled auto-merge August 24, 2026 05:55
@github-actions
github-actions Bot disabled auto-merge August 24, 2026 06:19
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.
@seonghobae
seonghobae force-pushed the feat/leftover-map-axis-share-v21216 branch from 31c150c to 5a4a41b Compare August 24, 2026 07:03
@seonghobae
seonghobae enabled auto-merge August 24, 2026 07:32
@github-actions
github-actions Bot disabled auto-merge August 24, 2026 07:48
@seonghobae
seonghobae enabled auto-merge (squash) August 24, 2026 07:59
@opencode-agent
opencode-agent Bot disabled auto-merge August 24, 2026 08:42
@seonghobae
seonghobae enabled auto-merge August 24, 2026 11:50
@seonghobae
seonghobae merged commit eeecd6e into main Aug 24, 2026
32 of 33 checks passed
seonghobae added a commit that referenced this pull request Aug 24, 2026
…ource-detail-state-filter

Resolves fresh conflicts in tests/test_migration_replay.py (additive
test concatenation) and frontend/src/i18n.ts (additive locale key
concatenation across ko/zh/ja/vi).
seonghobae added a commit that referenced this pull request Aug 24, 2026
…are)

# Conflicts:
#	frontend/package.json
#	frontend/src/i18n.ts
#	pyproject.toml
#	uv.lock
seonghobae added a commit that referenced this pull request Aug 24, 2026
…ADR 0200 point 5, no activation) (#586)

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

* fix: detach provider parse error context

* fix(frontend): restore authenticated admin boundary

* fix(frontend): use safe OIDC return URL

* fix: close provider transport error boundary

* fix: hide raw TEPP transport failures

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

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

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

* docs: remove nonexistent provider-boundary ADR citations

---------

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

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

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

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

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

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

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

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

* docs: docstring for the worker-side ABAC predicate

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore(ask): express recovery windows without make_interval

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

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

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

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

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

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

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

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

---------

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

* fix: shorten the orchestrator healthcheck's retry budget

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

* docs: restore non-identifying gap baseline

* fix(frontend): restore authenticated admin boundary

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

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

* docs: correct live Strix failure count

* fix: preserve raw organization ties before aliases

* docs: clarify alias resolution order

* fix: recheck organization evidence under lock

* fix: require exact SKOS alias matches

* fix: exclude full inferred ancestor path

* docs: align ancestor exclusion contract

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

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

* docs: resolve SKOS ADR number collision

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

* feat: add bounded ontology and provenance explorer

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

* fix: keep ontology JSON-LD exports filtered

* fix: validate ontology neighborhood request bounds

* fix: escape ontology CSV formulas

* fix: make ontology layout ordering deterministic

* fix: make ontology explorer controls accessible

* fix: translate ontology post node labels

* fix: deduplicate ontology node evidence

* fix: drop unlabeled ontology edges

* fix: harden typed ontology neighborhood boundaries

* fix: bound ontology fact loading by requested edge limit

* fix: complete ontology explorer verification coverage

* fix: hide unauthorized ontology parents

* test: reproduce missing Storybook viewport addon

* fix: declare Storybook viewport addon

* fix: register Storybook viewport addon

* fix: use Storybook 10 built-in viewport feature

* fix: keep Storybook 10 essentials zero-config

* fix: declare Storybook built-in viewport options

* test: lock Storybook 10 built-in viewport contract

* fix: bind narrow ontology story to mobile viewport

* test: reproduce cutoff and uppercase focus defects

* fix: apply ontology cutoff before SQL limit

* test: require localized ontology stabilization copy

* feat: localize ontology paging and provenance guidance

* test: reproduce ontology paging and provenance UX defects

* fix: stabilize ontology paging and provenance UX

* test: reproduce bounded window and disconnected first page

* fix: preserve proximity and source truncation truth

* fix: expose bounded ontology source windows

* fix: keep ontology loader test doubles compatible

* test: reproduce duplicate ontology focus rule

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

* test: require a next action for hard ontology bounds

* test: require localized hard-bound guidance

* feat: localize hard ontology query-bound guidance

* fix: validate provider chat response envelopes

* fix: close remaining provider error leaks

* fix: normalize summary enrichment failures

* fix: normalize unexpected provider failures

* fix: keep TEPP and worker errors provider-safe

* fix: bound and batch ontology neighborhood evidence

* fix: preserve ontology visibility query compatibility

* fix: restore ontology explorer next actions

* fix: honor ontology cutoff and canonical focus UUIDs

* fix: read ontology visibility records safely

* fix: preserve provider parse failures and truncation

* fix: preserve ontology explorer session and parent visibility

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

* fix: authorize final ontology endpoints

* fix: preserve ontology evidence and stale diagnostics

* fix: make tenant settings migration replayable

* fix: harden ontology explorer paging and login return

* docs: sanitize baseline and record ontology gates

* fix: align provider changelog release

* test: assert ontology fact query limit argument

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

* feat: continue ontology neighborhoods with an opaque source cursor

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

* fix: retain paged ontology relations

* fix: reuse expanded ontology window

* fix: seal ontology continuation windows

* docs: record ontology continuation safeguards

* test: assert continuation cursor anchor

* fix: deduplicate ontology source-window edges

* fix: preserve ontology source cursor ordering

* Fix ontology cursor after derived edge overflow

* fix(ontology): remove unused subclass IRI constant

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

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

* test(frontend): align rejected ontology fixture rows

* fix: make ontology page retries effective

* fix(security): replace custom source cursor cipher

* fix(security): keep aggregate SQL immutable

* test(security): preserve reviewed SQL contract

* fix(ontology): keep paging safe and stable

* fix: conceal ontology error details

* fix: distinguish full ontology page from truncation

* test: use one ontology ingestion import style

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

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

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

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

* fix: allocate migration 0175 for ontology truth

* fix: restore ontology snapshot on reset

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test(red): require scrollable lineage DAG viewport

* test(red): require mobile lineage scroll guidance

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

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

* test(storybook): cover mobile lineage scrolling

* docs(storybook): record mobile lineage scroll state

---------

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: detach provider parse error context

* fix(frontend): restore authenticated admin boundary

* fix(frontend): use safe OIDC return URL

* fix: close provider transport error boundary

* fix: hide raw TEPP transport failures

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

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

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

* docs: remove nonexistent provider-boundary ADR citations

---------

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

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

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

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

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

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

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

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

* docs: docstring for the worker-side ABAC predicate

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore(ask): express recovery windows without make_interval

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

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

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

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

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

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

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

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

---------

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

* fix: shorten the orchestrator healthcheck's retry budget

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

* docs: restore non-identifying gap baseline

* fix(frontend): restore authenticated admin boundary

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

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

* docs: correct live Strix failure count

* fix: preserve raw organization ties before aliases

* docs: clarify alias resolution order

* fix: recheck organization evidence under lock

* fix: require exact SKOS alias matches

* fix: exclude full inferred ancestor path

* docs: align ancestor exclusion contract

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

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

* docs: resolve SKOS ADR number collision

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

* feat: add bounded ontology and provenance explorer

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

* fix: keep ontology JSON-LD exports filtered

* fix: validate ontology neighborhood request bounds

* fix: escape ontology CSV formulas

* fix: make ontology layout ordering deterministic

* fix: make ontology explorer controls accessible

* fix: translate ontology post node labels

* fix: deduplicate ontology node evidence

* fix: drop unlabeled ontology edges

* fix: harden typed ontology neighborhood boundaries

* fix: bound ontology fact loading by requested edge limit

* fix: complete ontology explorer verification coverage

* fix: hide unauthorized ontology parents

* test: reproduce missing Storybook viewport addon

* fix: declare Storybook viewport addon

* fix: register Storybook viewport addon

* fix: use Storybook 10 built-in viewport feature

* fix: keep Storybook 10 essentials zero-config

* fix: declare Storybook built-in viewport options

* test: lock Storybook 10 built-in viewport contract

* fix: bind narrow ontology story to mobile viewport

* test: reproduce cutoff and uppercase focus defects

* fix: apply ontology cutoff before SQL limit

* test: require localized ontology stabilization copy

* feat: localize ontology paging and provenance guidance

* test: reproduce ontology paging and provenance UX defects

* fix: stabilize ontology paging and provenance UX

* test: reproduce bounded window and disconnected first page

* fix: preserve proximity and source truncation truth

* fix: expose bounded ontology source windows

* fix: keep ontology loader test doubles compatible

* test: reproduce duplicate ontology focus rule

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

* test: require a next action for hard ontology bounds

* test: require localized hard-bound guidance

* feat: localize hard ontology query-bound guidance

* fix: validate provider chat response envelopes

* fix: close remaining provider error leaks

* fix: normalize summary enrichment failures

* fix: normalize unexpected provider failures

* fix: keep TEPP and worker errors provider-safe

* fix: bound and batch ontology neighborhood evidence

* fix: preserve ontology visibility query compatibility

* fix: restore ontology explorer next actions

* fix: honor ontology cutoff and canonical focus UUIDs

* fix: read ontology visibility records safely

* fix: preserve provider parse failures and truncation

* fix: preserve ontology explorer session and parent visibility

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

* fix: authorize final ontology endpoints

* fix: preserve ontology evidence and stale diagnostics

* fix: make tenant settings migration replayable

* fix: harden ontology explorer paging and login return

* docs: sanitize baseline and record ontology gates

* fix: align provider changelog release

* test: assert ontology fact query limit argument

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

* feat: continue ontology neighborhoods with an opaque source cursor

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

* fix: retain paged ontology relations

* fix: reuse expanded ontology window

* fix: seal ontology continuation windows

* docs: record ontology continuation safeguards

* test: assert continuation cursor anchor

* fix: deduplicate ontology source-window edges

* fix: preserve ontology source cursor ordering

* Fix ontology cursor after derived edge overflow

* fix(ontology): remove unused subclass IRI constant

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

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

* test(frontend): align rejected ontology fixture rows

* fix: make ontology page retries effective

* fix(security): replace custom source cursor cipher

* fix(security): keep aggregate SQL immutable

* test(security): preserve reviewed SQL contract

* fix(ontology): keep paging safe and stable

* fix: conceal ontology error details

* fix: distinguish full ontology page from truncation

* test: use one ontology ingestion import style

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

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

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

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

* fix: allocate migration 0175 for ontology truth

* fix: restore ontology snapshot on reset

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

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

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

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test(red): require scrollable lineage DAG viewport

* test(red): require mobile lineage scroll guidance

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

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

* test(storybook): cover mobile lineage scrolling

* docs(storybook): record mobile lineage scroll state

---------

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

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

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

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

---------

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

---------

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant