Skip to content
Merged
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
14 changes: 7 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,13 +240,13 @@ app/ # Next.js App Router
oit-portfolio/ # OIT's FY2027 EA inventory + owner-confirmed crosswalks
operational-excellence/ # Oct 2025 survey — themes, responses, candidate projects
ai4ra-ecosystem/ # AI4RA partnership deep-dive (linked from /about)
internal/ # Auth-gated ops surfaces (sync trigger, agent log).
# Request queue moved public → /portfolio/pipeline
# (2026-07-24); /internal/requests redirects there.
# NOTE: /internal/portfolio still renders a second
# view of the inventory, which predates and
# contradicts the one-story directive — under
# review; don't build on it.
internal/ # Auth-gated ops surfaces ONLY (sync trigger, agent
# log). No inventory or request views live here:
# /internal/requests → /portfolio/pipeline
# (2026-07-24) and /internal/portfolio
# /portfolio (2026-07-27). Both redirect in
# proxy.ts before the auth gate. Don't add a
# second view of anything public here.
admin/ # Registry + submissions admin
api/ # Next.js API routes
docs/ # Technical + user documentation
Expand Down
42 changes: 29 additions & 13 deletions app/internal/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,16 @@ export default async function InternalHome() {
Internal coordination view
</h1>
<p className="mt-3 max-w-2xl text-base leading-relaxed text-gray-700">
The auth-gated IIDS-only view of the institutional AI inventory.
Same data as the public portfolio with sharper blocker detail
(named individuals, contact history) and the embargoed and
internal-only records visible.
Operations surfaces only — sync control and agent traffic. The
inventory itself is public at{" "}
<Link href="/portfolio" className="underline decoration-brand-clearwater underline-offset-2">
/portfolio
</Link>{" "}
and the request queue at{" "}
<Link href="/portfolio/pipeline" className="underline decoration-brand-clearwater underline-offset-2">
/portfolio/pipeline
</Link>
. The site tells one story; there is no internal copy of either.
</p>
</header>

Expand All @@ -52,17 +58,17 @@ export default async function InternalHome() {
</p>
</div>
<Link
href="/internal/portfolio"
href="/portfolio"
className="group rounded-xl border border-gray-200 bg-white p-5 shadow-sm transition-all hover:border-ui-gold/40 hover:shadow-md"
>
<p className="text-xs font-medium uppercase tracking-wider text-ui-gold-dark">
Drill in
Inventory
</p>
<p className="mt-1 text-base font-semibold text-ui-charcoal group-hover:text-ui-gold-dark">
Internal portfolio &rarr;
Projects &rarr;
</p>
<p className="mt-2 text-xs text-gray-500">
Full inventory with internal-text blocker detail
Public at /portfolio — the same records these counts describe
</p>
</Link>
</section>
Expand Down Expand Up @@ -114,13 +120,23 @@ export default async function InternalHome() {

<section className="rounded-xl border border-dashed border-gray-300 bg-white/50 p-6">
<h2 className="text-base font-semibold text-ui-charcoal">
Coming in Sprint 3
Still outstanding
</h2>
<ul className="mt-3 space-y-1 text-sm text-gray-600">
<li>&bull; ClickUp write-side — new submissions create ClickUp tasks</li>
<li>&bull; Submitter status pages (<code>/intake/[token]</code>)</li>
<li>&bull; Submission similarity surfaced live during the assessment</li>
<li>&bull; Named-SLA acknowledgment email on intake</li>
<li>
&bull; ClickUp write-side — new submissions create ClickUp tasks.
Read-side shipped (ADR 0004); writes remain future work.
</li>
<li>
&bull; Named-SLA acknowledgment <em>email</em> on intake. The named
human and SLA render on the results page and on{" "}
<code>/intake/[token]</code>, but nothing is actually sent — there
is no mailer in the stack.
</li>
<li>
&bull; TDX request sync (<code>scripts/sync-tdx.ts</code>) — blocked
on API access. ADR 0005 Phase 4.
</li>
</ul>
</section>
</div>
Expand Down
46 changes: 6 additions & 40 deletions app/internal/portfolio/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,14 @@
import { notFound } from "next/navigation";
import ProjectDetail from "@/components/ProjectDetail";
import {
getApplicationBySlug,
getRelatedApplications,
} from "@/lib/work";
import { getProjectStatusBySlug } from "@/lib/clickup-data";
import { redirect } from "next/navigation";

export const dynamic = "force-dynamic";
// Retired with the internal portfolio index — see ../page.tsx for why.
// Keeps the slug so a bookmarked project detail lands on that project's
// public page rather than the inventory index.

export async function generateMetadata({
export default async function InternalProjectRedirect({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const app = await getApplicationBySlug(slug, { audience: "internal" });
if (!app) return { title: "Not found" };
return {
title: `${app.name} · IIDS Internal`,
description: app.tagline ?? app.description.slice(0, 160),
};
}

export default async function InternalProjectDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const app = await getApplicationBySlug(slug, { audience: "internal" });
if (!app) notFound();

const [related, clickup] = await Promise.all([
getRelatedApplications(app, { audience: "internal" }),
getProjectStatusBySlug(slug),
]);

return (
<ProjectDetail
app={app}
related={related}
audience="internal"
basePath="/internal/portfolio"
clickup={clickup}
/>
);
redirect(`/portfolio/${slug}`);
}
85 changes: 18 additions & 67 deletions app/internal/portfolio/page.tsx
Original file line number Diff line number Diff line change
@@ -1,70 +1,21 @@
import Link from "next/link";
import PortfolioCard from "@/components/PortfolioCard";
import { listApplications, groupByHomeUnit } from "@/lib/work";
import { redirect } from "next/navigation";

export const dynamic = "force-dynamic";
// The internal portfolio view was retired on 2026-07-27. It rendered the
// same inventory as /portfolio — no project has ever carried the
// `Internal-only` visibility tier, so the extra tier in its query matched
// nothing — and its only exclusive content was auto-derived placeholder
// blocker text. That made it a second view of the inventory, which the
// one-story directive rules out (ADR 0005 amendment, 2026-07-24, extended
// 2026-07-27).
//
// The `audience: "internal"` seam in lib/work.ts stays. If genuinely
// sensitive blocker detail is ever authored, the query path back is
// intact — but restoring a separate surface needs a deliberate decision,
// not a page that quietly persisted.
//
// proxy.ts redirects this prefix before the auth gate; this handler is
// the belt-and-braces copy, matching /internal/requests.

export default async function InternalPortfolioPage() {
const apps = await listApplications({ audience: "internal" });
const groups = groupByHomeUnit(apps);

const total = apps.length;
const blockerCount = apps.reduce((sum, a) => sum + a.activeBlockers.length, 0);
const internalOnly = apps.filter((a) => a.visibilityTier === "internal").length;
const embargoed = apps.filter((a) => a.visibilityTier === "embargoed").length;

return (
<div className="space-y-10">
{/* Header */}
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-ui-gold-dark">
IIDS Internal
</p>
<h1 className="mt-2 text-3xl font-black tracking-tight text-ui-charcoal">
Internal portfolio view
</h1>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-gray-700">
The full IIDS-coordinated AI inventory, including embargoed and
internal-only entries, with sharper blocker detail (named
individuals, contact history) than the public portfolio shows.
</p>
<p className="mt-2 text-sm text-gray-500">
{total} projects · {embargoed} embargoed · {internalOnly}{" "}
internal-only · {blockerCount} active blocker
{blockerCount === 1 ? "" : "s"}
</p>
<p className="mt-3 text-xs text-gray-500">
<Link
href="/portfolio"
className="text-ui-gold-dark hover:underline"
>
View the public portfolio &rarr;
</Link>
</p>
</div>

{/* Groups by home unit */}
{groups.map(({ unit, items }) => (
<section key={unit} className="space-y-4">
<div className="flex items-baseline gap-3">
<h2 className="text-xl font-black tracking-tight text-brand-black">{unit}</h2>
<span className="text-sm text-ink-subtle">
{items.length}{" "}
{items.length === 1 ? "project" : "projects"}
</span>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map((app) => (
<PortfolioCard
key={app.id}
app={app}
audience="internal"
basePath="/internal/portfolio"
/>
))}
</div>
</section>
))}
</div>
);
export default function InternalPortfolioRedirect() {
redirect("/portfolio");
}
2 changes: 1 addition & 1 deletion components/ProjectDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ export interface ProjectDetailProps {
app: ApplicationWithBlockers;
related: RelatedApp[];
audience: "public" | "internal";
basePath: string; // "/portfolio" or "/internal/portfolio"
basePath: string; // "/portfolio" — the only caller since 2026-07-27
// ClickUp-synced status narrative + ROI (ADR 0004). Null/undefined when
// the project has no mapped ClickUp list or sync hasn't run.
clickup?: ClickUpProjectStatus | null;
Expand Down
36 changes: 36 additions & 0 deletions docs/adr/0005-unified-technology-request-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,42 @@ story with no public vs. internal distinction**.
queue**. Prioritization rounds (Phase 4) will make their own
visibility call when they land.

### 2026-07-27 — `/internal/portfolio` retired; the directive applies to the inventory too

**Context.** The 2026-07-24 amendment above retired `/internal/requests`
in favour of one public queue. It did not sweep `/internal/portfolio`,
which went on rendering a parallel view of the inventory — the same
duplication, one surface over.

The audit that found it also found that it had **no exclusive content**:

- No project has ever carried the `Internal-only` visibility tier (27
`Public`, 2 `Partial`). The internal audience adds `internal` to the
tier filter in `lib/work.ts`, and that tier matches nothing, so both
routes rendered the same 29 projects.
- Its one genuine difference — `internal_text` on blockers — held only
auto-derived placeholders from `scripts/seed-portfolio.ts`, whose text
reads *"Auto-seeded from … Refine with the actual submission date, the
named OIT contact."* Notes-to-self, not withheld institutional detail.

So the cost of the duplication was real (a second thing to keep current,
and a standing contradiction of the directive) and the benefit was zero.

**Decision.** `/internal/portfolio` and `/internal/portfolio/[slug]`
redirect to `/portfolio` and `/portfolio/<slug>`, in `proxy.ts` ahead of
the auth gate so a bookmarked link resolves without a credential prompt.
Deep links keep their slug. `/internal` keeps its ops surfaces — the sync
trigger and the agent log — and nothing else.

**The seam stays.** `audience: "internal"` remains in `lib/work.ts`, and
`blockers.internal_text` remains in the schema. If sharper blocker detail
is ever genuinely authored, the query path back is intact. What is gone
is a *surface* that existed by default rather than by decision.

**Generalised.** The directive is not "one request queue" — it is **one
view of anything the public site shows**. `/internal` is for operating
the site, not for reading it differently.

### 2026-07-27 — Status: Proposed → Accepted for Phase 1

**Context.** This ADR was written as a proposal to the UTR working group
Expand Down
11 changes: 9 additions & 2 deletions lib/work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
// - internal: not on the public site at all
//
// Blockers carry public_text (safe for /portfolio) and internal_text
// (sharper detail, only shown on /internal/portfolio).
// (sharper detail). No surface renders internal_text as of 2026-07-27 —
// /internal/portfolio was retired as a duplicate inventory view. The
// column and the query seam stay; restoring a reader for them is a
// deliberate decision, not a default.

import "server-only";
import { query } from "./db";
Expand Down Expand Up @@ -328,7 +331,11 @@ export interface ListOptions {
* - "public" — default for the public /portfolio surface; includes
* public + embargoed (embargoed entries render with a held-detail
* notice but are not hidden).
* - "internal" — for /internal/portfolio; includes all three tiers.
* - "internal" — includes all three tiers. No route passes this since
* /internal/portfolio was retired (2026-07-27); the /internal index
* still uses it for its ops counts. Note that no project has ever
* carried the `Internal-only` tier, so today it selects the same
* rows as "public".
*/
audience?: "public" | "internal";
}
Expand Down
11 changes: 11 additions & 0 deletions proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ export function proxy(req: NextRequest) {
);
}

// /internal/portfolio was the second inventory view the same directive
// rules out (ADR 0005 amendment, 2026-07-27). It survived the July
// sweep that caught /internal/requests. Deep links keep their slug so a
// bookmarked project lands on that project, not the index.
if (req.nextUrl.pathname.startsWith("/internal/portfolio")) {
const slug = req.nextUrl.pathname.slice("/internal/portfolio".length);
return NextResponse.redirect(
new URL(`/portfolio${slug}`, req.nextUrl.origin)
);
}

const user = process.env.BASIC_AUTH_USER;
const pass = process.env.BASIC_AUTH_PASS;

Expand Down
Loading