Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f995f70
feat: implement link-attributes
smnhgn Aug 7, 2026
ea5ff3e
feat: add swagger docs
smnhgn Aug 7, 2026
c2be3ad
refactor: run spotlessApply
smnhgn Aug 7, 2026
a1a698a
refactor: checkValidValue in column.scala
smnhgn Aug 7, 2026
8aa0897
fix: usage of get on optional type in StructureController
smnhgn Aug 7, 2026
d42bda8
fix: keep attributes in link to concat
smnhgn Aug 17, 2026
1919935
fix: keep attributes in to shape link
smnhgn Aug 17, 2026
d562be0
fix: validate link formatPattern if set on create
smnhgn Aug 17, 2026
5f6b090
fix: do not delete attribute values on definition update
smnhgn Aug 17, 2026
5c5de18
fix: invalidate caches for dependent columns after kind change
smnhgn Aug 18, 2026
d828e24
fix: proper null handling for all link attribute kinds
smnhgn Aug 18, 2026
a7ef7d5
fix: remove duplicate check for maxCount
smnhgn Aug 18, 2026
0691d32
fix: add validation for link attribute names
smnhgn Aug 18, 2026
43964b1
fix: various PR issues:
smnhgn Aug 18, 2026
42f8609
fix: more PR fixes:
smnhgn Aug 18, 2026
bb43147
refactor: remove dead/unused code
smnhgn Aug 18, 2026
9586536
fix: allow delete of linkAttributes and formatPattern
smnhgn Aug 19, 2026
525148b
fix: make sure multiple linkAttributes work if maxCount limit is incr…
smnhgn Aug 24, 2026
978f39a
fix: prevent multilanguage for linkAttributesDefinitions (for now)
smnhgn Aug 24, 2026
166bc6d
refactor: generalize attribute test helper
smnhgn Aug 24, 2026
f5c8594
fix: send 400 instead of 500 for some invalid linkAttribute requests
smnhgn Aug 24, 2026
5c7fef5
chore: add feature description and feature decisions
smnhgn Sep 2, 2026
c4b5b3a
fix: implement PR Feedback
smnhgn Sep 2, 2026
c83cf10
chore: setup matt pocock skills
smnhgn Sep 2, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,8 @@ local_logging.properties

*.local*

# Agent skills: local issue tracker (specs and tickets), see docs/agents/issue-tracker.md
.scratch/

# VSCode specific
.vscode/
21 changes: 21 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# CLAUDE.md

Orientation for this repo: `README.md` (API overview), `GETTING_STARTED.md`
(setup and tests), `CONTEXT.md` (domain glossary).

## Agent skills

### Issue tracker

Issues and specs live as markdown files in `.scratch/`; there is no remote
tracker. See `docs/agents/issue-tracker.md`.

### Triage labels

The five canonical roles under their default names, written as a `Status:` line
in the ticket file. See `docs/agents/triage-labels.md`.

### Domain docs

Single-context: `CONTEXT.md` and `docs/adr/` at the repo root. See
`docs/agents/domain.md`.
69 changes: 69 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Tableaux

Tableaux is a REST service for storing data in tables that can link to each other. This glossary fixes the words used for the concepts that are easy to confuse — especially around links, where the same word means different things in the API, the database and everyday speech.

## Language

### Links

**Link**:
A single connection between one row and one row in another (or the same) table. This is what carries link attribute values.
_Avoid_: edge, connection, relation

**Link relation**:
The relationship behind a pair of link columns, identified by a `link_id`. It owns the link attribute definitions and backs one `link_table_<linkId>` table.
_Avoid_: using "link" for this — a link is the individual connection

**Link column**:
A column of kind `link` that exposes, for each of its rows, the links reaching out from that row.
_Avoid_: relation column, reference column

**Backlink column**:
The link column automatically created in the target table for the opposite direction of the same link relation.
_Avoid_: reverse column, inverse link, mirror column

### Attributes

**Link attribute**:
A named scalar carried by a link itself rather than by either of the two rows it connects.
_Avoid_: link property, edge attribute

**Link attribute definition**:
The declaration of one link attribute — its `name`, `kind`, `multilanguage` flag and display info. Lives on the link relation and is therefore identical on both link columns, where it appears as `linkAttributes`.
_Avoid_: link attribute column — a definition is deliberately not a column

**Link attribute value**:
The value of one link attribute on one link, exposed as `attributes` on that link. Bound by position: `attributes[i]` belongs to `linkAttributes[i]`.
_Avoid_: attribute value (unqualified)

**Column attributes**:
The free-form JSON object that can be stored on any column as `attributes`. Older than and unrelated to link attributes, despite the identical key name.
_Avoid_: attributes (unqualified) — always say which of the three you mean

### Column model

**Identifier column**:
A column flagged `identifier`, whose value represents its row wherever that row is shown as a link value.
_Avoid_: key column, primary column, display column

**Concat column**:
The virtual column with id 0 that a table gets when its identifier columns need to be presented as one value.
_Avoid_: identifier column, combined column

**Group column**:
A column that combines several other columns into one field for display purposes.
_Avoid_: composite column

**Format pattern**:
A display template on a column, given as `formatPattern`, with `{{...}}` placeholders that are filled from the column's own value or its parts.
_Avoid_: template, display pattern

**Langtag**:
An RFC 5646 language tag. A multilanguage value is an object keyed by langtag.
_Avoid_: locale, language code

### Process

**Rollout gate**:
A deliberately temporary restriction that limits a capability the backend already supports, until the frontend is ready for it. Implemented as a constant with a test-only override rather than as a configuration flag, so the code behind the gate stays reachable by tests.
_Avoid_: feature flag — a feature flag is operator-facing and configurable, a rollout gate is neither
51 changes: 50 additions & 1 deletion GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- [`link`](#link)
- [`concat` and `group`](#concat-and-group)
- [`attachment`](#attachment)
- [2.3. Errors](#23-errors)

# 1. Preface

Expand Down Expand Up @@ -399,9 +400,45 @@ A link column always points to a specific table — in this case table `1`. A li
]
```

A link can also carry values of its own — a percentage, a date or a note per association — by defining `linkAttributes` on the link column. The definitions belong to the link itself and are therefore identical on the link column and its backlink column, while the values belong to each individual association:

```json
{
"id": 8,
"name": "country",
"kind": "link",
"toTable": 1,
"linkAttributes": [ // only present if at least one attribute is defined
{
"name": "percentage", // referenced by name only, has no id and no ordering
"kind": "integer", // text, numeric, integer, boolean, date or datetime
"multilanguage": false,
"displayName": {},
"description": {}
}
],
"formatPattern": "{{value}} ({{attributes.percentage}}%)" // only present if set
// ...
}
```

The cell value carries the attribute values positionally — `attributes[i]` belongs to `linkAttributes[i]`:

```json
[
{
"id": 13,
"value": "Czech Republic",
"attributes": [50] // only present if something is stored for this link
}
]
```

See [Link attributes](docs/features/link-attributes.md) for the full behaviour: how stored values are migrated when a definition changes, and which requests can write them.

#### `concat` and `group`

This column kinds combine multiple columns into one column. Setting multiple `identifier` columns in a table will automatically add a `concat` column at the beginning of the columns array. A `concat` column combines the values of the `identifier` columns into one column and is used to reference a foreign row in a link. The `group` column lets you combine multiple columns into one, for example grouping three columns `height`, `length`, and `depth` together into a single field for the UI as `<height> x <length> x <depth>`.
This column kinds combine multiple columns into one column. Setting multiple `identifier` columns in a table will automatically add a `concat` column at the beginning of the columns array. A `concat` column combines the values of the `identifier` columns into one column and is used to reference a foreign row in a link. The `group` column lets you combine multiple columns into one, for example grouping three columns `height`, `length`, and `depth` together into a single field for the UI as `<height> x <length> x <depth>`. How such a combined value is rendered can be controlled with a `formatPattern` on the column, using `{{...}}` placeholders. `formatPattern` also exists on [`link`](#link) columns, where the available placeholders are `{{value}}` and `{{attributes.<name>}}`.

Here is an example of a `concat` cell which combines three columns (`link`, `shorttext`, and `numeric`):

Expand Down Expand Up @@ -450,3 +487,15 @@ Here is an example of an attachment cell:
"updatedAt": "2017-03-23T10:01:47.604+01:00"
}
```

## 2.3. Errors

Errors are not returned as JSON. The response body is the plain error message, and the machine-readable error id is sent as the **HTTP reason phrase** next to the status code:

```txt
HTTP/1.1 400 error.json.linkAttributes

Multilanguage linkAttributes are not supported yet, but 'note' is multilanguage.
```

**Take note of the behavior!** The reason phrase, not the body, is what a client should switch on — the body is human-readable text and its wording is not part of the contract. And `404` is the exception: its reason phrase is always the literal `NOT FOUND`, so a not-found response does not carry its error id at all.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ Feature flags are used to enable or disable certain features. They have to be co
- `isRowPermissionCheckEnabled`: Enable or disable row permission checks (default: false)
- `isPublicFileServerEnabled`: Enable or disable the public file server. If enabled, files are accessible without authentication (default: false)

## Feature documentation

Behaviour of individual features is documented in `docs/features/`. The field-by-field API reference stays in `swagger.json`, served at `/docs`.

- [Link attributes](docs/features/link-attributes.md): values carried by a link itself rather than by the rows it connects

## Highlevel Features

- Content Creation System
Expand Down
7 changes: 7 additions & 0 deletions docs/adr/0006-link-attributes-are-not-columns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Link attributes are not columns; they are referenced by name

A link attribute is declared with a `name`, a `kind`, a `multilanguage` flag and display infos, so `LinkAttributeDefinition` in `src/main/scala/com/campudus/tableaux/database/domain/link.scala` ends up looking a lot like `ColumnDefinition`. The obvious next step would have been to make it one: a row in `system_columns` with a real `id` and an `ordering`, reusing everything that already exists for columns. **We deliberately did not.** A link attribute has no id, no ordering and no `system_columns` row; the definitions are stored as a JSON array in `system_link_table.attributes` and are addressed only by their `name`.

Two reasons. First, a column is not a small thing to add: every part of the system that iterates columns would have to decide what a link attribute means to it — column ordering, role-based authorization, history, identifier flattening and the concat column, group columns, the column cache, the structure endpoints. None of those wants a column that cannot stand on its own. Second, a column belongs to exactly one table, but a link attribute belongs to the *link relation* and has to be identical for the link column and its backlink column. As a column it would have been either two rows kept in sync, or one row surfacing in a table it does not belong to. Hanging it off `system_link_table` makes "both directions see the same definitions" a property of the storage rather than something to maintain.

The price is that there is no stable identifier. Renaming an attribute is lossless only because the definition list is diffed by position and not by name (see ADR 0007). The name is also the handle used in `formatPattern` as `{{attributes.<name>}}`, which is why it is restricted to `\w+` — a dot would make `{{attributes.a.b}}` ambiguous to the `\{\{([\w.]+)\}\}` parser. And should a link attribute ever need something a column has — read-only, per-attribute permissions — there is no row to put it on.
7 changes: 7 additions & 0 deletions docs/adr/0007-link-attribute-values-are-positional.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Link attribute values are transferred and stored positionally, not keyed by name

A link carries its attribute values as a bare array — `"attributes": [50]` next to the column's `"linkAttributes": [{"name": "percentage", ...}]` — and the same array shape is what lands in `link_table_<linkId>.attributes`. The obvious alternative was an object keyed by name, `{"percentage": 50}`, which is self-describing and survives reordering. **We chose position: `attributes[i]` belongs to `linkAttributes[i]`.**

The reason is the migration path, not the wire format. When a definition list is replaced, `ColumnModel.updateLinkAttributesDefinition` has to decide what happens to every value already stored against the old list. Diffing by position gives an unambiguous old-slot-to-new-slot correspondence, so the migration can walk the slots and cast or reshape each one in place, and a *rename* is simply a slot whose `name` changed — cosmetic, no query, values kept. Matching by name would make a rename indistinguishable from removing one attribute and adding another, and the values would be dropped every time somebody fixed a typo. Since values are diffed positionally anyway, storing and transferring them any other way would only add a translation layer that can disagree with the migration.

What this costs: a write has to supply exactly as many values as there are definitions (a length mismatch is a 400, not a partial update), adding or removing a definition has to touch every stored array to pad it with `null` or truncate it, and clients have to keep two distinct meanings apart — `null` in a slot is "cleared", a missing `attributes` key is "nothing stored". It also means **reordering definitions is not cosmetic**: swapping two entries reinterprets every stored value against its new slot's kind, and is migrated as such. Whether the frontend should offer reordering at all is therefore a product question, not a technical one.
54 changes: 54 additions & 0 deletions docs/agents/domain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Domain Docs

How the engineering skills should consume this repo's domain documentation when
exploring the codebase.

## Before exploring, read these

- **`CONTEXT.md`** at the repo root: the domain glossary.
- **`docs/adr/`**: read ADRs that touch the area you're about to work in.
- **`docs/features/<feature>.md`**: durable documentation of a shipped feature's
behaviour. Read the one covering your area if it exists.

If any of these files don't exist, **proceed silently**. Don't flag their
absence; don't suggest creating them upfront. The `/domain-modeling` skill
(reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates
them lazily when terms or decisions actually get resolved.

## File structure

This is a single-context repo:

```
/
├── CONTEXT.md
├── docs/
│ ├── adr/
│ │ ├── 0001-target-platform-vertx-4-5-11-scala-3-3-lts.md
│ │ └── …
│ └── features/
│ └── link-attributes.md
└── src/main/scala/
```

If this ever grows into several independent contexts, the multi-context layout is
a root `CONTEXT-MAP.md` pointing at one `CONTEXT.md` per context, with
context-scoped ADRs under `src/<context>/docs/adr/`.

## Use the glossary's vocabulary

When your output names a domain concept (in an issue title, a refactor proposal,
a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift
to synonyms the glossary explicitly avoids.

If the concept you need isn't in the glossary yet, that's a signal: either you're
inventing language the project doesn't use (reconsider) or there's a real gap
(note it for `/domain-modeling`).

## Flag ADR conflicts

If your output contradicts an existing ADR, surface it explicitly rather than
silently overriding:

> _Contradicts ADR-0007 (link attribute values are positional), but worth
> reopening because…_
36 changes: 36 additions & 0 deletions docs/agents/issue-tracker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Issue tracker: Local Markdown

Issues and specs for this repo live as markdown files in `.scratch/`.

There is no remote issue tracker for these skills to call: the GitHub remote
(`campudus/tableaux`) carries code and PRs only, and the YouTrack tickets that
branch names refer to (`GRUD_DEV-1199/link-attributes`) are outside what any
skill reads or writes. Never try to fetch a `GRUD_DEV-*` ticket; ask the user to
paste it if its content matters.

## Conventions

- One feature per directory: `.scratch/<feature-slug>/`
- The spec is `.scratch/<feature-slug>/spec.md`
- Implementation issues are one file per ticket at `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, numbered from `01`, never a single combined tickets file
- Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings)
- Comments and conversation history append to the bottom of the file under a `## Comments` heading

## When a skill says "publish to the issue tracker"

Create a new file under `.scratch/<feature-slug>/` (creating the directory if needed).

## When a skill says "fetch the relevant ticket"

Read the file at the referenced path. The user will normally pass the path or the issue number directly.

## Wayfinding operations

Used by `/wayfinder`. The **map** is a file with one **child** file per ticket.

- **Map**: `.scratch/<effort>/map.md` (the Notes / Decisions-so-far / Fog body).
- **Child ticket**: `.scratch/<effort>/issues/NN-<slug>.md`, numbered from `01`, with the question in the body. A `Type:` line records the ticket type (`research`/`prototype`/`grilling`/`task`); a `Status:` line records `claimed`/`resolved`.
- **Blocking**: a `Blocked by: NN, NN` line near the top. A ticket is unblocked when every file it lists is `resolved`.
- **Frontier**: scan `.scratch/<effort>/issues/` for files that are open, unblocked, and unclaimed; first by number wins.
- **Claim**: set `Status: claimed` and save before any work.
- **Resolve**: append the answer under an `## Answer` heading, set `Status: resolved`, then append a context pointer (gist + link) to the map's Decisions-so-far in `map.md`.
18 changes: 18 additions & 0 deletions docs/agents/triage-labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Triage Labels

The skills speak in terms of five canonical triage roles. This repo tracks issues
as local markdown files, so a "label" is the value of the `Status:` line near the
top of the issue file — there is no label API to call.

| Label in mattpocock/skills | `Status:` value in our files | Meaning |
| -------------------------- | ---------------------------- | ---------------------------------------- |
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
| `needs-info` | `needs-info` | Waiting on reporter for more information |
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
| `ready-for-human` | `ready-for-human` | Requires human implementation |
| `wontfix` | `wontfix` | Will not be actioned |

When a skill mentions a role (e.g. "apply the AFK-ready triage label"), write the
corresponding value from the right-hand column into the file's `Status:` line.

Edit the right-hand column to match whatever vocabulary you actually use.
Loading
Loading