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: 51 additions & 0 deletions docs/specs/2026-06-01-chat-inventory-intake-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,3 +310,54 @@ Each phase is independently shippable.
- Promoting the registry so the chat consumes capabilities *over* MCP (Approach 3),
once capabilities stabilize.
- Optional light gate on the intake front door if draft spam becomes a problem.

---

## Amendments

Appended per [`DRIFT.md`](DRIFT.md). Original text above is never edited.

### 2026-09-14 — §6.1 vision now reaches the model (as-built fix)

**What changed.** §6.1 requires the photo's bytes on the outgoing user message. The chat
client never sent them: `ChatFab` called `sendMessage({ text })`, so only the
`[Attached photos: …]` hint went out and the model never saw a photo, although the route
already accepted image parts. Now, alongside the Notion upload, the browser draws a JPEG copy
no larger than 1568px on the long edge and sends it as a file part on the same message. The
hint is unchanged, so `create_tool` still attaches the same Notion upload.

**Two details §6.1 did not specify.**

1. **Earlier photos are bounded.** The chat re-sends the whole conversation every turn. The
latest user message keeps all its photos; earlier turns keep at most four more, newest
first (`withRecentPhotos`), so a follow-up about a recent photo still works without every
photo riding along forever (Article 4).
2. **A photo the browser cannot decode still uploads.** HEIC in most browsers produces no copy
for the model, which is exactly the behaviour before this fix.

**Why it was missed.** No test followed a photo from the client to the model; the component
test checked only the hint text. Both ends are now asserted: `ChatFab.test.tsx` checks the file
part is sent, and the chat route test checks it reaches `streamText`.

**Status.** Accepted. The spec was right and the code did not meet it.

### 2026-09-14 — the duplicate card no longer offers "add a unit" (amends §4.4)

**What changed.** §4.4's duplicate card offered **Add a unit to the existing tool**. The button
seeded a message nothing could act on: `create_tool` always creates a new tool, and no tool
adds a unit to an existing one. The card now offers **No, create a new tool** and **Discard**,
and the prompt tells the assistant to point at the existing listing and say that staff add
units for now. The prompt also now says how to resolve **No, create a new tool**, which it
never listed. `intake.actionAddUnit` is removed from all 12 locale files.

**Why remove rather than build.** Adding units is being redesigned as part of moving v5's data
off Notion, and a button that visibly does nothing is worse than no button.

**Status.** Accepted.

### 2026-09-14 — intake is staff-only (answers §11's light gate)

§11 left open a gate on the intake front door. It now exists — see the auth spec's amendment
of the same date. Anonymous visitors and students cannot use intake on the chat surface.

**Status.** Accepted.
23 changes: 23 additions & 0 deletions docs/specs/2026-07-29-auth-and-rate-limiting-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,3 +412,26 @@ duplicated in `auth.spec.ts`.

**Status.** Accepted. The spec's intent (no live OAuth, assert the header states) is met; the
mechanism is one layer up from the cookie because the suite deliberately has no secret.

### 2026-09-14 — adding equipment is staff-only (amends §3.4 and §8)

**What changed.** §3.4 and §8 say no capability is role-gated in v5. The intake capability
(`research_tool`, `propose_listing`, `create_tool`) now declares a minimum role of `staff`.
On the chat surface an anonymous visitor or a signed-in student gets none of its tools, and
its prompt fragment is replaced by a short note telling the assistant that adding equipment
is limited to staff. The header's "Add" entry point appears only for staff and admins.

**Where the rule lives.** A capability declares `minimumRole`; `capabilitiesForRole` in
`v5/src/lib/capabilities/access.ts` enforces it once, when the chat composes its tools. That
keeps §3.4's rule that authorization is enforced once rather than per tool, and no tool's
`run()` checks a role. MCP is unchanged: its trust boundary is `MCP_TOKEN`, which already
gates `create_tool` there, and an MCP caller has no role to check.

**Why.** Drafts-by-default (Article 5) kept unreviewed listings out of the catalog, but not out
of Notion: any visitor could create draft pages and trigger paid web research. The ISAM demo
puts the live app in front of a public audience on shared wifi, and the lab's direction as of
2026-09-14 is that admins and SuperMakers add equipment, not visitors. Until in-app roles
exist, SuperMakers belong on `AUTH_STAFF_EMAILS`.

**Status.** Accepted. Covered by `src/lib/capabilities/access.test.ts`, the chat route tests
(anonymous, student, staff), `intake.test.ts`, and `PrimaryNav.test.tsx`.
11 changes: 11 additions & 0 deletions docs/specs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@ The banner is the one worth learning from: **no test and no mechanical check cou
caught it.** A missing banner adds no surface and breaks nothing. It took opening the page
and noticing the catalogue claimed two machines with nothing saying they were invented.

### Pre-ISAM fixes, 2026-09-14

Found by reading the code ahead of the ISAM demo. None of them adds or removes surface, so
`spec:coverage` could not see them.

| Finding | Bucket | Outcome |
|---|---|---|
| Intake §6.1 **photos never reached the model** — the client sent only the text hint | NOT-BUILT | **Closed** — a downscaled image part goes out with the message; intake spec amended |
| Intake §4.4 **"add a unit" button** seeded a message no tool could act on | NOT-BUILT | **Closed** — button withdrawn; intake spec amended |
| **Anyone could add equipment**, anonymous visitors included | DECISION | **Closed** — intake is staff-only; auth spec amended |

Also here: [`2026-05-29-v5-test-suite-design.md`](2026-05-29-v5-test-suite-design.md)
(implemented) and [`2026-06-01-chat-inventory-intake-design.md`](2026-06-01-chat-inventory-intake-design.md)
(implemented; extended by #8).
Expand Down
1 change: 0 additions & 1 deletion v5/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,6 @@
"actionConfirmVariant": "إنه {variant}",
"actionEdit": "تعديل",
"actionDiscard": "تجاهل",
"actionAddUnit": "أضِف وحدة إلى الأداة الموجودة",
"actionCreateAnyway": "لا، أنشئ أداة جديدة",
"actionOpenDraft": "افتح المسودة في Notion"
},
Expand Down
1 change: 0 additions & 1 deletion v5/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,6 @@
"actionConfirmVariant": "It’s the {variant}",
"actionEdit": "Edit",
"actionDiscard": "Discard",
"actionAddUnit": "Add a unit to the existing tool",
"actionCreateAnyway": "No, create a new tool",
"actionOpenDraft": "Open draft in Notion"
},
Expand Down
1 change: 0 additions & 1 deletion v5/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,6 @@
"actionConfirmVariant": "Es el {variant}",
"actionEdit": "Editar",
"actionDiscard": "Descartar",
"actionAddUnit": "Añadir una unidad a la herramienta existente",
"actionCreateAnyway": "No, crear una herramienta nueva",
"actionOpenDraft": "Abrir el borrador en Notion"
},
Expand Down
1 change: 0 additions & 1 deletion v5/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,6 @@
"actionConfirmVariant": "C’est le {variant}",
"actionEdit": "Modifier",
"actionDiscard": "Écarter",
"actionAddUnit": "Ajouter une unité à l’outil existant",
"actionCreateAnyway": "Non, créer un nouvel outil",
"actionOpenDraft": "Ouvrir le brouillon dans Notion"
},
Expand Down
1 change: 0 additions & 1 deletion v5/messages/he.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,6 @@
"actionConfirmVariant": "זה {variant}",
"actionEdit": "עריכה",
"actionDiscard": "התעלמות",
"actionAddUnit": "הוספת יחידה לכלי הקיים",
"actionCreateAnyway": "לא, ליצור כלי חדש",
"actionOpenDraft": "פתיחת הטיוטה ב-Notion"
},
Expand Down
1 change: 0 additions & 1 deletion v5/messages/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,6 @@
"actionConfirmVariant": "यह {variant} है",
"actionEdit": "संपादित करें",
"actionDiscard": "छोड़ दें",
"actionAddUnit": "मौजूदा टूल में एक यूनिट जोड़ें",
"actionCreateAnyway": "नहीं, नया टूल बनाएँ",
"actionOpenDraft": "Notion में ड्राफ़्ट खोलें"
},
Expand Down
1 change: 0 additions & 1 deletion v5/messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,6 @@
"actionConfirmVariant": "{variant} です",
"actionEdit": "編集",
"actionDiscard": "破棄",
"actionAddUnit": "既存のツールにユニットを追加",
"actionCreateAnyway": "いいえ、新しいツールを作成",
"actionOpenDraft": "Notion で下書きを開く"
},
Expand Down
1 change: 0 additions & 1 deletion v5/messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,6 @@
"actionConfirmVariant": "{variant}입니다",
"actionEdit": "편집",
"actionDiscard": "버리기",
"actionAddUnit": "기존 도구에 유닛 추가",
"actionCreateAnyway": "아니요, 새 도구 만들기",
"actionOpenDraft": "Notion에서 초안 열기"
},
Expand Down
1 change: 0 additions & 1 deletion v5/messages/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,6 @@
"actionConfirmVariant": "É o {variant}",
"actionEdit": "Editar",
"actionDiscard": "Descartar",
"actionAddUnit": "Adicionar uma unidade à ferramenta existente",
"actionCreateAnyway": "Não, criar uma ferramenta nova",
"actionOpenDraft": "Abrir o rascunho no Notion"
},
Expand Down
1 change: 0 additions & 1 deletion v5/messages/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,6 @@
"actionConfirmVariant": "Это {variant}",
"actionEdit": "Изменить",
"actionDiscard": "Отклонить",
"actionAddUnit": "Добавить единицу к существующему инструменту",
"actionCreateAnyway": "Нет, создать новый инструмент",
"actionOpenDraft": "Открыть черновик в Notion"
},
Expand Down
1 change: 0 additions & 1 deletion v5/messages/tr.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,6 @@
"actionConfirmVariant": "Bu {variant}",
"actionEdit": "Düzenle",
"actionDiscard": "Vazgeç",
"actionAddUnit": "Mevcut araca bir birim ekle",
"actionCreateAnyway": "Hayır, yeni bir araç oluştur",
"actionOpenDraft": "Taslağı Notion’da aç"
},
Expand Down
1 change: 0 additions & 1 deletion v5/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,6 @@
"actionConfirmVariant": "是 {variant}",
"actionEdit": "编辑",
"actionDiscard": "丢弃",
"actionAddUnit": "为已有工具添加一台设备",
"actionCreateAnyway": "不,创建新工具",
"actionOpenDraft": "在 Notion 中打开草稿"
},
Expand Down
95 changes: 95 additions & 0 deletions v5/src/app/api/chat/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,3 +563,98 @@ describe("PDF manual collection (focused tool)", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
});

// ── Adding equipment is staff-only (auth spec amendment 2026-09-14) ──
describe("POST /api/chat — who may add equipment", () => {
const INTAKE_TOOLS = ["research_tool", "propose_listing", "create_tool"];
const SECRET = "chat-route-test-secret";
const ASK = "I'd like to add new equipment to the inventory.";

async function postAs(email: string, name: string) {
vi.stubEnv("AUTH_SECRET", SECRET);
const token = await signSession(
createSessionPayload({ sub: `sub-${email}`, email, name }),
SECRET
);
await POST(
chatRequest(
{ messages: [userMessage(ASK)] },
{ cookie: `${SESSION_COOKIE_NAME}=${token}` }
)
);
}

it("gives an anonymous visitor no intake tools, and tells the assistant why", async () => {
await POST(chatRequest({ messages: [userMessage(ASK)] }));

for (const name of INTAKE_TOOLS) {
expect(captured.args.tools).not.toHaveProperty(name);
}
expect(captured.args.system).toContain("limited to lab staff");
expect(captured.args.system).not.toContain("act as an intake agent");
});

it("gives a signed-in student no intake tools either", async () => {
await postAs("ada@cornell.edu", "Ada Lovelace");

for (const name of INTAKE_TOOLS) {
expect(captured.args.tools).not.toHaveProperty(name);
}
expect(captured.args.system).toContain("limited to lab staff");
});

it("gives staff the intake tools and the full intake instructions", async () => {
vi.stubEnv("AUTH_STAFF_EMAILS", "niti@cornell.edu");
await postAs("niti@cornell.edu", "Niti Parikh");

for (const name of INTAKE_TOOLS) {
expect(captured.args.tools).toHaveProperty(name);
}
expect(captured.args.system).toContain("act as an intake agent");
expect(captured.args.system).not.toContain("limited to lab staff");
});

it("keeps reporting a problem open to anonymous visitors", async () => {
await POST(chatRequest({ messages: [userMessage("the printer is jammed")] }));
expect(captured.args.tools).toHaveProperty("report_issue");
});
});

// ── Photos reach the model (intake spec §6.1) ────────────────────────
describe("POST /api/chat — photos reach the model", () => {
it("passes an attached photo to the model on the user message", async () => {
await POST(
chatRequest({
messages: [
{
id: "1",
role: "user",
parts: [
{
type: "text",
text: "what printer is this?\n\n[Attached photos: file_upload_id=fu_1 name=plate.jpg]",
},
{
type: "file",
mediaType: "image/jpeg",
filename: "plate.jpg",
url: "data:image/jpeg;base64,AAAA",
},
],
},
],
})
);

const user = captured.args.messages.find((m: any) => m.role === "user");
expect(user.content).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "file",
mediaType: "image/jpeg",
data: "data:image/jpeg;base64,AAAA",
}),
])
);
});
});
20 changes: 14 additions & 6 deletions v5/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import { checkRateLimit, type RateLimitDecision } from "../../../lib/rate-limit"
import { resolveIdentity } from "../../../lib/auth/identity";
import { siteConfig } from "../../../lib/site-config";
import { chatModel } from "../../../lib/model";
import { CAPABILITIES, composeChat } from "../../../lib/capabilities";
import {
CAPABILITIES,
capabilitiesForRole,
composeChat,
} from "../../../lib/capabilities";
import type {
CapabilityCtx,
UploadedImage,
Expand Down Expand Up @@ -117,11 +121,15 @@ export async function POST(req: Request) {
// then add the provider-native research tools (Anthropic web tools are not
// capabilities — the intake capability's prompt tells the agent to use
// them). web_fetch keeps the focused-tool domain allow-list.
const { tools: capabilityTools, system } = composeChat(CAPABILITIES, ctx, {
tools,
focusedTool: focused,
locale,
});
//
// The registry is composed as this caller may use it: a capability whose
// minimum role they do not meet contributes no tools, only a note on why
// (auth spec amendment 2026-09-14).
const { tools: capabilityTools, system } = composeChat(
capabilitiesForRole(CAPABILITIES, identity.role),
ctx,
{ tools, focusedTool: focused, locale }
);

const result = streamText({
model: chatModel,
Expand Down
Loading