-
Notifications
You must be signed in to change notification settings - Fork 0
Every surface that acts resolves availability, not just the nav #96
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6f04098
1d7e171
1d46717
b6406a8
94f2d81
5f02caa
5ce911d
f3d7df7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| -- Index the read that decides capability availability. | ||
| -- | ||
| -- `capability-registry/manifest.ts` answers "what does this tenant's manifest | ||
| -- declare?" with the newest `Tenant.Reconciled` audit row for the institution. | ||
| -- Until this slice that ran once per page render, from the app layout. It now | ||
| -- also runs on every route bound to a capability and on every fifteen-second | ||
| -- poll of /api/reports/pulse, so its cost sits on ordinary traffic. | ||
| -- | ||
| -- The existing ("institutionId", "occurredAt") index cannot serve it: the | ||
| -- planner walks that institution's audit history backwards, discarding every | ||
| -- row whose action is not Tenant.Reconciled. For a tenant with NO reconcile row | ||
| -- — which is every tenant today, the pilot included — that is a walk of the | ||
| -- whole history to return nothing, and an append-only log only grows. | ||
| -- | ||
| -- Putting "action" between the two makes it a single index seek. The cost is | ||
| -- one more index to maintain on an insert-only table, which is the right side | ||
| -- of the trade for a read that is now on the request path. | ||
| CREATE INDEX "AuditEvent_institutionId_action_occurredAt_idx" | ||
| ON "AuditEvent"("institutionId", "action", "occurredAt"); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,8 @@ import { | |
| toSeatFacts, | ||
| } from "@/lib/seats" | ||
| import { auth } from "@/lib/auth" | ||
| import { offeredTo } from "@/lib/capability-registry/gate" | ||
| import { CapabilityUnavailable } from "@/components/CapabilityUnavailable" | ||
| import { db } from "@/lib/db" | ||
| import { canViewOrg, getUserContext } from "@/lib/rbac" | ||
| import { withTenantScope } from "@/lib/tenant-scope" | ||
|
|
@@ -73,6 +75,25 @@ export default async function HandoffPage({ | |
| const ctx = await getUserContext(session.user.id) | ||
| if (!canViewOrg(ctx, org)) notFound() | ||
|
|
||
| // Bound to `collaboration.institutional-memory` in routes.ts — the handoff | ||
| // packet is the preserved knowledge for a seat, so it is the same capability | ||
| // the memory route carries, and a tenant that does not have one does not | ||
| // have the other. | ||
| const memory = await offeredTo("collaboration.institutional-memory", org.institutionId) | ||
| if (!memory.available) | ||
| return ( | ||
| <div className="w-full"> | ||
| <div className="mb-6"> | ||
| <h1 className="text-text-1">{org.name}</h1> | ||
| </div> | ||
| {/* The refusal keeps the club's tab strip. Members, Finance, | ||
| Documents and Impact are unaffected by this capability, and a page | ||
| that withholds one thing must not also strand the person on it. */} | ||
| <OrgTabs slug={slug} /> | ||
| <CapabilityUnavailable decision={memory} /> | ||
| </div> | ||
| ) | ||
|
Comment on lines
+78
to
+95
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win Resolve availability before the handoff data query. The query at Lines 56-72 runs before this gate. It loads advisors, roles, holdings, assignments, and memory-record counts even when the capability is unavailable. First load only the organization fields required for authorization and capability resolution. Run the full handoff query only after 🤖 Prompt for AI Agents |
||
|
|
||
| const now = new Date() | ||
| const [deliverables, pendingApprovals, budgetLines] = await Promise.all([ | ||
| db.deliverable.findMany({ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| import { CapabilityUnavailableError } from "@/lib/capability-registry/gate" | ||
| import { createMemoryCard } from "./actions" | ||
|
|
||
| /** | ||
| * The mutating surface refuses, and the refusal is reachable. | ||
| * | ||
| * ── Why this test exists at all ───────────────────────────────────────────── | ||
| * | ||
| * /orgs/[slug]/memory has been bound to `collaboration.institutional-memory` | ||
| * since the second slice, and until this one the binding was read by CI and by | ||
| * nothing at runtime. The side nav resolved the capability and dropped the | ||
| * link; the page still rendered its "Add to memory" form, and this action still | ||
| * wrote. A gate that only removes a link cannot refuse a form POST, and a form | ||
| * POST is what writes to the database. | ||
| * | ||
| * So the assertion that matters is not that an error is thrown — it is that | ||
| * `$transaction` is never reached. A refusal that fires after the write is not | ||
| * a refusal. | ||
| * | ||
| * ── The two questions, kept apart ─────────────────────────────────────────── | ||
| * | ||
| * `canContribute` is mocked TRUE throughout. Every refusal below is therefore | ||
| * the deployment's answer, not the person's: someone who unambiguously may | ||
| * write is still refused because this tenant is not offered the capability. | ||
| * That is the property the registry exists for — promotion is not procurement | ||
| * — and it would be invisible in a test where the role could also explain it. | ||
| */ | ||
|
|
||
| const mockState: { modules: string[] | null; wrote: boolean } = { | ||
| modules: ["organizations@1.0.0"], | ||
| wrote: false, | ||
| } | ||
|
|
||
| jest.mock("next/cache", () => ({ revalidatePath: () => undefined })) | ||
|
|
||
| jest.mock("@/lib/auth", () => ({ auth: async () => ({ user: { id: "user-1" } }) })) | ||
|
|
||
| jest.mock("@/lib/tenant-scope", () => ({ | ||
| withTenantScope: <T,>(_userId: string, fn: () => Promise<T>) => fn(), | ||
| })) | ||
|
|
||
| jest.mock("@/lib/rbac", () => ({ | ||
| getUserContext: async () => ({ institutionRoles: [], orgRoles: [] }), | ||
| // The permission question, answered YES for every case below. | ||
| canContribute: () => true, | ||
| })) | ||
|
|
||
| jest.mock("@/lib/db", () => ({ | ||
| db: { | ||
| organization: { | ||
| findUnique: async () => ({ id: "org-1", institutionId: "inst-1", slug: "chess" }), | ||
| }, | ||
| role: { findFirst: async () => null }, | ||
| auditEvent: { | ||
| findFirst: async () => | ||
| mockState.modules === null ? null : { metadata: { modules: mockState.modules } }, | ||
| create: () => ({}), | ||
| }, | ||
| memoryRecord: { create: () => ({}) }, | ||
| $transaction: async () => { | ||
| mockState.wrote = true | ||
| return [] | ||
| }, | ||
| }, | ||
| })) | ||
|
|
||
| function card(): FormData { | ||
| const form = new FormData() | ||
| form.set("type", "LESSON") | ||
| form.set("title", "Book the hall before the caterer") | ||
| form.set("body", "The hall goes first every year and the caterer never does.") | ||
| return form | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| mockState.modules = ["organizations@1.0.0"] | ||
| mockState.wrote = false | ||
| }) | ||
|
|
||
| describe("createMemoryCard resolves availability before it writes", () => { | ||
| it("writes when the tenant's manifest declares the module", async () => { | ||
| // The control's control: without this, every assertion below could pass | ||
| // because the action never works. | ||
| await createMemoryCard("chess", card()) | ||
| expect(mockState.wrote).toBe(true) | ||
| }) | ||
|
|
||
| it("refuses, and writes nothing, when the manifest does not declare it", async () => { | ||
| mockState.modules = ["reporting@1.0.0"] | ||
|
|
||
| await expect(createMemoryCard("chess", card())).rejects.toBeInstanceOf( | ||
| CapabilityUnavailableError, | ||
| ) | ||
| expect(mockState.wrote).toBe(false) | ||
| }) | ||
|
|
||
| it("names the module the tenant is missing, not just 'unavailable'", async () => { | ||
| mockState.modules = [] | ||
|
|
||
| let refusal: CapabilityUnavailableError | null = null | ||
| try { | ||
| await createMemoryCard("chess", card()) | ||
| } catch (e) { | ||
| refusal = e as CapabilityUnavailableError | ||
| } | ||
| if (!refusal) throw new Error("the action wrote; the capability gate never fired") | ||
|
|
||
| expect(refusal.decision.refusals[0].code).toBe("MODULE_NOT_DECLARED") | ||
| expect(refusal.decision.refusals[0].detail).toContain("organizations") | ||
| }) | ||
|
|
||
| it("still writes for a tenant nobody has reconciled", async () => { | ||
| // null is not []. A hand-built pilot has no manifest, and refusing it here | ||
| // would take a working product dark on a document that does not exist. | ||
| mockState.modules = null | ||
|
|
||
| await createMemoryCard("chess", card()) | ||
| expect(mockState.wrote).toBe(true) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
Repository: Tenurework/Tenure
Length of output: 13676
🏁 Script executed:
Repository: Tenurework/Tenure
Length of output: 50373
🏁 Script executed:
Repository: Tenurework/Tenure
Length of output: 7612
🏁 Script executed:
Repository: Tenurework/Tenure
Length of output: 13431
Avoid blocking live
AuditEventwrites during index creation.Production runs PostgreSQL migrations through
prisma migrate deploywhile the existing service remains live. PlainCREATE INDEXblocks concurrent writes toAuditEventuntil the index build completes.Apply
CREATE INDEX CONCURRENTLYoutside Prisma’s migration transaction, or run this migration during a write-safe maintenance window.🧰 Tools
🪛 Squawk (2.61.0)
[warning] 18-19: During normal index creation, table updates are blocked, but reads are still allowed. Use
concurrentlyto avoid blocking writes.(require-concurrent-index-creation)
🤖 Prompt for AI Agents
Source: Linters/SAST tools