@lotics/cli 0.195.0 → 0.196.0

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/src/cli.js CHANGED
@@ -36006,6 +36006,7 @@ function resolveScreen(app, screen, entityByAlias, roles) {
36006
36006
  if (findings.length > 0) return { type: "invalid", findings };
36007
36007
  return { type: "resolved", screen: { app, screen, entity, shapeLabel, record: record2, tabs, slots } };
36008
36008
  }
36009
+ var RECORD_CHILD_VIA = ["parent", "party"];
36009
36010
  var CHILD_COLUMN_ROLES = [
36010
36011
  "identity",
36011
36012
  "mark",
@@ -36018,9 +36019,9 @@ var CHILD_COLUMN_ROLES = [
36018
36019
  "lifecycle",
36019
36020
  "verdict"
36020
36021
  ];
36021
- function parentLink(child, entity, roles) {
36022
+ function childLink(child, entity, roles, via) {
36022
36023
  return child.fields.find(
36023
- (field) => field.type === "select_record_link" && field.target_entity === entity.alias && roleOf(roles, child.alias, field.alias)?.role === "parent"
36024
+ (field) => field.type === "select_record_link" && field.target_entity === entity.alias && roleOf(roles, child.alias, field.alias)?.role === via
36024
36025
  );
36025
36026
  }
36026
36027
  function fieldsWithRole(entity, roles, role) {
@@ -36077,16 +36078,25 @@ function recordSections(entity, entities, roles, header) {
36077
36078
  const shown = facts.filter((field) => !folded.has(field.alias));
36078
36079
  const sets = ownSets.map((field) => ({ kind: "expected_set", source: "own", field }));
36079
36080
  const children = [];
36080
- for (const child of entities) {
36081
- const parentField = parentLink(child, entity, roles);
36082
- if (parentField === void 0) continue;
36083
- const setField = fieldsWithRole(child, roles, "expected_set")[0];
36084
- if (setField !== void 0) {
36085
- sets.push({ kind: "expected_set", source: "child", child, parentField, setField, filesField: fileFields(child, roles)[0] });
36086
- continue;
36081
+ const drawn = /* @__PURE__ */ new Set();
36082
+ for (const via of RECORD_CHILD_VIA) {
36083
+ for (const child of entities) {
36084
+ if (drawn.has(child.alias)) continue;
36085
+ const link = childLink(child, entity, roles, via);
36086
+ if (link === void 0) continue;
36087
+ drawn.add(child.alias);
36088
+ const setField = fieldsWithRole(child, roles, "expected_set").find(
36089
+ (field) => field.type === "select" && field.multi !== true
36090
+ );
36091
+ if (setField !== void 0) {
36092
+ sets.push({ kind: "expected_set", source: "child", child, via, link, setField, filesField: fileFields(child, roles)[0] });
36093
+ continue;
36094
+ }
36095
+ const byRole = CHILD_COLUMN_ROLES.flatMap((role) => fieldsWithRole(child, roles, role)).filter(
36096
+ (field) => field.alias !== link.alias
36097
+ );
36098
+ children.push({ kind: "children", child, via, link, fields: byRole });
36087
36099
  }
36088
- const byRole = CHILD_COLUMN_ROLES.flatMap((role) => fieldsWithRole(child, roles, role));
36089
- children.push({ kind: "children", child, parentField, fields: byRole });
36090
36100
  }
36091
36101
  return [
36092
36102
  ...shown.length > 0 ? [{ kind: "facts", fields: shown, levels }] : [],
@@ -36674,7 +36684,7 @@ function resultSideEffects(result) {
36674
36684
  }
36675
36685
 
36676
36686
  // src/version.ts
36677
- var VERSION = "0.195.0";
36687
+ var VERSION = "0.196.0";
36678
36688
 
36679
36689
  // src/timezone.ts
36680
36690
  function machineTimezone() {
@@ -39153,11 +39163,11 @@ function bindScreens(screens, live, roles, entities) {
39153
39163
  if (own === void 0) continue;
39154
39164
  const sections = recordSections(screen.entity, entities, roles, recordHeader(screen));
39155
39165
  const children = /* @__PURE__ */ new Map();
39156
- const bindChild = (section, child, parentField, drawn, set2, files) => {
39157
- const found = bindTable(`${at2}.${child.alias}`, child, [parentField, ...drawn], aliased, live, missing);
39166
+ const bindChild = (section, child, via, linkField, drawn, set2, files) => {
39167
+ const found = bindTable(`${at2}.${child.alias}`, child, [linkField, ...drawn], aliased, live, missing);
39158
39168
  if (found === void 0) return;
39159
- const parent = found.fields.get(parentField.alias);
39160
- if (parent === void 0) return;
39169
+ const link = found.fields.get(linkField.alias);
39170
+ if (link === void 0) return;
39161
39171
  const drawnRoles = /* @__PURE__ */ new Map();
39162
39172
  const fields = /* @__PURE__ */ new Map();
39163
39173
  for (const field of drawn) {
@@ -39169,6 +39179,7 @@ function bindScreens(screens, live, roles, entities) {
39169
39179
  }
39170
39180
  children.set(child.alias, {
39171
39181
  section,
39182
+ via,
39172
39183
  // Keyed by the ENTITY, not the screen: two screens over one entity read
39173
39184
  // the same rows through the same filter, and a second alias for them is
39174
39185
  // a second manifest entry, a second `.d.ts` entry and a second cache key
@@ -39178,7 +39189,7 @@ function bindScreens(screens, live, roles, entities) {
39178
39189
  entity: child,
39179
39190
  table: found.table,
39180
39191
  tableAlias: found.tableAlias,
39181
- parent,
39192
+ link,
39182
39193
  fields,
39183
39194
  roles: drawnRoles,
39184
39195
  identity: child.fields.find((field) => roleOf(roles, child.alias, field.alias)?.role === "identity")?.alias,
@@ -39192,7 +39203,7 @@ function bindScreens(screens, live, roles, entities) {
39192
39203
  const role = roleOf(roles, section.child.alias, field.alias)?.role;
39193
39204
  return role !== void 0 && DRAWN_ROLES.includes(role);
39194
39205
  });
39195
- bindChild("children", section.child, section.parentField, drawn);
39206
+ bindChild("children", section.child, section.via, section.link, drawn);
39196
39207
  continue;
39197
39208
  }
39198
39209
  if (section.kind !== "expected_set" || section.source !== "child") continue;
@@ -39200,7 +39211,8 @@ function bindScreens(screens, live, roles, entities) {
39200
39211
  bindChild(
39201
39212
  "expected_set",
39202
39213
  section.child,
39203
- section.parentField,
39214
+ section.via,
39215
+ section.link,
39204
39216
  [...name, section.setField, ...section.filesField === void 0 ? [] : [section.filesField]],
39205
39217
  section.setField,
39206
39218
  section.filesField
@@ -39246,7 +39258,7 @@ function planQueries(bound) {
39246
39258
  filter: {
39247
39259
  node_type: "condition",
39248
39260
  type: "select_record_link",
39249
- field_key: child.parent.id,
39261
+ field_key: child.link.id,
39250
39262
  operator: "has_any_of",
39251
39263
  value: [`{{params.${child.param}}}`]
39252
39264
  }
@@ -39254,7 +39266,7 @@ function planQueries(bound) {
39254
39266
  columns: [...child.fields.values()].map((field) => field.id)
39255
39267
  },
39256
39268
  params: { [child.param]: { type: "record_link", table_id: entry.table.id } },
39257
- description: `${child.entity.label} \u2014 the rows under one ${entry.screen.entity.label}`
39269
+ description: `${child.entity.label} \u2014 the rows ${child.via === "party" ? "naming" : "under"} one ${entry.screen.entity.label}`
39258
39270
  };
39259
39271
  }
39260
39272
  }
@@ -39568,8 +39580,12 @@ function roleColumn(spec) {
39568
39580
  // the same value reads "—" in the facts beside it.
39569
39581
  case "measure":
39570
39582
  return `${head}, width: 96, align: "right", cell: (${bind}) => { const level = amount(${ref}); return level === null ? null : <NumberCell value={level} />; } }`;
39583
+ // ONE VALUE, ONE READING: the currency is the FIELD's, so a total reads the
39584
+ // same money in the band, in this column and in the facts behind it. Stated
39585
+ // nowhere, the cell falls back to the reader's locale, which is the only
39586
+ // answer a field that names no currency has.
39571
39587
  case "amount":
39572
- return `${head}, width: 128, align: "right", cell: (${bind}) => { const sum = amount(${ref}); return sum === null ? null : <MoneyCell value={sum} />; } }`;
39588
+ return `${head}, width: 128, align: "right", cell: (${bind}) => { const sum = amount(${ref}); return sum === null ? null : <MoneyCell value={sum}${spec.currency === void 0 ? "" : ` currency=${str(spec.currency)}`} />; } }`;
39573
39589
  case "lifecycle":
39574
39590
  return `${head}, width: 120, cell: (${bind}) => { const held = stageOf(${options}, ${ref}); return held === null ? null : <StageCell stage={held} />; } }`;
39575
39591
  case "verdict":
@@ -39582,11 +39598,13 @@ function childColumn(child, alias) {
39582
39598
  const field = child.fields.get(alias);
39583
39599
  const role = child.roles.get(alias);
39584
39600
  if (field === void 0 || role === void 0) return null;
39601
+ const declared = child.entity.fields.find((candidate) => candidate.alias === alias);
39585
39602
  return roleColumn({
39586
39603
  role,
39587
39604
  key: field.alias,
39588
39605
  label: field.label,
39589
39606
  type: field.type,
39607
+ currency: declared === void 0 ? void 0 : currencyOf(declared),
39590
39608
  bind: "child",
39591
39609
  ref: `child[F.${child.tableAlias}.${field.alias}]`,
39592
39610
  options: `${camel(child.alias)}Fields[F.${child.tableAlias}.${field.alias}]`
@@ -39906,6 +39924,7 @@ ${pageBody}
39906
39924
  key: field.alias,
39907
39925
  label: field.label,
39908
39926
  type: field.type,
39927
+ currency: currencyOf(slot2.field),
39909
39928
  bind: "r",
39910
39929
  ref: `r[T.${field.alias}]`,
39911
39930
  options: `fields[T.${field.alias}]`
@@ -40618,7 +40637,7 @@ Captured ${totalRows} row${totalRows === 1 ? "" : "s"} across ${result.captured.
40618
40637
  }
40619
40638
 
40620
40639
  // src/model_reference.md
40621
- var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing the tables, fields, options, views, roles and first rows\na workspace starts with. `lotics scaffold check model.json` proves it offline \u2014\nno account, no network. `lotics setup model.json --email you@company.com` creates\nthe account and applies it. `lotics scaffold apply model.json` applies it again,\ninto the workspace the credential names.\n\n**There are two forms of this file.** The full one, below, spells the model out.\nThe `from` one names a published preset and carries only what this business\ndiffers by \u2014 see \xA7 Starting from a preset, and prefer it whenever a preset fits\nthe trade.\n\nApps are PLANNED here and built afterwards: `apps` names each app\'s screens as a\nshape over an entity, checked against the roles `field_roles` gives its fields,\nso the plan is refused before anyone builds a screen (\xA7 Apps and screens). The\nbuilt app lives in the workspace; publishing that workspace as a package is how\nit ships.\n\n## The rules\n\n- **At least one entity, at most 50.** More tables than that is a data model\n being designed, not scaffolded \u2014 scaffold the rest in a second call.\n- **Adoption is explicit.** `lotics setup` REFUSES an entity whose `label`\n already names a table in the workspace, naming every colliding label at once.\n `lotics scaffold apply` adopts those tables and adds the fields, options and\n views they are missing. Nothing is ever modified or deleted, so applying the\n same model twice creates nothing the second time.\n- **Adoption is by LABEL, not alias.** Change an entity\'s `label` and the next\n run asks for a NEW table beside the old one. Renames and deletions go through\n `lotics run update_table` / `lotics run delete_table`, never through the file.\n- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere, and the result says\n `rows_skipped: true`: sample rows landing among a customer\'s real ones cannot\n be told apart from them.\n- **After the first run the WORKSPACE is the source of truth.** The file is an\n authoring input, not a mirror \u2014 scaffold never deletes what the file stopped\n naming.\n- **`lotics scaffold check` decides all of it offline**, and reports every\n problem in one run rather than the first: an alias that resolves to nothing, a\n link whose pair is not symmetric, and the rows themselves \u2014 a field the entity\n does not declare, an option alias the field does not declare, a link naming no\n row in the file, a `ref` used twice, a date that is not one, a value on a\n platform-computed field, and a files cell that is neither a relative path\n beside this file nor a `fil_` id.\n\n## Top level\n\n```jsonc\n{\n "entities": [ /* the tables */ ],\n "roles": [ /* workspace groups to create */ ], // optional\n "templates":[ /* inline html / email templates */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* the reporting role each field plays, keyed by entity then field */ }, // optional\n "apps": [ /* the screens each app will have, as shapes over entities */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ], // optional\n "preset": { /* a trade\'s branches, for a PUBLISHED model */ } // optional\n}\n```\n\nThe other form names a preset instead of restating one:\n\n```jsonc\n{\n "from": "field_service", // the preset this model starts from, by slug\n "variants": ["crews"], // optional \u2014 its branches to merge in, in order\n "rename": { // optional \u2014 what THIS business calls each table\n "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } }\n },\n "entities": [ /* tables the preset does not declare */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* roles on the preset\'s fields and this business\'s own */ }, // optional\n "apps": [ /* the screens each app will have */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ] // optional\n}\n```\n\n**A model may not carry** `fixtures`, `knowledge` or `knowledge_expects`, and no\n`excel` / `word` / `pdf-form` template: each of those is content that lives in a\npublished bundle, which a model has none of. `apps` here is a plan of screens,\nnever built code. An unknown top-level key is an error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because scaffold matches by label.\n\n## Entity\n\n```jsonc\n{\n "alias": "order",\n "label": "Orders", // the table\'s name\n "description": "\u2026", // optional\n "fields": [ /* at least one */ ],\n "views": [ /* optional; an entity with none still gets the default grid */ ]\n}\n```\n\n## Field\n\nEvery field carries `alias`, `label`, an optional `description`, and an optional\n`required` \u2014 advisory only, read by app forms and workflows; the table itself has\nno required constraint. `label` may not contain `{` or `}` (formulas reference\nfields by label at the platform level).\n\n`default` is the value pre-filled into a NEW record. It applies on create only;\nexisting records are never backfilled. Only the types listed below accept one.\n\n### `text`\n\n```jsonc\n{ "alias": "name", "label": "Name", "type": "text",\n "unique": false, // optional \u2014 require distinct values\n "format": "text", // optional \u2014 "text" | "link" | "markdown"\n "default": "" } // optional\n```\n\n### `number`\n\n```jsonc\n{ "alias": "amount", "label": "Amount", "type": "number",\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage"\n "currency": "VND", // optional \u2014 ISO 4217\n "default": 0 } // optional\n```\n\n### `date`\n\n```jsonc\n{ "alias": "placed_on", "label": "Placed on", "type": "date",\n "format": "date", // optional \u2014 "date" | "datetime" | "date_range" | "datetime_range"\n "timezone": "Asia/Ho_Chi_Minh", // optional \u2014 IANA name\n "derive_from": "created_at", // optional \u2014 "created_at" | "updated_at"; makes the field read-only\n "default": "2026-01-01" } // optional; refused together with derive_from\n```\n\n### `boolean`\n\n```jsonc\n{ "alias": "paid", "label": "Paid", "type": "boolean", "default": false }\n```\n\n### `select`\n\n```jsonc\n{ "alias": "tier", "label": "Tier", "type": "select",\n "options": [ // at least one\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "multi": false, // optional\n "default": ["standard"] } // optional \u2014 option ALIASES; one unless multi\n```\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet.\n\n```jsonc\n{ "alias": "owner", "label": "Owner", "type": "select_member", "multi": false }\n```\n\n### `select_record_link`\n\n```jsonc\n{ "alias": "customer", "label": "Customer", "type": "select_record_link",\n "target_entity": "customer", // an entity alias this model declares\n "cardinality": "one", // optional \u2014 "one" | "many" (default "many")\n "sync_both_ways": true, // optional \u2014 keep a paired field on the target\n "paired_field_alias": "orders", // the partner field ON THE TARGET entity\n "display_field_aliases": ["name"] } // optional \u2014 what the link shows / the picker\'s columns\n```\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused. Declare\none side only (with no `paired_field_alias`) for a link with no back-reference.\n\n### `files`\n\n```jsonc\n{ "alias": "attachments", "label": "Attachments", "type": "files" }\n```\n\n### `formula`\n\n```jsonc\n{ "alias": "total", "label": "Total", "type": "formula",\n "formula": {\n "expression": "{amount} * 1.1", // fields on THIS entity, by alias, in braces\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage" | "link"\n "currency": "VND" // optional\n } }\n```\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n```jsonc\n{ "alias": "total_ordered", "label": "Total ordered", "type": "rollup",\n "source_field_alias": "orders", // a select_record_link field on THIS entity\n "aggregate_option": {\n "operation": "sum", // count | sum | avg | median | min | max | range |\n // empty | filled | percent_empty | percent_filled |\n // unique | percent_unique |\n // earliest | latest | date_range |\n // checked | unchecked | percent_checked |\n // percent_unchecked\n "field_key": "amount" // a field ALIAS on the linked entity ("count" may omit it)\n },\n "filter": { /* optional \u2014 see Views; every field_key is an alias on the LINKED entity */ } }\n```\n\nThe operation must be one the aggregated field\'s type allows \u2014 `sum` over a\nnumber, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n```jsonc\n{ "alias": "customer_tier", "label": "Customer tier", "type": "lookup",\n "source_field_alias": "customer", // a select_record_link field on THIS entity\n "lookup_field_alias": "tier", // a field alias on the linked entity\n "order_by": { "field_key": "placed_on", "direction": "desc" } } // optional \u2014 pick the single extreme row\n```\n\n### `autonumber`\n\n```jsonc\n{ "alias": "seq", "label": "No.", "type": "autonumber",\n "prefix": "SO-", // optional \u2014 ignored when template is set\n "padding": 4, // optional \u2014 1..20, zero-pads the integer\n "template": "SO-{YEAR}-{N:4}" } // optional \u2014 {N}, {N:W}, {YEAR}, {YEAR:2}, {MONTH}, {DAY}\n```\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n```jsonc\n{\n "alias": "gold",\n "label": "Gold customers",\n "description": "\u2026", // optional\n "columns": [ // optional \u2014 omit to show every field\n { "field_alias": "name", "visibility": "visible", "width": 240 },\n { "field_alias": "tier", "visibility": "hidden" }\n ],\n "filters": { // optional\n "node_type": "group",\n "logic": "and", // "and" | "or"\n "children": [\n { "node_type": "condition", "type": "select", "field_key": "tier",\n "operator": "has_any_of", "value": ["gold"] }\n ]\n },\n "sort": [ { "field_key": "name", "order": "asc" } ], // optional; order is "asc" | "desc" | null\n "summary": { "amount": "sum" }, // optional \u2014 field alias \u2192 footer operation\n "frozen_columns": 1 // optional\n}\n```\n\nA condition\'s `type` is the field\'s type and its `operator` is one that type\nadmits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n`columns`, when present, is exhaustive and must not be empty: a view renders\nexactly the entries it holds. Omit the key to show every field.\n\n## Roles\n\nA role becomes a workspace group. Members are added afterwards, in the app.\n\n```jsonc\n{ "alias": "sales", "label": "Sales" }\n```\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes. An `html` template renders to a PDF when a workflow generates\nfrom it; `{{name}}` is filled from the workflow\'s data.\n\n```jsonc\n{ "alias": "order_ack", "label": "Order acknowledgement", "type": "email",\n "content": "<p>Hello {{customer}}\u2026</p>" }\n```\n\nA paper that has to look like a counterparty produced it \u2014 an official letter,\nan acceptance minute, a supplier\'s bill \u2014 is the same `html` template with a\nshell around the body: a letterhead, a reference line, a seal and a signature\nblock, and paper grain over everything. One shell, many bodies; the data is the\nonly thing that changes, so a workflow can re-issue it over any record.\n\n```jsonc\n{ "alias": "cong_van", "label": "C\xF4ng v\u0103n", "type": "html",\n "content": "\u2026the page below, as one JSON string\u2026" }\n```\n\n```html\n<style>\n .sheet{position:relative;width:718px;padding:44px 58px 30px;background:#fbfaf6;color:#111;font:14.2px/1.5 \'Liberation Serif\',serif}\n .grain{position:absolute;inset:0;opacity:.34;mix-blend-mode:multiply;background:url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'140\' height=\'140\'><filter id=\'f\'><feTurbulence baseFrequency=\'.9\' numOctaves=\'2\'/><feColorMatrix values=\'0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0\'/></filter><rect width=\'140\' height=\'140\' filter=\'url(%23f)\'/></svg>")}\n .top{display:flex;text-align:center;font-size:13.4px} .top>div{flex:1} .u{display:inline-block;border-bottom:1px solid #111;font-weight:700}\n .ref{display:flex;text-align:center;font-size:13.4px;margin-top:6px} .ref>div{flex:1} .ref .r{font-style:italic}\n h1{text-align:center;font-size:15.6px;margin:26px 0 18px} p{text-align:justify;text-indent:26px;margin:0 0 9px}\n .sig{display:flex;margin-top:20px} .sig .l{flex:1} .sig .r{width:290px;text-align:center;position:relative}\n .sig .nm{font-weight:700;margin-top:96px} .seal{position:absolute;left:4px;top:8px;width:166px;height:166px;opacity:.66;mix-blend-mode:multiply;transform:rotate(-17deg)}\n </style>\n <div class=\'sheet\'><div class=\'grain\'></div>\n <div class=\'top\'><div><b>{{issuer_parent}}</b><br><span class=\'u\'>{{issuer}}</span></div>\n <div><b>C\u1ED8NG H\xD2A X\xC3 H\u1ED8I CH\u1EE6 NGH\u0128A VI\u1EC6T NAM</b><br><span class=\'u\'>\u0110\u1ED9c l\u1EADp - T\u1EF1 do - H\u1EA1nh ph\xFAc</span></div></div>\n <div class=\'ref\'><div>S\u1ED1: {{number}}</div><div class=\'r\'>{{place}}, ng\xE0y {{day}} th\xE1ng {{month}} n\u0103m {{year}}</div></div>\n <h1>{{title}}</h1>\n <p>K\xEDnh g\u1EEDi: {{recipient}}.</p>\n {{{body}}}\n <div class=\'sig\'><div class=\'l\'><b>N\u01A1i nh\u1EADn:</b><br>- Nh\u01B0 tr\xEAn;<br>- L\u01B0u VT.</div>\n <div class=\'r\'><img class=\'seal\' src=\'{{seal_url}}\'><b>{{signer_title}}</b><div class=\'nm\'>{{signer}}</div></div></div>\n </div>\n```\n\n`lotics preview <file.html>` renders any such page to a PNG the way a demo\'s\nprops are made, sized to its content, so a paper can be looked at before it is\nput in a template.\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model, attaching at most 2000 documents between them \u2014 a real data set\nbelongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n`ref` is a local handle (lowercase letters, digits, underscores) that other rows\'\nlink fields address. It is never persisted.\n\nA `files` cell attaches documents: paths relative to this file (no `..`, never\nabsolute), which `check` proves exist and `apply` uploads into the workspace\nbefore any row is written \u2014 a paperwork business seeds its papers with its\nrows. The server accepts only `fil_` ids of files this workspace owns, which is\nwhat the upload leaves behind. After a run that wrote rows, `apply` writes the\nrecord ids beside the file (`<model>.last_run.json`): `delete_records` over\nthem is how a seeded set is reset, and applying again re-dates it.\n\n`fields` is keyed by field alias, and every value is read against the field\'s\nDECLARED type:\n\n| Field type | Value |\n|---|---|\n| `text` / `number` / `boolean` | the value itself |\n| `date` | `"2026-03-14"`, or a relative expression (below) |\n| `select` | the option ALIAS \u2014 `"gold"`, or `["gold","vip"]` for a multi-select |\n| `select_record_link` | `"<entity-alias>:<ref>"` naming another row in this file \u2014 `"customer:acme"`, or an array for several |\n| `select_member` | `"self"` only \u2014 the person applying the model |\n| `files` | paths beside this file \u2014 `["scans/pccc_letter.png"]` \u2014 uploaded by `apply`/`setup` before the rows are posted; or `fil_` ids of files already in this workspace |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n### Relative dates\n\nA date cell holds a literal `YYYY-MM-DD`, or an expression relative to the day\nthe model is applied, so a screen that opens on "this month" is not empty a month\nlater:\n\n- `@today` \u2014 the day of the run, in the workspace\'s timezone\n- `@month-start` \u2014 the 1st of that month\n- either with a whole-day offset: `@today-14`, `@month-start+9`\n\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Field roles\n\n`field_roles` names the reporting role a field plays on its entity \u2014 keyed by\nentity alias, then field alias \u2014 so every screen over the entity agrees on\nwhich column names the row and which select is the stage. A shape\'s slot binds\nto it (\xA7 Apps and screens). Like `rows` and `apps`, it is this file\'s: `check`\nproves it and the workspace never sees it. Each role sits on the types that can\nanswer it:\n\n| Role | On | Meaning |\n|---|---|---|\n| `identity` | `text`, `autonumber`, `select_record_link` | names the row \u2014 the register\'s first column; a link where the row is "the product, at this branch". One per entity |\n| `mark` | `files` | the row\'s picture. One per entity |\n| `lifecycle` | single `select` | the ordered stages a row walks; option order is the order. One per entity |\n| `measure` | `number`, `formula`, `rollup` | a level read against a limit \u2014 see `against` and `alert` |\n| `expected_set` | `select` | its OPTIONS are the required set (documents, checks, services); an option no row has is a gap to show, not nothing |\n| `amount` | `number`, `formula`, `rollup` | THE signed money of a ledger row. One per entity |\n| `when` | `date` | the ledger or timeline date. One per entity |\n| `party` | `select_record_link` | the counterparty. One per entity |\n| `parent` | `select_record_link` | the record this row belongs to \u2014 a line\'s order, a paper\'s case. The parent\'s record shows these rows; the row shows the parent as a fact. One per entity, and it links to an entity this model declares |\n| `contact` | `text` | the one way to reach a party. One per entity |\n| `verdict` | `boolean`, `formula` | a settled pass/fail \u2014 ticked, or computed. One per entity |\n\nA bare role name is the common form. A `measure` takes the object form to name\nits limit: `against` \u2014 a `number` field on the same entity, by alias, or a\nconstant \u2014 and `alert`, which side of it needs attention, `over` a capacity or\n`under` a minimum. The two come together.\n\n```jsonc\n"field_roles": {\n "product": { "name": "identity", "photo": "mark" },\n "stock": { "on_hand": { "role": "measure", "against": "minimum", "alert": "under" },\n "uptime": { "role": "measure", "against": 80, "alert": "under" } }\n}\n```\n\nIn a file that starts from a preset (\xA7 Starting from a preset), `field_roles`\nmay name the preset\'s fields as well as this business\'s own; a role the preset\ndeclares itself is kept unless this file names the same field, and `null`\nclears it.\n\n## Apps and screens\n\n`apps` is the plan: each app the reader will build, and each of its screens as\na SHAPE over an ENTITY. Nothing here is built by the scaffold \u2014 the plan is what\n`lotics scaffold check` prints back, screen by screen with the field in every\nslot, so it is read and corrected before a screen exists.\n\n```jsonc\n"apps": [\n {\n "alias": "sales", "name": "Sales",\n "description": "\u2026", "icon": "briefcase", "theme": { "color": "blue" }, // optional\n "screens": [\n { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" },\n { "alias": "orders", "label": "Orders", "shape": "lifecycle_desk", "entity": "order",\n "record": "drawer", // optional \u2014 "drawer" | "page"; absent, the shape decides\n "tabs": "stage", // optional \u2014 a select on the entity, or null; absent, the shape decides\n "slots": { "identity": "code" } } // optional \u2014 slot \u2192 field, where the roles cannot decide alone\n ]\n }\n]\n```\n\nA shape is a proven screen with named SLOTS, each filled by a field carrying a\nrole (\xA7 Field roles). A slot with exactly one candidate on the entity binds by itself;\ntwo candidates need naming in `slots`; a field fills one slot; a required slot\nwith none is refused \u2014 a lifecycle desk over an entity with no `lifecycle`\nselect cannot be built.\n\n| Shape | Answers | Required | Also fills | Record | Tabs |\n|---|---|---|---|---|---|\n| `lifecycle_desk` | what is stuck, what do I move next | `lifecycle`, `identity` | `mark`, `party`, `amount`, `when` | drawer | the lifecycle\'s stages |\n| `party_register` | who is this, our history, is there a risk | `identity` | `mark`, `contact`, `measure` (worth), `verdict` (risk) | page | none |\n| `offering_register` | what do we offer, at what price, can I sell it | `identity` | `mark`, `amount` (price), `measure` (availability) | page | none |\n| `transaction_ledger` | does this period reconcile, what is unexplained | `when`, `amount` | `party`, `expected_set` (document) | drawer | none |\n| `monitored_asset_set` | what needs attention, is that number normal | `identity`, `measure` (level) | `mark`, `lifecycle` | drawer | none |\n| `trend_deep_dive` | how did the period go, and why | `when` | `measure`, `amount` | drawer | none |\n\nEach shape is a `@lotics/ui` component of the same name (`LifecycleDesk`,\n`PartyRegister`, \u2026) whose props are these slots, so once the tables exist\n`lotics app create <name> --from <this file>#<app alias>` scaffolds the app with\none screen per entry, each slot reading the field the plan bound.\n\nA screen is that list and the RECORD it opens, and the record comes off the same\nroles \u2014 nothing to declare for it. It opens with a **header**: the `identity` as\nthe record\'s name, and on a `page` also the `when` under it and ONE headline\nfigure (the `amount` the screen reads, else its `measure`); the `lifecycle` is\nnot badged there, because the progress section is the rung it stands\non. Then its sections, in order: the **facts** (every field\nneither the header nor another section owns, the row\'s own `parent` among them),\nthe **progress** (the `lifecycle`\'s stages, and what moves the record on), a\n**required set** (a multi-select `expected_set`, or a child entity whose rows\ncarry one entry of it each \u2014 that child\'s `files` field is what a paper attaches\nto), the record\'s **own rows** (any other child, its role-bound fields as\ncolumns), and its **files** (every `files` field, the `mark` first). A child is\nan entity whose `parent` links here. The name and the figure are the header\'s\nalone \u2014 it states both in full, so a fact for either would be the same sentence\ntwice; a `drawer` has only the name. `check` prints the record under each\nscreen\'s slots:\n\n```\n Orders \u2014 lifecycle desk over Orders (12 rows) \xB7 drawer \xB7 tabs: Stage (New \u2192 Quoted \u2192 Confirmed \u2192 Shipped \u2192 Done)\n stage Stage \xB7 identity Order no. \xB7 mark Photo \xB7 party Customer \xB7 amount Total \xB7 when Due\n record: header (Order no.) \xB7 facts (Customer \xB7 Total \xB7 Due) \xB7 progress: Stage (5 stages) \xB7 documents: Papers (Kind: 4 required) \xB7 lines: Order lines (Product \xB7 Quantity \xB7 Line total) \xB7 files: Photo\n```\n\nIn that line `documents:` is a `RecordExpectedSet` and `lines:` a `RecordChildren`; a required set\nwhose entries are a CHILD entity carrying files takes `kind="files"`, and the entity\'s own\nmulti-select takes `kind="items"`, since nothing attaches to an option.\n\n`"shape": "custom"` is a screen of its own shape: it declares its slots under\n`roles` (slot \u2192 role) and they bind the same way; the scaffold gives it the\nrows and the slot list, and the screen is composed from the kit by hand.\n\n```jsonc\n{ "alias": "readings", "label": "Readings", "shape": "custom", "entity": "reading",\n "roles": { "subject": "identity", "reading": "measure" } }\n```\n\n## Applying packages\n\n`apply` copies published packages into the workspace AFTER the model\'s own\ntables exist \u2014 apps over the tables you just described, and any tables of their\nown they still need. Ordered, and run by `lotics setup` and `lotics scaffold\napply` alike.\n\n```jsonc\n"apply": [\n {\n "package": "apg_k3nf82ldpq",\n "bind": { // optional \u2014 which of YOUR tables each entity is\n "company": { "label": "Customers", "fields": { "name": "Company name" } }\n },\n "no_sample_data": true // optional\n }\n]\n```\n\n`bind` is keyed by the package\'s entity alias and holds the LABELS this\nworkspace uses: scaffold adopts by label, so binding points the package at the\ntables the model created instead of a second set beside them. Only naming\nmoves \u2014 a bound field must be the TYPE the package declares, or the copy is\nrefused. `lotics library list` is the shelf, and `lotics library show <apg_id>`\nlists the aliases to bind.\n\nEntries run in the order they are written, because a later one may bind onto a\ntable an earlier one created. **A refused entry stops the run and the entries\nbefore it stay** \u2014 they are separate copies, committed as they land, so the\nrefusal names them rather than leaving a caller to re-run the file and copy them\ntwice.\n\n## Presets\n\nA preset is a trade\'s model, published to be READ. An assistant reads it, asks\nat most two questions, picks a variant and writes a `model.json` from it \u2014\nnothing is copied, and a preset is a file rather than anything a workspace\ninstalls.\n\n```jsonc\n"preset": {\n "name": "Field service",\n "description": "Jobs, the crew that runs them, and what each one billed.",\n "questions": ["Do you dispatch crews, or one person per job?"], // at most 2\n "variants": {\n "crews": {\n "when": "work is dispatched to crews rather than to one person",\n "entities": [ /* tables this branch ADDS */ ],\n "fields": { "job": [ /* fields this branch ADDS to `job` */ ] }\n }\n }\n}\n```\n\nVariants are **additive only**: a branch adds entities and fields and never\nremoves them, so the base is a model in its own right rather than a draft.\n`lotics scaffold check` proves the base AND every variant merged onto it, so a\npreset ships with every branch already proven \u2014 the branch nobody took is the\none that fails in the workspace of whoever takes it.\n\n`preset` is not scaffolded. `lotics setup` and `lotics scaffold apply` ignore\nit and create the base model\'s tables.\n\n`lotics scaffold export` prints a workspace that already works as one of these\nfiles \u2014 the starting point for a preset or for another business\'s model, never a\nsource of truth: it carries one business\'s words and stops describing that\nworkspace the moment either changes.\n\n## Starting from a preset\n\n`lotics library list` is the shelf of them and `lotics library show <slug>`\nprints one whole: its questions, every table as `alias \xB7 label` with each field\nas `alias:type`, and each variant as `slug \xB7 when` followed by the tables and\nfields that branch adds. When one of them is the trade in front of you, do not\ntranscribe it \u2014 name it:\n\n```jsonc\n{\n "from": "field_service",\n "variants": ["crews"],\n "rename": { "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } } },\n "entities": [ /* a table this business has that the preset does not */ ],\n "rows": { "job": [ { "ref": "j1", "fields": { "code": "J-1" } } ] },\n "field_roles": { "job": { "code": "identity" } },\n "apps": [ /* the screens this business\'s apps will have */ ]\n}\n```\n\n- **`from`** is the preset\'s SLUG \u2014 its own file name, a lowercase slug. Naming\n it is what makes `entities` optional; every other rule on this page is\n unchanged, because the file is resolved into the full form and then checked and\n applied exactly as one. A slug nothing serves is refused with the ones there\n are, never resolved against something else.\n- **`variants`** names the branches to merge onto the base, in order. Pick the\n one whose `when` describes what the person said; a slug the preset does not\n declare is refused rather than ignored.\n- **`rename`** is keyed by the preset\'s entity alias and holds the labels this\n business uses \u2014 the same shape `apply[].bind` takes, and the same rule: only\n naming moves. An alias the preset does not declare, and a label that is\n already another table\'s, are both refused.\n- **`entities`** are added after the rename, already in this business\'s own\n words.\n- **`rows`**, **`field_roles`**, **`apps`** and **`apply`** mean exactly what\n they mean in the full form \u2014 `"rows"` are this business\'s real first records,\n `"field_roles"` may name the preset\'s fields as well as its own (a role the\n preset declares itself is kept unless this file names the same field, or\n clears it with `null`),\n `"apps"` the screens it will have (a preset carries none), `"apply"` the\n packages copied in once its tables exist.\n\nThis is the ONE thing on this page that needs the network: `check` reads the\npreset it names, once. Everything after that read is the same offline check.\n\nWrite the full form when no preset is the trade.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "fields": [\n { "alias": "code", "label": "Order no.", "type": "text", "unique": true },\n { "alias": "placed_on", "label": "Placed on", "type": "date", "format": "date" },\n {\n "alias": "amount",\n "label": "Amount",\n "type": "number",\n "format": "currency",\n "currency": "VND"\n },\n {\n "alias": "total",\n "label": "Total with VAT",\n "type": "formula",\n "formula": { "expression": "{amount} * 1.1", "format": "currency", "currency": "VND" }\n },\n {\n "alias": "customer",\n "label": "Customer",\n "type": "select_record_link",\n "target_entity": "customer",\n "cardinality": "one",\n "sync_both_ways": true,\n "paired_field_alias": "orders",\n "display_field_aliases": ["name"]\n }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "field_roles": {\n "customer": { "name": "identity" },\n "order": { "code": "identity", "placed_on": "when", "amount": "amount", "customer": "party" }\n },\n "apps": [\n {\n "alias": "sales",\n "name": "Sales",\n "screens": [\n { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" },\n { "alias": "orders", "label": "Orders", "shape": "transaction_ledger", "entity": "order" }\n ]\n }\n ],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\n`lotics scaffold check` on this file reports\n`2 tables, 9 fields, 2 links, 1 view, 1 role, 4 rows, 1 app, 2 screens`, then\nthe plan:\n\n```\nSales\n Customers \u2014 party register over Customers (2 rows) \xB7 page \xB7 tabs: none\n identity Name \xB7 mark (none) \xB7 contact (none) \xB7 worth (none) \xB7 risk (none)\n record: header (Name) \xB7 facts (Tier \xB7 Orders \xB7 Total ordered)\n Orders \u2014 transaction ledger over Orders (2 rows) \xB7 drawer \xB7 tabs: none\n when Placed on \xB7 amount Amount \xB7 party Customer \xB7 document (none)\n record: facts (Order no. \xB7 Placed on \xB7 Amount \xB7 Total with VAT \xB7 Customer)\n```\n\nEvery `(none)` is a slot no field fills \u2014 the picture a register has none of,\nthe contact nobody declared. Read it as the screen a person will see. Each\nrecord is a header and facts here: neither entity carries a stage, a required\nset, files, or rows of its own. The Customers page states its name in the header\nand nowhere else; the Orders drawer has no `identity` to state, so every field\nit reads is a fact.\n';
40640
+ var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing the tables, fields, options, views, roles and first rows\na workspace starts with. `lotics scaffold check model.json` proves it offline \u2014\nno account, no network. `lotics setup model.json --email you@company.com` creates\nthe account and applies it. `lotics scaffold apply model.json` applies it again,\ninto the workspace the credential names.\n\n**There are two forms of this file.** The full one, below, spells the model out.\nThe `from` one names a published preset and carries only what this business\ndiffers by \u2014 see \xA7 Starting from a preset, and prefer it whenever a preset fits\nthe trade.\n\nApps are PLANNED here and built afterwards: `apps` names each app\'s screens as a\nshape over an entity, checked against the roles `field_roles` gives its fields,\nso the plan is refused before anyone builds a screen (\xA7 Apps and screens). The\nbuilt app lives in the workspace; publishing that workspace as a package is how\nit ships.\n\n## The rules\n\n- **At least one entity, at most 50.** More tables than that is a data model\n being designed, not scaffolded \u2014 scaffold the rest in a second call.\n- **Adoption is explicit.** `lotics setup` REFUSES an entity whose `label`\n already names a table in the workspace, naming every colliding label at once.\n `lotics scaffold apply` adopts those tables and adds the fields, options and\n views they are missing. Nothing is ever modified or deleted, so applying the\n same model twice creates nothing the second time.\n- **Adoption is by LABEL, not alias.** Change an entity\'s `label` and the next\n run asks for a NEW table beside the old one. Renames and deletions go through\n `lotics run update_table` / `lotics run delete_table`, never through the file.\n- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere, and the result says\n `rows_skipped: true`: sample rows landing among a customer\'s real ones cannot\n be told apart from them.\n- **After the first run the WORKSPACE is the source of truth.** The file is an\n authoring input, not a mirror \u2014 scaffold never deletes what the file stopped\n naming.\n- **`lotics scaffold check` decides all of it offline**, and reports every\n problem in one run rather than the first: an alias that resolves to nothing, a\n link whose pair is not symmetric, and the rows themselves \u2014 a field the entity\n does not declare, an option alias the field does not declare, a link naming no\n row in the file, a `ref` used twice, a date that is not one, a value on a\n platform-computed field, and a files cell that is neither a relative path\n beside this file nor a `fil_` id.\n\n## Top level\n\n```jsonc\n{\n "entities": [ /* the tables */ ],\n "roles": [ /* workspace groups to create */ ], // optional\n "templates":[ /* inline html / email templates */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* the reporting role each field plays, keyed by entity then field */ }, // optional\n "apps": [ /* the screens each app will have, as shapes over entities */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ], // optional\n "preset": { /* a trade\'s branches, for a PUBLISHED model */ } // optional\n}\n```\n\nThe other form names a preset instead of restating one:\n\n```jsonc\n{\n "from": "field_service", // the preset this model starts from, by slug\n "variants": ["crews"], // optional \u2014 its branches to merge in, in order\n "rename": { // optional \u2014 what THIS business calls each table\n "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } }\n },\n "entities": [ /* tables the preset does not declare */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* roles on the preset\'s fields and this business\'s own */ }, // optional\n "apps": [ /* the screens each app will have */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ] // optional\n}\n```\n\n**A model may not carry** `fixtures`, `knowledge` or `knowledge_expects`, and no\n`excel` / `word` / `pdf-form` template: each of those is content that lives in a\npublished bundle, which a model has none of. `apps` here is a plan of screens,\nnever built code. An unknown top-level key is an error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because scaffold matches by label.\n\n## Entity\n\n```jsonc\n{\n "alias": "order",\n "label": "Orders", // the table\'s name\n "description": "\u2026", // optional\n "fields": [ /* at least one */ ],\n "views": [ /* optional; an entity with none still gets the default grid */ ]\n}\n```\n\n## Field\n\nEvery field carries `alias`, `label`, an optional `description`, and an optional\n`required` \u2014 advisory only, read by app forms and workflows; the table itself has\nno required constraint. `label` may not contain `{` or `}` (formulas reference\nfields by label at the platform level).\n\n`default` is the value pre-filled into a NEW record. It applies on create only;\nexisting records are never backfilled. Only the types listed below accept one.\n\n### `text`\n\n```jsonc\n{ "alias": "name", "label": "Name", "type": "text",\n "unique": false, // optional \u2014 require distinct values\n "format": "text", // optional \u2014 "text" | "link" | "markdown"\n "default": "" } // optional\n```\n\n### `number`\n\n```jsonc\n{ "alias": "amount", "label": "Amount", "type": "number",\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage"\n "currency": "VND", // optional \u2014 ISO 4217\n "default": 0 } // optional\n```\n\n### `date`\n\n```jsonc\n{ "alias": "placed_on", "label": "Placed on", "type": "date",\n "format": "date", // optional \u2014 "date" | "datetime" | "date_range" | "datetime_range"\n "timezone": "Asia/Ho_Chi_Minh", // optional \u2014 IANA name\n "derive_from": "created_at", // optional \u2014 "created_at" | "updated_at"; makes the field read-only\n "default": "2026-01-01" } // optional; refused together with derive_from\n```\n\n### `boolean`\n\n```jsonc\n{ "alias": "paid", "label": "Paid", "type": "boolean", "default": false }\n```\n\n### `select`\n\n```jsonc\n{ "alias": "tier", "label": "Tier", "type": "select",\n "options": [ // at least one\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "multi": false, // optional\n "default": ["standard"] } // optional \u2014 option ALIASES; one unless multi\n```\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet.\n\n```jsonc\n{ "alias": "owner", "label": "Owner", "type": "select_member", "multi": false }\n```\n\n### `select_record_link`\n\n```jsonc\n{ "alias": "customer", "label": "Customer", "type": "select_record_link",\n "target_entity": "customer", // an entity alias this model declares\n "cardinality": "one", // optional \u2014 "one" | "many" (default "many")\n "sync_both_ways": true, // optional \u2014 keep a paired field on the target\n "paired_field_alias": "orders", // the partner field ON THE TARGET entity\n "display_field_aliases": ["name"] } // optional \u2014 what the link shows / the picker\'s columns\n```\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused. Declare\none side only (with no `paired_field_alias`) for a link with no back-reference.\n\n### `files`\n\n```jsonc\n{ "alias": "attachments", "label": "Attachments", "type": "files" }\n```\n\n### `formula`\n\n```jsonc\n{ "alias": "total", "label": "Total", "type": "formula",\n "formula": {\n "expression": "{amount} * 1.1", // fields on THIS entity, by alias, in braces\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage" | "link"\n "currency": "VND" // optional\n } }\n```\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n```jsonc\n{ "alias": "total_ordered", "label": "Total ordered", "type": "rollup",\n "source_field_alias": "orders", // a select_record_link field on THIS entity\n "aggregate_option": {\n "operation": "sum", // count | sum | avg | median | min | max | range |\n // empty | filled | percent_empty | percent_filled |\n // unique | percent_unique |\n // earliest | latest | date_range |\n // checked | unchecked | percent_checked |\n // percent_unchecked\n "field_key": "amount" // a field ALIAS on the linked entity ("count" may omit it)\n },\n "filter": { /* optional \u2014 see Views; every field_key is an alias on the LINKED entity */ } }\n```\n\nThe operation must be one the aggregated field\'s type allows \u2014 `sum` over a\nnumber, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n```jsonc\n{ "alias": "customer_tier", "label": "Customer tier", "type": "lookup",\n "source_field_alias": "customer", // a select_record_link field on THIS entity\n "lookup_field_alias": "tier", // a field alias on the linked entity\n "order_by": { "field_key": "placed_on", "direction": "desc" } } // optional \u2014 pick the single extreme row\n```\n\n### `autonumber`\n\n```jsonc\n{ "alias": "seq", "label": "No.", "type": "autonumber",\n "prefix": "SO-", // optional \u2014 ignored when template is set\n "padding": 4, // optional \u2014 1..20, zero-pads the integer\n "template": "SO-{YEAR}-{N:4}" } // optional \u2014 {N}, {N:W}, {YEAR}, {YEAR:2}, {MONTH}, {DAY}\n```\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n```jsonc\n{\n "alias": "gold",\n "label": "Gold customers",\n "description": "\u2026", // optional\n "columns": [ // optional \u2014 omit to show every field\n { "field_alias": "name", "visibility": "visible", "width": 240 },\n { "field_alias": "tier", "visibility": "hidden" }\n ],\n "filters": { // optional\n "node_type": "group",\n "logic": "and", // "and" | "or"\n "children": [\n { "node_type": "condition", "type": "select", "field_key": "tier",\n "operator": "has_any_of", "value": ["gold"] }\n ]\n },\n "sort": [ { "field_key": "name", "order": "asc" } ], // optional; order is "asc" | "desc" | null\n "summary": { "amount": "sum" }, // optional \u2014 field alias \u2192 footer operation\n "frozen_columns": 1 // optional\n}\n```\n\nA condition\'s `type` is the field\'s type and its `operator` is one that type\nadmits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n`columns`, when present, is exhaustive and must not be empty: a view renders\nexactly the entries it holds. Omit the key to show every field.\n\n## Roles\n\nA role becomes a workspace group. Members are added afterwards, in the app.\n\n```jsonc\n{ "alias": "sales", "label": "Sales" }\n```\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes. An `html` template renders to a PDF when a workflow generates\nfrom it; `{{name}}` is filled from the workflow\'s data.\n\n```jsonc\n{ "alias": "order_ack", "label": "Order acknowledgement", "type": "email",\n "content": "<p>Hello {{customer}}\u2026</p>" }\n```\n\nA paper that has to look like a counterparty produced it \u2014 an official letter,\nan acceptance minute, a supplier\'s bill \u2014 is the same `html` template with a\nshell around the body: a letterhead, a reference line, a seal and a signature\nblock, and paper grain over everything. One shell, many bodies; the data is the\nonly thing that changes, so a workflow can re-issue it over any record.\n\n```jsonc\n{ "alias": "cong_van", "label": "C\xF4ng v\u0103n", "type": "html",\n "content": "\u2026the page below, as one JSON string\u2026" }\n```\n\n```html\n<style>\n .sheet{position:relative;width:718px;padding:44px 58px 30px;background:#fbfaf6;color:#111;font:14.2px/1.5 \'Liberation Serif\',serif}\n .grain{position:absolute;inset:0;opacity:.34;mix-blend-mode:multiply;background:url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'140\' height=\'140\'><filter id=\'f\'><feTurbulence baseFrequency=\'.9\' numOctaves=\'2\'/><feColorMatrix values=\'0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0\'/></filter><rect width=\'140\' height=\'140\' filter=\'url(%23f)\'/></svg>")}\n .top{display:flex;text-align:center;font-size:13.4px} .top>div{flex:1} .u{display:inline-block;border-bottom:1px solid #111;font-weight:700}\n .ref{display:flex;text-align:center;font-size:13.4px;margin-top:6px} .ref>div{flex:1} .ref .r{font-style:italic}\n h1{text-align:center;font-size:15.6px;margin:26px 0 18px} p{text-align:justify;text-indent:26px;margin:0 0 9px}\n .sig{display:flex;margin-top:20px} .sig .l{flex:1} .sig .r{width:290px;text-align:center;position:relative}\n .sig .nm{font-weight:700;margin-top:96px} .seal{position:absolute;left:4px;top:8px;width:166px;height:166px;opacity:.66;mix-blend-mode:multiply;transform:rotate(-17deg)}\n </style>\n <div class=\'sheet\'><div class=\'grain\'></div>\n <div class=\'top\'><div><b>{{issuer_parent}}</b><br><span class=\'u\'>{{issuer}}</span></div>\n <div><b>C\u1ED8NG H\xD2A X\xC3 H\u1ED8I CH\u1EE6 NGH\u0128A VI\u1EC6T NAM</b><br><span class=\'u\'>\u0110\u1ED9c l\u1EADp - T\u1EF1 do - H\u1EA1nh ph\xFAc</span></div></div>\n <div class=\'ref\'><div>S\u1ED1: {{number}}</div><div class=\'r\'>{{place}}, ng\xE0y {{day}} th\xE1ng {{month}} n\u0103m {{year}}</div></div>\n <h1>{{title}}</h1>\n <p>K\xEDnh g\u1EEDi: {{recipient}}.</p>\n {{{body}}}\n <div class=\'sig\'><div class=\'l\'><b>N\u01A1i nh\u1EADn:</b><br>- Nh\u01B0 tr\xEAn;<br>- L\u01B0u VT.</div>\n <div class=\'r\'><img class=\'seal\' src=\'{{seal_url}}\'><b>{{signer_title}}</b><div class=\'nm\'>{{signer}}</div></div></div>\n </div>\n```\n\n`lotics preview <file.html>` renders any such page to a PNG the way a demo\'s\nprops are made, sized to its content, so a paper can be looked at before it is\nput in a template.\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model, attaching at most 2000 documents between them \u2014 a real data set\nbelongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n`ref` is a local handle (lowercase letters, digits, underscores) that other rows\'\nlink fields address. It is never persisted.\n\nA `files` cell attaches documents: paths relative to this file (no `..`, never\nabsolute), which `check` proves exist and `apply` uploads into the workspace\nbefore any row is written \u2014 a paperwork business seeds its papers with its\nrows. The server accepts only `fil_` ids of files this workspace owns, which is\nwhat the upload leaves behind. After a run that wrote rows, `apply` writes the\nrecord ids beside the file (`<model>.last_run.json`): `delete_records` over\nthem is how a seeded set is reset, and applying again re-dates it.\n\n`fields` is keyed by field alias, and every value is read against the field\'s\nDECLARED type:\n\n| Field type | Value |\n|---|---|\n| `text` / `number` / `boolean` | the value itself |\n| `date` | `"2026-03-14"`, or a relative expression (below) |\n| `select` | the option ALIAS \u2014 `"gold"`, or `["gold","vip"]` for a multi-select |\n| `select_record_link` | `"<entity-alias>:<ref>"` naming another row in this file \u2014 `"customer:acme"`, or an array for several |\n| `select_member` | `"self"` only \u2014 the person applying the model |\n| `files` | paths beside this file \u2014 `["scans/pccc_letter.png"]` \u2014 uploaded by `apply`/`setup` before the rows are posted; or `fil_` ids of files already in this workspace |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n### Relative dates\n\nA date cell holds a literal `YYYY-MM-DD`, or an expression relative to the day\nthe model is applied, so a screen that opens on "this month" is not empty a month\nlater:\n\n- `@today` \u2014 the day of the run, in the workspace\'s timezone\n- `@month-start` \u2014 the 1st of that month\n- either with a whole-day offset: `@today-14`, `@month-start+9`\n\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Field roles\n\n`field_roles` names the reporting role a field plays on its entity \u2014 keyed by\nentity alias, then field alias \u2014 so every screen over the entity agrees on\nwhich column names the row and which select is the stage. A shape\'s slot binds\nto it (\xA7 Apps and screens). Like `rows` and `apps`, it is this file\'s: `check`\nproves it and the workspace never sees it. Each role sits on the types that can\nanswer it:\n\n| Role | On | Meaning |\n|---|---|---|\n| `identity` | `text`, `autonumber`, `select_record_link` | names the row \u2014 the register\'s first column; a link where the row is "the product, at this branch". One per entity |\n| `mark` | `files` | the row\'s picture. One per entity |\n| `lifecycle` | single `select` | the ordered stages a row walks; option order is the order. One per entity |\n| `measure` | `number`, `formula`, `rollup` | a level read against a limit \u2014 see `against` and `alert` |\n| `expected_set` | `select` | its OPTIONS are the required set (documents, checks, services); an option no row has is a gap to show, not nothing |\n| `amount` | `number`, `formula`, `rollup` | THE signed money of a ledger row. One per entity |\n| `when` | `date` | the ledger or timeline date. One per entity |\n| `party` | `select_record_link` | the counterparty. One per entity |\n| `parent` | `select_record_link` | the record this row belongs to \u2014 a line\'s order, a paper\'s case. The parent\'s record shows these rows; the row shows the parent as a fact. One per entity, and it links to an entity this model declares |\n| `contact` | `text` | the one way to reach a party. One per entity |\n| `verdict` | `boolean`, `formula` | a settled pass/fail \u2014 ticked, or computed. One per entity |\n\nA bare role name is the common form. A `measure` takes the object form to name\nits limit: `against` \u2014 a `number` field on the same entity, by alias, or a\nconstant \u2014 and `alert`, which side of it needs attention, `over` a capacity or\n`under` a minimum. The two come together.\n\n```jsonc\n"field_roles": {\n "product": { "name": "identity", "photo": "mark" },\n "stock": { "on_hand": { "role": "measure", "against": "minimum", "alert": "under" },\n "uptime": { "role": "measure", "against": 80, "alert": "under" } }\n}\n```\n\nIn a file that starts from a preset (\xA7 Starting from a preset), `field_roles`\nmay name the preset\'s fields as well as this business\'s own; a role the preset\ndeclares itself is kept unless this file names the same field, and `null`\nclears it.\n\n## Apps and screens\n\n`apps` is the plan: each app the reader will build, and each of its screens as\na SHAPE over an ENTITY. Nothing here is built by the scaffold \u2014 the plan is what\n`lotics scaffold check` prints back, screen by screen with the field in every\nslot, so it is read and corrected before a screen exists.\n\n```jsonc\n"apps": [\n {\n "alias": "sales", "name": "Sales",\n "description": "\u2026", "icon": "briefcase", "theme": { "color": "blue" }, // optional\n "screens": [\n { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" },\n { "alias": "orders", "label": "Orders", "shape": "lifecycle_desk", "entity": "order",\n "record": "drawer", // optional \u2014 "drawer" | "page"; absent, the shape decides\n "tabs": "stage", // optional \u2014 a select on the entity, or null; absent, the shape decides\n "slots": { "identity": "code" } } // optional \u2014 slot \u2192 field, where the roles cannot decide alone\n ]\n }\n]\n```\n\nA shape is a proven screen with named SLOTS, each filled by a field carrying a\nrole (\xA7 Field roles). A slot with exactly one candidate on the entity binds by itself;\ntwo candidates need naming in `slots`; a field fills one slot; a required slot\nwith none is refused \u2014 a lifecycle desk over an entity with no `lifecycle`\nselect cannot be built.\n\n| Shape | Answers | Required | Also fills | Record | Tabs |\n|---|---|---|---|---|---|\n| `lifecycle_desk` | what is stuck, what do I move next | `lifecycle`, `identity` | `mark`, `party`, `amount`, `when` | drawer | the lifecycle\'s stages |\n| `party_register` | who is this, our history, is there a risk | `identity` | `mark`, `contact`, `measure` (worth), `verdict` (risk) | page | none |\n| `offering_register` | what do we offer, at what price, can I sell it | `identity` | `mark`, `amount` (price), `measure` (availability) | page | none |\n| `transaction_ledger` | does this period reconcile, what is unexplained | `when`, `amount` | `party`, `expected_set` (document) | drawer | none |\n| `monitored_asset_set` | what needs attention, is that number normal | `identity`, `measure` (level) | `mark`, `lifecycle` | drawer | none |\n| `trend_deep_dive` | how did the period go, and why | `when` | `measure`, `amount` | drawer | none |\n\nEach shape is a `@lotics/ui` component of the same name (`LifecycleDesk`,\n`PartyRegister`, \u2026) whose props are these slots, so once the tables exist\n`lotics app create <name> --from <this file>#<app alias>` scaffolds the app with\none screen per entry, each slot reading the field the plan bound.\n\nA screen is that list and the RECORD it opens, and the record comes off the same\nroles \u2014 nothing to declare for it. It opens with a **header**: the `identity` as\nthe record\'s name, and on a `page` also the `when` under it and ONE headline\nfigure (the `amount` the screen reads, else its `measure`); the `lifecycle` is\nnot badged there, because the progress section is the rung it stands\non. Then its sections, in order: the **facts** (every field\nneither the header nor another section owns, the row\'s own `parent` among them),\nthe **progress** (the `lifecycle`\'s stages, and what moves the record on), a\n**required set** (a multi-select `expected_set`, or a child entity whose rows\ncarry one entry of it each \u2014 that child\'s `files` field is what a paper attaches\nto), the record\'s **own rows** (any other child, its role-bound fields as\ncolumns), and its **files** (every `files` field, the `mark` first). A child is\nan entity whose `parent` OR `party` links here \u2014 so a party\'s record is its\nhistory, the rows that name it. The name and the figure are the header\'s\nalone \u2014 it states both in full, so a fact for either would be the same sentence\ntwice; a `drawer` has only the name. `check` prints the record under each\nscreen\'s slots:\n\n```\n Orders \u2014 lifecycle desk over Orders (12 rows) \xB7 drawer \xB7 tabs: Stage (New \u2192 Quoted \u2192 Confirmed \u2192 Shipped \u2192 Done)\n stage Stage \xB7 identity Order no. \xB7 mark Photo \xB7 party Customer \xB7 amount Total \xB7 when Due\n record: header (Order no.) \xB7 facts (Customer \xB7 Total \xB7 Due) \xB7 progress: Stage (5 stages) \xB7 documents: Papers (Kind: 4 required) \xB7 lines: Order lines (Product \xB7 Quantity \xB7 Line total) \xB7 files: Photo\n```\n\nIn that line `documents:` is a `RecordExpectedSet`, and `lines:` a `RecordChildren` over the rows\nthis record owns \u2014 `history:` where they name it as their `party` instead; a required set\nwhose entries are a CHILD entity carrying files takes `kind="files"`, and the entity\'s own\nmulti-select takes `kind="items"`, since nothing attaches to an option.\n\n`"shape": "custom"` is a screen of its own shape: it declares its slots under\n`roles` (slot \u2192 role) and they bind the same way; the scaffold gives it the\nrows and the slot list, and the screen is composed from the kit by hand.\n\n```jsonc\n{ "alias": "readings", "label": "Readings", "shape": "custom", "entity": "reading",\n "roles": { "subject": "identity", "reading": "measure" } }\n```\n\n## Applying packages\n\n`apply` copies published packages into the workspace AFTER the model\'s own\ntables exist \u2014 apps over the tables you just described, and any tables of their\nown they still need. Ordered, and run by `lotics setup` and `lotics scaffold\napply` alike.\n\n```jsonc\n"apply": [\n {\n "package": "apg_k3nf82ldpq",\n "bind": { // optional \u2014 which of YOUR tables each entity is\n "company": { "label": "Customers", "fields": { "name": "Company name" } }\n },\n "no_sample_data": true // optional\n }\n]\n```\n\n`bind` is keyed by the package\'s entity alias and holds the LABELS this\nworkspace uses: scaffold adopts by label, so binding points the package at the\ntables the model created instead of a second set beside them. Only naming\nmoves \u2014 a bound field must be the TYPE the package declares, or the copy is\nrefused. `lotics library list` is the shelf, and `lotics library show <apg_id>`\nlists the aliases to bind.\n\nEntries run in the order they are written, because a later one may bind onto a\ntable an earlier one created. **A refused entry stops the run and the entries\nbefore it stay** \u2014 they are separate copies, committed as they land, so the\nrefusal names them rather than leaving a caller to re-run the file and copy them\ntwice.\n\n## Presets\n\nA preset is a trade\'s model, published to be READ. An assistant reads it, asks\nat most two questions, picks a variant and writes a `model.json` from it \u2014\nnothing is copied, and a preset is a file rather than anything a workspace\ninstalls.\n\n```jsonc\n"preset": {\n "name": "Field service",\n "description": "Jobs, the crew that runs them, and what each one billed.",\n "questions": ["Do you dispatch crews, or one person per job?"], // at most 2\n "variants": {\n "crews": {\n "when": "work is dispatched to crews rather than to one person",\n "entities": [ /* tables this branch ADDS */ ],\n "fields": { "job": [ /* fields this branch ADDS to `job` */ ] }\n }\n }\n}\n```\n\nVariants are **additive only**: a branch adds entities and fields and never\nremoves them, so the base is a model in its own right rather than a draft.\n`lotics scaffold check` proves the base AND every variant merged onto it, so a\npreset ships with every branch already proven \u2014 the branch nobody took is the\none that fails in the workspace of whoever takes it.\n\n`preset` is not scaffolded. `lotics setup` and `lotics scaffold apply` ignore\nit and create the base model\'s tables.\n\n`lotics scaffold export` prints a workspace that already works as one of these\nfiles \u2014 the starting point for a preset or for another business\'s model, never a\nsource of truth: it carries one business\'s words and stops describing that\nworkspace the moment either changes.\n\n## Starting from a preset\n\n`lotics library list` is the shelf of them and `lotics library show <slug>`\nprints one whole: its questions, every table as `alias \xB7 label` with each field\nas `alias:type`, and each variant as `slug \xB7 when` followed by the tables and\nfields that branch adds. When one of them is the trade in front of you, do not\ntranscribe it \u2014 name it:\n\n```jsonc\n{\n "from": "field_service",\n "variants": ["crews"],\n "rename": { "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } } },\n "entities": [ /* a table this business has that the preset does not */ ],\n "rows": { "job": [ { "ref": "j1", "fields": { "code": "J-1" } } ] },\n "field_roles": { "job": { "code": "identity" } },\n "apps": [ /* the screens this business\'s apps will have */ ]\n}\n```\n\n- **`from`** is the preset\'s SLUG \u2014 its own file name, a lowercase slug. Naming\n it is what makes `entities` optional; every other rule on this page is\n unchanged, because the file is resolved into the full form and then checked and\n applied exactly as one. A slug nothing serves is refused with the ones there\n are, never resolved against something else.\n- **`variants`** names the branches to merge onto the base, in order. Pick the\n one whose `when` describes what the person said; a slug the preset does not\n declare is refused rather than ignored.\n- **`rename`** is keyed by the preset\'s entity alias and holds the labels this\n business uses \u2014 the same shape `apply[].bind` takes, and the same rule: only\n naming moves. An alias the preset does not declare, and a label that is\n already another table\'s, are both refused.\n- **`entities`** are added after the rename, already in this business\'s own\n words.\n- **`rows`**, **`field_roles`**, **`apps`** and **`apply`** mean exactly what\n they mean in the full form \u2014 `"rows"` are this business\'s real first records,\n `"field_roles"` may name the preset\'s fields as well as its own (a role the\n preset declares itself is kept unless this file names the same field, or\n clears it with `null`),\n `"apps"` the screens it will have (a preset carries none), `"apply"` the\n packages copied in once its tables exist.\n\nThis is the ONE thing on this page that needs the network: `check` reads the\npreset it names, once. Everything after that read is the same offline check.\n\nWrite the full form when no preset is the trade.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "fields": [\n { "alias": "code", "label": "Order no.", "type": "text", "unique": true },\n { "alias": "placed_on", "label": "Placed on", "type": "date", "format": "date" },\n {\n "alias": "amount",\n "label": "Amount",\n "type": "number",\n "format": "currency",\n "currency": "VND"\n },\n {\n "alias": "total",\n "label": "Total with VAT",\n "type": "formula",\n "formula": { "expression": "{amount} * 1.1", "format": "currency", "currency": "VND" }\n },\n {\n "alias": "customer",\n "label": "Customer",\n "type": "select_record_link",\n "target_entity": "customer",\n "cardinality": "one",\n "sync_both_ways": true,\n "paired_field_alias": "orders",\n "display_field_aliases": ["name"]\n }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "field_roles": {\n "customer": { "name": "identity" },\n "order": { "code": "identity", "placed_on": "when", "amount": "amount", "customer": "party" }\n },\n "apps": [\n {\n "alias": "sales",\n "name": "Sales",\n "screens": [\n { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" },\n { "alias": "orders", "label": "Orders", "shape": "transaction_ledger", "entity": "order" }\n ]\n }\n ],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\n`lotics scaffold check` on this file reports\n`2 tables, 9 fields, 2 links, 1 view, 1 role, 4 rows, 1 app, 2 screens`, then\nthe plan:\n\n```\nSales\n Customers \u2014 party register over Customers (2 rows) \xB7 page \xB7 tabs: none\n identity Name \xB7 mark (none) \xB7 contact (none) \xB7 worth (none) \xB7 risk (none)\n record: header (Name) \xB7 facts (Tier \xB7 Orders \xB7 Total ordered) \xB7 history: Orders (Order no. \xB7 Placed on \xB7 Amount)\n Orders \u2014 transaction ledger over Orders (2 rows) \xB7 drawer \xB7 tabs: none\n when Placed on \xB7 amount Amount \xB7 party Customer \xB7 document (none)\n record: facts (Order no. \xB7 Placed on \xB7 Amount \xB7 Total with VAT \xB7 Customer)\n```\n\nEvery `(none)` is a slot no field fills \u2014 the picture a register has none of,\nthe contact nobody declared. Read it as the screen a person will see. Neither\nentity carries a stage, a required set or files, so the rest is facts \u2014 except\nthe customer\'s `history:`, which is the orders that name it as their party, a\nsection nothing in the file declares. The Customers page states its name in the\nheader and nowhere else; the Orders drawer has no `identity` to state, so every\nfield it reads is a fact.\n';
40622
40641
 
40623
40642
  // src/scaffold_commands.ts
40624
40643
  function printModelReference() {
@@ -40774,8 +40793,10 @@ function describeRecord(model, resolved) {
40774
40793
  const set2 = `${required2.options.length} required`;
40775
40794
  return section.source === "own" ? `documents: ${name} (${set2})` : `documents: ${name} (${required2.label}: ${set2})`;
40776
40795
  }
40777
- case "children":
40778
- return `lines: ${section.child.label} (${section.fields.length === 0 ? "none" : labels(section.fields)})`;
40796
+ case "children": {
40797
+ const heading = section.via === "party" ? "history" : "lines";
40798
+ return `${heading}: ${section.child.label} (${section.fields.length === 0 ? "none" : labels(section.fields)})`;
40799
+ }
40779
40800
  case "files":
40780
40801
  return `files: ${labels(section.fields)}`;
40781
40802
  }
@@ -37,7 +37,7 @@ Per-command syntax, flags, contracts, and gotchas for the public `lotics` CLI. S
37
37
  | `lotics knowledge tag <id...> [--add <a,b>] [--remove <c,d>]` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, add_tags?, remove_tags? }` — one transaction over the whole set. A **DIFF applied to each doc's own labels**, never a replacement: the docs named on one command line carry different labels, so one array across them would strip whatever the others were filed under. Removal matches case-insensitively; adding a label a doc already carries writes nothing. Ids may be separate arguments or comma-separated. At least one of --add/--remove required. |
38
38
  | `lotics knowledge hide <id...>` / `lotics knowledge unhide <id...>` | `PATCH /v1/knowledge_docs` with `{ knowledge_doc_ids, hidden }`. Hiding takes docs out of every **listing** — the Library's list, `list_knowledge`, and the corpus `grep_knowledge` searches — while leaving IAM untouched and keeping them readable **by id** (`read_knowledge` with an id, a code run staging one, an app agent's declared set). So it can never silently break an app that depends on a doc, and unhiding costs nothing. Refuses the no-argument form rather than reading it as "everything". |
39
39
  | `lotics knowledge rm <id>` | Archive the doc via `delete_knowledge` (`{ knowledge_doc_id }`). The REST execute path does not gate `needsApproval`, so this runs unattended. |
40
- | `lotics app create <name> [path]` | Scaffold a Vite+React+TS custom-code app project; POST /v1/apps; npm install; vite build; upload as v1. The generated `package.json#name` is the app name FOLDED to ASCII (`Đơn hàng` → `don-hang`), never stripped of it — dropping the marks would treat each accented vowel as a separator and can slug a name away to nothing. The app's display name is unaffected; this is the npm field only. **`--from <model.json>#<app>`** scaffolds the app a PLAN describes: the file is checked as `scaffold check` checks it, the named app (or the only one) is taken, every screen's entity is found as a live table BY LABEL and every field the model declares on it — the record shows the ones the list leaves out — and, for each child entity a record section is over, its table and the fields that section draws — a table the workspace lacks is refused first, naming every missing one and `scaffold apply`; then a field a table lacks, a label two tables or two fields share, or a field whose live type is not the model's — all before anything is created. Each screen is written as its registry shape from `@lotics/ui` (`LifecycleDesk`, `PartyRegister`, …) over `useQuery(<screen alias>)`, the slots reading the bound fields through `F`; a `custom` screen arrives as its rows and the slot list. The RECORD that shape opens is written too, off the same roles (`recordSections`): a `RecordFacts` of every field no other section owns, a `RecordProgress` over the lifecycle, a `RecordExpectedSet` per required set — the entity's own multi-select, or a child entity whose rows each file one entry — a `RecordChildren` per child entity — the identity, the figures and two more roles as columns, ranked so a narrow register sheds the contact before the money, with the row's whole projection in the drawer behind it — and a `RecordFiles` per files field. A `page` record is one `RecordPage` whose facts take the aside beside that work, or the main column itself where the record has none; a `drawer` record is the same sections stacked. The manifest declares one `project` query per screen (every column the screen and its record read, a files cell whole, a dated book newest first) plus one per child entity, filtered to the parent record through the child's `parent` link and taking it as a declared `{{params.<entity>_id}}`; the `.lotics` companions and `app_fields.ts` are written for them before the first build, and the deploy pushes the queries as it pushes any |
40
+ | `lotics app create <name> [path]` | Scaffold a Vite+React+TS custom-code app project; POST /v1/apps; npm install; vite build; upload as v1. The generated `package.json#name` is the app name FOLDED to ASCII (`Đơn hàng` → `don-hang`), never stripped of it — dropping the marks would treat each accented vowel as a separator and can slug a name away to nothing. The app's display name is unaffected; this is the npm field only. **`--from <model.json>#<app>`** scaffolds the app a PLAN describes: the file is checked as `scaffold check` checks it, the named app (or the only one) is taken, every screen's entity is found as a live table BY LABEL and every field the model declares on it — the record shows the ones the list leaves out — and, for each child entity a record section is over, its table and the fields that section draws — a table the workspace lacks is refused first, naming every missing one and `scaffold apply`; then a field a table lacks, a label two tables or two fields share, or a field whose live type is not the model's — all before anything is created. Each screen is written as its registry shape from `@lotics/ui` (`LifecycleDesk`, `PartyRegister`, …) over `useQuery(<screen alias>)`, the slots reading the bound fields through `F`; a `custom` screen arrives as its rows and the slot list. The RECORD that shape opens is written too, off the same roles (`recordSections`): a `RecordFacts` of every field no other section owns, a `RecordProgress` over the lifecycle, a `RecordExpectedSet` per required set — the entity's own multi-select, or a child entity whose rows each file one entry — a `RecordChildren` per child entity — one whose `parent` link names this record, or whose `party` link does, which is how a party's record holds its history — the identity, the figures and two more roles as columns, ranked so a narrow register sheds the contact before the money, with the row's whole projection in the drawer behind it — and a `RecordFiles` per files field. A `page` record is one `RecordPage` whose facts take the aside beside that work, or the main column itself where the record has none; a `drawer` record is the same sections stacked. The manifest declares one `project` query per screen (every column the screen and its record read, a files cell whole, a dated book newest first) plus one per child entity, filtered to the record through whichever of those links names it and taking it as a declared `{{params.<entity>_id}}`; the `.lotics` companions and `app_fields.ts` are written for them before the first build, and the deploy pushes the queries as it pushes any |
41
41
  | `lotics app pull <app_id> [path]` | Download source archive from R2 (presigned), extract, install dependencies (`npm ci --ignore-scripts` when a lockfile is present, else `npm install --ignore-scripts`), stamp package.json's `lotics` field. With no `[path]`: refresh the cwd IN PLACE when it's already this app's own project (its manifest `app_id` matches — the documented `cd <app> && lotics app pull` flow), else clone into an `<name>/` subdir. **A pull never overwrites a file that differs from what it is about to write** — it writes only what is ABSENT or already identical, keeps the rest, and reports which files it kept plus the commands that close the gap. The same rule covers `src/workflows/<alias>.ts` and `src/agents/<alias>.md`, so an unpushed body or prompt survives too. Those two are written from the LIVE App row (`apps.workflows` / `apps.agents`), which owns them, and the archive's own copy of them is deliberately SKIPPED on extract: a deploy tars the whole source directory, so the tarball holds a deploy-time snapshot that is stale for anything authored since. The comparison is against the app's own content, not git, so it holds for a project that was never a repo. `--force` takes the app's copy and DISCARDS local edits; there is no other way to lose them. **For a KEPT workflow body or agent prompt the pull records the server's fingerprint only when the server has not moved** — that token is `set_app_workflow`/`set_app_agent`'s lost-update precondition, so recording one for text the author has not seen would clear the next push's refusal by disarming the guard, and silently overwrite whoever edited it. When the live text HAS moved, the pull writes it beside the checkout (`.lotics/agents/<alias>.live.md`, `.lotics/workflows/<alias>.live.ts`), names it, and leaves the token stale: the next deploy is refused, which is correct, and the text to merge is now on disk. A file whose prose/body already reads back AS the live text is never "kept" at all — the baselines are healed from it, so a checkout whose prose was pushed out of band (chat, `lotics run set_app_agent`) converges instead of latching. **A pull also REPORTS the files it restored** when refreshing a tree that already claimed a version: a pull mirrors the last DEPLOYED source, so a file deleted locally comes back until the deletion itself ships, and saying so is the only honest fix — nothing can read a deletion off the disk. **When a pull ACROSS versions keeps files, the manifest is left on the OLDER of the two versions** — the tree is then part one and part the other, and claiming the newer would make `deploy`'s `prev_version_id` check pass and ship a half-and-half bundle. Older, not "the one it had": `--from-version` pulls a deliberately old revision, so the version it had is the NEWER side, and holding that would match what the server serves and let the old source ship. Either way a deploy from that tree is refused until you reconcile the listed files by hand and pull again, or take the app's copy with `--force`. The report says which version each side is on, because a kept file is your unshipped work when the project was already current and merely the OLD version when it was behind — and nothing in a byte comparison can tell those apart. **`--from-version <apv_…>`** pulls an OLDER revision instead of the current one (`lotics app versions` lists the ids) — point it at a NEW path to read a previous revision without disturbing the project you are in. The manifest records the version actually written, never the live pointer, so a deploy from that checkout is refused by the version guard rather than shipping old source over newer. — `workflows` and `agents` are sourced from the live App row (NOT the archived manifest), so `set_app_workflow` / `set_app_agent` authoring survives the pull. Regenerates `.lotics/app_{workflows,queries,agents}.d.ts` so `useWorkflow` / `useQuery` / `useAgentRun` stay typed, AND — AFTER `npm install`, so `node_modules/@lotics/ui` actually exists to read — `.lotics/tsconfig.link.json`'s peer pins and, for a kit old enough to ship one, its `react-native` augmentation (a kit that ships none has the previously-written copy deleted); a pulled project's own `tsc` used to fail until `app codegen` was run by hand, because nothing had regenerated either one after install populated node_modules. AND the runtime `.lotics/app_fields.ts` (the same generation `app codegen` runs, off the app row already fetched). That one is not optional: `app deploy` tars source with `--exclude=.lotics`, so no archive can carry it, and a pulled project whose `src/` imports `F`/`OPT` would fail to build with `Could not resolve "../../.lotics/app_fields"` until `app codegen` was run by hand. Skipped under `--view-as` (the schema is read as that member and silently drops tables they cannot see — a narrowed `F` map compiles and then throws at runtime, worse than the missing module). A schema fetch failure is non-fatal and names the right recovery for what is on disk: an existing file is kept, an ABSENT one warns about the build error and points at `app codegen`. Pull GENERATES but never RECONCILES `.lotics/` — deleting a companion whose alias the manifest no longer declares is `app codegen`'s alone, since pull's authority is the server's alias set and a declared-but-not-yet-`set` alias is supported. Also writes one `src/workflows/<alias>.ts` per bound workflow (faithful body from `get_app_workflow`) and one `src/agents/<alias>.md` per bound agent (its instructions, straight off the live row) — so the prose an author actually edits lives in a file. A pull writes it from live UNLESS the local file holds unpushed work, in which case it is kept and the live text is parked beside the checkout — the same rule the rest of this row describes. A legacy workflow alias with no rendered source, or an agent with no instructions, warns and is skipped. The stamped `lotics.agents` map carries the TYPED half only (`inputs`/`outputs`/`tool_names`/`model_tier`/…) — an agent's prose lives solely in its `.md`, so there is never a second local copy to desync; a stale `instructions` left by an older CLI is inert and disappears on the next pull |
42
42
  | `lotics app deploy [--prune] -m <message>` | `-m` is OPTIONAL — omitted, the deploy derives the version message from what it actually pushed; pass `-m` when you have a reason worth recording. `npm run typecheck`, `npm run build`; tar source + dist; POST /v1/apps/{id}/versions multipart. **One command ships everything**: before the bundle moves, a deploy pushes every binding the project has ahead of the app — an edited workflow body or declaration, edited agent prose, a changed query — through `set_app_query`, then `set_app_workflow`, then `set_app_agent`, and fails the release if any push is refused. That order is required: an agent declares the query and workflow aliases it may call, so pushing it before its own new query is refused. A workflow's `description` is part of that push and is compared against the recorded baseline, not the live app — it lives on the workflow ROW, which `getApp` does not carry. It never AUTHORS a binding itself — those verbs stay the single writers — and each push carries the fingerprint the project last saw live (`lotics.synced`), so a stale checkout is refused rather than overwriting another author's edit. `package.json` means the same thing for both artifacts: editing `lotics.agents.<alias>.inputs`/`outputs` is pushed exactly like the workflow equivalent (only those two fields — `set_app_agent` merges, so everything the manifest does not model is left untouched). It also regenerates the `.lotics/*.d.ts` companions and `.lotics/app_fields.ts` before building, since the build INLINES the latter and a stale copy would ship ids that no longer name what the source thinks they do — and then runs the app's own `npm run typecheck` against them, because Vite strips types and a filter or sort key the query does not project would otherwise ship. A `package.json` with no `typecheck` script is warned about, never passed in silence. `lotics app check` reports the same set without pushing; neither has a `--strict`. What the version RECORDS as the aliases it calls — the set `remove_app_workflow` / `remove_app_query` / `remove_app_agent` consult to refuse unbinding one the served version still reaches — is read by the SERVER out of the source archive this deploy uploads, not reported by the deploy. That matters because the deploy is also what unbinds: a client supplying the evidence used to refuse its own removal cannot be checked by it. After a successful deploy it warns about any alias the source CALLS that is NOT bound, and **names the inverse** — but as a TRANSITION, not a state: bindings this bundle *stopped* calling, compared against what the previous deploy's bundle called (`package.json#lotics.bundle_calls`, which a deploy records). It does NOT remove them: **`--prune` does, and only when passed.** The distinction is what makes the report readable. A static scan sees the bundle's call sites and an agent's `query_aliases`/`workflow_aliases`; it cannot see `lotics run run_app_query`/`run_app_workflow`, whose whole contract is that the alias is bound server-side, or chat's call under `app:use` — and the capability catalog publishes EVERY declared alias to both. So an alias the bundle never called is the normal shape of an agent-facing binding, not a dead one, and reporting it fired on every app built to be driven by an agent while pointing at the flag that deletes it. An alias that WAS called and is not any more is different: that is a call site the author removed, which is compile-, check- and deploy-clean while the binding keeps serving. **No baseline ⇒ nothing reported** — a manifest from before this field, or one a `pull` rebuilt from the server row, costs one quiet deploy and then self-heals, because silence is the only honest answer with nothing to compare. The baseline is STICKY: it advances only once nothing is outstanding, so the `--prune` this warning names still finds the transition on a later run instead of reporting ✓ over a binding that still serves. Pruning runs AFTER the version is live, because the removal tools refuse an alias the SERVED version still declares — so doing it first is refused by the guard that makes it safe. `--prune` is skipped ENTIRELY (with a warning, never a failure) when the source computes an alias at run time, since the scan cannot tell which binding that reaches and pruning "the rest" would be guessing with a deletion. **A removal DELETES the local declaration too** — `package.json#lotics.<kind>.<alias>` and its `synced` baseline — because leaving it would undo the prune: the manifest is what the next plain deploy pushes FROM, so the binding came straight back. That makes the act destructive rather than merely reversible, so what it deleted is written to `.lotics/pruned/<kind>/<alias>.json` and the ✓ names that file plus the `set` verb that re-binds it, on the same line. (These trees are never committed, so git is not the fallback; `lotics app pull --from-version <apv_…>` is the only other route back.) After a successful prune the generated companions are regenerated from the narrowed manifest — the `.d.ts` set and, when a query was pruned, `.lotics/app_fields.ts`, whose table set is derived from the surviving query ASTs. A table named ONLY by the pruned query leaves `F`/`OPT`, which is reported: if your source still addresses it, add the table id to `package.json#lotics.codegen.tables`. A local write that fails at any of this reports what could not be written and which aliases were already unbound server-side; it never fails the release, which is already live. A binding that will not unbind is reported and does NOT fail the release: the version is live and correct — and the server refuses to unbind a WORKFLOW this workspace has actually run (a recorded execution means a caller the source cannot name), which surfaces here as `✗ could not unbind …` with the date it last ran. After a successful deploy it also REFRESHES the `.lotics/workflows/<alias>.globals.d.ts` of any alias whose `// lotics:declaration` stamp says this deploy moved its declaration (only those — refreshing every bound alias would cost one round trip each on every deploy to fix something only ever wrong right after a manifest edit), from the manifest declaration, re-wrapping the SAME on-disk body (never re-fetching it, so local edits survive). A deploy is the moment the manifest becomes real, so it is also the moment the local types stop matching it — and the author's next act is usually `workflow set`, whose body would otherwise be typechecked against the declaration as it stood before this deploy. Non-fatal: the release already shipped, and stale types never fail it. |
43
43
  | `lotics app versions [app_id]` | `GET /v1/apps/{id}/versions` — print deploy history newest-first (version number, timestamp, deployer name, build status, the `-m` message; `*` marks the currently-served version). app_id from the local manifest, or pass one to inspect any app without pulling it. Admin-only server-side (mirrors deploy + source download). Answers "what shipped, when, by whom" — e.g. whether a fix was live at an incident's time. Title → stderr, table → stdout (pipeable). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/cli",
3
- "version": "0.195.0",
3
+ "version": "0.196.0",
4
4
  "description": "Lotics SDK and CLI for AI agents",
5
5
  "type": "module",
6
6
  "bin": {