# @ai-matrx/associations

THE one way to relate two entities on the AI Matrx platform: association edges,
categories, favorites/recents, and the generated entity-type vocabulary — shipped
as one package a Matrx client or enterprise app installs.

The package ships the types root, the headless `/core`, the React binding
`/react`, and the package-owned lazy React entry `/react/lazy`: the service
chokepoint, guards, never-throw result funnel, registry merge engine,
subscribable cache store, hooks, and the association faces (cards, list,
pickers, attached-items sheet, capture toolbar). Everything below about the
schema is binding — it is the package's product.

```ts
import {
  type EntityTypeToken,        // generated from platform.entity_types
  ENTITY_TYPE_METADATA,        // per-token schema/table/label/titleColumn/contentRole
  isEntityTypeToken,           // runtime guard, narrows to the union
  type AssociationEdge,        // one assoc_for_entity row (both directions)
  type AssociationsRpcResult,  // the never-throw result envelope
  type DemandedRpcName,        // the 27-function demanded RPC surface
  type AssociationsConfig,     // the seven ports a host binds
} from "@ai-matrx/associations";
```

```ts
import {
  createAssociationsStore, // the ONE runtime object a host constructs
} from "@ai-matrx/associations/core";

const store = createAssociationsStore({
  dataSource: supabase,                       // structural subset of SupabaseClient
  identity: { requireUserId },                // throws when unauthenticated
  errorSink: (e) => report(e),                // OPTIONAL — default screams to console
  entityOverlay: { file: { hrefFor: (id) => `/files/f/${id}` } }, // optional
});

await store.load("task", taskId);             // lazy, deduped, subscribable
store.getEdges("task", taskId);               // sync, stable idle default
await store.add({ sourceType: "note", sourceId, targetType: "task", targetId });
store.titles.prime("task", taskId, "Renamed");
await store.favorites.setFavorite("note", noteId, true);

// There is NO boot check to call (0.9.0). A database that cannot answer a
// demanded function says so at the first real call: PostgREST returns
// PGRST202 and the package screams `demanded_schema_violation` with a remedy.
```

`useContainerLinks({ containerType, containerId, orgId })` is the canonical
container-side React API. Its `attach`, `detach`, and `setPinned` operations all
target the exact role-qualified edge; `setPinned` preserves the edge's other
metadata fields. `AssociationCardGrid` and the default `AssociationList`
adapter expose those same operations without host wiring.

```tsx
// /react — the banner'd React binding (peer: react >=18;
// @ai-matrx/design-system is a real dependency — the faces' chrome
// installs automatically). Bind ONCE:
import {
  AssociationsProvider,
  useAssociations,              // the seven hooks keep the app originals' exact signatures
  AssociationCardGrid,          // + AssociationCard / AssociationList / pickers / capture toolbar
  PrimaryEntityProvider,
} from "@ai-matrx/associations/react";

<AssociationsProvider
  store={store}                 // the /core store from createAssociationsStore
  notifier={toastAdapter}       // optional — absent warns once, failures still hit errorSink
  windowShell={windowAdapter}   // optional — absent uses the package's OWN draggable window
  capture={{ uploadFile }}      // optional — bind ONE primitive, get the whole upload flow
  pickerOverrides={{ file: FilePickerWindowAdapter }} // optional — the in-package picker is the default
  entityDoors={{ EntityRef, DoorControls, UnresolvedRef }} // optional — hrefFor anchors by default
>
  <PrimaryEntityProvider value={{ type: "organization", id, orgId, label }}>
    <AssociationCardGrid />
  </PrimaryEntityProvider>
</AssociationsProvider>;
```

The faces style with Tailwind semantic tokens (`bg-card`,
`text-muted-foreground`, …): register this package as a Tailwind v4 source in
the consuming app (`@source "../node_modules/@ai-matrx/associations/dist"`)
the same way `@ai-matrx/design-system` documents.

## The demanded schema — read this first

This package is **opinionated like the Python `matrx-*` packages**: it does not
adapt to your database — it DEMANDS ours. A consumer who won't run our schema is
not this package's consumer. It ships no DDL (the platform DB owns that; managed
consumers run on our infrastructure); it ships the demand three ways —
documented (here), typed (`DemandedRpcSignatures`, generated from the live DB),
and screamed at the first real call (PGRST202 → `demanded_schema_violation`,
with a remedy). Every violation
**screams, never degrades**.

1. **The edge.** `platform.associations(source_type, source_id, target_type,
   target_id, organization_id, label, metadata, role, position, created_by,
   created_at, deleted_at, …)` — one polymorphic table, unique on the 5-tuple
   `(source_type, source_id, target_type, target_id, role)` `NULLS NOT DISTINCT`.
   Type columns are FK-validated against `platform.entity_types.token`.
2. **RPC-only.** The client holds **no grant** on the table. Every operation goes
   through the 27 SECURITY DEFINER functions in `DEMANDED_RPC_NAMES` (`assoc_*`
   ×8, `conversation_file*` ×3, `agent_resource_*` ×2, `cat_*` ×5, `ues_*` ×4,
   `reference_search_candidates`, and the W6 comments family `cmt_*` ×4). Those readers consume the live-only
   `associations_live` view, so tombstone semantics (a trashed entity
   soft-removes its edges; restore revives them) hold by construction — this
   package never writes a `.is("deleted_at", null)` filter. The DB's legacy
   `assoc_link`/`assoc_list`/`assoc_unlink` trio is deliberately NOT demanded.
3. **Direction is registry-owned: little→big.** The resource is the SOURCE, the
   container the TARGET (`task → organization`, `file → scope`). The DB trigger
   `trg_associations_auto_orient` rejects reversed writes — this package
   surfaces that error verbatim and never flips an edge for you.
4. **A link is never a grant.** Access lives in `iam.permissions` /
   `iam.memberships`. The ONLY sanctioned association→access bridge is DB-side
   conveyance (`platform.association_types.conveys_max` → reachability). This
   package makes **zero access decisions**.
5. **A missing function screams.** A PostgREST PGRST202 on any demanded RPC maps
   to the `demanded_schema_violation` result code and always reaches your
   errorSink: a database without our schema produces one unambiguous scream, not
   a mystery toast.

## The token vocabulary — writes strict, reads tolerant

`EntityTypeToken` mirrors `platform.entity_types` 1:1 (651 active tokens),
regenerated by `pnpm gen:entity-types` inside this package. Skew policy:

- **Writes are STRICT** against the installed vocabulary: a token this version
  doesn't know fails at the guard, in code, before Postgres — exactly like a
  missing DB type. The fix is `pnpm up @ai-matrx/associations` (registry changes
  ship as **patch releases**), never a raw-string widening.
- **Reads are TOLERANT**: an edge arriving with a token this version doesn't
  know renders through the fallback (default icon, raw label, no door) instead
  of crashing.

## The ports — two required, the rest DEFAULTED

One `AssociationsConfig`, bound once per host. Only **`dataSource`** (a
structural subset of `SupabaseClient` answering the demanded RPCs) and
**`identity`** are required — construction throws without them, because they
ARE your app's identity and no package can invent them.

Everything else ships a working default (C23 — you override ports, you never
have to fill them):

| Port | Default when unbound |
|---|---|
| `errorSink` | `createDefaultErrorSink()` — tagged `console.error` per event; announces ONCE that no sink is bound and names the remedy. |
| `windowShell` | `DefaultWindowShell` — a real draggable / resizable / maximizable / viewport-clamped / portalled / non-modal window; full-width bottom card under 640px. Bind this port only for a real window MANAGER (tray docking, workspace persistence). |
| `capture` | Bind `uploadFile(file, { folderPath, visibility })` alone and the package ships the file input, drag-and-drop, batching, the stall watchdog and every loud outcome. Bind `requestUpload` instead when you own a richer pipeline. Chips with no path behind them are never rendered. |
| `pickerOverrides` | The in-package `AssociationPicker` serves every token (candidate list, search, attach/detach, create-then-associate footer). |
| `entityDoors` | `DoorControlsSlot` renders a real labeled `target="_blank"` anchor from the overlay's `hrefFor`; a token with no route renders nothing rather than a door to nowhere. |
| `entityOverlay` | Convention columns, default icon, `${label}s`, no door. |
| `notifier` | Warn-once through the errorSink, and every user-facing failure still reaches the errorSink. (No toast stack ships: a toast surface needs a host-mounted root, so a "default" would be the silent no-op this package exists to prevent.) |
| `authorDisplay` | `cmt_list`'s denormalized author fields, then "Unknown user" — never blank. |

## Code splitting — the package owns it

Import the heavy faces from **`@ai-matrx/associations/react/lazy`** and each one
arrives in its own chunk on first render. No `next/dynamic`, no `ssr: false`,
no host glue:

```tsx
import { AssociationCardGrid } from "@ai-matrx/associations/react/lazy";
```

For a HOST component you bind to a port, use the same seam:

```tsx
windowShell: {
  Window: lazyWindowShell(() => import("./hostWindow").then((m) => m.HostWindow)),
},
pickerOverrides: {
  file: lazyPickerOverride(() => import("./hostPicker").then((m) => m.HostPicker)),
},
```

A per-token picker override should bind `useAssociationPickerBridge(props)` for
its attach/detach writes — it carries the loud partial-outcome, refusal and
created-but-unattached reporting so an override never re-implements them.

## Versioning

Consumed by Matrx repos as `latest` (`workspace:*` inside the aidream monorepo)
per THE LATEST LAW — never pinned. Registry token changes → regenerate → patch.
Contract changes → minor/major per semver.

## License

MIT
