Repository navigation
Expand file tree
/
Copy pathprotocol.zod.ts
More file actions
4144 lines (3963 loc) · 227 KB
/
Copy pathprotocol.zod.ts
File metadata and controls
4144 lines (3963 loc) · 227 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { z } from 'zod';
import { ViewSchema } from '../ui/view.zod';
import { I18nLabelSchema } from '../ui/i18n.zod';
import { DiscoverySchema } from './discovery.zod';
import {
BatchUpdateRequestSchema,
BatchUpdateResponseSchema,
UpdateManyRequestSchema,
DeleteManyRequestSchema,
} from './batch.zod';
import { MetadataCacheRequestSchema, MetadataCacheResponseSchema } from './http-cache.zod';
import { QUERY_DISTINCT_REMOVED } from '../data/query.zod';
import { retiredKey } from '../shared/retired-key';
// The closed list of refinements that reach the published JSON Schema (#18670
// item 2) — `UpdateAiConversationRequest`'s at-least-one rule is declared
// through it, so `api/UpdateAiConversationRequest.json` states it.
import { requiredOneOf } from '../shared/refinement-projection';
import { MetadataItemNameSchema } from '../shared/identifiers.zod';
import { DroppedFieldsEventSchema, QueryWithTransportSchema, ValidationAdvisoryEventSchema } from '../data/data-engine.zod';
import {
AnalyticsQueryRequestSchema,
AnalyticsResultResponseSchema,
GetAnalyticsMetaRequestSchema,
AnalyticsMetadataResponseSchema
} from './analytics.zod';
import { RealtimePresenceSchema, TransportProtocol } from './realtime.zod';
import { ObjectPermissionSchema, EffectiveObjectPermissionSchema, FieldPermissionSchema } from '../security/permission.zod';
import { ActionDescriptorSchema } from '../automation/node-executor.zod';
import { PlatformTranslationDataSchema } from '../system/translation.zod';
// #5950 / #5882 — the ADR-0010 read-side protection envelope both metadata-item
// responses publish. Same three vocabularies the resolver filters against, so a
// value this spec cannot name is a value the resolver would have dropped.
import {
MetadataLockSchema,
MetadataLockSourceSchema,
MetadataProvenanceSchema,
} from '../kernel/metadata-protection.zod';
import { MetadataValidationResultSchema } from '../kernel/metadata-plugin.zod';
// [#23183] The package-id grammar the app item read's package-id key is
// recognised by ({@link isAppPackageIdKey}) — its one declaration.
import { MANIFEST_ID_PATTERN } from '../kernel/manifest.zod';
// [#10235] The per-column sortability projection the object read serves on its
// envelope — computed at serve time, never authorable; see sortability.zod.ts.
import { ObjectSortabilitySchema } from './sortability.zod';
import {
ListPackagesRequestSchema,
ListPackagesResponseSchema,
GetPackageRequestSchema,
GetPackageResponseSchema,
InstallPackageRequestSchema,
InstallPackageResponseSchema,
UninstallPackageRequestSchema,
UninstallPackageResponseSchema,
EnablePackageRequestSchema,
EnablePackageResponseSchema,
DisablePackageRequestSchema,
DisablePackageResponseSchema,
} from '../kernel/package-registry.zod';
import type {
ListPackagesRequest,
ListPackagesResponse,
GetPackageRequest,
GetPackageResponse,
InstallPackageRequest,
InstallPackageResponse,
UninstallPackageRequest,
UninstallPackageResponse,
EnablePackageRequest,
EnablePackageResponse,
DisablePackageRequest,
DisablePackageResponse,
InstalledPackage,
PackageStatus,
} from '../kernel/package-registry.zod';
import { lazySchema } from '../shared/lazy-schema';
export const AutomationTriggerRequestSchema = lazySchema(() => z.object({
trigger: z.string(),
payload: z.record(z.string(), z.unknown())
}));
export const AutomationTriggerResponseSchema = lazySchema(() => z.object({
success: z.boolean(),
jobId: z.string().optional(),
result: z.unknown().optional()
}));
/**
* Response for `GET /api/v1/automation/actions` (ADR-0018).
*
* Returns the live action/node registry — the platform's built-in actions plus
* any plugin-contributed ones — backing the designer palette and flow
* validation. Each entry is a canonical {@link ActionDescriptorSchema}.
*/
export const AutomationActionsResponseSchema = lazySchema(() => z.object({
actions: z.array(ActionDescriptorSchema).describe('Registered action descriptors (built-in + plugin)'),
total: z.number().int().nonnegative().describe('Number of descriptors returned (after any filters)'),
}));
/**
* ObjectStack Protocol - Zod Schema Definitions
*
* Defines the runtime-validated contract for interacting with ObjectStack metadata and data.
* Used by API adapters (HTTP, WebSocket, gRPC) to fetch data/metadata without knowing engine internals.
*
* This protocol enables:
* - Runtime request/response validation at API gateway level
* - Automatic API documentation generation
* - Type-safe RPC communication between microservices
* - Client SDK generation from schemas
*
* Architecture Alignment:
* - Salesforce: REST API Request/Response schemas
* - Kubernetes: API Resource schemas with runtime validation
* - GraphQL: Schema-first API design
*/
// ==========================================
// Discovery & Metadata Operations
// ==========================================
/**
* Get API Discovery Request
* No parameters needed
*/
export const GetDiscoveryRequestSchema = lazySchema(() => z.object({}));
/**
* Get API Discovery Response
* Derived from DiscoverySchema (single source of truth) for protocol-level use.
*
* All fields from DiscoverySchema are available but made optional (except `version`)
* to support progressive disclosure and backward compatibility with existing clients.
*
* - `routes` provides a flat endpoint map for client routing.
* - `services` is the single source of truth for service availability.
* - `apiName` is kept as an optional alias for `name` for backward compatibility.
*
* ## This schema is the CLIENT's tolerance, never a producer's licence (#4828)
*
* `.partial()` exists so a client can parse an OLDER server's response without
* exploding, and zod's default unknown-key strip lets it parse a NEWER one.
* That tolerance is correct at this boundary and wrong as a producer contract —
* and for a long time it was the only schema anything referenced, so it acted
* as both. The result was `declared ≠ enforced` in both directions at once:
* `getDiscovery()` never emitted the required `name`/`environment`/`locale` and
* still parsed clean, while the runtime dispatcher emitted `features` and
* `endpoints` — declared nowhere — and also parsed clean.
*
* So the two schemas now have separate jobs, and a gate compares them:
*
* - **{@link DiscoverySchema} is authoritative for PRODUCERS.** Every producer's
* live shape must satisfy it (`packages/metadata-protocol`, `packages/runtime`
* and `packages/rest` each carry a `discovery-schema-conformance.test.ts`).
* - **This schema is the CONSUMER's parse**, and its key set is the allowance
* those producer gates check against — i.e. `DiscoverySchema`'s keys plus the
* declared deprecated aliases. `./discovery.test.ts` pins that equivalence, so
* a key can never again appear on one side only.
*
* ## `apiName` retirement schedule (ADR-0087)
*
* `apiName` is the sole surviving deprecated alias here. `name` is canonical and
* REQUIRED by `DiscoverySchema`; as of protocol 17 every producer emits `name`,
* and `getDiscovery()` additionally emits `apiName` with the identical value so
* clients pinned to the alias keep working.
*
* - **Protocol 17 (now)**: both emitted; `name` canonical, `apiName` deprecated.
* - **Protocol 18**: producers stop emitting `apiName` and it is removed from
* this schema. Consumers migrate to `name` — a pure rename with no semantic
* change, which is why it needs no D2 conversion entry (that table converts
* AUTHORED metadata at load; a response payload has no load seam).
*
* Consumers to migrate before 18 — measured 2026-08-05 across `objectstack`,
* `objectui` and `cloud`: `packages/client/tests/integration/01-discovery.test.ts`
* (TC-DISC-001/002). No product code in any of the three repos reads `apiName`.
*
* @see DiscoverySchema in ./discovery.zod.ts — the canonical definition.
*/
export const GetDiscoveryResponseSchema = lazySchema(() => DiscoverySchema
.partial()
.required({ version: true })
.extend({
/**
* @deprecated Use `name` instead. Removed in protocol 18 — see the
* retirement schedule above. Emitted alongside `name` until then.
*/
apiName: z.string().optional().describe('API name (deprecated — use `name`; removed in protocol 18)'),
}));
/**
* Get Metadata Types Request
*/
export const GetMetaTypesRequestSchema = lazySchema(() => z.object({}));
/**
* Get Metadata Types Response
*
* Phase 3a-1: returns the bare `types` array (for backwards compatibility)
* plus rich per-type `entries` describing label, domain, file patterns, and
* runtime capability flags. Admin UIs (Metadata Directory, Resource list,
* Quick Find) consume `entries`; legacy code that only needs the type list
* keeps using `types`.
*/
export const GetMetaTypesResponseSchema = lazySchema(() => z.object({
types: z.array(z.string()).describe('Available metadata type names (e.g., "object", "plugin", "view")'),
entries: z.array(z.object({
type: z.string().describe('Singular type identifier'),
label: z.string().describe('Human-readable label'),
description: z.string().optional().describe('Brief description'),
filePatterns: z.array(z.string()).describe('Glob patterns used to discover artifacts of this type'),
supportsOverlay: z.boolean().describe('Whether a stored overlay row of an item a managed package ships may be removed through the ordinary delete door without the type\'s environment-overlay permission: the removal removes no managed definition, and changes what is served for permission alone; it opens no save and it decides no read'),
allowEnvironmentOverlay: z.boolean().describe('True when the registry grants environment overlays of this type\'s packaged items, or when OS_METADATA_WRITABLE names the type (overrideSource env), which opens the type\'s writes of items no managed package ships and never a managed item (ADR-0131 D6)'),
allowRuntimeCreate: z.boolean().describe('New artifacts of this type can be created via runtime API'),
supportsVersioning: z.boolean().describe('History is tracked for this type'),
executionPinned: z.boolean().describe('Runtime transactions pin a specific historical version_hash (ADR-0009)'),
loadOrder: z.number().int().describe('Loading priority (lower = earlier)'),
domain: z.enum(['data', 'ui', 'automation', 'system', 'security', 'ai']).describe('Protocol domain'),
overrideSource: z.enum(['registry', 'env']).describe('Whether allowEnvironmentOverlay comes from the static registry or is elevated by the OS_METADATA_WRITABLE environment variable'),
createSeed: z.unknown().optional().describe('Authoritative minimal valid create seed for this type — Studio/CLI/API derive create defaults from it (single source of truth in @objectstack/spec). Absent for canvas-create types whose shape is built interactively.'),
})).optional().describe('Enriched per-type registry entries (Phase 3a)'),
}));
/**
* Get Metadata Items Request
* Get all items of a specific metadata type
*
* **`environmentId` is deliberately NOT a member here — or on any meta-read
* request schema** (maintainer ruling 2026-08-18, commit 2a29caa53). It is the
* TRANSPORT-level multi-kernel routing key: the REST layer resolves the target
* kernel from it *before* the protocol call, and the implementation's own
* parameter types (`@objectstack/metadata-protocol`) never read it off the
* request. Its absence from these schemas is a recorded contract decision —
* "not part of the request" — not an undeclared-surface omission. Callers that
* must carry it alongside a request do so in a typed transport envelope at
* their own layer (see `TransportScopedMetaRequest` in `@objectstack/rest`),
* never by widening these shapes.
*/
export const GetMetaItemsRequestSchema = lazySchema(() => z.object({
type: z.string().describe('Metadata type name (e.g., "object", "plugin")'),
packageId: z.string().optional().describe('Optional package ID to filter items by'),
previewDrafts: z.boolean().optional().describe(
'Draft-visibility switch (ADR-0033 draft-overlay preview): when true, '
+ 'pending `state=\'draft\'` rows are overlaid on the active list — draft '
+ 'wins on name collision, draft-only items appear, and each overlaid item '
+ 'is tagged `_draft: true` so UIs can badge the preview. Absent/false = '
+ 'published world only. Declaration ≠ authorization: this member only '
+ 'switches which rows are read, and ADR-0106 masking is unaffected — '
+ 'callers without draft-preview authorization are refused upstream '
+ '(admin-gated), not by this schema.',
),
}));
/**
* What the automation engine threw when it refused to register a stored flow:
* the engine's own `code` and `message`, verbatim. ⛔ Never paraphrased, never
* re-coded: a consumer renders `message`, and branches on `code` only where
* the engine set one (many registration refusals are plain errors, which carry
* none — so `code` is absent there rather than invented).
*/
export const FlowRegistrationRefusalSchema = lazySchema(() => z.object({
code: z.string().optional().describe(
'The machine code the engine\'s refusal carried, verbatim. Absent when the thrown error carried none.',
),
message: z.string().describe(
'The engine\'s refusal message, verbatim. Render it as sent; do not parse it.',
),
}));
/**
* The automation engine's verdict on ONE stored flow, as the metadata read
* serves it — beside the item, never inside it.
*
* Two facts, deliberately apart: the stored `status` is what the author
* declared and stays as authored, while this is what the engine made of the
* definition. Before this member existed, a flow the engine refused at boot
* (withdrawn, with a warn line) was served as `status: 'active'` and nothing
* on the read said it never runs.
*
* A closed set, total over the engine's state for a name:
*
* - `registered` — the engine holds a registration under the name.
* Registration is not arming: whether a trigger is bound is
* `GET /automation/_status`'s answer, not this one.
* - `refused` — the engine's last attempt to register the stored flow threw,
* and it holds nothing under the name; `reason` is what it threw. A later
* successful registration of the name (a save, a publish, a reload) ends
* the state.
* - `unregistered` — the engine holds nothing under the name and recorded no
* refusal: the flow has not reached it (a read during boot, or between a
* save and the engine following it), or it was unregistered on purpose.
*
* It is the verdict of the engine in the process that answered the read: on a
* deployment of several processes each answers for its own engine, and no
* cross-process store is consulted.
*/
export const FlowRegistrationSchema = lazySchema(() => z.discriminatedUnion('state', [
z.object({
state: z.literal('registered').describe('The engine holds a registration under the flow\'s name.'),
}),
z.object({
state: z.literal('refused').describe(
'The engine refused to register the stored flow and holds nothing under its name: it never runs.',
),
reason: FlowRegistrationRefusalSchema.describe('What the engine threw, verbatim.'),
}),
z.object({
state: z.literal('unregistered').describe(
'The engine holds nothing under the flow\'s name and recorded no refusal for it.',
),
}),
]));
/**
* Get Metadata Items Response
*
* `registrations` is the list's position for the automation engine's verdict
* ({@link FlowRegistrationSchema}): the items stay bare documents, so the
* verdict sits beside them, keyed by each item's `name` — readable without a
* per-item read, and never inside an item.
*/
export const GetMetaItemsResponseSchema = lazySchema(() => z.object({
type: z.string().describe('Metadata type name'),
items: z.array(z.unknown()).describe('Array of metadata items'),
registrations: z.record(z.string(), FlowRegistrationSchema).optional().describe(
'The automation engine\'s verdict on each listed flow, keyed by the item\'s `name`: one entry per '
+ 'listed item that is not a pending draft. Present only on a `flow` list answered by a process that '
+ 'runs an automation service; absent otherwise, and absence means no engine judged them, never '
+ '`registered`. The verdict is the answering process\'s engine. Beside the items, never inside one: '
+ 'an item\'s `status` stays what its author declared.',
),
}));
/**
* [#22877] The `member` every by-name door that reads or changes ONE stored
* item declares (get, delete, publish, history; revert has no declared
* request) — one sentence, so the four requests cannot drift apart. On the
* REST door it is the `?member=` query parameter of each route's closed set.
*/
const BUNDLE_MEMBER_SELECTOR_DESCRIPTION =
'Bundle member selector, for a metadata type whose identity is a pair (today only `email_template`, ' +
'identified by `(name, locale)`): the member this request addresses, spelled as its discriminator value ' +
'(`zh-CN`). Absent, the canonical member (`en-US` for `email_template`), which is what a bare-name read ' +
'answers; naming the canonical value is the same request. It is not the response translation locale, ' +
'which `?locale=` and `Accept-Language` choose. Refused with 400 `VALIDATION_ERROR` on a type that has ' +
'no members, and for a blank value or one longer than a stored member can be (100 characters). A ' +
'well-formed member with nothing stored or shipped answers as that door answers an absent item.';
/**
* Get Metadata Item Request
* Get a specific metadata item by type and name
*/
export const GetMetaItemRequestSchema = lazySchema(() => z.object({
type: z.string().describe('Metadata type name'),
name: z.string().describe(
'Item name: the item\'s own `name`. For `type: \'app\'` only, it may instead be the id of the '
+ 'package that ships the app (`manifest.id`, reverse-domain, e.g. `com.acme.crm`): with one app '
+ 'per package the app IS the package container, and the package id is its route key (ADR-0048 '
+ '§3.1). The two grammars are disjoint (an app name is snake_case with no dot; a package id '
+ 'matches `MANIFEST_ID_PATTERN`, with at least one dot), so an app key in the package-id grammar '
+ 'always names the package (`isAppPackageIdKey`). That read answers the package\'s one app as the '
+ 'by-name read answers it, under the app\'s own `name`, and the per-caller read gate judges it as '
+ 'it judges that app read by name (an app withheld from the caller answers 403 '
+ '`PERMISSION_DENIED`). A package with no app, or with more than one, answers as an absent item. '
+ 'A `packageId` naming a different package is refused with 400 `INVALID_REQUEST`. Every other '
+ 'type, and every other key, is read by name.',
),
packageId: z.string().optional().describe('Optional package ID to filter items by'),
member: z.string().optional().describe(BUNDLE_MEMBER_SELECTOR_DESCRIPTION),
state: z.enum(['active', 'draft']).optional().describe(
'Draft-visibility switch — which lifecycle row to read (strict mode): '
+ '`\'draft\'` opens the pending draft buffer (Studio\'s editor read) and '
+ 'fails when no draft exists; absent or `\'active\'` reads the live '
+ 'published row. Distinct from `previewDrafts`, which FALLS BACK to the '
+ 'active row when no draft exists. Declaration ≠ authorization: this '
+ 'member only selects which stored row is read — ADR-0106 masking is '
+ 'unaffected, and draft access is gated upstream, not by this schema.',
),
previewDrafts: z.boolean().optional().describe(
'Draft-visibility switch (ADR-0033 draft-overlay preview, non-strict): '
+ 'when true and `state` is not `\'draft\'`, a pending draft row is '
+ 'preferred if one exists, else the read falls back to the active row — '
+ 'the render path degrades to the published value instead of erroring. A '
+ 'served draft is tagged `_draft: true` so UIs can badge it. Declaration '
+ '≠ authorization: this member only switches which row is read, and '
+ 'ADR-0106 masking is unaffected — draft preview is admin-gated '
+ 'upstream, not by this schema.',
),
}));
/**
* [#23183] Does `name` address the app item read by PACKAGE ID — the arm
* {@link GetMetaItemRequestSchema}'s `name` declares (ADR-0048 §3.1)? True
* exactly when `type` is `app` and `name` is in the package-id grammar
* ({@link MANIFEST_ID_PATTERN}), which no app name can be: `AppSchema.name` is
* snake_case with no dot, and a package id carries at least one. So the arm is
* unambiguous, and a by-name read of every other key is unchanged.
*
* The one spelling of the rule: the producer that resolves the key
* (`getMetaItem` in `@objectstack/metadata-protocol`) asks it, and so does any
* door that must tell the arm from a by-name read — ⛔ never a second regex or
* type test beside it. `type` is the canonical SINGULAR type: a door folds
* `apps` once, at its boundary, before asking.
*/
export function isAppPackageIdKey(type: string, name: string): boolean {
return type === 'app' && MANIFEST_ID_PATTERN.test(name);
}
/**
* ADR-0010 read-side protection envelope — the flags a metadata READ publishes
* alongside the document, all derived in one place.
*
* These are the UN-prefixed, envelope-level counterparts of the `_lock` /
* `_provenance` fields `MetadataProtectionFields` splices into the document
* itself: the document stores `_lock`, and the read RESOLVES it — joined with
* the write doors' locked-packaged-base verdict — into `lock` plus the three
* `editable` / `deletable` / `resettable` verdicts Studio renders affordances
* from (ADR-0010 §5), so no consumer re-implements the lock algebra.
*
* Shared by {@link GetMetaItemResponseSchema} and
* {@link GetMetaItemLayeredResponseSchema} — both are produced by the SAME
* derivation in `metadata-protocol` (`resolveLockState` joined with
* `packagedBaseRefusal`), so a mixin is what keeps the two declarations from
* drifting apart key by key. Module-local on purpose: it
* is a shape these two responses share, not a new public vocabulary.
*
* Every key is optional HERE and tightened per-response where the producer
* guarantees presence — see each schema's note. Optionality is measured, not
* assumed: the six `lockReason` … `packageVersion` keys are spread only when
* `!== undefined` (they read off `_`-prefixed document fields that are
* themselves optional), so they are conditional on EVERY path.
*/
const MetadataProtectionEnvelopeFields = {
lock: MetadataLockSchema.optional().describe(
'Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; '
+ '`no-overlay` / `no-delete` / `full` mean the write doors refuse the '
+ 'corresponding write with 403. Joins two refusals: the document\'s own '
+ '`_lock` (`ITEM_LOCKED`; the packaged artifact wins over any overlay), '
+ 'and the locked packaged base — an item a code package ships, on a type with '
+ 'no per-org overlay channel (`NOT_OVERRIDABLE`, or `ITEM_LOCKED` when the '
+ 'write names the read-only package).',
),
lockReason: z.string().optional().describe(
'Human-readable explanation shown next to a refused write. Present only when '
+ 'the resolved item declares `_lockReason`.',
),
lockSource: MetadataLockSourceSchema.optional().describe(
'Which layer asserted the lock. Present only when the resolved item declares '
+ '`_lockSource`.',
),
lockDocsUrl: z.string().optional().describe(
'Documentation link surfaced beside `lockReason`. Present only when the '
+ 'resolved item declares `_lockDocsUrl`.',
),
provenance: MetadataProvenanceSchema.optional().describe(
'Where the item came from (package | org | env-forced). Present only when the '
+ 'resolved item declares `_provenance`.',
),
packageId: z.string().optional().describe(
'Owning package machine id. Present only when the resolved item declares '
+ '`_packageId`.',
),
packageVersion: z.string().optional().describe(
'Owning package version. Present only when the resolved item declares '
+ '`_packageVersion`.',
),
editable: z.boolean().optional().describe(
'Whether an overlay write is permitted — false iff `lock` is `no-overlay` or '
+ '`full`. A derived verdict: do not recompute it from `lock` client-side.',
),
deletable: z.boolean().optional().describe(
'Whether deleting the overlay is permitted — false iff `lock` is `no-delete` '
+ 'or `full`.',
),
resettable: z.boolean().optional().describe(
'Whether the item can be reset to its packaged default — true iff it is '
+ 'artifact-backed, i.e. there is a baseline to reset TO.',
),
} as const;
/**
* Get Metadata Item Response
*
* Describes the FULL body `GET /api/v1/meta/:type/:name` can return, not the
* three-key subset it used to claim (#5950 — the read-side twin of the write-side
* gap #5745 closed on {@link SaveMetaItemResponseSchema}).
*
* The declaration stopped at `{ type, name, item }` while the uncached branch
* served ten more keys: the ADR-0010 protection envelope, spread onto the wire
* verbatim by the REST layer (`rest-server.ts`'s `translateMetaEnvelope` does
* `{ ...envelope, item }`). `lock` in particular is the read half of the ADR-0008
* optimistic-concurrency story the write half already declares — so an SDK caller
* typed against this response could not see it, and reading it meant a cast, the
* consumer-side tolerance this repo rejects by Prime Directive #12.
*
* **Why every protection key is optional, measured rather than assumed.** This
* route reaches a body by two branches and they publish different amounts:
*
* - **cached** (`getMetaItemCached`, THE DEFAULT — `enableCache` defaults to
* `true`): the REST layer rebuilds the envelope as `{ type, name, item }` and
* deliberately resolves NO lock — it is the fast published-value path and
* never consults the lock resolver (`rest-server.ts`, the `cachedEnvelope`
* note). All ten keys are ABSENT.
* - **uncached** (`getMetaItem`): `lock`, `editable`, `deletable` and
* `resettable` are always set; the other six appear only when the resolved
* document carries the corresponding `_`-prefixed field.
*
* So `optional` here means "this deployment/branch did not publish it", NEVER
* "unlocked" — a consumer that needs the OCC carriers must read the uncached
* path and must not read absence as `lock: 'none'`. Declaring them required
* would make the default deployment's own response fail its own contract, which
* is the #5563 defect in mirror image.
*
* ⚠️ This is a DECLARATION change only — zero runtime behaviour is altered. That
* lock presence depends on a server-side cache setting is a separate, larger
* question (#5950 says so explicitly) and is deliberately NOT decided here.
*
* [#10235] `sortability` is the one envelope key that is NOT part of the
* protection family: the per-column sortability projection, present exactly
* when the served type is `object` (every branch — cached included, unlike the
* protection keys — because it is computed from the served document itself,
* never from the lock resolver). See `sortability.zod.ts` for the category
* set and the consumer contract.
*
* [#22114] `version` is the READ half of the ADR-0008 optimistic-concurrency
* chain: the token a client sends back as `If-Match`, served by the read it
* edits from. ADR-0008 says clients pass `If-Match: <hash>`, and a client can
* only send a token it was served — before this member only a save receipt
* served one, so the first save after a load could not be pinned and two
* editors who each loaded and saved once overwrote each other. It is the
* keyed token of the stored row a SAVE to this item would compare against —
* the same producer and the same row as {@link SaveMetaItemResponseSchema}'s
* `version`, so a read after a save serves the receipt's token byte for byte.
* Like the protection keys it is published by the uncached branch only (the
* cached branch never consults the store's rows), and it is the one key whose
* `null` is a statement: no stored row is there, so the next save is a create.
*
* [#23316] `registration` is the automation engine's verdict on a `flow`
* ({@link FlowRegistrationSchema}), an envelope sibling of `lock` for the same
* reason `lock` is one: it is a fact about the item that the document cannot
* carry. The stored `status` is the author's declared state, and a flow the
* engine refused at boot used to be served as `status: 'active'` with nothing
* beside it. Unlike the protection keys it is published by BOTH branches:
* the cached branch is the default door, so it carries the verdict too, read
* live and kept out of the ETag ({@link GetMetaItemCachedResponseSchema}). It
* is absent wherever no automation engine answered, so absence is never read
* as `registered`.
*
* [#24298] `draftChanges` is a pending draft's per-key provenance: for each
* changed key path of the draft body, which authoring surface (`copilot`,
* `studio`, `api`) last changed it, under which actor, and when. It is what
* lets Studio's properties mark a field or a view setting the AI copilot
* changed, with the draft chip and the source line objectui#12725 rules
* (「由 AI 副驾在 2 分钟前作为草稿加入」). It carries ADR-0033's provenance
* item, "attribute each AI draft to the acting user + mark it AI-sourced",
* at the grain of a key: `actor` is the acting user and `by: 'copilot'` the
* AI-sourced mark, two members because one user makes both kinds of edit.
* Four measured choices:
*
* - **On this read, not on the `_drafts` row.** The reader is Studio's
* properties, and they read the item they edit through the strict draft
* read (`?state=draft`, the client's `getDraft`); the `_drafts` list is the
* change badge's and the changes drawer's read, one row per item. Here the
* provenance comes from the same stored row as the body and `version` it
* sits beside, so a mark can never describe a body the editor has not
* loaded.
* - **Beside `item`, never inside it**, like `registration`: a fact about the
* stored row the document cannot carry. Inside the body it would be one more
* read decoration every GET → edit → PUT round trip must strip
* (`METADATA_READ_DECORATIONS`), and `provenance` is already the ADR-0010
* envelope key (where the item came from), hence the distinct name.
* - **A path is an array of keys, and an array value is one leaf.** Keys may
* contain dots (a permission set's `fields` map is keyed `object.field`), so
* a dotted string would be ambiguous; and an index (`columns[3]`) moves when
* a column is inserted or reordered, so the path stops at `columns` — the
* granularity the ruling names, "a field or a view setting". The ruled
* properties artboard also marks ELEMENTS of an array setting: a kanban
* card's two copilot-added fields, under the line
* 「读者」「应还日期」两项由 AI 副驾在 2 分钟前作为草稿加入. The reader finds
* which elements by comparing the draft's array with the published body's,
* and the array's entry says who last changed it and when. Attribution is
* per array, never per element.
* - **Optional, never defaulted to `[]`.** Absent means "not recorded" (a
* server with no writer, or a draft row saved before one); an empty-array
* default would answer "nothing changed" for exactly those rows.
*
* The history log's per-event `source` (see `HistoryMetaItemResponseSchema`)
* is not this vocabulary: it is an open label of the code path that wrote the
* event, not the surface a person or the copilot used, and it is per write,
* not per key.
*/
export const GetMetaItemResponseSchema = lazySchema(() => z.object({
type: z.string().describe('Metadata type name'),
name: z.string().describe('Item name'),
item: z.unknown().describe('Metadata item definition'),
sortability: ObjectSortabilitySchema.optional().describe(
'Per-column sortability projection — present exactly when `type` '
+ 'is `object`, on every serving branch. Computed at serve time from the '
+ 'served document via the spec\'s own storage predicates; consumers render '
+ 'sort affordances from this signal and never re-derive it from field '
+ '`type`. See `ObjectSortabilitySchema` for the closed category set.',
),
...MetadataProtectionEnvelopeFields,
version: z.string().nullable().optional().describe(
'ADR-0008 version token of the STORED row a save to this item compares '
+ 'against, at this read\'s scope (the environment\'s partition and '
+ '`?package=`) and lifecycle (`?state=draft` reads the draft row, the plain '
+ 'read the active row). The same keyed token a save receipt\'s `version` '
+ 'serves for that row: send it back as the `If-Match` request header on '
+ '`PUT /meta/:type/:name` (with `?mode=draft` for the draft) and a '
+ 'concurrent edit is refused 409 `METADATA_CONFLICT` instead of '
+ 'overwritten. `null` when no stored row is there — an item served from '
+ 'code or a package artifact, or from a row in another scope (a '
+ 'package-less row a package read falls back to) — so the next save '
+ 'creates one: pin it with '
+ '`If-None-Match: *`. A `null` beside `editable: false` is no such '
+ 'invitation: that item takes no save at this address, so no pin is '
+ 'prescribed for it. ABSENT on the cached published-value branch and on a '
+ '`?preview=draft` read, which publish no version: absence means "not '
+ 'published here", never "no row". Opaque — echo it verbatim, never parse '
+ 'it.',
),
registration: FlowRegistrationSchema.optional().describe(
'The automation engine\'s verdict on this flow (see `FlowRegistrationSchema`): `registered`, or '
+ '`refused` with the engine\'s `reason` verbatim, or `unregistered`. Present only on a `flow` read '
+ 'answered by a process that runs an automation service; absent otherwise, and absence means no '
+ 'engine judged it, never `registered`. Unlike the protection keys it is published by the cached '
+ 'branch too, read live and never part of the ETag, so a cached read that carries it is never '
+ 'answered 304; it is absent on a draft read, whose pending body no engine holds. The verdict is '
+ 'the answering process\'s engine. Beside `item`, never inside it: the item\'s `status` stays what '
+ 'its author declared.',
),
draftChanges: z.array(z.object({
path: z.array(z.string().min(1)).describe(
'The changed key path inside `item`: one object key per segment, from the body root down to the '
+ 'deepest key whose value the write changed, added or removed. `["fields", "renewal_count"]` is a '
+ 'field the write added; `["fields", "renewal_count", "label"]` is one property of it. A segment '
+ 'is the key exactly as stored, dots included (a permission set\'s `fields` map is keyed '
+ '`object.field`), so a path is never one dotted string. An array is one value: its path ends at '
+ 'its key (`["columns"]`) and never carries an index, which an insert or a reorder would shift. '
+ 'Which elements of an array the draft added or dropped is read by comparing it with the '
+ 'published body\'s array; the entry answers only who last changed the array as a whole. '
+ '`[]` is the whole body: the write that created the draft of an item with no published body.',
),
by: z.enum(['copilot', 'studio', 'api']).describe(
'The authoring surface of the write that last changed this path. Closed: `copilot` is an AI '
+ 'assistant\'s tool writing on a person\'s behalf (Studio\'s AI copilot); `studio` is a person '
+ 'editing in Studio; `api` is any other caller of the save door. It names the surface, not the '
+ 'person: a copilot write and a Studio write of the same user carry the same `actor`.',
),
actor: z.string().nullable().describe(
'The identity that write was recorded under: the save\'s `actor`, the value its history event '
+ 'carries. `null` when the write recorded none, never a sentinel string.',
),
at: z.string().datetime().describe(
'When that write happened: an ISO-8601 instant in UTC, written with `Z` (the form '
+ '`toISOString()` produces). An offset or a local time is refused.',
),
}).describe('One changed key path of the pending draft, and the last write that changed it.'))
.optional().describe(
'Per-key provenance of the pending draft: which authoring surface last changed each key path of '
+ '`item`, under which actor, and when. No server writes the key yet: until the engine\'s draft '
+ 'save records it, every draft read is one whose saves recorded none, and the key is absent. '
+ 'The contract a writer must meet: it compares each draft save with the body that save replaced '
+ '(the previous draft, or the published body for the draft\'s first save); it keeps the entries '
+ 'from the first recorded save until the draft is published or discarded, which remove the draft '
+ 'row and the entries with it; and it keeps no two entries on one path, replacing every entry at '
+ 'or under a path a save changes, so the last write to change a key is the entry whose `path` is '
+ 'the longest prefix of that key\'s path, and entries under a key\'s path are changes inside it. '
+ 'The entries name which writes changed what; they are not a list of every difference from the '
+ 'published body (compare the two bodies for that). Served only on a `?state=draft` read that '
+ 'answers a stored draft row. ABSENT on the active read, the cached branch and a '
+ '`?preview=draft` read, and for a draft row whose saves recorded none: absence means "not '
+ 'recorded", never "no changes".',
),
}));
/**
* Get Metadata Item — LAYERED Request
*
* Request shape for `GET /api/v1/meta/:type/:name/layers` (the
* `getMetaItemLayered` protocol method). Mirrors the implementation's
* parameter type in `@objectstack/metadata-protocol` member for member — a
* declared-surface catch-up, not a new capability: the verb and every member
* here already ship and are enforced.
*/
export const GetMetaItemLayeredRequestSchema = lazySchema(() => z.object({
type: z.string().describe('Metadata type name'),
name: z.string().describe('Item name'),
packageId: z.string().optional().describe(
'Optional package ID — scopes the `code` layer so a same-name collision '
+ 'resolves to the requested package\'s artifact (ADR-0048).',
),
}));
/**
* Get Metadata Item — LAYERED Response
*
* The body of `GET /api/v1/meta/:type/:name/layers`: a three-layer diagnostic
* projection that shows the packaged baseline, the tenant's customization row
* and the merged result SIDE BY SIDE, which is what drives Studio's
* "code default vs override vs effective" comparison tabs.
*
* **Why this is a separate schema on a separate path** (#5882, ruled B by the
* maintainer 2026-08-06). This projection used to be reached by putting
* `?layers=true` on the ordinary read, so one route answered two unrelated
* resource representations while `packages/spec` declared only one of them —
* anything generating a client from the route table (SDK annotations, codegen,
* an AI-written integration) produced a parser that was simply wrong for the
* flagged call. Collapsing the three layers into
* {@link GetMetaItemResponseSchema}'s single `item` was never an option: seeing
* the layers apart IS the diagnostic. The rejected alternative was teaching the
* route declaration to express "two shapes, chosen by query flag"; that adds a
* new primitive every future tool must understand, and conditional response
* selection is precisely where codegen and AI clients go wrong. One path, one
* response shape — so the projection got its own path.
*
* The `?layers=` flag still answers this same body during its deprecation
* window, marked with `Deprecation` / `Link` response headers.
*
* **Required vs optional, measured against the producer.** Unlike the ordinary
* read there is exactly ONE producer path here (`getMetaItemLayered`; the
* layered view deliberately skips the cache), so the four resolved verdicts
* `lock` / `editable` / `deletable` / `resettable` are ALWAYS set and are
* required below. The six conditional protection keys stay optional for the
* same reason they are optional on the ordinary read.
*/
export const GetMetaItemLayeredResponseSchema = lazySchema(() => z.object({
type: z.string().describe('Metadata type name (canonical singular)'),
name: z.string().describe('Item name'),
code: z.unknown().describe(
'LAYER 1 — the packaged artifact baseline exactly as shipped, before any '
+ 'tenant customization. `null` when no artifact ships this item (it exists '
+ 'only as an overlay).',
),
overlay: z.unknown().describe(
'LAYER 2 — the stored customization row ALONE, not merged with `code`. '
+ '`null` when the environment has not customized the item.',
),
overlayScope: z.enum(['env']).nullable().describe(
'Which scope the `overlay` row was read from: `env`, the environment layer — '
+ 'the only layer a read serves (ADR-0131 D6). `null` exactly when `overlay` is null.',
),
effective: z.unknown().describe(
'LAYER 3 — the merged result, i.e. the value an ordinary '
+ '`GET /meta/:type/:name` would return under `item`. `null` when the item '
+ 'resolves to nothing at all.',
),
_diagnostics: MetadataValidationResultSchema.optional().describe(
'Load-time spec-validation verdict for `effective`, so the Studio edit page '
+ 'can raise invalid-metadata banners and inline field errors without a '
+ 'second round trip. ABSENT for metadata types that register no Zod schema '
+ '(function / service / router) — absence means "no opinion", never "valid".',
),
...MetadataProtectionEnvelopeFields,
// The four resolved verdicts are unconditional on this single-producer path —
// tightened from the mixin's optional baseline. See the note above.
lock: MetadataLockSchema.describe(
'Resolved lock verdict (ADR-0010 §3.3), artifact winning over overlay. Always '
+ 'present on this path.',
),
editable: z.boolean().describe('Whether an overlay write is permitted. Always present on this path.'),
deletable: z.boolean().describe('Whether deleting the overlay is permitted. Always present on this path.'),
resettable: z.boolean().describe('Whether the item can be reset to its packaged default. Always present on this path.'),
}));
/**
* One finding from the #4463 runtime authoring gate — the fourth door.
*
* The gate runs the SHARED author-time rule registry (`@objectstack/lint`'s
* `AUTHORING_RULES`, the same table `os validate` / `os build` / `os lint` run)
* over a body about to go `active`, and partitions its findings by severity.
* The `error` half becomes the 422 `invalid_metadata` envelope's `issues[]`;
* the rest are ADVISORY — they do not block the write, and #4463 D3 decided
* they ride the 2xx response instead of being discarded.
*
* This is that ONE element shape, declared once and used by both halves, which
* is the whole point of D3's "reuse the Zod envelope": a consumer reads the
* same six keys whether the verdict arrived on a refusal or on a success.
* `@objectstack/metadata-protocol` re-exports this type as its
* `RuntimeAuthoringIssue` rather than declaring a second interface (#4717).
*/
export const RuntimeAuthoringIssueSchema = lazySchema(() => z.object({
rule: z.string().describe(
'Stable diagnostic rule id (`flow-multi-write-unfiltered`, '
+ '`approval-expression-invalid`, …). Machine-readable and stable across '
+ 'releases — the key a renderer groups or suppresses by.',
),
path: z.string().describe(
'Config path inside the SUBMITTED body (`flows[0].nodes[1].config.multi`), '
+ 'so an editor can jump to the offending key. May be empty when the '
+ 'finding is about the item as a whole. The TOP-LEVEL collection entry '
+ 'follows one rule. The gate evaluates against a private per-write '
+ 'snapshot whose indexes no caller can resolve; a collection-resident '
+ 'write type is one whose collection that snapshot fills with the '
+ 'tenant\'s other stored items beside the written one, and there the '
+ 'entry is keyed by NAME, never by an array index — for example '
+ '`objects.acme_invoice.sharingModel` (an `object` write) or '
+ '`datasets.acme_invoice_metrics.dimensions[0].field` (a `dataset` '
+ 'write). Every other write type is the sole member of its own '
+ 'collection in that snapshot, so its `[0]` IS the written item and '
+ 'stays positional (for example `flows[0]...`). Which write types are '
+ 'collection-resident is derived, never listed: `@objectstack/lint`\'s '
+ 'runtime gate computes the set (`NAME_KEYED_STACK_KEYS`) as the '
+ 'collections it carries as resolution context that a gated write type '
+ 'also lands in, so it changes with the gate, not with this text. An '
+ 'entry whose name cannot be spliced into a dotted path keeps its index. '
+ 'Nested positions inside one named item stay positional '
+ '(`objects.acme_invoice.indexes[1]`): they index the author\'s own '
+ 'document.',
),
where: z.string().describe(
'Human-readable location — `flow "leave_approval" · node "approve"`. Prose '
+ 'for a person; use `path` for anything mechanical.',
),
message: z.string().describe('What is wrong, in the rule author\'s own words.'),
hint: z.string().describe('How to fix it.'),
severity: z.enum(['error', 'warning', 'info']).describe(
'How the gate treated this finding. `error` means the write was REFUSED '
+ '(these appear on the 422, never on a 2xx); `warning` / `info` are '
+ 'advisory — the write succeeded and the finding is FYI.',
),
}));
/**
* Save Metadata Item Request
* Create or update a metadata item — the request shape for
* `PUT /api/v1/meta/:type/:name` (the `saveMetaItem` protocol method).
*
* Declared member for member against the implementation's parameter type in
* `@objectstack/metadata-protocol` and the REST save door's actual sends — a
* declared-surface catch-up, not a new capability (#12004, the maintainer-ruled
* pattern of commit cccbe51bf, 2026-08-22 option B, carried one door over
* exactly as #11679/PR #12003 carried it to the reset twin). `saveMetaItem`
* is a REQUIRED protocol member, so this was the sharpest instance of the
* request-shape gap: the schema declared 3 of the ~11 members the door
* sends, and the call-site literal had to stay behind an `as any` cast
* (removing it surfaced `TS2353` on the undeclared keys — pure
* request-shape smuggling, never member-existence feature detection).
* Every member below already ships and is read and enforced by the
* implementation.
*
* `name` carries the enforced item-name grammar (commit 311433f6b — lowercase
* snake_case segments, optionally dot-qualified; `shared/identifiers.zod.ts`
* is the single source). The implementation refuses an off-grammar name at
* the door with `400 INVALID_REQUEST`, so declared = enforced. The read and
* delete request shapes deliberately stay `z.string()`: pre-grammar residue
* rows must remain listable and clearable.
*
* Two members the implementation's parameter type family carries are
* deliberately NOT declared:
*
* - `environmentId` — the TRANSPORT-level multi-kernel routing key, OUT of
* protocol request shapes by the maintainer ruling of 2026-08-18 (commit 2a29caa53):
* `resolveProtocol(environmentId)` has already selected the target kernel
* before this method is entered, and `packages/rest` layers that one
* member on top of the declared shape via its `TransportScopedMetaRequest`
* wrapper, which is where a routing key belongs.
* - `source` — write-provenance for the history/audit rows
* (`'protocol.saveMetaItem'` by default). The implementation declares it,
* but NO door sends it: its only producer is the implementation's own
* internal `migrateStoredMetadata` call (`'migrate-stored'`), which does
* not travel through this contract. The publish-door precedent (#11426)
* leaves such a member undeclared until a producer on THIS contract pulls
* it — declaring it here would advertise a wire-authorable provenance
* channel the REST layer deliberately never reads.
*
* `writeFace` IS declared, and the distinction with `source` is the point:
* both are server-stated, but `writeFace` is sent by three doors (the two
* metadata write doors and the external-table import) and the
* duplicate-package internal call — four real producers on this parameter
* — and the implementation branches its refusal envelopes on it. See the
* member's own doc for why declaring it does not make it client-authorable.
*/
export const SaveMetaItemRequestSchema = lazySchema(() => z.object({
type: z.string().describe('Metadata type name'),
name: MetadataItemNameSchema.describe(
'Item name — lowercase snake_case segments, optionally dot-qualified '
+ '(`crm_lead`, `crm_lead.pipeline`). Slash-compound names are refused at '
+ 'the publish door.',
),
item: z.unknown().describe('Metadata item definition'),
parentVersion: z.string().nullable().optional().describe(
'ADR-0008 optimistic-concurrency pin: the version token the caller '
+ 'believes is current (on the REST door, the `If-Match` request '
+ 'header; the item read\'s `version` and a save receipt\'s `version` '
+ 'serve it). Present as a string, a concurrent edit is reported as a 409 '
+ 'conflict instead of silently overwritten. ⚠️ `null` is NOT the same '
+ 'as absent: a present `null` asserts "no current row of this '
+ 'lifecycle" — the first-write pin, refused 409 when a row already '
+ 'exists (on the REST door, `If-None-Match: *`; that header takes `*` '
+ 'alone and never beside `If-Match`, and any other spelling is refused '
+ '400) — while an ABSENT key is unpinned: the implementation adopts '
+ 'the current row\'s hash as the parent (last-write-wins). Nullable '
+ 'because that is the implementation\'s parameter type, and unlike the '
+ 'reset twin (which folds a present `null` back to the current hash) '
+ 'this verb passes `null` through to the repository\'s conflict check '
+ 'unchanged.',
),
actor: z.string().optional().describe(
'Identity recorded on the write\'s history event (`recorded_by`, a '
+ 'lookup into `sys_user`) and audit row. On the REST door this is the '
+ 'request\'s authenticated identity (one producer) — never a '
+ 'caller-supplied header. Absent, the event is recorded actor-less '
+ '(null), deliberately not attributed to "system".',
),
force: z.boolean().optional().describe(
'Destructive-change acknowledgement (`?force=true` on the REST door): '
+ 'skips the safety diff that refuses an `object` save whose body drops '
+ 'fields or narrows types the stored item still carries (409 with the '
+ 'findings otherwise). Only `object` saves reach that diff, so the '
+ 'flag is inert for every other type. Absent = the guard runs.',
),
mode: z.enum(['draft', 'publish']).optional().describe(
'Per-item lifecycle (ADR-0005 drafts): `draft` stages the body as a '
+ 'pending draft overlay (`?mode=draft` on the REST door; the publish '
+ 'door promotes it later); `publish` or ABSENT writes straight to the '
+ 'live `active` row — the legacy default, kept so callers that '
+ 'predate the draft/publish split keep working. Any value other than '
+ '`draft` is read as `publish`.',
),
packageId: z.string().nullable().optional().describe(
'ADR-0048 — the software package to bind the saved row to '
+ '(`sys_metadata.package_id`; `?package=<id>` on the REST door, where '
+ '`all` and an empty value name no package). Set when authoring inside '
+ 'a Studio package workspace; a named read-only base package is '
+ 'refused. On create the row is stamped with this id; on update an '
+ 'existing binding is preserved, never silently re-bound. Absent or '
+ '`null` names no package. An `active` save then writes the row of the '
+ 'one package the item\'s active rows are bound to, as a save naming '
+ 'that package does, is refused `409 RESOURCE_CONFLICT` when they are '
+ 'bound to two or more, and with none writes the env-local overlay (no '
+ 'package stamp); a `draft` save writes the draft of the one package '
+ 'the item\'s active rows are bound to (a pending change stays in the '
+ 'package it edits), else its package-unbound draft, adopted into that '
+ 'package — the draft the per-item publish naming no package promotes. '
+ 'For an item never published into a package it is the package-unbound '
+ 'draft, else the one package\'s draft, and with no draft at all the save '
+ 'creates the package-unbound draft. It is refused `409 RESOURCE_CONFLICT` '
+ 'when the active rows (or, with none and no package-unbound draft, the '
+ 'drafts) are bound to two or more packages, as that publish is (see '
+ '`PublishMetaItemRequestSchema.packageId`). The row written is also the one the parent-version '
+ 'resolution reads: an unpinned save takes its version as the parent '
+ '(last-write-wins), a pinned one is judged against it, and the item '
+ 'read at the same scope serves it as `version`.',
),
writeFace: z.enum(['package-duplicate', 'meta-envelope', 'meta-dispatch', 'external-import']).optional().describe(
'Which write door a refusal is being rendered FOR — stated by the '
+ 'SERVER, never by a remote caller: every door builds this request '
+ 'field by field and never spreads a request body into it, so there is '
+ 'no path for a client to smuggle a face in, and a face arriving in a '
+ 'wire body is simply never read. Two refusals branch on it, for '
+ 'different questions: the 409 destructive-change remedy names the '
+ 'acknowledgement mechanism that actually exists on the refusing door '
+ '(`?force=true` on the REST doors; the dispatcher, the '
+ 'duplicate-package door and the external-table import have none), '
+ 'and the 422 invalid-metadata '
+ 'message adapts to whether a structured `issues[]` channel reaches '
+ 'the consumer beside it. Absent = the conservative default wording.',
),
}));
/**
* Save Metadata Item Response
*
* Describes the FULL body `PUT /api/v1/meta/:type/:name` returns, not a subset
* of it (#5745, settled by the #5563 maintainer ruling "补齐 spec 字段"). The
* declaration previously stopped at `{ success, message }`, so a `.parse()` of
* a real response silently STRIPPED `version` / `seq` / `state` — and
* `SaveMetaItemResponse` could not even name them at the type level. `version`
* in particular is the token the ADR-0008 optimistic-concurrency chain already
* runs on (echo it back as `If-Match` on the next write to get a 409 instead of
* a lost update), so leaving it undeclared meant the OCC carrier existed on the
* wire with no contract behind it.
*
* Presence was measured against `origin/main`, not assumed: the sole producer is
* `ObjectStackProtocolImplementation.saveMetaItem`, whose single success return
* is the repository write path, and the REST route hands that object to
* `res.json()` verbatim. That path always sets `version` / `seq` / `state`, so
* the three are REQUIRED here; `projectionApplied` is conditional on an
* ADR-0094 mutation projector being registered for the type, so it alone is
* optional. (A second, receipt-less legacy return used to exist and would have
* forced all three to be optional — it was proved unreachable and deleted in
* #5264 / PR #5782, which is why `required` is safe to state.)
*/
export const SaveMetaItemResponseSchema = lazySchema(() => z.object({
success: z.boolean(),
version: z.string().describe(
'Version token of the row this write left, and the token the ADR-0008 '
+ 'optimistic-concurrency chain runs on: send it back as the `If-Match` '
+ 'request header on the next write to that item and a concurrent edit is '
+ 'refused 409 `METADATA_CONFLICT` instead of silently overwritten. The item '
+ 'read serves the same token for the same row (`GetMetaItemResponseSchema.version`). '
+ 'A keyed digest of the stored content hash, never the hash itself (under '
+ 'the registered crypto provider\'s key, or a process-scoped key while none '
+ 'is registered). Opaque to callers — echo it verbatim, never parse it. '
+ 'Currently emitted as `hmac-sha256:` plus 64 hex characters, but the '
+ 'format is not part of this contract.',
),