@lotics/app-sdk 0.101.1 → 0.101.2

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.
package/dist/index.js CHANGED
@@ -16355,6 +16355,7 @@ function numberUnitRefusal(format2, unit) {
16355
16355
  function effectiveSelectOptions(field) {
16356
16356
  if (field === void 0) return null;
16357
16357
  if (field.type === "select") return field.options ?? null;
16358
+ if (field.type === "formula") return field.formula?.options ?? null;
16358
16359
  if (field.type === "lookup" && field.lookup_field_type === "select") {
16359
16360
  return field.lookup_field_options ?? null;
16360
16361
  }
@@ -19152,6 +19153,9 @@ function resolveFilterType(field) {
19152
19153
  return "date";
19153
19154
  case "boolean":
19154
19155
  return "boolean";
19156
+ // Its cell is a single select's: `[key]`.
19157
+ case "select":
19158
+ return "select";
19155
19159
  default:
19156
19160
  return "text";
19157
19161
  }
@@ -19399,7 +19403,7 @@ var tableLookupFieldSchema = tableFieldBaseSchema.extend({
19399
19403
  var tableFilesFieldSchema = tableFieldBaseSchema.extend({
19400
19404
  type: zod_default.literal("files").describe("Field type for file attachments (images, PDFs, documents)")
19401
19405
  });
19402
- var formulaOutputTypeSchema = zod_default.enum(["number", "text", "date", "datetime", "boolean"]);
19406
+ var formulaOutputTypeSchema = zod_default.enum(["number", "text", "date", "datetime", "boolean", "select"]);
19403
19407
  var formulaInputSchema = zod_default.object({
19404
19408
  expression: zod_default.string().describe(
19405
19409
  "JavaScript expression. Uses {Field Name} for field references."
@@ -19410,10 +19414,13 @@ var formulaInputSchema = zod_default.object({
19410
19414
  currency: zod_default.string().optional().describe("ISO 4217 currency code, e.g. USD, VND, EUR"),
19411
19415
  unit: zod_default.string().optional().describe(UNIT_DESCRIPTION),
19412
19416
  unit_field: zod_default.string().optional().describe(UNIT_FIELD_DESCRIPTION),
19413
- currency_field: zod_default.string().optional().describe(CURRENCY_FIELD_DESCRIPTION)
19417
+ currency_field: zod_default.string().optional().describe(CURRENCY_FIELD_DESCRIPTION),
19418
+ options: zod_default.array(tableSelectFieldOptionSchema).min(1).optional().describe(
19419
+ "The categories the formula yields, drawn as a single select's options are. The expression yields one option's `key` (text) or null; a key it does not declare computes an error. Omit for a formula yielding a plain value."
19420
+ )
19414
19421
  });
19415
19422
  var formulaSchema = formulaInputSchema.extend({
19416
- output_type: formulaOutputTypeSchema.optional().describe("Output type of the formula. Inferred by the backend from the expression \u2014 read-only on input. `datetime` when the result carries a time of day (references a datetime field or a time-bearing helper)."),
19423
+ output_type: formulaOutputTypeSchema.optional().describe("Output type of the formula. Inferred by the backend from the expression \u2014 read-only on input. `datetime` when the result carries a time of day (references a datetime field or a time-bearing helper); `select` when the formula declares `options`."),
19417
19424
  volatile: zod_default.boolean().optional().describe("Whether the formula's result depends on `now()` or other non-deterministic helpers. Inferred by the backend \u2014 read-only on input.")
19418
19425
  });
19419
19426
  var tableFormulaFieldSchema = tableFieldBaseSchema.extend({
@@ -19490,6 +19497,11 @@ var tableFieldInputSchema = zod_default.discriminatedUnion("type", [
19490
19497
  tableAutonumberFieldSchema
19491
19498
  ]);
19492
19499
  var tableFieldsSchema = zod_default.array(tableFieldSchema);
19500
+ function selectOptionsOf(field) {
19501
+ if (field.type === "select") return field.options;
19502
+ if (field.type === "formula") return field.formula.options;
19503
+ return void 0;
19504
+ }
19493
19505
  var tableFieldCreateBaseSchema = zod_default.object({
19494
19506
  name: zod_default.string().describe("Display name"),
19495
19507
  description: zod_default.string().describe("Field description"),
@@ -19634,6 +19646,10 @@ function projectCell(field, value) {
19634
19646
  case "select_member":
19635
19647
  if (field.multi === true || !Array.isArray(value)) return value;
19636
19648
  return value.length === 0 ? null : value[0];
19649
+ // A formula yielding one of its options holds a single select's cell.
19650
+ case "formula":
19651
+ if (field.formula.options === void 0 || !Array.isArray(value)) return value;
19652
+ return value.length === 0 ? null : value[0];
19637
19653
  case "select_record_link":
19638
19654
  return Array.isArray(value) ? extractRecordLinkIds(value) : value;
19639
19655
  default:
@@ -25463,6 +25479,7 @@ async function evaluateRead(source, path, coerce, ctx) {
25463
25479
  return current;
25464
25480
  }
25465
25481
  function isScalarWrappedField(field) {
25482
+ if (field.type === "formula") return field.formula.options !== void 0;
25466
25483
  return (field.type === "select" || field.type === "select_member") && field.multi !== true;
25467
25484
  }
25468
25485
  function unwrapScalarField(value) {
@@ -25485,7 +25502,10 @@ async function coerceFieldDisplay(value, field, ctx) {
25485
25502
  if (value === null || value === void 0) return value;
25486
25503
  switch (field.type) {
25487
25504
  case "select":
25488
- return coerceSelectDisplay(value, field);
25505
+ return coerceSelectDisplay(value, field.options);
25506
+ // One of its own options, named as a select's is.
25507
+ case "formula":
25508
+ return field.formula.options === void 0 ? value : coerceSelectDisplay(value, field.formula.options);
25489
25509
  case "select_record_link":
25490
25510
  return coerceRecordLinkDisplay(value, field, ctx);
25491
25511
  case "select_member":
@@ -25496,8 +25516,8 @@ async function coerceFieldDisplay(value, field, ctx) {
25496
25516
  return value;
25497
25517
  }
25498
25518
  }
25499
- function coerceSelectDisplay(value, field) {
25500
- const optionMap = new Map(field.options.map((o) => [o.key, o.name]));
25519
+ function coerceSelectDisplay(value, options) {
25520
+ const optionMap = new Map(options.map((o) => [o.key, o.name]));
25501
25521
  const resolve = (key) => {
25502
25522
  if (typeof key !== "string") return "";
25503
25523
  return optionMap.get(key) ?? key;
@@ -27317,6 +27337,10 @@ function getFieldDisplayValue(field, value) {
27317
27337
  return option?.name ?? selectValues[0] ?? "";
27318
27338
  }
27319
27339
  case "formula": {
27340
+ if (field.formula.options !== void 0) {
27341
+ const [key] = Array.isArray(value) ? value : [];
27342
+ return key === void 0 ? "" : field.formula.options.find((option) => option.key === key)?.name ?? key;
27343
+ }
27320
27344
  const formulaVal = getFormulaValue(value);
27321
27345
  return formulaVal !== null ? String(formulaVal) : "";
27322
27346
  }
@@ -29522,11 +29546,10 @@ function compareFieldValues(aValue, bValue, field, blankPosition = "bottom", ord
29522
29546
  if (aValue === bValue) {
29523
29547
  return 0;
29524
29548
  }
29525
- if (field.type === "select") {
29526
- const selectField = field;
29527
- const options = selectField.options || [];
29528
- const aSelectArray = aValue;
29529
- const bSelectArray = bValue;
29549
+ const options = selectOptionsOf(field);
29550
+ if (options !== void 0) {
29551
+ const aSelectArray = Array.isArray(aValue) ? aValue : null;
29552
+ const bSelectArray = Array.isArray(bValue) ? bValue : null;
29530
29553
  const aSelectValue = aSelectArray?.[0];
29531
29554
  const bSelectValue = bSelectArray?.[0];
29532
29555
  const aIsEmpty2 = isArrayFieldEmpty(aSelectArray);
@@ -29683,6 +29706,10 @@ function referencedFieldKeys(expression, fields) {
29683
29706
  return prepareFormulaExpression(expression, fields).referencedKeys;
29684
29707
  }
29685
29708
 
29709
+ // ../shared/src/formula_language.ts
29710
+ var HELPERS2 = new Set(Object.keys(createTimezoneAwareFunctions()));
29711
+ var BY_LOWERCASE = new Map([...HELPERS2].map((name) => [name.toLowerCase(), name]));
29712
+
29686
29713
  // ../shared/src/formula.ts
29687
29714
  function prepareFormulaContext(fields) {
29688
29715
  const formulaFields = fields.filter((field) => field.type === "formula");
@@ -29833,12 +29860,13 @@ function computeFormulaFieldsWithContext(row2, fields, context, linkedTableRecor
29833
29860
  continue;
29834
29861
  }
29835
29862
  const everyReferenceEmpty = referencedKeys.length > 0 && referencedKeys.every((fieldKey) => evaluationContext[fieldKey] === null);
29836
- const formulaResult = evaluateFormulaWithJS(
29863
+ const evaluated2 = evaluateFormulaWithJS(
29837
29864
  prepared.refs.evaluable,
29838
29865
  { data: evaluationContext },
29839
29866
  timezone,
29840
29867
  everyReferenceEmpty
29841
29868
  );
29869
+ const formulaResult = formulaField.formula.options === void 0 ? evaluated2 : selectResult(formulaField.formula.options, evaluated2);
29842
29870
  result[formulaField.key] = formulaResult;
29843
29871
  if (isFormulaError(formulaResult)) {
29844
29872
  erroredFormulaKeys.add(formulaField.key);
@@ -29857,6 +29885,12 @@ function computeFormulaFieldsWithContext(row2, fields, context, linkedTableRecor
29857
29885
  }
29858
29886
  return result;
29859
29887
  }
29888
+ function selectResult(options, evaluated2) {
29889
+ if (evaluated2 === null || evaluated2 === "") return null;
29890
+ if (isFormulaError(evaluated2)) return evaluated2;
29891
+ if (typeof evaluated2 === "string" && options.some((option) => option.key === evaluated2)) return [evaluated2];
29892
+ return `${FORMULA_ERROR_PREFIX}yields ${JSON.stringify(evaluated2)}, which is none of its options (${options.map((option) => option.key).join(", ")})`;
29893
+ }
29860
29894
  function normalizeEmptyToNull(value) {
29861
29895
  return isEmptyFieldValue(value) ? null : value;
29862
29896
  }
@@ -4,5 +4,5 @@
4
4
  * tarball. Written at every build, never edited.
5
5
  */
6
6
  export type QueryAggregate = { by?: readonly string[]; day?: string; sum?: string; };
7
- export type TableField = { key: string; name: string; description: string; type: "number"; format: "number" | "currency" | "percentage"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; currency?: string | null | undefined; unit?: string | null | undefined; unit_field?: string | null | undefined; currency_field?: string | null | undefined; default_value?: number | null | undefined; } | { key: string; name: string; description: string; type: "text"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; unique?: boolean | undefined; format?: "text" | "link" | "markdown" | undefined; default_value?: string | null | undefined; } | { key: string; name: string; description: string; type: "date"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; format?: "date" | "datetime" | "date_range" | "datetime_range" | undefined; timezone?: string | undefined; derive_from?: "created_at" | "updated_at" | undefined; default_value?: string | null | undefined; } | { key: string; name: string; description: string; type: "boolean"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; default_value?: boolean | null | undefined; } | { key: string; name: string; description: string; type: "select"; options: { key: string; name: string; color: "red" | "orange" | "amber" | "yellow" | "lime" | "green" | "emerald" | "teal" | "cyan" | "sky" | "blue" | "indigo" | "violet" | "purple" | "fuchsia" | "pink" | "rose" | "slate" | "gray" | "zinc" | "neutral" | "stone"; mark?: { kind: string; name: string; } | undefined; }[]; confirm_before_update?: boolean | undefined; required?: boolean | undefined; multi?: boolean | undefined; default_value?: string[] | null | undefined; } | { key: string; name: string; description: string; type: "select_member"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; multi?: boolean | undefined; default_value?: string[] | null | undefined; } | { key: string; name: string; description: string; type: "select_record_link"; table_id: string; display_field_keys: string[]; confirm_before_update?: boolean | undefined; required?: boolean | undefined; display_field_widths?: Record<string, number> | undefined; paired_field_key?: string | undefined; cardinality?: "one" | "many" | undefined; } | { key: string; name: string; description: string; type: "rollup"; source_field_key: string; aggregate_option: { operation: "count"; field_key?: string | undefined; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique"; field_key: string; } | { operation: "min" | "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range"; field_key: string; } | { operation: "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "earliest" | "latest"; field_key: string; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "selected"; field_key: string; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "selected"; field_key: string; } | { operation: "empty" | "filled" | "percent_empty" | "percent_filled" | "selected"; field_key: string; } | { operation: "checked" | "unchecked" | "percent_checked" | "percent_unchecked"; field_key: string; } | { operation: "empty" | "filled" | "percent_empty" | "percent_filled" | "selected"; field_key: string; } | { operation: "min" | "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range" | "earliest" | "latest" | "checked" | "unchecked" | "percent_checked" | "percent_unchecked"; field_key: string; } | { operation: "min" | "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range" | "earliest" | "latest"; field_key: string; }; confirm_before_update?: boolean | undefined; required?: boolean | undefined; filter?: TableRecordFiltersGroupNode | undefined; aggregate_field_type?: "number" | "boolean" | "text" | "date" | "select" | "select_member" | "select_record_link" | "rollup" | "files" | "formula" | "lookup" | "button" | "autonumber" | undefined; aggregate_field_format?: string | undefined; aggregate_field_currency?: string | undefined; aggregate_field_unit?: string | undefined; aggregate_field_unit_field?: string | undefined; aggregate_field_currency_field?: string | undefined; } | { key: string; name: string; description: string; type: "lookup"; source_field_key: string; lookup_field_key: string; confirm_before_update?: boolean | undefined; required?: boolean | undefined; lookup_field_type?: "number" | "boolean" | "text" | "date" | "select" | "select_member" | "select_record_link" | "rollup" | "files" | "formula" | "lookup" | "button" | "autonumber" | undefined; lookup_field_format?: string | undefined; lookup_field_currency?: string | undefined; lookup_field_unit?: string | undefined; lookup_field_options?: { key: string; name: string; color?: string | undefined; mark?: { kind: string; name: string; } | undefined; }[] | undefined; order_by?: { field_key: string; direction: "asc" | "desc"; } | undefined; } | { key: string; name: string; description: string; type: "files"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; } | { key: string; name: string; description: string; type: "formula"; formula: { expression: string; format?: "number" | "currency" | "percentage" | "link" | undefined; currency?: string | undefined; unit?: string | undefined; unit_field?: string | undefined; currency_field?: string | undefined; output_type?: "number" | "boolean" | "text" | "date" | "datetime" | undefined; volatile?: boolean | undefined; }; confirm_before_update?: boolean | undefined; required?: boolean | undefined; } | { key: string; name: string; description: string; type: "button"; text: string; confirm_before_update?: boolean | undefined; required?: boolean | undefined; workflow_id?: string | null | undefined; } | { key: string; name: string; description: string; type: "autonumber"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; prefix?: string | undefined; padding?: number | undefined; template?: string | undefined; };
7
+ export type TableField = { key: string; name: string; description: string; type: "number"; format: "number" | "currency" | "percentage"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; currency?: string | null | undefined; unit?: string | null | undefined; unit_field?: string | null | undefined; currency_field?: string | null | undefined; default_value?: number | null | undefined; } | { key: string; name: string; description: string; type: "text"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; unique?: boolean | undefined; format?: "text" | "link" | "markdown" | undefined; default_value?: string | null | undefined; } | { key: string; name: string; description: string; type: "date"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; format?: "date" | "datetime" | "date_range" | "datetime_range" | undefined; timezone?: string | undefined; derive_from?: "created_at" | "updated_at" | undefined; default_value?: string | null | undefined; } | { key: string; name: string; description: string; type: "boolean"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; default_value?: boolean | null | undefined; } | { key: string; name: string; description: string; type: "select"; options: { key: string; name: string; color: "red" | "orange" | "amber" | "yellow" | "lime" | "green" | "emerald" | "teal" | "cyan" | "sky" | "blue" | "indigo" | "violet" | "purple" | "fuchsia" | "pink" | "rose" | "slate" | "gray" | "zinc" | "neutral" | "stone"; mark?: { kind: string; name: string; } | undefined; }[]; confirm_before_update?: boolean | undefined; required?: boolean | undefined; multi?: boolean | undefined; default_value?: string[] | null | undefined; } | { key: string; name: string; description: string; type: "select_member"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; multi?: boolean | undefined; default_value?: string[] | null | undefined; } | { key: string; name: string; description: string; type: "select_record_link"; table_id: string; display_field_keys: string[]; confirm_before_update?: boolean | undefined; required?: boolean | undefined; display_field_widths?: Record<string, number> | undefined; paired_field_key?: string | undefined; cardinality?: "one" | "many" | undefined; } | { key: string; name: string; description: string; type: "rollup"; source_field_key: string; aggregate_option: { operation: "count"; field_key?: string | undefined; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique"; field_key: string; } | { operation: "min" | "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range"; field_key: string; } | { operation: "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "earliest" | "latest"; field_key: string; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "selected"; field_key: string; } | { operation: "unique" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "selected"; field_key: string; } | { operation: "empty" | "filled" | "percent_empty" | "percent_filled" | "selected"; field_key: string; } | { operation: "checked" | "unchecked" | "percent_checked" | "percent_unchecked"; field_key: string; } | { operation: "empty" | "filled" | "percent_empty" | "percent_filled" | "selected"; field_key: string; } | { operation: "min" | "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range" | "earliest" | "latest" | "checked" | "unchecked" | "percent_checked" | "percent_unchecked"; field_key: string; } | { operation: "min" | "unique" | "date_range" | "empty" | "filled" | "percent_empty" | "percent_filled" | "percent_unique" | "sum" | "avg" | "median" | "max" | "range" | "earliest" | "latest"; field_key: string; }; confirm_before_update?: boolean | undefined; required?: boolean | undefined; filter?: TableRecordFiltersGroupNode | undefined; aggregate_field_type?: "number" | "boolean" | "text" | "date" | "select" | "select_member" | "select_record_link" | "rollup" | "files" | "formula" | "lookup" | "button" | "autonumber" | undefined; aggregate_field_format?: string | undefined; aggregate_field_currency?: string | undefined; aggregate_field_unit?: string | undefined; aggregate_field_unit_field?: string | undefined; aggregate_field_currency_field?: string | undefined; } | { key: string; name: string; description: string; type: "lookup"; source_field_key: string; lookup_field_key: string; confirm_before_update?: boolean | undefined; required?: boolean | undefined; lookup_field_type?: "number" | "boolean" | "text" | "date" | "select" | "select_member" | "select_record_link" | "rollup" | "files" | "formula" | "lookup" | "button" | "autonumber" | undefined; lookup_field_format?: string | undefined; lookup_field_currency?: string | undefined; lookup_field_unit?: string | undefined; lookup_field_options?: { key: string; name: string; color?: string | undefined; mark?: { kind: string; name: string; } | undefined; }[] | undefined; order_by?: { field_key: string; direction: "asc" | "desc"; } | undefined; } | { key: string; name: string; description: string; type: "files"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; } | { key: string; name: string; description: string; type: "formula"; formula: { expression: string; format?: "number" | "currency" | "percentage" | "link" | undefined; currency?: string | undefined; unit?: string | undefined; unit_field?: string | undefined; currency_field?: string | undefined; options?: { key: string; name: string; color: "red" | "orange" | "amber" | "yellow" | "lime" | "green" | "emerald" | "teal" | "cyan" | "sky" | "blue" | "indigo" | "violet" | "purple" | "fuchsia" | "pink" | "rose" | "slate" | "gray" | "zinc" | "neutral" | "stone"; mark?: { kind: string; name: string; } | undefined; }[] | undefined; output_type?: "number" | "boolean" | "text" | "date" | "datetime" | "select" | undefined; volatile?: boolean | undefined; }; confirm_before_update?: boolean | undefined; required?: boolean | undefined; } | { key: string; name: string; description: string; type: "button"; text: string; confirm_before_update?: boolean | undefined; required?: boolean | undefined; workflow_id?: string | null | undefined; } | { key: string; name: string; description: string; type: "autonumber"; confirm_before_update?: boolean | undefined; required?: boolean | undefined; prefix?: string | undefined; padding?: number | undefined; template?: string | undefined; };
8
8
  export type TableRecordFilters = TableRecordFiltersConditionNode | TableRecordFiltersTraversalNode | TableRecordFiltersGroupNode;
package/docs/ai.md CHANGED
@@ -13,7 +13,7 @@ Don't run a structured extraction through `askAi` (the result is stranded in a c
13
13
 
14
14
  ## Declared agents — what `useAgentRun` runs
15
15
 
16
- An agent is **declared on the app server-side, by alias**, with the `set_app_agent` tool (removed with `remove_app_agent`) — the one way an agent is bound or changed, each write a new version of the app; a deploy never touches it, and `get_app_agent` reads one back. Invoking an alias that isn't bound fails with a "no agent alias" error naming `set_app_agent`, and a deploy warns about any alias the code calls that is not bound.
16
+ An agent is **declared on the app server-side, by alias**, with the `set_app_agent` tool (removed with `remove_app_agent`) — the one way an agent is bound or changed, each write a new version of the app; a deploy never touches it, and `get_app_agent` reads one back. Invoking an alias that isn't bound fails with a "no agent alias" error naming `set_app_agent`, and a sandbox deploy warns about any alias the code calls that is not bound.
17
17
 
18
18
  A declaration carries:
19
19
 
@@ -38,7 +38,7 @@ A declaration carries:
38
38
 
39
39
  So an agent that reads or writes records needs a named query or workflow for each thing it touches — the same declaration the app's own UI uses. Declare only what that agent needs; a second agent in the same app can declare a narrower set.
40
40
 
41
- **Typing.** `.lotics/app_agents.d.ts`, generated from the app's live bindings whenever a sandbox session opens on the app, augments the SDK's `AppAgents` (alias → input shape) and `AppAgentResults` (alias → declared output shape) interfaces — `useAgentRun("recognize")` then types both `run(input)` and `output`. An alias it does not carry — every alias, in a project on your own machine — falls back to `Record<string, unknown>` input / `unknown` output.
41
+ **Typing.** `.lotics/app_agents.d.ts`, generated from the app's live bindings whenever a sandbox session opens on the app and by `lotics app create --custom` and `lotics app deploy`, augments the SDK's `AppAgents` (alias → input shape) and `AppAgentResults` (alias → declared output shape) interfaces — `useAgentRun("recognize")` then types both `run(input)` and `output`. An alias it does not carry falls back to `Record<string, unknown>` input / `unknown` output.
42
42
 
43
43
  ---
44
44
 
@@ -10,9 +10,9 @@ The read model in one paragraph: an app never sends a raw query. It invokes a **
10
10
  alias** (bound with `set_app_query`) and fills the template's declared `{{params.x}}`
11
11
  value holes; the server holds the canonical AST and runs it under the **app owner's** authority
12
12
  ([./security.md](./security.md)). The generated `.lotics/app_queries.d.ts` augments `AppQueries`,
13
- so an unbound alias is a compile-time error and params are typed per the binding. It is written
14
- from the app's live bindings whenever a sandbox session opens on the app; a project on your own
15
- machine has none, so every alias is accepted there as a plain string, untyped. Every hook reads through the SDK's own cache, over the host RPC bridge.
13
+ so params are typed per the binding; an alias it does not carry is accepted as a plain string,
14
+ untyped. It is written from the app's live bindings whenever a sandbox session opens on the app,
15
+ and by `lotics app create --custom` and `lotics app deploy`. Every hook reads through the SDK's own cache, over the host RPC bridge.
16
16
 
17
17
  ## Choosing a read
18
18
 
package/docs/mutations.md CHANGED
@@ -41,12 +41,11 @@ render: one callable per alias, each run exactly as `useWorkflow`'s.
41
41
 
42
42
  Typing comes from `.lotics/app_workflows.d.ts`, which augments the SDK's `AppWorkflows` and
43
43
  `AppWorkflowResults` interfaces from the app's bound declarations. The declarations are generated from the app's live bindings into `.lotics/` whenever a sandbox
44
- session opens on the app. A project on your own machine has none, so every alias is accepted as a
45
- plain string there, untyped. The result:
44
+ session opens on the app, and by `lotics app create --custom` and `lotics app deploy`. The result:
46
45
 
47
46
  | Declaration | Call-site type |
48
47
  |---|---|
49
- | Alias not declared | Compile-time error at `useWorkflow("...")` |
48
+ | Alias not declared | Accepted as a plain string: untyped `Record<string, unknown>` payload, `unknown` result |
50
49
  | Declared with `inputs` | `(inputs: <declared shape>) => Promise<WorkflowResult<TData>>` — inputs required and shaped |
51
50
  | Declared with an empty `inputs: {}` | `(inputs?: Record<string, never>)` — call with `{}` or nothing |
52
51
  | Declared without `inputs` | Untyped `Record<string, unknown>` payload (and unvalidated server-side — declare inputs for any real workflow) |
@@ -273,8 +272,8 @@ When the alias declares `inputs`, the server validates the payload before the wo
273
272
  - **`select` names its option set one of two ways — declare exactly one (both or neither is a
274
273
  set-time error).** *Inline* `options: [{label, value}]` freezes the set into the generated
275
274
  literal union and is **format-only at run time**: any well-formed key is accepted, and an
276
- option added to the live field after deploy is valid at run time but fails the compile-time
277
- type (widen or redeploy the declaration when the frozen union gets in the way).
275
+ option added to the live field after binding is valid at run time but fails the compile-time
276
+ type (widen or rebind the declaration when the frozen union gets in the way).
278
277
  *Field-referenced* `field: "fld_…"` (a select field's global key — `fld_…`, no table prefix)
279
278
  is **drift-proof**: the union is resolved from the field's *current* options at type-gen, and
280
279
  the submitted value is validated against the field's *current* options at run time — an option
package/docs/queries.md CHANGED
@@ -54,7 +54,7 @@ query's reach (a token in a `table_id` or field-key position fails validation).
54
54
 
55
55
  ### What deploy validates
56
56
 
57
- Deploy fails — with the compiler's own message, never raw SQL/Postgres text — when any alias:
57
+ Binding fails — with the compiler's own message, never raw SQL/Postgres text — when any alias:
58
58
 
59
59
  1. isn't a valid identifier, or the declaration isn't `{ ast, params?, description? }` (a
60
60
  description over 300 characters is refused here too);
@@ -72,12 +72,12 @@ Deploy fails — with the compiler's own message, never raw SQL/Postgres text
72
72
  overrides, `writable_target` on a computed column;
73
73
  7. fails to **compile to SQL** — several rejections exist only in the compiler (the expression
74
74
  allowlist, link extraction over a composed parent, derived-filter operators with no
75
- translation). Deploy compiles every query so these fail at deploy, not at first run;
75
+ translation). Binding compiles every query so these fail at bind, not at first run;
76
76
  8. has optional params and the **pruned** shape (all optional params omitted) fails any of the
77
77
  above — each omitted optional param executes a pruned variant of the template, and the
78
- all-params shape compiling doesn't prove the pruned one does. Deploy validates both.
78
+ all-params shape compiling doesn't prove the pruned one does. Binding validates both.
79
79
 
80
- What deploy **cannot** catch: data-dependent failures (a statement timeout on a huge unindexed
80
+ What binding **cannot** catch: data-dependent failures (a statement timeout on a huge unindexed
81
81
  scan, the file-presign ceiling) — those surface at run time as clean, safe errors (§10).
82
82
 
83
83
  ### Execution
@@ -162,7 +162,7 @@ only. Read what a row holds through your projection and the `read*` helpers, nev
162
162
  ## 2. The AST node reference
163
163
 
164
164
  Eleven node kinds. Every node except `from_table` wraps one or more child nodes; each subtree
165
- compiles to a derived `SELECT`. All structural errors below fail at **deploy** (the output
165
+ compiles to a derived `SELECT`. All structural errors below fail at **bind** (the output
166
166
  schema + compile passes run there); only data-dependent errors fail at run time.
167
167
 
168
168
  ### `from_table` — the only leaf
@@ -179,7 +179,7 @@ Scans one table. Output = one column per field on the table (named by field key,
179
179
  plus the system columns. `filter`, `search` (AND-ed with `filter`), `sort`, and `limit` apply
180
180
  inside the scan — this is the **source layer**, where the richest operator support and all
181
181
  indexes live (§10). `sort` entries are `{ field_key, order: "asc"|"desc", blank_position?:
182
- "top"|"bottom" }` (blanks default to bottom). Unknown filter/sort field keys fail deploy.
182
+ "top"|"bottom" }` (blanks default to bottom). Unknown filter/sort field keys fail binding.
183
183
 
184
184
  ### `project` — choose and compute columns
185
185
 
@@ -200,7 +200,7 @@ indexes live (§10). `sort` entries are `{ field_key, order: "asc"|"desc", blank
200
200
  `type`, is always nullable, and is never writable.
201
201
  - Output names must be unique and non-reserved.
202
202
  - A passthrough may declare a `type` **override** — realized as a *real, guarded SQL cast*
203
- (§4), never a relabel. An uncastable combination is rejected at deploy.
203
+ (§4), never a relabel. An uncastable combination is rejected at bind.
204
204
  - **Project only what you render.** A bare `from_table` ships every column — including `files`
205
205
  cells with storage keys — to the client (over-exposure + the presign ceiling at scale).
206
206
  - **A files column takes `limit` — bound it when the surface renders a THUMBNAIL, not the
@@ -209,7 +209,7 @@ indexes live (§10). `sort` entries are `{ field_key, order: "asc"|"desc", blank
209
209
  without it the response pays to resolve every entry toward the presign ceiling (§10). The bound
210
210
  is per CELL, caps at `FILES_PROJECTION_MAX_LIMIT` (10), and is a different axis from the
211
211
  query's own `limit`, which counts ROWS; `limit` on any non-`files` column or computed source is
212
- rejected at deploy. **On a UNION, set it on every arm** — arms align by name and type, not by
212
+ rejected at bind. **On a UNION, set it on every arm** — arms align by name and type, not by
213
213
  `limit`, so an arm that omits it still yields whole cells.
214
214
 
215
215
  ### `filter` — predicate over derived columns
@@ -238,7 +238,7 @@ source filter automatically (making it index-servable); otherwise it evaluates p
238
238
 
239
239
  Single **equality** predicate on one output column per side — no multi-column ON, no
240
240
  inequality/range joins. The two ON columns must share a SQL value category (text/number/…);
241
- mismatches are rejected at deploy (cast one side with a projection type override). Column-name
241
+ mismatches are rejected at bind (cast one side with a projection type override). Column-name
242
242
  collisions between the sides are rejected — `project`-rename first. A `left` join makes every
243
243
  right-side column nullable. Addressing (write-through, `record_id` filtering) follows the
244
244
  **left** side only.
@@ -268,7 +268,7 @@ came from.
268
268
  ```
269
269
 
270
270
  Full semantics in §8. `by` may be empty (a single-row aggregate); `aggregates` needs ≥ 1 entry.
271
- Aggregate operation × input-column type is validated at deploy. Grouping collapses rows —
271
+ Aggregate operation × input-column type is validated at bind. Grouping collapses rows —
272
272
  addressing is dropped.
273
273
 
274
274
  ### `window` — aggregate without collapsing
@@ -414,7 +414,7 @@ must be valid over `field`'s type — a refusal lists the valid ones.
414
414
  (`round`/`floor`/`ceil`/`abs` are 1-arg; `round` rounds to an integer.)
415
415
  - **Operators**: `+ - * / %`, `== === != !==`, `< > <= >=`, `&& ||`, `??` (→ `COALESCE`),
416
416
  unary `- + !`, and the ternary `cond ? a : b`.
417
- - **Not supported** (rejected at deploy): indexing `[…]`, nested access (`input.a.b`), list/map
417
+ - **Not supported** (rejected at bind): indexing `[…]`, nested access (`input.a.b`), list/map
418
418
  literals, arrow functions, method calls, any other function. Project the raw columns and
419
419
  compute client-side instead.
420
420
 
@@ -424,7 +424,7 @@ must be valid over `field`'s type — a refusal lists the valid ones.
424
424
  ```
425
425
 
426
426
  Prefer the typed variants where one exists — expressions are strings and get less structural
427
- validation (though they still compile-check at deploy).
427
+ validation (though they still compile-check at bind).
428
428
 
429
429
  ---
430
430
 
@@ -445,7 +445,7 @@ a **real SQL cast**, guarded so malformed values become NULL instead of aborting
445
445
  | `text` → `number` / `boolean` / `date` / `datetime` | guarded cast; a non-conforming value → NULL |
446
446
  | `json` → any scalar | via the JSON scalar text, guarded the same way |
447
447
  | scalar → `text` / `json` | plain cast |
448
- | array-valued (`select`/`member`/`link`/`files`) ↔ scalar | **impossible — rejected at deploy** |
448
+ | array-valued (`select`/`member`/`link`/`files`) ↔ scalar | **impossible — rejected at bind** |
449
449
 
450
450
  The guarded-NULL rule means a query never aborts because one row holds `"n/a"` in a text column
451
451
  you cast to number — that row's cell is NULL. Use overrides to align UNION arms or to make a
@@ -469,7 +469,7 @@ derived surfaces share one implementation.
469
469
  | select_member | `select_member` | select ops + `is_current_member`, `is_not_current_member` (field-scoped, no value; `is_not_current_member` matches every cell not holding the requester, an empty one included) | same — `is_current_member` **works at the runtime layer** too | `unnest` fans member ids. Anonymous public request: current-member binds the app owner (§1). |
470
470
  | select_record_link | `select_record_link` | membership by linked-record **id**: `has_any_of`, `has_none_of`, `has_all_of`; text over the cached **display**: `contains`, `not_contains`, `starts_with`, `ends_with`; `is_empty`, `is_not_empty` | same — id-membership works at the runtime layer (converged `[{id}]` containment) | Sorting orders by raw JSON, not display text — project the display (link extraction / `display_output`) and sort that. `unnest` fans link ids (+ display). |
471
471
  | files | `files` | `has_filename`, `has_mime_type` (substring), `has_file_count` (exact count), `is_empty`, `is_not_empty` | same | Presign-enriched at delivery (§1); `unnest` fans file ids. Only presence-counting aggregates. |
472
- | formula | its declared output type (`number`/`text`/`boolean`/`date`/`datetime`; `json` until inferred) | filtered by the output type's operators; `#ERROR:` cells are guarded — they count as empty and never match value operators | same | Extracted as a real scalar, so numeric formulas feed `sum`/`avg`/sorts. A **datetime-output** formula matches day-level date filters (full-day expansion). |
472
+ | formula | its declared output type (`number`/`text`/`boolean`/`date`/`datetime`, or `select` for one stating its own options, its cell a single select's; `json` until inferred) | filtered by the output type's operators; `#ERROR:` cells are guarded — they count as empty and never match value operators | same | Extracted as a real scalar, so numeric formulas feed `sum`/`avg`/sorts. A **datetime-output** formula matches day-level date filters (full-day expansion). |
473
473
  | rollup | decided by the OPERATION, never by the aggregated field: `earliest`/`latest` → `date`/`datetime` per that field's format; **every** other op → `number` | number or date operators per output type; text operators route as text | same | Datetime-format rollups match day-level date filters. `date_range` counts DAYS between the extremes, so it is a number despite its date input. A presence count (`filled`/`empty`/`percent_*`) is a number whatever it counts — including over `files`. |
474
474
  | lookup | the type of the VALUE the looked-up field yields — a formula's output type, a rollup's operation result, an autonumber's display string (`text`); never the word `formula`/`rollup` | operators of that type, evaluated with ANY-element semantics over the linked values (a row matches if *any* linked value matches) | array-typed lookups follow their column type's rules | **Projection returns the first element only.** Emptiness on a files-lookup checks the inner file arrays (a linked record with zero files counts empty). For all values across links: unnest the link column + join (§8). |
475
475
  | autonumber | `text` | text operators — filter by the visible composed string (`"ORD-0042"`) | same | Sorts numerically when the stored value is a bare integer, lexicographically otherwise — so `"ORD-0042"` orders by its padding and an unprefixed `"124"` orders after `"99"`, not before it. |
@@ -554,7 +554,7 @@ stay index-served. Two access classes:
554
554
  Tokens interpolate in **value positions**. A string that *is* exactly one token is replaced by
555
555
  the param's typed value (an array param yields an array); a token embedded in a larger string
556
556
  interpolates as text. Declared-but-unreferenced params are fine; referenced-but-undeclared
557
- tokens fail deploy.
557
+ tokens fail binding.
558
558
 
559
559
  **Composable optional filters (one query, many scopes).** Expose several independent filter
560
560
  axes from ONE named query — don't shard into a query-per-combination. Mark each scoping param
@@ -653,7 +653,7 @@ anywhere in the record*.
653
653
  ### The 21 aggregate operations
654
654
 
655
655
  `group` and `window` share one operation vocabulary. `count` is `COUNT(*)` (no `input_column`);
656
- everything else requires an `input_column` whose type must be compatible — checked at deploy:
656
+ everything else requires an `input_column` whose type must be compatible — checked at bind:
657
657
 
658
658
  | Operation | Valid input column types | Result | Notes |
659
659
  | --- | --- | --- | --- |
@@ -680,7 +680,7 @@ sizes on a shipment, the tags on a ticket) without a second query.
680
680
  "distinct": true, "separator": ", ", "max_values": 20 }
681
681
  ```
682
682
 
683
- - **`type` must be `text`** — declaring anything else is rejected at deploy.
683
+ - **`type` must be `text`** — declaring anything else is rejected at bind.
684
684
  - **`distinct`** defaults to **true**: three containers sized 40HC/40HC/20DC give `20DC, 40HC`.
685
685
  Pass `false` to keep every occurrence. Values are always sorted, so the column doesn't
686
686
  reshuffle between reads.
@@ -713,7 +713,7 @@ over a `link` read of it is `true`.
713
713
  { "bucket": { "source": "created", "granularity": "month", "output": "period" } }
714
714
  ```
715
715
 
716
- - `source` must be a `date` or `datetime` **column** of the input (deploy-checked).
716
+ - `source` must be a `date` or `datetime` **column** of the input (checked at bind).
717
717
  - `granularity`: `day` | `week` | `month` | `quarter` | `year`. **Weeks are ISO — Monday
718
718
  start.**
719
719
  - Buckets are computed on the stored **wall-clock** value in the field's timezone (a 23:30
@@ -737,7 +737,7 @@ the timeout for nothing.
737
737
  legal SQL window call are accepted: **`count`, `sum`, `avg`, `min`, `max`, `earliest`, `latest`,
738
738
  `filled`, `checked`, `unchecked`.** The rest cannot take an OVER clause (`median` is an
739
739
  ordered-set aggregate; `unique`/`percent_unique`/`string_agg` need DISTINCT;
740
- `range`/`empty`/`date_range`/`percent_*` compose multiple calls) — rejected at deploy. The optional `frame` applies **only**
740
+ `range`/`empty`/`date_range`/`percent_*` compose multiple calls) — rejected at bind. The optional `frame` applies **only**
741
741
  to these; a `frame` on a window with no `aggregates` is rejected as dead config.
742
742
 
743
743
  **Window `functions` — ranking / navigation.** Each is `{ "output", "fn", … }` (its own arg shape, no
@@ -751,7 +751,7 @@ to these; a `frame` on a window with no `aggregates` is rejected as dead config.
751
751
  | `dense_rank` | — | `number`, non-null. Ties share a rank; the next rank does **not** skip (1,2,2,3). |
752
752
  | `percent_rank` / `cume_dist` | — | `number`, non-null. Relative position in [0, 1]. |
753
753
  | `ntile` | `buckets` (positive int) | `number`, non-null. The row's bucket (1..buckets) splitting the partition into equal groups. |
754
- | `lag` / `lead` | `input_column`, `offset?` (int ≥ 1, default 1), `default?` (literal) | the input column's type, **nullable**. The value `offset` rows before / after this one; at the partition edge, `default` if given else NULL. `default`'s type must match the input column (checked at deploy). |
754
+ | `lag` / `lead` | `input_column`, `offset?` (int ≥ 1, default 1), `default?` (literal) | the input column's type, **nullable**. The value `offset` rows before / after this one; at the partition edge, `default` if given else NULL. `default`'s type must match the input column (checked at bind). |
755
755
 
756
756
  **Top-N per group** — rank within each partition, then filter on the derived rank column:
757
757
 
@@ -791,7 +791,7 @@ first. For a fixed, small set of columns, N filtered aggregate queries also work
791
791
 
792
792
  ## 9. Runtime refinement — filter / sort / limit / offset / count
793
793
 
794
- Each query request carries optional refinement the server wraps **around** the deployed
794
+ Each query request carries optional refinement the server wraps **around** the bound
795
795
  template as derived nodes, in this order: `filter` (narrow) → `sort` (order) → `limit`+`offset`
796
796
  (page):
797
797
 
@@ -857,11 +857,11 @@ ordering and scoping into the template or its params.
857
857
  | Statement timeout | **15 s** per query execution | 400: `query timed out after 15s — narrow the filter or simplify the query` |
858
858
  | Concurrent query executions | server-configured bulkhead (bounded slots + bounded queue wait) | **503**: `The app is handling too many requests right now. Please retry in a moment.` — a distinct busy-retry outcome; back off and retry |
859
859
  | Signed file URLs per response | server-configured, default **2,000 file entries** | 400 naming the alias, the count, and the ceiling — project files columns only where rendered, narrow, or paginate |
860
- | Traversal depth | 3 hops | **silent match-nothing** — a longer path compiles to `FALSE` (deploys green, returns zero rows) |
861
- | Param schema depth | 8 | deploy reject |
860
+ | Traversal depth | 3 hops | **silent match-nothing** — a longer path compiles to `FALSE` (binds green, returns zero rows) |
861
+ | Param schema depth | 8 | bind reject |
862
862
 
863
863
  Any other execution failure returns a generic `query execution failed` (the real error — which
864
- may embed SQL — is server-logged only). Deploy-time and validation errors are always specific.
864
+ may embed SQL — is server-logged only). Bind-time and validation errors are always specific.
865
865
 
866
866
  ### Index reality, in plain terms
867
867
 
@@ -872,20 +872,21 @@ Records are stored partitioned by workspace, so a query reads only its own works
872
872
  layer** — `select`/`select_member`/`select_record_link` `has_any_of` / `has_all_of`, and
873
873
  `is_current_member`. These compile to containment the JSONB GIN index serves.
874
874
  - **Trigram-served:** `from_table.search` (§7).
875
- - **B-tree-served (automatic for deployed queries):** text `equals`, number and date
876
- comparisons (exact and range), and `sort` fields — for fields referenced in a **deployed
877
- named query's template**. The platform provisions a partial expression index per referenced
878
- field automatically: built online whenever a query is bound (`set_app_query` or `apply_model`), re-synced daily,
875
+ - **B-tree-served (automatic for bound queries):** text `equals`, number and date
876
+ comparisons (exact and range), `from_table.sort` fields, and a one-key `sort` node over a
877
+ number or date field the rows pass unchanged — for fields referenced in a **bound named
878
+ query's template**. The platform provisions a partial expression index per referenced
879
+ field automatically: built online whenever `set_app_query` or `set_app_queries` binds a query, re-synced daily,
879
880
  capped at 8 per table (fields past the cap fall back to the scan tier, with a server WARN).
880
881
  A `{{params.…}}` value hole doesn't change this — the field key is static in the template,
881
882
  so it still gets its index. Index-seek speed at any table size once provisioned.
882
883
  - **Table scan (linear in table size):** everything else — text `contains`, negations
883
884
  (`has_none_of`, `is_none_of`, `not_*`), emptiness, files predicates, and predicates/sorts on
884
- fields that appear **only** in the runtime `filter`/`sort` options rather than the deployed
885
+ fields that appear **only** in the runtime `filter`/`sort` options rather than the bound
885
886
  template. Fine on thousands of rows; on very large tables these dominate latency and are the
886
887
  usual timeout cause.
887
888
 
888
- **Filter shape drives latency.** Equality/range/sort predicates in the deployed template are
889
+ **Filter shape drives latency.** Equality/range/sort predicates in the bound template are
889
890
  index-served; lead with those or a GIN-served membership filter / `search`, and let scan-shaped
890
891
  predicates refine the already-narrowed set. Derived-layer filters run over the subquery result
891
892
  (no index), so **filter at the source layer whenever the field exists there** — the runtime
@@ -893,14 +894,16 @@ filter is for caller-driven refinement, not for the main cut (a runtime-only fie
893
894
  managed index). (The engine pushes eligible filter-over-union predicates down automatically,
894
895
  but don't rely on that for other shapes.)
895
896
 
896
- **Sorted, paginated unions are index-served — write them plainly.** A `limit(sort(union(project(from_table) …)))`
897
- register (the "search all three tables, sort by one column, paginate" shape) is compiled to push
898
- `ORDER BY … LIMIT` into each arm and `MergeAppend` the per-arm sort-index scans — it does **not**
899
- scan every arm whole and heap-sort the union. So a single-key sort over a union of tables, each
900
- projecting that key as a direct field passthrough, is fast at any size. Don't hand-split it into
901
- per-table queries you merge client-side; the merge already happens in the index. (Requires the
902
- sort key to be a bare field projection — a computed/literal/type-overridden sort column falls back
903
- to the whole-union sort.)
897
+ **A sorted page reads in its sort index — declare the order it is read in.** A page sorted by one
898
+ column that passes a number or date field unchanged (a bare `project` source with no `type`
899
+ override, an `unpivot` passthrough, or a row column that reads the same field in every row) reads
900
+ the table in that field's sort index and stops at the page, instead of reading every row and
901
+ sorting. Over a `union`, each arm is paged this way on its own and the arms are merged, so a
902
+ register over several tables or `unpivot` sides is fast at any size — don't hand-split it into
903
+ per-table queries you merge client-side. The index exists when a bound template sorts that field
904
+ in the same direction and blank position: give the query its default order as a top-level `sort`,
905
+ and a page the app sorts the same way is served. A text column, a computed, literal or cast column,
906
+ a sort on more than one key, and a cursor (`keyset`) page sort every row.
904
907
 
905
908
  **An aggregate arm you cannot filter costs its whole table, every execution.** A `join` whose right
906
909
  side is a `group` over entire tables, keyed on a value the LEFT side supplies at run time, has no
package/docs/recipes.md CHANGED
@@ -85,14 +85,14 @@ Filter **server-side** so the app never receives another row.
85
85
  ```
86
86
 
87
87
  - `{{params.<name>}}` resolves **only in value positions**. A token in `table_id` or `field_key` is
88
- not a real identifier and fails the deploy.
88
+ not a real identifier and fails the binding.
89
89
  - `useQuery("lookupShipment", { code })` is reactive on `code`. Gate on a *submitted* code — an
90
90
  empty one matches nothing, so render a prompt rather than an empty table.
91
91
  - An **autonumber** field stores the composed string (`DL-2026-0006`), so `equals` / `contains`
92
92
  filter it directly as text. No reverse mapping.
93
93
 
94
94
  **A code lookup carries no viewer identity, so it is an enumerable IDOR.** Fine for an internal or
95
- embedded app; never ship it as a public per-user portal — see `lotics docs security`.
95
+ embedded app; never ship it as a public per-user portal — see [security](./security.md).
96
96
 
97
97
  ## Compose many optional filter axes in one query
98
98
 
@@ -103,7 +103,7 @@ whose `{{params.x}}` the caller did not pass, and collapses emptied groups to ma
103
103
  So `useQuery("search", { keyword })` filters by keyword alone, and `{}` returns everything. A
104
104
  missing *required* param still fails.
105
105
 
106
- Full pattern, including the date-param trick for range filters: `lotics docs queries` →
106
+ Full pattern, including the date-param trick for range filters: [queries](./queries.md) →
107
107
  "Composable optional filters".
108
108
 
109
109
  ## Decode cells with the accessors, never by hand
@@ -128,7 +128,7 @@ lotics run search_app_icons '{"query":"shipping"}' → ["truck","ship","packag
128
128
 
129
129
  `theme.color` is a named palette colour: `red orange amber yellow lime green emerald teal cyan sky
130
130
  blue indigo violet purple fuchsia pink rose slate gray zinc neutral stone`. `null` clears either.
131
- `update_app` sets both; a deploy warns while they are unset.
131
+ `update_app` sets both.
132
132
 
133
133
  **In-app palette** — the app's own `src/theme.ts`, exact hex. This is what the app *renders* with;
134
134
  the launcher colour does not feed it. Pick the named colour closest to your brand hex.
package/docs/security.md CHANGED
@@ -22,7 +22,7 @@ Comments are the one deliberate exception: access is app-authority (any member w
22
22
 
23
23
  ## The caller boundary
24
24
 
25
- Callers never submit query ASTs or workflow definitions — the server holds the canonical, deploy-validated template and the caller supplies only an **alias plus typed values**. Three per-input constraints are enforced server-side on **workflow and agent-run inputs** at invocation time, so a hand-crafted request can't redirect a run executing under owner authority:
25
+ Callers never submit query ASTs or workflow definitions — the server holds the canonical, bind-validated template and the caller supplies only an **alias plus typed values**. Three per-input constraints are enforced server-side on **workflow and agent-run inputs** at invocation time, so a hand-crafted request can't redirect a run executing under owner authority:
26
26
 
27
27
  | Input type | Server-enforced bound |
28
28
  |---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.101.1",
3
+ "version": "0.101.2",
4
4
  "description": "The SDK a Lotics custom-code app reads and writes through \u2014 typed hooks over the host bridge, cell readers, mount() and AppRouter",
5
5
  "type": "module",
6
6
  "exports": {