Skip to content
Merged
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
20 changes: 20 additions & 0 deletions .changeset/rfc45-step5-mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@cloudhal/mcp-server": patch
"@cloudhal/core-ts": patch
"@cloudhal/api": patch
---

RFC-0045 rollout step 5: reference and repeater fields over MCP. The two types stay refused on every production write path until step 7 (`fields_unsupported`), and the field inputs do not list them there; a store with `SiteGraphStoreOptions.fieldTypes` gets tools that do. Nothing changes for the nine existing types, except as listed.

- **`list_collections`** returns, beside `fieldsSchema`, its strict profile `fieldsSchemaStrict` (every key required, `null` for "leave as it is") and `strictCost` (`properties`, `depth`, `enumValues`, `unions`). Reference targets are named by their collections' labels.
- **`create_entry` / `update_entry`** take `fieldsStrict`, for strict structured output, instead of `fields` (`invalid` when both are given): `null` or a missing key leaves a value as it is (on create, no value); `null` sub-values in repeater items are stripped, and items left empty are dropped and reported in `droppedItems`. `update_entry` also takes `clearFields` (set and clear of one key is `invalid`, an unknown key `unknown_field`); on create it is `invalid`. A given reference or repeater value replaces the whole value.
- **Prechecks are the store's own write plan**, run as a dry run: the new `NodeWriteOptions.dryRun` on `createContentEntry`/`updateContentEntry`, and `deleteContentEntry(entry, { dryRun })`, throw the plan-time refusals (values, links, references, a release hold, gates, assets, terms) and write nothing. So `create_entry` and `update_entry` refuse reference targets (`unknown_entry`), repeater items and the references cap before anyone is asked, and `delete_entry` refuses `entry_referenced` with the published referrers. Behaviour change: those prechecks now also run the store's translation-link and canonical-id checks, which the handler ran anyway.
- **Approval prompts** name targets readably, from the dry run's planned entry (canonical ids): `venue → "Main hall" (entry_…, Places)`, `speakers: 2 items → 3 items (added "Ada Lovelace", Speaker; changed item 1: person → "Grace Hopper" (entry_…, People))`, quoted and truncated.
- **New read-only tool `list_references`** (`content:read`) with `direction: "to"` (referrers, published first, with the members that show them) or `"from"` (the groups an entry names, its original's inherited ones included). 110 → 111 tools. The API's `GET …/entries/:entryId/references` and the tool share core-ts's new `listReferrers` (and `listReferenceTargets`, `referenceCard`).
- **`list_entries`** returns each entry's `groupId`, the value a reference stores.
- **Translation tools**: `get_translation_source` lists a localized repeater's text and rich-text sub-values by item path (`speakers/0/talk`) with the source's `sourceRev`; `save_translation` takes them back with that `sourceRev` (`conflict` when the source changed) and folds them into a copy of the source's list, keeping each item's other sub-values (core-ts `translatedItemValues`). Once the translation holds its own list for that repeater, item paths are refused and the list is sent whole; a whole list of another length than the source's is refused too (`translation_shape_mismatch`, new code, 409 in the API). A path naming no such value is refused. Keys whose first part is not a repeater (`og/image`) stay plain keys.
- **`normalizeEntryFields`** is exported from `@cloudhal/core-ts`: MCP's approval prompts read stored values through the markers exactly as the store does.
- **`continueFieldOperation`** with `onInvalid: "clear"` reports the values it drops as cleared, without the stored request's `field_values_unconvertible` refusal.
- **Removals during `rename_field`** over MCP: the old and the new key are one field; removing either removes both, so the writer's move cannot bring the value back, and setting one while removing the other is `invalid`.
- **Operation tools** take step 4's inputs: `sub` (`rename_field`, `change_field_type`, `remove_field`), `collections`, `maxItems`, `subFields` and `itemLabel` on `change_field_type`, and `onInvalid: "clear"` on a continuation; results carry `unconvertible`.
- **`defineTools({ referenceTypes })`** and `defaultToolList(referenceTypes)`: `createCloudhalMcpServer` passes whether its store has `fieldTypes`.
19 changes: 19 additions & 0 deletions docs/rfcs/0045-collections-stage-4-references-and-repeaters.md
Original file line number Diff line number Diff line change
Expand Up @@ -1370,3 +1370,22 @@ Round 1 dropped the "fits" shortcut for text ↔ reference, but then the table r
- **Rebuilds read a running reference (multiple) ↔ repeater conversion through its old type** (`reshapedRows`, top level). A stored value still in the old shape keeps its rows: from a reference, each id-shaped string at the field path; from a repeater, each item's id-shaped sub-value at `<field>.<sub>`. A mid-operation import therefore keeps the target locked.
- **The edition's per-sub-field held check applies only while a text → reference marker runs on the definition.** Otherwise every stored id of the field is held, as before step 4.


### Amended in 5 (2026-11-13, rollout step 5)

- **Prechecks reuse the store's write plan.** `create_entry`, `update_entry` and `delete_entry` run the store's own plan as a dry run (`NodeWriteOptions.dryRun`; `deleteContentEntry(entry, { dryRun })`), which throws every plan-time refusal and writes nothing. MCP no longer keeps a copy of the field checks: values, assets, `requiredToPublish`, translation links, canonical ids, the target guard's plan-time checks (`unknown_entry`, own group, `where`), the references cap, and `planLeave` (`entry_referenced`, `field_operation_running`). The batch-time guards still decide the real write.
- **`fieldsStrict` and `clearFields`** follow §6 as written. The strict values are read by core-ts's `readStrictFields` (`fields/strict-input.ts`), beside the strict schema it reads, and MCP merges them as one merge patch. An unknown key in `fieldsStrict` is `unknown_field` even when its value is `null`, so a typo is never silently "left as it is". An item that was already empty as given is not a strict drop: the checker refuses it ("no empty item").
- **`update_collection`'s and `change_field_type`'s type lists follow the store's capability** by its presence (`SiteGraphStoreOptions.fieldTypes`): the tool list is built when a server starts, before any site is named, so it cannot ask `supported(siteId)`. Production passes none until step 7, and the store still decides per site. `defsClosure` and the resolving-`$ref`s test were already in place from step 1; the test now covers both tool lists.
- **`list_references`** shares the API's referrers read, moved to core-ts (`references/referrers.ts`, `listReferrers`). `direction: "from"` reads the entry's own `entry_refs` rows, and for a same-site translation the rows of its original it shows by the `shared` rule (`inherited`), each with the group's members in the site, any status. Like the API's route, it is an editor's view of the working copy: site-scoped (another site's entry is `not_found`), no gates. Titles are marked as content (`notice`).
- **Translation paths** are `key/index/sub` (`speakers/0/talk`), relative to `fields`, as issue paths are (§3 "Machine translation").
- A key is an item path only when the part before its first `/` names a repeater of the collection (`itemPathRepeater`); any other key, such as `og/image` in a collection without definitions, is a plain key, as before.
- Item paths write into a copy of the source's list (or into a list given whole in the same call), so each item keeps the source's other sub-values: targets, images, selects.
- **Once the same-locale translation holds its own list for that repeater, item paths are refused** (`translation_shape_mismatch`, 409, "send the whole list"): re-translate by sending the whole list, in the source's order. Either side may have been reordered or edited since the first translation, and no revision check can tell whether an index still names the same item, so the rule does not try (review round 3 replaced a `translation.source.rev` check that had holes).
- A list given whole must have the source's length (`translation_shape_mismatch`).
- Paths name items by index, so `get_translation_source` returns `sourceRev`, and `save_translation` requires it with item paths (`invalid` without, `conflict` when the source moved on). The handler refuses paths that no longer fit, as the precheck does; nothing is dropped silently.
- **`set_field_localized`** takes no `sub` over MCP, since the store refuses it (§7 "Amended in 4").
- **The dry run's reach.** It returns after the asset references, the release hold (`scheduled_locked`), time gates and the term invariants (`required_term`), with the planned entry. It does not plan the path change (`path_conflict`, redirects), a collection's first entry, list values or source-forward, and slug uniqueness is decided by the write. The prechecks build the same entry the handler writes (terms, cover, title and body included).
- **Approval prompts describe the planned entry** the dry run returns, not the raw patch: a translation's id reads as the group it is stored as, and a key the write would not remove reads as its planned value. Items compare as canonical JSON (key order never counts) against the stored list read through the field-operation markers by the store's own `normalizeEntryFields` (now exported, so the two readings cannot drift: an edition member is no translation there), so a running sub-field operation does not read as every item changing. An added and a removed item at one index read as a changed item (`changed item 1: person → "Grace Hopper" (entry_…, People)`), and `reordered` is noted whenever the items both lists hold change order.
- **Removals during `rename_field`.** While a top-level rename runs, an entry not rewritten yet holds the value under the old key, and the writer moves it. The two keys are one field: removing either (`clearFields`, or `null` in `fields`) removes both, so the move cannot bring the value back, the old key is a known name meanwhile, and setting one while removing the other is `invalid`. This is MCP's patch reading; the API and Studio write whole entries.
- **A continuation's `onInvalid: "clear"`** applies to its report too (`continueFieldOperation`): values it drops are counted as cleared, with no `field_values_unconvertible` refusal. MCP's continuation headline is the stored operation's.
- **`list_entries`** returns `groupId`; `where` on a reference field stays refused (`invalid_query`) until step 8.
1 change: 1 addition & 0 deletions packages/api/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export const INVARIANT_STATUS: Record<InvariantCode, 400 | 409 | 410 | 415 | 422
too_many_references: 422,
// §2: a target that is not there is 422; what another entry or a running operation holds, 409.
unknown_entry: 422,
translation_shape_mismatch: 409,
reference_target_moved: 409,
entry_referenced: 409,
translation_group_in_use: 409,
Expand Down
79 changes: 4 additions & 75 deletions packages/api/src/routes/entry-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
* `rate_limits` counters applies.
* - `GET …/entries/:entryId/references?limit=&offset=`: the referrers of the entry's translation
* group from `entry_refs`, published holders first, each holder with the same-site members that
* show its value (`shared` 1, or 2 without their own value), paged.
* show its value (`shared` 1, or 2 without their own value), paged (`listReferrers`, which MCP's
* `list_references` shares).
*/
import type { ContentEntry } from "@cloudhal/core-contracts";
import { listReferrers } from "@cloudhal/core-ts";
import { memberGroup } from "@cloudhal/core-ts/fields";
import type { SqlDriver } from "@cloudhal/storage";
import type { Context } from "hono";
import { Hono } from "hono";
import type { Env } from "../context.ts";
Expand Down Expand Up @@ -65,77 +65,6 @@ async function rateLimited(c: Context<Env>, siteId: string): Promise<boolean> {
return count > PICK_WINDOW.hits;
}

interface Card {
groupId: string;
id: string;
title: string;
collection: string;
status: ContentEntry["status"];
locale: string;
}

const cardOf = (e: ContentEntry): Card => ({
groupId: memberGroup(e),
id: e.id,
title: e.title,
collection: e.collection,
status: e.status,
locale: e.locale,
});

interface RefRow {
holder: string;
field: string;
shared: number;
root_key: string;
source_status: string;
body: string;
}

/** One page of the group's rows, each holder with the members that show its value. */
async function referrers(
sql: SqlDriver,
siteId: string,
page: { group: string; limit: number; offset: number },
) {
const rows = await sql.all<RefRow & Record<string, unknown>>(
`SELECT r.source_id AS holder, r.field AS field, r.shared AS shared, r.root_key AS root_key,
r.source_status AS source_status, n.body AS body
FROM entry_refs r JOIN nodes n ON n.id = r.source_id AND n.site_id = r.site_id
WHERE r.site_id = ? AND r.target_id = ?
ORDER BY r.source_status = 'published' DESC, r.source_id, r.field LIMIT ? OFFSET ?`,
[siteId, page.group, page.limit + 1, page.offset],
);
const shown = rows.slice(0, page.limit);
const holders = [...new Set(shown.map((r) => r.holder))];
const members = holders.length
? await sql.all<{ body: string }>(
`SELECT body FROM nodes WHERE site_id = ? AND kind = 'contentEntry'
AND json_extract(body, '$.translation.group') IN (SELECT value FROM json_each(?))
AND json_extract(body, '$.translation.source.siteId') IS NULL`,
[siteId, JSON.stringify(holders)],
)
: [];
const inheriting = members.map((m) => JSON.parse(m.body) as ContentEntry);
const items = shown.map((r) => ({
...cardOf(JSON.parse(r.body) as ContentEntry),
field: r.field,
shownBy: inheriting
.filter(
(m) =>
m.translation?.group === r.holder &&
// Only the row's own holder is left out: another holder on this page may still show it.
m.id !== r.holder &&
(r.shared === 1 || !Object.hasOwn(m.fields ?? {}, r.root_key)),
)
.map(cardOf),
}));
return {
referrers: items,
...(rows.length > page.limit ? { next: page.offset + page.limit } : {}),
};
}

export const entryReferences: Hono<Env> = new Hono<Env>()
.get("/pick", async (c) => {
const siteId = param(c, "siteId");
Expand All @@ -154,5 +83,5 @@ export const entryReferences: Hono<Env> = new Hono<Env>()
limit: Math.max(1, intParam(c.req.query("limit"), REFERRERS_LIMIT, 20)),
offset: intParam(c.req.query("offset"), Number.MAX_SAFE_INTEGER, 0),
};
return c.json({ groupId: page.group, ...(await referrers(store.sql, entry.siteId, page)) });
return c.json({ groupId: page.group, ...(await listReferrers(store.sql, entry.siteId, page)) });
});
4 changes: 3 additions & 1 deletion packages/core-ts/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,9 @@ export type InvariantCode =
/** §2.6: field operations on reference and repeater fields come with rollout step 4. */
| "field_operation_unsupported"
/** §2.8: adding a reference or repeater field over a key entries already hold. */
| "field_key_in_use";
| "field_key_in_use"
/** §3: translated repeater items go into a list of a length other than the source's. */
| "translation_shape_mismatch";

export class InvariantError extends Error {
override readonly name = "InvariantError";
Expand Down
10 changes: 9 additions & 1 deletion packages/core-ts/src/fields-operations/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,13 +213,21 @@ export async function continueFieldOperation(
options: FieldOperationOptions,
): Promise<FieldOperationResult> {
const ctx = operationContext(store, options);
const record = await readOperation(ctx, siteId, operationId);
const stored = await readOperation(ctx, siteId, operationId);
// RFC-0045 §7: the call's `onInvalid: "clear"` applies to what is left, so the report counts
// the values it drops as cleared, without the stored request's refusal.
const record = { ...stored, op: withOnInvalid(stored.op, options.onInvalid) };
const site = await requireSite(ctx, siteId);
const report = await remaining(ctx, site, record);
if (record.after || record.reverted) return result(record, report, { resumed: true });
return { ...(await proceed(ctx, record, report, options)), resumed: true };
}

/** `op` with a continuing call's `onInvalid` (only a type change has one). */
function withOnInvalid(op: FieldOperation, onInvalid: "clear" | undefined): FieldOperation {
return onInvalid !== undefined && op.op === "change_field_type" ? { ...op, onInvalid } : op;
}

/** The report of what is left: through the marker when phase A ran, else the dry run. */
async function remaining(
ctx: OperationContext,
Expand Down
4 changes: 4 additions & 0 deletions packages/core-ts/src/fields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,15 @@ export {
type ResolvedFields,
resolveFields,
} from "./resolve.ts";
export { type DroppedItem, readStrictFields, type StrictFieldsRead } from "./strict-input.ts";
export {
itemPathRepeater,
localizedFieldValues,
sharedFieldIssues,
sharedFieldKeys,
type TranslatedItemsInput,
translatableFieldValues,
translatedItemValues,
} from "./translation.ts";
export {
ALL_FIELD_TYPES,
Expand Down
68 changes: 68 additions & 0 deletions packages/core-ts/src/fields/strict-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* RFC-0045 §6 "The strict profile and what `null` means": how a writer reads values produced
* against `fieldsSchemaStrict` (MCP's `fieldsStrict`), where every key is present.
*
* - A top-level `null`, or a key left out, means "leave as it is": on create no value, on update
* the stored value stays. Splitting a schema into subsets is safe for that reason.
* - A given value replaces the whole value (a reference list, a repeater's items included).
* - Inside a repeater item, `null` means "unset in this item": the sub-value is stripped. An item
* left with no sub-value is dropped and reported (`droppedItems`), so the "no empty item" rule
* never fires on strict output.
*
* Everything else (types, limits, targets) is the store's checker's. Pure and Worker-safe.
*/
import type { FieldDefinition } from "@cloudhal/core-contracts";
import { isRecord } from "./types.ts";

/** An item of a repeater whose sub-values were all `null`, dropped from the list given. */
export interface DroppedItem {
/** The repeater's key. */
field: string;
/** The item's index in the list as given. */
index: number;
}

export interface StrictFieldsRead {
/** The keys to set, with their values as they will be written. */
values: Record<string, unknown>;
droppedItems: DroppedItem[];
}

/** A repeater's items with `null` sub-values stripped and all-null items dropped. */
function stripItems(key: string, items: readonly unknown[], dropped: DroppedItem[]): unknown[] {
const out: unknown[] = [];
items.forEach((item, index) => {
if (!isRecord(item)) {
out.push(item);
return;
}
const kept = Object.fromEntries(Object.entries(item).filter(([, v]) => v !== null));
if (Object.keys(kept).length === 0 && Object.keys(item).length > 0)
dropped.push({ field: key, index });
else out.push(kept);
});
return out;
}

/**
* The values a strict-profile input sets: `null` keys left out, repeater items stripped of `null`
* sub-values, and items with nothing left dropped. Keys the definitions do not know are kept as
* given, for the checker to refuse.
*/
export function readStrictFields(
defs: readonly FieldDefinition[],
input: Readonly<Record<string, unknown>>,
): StrictFieldsRead {
const repeaters = new Set(defs.filter((d) => d.type === "repeater").map((d) => d.key));
const droppedItems: DroppedItem[] = [];
// `fromEntries` defines own keys: a `__proto__` key stays data, for the writer to refuse.
const values = Object.fromEntries(
Object.entries(input)
.filter(([, value]) => value !== null)
.map(([key, value]) => [
key,
repeaters.has(key) && Array.isArray(value) ? stripItems(key, value, droppedItems) : value,
]),
);
return { values, droppedItems };
}
Loading
Loading