Workspace Modules

The plug-in contract for web workspace modules.

Overview

The KAP web app is organized into workspaces (Data Explorer, Integrity Engineer), each a set of modules (Campaigns, Investigate, Assets, …). A module is a self-contained page that plugs into the shell through a small, typed registry — so adding a feature is registering it, not rewiring the shell. The contract lives in web/lib/workspaces/.

The module registry

Every module is one ModuleId (a string-literal union in config.ts) with an entry in MODULE_REGISTRY (modules.ts):

interface WorkspaceModule {
  id: ModuleId;
  label: string;
  icon?: LucideIcon;
  path: string;                 // URL segment: /w/<workspace>/<path>
  requires?: 'dataset' | 'organization' | 'none';
  requiredRole?: WorkspaceRole; // role gate
}

ModuleId is the single source of truth; MODULE_REGISTRY is Record<ModuleId, WorkspaceModule>, so a missing entry is a type error (a test also asserts one key per ModuleId).

Per-workspace configuration

WORKSPACES (config.ts) is Record<WorkspaceSlug, WorkspaceConfig>. Each workspace declares which modules it exposes, their order/labels/grouping, its default module, and its default AI engine:

interface WorkspaceConfig {
  slug: WorkspaceSlug;
  routePrefix: string;               // /w/integrity
  modules?: ModuleId[];              // which modules exist here
  navigation?: WorkspaceNavigationItem[]; // order, label, description, group
  defaultModule?: ModuleId;
  defaultEngine?: SystemId;          // maps to system_type (see Web ↔ AI Integration)
}

navigation items carry a workspace-specific label + description and a group (scope | prepare | review | act | assist) — the same ModuleId can be labelled differently per workspace (e.g. insights is “Notes” in Data Explorer, “Findings” in Integrity).

Rendering the sidebar

buildSidebarModuleItems() (navigation.ts) is the pure function the sidebar consumes. It takes the workspace slug, the current module, and two gates:

  • canAccessModule(module) — role gate (from WorkspaceProvider). A failing module stays visible but disabled with a “Requires role” tooltip. The role model behind it — and where the gate is (and isn’t) enforced server-side — is documented in Roles & Access Control.
  • hiddenModules — a Set<ModuleId> removed entirely from the nav (data-driven). Derived in context.tsx from scope; e.g. assets is hidden unless the org in scope actually has assets.

Items are grouped by navigation.group and rendered by components/sidebar.tsx. WorkspaceProvider (context.tsx) exposes current, currentModule, scope, canAccessModule, and hiddenModules via useWorkspace().

Context bar

context-policy.ts maps each module to a WorkspaceContextPolicy — whether the context bar shows an organization/campaign selector, a read-only summary, or nothing, plus campaignRequired. Modules with no entry fall back to a sensible default. (The Assets policy is org-scoped: organization: 'summary', campaign: 'hidden'.)

The page

A module’s page lives at app/(authenticated)/w/<workspace>/<module>/page.tsx. It’s a normal Next.js server component; the registry gives it its nav entry, route, role gate, and context policy.

Worked example: the Assets module

Adding the org-scoped Assets register touched exactly the contract surfaces above — nothing in the shell:

  1. config.ts — add 'assets' to the ModuleId union, to each workspace’s modules, and a navigation entry (Integrity → review, Data Explorer → prepare); add it to the isModuleId runtime list.
  2. modules.ts — a MODULE_REGISTRY.assets entry (icon: Boxes, requires: 'none', requiredRole: 'viewer').
  3. context-policy.ts — an org-scoped policy (no campaign selector).
  4. context.tsx — org-driven hiddenModules gating (hide assets when the org in scope has no assets), fed by orgsWithAssets from the layout.
  5. Pages — w/integrity/assets/page.tsx and w/data-explorer/assets/page.tsx rendering the shared client.

The two asset pages show different facets — and different verbs

AssetsRegisterClient is one component behind both routes, switched by showFindings / showImages / showAnomalies. The split is not cosmetic: it divides the finding workflow by act.

Data Explorer Integrity
Anomalies block (read-only) yes no
Finding evidence block yes no
Findings block + Decide no yes
Create a finding from an anomaly on the asset no “New finding” → the class checklist
Claim a condition nobody drew no “New finding” → Claim a condition not listed above
Curate what one cites a finding row under “Finding evidence” no
Read why it was decided no “history” on a finding row
Correct one after it was raised no “Correct” on a finding row

“New finding” offers two paths, and the difference matters. The checklist is the asset’s own unclaimed anomaly classes: tick one and it becomes a finding citing that class’s frames automatically. That is triage of what the AI or a reviewer already drew, and it is most of the work. Beneath it, “Claim a condition not listed above” opens a picker over both catalogs — the API-571 mechanisms and the full observation vocabulary — for the condition an engineer sees that nobody annotated.

Until 2026-08-25 only the checklist existed, so a finding could be raised only for something already drawn, and damage_mechanism_id — which createEquipmentDamageMechanism has always accepted — was reachable from no screen. A class claimed through the picker cites no images: nobody has drawn it, so there is nothing to cite, and evidence is added later from the Data Explorer.

A class the asset already has a finding on is offered anyway, marked. The checklist badges it 2nd; the picker appends “already has a finding”. Raising it produces a second, separate claim with its own evidence — which is the point of D4-D: the class classifies a finding, it does not identify one, so corrosion on the north nozzle and corrosion on the south one are two things to inspect rather than one row to argue over. The route refused this with a 409 until 2026-08-25, two days after migration 20260823140000 dropped the unique constraint it was enforcing. Marked rather than hidden, because raising a duplicate unknowingly is the failure mode worth preventing — and hiding the class prevented the legitimate case instead.

A finding is a claim that an asset exhibits a condition, identified by the pair (asset, condition class). Making that claim is a judgement, so it belongs to the integrity engineer; preparing the evidence is the Data Explorer’s job, so that is where evidence is contributed to a claim already made. The two controls map onto POST /equipment-damage-mechanisms and PATCH /equipment-damage-mechanisms/{id}/evidence respectively.

Evidence is curated per finding, not per anomaly class. Until 2026-08-25 the editor hung off each anomaly row in the Anomalies block and matched a finding to the class by annotation_categories id. Three findings could never reach it: one grounded in an API-571 mechanism (no such id to join on — so the findings most likely to matter had no evidence control at all), one claimed directly from the class picker (its class need not appear on any anomaly), and any finding needing a frame that shows the condition without carrying the class.

The Finding evidence block keys on the finding instead: each one expands to the asset’s whole confirmed set, every frame individually citable through PATCH /equipment-damage-mechanisms/{id}/evidence. Where the finding is observation-grounded and the asset carries that class, those frames are ordered first, highlighted, and offered in bulk — the one thing the class-matched control was better at, kept. The Anomalies block is now read-only and points here.

Only the confirmed set is citable, in both. A nearby anomaly is evidence about the neighbourhood, not about this asset.

The third row reads the judgement back. A finding row’s badge says what its credibility is; history opens the append-only decision log behind it — each verdict, its date, the engineer’s reasoning, and the supersedes chain that makes a changed mind read as a history rather than an overwrite (FindingDecisionHistory). It fetches nothing: GET /api/equipment/{id} has attached decisions to every finding since the log shipped, newest first, so decisions[0] is the verdict credibility currently holds, and an empty list means undecided rather than decided-and-unrecorded — the Decide route writes the log before it moves the credibility.

Decide offers two verbs, not three. reclassify was retired on 2026-08-25 (D2 of 20260821_findings_parity_with_anomalies.md, migration 20260825140000). It wrote credibility = 'reclassified' and never touched the grounding, so a “reclassified” finding went on claiming the same class in a state nobody could act on — neither believed nor ruled out. Moving a finding’s class is now Correct, which changes it for real. Historic reclassify rows stay in the log and still render; the log’s CHECK deliberately still permits the value, because a constraint on an append-only audit trail describes everything ever validly written, not what the application currently offers.

That retirement is the one thing that breaks the decisions[0] ↔︎ credibility correspondence above, and only for findings that held reclassified when the migration ran: those read possible while their newest decision still reads reclassify. The log keeps the fuller story, and no future decision can recreate the state.

The decider is named as of 2026-08-24. The log carries decided_by as a uuid, and public.profiles used to admit a SELECT only for auth.uid() = id — so no caller could resolve a colleague’s id to a person and the panel showed no name at all, a raw uuid being worse than none. 20260823140100_profiles_visible_to_org_comembers.sql added shares_organization_with(uuid) and a co-member SELECT policy; the equipment route resolves the ids and passes decided_by_name. A decision by someone outside the caller’s organizations still renders unnamed rather than as a uuid.

Correcting a finding is not deciding one, and the row keeps them apart. Correct amends what the finding says — its evidence_notes, and the condition class it claims — through PATCH /equipment-damage-mechanisms/{id} (amendEquipmentDamageMechanism), landing in the append-only equipment_damage_mechanism_edits log. Decide rules on whether the claim holds, and lands in the decision log. The separation is load-bearing: if a typo fix appeared in the decision log, “who confirmed this finding” would stop being decisions[0] and become a filtered query. Opening either control closes the other.

Moving a finding’s class returns its credibility to possible — the earlier verdict was about a different claim — and the form says so before saving when there is a verdict to lose. That reset is not written as a decision: nobody decided anything, and inventing a decision row would put words in an engineer’s mouth. The class picker offers both catalogs — the observation vocabulary (listAnnotationCategories) and the API-571 mechanisms (listDamageMechanisms, added 2026-08-25) — through the shared condition-class-picker module, so a mechanism-grounded finding can be moved between mechanisms and not merely out of that catalog. The picker carries each option’s kind rather than inferring it: both catalogs are keyed by uuid, so nothing in an id says which table it came from, and the route sets one grounding column and nulls the other in a single statement to satisfy edm_one_grounding without an intermediate state.

Two consequences worth knowing before changing either page:

  • Integrity has the anomaly data without rendering the block. The asset payload carries anomalies regardless of showAnomalies, which only controls rendering — so the “New finding” dialog opens on the asset’s unclaimed anomaly classes without the page growing an Anomalies block.
  • Only observation-grounded findings can be matched to an anomaly. The join is an annotation_categories id (AssetAnomaly.category_id against observation.id); a finding grounded in an API-571 mechanism has none, so the “Add” control never appears for it. Likewise a class recorded only as a frame-level damage_tag has no category and cannot ground a finding — the dialog lists it as unraisable rather than hiding it.

Rationale and the decisions behind it: docs/proposals/20260816_pack_anomalies_into_findings.md (D2).

The Classes module — editing the condition vocabulary

Every anomaly and every observation-grounded finding names a class from annotation_categories. Until 2026-08-25 that vocabulary could only be changed by a migration: the catalog was seeded from a COCO export, and an inspector who needed a word it did not contain had nowhere to put it. Classes (/w/data-explorer/categories) is where that vocabulary is now created, renamed and retired.

Data Explorer only, and deliberately. Integrity consumes the vocabulary — it grounds findings in it and decides on them. Data Explorer curates it, next to the annotation work that populates it. Putting the editor in both would put the question “who owns this word?” in two places.

Three verbs, and the missing fourth is the interesting one.

Verb Route What it touches
Create POST /annotation-categories (createAnnotationCategory) one new row, coco_category_id left NULL
Rename PATCH /annotation-categories/{id} (amendAnnotationCategory) the row and images.tags on every image carrying it
Retire the same PATCH, retired: true retired_at only
Delete — there is no route, and no button

edm_observation_category_id_fkey is ON DELETE CASCADE (docs/issues/20260818_deleting_an_annotation_category_deletes_its_findings.md), so deleting a class would take every finding grounded in it. Retirement sidesteps that rather than trying to make deletion safe: a retired class stops being offered for new work, everything already recorded in its terms keeps its meaning and still renders, and — unlike deletion — it can be undone. That is why the screen shows retired classes under their own heading instead of hiding them: the control to bring one back has to live somewhere. Merge is deferred (D2 of 20260817_free_findings_and_category_editor.md); near-duplicate classes accumulating faster than retirement removes them is the signal to build it, which is also why a duplicate name is refused at creation rather than cleaned up later.

A rename does more than it looks. handle_annotation_category_update() rewrites images.tags across the class’s images on every name change. Until 20260825160000 it selected those images on annotations.category_id alone — correct while the vocabulary was global, and quietly cross-tenant from the moment 20260818160000 gave the row an organization_id. The organization predicate (image → dataset → organization) landed in the same migration that made this module possible, because nothing in the product could trigger a rename before it: the gap was theoretical until the editor made it reachable.

includeRetired=true is this module’s flag alone. listAnnotationCategories hides retired classes by default, so every picker offers only live ones; this screen is the one reader that must see a retirement in order to undo it.

A viewer sees the vocabulary and no controls. canEdit reaches the client from the server page, which reads organization_members — the workspace scope payload carries organization ids and names and no role. It is a UI hint, not the gate: annotation_categories_insert_policy and _update_policy gate on is_org_editor_secure per CONTRACT-API-001. Hiding the buttons only spares a viewer one that would always answer 403.

Rationale and the decisions behind it: docs/proposals/20260817_free_findings_and_category_editor.md (D2).

Tip

The registry is typed end to end: forget the ModuleId union or the MODULE_REGISTRY entry and it fails to compile — but isModuleId’s hand-written list and the nav-order tests are string-based, so update those too.