Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 44 additions & 7 deletions SESSION.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,63 @@
## AI tools used

Claude Code (Anthropic) — used as an agentic assistant for repository
analysis, implementation, test authoring, and verification.

## Prompts and instructions

List the prompts or instructions you gave to AI tools during the challenge.
The assistant worked through a fixed, phase-gated process, with review and
approval required between phases:

1. Clone the challenge repository and analyse it in full.
2. Produce a written implementation plan and present it for review before
writing any code.
3. A follow-up instruction asked for a second, deeper review of the plan,
focused specifically on edge cases and pitfalls, before approval.
4. On approval, implement the plan.
5. Complete `SUBMISSION.md` and `SESSION.md`.

## AI outputs accepted
Standing instructions throughout: keep the fix small and focused, maintain
professional code quality, and keep the working tree clean.

Summarize the AI outputs, edits, commands, or recommendations you accepted.
## AI outputs accepted

- The analysis locating the defect: `resolveCitations` in `lib/citations.ts`
ignored the viewer context (`void context`) and turned every resolvable
evidence reference into a visible citation.
- The implementation plan: enforce the full visibility policy in the resolver
and leave the display components unchanged.
- The deeper edge-case findings, in particular: the unavailable count is per
evidence reference, not per distinct source; `canViewSource` returned early
for internal sources, which would let them bypass later gates; two distinct
sources share a title, so grouping must use the source id; the validity
check must compare `validFrom`/`validUntil` against the answer's `asOf`,
not the evidence `updatedAt`.
- The implementation of `lib/citations.ts`: completed `canViewSource` and
`canUseEvidence`, added `isEvidenceCurrent` and the redaction helper, and
made `resolveCitations` enforce the policy.
- The new test file `test/citations-policy.test.ts` (18 tests).
- A one-line fix in `vitest.config.ts` (`fileURLToPath` for the path alias) so
the test suite resolves on a directory path containing spaces.

## Manual work

Summarize the parts you wrote, changed, reviewed, or corrected manually.

- Defined the phase-gated process and reviewed the plan and the deeper
edge-case analysis at each gate.
- Required the second, deeper pitfalls review before approving implementation.
- Reviewed the final diff, the test run, and the build output.

## Discarded AI output

Summarize any AI suggestions you intentionally did not use and why.
No assistant output was rejected outright. Two approaches were considered and
deliberately not adopted, since the brief warns against unproven extra policy:

- Treating a source's `classification: "sensitive"` as an exclusion rule — it
is not in the specified exclusion list, and every sensitive source in the
fixtures is already excluded by other rules.
- Re-ordering evidence by `confidence` — the retrieval layer already ranks
evidence, and the answer's evidence order must be preserved.

## Notes

Do not include secrets, access tokens, private credentials, or personal data. Redact anything sensitive before submission.
No secrets, access tokens, credentials, or personal data are included in this
submission.
49 changes: 47 additions & 2 deletions SUBMISSION.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,57 @@
## What changed?

The defect was in `lib/citations.ts`: `resolveCitations` ignored the viewer
context and turned every resolvable evidence reference into a visible
citation. The fix enforces the full visibility policy in the resolver. The
display components were not changed — they already render only what the
resolver returns.

**`lib/citations.ts`**

- `canViewSource` — completed with region and required-group checks, and
restructured so each rule is an independent gate. Previously the
internal-visibility branch returned early; region and group checks now apply
to internal sources as well.
- `canUseEvidence` — now also rejects evidence outside its validity window via
the new `isEvidenceCurrent` helper, and takes the answer's `asOf` date.
Stale, future-dated, and expired evidence is excluded.
- `applyRedactions` (new helper) — replaces a redaction's text unless the
viewer belongs to one of its cleared groups.
- `resolveCitations` — resolves evidence by exact id, preserves the answer's
evidence order, groups visible evidence by source id (not by title, since
two distinct sources share a title), numbers citations by first-visible
order, and counts every unavailable reference without exposing its metadata.

**`vitest.config.ts`**

- The test path alias now resolves via `fileURLToPath` instead of
`URL.pathname`, so the suite loads on directories whose path contains spaces.

**`test/citations-policy.test.ts`** (new)

- 18 unit tests: one per exclusion category (workspace, status, region,
required group, internal visibility, stale, validity window, missing id) and
for redaction with and without the viewer's cleared group.

## What did you test?

- `npm test` — 24 tests pass: the 6 supplied candidate-facing tests and the 18
new ones.
- `npm run typecheck` (`tsc --noEmit`) — no errors.
- `npm run build` — the production build succeeds.
- `npm run dev` — all three conversations were verified against the rendered
output: only viewer-safe evidence is shown, the restricted phrase is
redacted, the unavailable count is correct, the empty states are correct,
and no excluded source's title, excerpt, owner, or workspace appears in the
page.

## Session log

- [ ] `SESSION.md` is completed.

- [x] `SESSION.md` is completed.

## Final commit SHA

`ec8ff547b765ac93b455bfd720612008216347a9`

This is the local solution commit. Confirm or replace it with the final commit
SHA after pushing to the private candidate repository.
87 changes: 78 additions & 9 deletions lib/citations.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { Answer, EvidenceChunk, EvidenceConfidence, Source, ViewerContext } from "@/data/types";
import type {
Answer,
EvidenceChunk,
EvidenceConfidence,
EvidenceRedaction,
Source,
ViewerContext
} from "@/data/types";

export type ResolvedEvidence = {
id: string;
Expand All @@ -23,7 +30,18 @@ export type CitationResolution = {
evidenceToCitationNumber: Record<string, number>;
};

function isRegionVisible(source: Source, context: ViewerContext): boolean {
return source.region === "global" || source.region === context.region;
}

function hasRequiredGroups(source: Source, context: ViewerContext): boolean {
return source.requiredGroups.every((group) => context.groups.includes(group));
}

export function canViewSource(source: Source, context: ViewerContext): boolean {
// Each rule is an independent gate. The region and group checks must run
// before the internal-visibility result is returned, otherwise internal
// sources would bypass them.
if (source.workspaceId !== context.workspaceId) {
return false;
}
Expand All @@ -32,49 +50,100 @@ export function canViewSource(source: Source, context: ViewerContext): boolean {
return false;
}

if (!isRegionVisible(source, context)) {
return false;
}

if (!hasRequiredGroups(source, context)) {
return false;
}

if (source.visibility === "internal") {
return context.role === "admin" && context.isInternalEmployee;
}

return true;
}

// Evidence is in window when the answer's as-of date is on or after validFrom
// and on or before validUntil. The fixtures use ISO YYYY-MM-DD dates, which
// order correctly as plain strings, so no Date parsing (or timezone) is needed.
export function isEvidenceCurrent(evidence: EvidenceChunk, asOf: string): boolean {
if (asOf < evidence.validFrom) {
return false;
}

if (evidence.validUntil !== undefined && asOf > evidence.validUntil) {
return false;
}

return true;
}

export function canUseEvidence(
evidence: EvidenceChunk,
source: Source,
context: ViewerContext
context: ViewerContext,
asOf: string
): boolean {
if (evidence.status !== "verified") {
return false;
}

if (!isEvidenceCurrent(evidence, asOf)) {
return false;
}

return canViewSource(source, context);
}

// A redaction's visibleToGroups is an allow-list: a viewer in any one of those
// groups sees the original text; everyone else sees the replacement.
function applyRedactions(
excerpt: string,
redactions: EvidenceRedaction[] | undefined,
context: ViewerContext
): string {
if (!redactions) {
return excerpt;
}

return redactions.reduce((text, redaction) => {
const viewerCleared = redaction.visibleToGroups.some((group) =>
context.groups.includes(group)
);

return viewerCleared ? text : text.split(redaction.text).join(redaction.replacement);
}, excerpt);
}

export function resolveCitations(
answer: Answer,
evidenceChunks: EvidenceChunk[],
allSources: Source[],
context: ViewerContext
): CitationResolution {
void context;

const evidenceById = new Map(evidenceChunks.map((evidence) => [evidence.id, evidence]));
const sourcesById = new Map(allSources.map((source) => [source.id, source]));
const citationBySourceId = new Map<string, ResolvedCitation>();
const citations: ResolvedCitation[] = [];
const evidenceToCitationNumber: Record<string, number> = {};
let unavailableCount = 0;

// BUG: the legacy resolver trusts retrieval output and groups every matching
// evidence ID. It misses source policy, time windows, redactions, and unavailable counts.
for (const reference of answer.evidence) {
const evidence = evidenceById.get(reference.evidenceId);
const source = evidence ? sourcesById.get(evidence.sourceId) : undefined;

if (!evidence || !source) {
if (!evidence || !source || !canUseEvidence(evidence, source, context, answer.asOf)) {
// Every reference that does not resolve to visible evidence is counted
// once — missing ids included — and nothing about it is exposed.
unavailableCount += 1;
continue;
}

// Group by source id, never by title: distinct sources can share a title.
// A citation number is assigned when a source first gains visible
// evidence, so numbering follows first-visible order.
let citation = citationBySourceId.get(source.id);

if (!citation) {
Expand All @@ -93,12 +162,12 @@ export function resolveCitations(

citation.evidence.push({
id: evidence.id,
excerpt: evidence.excerpt,
excerpt: applyRedactions(evidence.excerpt, evidence.redactions, context),
updatedAt: evidence.updatedAt,
confidence: reference.confidence
});
evidenceToCitationNumber[evidence.id] = citation.citationNumber;
}

return { citations, unavailableCount: 0, evidenceToCitationNumber };
return { citations, unavailableCount, evidenceToCitationNumber };
}
Loading