primitive-admin 1.2.0-alpha.3 → 1.2.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/assets/skill/skills/primitive-platform/SKILL.md +17 -0
  2. package/dist/src/commands/agent-sessions.js +4 -0
  3. package/dist/src/commands/agent-sessions.js.map +1 -1
  4. package/dist/src/commands/auth.js +7 -4
  5. package/dist/src/commands/auth.js.map +1 -1
  6. package/dist/src/commands/connections.js +11 -12
  7. package/dist/src/commands/connections.js.map +1 -1
  8. package/dist/src/commands/database-type-configs.js +2 -76
  9. package/dist/src/commands/database-type-configs.js.map +1 -1
  10. package/dist/src/commands/database-types.js +313 -13
  11. package/dist/src/commands/database-types.js.map +1 -1
  12. package/dist/src/commands/databases.d.ts +9 -0
  13. package/dist/src/commands/databases.js +78 -403
  14. package/dist/src/commands/databases.js.map +1 -1
  15. package/dist/src/commands/documents.d.ts +32 -2
  16. package/dist/src/commands/documents.js +344 -711
  17. package/dist/src/commands/documents.js.map +1 -1
  18. package/dist/src/commands/functions.js +2 -2
  19. package/dist/src/commands/functions.js.map +1 -1
  20. package/dist/src/commands/llm.js +6 -9
  21. package/dist/src/commands/llm.js.map +1 -1
  22. package/dist/src/commands/prompts.d.ts +7 -0
  23. package/dist/src/commands/prompts.js +44 -0
  24. package/dist/src/commands/prompts.js.map +1 -1
  25. package/dist/src/commands/settings.d.ts +9 -12
  26. package/dist/src/commands/settings.js +79 -31
  27. package/dist/src/commands/settings.js.map +1 -1
  28. package/dist/src/commands/sync.d.ts +43 -110
  29. package/dist/src/commands/sync.js +356 -1251
  30. package/dist/src/commands/sync.js.map +1 -1
  31. package/dist/src/lib/aggregate-output.d.ts +25 -0
  32. package/dist/src/lib/aggregate-output.js +53 -0
  33. package/dist/src/lib/aggregate-output.js.map +1 -0
  34. package/dist/src/lib/api-client.d.ts +48 -204
  35. package/dist/src/lib/api-client.js +57 -321
  36. package/dist/src/lib/api-client.js.map +1 -1
  37. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +6 -7
  38. package/dist/src/lib/codegen-shared/generatedFiles.js +2 -2
  39. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
  40. package/dist/src/lib/codegen-shared/prettierStable.d.ts +1 -1
  41. package/dist/src/lib/codegen-shared/prettierStable.js +1 -1
  42. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +2 -2
  43. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +2 -2
  44. package/dist/src/lib/config-object-descriptor.js +15 -10
  45. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  46. package/dist/src/lib/config-surface.d.ts +2 -1
  47. package/dist/src/lib/config-surface.js +4 -1
  48. package/dist/src/lib/config-surface.js.map +1 -1
  49. package/dist/src/lib/connections-list.d.ts +10 -0
  50. package/dist/src/lib/connections-list.js +18 -0
  51. package/dist/src/lib/connections-list.js.map +1 -0
  52. package/dist/src/lib/csv.d.ts +8 -9
  53. package/dist/src/lib/csv.js +3 -4
  54. package/dist/src/lib/csv.js.map +1 -1
  55. package/dist/src/lib/db-codegen/dbGenerator.js +5 -16
  56. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
  57. package/dist/src/lib/db-codegen/dbNaming.d.ts +5 -13
  58. package/dist/src/lib/db-codegen/dbNaming.js +5 -17
  59. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  60. package/dist/src/lib/db-codegen/dbTemplates.d.ts +7 -56
  61. package/dist/src/lib/db-codegen/dbTemplates.js +25 -125
  62. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  63. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +2 -2
  64. package/dist/src/lib/db-codegen/dbTsTypes.js +2 -2
  65. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +60 -2
  66. package/dist/src/lib/db-codegen/dbTypeIR.js +12 -1
  67. package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -1
  68. package/dist/src/lib/document-export-record-log.d.ts +57 -0
  69. package/dist/src/lib/document-export-record-log.js +215 -0
  70. package/dist/src/lib/document-export-record-log.js.map +1 -0
  71. package/dist/src/lib/document-ingest-artifact.d.ts +13 -2
  72. package/dist/src/lib/document-ingest-artifact.js +7 -3
  73. package/dist/src/lib/document-ingest-artifact.js.map +1 -1
  74. package/dist/src/lib/document-ingest-rows.d.ts +16 -0
  75. package/dist/src/lib/document-ingest-rows.js +17 -0
  76. package/dist/src/lib/document-ingest-rows.js.map +1 -1
  77. package/dist/src/lib/document-ingest.d.ts +22 -0
  78. package/dist/src/lib/document-ingest.js +33 -0
  79. package/dist/src/lib/document-ingest.js.map +1 -1
  80. package/dist/src/lib/function-sync.d.ts +3 -16
  81. package/dist/src/lib/function-sync.js +14 -18
  82. package/dist/src/lib/function-sync.js.map +1 -1
  83. package/dist/src/lib/function-triggers.d.ts +3 -15
  84. package/dist/src/lib/function-triggers.js +10 -72
  85. package/dist/src/lib/function-triggers.js.map +1 -1
  86. package/dist/src/lib/generated-allowlist.js +0 -20
  87. package/dist/src/lib/generated-allowlist.js.map +1 -1
  88. package/dist/src/lib/generated-config-surfaces.d.ts +212 -49
  89. package/dist/src/lib/generated-config-surfaces.js +759 -184
  90. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  91. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  92. package/dist/src/lib/generated-sdk-types.js +1 -1
  93. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  94. package/dist/src/lib/login-scope-lines.d.ts +15 -0
  95. package/dist/src/lib/login-scope-lines.js +22 -0
  96. package/dist/src/lib/login-scope-lines.js.map +1 -0
  97. package/dist/src/lib/paginate.d.ts +0 -5
  98. package/dist/src/lib/paginate.js +0 -5
  99. package/dist/src/lib/paginate.js.map +1 -1
  100. package/dist/src/lib/prompt-schema-codegen.js +5 -2
  101. package/dist/src/lib/prompt-schema-codegen.js.map +1 -1
  102. package/dist/src/lib/refresh-admin-credentials.d.ts +7 -0
  103. package/dist/src/lib/refresh-admin-credentials.js +16 -4
  104. package/dist/src/lib/refresh-admin-credentials.js.map +1 -1
  105. package/dist/src/lib/root-import-decision.d.ts +15 -14
  106. package/dist/src/lib/root-import-decision.js +44 -51
  107. package/dist/src/lib/root-import-decision.js.map +1 -1
  108. package/dist/src/lib/snapshot-build-rows.d.ts +21 -0
  109. package/dist/src/lib/snapshot-build-rows.js +22 -0
  110. package/dist/src/lib/snapshot-build-rows.js.map +1 -1
  111. package/dist/src/lib/snapshot-build.js +8 -3
  112. package/dist/src/lib/snapshot-build.js.map +1 -1
  113. package/dist/src/lib/swift-codegen/banners.d.ts +34 -7
  114. package/dist/src/lib/swift-codegen/banners.js +37 -7
  115. package/dist/src/lib/swift-codegen/banners.js.map +1 -1
  116. package/dist/src/lib/swift-codegen/dbGenerator.d.ts +4 -3
  117. package/dist/src/lib/swift-codegen/dbGenerator.js +14 -117
  118. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -1
  119. package/dist/src/lib/swift-codegen/functionGenerator.d.ts +3 -3
  120. package/dist/src/lib/swift-codegen/functionGenerator.js +22 -17
  121. package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -1
  122. package/dist/src/lib/swift-codegen/generator.d.ts +5 -5
  123. package/dist/src/lib/swift-codegen/generator.js +35 -40
  124. package/dist/src/lib/swift-codegen/generator.js.map +1 -1
  125. package/dist/src/lib/toml-database-config.d.ts +14 -75
  126. package/dist/src/lib/toml-database-config.js +14 -305
  127. package/dist/src/lib/toml-database-config.js.map +1 -1
  128. package/dist/src/lib/workflow-codegen/generator.d.ts +7 -7
  129. package/dist/src/lib/workflow-codegen/generator.js +16 -23
  130. package/dist/src/lib/workflow-codegen/generator.js.map +1 -1
  131. package/dist/src/lib/workflow-codegen/invokerIR.d.ts +6 -12
  132. package/dist/src/lib/workflow-codegen/invokerIR.js +4 -5
  133. package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -1
  134. package/dist/src/lib/workflow-toml-validator.d.ts +2 -0
  135. package/dist/src/lib/workflow-toml-validator.js +28 -1
  136. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  137. package/package.json +3 -5
  138. package/dist/src/lib/generated-workflow-model-fields.d.ts +0 -20
  139. package/dist/src/lib/generated-workflow-model-fields.js +0 -53
  140. package/dist/src/lib/generated-workflow-model-fields.js.map +0 -1
  141. package/dist/src/lib/workflow-field-descriptor.d.ts +0 -91
  142. package/dist/src/lib/workflow-field-descriptor.js +0 -153
  143. package/dist/src/lib/workflow-field-descriptor.js.map +0 -1
@@ -2386,6 +2386,21 @@ export const RETIRED_CONFIG_KEYS = {
2386
2386
  "`admin-only` (app admins; `personal-uploads` is the per-uploader " +
2387
2387
  "preset).",
2388
2388
  },
2389
+ // #4283 — #4116 made the bucket author its rule set by name and kept the
2390
+ // id spelling as a deprecated key. Its own sentence rather than the type
2391
+ // configs' shared one, which says a pulled file already spells the name:
2392
+ // a bucket file pulled before #4116 does not. The `request` half is never
2393
+ // surfaced, for the reason given at `RULE_SET_ID_RETIRED`.
2394
+ ruleSetId: {
2395
+ toml: "`ruleSetId` is no longer authored in TOML (#4283). A rule-set id " +
2396
+ "names a row in one app and resolves to nothing in another, so the " +
2397
+ "reference is authored by name: replace this line with " +
2398
+ '`ruleSetName = "<name>"`, where the name is the `name` the rule ' +
2399
+ "set's own `rule-sets/<key>.toml` declares, and push again, or " +
2400
+ "`config pull` rewrites the file.",
2401
+ request: "`ruleSetId` is the live request key on this surface; only its TOML " +
2402
+ "spelling is retired (#4283). Send the rule set's id.",
2403
+ },
2389
2404
  },
2390
2405
  // #2645 — pausing moved off `state` onto its own field, so `state` stopped
2391
2406
  // being author-writable rather than becoming a second way to say "paused".
@@ -2423,9 +2438,46 @@ export const RETIRED_CONFIG_KEYS = {
2423
2438
  },
2424
2439
  },
2425
2440
  // #3997 — keyed by each table's TOML prefix. Only `database-type-config`
2426
- // uses `[type]`, so that entry cannot reach another surface; the blob
2427
- // bucket's `[bucket] ruleSetId` is its live key and is not listed.
2428
- type: { ruleSetId: RULE_SET_ID_RETIRED },
2441
+ // uses `[type]`, so that entry cannot reach another surface. The blob
2442
+ // bucket's `[bucket] ruleSetId` has its own entry under `bucket` (#4283).
2443
+ //
2444
+ // #3189 — server-functions phase 6 retires database operations, and with
2445
+ // them the two type-level CEL keys that existed for operations: the access
2446
+ // an operation inherited and the gate on the (already retired, #3991)
2447
+ // database CEL context. Data access goes through a function, whose `access`
2448
+ // gate is the authorization; per-database values are resource metadata
2449
+ // categories. Refused rather than ignored: a file that keeps the line would
2450
+ // otherwise read as though it still gated something.
2451
+ type: {
2452
+ ruleSetId: RULE_SET_ID_RETIRED,
2453
+ defaultAccess: {
2454
+ toml: "`defaultAccess` is no longer a database type key (#3189): it was the " +
2455
+ "access database operations inherited, and operations are retired. " +
2456
+ "Read and write the type's records from a function — its `access` " +
2457
+ "gate is the authorization. Delete the line and push again.",
2458
+ request: "`defaultAccess` is retired (#3189): it was the access database " +
2459
+ "operations inherited, and operations are retired. Gate data access " +
2460
+ "with a function's `access` rule; omit the field.",
2461
+ },
2462
+ celContextAccess: {
2463
+ toml: "`celContextAccess` is no longer a database type key (#3189): the " +
2464
+ "database CEL context it gated is gone (#3991). Per-database values " +
2465
+ "are resource metadata categories, each with its own readRule and " +
2466
+ "writeRule. Delete the line and push again.",
2467
+ request: "`celContextAccess` is retired (#3189): the database CEL context it " +
2468
+ "gated is gone (#3991). Use a resource metadata category; omit the " +
2469
+ "field.",
2470
+ },
2471
+ metadataAccess: {
2472
+ toml: "`metadataAccess` is no longer a database type key (#3189): it was " +
2473
+ "the legacy spelling of `celContextAccess`, which gated the retired " +
2474
+ "database CEL context (#3991). Per-database values are resource " +
2475
+ "metadata categories. Delete the line and push again.",
2476
+ request: "`metadataAccess` is retired (#3189): the database CEL context it " +
2477
+ "gated is gone (#3991). Use a resource metadata category, which has " +
2478
+ "its own readRule and writeRule; omit the field.",
2479
+ },
2480
+ },
2429
2481
  groupTypeConfig: { ruleSetId: RULE_SET_ID_RETIRED },
2430
2482
  collectionTypeConfig: { ruleSetId: RULE_SET_ID_RETIRED },
2431
2483
  // #2803 — the OBJECT-level availability leaves TOML. The per-version
@@ -2600,6 +2652,41 @@ export const RETIRED_CONFIG_KEYS = {
2600
2652
  },
2601
2653
  },
2602
2654
  };
2655
+ /**
2656
+ * Top-level TOML tables a configuration object's FILE retired — keyed by the
2657
+ * surface's label, then by the table key. Only the TOML half exists: these were
2658
+ * sub-trees applied through their own endpoints, never request keys of the
2659
+ * object itself, so no request body can carry one.
2660
+ *
2661
+ * Without an entry here a retired table would be answered as an unrecognized
2662
+ * one ("check the spelling, or upgrade the CLI"), which is backwards for a
2663
+ * table removed on purpose.
2664
+ */
2665
+ export const RETIRED_DOCUMENT_KEYS = {
2666
+ // #3189 — server-functions phase 6 retires database operations and
2667
+ // type-scoped subscriptions. Data access and change feeds are a function's
2668
+ // now; the rows the two tables created are swept at deploy.
2669
+ "database-type-config": {
2670
+ operations: {
2671
+ toml: "`[[operations]]` is no longer part of a database type file (#3189): " +
2672
+ "database operations are retired. Read and write the type's records " +
2673
+ "from a function (`ctx.db(<databaseId>).model(<Model>)`), and call the " +
2674
+ "function from the client. Delete every `[[operations]]` block and " +
2675
+ "push again.",
2676
+ },
2677
+ subscriptions: {
2678
+ toml: "`[[subscriptions]]` is no longer part of a database type file " +
2679
+ "(#3189): type-scoped subscriptions are retired. Publish changes on a " +
2680
+ "channel from the function that writes them " +
2681
+ "(`ctx.channels.authorize` / `ctx.channels.publish`). Delete every " +
2682
+ "`[[subscriptions]]` block and push again.",
2683
+ },
2684
+ },
2685
+ };
2686
+ /** The retired-table guidance for `key` on the surface labelled `label`, or null. */
2687
+ export function retiredDocumentKey(label, key) {
2688
+ return RETIRED_DOCUMENT_KEYS[label]?.[key] ?? null;
2689
+ }
2603
2690
  /** The retired-key entry for `key` under `prefix`, or null when it is simply unknown. */
2604
2691
  export function retiredConfigKey(prefix, key) {
2605
2692
  return RETIRED_CONFIG_KEYS[prefix]?.[key] ?? null;
@@ -3336,7 +3423,8 @@ export function parseFunctionManifest(value) {
3336
3423
  // ── src/config-surface/function-retired-keys.ts ──────────────────────────
3337
3424
  /**
3338
3425
  * The runtime keys a `functions/<key>.toml` no longer decides anything with —
3339
- * #3482, project `server-functions` phase 4.
3426
+ * #3482 retired them, and #3188 (project `server-functions` phase 5) refuses
3427
+ * them.
3340
3428
  *
3341
3429
  * The sponsor settled it on 2026-09-15 (the intent, "Mode vocabulary?",
3342
3430
  * amended again): the config says NOTHING about how a function runs. A
@@ -3345,44 +3433,50 @@ export function parseFunctionManifest(value) {
3345
3433
  * and a function that must not run under one runtime tests for it in code with
3346
3434
  * `assertRuntime`.
3347
3435
  *
3348
- * ── Why these keys are accepted, not refused ──────────────────────────────
3436
+ * ── Accepted through phase 4, refused from phase 5 ────────────────────────
3349
3437
  *
3350
- * Every tree pushed since #3454 that declares a cron trigger carries
3351
- * `mode = …` ON THE ENTRY, because `cron-trigger-mode-required` demanded it;
3352
- * plenty carry a top-level `mode` or the older `durable`. Removing the keys
3353
- * from the accepted set outright would refuse those trees with an unknown-key
3354
- * error one day after the platform insisted on them, which is the opposite of
3355
- * principle 5 inside a non-breaking phase.
3438
+ * Every tree pushed since #3454 that declares a cron trigger carried
3439
+ * `mode = …` ON THE ENTRY, because `cron-trigger-mode-required` demanded it,
3440
+ * so #3482 accepted the keys and warned rather than refusing them one day
3441
+ * after the platform insisted on them. Phase 5 is the declared breaking phase
3442
+ * that ends the grace: `mode`, `durable` and a cron entry's `mode` are refused
3443
+ * with one sentence per key, naming the key, saying the caller picks the
3444
+ * runtime, and saying to delete the line. `config pull` still strips them
3445
+ * (`stripRetiredFunctionKeys` in the CLI reads this scanner), so a tree pulled
3446
+ * from a version pushed before the retirement pushes again unchanged.
3356
3447
  *
3357
- * So through phase 4 they are ACCEPTED and IGNORED, with one warning per key
3358
- * naming it and the line to delete; from phase 5 (#3188) they are refused with
3359
- * the rest of the retirement matrix. The value is not validated — there is
3360
- * nothing left to validate it against, and refusing a bad value for a key
3361
- * nobody reads would be a refusal about spelling alone.
3448
+ * One rule id, one handler: `refusedFunctionKeys` is declared `preflight` on
3449
+ * the `function` surface and the CLI's push preflight runs it before the first
3450
+ * request. The value is not validated — the key's PRESENCE is the refusal.
3362
3451
  *
3363
- * Pure: no models, no tenant context, no `env` (see `types.ts` §Purity). Both
3364
- * the CLI preflight (vendored by `cli/scripts/gen-config-surfaces.mjs`) and the
3365
- * admin config-version route read this one scanner, so the warning an author
3366
- * sees from `config push` is the sentence the API answers with.
3452
+ * Pure: no models, no tenant context, no `env` (see `types.ts` §Purity). The
3453
+ * CLI preflight reaches this module through the vendored copy
3454
+ * (`cli/scripts/gen-config-surfaces.mjs`), so the sentence an author reads from
3455
+ * `config push` is the one this module writes.
3367
3456
  */
3368
3457
  /** The two `[function]` keys that no longer say anything (#3482). */
3369
3458
  export const RETIRED_FUNCTION_RUNTIME_KEYS = ["mode", "durable"];
3459
+ /**
3460
+ * The rule ids this module owns, declared on the `function` surface (#3374's
3461
+ * anchor). One id covers the three spellings: they are one rule — the config
3462
+ * does not choose the runtime — met in three places.
3463
+ */
3464
+ export const RETIRED_RUNTIME_KEY_RULE_IDS = ["retired-runtime-key"];
3465
+ /** The one refusal constructor (#3373's anchor). */
3466
+ function refuseRuntimeKey(ruleId, path, message) {
3467
+ return { ruleId, path, message };
3468
+ }
3370
3469
  const RUNTIME_IS_THE_CALLERS = "a function is a function, and the caller picks the runtime at each call — " +
3371
3470
  "`invoke` runs it inside the request, `start` runs it as a task";
3372
- const FROM_PHASE_5 = "Remove the line; from phase 5 it is refused.";
3373
3471
  /**
3374
- * Every retired runtime key a `[function]` table still carries.
3472
+ * Every retired runtime key a `[function]` table carries.
3375
3473
  *
3376
3474
  * Reads the table the way the author wrote it: the two top-level keys, and a
3377
- * `mode` on each `[[function.triggers.cron]]` entry. A cron entry's sentence
3378
- * says the extra thing its author needs to hear — that the schedule no longer
3379
- * chooses, because every fire starts a task run (#3334's runner branch is
3380
- * gone).
3381
- *
3382
- * Order is the order an author reads their file in: the top-level keys first,
3383
- * then the cron entries in declaration order.
3475
+ * `mode` on each `[[function.triggers.cron]]` entry. Order is the order an
3476
+ * author reads their file in: the top-level keys first, then the cron entries
3477
+ * in declaration order.
3384
3478
  */
3385
- export function ignoredFunctionKeys(functionTable) {
3479
+ function retiredKeySites(functionTable) {
3386
3480
  const table = functionTable;
3387
3481
  if (!table || typeof table !== "object" || Array.isArray(table))
3388
3482
  return [];
@@ -3390,10 +3484,7 @@ export function ignoredFunctionKeys(functionTable) {
3390
3484
  for (const key of RETIRED_FUNCTION_RUNTIME_KEYS) {
3391
3485
  if (table[key] === undefined)
3392
3486
  continue;
3393
- found.push({
3394
- path: key,
3395
- message: `\`${key}\` is ignored (#3482): ${RUNTIME_IS_THE_CALLERS}. ${FROM_PHASE_5}`,
3396
- });
3487
+ found.push({ path: key, cronEntry: null, key });
3397
3488
  }
3398
3489
  const triggers = table.triggers;
3399
3490
  const rawCron = triggers && typeof triggers === "object" && !Array.isArray(triggers)
@@ -3411,19 +3502,38 @@ export function ignoredFunctionKeys(functionTable) {
3411
3502
  return;
3412
3503
  const rawName = entry.name;
3413
3504
  const name = typeof rawName === "string" && rawName ? rawName : `#${index + 1}`;
3414
- found.push({
3415
- path: `triggers.cron.${name}.mode`,
3416
- message: `\`mode\` on cron trigger '${name}' is ignored (#3482): every cron ` +
3417
- `fire starts a task run, and ${RUNTIME_IS_THE_CALLERS}. ${FROM_PHASE_5}`,
3418
- });
3505
+ found.push({ path: `triggers.cron.${name}.mode`, cronEntry: name, key: "mode" });
3419
3506
  });
3420
3507
  return found;
3421
3508
  }
3509
+ /**
3510
+ * Every retired runtime key the table carries, each as the refusal the author
3511
+ * reads. A cron entry's sentence says the extra thing its author needs to hear
3512
+ * — that the schedule no longer chooses, because every fire starts a task run.
3513
+ */
3514
+ export function refusedFunctionKeys(functionTable) {
3515
+ return retiredKeySites(functionTable).map((site) => refuseRuntimeKey("retired-runtime-key", site.path, site.cronEntry === null
3516
+ ? `\`${site.key}\` is not a key of a function: ${RUNTIME_IS_THE_CALLERS}. ` +
3517
+ "Delete the line."
3518
+ : `\`mode\` on cron trigger '${site.cronEntry}' is not a key of a ` +
3519
+ `function: every cron fire starts a task run, and ${RUNTIME_IS_THE_CALLERS}. ` +
3520
+ "Delete the line."));
3521
+ }
3522
+ /**
3523
+ * The same refusals, rendered for one file — the shape `config push` and
3524
+ * `config diff` print.
3525
+ *
3526
+ * `label` is what the reader can act on: `functions/<key>.toml` from the CLI,
3527
+ * and the function's key from the API, which has no file to name.
3528
+ */
3529
+ export function refusedFunctionKeyMessages(label, functionTable) {
3530
+ return refusedFunctionKeys(functionTable).map((refusal) => `${label}: ${refusal.message}`);
3531
+ }
3422
3532
  /**
3423
3533
  * The database types a `[function]` table's retired trigger block names —
3424
3534
  * #3483.
3425
3535
  *
3426
- * Beside `ignoredFunctionKeys` because it answers the same question about the
3536
+ * Beside `refusedFunctionKeys` because it answers the same question about the
3427
3537
  * same table, for the same reader: `config pull` writes a version's AUTHORED
3428
3538
  * bytes, and a version pushed before the removal carries the block. Without
3429
3539
  * this the pulled tree would be refused on its next push — a tree an author
@@ -3484,16 +3594,6 @@ function retiredDatabaseTriggerValue(functionTable) {
3484
3594
  }
3485
3595
  return triggers.database;
3486
3596
  }
3487
- /**
3488
- * The same scan, rendered for one file — the shape `config push` and
3489
- * `config diff` print and the admin route answers with.
3490
- *
3491
- * `label` is what the reader can act on: `functions/<key>.toml` from the CLI,
3492
- * and the function's key from the API, which has no file to name.
3493
- */
3494
- export function ignoredFunctionKeyWarnings(label, functionTable) {
3495
- return ignoredFunctionKeys(functionTable).map((ignored) => `${label}: ${ignored.message}`);
3496
- }
3497
3597
  export const EMPTY_TRIGGER_DECLARATION = {
3498
3598
  webhook: null,
3499
3599
  cron: [],
@@ -4194,7 +4294,10 @@ export function validateSigningSecretDeclaration(input) {
4194
4294
  * `preflight`.
4195
4295
  */
4196
4296
  /** The rule ids this module owns — the union type below anchors them (#3373). */
4197
- export const WORKFLOW_RULE_IDS = ["system-access-rule"];
4297
+ export const WORKFLOW_RULE_IDS = [
4298
+ "system-access-rule",
4299
+ "retired-database-step-kind",
4300
+ ];
4198
4301
  /** The one refusal constructor: a new branch cannot compile without an id. */
4199
4302
  function refuseWorkflowRule(ruleId, message) {
4200
4303
  return { ruleId, message };
@@ -4217,6 +4320,64 @@ export function checkWorkflowSystemAccessRule(input) {
4217
4320
  }
4218
4321
  return null;
4219
4322
  }
4323
+ /**
4324
+ * The DSL step kinds that executed a registered database operation (#3189,
4325
+ * phase 6 of project `server-functions`, matrix row M8).
4326
+ *
4327
+ * Every `database.*` runner except `database.syncIndexes` called
4328
+ * `DatabaseOperationsController.execute`, so retiring database operations
4329
+ * retires these fourteen kinds with them: the seven base kinds and their seven
4330
+ * `*ForUser` twins. `database.syncIndexes` (which calls the records index
4331
+ * route), `database.create` and `database.delete` (resource lifecycle) are not
4332
+ * here and keep running until the engine retires.
4333
+ */
4334
+ export const RETIRED_DATABASE_STEP_KINDS = [
4335
+ "database.query",
4336
+ "database.mutate",
4337
+ "database.count",
4338
+ "database.aggregate",
4339
+ "database.pipeline",
4340
+ "database.applyToQuery",
4341
+ "database.batch",
4342
+ "database.queryForUser",
4343
+ "database.mutateForUser",
4344
+ "database.countForUser",
4345
+ "database.aggregateForUser",
4346
+ "database.pipelineForUser",
4347
+ "database.applyToQueryForUser",
4348
+ "database.batchForUser",
4349
+ ];
4350
+ /**
4351
+ * Every step in a definition — or the one step a run has reached — whose kind
4352
+ * is a retired database kind, with the sentence naming the replacement.
4353
+ *
4354
+ * Walks the top-level `steps` and a `collect` step's nested `step`, the one
4355
+ * place a step definition nests another. Push (the CLI preflight), save, the
4356
+ * activation lint and the engine all ask this one question, so a definition is
4357
+ * refused by the same words wherever it is caught, and a run that reaches such
4358
+ * a step fails with them.
4359
+ */
4360
+ export function checkWorkflowRetiredStepKinds(input) {
4361
+ const violations = [];
4362
+ const visit = (step) => {
4363
+ if (!step || typeof step !== "object" || Array.isArray(step))
4364
+ return;
4365
+ const kind = step.kind;
4366
+ const id = step.id;
4367
+ if (typeof kind === "string" &&
4368
+ RETIRED_DATABASE_STEP_KINDS.includes(kind)) {
4369
+ const name = typeof id === "string" && id ? `step "${id}"` : "a step";
4370
+ violations.push(refuseWorkflowRule("retired-database-step-kind", `${name} uses ${kind}, which was retired with database operations — ` +
4371
+ "move its work into a server function (ctx.db(databaseId).model(name)) " +
4372
+ "and call that function from the workflow with a workflow.call step"));
4373
+ }
4374
+ if (kind === "collect")
4375
+ visit(step.step);
4376
+ };
4377
+ if (Array.isArray(input.steps))
4378
+ input.steps.forEach(visit);
4379
+ return violations;
4380
+ }
4220
4381
  // ── src/config-surface/workflow.ts ───────────────────────────────────────
4221
4382
  /**
4222
4383
  * The `workflow` configuration object's definition (issue #2644, phase 1).
@@ -4339,6 +4500,10 @@ export const WORKFLOW_SURFACE = {
4339
4500
  // Accepted on BOTH modes — #807's deferred second PATCH is an update
4340
4501
  // call. The CLI's create-only send is a sequencing invariant owned by
4341
4502
  // `applyWorkflowBody`, not a statement about what the server accepts.
4503
+ // #3188 retired the synchronous run route this flag opted into; the
4504
+ // field still round-trips (pull, diff, push) until the workflow
4505
+ // surface itself retires, and `config push` notes a `true` as
4506
+ // ignored. Its step-kind rule went with the route.
4342
4507
  {
4343
4508
  field: "syncCallable",
4344
4509
  tomlKey: "syncCallable",
@@ -4563,8 +4728,9 @@ export const WORKFLOW_SURFACE = {
4563
4728
  },
4564
4729
  // ── Cross-field rules (#3374) ─────────────────────────────────────────────
4565
4730
  //
4566
- // One rule the CLI's push preflight runs — through the vendored copy of
4567
- // `workflow-rules.ts` — and seven the server runs at save time. What stays
4731
+ // Two rules the CLI's push preflight runs — through the vendored copy of
4732
+ // `workflow-rules.ts` (the server runs both too) — and six more the server
4733
+ // runs at save time. What stays
4568
4734
  // UNDECLARED, on purpose: the step-body validation (`validateWorkflowDefinition`
4569
4735
  // and the `steps` structural sub-tree, which is not a field-table rule), and
4570
4736
  // the enable-time activation lint (`workflow-activation-lint.ts`, a transition
@@ -4581,6 +4747,17 @@ export const WORKFLOW_SURFACE = {
4581
4747
  stage: "preflight",
4582
4748
  handler: enforcedBy("src/config-surface/workflow-rules.ts", "checkWorkflowSystemAccessRule"),
4583
4749
  },
4750
+ {
4751
+ id: "retired-database-step-kind",
4752
+ description: "A step may not use a retired `database.*` kind (`database.query`, " +
4753
+ "`mutate`, `count`, `aggregate`, `pipeline`, `applyToQuery`, `batch` " +
4754
+ "and their `*ForUser` twins): each executed a registered database " +
4755
+ "operation, and operations retired (#3189). `database.syncIndexes`, " +
4756
+ "`database.create` and `database.delete` stay. Push, save, the " +
4757
+ "activation lint and the engine ask the same predicate.",
4758
+ stage: "preflight",
4759
+ handler: enforcedBy("src/config-surface/workflow-rules.ts", "checkWorkflowRetiredStepKinds"),
4760
+ },
4584
4761
  workflowApplyRule("run-as-value", "validateWorkflowIdentityConfig", '`runAs` must be "caller" or "system" when present — the runner has no ' +
4585
4762
  "third identity."),
4586
4763
  workflowApplyRule("capabilities-shape", "validateWorkflowIdentityConfig", "`capabilities` must be an array or set of capability strings."),
@@ -4594,8 +4771,6 @@ export const WORKFLOW_SURFACE = {
4594
4771
  workflowApplyRule("access-rule-required", "validateWorkflowIdentityConfig", "On create, a caller-mode workflow must state its `accessRule` (`\"true\"` " +
4595
4772
  "for any app member), so a locked workflow is a decision rather than a " +
4596
4773
  "default."),
4597
- workflowApplyRule("sync-callable-step-kind", "validateSyncCallableSteps", "A `syncCallable = true` workflow may not carry a step kind that only " +
4598
- "the durable runtime can execute (`delay`, `event.wait`)."),
4599
4774
  ],
4600
4775
  };
4601
4776
  const FIELD_RANGES = [
@@ -5697,6 +5872,9 @@ export const AGENT_ERROR_CODES = [
5697
5872
  "AGENT_TOOL_ARGUMENTS_INVALID",
5698
5873
  "AGENT_ROUND_TRUNCATED",
5699
5874
  "AGENT_ROUND_EMPTY",
5875
+ // #4262 — a single-round test case whose seeded history, context or
5876
+ // assertions cannot be a round of this agent (`agent-test-case.ts`).
5877
+ "AGENT_TEST_CASE_INVALID",
5700
5878
  ];
5701
5879
  /**
5702
5880
  * The platform maximums. An agent may lower `maxSteps`, `waitLimitSeconds`
@@ -5721,6 +5899,20 @@ export const AGENT_NAME = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
5721
5899
  export const AGENT_ANSWERED_BY = ["initiator", "participants"];
5722
5900
  export const AGENT_TOOL_INVOKE = ["request", "task"];
5723
5901
  export const AGENT_TOOL_HISTORY_SCOPES = ["session", "turn"];
5902
+ /**
5903
+ * The result the platform records for a call to a client tool with
5904
+ * `endsTurn = true`: the call is final when its response is committed, and
5905
+ * later rounds read this as its tool result.
5906
+ */
5907
+ export const AGENT_TERMINAL_CALL_OUTPUT = Object.freeze({
5908
+ delivered: true,
5909
+ });
5910
+ /** The schema of `AGENT_TERMINAL_CALL_OUTPUT`: a terminal client tool's output type. */
5911
+ export const AGENT_TERMINAL_CALL_OUTPUT_SCHEMA = Object.freeze({
5912
+ type: "object",
5913
+ properties: { delivered: { type: "boolean" } },
5914
+ required: ["delivered"],
5915
+ });
5724
5916
  const AGENT_TOP_LEVEL_KEYS = [
5725
5917
  "answeredBy",
5726
5918
  "maxSteps",
@@ -5742,6 +5934,7 @@ const AGENT_TOOL_KEYS = [
5742
5934
  "approval",
5743
5935
  "statusText",
5744
5936
  "historyScope",
5937
+ "endsTurn",
5745
5938
  "inputSchema",
5746
5939
  "outputSchema",
5747
5940
  ];
@@ -5870,8 +6063,22 @@ function checkAgentTool(tool, index, names, out) {
5870
6063
  out.push(agentInvalid(`${path}.invoke`, `must be one of ${AGENT_TOOL_INVOKE.join(" | ")}`));
5871
6064
  }
5872
6065
  }
6066
+ const terminal = tool.endsTurn === true;
6067
+ if (tool.endsTurn !== undefined && typeof tool.endsTurn !== "boolean") {
6068
+ out.push(agentInvalid(`${path}.endsTurn`, "must be true or false"));
6069
+ }
6070
+ if (terminal && tool.approval === true) {
6071
+ out.push(agentInvalid(`${path}.endsTurn`, "cannot be set with approval = true: a terminal call never pauses"));
6072
+ }
5873
6073
  for (const key of ["inputSchema", "outputSchema"]) {
5874
6074
  const schemaPath = `${path}.${key}`;
6075
+ if (isClient && terminal && key === "outputSchema") {
6076
+ if (tool[key] !== undefined) {
6077
+ out.push(agentInvalid(schemaPath, "is not declared on a client tool with endsTurn = true: the " +
6078
+ "platform records a terminal client call's result — remove it"));
6079
+ }
6080
+ continue;
6081
+ }
5875
6082
  if (isClient) {
5876
6083
  if (tool[key] === undefined) {
5877
6084
  out.push(agentInvalid(schemaPath, "is required on a client tool"));
@@ -6146,17 +6353,14 @@ export function agentRoundNotAgentRefusal(promptKey) {
6146
6353
  "agent rounds. Run it with ctx.prompts.run, or name an agent prompt");
6147
6354
  }
6148
6355
  /**
6149
- * The refusal of a prompt test case that involves an agent (§D1: prompt test
6150
- * cases are refused for the kind in v1; multi-turn tests are deferred).
6151
- * `subject` is a case written ON the agent, `evaluator` a case that names the
6152
- * agent as its judge.
6356
+ * The refusal of a prompt test case that names an agent as its judge (§D1: an
6357
+ * evaluator runs single-shot, which an agent never does). A case written ON an
6358
+ * agent is a single-round test since #4262 (`agent-test-case.ts`), so
6359
+ * `evaluator` is the one role left.
6153
6360
  */
6154
- export function agentTestCaseRefusal(promptKey, role) {
6155
- return agentRefusal("AGENT_TEST_CASE_NOT_SUPPORTED", role === "subject"
6156
- ? `Prompt "${promptKey}" is an agent (kind = "agent"); prompt test ` +
6157
- "cases are not supported for agents yet. Test its tools as functions"
6158
- : `Prompt "${promptKey}" is an agent (kind = "agent") and cannot be an ` +
6159
- "evaluator: an evaluator is run single-shot. Name a chat prompt");
6361
+ export function agentTestCaseRefusal(promptKey, _role = "evaluator") {
6362
+ return agentRefusal("AGENT_TEST_CASE_NOT_SUPPORTED", `Prompt "${promptKey}" is an agent (kind = "agent") and cannot be an ` +
6363
+ "evaluator: an evaluator is run single-shot. Name a chat prompt");
6160
6364
  }
6161
6365
  /** A deep copy, so a reader's defaults never reach the caller's value. */
6162
6366
  function agentCopy(value) {
@@ -6165,7 +6369,7 @@ function agentCopy(value) {
6165
6369
  /**
6166
6370
  * The declaration with every default applied: `answeredBy = "initiator"`, the
6167
6371
  * platform maximums for the limits, and per tool `invoke = "request"` (server
6168
- * tools), `approval = false` and `historyScope = "session"`.
6372
+ * tools), `approval = false`, `historyScope = "session"` and `endsTurn = false`.
6169
6373
  *
6170
6374
  * The ONLY accessor for defaults — the stored value never carries them
6171
6375
  * (DSO-3798-001). Takes a value `resolveAgentDeclaration` accepted (or `null`)
@@ -6185,6 +6389,7 @@ export function normalizeAgentDeclaration(declaration) {
6185
6389
  ...(tool.runs === "client" ? {} : { invoke: tool.invoke ?? "request" }),
6186
6390
  approval: tool.approval ?? false,
6187
6391
  historyScope: tool.historyScope ?? "session",
6392
+ endsTurn: tool.endsTurn ?? false,
6188
6393
  })),
6189
6394
  events: source.events ?? [],
6190
6395
  };
@@ -6902,6 +7107,365 @@ export function agentSchemaTranslationRefusals(input) {
6902
7107
  }
6903
7108
  return out;
6904
7109
  }
7110
+ // ── src/config-surface/agent-test-case.ts ────────────────────────────────
7111
+ /**
7112
+ * What an agent test case may say — issue #4262 (single-round prompt tests
7113
+ * from a seeded history; design-docs/agents.md §D1).
7114
+ *
7115
+ * A test case on an agent prompt carries one `[test.agent]` block: a seeded
7116
+ * `history` (JSON text), the round's turn `context`, `timezone` and `locale`,
7117
+ * and two assertions on the round's calls (`expectedToolCall`,
7118
+ * `expectedNoToolCall`). On the wire and in storage those are six flat
7119
+ * `BlockTestCase` fields, `agentHistory` … `agentExpectedNoToolCall`.
7120
+ *
7121
+ * This module is the whole FILE-DECIDABLE rule for them: the history's shape,
7122
+ * its turns, its call and result pairing, where its events sit, the
7123
+ * assertions' shapes, and — when the caller passes the agent's declaration —
7124
+ * that the event kinds and the asserted tool are declared. The admin test-case
7125
+ * routes call it, and so does the CLI's push preflight through the vendored
7126
+ * artifact, so a refusal at push and a refusal at the route cannot disagree.
7127
+ * What needs app state or a schema validator (the input, context and event
7128
+ * schemas, the send route's zone and tag rules) is `agent-test-round.ts`'s.
7129
+ *
7130
+ * Every refusal carries `AGENT_TEST_CASE_INVALID` and the caller gets every
7131
+ * offending item at once, as the declaration resolver does.
7132
+ *
7133
+ * Pure and dependency-free apart from the agent declaration's types, like
7134
+ * every module under `src/config-surface/`.
7135
+ */
7136
+ /** The six wire and stored fields, in the order the surface declares them. */
7137
+ export const AGENT_TEST_CASE_FIELDS = [
7138
+ "agentHistory",
7139
+ "agentContext",
7140
+ "agentTimezone",
7141
+ "agentLocale",
7142
+ "agentExpectedToolCall",
7143
+ "agentExpectedNoToolCall",
7144
+ ];
7145
+ function agentTestRefusal(message) {
7146
+ return { code: "AGENT_TEST_CASE_INVALID", message: `[test.agent] ${message}` };
7147
+ }
7148
+ function agentTestIsObject(value) {
7149
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7150
+ }
7151
+ function agentTestIsText(value) {
7152
+ return typeof value === "string" && value.trim() !== "";
7153
+ }
7154
+ /** `null` and `undefined` are absence; so is `""` for a text field. */
7155
+ function agentTestIsUnset(value) {
7156
+ if (value === null || value === undefined)
7157
+ return true;
7158
+ return typeof value === "string" && value.trim() === "";
7159
+ }
7160
+ /** A JSON field: the value as given, or the JSON text a sidecar carries parsed. */
7161
+ function agentTestJsonValue(value) {
7162
+ if (typeof value !== "string")
7163
+ return { ok: true, value };
7164
+ try {
7165
+ return { ok: true, value: JSON.parse(value) };
7166
+ }
7167
+ catch {
7168
+ return { ok: false };
7169
+ }
7170
+ }
7171
+ /** The names a declaration declares, or `null` when the caller passed none. */
7172
+ function agentTestDeclaredNames(declaration, given) {
7173
+ if (!given)
7174
+ return null;
7175
+ let block = declaration;
7176
+ if (typeof block === "string") {
7177
+ try {
7178
+ block = JSON.parse(block);
7179
+ }
7180
+ catch {
7181
+ block = null;
7182
+ }
7183
+ }
7184
+ const names = (list) => new Set((Array.isArray(list) ? list : [])
7185
+ .map((item) => (agentTestIsObject(item) ? item.name : undefined))
7186
+ .filter((name) => typeof name === "string"));
7187
+ const source = agentTestIsObject(block) ? block : {};
7188
+ return { tools: names(source.tools), events: names(source.events) };
7189
+ }
7190
+ const AGENT_TEST_ENTRY_KEYS = {
7191
+ user: ["role", "text"],
7192
+ assistant: ["role", "text", "reasoning", "calls"],
7193
+ tool: ["role", "callId", "output", "error"],
7194
+ event: ["role", "kind", "refersTo", "data"],
7195
+ };
7196
+ /**
7197
+ * The history's entries and every refusal of their shape. A user entry
7198
+ * starts a turn and the last one's turn is the current one; every call is
7199
+ * answered exactly once, by a tool entry that follows its assistant entry;
7200
+ * an event precedes the current turn's user entry (a live event reaches the
7201
+ * model only in a later turn's history).
7202
+ */
7203
+ function agentTestReadHistory(raw, names, out) {
7204
+ if (agentTestIsUnset(raw)) {
7205
+ out.push(agentTestRefusal("history is required on an agent case: a JSON array of user, assistant, tool and event entries, oldest first"));
7206
+ return null;
7207
+ }
7208
+ if (typeof raw !== "string") {
7209
+ out.push(agentTestRefusal("history must be JSON text (a JSON array written as one string)"));
7210
+ return null;
7211
+ }
7212
+ let parsed;
7213
+ try {
7214
+ parsed = JSON.parse(raw);
7215
+ }
7216
+ catch (error) {
7217
+ out.push(agentTestRefusal(`history is not valid JSON (${error?.message ?? "parse failed"})`));
7218
+ return null;
7219
+ }
7220
+ if (!Array.isArray(parsed)) {
7221
+ out.push(agentTestRefusal("history must be a JSON array of entries"));
7222
+ return null;
7223
+ }
7224
+ const entries = [];
7225
+ const calls = new Map();
7226
+ /** The calls of the last assistant entry, which tool entries may answer. */
7227
+ let open = new Set();
7228
+ let seenUser = false;
7229
+ const lastUser = parsed.reduce((at, entry, index) => (agentTestIsObject(entry) && entry.role === "user" ? index : at), -1);
7230
+ if (lastUser === -1) {
7231
+ out.push(agentTestRefusal("history needs at least one user entry; the last one is the round's current turn"));
7232
+ }
7233
+ parsed.forEach((entry, index) => {
7234
+ const at = `history[${index}]`;
7235
+ if (!agentTestIsObject(entry)) {
7236
+ out.push(agentTestRefusal(`${at} must be an object with a role`));
7237
+ return;
7238
+ }
7239
+ // An own-property read: a role such as "constructor" or "__proto__" must
7240
+ // not resolve to an inherited member of the table.
7241
+ const allowed = typeof entry.role === "string" && Object.prototype.hasOwnProperty.call(AGENT_TEST_ENTRY_KEYS, entry.role)
7242
+ ? AGENT_TEST_ENTRY_KEYS[entry.role]
7243
+ : undefined;
7244
+ if (!allowed) {
7245
+ out.push(agentTestRefusal(`${at}.role must be "user", "assistant", "tool" or "event"; found ${JSON.stringify(entry.role)}`));
7246
+ return;
7247
+ }
7248
+ const unknown = Object.keys(entry).filter((key) => !allowed.includes(key));
7249
+ if (unknown.length > 0) {
7250
+ out.push(agentTestRefusal(`${at} has ${unknown.map((k) => `"${k}"`).join(", ")}, which a ${entry.role} entry does not carry; allowed: ${allowed.join(", ")}`));
7251
+ }
7252
+ if (entry.role === "user") {
7253
+ if (typeof entry.text !== "string" || entry.text === "") {
7254
+ out.push(agentTestRefusal(`${at}.text must be a non-empty string`));
7255
+ return;
7256
+ }
7257
+ open = new Set();
7258
+ seenUser = true;
7259
+ entries.push({ role: "user", text: entry.text });
7260
+ return;
7261
+ }
7262
+ if (entry.role === "assistant") {
7263
+ if (!seenUser) {
7264
+ out.push(agentTestRefusal(`${at} is an assistant entry before the first user entry; a turn starts with a user entry`));
7265
+ }
7266
+ const hasText = entry.text !== undefined;
7267
+ const hasCalls = entry.calls !== undefined;
7268
+ if (hasText && typeof entry.text !== "string") {
7269
+ out.push(agentTestRefusal(`${at}.text must be a string`));
7270
+ }
7271
+ if (entry.reasoning !== undefined && typeof entry.reasoning !== "string") {
7272
+ out.push(agentTestRefusal(`${at}.reasoning must be a string`));
7273
+ }
7274
+ const read = [];
7275
+ if (hasCalls) {
7276
+ if (!Array.isArray(entry.calls) || entry.calls.length === 0) {
7277
+ out.push(agentTestRefusal(`${at}.calls must be a non-empty array of { callId, name, arguments }`));
7278
+ }
7279
+ else {
7280
+ entry.calls.forEach((call, callIndex) => {
7281
+ const callAt = `${at}.calls[${callIndex}]`;
7282
+ if (!agentTestIsObject(call) ||
7283
+ !agentTestIsText(call.callId) ||
7284
+ !agentTestIsText(call.name) ||
7285
+ !("arguments" in call) ||
7286
+ Object.keys(call).some((key) => !["callId", "name", "arguments"].includes(key))) {
7287
+ out.push(agentTestRefusal(`${callAt} must be { callId, name, arguments }`));
7288
+ return;
7289
+ }
7290
+ if (calls.has(call.callId)) {
7291
+ out.push(agentTestRefusal(`${callAt}: call id "${call.callId}" is used twice; call ids are unique across the history`));
7292
+ return;
7293
+ }
7294
+ calls.set(call.callId, { answered: false });
7295
+ read.push({ callId: call.callId, name: call.name, arguments: call.arguments });
7296
+ });
7297
+ }
7298
+ }
7299
+ if (!(typeof entry.text === "string" && entry.text !== "") && read.length === 0 && !hasCalls) {
7300
+ out.push(agentTestRefusal(`${at} needs text or calls`));
7301
+ }
7302
+ open = new Set(read.map((call) => call.callId));
7303
+ entries.push({
7304
+ role: "assistant",
7305
+ ...(typeof entry.text === "string" ? { text: entry.text } : {}),
7306
+ ...(typeof entry.reasoning === "string" ? { reasoning: entry.reasoning } : {}),
7307
+ ...(read.length > 0 ? { calls: read } : {}),
7308
+ });
7309
+ return;
7310
+ }
7311
+ if (entry.role === "tool") {
7312
+ if (!agentTestIsText(entry.callId)) {
7313
+ out.push(agentTestRefusal(`${at}.callId must name a call of an earlier assistant entry`));
7314
+ return;
7315
+ }
7316
+ const hasOutput = "output" in entry;
7317
+ const hasError = "error" in entry;
7318
+ if (hasOutput === hasError) {
7319
+ out.push(agentTestRefusal(`${at} must carry exactly one of output or error`));
7320
+ return;
7321
+ }
7322
+ const call = calls.get(entry.callId);
7323
+ if (!call) {
7324
+ out.push(agentTestRefusal(`${at} answers call "${entry.callId}", which no earlier assistant entry makes`));
7325
+ return;
7326
+ }
7327
+ if (call.answered) {
7328
+ out.push(agentTestRefusal(`${at} is a second result for call "${entry.callId}"; every call has exactly one`));
7329
+ return;
7330
+ }
7331
+ if (!open.has(entry.callId)) {
7332
+ out.push(agentTestRefusal(`${at} answers call "${entry.callId}", which is not a call of the assistant entry just before it; ` +
7333
+ "a result follows its call"));
7334
+ return;
7335
+ }
7336
+ call.answered = true;
7337
+ if (hasError) {
7338
+ const error = entry.error;
7339
+ if (!agentTestIsObject(error) || !agentTestIsText(error.code) || typeof error.message !== "string") {
7340
+ out.push(agentTestRefusal(`${at}.error must be { code, message }`));
7341
+ return;
7342
+ }
7343
+ entries.push({ role: "tool", callId: entry.callId, error: { code: error.code, message: error.message } });
7344
+ return;
7345
+ }
7346
+ entries.push({ role: "tool", callId: entry.callId, output: entry.output });
7347
+ return;
7348
+ }
7349
+ // An event.
7350
+ if (!agentTestIsText(entry.kind)) {
7351
+ out.push(agentTestRefusal(`${at}.kind must name an event kind the agent declares`));
7352
+ return;
7353
+ }
7354
+ if (names && !names.events.has(entry.kind)) {
7355
+ out.push(agentTestRefusal(`${at} has event kind "${entry.kind}", which the agent does not declare` +
7356
+ (names.events.size > 0 ? ` (declared: ${[...names.events].join(", ")})` : "")));
7357
+ }
7358
+ if (entry.refersTo !== undefined && (!agentTestIsText(entry.refersTo) || !calls.has(entry.refersTo))) {
7359
+ out.push(agentTestRefusal(`${at}.refersTo names call ${JSON.stringify(entry.refersTo)}, which no earlier assistant entry makes`));
7360
+ }
7361
+ if (lastUser !== -1 && index > lastUser) {
7362
+ out.push(agentTestRefusal(`${at} is an event after the current turn's user entry; a live event reaches the model only in a ` +
7363
+ "later turn, so place it before the last user entry"));
7364
+ }
7365
+ entries.push({
7366
+ role: "event",
7367
+ kind: entry.kind,
7368
+ ...(typeof entry.refersTo === "string" ? { refersTo: entry.refersTo } : {}),
7369
+ data: entry.data ?? null,
7370
+ });
7371
+ });
7372
+ for (const [callId, call] of calls) {
7373
+ if (!call.answered) {
7374
+ out.push(agentTestRefusal(`call "${callId}" has no result; write one tool entry for it (an unanswered call as an error, ` +
7375
+ 'e.g. { "code": "superseded", "message": "…" })'));
7376
+ }
7377
+ }
7378
+ const last = parsed[parsed.length - 1];
7379
+ if (agentTestIsObject(last) && last.role === "assistant") {
7380
+ out.push(agentTestRefusal("history ends with an assistant entry; a round starts from a user entry or a tool result"));
7381
+ }
7382
+ return entries;
7383
+ }
7384
+ function agentTestReadExpectedToolCall(raw, names, out) {
7385
+ if (agentTestIsUnset(raw))
7386
+ return null;
7387
+ const parsed = agentTestJsonValue(raw);
7388
+ const value = parsed.ok ? parsed.value : undefined;
7389
+ if (!agentTestIsObject(value) ||
7390
+ !agentTestIsText(value.name) ||
7391
+ Object.keys(value).some((key) => key !== "name" && key !== "arguments")) {
7392
+ out.push(agentTestRefusal("expectedToolCall must be { name, arguments? } (arguments is matched as a subset)"));
7393
+ return null;
7394
+ }
7395
+ if (names && !names.tools.has(value.name)) {
7396
+ out.push(agentTestRefusal(`expectedToolCall names tool "${value.name}", which the agent does not declare` +
7397
+ (names.tools.size > 0 ? ` (declared: ${[...names.tools].join(", ")})` : "")));
7398
+ }
7399
+ return { name: value.name, ...("arguments" in value ? { arguments: value.arguments } : {}) };
7400
+ }
7401
+ /**
7402
+ * The one rule: is this case's agent block something a round can be built
7403
+ * from? `{ ok: true, testCase }` carries it read (`null` on a case whose
7404
+ * prompt is not an agent). On a chat or decisions case, every agent key
7405
+ * holding a non-null value is refused by name; an explicit `null` is unset on
7406
+ * any case, which is how a full-state update clears one.
7407
+ */
7408
+ export function resolveAgentTestCase(input) {
7409
+ const { fields } = input;
7410
+ const kind = agentTestIsUnset(input.kind) ? "chat" : String(input.kind).trim();
7411
+ const refusals = [];
7412
+ if (kind !== "agent") {
7413
+ const owner = kind === "chat" || kind === "decisions" ? `a ${kind} prompt` : `a ${kind}`;
7414
+ for (const key of AGENT_TEST_CASE_FIELDS) {
7415
+ const value = fields[key];
7416
+ if (value !== null && value !== undefined) {
7417
+ refusals.push(agentTestRefusal(`${key} is set on a case of ${owner}; only an agent prompt's case carries the agent block. ` +
7418
+ "Remove it, or send it as null"));
7419
+ }
7420
+ }
7421
+ return refusals.length > 0 ? { ok: false, refusals } : { ok: true, testCase: null };
7422
+ }
7423
+ const names = agentTestDeclaredNames(input.declaration, "declaration" in input);
7424
+ const history = agentTestReadHistory(fields.agentHistory, names, refusals);
7425
+ let context = null;
7426
+ if (!agentTestIsUnset(fields.agentContext)) {
7427
+ const parsed = agentTestJsonValue(fields.agentContext);
7428
+ if (parsed.ok && agentTestIsObject(parsed.value))
7429
+ context = parsed.value;
7430
+ else
7431
+ refusals.push(agentTestRefusal("context must be a JSON object (turn.* for the round)"));
7432
+ }
7433
+ const text = (key, label, example) => {
7434
+ const value = fields[key];
7435
+ if (value === null || value === undefined)
7436
+ return null;
7437
+ if (typeof value !== "string" || value === "") {
7438
+ refusals.push(agentTestRefusal(`${label} must be ${example}`));
7439
+ return null;
7440
+ }
7441
+ return value;
7442
+ };
7443
+ const timezone = text("agentTimezone", "timezone", 'an IANA time zone name such as "America/New_York"');
7444
+ const locale = text("agentLocale", "locale", 'a BCP 47 language tag such as "en-US"');
7445
+ const expectedToolCall = agentTestReadExpectedToolCall(fields.agentExpectedToolCall, names, refusals);
7446
+ let expectedNoToolCall = false;
7447
+ const noCall = fields.agentExpectedNoToolCall;
7448
+ if (noCall !== null && noCall !== undefined) {
7449
+ if (typeof noCall !== "boolean")
7450
+ refusals.push(agentTestRefusal("expectedNoToolCall must be true or false"));
7451
+ else
7452
+ expectedNoToolCall = noCall;
7453
+ }
7454
+ if (expectedToolCall && expectedNoToolCall) {
7455
+ refusals.push(agentTestRefusal("sets both expectedToolCall and expectedNoToolCall = true; a round cannot both call a tool and call none"));
7456
+ }
7457
+ const subset = fields.expectedJsonSubset;
7458
+ if (!agentTestIsUnset(subset) && !(agentTestIsObject(subset) && Object.keys(subset).length === 0) && subset !== "{}") {
7459
+ refusals.push(agentTestRefusal("expectedJsonSubset is not used on an agent case: an agent's answer is text, and anything structured is a " +
7460
+ "tool call. Assert it with expectedToolCall"));
7461
+ }
7462
+ if (refusals.length > 0 || !history)
7463
+ return { ok: false, refusals };
7464
+ return {
7465
+ ok: true,
7466
+ testCase: { history, context, timezone, locale, expectedToolCall, expectedNoToolCall },
7467
+ };
7468
+ }
6905
7469
  // ── src/config-surface/prompt.ts ─────────────────────────────────────────
6906
7470
  /**
6907
7471
  * The `prompt` configuration object's definition (issue #2644, phase 2).
@@ -8246,8 +8810,8 @@ export const CRON_TRIGGER_SURFACE = {
8246
8810
  * halves of the access model (#1020), and the only access keys: at most one may
8247
8811
  * change per request, which is the controller's rule and stays there. The rule
8248
8812
  * set is authored by name (`ruleSetName`) and sent as `ruleSetId`, as the type
8249
- * configs do (#4116); the id spelling in TOML is deprecated. The pre-#1020
8250
- * `accessPolicy` spelling is a retired key (#3996, `retired-keys.ts`).
8813
+ * configs do (#4116). The id spelling in TOML (#4283) and the pre-#1020
8814
+ * `accessPolicy` spelling (#3996) are retired keys (`retired-keys.ts`).
8251
8815
  */
8252
8816
  const BLOB_BUCKET_HANDLER = "src/app-api/controllers/blob-buckets-controller.ts";
8253
8817
  export const BLOB_BUCKET_SURFACE = {
@@ -8303,7 +8867,7 @@ export const BLOB_BUCKET_SURFACE = {
8303
8867
  // Authored as a name, sent as an id (#4116), so a tree copied into
8304
8868
  // another app binds that app's rule set. Existence-checked against
8305
8869
  // AccessRuleSet; mutually exclusive with a preset change in the same
8306
- // request.
8870
+ // request. The TOML spelling of the id is a retired key (#4283).
8307
8871
  validation: handledBy(BLOB_BUCKET_HANDLER, "BlobBucketsController", "apply"),
8308
8872
  },
8309
8873
  ],
@@ -8353,14 +8917,6 @@ export const BLOB_BUCKET_SURFACE = {
8353
8917
  note: "The bucket key. `config pull` writes it for readability; push takes " +
8354
8918
  "the identity from the file name and attaches `bucketKey` itself.",
8355
8919
  },
8356
- ruleSetId: {
8357
- kind: "deprecated",
8358
- note: "The id spelling of the rule-set reference (#4116). An id names a " +
8359
- "rule set in one app, so a copied tree cannot resolve it; " +
8360
- "`ruleSetName` is the authored key. Still accepted on push, with " +
8361
- "an advisory line, and `config pull` rewrites the file to " +
8362
- "`ruleSetName`. Retiring it is a later change of its own.",
8363
- },
8364
8920
  },
8365
8921
  responseOnlyKeys: {},
8366
8922
  },
@@ -8541,20 +9097,21 @@ export const EMAIL_TEMPLATE_SURFACE = {
8541
9097
  * The `database-type-config` configuration object's definition (issue #2644, phase 3).
8542
9098
  *
8543
9099
  * `database-type-configs/<databaseType>.toml` is the most structured file in the sync
8544
- * slot: a `[type]` scalar table, plus four authored sub-trees that keep the
9100
+ * slot: a `[type]` scalar table, plus three authored sub-trees that keep the
8545
9101
  * validators that already own them, per the spec's scope boundary —
8546
9102
  *
8547
9103
  * `[models.*]` → the stored `schema` TOML string (`parseSchemaToml`)
8548
- * `[[operations]]` → `DatabaseTypeOperation` rows (`toml-params-validator.ts`)
8549
- * `[[subscriptions]]` → `DatabaseTypeSubscription` rows (#803)
8550
9104
  * `[triggers]` → the trigger-rule tree (`validateTriggerRules`)
8551
9105
  * `[metadata]`/`secrets` → the declared-access manifest (#1304)
8552
9106
  *
8553
- * `operations` and `subscriptions` are separate models with their own
8554
- * endpoints, so they are not fields of `DatabaseTypeConfig` at all; `schema`,
8555
- * `triggers` and `metadataManifest` ARE model fields and are declared
8556
- * `structural` here, naming the channel and the validator, so their absence
8557
- * from the `[type]` table reads as a decision rather than an omission.
9107
+ * `schema`, `triggers` and `metadataManifest` are model fields and are
9108
+ * declared `structural` here, naming the channel and the validator, so their
9109
+ * absence from the `[type]` table reads as a decision rather than an omission.
9110
+ *
9111
+ * #3189 — `[[operations]]` and `[[subscriptions]]` retired with database
9112
+ * operations and type-scoped subscriptions; `config push` refuses a file that
9113
+ * still carries either, with the sentence `retired-keys.ts` states
9114
+ * (`RETIRED_DOCUMENT_KEYS`).
8558
9115
  *
8559
9116
  * The exposed `[type]` scalars are exactly the file-OWNED ones (#1567): `sync
8560
9117
  * push` sends each as value-or-`null` so removing a line from the TOML clears
@@ -8593,30 +9150,6 @@ export const DATABASE_TYPE_SURFACE = {
8593
9150
  // spelling is retired in TOML (#3997): `retired-keys.ts`, `type`.
8594
9151
  validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
8595
9152
  },
8596
- {
8597
- field: "metadataAccess",
8598
- tomlKey: "celContextAccess",
8599
- type: "string",
8600
- emit: "whenSet",
8601
- writableOn: BOTH,
8602
- // CEL, parsed before storing (#1336). The legacy `metadataAccess`
8603
- // spelling stays readable on push — see tomlOnlyKeys.
8604
- deprecated: {
8605
- note: "one CEL expression gates both read and update of the database " +
8606
- "CEL context; use a resource metadata category, which has " +
8607
- "separate readRule and writeRule",
8608
- },
8609
- validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
8610
- },
8611
- {
8612
- field: "defaultAccess",
8613
- tomlKey: "defaultAccess",
8614
- type: "string",
8615
- emit: "whenSet",
8616
- writableOn: BOTH,
8617
- // CEL, plus the shared `md`-shape allowlist (#1523).
8618
- validation: handledBy(DB_TYPE_HANDLER, "DatabaseTypeConfigController", "apply"),
8619
- },
8620
9153
  {
8621
9154
  field: "autoPopulatedFields",
8622
9155
  tomlKey: "autoPopulatedFields",
@@ -8676,44 +9209,8 @@ export const DATABASE_TYPE_SURFACE = {
8676
9209
  note: "User who created the config; assigned server-side.",
8677
9210
  },
8678
9211
  },
8679
- tomlOnlyKeys: {
8680
- metadataAccess: {
8681
- kind: "deprecated",
8682
- note: "The legacy spelling of `celContextAccess`. Read on push when the " +
8683
- "newer key is absent (#1567 keeps `celContextAccess = \"\"` an " +
8684
- "explicit clear); pull writes `celContextAccess`.",
8685
- },
8686
- },
8687
- requestOnlyKeys: {
8688
- pendingOpDeletes: {
8689
- note: "Operation names this push is deleting, sent with the type-config " +
8690
- "PATCH so the server validates the remaining schema against the " +
8691
- "operations that will survive rather than the current set.",
8692
- },
8693
- pendingOpUpdates: {
8694
- note: "#1336 — the post-push form of the operations this push rewrites " +
8695
- "({ name, access, params }), so the child-op re-lint evaluates " +
8696
- "each against what the push is about to store rather than the " +
8697
- "stale row. Read by `type-config-access-lint.ts`, the same " +
8698
- "look-ahead contract as `pendingOpDeletes` / `finalOpNames`.",
8699
- },
8700
- pendingOpUpserts: {
8701
- modes: ["update"],
8702
- note: "#2732 — the full post-push BODY of every operation this push " +
8703
- "lands ({ name, type, modelName, access, definition, params }), so " +
8704
- "the schema-edit gate lints a rewritten operation as rewritten " +
8705
- "rather than as stored. That is what makes removing a model and " +
8706
- "rewriting the operations that named it a single push. Read by " +
8707
- "`pending-op-set.ts`, which honors an entry only when it is a " +
8708
- "whole operation body whose name `finalOpNames` keeps — anything " +
8709
- "less is ignored and the stored operation is gated instead. A " +
8710
- "create has no stored operations to gate.",
8711
- },
8712
- finalOpNames: {
8713
- note: "The operation names the file will end with, for the same " +
8714
- "look-ahead validation as `pendingOpDeletes`.",
8715
- },
8716
- },
9212
+ tomlOnlyKeys: {},
9213
+ requestOnlyKeys: {},
8717
9214
  responseOnlyKeys: {},
8718
9215
  },
8719
9216
  ],
@@ -8723,15 +9220,6 @@ export const DATABASE_TYPE_SURFACE = {
8723
9220
  note: "#666 — the `[models.<Name>.fields.*]` blocks, re-serialized into the " +
8724
9221
  "stored `schema` TOML string on push and merged back on pull.",
8725
9222
  },
8726
- operations: {
8727
- kind: "structural",
8728
- note: "The `[[operations]]` array. Keeps the validator that already owns it " +
8729
- "(`cli/src/lib/toml-params-validator.ts`) and its own endpoints.",
8730
- },
8731
- subscriptions: {
8732
- kind: "structural",
8733
- note: "#803 — the `[[subscriptions]]` array, applied through its own endpoints.",
8734
- },
8735
9223
  triggers: {
8736
9224
  kind: "structural",
8737
9225
  note: "The type's authored trigger table, stored on the type config as " +
@@ -9219,7 +9707,7 @@ export const TRANSFORM_SURFACE = {
9219
9707
  * config version (`ServerFunctionConfig`), but it is ONE table in the file: an
9220
9708
  * author does not write a version, they write a function and push it. The
9221
9709
  * definition therefore declares the header's field surface and lists the
9222
- * version-carried keys (`entry`, `mode`, `limits`) in `tomlOnlyKeys` beside
9710
+ * version-carried keys (`entry`, `limits`) in `tomlOnlyKeys` beside
9223
9711
  * `key` — they are authored, they are not fields of the header model, and they
9224
9712
  * travel on the config-version route rather than in the header body. Declaring
9225
9713
  * a second table over `ServerFunctionConfig` at the same `tomlPath` is not
@@ -9307,6 +9795,20 @@ const TRIGGER_DECLARATION_RULES = functionRules("preflight", "src/config-surface
9307
9795
  ["cron-trigger-expression-required", "A cron entry carries a `cron` expression."],
9308
9796
  ["cron-trigger-overlap-policy", "`overlapPolicy` is `skip` or `allow`."],
9309
9797
  ]);
9798
+ /**
9799
+ * The retired runtime keys (#3188). `mode`, `durable` and a cron entry's
9800
+ * `mode` were accepted with a warning through phase 4 (#3482); phase 5 refuses
9801
+ * them, first in the preflight's order, so an author reads them in the order
9802
+ * their file carries them.
9803
+ */
9804
+ const RETIRED_RUNTIME_KEY_RULES = functionRules("preflight", "src/config-surface/function-retired-keys.ts", "refusedFunctionKeys", [
9805
+ [
9806
+ "retired-runtime-key",
9807
+ "`mode`, `durable` and a cron entry's `mode` are refused: the caller " +
9808
+ "picks the runtime at each call (`invoke` or `start`) and every cron " +
9809
+ "fire starts a task run, so the file has nothing to say. Delete the line.",
9810
+ ],
9811
+ ]);
9310
9812
  /**
9311
9813
  * The file-decidable half of the webhook `signingSecret` rule (#3320). The
9312
9814
  * SAME handler under the SAME ids is declared `apply` on the standalone
@@ -9464,14 +9966,6 @@ export const SERVER_FUNCTION_SURFACE = {
9464
9966
  note: "#3462 — WHICH admin put the active version live. Same two doors, " +
9465
9967
  "same reason: the actor is observed, never declared.",
9466
9968
  },
9467
- runAs: {
9468
- kind: "server-owned",
9469
- note: "RETIRED from the write surface by #3279: function code acts as " +
9470
- "the system whoever invoked it, so the mode the column chose " +
9471
- "between no longer exists. Declared-and-deprecated storage only " +
9472
- "until phase 6 drops the column; a stored value decides nothing " +
9473
- "and is never echoed.",
9474
- },
9475
9969
  deleteLockedAt: {
9476
9970
  kind: "server-owned",
9477
9971
  note: "#3186 — the lease a hard delete takes before it scans for live " +
@@ -9508,24 +10002,21 @@ export const SERVER_FUNCTION_SURFACE = {
9508
10002
  },
9509
10003
  mode: {
9510
10004
  kind: "structural",
9511
- note: "RETIRED (#3482), and still ACCEPTED through phase 4. A function " +
9512
- "is a function: the CALLER picks the runtime at each call, with " +
9513
- "`invoke` running it inside the request and `start` running it as " +
9514
- "a task, cron fires always starting a task run and webhook " +
9515
- "deliveries always running as a request. A file that still " +
9516
- "carries the key pushes, with a warning naming it as ignored and " +
9517
- "every other key applied, and `config pull` removes it; from " +
9518
- "phase 5 (#3188) it is refused as an unknown key. Nothing reads " +
9519
- "it, and its value is not validated. A function that must not run " +
9520
- "under one runtime says so in code with `assertRuntime`.",
10005
+ note: "RETIRED (#3482) and REFUSED from phase 5 (#3188) by the " +
10006
+ "`retired-runtime-key` rule, whose sentence names the key and " +
10007
+ "says to delete the line. A function is a function: the CALLER " +
10008
+ "picks the runtime at each call, with `invoke` running it inside " +
10009
+ "the request and `start` running it as a task, cron fires always " +
10010
+ "starting a task run and webhook deliveries always running as a " +
10011
+ "request. Listed here so the refusal is the rule's sentence rather " +
10012
+ "than the generic unknown-key one; `config pull` removes it from a " +
10013
+ "version pushed before the retirement.",
9521
10014
  },
9522
10015
  durable: {
9523
10016
  kind: "structural",
9524
- note: "RETIRED (#3482), and still ACCEPTED through phase 4 — the same " +
9525
- "as `mode`, whose alias it was. It named one runner for the whole " +
9526
- "function, and there is no such thing any more. A file that " +
9527
- "carries it pushes with a warning, `config pull` removes it, and " +
9528
- "phase 5 refuses it.",
10017
+ note: "RETIRED (#3482) and REFUSED from phase 5 (#3188) — the same as " +
10018
+ "`mode`, whose alias it was, by the same rule. It named one runner " +
10019
+ "for the whole function, and there is no such thing any more.",
9529
10020
  },
9530
10021
  limits: {
9531
10022
  kind: "structural",
@@ -9563,11 +10054,12 @@ export const SERVER_FUNCTION_SURFACE = {
9563
10054
  tomlDocumentKeys: {},
9564
10055
  // ── Cross-field rules (#3374) ─────────────────────────────────────────────
9565
10056
  //
9566
- // Single-field refusals inside these modules (`mode`'s value, a webhook
10057
+ // Single-field refusals inside these modules (a retired `mode` key, a webhook
9567
10058
  // block's `verificationScheme`) are declared here too: this is the only
9568
10059
  // id-bearing slot until #3375 gives `ConfigField.validation` a stage, and a
9569
10060
  // module's list must equal its declarations in both directions.
9570
10061
  crossFieldRules: [
10062
+ ...RETIRED_RUNTIME_KEY_RULES,
9571
10063
  ...TRIGGER_DECLARATION_RULES,
9572
10064
  ...SIGNING_SECRET_RULES,
9573
10065
  // #3376 — after the signing-secret group, mirroring the order
@@ -9601,8 +10093,23 @@ export const SERVER_FUNCTION_SURFACE = {
9601
10093
  * WIRE shape is the parsed value. That asymmetry is the handler's, not the
9602
10094
  * definition's: `parseTestCaseToml` parses on push and the admin API
9603
10095
  * re-stringifies on store.
10096
+ *
10097
+ * #4262 — an agent prompt's case carries one more block, `[test.agent]`: the
10098
+ * seeded history its single round starts from, the round's turn context, zone
10099
+ * and tag, and two assertions on the round's calls. Six flat fields on the
10100
+ * wire, each `whenSet`, so a chat case's sidecar never grows an empty block.
10101
+ * `history` is typed `string` — JSON text in TOML, on the wire and in storage —
10102
+ * because `isEmptyForEmit` reads every array as empty for a `json` field, so a
10103
+ * `whenSet` array would never pull and would compare equal to anything.
9604
10104
  */
9605
10105
  const TEST_CASE_HANDLER = "src/admin-api.ts";
10106
+ /**
10107
+ * #4262 — the agent block's one rule, vendored: the admin routes call it on
10108
+ * create and update, the CLI's preflight on an agent prompt's sidecar. Its
10109
+ * schema half (input, context and event schemas) needs the app's agent, so the
10110
+ * stage is `apply`.
10111
+ */
10112
+ const AGENT_TEST_CASE_RULE = handledBy("src/config-surface/agent-test-case.ts", "resolveAgentTestCase", "apply");
9606
10113
  /**
9607
10114
  * ── Why this module's CLI handler is declared `apply` (#3375) ─────────────
9608
10115
  *
@@ -9704,7 +10211,72 @@ export const TEST_CASE_SURFACE = {
9704
10211
  // JSON object, deep-partial-matched against the parsed output.
9705
10212
  validation: handledBy(CLI_SYNC, "parseTestCaseToml", "apply"),
9706
10213
  },
10214
+ // #4262 — `[test.agent]`, an agent prompt's case only.
10215
+ {
10216
+ field: "agentHistory",
10217
+ tomlKey: "history",
10218
+ tomlGroup: "agent",
10219
+ // JSON text of the seeded history, stored as authored.
10220
+ type: "string",
10221
+ emit: "whenSet",
10222
+ writableOn: BOTH,
10223
+ validation: AGENT_TEST_CASE_RULE,
10224
+ },
10225
+ {
10226
+ field: "agentContext",
10227
+ tomlKey: "context",
10228
+ tomlGroup: "agent",
10229
+ // `turn.*` for the round; TOML carries the JSON text.
10230
+ type: "json",
10231
+ emit: "whenSet",
10232
+ writableOn: BOTH,
10233
+ validation: AGENT_TEST_CASE_RULE,
10234
+ },
10235
+ {
10236
+ field: "agentTimezone",
10237
+ tomlKey: "timezone",
10238
+ tomlGroup: "agent",
10239
+ type: "string",
10240
+ emit: "whenSet",
10241
+ writableOn: BOTH,
10242
+ validation: AGENT_TEST_CASE_RULE,
10243
+ },
10244
+ {
10245
+ field: "agentLocale",
10246
+ tomlKey: "locale",
10247
+ tomlGroup: "agent",
10248
+ type: "string",
10249
+ emit: "whenSet",
10250
+ writableOn: BOTH,
10251
+ validation: AGENT_TEST_CASE_RULE,
10252
+ },
10253
+ {
10254
+ field: "agentExpectedToolCall",
10255
+ tomlKey: "expectedToolCall",
10256
+ tomlGroup: "agent",
10257
+ // `{ name, arguments? }`; TOML carries the JSON text.
10258
+ type: "json",
10259
+ emit: "whenSet",
10260
+ writableOn: BOTH,
10261
+ validation: AGENT_TEST_CASE_RULE,
10262
+ },
10263
+ {
10264
+ field: "agentExpectedNoToolCall",
10265
+ tomlKey: "expectedNoToolCall",
10266
+ tomlGroup: "agent",
10267
+ type: "boolean",
10268
+ emit: "whenSet",
10269
+ writableOn: BOTH,
10270
+ validation: AGENT_TEST_CASE_RULE,
10271
+ },
9707
10272
  ],
10273
+ tomlGroups: {
10274
+ agent: {
10275
+ note: "#4262 — `[test.agent]`: the seeded history, turn context and " +
10276
+ "tool-call assertions of a single-round test on an agent prompt. " +
10277
+ "Refused on any other case.",
10278
+ },
10279
+ },
9708
10280
  notExposed: {
9709
10281
  testCaseId: {
9710
10282
  kind: "server-owned",
@@ -10561,6 +11133,12 @@ export const GENERATED_CONFIG_MODEL_FIELDS = {
10561
11133
  "evaluatorPromptId",
10562
11134
  "evaluatorConfigId",
10563
11135
  "attachments",
11136
+ "agentHistory",
11137
+ "agentContext",
11138
+ "agentTimezone",
11139
+ "agentLocale",
11140
+ "agentExpectedToolCall",
11141
+ "agentExpectedNoToolCall",
10564
11142
  "createdBy",
10565
11143
  "createdAt",
10566
11144
  "modifiedAt",
@@ -10606,8 +11184,6 @@ export const GENERATED_CONFIG_MODEL_FIELDS = {
10606
11184
  "databaseType",
10607
11185
  "ruleSetId",
10608
11186
  "triggers",
10609
- "metadataAccess",
10610
- "defaultAccess",
10611
11187
  "metadataManifest",
10612
11188
  "autoPopulatedFields",
10613
11189
  "timestamps",
@@ -10663,7 +11239,6 @@ export const GENERATED_CONFIG_MODEL_FIELDS = {
10663
11239
  "activeConfigId",
10664
11240
  "activeConfigActivatedAt",
10665
11241
  "activeConfigActivatedBy",
10666
- "runAs",
10667
11242
  "access",
10668
11243
  "inputSchema",
10669
11244
  "outputSchema",