-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathspec-changes.json
More file actions
1029 lines (1029 loc) · 93.2 KB
/
Copy pathspec-changes.json
File metadata and controls
1029 lines (1029 loc) · 93.2 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": "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": "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": "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.node.config.retry.retryDelayMs / job.retryPolicy.maxRetries / job.retryPolicy.backoffMultiplier",
"to": "retry policy unified across job.retryPolicy and try_catch retry: 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)",
"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
}
],
"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": "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": "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": "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": "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": "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": "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.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": "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": "data.query.fields",
"replacement": "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted path for a single related column (`fields: ['owner.name']`)",
"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": "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": "data.query.joins",
"replacement": "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted `fields` path for a single related column (`fields: ['owner.name']`)",
"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": "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": "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow",
"replacement": "the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation",
"migrationId": "workflow-service-slot-retired",
"toMajor": 17,
"rationale": "The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078, #4451."
},
{
"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": "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 hit above). 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": "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": "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": "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": "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."
}
],
"removed": []
},
"perMajor": [
{
"from": 10,
"to": 11,
"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
}
],
"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."
}
],
"removed": []
},
{
"from": 11,
"to": 12,
"added": [],
"converted": [],
"migrated": [
{
"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."
}
],
"removed": []
},
{
"from": 12,
"to": 13,
"added": [],
"converted": [
{
"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
}
],
"migrated": [
{
"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": "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": "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."
}
],
"removed": []
},
{
"from": 13,
"to": 14,
"added": [],
"converted": [
{
"surface": "book.audience",
"to": "book audience gated arm '{ profile }' → '{ permissionSet }' (ADR-0090 D2/D9)",
"conversionId": "book-audience-profile-to-permission-set",
"toMajor": 14
}
],
"migrated": [],
"removed": []
},
{
"from": 14,
"to": 15,
"added": [],
"converted": [
{
"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
}
],
"migrated": [
{
"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."
}
],
"removed": []
},
{
"from": 15,
"to": 16,
"added": [],
"converted": [],
"migrated": [
{
"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."
}
],
"removed": []
},
{
"from": 16,
"to": 17,
"added": [],
"converted": [
{
"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": "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": "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": "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.node.config.retry.retryDelayMs / job.retryPolicy.maxRetries / job.retryPolicy.backoffMultiplier",
"to": "retry policy unified across job.retryPolicy and try_catch retry: 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)",
"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
}
],
"migrated": [
{
"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": "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": "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.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": "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": "data.query.fields",
"replacement": "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted path for a single related column (`fields: ['owner.name']`)",
"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": "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": "data.query.joins",
"replacement": "expand (`expand: { owner: { object: 'user', fields: ['name'] } }`), or a dotted `fields` path for a single related column (`fields: ['owner.name']`)",
"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": "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": "CoreServiceName 'workflow' / IWorkflowService / WorkflowProtocol / discovery routes.workflow / RestApiRouteCategory workflow",
"replacement": "the live mechanisms the slot only ever pointed at: `state_machine` validation rules for record state machines, approval flow nodes on the approvals runtime (ADR-0019) for approvals, lifecycle hooks + `record_change` flows (service-automation) for record-triggered automation",
"migrationId": "workflow-service-slot-retired",
"toMajor": 17,
"rationale": "The workflow slot was declared end to end and implemented nowhere: no code in either repository ever registered or resolved it (ADR-0115 Evidence 5 — the only touches were plugin-dev's retired stub probe and the generic discovery walk), no implementation of any WorkflowProtocol method ever existed, and no host ever mounted `/api/v1/workflow` (the pre-#3586 DEFAULT_DISPATCHER_ROUTES listed it among routes that never existed). Every part of it was ADR-0078's silently-inert declaration: a CoreServiceName nothing filled, a contract nothing implemented, a protocol nothing served, a discovery route field no builder could truthfully populate. These are TS/API surfaces and a discovery RESPONSE field — never stored in stack metadata, so there is no source for the chain to rewrite; consumers of the deleted types move their imports themselves. ADR-0049 / ADR-0078, #4451."
},
{
"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": "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 hit above). 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": "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",