From f3407572d7a3671a33249be2f17d0a6813af5349 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 14:59:50 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(spec,metadata-protocol):=20retire=20`fi?= =?UTF-8?q?eld`'s=20runtime-create=20door=20=E2=80=94=20an=20accepted=20PU?= =?UTF-8?q?T=20never=20reached=20the=20object=20(#7893)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry declared `field` with `allowRuntimeCreate: true`, so `PUT /api/v1/meta/field/.` was a sanctioned write. It was accepted, persisted and reported valid — and read by nothing. Measured end-to-end through the real HttpDispatcher -> ObjectStackProtocolImplementation -> SysMetadataRepository: the write answered 200 `state=active`, and `GET /meta/object/showcase_task` then listed `fields = [title, status]` with the new field absent, forever. The row is self-readable by name with `_diagnostics.valid: true` — well-formed and universally inert. `field` is the one declared type with no standalone existence: fields are authored inside the object (`ObjectSchema.fields`), so a `field` write mints a separate row keyed ('field','.') and nothing composes fragment rows into their parent. ADR-0049 enforce-or-remove; maintainer ruled REMOVE on 2026-08-12. Adding a field at runtime is NOT lost: `object` keeps `allowRuntimeCreate: true`, so writing the object with the field in `fields` both persists and composes. What is withdrawn is a second, broken spelling of that operation. The refusal says so — `codeOnlySourceHint` gives fragment types their real remedy instead of reading `field`'s `filePatterns` back (`**/*.field.ts` matches nothing in any app). - `field` -> `allowRuntimeCreate: false`, with the ruling, the measurement and the rejected options recorded at the registry entry. - ADR-0087 D3 SemanticMigration `field-runtime-create-withdrawn` (major 17). No D2 conversion: `allowRuntimeCreate` is a platform registry value, not an authorable one, so no authored source changes. - #7743's overlay refusal (403 NOT_OVERRIDABLE) is untouched, and its pinned control "THE FEATURE — allowRuntimeCreate: true is real and must survive" is retired deliberately and on the record, with the inversion documented. - Harness fix: the registry double's `registerItem` mirrored the wrong arity (`(type, name, item)` vs the producer's `(type, item, keyStrategy)`), so object write-through silently no-opped and the object-route control could not see it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH --- .changeset/field-runtime-create-retired.md | 73 +++++++++ content/docs/concepts/metadata-lifecycle.mdx | 3 +- docs/protocol-upgrade-guide.md | 3 + .../src/protocol.code-only-types.test.ts | 48 ++++-- .../protocol.org-scoped-write-refused.test.ts | 28 ++-- packages/metadata-protocol/src/protocol.ts | 33 +++- .../objectql/src/overlay-precedence.test.ts | 21 ++- .../src/meta-field-overlay-lock.test.ts | 148 ++++++++++++++---- packages/spec/spec-changes.json | 14 ++ .../src/kernel/metadata-create-seeds.test.ts | 12 +- .../spec/src/kernel/metadata-plugin.zod.ts | 74 ++++++++- .../metadata-type-field-registration.test.ts | 128 +++++++++++++++ .../17.field-runtime-create-withdrawn.ts | 70 +++++++++ packages/spec/src/migrations/registry.ts | 66 ++++++++ 14 files changed, 663 insertions(+), 58 deletions(-) create mode 100644 .changeset/field-runtime-create-retired.md create mode 100644 packages/spec/src/kernel/metadata-type-field-registration.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.field-runtime-create-withdrawn.ts diff --git a/.changeset/field-runtime-create-retired.md b/.changeset/field-runtime-create-retired.md new file mode 100644 index 0000000000..b92b2d209b --- /dev/null +++ b/.changeset/field-runtime-create-retired.md @@ -0,0 +1,73 @@ +--- +'@objectstack/spec': major +'@objectstack/metadata-protocol': major +--- + +`field` loses `allowRuntimeCreate` — a standalone `field` write is refused instead of silently doing nothing (#7893, ADR-0049 enforce-or-remove, maintainer-ruled 2026-08-12) + +**BEHAVIOUR CHANGE — what now gets refused, and what to do instead.** + +| | Before | After | +| --- | --- | --- | +| `PUT /api/v1/meta/field/{object}.{name}` | `200 {"success":true,"state":"active"}` — row persisted, `_diagnostics.valid: true`, and the field **never** appeared in the object's `fields` | `403 NOT_CREATABLE`, naming the remedy | +| `PUT /api/v1/meta/fields/{object}.{name}` (plural) | `200` — same, via the URL fold closed in #7894 | `403 NOT_CREATABLE` — folds onto the singular and earns the same verdict | +| `PUT /api/v1/meta/object/{name}` with the field in `fields` | `200`, and the field **is** read back | unchanged — this is the route to use | + +**The one-line fix:** author the field inside its object and write the whole +object — `PUT /api/v1/meta/object/{object}` with the new entry in `fields` — or +declare it in the object source (`**/*.object.ts`) and redeploy. The refusal +body says exactly this. + +**Adding a field at runtime is not lost.** `object` keeps +`allowRuntimeCreate: true`, so the operation still works on the route that +actually composes; what is withdrawn is a second, broken *spelling* of it. This +is deliberately **not** the `api` (#5488) rationale reused — that ruling rested +on "zero business pull", and "add a field" is the opposite, a core Studio/CRM +operation. + +**Why it was removed rather than built.** `field` is the one declared type with +no standalone existence: fields are authored inside the object +(`ObjectSchema.fields`), so a `field` write minted a *separate* `sys_metadata` +row keyed `('field','.')` and nothing composed fragment rows into +their parent — `applyRegistryWriteThrough` routes only `type === 'object'`, and +`filePatterns` (`**/*.field.ts`) match nothing in any app. Measured end-to-end: +the write answered 200 `state=active` and `GET /meta/object/showcase_task` then +listed `fields = [title, status]` with the new field absent, forever. The row +was even self-readable by name with `_diagnostics.valid: true` — well-formed and +universally inert. Building the read path is a feature spanning at least three +packages (a composition step that does not exist, ~20 `gate.fields` call sites, +physical schema/migrations, cold boot); if ever wanted it is a separate card, +implementation first and declaration second. + +**Existing rows.** `field` rows already written through the retired channel stay +in `sys_metadata` and are **inert** — they were inert before this change too, +because no read path ever composed them into an object. Nothing that used to +work stops working, and no stored data is reinterpreted. They remain +self-readable by name and still report `_diagnostics.valid: true`, which asserts +only that the isolated document is well-formed (#8169 — the envelope has no "in +effect" axis). Delete them at leisure: `deleteMetaItem` is deliberately not +gated by this refusal, so repair stays possible. + +**Not changed:** #7743's overlay refusal. Overwriting a field a code package +ships is still `403 NOT_OVERRIDABLE` — a different gate for a different +question. Making field *overrides* legal was never part of this decision. + +**Escape hatch:** an operator may set `OS_METADATA_WRITABLE=field` on a single +deployment. Note this unlocks the *write* only — the field still will not reach +its object, so it is a diagnostic, not a workaround. + +The retirement kit: + +- `field` flipped to `allowRuntimeCreate: false` in + `DEFAULT_METADATA_TYPE_REGISTRY`, with the ruling, the measurement and the + rejected options recorded at the entry. +- ADR-0087 D3 `SemanticMigration` `field-runtime-create-withdrawn` (major 17). + There is **no** D2 conversion, deliberately: `allowRuntimeCreate` is a + platform registry value, not an authorable one, so no authored source + changes — an `**/*.object.ts` file valid before this change is valid after it, + byte for byte. What changed is a runtime HTTP verdict. +- The refusal's prescription no longer reads `field`'s own `filePatterns` back: + `**/*.field.ts` names a route that has never worked, so `codeOnlySourceHint` + gives fragment types their real remedy instead. +- `field` auto-enrolled into the derived code-only refusal suite (both kernel + topologies), and the plural spelling is pinned as folding onto the same gate. diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index 3712594057..32853d9871 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -111,7 +111,8 @@ In shared-database multi-tenancy, **most metadata types must not be per-org cust | `flow` | ✅ | Per-org overlays are allowed for automation definitions. | | `agent` | ❌ | Agents are platform-owned and closed to third parties (ADR-0063 §2) — no per-org agent fork. | | `permission`, `position` | ✅ | Per-org overlays are allowed; tenant-level controls layer on top. | -| `object`, `field` | ❌ | Defines the table schema. Overriding a packaged object/field would break existing data — but both set `allowRuntimeCreate: true`, so tenants *can* author brand-new objects and fields. | +| `object` | ❌ | Defines the table schema. Overriding a packaged object would break existing data — but `allowRuntimeCreate: true`, so tenants *can* author brand-new objects, **and this is also how a new field is added** (write the object with the field in `fields`). | +| `field` | ❌ | **Also `allowRuntimeCreate: false` since protocol 17** (#7893). A field is not a standalone artifact — fields are authored inside their object (`ObjectSchema.fields`), so a `field` write minted a *separate* `sys_metadata` row keyed `('field','.')` that nothing ever composed into the parent: `PUT /meta/field/showcase_task.zz_probe` answered 200 `state=active`, the row read back `_diagnostics.valid: true`, and `GET /meta/object/showcase_task` never listed the field. The door is closed rather than bridged: add a field by writing its **object** (`PUT /meta/object/:name`, or `**/*.object.ts` and redeploy), which both persists and composes. Existing rows are untouched — they were inert before the change too — and remain deletable. | | `datasource` | ❌ | Connection strings; multi-tenant isolation is enforced at a higher layer. (`allowRuntimeCreate: true` — the datasource wizard persists `origin: 'runtime'` rows.) | | `job` | ❌ | **Also `allowRuntimeCreate: false` since protocol 17** (#4509). `JobSchema.handler` names a function in the compiled bundle's function table, which a runtime writer has no way to reach — so a job created in Studio or through `PUT /meta` parsed, saved, reported success and was never scheduled. The door is closed rather than bridged: `job` stays first-class through `*.job.ts` / `defineStack({ jobs, functions })`, where every schedule shape, `retryPolicy` and `timeout` does reach the scheduler. Existing rows are untouched — they were never scheduled — and `migrateStoredMetadata` reports them `skipped`. | diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 3e8f328c00..268a807aa3 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -434,6 +434,9 @@ ONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely - **`external-lookup-message-queue-families-retired`** — `data.externalLookup / data.externalDataSource / data.externalFieldMapping (the whole of data/external-lookup.zod.ts — 3 defs, 8 exported names) and system.messageQueue (the whole of system/message-queue.zod.ts — MessageQueueConfig, MessageQueueProvider, TopicConfig, ConsumerConfig, DeadLetterQueue — 5 defs, 14 exported names)` → (removed — there is no replacement key, because there was never a key: neither family was reachable from any metadata-type binding, stack collection or /meta door, so no document could carry either. For external data: `object.external` (`ObjectExternalBindingSchema`, ADR-0015/0062) names a datasource by reference and connection credentials live in the datasource config — never inline in object metadata; `data/external-catalog.zod.ts` is that federated path's catalog surface and is untouched. For message queues: the LIVE surface is `kernel/events/integrations.zod.ts`'s `EventMessageQueueConfig` (`EventBusConfig.messageQueue`), which deliberately carries NO credential field — broker connection and SASL credentials are runtime deployment configuration, not authorable metadata. Either capability returns via the ENFORCE route of ADR-0049 through a new ADR — the executor / broker admin service first, the vocabulary second) - Why not automatic: Both families are the #8075 census verdict (fork (b), accepted 2026-08-12): security-shaped declared surface with inline-credential sinks and ZERO consumers. `ExternalDataSourceSchema.authentication.config` is a record of unknown whose own docblock example wrote `"clientSecret": "..."` inline, and `MessageQueueConfigSchema.sasl.password` was a required inline broker credential — the #7990 class (cleartext-at-rest credential sinks), except that unlike #7990's two measured surfaces nothing ever persisted these: no metadata-type binding (kernel/metadata-type-schemas.ts imports neither module), no stack collection, no object/field embedding (`object.external` binds `ObjectExternalBindingSchema` — remoteName/remoteSchema/writable/columnMap, no authentication), and zero imports outside packages/spec repo-wide, with the corpus-reach control (`DatasourceSchema` under identical exclusions) returning hits in the same run. The consumed MQ near-namesake `kernel/EventMessageQueueConfig` deliberately has no credential key, so the consumed shape had no credential and the credential-bearing shape had no consumer. A dead schema minus one field is still a dead schema, so the whole declarations go, not just the credential faces (#3950: an exported schema with no consumer reads as a capability to whoever finds it — here it read as an invitation to author secrets in cleartext). With no carrier key there is nothing to tombstone and no source or `sys_metadata` row for a D2 conversion to rewrite: route 3, the #4834 / #4988 / #5055 / #6486 shape — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. ⚠️ The #5552 `data/ExternalFieldMapping:transform` tombstone (one of that retirement's three spellings) is SUBSUMED by the def retirement, the WidgetManifest.performance way: it goes with the shape that carried it. The base `shared/FieldMapping` tombstone and the `integration/ConnectorFieldMapping` spelling are untouched and still reject `transform` with the #5552 prescription. ⚠️ The #7990 Option-B reopen trigger ("a third measured artefact-type surface") is NOT met by this census — that ruling's parked class-level write-boundary guard stays parked; this is the ADR-0049 leg of the fork the triage pre-agreed. - Done when: No code imports `ExternalLookup(Schema|Parsed)`, `ExternalDataSource(Schema)`, `ExternalFieldMapping(Schema|Parsed)`, `MessageQueueConfig(Schema|Parsed)`, `MessageQueueProvider(Schema)`, `TopicConfig(Schema|Parsed)`, `ConsumerConfig(Schema|Parsed)` or `DeadLetterQueue(Schema|Parsed)` from `@objectstack/spec`, `@objectstack/spec/data` or `@objectstack/spec/system` — every one is TS2305 after upgrade, on every public entry (pinned by resolved symbol identity in `data/external-lookup-retirement.test.ts` and `system/message-queue-retirement.test.ts`). No metadata document needs editing, because none could ever carry one of these shapes. `kernel/EventMessageQueueConfig` (with its inline provider enum and no credential key), `data/external-catalog.zod.ts`, `object.external` and `kernel/DeadLetterQueueEntry` survive unchanged. +- **`field-runtime-create-withdrawn`** — `PUT /api/v1/meta/field/{object}.{name} (runtime-authored standalone `field` items)` → Author the field inside its object and write the whole object — PUT /api/v1/meta/object/{object} with the new field in `fields` — or declare it in the object source (`**/*.object.ts`) and redeploy + - Why not automatic: The `field` registry entry declared `allowRuntimeCreate: true` and the platform never built a read path for it. Measured end-to-end through the real HttpDispatcher -> ObjectStackProtocolImplementation -> SysMetadataRepository (#7893): `PUT /api/v1/meta/field/showcase_task.zz_probe` answered 200 with {"success":true,"state":"active","message":"Saved field …"}, the row persisted, and `GET /api/v1/meta/object/showcase_task` then listed fields = [title, status] with zz_probe ABSENT — forever. The row is even self-readable by name (`GET /meta/field/showcase_task.zz_probe` -> 200, `_diagnostics.valid: true`), which makes it well-formed and universally inert rather than malformed. The seam is that `field` is the ONE declared type with no standalone existence: fields are authored inside the object (`ObjectSchema.fields`), a `field` write mints a SEPARATE row keyed ('field','.'), and nothing composes fragment rows into their parent — `applyRegistryWriteThrough` routes only `type === 'object'`, and `filePatterns` (`**/*.field.ts`) match nothing in any app. A declared capability the platform cannot honour is ADR-0049 false compliance, and the maintainer ruled REMOVE on 2026-08-12 rather than build the read path, which is a feature spanning at least three packages (a composition step that does not exist, ~20 `gate.fields` call sites, physical schema/migrations, and cold boot via `loadMetaFromDb`); if ever wanted it is a separate card — implementation first, declaration second. ⚠️ This is NOT the #5488 (`api`) rationale reused: that ruling rested on "zero business pull", and "add a field" is the opposite — a core Studio/CRM operation. The justification here is that the operation REMAINS AVAILABLE on the route that actually composes: `object` keeps `allowRuntimeCreate: true`, so what is withdrawn is a second, broken SPELLING of adding a field, not the ability to add one. There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and no authored source changes — an `**/*.object.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `api` (#5488) and `BatchOptions.validateOnly` (#4052) take. ADR-0049 / ADR-0087, #7893 (split from #7743). + - Done when: No caller creates a standalone `field` item through the runtime metadata API. `PUT /api/v1/meta/field/{object}.{name}` answers 403 with `code: "NOT_CREATABLE"` and a body naming both flags (`allowRuntimeCreate=false, allowOrgOverride=false`) and the prescription `PUT /api/v1/meta/object/:object with the new field in `fields``. The plural spelling `PUT /api/v1/meta/fields/{object}.{name}` folds onto the singular (#7894) and earns the same refusal — verify it, because it was a separate door until 2026-08-12. ⚠️ Verify the OBJECT route is UNAFFECTED, which is the whole point of the change: `PUT /api/v1/meta/object/{name}` with a new entry in `fields` still answers 200, and `GET /api/v1/meta/object/{name}` READS THE NEW FIELD BACK (assert on `body.data.item.fields`, not `body.item`, which is undefined and makes an empty read look like a pass). Assert a DECLARED field is present in the same response, so a dead read cannot be what makes the check pass. ⚠️ #7743's overlay refusal is untouched and must stay: overwriting a field a code package ships is still 403 `NOT_OVERRIDABLE`, a different gate for a different question — making field OVERRIDES legal was never part of this decision. DISPOSITION OF EXISTING ROWS: `field` rows already written through the retired channel stay in `sys_metadata` and are INERT — they were inert before this change too, since no read path ever composed them into an object, so nothing that used to work stops working and no data is silently reinterpreted. They remain self-readable by name and still report `_diagnostics.valid: true`, which asserts only that the isolated document is well-formed (see #8169 — the envelope has no "in effect" axis). They may be deleted at leisure: `deleteMetaItem` is deliberately NOT gated by this refusal, so repair stays possible. An operator who needs the write door back on one deployment sets `OS_METADATA_WRITABLE=field`; note this unlocks the WRITE only — the field still will not reach its object, which is why it is a diagnostic and not a workaround. - **`filter-regex-options-retired`** — `data.filter $regex / $options — in a STORED filter (dashboard widget filter and globalFilters, report runtimeFilter, page and component filter, solution-blueprint filter), and equally in the where clause of a query request` → $icontains for the case-insensitive substring match this was almost always used for, or $contains for a case-sensitive one — a pattern that genuinely needs a regular expression has no filter-level replacement - Why not automatic: Like `driver-aggregate-undeclared-key-aliases-removed` and `driver-sql-distinct-bare-filter-typed`, this entry records a LENIENCY being withdrawn rather than a declared surface: `$regex` was never in `FILTER_OPERATORS` and never a key on `StringOperatorSchema`. That is measured, not assumed — `git log -S'$regex'` over `packages/spec/src` returns only doc comments describing how `$contains` LOWERS to MongoDB (`Contains substring - SQL: LIKE %?% | MongoDB: $regex`), plus #5701 itself, which added the name solely as `RETIRED_FILTER_OPERATORS` prescription data. ⚠️ But it differs from those two in the one way that decides the disposition, so a reader should not have to infer it: those were driver CALL ARGUMENTS, code and never stack metadata, whereas a filter IS stored metadata. `FilterConditionSchema` is an OPEN RECORD (`z.record(z.string(), z.unknown())`) because a filter key is a field name, so a stored `{ name: { $regex: 'acme.*' } }` parses GREEN and always will — a `retiredKey()` tombstone cannot exist on an open map, which is exactly why the ledger has to carry this. What such a stack used to get was four different answers from four backends: `driver-sql` and Turso's remote transport compiled it to a LIKE-escaped SUBSTRING (so `a.b` matched only the literal `a.b` and the regex was silently never a regex), `driver-memory` and objectql's `having` ran it as a real `RegExp` (so the same filter also matched `axb`, and an INVALID pattern was caught and answered `false` — zero rows, in silence), and `driver-mongodb` refused it with a bare `Error` carrying no `code` and no `status`. It is now refused everywhere with INVALID_FILTER / 400 naming the replacement. There is deliberately NO D2 conversion and this sits in `semantic` rather than among the mechanical transforms: rewriting `$regex` to `$icontains` is NOT lossless in either direction — a regex metacharacter becomes a literal — so an auto-applied rewrite would silently change which rows a dashboard, report or permission filter selects, a wrong number rather than a missing one. Choosing the substring the pattern MEANT is a judgment about the query, not a transform. ⚠️ This entry covers BOTH HALVES of the #4706 ruling (B), not just the driver one: the contract half (#5701 — the `$icontains` declaration, the `$contains` family pinned case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the ADR-0087 disposition gate (#6148) existed and so was never asked for a ledger entry; the driver half (#5702) is where the refusal became executable. One surface, one entry, registered from the half that made it observable. ADR-0049 / ADR-0087, #4706 / #5701 / #5702. - Done when: No stored filter and no request `where` spells `$regex` or `$options` — grep the stack for both. Each one is rewritten by asking what the pattern MEANT, not by transliterating it: a bare substring pattern becomes `$icontains` (or `$contains` when the match must stay case-sensitive), and its metacharacters are dropped rather than escaped, because they were never honoured as a regex on the SQL family in the first place. ⚠️ Expect the answer to CHANGE on any stack that ran on `driver-memory`, `driver-mongodb` or objectql `having`, where the pattern really was evaluated as a regular expression; on the SQL family the rewritten filter returns what it always returned. A pattern that genuinely needs alternation, anchoring or character classes has no filter-level replacement — move that predicate into a formula field or a server-side view, or open an issue for it. Verify by loading the stack: a surviving `$regex` or `$options` is answered INVALID_FILTER / 400 with a message naming the replacement, on every backend. diff --git a/packages/metadata-protocol/src/protocol.code-only-types.test.ts b/packages/metadata-protocol/src/protocol.code-only-types.test.ts index 0ac95183e7..572207326e 100644 --- a/packages/metadata-protocol/src/protocol.code-only-types.test.ts +++ b/packages/metadata-protocol/src/protocol.code-only-types.test.ts @@ -126,6 +126,33 @@ const PROBES: Record }> = objectParams: { object: 'example_object', operation: 'find' }, }, }, + // [#7893] The fifth flagged type, maintainer-ruled 2026-08-12. `field` + // declared `allowRuntimeCreate: true` and the platform never built the read + // path: a `field` write mints a SEPARATE `sys_metadata` row keyed + // `('field', '.')`, and nothing composes fragment rows into + // their parent — measured end-to-end, `PUT /meta/field/showcase_task.zz_probe` + // answered 200 `state=active` while `GET /meta/object/showcase_task` never + // listed the field. ADR-0049 enforce-or-remove, ruled REMOVE. + // + // ⚠️ The name is DOTTED because that is the only shape this type is ever + // addressed by (`.`); a bare name would probe a spelling no + // caller uses. The parent is deliberately an object NO artifact ships, so + // the write is classified `runtime-only` (a CREATE) and earns + // `NOT_CREATABLE` — an artifact-backed parent would take #7743's + // `isNestedArtifactField` path and answer `NOT_OVERRIDABLE` instead, which + // is a different gate and stays untouched by this card. + // + // Schema-valid on purpose, like the four above: `field` resolves + // `FieldSchema`, so a malformed body would 422 before the registry consult + // and the probe would prove nothing about the code-only gate. + field: { + name: 'rc3_field_probe.zz_probe', + item: { + name: 'zz_probe', + label: 'Probe', + type: 'text', + }, + }, }; function makeStubEngine(artifacts: Array<{ type: string; name: string }> = []) { @@ -228,17 +255,20 @@ describe('code-only metadata types are refused on every kernel (#5086)', () => { it('covers every code-only type the registry declares', () => { // Today: job (#4509), agent (ADR-0063 §2), capability (#5961, - // ADR-0066 D1) and api (#5488, ADR-0049 remove side — the maintainer + // ADR-0066 D1), api (#5488, ADR-0049 remove side — the maintainer // ruling of 2026-08-07 withdrew a runtime create door the endpoint - // matcher could never read). When a fifth type is flagged, this fails - // until it has a schema-valid probe above — which is the whole cost of - // covering it, and is exactly what happened when `capability` joined - // and again when `api` did: this assertion and the generated cases - // went red on the spec-side registry edit alone, before a line of this - // file was touched. That auto-enrolment is the point of deriving the - // set instead of listing it (Prime Directive #8). + // matcher could never read) and field (#7893, the same remove side — + // ruled 2026-08-12; a `field` write minted a standalone row nothing + // ever composed into its parent object). When a sixth type is flagged, + // this fails until it has a schema-valid probe above — which is the + // whole cost of covering it, and is exactly what happened when + // `capability` joined, again when `api` did, and again for `field`: + // this assertion and the generated cases went red on the spec-side + // registry edit alone, before a line of this file was touched. That + // auto-enrolment is the point of deriving the set instead of listing it + // (Prime Directive #8). expect(CODE_ONLY_TYPES.length).toBeGreaterThan(0); - expect([...CODE_ONLY_TYPES].sort()).toEqual(['agent', 'api', 'capability', 'job']); + expect([...CODE_ONLY_TYPES].sort()).toEqual(['agent', 'api', 'capability', 'field', 'job']); for (const type of CODE_ONLY_TYPES) { expect(PROBES[type], `no probe payload for code-only type '${type}'`).toBeDefined(); } diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index a7410e325b..c8c9b33fe5 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -528,10 +528,11 @@ describe('#6190 — org-scoped writes of non-org-overridable types are refused', // instead, the enforcement cases above go red rather than this one — // which is why they, not this, are the acceptance criterion. Recorded // as a measurement so the blast radius of the ruling is auditable: - // 18 of 27 registry entries change behaviour here. (It was 19 when the + // 17 of 27 registry entries change behaviour here. (It was 19 when the // ruling was made; #5488 has since withdrawn `api`'s runtime-create - // door entirely, so `api` now sits in the CODE-ONLY tier — refused - // env-wide and org-scoped alike, before this gate is consulted.) + // door entirely and #7893 withdrew `field`'s, so both now sit in the + // CODE-ONLY tier — refused env-wide and org-scoped alike, before this + // gate is consulted.) const affected = DEFAULT_METADATA_TYPE_REGISTRY .filter((e) => !e.allowOrgOverride && e.allowRuntimeCreate) .map((e) => e.type); @@ -541,14 +542,21 @@ describe('#6190 — org-scoped writes of non-org-overridable types are refused', expect(orgOverridable).toEqual(['view', 'dashboard', 'report', 'translation', 'email_template']); // The types the maintainer ruling names explicitly, all present. - for (const t of ['object', 'field', 'hook', 'seed', 'mapping', 'flow']) { + for (const t of ['object', 'hook', 'seed', 'mapping', 'flow']) { expect(affected, `${t} must be refused org-scoped`).toContain(t); } - // `api` was also named by the ruling; it left this set for the - // stronger tier, not for a per-org channel — pin the direction. - expect( - DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === 'api'), - ).toMatchObject({ allowOrgOverride: false, allowRuntimeCreate: false }); - expect(affected).toHaveLength(18); + // `api` and `field` were also named by the ruling; each left this set + // for the STRONGER tier, not for a per-org channel — pin the direction, + // because "no longer in the org-scoped refusal set" reads identically + // to "now permitted org-scoped" unless the destination is asserted. + // (`field`: #7893, maintainer-ruled 2026-08-12 — the standalone create + // door minted a row no read path composed into its parent object.) + for (const t of ['api', 'field']) { + expect(affected, `${t} must have left this set for the code-only tier`).not.toContain(t); + expect( + DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === t), + ).toMatchObject({ allowOrgOverride: false, allowRuntimeCreate: false }); + } + expect(affected).toHaveLength(17); }); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 0f980a4131..5923844e65 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -9180,14 +9180,45 @@ export class ObjectStackProtocolImplementation implements return false; } + /** + * [#7893] The prescription for a type whose artifacts are NOT standalone. + * + * `filePatterns` is the right source for every OTHER type, because for + * every other type it names a file a loader really ingests. `field` is the + * one declared type where it does not: `**\/*.field.ts` matches nothing in + * any app (see {@link isNestedArtifactField} for the measurement), because + * fields are authored INSIDE the object. Reading the glob back would answer + * a refusal with a route that has never worked — moving the confusion one + * layer down instead of ending it, which is exactly what #5086's refusal + * shape exists to avoid. + * + * The remedy named here is the one the maintainer ruled on 2026-08-12 and + * is a route that genuinely composes: `object` keeps `allowRuntimeCreate: + * true`, so writing the whole object with the new field in `fields` both + * persists and reaches every consumer. Adding a field at runtime is still + * possible; only this spelling of it is withdrawn. + * + * Keyed by type rather than branched inline so a second fragment type, if + * one is ever declared, states its own remedy instead of inheriting an + * unrelated one. + */ + private static readonly NESTED_TYPE_REMEDY: Readonly> = { + field: ' Fields are not standalone artifacts: author it inside its object and write the whole ' + + 'object instead (PUT /api/v1/meta/object/:object with the new field in `fields`), or declare ' + + 'it in the object source (**/*.object.ts) and redeploy.', + }; + /** * The prescription half of a code-only refusal (#5086): where the author * is supposed to declare this item instead. Read from the type's own * registry entry (`filePatterns`), so a newly-flagged type carries an - * accurate hint the day it is flagged — nothing here to keep in sync. + * accurate hint the day it is flagged — nothing here to keep in sync, + * except the fragment types {@link NESTED_TYPE_REMEDY} names explicitly. */ private static codeOnlySourceHint(type: string): string { const singular = PLURAL_TO_SINGULAR[type] ?? type; + const nested = ObjectStackProtocolImplementation.NESTED_TYPE_REMEDY[singular]; + if (nested) return nested; const entry = DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === singular); const pattern = entry?.filePatterns?.[0]; return pattern ? ` Declare it in source (${pattern}) and redeploy.` : ''; diff --git a/packages/objectql/src/overlay-precedence.test.ts b/packages/objectql/src/overlay-precedence.test.ts index 05b4a2fe70..abd6a7cfab 100644 --- a/packages/objectql/src/overlay-precedence.test.ts +++ b/packages/objectql/src/overlay-precedence.test.ts @@ -230,7 +230,7 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { // it. The reintroduction guard below is what holds the line now. { type: 'hook', item: { name: 'before_save', object: 'case', events: ['beforeInsert'] } }, { type: 'hooks', item: { name: 'before_save', object: 'case', events: ['beforeInsert'] } }, // plural - // object/field reverted to allowOrgOverride:false on 2026-05-29 — + // object reverted to allowOrgOverride:false on 2026-05-29 — // packaged items locked, brand-new tenant-authored items succeed. { type: 'object', @@ -240,10 +240,21 @@ describe('overlay whitelist enforcement (shared-DB invariant)', () => { fields: { title: { name: 'title', type: 'text', label: 'Title' } }, }, }, - { - type: 'field', - item: { name: 'tenant_widget_color', type: 'text', label: 'Color' }, - }, + // `field` left this list on 2026-08-12 (#7893, maintainer-ruled), + // the same way `validation` left it with its kind: "runtime- + // creatable" stopped describing it. Unlike `validation` the KIND + // survives — reads, `/meta/types` and #7743's overlay refusal all + // still need it — but its CREATE door is closed + // (`allowRuntimeCreate: false`), because a standalone `field` write + // minted a row keyed ('field','.') that nothing ever + // composed into the parent object: measured 200 `state=active` at + // the write, and the field absent from `GET /meta/object/...` + // forever. A field is added by writing its OBJECT — the `object` + // specimen directly above carries `fields`, which is that route. + // The line is now held by `protocol.code-only-types.test.ts` (which + // derives the code-only set from the registry, so `field` + // auto-enrolled) and by the declaration pin in + // `packages/spec/src/kernel/metadata-type-field-registration.test.ts`. // datasource/datasources became runtime-creatable with the // ADR-0015 Addendum (UI "Add Datasource"). Brand-new runtime // datasources succeed; code-defined collisions are refused via diff --git a/packages/runtime/src/meta-field-overlay-lock.test.ts b/packages/runtime/src/meta-field-overlay-lock.test.ts index 08a321c82a..8289e69bea 100644 --- a/packages/runtime/src/meta-field-overlay-lock.test.ts +++ b/packages/runtime/src/meta-field-overlay-lock.test.ts @@ -65,28 +65,36 @@ * field override 403 200, row persisted → RED * …on `status` 403 200, row persisted → RED * …env kernel 403 200, row persisted → RED - * brand-new field 200 200, unchanged → GREEN - * field of a runtime-created object → GREEN + * brand-new field 403 200, row persisted (#7893) → RED + * field of a runtime-created object — same, 403 (#7893) → RED * object override 403 403, unchanged → GREEN * view override 200 200, unchanged → GREEN * dashboard 200 200, unchanged → GREEN * job 403 403, unchanged → GREEN * plural spelling 403 200, row under type='fields' (#7894) → RED + * object route creates AND composes (#7893 control) → GREEN * - * The last row INVERTED when #7894 landed. It used to assert the defect (200, - * plus a row under the plural key) and carried instructions to flip it; the - * flip is done, and because the old case had really measured that 200, the new - * 403 cannot be passing by never reaching the boundary. See the #7894 block at - * the bottom of this file for the plural fold, its positive controls, and the - * refusal limb. + * Two rows INVERTED after #7743, and each inversion is this file's anti-vacuity + * proof rather than a rewrite of history: * - * The greens are NOT slack. Four of them are the negative direction the card + * • the plural spelling, when #7894 landed. It used to assert the defect + * (200, plus a row under the plural key) and carried instructions to flip + * it; because the old case had really measured that 200, the new 403 cannot + * be passing by never reaching the boundary. + * • the two brand-new-field rows, when #7893 landed. They used to assert + * `200` under the banner "THE FEATURE — `allowRuntimeCreate: true` is real + * and must survive", and the maintainer ruled on 2026-08-12 that the + * feature was never real: the write persisted a standalone row that no read + * path ever composed into its parent object. Same argument applies — those + * cases demonstrably reached the write door, because they measured its 200. + * See the #7893 block for the full record. + * + * The greens are NOT slack. Four of them are the negative direction #7743 * demanded: `object` / `view` / `dashboard` / `job` were measured as ALREADY * CORRECT in the same QA run, so a fix that tightened any of them is over-reach - * and must fail here. Two are the legitimate `field` write: - * `allowRuntimeCreate: true` is real, and a fix that refused every `field` PUT - * would pass a one-directional test while breaking the feature. The last is a - * hole this card does NOT close and refuses to hide — see below. + * and must fail here. The last is #7893's positive control — the OBJECT route + * still creates a field AND reads it back, which is what makes the retirement a + * redirect rather than a lost capability. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -237,10 +245,26 @@ function makeEngine() { getPackage: () => undefined, isPackageDisabled: () => false, applyNavContributions: (app: unknown) => app, - registerItem: (type: string, name: string, item: unknown) => { + // [#7893] Signature mirrors the PRODUCER's call, not a convenient + // shape: the real `SchemaRegistry.registerItem` takes + // `(type, item, keyStrategy, ownerId?)` — the ITEM second — and + // `applyObjectRegistryMutation` calls it as + // `registerItem(type, item, 'name')`. + // + // ⚠️ This double previously declared `(type, name, item)`. Nothing + // failed: the call still "succeeded", storing the item object as the + // KEY and the string `'name'` as the VALUE, so the seeded entry was + // never replaced and every read served the stale body. A write-through + // that silently no-ops is exactly what an object-route control must be + // able to see, so the arity is pinned to the producer's here. + registerItem: (type: string, item: any, keyStrategy?: string, _ownerId?: string) => { + const key = keyStrategy === 'object' + ? (item?.object as string) + : (item?.name as string); + if (!key) return; let byName = runtimeItems.get(type); if (!byName) { byName = new Map(); runtimeItems.set(type, byName); } - byName.set(name, item); + byName.set(key, item); }, registerObject: () => {}, }, @@ -396,12 +420,35 @@ describe('#7743 — PUT /meta/field/. honours the registry overla expect(metaRow(engine, 'field', 'showcase_task.title')).toBeUndefined(); }); - // ── THE FEATURE — `allowRuntimeCreate: true` is real and must survive ── + // ── #7893 — THE FEATURE THAT WASN'T: the create tier is RETIRED ──────── + // + // This block REPLACES two cases pinned under the banner "THE FEATURE — + // `allowRuntimeCreate: true` is real and must survive". Those pins were + // doing exactly their job: #7743 wrote them so that a later fix could not + // quietly retire runtime field authoring, and they predicted in their own + // comment that "a fix that refused every `field` PUT" would be the way this + // file went wrong. That prediction was correct about the MECHANISM and + // wrong about the PREMISE they shared — that there was a create door here + // worth protecting. + // + // There was not. Measured end-to-end through this same harness (#7893): + // `PUT /field/showcase_task.zz_probe` answered 200 `state=active` and + // persisted a row, and `GET /object/showcase_task` then listed + // `fields: ['title','status']` — the field ABSENT, forever. `field` is the + // one declared type with no standalone existence: the write minted a + // separate row keyed `('field','.')` and nothing composes + // fragment rows into their parent. The door opened onto nothing. + // + // Maintainer ruled REMOVE on 2026-08-12 (ADR-0049 enforce-or-remove), so + // the retirement is deliberate and on the record here, the way #5488 + // retired `api`'s door in the change that flipped it. + // + // ⚠️ Adding a field at runtime is NOT lost — it moved to the route that + // actually composes. `object` keeps `allowRuntimeCreate: true`, so the + // remedy in the refusal body (`PUT /meta/object/:object` with the new field + // in `fields`) is a live capability, pinned by the CONTROL below. - it('CONTROL — a brand-new field on a packaged object is still CREATED (allowRuntimeCreate)', async () => { - // The registry declares two orthogonal tiers and this card closes only - // the overlay one. A fix that refused every `field` PUT would pass all - // four cases above and silently retire runtime field authoring. + it('#7893 — a brand-new field is REFUSED as code-only, naming the object route', async () => { const { engine, dispatcher } = makeStack(); const res = responseOf(await dispatcher.handleMetadata( @@ -409,15 +456,31 @@ describe('#7743 — PUT /meta/field/. honours the registry overla { name: 'zz_new_probe', label: 'Probe', type: 'text' }, )); - expect(res.status).toBe(200); - expect(metaRow(engine, 'field', 'showcase_task.zz_new_probe')).toBeDefined(); + // ADR-0112 — code AND status, never "it threw". `NOT_CREATABLE` rather + // than `NOT_OVERRIDABLE`: no package ships `zz_new_probe`, so this is a + // CREATE, and it is the create tier this card closed. + expect(res.status).toBe(403); + expect(res.body?.error?.code).toBe('NOT_CREATABLE'); + // The prescription is the whole point of the refusal: a 403 that does + // not say where to go leaves the author exactly as stuck as the 200 did. + expect(res.body?.error?.message).toMatch(/code-only/); + expect(res.body?.error?.message).toContain('PUT /api/v1/meta/object/:object'); + // ⚠️ It must NOT read `field`'s own `filePatterns` back — `**/*.field.ts` + // matches nothing in any app, so prescribing it would name a route that + // has never worked. + expect(res.body?.error?.message).not.toContain('*.field.ts'); + // Nothing persisted: refused BEFORE the row is minted, so no new inert + // row can be created from today on. + expect(metaRow(engine, 'field', 'showcase_task.zz_new_probe')).toBeUndefined(); }); - it('CONTROL — a field of a RUNTIME-created object is not artifact-backed, so it stays writable', async () => { - // The boundary the predicate must draw: the object exists, but no - // package ships it, so neither does it ship the field. Resolving the - // parent through the artifact-only lookup is what keeps this true — a - // plain-key registry entry must not be able to manufacture an artifact. + it('#7893 — a field of a RUNTIME-created object is refused too: the tier is the type, not the parent', async () => { + // #7743 kept this one writable because the parent is not artifact-backed, + // so the write was a CREATE rather than an overlay. That reasoning was + // about which TIER applies; the create tier itself is now closed, so the + // answer flips here as well. Pinned separately because it is the case + // that would silently survive if someone gated on the parent's + // provenance instead of on the type's registry entry. const { engine, dispatcher } = makeStack(); const res = responseOf(await dispatcher.handleMetadata( @@ -425,8 +488,37 @@ describe('#7743 — PUT /meta/field/. honours the registry overla { name: 'note', label: 'Renamed', type: 'text' }, )); + expect(res.status).toBe(403); + expect(res.body?.error?.code).toBe('NOT_CREATABLE'); + expect(metaRow(engine, 'field', 'runtime_thing.note')).toBeUndefined(); + }); + + it('#7893 POSITIVE CONTROL — the OBJECT route still creates, and still composes', async () => { + // The capability the retirement redirects to must be live, or the + // refusal's prescription is itself false compliance. `object` carries + // the IDENTICAL flag pair `field` used to (`supportsOverlay: false, + // allowRuntimeCreate: true`) — which is also what falsifies the card's + // stated root cause, that the read skip came from `supportsOverlay`. + // Same flags, opposite outcome ⇒ the flag was never the cause. + const { engine, dispatcher } = makeStack(); + + const res = responseOf(await dispatcher.handleMetadata( + '/object/runtime_thing', ctx(), 'PUT', + { ...RUNTIME_OBJECT, fields: { ...RUNTIME_OBJECT.fields, extra: { type: 'text', label: 'Extra' } } }, + )); + expect(res.status).toBe(200); - expect(metaRow(engine, 'field', 'runtime_thing.note')).toBeDefined(); + expect(metaRow(engine, 'object', 'runtime_thing')).toBeDefined(); + + // …and the field is READ BACK, which is the half `field` never had. + const read = responseOf(await dispatcher.handleMetadata('/object/runtime_thing', ctx(), 'GET')); + const fields = Object.keys((read.body as any)?.data?.item?.fields ?? {}); + expect(fields, 'the object route composes what the field route never did').toContain('extra'); + // ANTI-VACUITY — the declared field is present in the same read, so an + // empty/dead read cannot be what makes the assertion above pass. This + // arm caught a false pass during #7893's investigation, where the body + // was read at `body.item` (undefined) instead of `body.data.item`. + expect(fields).toContain('note'); }); // ── THE NEGATIVE DIRECTION — the four types measured as ALREADY CORRECT ─ diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 700209ee20..eaef52793c 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -747,6 +747,13 @@ "toMajor": 17, "rationale": "Both families are the #8075 census verdict (fork (b), accepted 2026-08-12): security-shaped declared surface with inline-credential sinks and ZERO consumers. `ExternalDataSourceSchema.authentication.config` is a record of unknown whose own docblock example wrote `\"clientSecret\": \"...\"` inline, and `MessageQueueConfigSchema.sasl.password` was a required inline broker credential — the #7990 class (cleartext-at-rest credential sinks), except that unlike #7990's two measured surfaces nothing ever persisted these: no metadata-type binding (kernel/metadata-type-schemas.ts imports neither module), no stack collection, no object/field embedding (`object.external` binds `ObjectExternalBindingSchema` — remoteName/remoteSchema/writable/columnMap, no authentication), and zero imports outside packages/spec repo-wide, with the corpus-reach control (`DatasourceSchema` under identical exclusions) returning hits in the same run. The consumed MQ near-namesake `kernel/EventMessageQueueConfig` deliberately has no credential key, so the consumed shape had no credential and the credential-bearing shape had no consumer. A dead schema minus one field is still a dead schema, so the whole declarations go, not just the credential faces (#3950: an exported schema with no consumer reads as a capability to whoever finds it — here it read as an invitation to author secrets in cleartext). With no carrier key there is nothing to tombstone and no source or `sys_metadata` row for a D2 conversion to rewrite: route 3, the #4834 / #4988 / #5055 / #6486 shape — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. ⚠️ The #5552 `data/ExternalFieldMapping:transform` tombstone (one of that retirement's three spellings) is SUBSUMED by the def retirement, the WidgetManifest.performance way: it goes with the shape that carried it. The base `shared/FieldMapping` tombstone and the `integration/ConnectorFieldMapping` spelling are untouched and still reject `transform` with the #5552 prescription. ⚠️ The #7990 Option-B reopen trigger (\"a third measured artefact-type surface\") is NOT met by this census — that ruling's parked class-level write-boundary guard stays parked; this is the ADR-0049 leg of the fork the triage pre-agreed." }, + { + "surface": "PUT /api/v1/meta/field/{object}.{name} (runtime-authored standalone `field` items)", + "replacement": "Author the field inside its object and write the whole object — PUT /api/v1/meta/object/{object} with the new field in `fields` — or declare it in the object source (`**/*.object.ts`) and redeploy", + "migrationId": "field-runtime-create-withdrawn", + "toMajor": 17, + "rationale": "The `field` registry entry declared `allowRuntimeCreate: true` and the platform never built a read path for it. Measured end-to-end through the real HttpDispatcher -> ObjectStackProtocolImplementation -> SysMetadataRepository (#7893): `PUT /api/v1/meta/field/showcase_task.zz_probe` answered 200 with {\"success\":true,\"state\":\"active\",\"message\":\"Saved field …\"}, the row persisted, and `GET /api/v1/meta/object/showcase_task` then listed fields = [title, status] with zz_probe ABSENT — forever. The row is even self-readable by name (`GET /meta/field/showcase_task.zz_probe` -> 200, `_diagnostics.valid: true`), which makes it well-formed and universally inert rather than malformed. The seam is that `field` is the ONE declared type with no standalone existence: fields are authored inside the object (`ObjectSchema.fields`), a `field` write mints a SEPARATE row keyed ('field','.'), and nothing composes fragment rows into their parent — `applyRegistryWriteThrough` routes only `type === 'object'`, and `filePatterns` (`**/*.field.ts`) match nothing in any app. A declared capability the platform cannot honour is ADR-0049 false compliance, and the maintainer ruled REMOVE on 2026-08-12 rather than build the read path, which is a feature spanning at least three packages (a composition step that does not exist, ~20 `gate.fields` call sites, physical schema/migrations, and cold boot via `loadMetaFromDb`); if ever wanted it is a separate card — implementation first, declaration second. ⚠️ This is NOT the #5488 (`api`) rationale reused: that ruling rested on \"zero business pull\", and \"add a field\" is the opposite — a core Studio/CRM operation. The justification here is that the operation REMAINS AVAILABLE on the route that actually composes: `object` keeps `allowRuntimeCreate: true`, so what is withdrawn is a second, broken SPELLING of adding a field, not the ability to add one. There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and no authored source changes — an `**/*.object.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `api` (#5488) and `BatchOptions.validateOnly` (#4052) take. ADR-0049 / ADR-0087, #7893 (split from #7743)." + }, { "surface": "data.filter $regex / $options — in a STORED filter (dashboard widget filter and globalFilters, report runtimeFilter, page and component filter, solution-blueprint filter), and equally in the where clause of a query request", "replacement": "$icontains for the case-insensitive substring match this was almost always used for, or $contains for a case-sensitive one — a pattern that genuinely needs a regular expression has no filter-level replacement", @@ -1783,6 +1790,13 @@ "toMajor": 17, "rationale": "Both families are the #8075 census verdict (fork (b), accepted 2026-08-12): security-shaped declared surface with inline-credential sinks and ZERO consumers. `ExternalDataSourceSchema.authentication.config` is a record of unknown whose own docblock example wrote `\"clientSecret\": \"...\"` inline, and `MessageQueueConfigSchema.sasl.password` was a required inline broker credential — the #7990 class (cleartext-at-rest credential sinks), except that unlike #7990's two measured surfaces nothing ever persisted these: no metadata-type binding (kernel/metadata-type-schemas.ts imports neither module), no stack collection, no object/field embedding (`object.external` binds `ObjectExternalBindingSchema` — remoteName/remoteSchema/writable/columnMap, no authentication), and zero imports outside packages/spec repo-wide, with the corpus-reach control (`DatasourceSchema` under identical exclusions) returning hits in the same run. The consumed MQ near-namesake `kernel/EventMessageQueueConfig` deliberately has no credential key, so the consumed shape had no credential and the credential-bearing shape had no consumer. A dead schema minus one field is still a dead schema, so the whole declarations go, not just the credential faces (#3950: an exported schema with no consumer reads as a capability to whoever finds it — here it read as an invitation to author secrets in cleartext). With no carrier key there is nothing to tombstone and no source or `sys_metadata` row for a D2 conversion to rewrite: route 3, the #4834 / #4988 / #5055 / #6486 shape — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. ⚠️ The #5552 `data/ExternalFieldMapping:transform` tombstone (one of that retirement's three spellings) is SUBSUMED by the def retirement, the WidgetManifest.performance way: it goes with the shape that carried it. The base `shared/FieldMapping` tombstone and the `integration/ConnectorFieldMapping` spelling are untouched and still reject `transform` with the #5552 prescription. ⚠️ The #7990 Option-B reopen trigger (\"a third measured artefact-type surface\") is NOT met by this census — that ruling's parked class-level write-boundary guard stays parked; this is the ADR-0049 leg of the fork the triage pre-agreed." }, + { + "surface": "PUT /api/v1/meta/field/{object}.{name} (runtime-authored standalone `field` items)", + "replacement": "Author the field inside its object and write the whole object — PUT /api/v1/meta/object/{object} with the new field in `fields` — or declare it in the object source (`**/*.object.ts`) and redeploy", + "migrationId": "field-runtime-create-withdrawn", + "toMajor": 17, + "rationale": "The `field` registry entry declared `allowRuntimeCreate: true` and the platform never built a read path for it. Measured end-to-end through the real HttpDispatcher -> ObjectStackProtocolImplementation -> SysMetadataRepository (#7893): `PUT /api/v1/meta/field/showcase_task.zz_probe` answered 200 with {\"success\":true,\"state\":\"active\",\"message\":\"Saved field …\"}, the row persisted, and `GET /api/v1/meta/object/showcase_task` then listed fields = [title, status] with zz_probe ABSENT — forever. The row is even self-readable by name (`GET /meta/field/showcase_task.zz_probe` -> 200, `_diagnostics.valid: true`), which makes it well-formed and universally inert rather than malformed. The seam is that `field` is the ONE declared type with no standalone existence: fields are authored inside the object (`ObjectSchema.fields`), a `field` write mints a SEPARATE row keyed ('field','.'), and nothing composes fragment rows into their parent — `applyRegistryWriteThrough` routes only `type === 'object'`, and `filePatterns` (`**/*.field.ts`) match nothing in any app. A declared capability the platform cannot honour is ADR-0049 false compliance, and the maintainer ruled REMOVE on 2026-08-12 rather than build the read path, which is a feature spanning at least three packages (a composition step that does not exist, ~20 `gate.fields` call sites, physical schema/migrations, and cold boot via `loadMetaFromDb`); if ever wanted it is a separate card — implementation first, declaration second. ⚠️ This is NOT the #5488 (`api`) rationale reused: that ruling rested on \"zero business pull\", and \"add a field\" is the opposite — a core Studio/CRM operation. The justification here is that the operation REMAINS AVAILABLE on the route that actually composes: `object` keeps `allowRuntimeCreate: true`, so what is withdrawn is a second, broken SPELLING of adding a field, not the ability to add one. There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and no authored source changes — an `**/*.object.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `api` (#5488) and `BatchOptions.validateOnly` (#4052) take. ADR-0049 / ADR-0087, #7893 (split from #7743)." + }, { "surface": "data.filter $regex / $options — in a STORED filter (dashboard widget filter and globalFilters, report runtimeFilter, page and component filter, solution-blueprint filter), and equally in the where clause of a query request", "replacement": "$icontains for the case-insensitive substring match this was almost always used for, or $contains for a case-sensitive one — a pattern that genuinely needs a regular expression has no filter-level replacement", diff --git a/packages/spec/src/kernel/metadata-create-seeds.test.ts b/packages/spec/src/kernel/metadata-create-seeds.test.ts index 8b42e6a6b0..0fd95bfd01 100644 --- a/packages/spec/src/kernel/metadata-create-seeds.test.ts +++ b/packages/spec/src/kernel/metadata-create-seeds.test.ts @@ -54,8 +54,18 @@ describe('metadata create seeds validate against their spec schemas', () => { // identity types legitimately have no static minimal create literal. const KNOWN_UNSEEDED = new Set([ 'report', // canvas-create: dataset/measures picked interactively - 'app', 'field', 'seed', 'job', 'datasource', 'doc', 'book', + 'app', 'seed', 'job', 'datasource', 'doc', 'book', 'permission', 'position', 'agent', 'tool', 'skill', 'email_template', + // [#7893] `field` is code-only by declaration as of 2026-08-12 + // (`allowRuntimeCreate: false` + `allowOrgOverride: false`, ADR-0049 + // remove side): a standalone `field` write minted a row no read path + // ever composed into its parent object, so there is no runtime create + // surface for a create seed to seed. A pre-filled "New Field" form whose + // save can only 403 is the UI half of the same false compliance. Fields + // are authored inside their object — `object` has the create seed, and + // its `fields: {}` is where a new field goes. Same category as + // `capability` / `api`, not deferred work. + 'field', // [#5961] `capability` is code-only by declaration // (`allowRuntimeCreate: false` + `allowOrgOverride: false`, ADR-0066 D1): // there is no runtime create surface for a create seed to seed. It is on diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts index b2cd20564c..575f36b269 100644 --- a/packages/spec/src/kernel/metadata-plugin.zod.ts +++ b/packages/spec/src/kernel/metadata-plugin.zod.ts @@ -619,14 +619,82 @@ export const DEFAULT_METADATA_TYPE_REGISTRY: MetadataTypeRegistryEntryParsed[] = // // `object` and `field`: packaged items are LOCKED (`allowOrgOverride: false`). // Runtime API rejects overlay writes against artifact-backed objects/fields - // with `403 not_overridable`. Tenants CAN create brand-new objects/fields - // (`allowRuntimeCreate: true`). Rationale: object schema = physical table + // with `403 not_overridable`. Tenants CAN create brand-new OBJECTS + // (`allowRuntimeCreate: true`); `field` is the exception and the entry below + // records why. Rationale: object schema = physical table // DDL; per-org overlay of packaged objects creates upgrade conflicts and // multi-tenant schema drift. New tenant-owned objects live in their own // namespace and are free to evolve. (Mirrors Salesforce: standard objects // are not modifiable per-org beyond layout/label; custom objects are full). { type: 'object', label: 'Object', filePatterns: ['**/*.object.ts', '**/*.object.yml', '**/*.object.json'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: true, supportsVersioning: true, executionPinned: false, loadOrder: 10, domain: 'data' }, - { type: 'field', label: 'Field', filePatterns: ['**/*.field.ts', '**/*.field.yml'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: true, supportsVersioning: false, executionPinned: false, loadOrder: 20, domain: 'data' }, + // [#7893] `field` — the STANDALONE runtime-create door is RETIRED + // (`allowRuntimeCreate: false`), maintainer ruling 2026-08-12. + // + // WHY THIS FLAG IS `false` (the decision this entry records): + // + // `field` is the one declared type with NO STANDALONE EXISTENCE. Fields are + // authored INSIDE the object (`ObjectSchema.fields`, a `z.record(name, + // FieldSchema)`), so a `field` write mints a SEPARATE `sys_metadata` row + // keyed `('field', '.')` — and nothing composes fragment rows + // into their parent. Measured end-to-end through the real `HttpDispatcher` → + // `ObjectStackProtocolImplementation` → `SysMetadataRepository` (#7893): + // + // PUT /api/v1/meta/field/showcase_task.zz_probe -> 200, state=active, + // "Saved field 'showcase_task.zz_probe' (env-wide, state=active)" + // GET /api/v1/meta/object/showcase_task -> fields = [title, status] + // `zz_probe` ABSENT, forever + // GET /api/v1/meta/field/showcase_task.zz_probe -> 200, _diagnostics.valid=true + // + // So the row is self-readable and well-formed, and reaches no object's + // `fields` — therefore no ObjectQL query, no physical column, no consumer + // that matters. Self-readable and universally inert. `allowRuntimeCreate: + // true` declared a capability the platform never built: there is no + // composition step, `applyRegistryWriteThrough` routes only + // `type === 'object'`, and `filePatterns` (`**\/*.field.ts`) match nothing in + // any app. ADR-0049 calls a declared-but-unhonoured capability false + // compliance and requires enforce-or-remove; this is the remove side. + // + // ⚠️ THE READ SKIP IS NOT CAUSED BY `supportsOverlay: false`, and a fix that + // "corrects" that flag is a REGRESSION. `supportsOverlay` gates no read path + // at all — only `assertDeleteAllowed` consults it, so flipping it to `true` + // changes nothing on the read and silently WIDENS the delete authorization + // gate. The control that settles it is one line up: `object` carries the + // IDENTICAL pair (`supportsOverlay: false, allowRuntimeCreate: true`) and a + // runtime-created object is fully readable (measured: `GET + // /meta/object/runtime_thing` -> `fields: ['note']`). Same flags, opposite + // outcome, so the flag is not the cause — the missing composition step is. + // + // WHAT DOES NOT CHANGE, and why this is not a lost capability: adding a + // field at runtime is a core Studio/CRM operation and it REMAINS possible — + // through the object, which is where a field actually lives. `object` keeps + // `allowRuntimeCreate: true`, so `PUT /api/v1/meta/object/` with the + // new field in `fields` both persists AND composes. What is withdrawn is a + // second, broken SPELLING of that operation, not the operation. The refusal + // says so: `codeOnlySourceHint` gives `field` the object-route prescription + // rather than reading its `filePatterns` back (see the metadata-protocol + // call site — the glob would name a file no loader has ever ingested). + // + // ⚠️ NOT borrowed from #5488 (`api`), whose mechanism this reuses but whose + // JUSTIFICATION does not transfer: that ruling rested on "zero business pull + // for Studio-authored runtime endpoints today", and "add a field" is the + // opposite of zero-pull. The justification here is this card's own ruling and + // the object route staying open, not an absence of demand. + // + // Options rejected, on the record: (1) BUILD the read path — a feature + // spanning >= 3 packages (a composition step that does not exist, ~20 + // `gate.fields` call sites, physical schema/migrations, and cold boot via + // `loadMetaFromDb`); a separate card if ever wanted, implementation first, + // declaration second. (3) DOCUMENT it as inert — precisely the shape ADR-0049 + // forbids. + // + // ⚠️ `allowOrgOverride: false` is UNCHANGED and #7743's overlay refusal + // STAYS: an artifact-backed field is still refused `403 NOT_OVERRIDABLE` via + // `isNestedArtifactField`. Making field OVERRIDES legal is a separate + // decision from making field CREATES work; this entry touches only the + // create tier. `OS_METADATA_WRITABLE=field` remains the one operator escape + // hatch, and `deleteMetaItem` is deliberately NOT gated by this refusal, so + // repair of rows written through the retired channel stays possible. + { type: 'field', label: 'Field', filePatterns: ['**/*.field.ts', '**/*.field.yml'], supportsOverlay: false, allowOrgOverride: false, allowRuntimeCreate: false, supportsVersioning: false, executionPinned: false, loadOrder: 20, domain: 'data' }, // ADR-0088 (#4509) — the `validation` kind is RETIRED. It failed the // admission test on its first clause: no independent lifecycle. A rule only // means anything against an object, and the only shape the engine evaluates diff --git a/packages/spec/src/kernel/metadata-type-field-registration.test.ts b/packages/spec/src/kernel/metadata-type-field-registration.test.ts new file mode 100644 index 0000000000..b2909068ab --- /dev/null +++ b/packages/spec/src/kernel/metadata-type-field-registration.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #7893 — `field`'s runtime-create door is RETIRED, pinned at the DECLARATION. + * + * ## What was wrong, in one sentence + * + * The registry declared `field` with `allowRuntimeCreate: true`, so + * `PUT /api/v1/meta/field/.` was a SANCTIONED write — and it was + * accepted, persisted and reported valid while reaching no object's `fields`, + * ever. `declared != enforced`, Prime Directive #10, and ADR-0049 calls it + * false compliance. + * + * ## The seam, because the card's own "Root Cause" section names the wrong one + * + * `field` is the ONE declared type with no standalone existence. Fields are + * authored inside the object (`ObjectSchema.fields`), so a `field` write mints + * a SEPARATE `sys_metadata` row keyed `('field','.')` and nothing + * composes fragment rows into their parent. + * + * ⚠️ It is NOT `supportsOverlay: false`, and the last case in this file is the + * control that says so: `object` carries the IDENTICAL flag pair and a + * runtime-created object is fully readable. `supportsOverlay` gates no read + * path at all — only `assertDeleteAllowed` consults it — so "fixing" it would + * change nothing on the read while silently widening the delete authorization + * gate. + * + * ## The two doors this file keeps apart + * + * These cases pin the CREATE tier only. Whether a field a code package ships + * may be OVERLAID is #7743's question, answered `403 NOT_OVERRIDABLE` by + * `isNestedArtifactField` in `metadata-protocol`, and untouched here — the + * maintainer ruling of 2026-08-12 is explicit that making field OVERRIDES legal + * is a separate decision from making field CREATES work. + * + * The route-level behaviour (both refusals, on both kernel topologies, plus the + * plural-URL fold from #7894) is pinned where it is exercised: + * `packages/runtime/src/meta-field-overlay-lock.test.ts`. This file pins the + * declaration those gates read, because a silent re-flip of the flag is the one + * way the retirement could be undone without any test noticing. + */ + +import { describe, it, expect } from 'vitest'; + +import { DEFAULT_METADATA_TYPE_REGISTRY, MetadataTypeSchema } from './metadata-plugin.zod'; +import { getMetadataTypeSchema } from './metadata-type-schemas'; + +const entryFor = (type: string) => DEFAULT_METADATA_TYPE_REGISTRY.find((e) => e.type === type); +const fieldEntry = () => entryFor('field'); + +describe('`field` is still a declared metadata kind — only its create door closed', () => { + it('is a member of MetadataTypeSchema and has a registry entry', () => { + // The retirement withdraws a WRITE channel, not the type. Reads, the + // `/meta/types` descriptor and #7743's overlay refusal all need it declared. + expect(MetadataTypeSchema.safeParse('field').success).toBe(true); + expect(fieldEntry(), '`field` missing from DEFAULT_METADATA_TYPE_REGISTRY').toBeDefined(); + expect(fieldEntry()!.domain).toBe('data'); + }); + + it('still resolves a schema — schema RESOLUTION is not the write door', () => { + // Same separation `api` (#5488) and `capability` (#5961) keep: a code-only + // type still needs its shape resolvable for validation, diagnostics and + // generated docs. Losing this would make `_diagnostics` silently opinionless + // on every existing `field` row. + expect(getMetadataTypeSchema('field')).toBeDefined(); + }); +}); + +describe('`field` registry flags — the authorization verdict, written down', () => { + // ── RETIREMENT PINS (#7893) ─────────────────────────────────────────────── + // + // These REPLACE the tripwire in `packages/runtime`'s route test that asserted + // the opposite ("THE FEATURE — `allowRuntimeCreate: true` is real and must + // survive", #7743). That pin was doing its job: it existed so a later fix + // could not quietly retire runtime field authoring. The retirement is now the + // RULED outcome (maintainer, 2026-08-12), so it is deliberate and on the + // record rather than quiet — and these pins are what make a silent re-flip + // loud from here on. + + it('declares `allowRuntimeCreate: false` — the runtime create door is retired (#7893)', () => { + expect(fieldEntry()!.allowRuntimeCreate).toBe(false); + }); + + it('IS code-only: no runtime write channel is declared (#5086 refuses the inlet)', () => { + // The exact predicate #5086 (PR #5263) refuses on, spelled as the gate + // spells it. `true` here is what makes PUT /api/v1/meta/field/:name answer + // 403 `NOT_CREATABLE` instead of 200 "Saved" — in draft mode too, since the + // inlet runs before the draft/publish branch and does not look at `mode`. + const entry = fieldEntry()!; + const codeOnly = entry.allowRuntimeCreate === false && entry.allowOrgOverride === false; + expect(codeOnly).toBe(true); + }); + + it('declares `allowOrgOverride: false` — UNCHANGED, #7743’s overlay refusal stays', () => { + // Binding carry-over of the 2026-08-12 ruling. This card closed the create + // tier; the override tier was already closed and must not move in either + // direction as a side effect. + expect(fieldEntry()!.allowOrgOverride).toBe(false); + }); + + it('leaves `supportsOverlay: false` alone — flipping it would widen the DELETE gate', () => { + // The card's stated root cause was that the read skipped fields "since + // `field` has `supportsOverlay: false`". Following it would have been a + // regression: the flag gates no read path, and `assertDeleteAllowed` is its + // only consumer, so a flip silently widens delete authorization. + expect(fieldEntry()!.supportsOverlay).toBe(false); + }); +}); + +describe('the control that falsifies the card’s stated root cause', () => { + it('`object` carries the IDENTICAL overlay flag and stays runtime-creatable', () => { + // Same `supportsOverlay: false`, opposite outcome — so the flag was never + // what skipped the read. What `object` has and `field` never did is a + // composition path: an object write reaches `applyObjectRegistryMutation` + // and is read back in full. + const object = entryFor('object')!; + const field = fieldEntry()!; + + expect(object.supportsOverlay).toBe(field.supportsOverlay); + expect(object.supportsOverlay).toBe(false); + + // …and this is the half that must NOT be equal any more: the object route + // is the remedy the refusal prescribes, so it has to stay open or the + // prescription is itself false compliance. + expect(object.allowRuntimeCreate).toBe(true); + expect(field.allowRuntimeCreate).toBe(false); + }); +}); diff --git a/packages/spec/src/migrations/entries/semantic/17.field-runtime-create-withdrawn.ts b/packages/spec/src/migrations/entries/semantic/17.field-runtime-create-withdrawn.ts new file mode 100644 index 0000000000..4e4d0f124e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.field-runtime-create-withdrawn.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'field-runtime-create-withdrawn', + surface: 'PUT /api/v1/meta/field/{object}.{name} (runtime-authored standalone `field` items)', + replacement: + 'Author the field inside its object and write the whole object — ' + + 'PUT /api/v1/meta/object/{object} with the new field in `fields` — ' + + 'or declare it in the object source (`**/*.object.ts`) and redeploy', + reason: + 'The `field` registry entry declared `allowRuntimeCreate: true` and the platform never ' + + 'built a read path for it. Measured end-to-end through the real HttpDispatcher -> ' + + 'ObjectStackProtocolImplementation -> SysMetadataRepository (#7893): ' + + "`PUT /api/v1/meta/field/showcase_task.zz_probe` answered 200 with " + + '{"success":true,"state":"active","message":"Saved field …"}, the row persisted, and ' + + '`GET /api/v1/meta/object/showcase_task` then listed fields = [title, status] with ' + + 'zz_probe ABSENT — forever. The row is even self-readable by name ' + + '(`GET /meta/field/showcase_task.zz_probe` -> 200, `_diagnostics.valid: true`), which ' + + 'makes it well-formed and universally inert rather than malformed. The seam is that ' + + '`field` is the ONE declared type with no standalone existence: fields are authored ' + + 'inside the object (`ObjectSchema.fields`), a `field` write mints a SEPARATE row keyed ' + + "('field','.'), and nothing composes fragment rows into their parent — " + + "`applyRegistryWriteThrough` routes only `type === 'object'`, and `filePatterns` " + + '(`**/*.field.ts`) match nothing in any app. A declared capability the platform cannot ' + + 'honour is ADR-0049 false compliance, and the maintainer ruled REMOVE on 2026-08-12 ' + + 'rather than build the read path, which is a feature spanning at least three packages ' + + '(a composition step that does not exist, ~20 `gate.fields` call sites, physical ' + + 'schema/migrations, and cold boot via `loadMetaFromDb`); if ever wanted it is a ' + + 'separate card — implementation first, declaration second. ' + + '⚠️ This is NOT the #5488 (`api`) rationale reused: that ruling rested on "zero ' + + 'business pull", and "add a field" is the opposite — a core Studio/CRM operation. The ' + + 'justification here is that the operation REMAINS AVAILABLE on the route that actually ' + + 'composes: `object` keeps `allowRuntimeCreate: true`, so what is withdrawn is a second, ' + + 'broken SPELLING of adding a field, not the ability to add one. ' + + 'There is NO D2 conversion, for the reason this list exists: nothing in an authored ' + + 'source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an ' + + 'authorable one, and no authored source changes — an `**/*.object.ts` file valid before ' + + 'this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, ' + + 'so it is one semantic TODO for operators and Studio callers rather than a stack ' + + 'conversion — the same disposition `api` (#5488) and `BatchOptions.validateOnly` ' + + '(#4052) take. ADR-0049 / ADR-0087, #7893 (split from #7743).', + acceptanceCriteria: + 'No caller creates a standalone `field` item through the runtime metadata API. ' + + '`PUT /api/v1/meta/field/{object}.{name}` answers 403 with `code: "NOT_CREATABLE"` and ' + + 'a body naming both flags (`allowRuntimeCreate=false, allowOrgOverride=false`) and the ' + + 'prescription `PUT /api/v1/meta/object/:object with the new field in `fields``. The ' + + 'plural spelling `PUT /api/v1/meta/fields/{object}.{name}` folds onto the singular ' + + '(#7894) and earns the same refusal — verify it, because it was a separate door until ' + + '2026-08-12. ⚠️ Verify the OBJECT route is UNAFFECTED, which is the whole point of the ' + + 'change: `PUT /api/v1/meta/object/{name}` with a new entry in `fields` still answers ' + + '200, and `GET /api/v1/meta/object/{name}` READS THE NEW FIELD BACK (assert on ' + + '`body.data.item.fields`, not `body.item`, which is undefined and makes an empty read ' + + 'look like a pass). Assert a DECLARED field is present in the same response, so a dead ' + + 'read cannot be what makes the check pass. ' + + '⚠️ #7743\'s overlay refusal is untouched and must stay: overwriting a field a code ' + + 'package ships is still 403 `NOT_OVERRIDABLE`, a different gate for a different ' + + 'question — making field OVERRIDES legal was never part of this decision. ' + + 'DISPOSITION OF EXISTING ROWS: `field` rows already written through the retired channel ' + + 'stay in `sys_metadata` and are INERT — they were inert before this change too, since ' + + 'no read path ever composed them into an object, so nothing that used to work stops ' + + 'working and no data is silently reinterpreted. They remain self-readable by name and ' + + 'still report `_diagnostics.valid: true`, which asserts only that the isolated document ' + + 'is well-formed (see #8169 — the envelope has no "in effect" axis). They may be deleted ' + + 'at leisure: `deleteMetaItem` is deliberately NOT gated by this refusal, so repair stays ' + + 'possible. An operator who needs the write door back on one deployment sets ' + + '`OS_METADATA_WRITABLE=field`; note this unlocks the WRITE only — the field still will ' + + 'not reach its object, which is why it is a diagnostic and not a workaround.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 308e0dbc3a..375471f6f0 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -3114,6 +3114,72 @@ const step17: MigrationStep = { + 'credential key), `data/external-catalog.zod.ts`, `object.external` and ' + '`kernel/DeadLetterQueueEntry` survive unchanged.', }, + { + id: 'field-runtime-create-withdrawn', + surface: 'PUT /api/v1/meta/field/{object}.{name} (runtime-authored standalone `field` items)', + replacement: + 'Author the field inside its object and write the whole object — ' + + 'PUT /api/v1/meta/object/{object} with the new field in `fields` — ' + + 'or declare it in the object source (`**/*.object.ts`) and redeploy', + reason: + 'The `field` registry entry declared `allowRuntimeCreate: true` and the platform never ' + + 'built a read path for it. Measured end-to-end through the real HttpDispatcher -> ' + + 'ObjectStackProtocolImplementation -> SysMetadataRepository (#7893): ' + + "`PUT /api/v1/meta/field/showcase_task.zz_probe` answered 200 with " + + '{"success":true,"state":"active","message":"Saved field …"}, the row persisted, and ' + + '`GET /api/v1/meta/object/showcase_task` then listed fields = [title, status] with ' + + 'zz_probe ABSENT — forever. The row is even self-readable by name ' + + '(`GET /meta/field/showcase_task.zz_probe` -> 200, `_diagnostics.valid: true`), which ' + + 'makes it well-formed and universally inert rather than malformed. The seam is that ' + + '`field` is the ONE declared type with no standalone existence: fields are authored ' + + 'inside the object (`ObjectSchema.fields`), a `field` write mints a SEPARATE row keyed ' + + "('field','.'), and nothing composes fragment rows into their parent — " + + "`applyRegistryWriteThrough` routes only `type === 'object'`, and `filePatterns` " + + '(`**/*.field.ts`) match nothing in any app. A declared capability the platform cannot ' + + 'honour is ADR-0049 false compliance, and the maintainer ruled REMOVE on 2026-08-12 ' + + 'rather than build the read path, which is a feature spanning at least three packages ' + + '(a composition step that does not exist, ~20 `gate.fields` call sites, physical ' + + 'schema/migrations, and cold boot via `loadMetaFromDb`); if ever wanted it is a ' + + 'separate card — implementation first, declaration second. ' + + '⚠️ This is NOT the #5488 (`api`) rationale reused: that ruling rested on "zero ' + + 'business pull", and "add a field" is the opposite — a core Studio/CRM operation. The ' + + 'justification here is that the operation REMAINS AVAILABLE on the route that actually ' + + 'composes: `object` keeps `allowRuntimeCreate: true`, so what is withdrawn is a second, ' + + 'broken SPELLING of adding a field, not the ability to add one. ' + + 'There is NO D2 conversion, for the reason this list exists: nothing in an authored ' + + 'source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an ' + + 'authorable one, and no authored source changes — an `**/*.object.ts` file valid before ' + + 'this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, ' + + 'so it is one semantic TODO for operators and Studio callers rather than a stack ' + + 'conversion — the same disposition `api` (#5488) and `BatchOptions.validateOnly` ' + + '(#4052) take. ADR-0049 / ADR-0087, #7893 (split from #7743).', + acceptanceCriteria: + 'No caller creates a standalone `field` item through the runtime metadata API. ' + + '`PUT /api/v1/meta/field/{object}.{name}` answers 403 with `code: "NOT_CREATABLE"` and ' + + 'a body naming both flags (`allowRuntimeCreate=false, allowOrgOverride=false`) and the ' + + 'prescription `PUT /api/v1/meta/object/:object with the new field in `fields``. The ' + + 'plural spelling `PUT /api/v1/meta/fields/{object}.{name}` folds onto the singular ' + + '(#7894) and earns the same refusal — verify it, because it was a separate door until ' + + '2026-08-12. ⚠️ Verify the OBJECT route is UNAFFECTED, which is the whole point of the ' + + 'change: `PUT /api/v1/meta/object/{name}` with a new entry in `fields` still answers ' + + '200, and `GET /api/v1/meta/object/{name}` READS THE NEW FIELD BACK (assert on ' + + '`body.data.item.fields`, not `body.item`, which is undefined and makes an empty read ' + + 'look like a pass). Assert a DECLARED field is present in the same response, so a dead ' + + 'read cannot be what makes the check pass. ' + + '⚠️ #7743\'s overlay refusal is untouched and must stay: overwriting a field a code ' + + 'package ships is still 403 `NOT_OVERRIDABLE`, a different gate for a different ' + + 'question — making field OVERRIDES legal was never part of this decision. ' + + 'DISPOSITION OF EXISTING ROWS: `field` rows already written through the retired channel ' + + 'stay in `sys_metadata` and are INERT — they were inert before this change too, since ' + + 'no read path ever composed them into an object, so nothing that used to work stops ' + + 'working and no data is silently reinterpreted. They remain self-readable by name and ' + + 'still report `_diagnostics.valid: true`, which asserts only that the isolated document ' + + 'is well-formed (see #8169 — the envelope has no "in effect" axis). They may be deleted ' + + 'at leisure: `deleteMetaItem` is deliberately NOT gated by this refusal, so repair stays ' + + 'possible. An operator who needs the write door back on one deployment sets ' + + '`OS_METADATA_WRITABLE=field`; note this unlocks the WRITE only — the field still will ' + + 'not reach its object, which is why it is a diagnostic and not a workaround.', + }, { id: 'filter-regex-options-retired', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a From f10e67a1d698cfe0ee2908e7402d63873d598820 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 16:01:03 +0000 Subject: [PATCH 2/2] test(runtime): pin the plural URL fold onto the CREATE tier, and record the ADR-0087 disposition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing #7894 plural pin targets `showcase_task.title` — a field the package ships — so it exercises #7743's OVERLAY tier and answers NOT_OVERRIDABLE. Measured: reverting `allowRuntimeCreate` to true leaves that case green while both create cases go red, so it said nothing about the tier this card retires. Adds the plural + brand-new-name case so the fold is pinned on the CREATE lock too. Also adds the `adr-0087: registered field-runtime-create-withdrawn` disposition marker the changeset was missing (check-adr-0087-registration, #6148). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH --- .changeset/field-runtime-create-retired.md | 6 +++- .../src/meta-field-overlay-lock.test.ts | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.changeset/field-runtime-create-retired.md b/.changeset/field-runtime-create-retired.md index b92b2d209b..a62ba23d2e 100644 --- a/.changeset/field-runtime-create-retired.md +++ b/.changeset/field-runtime-create-retired.md @@ -70,4 +70,8 @@ The retirement kit: `**/*.field.ts` names a route that has never worked, so `codeOnlySourceHint` gives fragment types their real remedy instead. - `field` auto-enrolled into the derived code-only refusal suite (both kernel - topologies), and the plural spelling is pinned as folding onto the same gate. + topologies), and the plural spelling is pinned as folding onto the same gate — + on the CREATE tier as well as the overlay one (#7894 closed the plural door; + this card verifies the fold reaches the tier it retired). + + diff --git a/packages/runtime/src/meta-field-overlay-lock.test.ts b/packages/runtime/src/meta-field-overlay-lock.test.ts index 8289e69bea..fd01eac1f1 100644 --- a/packages/runtime/src/meta-field-overlay-lock.test.ts +++ b/packages/runtime/src/meta-field-overlay-lock.test.ts @@ -609,6 +609,38 @@ describe('#7743 — PUT /meta/field/. honours the registry overla expect(metaRow(engine, 'field', 'showcase_task.title')).toBeUndefined(); }); + it('#7894 x #7893 — the plural spelling folds onto the CREATE lock too, not just the overlay one', async () => { + // ⚠️ The case above folds a plural OVERRIDE (`showcase_task.title` is + // a field the package ships), so it is pinned on #7743's overlay tier + // and answers NOT_OVERRIDABLE. It therefore says nothing about the + // tier THIS card closed, and measurably so: reverting + // `allowRuntimeCreate` to `true` leaves the case above GREEN while the + // two create cases go red. Two independent gates reachable through the + // same URL fold, and only one of them had a plural pin. + // + // This is that missing half — plural + brand-new name, so the fold + // lands on the CREATE lock. Without it, a future change to + // PLURAL_TO_SINGULAR could reopen `PUT /meta/fields/.` as a + // create door while every existing pin here stayed green. + const { engine, dispatcher } = makeStack(); + + const res = responseOf(await dispatcher.handleMetadata( + '/fields/showcase_task.zz_plural_probe', ctx(), 'PUT', + { name: 'zz_plural_probe', label: 'Probe', type: 'text' }, + )); + + // ADR-0112 — code AND status. NOT_CREATABLE, not NOT_OVERRIDABLE: + // nothing ships `zz_plural_probe`, so the fold arrives at the create + // tier and earns the retirement's own refusal, prescription included. + expect(res.status).toBe(403); + expect(res.body?.error?.code).toBe('NOT_CREATABLE'); + expect(res.body?.error?.message).toContain('PUT /api/v1/meta/object/:object'); + // Neither namespace is minted — the plural spelling cannot be used to + // route around the retirement into a second inert row. + expect(metaRow(engine, 'fields', 'showcase_task.zz_plural_probe')).toBeUndefined(); + expect(metaRow(engine, 'field', 'showcase_task.zz_plural_probe')).toBeUndefined(); + }); + it('#7894 — the other three unmapped types answer as their singular does', async () => { // `seed` / `external_catalog` / `translation` were unmapped alongside // `field`. The assertion is deliberately body-AGNOSTIC: what the card is