From 6298c1fcb8ba25817b07b659ca461bd0150df9bb Mon Sep 17 00:00:00 2001 From: ProfessorPolymorphic <116023536+ProfessorPolymorphic@users.noreply.github.com> Date: Mon, 27 Jul 2026 06:57:16 -0700 Subject: [PATCH] =?UTF-8?q?Retire=20/internal/portfolio=20=E2=80=94=20the?= =?UTF-8?q?=20inventory=20has=20one=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-07-24 amendment to ADR 0005 retired /internal/requests in favour of one public queue. It missed /internal/portfolio, which kept rendering a parallel view of the inventory — the same duplication, one surface over. It had no exclusive content. No project has ever carried the `Internal-only` visibility tier (27 Public, 2 Partial), so the extra tier the internal audience adds to the query in lib/work.ts matched nothing and both routes rendered the same 29 projects; the live /internal counts confirm it reads "2 embargoed · 0 internal-only". Its one real difference, internal_text on blockers, held only auto-derived placeholders from the seed script whose text reads "Auto-seeded from … Refine with the actual submission date, the named OIT contact" — notes to ourselves, not withheld institutional detail. Real cost, zero benefit. Both routes now redirect to their public equivalents in proxy.ts, ahead of the auth gate, so a bookmarked link resolves without a credential prompt; deep links keep their slug. Verified in the browser: /internal/portfolio lands on /portfolio unauthenticated, and /internal/portfolio/openera lands on /portfolio/openera. The seam stays. audience:"internal" remains in lib/work.ts and blockers.internal_text remains in the schema, both with comments saying no surface reads them. Restoring a reader should be a decision, not a page that quietly persisted. /internal now describes itself as ops-only and links out to the public inventory rather than a private copy of it. Its "Coming in Sprint 3" list was also stale — the submitter status page and live similarity shipped in Sprint 3a — so it now names only what is genuinely outstanding, including the honest detail that the named-SLA acknowledgment email has never been sent because there is no mailer in the stack. ADR 0005 gains an amendment generalising the rule: it is not "one request queue", it is one view of anything the public site shows. /internal is for operating the site, not reading it differently. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 14 +-- app/internal/page.tsx | 42 ++++++--- app/internal/portfolio/[slug]/page.tsx | 46 ++-------- app/internal/portfolio/page.tsx | 85 ++++--------------- components/ProjectDetail.tsx | 2 +- ...005-unified-technology-request-registry.md | 36 ++++++++ lib/work.ts | 11 ++- proxy.ts | 11 +++ 8 files changed, 117 insertions(+), 130 deletions(-) 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

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

-

- {total} projects · {embargoed} embargoed · {internalOnly}{" "} - internal-only · {blockerCount} active blocker - {blockerCount === 1 ? "" : "s"} -

-

- - View the public portfolio → - -

-
- - {/* Groups by home unit */} - {groups.map(({ unit, items }) => ( -
-
-

{unit}

- - {items.length}{" "} - {items.length === 1 ? "project" : "projects"} - -
-
- {items.map((app) => ( - - ))} -
-
- ))} -
- ); +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;