@elytracms/studio 0.0.99 → 0.0.101

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/convex/analytics-cost.test.ts +1 -1
  2. package/convex/cms.ts +29 -2
  3. package/convex/records-column-transform.convex.test.ts +5 -3
  4. package/convex/records-one-write.convex.test.ts +218 -0
  5. package/convex/redirect-migration.test.ts +1 -1
  6. package/convex/redirects.ts +2 -2
  7. package/convex/shared-capture.convex.test.ts +165 -0
  8. package/convex/version-kind-stamping.test.ts +118 -0
  9. package/dist/components/ai/ai-chat-panel-lazy.js +7 -2
  10. package/dist/components/ai/ai-chat-panel-lazy.js.map +1 -1
  11. package/dist/components/ai/proposal-card.js +2 -1
  12. package/dist/components/ai/proposal-card.js.map +1 -1
  13. package/dist/components/ai/proposal-unfilled.d.ts +14 -0
  14. package/dist/components/ai/proposal-unfilled.js +33 -0
  15. package/dist/components/ai/proposal-unfilled.js.map +1 -0
  16. package/dist/components/ai/translate-document-action.js +8 -6
  17. package/dist/components/ai/translate-document-action.js.map +1 -1
  18. package/dist/components/app-views/studio-config.d.ts +12 -12
  19. package/dist/components/app-views/studio-config.js.map +1 -1
  20. package/dist/components/builder/builder-store.d.ts +37 -1
  21. package/dist/components/builder/builder-store.js +21 -6
  22. package/dist/components/builder/builder-store.js.map +1 -1
  23. package/dist/components/builder/canvas-chrome-css.d.ts +1 -1
  24. package/dist/components/builder/canvas-chrome-css.js +1 -1
  25. package/dist/components/builder/canvas-chrome-css.js.map +1 -1
  26. package/dist/components/builder/canvas-chrome.d.ts +1 -1
  27. package/dist/components/builder/canvas-chrome.js +1 -1
  28. package/dist/components/builder/canvas-chrome.js.map +1 -1
  29. package/dist/components/builder/canvas-rebase.d.ts +4 -4
  30. package/dist/components/builder/canvas-rebase.js +3 -2
  31. package/dist/components/builder/canvas-rebase.js.map +1 -1
  32. package/dist/components/builder/canvas-render-context.d.ts +1 -1
  33. package/dist/components/builder/canvas-render-context.js +1 -1
  34. package/dist/components/builder/canvas-render-context.js.map +1 -1
  35. package/dist/components/builder/composition-field-editor-lazy.js +2 -1
  36. package/dist/components/builder/composition-field-editor-lazy.js.map +1 -1
  37. package/dist/components/builder/reuse-context.d.ts +8 -2
  38. package/dist/components/builder/reuse-context.js +62 -7
  39. package/dist/components/builder/reuse-context.js.map +1 -1
  40. package/dist/components/builder/reuse.d.ts +106 -70
  41. package/dist/components/builder/reuse.js +125 -108
  42. package/dist/components/builder/reuse.js.map +1 -1
  43. package/dist/components/cms/cms-store.d.ts +9 -1
  44. package/dist/components/cms/cms-store.js +1 -0
  45. package/dist/components/cms/cms-store.js.map +1 -1
  46. package/dist/components/cms/detail-editor.js +13 -13
  47. package/dist/components/cms/detail-editor.js.map +1 -1
  48. package/dist/components/cms/prop-fields.js +1 -1
  49. package/dist/components/cms/prop-fields.js.map +1 -1
  50. package/dist/components/cms/rich-text/lazy.js +2 -1
  51. package/dist/components/cms/rich-text/lazy.js.map +1 -1
  52. package/dist/components/cms/rich-text/toolbar-state.d.ts +1 -1
  53. package/dist/components/cms/rich-text/toolbar-state.js +1 -1
  54. package/dist/components/cms/rich-text/toolbar-state.js.map +1 -1
  55. package/dist/components/cms/use-field-focus.js +3 -3
  56. package/dist/components/cms/use-field-focus.js.map +1 -1
  57. package/dist/components/cms/version-compare-lazy.js +2 -1
  58. package/dist/components/cms/version-compare-lazy.js.map +1 -1
  59. package/dist/components/components/components-index.js +1 -1
  60. package/dist/components/components/components-index.js.map +1 -1
  61. package/dist/components/deployment-setup-gate.d.ts +1 -1
  62. package/dist/components/deployment-setup-gate.js +1 -1
  63. package/dist/components/deployment-setup-gate.js.map +1 -1
  64. package/dist/components/load-failure.d.ts +62 -0
  65. package/dist/components/load-failure.js +110 -0
  66. package/dist/components/load-failure.js.map +1 -0
  67. package/dist/components/notifications/shell-findings.js +3 -3
  68. package/dist/components/notifications/shell-findings.js.map +1 -1
  69. package/dist/components/records/record-rail.js +16 -5
  70. package/dist/components/records/record-rail.js.map +1 -1
  71. package/dist/customization.d.ts +3 -3
  72. package/dist/customization.js.map +1 -1
  73. package/dist/lib/ai/apply-content.js +99 -28
  74. package/dist/lib/ai/apply-content.js.map +1 -1
  75. package/dist/lib/ai/chat-context.d.ts +73 -7
  76. package/dist/lib/ai/chat-context.js +133 -14
  77. package/dist/lib/ai/chat-context.js.map +1 -1
  78. package/dist/lib/ai/chat-state.d.ts +7 -1
  79. package/dist/lib/ai/chat-state.js.map +1 -1
  80. package/dist/lib/ai/constraints.d.ts +5 -0
  81. package/dist/lib/ai/constraints.js +21 -6
  82. package/dist/lib/ai/constraints.js.map +1 -1
  83. package/dist/lib/ai/fake-provider.d.ts +76 -26
  84. package/dist/lib/ai/fake-provider.js +210 -66
  85. package/dist/lib/ai/fake-provider.js.map +1 -1
  86. package/dist/lib/ai/index.d.ts +1 -0
  87. package/dist/lib/ai/index.js +1 -0
  88. package/dist/lib/ai/index.js.map +1 -1
  89. package/dist/lib/ai/keyless-assistant.d.ts +37 -0
  90. package/dist/lib/ai/keyless-assistant.js +193 -0
  91. package/dist/lib/ai/keyless-assistant.js.map +1 -0
  92. package/dist/lib/ai/shape.d.ts +25 -2
  93. package/dist/lib/ai/shape.js +58 -7
  94. package/dist/lib/ai/shape.js.map +1 -1
  95. package/dist/lib/ai/tools.d.ts +11 -1
  96. package/dist/lib/ai/tools.js +51 -6
  97. package/dist/lib/ai/tools.js.map +1 -1
  98. package/dist/lib/ai/translate-document.js +6 -5
  99. package/dist/lib/ai/translate-document.js.map +1 -1
  100. package/dist/lib/ai/types.d.ts +22 -7
  101. package/dist/lib/ai/types.js.map +1 -1
  102. package/dist/lib/ai/update-fields.js +9 -6
  103. package/dist/lib/ai/update-fields.js.map +1 -1
  104. package/dist/lib/ai/writer-form.d.ts +11 -3
  105. package/dist/lib/ai/writer-form.js +105 -18
  106. package/dist/lib/ai/writer-form.js.map +1 -1
  107. package/dist/lib/auth-errors.d.ts +1 -1
  108. package/dist/lib/auth-errors.js +1 -1
  109. package/dist/lib/auth-errors.js.map +1 -1
  110. package/dist/lib/engine-version.d.ts +1 -1
  111. package/dist/lib/engine-version.js +1 -1
  112. package/dist/lib/engine-version.js.map +1 -1
  113. package/dist/lib/i18n/messages.de.d.ts +1 -1
  114. package/dist/lib/i18n/messages.de.js +24 -7
  115. package/dist/lib/i18n/messages.de.js.map +1 -1
  116. package/dist/lib/i18n/messages.en.d.ts +19 -3
  117. package/dist/lib/i18n/messages.en.js +30 -9
  118. package/dist/lib/i18n/messages.en.js.map +1 -1
  119. package/dist/lib/operations/cms-persistence.d.ts +18 -0
  120. package/dist/lib/operations/cms-persistence.js +56 -15
  121. package/dist/lib/operations/cms-persistence.js.map +1 -1
  122. package/dist/lib/persistence/convex-adapter.d.ts +2 -0
  123. package/dist/lib/persistence/convex-adapter.js +67 -2
  124. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  125. package/dist/lib/workspace/index.d.ts +3 -3
  126. package/dist/lib/workspace/index.js +3 -3
  127. package/dist/lib/workspace/index.js.map +1 -1
  128. package/dist/router/navigate-once.d.ts +1 -1
  129. package/dist/router/navigate-once.js.map +1 -1
  130. package/dist/router/root.d.ts +3 -2
  131. package/dist/router/root.js +25 -4
  132. package/dist/router/root.js.map +1 -1
  133. package/package.json +3 -3
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/lib/ai/types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * `@elytracms/studio/lib/ai` — headless AI platform seam types (EC-331, phase 1).\n *\n * Pure types only: this module imports nothing at runtime (every import is\n * `import type`), so referencing `StudioAiConfig` from the studio-config type does\n * NOT pull the `ai` / `@ai-sdk/*` runtime onto the critical path. The runtime lives\n * in the sibling modules (`config`, `anthropic-provider`, `fake-provider`,\n * `knowledge`, `shape`, `apply-content`, `route`, `provenance`), reached only when\n * the `ai` block is configured and the AI subsystem actually boots. The Anthropic\n * provider additionally loads `ai`/`@ai-sdk/anthropic` via dynamic `import()`, so\n * even importing this barrel never eagerly loads the SDK.\n */\nimport type {\n AssetFieldDef,\n CollectionDef,\n CollectionRegistry,\n CmsValidationIssue,\n FieldValues,\n JsonSchema,\n ValidateDocumentOptions,\n} from '@elytracms/core/cms-core'\n// Type-only on purpose (erased at compile; `verbatimModuleSyntax`): referencing the\n// SDK's `LanguageModel` here creates NO runtime edge — pinned in `bundle.test.ts`.\nimport type { LanguageModel } from 'ai'\n\n// --- Provider seam ----------------------------------------------------------------\n\n/**\n * The capabilities the engine requires of a provider. Both must be `true` — the\n * capability gate (`resolveAiConfig`) fails loud otherwise (tool-calling +\n * structured output are the non-negotiable floor for schema-guaranteed authoring).\n */\nexport interface AiProviderCapabilities {\n /** The provider can call tools (function/tool calling). */\n toolCalling: boolean\n /** The provider can return schema-constrained structured output. */\n structuredOutput: boolean\n}\n\n/** A request for one schema-constrained structured object — the engine's core primitive. */\nexport interface GenerateStructuredRequest {\n /** System prompt: rules, injection stance, closed-world constraints. */\n system?: string\n /** The task prompt plus assembled context (knowledge, existing values, prior issues). */\n prompt: string\n /** The target shape as a JSON Schema (draft 2020-12 subset) the output must satisfy. */\n schema: JsonSchema\n /** A short name for the schema (some providers surface it as the tool/output name). */\n schemaName?: string\n /** A one-line description of what the schema represents. */\n schemaDescription?: string\n}\n\n/** Token usage a provider may report (best-effort; recorded for soft spend counters). */\nexport interface AiUsage {\n inputTokens?: number\n outputTokens?: number\n}\n\nexport interface GenerateStructuredResult {\n /** The structured object the provider produced (best-effort match to the request schema). */\n object: unknown\n usage?: AiUsage\n}\n\n/**\n * A provider adapter over a tool-calling, structured-output model. Anthropic (or\n * the Vercel AI Gateway) in production, {@link FakeProvider} (deterministic,\n * keyless) in tests/playground — all implement this identical surface so the whole\n * loop runs without a key.\n */\nexport interface AiProvider {\n /** Stable provider id (e.g. `anthropic`, `fake`); recorded in provenance. */\n readonly id: string\n /** The model id the provider targets, when fixed; recorded in provenance. */\n readonly model?: string\n /** The provider's capabilities — validated by the capability gate at config load. */\n capabilities(): AiProviderCapabilities\n /** Produce a single schema-constrained structured object. */\n generateStructured(request: GenerateStructuredRequest): Promise<GenerateStructuredResult>\n /**\n * The AI-SDK `LanguageModel` behind this provider — the seam the route's native\n * chat branch consumes (EC-334: `streamText` over UIMessages, tools declared\n * without `execute` so they forward to the client). Optional: a pure structured\n * proxy (the client-side route provider) exposes none — the route answers 501\n * for chat requests then.\n */\n languageModel?(): Promise<LanguageModel>\n}\n\n// --- Config seam ------------------------------------------------------------------\n\n/** Soft, advisory limits for the route counter and the engine repair loop. */\nexport interface AiLimits {\n /** Soft per-project ceiling on route requests per minute (advisory; route-enforced). */\n maxRequestsPerMinute?: number\n /** Cap on repair attempts after the first generation (engine default when omitted). */\n maxRepairAttempts?: number\n}\n\n/**\n * The `ai` block of `defineStudio({ ai })` (EC-331). Studio-only live code — like\n * `fieldRenderers` / `i18n`, it never reaches the delivery bundle. The block's\n * ABSENCE means the feature is fully absent (no AI UI, no AI deps on the critical\n * path); `enabled: false` is a temporary kill-switch that keeps the config.\n */\nexport interface StudioAiConfig {\n /**\n * Temporary kill-switch. Presence of the whole `ai` block enables the feature;\n * `enabled: false` disables it without removing the config. Omitted ⇒ enabled.\n */\n enabled?: boolean\n /** The provider adapter (live code): Anthropic in production, FakeProvider in tests/playground. */\n provider: AiProvider\n /** Project knowledge sources assembled into the model's context. */\n knowledge?: KnowledgeSource[]\n /** Soft rate / repair limits. */\n limits?: AiLimits\n}\n\n// --- Knowledge seam ---------------------------------------------------------------\n\n/** An inline knowledge literal (markdown prose versioned with the repo). */\nexport interface KnowledgeItem {\n title?: string\n content: string\n}\n\n/**\n * A `fromCollection('<id>')` source: studio-editable knowledge whose *content* is\n * prose held in the DB (data), while the mechanism stays code. Resolved by a\n * caller-supplied collection reader (starter-kit / studio wires it to operations).\n */\nexport interface KnowledgeCollectionSource {\n readonly kind: 'collection'\n /** Collection id whose documents supply editable knowledge prose. */\n readonly collectionId: string\n /** Field carrying the prose; when omitted the reader picks the collection's title/first text field. */\n readonly field?: string\n}\n\n/** An async (or sync) function producing knowledge items at resolve time. */\nexport type KnowledgeFn = () =>\n | KnowledgeItem\n | KnowledgeItem[]\n | Promise<KnowledgeItem | KnowledgeItem[]>\n\n/** A knowledge source: inline literal, async function, or `fromCollection(...)` sugar. */\nexport type KnowledgeSource = KnowledgeItem | KnowledgeFn | KnowledgeCollectionSource\n\n/** The assembled knowledge context — a flat, deterministic list of resolved items. */\nexport interface ResolvedKnowledge {\n items: KnowledgeItem[]\n}\n\n/** Reads knowledge prose out of a `fromCollection` source (wired to the operations layer). */\nexport type KnowledgeCollectionReader = (\n source: KnowledgeCollectionSource,\n) => KnowledgeItem[] | Promise<KnowledgeItem[]>\n\n// --- Engine seam ------------------------------------------------------------------\n\n/**\n * A relation candidate id (from a `searchDocuments` tool result). A relation may ONLY\n * use a searched id, or one the document already references — at every position\n * (EC-593): the create shape emits them as an enum, so a hallucinated id is not even\n * representable as valid structured output, and the writer-form converter refuses (edit)\n * or drops (create) any other.\n */\nexport interface RelationCandidate {\n id: string\n /** Human label (title) for the model to choose against. */\n label?: string\n}\n\n/**\n * An asset candidate id (from a `listAssets` tool result). An asset position may ONLY\n * hold a searched library asset that fits its `accept` (EC-588), or one the document\n * already references — at every position (EC-593). From the EXISTING media library ONLY\n * (EC-381): the assistant never generates or uploads an asset; empty beats wrong.\n */\nexport interface AssetCandidate {\n id: string\n /** Human label (title / filename) for the model to choose against. */\n label?: string\n}\n\n/**\n * THE id leash (EC-381, at every position since EC-593): what the model searched this\n * conversation, asked per POSITION rather than per top-level field name — a relation by\n * its target collection, an asset by its declaration (its `accept`). A nested relation\n * in an object, an asset prop of a component inside a composition and a top-level field\n * are judged by the same two questions. The chat's per-conversation candidate store is\n * one (`createChatCandidateStore`); `writerLeash` builds one from plain search results.\n */\nexport interface WriterLeash {\n /** The searched documents a reference to `target` may use. */\n relationCandidates(target: string): readonly RelationCandidate[]\n /** The searched library assets an asset position declared by `field` may hold (its `accept` applied). */\n assetCandidates(field: AssetFieldDef): readonly AssetCandidate[]\n}\n\n/** The typed draft-values patch the engine emits — applied by the draft-write path. */\nexport interface ContentPatch {\n collection: string\n id: string\n /** Non-localized field values. */\n values: FieldValues\n /** Per-locale field values (localized fields, under the target locale). */\n localized?: Record<string, FieldValues>\n}\n\n/** Origin marker the draft-write path records so an AI-authored draft is attributable. */\nexport interface AiProvenance {\n origin: 'assistant'\n /** Provider id (e.g. `anthropic`, `fake`). */\n provider: string\n /** Model id, when the provider fixes one. */\n model?: string\n /** ISO timestamp when the content was generated. */\n at: string\n}\n\nexport interface ApplyContentInput {\n provider: AiProvider\n registry: CollectionRegistry\n collection: CollectionDef\n /** Document id the patch targets (an existing doc for updates, or a fresh id for creation). */\n documentId: string\n /** The author's instruction / prompt. */\n instruction: string\n /** Assembled knowledge context (from `resolveKnowledge`). */\n knowledge?: ResolvedKnowledge\n /**\n * THE id leash at every position (EC-381 → EC-593): the ONLY ids a relation or an\n * asset may use, wherever it sits. Emitted as an enum on the shape; a position with\n * none is omitted (so a create proposal simply leaves it empty — empty beats wrong),\n * and an id outside it is dropped and reported as a warning. Absent → no ids at all.\n */\n leash?: WriterLeash\n /**\n * Existing draft values (updates): the model output merges over these before\n * validation, and an asset position carries its placement sub-fields from the value\n * here (EC-570 #3 — the one carry rule, `carryAssetPlacements`).\n */\n existing?: FieldValues\n /**\n * Target locale for localized fields (their values land under `localized[locale]`);\n * absent → the project's default locale (`validateOptions.localeConfig.default`).\n */\n locale?: string\n /**\n * THE validation option bag (EC-497) — the studio session's\n * `gateValidateOptions(...)`, passed WHOLE rather than reassembled from\n * individual keys here. EC-473 had already established that the assistant must\n * judge its output by the editor's rules; this makes that structural: there is\n * one bag, and a surface cannot quietly take a subset of it. EC-580 removed the\n * one deviation it used to carry (a per-generation `{ default: locale }` config):\n * a create in a non-default locale is judged by the project's own config, so a\n * required localized field needs its default-locale value here exactly as at\n * publish.\n */\n validateOptions?: ValidateDocumentOptions\n /** Max repair attempts after the first generation (default {@link DEFAULT_MAX_REPAIR_ATTEMPTS}). */\n maxRepairAttempts?: number\n}\n\n/** The engine's outcome: a valid, leash-compliant patch, or the blocking issues. */\nexport type ApplyContentResult =\n | {\n ok: true\n patch: ContentPatch\n provenance: AiProvenance\n /** Total generation attempts made (1 = happy path, >1 = repaired). */\n attempts: number\n /** Non-error issues surfaced by the accepted document (e.g. warnings). */\n issues: CmsValidationIssue[]\n }\n | {\n ok: false\n /** The error-severity issues from the final failed attempt. */\n issues: CmsValidationIssue[]\n attempts: number\n provenance: AiProvenance\n }\n\n// --- Route seam -------------------------------------------------------------------\n\n/** A validated studio session, from the host-wired `getSession`. Opaque beyond `userId`. */\nexport interface AiSession {\n /** Authenticated principal id. */\n userId: string\n [key: string]: unknown\n}\n\nexport interface AiRouteHandlerOptions {\n /** The provider (holds the host-env key). The route is a thin proxy over it. */\n provider: AiProvider\n /**\n * Server-side session check (reject anonymous callers). Returns a session for an\n * authenticated caller, `null` otherwise. Wired by the host to its own auth — the\n * route consumes it, never re-implements it.\n */\n getSession: (request: Request) => AiSession | null | Promise<AiSession | null>\n /** Soft per-project rate limit (advisory spend guard). */\n limits?: AiLimits\n /** Derive the per-project rate-bucket key from a request (default: one global bucket). */\n projectKey?: (request: Request) => string\n /**\n * Millisecond clock the rate window is decided against (default `Date.now`).\n * Injected so the 60 s boundary is testable with a fixed clock (EC-602, the\n * AGENTS.md §4 determinism rule). Milliseconds rather than the house ISO\n * `Clock` (`@elytracms/core/determinism`): that one stamps records; this one\n * does window arithmetic.\n */\n now?: () => number\n}\n\n/** The mounted route handler: a framework-agnostic Web `Request → Response`. */\nexport type AiRouteHandler = (request: Request) => Promise<Response>\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../src/lib/ai/types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * `@elytracms/studio/lib/ai` — headless AI platform seam types (EC-331, phase 1).\n *\n * Pure types only: this module imports nothing at runtime (every import is\n * `import type`), so referencing `StudioAiConfig` from the studio-config type does\n * NOT pull the `ai` / `@ai-sdk/*` runtime onto the critical path. The runtime lives\n * in the sibling modules (`config`, `anthropic-provider`, `fake-provider`,\n * `knowledge`, `shape`, `apply-content`, `route`, `provenance`), reached only when\n * the `ai` block is configured and the AI subsystem actually boots. The Anthropic\n * provider additionally loads `ai`/`@ai-sdk/anthropic` via dynamic `import()`, so\n * even importing this barrel never eagerly loads the SDK.\n */\nimport type {\n AssetFieldDef,\n CollectionDef,\n CollectionRegistry,\n CmsValidationIssue,\n FieldValues,\n JsonSchema,\n ValidateDocumentOptions,\n} from '@elytracms/core/cms-core'\n// Type-only on purpose (erased at compile; `verbatimModuleSyntax`): referencing the\n// SDK's `LanguageModel` here creates NO runtime edge — pinned in `bundle.test.ts`.\nimport type { LanguageModel } from 'ai'\n\n// --- Provider seam ----------------------------------------------------------------\n\n/**\n * The capabilities the engine requires of a provider. Both must be `true` — the\n * capability gate (`resolveAiConfig`) fails loud otherwise (tool-calling +\n * structured output are the non-negotiable floor for schema-guaranteed authoring).\n */\nexport interface AiProviderCapabilities {\n /** The provider can call tools (function/tool calling). */\n toolCalling: boolean\n /** The provider can return schema-constrained structured output. */\n structuredOutput: boolean\n}\n\n/** A request for one schema-constrained structured object — the engine's core primitive. */\nexport interface GenerateStructuredRequest {\n /** System prompt: rules, injection stance, closed-world constraints. */\n system?: string\n /** The task prompt plus assembled context (knowledge, existing values, prior issues). */\n prompt: string\n /** The target shape as a JSON Schema (draft 2020-12 subset) the output must satisfy. */\n schema: JsonSchema\n /** A short name for the schema (some providers surface it as the tool/output name). */\n schemaName?: string\n /** A one-line description of what the schema represents. */\n schemaDescription?: string\n}\n\n/** Token usage a provider may report (best-effort; recorded for soft spend counters). */\nexport interface AiUsage {\n inputTokens?: number\n outputTokens?: number\n}\n\nexport interface GenerateStructuredResult {\n /** The structured object the provider produced (best-effort match to the request schema). */\n object: unknown\n usage?: AiUsage\n}\n\n/**\n * A provider adapter over a tool-calling, structured-output model. Anthropic (or\n * the Vercel AI Gateway) in production, {@link FakeProvider} (deterministic,\n * keyless) in tests/playground — all implement this identical surface so the whole\n * loop runs without a key.\n */\nexport interface AiProvider {\n /** Stable provider id (e.g. `anthropic`, `fake`); recorded in provenance. */\n readonly id: string\n /** The model id the provider targets, when fixed; recorded in provenance. */\n readonly model?: string\n /** The provider's capabilities — validated by the capability gate at config load. */\n capabilities(): AiProviderCapabilities\n /** Produce a single schema-constrained structured object. */\n generateStructured(request: GenerateStructuredRequest): Promise<GenerateStructuredResult>\n /**\n * The AI-SDK `LanguageModel` behind this provider — the seam the route's native\n * chat branch consumes (EC-334: `streamText` over UIMessages, tools declared\n * without `execute` so they forward to the client). Optional: a pure structured\n * proxy (the client-side route provider) exposes none — the route answers 501\n * for chat requests then.\n */\n languageModel?(): Promise<LanguageModel>\n}\n\n// --- Config seam ------------------------------------------------------------------\n\n/** Soft, advisory limits for the route counter and the engine repair loop. */\nexport interface AiLimits {\n /** Soft per-project ceiling on route requests per minute (advisory; route-enforced). */\n maxRequestsPerMinute?: number\n /** Cap on repair attempts after the first generation (engine default when omitted). */\n maxRepairAttempts?: number\n}\n\n/**\n * The `ai` block of `defineStudio({ ai })` (EC-331). Studio-only live code — like\n * `fieldRenderers` / `i18n`, it never reaches the delivery bundle. The block's\n * ABSENCE means the feature is fully absent (no AI UI, no AI deps on the critical\n * path); `enabled: false` is a temporary kill-switch that keeps the config.\n */\nexport interface StudioAiConfig {\n /**\n * Temporary kill-switch. Presence of the whole `ai` block enables the feature;\n * `enabled: false` disables it without removing the config. Omitted ⇒ enabled.\n */\n enabled?: boolean\n /** The provider adapter (live code): Anthropic in production, FakeProvider in tests/playground. */\n provider: AiProvider\n /** Project knowledge sources assembled into the model's context. */\n knowledge?: KnowledgeSource[]\n /** Soft rate / repair limits. */\n limits?: AiLimits\n}\n\n// --- Knowledge seam ---------------------------------------------------------------\n\n/** An inline knowledge literal (markdown prose versioned with the repo). */\nexport interface KnowledgeItem {\n title?: string\n content: string\n}\n\n/**\n * A `fromCollection('<id>')` source: studio-editable knowledge whose *content* is\n * prose held in the DB (data), while the mechanism stays code. Resolved by a\n * caller-supplied collection reader (starter-kit / studio wires it to operations).\n */\nexport interface KnowledgeCollectionSource {\n readonly kind: 'collection'\n /** Collection id whose documents supply editable knowledge prose. */\n readonly collectionId: string\n /** Field carrying the prose; when omitted the reader picks the collection's title/first text field. */\n readonly field?: string\n}\n\n/** An async (or sync) function producing knowledge items at resolve time. */\nexport type KnowledgeFn = () =>\n | KnowledgeItem\n | KnowledgeItem[]\n | Promise<KnowledgeItem | KnowledgeItem[]>\n\n/** A knowledge source: inline literal, async function, or `fromCollection(...)` sugar. */\nexport type KnowledgeSource = KnowledgeItem | KnowledgeFn | KnowledgeCollectionSource\n\n/** The assembled knowledge context — a flat, deterministic list of resolved items. */\nexport interface ResolvedKnowledge {\n items: KnowledgeItem[]\n}\n\n/** Reads knowledge prose out of a `fromCollection` source (wired to the operations layer). */\nexport type KnowledgeCollectionReader = (\n source: KnowledgeCollectionSource,\n) => KnowledgeItem[] | Promise<KnowledgeItem[]>\n\n// --- Engine seam ------------------------------------------------------------------\n\n/**\n * A relation candidate id (from a `searchDocuments` tool result). A relation may ONLY\n * use a searched id, or one the document already references — at every position\n * (EC-593): the create shape emits them as an enum, so a hallucinated id is not even\n * representable as valid structured output, and the writer-form converter refuses (edit)\n * or drops (create) any other.\n */\nexport interface RelationCandidate {\n id: string\n /** Human label (title) for the model to choose against. */\n label?: string\n}\n\n/**\n * An asset candidate id (from a `listAssets` tool result). An asset position may ONLY\n * hold a searched library asset that fits its `accept` (EC-588), or one the document\n * already references — at every position (EC-593). From the EXISTING media library ONLY\n * (EC-381): the assistant never generates or uploads an asset; empty beats wrong.\n */\nexport interface AssetCandidate {\n id: string\n /** Human label (title / filename) for the model to choose against. */\n label?: string\n}\n\n/**\n * THE id leash (EC-381, at every position since EC-593): what the model searched this\n * conversation, asked per POSITION rather than per top-level field name — a relation by\n * its target collection, an asset by its declaration (its `accept`). A nested relation\n * in an object, an asset prop of a component inside a composition and a top-level field\n * are judged by the same two questions. The chat's per-conversation candidate store is\n * one (`createChatCandidateStore`); `writerLeash` builds one from plain search results.\n */\nexport interface WriterLeash {\n /** The searched documents a reference to `target` may use. */\n relationCandidates(target: string): readonly RelationCandidate[]\n /** The searched library assets an asset position declared by `field` may hold (its `accept` applied). */\n assetCandidates(field: AssetFieldDef): readonly AssetCandidate[]\n}\n\n/** The typed draft-values patch the engine emits — applied by the draft-write path. */\nexport interface ContentPatch {\n collection: string\n id: string\n /** Non-localized field values. */\n values: FieldValues\n /** Per-locale field values (localized fields, under the target locale). */\n localized?: Record<string, FieldValues>\n}\n\n/** Origin marker the draft-write path records so an AI-authored draft is attributable. */\nexport interface AiProvenance {\n origin: 'assistant'\n /** Provider id (e.g. `anthropic`, `fake`). */\n provider: string\n /** Model id, when the provider fixes one. */\n model?: string\n /** ISO timestamp when the content was generated. */\n at: string\n}\n\nexport interface ApplyContentInput {\n provider: AiProvider\n registry: CollectionRegistry\n collection: CollectionDef\n /**\n * The fresh id of the document the patch creates. The draft starts from the collection's\n * initial values (EC-716: `seedDefaults`, as a manual create does) — an edit of an\n * existing document is `buildUpdateProposal`'s, never this engine's.\n */\n documentId: string\n /** The author's instruction / prompt. */\n instruction: string\n /** Assembled knowledge context (from `resolveKnowledge`). */\n knowledge?: ResolvedKnowledge\n /**\n * THE id leash at every position (EC-381 → EC-593): the ONLY ids a relation or an\n * asset may use, wherever it sits. Emitted as an enum on the shape; a position with\n * none is omitted (so a create proposal simply leaves it empty — empty beats wrong),\n * and an id outside it is dropped and reported as a warning. Absent → no ids at all.\n */\n leash?: WriterLeash\n /**\n * Target locale for localized fields (their values land under `localized[locale]`);\n * absent → the project's default locale (`validateOptions.localeConfig.default`).\n */\n locale?: string\n /**\n * THE validation option bag (EC-497) — the studio session's\n * `gateValidateOptions(...)`, passed WHOLE rather than reassembled from\n * individual keys here. EC-473 had already established that the assistant must\n * judge its output by the editor's rules; this makes that structural: there is\n * one bag, and a surface cannot quietly take a subset of it. EC-580 removed the\n * one deviation it used to carry (a per-generation `{ default: locale }` config):\n * a create in a non-default locale is judged by the project's own config, so a\n * required localized field needs its default-locale value here exactly as at\n * publish.\n */\n validateOptions?: ValidateDocumentOptions\n /** Max repair attempts after the first generation (default {@link DEFAULT_MAX_REPAIR_ATTEMPTS}). */\n maxRepairAttempts?: number\n}\n\n/**\n * A writable position an AI create left without a value (EC-715) — said on the proposal and\n * in the tool output, never left for the editor to discover:\n * - `empty` — the draft gave it no value (a date the model did not set, an optional text…);\n * - `no-candidates` — a relation or asset field nothing was searched for, so the shape could\n * not offer it (empty beats wrong, EC-381);\n * - `dropped` — the model named an id outside what it searched; the id was removed.\n */\nexport interface UnfilledPosition {\n /** The top-level field. */\n field: string\n /** Where below the field value (index-bearing; `[]` — the field itself). */\n at: readonly (string | number)[]\n reason: 'empty' | 'no-candidates' | 'dropped'\n}\n\n/** The engine's outcome: a valid, leash-compliant patch, or the blocking issues. */\nexport type ApplyContentResult =\n | {\n ok: true\n patch: ContentPatch\n provenance: AiProvenance\n /** Total generation attempts made (1 = happy path, >1 = repaired). */\n attempts: number\n /** Non-error issues surfaced by the accepted document (e.g. warnings). */\n issues: CmsValidationIssue[]\n /** EC-715: the writable positions the draft left without a value, and the dropped ids. */\n unfilled: UnfilledPosition[]\n }\n | {\n ok: false\n /** The error-severity issues from the final failed attempt. */\n issues: CmsValidationIssue[]\n attempts: number\n provenance: AiProvenance\n }\n\n// --- Route seam -------------------------------------------------------------------\n\n/** A validated studio session, from the host-wired `getSession`. Opaque beyond `userId`. */\nexport interface AiSession {\n /** Authenticated principal id. */\n userId: string\n [key: string]: unknown\n}\n\nexport interface AiRouteHandlerOptions {\n /** The provider (holds the host-env key). The route is a thin proxy over it. */\n provider: AiProvider\n /**\n * Server-side session check (reject anonymous callers). Returns a session for an\n * authenticated caller, `null` otherwise. Wired by the host to its own auth — the\n * route consumes it, never re-implements it.\n */\n getSession: (request: Request) => AiSession | null | Promise<AiSession | null>\n /** Soft per-project rate limit (advisory spend guard). */\n limits?: AiLimits\n /** Derive the per-project rate-bucket key from a request (default: one global bucket). */\n projectKey?: (request: Request) => string\n /**\n * Millisecond clock the rate window is decided against (default `Date.now`).\n * Injected so the 60 s boundary is testable with a fixed clock (EC-602, the\n * AGENTS.md §4 determinism rule). Milliseconds rather than the house ISO\n * `Clock` (`@elytracms/core/determinism`): that one stamps records; this one\n * does window arithmetic.\n */\n now?: () => number\n}\n\n/** The mounted route handler: a framework-agnostic Web `Request → Response`. */\nexport type AiRouteHandler = (request: Request) => Promise<Response>\n"]}
@@ -20,7 +20,9 @@
20
20
  * translate and the read form use — so a nested markdown sub-field, a nested
21
21
  * asset swap and a block's asset prop are converted, carried and leashed exactly
22
22
  * as a top-level field is.
23
- * - **leashes hold, loudly**: `ai.exclude` fields are untouchable; relation AND
23
+ * - **leashes hold, loudly**: writer-locked positions are untouchable (EC-716 —
24
+ * `ai.exclude` and `form.readOnly`, at every depth: a change naming one, or a value
25
+ * that would change one below the field it names, is refused); relation AND
24
26
  * asset ids (EC-381) must come from searched candidates (`searchDocuments` /
25
27
  * `listAssets`) or already be on the document — at every position; every
26
28
  * targeting failure is an explicit issue — never a silent drop (unlike the
@@ -47,12 +49,12 @@
47
49
  * it to `getDocument` + the candidate store and hands the result to the review
48
50
  * sink.
49
51
  */
50
- import { cmsIssue, isFieldLocalized, isSequenceValue, resolveField, validateDocument, writeField, } from '@elytracms/core/cms-core';
52
+ import { cmsIssue, isFieldLocalized, isSequenceValue, resolveField, validateDocument, writeField, writerLockOf, } from '@elytracms/core/cms-core';
51
53
  import { editBlockRoots } from '@elytracms/core/project-graph';
52
54
  import { diffDocuments } from '@elytracms/core/content';
53
55
  import { introducedErrors, warningsOf } from './proposal-validation.js';
54
56
  import { fieldWritePatch } from '../cms/field-patch.js';
55
- import { fromWriterFieldValue, fromWriterFormAtNode, heldReferencesOf } from './writer-form.js';
57
+ import { fromWriterFieldValue, fromWriterFormAtNode, heldReferencesOf, lockedWriteMessage } from './writer-form.js';
56
58
  function isNonEmptyString(value) {
57
59
  return typeof value === 'string' && value.length > 0;
58
60
  }
@@ -94,9 +96,10 @@ function applyOneChange(input, ctx, doc, change) {
94
96
  if (!field) {
95
97
  return issue('invalid-field-value', `Unknown field "${change.field}" on collection "${collection.id}" — use a field name from the schema.`);
96
98
  }
97
- if (field.ai?.exclude) {
98
- return issue('invalid-field-value', `Field "${change.field}" must not be edited by the assistant.`);
99
- }
99
+ // EC-716: the field's own lock; a lock BELOW it is the converter's (it refuses there too).
100
+ const lock = writerLockOf(field);
101
+ if (lock !== undefined)
102
+ return issue('invalid-field-value', lockedWriteMessage(`Field "${change.field}"`, lock));
100
103
  const localized = isFieldLocalized(collection, change.field);
101
104
  if (!localized && change.locale !== undefined) {
102
105
  return issue('unknown-locale', `Field "${change.field}" is not localized — omit "locale".`);
@@ -1 +1 @@
1
- {"version":3,"file":"update-fields.js","sourceRoot":"","sources":["../../../src/lib/ai/update-fields.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `buildUpdateProposal` — the EC-378 update-proposal builder (the edit-flow\n * counterpart of `applyContentWithShape`, minus the generation step).\n *\n * The MODEL authors the field changes directly in the `updateFields` tool input\n * (it has the conversation + `getDocument` output as context); this module turns\n * those changes into a reviewable proposal with the SAME engine discipline the\n * create flow has:\n *\n * - **merge semantics**: changes apply per field over the EXISTING document —\n * never a whole-document rewrite; the patch is the merged document, the diff\n * (`diffDocuments`) is what the editor reviews (changed fields only).\n * - **targeting is addressed with the engine's existing conventions** — field\n * name + optional locale bucket (the diff / `RestoreScope` shape) and, for a\n * component inside a composition, `blockId` + `prop`: the component's node id\n * at ANY depth the value-position traversal reaches (a top-level block, a child\n * in a slot, a component embedded in rich text — EC-593). No new path grammar.\n * - **one converter** (EC-593, closes EC-571): every value goes from its writer\n * form to its stored form through `writer-form.ts` — the same fold create,\n * translate and the read form use — so a nested markdown sub-field, a nested\n * asset swap and a block's asset prop are converted, carried and leashed exactly\n * as a top-level field is.\n * - **leashes hold, loudly**: `ai.exclude` fields are untouchable; relation AND\n * asset ids (EC-381) must come from searched candidates (`searchDocuments` /\n * `listAssets`) or already be on the document — at every position; every\n * targeting failure is an explicit issue — never a silent drop (unlike the\n * create shape, which makes bad ids structurally unrepresentable, an update\n * change is imperative — so it FAILS instead of being filtered).\n * - **content, never structure** (ratified 4c): a composition's components are\n * never added, removed or moved by the assistant — a whole-value write of a\n * `blocks` field is refused with the way to change a prop instead, and a\n * rich-text value must keep the components embedded in it.\n * - **validation parity**: the merged document runs through `validateDocument`\n * with the studio's injected leashes. Only errors the change INTRODUCES block\n * the proposal — pre-existing brokenness on the document is not the\n * assistant's doing and must not force a whole-document repair (the editor's\n * own save path tolerates it the same way; errors gate publish, not drafts).\n *\n * Locale-awareness: a localized field's change lands in the change's `locale`,\n * defaulting to the editor's working locale (`input.locale`). Non-localized\n * fields reject a `locale` loudly. The value a change replaces is the DISPLAYED\n * value of that bucket (ratified 2c): a new locale bucket carries its placement\n * sub-fields — and a component prop change its whole composition — from the\n * fallback, exactly as the pickers do.\n *\n * Pure and deterministic — no provider, no I/O; the executor (`tools.ts`) wires\n * it to `getDocument` + the candidate store and hands the result to the review\n * sink.\n */\nimport {\n cmsIssue,\n isFieldLocalized,\n isSequenceValue,\n resolveField,\n validateDocument,\n writeField,\n} from '@elytracms/core/cms-core'\nimport { editBlockRoots } from '@elytracms/core/project-graph'\nimport type {\n CmsDocument,\n CmsValidationIssue,\n CollectionDef,\n CollectionRegistry,\n DocumentPatch,\n FieldDef,\n FieldValues,\n ValidateDocumentOptions,\n} from '@elytracms/core/cms-core'\nimport { diffDocuments } from '@elytracms/core/content'\nimport { introducedErrors, warningsOf } from './proposal-validation'\nimport { fieldWritePatch } from '../cms/field-patch'\nimport { fromWriterFieldValue, fromWriterFormAtNode, heldReferencesOf } from './writer-form'\nimport type { WriterFormContext, WriterFormProblem } from './writer-form'\nimport type { ContentPatch, WriterLeash } from './types'\n\n/**\n * One field change of an `updateFields` proposal, addressed with the engine's\n * existing conventions (diff / restore-scope): `field` (+ `locale` bucket for a\n * localized field), and for a component inside a composition optionally\n * `blockId` + `prop` to change one prop of that component — at any depth.\n * `value` is the new value (`null`/omitted clears the field or prop).\n */\nexport interface UpdateFieldChange {\n field: string\n /** Locale bucket for a LOCALIZED field (defaults to the editor's working locale). */\n locale?: string\n /** A component's stable node id (from `getDocument`) — at any depth: slots, rich-text embeds. */\n blockId?: string\n /** With `blockId`: the component prop to change. */\n prop?: string\n /** The new value; `null` / omitted clears the field or prop. */\n value?: unknown\n}\n\nexport interface BuildUpdateProposalInput {\n registry: CollectionRegistry\n collection: CollectionDef\n /** The CURRENT document — the proposal's \"before\" side. Never mutated. */\n document: CmsDocument\n changes: readonly UpdateFieldChange[]\n /** The editor's working locale — the default bucket for localized fields. */\n locale?: string\n /**\n * THE id leash at every position (EC-381 → EC-593): what the model searched this\n * conversation. An id that is neither searched nor already on the document fails.\n */\n leash?: WriterLeash\n /**\n * THE validation option bag (EC-497) — the studio session's\n * `gateValidateOptions(...)`, whole. It also carries the project's\n * `localeConfig` — what the unknown-locale targeting check and the displayed-value\n * carry read — and the `lookupManifest` the converter reaches component props\n * through: one source for \"which locales exist\" and \"which props a component\n * declares\", not two that can disagree.\n */\n validateOptions?: ValidateDocumentOptions\n}\n\n/** The `before` snapshot the review diff renders against. */\nexport interface UpdateBeforeSnapshot {\n values: FieldValues\n localized?: Record<string, FieldValues>\n}\n\nexport type BuildUpdateProposalResult =\n | {\n ok: true\n kind: 'proposal'\n /** The MERGED document as a patch (full values; the diff shows changed fields only). */\n patch: ContentPatch\n /**\n * EC-595 A2 — the proposal as the positions it changes (a field's bucket, or one\n * component prop by node path): what an accepted proposal persists, so applying it\n * never overwrites a change made to any other position since it was built.\n */\n patches: DocumentPatch[]\n before: UpdateBeforeSnapshot\n /** Human-readable descriptors of the changed fields (e.g. `title (de)`). */\n changedFields: string[]\n /** Non-error issues on the accepted result (warnings). */\n issues: CmsValidationIssue[]\n }\n | {\n /** Every change resolved to the current values — nothing to propose. */\n ok: true\n kind: 'no-change'\n }\n | { ok: false; issues: CmsValidationIssue[] }\n\ntype ChangeIssues = { issues: CmsValidationIssue[] }\ntype ChangeOutcome = { doc: CmsDocument; patch: DocumentPatch } | ChangeIssues\ntype IssueCode = Parameters<typeof cmsIssue>[0]['code']\n\nfunction isNonEmptyString(value: unknown): value is string {\n return typeof value === 'string' && value.length > 0\n}\n\n// The present/absent-key field write is core's `writeField` (EC-580) — this module\n// used to carry a local copy of the editor's `setFieldValue` (\"Local here (not\n// imported)\", MODE F5b.bkt). The locale is resolved per change below: a localized\n// field always arrives with one (a missing one is an explicit issue first).\n\n/**\n * The value a change REPLACES (ratified 2c, EC-593): the DISPLAYED value of the field in\n * the resolved bucket — core's `resolveField`, the rule every editor surface reads — so a\n * new locale bucket carries its placement sub-fields (and a component prop change its\n * composition) from the fallback, as the pickers do. It used to be the exact bucket only.\n * Without a locale config there is no fallback to display: the bucket itself.\n */\nfunction displayedFieldValue(\n input: BuildUpdateProposalInput,\n doc: CmsDocument,\n fieldName: string,\n locale: string | undefined,\n): unknown {\n if (locale === undefined) return doc.values[fieldName]\n const config = input.validateOptions?.localeConfig\n return config\n ? resolveField(input.collection, doc, fieldName, locale, config).value\n : doc.localized?.[locale]?.[fieldName]\n}\n\n/** Apply ONE change to a (copy of the) document, or report the targeting issues. */\nfunction applyOneChange(\n input: BuildUpdateProposalInput,\n ctx: WriterFormContext,\n doc: CmsDocument,\n change: UpdateFieldChange,\n): ChangeOutcome {\n const { collection } = input\n const issueAt = (code: IssueCode, message: string, at: readonly (string | number)[] = []): CmsValidationIssue =>\n cmsIssue({\n code,\n message,\n path: ['documents', `${collection.id}:${doc.id}`, 'values', change.field, ...at],\n collectionId: collection.id,\n documentId: doc.id,\n meta: { field: change.field, ...(change.locale !== undefined ? { locale: change.locale } : {}) },\n })\n const issue = (code: IssueCode, message: string): ChangeIssues => ({ issues: [issueAt(code, message)] })\n const fromProblems = (problems: readonly WriterFormProblem[]): ChangeIssues => ({\n issues: problems.map((problem) => issueAt(problem.code, problem.message, problem.at)),\n })\n\n const field = collection.fields.find((f) => f.name === change.field)\n if (!field) {\n return issue(\n 'invalid-field-value',\n `Unknown field \"${change.field}\" on collection \"${collection.id}\" — use a field name from the schema.`,\n )\n }\n if (field.ai?.exclude) {\n return issue('invalid-field-value', `Field \"${change.field}\" must not be edited by the assistant.`)\n }\n\n const localized = isFieldLocalized(collection, change.field)\n if (!localized && change.locale !== undefined) {\n return issue('unknown-locale', `Field \"${change.field}\" is not localized — omit \"locale\".`)\n }\n if (\n change.locale !== undefined &&\n input.validateOptions?.localeConfig &&\n !input.validateOptions.localeConfig.locales.includes(change.locale)\n ) {\n return issue(\n 'unknown-locale',\n `Unknown locale \"${change.locale}\" — this project has: ${input.validateOptions?.localeConfig?.locales.join(', ')}.`,\n )\n }\n const locale = localized ? change.locale ?? input.locale : undefined\n if (localized && locale === undefined) {\n return issue('missing-localized-value', `Field \"${change.field}\" is localized — provide \"locale\".`)\n }\n\n const displayed = displayedFieldValue(input, doc, change.field, locale)\n // The converter already wrote every value it changed in its stored form. The write\n // does not re-canonicalise component props (no manifest lookup): that would rewrite\n // the prop wrappers of every UNTOUCHED node too, and a proposal must change only what\n // it changes (the persister canonicalises on save, as for every editor write).\n const write = (value: unknown): { doc: CmsDocument; patch: DocumentPatch } => {\n const written = writeField(collection, doc, change.field, locale, value)\n return { doc: written, patch: fieldWritePatch(collection, written, change.field, locale) }\n }\n\n if (change.blockId !== undefined || change.prop !== undefined) {\n // A bucket that does not hold the field yet is written whole (from its displayed\n // fallback): a node path into it would address a node that is not there.\n const ownsBucket = locale === undefined || change.field in (doc.localized?.[locale] ?? {})\n return applyNodePropChange(field, change, displayed, locale, ownsBucket, ctx, issue, fromProblems, write)\n }\n\n // EC-593: the ONE writer-form converter, at every depth (EC-571: this path used to\n // convert top-level rich text only and store a nested markdown string raw).\n const converted = fromWriterFieldValue(field, change.value, displayed, ctx, locale)\n if (converted.problems.length > 0) return fromProblems(converted.problems)\n return write(converted.value)\n}\n\n/**\n * Change one component prop, addressed by node id at any depth (EC-593: it used to be a\n * ROOT block of a `blocks` field only, written verbatim — no carry, no leash, no\n * markdown conversion, and the persisted patch carried the RAW model value).\n */\nfunction applyNodePropChange(\n field: FieldDef,\n change: UpdateFieldChange,\n displayed: unknown,\n locale: string | undefined,\n ownsBucket: boolean,\n ctx: WriterFormContext,\n issue: (code: IssueCode, message: string) => ChangeIssues,\n fromProblems: (problems: readonly WriterFormProblem[]) => ChangeIssues,\n write: (value: unknown) => { doc: CmsDocument; patch: DocumentPatch },\n): ChangeOutcome {\n if (!isNonEmptyString(change.blockId)) {\n return issue(\n 'invalid-field-value',\n `Changing a component on \"${change.field}\" needs \"blockId\" — component ids come from getDocument.`,\n )\n }\n if (!isNonEmptyString(change.prop)) {\n return issue(\n 'invalid-field-value',\n `Changing component \"${change.blockId}\" needs \"prop\" — the component property to change.`,\n )\n }\n // EC-576 tolerant read (reseed gate, EC-576 step 4 deletes it with the other legacy\n // blocks readers): a pre-0.0.84 bare node / node list is lifted into the v2 sequence\n // the traversal reads — and the write is that sequence (no writer re-emits the bare shape).\n const composition =\n field.type === 'blocks' && displayed !== undefined && !isSequenceValue(displayed)\n ? editBlockRoots(displayed, (roots) => roots)\n : displayed\n const result = fromWriterFormAtNode(field, composition, change.blockId, change.prop, change.value, ctx, locale)\n if (!result.ok) return issue('invalid-field-value', result.message)\n if (result.problems.length > 0) return fromProblems(result.problems)\n const written = write(result.value)\n // EC-595 A2: the position this change wrote — the prop by its node path, or (where the\n // grammar does not reach the node, the bucket is new, or a legacy value was lifted)\n // the field bucket, whole: a coarser path, never a wrong one.\n if (!result.exact || !ownsBucket || composition !== displayed) return written\n const patch: DocumentPatch =\n result.propValue === undefined\n ? { op: 'unset', path: result.path }\n : { op: 'set', path: result.path, value: result.propValue }\n return { doc: written.doc, patch }\n}\n\n/**\n * Build the reviewable update proposal: apply every change over a copy of the\n * document (loud targeting), validate the merged result with the injected\n * leashes (only errors the change INTRODUCES block), and report the semantic\n * diff. `no-change` when the changes resolve to the current values.\n */\nexport function buildUpdateProposal(input: BuildUpdateProposalInput): BuildUpdateProposalResult {\n const { registry, collection, document } = input\n\n let next: CmsDocument = structuredClone(document)\n const targetingIssues: CmsValidationIssue[] = []\n const patches: DocumentPatch[] = []\n // EC-593: one converter context for every change — the validator's own prop lookup,\n // the conversation's leash, and the ids the ORIGINAL document already references.\n const ctx: WriterFormContext = {\n ...(input.validateOptions?.lookupManifest ? { lookupManifest: input.validateOptions.lookupManifest } : {}),\n ...(input.leash ? { leash: input.leash } : {}),\n held: heldReferencesOf(document),\n }\n for (const change of input.changes) {\n const outcome = applyOneChange(input, ctx, next, change)\n if ('issues' in outcome) targetingIssues.push(...outcome.issues)\n else {\n next = outcome.doc\n patches.push(outcome.patch)\n }\n }\n if (targetingIssues.length > 0) return { ok: false, issues: targetingIssues }\n\n // EC-497: THE bag, whole — no per-call-site reassembly.\n const validateOptions: ValidateDocumentOptions = input.validateOptions ?? {}\n const before = validateDocument(registry, document, validateOptions)\n const issues = validateDocument(registry, next, validateOptions)\n const introduced = introducedErrors(before, issues)\n if (introduced.length > 0) return { ok: false, issues: introduced }\n\n const diff = diffDocuments(collection, document, next)\n if (diff.changes.length === 0) return { ok: true, kind: 'no-change' }\n\n return {\n ok: true,\n kind: 'proposal',\n patch: {\n collection: collection.id,\n id: document.id,\n values: next.values,\n ...(next.localized && Object.keys(next.localized).length > 0\n ? { localized: next.localized }\n : {}),\n },\n patches,\n before: {\n values: document.values,\n ...(document.localized ? { localized: document.localized } : {}),\n },\n changedFields: diff.changes.map((change) =>\n change.locale !== undefined ? `${change.field} (${change.locale})` : change.field,\n ),\n issues: warningsOf(issues),\n }\n}\n"]}
1
+ {"version":3,"file":"update-fields.js","sourceRoot":"","sources":["../../../src/lib/ai/update-fields.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `buildUpdateProposal` — the EC-378 update-proposal builder (the edit-flow\n * counterpart of `applyContentWithShape`, minus the generation step).\n *\n * The MODEL authors the field changes directly in the `updateFields` tool input\n * (it has the conversation + `getDocument` output as context); this module turns\n * those changes into a reviewable proposal with the SAME engine discipline the\n * create flow has:\n *\n * - **merge semantics**: changes apply per field over the EXISTING document —\n * never a whole-document rewrite; the patch is the merged document, the diff\n * (`diffDocuments`) is what the editor reviews (changed fields only).\n * - **targeting is addressed with the engine's existing conventions** — field\n * name + optional locale bucket (the diff / `RestoreScope` shape) and, for a\n * component inside a composition, `blockId` + `prop`: the component's node id\n * at ANY depth the value-position traversal reaches (a top-level block, a child\n * in a slot, a component embedded in rich text — EC-593). No new path grammar.\n * - **one converter** (EC-593, closes EC-571): every value goes from its writer\n * form to its stored form through `writer-form.ts` — the same fold create,\n * translate and the read form use — so a nested markdown sub-field, a nested\n * asset swap and a block's asset prop are converted, carried and leashed exactly\n * as a top-level field is.\n * - **leashes hold, loudly**: writer-locked positions are untouchable (EC-716 —\n * `ai.exclude` and `form.readOnly`, at every depth: a change naming one, or a value\n * that would change one below the field it names, is refused); relation AND\n * asset ids (EC-381) must come from searched candidates (`searchDocuments` /\n * `listAssets`) or already be on the document — at every position; every\n * targeting failure is an explicit issue — never a silent drop (unlike the\n * create shape, which makes bad ids structurally unrepresentable, an update\n * change is imperative — so it FAILS instead of being filtered).\n * - **content, never structure** (ratified 4c): a composition's components are\n * never added, removed or moved by the assistant — a whole-value write of a\n * `blocks` field is refused with the way to change a prop instead, and a\n * rich-text value must keep the components embedded in it.\n * - **validation parity**: the merged document runs through `validateDocument`\n * with the studio's injected leashes. Only errors the change INTRODUCES block\n * the proposal — pre-existing brokenness on the document is not the\n * assistant's doing and must not force a whole-document repair (the editor's\n * own save path tolerates it the same way; errors gate publish, not drafts).\n *\n * Locale-awareness: a localized field's change lands in the change's `locale`,\n * defaulting to the editor's working locale (`input.locale`). Non-localized\n * fields reject a `locale` loudly. The value a change replaces is the DISPLAYED\n * value of that bucket (ratified 2c): a new locale bucket carries its placement\n * sub-fields — and a component prop change its whole composition — from the\n * fallback, exactly as the pickers do.\n *\n * Pure and deterministic — no provider, no I/O; the executor (`tools.ts`) wires\n * it to `getDocument` + the candidate store and hands the result to the review\n * sink.\n */\nimport {\n cmsIssue,\n isFieldLocalized,\n isSequenceValue,\n resolveField,\n validateDocument,\n writeField,\n writerLockOf,\n} from '@elytracms/core/cms-core'\nimport { editBlockRoots } from '@elytracms/core/project-graph'\nimport type {\n CmsDocument,\n CmsValidationIssue,\n CollectionDef,\n CollectionRegistry,\n DocumentPatch,\n FieldDef,\n FieldValues,\n ValidateDocumentOptions,\n} from '@elytracms/core/cms-core'\nimport { diffDocuments } from '@elytracms/core/content'\nimport { introducedErrors, warningsOf } from './proposal-validation'\nimport { fieldWritePatch } from '../cms/field-patch'\nimport { fromWriterFieldValue, fromWriterFormAtNode, heldReferencesOf, lockedWriteMessage } from './writer-form'\nimport type { WriterFormContext, WriterFormProblem } from './writer-form'\nimport type { ContentPatch, WriterLeash } from './types'\n\n/**\n * One field change of an `updateFields` proposal, addressed with the engine's\n * existing conventions (diff / restore-scope): `field` (+ `locale` bucket for a\n * localized field), and for a component inside a composition optionally\n * `blockId` + `prop` to change one prop of that component — at any depth.\n * `value` is the new value (`null`/omitted clears the field or prop).\n */\nexport interface UpdateFieldChange {\n field: string\n /** Locale bucket for a LOCALIZED field (defaults to the editor's working locale). */\n locale?: string\n /** A component's stable node id (from `getDocument`) — at any depth: slots, rich-text embeds. */\n blockId?: string\n /** With `blockId`: the component prop to change. */\n prop?: string\n /** The new value; `null` / omitted clears the field or prop. */\n value?: unknown\n}\n\nexport interface BuildUpdateProposalInput {\n registry: CollectionRegistry\n collection: CollectionDef\n /** The CURRENT document — the proposal's \"before\" side. Never mutated. */\n document: CmsDocument\n changes: readonly UpdateFieldChange[]\n /** The editor's working locale — the default bucket for localized fields. */\n locale?: string\n /**\n * THE id leash at every position (EC-381 → EC-593): what the model searched this\n * conversation. An id that is neither searched nor already on the document fails.\n */\n leash?: WriterLeash\n /**\n * THE validation option bag (EC-497) — the studio session's\n * `gateValidateOptions(...)`, whole. It also carries the project's\n * `localeConfig` — what the unknown-locale targeting check and the displayed-value\n * carry read — and the `lookupManifest` the converter reaches component props\n * through: one source for \"which locales exist\" and \"which props a component\n * declares\", not two that can disagree.\n */\n validateOptions?: ValidateDocumentOptions\n}\n\n/** The `before` snapshot the review diff renders against. */\nexport interface UpdateBeforeSnapshot {\n values: FieldValues\n localized?: Record<string, FieldValues>\n}\n\nexport type BuildUpdateProposalResult =\n | {\n ok: true\n kind: 'proposal'\n /** The MERGED document as a patch (full values; the diff shows changed fields only). */\n patch: ContentPatch\n /**\n * EC-595 A2 — the proposal as the positions it changes (a field's bucket, or one\n * component prop by node path): what an accepted proposal persists, so applying it\n * never overwrites a change made to any other position since it was built.\n */\n patches: DocumentPatch[]\n before: UpdateBeforeSnapshot\n /** Human-readable descriptors of the changed fields (e.g. `title (de)`). */\n changedFields: string[]\n /** Non-error issues on the accepted result (warnings). */\n issues: CmsValidationIssue[]\n }\n | {\n /** Every change resolved to the current values — nothing to propose. */\n ok: true\n kind: 'no-change'\n }\n | { ok: false; issues: CmsValidationIssue[] }\n\ntype ChangeIssues = { issues: CmsValidationIssue[] }\ntype ChangeOutcome = { doc: CmsDocument; patch: DocumentPatch } | ChangeIssues\ntype IssueCode = Parameters<typeof cmsIssue>[0]['code']\n\nfunction isNonEmptyString(value: unknown): value is string {\n return typeof value === 'string' && value.length > 0\n}\n\n// The present/absent-key field write is core's `writeField` (EC-580) — this module\n// used to carry a local copy of the editor's `setFieldValue` (\"Local here (not\n// imported)\", MODE F5b.bkt). The locale is resolved per change below: a localized\n// field always arrives with one (a missing one is an explicit issue first).\n\n/**\n * The value a change REPLACES (ratified 2c, EC-593): the DISPLAYED value of the field in\n * the resolved bucket — core's `resolveField`, the rule every editor surface reads — so a\n * new locale bucket carries its placement sub-fields (and a component prop change its\n * composition) from the fallback, as the pickers do. It used to be the exact bucket only.\n * Without a locale config there is no fallback to display: the bucket itself.\n */\nfunction displayedFieldValue(\n input: BuildUpdateProposalInput,\n doc: CmsDocument,\n fieldName: string,\n locale: string | undefined,\n): unknown {\n if (locale === undefined) return doc.values[fieldName]\n const config = input.validateOptions?.localeConfig\n return config\n ? resolveField(input.collection, doc, fieldName, locale, config).value\n : doc.localized?.[locale]?.[fieldName]\n}\n\n/** Apply ONE change to a (copy of the) document, or report the targeting issues. */\nfunction applyOneChange(\n input: BuildUpdateProposalInput,\n ctx: WriterFormContext,\n doc: CmsDocument,\n change: UpdateFieldChange,\n): ChangeOutcome {\n const { collection } = input\n const issueAt = (code: IssueCode, message: string, at: readonly (string | number)[] = []): CmsValidationIssue =>\n cmsIssue({\n code,\n message,\n path: ['documents', `${collection.id}:${doc.id}`, 'values', change.field, ...at],\n collectionId: collection.id,\n documentId: doc.id,\n meta: { field: change.field, ...(change.locale !== undefined ? { locale: change.locale } : {}) },\n })\n const issue = (code: IssueCode, message: string): ChangeIssues => ({ issues: [issueAt(code, message)] })\n const fromProblems = (problems: readonly WriterFormProblem[]): ChangeIssues => ({\n issues: problems.map((problem) => issueAt(problem.code, problem.message, problem.at)),\n })\n\n const field = collection.fields.find((f) => f.name === change.field)\n if (!field) {\n return issue(\n 'invalid-field-value',\n `Unknown field \"${change.field}\" on collection \"${collection.id}\" — use a field name from the schema.`,\n )\n }\n // EC-716: the field's own lock; a lock BELOW it is the converter's (it refuses there too).\n const lock = writerLockOf(field)\n if (lock !== undefined) return issue('invalid-field-value', lockedWriteMessage(`Field \"${change.field}\"`, lock))\n\n const localized = isFieldLocalized(collection, change.field)\n if (!localized && change.locale !== undefined) {\n return issue('unknown-locale', `Field \"${change.field}\" is not localized — omit \"locale\".`)\n }\n if (\n change.locale !== undefined &&\n input.validateOptions?.localeConfig &&\n !input.validateOptions.localeConfig.locales.includes(change.locale)\n ) {\n return issue(\n 'unknown-locale',\n `Unknown locale \"${change.locale}\" — this project has: ${input.validateOptions?.localeConfig?.locales.join(', ')}.`,\n )\n }\n const locale = localized ? change.locale ?? input.locale : undefined\n if (localized && locale === undefined) {\n return issue('missing-localized-value', `Field \"${change.field}\" is localized — provide \"locale\".`)\n }\n\n const displayed = displayedFieldValue(input, doc, change.field, locale)\n // The converter already wrote every value it changed in its stored form. The write\n // does not re-canonicalise component props (no manifest lookup): that would rewrite\n // the prop wrappers of every UNTOUCHED node too, and a proposal must change only what\n // it changes (the persister canonicalises on save, as for every editor write).\n const write = (value: unknown): { doc: CmsDocument; patch: DocumentPatch } => {\n const written = writeField(collection, doc, change.field, locale, value)\n return { doc: written, patch: fieldWritePatch(collection, written, change.field, locale) }\n }\n\n if (change.blockId !== undefined || change.prop !== undefined) {\n // A bucket that does not hold the field yet is written whole (from its displayed\n // fallback): a node path into it would address a node that is not there.\n const ownsBucket = locale === undefined || change.field in (doc.localized?.[locale] ?? {})\n return applyNodePropChange(field, change, displayed, locale, ownsBucket, ctx, issue, fromProblems, write)\n }\n\n // EC-593: the ONE writer-form converter, at every depth (EC-571: this path used to\n // convert top-level rich text only and store a nested markdown string raw).\n const converted = fromWriterFieldValue(field, change.value, displayed, ctx, locale)\n if (converted.problems.length > 0) return fromProblems(converted.problems)\n return write(converted.value)\n}\n\n/**\n * Change one component prop, addressed by node id at any depth (EC-593: it used to be a\n * ROOT block of a `blocks` field only, written verbatim — no carry, no leash, no\n * markdown conversion, and the persisted patch carried the RAW model value).\n */\nfunction applyNodePropChange(\n field: FieldDef,\n change: UpdateFieldChange,\n displayed: unknown,\n locale: string | undefined,\n ownsBucket: boolean,\n ctx: WriterFormContext,\n issue: (code: IssueCode, message: string) => ChangeIssues,\n fromProblems: (problems: readonly WriterFormProblem[]) => ChangeIssues,\n write: (value: unknown) => { doc: CmsDocument; patch: DocumentPatch },\n): ChangeOutcome {\n if (!isNonEmptyString(change.blockId)) {\n return issue(\n 'invalid-field-value',\n `Changing a component on \"${change.field}\" needs \"blockId\" — component ids come from getDocument.`,\n )\n }\n if (!isNonEmptyString(change.prop)) {\n return issue(\n 'invalid-field-value',\n `Changing component \"${change.blockId}\" needs \"prop\" — the component property to change.`,\n )\n }\n // EC-576 tolerant read (reseed gate, EC-576 step 4 deletes it with the other legacy\n // blocks readers): a pre-0.0.84 bare node / node list is lifted into the v2 sequence\n // the traversal reads — and the write is that sequence (no writer re-emits the bare shape).\n const composition =\n field.type === 'blocks' && displayed !== undefined && !isSequenceValue(displayed)\n ? editBlockRoots(displayed, (roots) => roots)\n : displayed\n const result = fromWriterFormAtNode(field, composition, change.blockId, change.prop, change.value, ctx, locale)\n if (!result.ok) return issue('invalid-field-value', result.message)\n if (result.problems.length > 0) return fromProblems(result.problems)\n const written = write(result.value)\n // EC-595 A2: the position this change wrote — the prop by its node path, or (where the\n // grammar does not reach the node, the bucket is new, or a legacy value was lifted)\n // the field bucket, whole: a coarser path, never a wrong one.\n if (!result.exact || !ownsBucket || composition !== displayed) return written\n const patch: DocumentPatch =\n result.propValue === undefined\n ? { op: 'unset', path: result.path }\n : { op: 'set', path: result.path, value: result.propValue }\n return { doc: written.doc, patch }\n}\n\n/**\n * Build the reviewable update proposal: apply every change over a copy of the\n * document (loud targeting), validate the merged result with the injected\n * leashes (only errors the change INTRODUCES block), and report the semantic\n * diff. `no-change` when the changes resolve to the current values.\n */\nexport function buildUpdateProposal(input: BuildUpdateProposalInput): BuildUpdateProposalResult {\n const { registry, collection, document } = input\n\n let next: CmsDocument = structuredClone(document)\n const targetingIssues: CmsValidationIssue[] = []\n const patches: DocumentPatch[] = []\n // EC-593: one converter context for every change — the validator's own prop lookup,\n // the conversation's leash, and the ids the ORIGINAL document already references.\n const ctx: WriterFormContext = {\n ...(input.validateOptions?.lookupManifest ? { lookupManifest: input.validateOptions.lookupManifest } : {}),\n ...(input.leash ? { leash: input.leash } : {}),\n held: heldReferencesOf(document),\n }\n for (const change of input.changes) {\n const outcome = applyOneChange(input, ctx, next, change)\n if ('issues' in outcome) targetingIssues.push(...outcome.issues)\n else {\n next = outcome.doc\n patches.push(outcome.patch)\n }\n }\n if (targetingIssues.length > 0) return { ok: false, issues: targetingIssues }\n\n // EC-497: THE bag, whole — no per-call-site reassembly.\n const validateOptions: ValidateDocumentOptions = input.validateOptions ?? {}\n const before = validateDocument(registry, document, validateOptions)\n const issues = validateDocument(registry, next, validateOptions)\n const introduced = introducedErrors(before, issues)\n if (introduced.length > 0) return { ok: false, issues: introduced }\n\n const diff = diffDocuments(collection, document, next)\n if (diff.changes.length === 0) return { ok: true, kind: 'no-change' }\n\n return {\n ok: true,\n kind: 'proposal',\n patch: {\n collection: collection.id,\n id: document.id,\n values: next.values,\n ...(next.localized && Object.keys(next.localized).length > 0\n ? { localized: next.localized }\n : {}),\n },\n patches,\n before: {\n values: document.values,\n ...(document.localized ? { localized: document.localized } : {}),\n },\n changedFields: diff.changes.map((change) =>\n change.locale !== undefined ? `${change.field} (${change.locale})` : change.field,\n ),\n issues: warningsOf(issues),\n }\n}\n"]}
@@ -1,4 +1,4 @@
1
- import type { CmsDocument, CollectionDef, FieldDef, PropFieldLookup, ValuePath, ValuePosition } from '@elytracms/core/cms-core';
1
+ import type { CmsDocument, CollectionDef, FieldDef, PropFieldLookup, ValuePath, ValuePosition, WriterLock } from '@elytracms/core/cms-core';
2
2
  import type { WriterLeash } from './types.js';
3
3
  /** The ids a document already references — reusable anywhere in it without a search. */
4
4
  export interface HeldReferences {
@@ -34,7 +34,14 @@ export interface WriterFormProblem {
34
34
  readonly message: string;
35
35
  /** An id outside the leash: it was REMOVED from the converted value. */
36
36
  readonly leash?: true;
37
+ /** A write to a writer-locked position (EC-716): the position KEPT its current value. */
38
+ readonly locked?: true;
37
39
  }
40
+ /**
41
+ * What a writer is told about a write to a locked position (EC-716) — one wording for the
42
+ * create's dropped-value warning, the edit's refusal and a component prop's refusal.
43
+ */
44
+ export declare function lockedWriteMessage(subjectText: string, lock: WriterLock): string;
38
45
  export interface FromWriterFormResult {
39
46
  /** The stored form (`undefined` — clear the position). */
40
47
  readonly value: unknown;
@@ -84,8 +91,9 @@ export declare function isTextPosition(position: ValuePosition): boolean;
84
91
  * same traversal"): a `text` value, and a `richText` / `blocks` sequence (its prose —
85
92
  * the sequence's own text — is the caller's to split) together with every text position
86
93
  * below it: component text props in a composition and in rich-text embeds, object
87
- * sub-fields, repeater items, asset placement sub-fields. An `ai.exclude` position (and
88
- * everything below it) is not text a writer may touch. Depth-first, document order.
94
+ * sub-fields, repeater items, asset placement sub-fields. A writer-locked position
95
+ * (EC-716: `ai.exclude` or `form.readOnly`) and everything below it is not text a writer
96
+ * may touch — not a unit, and so never a skip either. Depth-first, document order.
89
97
  */
90
98
  export declare function textPositionsOf(position: ValuePosition, ctx: Pick<WriterFormContext, 'lookupManifest'>): ValuePosition[];
91
99
  /**
@@ -36,11 +36,16 @@
36
36
  * (`FIELD_TYPES.blocks.writer === 'composition'`), and a rich-text value must keep the
37
37
  * component nodes it holds (same ids, same components, same places). A component's
38
38
  * PROP is content — {@link fromWriterFormAtNode} changes one, by its node, at any depth
39
- * the traversal reaches.
39
+ * the traversal reaches;
40
+ * - **the writer lock** (EC-716, owner ruling "Yes, honour readOnly") — a position core's
41
+ * `writerLockOf` locks (`ai.exclude` or `form.readOnly`, and everything below it) keeps
42
+ * its CURRENT value. Leaving it out of the written value, or echoing it back unchanged,
43
+ * is no write; any other value is reported as a `locked` problem (create drops it, edit
44
+ * refuses — the same two policies as the id leash).
40
45
  *
41
46
  * Pure and deterministic; no provider, no I/O.
42
47
  */
43
- import { carryAssetPlacements, childPositions, fieldPath, fieldTypeOf, fieldValuePosition, isSequenceValue, rebuildFromChildren, referenceNode, sequenceComponentNodes, walkReferences, walkValuePositions, } from '@elytracms/core/cms-core';
48
+ import { carryAssetPlacements, childPositions, fieldPath, fieldTypeOf, fieldValuePosition, isSequenceValue, rebuildFromChildren, referenceNode, seedObjectItem, sequenceComponentNodes, walkReferences, walkValuePositions, writerLockOf, } from '@elytracms/core/cms-core';
44
49
  import { parseRichTextValue, richTextValueToMarkdownProse } from '@elytracms/core/rich-text';
45
50
  import { richTextValueFromModelInput } from './rich-text-input.js';
46
51
  export const NO_HELD_REFERENCES = { documents: new Map(), assets: new Set() };
@@ -65,6 +70,15 @@ export function heldReferencesOf(document) {
65
70
  });
66
71
  return { documents, assets };
67
72
  }
73
+ /**
74
+ * What a writer is told about a write to a locked position (EC-716) — one wording for the
75
+ * create's dropped-value warning, the edit's refusal and a component prop's refusal.
76
+ */
77
+ export function lockedWriteMessage(subjectText, lock) {
78
+ return lock === 'excluded'
79
+ ? `${subjectText} must not be edited by the assistant.`
80
+ : `${subjectText} is read-only — no one changes it in the studio, the assistant included.`;
81
+ }
68
82
  // --- Writer form → stored form -------------------------------------------------------
69
83
  /**
70
84
  * Convert a value a model authored at `position` (its `value` is the WRITER form) into
@@ -135,6 +149,56 @@ function convertChildren(pos, current, ctx, problems, root) {
135
149
  return rebuildFromChildren(pos, positionCtx, (child) => convert(child, currentChildren.get(childKey(pos, child)), ctx, problems, root));
136
150
  }
137
151
  function convert(pos, current, ctx, problems, root) {
152
+ // EC-716: a locked position keeps its current value — and so, by not being visited,
153
+ // does everything below it (the lock cascades as the form's `readOnly` does).
154
+ const lock = writerLockOf(pos.field);
155
+ if (lock === undefined)
156
+ return convertValue(pos, current, ctx, problems, root);
157
+ if (pos.value === undefined || isEcho(pos, current, ctx, root))
158
+ return current;
159
+ problems.push({
160
+ at: pos.at,
161
+ code: 'invalid-field-value',
162
+ message: lockedWriteMessage(`Field ${subject(pos, root)}`, lock),
163
+ locked: true,
164
+ });
165
+ return current;
166
+ }
167
+ /**
168
+ * Whether a value written to a locked position leaves it as it is: a clear of an empty
169
+ * position, or the current value echoed back — as stored, in the form the model READ it
170
+ * in ({@link toWriterForm}: rich text as markdown), or in any writer form that converts to
171
+ * exactly the stored value.
172
+ */
173
+ function isEcho(pos, current, ctx, root) {
174
+ const written = pos.value;
175
+ if (written === null)
176
+ return current === undefined || current === null;
177
+ if (current === undefined || current === null)
178
+ return false;
179
+ const json = canonicalJson(written);
180
+ if (json === canonicalJson(current))
181
+ return true;
182
+ if (json === canonicalJson(toWriterForm({ ...pos, value: current }, ctx)))
183
+ return true;
184
+ const scratch = [];
185
+ const stored = convertValue(pos, current, ctx, scratch, root);
186
+ return scratch.length === 0 && canonicalJson(stored) === canonicalJson(current);
187
+ }
188
+ /**
189
+ * A repeater's current value with every item the written list adds seeded the way a
190
+ * manual "add item" seeds it (core's `seedObjectItem`), so a locked sub-field of a NEW item
191
+ * keeps its default rather than staying empty (EC-716, sol-C 22 at item level). Items are
192
+ * matched by index — the matching the converter uses for every repeater.
193
+ */
194
+ function withSeededItems(current, length, fields) {
195
+ const items = Array.isArray(current) ? current : [];
196
+ if (items.length >= length)
197
+ return current;
198
+ return [...items, ...Array.from({ length: length - items.length }, () => seedObjectItem(fields))];
199
+ }
200
+ /** {@link convert} for a position no lock holds: the writer form → the stored form. */
201
+ function convertValue(pos, current, ctx, problems, root) {
138
202
  const { field } = pos;
139
203
  const raw = pos.value === null ? undefined : pos.value;
140
204
  const report = (code, message, leash) => {
@@ -246,9 +310,15 @@ function convert(pos, current, ctx, problems, root) {
246
310
  return undefined;
247
311
  return relation.multiple === true ? refs : refs[0];
248
312
  }
249
- case 'object':
313
+ case 'object': {
250
314
  // A value of the wrong shape has no children; the validator names it.
251
- return raw === undefined ? undefined : convertChildren({ ...pos, value: raw }, current, ctx, problems, root);
315
+ if (raw === undefined)
316
+ return undefined;
317
+ const base = field.type === 'object' && field.multiple && Array.isArray(raw)
318
+ ? withSeededItems(current, raw.length, field.fields)
319
+ : current;
320
+ return convertChildren({ ...pos, value: raw }, base, ctx, problems, root);
321
+ }
252
322
  case 'value':
253
323
  return raw;
254
324
  }
@@ -273,16 +343,31 @@ export function fromWriterFormAtNode(field, displayed, nodeId, prop, raw, ctx, l
273
343
  const root = fieldValuePosition(field, displayed, locale !== undefined ? { locale } : {});
274
344
  const isTarget = (position) => position.kind === 'prop' && position.component?.nodeId === nodeId && position.field.name === prop;
275
345
  let target;
346
+ // EC-716: the lock the target sits under — its own, or the nearest locked ancestor's
347
+ // (a component embedded in a read-only rich-text prop, a prop of a locked object…).
348
+ let targetLock;
276
349
  const declared = [];
277
350
  let componentId;
278
- walkValuePositions(root, positionCtx, (position) => {
279
- if (position.kind !== 'prop' || position.component?.nodeId !== nodeId)
280
- return;
281
- componentId = position.component.componentId;
282
- declared.push(position.field.name);
283
- if (target === undefined && position.field.name === prop)
284
- target = position;
285
- });
351
+ const visit = (position, inherited) => {
352
+ const lock = writerLockOf(position.field) ?? inherited;
353
+ if (position.kind === 'prop' && position.component?.nodeId === nodeId) {
354
+ componentId = position.component.componentId;
355
+ declared.push(position.field.name);
356
+ if (target === undefined && position.field.name === prop) {
357
+ target = position;
358
+ targetLock = lock;
359
+ }
360
+ }
361
+ for (const child of childPositions(position, positionCtx))
362
+ visit(child, lock);
363
+ };
364
+ visit(root, undefined);
365
+ if (target && targetLock !== undefined) {
366
+ return {
367
+ ok: false,
368
+ message: lockedWriteMessage(`Prop "${prop}" of component "${nodeId}" (${componentId})`, targetLock),
369
+ };
370
+ }
286
371
  if (!target) {
287
372
  if (componentId === undefined) {
288
373
  return {
@@ -309,22 +394,24 @@ export function isTextPosition(position) {
309
394
  const { type } = position.field;
310
395
  return type === 'text' || type === 'richText' || type === 'blocks';
311
396
  }
312
- function isExcludedBelow(position) {
313
- return position.at.length > 0 && position.field.ai?.exclude === true;
397
+ /** EC-716: a writer-locked position — no unit, no write, nothing below it either. */
398
+ function isLocked(position) {
399
+ return writerLockOf(position.field) !== undefined;
314
400
  }
315
401
  /**
316
402
  * Every TEXT position at or below `position` (EC-593, ratified 4e — "translate uses the
317
403
  * same traversal"): a `text` value, and a `richText` / `blocks` sequence (its prose —
318
404
  * the sequence's own text — is the caller's to split) together with every text position
319
405
  * below it: component text props in a composition and in rich-text embeds, object
320
- * sub-fields, repeater items, asset placement sub-fields. An `ai.exclude` position (and
321
- * everything below it) is not text a writer may touch. Depth-first, document order.
406
+ * sub-fields, repeater items, asset placement sub-fields. A writer-locked position
407
+ * (EC-716: `ai.exclude` or `form.readOnly`) and everything below it is not text a writer
408
+ * may touch — not a unit, and so never a skip either. Depth-first, document order.
322
409
  */
323
410
  export function textPositionsOf(position, ctx) {
324
411
  const positionCtx = ctx.lookupManifest ? { lookupManifest: ctx.lookupManifest } : {};
325
412
  const out = [];
326
413
  const visit = (current) => {
327
- if (isExcludedBelow(current))
414
+ if (isLocked(current))
328
415
  return;
329
416
  if (isTextPosition(current))
330
417
  out.push(current);
@@ -346,7 +433,7 @@ export function textPositionsOf(position, ctx) {
346
433
  export function rebuildTextPositions(position, ctx, replace) {
347
434
  const positionCtx = ctx.lookupManifest ? { lookupManifest: ctx.lookupManifest } : {};
348
435
  const rebuild = (current) => {
349
- if (isExcludedBelow(current))
436
+ if (isLocked(current))
350
437
  return current.value;
351
438
  if (current.field.type === 'text')
352
439
  return replace(current);