-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathspec-changes.json
More file actions
2105 lines (2105 loc) · 433 KB
/
Copy pathspec-changes.json
File metadata and controls
2105 lines (2105 loc) · 433 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
{
"$comment": "GENERATED (ADR-0087 D4) — do not edit. Regenerate with: pnpm --filter @objectstack/spec gen:spec-changes. A projection of the D2 conversion table + D3 migration chain; the upgrade guide and the MCP spec_changes tool derive from this same data.",
"protocolVersion": "17.0.0",
"supportFloor": 10,
"migrateCommand": "objectstack migrate meta --from <N> (N >= 10)",
"aggregate": {
"from": 10,
"to": 17,
"added": [],
"converted": [
{
"surface": "flow.node.type",
"to": "flow callout node types 'http_request' / 'http_call' / 'webhook' → 'http'",
"conversionId": "flow-node-http-callout-rename",
"toMajor": 11
},
{
"surface": "page.kind",
"to": "page kind 'jsx' → 'html' (ADR-0080 canonical spelling)",
"conversionId": "page-kind-jsx-to-html",
"toMajor": 11
},
{
"surface": "flow.node.config.filter",
"to": "CRUD flow-node config key 'filters' → 'filter'",
"conversionId": "flow-node-crud-filter-alias",
"toMajor": 11
},
{
"surface": "object.compactLayout",
"to": "object key 'compactLayout' → 'highlightFields' (ADR-0085 semantic roles)",
"conversionId": "object-compactLayout-to-highlightFields",
"toMajor": 11
},
{
"surface": "stack.roles",
"to": "stack collection key 'roles' → 'positions' (ADR-0090 D3)",
"conversionId": "stack-roles-to-positions",
"toMajor": 13
},
{
"surface": "object.sharingModel",
"to": "object sharingModel 'read' → 'public_read', 'read_write' → 'public_read_write' (ADR-0090 D4)",
"conversionId": "owd-legacy-read-aliases",
"toMajor": 13
},
{
"surface": "sharingRule.sharedWith.type",
"to": "sharing-rule recipient type 'role' → 'position' (ADR-0090 D3)",
"conversionId": "sharing-recipient-role-to-position",
"toMajor": 13
},
{
"surface": "book.audience",
"to": "book audience gated arm '{ profile }' → '{ permissionSet }' (ADR-0090 D2/D9)",
"conversionId": "book-audience-profile-to-permission-set",
"toMajor": 14
},
{
"surface": "view.form.visibleOn",
"to": "view form section/field key 'visibleOn' → 'visibleWhen' (ADR-0089)",
"conversionId": "view-visibleOn-to-visibleWhen",
"toMajor": 15
},
{
"surface": "page.component.visibility",
"to": "page component key 'visibility' → 'visibleWhen' (ADR-0089)",
"conversionId": "page-component-visibility-to-visibleWhen",
"toMajor": 15
},
{
"surface": "action.execute",
"to": "action key 'execute' → 'target' (the deprecated handler alias, #3713)",
"conversionId": "action-execute-to-target",
"toMajor": 17
},
{
"surface": "field.conditionalRequired",
"to": "field key 'conditionalRequired' → 'requiredWhen' (the deprecated predicate alias, #3754)",
"conversionId": "field-conditionalRequired-to-requiredWhen",
"toMajor": 17
},
{
"surface": "agent.tools",
"to": "agent key 'tools' removed — declare capability in a skill (ADR-0064, #3894)",
"conversionId": "agent-tools-to-skills",
"toMajor": 17
},
{
"surface": "sharingRule.accessLevel",
"to": "sharing-rule accessLevel 'full' → 'edit' (#3865 — `full` never granted more than `edit`)",
"conversionId": "sharing-rule-access-level-full-to-edit",
"toMajor": 17
},
{
"surface": "flow.node.config.objectName",
"to": "CRUD flow-node config key 'object' → 'objectName' (#3796 — `readAliasedConfig` shim graduation)",
"conversionId": "flow-node-crud-object-alias",
"toMajor": 17
},
{
"surface": "flow.node.notify.config",
"to": "notify flow-node config keys 'to' → 'recipients', 'subject' → 'title', 'body' → 'message', 'url' → 'actionUrl' (#3796), and nested 'source: {object, id}' → 'sourceObject' / 'sourceId' (#4045)",
"conversionId": "flow-node-notify-config-aliases",
"toMajor": 17
},
{
"surface": "flow.node.wait.waitEventConfig",
"to": "wait flow-node loose config keys → the declared `waitEventConfig` block: 'eventType', 'timerDuration'/'duration' → 'timerDuration', 'signalName'/'signal' → 'signalName', 'timeoutMs' (#4045)",
"conversionId": "flow-node-wait-event-config-lift",
"toMajor": 17
},
{
"surface": "flow.node.connector_action.connectorConfig",
"to": "connector_action flow-node loose config keys 'connectorId' / 'actionId' / 'input' → the declared `connectorConfig` block (#4045)",
"conversionId": "flow-node-connector-config-lift",
"toMajor": 17
},
{
"surface": "flow.node.map.config.flowName",
"to": "map flow-node config key 'flow' → 'flowName' (#4045 — undeclared executor fallback graduation)",
"conversionId": "flow-node-map-flow-alias",
"toMajor": 17
},
{
"surface": "flow.node.subflow.config.flowName",
"to": "subflow flow-node config key 'flow' → 'flowName' (#4278 — undeclared executor fallback graduation)",
"conversionId": "flow-node-subflow-flow-alias",
"toMajor": 17
},
{
"surface": "flow.node.script.config",
"to": "script flow-node config keys 'functionName' → 'function', 'input' → 'inputs' (#3796)",
"conversionId": "flow-node-script-config-aliases",
"toMajor": 17
},
{
"surface": "permission.rowLevelSecurity.priority",
"to": "RLS-policy key 'priority' removed (#3896 audit — policies OR-combine, so the promised conflict-resolution semantics cannot exist; dropping it changes no outcome)",
"conversionId": "permission-rls-priority-removed",
"toMajor": 17
},
{
"surface": "tool.category / tool.permissions / tool.active / tool.builtIn",
"to": "tool keys 'category'/'permissions'/'active'/'builtIn' removed (#3896 close-out — authorable and inert; permissions gated nothing, active:false withdrew nothing)",
"conversionId": "tool-inert-authoring-keys-removed",
"toMajor": 17
},
{
"surface": "app.version / app.aria / app.objects / app.apis / app.sharing / app.embed / app.mobileNavigation / app.contextSelectors.includeAll / app.contextSelectors.placement / app.homePageId / app.areas.order",
"to": "app keys 'version'/'aria'/'objects'/'apis'/'sharing'/'embed'/'mobileNavigation'/'homePageId' plus contextSelectors 'includeAll'/'placement' and areas 'order' removed (liveness audits #4001, #4509, #4667 — unread or wrongly encoded; sharing/embed declared a public surface no route enforced, mobileNavigation was fully unimplemented, includeAll was deliberately disobeyed because an 'All' row would clear a mandatory scope, homePageId WAS read by objectui's console before v17 but encoded the landing page as an ID cross-reference that silently fell back when it dangled — the landing page is the first nav item (premise corrected in #4709; the retirement stands), and no renderer ever sorted areas)",
"conversionId": "app-dead-authoring-keys-removed",
"toMajor": 17
},
{
"surface": "app.areas.visible / app.areas.requiredPermissions",
"to": "navigation-area keys 'visible'/'requiredPermissions' removed (#4651, ADR-0049 — FAIL-OPEN access gates: no layer ever read them, so a 'hidden' or permission-gated area was served and rendered to every user, while the identically named keys on a navigation ITEM and on the APP are enforced; gate the items inside the area, or gate the app)",
"conversionId": "app-area-fail-open-gates-removed",
"toMajor": 17
},
{
"surface": "object.fields.*.required / object.fields.*.storage.notNull",
"to": "required fields gain explicit 'storage.notNull: true' (ADR-0113 — pre-17 'required' implied the column constraint; post-17 it is only the write contract)",
"conversionId": "field-required-notnull-explicit",
"toMajor": 17
},
{
"surface": "action.shortcut / action.bulkEnabled",
"to": "action keys 'shortcut'/'bulkEnabled' removed (#3896 close-out — no keydown path dispatches shortcuts; the multi-select toolbar reads the view's bulkActions)",
"conversionId": "action-inert-keys-removed",
"toMajor": 17
},
{
"surface": "flow.active / flow.template / flow.nodes[].outputSchema / flow.errorHandling.fallbackNodeId",
"to": "flow keys 'active'/'template', node 'outputSchema' and errorHandling 'fallbackNodeId' removed (#3896 close-out — active:false never stopped a flow; status is the enforced lifecycle)",
"conversionId": "flow-inert-keys-removed",
"toMajor": 17
},
{
"surface": "view.list.responsive / view.list.performance / view.form.defaultSort / view.form.aria",
"to": "view keys removed (#3896 close-out): list 'responsive'/'performance', form 'defaultSort'/'aria' — no renderer read them (list aria/data and form data stay live)",
"conversionId": "view-inert-keys-removed",
"toMajor": 17
},
{
"surface": "view.list.striped / view.list.bordered / view.list.virtualScroll",
"to": "view list keys removed (#7176): 'striped'/'bordered'/'virtualScroll' — every measured reader copied the key forward and none applied it (pass-through-only; ADR-0049 enforce-or-remove)",
"conversionId": "view-list-passthrough-keys-removed",
"toMajor": 17
},
{
"surface": "view.list.exportOptions / view.listViews.*.exportOptions",
"to": "list-view export format 'pdf' removed (#8010 — PDF export was declined as #1301 NOT_PLANNED; ObjectGrid dropped the declared format from the menu with only a runtime console.warn)",
"conversionId": "view-export-options-pdf-removed",
"toMajor": 17
},
{
"surface": "dashboard.aria / dashboard.performance / dashboard.widgets[].performance",
"to": "dashboard keys 'aria'/'performance' and widget 'performance' removed (#3896 close-out — no renderer applied any of them)",
"conversionId": "dashboard-inert-keys-removed",
"toMajor": 17
},
{
"surface": "dashboard.widgets[].responsive",
"to": "dashboard widget key 'responsive' removed (#4876 — no renderer ever applied per-widget breakpoint overrides; page.components[].responsive is unaffected)",
"conversionId": "dashboard-widget-responsive-removed",
"toMajor": 17
},
{
"surface": "dashboard.widgets[].actionUrl / dashboard.widgets[].actionType / dashboard.widgets[].actionIcon / dashboard.widgets[].aria",
"to": "dashboard widget keys 'actionUrl'/'actionType'/'actionIcon' and 'aria' removed (#5010 — no renderer ever drew a per-widget action button, and widget ARIA attributes never reached the DOM; use header.actions[] and the widget title/description)",
"conversionId": "dashboard-widget-action-aria-removed",
"toMajor": 17
},
{
"surface": "dashboard.widgets[].compareTo",
"to": "dashboard widget 'compareTo' converged on the executor's { kind, dimension? } contract (#5011 — the bare strings and { offset: '1y' } rewrite mechanically; other { offset } durations have no faithful target and are reported, not guessed)",
"conversionId": "dashboard-widget-compareto-converged",
"toMajor": 17
},
{
"surface": "agent.knowledge",
"to": "agent key 'knowledge' removed (#3896 close-out — declaring sources/indexes never scoped retrieval; restrict at the knowledge-service level)",
"conversionId": "agent-knowledge-removed",
"toMajor": 17
},
{
"surface": "skill.triggerPhrases",
"to": "skill key 'triggerPhrases' removed (#3896 close-out — activation is triggerConditions + the agent's skills[] allowlist; phrases were a dead-end projection)",
"conversionId": "skill-trigger-phrases-removed",
"toMajor": 17
},
{
"surface": "stack.api.requireAuth",
"to": "stack key 'api.requireAuth' removed — anonymous access is always denied; publish public surfaces by declaration (#3963)",
"conversionId": "stack-api-require-auth-removed",
"toMajor": 17
},
{
"surface": "flow.node.waitEventConfig",
"to": "waitEventConfig keys 'timeoutMs' (→ 'timerDuration', stringified — its only reader used it as the duration) and 'onTimeout' (removed — zero readers, so no timeout ever fired) (#4158)",
"conversionId": "flow-node-wait-timeout-keys-removed",
"toMajor": 17
},
{
"surface": "datasource.readReplicas",
"to": "datasource key 'readReplicas' removed (#4468 — no driver opened a replica connection and no query path splits reads from writes; front replicas behind one endpoint and point `config` at it)",
"conversionId": "datasource-read-replicas-removed",
"toMajor": 17
},
{
"surface": "datasource.capabilities",
"to": "datasource key 'capabilities' removed (#4583 — eleven flags no code read; pushdown comes from the driver's own supports.*, and `readOnly` never made anything read-only)",
"conversionId": "datasource-capabilities-removed",
"toMajor": 17
},
{
"surface": "datasource.retryPolicy / datasource.healthCheck / datasource.external.label / datasource.external.requirePermission",
"to": "datasource keys 'retryPolicy'/'healthCheck' and external 'label'/'requirePermission' removed (#4583 — nothing retried, nothing probed on a schedule, and the federation label/permission were read by nobody)",
"conversionId": "datasource-inert-blocks-removed",
"toMajor": 17
},
{
"surface": "mapping.extractQuery / mapping.errorPolicy / mapping.batchSize",
"to": "mapping keys 'extractQuery'/'errorPolicy'/'batchSize' removed (#4509 — no exporter reads a mapping, error handling belongs to the import request, and the write path sizes its own batches)",
"conversionId": "mapping-inert-keys-removed",
"toMajor": 17
},
{
"surface": "book.translations / book.groups.translations",
"to": "book keys 'translations' (book-level and group-level) removed (#4667 — no resolver read them; the tree endpoint and portal render labels verbatim, so a localized book served its authoring locale to everyone). Localize the docs instead: `doc.translations` is live",
"conversionId": "book-translations-removed",
"toMajor": 17
},
{
"surface": "job.id",
"to": "job key 'id' removed (#4667 — nothing read it; `name` is the job's identity everywhere, so two jobs differing only in `id` were the same job, and the key's own description advertised an override that did not exist)",
"conversionId": "job-id-removed",
"toMajor": 17
},
{
"surface": "translation.validationMessages",
"to": "translation key 'validationMessages' removed (#4667 — no resolver read it, so a translated rule message was stored and never shown; #3778's migration table had been steering retired `errors:` authors into it). Author the message on the rule itself (`object.validations[].message`)",
"conversionId": "translation-validation-messages-removed",
"toMajor": 17
},
{
"surface": "datasource.config",
"to": "datasource config keys → canonical per driver: sqlite 'file'/'database' → 'filename', postgres/mysql 'connectionString' → 'url' and 'user' → 'username', mongo 'uri' → 'url' and 'user' → 'username' (#4456 — driver-factory `??` fallback graduation)",
"conversionId": "datasource-config-driver-key-aliases",
"toMajor": 17
},
{
"surface": "datasource.driver",
"to": "datasource driver id 'mongo' → 'mongodb' — the canonical id both boot hosts, the driver package and the published DRIVER_CATALOG already used (#6345)",
"conversionId": "datasource-driver-mongo-to-mongodb",
"toMajor": 17
},
{
"surface": "flow.node.script.config.actionType / flow.node.script.config.template / flow.node.script.config.recipients / flow.node.script.config.variables / flow.node.script.config.script",
"to": "script flow-node config keys 'actionType' (→ 'function' when it was shorthand for one; otherwise removed — 'email'/'slack' were logger-backed stubs that delivered nothing), plus 'template' / 'recipients' / 'variables' (fed those stubs) and 'script' (inline JS the runtime never executed) (#4343)",
"conversionId": "flow-node-script-branch-keys-removed",
"toMajor": 17
},
{
"surface": "flow.errorHandling.retryDelayMs / flow.node.config.retry.retryDelayMs / job.retryPolicy.maxRetries / job.retryPolicy.backoffMultiplier",
"to": "retry policy unified across job.retryPolicy, try_catch retry and flow.errorHandling: base delay 'retryDelayMs' → 'backoffMs', and the pre-17 job defaults (maxRetries 3, backoffMultiplier 2) written out explicitly now that the merged default is 0 / 1 (#4661, #4964)",
"conversionId": "retry-policy-converged",
"toMajor": 17
},
{
"surface": "object.managedBy",
"to": "object managedBy 'system' → 'system-data' (#3355 — ADR-0103's residual bucket named the engine-owned half v16 had already moved out to `engine-owned`; the rename leaves the name describing what the bucket actually holds: admin/user-writable platform data)",
"conversionId": "object-managed-by-system-to-system-data",
"toMajor": 17
},
{
"surface": "object.enable.trash / object.enable.mru",
"to": "object capability flags 'enable.trash'/'enable.mru' removed (#3207, #2377 close-out — no recycle bin and no MRU tracking ever ran; both default-true flags gated nothing)",
"conversionId": "object-enable-trash-mru-removed",
"toMajor": 17
},
{
"surface": "hook.body.capabilities / action.body.capabilities",
"to": "script-body capability token 'crypto.hash' removed (#4391 — the sandbox never installed ctx.crypto.hash, so the token granted a call that always threw; the CLI inferred it too)",
"conversionId": "hook-body-crypto-hash-removed",
"toMajor": 17
},
{
"surface": "dataset.measures[].aggregate",
"to": "dataset measure aggregates 'array_agg' / 'string_agg' removed (#6188 — no SQL backend compiled them and the v1 dataset runtime refused them by name, so a measure declaring one never produced a value; the measure is dropped, and with it any derived measure left referencing it)",
"conversionId": "dataset-measure-array-string-agg-removed",
"toMajor": 17
},
{
"surface": "connector.rateLimitConfig",
"to": "connector key 'rateLimitConfig' removed (#4911 — no outbound rate-limiting engine exists; the runtime's only token bucket limits INBOUND requests, so every knob here was inert while reading like a configured cap. The whole ConnectorRateLimitConfig shape went with it)",
"conversionId": "connector-rate-limit-config-removed",
"toMajor": 17
},
{
"surface": "connector.fieldMappings[].transform / externalLookup.fieldMappings[].transform",
"to": "field-mapping key 'transform' removed (#5552 — the whole five-member FieldMappingTransform union went with it: no runtime ever executed constant/cast/lookup/javascript/map, and the javascript member advertised dialect=\"js\", retired in #3278. The enforced transform pipeline is the import mapping's string-enum `mapping.fieldMapping[].transform`, which is unaffected)",
"conversionId": "field-mapping-transform-removed",
"toMajor": 17
},
{
"surface": "theme.typography.fontSize / theme.typography.fontWeight / theme.typography.lineHeight / theme.typography.letterSpacing / theme.typography.fontFamily.heading / theme.typography.fontFamily.mono / theme.animation / theme.zIndex",
"to": "theme keys 'typography.fontSize'/'fontWeight'/'lineHeight'/'letterSpacing', 'typography.fontFamily.heading'/'mono', 'animation' and 'zIndex' removed (#5021, ADR-0049 — the engine emitted --font-size-*, --font-weight-*, --line-height-*, --letter-spacing-*, --duration-*, --timing-*, --z-*, --font-heading and --font-mono faithfully, and no first-party component or stylesheet has ever read one. Re-declare any variable you actually consume under customVars, which emits it verbatim)",
"conversionId": "theme-inert-token-scales-removed",
"toMajor": 17
},
{
"surface": "page.component.page-header.description",
"to": "page-header component prop 'description' → 'subtitle' (objectui#3226 — the `subtitle ?? description` fallback retires)",
"conversionId": "page-header-subtitle-alias",
"toMajor": 17
},
{
"surface": "object.indexes[].type / object.indexes[].partial",
"to": "object index keys 'indexes[].type'/'indexes[].partial' removed (#5248, #4943 — no driver ever read either: the index method is the dialect's choice and a partial index is built by a database-layer migration, not declared)",
"conversionId": "object-index-type-partial-removed",
"toMajor": 17
},
{
"surface": "page.component.element:record_picker.displayField",
"to": "record-picker component prop 'displayField' → 'labelField' (#5775 — the required key no renderer read; `labelField ?? 'name'` is what renders the row)",
"conversionId": "record-picker-display-field-to-label-field",
"toMajor": 17
},
{
"surface": "page.component.element:record_picker.searchFields / page.component.element:record_picker.multiple",
"to": "record-picker component props 'searchFields'/'multiple' removed (#5775 — the control is a plain single-select with no search box; neither key had a reader)",
"conversionId": "record-picker-inert-keys-removed",
"toMajor": 17
},
{
"surface": "page.component.page:card.body",
"to": "page:card component prop 'body' → 'children' (#5775 — one composition key across every container; the card renderer already reads both)",
"conversionId": "page-card-body-to-children",
"toMajor": 17
},
{
"surface": "page.component.element:button.action.params",
"to": "inline type:'api' action prop 'params' (object form) → 'bodyExtra' (#5777 — the payload gets its own key; `params` stays the ActionParam[] definition array)",
"conversionId": "inline-action-api-params-to-body-extra",
"toMajor": 17
},
{
"surface": "page.component.page:tabs.type",
"to": "page:tabs component prop 'type' → 'tabStyle' (#6776 — a props key named `type` collides with the node's dispatch key and is unauthorable in flat/JSX carriers; `tabStyle` is the spelling the renderer reads in all of them)",
"conversionId": "page-tabs-type-to-tab-style",
"toMajor": 17
},
{
"surface": "page.component.page:header.icon / page.component.page:card.actions",
"to": "page:header prop 'icon' and page:card prop 'actions' removed (#6946 — neither has a renderer read point in objectui; the header resolves icons per action and the card renders title/children/footer only)",
"conversionId": "page-structure-inert-keys-removed",
"toMajor": 17
},
{
"surface": "page.component.record:details.layout",
"to": "record:details component prop 'layout' removed (#6946 — the declared auto|custom modes were never implemented; the renderer branches only on inline|compact, values the schema never permitted, so both legal values selected nothing)",
"conversionId": "record-details-layout-removed",
"toMajor": 17
},
{
"surface": "app.hidden",
"to": "stored app publish gate 'hidden' → '_unpublished' (#4829, ADR-0045 amended — `hidden` carried BOTH the publish gate and 'keep out of the App Switcher', so the built-in Account app was withheld from every non-builder; the gate is now the machine-managed `_unpublished`, and `hidden` is navigation presentation only, never an access gate. Stored rows only — an authored `hidden: true` is left untouched)",
"conversionId": "app-hidden-to-unpublished",
"toMajor": 17
},
{
"surface": "action.locations[]",
"to": "action location 'global_nav' removed (#6888 — no running-app surface rendered it; the ⌘K palette reads no action metadata, while the Studio designer previewed a command-palette frame for it. The value is stripped and the key kept, so an action left with no location becomes the documented headless shape `locations: []`)",
"conversionId": "action-global-nav-location-removed",
"toMajor": 17
}
],
"migrated": [
{
"surface": "object.titleFormat",
"replacement": "object.nameField",
"migrationId": "object-titleFormat-to-nameField",
"toMajor": 11,
"rationale": "A single-field `titleFormat` maps 1:1 to `nameField`, but a composite template (e.g. `{firstName} {lastName}`) has no lossless single-field target — it must become a formula field designated as `nameField`. The choice of formula is a judgment the transform cannot make."
},
{
"surface": "security.rls.predicate",
"replacement": "CEL predicate",
"migrationId": "rls-sql-predicate-to-cel",
"toMajor": 11,
"rationale": "SQL-ish RLS predicates were deprecated in favor of canonical CEL. Translation is not a pure token rename — operators, functions, and null semantics differ — so it cannot be applied losslessly by the chain."
},
{
"surface": "api.requireAuth",
"replacement": "explicit `api: { requireAuth: false }` (intentionally-public deployments only)",
"migrationId": "rest-requireauth-default-flip",
"toMajor": 12,
"rationale": "The global default flipped from `false` to `true` in protocol 12: anonymous requests to the `/data/*` CRUD and batch endpoints are rejected with 401 unless the stack opts out. Whether anonymous access was intentional (demo / kiosk) or an accident is a security judgment no transform can make."
},
{
"surface": "CEL/formula: current_user.roles",
"replacement": "current_user.positions",
"migrationId": "cel-current-user-roles-to-positions",
"toMajor": 13,
"rationale": "The EvalUser/CEL contract renamed `current_user.roles` to `current_user.positions`. The token lives inside free-form expression strings, where a blind textual substitution could corrupt string literals or comments — so the rewrite is delegated to the author."
},
{
"surface": "object.sharingModel: 'full'",
"replacement": "'public_read_write' or explicit sharing rules",
"migrationId": "owd-full-alias-removed",
"toMajor": 13,
"rationale": "The legacy `'full'` OWD alias implied full access (including transfer/ delete) — wider than any canonical OWD value, so it has no lossless target ('read'/'read_write' converted mechanically; this one did not). Choosing between `public_read_write` and explicit sharing rules is a security-posture decision."
},
{
"surface": "permissionSet.kind / permissionSet.isProfile",
"replacement": "position-based assignment + permission-set grants (ADR-0090 D2)",
"migrationId": "permission-set-profile-removed",
"toMajor": 13,
"rationale": "The Profile concept was removed: `isProfile` is gone from `PermissionSetSchema` and the `profile` metadata kind folded into `position`. Mapping a profile onto positions and permission-set grants is an authorization-design decision, not a rename."
},
{
"surface": "position.parent / sharingRule recipient role_and_subordinates",
"replacement": "business-unit tree + `unit_and_subordinates` (ADR-0090 D3)",
"migrationId": "position-hierarchy-flattened",
"toMajor": 13,
"rationale": "Positions are flat in v2 — `parent` was removed and the `role_and_subordinates` recipient with it; hierarchy lives on the business-unit tree, which expands a DIFFERENT structure than the retired role tree. Re-homing an org hierarchy is a judgment call."
},
{
"surface": "object.sharingModel (absent, custom object with owner field)",
"replacement": "an explicit `sharingModel` declaration",
"migrationId": "sharing-model-secure-default",
"toMajor": 13,
"rationale": "ADR-0090 D1 secure default: a custom object with an owner field and NO `sharingModel` now resolves `private` (it used to fall through to fully public). Restoring the old exposure must be a deliberate, visible declaration — the chain must not silently re-open data."
},
{
"surface": "view form fields/sections · page components (undeclared keys)",
"replacement": "declared keys only (`visibleWhen` for visibility predicates)",
"migrationId": "ui-schemas-strict-unknown-keys",
"toMajor": 15,
"rationale": "The `.strict()` flip (ADR-0089 D3a) turns a previously silently-stripped unknown key into a parse error. There is no mapping target for an arbitrary unknown key — auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide: fix the typo, move it to the right layer, or delete dead metadata."
},
{
"surface": "dashboard widgets (undeclared top-level keys — legacy inline analytics, objectui-internal `component`/`data`, or typos)",
"replacement": "declared keys only (`dataset` + `dimensions` + `values` for analytics; `options` for renderer-specific extras)",
"migrationId": "dashboard-widget-strict-unknown-keys",
"toMajor": 16,
"rationale": "The `.strict()` flip turns a previously silently-stripped unknown key into a parse error. There is no mapping target for an arbitrary unknown key — auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide: bind a `dataset` and select `dimensions`/`values`, move a renderer setting under `options`, or delete the dead key."
},
{
"surface": "ActionDescriptor.isAsync (the descriptor an executor publishes via `registerNodeExecutor` / `defineActionDescriptor`)",
"replacement": "nothing to re-declare — delete the key. Suspension is `execute()` RETURNING `suspend: true`, and permission to suspend is `supportsPause: true` on the same descriptor (with the `resumeAuthority` its pauses need)",
"migrationId": "action-descriptor-is-async-retired",
"toMajor": 17,
"rationale": "ADR-0049 enforce-or-remove. `isAsync` declared \"this action suspends the flow awaiting an external reply\" and NOTHING read it: a fresh three-repo measurement (#6748, re-run at pickup) found zero property reads across objectstack, objectui and cloud — every hit was the declaration itself, a generated baseline, one of five shipped descriptors WRITING it, a test fixture pinning the shape, or prose. So declaring it never made a node suspend and omitting it never stopped one, which is the silently-inert declaration ADR-0049 exists to end. It was always a second, weaker spelling of the capability `supportsPause` states, and the two diverged in exactly the way a duplicated declaration does: `screen` declared both, `map` and `wait` declared `isAsync` alongside `supportsPause`, and nothing anywhere reconciled them. The sibling took the ENFORCE leg of the same ruling in #6667 — `AutomationEngine` now refuses a suspension whose type does not declare `supportsPause: true` — so the capability this key gestured at is now a real, enforced fact under one name. This one had no consumer to grow into and takes the remove leg. Why D3 semantic and not a D2 conversion: an ActionDescriptor is published from an executor's TypeScript, never stored in stack metadata — no stack, example or template carries the key — so there is no source for the chain to rewrite and `os migrate meta` cannot reach it. The schema tombstones it via `retiredKey()` and descriptor authors delete the key themselves; that rejection (a `tsc` error at the authoring site, and a parse error inside `defineActionDescriptor`) is the channel a third-party plugin author actually meets. The `EnhancedApiError.fieldErrors` disposition, one layer down."
},
{
"surface": "automation.ActionDescriptor.resumeAuthority — an OMITTED value on a pausing node descriptor (supportsPause: true, or any executor whose execute() returns suspend: true)",
"replacement": "an explicit resumeAuthority: 'any' on the descriptor, for a pausing node whose pauses really are meant to be continued through the generic resume route (POST /automation/:name/runs/:runId/resume) — a screen-style collected-input pause, or a signal wait an external producer resumes. Declare 'service' instead if continuing is the tail of a decision your own service must authorize and record first. Either value is a one-line addition; only the silence changed meaning",
"migrationId": "action-descriptor-resume-authority-default-flip",
"toMajor": 17,
"rationale": "A SECURE-DEFAULT FLIP with no metadata shape to rewrite — the same category as protocol 12's `rest-requireauth-default-flip`, and it is registered here for the same reason: whether a given pause is genuinely open to the generic route is a trust judgment no transform can make. The #3801 resume gate keys on the SUSPENDED NODE, and `ActionDescriptor.resumeAuthority` used to default to `'any'`, so a pausing node type shipped raw-resumable unless its author remembered the field. It now resolves to `'service'` when absent: an unclaimed pause is refused on the generic route with `PERMISSION_DENIED` / 403 until its descriptor states who may continue it. #3823 is the incident that decided the direction — ADR-0044 pointed an approval's revise edge at a generic `wait`, `wait` is legitimately `'any'`, and the pause standing in a service-owned position inherited a fail-open value nobody chose; the demonstrated cost was an unaudited resubmit plus a destroyed remote run. The two possible mistakes are asymmetric, which is the whole argument: guessing `'any'` walks past a decision nothing recorded and is silent, while guessing `'service'` returns a refusal naming the missing field. ⚠️ The surface is a DESCRIPTOR FIELD set in plugin CODE, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) already carry. It differs from those in one way a reader should not have to infer: nothing is REMOVED, so tsc reports nothing at all — the field was already optional after step one and an omission still compiles. The enforced channels are all run-time: a registration warning naming the node type (once per type per engine), the refusal message on the resume itself, and `check:resume-authority-declared` for executors living in this repo. For a third-party plugin the generated upgrade guide is the only channel that arrives BEFORE a user hits a run that will not continue. In-tree the flip moves nothing: all six shipped pausing types (screen, wait, subflow, map, approval, approval_revise) declare their authority explicitly. ADR-0044 amendment (2026-07-28) and its 2026-08-08 landing section, ADR-0019 #3801 addendum, #5561."
},
{
"surface": "ui.actionSession.roles",
"replacement": "ui.actionSession.positions (an action body reads `ctx.session.positions`)",
"migrationId": "action-session-roles-to-positions",
"toMajor": 17,
"rationale": "The MIRROR-IMAGE sibling of `actor-user-roles-to-positions`, and the reason both are in this step: the hook `ctx.session` carried `roles` declared-and-never-produced (removed outright, #5050), while the ACTION body's `ctx.session` carries it produced-and-really-populated. `buildActionSession()` (`packages/runtime/src/action-execution.ts`) copies `ExecutionContext.positions` into a key spelled `roles` — the ADR-0090 D3 vocabulary handed to the author under the one spelling that ADR bans — so a body author met two different answers to one key name on one platform: rejected in a hook, live and full of values in an action. #5613 ruled contract-first (maintainer, 2026-08-06: \"C skeleton + A semantics\"): phase 1 (#5697) declared the previously undeclared shape as `ActionSessionSchema`, and phase 2 renames the key. `positions` is now the canonical key on that schema and `roles` a deprecated alias of it (#5779); the producer emits both for one deprecation window (#5613 runtime half), after which `roles` is removed on the path the v16 session-alias removal already walked (#3280 deprecated → #3290 removed). Why this is a D3 semantic TODO and not a D2 conversion, on two independent grounds: FIRST, there is no source to convert — an action `ctx.session` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key — the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape. SECOND, the only place the key is ever SPELLED is inside an action body: author-written JS/TS, or a sandboxed script whose `ScriptContext.session` is still `unknown`. A declarative transform cannot safely rewrite an identifier inside free-form code — exactly the reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. Note what is deliberately NOT done here: the alias is not tombstoned. A `retiredKey()` REJECTS the key, and a deprecation window exists precisely so the old spelling keeps working while its readers move — tombstoning during the window would be the removal it is meant to defer. The tombstone (or the plain deletion the authorable-surface ratchet adjudicates) belongs to the release that closes the window. Until then this entry IS the channel: `spec-changes.json` and the generated upgrade guide are how a reader learns the rename before the removal reaches them. ADR-0090 D3, ADR-0087, #5613 / #5779."
},
{
"surface": "action body / AI route: ctx.user.roles (req.user.roles)",
"replacement": "ctx.user.positions (an AI route handler reads `req.user.positions`) — the same array, under the one spelling ADR-0090 D3 sanctions",
"migrationId": "actor-user-roles-to-positions",
"toMajor": 17,
"rationale": "The THIRD face of the ADR-0090 `roles` → `positions` rename, and the only one whose surface the spec never declared. `ActorUser` (`packages/runtime/src/security/actor-user.ts`) is the ONE producer of the `user` envelope handed to an action body as `ctx.user` and to an AI route handler as `req.user`; it declared `positions` and `roles` side by side and filled them from a SINGLE assignment (`roles: core.positions`), so the two keys were verbatim identical on every dispatch — a second spelling of the vocabulary ADR-0090 D3 reserves and bans, published straight into author-written code. The maintainer ruled it closed IMMEDIATELY (2026-08-06 14:49Z, #6011): no deprecation window, no dual-emit, the alias simply gone in 17 (PR #6048). ⚠️ Do not read this entry across to its sibling `action-session-roles-to-positions`: `action-session-roles-to-positions` governs `ctx.session`, a DIFFERENT object reached through the same `ctx`, and that one KEEPS its one-window dual-emit (#5613). Same word, same dispatch, two faces, two schedules — `ctx.user.roles` is absent in 17 while `ctx.session.roles` still answers for the length of its window. What makes this entry different in KIND from both session-side siblings: `ctx.user` has no spec schema and never had one. It is a runtime TS interface, so unlike `HookContext.session.roles` (tombstoned on a deliberately non-strict `HookContextSchema`, #5050) and unlike `ActionSessionSchema` (declared contract-first at #5697 precisely so its key could be renamed), there is no schema key here to tombstone and no `retiredKey()` prescription that could reach anybody — nothing ever ran an `ActorUser` through a `.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it reports at the READ site inside the author's own body; for an untyped or sandboxed body there is no enforced channel at all, which is exactly why this ledger entry has to exist — `spec-changes.json` and the generated upgrade guide are the ONLY way such a reader learns of the rename. It is the `findStream` (#4484) / `IStorageService.list` (#5540) disposition — a TS/API contract, no stored source, no tombstone, tsc at the call site — applied to a surface that lives one layer further out than either: those two are at least DECLARED in `packages/spec/src/contracts`, this one only in `packages/runtime`. Why it is a D3 semantic TODO and not a D2 conversion, on the same two independent grounds as its session sibling: FIRST, there is no source to convert — an `ActorUser` is constructed per dispatch and never persisted, so no `sys_metadata` row, example or template can carry the key (the `openApi31` (#4579) / `activationEvents` (#4657) / `hook-context-session-roles-retired` (#5050) shape). SECOND, the only place the key is ever SPELLED is inside an action body or an AI route handler: author-written JS/TS, or a sandboxed script. A declarative transform cannot safely rewrite an identifier inside free-form code — the same reason the ADR-0090 wave delegated `current_user.roles` to the author at step 13 (`cel-current-user-roles-to-positions`) instead of substituting text. The removal's hard precondition was met before it landed, and the result is recorded here because the ledger is where an upgrading consumer meets it: the declaration's own comment claimed the alias was \"kept for the REST/AI shapes\", and that claim was DISPROVEN face by face against `origin/main` — repo-wide `user.roles` was 4 hits, all of them in the pins PR #6048 flipped; the four `ActorUser` construction sites build server-side envelopes that never enter a response body; objectui's `.roles` reads belong to two unrelated producers (the better-auth session, and the `/auth/me/permissions` payload). The `cloud` repo was NOT reachable in that session and is the one consumer face left unverified — this entry, and the changeset's FROM/TO prescription, are its disposition. ADR-0090 D3 / ADR-0049 / ADR-0087, #6011 (PR #6048)."
},
{
"surface": "data.query.aggregations[].distinct",
"replacement": "the `count_distinct` aggregation FUNCTION for a deduplicated count — the one deduplicating spelling every face computes, lowered to `COUNT(DISTINCT field)` on both SQL faces since #6409. `SUM(DISTINCT …)` / `AVG(DISTINCT …)` get no replacement: no backend ever computed them here, and a per-row measure that needs deduplicating before summing is a modelling problem to fix in the data",
"migrationId": "aggregation-node-distinct-retired",
"toMajor": 17,
"rationale": "A DIVERGENCE, not an inert declaration — which is why it outlived the #4286 sweep that dispositioned every other `data.query.*` member. That sweep asked which keys no executor reads; this one HAD an executor, exactly one out of six. The engine's in-memory fallback (`objectql/src/in-memory-aggregation.ts`) deduplicated the values before applying the function, while `SqlDriver.aggregate`, the Turso `RemoteTransport.aggregate`, `driver-mongodb`'s `buildAggregationStage`, `driver-memory`'s `computeAggregate` and service-analytics' `AGGREGATE_SQL` all ignored the key. So `{ function: 'sum', field: 'amount', distinct: true }` answered a deduplicated sum when the engine fell back in memory and an ordinary sum on every SQL datasource: one query, two numbers, chosen by which backend happened to serve it — and unlike the #6203 / #5907 divergences closed on the same axis, the wrong answer here is a plausible NUMBER rather than a refusal, so nothing surfaced it to the author. Measured blast radius inside the fallback: `sum` and `avg` only — `count` returned from its own branch before reaching the dedupe, `count_distinct` fed the values into a Set (dedupe-then-Set is Set), and dedupe does not move `min`/`max`. ENFORCE was weighed and rejected (maintainer ruling 2026-08-09): `count_distinct` already covers the only spelling anyone has measured demand for, and lowering `SUM(DISTINCT …)` across five faces — two of them frozen under #5499 — buys a shape that is near-universally a modelling mistake. A REQUEST surface — `QueryAST` is the client SDK builder's output and the `POST /data/:object/query` body, never stored in stack metadata — so there is no source for the chain to rewrite and callers move their own queries: the #4286 disposition for `joins`/`cursor`/`distinct`/`windowFunctions`, applied verbatim one level down. ADR-0049, #6815."
},
{
"surface": "api.analyticsQueryRequest.query",
"replacement": "bare AnalyticsQuery body (top-level cube/measures/dimensions/where/...)",
"migrationId": "analytics-query-request-envelope-retired",
"toMajor": 17,
"rationale": "The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded analytics shim (#3891), never stored in stack metadata — there is no source for the chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the query.* fields to the body top level themselves."
},
{
"surface": "api.analyticsQueryRequest.format",
"replacement": "(removed — responses are always the JSON envelope; use the export surface for CSV/XLSX)",
"migrationId": "analytics-query-request-format-retired",
"toMajor": 17,
"rationale": "The `format` key was declared but never implemented (declared ≠ enforced): every response is the JSON envelope regardless of the requested value, so there is no behaviour to preserve and nothing stored to rewrite."
},
{
"surface": "PUT /api/v1/meta/api/{name} (runtime-authored `api` endpoints, draft and active alike)",
"replacement": "Declare the endpoint as a stack artifact (`**/*.api.ts`, or `defineStack({ apis })`) and ship it through `publishPackage`",
"migrationId": "api-runtime-create-withdrawn",
"toMajor": 17,
"rationale": "The `api` registry entry declared `allowRuntimeCreate: true` and the runtime never honoured it. Measured on a real showcase boot (#5488): `PUT /api/v1/meta/api/e8_backdoor` answered 200 with `{\"success\":true,…,\"message\":\"Saved …\"}`, and the declared route then answered 404 forever — with NO `[EndpointMatcher] … EXCLUDED` line, because the endpoint was never in the index to be excluded from. The serving criterion belongs to `IMetadataService.matchEndpoint` -> `EndpointMatcher` -> `MetadataManager.listForIndex('api')`, which reads the manager's registry plus its registered loaders (`[\"filesystem\",\"memory\"]` on dev/serve); a runtime write lands in `sys_metadata`, which is in neither. A declared capability the runtime does not honour is ADR-0049 false compliance, and a write that answers \"Saved\" and then 404s forever is its most dangerous shape for the AI authors ADR-0033 targets. The maintainer ruled REMOVE on 2026-08-07 rather than converge the read path, because making the matcher read `sys_metadata` re-opens cache, invalidation, tenancy and the ADR-0110 D3 miss-vs-outage distinction on a new read path, and there is no business pull for Studio-authored endpoints today (zero `.api.*` artifacts author them at runtime; showcase uses the artifact route, #5040 E8 LIVE). There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and the artifact route it points authors toward is untouched — a `**/*.api.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) takes. Consequently `gateApiDraftsForPublish` (PR #5279) is retired with it: it gated a promotion into a state the matcher can never read, and with the inlet closed no `api` draft can exist for it to judge. Re-entry is recorded in the ruling: if #2657 Part B promotes `apis` to a registered type WITH A REAL CONSUMPTION PATH, the flag flips back then — implementation first, declaration second. ADR-0049 / ADR-0121, #5488 (subsumes #5311)."
},
{
"surface": "data.object.enable.apiMethods (the eight legacy non-primitive values)",
"replacement": "the six primitives only — `get` / `list` / `create` / `update` / `delete` / `bulk`: replace each legacy value with the primitives it derives from, de-duplicate, and delete the key entirely if the result names all six",
"migrationId": "apimethod-enum-shrink",
"toMajor": 17,
"rationale": "The authored `enable.apiMethods` enum is now exactly the six primitives. The eight legacy values — `upsert`, `aggregate`, `history`, `search`, `restore`, `purge`, `import`, `export` — are no longer authorable, because they are DERIVED effective operations resolved by the server's single derivation table, and an enum that lets an author name both a primitive and something derived from it has two spellings for one fact. The FROM → TO is a table rather than a rename: `upsert` → `create` + `update`; `import` → `create` + `update`; `export`, `aggregate` and `search` → `list`; `history` → `get`; and `restore` / `purge` map to NOTHING — they never derived, because `enable.trash` was retired in #2377, so the value is deleted outright. That last row is why this is a semantic entry and not a mechanical conversion, and the reason is a security one: the mapping WIDENS. An allowlist naming `history` was granting read of one record's audit trail; rewritten to `get` it grants ordinary record reads, and an allowlist naming `search` becomes a grant of full `list`. A transform that applied the table silently would broaden real API permissions without anyone reading the diff, so the rewrite is delegated to the author with the widening flagged. The reporter codemod exists for exactly that shape: `node scripts/codemod/apimethods-legacy-to-primitives.mjs` scans, reports the exact replacement per site, and FLAGS the allowlists the mapping would widen so the edit stays reviewable — it reports, it does not rewrite. Stored metadata keeps parsing (permanent tolerance, narrowing only), so nothing breaks at rest; what changes is what an author may newly write. Registered by the #6350 stock reconciliation; #3543 (P2 of #3391) predates the #6148 completeness gate. ADR-0087, #3543 (backfilled #6350)."
},
{
"surface": "sys_audit_log.action — the values 'export' and 'permission_change' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). The same two values also left the shipped list-view filters on that object: 'permission_change' from the auth_events view and 'export' from the config_changes view",
"replacement": "nothing, for either value — both are removed rather than renamed, because neither named an event this platform records. For permission changes, read the ordinary `create` / `update` rows on the permission objects themselves: a grant or binding write is an ordinary record write and the generic audit writer already ledgers it, so a second semantically-duplicate row was never minted. For `export` there is no replacement and nothing is lost: no export feature ever wrote an audit row. A consumer filtering `sys_audit_log` on either value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise",
"migrationId": "audit-log-action-enum-retired",
"toMajor": 17,
"rationale": "Maintainer ruling 2026-08-12 (#7675), the retirement half of a two-half verdict: the cheap writers get built (#8144 login/logout, #8145 config_change) and the enum values with no feature behind them are retired. 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. The defect was false compliance on a COMPLIANCE surface, which is the sharpest form of ADR-0049 declared-≠-enforced: an auditor reading the action enum believed the platform captured permission changes and data exports, and the shipped list views and dashboard widgets showed them a filter and a tile for exactly those events. Both were permanently empty. Measured by enumerating every `sys_audit_log` writer in the repo — there are exactly two: plugin-audit`s generic hook writer, whose `actionFor` maps afterInsert/Update/Delete to create/update/delete and nothing else, and plugin-auth`s admin user-import. Neither has ever emitted `export` or `permission_change`. This is an enum-VALUE retirement, so the bookkeeping differs from a key retirement in the two ways `hook-body-crypto-hash-removed`, `dataset-measure-array-string-agg-removed` and `action-global-nav-location-removed` already record: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and the four surface ratchets are expected to be byte-identical (no def changed). It differs from all three in being a SEMANTIC entry rather than a D2 conversion, and the reason is that there is no source to rewrite: `sys_audit_log` is a platform-owned, append-only object whose every field is `readonly: true`. Nobody authors an audit row and nobody authors this enum — the values appear only in rows the runtime writes and in queries consumers send. A conversion rewrites authored metadata or a stored `sys_metadata` row; this surface is neither, so the disposition is the one `BatchOptions.validateOnly` and the notification cursor already take in this major. ⚠️ Historical ROWS are deliberately untouched. A deployment that somehow holds a row with either value keeps it, and keeps reading it back: the enum is not enforced on this object at all (`validateRecord` skips `readonly` fields, and every field here is readonly), so nothing rejects stored history and no backfill is required or wanted. Deleting audit history to satisfy a schema narrowing would be the one genuinely destructive reading of this change. ADR-0049 / ADR-0087, #8147."
},
{
"surface": "sys_audit_log.action — the value 'restore' left the select enum declared by plugin-audit (packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts). It also left the shipped writes_only list-view filter on that object, and the generated option label in all four plugin-audit translation bundles",
"replacement": "nothing — the value is removed rather than renamed, because it never named an event this platform records. There is no undelete or restore capability to point at: deletes are hard deletes, and the record-level audit writer maps the ObjectQL lifecycle to `create` / `update` / `delete` only. A consumer filtering `sys_audit_log` on this value was reading an empty result set on every deployment, and still is — what changed is that the contract no longer promises otherwise. If you were counting on a restore trail, the capability itself is the missing piece (#1883, #3146), not this enum row",
"migrationId": "audit-log-action-restore-retired",
"toMajor": 17,
"rationale": "The same maintainer ruling as `audit-log-action-enum-retired`, carried to the one value #7675's own survey did not name (#8315, triage 2026-08-13). 原则记录:空 widget + 永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎. `restore` is the least ambiguous member of the family: the record-level writer could not have produced it even by accident, because `actionFor()` in audit-writers.ts is typed `'create' | 'update' | 'delete' | null` and its caller early-returns on null. A tree-wide search finds no other producer. What made it a card rather than a tidy-up is that TWO shipped declarations asserted the opposite, so a declaration-reading audit scored the action as covered: the `writes_only` list view offered it as a filter value, and the module docblock of auth-event-audit.ts named it among the actions the writer emits. The comment is the ADR-0049 declared-≠-enforced shape in its purest form (#8011) — a sentence next to a mechanism, contradicted by the type signature of that very mechanism, with nothing in CI able to tell. Both declarations are corrected in one change, and the invariant behind the comment (every declared action has a writer) now has a pin test under it rather than prose. Bookkeeping is identical to the sibling entry, for the same reasons: an enum-VALUE retirement puts nothing in RETIRED_KEYS_BY_MAJOR (no authorable KEY changed) and leaves the four surface ratchets byte-identical (no def changed), and it is a SEMANTIC entry rather than a D2 conversion because there is no source to rewrite — `sys_audit_log` is a platform-owned, append-only object whose every field is `readonly: true`, so nobody authors an audit row and nobody authors this enum. ⚠️ This is a statement about the WRITER, not a product stance against undelete. Soft delete/restore is parked, not rejected (#1883 pm:on-hold, #3146 status:parked). If that capability lands, this value returns WITH its writer — the emission point, its tests, and the view that surfaces it — never as a bare enum row again. ⚠️ Historical ROWS are deliberately untouched, exactly as for the sibling entry: the enum is not enforced on this object at all (`validateRecord` skips `readonly` fields), so any stored row keeps parsing and reading back, and no backfill is required or wanted. Deleting audit history to satisfy a schema narrowing would be the one genuinely destructive reading of this change. ADR-0049 / ADR-0087, #8315, #7675, #8147."
},
{
"surface": "api.authConfig.features.passkeys / api.authConfig.features.magicLink",
"replacement": "(removed — no replacement flag; the capabilities are not advertised)",
"migrationId": "auth-config-unadvertised-reserved-features",
"toMajor": 17,
"rationale": "Both flags were served by `GET /api/v1/auth/config` from introduction and read by no client: no login UI anywhere renders a passkey or magic-link affordance off them, so the payload advertised two sign-in methods a user could never reach, and a deployer setting `plugins.passkeys` / `plugins.magicLink` flipped a switch with no observable effect (ADR-0049 enforce-or-remove; maintainer ruling 2026-08-11 on #7481 chose remove over keep-as-reserved). The two are not equally empty: nothing at all is wired behind `passkeys`, whereas `magicLink`'s better-auth endpoints are live and only their advertisement was withdrawn. This is a RESPONSE surface — nobody authors or persists an `AuthFeaturesConfig` — so there is no source for the chain to rewrite; the schema tombstones both keys via retiredKey() and consumers drop their read. The withdrawal is conditional: both return to the payload in the change that ships the login UI (objectui#4179). ADR-0049, #7481."
},
{
"surface": "the protocol-17 authoring schemas closed against undeclared keys (#4001) — `automation/` (flow and its six nested blocks, control-flow, state-machine, webhook, time-relative trigger, flow function), `security/` (permission sets, RLS policies, sharing rules) and `identity/position`, `ui/` (responsive, theme, chart, `AriaProps`, fifteen `view` sub-blocks, `ViewItem`, `userFilters`) — plus the `view` write-path identity precondition one level above them",
"replacement": "declared keys only. Each rejection names the surface, echoes the offending key and — where the word is recognisable — gives the canonical spelling, a retired-key tombstone, or a prescription where a rename would be wrong. A `view` body must additionally carry at least one key some union member declares, discounting the identity keys the write path stamps itself (`VIEW_WRITE_PATH_IDENTITY_KEYS`)",
"migrationId": "authoring-schemas-strict-unknown-keys",
"toMajor": 17,
"rationale": "zod's default `.strip` discarded any key these schemas did not declare and let the parse SUCCEED, so the author — increasingly an AI — got a success envelope and shipped metadata that quietly ignored what they wrote. Closing them turns that into a loud parse error (ADR-0049 enforce-or-remove, ADR-0078 no-silently-inert). It is not losslessly convertible for the same reason the two precedent entries at majors 15 and 16 are not: an arbitrary unknown key has no mapping target, and auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide, fix the typo, move it to the layer that owns it, or delete dead metadata. The named renames the errors carry are a help, not a transform: a large share of this wave is PRESCRIPTIONS rather than renames precisely because renaming would be wrong (`inputSchema.optional` is the opposite polarity of `required`; `errorHandling.maxAttempts` counts the first attempt where `maxRetries` counts the ones after it; a `responsiveStyles` bucket written on `responsive` is a wrong-layer pointer, and the two breakpoint vocabularies sixteen lines apart cannot be bridged by edit distance; `aria.live` is real on exactly one renderer and `ariaLabelledBy` has nothing to rename to; `finally` on `try_catch` and `context` on a state machine have no key at all). The eleventh member is not an unknown-key close but the same defect one level up — the `view` union had an arm that both stripped and required nothing, so it matched every object and `saveMetaItem` persisted garbage as an ACTIVE view overlay that read back badged valid. This is ONE entry for the whole major by the ruling on #7630 (2026-08-12), mirroring the registry's only two precedents of this shape; the eleven batches it folds are the changesets `unknown-key-strictness-tier-a`, `-step2`, `-automation-batch11`, `-ui-batch13`, `-ui-batch15`, `-ui-batch16`, `strict-automation-control-flow-state-machine`, `view-subblock-strictness-batch18`, `rare-jars-shave`, `user-filters-allow-add-tab-promote-and-close` and `view-union-identity-precondition`, each carrying its own FROM → TO table in `CHANGELOG.md`. ADR-0049 / ADR-0078 / ADR-0087, #4001, #5073, #5599 (registered #7630, backfilling #6350)."
},
{
"surface": "api.batchOptions.validateOnly",
"replacement": "(removed — no dry-run today; open an issue to design a no-commit batch preview)",
"migrationId": "batch-options-validate-only-retired",
"toMajor": 17,
"rationale": "The `validateOnly` key promised a dry-run (\"validate records without persisting\") but no batch surface ever read it — updateManyData / deleteManyData / batchData persist regardless. There is no behaviour to preserve and nothing stored to rewrite (it only ever appeared in an HTTP request body). Callers must stop sending it."
},
{
"surface": "api.batchOperationResult — the per-row `results` entries of BatchUpdateResponse (`POST /data/:object/batch`, `/updateMany`, `/deleteMany`)",
"replacement": "`errors: ApiError[]` (was `error: string` — read `row.errors?.[0]?.message`, branch on `row.errors?.[0]?.code`), `data` (was `record`), and `index` (new — the row's position in the request array)",
"migrationId": "batch-row-result-schema-shape",
"toMajor": 17,
"rationale": "The rows the three bulk-write endpoints emitted had drifted from the schema that declared them: `BatchOperationResultSchema`, the client SDK's exported `BatchOperationResult` type and the reference docs all said `errors: ApiError[]` / `data` / `index`, while the wire carried `error: string` / `record` and never sent `index` at all. A TypeScript consumer written against the published type compiled, validated and read `undefined` at runtime — the declared-but-not-delivered shape this registry exists to close, on the response envelope (ADR-0119 D4 deferred the reconciliation off a bug fix; this is that tracked change, shipped in the 17 major window). The ADR-0119/#4620 rollback marking is structured in the same move: the `ROLLED_BACK:` / `NOT_ATTEMPTED:` message-string prefixes become registered `ApiError.code` values (message keeps the human-readable cause and causal row index), so \"attempted and undone\" vs \"never ran\" is machine-readable instead of a regex convention. A RESPONSE surface — nothing stored in stack metadata carries a batch row, so there is no source for the chain to rewrite; consumers of the legacy keys move their reads themselves. Off-contract readers only: the legacy keys were never in the schema or the SDK types, so a typed consumer needs no change. #4793."
},
{
"surface": "client.DeleteDataResult.deleted (the return of `client.data.delete()`)",
"replacement": "`success` — `r.deleted` → `r.success`. Same call, same wire body, declared name",
"migrationId": "client-delete-result-success",
"toMajor": 17,
"rationale": "`DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a declaration that contradicted it: the interface declared `deleted: boolean` while `DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never been declared by any schema and no server path has ever returned it on `/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / `_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the branch was never taken; `if (r.success)` was rejected by the compiler and correct on the wire. So this rename REVEALS a defect rather than breaking working code — every reader of the old key was already reading `undefined`, on every deployment and not just some, because the protocol path has always answered `success`. It is registered as a semantic entry rather than a mechanical conversion for the reason the rewrite itself does not capture: the key is one token, but a call site that branched on `r.deleted` has been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what actually has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why the ledger entry is the only notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent (the same ruling #5581 applied on the producer side). No deprecated `deleted?: boolean` transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. Registered by the #6350 stock reconciliation. ADR-0087, #5638 (backfilled #6350)."
},
{
"surface": "connector.authentication on AUTHORED entries (defineStack `connectors:`, `PUT /meta/connector/:name`) — previously refused only on provider-bound instances (ADR-0097 §3), now refused on catalog descriptors too",
"replacement": "a catalog descriptor drops `authentication` (or sets `{ type: \"none\" }`) and documents the auth scheme in `description`; a dispatchable instance declares `provider` and references its credential with `auth: { type, credentialRef }` (ADR-0097 §3). Runtime `registerConnector` calls are unaffected — the runtime shape still carries resolved secrets inline.",
"migrationId": "connector-inline-authentication-publish-refused",
"toMajor": 17,
"rationale": "A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` / `password` / `clientSecret` is cleartext at rest, readable through the data API (#7990). No mechanical rewrite exists: whether the entry should become a `none` descriptor or a provider-bound instance with a `credentialRef` — and which secret store receives the credential — is a judgment about the connector, not a rename."
},
{
"surface": "dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')",
"replacement": "compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter`",
"migrationId": "dashboard-widget-compareto-offset",
"toMajor": 17,
"rationale": "The widget declared three comparison arms; the analytics executor implements one shape, `{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset path — the spec's single author-facing analytics shape — `{ offset }` was forwarded verbatim into that contract and threw `compareTo requires a timeDimension \"undefined\"`, taking the widget down; the arm ever only ran on the legacy inline chart path (#5011). The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every other duration has NO faithful target: `previousPeriod` shifts by the length of whatever window the widget's filter resolves to, which equals `7d` only when that window happens to be seven days long. Rewriting mechanically would silently change which rows the comparison column counts — a wrong number rather than a missing one, which is strictly worse and exactly the class this convergence exists to end. Re-stating the intended window is a judgment about the presentation, not a transform."
},
{
"surface": "contracts.IDataDriver.findStream / data.DriverInterfaceSchema.findStream",
"replacement": "find() with limit/offset — the paged read whose determinism IS enforced (IDataDriver.find, data/pagination-conformance.ts)",
"migrationId": "data-driver-find-stream-retired",
"toMajor": 17,
"rationale": "`findStream` was a REQUIRED contract method documented as \"optimized for large datasets to avoid memory overflow\", and in two of its three implementations it delivered the opposite: `SqlDriver` and `InMemoryDriver` both awaited `find()` for the ENTIRE result set and then yielded it row by row, so the peak memory a caller was promised protection from was already reached before the first yield. The third (`MongoDBDriver._findStream`) did walk a cursor, but it was the one read path in that driver never routed through `buildFindOptions`, so it hardcoded `projection: { _id: 0 }` and silently discarded `query.fields`. None of it was ever observed, because the method had NO caller in either repository: the engine exposes no stream entry, and the REST export, import and bulk-read paths all go through `find()`. The ~20 driver test doubles that existed only to satisfy a required method almost all threw `not implemented`, and nothing ever noticed — which is the proof, not the anecdote. Being REQUIRED, it also taxed every new driver and every test double with an implementation of a capability the platform does not have. Rather than build a caller to justify three implementations, the method is retired; a real cursor-based read should return WITH the caller that needs it (ADR-0049 enforce-or-remove). This is a TS/API contract surface — a driver is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone either: nothing ever ran a driver object through `DriverInterfaceSchema.parse()`, so a prescription there would have no one to reach. The enforced channel is tsc, and it points at callers. ADR-0049 / ADR-0078, #4484."
},
{
"surface": "contracts.IDataDriver query parameter — find / findOne / count / updateMany / deleteMany / explain",
"replacement": "`DriverQuery` (`Omit<QueryAST, \"object\">`): delete the redundant `object:` key from the query literal at the call site — the object name is already the FIRST argument",
"migrationId": "data-driver-query-omit-object",
"toMajor": 17,
"rationale": "Every one of these methods takes the object name as its first argument, and then required a `QueryAST` that lists `object` as mandatory — the same fact demanded twice, with two places for it to disagree. The layers above had already paid for that ambiguity: the objectql engine deliberately writes its key order as `{ ...query, object }` so a smuggled `query.object` cannot override the resolved name, and the wire layer spends a named 400 (`QUERY_OBJECT_MISMATCH`) refusing the inconsistency. The driver side paid in blanket casts: a direct caller holding only a `where` could not name the type, wrote `as any`, and switched off checking for `where` / `orderBy` / `fields` along with it — 20 such sites measured in cloud#1053, and cloud#1030's `$like` reached runtime through exactly that hole. This is a TS contract surface with no authored source for the chain to rewrite, which is why it is a semantic entry and not a D2 conversion; for a typed caller the compiler names every site (TS2353 `'object' does not exist in type 'DriverQuery'`), and for an untyped JS caller there is no constrained channel at all, which is exactly why this ledger entry must exist. Neither side is forced to move: a caller holding a `QueryAST` VALUE passes it unchanged (excess properties are only rejected on fresh literals), and an implementation still declaring `query: QueryAST` keeps compiling under parameter bivariance. What an implementation may no longer do is READ `query.object` — callers are now entitled to omit it. Registered by the #6350 stock reconciliation. #5181 was the audit's CONTROL sample, drawn to show that not every flagged candidate is an omission, and it was one: the seven `IDataDriver` hits in the ledger are all prose inside other entries, and the only subject-level hit on this interface is `data-driver-find-stream-retired` — a DIFFERENT member. Two later, smaller driver call-parameter changes (#6321, #6083) both registered, and both cite #5181 as background; the larger sibling they derive from never got its own entry. ADR-0087, #5181 (backfilled #6350)."
},
{
"surface": "contracts.IDataEngine.batch / data.DataEngineBatchRequestSchema",
"replacement": "`IObjectQLEngine.transaction(cb)` for in-process multi-write atomicity; the metadata protocol's `batchData` with `options.atomic: true` for a batch over one object; `POST {basePath}/batch` on the wire",
"migrationId": "data-engine-batch-retired",
"toMajor": 17,
"rationale": "`batch?` was declared on `IDataEngine` for as long as that contract existed and was never implemented by any engine: `ObjectQL` has no `batch` method and there is no other engine in the tree. It also had no caller — `DataEngineRequest` was imported by exactly one file, the contract declaring the member. Its entire specification was a three-word doc comment (\"Batch Operations (Transactional)\"), which settles nothing about partial failure, ordering, cross-object references, rollback scope, or what `transaction: false` was supposed to mean — the questions a batch API exists to answer. Contrast its neighbours `getDefaultDriverName?` / `getDriverByName?`, whose optionality is evidenced: each names its implementer and its probing caller. The tell that nobody ever designed against it is in the schema: `DataEngineBatchRequestSchema.requests` nested the request union RECURSIVELY, so a batch could contain batches, with no statement anywhere about what that meant for ordering or rollback. The only test was a type pin — an ad-hoc object literal carrying a `batch` property, asserting the property was defined — which could not fail while the declaration existed and would have passed unchanged for the member's whole life with no engine implementing it. What it claimed is now covered by members that are real, so the removal deletes a false affordance rather than a capability: ADR-0119 D1 made `transaction` reachable through the contract and D4 made `batchData`'s `atomic` honest, while the wire batch has always validated with `CrossObjectBatchRequestSchema` / `BatchUpdateRequestSchema` from `api/batch.zod.ts` — a different schema entirely, untouched here. TS/API surfaces only: an engine is CODE, never stack metadata, so there is no source for the chain to rewrite. Deliberately no schema tombstone either — nothing ever parsed `DataEngineBatchRequestSchema`, so a `retiredKey()` prescription would have no one to reach; its three `authorable-surface.json` baseline lines and its `json-schema.manifest.json` entry are dropped in the same change, deliberately. The enforced channel is tsc. ADR-0049 / ADR-0078, #4618."
},
{
"surface": "api.DataEventType 'data.field.changed'",
"replacement": "the `data.record.updated` event, whose payload already carries the per-field detail: `changes` (the changed fields), plus `before` / `after`",
"migrationId": "data-field-changed-event-retired",
"toMajor": 17,
"rationale": "`data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since #4639) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist, #3196) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673."
},
{
"surface": "datasource.config.password (postgres / mysql / mongo) and datasource.config.authToken (turso)",
"replacement": "the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference",
"migrationId": "datasource-config-inline-credential-refused",
"toMajor": 17,
"rationale": "A datasource artefact is persisted whole into `sys_metadata`, which is served back by the ordinary data API — an inline credential is cleartext at rest (#7990, maintainer-ruled per-artefact contract closure, 2026-08-12). There is no mechanical rewrite: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and deleting the cleartext, which a source-file transform cannot do — auto-deleting the key alone would silently drop a live credential instead."
},
{
"surface": "connection-material string keys of the built-in driver configs — postgres/mysql/mongo `url`/`host`/`database`/`username`, postgres `schema`/`applicationName`, mongo `authSource` and the `options` passthrough (judged deep), turso `url`/`syncUrl`/`encryptionKey`, sqlite/sqlite-wasm `filename` — values containing `${…}` placeholder syntax",
"replacement": "the literal value. For secret material, the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` reference. For environment-driven connections, the runtime environment itself: `OS_DATABASE_URL` and friends are translated into driver config by the boot hosts and never pass through the publish door",
"migrationId": "datasource-config-placeholder-refused",
"toMajor": 17,
"rationale": "A `${…}` placeholder in authored datasource config is resolved by NOTHING — it is stored verbatim in `sys_metadata` and handed verbatim to the database client at connect (#7990 census, measured during #8078), so the connection fails, or connects somewhere unintended, with no error naming the unresolved placeholder — the masked-failure shape. The syntax looked supported: it parsed green, stored fine, and failed at a distance; two shipped refusal messages (#8078 inline credentials, #8082 URL userinfo) had to warn \"do NOT substitute a placeholder\" around the broken escape. Maintainer-ruled direction 2 on #8336 (2026-08-13): refuse the syntax loudly at publish; implementing real resolution was explicitly rejected — a new capability with an env-exfiltration security surface and zero measured pull for actual substitution. There is no mechanical rewrite: the placeholder names a value that exists only in the author's intended deployment environment, which a source-file transform cannot know — substituting anything would invent a connection target."
},
{
"surface": "datasource.config.url (postgres / mysql / mongo / turso) and datasource.config.syncUrl (turso) — the URL userinfo password segment (`user:password@host`)",
"replacement": "the same URL with its userinfo password removed (a bare `user@host` stays legal), plus the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference",
"migrationId": "datasource-config-url-userinfo-refused",
"toMajor": 17,
"rationale": "The #7990 closure refused the inline credential KEYS, and #8078 measured that `config.url` still accepted the identical secret one syntax over — `postgresql://user:password@host/db` landed in `sys_metadata` cleartext exactly as `config.password` did, and the key refusal itself steered authors there (#8082, maintainer-ruled Option A, 2026-08-12). Runtime-environment DSNs (`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected by construction. There is no mechanical rewrite, for the same reason as the sibling entry `datasource-config-inline-credential-refused`: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and stripping the cleartext, which a source-file transform cannot do — auto-stripping the userinfo alone would silently drop a live credential instead. Do not substitute a `${…}` placeholder into the URL: placeholders in authored metadata are resolved by nothing and reach the database client verbatim (#8078, measured)."
},
{
"surface": "stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)",
"replacement": "the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps/<manifest.namespace>/<subpath>`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`",
"migrationId": "declarative-apis-endpoints-live",
"toMajor": 17,
"rationale": "This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is \"did the author of this endpoint mean for the internet to reach it?\" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with `ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: `const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type (defaults materialized, ADR-0122), where `authRequired` is required — annotating a declaration with it forces you to write the key out, and being made to think about a key whose only unrecoverable value is `false` is the one thing this entry is trying to avoid (#5227). Hold a parse RESULT with `ApiEndpointParsed`; write declarations as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps/<namespace>/…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call."
},
{
"surface": "a `beforeDelete` handler on a BY-ID `delete()` assigning `ctx.input.id` a DIFFERENT id, to move the delete onto that row",
"replacement": "delete the other row explicitly — `ctx.ql.delete(object, otherId)` / `ctx.api` — and let the addressed delete proceed or `throw` from the handler to stop it; to delete MANY rows, have the CALLER pass `{ multi: true, where: … }`. Writing the SAME id back is unaffected and stays legal.",
"migrationId": "delete-by-id-before-hook-repoint-retired",
"toMajor": 17,
"rationale": "The by-id target of an `update()` or `delete()` is now IMMUTABLE inside a `before*` handler, on both verbs, cleared or rebound. `delete()` was the last cell of that table still answering differently: it HONOURED a repoint, re-resolving the new target by re-reading its pre-image and rebinding `previous` (#5272), so `afterDelete` and the roll-up recompute saw the row actually deleted. It now refuses with `HookTargetRebindError` / `ERR_HOOK_TARGET_REBIND`, `path: 'by-id'`, exactly as the `update()` twin and both per-row paths (ADR-0058 Amendment II.1 / D4) already did.\n\nRead this as a RULING, not a defect report — that distinction is the reason the entry is worth its length. #5272's re-resolution was internally CORRECT and nothing stale ever leaked from it; the case that retires a rebind on `update()` (the write landing on a row whose pre-image, `readonlyWhen` locks and validation rules were never evaluated) simply did not apply to it. #5574's engine half (PR #6697) therefore left the asymmetry standing on purpose rather than folding a behaviour removal into an ordering change, and filed it as #6752. The 2026-08-09 maintainer ruling on that card closed it on three measured axes instead: compatibility cost zero (a repository-wide grep for assignments into a hook's `input.id`, re-run on the implementing PR's base, found six sites and ALL SIX are this family's own pins — no consumer anywhere repoints); one rule across both verbs beats two individually-correct rules an author has to memorize, since the justification for the split lived in an ADR rather than at the call site; and \"a hook silently redirects which row gets deleted\" is a top-grade footgun for authored — especially AI-authored — handlers however correctly the redirect is implemented. Correctness of a mechanism does not justify the surface it exposes. Aligning the other way, by building `update()` the same re-resolution, stays excluded by #5574's own recorded ruling (\"do not silently pick re-resolution instead\").\n\nWhy this is a D3 semantic TODO and not a D2 conversion, on the same two grounds as `hook-register-empty-object-target-refused` and `hook-context-session-roles-retired` at this step: FIRST, there is no source to convert — a `HookContext` is constructed per write and never persisted, so no `sys_metadata` row, example or template can carry the assignment. SECOND, the only place it is ever SPELLED is inside a handler body: author-written JS/TS, or a sandboxed script whose context is `unknown`. A declarative transform cannot safely rewrite an assignment inside free-form code, and the intent is not recoverable anyway — only the author knows whether the repoint meant \"delete that row INSTEAD\" or \"delete that row TOO\".\n\nWhat makes this one cheaper to meet than its two siblings, and worth saying because it bounds the work: the removed capability has an ENFORCED channel at run time. The refusal throws before anything is written and its message NAMES the retired capability and the three replacement routes, so a handler that still repoints fails loudly and self-describingly on its first execution rather than going quiet. This ledger entry is the channel that reaches an upgrader BEFORE that first execution. #6752, #5272, #5574, PR #6697, ADR-0058 Amendment II.2."
},
{
"surface": "driver aggregate() call argument — query.aggregate and aggregations[].func",
"replacement": "query.aggregations and aggregations[].function — the spellings QueryASTSchema and AggregationNodeSchema have always declared",
"migrationId": "driver-aggregate-undeclared-key-aliases-removed",
"toMajor": 17,
"rationale": "`SqlDriver.aggregate` and `RemoteTransport.aggregate` each read two aliases the Query Protocol has never declared: `query.aggregations || query.aggregate` and `agg.function || agg.func`. \"Never declared\" is measured, not assumed — `git log -S` over `data/query.zod.ts` finds no commit that ever introduced either name, there is no `retiredKey()` tombstone and no alias-table entry for them (the file's only alias table is `SortNode`'s `direction` → `order`), and neither appears in any upgrade guide or release note. So this entry does not record a declared surface being withdrawn; it records a LENIENCY being withdrawn, which is why it is here rather than behind a tombstone. The only writers in this repository were the two driver packages' own fixtures — #4984's family, where a fixture spelling the alias keeps the tolerant limb green forever and no test in existence can go red on its deletion — so ADR-0049 enforce-or-remove applies once those are re-spelt. ⚠️ Do NOT read this across to `dashboard`/`page` measures: `aggregate` IS the canonical key there and `func` IS a declared, loudly-suggesting alias (`DatasetMeasureSchema`, ui/dataset.zod.ts). That neighbouring vocabulary is untouched, and it is the most likely reason an off-repo caller ever wrote these keys on a QUERY — one habit, two surfaces, only one of which declared it. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone: nothing ever ran a query through `QueryASTSchema.parse()` on this path. The enforced channel is tsc at the call site, once the parameter is `DriverQuery` — and for an untyped JS caller there is no enforced channel at all, which is exactly why this ledger entry has to exist: the generated upgrade guide is the only way such a reader learns of the rename. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011). ADR-0049 / ADR-0087, #6321 (PR #6404)."
},
{
"surface": "data.DriverCapabilities.create / data.DriverCapabilities.read / data.DriverCapabilities.update / data.DriverCapabilities.delete / data.DriverCapabilities.bulkCreate / data.DriverCapabilities.bulkUpdate / data.DriverCapabilities.bulkDelete / data.DriverCapabilities.transactions / data.DriverCapabilities.savepoints / data.DriverCapabilities.isolationLevels / data.DriverCapabilities.queryFilters / data.DriverCapabilities.queryAggregations / data.DriverCapabilities.querySorting / data.DriverCapabilities.queryPagination / data.DriverCapabilities.queryWindowFunctions / data.DriverCapabilities.querySubqueries / data.DriverCapabilities.queryCTE / data.DriverCapabilities.joins / data.DriverCapabilities.fullTextSearch / data.DriverCapabilities.jsonQuery / data.DriverCapabilities.geospatialQuery / data.DriverCapabilities.streaming / data.DriverCapabilities.jsonFields / data.DriverCapabilities.arrayFields / data.DriverCapabilities.vectorSearch / data.DriverCapabilities.schemaSync / data.DriverCapabilities.migrations / data.DriverCapabilities.indexes / data.DriverCapabilities.connectionPooling / data.DriverCapabilities.preparedStatements / data.DriverCapabilities.queryCache",
"replacement": "(removed — delete the keys. A driver advertises a capability by implementing the corresponding IDataDriver method; the three bits that survive because method presence cannot carry the signal are `queryDateGranularity`, `autonumber` and `batchSchemaSync`)",
"migrationId": "driver-capabilities-inert-bits-removed",
"toMajor": 17,
"rationale": "The #4484 findStream close-out found `DriverCapabilities.streaming` pointing at a capability the contract no longer declares, and the follow-up audit (#4634) checked every bit in the record the same way, across objectstack and cloud (objectui confirmed clean): of 34 declared bits, THREE have a decision-making reader — `queryDateGranularity` (engine aggregate dispatch + checkDateBucketParity), `autonumber` (engine defers generation to the driver), `batchSchemaSync` (engine ANDs it with method presence, because a subclass can inherit `syncSchemasBatch` from a base whose transport batches while its own cannot) — and THIRTY-ONE were written by every driver and read by nothing. Their `.describe()` strings promised engine adaptation (\"if false, ObjectQL will filter/sort/paginate in memory\") that was never built, and zero readers let the values go WRONG unnoticed: SqlDriver declared `streaming: false` while implementing `findStream`; InMemoryDriver declared `streaming: true` over a full-table read (ADR-0078 false affordance, on the capability record itself). The real mechanism everywhere else is METHOD presence: transactions gate on `driver.beginTransaction`, aggregate pushdown on `typeof driver.aggregate`, schema sync on `typeof driver.syncSchema`, and the REQUIRED CRUD/bulk methods are called unconditionally. A driver is CODE, never stack metadata — `supports` literals live in driver classes and `DriverConfig.capabilities` is plugin TS configuration, neither ever a `sys_metadata` shape (the stack-tree neighbour, `datasource.capabilities`, was retired separately in #4583) — so there is no source for the D2 chain to rewrite and this entry is the D3 record. The keys are tombstoned rather than deleted because `DriverCapabilitiesSchema` is not `.strict()` and IS parsed (DriverConfigSchema / SQLDriverConfigSchema / NoSQLDriverConfigSchema embed it): a plain delete would silently strip a vendor's authored bit, replacing one silent no-op with another. `batchSchemaSync` also drops its `.default(false)` for `.optional()` — absence already meant false at both readers, and the default forced every capability object to spell out 30+ bits. ADR-0049 / ADR-0078, #4634."
},
{
"surface": "SqlDriver.distinct() third argument — any value",
"replacement": "a bare FilterCondition (@objectstack/spec/data) — the same value find() carries under query.where, never a query envelope",
"migrationId": "driver-sql-distinct-bare-filter-typed",
"toMajor": 17,
"rationale": "This entry records a TYPE being added, not a surface being withdrawn, and it says so up front because the distinction decides who has to do anything. `distinct` is not declared on `IDataDriver`, so #5181 / #6075 never reached it and it kept `filters?: any` while its body said something far more specific — `applyFilters(builder, filters)` is handed the ARGUMENT ITSELF, never a `.where` off it. ⚠️ RUNTIME BEHAVIOUR IS UNCHANGED by this entry's change: not one statement moved, so no upgrade breaks at run time and nothing that answered correctly stops. What the annotation removes is a compile-time hole, measured rather than assumed: a truthy NON-OBJECT third argument — `distinct('orders', 'product', 'completed')` — used to type-check and resolve the UNFILTERED set, because `applyFilters` emits no predicate at all for a truthy non-object, non-array filter. A call meaning \"which products among completed orders\" answered with EVERY product, silently. That spelling is now TS2345 at the call site. This is a driver CALL ARGUMENT — code, never stack metadata — so there is no source for the D2 chain to rewrite and deliberately no schema tombstone, the disposition `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540), `actor-user-roles-to-positions` (#6011) and `driver-aggregate-undeclared-key-aliases-removed` (#6321) already carry. ⚠️ It differs from those four in ONE measured way a reader should not have to infer: because nothing changed at run time, an untyped JS caller is not affected BY THE UPGRADE at all. The entry is here for a different reason — such a caller is exactly the one tsc can never reach, and the silent widening above is a defect they may ALREADY be sitting on, before and after this major. The generated upgrade guide is the only channel that reaches them, which is why the fix is written down rather than left to the compiler. ⛔ The reverse mismatch is NOT closed and no type can close it: `FilterCondition` is an open map (`[key: string]: any`) because a filter key IS a field name, so a query envelope `{ object, where }` is structurally a valid filter — one constraining columns named `object` and `where` — and so is a FilterArray. Both reach `distinct` type-checked and are refused at run time, loudly, with INVALID_FILTER / 400. `driver-memory`'s opposite half — where the BARE spelling returns the unfiltered set in silence — stays open under the #5499 freeze (#6320). ADR-0087, #6320."
},
{
"surface": "engine.find(object, { fields }) and engine.findOne(object, { fields }) carrying a dotted entry (`account.name`) — the direct engine path, not the REST ingress",
"replacement": "read the related record with `expand` (`{ expand: { account: { object: '<target>', fields: ['name'] } } }`), keeping the reference column itself in `fields` — the relation is carried by that column and projecting it away leaves expansion nothing to resolve (#7537); or denormalise the value onto the queried object (a stored field, written when the source changes) and name that — the same remedy the REST ingress has prescribed since #7532, and the sort axis since #6924",
"migrationId": "engine-dotted-projection-refused",
"toMajor": 17,
"rationale": "#7532 (PR #7588) closed the PROJECTION axis' dotted leg at the REST ingress (`assertProjectionFieldsExist`, `400 INVALID_FIELD`), which covers everything reaching `findData`. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and that caller set was measured, not assumed (#7589): a flow `get_record` node's authored `fields: ['name', 'account.name']` parses (`GetRecordConfigSchema` restricts nothing), travels verbatim into `data.find(...)`, cleared the engine's head-only projection filter on its head segment (`account` IS a field), and reached the driver as a projection column — where SQL renders `\"account\".\"name\"` against a table that was never joined, the DB answers `no such column`, and the driver's #3821 recovery ladder retries `select('*')`. The caller asked to narrow and silently received EVERY field, byte-identical to no projection at all, pointing away from both FLS and data minimisation.\n\nRuled 2026-08-12 on #7589 (Option B): a dotted entry the engine cannot resolve is refused loudly at the engine's own head-only projection filter, covering every caller that reaches the engine. The check it replaces was justified by a comment claiming the engine resolves relationship paths \"via populate\"; #7601 measured that NO populate step exists — after PR #7617 that comment was the last place in the repo asserting dotted-path resolution does — so what was removed is not a working feature but a path to widening, kept alive by a false premise. The unknown-PLAIN-column tolerance is explicitly KEPT by the same ruling (an unknown plain name still drops silently; an all-unknown projection still falls back to `*`), a registry-less host gets no verdict (the driver-side #3821 ladder remains its documented backstop, and a driver-side carve-out is measured-need only), and a dotted `fields` inside a nested `expand` degrades to an observable warning rather than a refusal — `expandRelatedRecords`' pre-existing graceful-degradation `catch` swallows every expand failure, the same posture the sort axis (#7095) records for the same catch.\n\nThis is a CODE-path API, not stored metadata, so — like `engine-find-formula-order-by-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists: the platform cannot decide between `expand` and denormalisation for the caller, and it must not resolve the path itself — no driver ever did, and inventing a join here is a feature decision, not a migration. #7589, #7532, #7601, #3821, #5918, ADR-0112."
},
{
"surface": "a `where` / filter naming a `formula` field — at BOTH doors: the REST ingress (`assertFilterFieldsExist`, covering everything that reaches `findData`) and the engine seam itself (`engine.find` / `findOne` / `count` / `aggregate` / `update` / `delete`), which saved reports, flows and dashboard widgets reach directly",
"replacement": "denormalise the value onto the object (a stored field, written when the source changes) and filter that — deliberately the same remedy, in the same words, the SORT axis prescribes (#6924 / #6994 / #7095) and the SEARCH axis has prescribed since #6674; `summary` and `autonumber` fields need NO action, because both get real maintained columns and filter correctly",
"migrationId": "engine-find-formula-filter-refused",
"toMajor": 17,
"rationale": "`formula` is the one field type no driver materialises a column for, and FILTER was the last of the three query axes still fail-open on it: SORT refuses it (#6994 at the ingress, #7095 at the engine) and SEARCH refuses it by name (#6674), while a `where` on a `formula` field cleared every gate precisely BECAUSE the object declares the field, reached a driver with no column behind it, and answered 200 with zero rows. Measured on a real `ObjectQL` with `is_open` a `formula` over the stored `status` column: `where {is_open: true}` and `where {is_open: false}` each returned 0 rows with NO error, while the controls `where {status: 'open'}` returned 4 rows and `where {subtask_total: 5}` (a `summary`, which HAS a column) returned 1 row.\n\nBOTH directions are wrong and the `false` one is the dangerous one: the same predicate against a STORED boolean returns every matching row, so a filter meaning \"not yet done\" silently became \"no records at all\" — a row SET changed under a 200, which no amount of inspecting the response can reveal, and the formula READS correctly in that very same response, so the field is visibly populated and simultaneously unfilterable. That is strictly worse than the sort axis it mirrors: a refused sort returns the same rows in a different order, a refused filter changes which rows exist.\n\nBoth doors now refuse it with `400 INVALID_FIELD` (#8296 / PR #8369), naming the offending key path and carrying the remedy sentence — the ingress gate (`assertFilterFieldsExist`, `@objectstack/metadata-protocol`) for everything reaching `findData`, and `assertFilterIsMaterializable` (`@objectstack/objectql`, `filter-comparand-shape.ts`) at the engine's own filter seam, which every caller-supplied `where` passes through whichever verb it arrived by. Both judge the field by the SAME `@objectstack/spec/data` predicate the SEARCH axis uses (`isVirtualSearchField` / `SEARCH_VIRTUAL_TYPES`, which holds `formula` and nothing else), so gate and drivers cannot disagree about which types have a column: a gate widened to the spec's `COMPUTED_VALUE_TYPES` (the WRITE contract) would refuse two working types. DOTTED filter paths are deliberately not judged on this axis at either door.\n\nThis is a CODE-path API, not stored metadata, so — like `engine-find-formula-order-by-refused` and `engine-dotted-projection-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and this ledger entry is the notification channel. No mechanical rewrite exists in either direction: the platform cannot invent the stored column the remedy prescribes, and it must not filter post-hoc instead — `driver.find` has already applied `limit` / `offset`, so a predicate applied after the formulas are evaluated would filter an ARBITRARY PAGE, which looks correct on small result sets and is wrong the moment pagination is involved.\n\nAUTHOR-REACHABLE SURFACES are why this is not merely a code-side note. A saved report's `query.filter` (`sys_saved_report`) is forwarded VERBATIM into `engine.find` by `plugin-reports` (`report-service.ts`, `where: q.filter`), bypassing the ingress gate entirely; flow node `config.filter` and dashboard widget filters are author-written the same way. A report or flow authored to filter on a formula field used to run and quietly return the wrong row set; it now fails loudly, with the remedy in the message.\n\nRegistered on the inherited ruling of #7095 (\"register it anyway\"), re-affirmed for this axis at triage on 2026-08-13 (#8370): the shape is identical to the sort axis and the consequence here is larger. #8296, #8370, #7095, #6994, #6924, #6674, ADR-0112."
},
{
"surface": "engine.find(object, { orderBy }) and engine.findOne(object, { orderBy }) naming a `formula` field — the direct engine path, not the REST ingress",
"replacement": "denormalise the value onto the object (a stored field, written when the source changes) and sort by that — the same remedy the REST ingress has prescribed since #6924 / #6994; a `summary` field is unaffected and still sorts, because it gets a real maintained column",
"migrationId": "engine-find-formula-order-by-refused",
"toMajor": 17,
"rationale": "#4226 / #4256 / #6994 closed the SORT axis at the REST ingress (`assertSortFieldsExist`, `400 INVALID_SORT`), which covers everything reaching `findData`: the list route, `POST /data/:object/query`, the export route and the RPC dispatcher. A caller reaching `engine.find()` / `engine.findOne()` DIRECTLY passed through none of it, and a `formula` ORDER BY there was dropped in silence. Measured on a real driver: `asc` and `desc` came back BYTE-IDENTICAL, in insertion order, under a success, with the rows carrying the very values they were asked to be ordered by. No column exists to order by (a formula is computed on read, so no driver materialises one), so the ORDER BY reached the driver, found nothing, and the unknown-column backstop returned the rows unordered.\n\nRuled 2026-08-10 on #7095: an ORDER BY the engine cannot apply is a 4xx with guidance prose at the public boundary, never a silent drop — the same direction as the analytics dataset refusal envelope and the #6924 sort-hint prescription. The engine's documented internal-caller tolerance (`assertProjectionFieldsExist`'s docblock) was to survive only behind a pinned internal path, and only if a MEASURED internal call site relied on it. The #7095 sweep of every in-tree `orderBy` reaching the engine directly — hooks, flows, reports, queue/job adapters, sharing, metadata loaders, expand sub-reads — found NONE: every hardcoded internal sort names a real stored column (`created_at`, `updated_at`, `version`, `priority`, `scheduled_for`, `started_at`, `next_run_at`, `recorded_at`, `id`), and no shipped object in the repo declares a `formula` field at all. So no internal path shipped, and there is no flag to opt back into the drop.\n\nThis is a CODE-path API, not stored metadata, so — like `hook-register-empty-object-target-refused` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. No mechanical rewrite exists in either direction: the platform cannot invent the stored column the remedy prescribes, and it must not sort post-hoc instead — `driver.find` has already applied `limit` / `offset`, so re-sorting after the formulas are evaluated would reorder an ARBITRARY PAGE, which looks correct on small result sets and is wrong the moment pagination is involved.\n\nONE AUTHOR-REACHABLE SURFACE reaches this indirectly and is why it is not purely a code-side note: a saved report's `query.orderBy` (`sys_saved_report`) is forwarded verbatim into `engine.find` by `plugin-reports`, bypassing the ingress gate. A report authored to sort by a formula field used to run and return rows in an arbitrary order; it now fails loudly, with the remedy in the message. One further path is deliberately NOT a refusal: a nested `expand` sort raises this refusal inside `expandRelatedRecords`, whose pre-existing graceful-degradation `catch` swallows every expand failure and retains the raw foreign keys — so that path moves from silent to OBSERVABLE (a warning naming the field and the fix) rather than refusing. Reversing that backstop is a separate decision on all expand failure modes. #7095, #6994, #6924, #4226, #4256, #3821, ADR-0112."
},
{
"surface": "data.engine.update options.upsert",
"replacement": "(removed — never implemented; express create-if-absent explicitly: `findOne` first, then `insert` or `update` on what you find)",
"migrationId": "engine-update-upsert-retired",
"toMajor": 17,
"rationale": "The `upsert` flag promised insert-if-absent on `engine.update()` but no engine or driver path ever read it: the key was declared on both update-options schemas and allowlisted by the unknown-option gate, yet `ObjectQL.update()` never referenced it and it was not a driver pass-through key — `{ upsert: true }` was accepted and silently dropped and the update stayed a plain update (ADR-0049 declared-but-unenforced). There is no behaviour to preserve and nothing stored to rewrite (it only ever appeared in a call-time option bag). Any future first-class upsert must reconcile with #7867's not-found gate — a by-id update whose id names no row throws RECORD_NOT_FOUND rather than inserting — which is why the flag is removed rather than implemented here."
},
{
"surface": "api.enhancedApiError.fieldErrors",
"replacement": "fields",
"migrationId": "enhanced-api-error-field-errors-renamed",
"toMajor": 17,
"rationale": "The wire has always carried `fields` — the validators, import coercion, validation-failure.ts, @objectstack/client and the console's field-error extractor all say `fields`, and nothing ever emitted `fieldErrors`, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977."
},
{
"surface": "automation.etlPipeline / automation.etlPipelineRun / automation.etlSource / automation.etlDestination / automation.etlTransformation (the whole L2 layer of automation/etl.zod.ts, its four enums and the `ETL` factory — 9 defs, 27 exported names)",
"replacement": "(removed — no protocol surface replaces it, deliberately. Layer by layer: connector-attached synchronisation is `ConnectorSchema.syncConfig` (`integration/connector.zod.ts`), which IS parsed and executed; per-field value transformation on import is `shared/mapping.zod.ts`, whose `transform` is applied row by row by the REST import path and recorded key by key in `packages/spec/liveness/mapping.json`; scheduling is `system/job.zod.ts`. What has NO replacement is multi-source, multi-stage movement with joins and aggregations — because it never had an implementation either. It returns through the ENFORCE route: the engine first, the vocabulary second)",
"migrationId": "etl-pipeline-layer-retired",
"toMajor": 17,
"rationale": "The reading #4738 used to retire L1 `DataSyncConfig`, re-measured one layer up and identical: narrative-only. No engine ever parsed, scheduled or executed an `ETLPipeline`. Measured on origin/main immediately before the removal: the only non-spec references in this repo are two fumadocs-generated documentation sources (`apps/docs/.source/*.ts`), not executors; objectui has no reference at all; there is no `liveness/etl.json` or `pipeline.json`, so no ADR-0049 gate ever had a reading on it — while the same file family's EXECUTED half does have one (`liveness/mapping.json`), which is the contrast that makes the absence meaningful rather than an oversight. The `etl` string in this registry was the one untested link the finding named, and it is not a loader path: it was the id of the #4962 retry-vocabulary entry, absorbed here. The layer was ADR-0078's asymmetry in its purest form — an author could write a complete ten-stage pipeline, get no error, and get no execution. It was also advertised: `packages/spec/docs/SYNC_ARCHITECTURE.md` named `ETLPipeline` as the recommended destination for authors displaced by the L1 retirement (#4738) and listed ten transformation types with copyable examples down to `script | Custom JavaScript/Python`. That document is rewritten in the same change; a retirement whose own doc still recommends the retired layer is self-contradictory, and forwarding L1's authors to a second layer with no executor was the defect compounding rather than closing. ⚠️ `etl-retry-converged-onto-retry-policy` (#4962) is SUBSUMED here, the #4657/#4834/#5055 way: both land in the unreleased protocol 17, so composed, a rename of `retry.maxAttempts` on a shape that does not survive the major has no observable effect — and keeping both would tell an upgrader to rewrite a key on a schema the same upgrade deletes. The `maxAttempts` `retiredKey()` tombstone goes with the shape that carried it, which is strictly stronger than the tombstone: there is no longer a `retry` block to author the key into. Route 3 — no carrier key, no parse site, so no D2 conversion and no tombstone; RETIRED_DEFS_BY_MAJOR plus this entry are the declaration. ADR-0049, ADR-0078, #6414."
},
{
"surface": "security.permissionSet.objects[].allowExport (ABSENT — a permission set that never declared the key)",
"replacement": "an explicit `allowExport: true` on the object entry (or the `*` wildcard) of every permission set whose holders are meant to keep exporting",
"migrationId": "export-axis-opt-in",
"toMajor": 17,
"rationale": "A secure-default FLIP, not a shape change — the same class as `rest-requireauth-default-flip` (ADR-0056 D2, protocol 12) and `action-descriptor-resume-authority-default-flip`, and it is registered for the same reason those are: the metadata is UNCHANGED and still parses, so no gate anywhere will tell an upgrader that what it MEANS has inverted. Before 17, `allowExport` unset inherited read; from 17 it denies. Reading a record and taking a bulk machine-readable copy of the whole table are different privileges — Salesforce \"Export Reports\", Dynamics \"Export to Excel\", NetSuite \"Export Lists\" and SAP `S_GUI` 61 all separate them — and the axis now says so. This cannot be a mechanical conversion in either direction: writing `allowExport: true` wherever the key is absent would preserve today's behaviour while silently defeating the entire point of the flip, and writing `false` would revoke a capability the deployment may legitimately want. Whether a given set's holders SHOULD be able to take a bulk copy is exactly the segregation-of-duties judgement the axis exists to make explicit, and it belongs to the operator. Two details decide who is actually affected: package-shipped sets are re-seeded on upgrade, so the built-ins are handled — at 17.0.0 `admin_full_access` and `organization_admin` carried the grant explicitly, ON A `*` WILDCARD, and protocol 18 REMOVES it (see `admin-export-wildcard-removed`: the wildcard made the axis undeniable for an org admin, so from 18 an admin exports only what an app set grants — do not read this clause as a standing promise that the built-ins keep exporting) — while ENVIRONMENT-AUTHORED sets are not and must be edited by hand. `member_default` deliberately does NOT carry the grant, so ordinary authenticated users lose export until an admin grants it; that is the point of the flip, not an oversight. Merge semantics are unchanged and most-permissive, exactly like the CRUD bits: any set granting `true` grants export, and `false` is authoring intent rather than a veto, because permission sets are additive capability containers (ADR-0090). The super-user bits no longer confer it: `viewAllRecords` / `modifyAllRecords` are \"may see all data\", not \"may take a bulk copy\". Registered by the #6350 stock reconciliation; #3544 / #3710 predate the #6148 completeness gate. ADR-0087, #3544 / #3710 (backfilled #6350)."
},
{
"surface": "@objectstack/rest: ExportFieldMeta.required / .system / .readonly / .hasDefault / .min / .max / .minLength / .maxLength (the map built by `buildFieldMetaMap`, reached as `PreparedImport.metaMap` from `prepareImportRequest`)",
"replacement": "the object schema you already hold — read `fields[name].required` / `.system` / `.readonly` / `.defaultValue` / `.min` / `.max` / `.minLength` / `.maxLength` off the same `ObjectSchema` you passed to `buildFieldMetaMap`, which is where the ENGINE reads them and therefore the only copy that cannot drift",
"migrationId": "export-field-meta-constraints-retired",
"toMajor": 17,
"rationale": "ADR-0049 enforce-or-remove. These eight were never a source of truth: `buildFieldMetaMap(schema)` DERIVED each one from the very `schema` its caller passed in, so the map carried a second copy of facts the caller already held. They existed for exactly one consumer — the import dry run's hand-copied pre-check mirror (`firstMissingRequiredField` / `firstConstraintViolation`, framework#3956) — and #4633 ruling D retired that mirror (PR #6532): the dry run now asks `DataProtocol.validateData` for the engine's verdict, which reads the object's own schema. That left all eight computed on every import and read by NOTHING, which is the declared-and-unread shape ADR-0049 exists for; a constraint vocabulary standing next to the presentation one with no enforcer behind it is precisely the thing an AI-authored consumer mistakes for a contract. Verified zero-reader before removal, per key and by type, across this repo (`packages/rest` itself, and all five in-repo dependents of `@objectstack/rest`: runtime, cli, verify, plugin-auth, plugin-dev) and the `objectui` sibling; plugin-auth's identity import forwards `prepared.metaMap` into `runImport` but reads only the presentation keys through `coerceRow`. Why this needs a ledger entry despite that sweep: it is the `findStream` (#4484) / `IStorageService.list` (#5540) / `actor-user-roles-to-positions` (#6011) disposition — a published TS surface with NO spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry a prescription, and the ledger is the only channel that reaches an upgrader. It is if anything blinder than those three: the keys shipped in a FINAL release (`@objectstack/rest` 14.5.0) and have been published in every release since, and because they were OPTIONAL keys on an interface that itself survives, a JavaScript consumer reading `meta.required` after the upgrade gets `undefined` with no error at all — tsc reports at the read site only for a typed consumer. Why D3 semantic and not a D2 conversion: there is nothing to convert. No authored or stored metadata changes shape — `required` / `min` / `maxLength` and the rest remain fully authorable on a field definition and fully enforced by the engine, which is where they always lived. The only place these eight are ever spelled is inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach them. ADR-0049 / ADR-0087, #6536 (the sweep PR #6532 deliberately deferred)."
},
{
"surface": "data.externalLookup / data.externalDataSource / data.externalFieldMapping (the whole of data/external-lookup.zod.ts — 3 defs, 8 exported names) and system.messageQueue (the whole of system/message-queue.zod.ts — MessageQueueConfig, MessageQueueProvider, TopicConfig, ConsumerConfig, DeadLetterQueue — 5 defs, 14 exported names)",
"replacement": "(removed — there is no replacement key, because there was never a key: neither family was reachable from any metadata-type binding, stack collection or /meta door, so no document could carry either. For external data: `object.external` (`ObjectExternalBindingSchema`, ADR-0015/0062) names a datasource by reference and connection credentials live in the datasource config — never inline in object metadata; `data/external-catalog.zod.ts` is that federated path's catalog surface and is untouched. For message queues: the LIVE surface is `kernel/events/integrations.zod.ts`'s `EventMessageQueueConfig` (`EventBusConfig.messageQueue`), which deliberately carries NO credential field — broker connection and SASL credentials are runtime deployment configuration, not authorable metadata. Either capability returns via the ENFORCE route of ADR-0049 through a new ADR — the executor / broker admin service first, the vocabulary second)",
"migrationId": "external-lookup-message-queue-families-retired",
"toMajor": 17,
"rationale": "Both families are the #8075 census verdict (fork (b), accepted 2026-08-12): security-shaped declared surface with inline-credential sinks and ZERO consumers. `ExternalDataSourceSchema.authentication.config` is a record of unknown whose own docblock example wrote `\"clientSecret\": \"...\"` inline, and `MessageQueueConfigSchema.sasl.password` was a required inline broker credential — the #7990 class (cleartext-at-rest credential sinks), except that unlike #7990's two measured surfaces nothing ever persisted these: no metadata-type binding (kernel/metadata-type-schemas.ts imports neither module), no stack collection, no object/field embedding (`object.external` binds `ObjectExternalBindingSchema` — remoteName/remoteSchema/writable/columnMap, no authentication), and zero imports outside packages/spec repo-wide, with the corpus-reach control (`DatasourceSchema` under identical exclusions) returning hits in the same run. The consumed MQ near-namesake `kernel/EventMessageQueueConfig` deliberately has no credential key, so the consumed shape had no credential and the credential-bearing shape had no consumer. A dead schema minus one field is still a dead schema, so the whole declarations go, not just the credential faces (#3950: an exported schema with no consumer reads as a capability to whoever finds it — here it read as an invitation to author secrets in cleartext). With no carrier key there is nothing to tombstone and no source or `sys_metadata` row for a D2 conversion to rewrite: route 3, the #4834 / #4988 / #5055 / #6486 shape — RETIRED_DEFS_BY_MAJOR plus this entry ARE the declaration. ⚠️ The #5552 `data/ExternalFieldMapping:transform` tombstone (one of that retirement's three spellings) is SUBSUMED by the def retirement, the WidgetManifest.performance way: it goes with the shape that carried it. The base `shared/FieldMapping` tombstone and the `integration/ConnectorFieldMapping` spelling are untouched and still reject `transform` with the #5552 prescription. ⚠️ The #7990 Option-B reopen trigger (\"a third measured artefact-type surface\") is NOT met by this census — that ruling's parked class-level write-boundary guard stays parked; this is the ADR-0049 leg of the fork the triage pre-agreed."
},
{
"surface": "PUT /api/v1/meta/field/{object}.{name} (runtime-authored standalone `field` items)",
"replacement": "Author the field inside its object and write the whole object — PUT /api/v1/meta/object/{object} with the new field in `fields` — or declare it in the object source (`**/*.object.ts`) and redeploy",
"migrationId": "field-runtime-create-withdrawn",
"toMajor": 17,
"rationale": "The `field` registry entry declared `allowRuntimeCreate: true` and the platform never built a read path for it. Measured end-to-end through the real HttpDispatcher -> ObjectStackProtocolImplementation -> SysMetadataRepository (#7893): `PUT /api/v1/meta/field/showcase_task.zz_probe` answered 200 with {\"success\":true,\"state\":\"active\",\"message\":\"Saved field …\"}, the row persisted, and `GET /api/v1/meta/object/showcase_task` then listed fields = [title, status] with zz_probe ABSENT — forever. The row is even self-readable by name (`GET /meta/field/showcase_task.zz_probe` -> 200, `_diagnostics.valid: true`), which makes it well-formed and universally inert rather than malformed. The seam is that `field` is the ONE declared type with no standalone existence: fields are authored inside the object (`ObjectSchema.fields`), a `field` write mints a SEPARATE row keyed ('field','<object>.<name>'), and nothing composes fragment rows into their parent — `applyRegistryWriteThrough` routes only `type === 'object'`, and `filePatterns` (`**/*.field.ts`) match nothing in any app. A declared capability the platform cannot honour is ADR-0049 false compliance, and the maintainer ruled REMOVE on 2026-08-12 rather than build the read path, which is a feature spanning at least three packages (a composition step that does not exist, ~20 `gate.fields` call sites, physical schema/migrations, and cold boot via `loadMetaFromDb`); if ever wanted it is a separate card — implementation first, declaration second. ⚠️ This is NOT the #5488 (`api`) rationale reused: that ruling rested on \"zero business pull\", and \"add a field\" is the opposite — a core Studio/CRM operation. The justification here is that the operation REMAINS AVAILABLE on the route that actually composes: `object` keeps `allowRuntimeCreate: true`, so what is withdrawn is a second, broken SPELLING of adding a field, not the ability to add one. There is NO D2 conversion, for the reason this list exists: nothing in an authored source spells this key. `allowRuntimeCreate` is a PLATFORM registry value, not an authorable one, and no authored source changes — an `**/*.object.ts` file valid before this change is valid after it, byte for byte. What changed is a runtime HTTP verdict, so it is one semantic TODO for operators and Studio callers rather than a stack conversion — the same disposition `api` (#5488) and `BatchOptions.validateOnly` (#4052) take. ADR-0049 / ADR-0087, #7893 (split from #7743)."
},
{
"surface": "data.filter $regex / $options — in a STORED filter (dashboard widget filter and globalFilters, report runtimeFilter, page and component filter, solution-blueprint filter), and equally in the where clause of a query request",
"replacement": "$icontains for the case-insensitive substring match this was almost always used for, or $contains for a case-sensitive one — a pattern that genuinely needs a regular expression has no filter-level replacement",
"migrationId": "filter-regex-options-retired",
"toMajor": 17,
"rationale": "Like `driver-aggregate-undeclared-key-aliases-removed` and `driver-sql-distinct-bare-filter-typed`, this entry records a LENIENCY being withdrawn rather than a declared surface: `$regex` was never in `FILTER_OPERATORS` and never a key on `StringOperatorSchema`. That is measured, not assumed — `git log -S'$regex'` over `packages/spec/src` returns only doc comments describing how `$contains` LOWERS to MongoDB (`Contains substring - SQL: LIKE %?% | MongoDB: $regex`), plus #5701 itself, which added the name solely as `RETIRED_FILTER_OPERATORS` prescription data. ⚠️ But it differs from those two in the one way that decides the disposition, so a reader should not have to infer it: those were driver CALL ARGUMENTS, code and never stack metadata, whereas a filter IS stored metadata. `FilterConditionSchema` is an OPEN RECORD (`z.record(z.string(), z.unknown())`) because a filter key is a field name, so a stored `{ name: { $regex: 'acme.*' } }` parses GREEN and always will — a `retiredKey()` tombstone cannot exist on an open map, which is exactly why the ledger has to carry this. What such a stack used to get was four different answers from four backends: `driver-sql` and Turso's remote transport compiled it to a LIKE-escaped SUBSTRING (so `a.b` matched only the literal `a.b` and the regex was silently never a regex), `driver-memory` and objectql's `having` ran it as a real `RegExp` (so the same filter also matched `axb`, and an INVALID pattern was caught and answered `false` — zero rows, in silence), and `driver-mongodb` refused it with a bare `Error` carrying no `code` and no `status`. It is now refused everywhere with INVALID_FILTER / 400 naming the replacement. There is deliberately NO D2 conversion and this sits in `semantic` rather than among the mechanical transforms: rewriting `$regex` to `$icontains` is NOT lossless in either direction — a regex metacharacter becomes a literal — so an auto-applied rewrite would silently change which rows a dashboard, report or permission filter selects, a wrong number rather than a missing one. Choosing the substring the pattern MEANT is a judgment about the query, not a transform. ⚠️ This entry covers BOTH HALVES of the #4706 ruling (B), not just the driver one: the contract half (#5701 — the `$icontains` declaration, the `$contains` family pinned case-sensitive, and the `RETIRED_FILTER_OPERATORS` prescriptions) landed before the ADR-0087 disposition gate (#6148) existed and so was never asked for a ledger entry; the driver half (#5702) is where the refusal became executable. One surface, one entry, registered from the half that made it observable. ADR-0049 / ADR-0087, #4706 / #5701 / #5702."
},
{
"surface": "flow.errorHandling.maxRetries (under strategy: 'retry')",
"replacement": "an explicit count >= 1 (e.g. maxRetries: 3), or strategy: 'fail'",
"migrationId": "flow-retry-max-retries-required",
"toMajor": 17,
"rationale": "maxRetries had two defaults — FlowSchema `.default(0)` and the engine's `maxRetries ?? 3` — so an unstated count retried 0 times through the schema and 3 times through a hand-built definition (#4247). With the engine's copy removed the unstated count is unambiguously 0, and retrying zero times is exactly `strategy: 'fail'`, so the schema now refuses the combination instead of it silently doing nothing. There is no lossless rewrite: 0 preserves the behaviour a parsed flow got but contradicts what its author wrote, and any positive count is a NEW decision about re-running the whole flow with its side effects. That choice is the author's."
},
{
"surface": "data.hookContext.session.roles",
"replacement": "(removed — gate on `session.userId` / `session.isSystem`; for PRIVILEGE ask the security service, which reads `permissions` / `positions` / posture off the execution context, ADR-0095 D3)",
"migrationId": "hook-context-session-roles-retired",
"toMajor": 17,
"rationale": "Declared on the runtime hook context, read by exactly two consumers, produced by nobody. The two readers were the approvals record lock and the delegation write guard, each opening with `session.roles?.includes('admin')`; ObjectQL's `buildSession()` builds the session field by field and has never written `roles`, and nothing else feeds a HookContext in objectstack, cloud or objectui (cloud's hook consumers read `hookContext?.session?.userId`; objectui's `roles` are the `/auth/me` user payload, a different surface; an ACTION body's `ctx.session` is a different untyped object that does carry `roles`, tracked apart and unaffected). Both branches were therefore dead on every real engine path — an authorization decision in shape only, and a second admin dialect competing with the one ADR-0090 D3 / ADR-0095 D3 sanction. #4839 (PR #5049) removed the readers; this removes the declaration, per ADR-0049 enforce-or-remove. This is a RUNTIME context, not stored metadata: the engine builds a HookContext per operation and nothing persists one, so no `sys_metadata` row, example or template can carry the key and there is no source for the D2 chain to rewrite — the `openApi31` (#4579) / `activationEvents` (#4657) shape, one semantic TODO rather than a stack conversion. The key IS tombstoned (`HookContextSchema` is deliberately not `.strict()` — a plain delete would strip it silently, #3733 / ADR-0104), so a consumer that parses a context it was handed still meets the prescription. ADR-0049, #5050."
},
{
"surface": "engine.registerHook(event, handler, { object: '' | [] | [''] }), and a scope whose `excludeObjects` cancels its `object` entirely",
"replacement": "name the object(s) — `object: 'account'` / `object: ['account', 'contact']` — or, for a global hook, `object: '*'` or no `object` key at all; for a cancelled scope, widen `object` or drop the overlapping names from `excludeObjects`",
"migrationId": "hook-register-empty-object-target-refused",
"toMajor": 17,
"rationale": "#4281 ruled that an empty hook target is not \"no target\" and closed the shape at the two METADATA doors — `HookSchema.object`'s refine and `hook-binder.ts`'s `normalizeObjects`. `engine.registerHook`, the CODE door, goes through neither, so all three spellings still registered, each producing a defect the author did not write: `''` is FALSY, so the allow face was skipped entirely and the entry became a GLOBAL hook (#4281's headline failure mode — blank intent taking the broadest possible blast radius); `[]` and `['']` are truthy but admit no object name, so the entry could never fire. #5928 then added the `excludeObjects` face, which brought a fourth shape reached by arithmetic rather than by one bad name: an `object` list every member of which is also excluded admits nothing, so that entry can never fire either. All four are ADR-0078 silently-inert declarations, and all four are now refused at REGISTRATION.\n\nNo mechanical rewrite exists, in either direction. The refused values carry no recoverable intent — `object: ''` could have meant `'*'` (what it actually did) or a specific object name the author forgot to fill in, and those are opposite registrations; choosing between them is a judgment the chain cannot make. Nor could the MATCHING read be changed instead: teaching the matcher that `''` is an unmatchable name would silently convert a hook firing on every object into one firing on none — the same class of defect pointing the other way, which is why #5928 declined to do it in passing.\n\nThis is a RUNTIME registration API, not stored metadata, so — like `hook-context-session-roles-retired` at this step — there is no `sys_metadata` row for the D2 chain to rewrite and the ledger entry is the notification channel. One metadata surface reaches it INDIRECTLY and is the reason this is not purely a code-side note: a `record-change` flow's start node forwards `config.objectName` verbatim into `registerHook` (`RecordChangeTrigger.start`), so a flow authored with a blank `objectName` used to bind a trigger to EVERY object in the tenant. It now fails to bind instead, loudly — the automation engine's per-flow bind guard warns and the `kernel:bootstrapped` binding audit re-reports it — which is the correct end state, but it is an observable change for that flow. #6573, #4281, #4001, #5928, ADR-0078."
},
{
"surface": "observability.SEMCONV.httpRequestErrorsTotal (the published metric name http_request_errors_total{method,route}, and its emission from the runtime dispatcher's per-route wrapper)",
"replacement": "the 5xx rate is `http_requests_total{status=~\"5..\"}` — the TRANSPORT emits that family through the `IHttpServer.afterResponse` seam, so it covers every inbound surface; unhandled-exception rate specifically, which is the one thing the retired counter uniquely reported, is the `errorReporter` (Sentry / Datadog / your adapter), which still fires on every 5xx throw",
"migrationId": "http-request-errors-total-retired",
"toMajor": 17,
"rationale": "ADR-0049 enforce-or-remove, on a DECLARED-not-enforced metric name. `SEMCONV` published `http_request_errors_total` as part of a stable namespace declared \"so hosts can wire alerts/dashboards against it\", but the only emitter was `@objectstack/runtime`'s `instrumentRouteHandler`, applied only by the dispatcher's own route Proxy — so the series never saw auth's `getRawApp()` mount, the REST data API via `RouteManager`, or any other inbound surface. Its two siblings in the same family were moved to the transport seam (#9650/#9835 for the counter, #9834/#10004 for the histogram) and this one could not follow: `HttpResponseObservation` carries `{method, routePattern, status, elapsedMs}` and NO throw signal of any kind, so every transport-side shape would have counted a DIFFERENT population rather than the same one more widely. The divergence was measured in both directions — the dispatcher answers its own errors through `errorResponseBase`, which sets a status and does not re-throw, so the old counter MISSED those, while its `catch` incremented unconditionally, so a thrown 4xx WAS counted as an error. And `http_requests_total` already carries a `status` label, so a status-class error counter would be fully derivable from data the transport already publishes. Maintainer ruling 2026-08-20 (option C of four presented, over B \"move it to the transport as a status class\" and D \"keep it dispatcher-scoped and rename it\"): RETIRE. A metric NAME is a RESPONSE surface, not authorable metadata — no stack, example or template carries it, so there is no source for a D2 conversion to rewrite and no schema to tombstone; a host names the series in its own dashboard or alert file, outside this repo. That is exactly why this entry exists: for an operator whose Grafana keys on the string, the ledger is the only notification channel there is. Same disposition, and the same reason, as `runtime-httpserver-wrapper-retired` (#5122) and `enhanced-api-error-field-errors-renamed` (#3977). ADR-0049 / ADR-0087, #9834."
},
{
"surface": "system.serverEvent / system.serverEventType / system.serverCapabilities / system.serverStatus (the lifecycle-event, capability-report and status vocabulary of system/http-server.zod.ts — 4 defs, 8 exported names)",
"replacement": "(removed — there is no replacement key, because there was never a key. Server lifecycle is the transport plugin's own start/stop seam; per-request and per-server observability is `system/metrics.zod.ts` and `system/logging.zod.ts` (plus `OS_SERVER_TIMING` for timings), and liveness is the `/health` endpoint. What a transport plugin can DO it states by implementing the kernel plugin contract — the seams it registers are the capability statement, and a self-described capability record can only disagree with them. Server-level configuration that IS authorable lives on `defineStack({ server })` / `StackServerConfigSchema`, which is unaffected)",
"migrationId": "http-server-runtime-vocabulary-retired",
"toMajor": 17,
"rationale": "The second and final ADR-0049 pass over `system/http-server.zod.ts`. #4938 removed the CONFIG half (`HttpServerConfigSchema`, nine keys, zero readers, zero authoring entry); this removes the RUNTIME half — a 7-member lifecycle event union with a timestamped envelope, an eight-boolean capability report, and a five-state status record with connection and request counters. Nothing ever emitted, consumed or parsed any of them. This card was HELD for four days rather than queued, on a specific and legitimate doubt: a response/capability vocabulary can be a REFERENCE surface for host implementers, so \"zero consumers in this repo\" is weaker evidence for one of those than for an authorable key (the CSS-variable rebuttal). The hold was lifted by measuring the reference reader itself rather than by re-running the same grep: `plugin-hono-server`, the one in-tree host implementation, neither implements nor reports any of the three — it names no capability record, no status shape and no event union, and what it registers is routes and middleware through the kernel plugin contract. A declaration-site grep put every declaration in this one file, a quoted-name sweep across objectstack and objectui found no reader outside it, and the control passed in the SAME run: `MiddlewareConfig`, declared twelve lines away, resolves to `packages/runtime/src/middleware.ts`. So the sweep could see a reader in this file when there was one. With no carrier key there is nothing to tombstone, and with no author there is no source or `sys_metadata` row for a D2 conversion to rewrite: RETIRED_DEFS_BY_MAJOR plus this entry are the declaration — route 3, the same shape as #4938 in this very file, #4834, #4988 and #5055. If host-implementer conformance becomes a real requirement it returns through the ENFORCE route: an adapter contract with a checker behind it, vocabulary second. ADR-0049, #5295."
},
{
"surface": "api.ImportRequest runAutomations — the declared default of the key on BOTH import bodies, POST /api/v1/data/:object/import (ImportRequest) and its async twin POST /api/v1/data/:object/import/jobs (CreateImportJobRequest, which IS the same schema object). It was declared default(false) and described as \"off by default for bulk\"; it is now default(true), which is what the server has always done",
"replacement": "an explicit runAutomations: false on any import request that is meant to load rows without firing triggers/hooks. That spelling is unchanged and has always been the only one the server read — what changes is that omitting the key now DECLARES what it already DID. Callers who want automations on need write nothing",
"migrationId": "import-run-automations-declared-default-corrected",
"toMajor": 17,
"rationale": "A DECLARATION corrected to match a runtime that did not move — the inverse of a behaviour flip, and registered here for the reason protocol 12's `rest-requireauth-default-flip` and this major's `action-descriptor-resume-authority-default-flip` are: whether a given import was meant to fire triggers is a judgment no transform can make, so the prescription is a TODO rather than a rewrite. The server decides in import-prepare.ts with `body?.runAutomations !== false`, i.e. an omitted flag runs automations, and has since #2922 — automations always ran on import historically (the engine ignored the flag entirely before then), so opt-out was made the explicit act, matching platform convention. The schema said the opposite in both machine-readable and human-readable form, and both SHIPPED: `.default(false)` in `@objectstack/spec`'s JSON Schema, and the describe prose in the published reference tables for both defs. ⚠️ Nothing in this repo reconciled the two and NO deployed caller changes behaviour: no request path parses an import body through this schema — the route reads the raw body, and the sole reference to `CreateImportJobRequestSchema` is the declarative `ImportJobApiContracts` catalog entry, a declaration and not a parse. That is exactly why this needed a ruling rather than a docs edit: the divergence was unobservable in-tree and observable only to a consumer OUTSIDE it. A client or SDK that validated its request through the published schema materialised `runAutomations: false` from the declared default and sent it explicitly, and the server honoured it — so the same request body produced opposite behaviour depending on whether the caller validated before sending, with the validating caller silently losing its triggers. Nothing rejected it, nothing warned, and the reference page told an author the wrong thing in the other direction. There is deliberately NO schema tombstone and no D2 conversion: no key is removed, and an HTTP request body is neither authored nor persisted — the same disposition `notification-list-cursor-retired` (#6361) takes for the sibling default on this major, and `batch-options-validate-only-retired` before it. The declared move itself is recorded mechanically, per key, in DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` fingerprints are re-derived on every build. Maintainer ruling 2026-08-09 (#6704, disposition A: the spec follows the runtime). ADR-0049 / ADR-0078."
},
{
"surface": "job.retryPolicy.maxRetries (> 10) / job.retryPolicy.backoffMultiplier (< 1)",
"replacement": "maxRetries <= 10, and backoffMultiplier >= 1",
"migrationId": "job-retry-policy-constraints-tightened",
"toMajor": 17,
"rationale": "The converged RetryPolicy (#4661) keeps the automation side's bounds, which the job side never had: `maxRetries` is capped at 10 and `backoffMultiplier` floored at 1. Neither has a lossless rewrite. Clamping `maxRetries: 20` to 10 would halve a retry budget its author chose, and a `backoffMultiplier` below 1 describes a delay that SHRINKS on each attempt — retrying a failing dependency ever faster, which is the opposite of backoff and was never a shape the engine meant to offer. Both now fail at parse time with the bound named, rather than being silently reinterpreted. Choosing the replacement count (or accepting the cap) is the author's call."
},
{
"surface": "api.listNotifications cursor — the key on BOTH halves of GET /api/v1/notifications (ListNotificationsRequestSchema and ListNotificationsResponseSchema) and the cursor argument of the client SDK call client.notifications.list(). The same entry covers the limit default: the request schema no longer declares default(20)",
"replacement": "a larger `limit` — the route answers the newest N notifications and has no page 2. There is no replacement for `cursor`, deliberately: nothing ever minted one, so no caller holds a value to carry over. Callers that looped on it were re-reading the first window and should read one window sized to what they display (the Console bell polls exactly this way). For the removed `limit` default, send the number you want explicitly if you were relying on 20 — omitting it takes the server window, which is 50 on the platform inbox and clamped into 1..200, and has been since before the declaration existed",
"migrationId": "notification-list-cursor-retired",
"toMajor": 17,
"rationale": "One capability, both halves, never half-deleted (maintainer ruling 2026-08-07, Option A, ruled jointly with #6363). `cursor` was declared on the request and on the response and honoured on neither: the dispatcher domain reads `read` / `type` / `limit` and nothing else, and no emit site has ever written the response key. It was worse than inert because it had a shipped PRODUCER — the SDK appended it to the query string — so a caller paginating by the published contract looped on page 1 forever, with no error and no 400. Measured over a real boot with 60 unread before the removal: page2 === page1, both parsing green against the response schema, which is why no conformance gate could see it. This is `data.query.cursor` (#4286, `query-cursor-retired`) one layer up, with the same verdict for the same reason, down to deleting the SDK producer alongside the key. A first-class inbox cursor, if one is ever designed, will be a response-minted opaque token — a different API — so keeping this one preserved a wrong design rather than a roadmap. The `limit` default goes with it because the FICTION WAS THE MECHANISM, not the number: no request path parses a query string through this schema (#3899 wired the catalog's requestSchema to the real entry for BODIES only), so `.default(20)` never stamped anything onto anything, and the server has always applied its own 50. Re-spelling 20 as 50 — the other arm the ruling allowed — would have kept a declaration that does not execute and merely made it coincide with the implementation until someone moved the clamp; `.optional()` plus prose is true about both the schema and the server. No constraint (`.int()` / `.max(200)`) is declared either, because the service CLAMPS an out-of-range limit rather than refusing it, and declaring a rejection the wire does not perform is the same defect mirrored. Route 2, and the split is worth stating exactly because the two halves of the bookkeeping go different ways. There IS a tombstone: both schemas are non-strict, so a bare deletion would have made Zod SILENTLY STRIP whatever a caller kept sending — a clean parse and a parameter that never takes effect, which is this issue's own defect re-created one layer down (#3733, ADR-0104). So `cursor` is `retiredKey()` on both halves, typed `never` for tsc and raising the prescription at any parse, and both keys are registered in RETIRED_KEYS_BY_MAJOR[17]. There is NO D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and these two shapes are HTTP-only — nobody authors a `ListNotificationsRequest` and nothing persists one. Request AND response shapes: two semantic TODOs for API callers, no stack conversion — the same disposition `BatchOptions.validateOnly` (#4052) and the `AnalyticsQueryRequest` envelope keys already take in this major. The `limit` default is declared separately and mechanically, in DEFAULT_CHANGES_BY_MAJOR[17] (#4666), whose `from`/`to` fingerprints are re-derived on every build. ADR-0049 / ADR-0078, #6361."
},
{
"surface": "protocol.deletePackage({ packageId }) with no `organizationId` (and its transport, DELETE /api/v1/packages/:id)",
"replacement": "explicit `allTenants: true` for a cross-tenant uninstall, or an `organizationId` to scope it",
"migrationId": "package-uninstall-explicit-all-tenants",
"toMajor": 17,
"rationale": "An uninstall that named no organization matched EVERY organization's rows — measured at 5 of 5 deleted, including a foreign org's (#7705, #7780). That width was never chosen; it fell out of a missing argument, and the two transports of the same route disagreed because of it. In protocol 17 the call is REFUSED instead: neither `organizationId` nor `allTenants: true` answers 400 `TENANT_SCOPE_REQUIRED` and deletes nothing, as does supplying both (they are contradictory, not redundant). Whether a given caller meant \"this tenant\" or \"every tenant\" is an intent no transform can recover: `resolveActiveOrganizationId` is catch-wrapped, so an accidental org-less call and a deliberate environment-wide one are byte-identical at the call site — which is the whole reason the parameter had to become explicit rather than conventional. Nothing in authored metadata spells this: it is a runtime call-site contract, so it is one semantic TODO for operators and API callers rather than a stack conversion — the same disposition `rest-requireauth-default-flip` (#12) takes for its own default flip."
},
{
"surface": "kernel.dynamicLoadRequest.activationEvents / studio.studioPluginManifest.activationEvents",
"replacement": "(removed — delete the key. Every plugin activates immediately on load/registration, which is the only behaviour that has ever existed; `activate()` still runs at registration time. Lazy activation, if built, returns via the enforce route of ADR-0049 through a new ADR, with a vocabulary its executor actually honours)",
"migrationId": "plugin-activation-events-retired",
"toMajor": 17,
"rationale": "Both `activationEvents` keys — and the `ActivationEventSchema` trigger vocabulary they embedded (`onCommand` / `onRoute` / … / `onView` after the #4653 convergence) — promised lazy plugin activation (\"plugins remain dormant until an activation event fires\") that no runtime in objectstack, cloud, cloud-v1 or objectui ever implemented: nothing anywhere read the key, every plugin activates immediately, and cloud-v1's own ROADMAP recorded lazy activation as unimplemented (planned v0.4.0). That is the ADR-0049 false-compliance shape in the semantically-lying direction: an author writing `activationEvents: [{ type: 'onMetadataType', pattern: 'flow' }]` expected deferral and got eager activation with a clean parse. Neither parent shape is stored metadata — `StudioPluginManifest` is TS configuration parsed by `defineStudioPlugin` (a root schema, never part of a stack tree) and `DynamicLoadRequest` is a runtime request shape with no caller — so no `sys_metadata` row can carry the key and there is no source for the D2 chain to rewrite; this entry is the D3 record. The kernel key is tombstoned via `retiredKey()` (its schema is not `.strict()`; a plain delete would strip an authored value silently), the studio key is rejected by the strict manifest parse with a guidance prescription (as are its former VS Code-flavoured aliases `activation` / `events` / `onActivate`), and the orphaned `ActivationEventSchema` / `ActivationEvent` exports are removed from `./kernel` and `./studio` with the keys (#3950: an exported schema with no consumer is read as a capability). #4657. SUPERSEDED ON THE KERNEL SIDE by #4834 (same unreleased major): the whole `DynamicLoadRequest` shape — and the rest of the plugin-runtime family with it — was removed, which took this key's `retiredKey()` tombstone with it. That is strictly stronger than the tombstone, not weaker: there is no longer a `DynamicLoadRequest` to author the key INTO, so the prescription an author needs is no longer \"delete this key\" but \"this request shape does not exist\" (see `plugin-runtime-family-retired`). The studio half of this entry is unaffected and still enforced by the strict manifest parse."
},
{
"surface": "manifest.loading (the whole block: strategy / preload / codeSplitting / dynamicImport / initialization / dependencyResolution / hotReload / caching / sandboxing / monitoring)",
"replacement": "nothing to re-declare — delete the key. Plugins are composed at boot: `defineStack` registers them and the kernel runs `init` then `start` in an order topologically resolved from each composed plugin's own `dependencies` / `optionalDependencies` (`resolvePluginOrder` in `packages/core/src/plugin-order.ts`). For the isolation `loading.sandboxing` appeared to configure, use the plugin trust tier (`manifest.runtime`, ADR-0025 §3.6) and the manifest permission declarations, which are the surfaces the platform actually enforces",
"migrationId": "plugin-manifest-loading-retired",
"toMajor": 17,
"rationale": "ADR-0049 enforce-or-remove; maintainer ruling 2026-08-04 on #4914. The block declared a complete plugin loading policy and NOTHING read it. A bare-name scan of all three repos — objectstack, cloud (measured 2026-08-09) and objectui (measured at pickup), each with a control probe proving the scan saw the tree — put every hit inside `packages/spec` itself: this module's own declaration, its own unit tests, the `Manifest.loading` embed and the generated artifacts. `manifest.loading.*` had zero readers in `packages/core`, `packages/runtime` and `packages/metadata`. So the key parsed, entered the manifest, and changed nothing — #3950, at the scale of a whole block. What made it outrank ordinary inert-key cleanup is `sandboxing`: it declared process / vm / iframe / web-worker isolation, IPC transports and an `allowedServices` ACL, so an AI author (ADR-0033) reading that vocabulary concluded the platform isolates plugins, wrote the config, and received a clean parse and zero isolation. An inert security control is worse than an absent one because it is believed. Hot reload was additionally a TWO-SOURCE defect: the docs pointed at this dead `PluginHotReloadSchema` while the only implementation body, `HotReloadManager` (`packages/core/src/hot-reload.ts`), reads a different vocabulary — `HotReloadConfigSchema` in `plugin-lifecycle-advanced.zod.ts`. Ruling §2 converges on the surviving side: that schema is KEPT as the starting point for a future enforce decision (it has an implementation body but no runtime composes it yet), and enforcing it is deliberately a separate decision, not this retirement. Why D3 semantic and not a D2 conversion: the chain walks a normalized STACK and `applyConversionsToStoredItem` maps a metadata type onto one of its collections. A package manifest is neither — `PLURAL_TO_SINGULAR` has no `packages` / `plugins` entry, so a manifest is not a stack collection member and a stored manifest row passes that seam through unchanged. A conversion would be a transform with no seam that ever runs."
},
{
"surface": "kernel.dynamicLoadRequest / kernel.dynamicUnloadRequest / kernel.dynamicPluginResult / kernel.pluginSource / kernel.dynamicPluginOperation",
"replacement": "(removed — there is no replacement shape, because there is no operation to describe. Plugins are composed at boot: `defineStack` registers them and the kernel runs register → init → start; the set is fixed until the process restarts. Delete the import and the value. Runtime plugin loading, if it is ever built, returns via the enforce route of ADR-0049 through a new ADR — loader first, vocabulary second)",
"migrationId": "plugin-runtime-family-retired",
"toMajor": 17,
"rationale": "The five schemas declared the \"Dynamic Loading\" capability — runtime load / unload / reload of plugins without a kernel restart, with sandboxing, integrity hashes, drain strategies and dependent-cascade policy — and NOTHING implemented it. A bare-name scan of objectstack, cloud and objectui found zero references outside this package's own declaration, its unit tests and the generated artifacts: no runtime ever received a `DynamicLoadRequest`, performed a load/unload, or produced a `DynamicPluginResult`. That is the ADR-0049 false-compliance shape at its most inviting to an AI author (ADR-0033), who reads `DynamicLoadRequestSchema` in the published IDE bundle as proof the platform hot-loads plugins and constructs a request that parses clean and is received by nobody (#3950: an exported schema with no consumer is read as a capability). The #3896 follow-up removed this module's discovery/sandbox config island and left these five in place explicitly — \"operation contracts, not security promises; the enforce-or-remove call on them is a design decision rather than a correction\" — but that suspension lived only in a changeset paragraph with no issue carrying it. #4834 is that decision, answered REMOVE. `experimental` was considered and rejected: it is only `.describe()` prose and cannot stop an import, the weakest of the three ADR-0049 channels. None of the five is stored metadata — they are root request/result payload shapes embedded in no parent schema and parsed against no metadata document — so no `sys_metadata` row can carry one and there is no source for the D2 chain to rewrite; this entry is the D3 record. The removal also subsumes the kernel half of `plugin-activation-events-retired` (#4657): that tombstone goes with the shape that carried it. ADR-0049, #4834."
},
{
"surface": "sys_position.permissions — the \"JSON-serialized array of permission strings\" textarea column left the platform position table declared by plugin-security (packages/plugins/plugin-security/src/objects/sys-position.object.ts), together with the clone_position copy entry that carried it between rows",
"replacement": "nothing on this table — delete the key from any authored `sys_position` seed row (stack `data` entries) or data-door write that still carries it. There are no direct position-level permission strings anywhere on the platform: capability reaches a position ONLY through permission-set bindings (`sys_position_permission_set` rows, created in Setup or by an app's kernel:ready binder) and is resolved from the position `name` at request time. A value that was recording intent as documentation belongs in `description`, which remains declared",
"migrationId": "position-permissions-column-retired",
"toMajor": 17,
"rationale": "Maintainer ruling 2026-08-20 (#9885), ADR-0049 enforce-or-remove: REMOVE. The object-scoped census (all sys_position-naming files, with same-object positive controls resolving `active` / `delegatable` / `is_default` / `name` to real readers) measured the column at zero on both sides: the only row writers — the builtin and declared position bootstrappers — set label / description / managed_by / active / is_default, and position→grant resolution consults `sys_position_permission_set` rows plus the position `name`, never this column. Its only in-repo reference was the clone_position action copying it between rows — a copy of a value nothing writes. objectui was searched under the same discipline (evidenceScope closure): no console surface names the column — the position pickers and Setup views read name / label / id only, so a designer preview consumer does not exist either. That left a declared free-text grant catalogue on a security object that no runtime enforced: an author — human or AI — who filled it believed they granted permission strings directly on the position, and nothing refused or honoured the value. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the ups-delegated-from-column-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — PositionSchema never declared `permissions`, and the surface ratchets are expected byte-identical), no liveness-ledger row is added (the ledger walks PositionSchema's shape, which never carried the key — a row would be an orphan), and the disposition is a SEMANTIC entry rather than a D2 conversion: no conversion in the chain rewrites seed rows today and the measured author base is zero, while the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. The live-authoring half is the PositionSchema strict-parse guidance for `permissions`, which names the binding table in the rejection. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If position-level direct grants ever become a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare."
},
{
"surface": "data.query.aggregations[].function ('array_agg' / 'string_agg')",
"replacement": "an ordinary `fields` query, shaped in the caller — or a stored field that materialises the roll-up. For a deduplicated COUNT the live spelling is unchanged: `count_distinct` stays declared",
"migrationId": "query-array-string-agg-retired",
"toMajor": 17,
"rationale": "The stored half of this retirement is a conversion (`dataset-measure-array-string-agg-removed`); this entry is the REQUEST half. `QueryAST` is never stored in stack metadata — it is the client SDK builder's output and the `POST /data/:object/query` body — so there is no source for the chain to rewrite and callers move their own queries. Both values were declared-but-unlowered on the SQL family: `SqlDriver.mapAggregateFunc` and the Turso `RemoteTransport.aggregate` compile five functions and refuse the rest, so a caller following the schema against a SQL datasource got a refusal, not an array. They did run on `driver-mongodb` and on the engine's in-memory fallback, which is what makes this the one narrowing in the batch that removes reachable behaviour: an aggregation that worked on one backend and failed on another is exactly the unpredictability the ruling ended, and #5499 has both of those backends frozen. `count_distinct` was deliberately NOT retired with them (maintainer, 2026-08-07) — it takes ADR-0049's enforce leg, and its SQL lowering is a separate drivers-side card. ADR-0049, #6188."
},
{
"surface": "data.query.cursor",
"replacement": "a `where` predicate on the sort key — `where: { created_at: { $gt: last.created_at } }` with the matching `orderBy` (the documented manual-keyset pattern)",
"migrationId": "query-cursor-retired",
"toMajor": 17,
"rationale": "The `cursor` key promised keyset pagination and no driver implemented it: the cursor was accepted and ignored, so every page came back identical — a caller looping \"until hasMore is false\" never terminates. Worse than inert, it had a shipped public producer (`QueryBuilder.cursor()`, removed with the key). The caller-built `Record<string, unknown>` shape also leaks sort/storage detail and squats on the reserved REST parameter set; a first-class cursor, if ever designed, will be a response-minted opaque token — a different API, so keeping this one preserved a wrong design rather than a roadmap. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286."
},
{
"surface": "data.query.distinct",
"replacement": "`groupBy` for unique combinations; the `count_distinct` aggregation for deduplicated counts; the SQL/memory drivers' `distinct(object, field)` door for one column's values",
"migrationId": "query-distinct-retired",
"toMajor": 17,
"rationale": "The `distinct` flag promised SELECT DISTINCT and no driver ever rendered it — but it was MIS-WIRED rather than merely dead (the harsher ADR-0078 class): the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate, so the caller got duplicate rows AND worse pagination metadata, and a side effect that \"confirmed\" the flag was doing something. It had a shipped public producer (`QueryBuilder.distinct()`, removed with the key). The count suppression is deleted in the same change — `total` is truthful for those queries again. A REQUEST surface, never stored; nothing to rewrite. ADR-0049 / ADR-0078, #4286."
},
{
"surface": "data.query.fields",
"replacement": "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (`fields: ['title', 'owner_id']`), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement: no driver ever resolved one, and the ingress refuses it (`400 INVALID_FIELD`, #7532). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis prescribes (#6924)",
"migrationId": "query-field-node-object-form-retired",
"toMajor": 17,
"rationale": "The `FieldNode` union declared a nested-select object form `{ field, fields, alias }` that was inert end to end: no producer emitted it, and no consumer read `.fields` or `.alias` — objectql's formula projection and known-field filters, driver-sql's `select()` and driver-memory's projection all treat the list as `string[]`, driver-mongodb keyed its projection with the entry itself, and the REST ingress stringified it. Nested selection is `expand`, which the engine resolves via batch `$in` queries. This is a REQUEST surface — `QueryAST` is never stored in stack metadata (no view, dataset or report authors one), so there is no source for the chain to rewrite: the schema narrows to `z.string()` and callers move their own select lists. ADR-0049 / ADR-0078, #4196."
},
{
"surface": "data.query.joins",
"replacement": "expand (`expand: { owner_id: { object: 'user', fields: ['name'] } }`), whose nested query selects the related record's own columns — keeping the foreign key in your own projection (`fields: ['title', 'owner_id']`), because the relation is carried by that column and projecting it away leaves expansion nothing to resolve. A dotted `fields` path is NOT a replacement: no driver ever resolved one, and the ingress refuses it (`400 INVALID_FIELD`, #7532). Where the value is wanted on the queried object itself, denormalise it onto that object (a stored field, written when the source changes) — the same remedy the sort axis prescribes (#6924)",
"migrationId": "query-joins-retired",
"toMajor": 17,
"rationale": "The `joins` array was declared-but-inert: no engine or driver read `query.joins` anywhere on the query path, so a query carrying it behaved exactly as if the key were absent — while the name squatted on the reserved REST parameter set. Related-record retrieval already has a live spelling (`expand`, resolved by the engine via batch `$in` queries), so the removal deletes the second, broken spelling rather than the capability, and the orphaned `JoinNode`/`JoinType`/`JoinStrategy` cluster goes with the key. A REQUEST surface — `QueryAST` is never stored in stack metadata — so there is no source for the chain to rewrite; callers move their own queries. ADR-0049 / ADR-0078, #4286."
},
{
"surface": "data.query.windowFunctions",
"replacement": "`aggregations` + `groupBy` for request-level analytics; `SqlDriver.findWithWindowFunctions(object, query)` for embedders on a SQL datasource",
"migrationId": "query-window-functions-retired",
"toMajor": 17,
"rationale": "The `windowFunctions` array was declared-but-inert on the query path: `find()` never applied a window function, so every OVER clause a caller declared was silently dropped. The capability only ever ran behind `SqlDriver.findWithWindowFunctions()`, a driver-level door that is not on the `IDataDriver` contract and whose flat input shape (`{ function, alias, partitionBy?, orderBy? }`) the spec vocabulary never matched — `WindowFunctionNodeSchema` declared `field`/`over`/`frame` members the door never read, so that cluster is removed with the key rather than left as a false affordance. A REQUEST surface, never stored; no source to rewrite. ADR-0049 / ADR-0078, #4286."
},
{
"surface": "ui.RecordDetailsProps.sections (the `record:details` page component)",
"replacement": "an OBJECT array — `sections: [{ label, columns, fields: [...] }]` — replacing the string-ID list; `label` gives the heading, `columns` its grid width, `name` makes the heading translatable, and the new sibling `hideFields` omits named fields from the body",
"migrationId": "record-details-sections-object-form",
"toMajor": 17,
"rationale": "`record:details` declared `sections` as a list of section IDs — `[\"overview\", \"financials\"]` — a shape nothing produced and nothing consumed, while every real page authored the object form. This is authorable metadata on the publish/parse path, so it is the D2 class exactly; it is registered as a SEMANTIC step rather than a mechanical conversion because a string ID carries no field list and the chain cannot invent one — only the author knows which fields the section named `overview` was meant to render. The measurement that made the type change safe is also what makes the prescription unambiguous: the ID-list form had zero read paths and zero producers. objectui's `RecordDetailsRenderer` maps every entry as an object (`s.name` / `s.label` / `s.title` / `s.fields`) with no string branch at all — a string entry spreads into a character map and renders nothing; `@object-ui/types`' `RecordDetailsComponentProps` mirror already declared `Array<{ name?, label?, fields, ... }>`; the Studio block designer can only author `{ label, columns, fields }`; `packages/lint` has modelled it as `nestedSections` all along; and every page in this repo — three showcase pages plus the `sys_user` platform page — authors the object form. So the break lands only on stored metadata written against a declaration nothing ever honoured, and it lands at publish time rather than rewriting data at rest. The same change DECLARED `hideFields`, which the `sys_user` platform page had been authoring undeclared. Registered by the #6350 stock reconciliation: #5611 predates the #6148 completeness gate, so nothing asked it what it had done about the ledger, and the sibling key on the same def — `ui/RecordDetailsProps:layout`, retired by #6350's neighbour — carries a tombstone while this face carried none. ADR-0087, #5611 (backfilled #6350)."
},
{
"surface": "restServer.openApi31",
"replacement": "(removed — no replacement key exists. Delete the key; for a real outbound webhook use `Webhook` from `@objectstack/spec/automation`. Config-driven OpenAPI 3.1 webhooks/callbacks documentation returns, if ever, via the enforce route of ADR-0049 through a new ADR)",
"migrationId": "rest-server-openapi31-block-removed",
"toMajor": 17,
"rationale": "The `openApi31` block (`webhooks` / `callbacks` / `jsonSchemaDialect` / `pathItemReferences`, typed by `OpenApi31ExtensionsSchema` with `OpenApiWebhookEventSchema` and `CallbackSchema` under it) promised OpenAPI 3.1 document synthesis nothing delivered: the REST server's `normalizeConfig` forwards only `api`/`crud`/`metadata`/`batch`/`routes`, and the served /openapi.json is the pre-generated @objectstack/spec contract enriched with the live server URL and the registered objects — a webhook declared here never appeared in any served document (ADR-0049; the #3197 connector-webhook shape one layer up). There is no behaviour to preserve and nothing stored to rewrite: `RestServerConfig` is plugin TS configuration (REST plugin constructor / `plugin-hono-server` `restConfig`), never a `sys_metadata` shape — the stack tree's `api` block declares only its four scoping/auth knobs. The three schemas are removed with the key (zero import-level consumers in objectstack / cloud / objectui); the key itself is tombstoned because the schema is not `.strict()` and a plain delete would strip it silently. #4579."
},
{
"surface": "runtime.HttpServer (the exported delegating wrapper class)",
"replacement": "register an `IHttpServer` ADAPTER INSTANCE directly as the `http.server` service — `HonoHttpServer` or whatever adapter the host already builds — instead of wrapping one",
"migrationId": "runtime-httpserver-wrapper-retired",
"toMajor": 17,
"rationale": "`@objectstack/runtime` exported an `HttpServer` class that took an `IHttpServer` in its constructor, declared `implements IHttpServer`, and forwarded only the contract's REQUIRED members (`get` / `post` / `put` / `delete` / `patch` / `use` / `listen` / `close`). It forwarded none of the OPTIONAL ones — `getPort?()`, `getRawApp?()`, `setFallbackHandler?()`. `packages/spec/src/contracts/http-server.ts` instructs consumers to feature-detect exactly those members with `typeof server.X === \"function\"` and to degrade when absent, so wrapping a capable adapter made every probe answer false and the capability vanish with the adapter underneath providing it the whole time. The sharpest consequence is worth writing down before anyone reaches for a wrapper of the same shape: a host that wrapped `HonoHttpServer` and registered the wrapper as `http.server` would answer 404 to every endpoint its metadata declared, because `setFallbackHandler` — since #5111 the ONLY entry path for declarative `apis:` endpoints — was never forwarded. This is a TS/API contract surface: an HTTP server adapter is CODE, never stack metadata, so there is no authored source for the chain to rewrite and deliberately no schema tombstone — nothing ever ran an adapter through a `.parse()`. That is precisely why this entry must exist: for an untyped JS host the ledger is the only notification channel there is, and for a typed one tsc reports at the construction site. Same disposition, and the same reason, as `storage-service-list-retired` (#5540) and `data-driver-find-stream-retired` (#4484). Registered by the #6350 stock reconciliation, not by the original change: #5122 landed before the #6148 completeness gate existed, so nothing ever asked it what it had done about the ledger. ADR-0049 / ADR-0087, #5122 (backfilled #6350)."
},
{
"surface": "@objectstack/spec: the exported type `SharingExecutionContext` (`contracts/sharing-service`), and its re-export from @objectstack/plugin-sharing — the six-field context shape (`userId` / `tenantId` / `positions` / `permissions` / `systemPermissions` / `isSystem`) that sharing, approval and report enforcement signatures used to name",
"replacement": "`ExecutionContext` from `@objectstack/spec` — the complete `resolveAuthzContext` envelope the contracts have declared since #6523. Every one of the retired type's six fields exists on it under the same name and type, so a value that satisfied the old type already satisfies the envelope: only the annotation is rewritten, never the value",
"migrationId": "sharing-execution-context-retired",
"toMajor": 17,
"rationale": "ADR-0049 enforce-or-remove, completing the #6206 ruling (2026-08-07: enforcement adjudicates on the WHOLE envelope, never a per-site subset). This type was the declared context parameter of 36 signatures across three contracts — `ISharingService` / `ISharingRuleService`, `IApprovalService`, `IReportService` — and it omitted four fields those gates need: `accessible_org_ids` (under the `group` tenancy posture this IS the Layer 0 wall, ADR-0105 D2), `org_user_ids`, `posture` (ADR-0095 D2) and `tabPermissions`. Its damage ran in the MIRROR direction of the share-link twin (#6430 / PR #6511): nothing trimmed the VALUES — the engine middleware always handed the whole context down — it was the declared TYPE that was narrow, so an implementation could not READ what it had been given without casting out of its own contract (`const posture = (context as any).posture` in plugin-approvals' privileged-override gate). #6523 / PR #7068 converged the contracts, PR #7140 and PR #7206 re-annotated the four implementations, and this card removes the now-unreferenced declaration (#7070, #7218). Why this needs a ledger entry despite nothing in-repo referencing it: it is the `export-field-meta-constraints-retired` / `hook-context-session-roles-retired` disposition — a PUBLISHED TypeScript surface with no spec schema, so there is no `retiredKey()` tombstone and no parse rejection that could carry the prescription, and the ledger is the only channel that reaches an upgrader. Why D3 semantic and not a D2 conversion: nothing authored or stored changes shape. The name is only ever spelled inside a consumer's own TypeScript, so no `objectstack migrate meta` transform can reach it, and no `sys_metadata` row carries it. ADR-0049 / ADR-0087, #7218."
},
{
"surface": "security.sharingRule.sharedWith.type `group` / `guest`, and owner-type rules (`type: owner` + `ownedBy`)",
"replacement": "`group` → `team` (the enforced runtime vocabulary); `guest` → delete the rule and expose the records through a public form or a share link; `type: owner` → rewrite as a `type: criteria` rule. `business_unit` is newly authorable for the single-unit case",
"migrationId": "sharing-rule-recipient-reconcile",
"toMajor": 17,
"rationale": "The authoring `ShareRecipientType` enum had drifted behind both the ADR-0090 D3 rename and the enforced runtime, in both directions at once. It still offered the pre-rename `group`, which the seed path silently SKIPPED, while omitting two recipients the runtime and bootstrap already enforced (`team` via `sys_team` / `sys_team_member`, and `business_unit`). It also offered `guest`, which had no runtime recipient mapping at all. Each of those is a rule that validated and then materialised nothing — the ADR-0078 shape, and on a SECURITY surface, where the failure is silent under-sharing: the author sees a valid rule and believes a set of people can reach the records, and no error ever contradicts them. Owner-type rules go for a different and sharper reason: they depend on live team / position membership, which the static materialiser cannot track, so they could not be made to work by fixing a name. They return as an enforced form only if membership-reactive re-materialisation is designed. This is a semantic entry rather than a mechanical conversion because only one of the three rewrites is a rename: `group` → `team` is mechanical, but `guest` and `type: owner` have no target — the author has to decide who was actually meant to reach those records and say so in a form the runtime enforces, and a transform that guessed would be inventing an access grant. After this change every authorable recipient and rule type on the SharingRule surface is enforced; the `queue` recipient stays runtime-reserved and deliberately non-authorable (there is no `sys_queue` yet). Note the two neighbouring conversions cover DIFFERENT faces of this schema and not this one: `sharing-recipient-role-to-position` is the ADR-0090 role → position rename and `sharing-rule-access-level-full-to-edit` is the access-level vocabulary. Registered by the #6350 stock reconciliation. ADR-0078 / ADR-0090 D3 / ADR-0087, #1878 (backfilled #6350)."
},
{
"surface": "data.query.orderBy[].direction (SortNode)",
"replacement": "`order` — `orderBy: [{ field: \"updated_at\", order: \"desc\" }]`. One word, same values (`asc` / `desc`)",
"migrationId": "sort-node-direction-rejected",
"toMajor": 17,
"rationale": "`SortNodeSchema` was a plain `z.object`, so zod's default `.strip` applied and a sort node spelling its direction `direction` lost the key silently. Measured on `main` before the change: `SortNodeSchema.parse({ field: \"updated_at\", direction: \"desc\" })` returned `{ field: \"updated_at\", order: \"asc\" }` — the key discarded and `order` falling back to its `asc` default, so the sort ran in the OPPOSITE direction and the request succeeded. Paired with `limit`, which is how a caller asks for \"the latest N\", that is not a reordered page but a DIFFERENT SET OF ROWS, returned under an ordinary 200 with nothing in the response to distinguish it from the answer that was asked for. `direction` is not a typo: it is the live vocabulary of a neighbouring contract, `IReportService.orderBy`, and `plugin-auth/objectql-adapter.ts` already translated between the two by hand — a translation known to be necessary and enforced nowhere, the ADR-0049 shape. Both doors closed in one change: `SortNodeSchema` is now a `strictObject` carrying `aliases: { direction: \"order\" }`, and `normalizeSortNodes` in `metadata-protocol` refuses `{ field, direction }` with `400 INVALID_SORT`. The alias is deliberate rather than left to the edit-distance fallback, because no edit distance bridges `direction` → `order` and a bare \"unrecognized key\" would leave the caller exactly where the silent strip did. This is registered as a semantic entry rather than a mechanical conversion for one reason worth stating: the rewrite itself is trivially mechanical, but a stored `direction: \"asc\"` is ambiguous evidence — the author may have written it meaning ascending and been silently GIVEN ascending, so the visible behaviour never contradicted them, and only they can say whether the sort they have been reading was the sort they asked for. Registered by the #6350 stock reconciliation: the in-code alias tombstone shipped with #4721, but the ledger half never did, and a retirement needs both — the tombstone is the proof the removal was declared, the ledger entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0049 / ADR-0087, #4721 (backfilled #6350)."
},
{
"surface": "type alias: the 102 XInput names of @objectstack/spec (ConnectorInput, AppInput, PageInput, ActionInput, ServiceObjectInput, ExecutionContextInput, TaskInput, … — 52 files across api/ automation/ data/ identity/ integration/ kernel/ security/ system/ ui/)",
"replacement": "the BARE name. ADR-0122 phase 2 moved the author state onto `X`, which makes `XInput` a character-for-character synonym of it — the permanent synonym D3 forbids. Drop the `Input` suffix: `ConnectorInput` -> `Connector`. Symmetrically, a consumer that held a PARSE RESULT under the bare name moves to `XParsed`, which phase 1 (16.x) already declared for every schema whose two shapes differ, so the target name has existed for a release. NINE `*Input` names are NOT retired and need no edit: `ExpressionInput`, `CronExpressionInput`, `TemplateExpressionInput` and `PredicateInput` are the bare aliases of their own `…InputSchema`, and `FormFieldInput`, `QueryInput`, `FieldInput`, `ObjectStackDefinitionInput` and `NavigationItemInput` are composed (recursive or `Partial`-shaped) types no bare alias denotes.",
"migrationId": "spec-type-alias-input-suffix-retired",
"toMajor": 17,
"rationale": "This entry exists for the reason `data-driver-find-stream-retired` (#4484), `storage-service-list-retired` (#5540) and `actor-user-roles-to-positions` (#6011) exist, and it is the same disposition: the surface is a TYPESCRIPT NAME, never stack metadata, so there is no source for a D2 conversion to rewrite and deliberately no schema tombstone — an `XInput` alias never had a carrier key, never emitted a def, and no `.parse()` ever saw it. Measured and verified rather than assumed: `json-schema/`, `json-schema.manifest/` and `authorable-surface/` are BYTE-IDENTICAL across this change, because those generators enumerate runtime `z.ZodType` exports and never read a type alias. So nothing left the published metadata surface and RETIRED_DEFS_BY_MAJOR is deliberately untouched — an entry there would falsely claim the metadata contract shrank. The enforced channel is tsc: the name is gone, so every consumer gets TS2724/TS2305 naming the import. That is loud but MUTE about the replacement — a compile error says `ConnectorInput` does not exist, not that `Connector` now means what it meant. The generated upgrade guide is the only channel that carries the second half, which is precisely the #6048 gap ADR-0087 registration exists to close. ⚠️ Deliberately NOT registered alongside it: the 1384 bare aliases the same change FLIPPED from `z.infer` to `z.input`. Those names all still exist and still resolve; what moved is which of a schema's two shapes they denote, and only where the two differ (663 of 1384 — the rest are isomorphic and the flip is a no-op there, pinned as such). A consumer holding an authored literal is made MORE correct by it, silently; one holding a parse result gets a tsc error at the first defaulted key it reads. Registering that as a rename would misdescribe it — no name was retired — and the changeset carries its own FROM -> TO for it. ADR-0122 D8/D9, #6083 (PR #6279)."
},
{
"surface": "contracts.IStorageService.list",
"replacement": "track the keys you wrote (sys_file / file-reference records, queryable through ObjectQL with real pagination) instead of enumerating the bucket — and where no such record exists, the cursor-shaped `list(prefix, { cursor, limit })` this entry reserved, restored in #6781",
"migrationId": "storage-service-list-retired",
"toMajor": 17,
"rationale": "`list(prefix)` was an OPTIONAL contract method documented as \"List files in a directory/prefix\", and the two shipped adapters answered the same call with two different semantics — both of them silently incomplete. `LocalStorageAdapter.list` was a single-level `readdir`, so a nested key `a/b/c` was invisible under `list('a')` (only `a/b` came back), and a subdirectory that `stat` succeeded on was pushed into the result as a file, yielding a `StorageFileInfo` whose `size` is a directory inode and which cannot be downloaded at all. `S3StorageAdapter.list` was RECURSIVE (`ListObjectsV2` matches the whole key) and read neither `IsTruncated` nor `ContinuationToken`, so past 1000 objects the \"all files\" a caller received was the first page, with no signal. One contract method, two dialects, both quietly incomplete — and the first feature that genuinely needed to enumerate a prefix (backup, orphan sweep, migration audit) would have got two different answers on two deployments without an error on either. #5172 was nearly that feature: it planned to drive attachment reclamation off `list(EMAIL_ATTACHMENT_KEY_PREFIX)`, found the local adapter could not see one level down, and switched to queue-driven deferred work instead. Nothing consumed it afterwards: the only in-repo call site was the `SwappableStorageService` pass-through (which itself rejects when the active adapter has no `list`), and REST, CLI and the storage routes never called it. Remove was chosen over align-and-tighten (maintainer ruling, 2026-08-05, #5266): aligning would grow a conformance surface nobody walks, while a prefix listing that cannot paginate is the wrong signature to inherit — when a real caller needs enumeration it returns cursor-shaped, `list(prefix, { cursor, limit })`, with adapter-conformance cases (nested keys, directory entries, >1000 objects) proving both backends agree. This is a TS/API contract surface — a storage adapter is CODE, never stack metadata — so there is no source for the chain to rewrite, and deliberately no schema tombstone: nothing ever ran an adapter through a `.parse()`, so a prescription there would reach no one. The enforced channel is tsc, and it reports at the call site. Same disposition, and the same reason, as `data-driver-find-stream-retired` (#4484). ADR-0049 / ADR-0087, #5540 (analysis #5266)."
},
{
"surface": "ai.tool.requiresConfirmation",
"replacement": "put the operation behind an ACTION and set `ai.requiresConfirmation: true` there — that is the flag the HITL approval queue actually reads, and the only path that stops execution",
"migrationId": "tool-requires-confirmation-retired",
"toMajor": 17,
"rationale": "`ToolSchema.requiresConfirmation` accepted `true` and no execution path ever read it: not the LLM tool set (a tool reaches the model as name / description / parameters only), not `ToolRegistry.execute`, not `POST /ai/tools/:name/execute`, and not the MCP bridge, which derives `destructiveHint` from a hardcoded name list. Setting it on a destructive tool produced NO PAUSE. For an ordinary dead property that is untidy; for a SAFETY property it is false compliance, the case ADR-0049 exists for — an author gates a destructive tool, sees the flag accepted, and ships believing a human is in the loop. It is made worse by the near-miss: `action.ai.requiresConfirmation` carries the same name and DOES work, so the mistake reads as correct in review. This is registered as a semantic entry rather than a mechanical conversion because the rewrite is not a rename at all — the replacement lives on a different metadata object at a different layer, and deciding which action should carry the gate (or whether the operation should be an action at all) is a judgement the chain cannot make. Deleting the key mechanically would be the worst possible transform here: it would leave the metadata parsing green while silently completing the removal of a safety gate the author believed was in place. `ToolSchema` was made `.strict()` in the same change, which is load-bearing rather than tidying — removing a key from a non-strict schema swaps one silent no-op for another, so the retired key now REJECTS and the parse error carries the prescription, that being the one channel every consumer bumping `@objectstack/spec` is guaranteed to hit. Registered by the #6350 stock reconciliation: the `retiredKey()` tombstone shipped with #3715 and still stands in `ai/tool.zod.ts`, but the ledger half never did. A retirement needs both — the tombstone is the proof the removal was declared, this entry is what `spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers. ADR-0033 §2 / ADR-0049 / ADR-0087, #3715 (backfilled #6350)."
},
{
"surface": "ui.touchInteraction / ui.gestureConfig / ui.dndConfig / ui.keyboardNavigationConfig / ui.componentAnimation / ui.motionConfig / ui.pageTransition / ui.offlineConfig (the whole export surface of ui/touch.zod.ts, ui/dnd.zod.ts, ui/keyboard.zod.ts, ui/animation.zod.ts and ui/offline.zod.ts — 32 defs, 64 exported names)",
"replacement": "(removed — there is no replacement key, because there was never a key. Touch targets, drag-and-drop, focus management, keyboard shortcuts and motion are RENDERER BUILT-IN behaviour: the component library decides them, not a per-page metadata author. Offline is a platform capability, and its vocabulary belongs on the sync engine that owns the queue, the conflict policy and the cache — none of which exists yet. Delete the import and the value. Whichever of these earns real product pull returns WITH its own vocabulary and its executor, the #4910 way, not by un-retiring a declaration)",
"migrationId": "ui-interaction-config-family-retired",
"toMajor": 17,
"rationale": "Five `@objectstack/spec/ui` modules declared a full interaction-configuration vocabulary — 22 `z.object` sites across touch/gesture, drag-and-drop, focus/keyboard, animation/motion and offline/sync — and NOTHING in the protocol carried them. This is the ADR-0049 false-compliance shape in its most inviting form for an AI author (ADR-0033), and worse than the ordinary declared-but-unread defect: `authorable-surface.json` listed 109 keys under these defs and `content/docs/references/ui/{touch,dnd,keyboard,animation,offline}.mdx` rendered them as authoring tables, so the published documentation advertised a vocabulary with no carrier key anywhere. An author following `dnd.mdx` and writing a `dnd:` block onto a page component was rejected by `PageComponentSchema` for an unrecognized key — the docs and the schema disagreeing about the platform (Prime Directive #10). Three independent measurements, each with its controls passing in the same run: (1) no module under `packages/spec/src` imported any of the five except the `ui/index.ts` barrel, so no schema declared a carrier key; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` (25 roots, 4742 nodes) reached none of the 21 named object shapes, while `PageSchema`, `WebhookSchema` and `StateMachineSchema` all resolved `direct` and a synthetic carrier flipped all 21 — so unreachability was a fact about the graph, not a broken walker; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these modules' own unit tests. objectui holds TYPE re-exports and parity ratchets, never validators, and says so (#2561). The 2026-08-04 ruling weighed wiring a carrier key (option B) and rejected it: that is a feature with a renderer behind it, not ledger clean-up. It also weighed tightening the shapes to `strictObject` and rejected that explicitly — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (#4583). Because there was no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, the same route 3 as #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ Not to be confused with #5021, which retired the THEME `animation` block — a different file, different defs, and that one did have a carrier key and therefore a tombstone. ADR-0049, #4988."
},
{
"surface": "ui.notificationAction / ui.embedConfig",
"replacement": "(removed — there is no replacement shape, because there was never a key to write either into. Delete the import and the value. Notification presentation is still described by the surviving `NotificationType` / `NotificationSeverity` / `NotificationPosition` vocabulary; public access to a form is granted by the LIVE `FormView.sharing` block (`SharingConfig`), which is untouched. Notification action buttons as metadata, and iframe embedding, return via the enforce route of ADR-0049 through a new ADR — carrier key and renderer first, vocabulary second)",
"migrationId": "ui-notification-action-embed-config-retired",
"toMajor": 17,
"rationale": "Both shapes were published `@objectstack/spec/ui` vocabulary with NO AUTHORING DOOR. #4001 批 14 measured them three ways on 2026-08-03 and this retirement re-ran all three against `origin/main` before removing anything, each with a positive control that passed in the same run: (1) CARRIER — no schema in `packages/spec/src` declared a key of either type (`ui/notification.zod`'s only non-test importer was the barrel; `ui/sharing.zod`'s were the barrel and `ui/view.zod.ts`, which names its SIBLING `SharingConfigSchema`), measured by resolving specifiers rather than substring-matching, because the repo holds two `sharing.zod` modules and a substring test miscredits `stack.zod.ts` to the UI one; (2) REACHABILITY — a BFS from the 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema`, over `build-schemas.ts`'s own walk including its derived-clone bridge, never reached either, while `Page` / `Action` / `DashboardWidget` / `Webhook` and `SharingConfig` itself all resolved `root-graph` in the same run and an injected synthetic carrier flipped both; (3) PARSE — zero `.parse()` in objectstack, cloud or objectui outside their own unit tests. So nobody could author one and nothing ever validated one: the #3950 shape, an exported schema with no consumer read as a capability, and the ADR-0033 trap where an AI author takes `EmbedConfigSchema` in the published bundle as proof the platform serves iframes. Neither is stored metadata and neither has a carrier, so no `sys_metadata` row can hold one and there is no source for the D2 chain to rewrite; this entry is the D3 record. 批 14 deliberately did NOT close them with `.strict()` — strictness is a property of a PARSE, and closing a shape nothing parses buys only \"a precisely-validated dead slot, the more convincing lie\" (#4583) — and filed the disposition as #5015, ruled REMOVE on 2026-08-04. Each was orphaned by an earlier retirement one level up: `NotificationAction` lost its wrappers at #4610 (`NotificationSchema` / `NotificationConfigSchema`, the #4535 C3 dual-source cleanup — that retirement's published \"zero consumers\" evidence was later falsified for objectui and is corrected on `ui/notification.zod`'s tombstone; the removal itself stands, #5781), and `EmbedConfig` lost its key at 17.0.0 when the 2026-06 liveness audit retired `App.embed` (no iframe route ever read it) — that key still stands as a `retiredKey()` tombstone in `app.zod.ts`, so an author who wrote the KEY already meets a prescription; this removes the value shape that outlived it. ⚠️ The retirement is per SCHEMA, not per file: `ui/sharing.zod` KEEPS `SharingConfigSchema`, a live door carried by `FormViewSchema.sharing` and read by `rest-server.ts` to mount the anonymous form routes, and `ui/notification.zod` keeps its three presentation enums. objectui consumed `NotificationActionSchema.shape.variant` as a VOCABULARY (never a parse) to pin its own hand-written `NotificationActionButton` interface — which is exactly why \"has a consumer\" never meant \"has an authoring door\" here; that pin is adapted objectui-side when it refreshes this dependency. ADR-0049, #5015."
},
{
"surface": "ui.widgetManifest / ui.widgetLifecycle / ui.widgetEvent / ui.widgetProperty / ui.widgetSource / ui.i18nObject / ui.pluralRule / ui.numberFormat / ui.dateFormat / ui.localeConfig (the widget-registration vocabulary of ui/widget.zod.ts, and the five doorless shapes of ui/i18n.zod.ts — 10 defs, 26 exported names)",
"replacement": "(removed — there is no replacement key, because there was never a key. A custom field widget is still named the same way it always was: `field.widget` is a plain string naming a component the RENDERER has registered, and objectui's registry has always carried its own runtime manifest for that (`RuntimeWidgetManifest` / `RuntimeWidgetSource` in `@object-ui/types`, objectui#3161 / #4115), which models different keys and never derived from these. For localisation: write the default-language string on `label` / `description` — the framework generates the translation key at registration time from the naming convention — and put translations in translation files, which is the LIVE `system/translation.zod.ts` surface. Widget registration and locale formatting as authorable protocol metadata return via the ENFORCE route of ADR-0049 through a new ADR — the registry / loader / formatter first, the vocabulary second)",
"migrationId": "ui-widget-i18n-family-retired",
"toMajor": 17,
"rationale": "`ui/widget.zod.ts` published a complete widget-registration vocabulary — a manifest with lifecycle hooks, custom events, configurable properties and an npm/remote/inline implementation-source union — and `ui/i18n.zod.ts` published a structured-label, plural-rule and locale-formatting vocabulary. NOTHING in the protocol carried either. Three independent measurements, re-run on `origin/main` immediately before the removal with their controls passing in the SAME run: (1) no module under `packages/spec/src` imported `widget.zod` at all, and the only imports of `i18n.zod` anywhere name `I18nLabelSchema` / `AriaPropsSchema` (both KEPT), so no schema declared a carrier key — `field.widget` is a `z.string()` naming a registered component and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack`'s `ObjectStackSchema` reached none of them, while `PageSchema` / `ObjectListViewSchema` resolved `direct` in the same run and a synthetic carrier flipped every one of them; (3) zero `.parse()` / `.safeParse()` in objectstack, objectui or cloud outside these files' own unit tests. `NumberFormat` / `DateFormat` DID have a carrier key (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier was itself doorless, so the subtree was `no door` rather than `no gate` and goes whole — leaving the two leaves behind would strand exported schemas with no consumer (#3950). `I18nObjectSchema` was additionally superseded by its own file-neighbour: `I18nLabelSchema`'s documentation already says translation keys are generated at registration time and translations live in translation files, and the live translation surface is `system/translation.zod.ts`, which uses none of these shapes. The 2026-08-06 ruling weighed giving them a carrier (option B) and rejected it: that is a feature with a registry and a renderer behind it, not ledger clean-up. Tightening them to `strictObject` was rejected earlier and explicitly (#4001 批 16) — strictness is a property of a PARSE and there is no parse, so it would spend a breaking change to leave \"a precisely validated dead slot, the more convincing lie\" (#4583). With no carrier key there is nothing to tombstone and no `sys_metadata` row or source file for a D2 conversion to rewrite: this entry is the D3 record, route 3, the same shape as #4988 (the ui/ interaction config family), #4834 (kernel plugin-runtime family) and #4938 (`HttpServerConfig`). ⚠️ `WidgetManifest.performance`'s own `retiredKey()` tombstone (#3896 close-out) is SUBSUMED here, the #4657/#4834 way: it goes with the shape that carried it, which is strictly stronger than the tombstone, because there is no longer a manifest to author the key INTO. ⚠️ One of the nine widget sites is deliberately NOT retired. `FieldWidgetPropsSchema` survives: it is a REACT PROPS CONTRACT rather than authorable metadata (it never appeared in `authorable-surface/` or `json-schema.manifest/` — its `onChange` is a `z.function()`), so \"zero parse\" is its design and not its defect, and it acquired a live cross-repo compile-time consumer one day before 批 16 measured: objectui PR #3289 (2026-08-03) renamed `@object-ui/fields`' validation slot onto the spec's `error` with no alias, the form renderer began producing it, and `packages/fields/src/__tests__/spec-symbol-batch7.test.ts` pins the shape against `import type { FieldWidgetProps } from '@objectstack/spec/ui'` as an intentional tripwire. Re-verified on objectui `origin/main` 2026-08-07. ADR-0049, #5055."
},
{
"surface": "sys_user_permission_set.delegated_from — the ADR-0091 D3 provenance column left the platform grant table declared by plugin-security (packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts). The sibling declaration on sys_user_position is untouched",
"replacement": "nothing on this table — delete the key from any authored `sys_user_permission_set` seed row (stack `data` entries) or data-door write that still carries it. Delegation semantics live on `sys_user_position`, where `delegated_from` remains declared AND runtime-enforced: the delegated-admin gate is what makes a position insert a delegation, and the explain engine attributes \"via delegation from X, until Y\". A permission-set grant that needs a provenance note keeps `reason` (free text), which remains declared on both grant tables",
"migrationId": "ups-delegated-from-column-retired",
"toMajor": 17,
"rationale": "Maintainer ruling 2026-08-18 (#9730), ADR-0049 enforce-or-remove: REMOVE. The runtime delegation gate is structurally scoped to sys_user_position (`isDelegationWrite` returns false for every other object, so `assertSelfDelegation` is unreachable for this table), and the explain engine reads delegation provenance from sys_user_position rows only. On sys_user_permission_set the column was therefore declared and data-door-writable while NO runtime consumer read it — its only enforcement was an authoring-time lint (the D3 \"delegation row needs a reason\" rule), which a row written through the generic data door never meets. That is declared-but-unenforced in its pure form, on a security object: an author who stamped delegated_from on a permission-set grant believed they constrained delegation, and nothing refused or honoured it. Producers measured at zero — the only object literals naming both the table and the column were lint test fixtures. This is a platform-object COLUMN retirement, not a spec-key retirement, so the bookkeeping follows the audit-log-action-enum-retired shape: nothing lands in RETIRED_KEYS_BY_MAJOR (no authorable spec KEY changed — the surface ratchets are expected byte-identical), and the disposition is a SEMANTIC entry rather than a D2 conversion. A conversion over stack `data` seed records would be mechanically expressible, but no conversion in the chain rewrites seed rows today and the measured author base is zero; the loud channel already exists at runtime — the engine schema preflight refuses an undeclared field with 400 INVALID_FIELD before the driver or any hook runs — so this entry carries the prescription and the refusal carries the enforcement. ⚠️ Existing physical columns are deliberately untouched: schema sync is additive (ADR-0045), so a deployed database keeps the column; the platform stops declaring, projecting or accepting it. Zero producers means no rows are expected to carry a value; no backfill or destructive DDL is required or wanted. If delegation at permission-set granularity ever becomes a real need, the column is re-declared then, WITH a runtime reader in the same PR — declare-and-enforce or do not declare."