Skip to content

feat(contracts): reference and repeater field types, gated off (RFC-0045 step 1, #114) - #203

Merged
Madraka merged 4 commits into
mainfrom
claude/rfc45-contract-core-18f2ba
Nov 11, 2026
Merged

Madraka merged 4 commits into
mainfrom
claude/rfc45-contract-core-18f2ba

Conversation

@Madraka

@Madraka Madraka commented Nov 11, 2026

Copy link
Copy Markdown
Contributor

RFC-0045 rollout step 1, "Contract and pure core" (#114). The two new field types exist in the contract. Every write path refuses them until step 7. Nothing changes for existing sites.

What changed

Contract (site.schema.json, content-entry.schema.json; TS and Rust regenerated with pnpm gen)

  • FieldDefinition gains two variants: ReferenceField and RepeaterField.
  • New SubFieldDefinition: nine *SubField variants with their own titles, scalar values only, and no repeater variant, so there is no recursion.
  • FieldMigration.fromType gains reference and repeater; FieldMigration gains sub.
  • Fixtures:
    • one valid: collection-stage4.json. It has single, multiple and where references, two repeaters, every sub-field type, a marker with sub, and fractional bounds;
    • twelve invalid: a reference without collections, six collections, a repeated collection, maxItems 51 on a reference and on a repeater, a nested repeater, a datetime sub-field, a localized sub-field, a multiple select sub-field, eleven sub-fields, zero sub-fields, and fromType: "blocks".
  • Schema subset: oneOf with a one-value type discriminator, no default, additionalProperties: false, and cross-file $ref only to common.schema.json#/$defs/*.

The gate (fields_unsupported; API 409, with an MCP hint)

  • checkFieldDefinitions(site, before, { referenceTypes }) refuses both types, and markers that name them, unless the caller says its capability allows them. No caller does yet.
  • checkBundle gains assertFieldTypes(siteId, siteRow, support). An absent FieldTypeSupport refuses, unlike assertGates. This one check covers import, pull, push, --replace, --revert, restores and edition copies, which all go through importSite.
  • change_field_type to either type is refused in checkOperation. The API and CLI already accept only the nine types.

Pure core (core-ts/src/references/ plus fields/, exported from @cloudhal/core-ts/fields)

  • Definition rules (references/definitions.ts):

    • a target is a declared, inferred or posts collection (reference_target_unknown);
    • where only with one target, on its list fields, without now (invalid_query);
    • maxItems only with multiple;
    • sub-field keys unique and not reserved, with sub-field bounds and options checked;
    • itemLabel names a text, select or reference sub-field;
    • at most 100 leaves per collection, and sub-fields count toward the per-site 200.
  • Values:

    • fieldPropSchema compiles a reference to a group-id pattern, or a list of them, and a repeater to ADR-0030's array of flat objects;
    • valueIssues checks repeats, empty items and each sub-value's own rules, and gives rich text in items its 50,000-character limit;
    • per-item requiredToPublish;
    • too_many_references (100 per entry).
  • resolveFields skips types it does not know.

  • fieldsJsonSchema(…, { strict: true, collectionName }), with strictFieldsSchema and strictSchemaCost. The RFC's example costs {8, 4, 4, 6}. The golden snapshot for existing types is unchanged.

  • pickMember (membership by COALESCE(translation.group, id), the locale rule, the collection filter) and referenceIssues (missing, hidden and locale reasons).

  • Dispatch tables:

    • COMPILE and EMIT get real entries;
    • FIELD_QUERY_OPS gets [], and structured data gets "no value";
    • EntryFieldsForm's CONTROLS gets a read-only JSON stub with no new words.

    FIELD_TYPES stays the nine enabled types, now typed EnabledFieldType, and ALL_FIELD_TYPES has all eleven. In Studio, FieldRow and ListFieldsSection show the type badge only for enabled types; their words come with step 6.

MCP

  • update_collection's $defs are now defsClosure(SiteSchema, <the enabled FieldDefinition>) instead of a hand-written list, and the input lists only the nine types.
  • A new test walks every tool's input and output schema and resolves each $ref. Without this change, pnpm gen alone would have left #/$defs/FieldCondition and #/$defs/SubFieldDefinition dangling.

Implementation watch list

Every item on the watch list belongs to phases 2 to 4: batch-time guard SQL, purges, marker interplay and the phase-2 behaviour changes. This PR adds no SQL, no store writes and no rows.

The one interplay item that applies here: markers naming the new types (fromType, sub) are refused by the gate on both the definitions path and the bundle path.

Left for later steps

  • fieldDefinitionChanges does not yet compare reference multiple, collections or sub-fields (§2.8). That needs stored values and lands with step 2.
  • SiteGraphStoreOptions.fieldTypes and CopySiteOptions.fieldTypes are step 2. Today nothing passes a capability.

Tests run locally

One file at a time, SQLite only; D1 and DO are left to CI:

  • core-contracts: vitest, 539 tests.
  • cargo test --workspace (19 fixture tests) and cargo clippy -p cloudhal-schema --all-targets -D warnings.
  • core-ts:
    • new test/references-core.test.ts (21);
    • fields-definitions, fields-query, fields-resolve (golden unchanged), fields-values, fields-gate, fields-format, fields-conversions, fields-translation;
    • store.test.ts -t better-sqlite3 (164, including the new shared testing-reference-gate.ts: create/update site, field operation, import merge and replace, a marker);
    • sync, sync-plan, sync-restore, structured-data.
  • mcp-server: new test/tool-schemas.test.ts (5), plus fields, collections, tools, listings.
  • api: fields-api.test.ts -t better-sqlite3, with a new case for 409 fields_unsupported on dry run and save, and 400 for change_field_type to reference.
  • ui: entry-fields-form.test.tsx, with a new stub case.
  • studio: fields-studio.test.tsx.
  • Typecheck, one package at a time: core-ts, api, cli, mcp-server, ui, astro-renderer, cloudflare, schedules, studio, starters/blog.
  • pnpm drift, pnpm size:check, pnpm design:lint on the touched UI files, biome check on every touched file, mcp-tools-doc --check and demo-archive --check.

Changeset: @cloudhal/core-contracts minor, because the contract changed; core-ts, mcp-server and api patch. The packages are a fixed group, so this makes the next release 0.8.0. Change it to patch if you'd rather not cut a minor for a gated contract.

🤖 Generated with Claude Code

Madraka and others added 2 commits November 11, 2026 03:45
Adds the ReferenceField and RepeaterField variants to FieldDefinition, the
SubFieldDefinition union (nine *SubField variants, scalar values only, no
recursion), and FieldMigration's reference/repeater fromType and sub. The
content entry's fields description names the new values. Fixtures: one valid
stage-4 site, twelve invalid ones. TS and Rust regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
…RFC-0045 step 1, #114)

The gate: checkFieldDefinitions and checkBundle's new assertFieldTypes refuse
reference and repeater definitions, and markers naming them, with
fields_unsupported unless a capability allows them; none is passed yet, so
site writes (Studio, MCP, API), field operations and every importSite bundle
(import, pull, push, --replace, --revert, restores, edition copies) refuse.

Pure core in core-ts/src/references/ and fields/: the §1 definition rules
(targets, where, itemLabel, sub-field and leaf caps), props compilation and
value checks for both types, per-item requiredToPublish, too_many_references,
resolveFields skipping unknown types, the strict fieldsJsonSchema profile with
strictSchemaCost, pickMember and referenceIssues. Dispatch tables:
FIELD_QUERY_OPS and structured data get empty entries; EntryFieldsForm shows a
read-only stub; FIELD_TYPES stays the nine enabled types (EnabledFieldType).

MCP: update_collection's $defs come from defsClosure and list only enabled
types; a test checks every tool schema's $refs resolve. API: 409 for
fields_unsupported, 422 for the other two new codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
Madraka added a commit that referenced this pull request Nov 11, 2026
…ps (PR #203 review)

- assertFieldTypes(siteId, bundle, support) now checks the working site row and
  every site body a snapshot ref names in bundle.blobs, so a clean working
  copy cannot carry a reference or repeater definition (or a marker naming
  one) into a snapshot a publish or rollback could make live. Entry values
  need no own check: they mean nothing without a definition.
- The admin bundle route keeps fields_unsupported (409 with details.issues)
  instead of turning it into 400 bad_request.
- validateProps checks own keys (Object.hasOwn), so constructor, toString or
  __proto__ are unknown props; a repeater item counts as empty unless it holds
  a declared sub-field.
- A reference's where refuses now only on date and datetime fields; on select,
  text and url it is a literal.
- The fields_unsupported MCP hint names update_collection's input and the
  enabled types, interpolated from FIELD_TYPES.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
…ps (PR #203 review)

- assertFieldTypes(siteId, bundle, support) now checks the working site row and
  every site body a snapshot ref names in bundle.blobs, so a clean working
  copy cannot carry a reference or repeater definition (or a marker naming
  one) into a snapshot a publish or rollback could make live. Entry values
  need no own check: they mean nothing without a definition.
- The admin bundle route keeps fields_unsupported (409 with details.issues)
  instead of turning it into 400 bad_request.
- validateProps checks own keys (Object.hasOwn), so constructor, toString or
  __proto__ are unknown props; a repeater item counts as empty unless it holds
  a declared sub-field.
- A reference's where refuses now only on date and datetime fields; on select,
  text and url it is a literal.
- The fields_unsupported MCP hint names update_collection's input and the
  enabled types, interpolated from FIELD_TYPES.
- The list fingerprint (RFC-0042 §4) leaves out RFC-0045's where and
  itemLabel, which never change a resolved value; collections, multiple,
  maxItems and fields count. Its schema-walking test covers the eleven types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
@Madraka
Madraka force-pushed the claude/rfc45-contract-core-18f2ba branch from 425ee00 to c3b0b65 Compare November 11, 2026 01:19
…s (PR #203 verify)

- The snapshot part of the RFC-0045 gate moves to prepareImport as
  assertSnapshotFieldTypes, over the bodies bodiesToWrite has hash-checked
  and validated as sites. An invalid blob is refused by its schema again
  ("is not a valid site", never a TypeError), a forged text under a hash the
  target holds is not read, and the refusal's details.issues is capped at 50
  with total.
- validateProps reads own values only (also for required), so a prop named
  isPrototypeOf the value leaves out is absent; repeater value checks,
  referenceUses and per-item requiredToPublish read item values the same way
  (ownValue). toLocaleString, isPrototypeOf and propertyIsEnumerable join
  RESERVED_FIELD_KEYS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
@Madraka
Madraka merged commit a1a45ef into main Nov 11, 2026
8 checks passed
@Madraka
Madraka deleted the claude/rfc45-contract-core-18f2ba branch November 11, 2026 01:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant