diff --git a/CLAUDE.md b/CLAUDE.md
index bf3ffb3..18756f4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/app/internal/page.tsx b/app/internal/page.tsx
index 8d516da..8e620e4 100644
--- a/app/internal/page.tsx
+++ b/app/internal/page.tsx
@@ -24,10 +24,16 @@ export default async function InternalHome() {
Internal coordination view
- 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{" "}
+
+ /portfolio
+ {" "}
+ and the request queue at{" "}
+
+ /portfolio/pipeline
+
+ . The site tells one story; there is no internal copy of either.
@@ -52,17 +58,17 @@ export default async function InternalHome() {
- Drill in
+ Inventory
- Internal portfolio →
+ Projects →
- Full inventory with internal-text blocker detail
+ Public at /portfolio — the same records these counts describe
@@ -114,13 +120,23 @@ export default async function InternalHome() {
- Coming in Sprint 3
+ Still outstanding
-
• ClickUp write-side — new submissions create ClickUp tasks
-
• Submitter status pages (/intake/[token])
-
• Submission similarity surfaced live during the assessment
+ • Named-SLA acknowledgment email on intake. The named
+ human and SLA render on the results page and on{" "}
+ /intake/[token], but nothing is actually sent — there
+ is no mailer in the stack.
+
+
+ • TDX request sync (scripts/sync-tdx.ts) — blocked
+ on API access. ADR 0005 Phase 4.
+
diff --git a/app/internal/portfolio/[slug]/page.tsx b/app/internal/portfolio/[slug]/page.tsx
index 5b8f658..250f951 100644
--- a/app/internal/portfolio/[slug]/page.tsx
+++ b/app/internal/portfolio/[slug]/page.tsx
@@ -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 (
-
- );
+ redirect(`/portfolio/${slug}`);
}
diff --git a/app/internal/portfolio/page.tsx b/app/internal/portfolio/page.tsx
index 6d2bad9..757c963 100644
--- a/app/internal/portfolio/page.tsx
+++ b/app/internal/portfolio/page.tsx
@@ -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 (
-
- {/* Header */}
-
-
- IIDS Internal
-
-
- Internal portfolio view
-
-
- 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.
-
- );
+export default function InternalPortfolioRedirect() {
+ redirect("/portfolio");
}
diff --git a/components/ProjectDetail.tsx b/components/ProjectDetail.tsx
index bca72e8..4aad7e3 100644
--- a/components/ProjectDetail.tsx
+++ b/components/ProjectDetail.tsx
@@ -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;
diff --git a/docs/adr/0005-unified-technology-request-registry.md b/docs/adr/0005-unified-technology-request-registry.md
index 3a61077..88dcb2e 100644
--- a/docs/adr/0005-unified-technology-request-registry.md
+++ b/docs/adr/0005-unified-technology-request-registry.md
@@ -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/`, 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
diff --git a/lib/work.ts b/lib/work.ts
index ec3a872..71e58af 100644
--- a/lib/work.ts
+++ b/lib/work.ts
@@ -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";
@@ -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";
}
diff --git a/proxy.ts b/proxy.ts
index f7e542b..c390a76 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -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;