@lotics/cli 0.253.0 → 0.255.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
@@ -52,7 +52,7 @@ var __toESM = (mod2, isNodeMode, target) => (target = mod2 != null ? __create(__
52
52
  var define_LOTICS_KIT_VERSIONS_default;
53
53
  var init_define_LOTICS_KIT_VERSIONS = __esm({
54
54
  "<define:__LOTICS_KIT_VERSIONS__>"() {
55
- define_LOTICS_KIT_VERSIONS_default = { runtime: "0.37.1" };
55
+ define_LOTICS_KIT_VERSIONS_default = { runtime: "0.39.1" };
56
56
  }
57
57
  });
58
58
 
@@ -47182,7 +47182,8 @@ var tableRecordTextFieldFilterSchema = zod_default.object({
47182
47182
  "is_empty",
47183
47183
  "is_not_empty",
47184
47184
  "is_any_of",
47185
- "is_none_of"
47185
+ "is_none_of",
47186
+ "contains_any_of"
47186
47187
  ]),
47187
47188
  value: zod_default.union([zod_default.string(), zod_default.array(zod_default.string())]).nullable()
47188
47189
  });
@@ -47549,7 +47550,7 @@ var NO_VALUE_OPERATORS = /* @__PURE__ */ new Set([
47549
47550
  "is_locked",
47550
47551
  "is_not_locked"
47551
47552
  ]);
47552
- var TEXT_ARRAY_OPERATORS = /* @__PURE__ */ new Set(["is_any_of", "is_none_of"]);
47553
+ var TEXT_ARRAY_OPERATORS = /* @__PURE__ */ new Set(["is_any_of", "is_none_of", "contains_any_of"]);
47553
47554
  function isUnstatedDatePoint(point) {
47554
47555
  if (point === null || point === void 0) return true;
47555
47556
  if (typeof point === "string") return point.trim() === "";
@@ -54089,9 +54090,12 @@ var alias = contractAliasSchema;
54089
54090
  var fieldAlias = contractAliasSchema.describe("A field alias of the entity named beside it");
54090
54091
  var isObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
54091
54092
  var selectsHold = (what) => zod_default.record(fieldAlias, zod_default.array(alias).min(1)).describe(what);
54093
+ var conditionsHold = (what) => zod_default.record(fieldAlias, zod_default.union([zod_default.array(alias).min(1), zod_default.boolean()])).describe(what);
54092
54094
  var milestoneSchema = zod_default.object({
54093
54095
  field: fieldAlias.describe("A date field: the stage is reached on the day it holds"),
54094
- when: selectsHold("The stage applies only while each named select holds one of these options; otherwise the row skips it")
54096
+ when: conditionsHold(
54097
+ "The stage applies only while each named select holds one of these options, and each named stored yes/no is this value; otherwise the row skips it"
54098
+ )
54095
54099
  }).strict();
54096
54100
  var recordStatusSchema = zod_default.object({
54097
54101
  field: fieldAlias.optional().describe("The single select that is the row's state \u2014 or `milestones` instead"),
@@ -54126,7 +54130,10 @@ var recordDisplaySchema = zod_default.object({
54126
54130
  tolerance: zod_default.record(alias, zod_default.number().min(0)).optional().describe(
54127
54131
  "How far past its `limits` bound a meter may stand, in percent of the bound: the bound is an amount expected (received against ordered), not a cap. Without one the bound is a cap, and past it is an overrun"
54128
54132
  ),
54129
- due: zod_default.array(fieldAlias).min(1).optional().describe("Dates that are deadlines: each counts down and turns overdue")
54133
+ due: zod_default.array(fieldAlias).min(1).optional().describe("Dates that are deadlines: each counts down and turns overdue"),
54134
+ starts: zod_default.record(fieldAlias, zod_default.enum(["today", "me"])).optional().describe(
54135
+ "What a field of a row being added starts at, where that start is certain: `today` a date (a moment starts now), `me` a member, the reader adding it; every other field starts empty unless the reader narrowed the register to a value or the field declares a `default`"
54136
+ )
54130
54137
  }).strict();
54131
54138
  var recordsSchema = zod_default.record(alias, recordDisplaySchema).describe("How a row of each entity is recognised, keyed by entity alias. Every entity an app lists, opens or picks has one");
54132
54139
  var readingWhere = zod_default.record(fieldAlias, zod_default.union([zod_default.array(alias).min(1), zod_default.boolean()])).describe("Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed");
@@ -54186,6 +54193,9 @@ var registerSchema = zod_default.object({
54186
54193
  search: zod_default.array(fieldAlias).min(1).optional().describe(
54187
54194
  "The fields the search box matches (a link by its row's title); a pasted list searches each line and names the lines no row matched. Absent, every word of the row"
54188
54195
  ),
54196
+ group: fieldAlias.optional().describe(
54197
+ "The field the rows open grouped by, the reader's grouping starting there \u2014 a period's rows read under each period, one per person; absent, ungrouped"
54198
+ ),
54189
54199
  sort: zod_default.object({
54190
54200
  field: fieldAlias.describe("The field rows are ordered by"),
54191
54201
  desc: zod_default.literal(true).optional().describe("Latest or largest first; absent, soonest or smallest first")
@@ -54198,8 +54208,10 @@ var registerSchema = zod_default.object({
54198
54208
  ]).optional().describe("The rows in view can be saved: `true`, as a spreadsheet of the columns; a `template`, as one document made from them"),
54199
54209
  readings: zod_default.array(figureReadingSchema).min(1).optional().describe("Readings of the register's own entity above its rows, over the rows in view \u2014 the search, the status and the filters narrow them")
54200
54210
  }).strict();
54201
- var shownWhen = selectsHold("Shown only while each named select of the record holds one of these options");
54202
- var blockCreateSchema = zod_default.union([zod_default.array(fieldAlias).min(1), zod_default.literal(false)]).optional().describe("The child's fields asked when a row is added here; absent, its title and every required field; false, rows are not added here");
54211
+ var shownWhen = conditionsHold(
54212
+ "Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value"
54213
+ );
54214
+ var blockCreateSchema = (fallback) => zod_default.union([zod_default.array(fieldAlias).min(1), zod_default.literal(false)]).optional().describe(`The child's fields asked when a row is added here; absent, ${fallback} a person writes; false, rows are not added here`);
54203
54215
  var blockTitle = zod_default.string().min(1).optional().describe("The block's heading; absent, the child entity's or the field's own label");
54204
54216
  var blockVia = fieldAlias.optional().describe("The child's link to this record, where it has more than one");
54205
54217
  var metricBlockSchema = metricReadingSchema.extend({ via: blockVia, when: shownWhen.optional() }).strict();
@@ -54212,7 +54224,7 @@ var blockSchema = zod_default.union([
54212
54224
  via: blockVia,
54213
54225
  title: blockTitle,
54214
54226
  columns: zod_default.array(fieldAlias).optional().describe("The child's fields read as columns after its title"),
54215
- create: blockCreateSchema,
54227
+ create: blockCreateSchema("its title, its picture, the block's columns and every required field"),
54216
54228
  where: selectsHold(
54217
54229
  "Only the child rows whose single select holds one of these options \u2014 every entry ANDed, read so on the server; a row added here holds them (one leg's fees, of a record holding both legs'). Each select is one the child requires, so no row stands in no block"
54218
54230
  ).optional(),
@@ -54222,15 +54234,29 @@ var blockSchema = zod_default.union([
54222
54234
  of: fieldAlias.optional().describe(
54223
54235
  "With `expect` on a select: the record's own multi-select holding the options this record expects a line for \u2014 the options it holds, in order, rather than every option. Its options are among the title's"
54224
54236
  ),
54237
+ under: fieldAlias.optional().describe(
54238
+ "The child's one-link to a document that covers its rows (an invoice over its fee lines), whose many-link points back and which links to this record: each document stands as a heading over the lines it covers, the lines no document covers first under their own heading, where the document's add makes one from the lines picked. Never also a rows block of the document"
54239
+ ),
54240
+ when: shownWhen.optional()
54241
+ }).strict(),
54242
+ zod_default.object({
54243
+ agenda: alias.describe(
54244
+ "A child entity read as planned entries on a time axis: by day, soonest first, each entry with its picture and the day that is today marked. The day is the first date on the child's row line (its `records` title or subtitle); within a day entries follow a datetime there, else the first single select after the date \u2014 its options in the day's order"
54245
+ ),
54246
+ via: blockVia,
54247
+ title: blockTitle,
54248
+ columns: zod_default.array(fieldAlias).optional().describe("The child's facts read on an entry's line after its title"),
54249
+ create: blockCreateSchema("its title, its picture, the block's columns and every required field"),
54250
+ start: fieldAlias.optional().describe("The record's date that is the first day: each day's heading counts from it (Day 1, Day 2); absent, the weekday and the date alone"),
54225
54251
  when: shownWhen.optional()
54226
54252
  }).strict(),
54227
54253
  zod_default.object({
54228
54254
  timeline: alias.describe(
54229
- "A child entity read as dated entries, newest first, with a composer where the app writes it \u2014 never the record's status history, which the record's companion reads beside its comments"
54255
+ "A child entity read as a log of dated entries, newest first, with a composer where the app writes it (entries planned ahead are an `agenda`) \u2014 never the record's status history, which `record.history` draws beside the record"
54230
54256
  ),
54231
54257
  via: blockVia,
54232
54258
  title: blockTitle,
54233
- create: blockCreateSchema,
54259
+ create: blockCreateSchema("its title and every required field"),
54234
54260
  when: shownWhen.optional()
54235
54261
  }).strict(),
54236
54262
  zod_default.object({
@@ -54259,6 +54285,9 @@ var blockSchema = zod_default.union([
54259
54285
  function isRowsBlock(block2) {
54260
54286
  return "rows" in block2 && !("pivot" in block2);
54261
54287
  }
54288
+ function isAgendaBlock(block2) {
54289
+ return "agenda" in block2;
54290
+ }
54262
54291
  function isReadingBlock(block2) {
54263
54292
  return "metric" in block2 || "breakdown" in block2 || "trend" in block2 || "pivot" in block2;
54264
54293
  }
@@ -54287,7 +54316,10 @@ var recordSchema = zod_default.object(
54287
54316
  sections: zod_default.array(recordSectionSchema).min(1).optional().describe(
54288
54317
  "The record in the order its work reaches it \u2014 each section its fields, then its blocks, then its acts. Absent, one section of every field a person writes that no other place shows"
54289
54318
  ),
54290
- comments: zod_default.literal(true).optional().describe("The record's comment thread: who wrote what and when, with a composer, beside the sections")
54319
+ comments: zod_default.literal(true).optional().describe("The record's comment thread: who wrote what and when, with a composer, beside the sections"),
54320
+ history: zod_default.literal(true).optional().describe(
54321
+ "The record's status history \u2014 each move, when and who \u2014 beside the sections, for a lifecycle whose past steps are looked up (a shipment, a container's gate moves, an order); the entity's `status.history` is written either way. Absent, it is drawn nowhere"
54322
+ )
54291
54323
  },
54292
54324
  {
54293
54325
  error: (issue2) => issue2.code === "unrecognized_keys" && issue2.keys.some((key) => RETIRED_RECORD_KEYS.has(key)) ? "`facts` and `blocks` are replaced by `sections` \u2014 each a title, its `fields`, `blocks` and `acts`, in the order the work reaches them; docs/app_system.md" : void 0
@@ -54312,7 +54344,9 @@ var actSchema = zod_default.object({
54312
54344
  "`rows` runs on the rows ticked in the register, `view` on every row the register shows (offered while the view is narrowed to its `when`; it states no `requires`); absent, on one record (its page and its row's menu)"
54313
54345
  ),
54314
54346
  of: alias.optional().describe("A rows block's child entity the act works on: offered on each child row, its conditions and its write that row's"),
54315
- when: zod_default.record(fieldAlias, zod_default.array(alias).min(1)).optional().describe("The act works only while each named select holds one of these options"),
54347
+ when: conditionsHold(
54348
+ "The act works only while each named select holds one of these options, and each named yes/no (stored, or a formula) is this value"
54349
+ ).optional(),
54316
54350
  requires: zod_default.array(fieldAlias).min(1).optional().describe("Fields that must be filled first; a blocked act names each one missing"),
54317
54351
  recommends: zod_default.array(fieldAlias).min(1).optional().describe("Fields the act runs without and reads when filled: while one is empty, the act says what it will leave blank"),
54318
54352
  asks: zod_default.array(zod_default.union([fieldAlias, actInputSchema])).min(1).optional().describe("What the reader states when pressing it \u2014 fields of the record (written by the act) or inputs"),
@@ -54350,7 +54384,7 @@ var modelAppSchema = zod_default.object({
54350
54384
  reads: appReads,
54351
54385
  writes: zod_default.literal("children").optional().describe("The reader adds and edits the record's child rows but not the record itself"),
54352
54386
  register: registerSchema.optional().describe("The rows: which columns and filters, the order, how a row is added"),
54353
- record: recordSchema.optional().describe("What a row opens: the door, its sections and its comment thread"),
54387
+ record: recordSchema.optional().describe("What a row opens: the door, its sections, and beside them its comment thread and its status history where stated"),
54354
54388
  acts: zod_default.array(actSchema).optional().describe("What the reader does to a record \u2014 each a press with its conditions and its write"),
54355
54389
  checks: zod_default.array(checkSchema).optional().describe("Formulas that warn while they stand, or refuse the acts and saves they name")
54356
54390
  }).strict();
@@ -54388,9 +54422,12 @@ var PERIOD_TO_PARAM = "to";
54388
54422
  function isDashboardSpec(file2) {
54389
54423
  return "dashboard" in file2;
54390
54424
  }
54425
+ var URI_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
54426
+ var MAIL_ADDRESS = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
54391
54427
  function contactHref(text) {
54392
54428
  const said = text.trim();
54393
- if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(said)) return `mailto:${said}`;
54429
+ if (URI_SCHEME.test(said)) return void 0;
54430
+ if (MAIL_ADDRESS.test(said)) return `mailto:${said}`;
54394
54431
  if (!/^\+?[\d\s().-]+$/.test(said) || !(/^[+0(]/.test(said) || /\d\s+\d/.test(said))) return void 0;
54395
54432
  const digits = said.replace(/\D/g, "");
54396
54433
  return digits.length >= 8 && digits.length <= 15 ? `tel:${said.startsWith("+") ? "+" : ""}${digits}` : void 0;
@@ -54398,12 +54435,16 @@ function contactHref(text) {
54398
54435
  function navigatesSections(drawn) {
54399
54436
  return drawn >= 3;
54400
54437
  }
54438
+ function heldTogether(one, other2) {
54439
+ if ("value" in one || "value" in other2) return "value" in one && "value" in other2 && one.value === other2.value;
54440
+ return one.options.some((option) => other2.options.includes(option));
54441
+ }
54401
54442
  function offeredTogether(one, other2) {
54402
54443
  const theirs = other2.when;
54403
54444
  if (one.when === void 0 || theirs === void 0) return false;
54404
54445
  const shared = one.when.flatMap((held2) => {
54405
54446
  const match2 = theirs.find((candidate) => candidate.field === held2.field);
54406
- return match2 === void 0 ? [] : [held2.options.some((option) => match2.options.includes(option))];
54447
+ return match2 === void 0 ? [] : [heldTogether(held2, match2)];
54407
54448
  });
54408
54449
  return shared.length > 0 && shared.every(Boolean);
54409
54450
  }
@@ -54474,6 +54515,13 @@ function recordDisplay(model, alias2) {
54474
54515
  }
54475
54516
  return void 0;
54476
54517
  }
54518
+ function whenConditions(when) {
54519
+ return Object.entries(when ?? {}).map(([field2, held2]) => typeof held2 === "boolean" ? { field: field2, value: held2 } : { field: field2, options: held2 });
54520
+ }
54521
+ function heldOptions(when, field2) {
54522
+ const held2 = when?.[field2];
54523
+ return Array.isArray(held2) ? held2 : void 0;
54524
+ }
54477
54525
  function milestonesOf(display) {
54478
54526
  return display?.status?.milestones?.map((stage) => typeof stage === "string" ? { field: stage, when: {} } : { field: stage.field, when: stage.when });
54479
54527
  }
@@ -54493,7 +54541,7 @@ function placedAct(model, app, act) {
54493
54541
  alias: act.alias,
54494
54542
  ...act.of === void 0 ? {} : { of: act.of },
54495
54543
  ...typeof moved === "string" ? { to: moved } : {},
54496
- ...act.when === void 0 ? {} : { when: Object.entries(act.when).map(([field2, options]) => ({ field: field2, options })) },
54544
+ ...act.when === void 0 ? {} : { when: whenConditions(act.when) },
54497
54545
  ...act.danger === true ? { danger: true } : {}
54498
54546
  };
54499
54547
  }
@@ -54514,14 +54562,14 @@ function actHome(model, app, act) {
54514
54562
  const order = field2?.type === "select" ? (field2.options ?? []).map((option) => option.alias) : [];
54515
54563
  if (typeof moved !== "string" || !order.includes(moved)) return void 0;
54516
54564
  const sections = app.record?.sections ?? [];
54517
- const held2 = act.when?.[status];
54565
+ const held2 = heldOptions(act.when, status);
54518
54566
  const own = held2 === void 0 ? -1 : sections.findIndex((section) => held2.every((option) => section.at?.includes(option) === true));
54519
54567
  const into = sections.findIndex((section) => section.at?.includes(moved) === true);
54520
54568
  const earlier = (from, to) => order.indexOf(from) < order.indexOf(to);
54521
54569
  const forward = held2 !== void 0 && held2.some((option) => earlier(option, moved));
54522
54570
  const passed = (stage) => stage.includes(order[0] ?? "") || actsOver(model, app.entity).some((one) => {
54523
54571
  const to = one.set?.[status];
54524
- return typeof to === "string" && stage.includes(to) && (one.when?.[status] ?? []).some((from) => earlier(from, to));
54572
+ return typeof to === "string" && stage.includes(to) && (heldOptions(one.when, status) ?? []).some((from) => earlier(from, to));
54525
54573
  });
54526
54574
  const correction = isCorrectionAct(model, app, act);
54527
54575
  if (own >= 0 && (into < 0 || into === own || forward || correction || passed(sections[into]?.at ?? []))) return { section: own, entry: false };
@@ -54617,7 +54665,7 @@ function entityLocks(model, entity) {
54617
54665
  );
54618
54666
  }
54619
54667
  function withEntityLocks(model, app) {
54620
- const children = unique(recordBlocks(app.record).flatMap((block2) => isRowsBlock(block2) ? [block2.rows] : []));
54668
+ const children = recordChildren(model, app.entity, recordBlocks(app.record));
54621
54669
  const gathered = [
54622
54670
  ...entityLocks(model, app.entity).map((check2) => ({ field: check2.field, ...check2.blocks === void 0 ? {} : { blocks: check2.blocks } })),
54623
54671
  ...children.filter((child) => child !== app.entity).flatMap((child) => entityLocks(model, child).map((check2) => ({ ...check2, of: child })))
@@ -54773,11 +54821,59 @@ function childList(model, entity, field2) {
54773
54821
  );
54774
54822
  return back.length > 0 && back.every((one) => one.type === "select_record_link" && one.cardinality === "one");
54775
54823
  }
54824
+ function agendaAxis(model, child) {
54825
+ const display = model.records?.[child.alias];
54826
+ const line = display === void 0 ? [] : [display.title, ...display.subtitle ?? []];
54827
+ const typed = line.flatMap((name) => {
54828
+ const field2 = fieldOf(child, name);
54829
+ return field2 === void 0 ? [] : [{ name, field: field2, type: resolvedFieldType(field2, child, model.entities) }];
54830
+ });
54831
+ const at2 = typed.findIndex((one) => one.type === "date");
54832
+ const day = typed[at2];
54833
+ if (day === void 0) {
54834
+ return { refused: `"${child.alias}" rows name no date on their line (\`records.${child.alias}\` title or subtitle) \u2014 an entry stands on the day it holds` };
54835
+ }
54836
+ const hour = (one) => one.type === "date" && one.field.type === "date" && one.field.format === "datetime";
54837
+ const time3 = typed.find(hour);
54838
+ if (time3 !== void 0) return { day: day.name, time: time3.name };
54839
+ const part = typed.slice(at2 + 1).find((one) => isSingleSelect(one.field));
54840
+ return { day: day.name, ...part === void 0 ? {} : { part: part.name } };
54841
+ }
54842
+ function documentUnder(model, parent, child, field2) {
54843
+ const link = fieldOf(child, field2);
54844
+ if (link === void 0) return { refused: `names "${field2}", which is not a field of "${child.alias}"` };
54845
+ if (link.type !== "select_record_link" || link.cardinality !== "one") {
54846
+ return { refused: `"${field2}" is ${link.type === "select_record_link" ? "a link to several rows" : `a ${link.type} field`} \u2014 a line stands under the one document its one-link names` };
54847
+ }
54848
+ const document2 = entityOf(model, link.target_entity);
54849
+ if (document2 === void 0) return { refused: `"${field2}" links to "${link.target_entity}", which this model does not declare` };
54850
+ const back = document2.fields.find(
54851
+ (one) => one.type === "select_record_link" && one.target_entity === child.alias && (one.cardinality ?? "many") === "many" && (one.paired_field_alias === field2 || link.paired_field_alias === one.alias)
54852
+ );
54853
+ if (back === void 0) {
54854
+ return { refused: `"${document2.alias}" has no many-link paired with "${field2}" \u2014 a document names the lines it covers; pair one both ways` };
54855
+ }
54856
+ const own = childLink(document2, parent, void 0);
54857
+ if ("refused" in own) return { refused: `stands the lines under "${document2.alias}", which is read under this record by its one link to "${parent}": it ${own.refused.replace(/^reads "[^"]+", which /, "")}` };
54858
+ return { field: field2, entity: document2.alias, via: own.link, back: back.alias };
54859
+ }
54860
+ function recordChildren(model, parent, blocks) {
54861
+ return unique(
54862
+ blocks.flatMap((block2) => {
54863
+ if (isAgendaBlock(block2)) return [block2.agenda];
54864
+ if (!isRowsBlock(block2)) return [];
54865
+ const child = entityOf(model, block2.rows);
54866
+ const document2 = child === void 0 || block2.under === void 0 ? void 0 : documentUnder(model, parent, child, block2.under);
54867
+ return [block2.rows, ...document2 === void 0 || "refused" in document2 ? [] : [document2.entity]];
54868
+ })
54869
+ );
54870
+ }
54776
54871
 
54777
54872
  // ../shared/src/schemas/app_model_validate_parts.ts
54778
54873
  init_define_LOTICS_KIT_VERSIONS();
54779
54874
  var error51 = (path40, message2) => ({ severity: "error", path: path40, message: message2 });
54780
- function checkSelectsHold(entity, when, at2) {
54875
+ var note = (path40, message2) => ({ severity: "note", path: path40, message: message2 });
54876
+ function checkWhen(entities, entity, when, at2, place) {
54781
54877
  const findings = [];
54782
54878
  for (const [name, options] of Object.entries(when ?? {})) {
54783
54879
  const field2 = fieldOf(entity, name);
@@ -54785,8 +54881,18 @@ function checkSelectsHold(entity, when, at2) {
54785
54881
  findings.push(error51(`${at2}.${name}`, `names "${name}", which is not a field of "${entity.alias}"`));
54786
54882
  continue;
54787
54883
  }
54788
- if (!isSingleSelect(field2)) {
54789
- findings.push(error51(`${at2}.${name}`, `"${name}" is not a single select \u2014 a condition waits on one option of one select`));
54884
+ if (typeof options === "boolean") {
54885
+ if (field2.type === "boolean") continue;
54886
+ if (field2.type === "formula" && resolvedFieldType(field2, entity, entities) === "boolean") {
54887
+ if (place === "stage") findings.push(error51(`${at2}.${name}`, `"${name}" is a formula \u2014 a stage is read as a write leaves the row, before a formula over it is computed; wait on a stored yes/no`));
54888
+ continue;
54889
+ }
54890
+ findings.push(error51(`${at2}.${name}`, `"${name}" is a ${field2.type} field \u2014 true or false waits on a yes/no, stored or a formula of one`));
54891
+ continue;
54892
+ }
54893
+ if (field2.type !== "select" || field2.multi === true) {
54894
+ const kind = field2.type === "select" ? "multi-select" : `${field2.type} field`;
54895
+ findings.push(error51(`${at2}.${name}`, `"${name}" is a ${kind} \u2014 options wait on a single select; a yes/no waits on \`true\` or \`false\``));
54790
54896
  continue;
54791
54897
  }
54792
54898
  const declared = new Set(field2.options.map((option) => option.alias));
@@ -54886,7 +54992,7 @@ function checkDashboard(model, app, at2) {
54886
54992
 
54887
54993
  // ../shared/src/schemas/app_model_validate_milestones.ts
54888
54994
  init_define_LOTICS_KIT_VERSIONS();
54889
- function checkMilestones(entity, display, at2) {
54995
+ function checkMilestones(entities, entity, display, at2) {
54890
54996
  const stages = milestonesOf(display);
54891
54997
  if (stages === void 0) return [];
54892
54998
  const findings = [];
@@ -54903,7 +55009,7 @@ function checkMilestones(entity, display, at2) {
54903
55009
  }
54904
55010
  if (seen.has(stage.field)) findings.push(error51(path40, `"${stage.field}" is already a milestone \u2014 each date marks one stage`));
54905
55011
  seen.add(stage.field);
54906
- findings.push(...checkSelectsHold(entity, stage.when, `${path40}.when`));
55012
+ findings.push(...checkWhen(entities, entity, stage.when, `${path40}.when`, "stage"));
54907
55013
  }
54908
55014
  for (const [index, closed] of (display.status?.closed ?? []).entries()) {
54909
55015
  if (!seen.has(closed)) findings.push(error51(`${at2}.status.closed.${index}`, `names "${closed}", which is not one of the milestones`));
@@ -54963,7 +55069,7 @@ function checkRecords(model) {
54963
55069
  const history = historyShape(model, alias2);
54964
55070
  if (history !== void 0 && "refused" in history) findings.push(error51(`${at2}.status.history`, history.refused));
54965
55071
  }
54966
- findings.push(...checkMilestones(entity, display, at2));
55072
+ findings.push(...checkMilestones(model.entities, entity, display, at2));
54967
55073
  if (display.figure !== void 0) {
54968
55074
  const figure = field2(display.figure, `${at2}.figure`);
54969
55075
  if (figure !== void 0 && valueType(figure) !== "number") {
@@ -54990,6 +55096,17 @@ function checkRecords(model) {
54990
55096
  findings.push(error51(`${at2}.tolerance.${name}`, `tolerates an excess on "${name}", which \`limits\` does not bound \u2014 a tolerance sits on a meter`));
54991
55097
  }
54992
55098
  }
55099
+ for (const [name, start] of Object.entries(display.starts ?? {})) {
55100
+ const path40 = `${at2}.starts.${name}`;
55101
+ const started = field2(name, path40);
55102
+ if (started === void 0) continue;
55103
+ if (!isAsked(started)) findings.push(error51(path40, `"${name}" is a ${started.type} field nobody writes, so an add never starts it`));
55104
+ else if (start === "today" && (started.type !== "date" || started.format === "date_range" || started.format === "datetime_range")) {
55105
+ findings.push(error51(path40, `"${name}" is ${started.type === "date" ? "a span of days" : `a ${started.type} field`} \u2014 \`today\` starts a date or a moment`));
55106
+ } else if (start === "me" && started.type !== "select_member") {
55107
+ findings.push(error51(path40, `"${name}" is a ${started.type} field \u2014 \`me\` starts a member field at the reader adding the row`));
55108
+ }
55109
+ }
54993
55110
  for (const [index, name] of (display.due ?? []).entries()) {
54994
55111
  const due = field2(name, `${at2}.due.${index}`);
54995
55112
  if (due !== void 0 && valueType(due) !== "date") findings.push(error51(`${at2}.due.${index}`, `"${name}" is not a date, so it cannot fall due`));
@@ -54998,238 +55115,19 @@ function checkRecords(model) {
54998
55115
  return findings;
54999
55116
  }
55000
55117
 
55001
- // ../shared/src/schemas/app_model_validate_acts.ts
55002
- init_define_LOTICS_KIT_VERSIONS();
55003
-
55004
- // ../shared/src/app_workflow_inputs.ts
55118
+ // ../shared/src/schemas/app_model_validate_visuals.ts
55005
55119
  init_define_LOTICS_KIT_VERSIONS();
55006
- var RECORD_INPUT = "record_id";
55007
- var RECORDS_INPUT = "record_ids";
55008
- var RECORDS_INPUT_MAX = 200;
55009
- var ADDED_SUFFIX = "_added";
55010
- var REMOVED_SUFFIX = "_removed";
55011
55120
 
55012
- // ../shared/src/schemas/app_model_validate_acts.ts
55013
- function checkChildCheck(model, check2, children, acts, path40) {
55014
- const child = check2.of === void 0 ? void 0 : entityOf(model, check2.of);
55015
- if (child === void 0 || !children.has(child.alias)) {
55016
- return [error51(`${path40}.of`, `names "${String(check2.of)}", which no rows block of this record holds \u2014 a check of a child is of rows the record lists`)];
55017
- }
55018
- const field2 = fieldOf(child, check2.field);
55019
- if (field2 === void 0) return [error51(`${path40}.field`, `names "${check2.field}", which is not a field of "${child.alias}"`)];
55020
- const result = field2.type === "formula" ? resolvedFieldType(field2, child, model.entities) : void 0;
55021
- const findings = [];
55022
- if (result === void 0 || !["boolean", "text"].includes(result)) {
55023
- findings.push(error51(`${path40}.field`, `"${check2.field}" is a ${field2.type} field \u2014 a check is a yes/no or text formula that stands while true or non-empty`));
55024
- }
55025
- if (check2.blocks !== void 0 && check2.blocks !== "all") {
55026
- for (const [index, target] of check2.blocks.entries()) {
55027
- if (target === "edit" || target === "delete" || acts.get(target) === child.alias) continue;
55028
- findings.push(error51(`${path40}.blocks.${index}`, `names "${target}" \u2014 a check of a child row refuses its saves ("edit"), its Delete ("delete"), an act of "${child.alias}" or "all"`));
55029
- }
55030
- }
55031
- return findings;
55032
- }
55033
- function checkAct(model, entity, act, at2) {
55034
- const findings = [];
55035
- const stages = new Set((milestonesOf(recordDisplay(model, entity.alias)) ?? []).map((stage) => stage.field));
55036
- if (act.on === "view" && (act.requires ?? []).length > 0) {
55037
- findings.push(error51(`${at2}.requires`, "runs over every row in view, and a field one row lacks would refuse them all \u2014 state a `when` the view is narrowed to, or run it on each record"));
55038
- }
55039
- const own = (name, path40) => {
55040
- const found = fieldOf(entity, name);
55041
- if (found === void 0) findings.push(error51(path40, `names "${name}", which is not a field of "${entity.alias}"`));
55042
- return found;
55043
- };
55044
- findings.push(...checkSelectsHold(entity, act.when, `${at2}.when`));
55045
- for (const [index, name] of (act.requires ?? []).entries()) own(name, `${at2}.requires.${index}`);
55046
- for (const [index, name] of (act.recommends ?? []).entries()) {
55047
- own(name, `${at2}.recommends.${index}`);
55048
- if ((act.requires ?? []).includes(name)) findings.push(error51(`${at2}.recommends.${index}`, `"${name}" is also in \`requires\` \u2014 the act runs without a field it recommends`));
55049
- }
55050
- const inputs = [];
55051
- for (const [index, ask] of (act.asks ?? []).entries()) {
55052
- if (isInput(ask)) {
55053
- findings.push(...checkInput(model, entity, ask, inputs, `${at2}.asks.${index}`));
55054
- inputs.push(ask);
55055
- continue;
55056
- }
55057
- const field2 = own(ask, `${at2}.asks.${index}`);
55058
- if (field2 !== void 0 && !isAsked(field2)) findings.push(error51(`${at2}.asks.${index}`, `asks "${ask}", a ${field2.type} field nobody writes`));
55059
- }
55060
- for (const [name, value] of Object.entries(act.set ?? {})) {
55061
- const field2 = own(name, `${at2}.set.${name}`);
55062
- if (field2 === void 0) continue;
55063
- const written = actSetValue(field2, value, inputs);
55064
- if ("refused" in written) findings.push(error51(`${at2}.set.${name}`, written.refused));
55065
- else if (written.kind === "clear" && stages.has(name)) {
55066
- findings.push(error51(`${at2}.set.${name}`, `clears the milestone "${name}" \u2014 a milestone is cleared by unticking it, which clears every later one`));
55067
- }
55068
- }
55069
- if (act.template !== void 0) findings.push(...checkTemplate(model, act.template, `${at2}.template`));
55070
- if (act.into !== void 0) {
55071
- const into = own(act.into, `${at2}.into`);
55072
- if (into !== void 0 && into.type !== "files") findings.push(error51(`${at2}.into`, `"${act.into}" is a ${into.type} field \u2014 a made document is kept in a files field`));
55073
- if (act.template === void 0) findings.push(error51(`${at2}.into`, `keeps a document no template makes \u2014 name the \`template\``));
55074
- if (act.on === "rows" || act.on === "view") findings.push(error51(`${at2}.into`, `keeps the one document made of many rows in each of them \u2014 over several rows the file is handed back, not kept`));
55075
- }
55076
- return findings;
55077
- }
55078
- function checkHistoryCopies(model, entity, act, at2) {
55079
- const shape = historyShape(model, entity.alias);
55080
- const status = model.records?.[entity.alias]?.status?.field;
55081
- if (shape === void 0 || "refused" in shape || status === void 0 || act.set?.[status] === void 0) return [];
55082
- const history = entityOf(model, shape.entity);
55083
- if (history === void 0) return [];
55084
- const taken = /* @__PURE__ */ new Set([shape.link, shape.status.field, shape.at.field, ...shape.by === void 0 ? [] : [shape.by]]);
55085
- const optionsHeld = (into, options) => into.type === "select" && into.multi !== true && options.every((one) => into.options.some((option) => option.alias === one.alias));
55086
- return (act.asks ?? []).flatMap((ask, index) => {
55087
- const name = isInput(ask) ? ask.input : ask;
55088
- const into = fieldOf(history, name);
55089
- if (into === void 0 || taken.has(name)) return [];
55090
- const holds = (() => {
55091
- if (isInput(ask)) {
55092
- switch (ask.type) {
55093
- case "text":
55094
- case "long_text":
55095
- return into.type === "text";
55096
- case "select":
55097
- return optionsHeld(into, ask.options ?? []);
55098
- case "link":
55099
- return into.type === "select_record_link" && into.target_entity === ask.entity && into.cardinality === "one";
55100
- case "member":
55101
- return into.type === "select_member";
55102
- default:
55103
- return into.type === ask.type;
55104
- }
55105
- }
55106
- const from = fieldOf(entity, ask);
55107
- if (from === void 0 || from.type !== into.type) return false;
55108
- if (from.type === "select") return from.multi !== true && optionsHeld(into, from.options);
55109
- if (from.type === "select_record_link") return into.type === "select_record_link" && into.target_entity === from.target_entity && from.cardinality === "one" && into.cardinality === "one";
55110
- return from.type !== "files";
55111
- })();
55112
- return holds ? [] : [error51(`${at2}.asks.${index}`, `asks "${name}", which the move copies onto "${history.alias}"'s "${name}", a ${into.type} field that cannot hold it \u2014 rename one, or make them one kind`)];
55113
- });
55114
- }
55115
- function checkInput(model, entity, ask, before, at2) {
55116
- const findings = [];
55117
- if (before.some((one) => one.input === ask.input) || fieldOf(entity, ask.input) !== void 0) {
55118
- findings.push(error51(`${at2}.input`, `"${ask.input}" names another ask or a field of "${entity.alias}" \u2014 an input's name is its own`));
55119
- }
55120
- if (ask.type === "select" !== (ask.options !== void 0)) {
55121
- findings.push(error51(at2, ask.type === "select" ? `"${ask.input}" is a select with no \`options\` \u2014 a choice names what it offers` : `"${ask.input}" is a ${ask.type} and states \`options\`, which only a select offers`));
55122
- }
55123
- if (ask.type === "link" !== (ask.entity !== void 0)) {
55124
- findings.push(error51(at2, ask.type === "link" ? `"${ask.input}" is a link with no \`entity\` \u2014 a link names the entity it picks a row of` : `"${ask.input}" is a ${ask.type} and states \`entity\`, which only a link picks from`));
55125
- }
55126
- if (ask.entity !== void 0 && entityOf(model, ask.entity) === void 0) {
55127
- findings.push(error51(`${at2}.entity`, `names entity "${ask.entity}", which this model does not declare`));
55128
- }
55129
- if (ask.default !== void 0) findings.push(...checkInputDefault(model, entity, ask, `${at2}.default`));
55130
- if (ask.input === RECORD_INPUT || ask.input === RECORDS_INPUT) {
55131
- findings.push(error51(`${at2}.input`, `"${ask.input}" is the input the act's workflow takes its record in \u2014 name the input for what the reader states`));
55132
- }
55133
- return findings;
55134
- }
55135
- function checkInputDefault(model, entity, ask, at2) {
55136
- const name = ask.default;
55137
- if (name === void 0) return [];
55138
- const field2 = fieldOf(entity, name);
55139
- if (field2 === void 0) return [error51(at2, `names "${name}", which is not a field of "${entity.alias}"`)];
55140
- const type = resolvedFieldType(field2, entity, model.entities) ?? field2.type;
55141
- const holds = (() => {
55142
- switch (ask.type) {
55143
- case "text":
55144
- case "long_text":
55145
- return type === "text";
55146
- case "number":
55147
- case "date":
55148
- return type === ask.type;
55149
- case "select":
55150
- return field2.type === "select" && field2.multi !== true && field2.options.every((option) => (ask.options ?? []).some((one) => one.alias === option.alias));
55151
- case "link":
55152
- return field2.type === "select_record_link" && field2.cardinality === "one" && field2.target_entity === ask.entity;
55153
- case "member":
55154
- return field2.type === "select_member" && field2.multi !== true;
55155
- }
55156
- })();
55157
- return holds ? [] : [error51(at2, `"${name}" is a ${type} field and "${ask.input}" asks a ${ask.type} \u2014 an input starts from a field holding what it states`)];
55158
- }
55159
- function offeredTogether2(one, other2) {
55160
- return Object.entries(one.when ?? {}).every(([field2, options]) => {
55161
- const theirs = other2.when?.[field2];
55162
- return theirs === void 0 || options.some((option) => theirs.includes(option));
55163
- });
55164
- }
55165
- function checkFootNeeds(model, entity, section, drawn, act, path40) {
55166
- const asked = new Set((act.asks ?? []).filter((ask) => typeof ask === "string"));
55167
- const writers = registerApps(model).flatMap((app) => (app.acts ?? []).filter((one) => actEntity(app, one) === entity.alias));
55168
- const writtenBy = (one) => [...Object.keys(one.set ?? {}), ...(one.asks ?? []).filter((ask) => typeof ask === "string"), ...one.into === void 0 ? [] : [one.into]];
55169
- const owned = new Set(writers.flatMap(writtenBy));
55170
- return (act.requires ?? []).flatMap((name, index) => {
55171
- const field2 = fieldOf(entity, name);
55172
- if (asked.has(name) || field2 === void 0 || !isAsked(field2) && !owned.has(name)) return [];
55173
- const at2 = `${path40}.requires.${index}`;
55174
- if (!drawn.has(name)) {
55175
- return [error51(at2, `"${act.alias}" needs "${name}", which "${section.title}" does not draw \u2014 its line would name a field the reader cannot see there; place it in the section, or have the act ask it`)];
55176
- }
55177
- if (!owned.has(name)) return [];
55178
- if (writers.some((one) => one.alias !== act.alias && writtenBy(one).includes(name) && offeredTogether2(one, act))) return [];
55179
- return [error51(at2, `"${act.alias}" needs "${name}", which no act offered together with it writes \u2014 it reads locked while "${act.alias}" waits; have "${act.alias}" ask it`)];
55180
- });
55181
- }
55182
-
55183
- // ../shared/src/schemas/app_model_resolve.ts
55121
+ // ../shared/src/schemas/app_model_plan.ts
55184
55122
  init_define_LOTICS_KIT_VERSIONS();
55185
55123
 
55186
- // ../shared/src/template_fields.ts
55124
+ // ../shared/src/schemas/app_model_resolved.ts
55187
55125
  init_define_LOTICS_KIT_VERSIONS();
55188
- var import_handlebars = __toESM(require_lib(), 1);
55189
- function rootName(node) {
55190
- if (!("type" in node) || node.type !== "PathExpression") return void 0;
55191
- if ("data" in node && node.data === true) return void 0;
55192
- if ("depth" in node && node.depth !== 0) return void 0;
55193
- const parts = "parts" in node && Array.isArray(node.parts) ? node.parts : [];
55194
- const [first2] = parts;
55195
- return typeof first2 === "string" ? first2 : void 0;
55196
- }
55197
- function visit(node, into) {
55198
- if (Array.isArray(node)) {
55199
- for (const child of node) visit(child, into);
55200
- return;
55201
- }
55202
- if (typeof node !== "object" || node === null || !("type" in node)) return;
55203
- const own = rootName(node);
55204
- if (own !== void 0) {
55205
- into.add(own);
55206
- return;
55207
- }
55208
- const params = "params" in node ? node.params : [];
55209
- const hash2 = "hash" in node ? node.hash : void 0;
55210
- if (node.type === "MustacheStatement" || node.type === "SubExpression") {
55211
- const called = Array.isArray(params) && params.length > 0;
55212
- if (!called && "path" in node) visit(node.path, into);
55213
- visit(params, into);
55214
- visit(hash2, into);
55215
- return;
55216
- }
55217
- if (node.type === "BlockStatement") {
55218
- const helper = "path" in node && typeof node.path === "object" && node.path !== null ? rootName(node.path) : void 0;
55219
- visit(params, into);
55220
- visit(hash2, into);
55221
- if (helper !== "each" && helper !== "with") {
55222
- if ("program" in node) visit(node.program, into);
55223
- if ("inverse" in node) visit(node.inverse, into);
55224
- }
55225
- return;
55226
- }
55227
- for (const value of Object.values(node)) visit(value, into);
55126
+ function resolvedBlocks(record2) {
55127
+ return record2.sections.flatMap((section) => section.blocks);
55228
55128
  }
55229
- function templateFields(content) {
55230
- const into = /* @__PURE__ */ new Set();
55231
- visit(import_handlebars.default.parse(content), into);
55232
- return [...into];
55129
+ function isResolvedDashboard(app) {
55130
+ return "readings" in app;
55233
55131
  }
55234
55132
 
55235
55133
  // ../shared/src/schemas/app_model_resolve_readings.ts
@@ -55330,18 +55228,6 @@ function readingFields(reading) {
55330
55228
  }
55331
55229
  }
55332
55230
 
55333
- // ../shared/src/schemas/app_model_plan.ts
55334
- init_define_LOTICS_KIT_VERSIONS();
55335
-
55336
- // ../shared/src/schemas/app_model_resolved.ts
55337
- init_define_LOTICS_KIT_VERSIONS();
55338
- function resolvedBlocks(record2) {
55339
- return record2.sections.flatMap((section) => section.blocks);
55340
- }
55341
- function isResolvedDashboard(app) {
55342
- return "readings" in app;
55343
- }
55344
-
55345
55231
  // ../shared/src/schemas/app_model_plan.ts
55346
55232
  function recordHeaderFields(display) {
55347
55233
  if (display === void 0) return [];
@@ -55359,15 +55245,25 @@ function historyOf(model, parent) {
55359
55245
  return shape === void 0 || "refused" in shape ? void 0 : shape;
55360
55246
  }
55361
55247
  function defaultCreate(model, entity, excluded) {
55248
+ return createAsking(model, entity, excluded, []);
55249
+ }
55250
+ function defaultBlockCreate(model, child, excluded, columns) {
55251
+ const image = model.records?.[child.alias]?.image;
55252
+ const later = new Set((milestonesOf(recordDisplay(model, child.alias)) ?? []).slice(1).map((stage) => stage.field));
55253
+ return createAsking(model, child, excluded, [...image === void 0 ? [] : [image], ...columns].filter((name) => !later.has(name)));
55254
+ }
55255
+ function createAsking(model, entity, excluded, read2) {
55362
55256
  const title = model.records?.[entity.alias]?.title;
55363
55257
  const rules = model.write_rules?.[entity.alias];
55364
55258
  const copied = new Set(Object.entries(rules?.fields ?? {}).flatMap(([field2, rule]) => rule.default_from === void 0 ? [] : [field2]));
55365
55259
  const stamped = actSetFields(model, entity.alias);
55366
55260
  const asked = (field2) => field2 !== void 0 && isAsked(field2) && !excluded.includes(field2.alias) && !copied.has(field2.alias) && !stamped.has(field2.alias);
55367
55261
  const titleField = title === void 0 ? void 0 : fieldOf(entity, title);
55262
+ const shown = read2.map((name) => fieldOf(entity, name)).filter(asked);
55368
55263
  const keyed = (rules?.natural_key ?? []).map((key) => fieldOf(entity, key)).filter(asked);
55369
55264
  return unique([
55370
55265
  ...asked(titleField) ? [titleField.alias] : [],
55266
+ ...shown.map((field2) => field2.alias),
55371
55267
  ...keyed.map((field2) => field2.alias),
55372
55268
  ...requiredOnCreate(model, entity, [...excluded, ...stamped])
55373
55269
  ]);
@@ -55380,9 +55276,9 @@ function appWritePlan(model, app) {
55380
55276
  const entity = entityOf(model, app.entity);
55381
55277
  const display = model.records?.[app.entity];
55382
55278
  const owned = (alias2) => actOwnedFields(model, alias2);
55383
- const added = (alias2, create) => {
55279
+ const added = (alias2, create, sent = []) => {
55384
55280
  const stamped = actSetFields(model, alias2);
55385
- return create.filter((name) => !stamped.has(name));
55281
+ return create.filter((name) => sent.includes(name) || !stamped.has(name));
55386
55282
  };
55387
55283
  if (entity !== void 0 && app.writes === "record" && peopleWrite(entity)) {
55388
55284
  const placed2 = unique([
@@ -55401,13 +55297,24 @@ function appWritePlan(model, app) {
55401
55297
  remove: true
55402
55298
  });
55403
55299
  }
55300
+ const saved = (child, fixed) => child.fields.filter((field2) => isAsked(field2) && !fixed.includes(field2.alias) && !owned(child.alias).has(field2.alias)).map((field2) => field2.alias);
55404
55301
  for (const block2 of resolvedBlocks(app.record)) {
55405
- if (block2.kind !== "rows" && block2.kind !== "timeline") continue;
55302
+ if (block2.kind !== "rows" && block2.kind !== "agenda" && block2.kind !== "timeline") continue;
55303
+ const under = block2.kind === "rows" ? block2.under : void 0;
55304
+ const document2 = under === void 0 ? void 0 : entityOf(model, under.entity);
55305
+ if (under !== void 0 && document2 !== void 0 && peopleWrite(document2) && !plan.has(document2.alias)) {
55306
+ const fixed = [under.via, under.back, ...under.where.map((one) => one.field)];
55307
+ plan.set(document2.alias, { ...under.create === void 0 ? {} : { create: added(document2.alias, under.create, fixed), parent: under.via }, stamps: [], update: saved(document2, fixed), remove: true });
55308
+ }
55406
55309
  const child = entityOf(model, block2.entity);
55407
55310
  if (child === void 0 || !peopleWrite(child) || plan.has(child.alias)) continue;
55408
- if (block2.kind === "rows") {
55409
- const update = child.fields.filter((field2) => isAsked(field2) && field2.alias !== block2.via && !owned(child.alias).has(field2.alias)).map((field2) => field2.alias);
55410
- plan.set(child.alias, { ...block2.create === void 0 ? {} : { create: added(child.alias, block2.create), parent: block2.via }, stamps: [], update, remove: true });
55311
+ if (block2.kind === "rows" || block2.kind === "agenda") {
55312
+ plan.set(child.alias, {
55313
+ ...block2.create === void 0 ? {} : { create: added(child.alias, block2.create), parent: block2.via },
55314
+ stamps: [],
55315
+ update: saved(child, [block2.via, ...under === void 0 ? [] : [under.field]]),
55316
+ remove: true
55317
+ });
55411
55318
  continue;
55412
55319
  }
55413
55320
  if (block2.create === void 0) continue;
@@ -55460,6 +55367,7 @@ function appDrawnFields(model, app) {
55460
55367
  ...app.register.filters,
55461
55368
  ...app.register.search ?? [],
55462
55369
  ...app.register.sort === void 0 ? [] : [app.register.sort.field],
55370
+ ...app.register.group === void 0 ? [] : [app.register.group],
55463
55371
  ...app.register.create ?? [],
55464
55372
  ...app.register.readings.flatMap(readingFields),
55465
55373
  ...app.record.sections.flatMap((section) => [...section.fields, ...section.when.map((gate) => gate.field)]),
@@ -55482,11 +55390,24 @@ function appDrawnFields(model, app) {
55482
55390
  const written = child === void 0 ? [] : child.fields.filter((field2) => isAsked(field2) && field2.alias !== block2.via).map((field2) => field2.alias);
55483
55391
  add(block2.entity, [...block2.columns, ...block2.create ?? [], ...block2.where.map((one) => one.field), ...written]);
55484
55392
  add(app.entity, [...blockTotals(model, app.entity, block2.entity, block2.via, block2.columns, block2.where).values(), ...block2.of === void 0 ? [] : [block2.of]]);
55393
+ const under = block2.under;
55394
+ const document2 = under === void 0 ? void 0 : entityOf(model, under.entity);
55395
+ if (under !== void 0 && document2 !== void 0) {
55396
+ add(block2.entity, [under.field]);
55397
+ const writes = document2.fields.filter((field2) => isAsked(field2) && field2.alias !== under.via).map((field2) => field2.alias);
55398
+ add(document2.alias, [under.via, under.back, ...under.where.map((one) => one.field), ...under.create ?? [], ...writes]);
55399
+ }
55400
+ }
55401
+ if (block2.kind === "agenda") {
55402
+ const child = entityOf(model, block2.entity);
55403
+ const written = child === void 0 ? [] : child.fields.filter((field2) => isAsked(field2) && field2.alias !== block2.via).map((field2) => field2.alias);
55404
+ add(block2.entity, [...block2.columns, ...block2.create ?? [], block2.day, ...block2.time === void 0 ? [] : [block2.time], ...block2.part === void 0 ? [] : [block2.part], ...written]);
55405
+ if (block2.start !== void 0) add(app.entity, [block2.start]);
55485
55406
  }
55486
55407
  if (block2.kind === "timeline") add(block2.entity, [block2.at, ...block2.by === void 0 ? [] : [block2.by], ...block2.create ?? []]);
55487
55408
  if (block2.kind === "reading") add(block2.reading.entity, [block2.via, ...readingFields(block2.reading)]);
55488
55409
  }
55489
- const moves = app.record.history === void 0 ? void 0 : historyOf(model, app.entity);
55410
+ const moves = app.record.moves === void 0 ? void 0 : historyOf(model, app.entity);
55490
55411
  if (moves !== void 0) add(moves.entity, [moves.at.field, ...moves.by === void 0 ? [] : [moves.by], moves.status.field]);
55491
55412
  const plan = appWritePlan(model, app);
55492
55413
  const moved = [app.entity, ...app.acts.flatMap((act) => act.of === void 0 ? [] : [act.of]), ...[...plan].flatMap(([alias2, writes]) => writes.create !== void 0 || writes.update.length > 0 ? [alias2] : [])];
@@ -55533,8 +55454,322 @@ function followLinks(model, drawn) {
55533
55454
  return drawn;
55534
55455
  }
55535
55456
 
55457
+ // ../shared/src/schemas/app_model_validate_visuals.ts
55458
+ function checkFirstClass(model, app, resolved, at2) {
55459
+ return [...checkFilesShown(model, app, at2), ...checkDocumentsPictured(model, resolved, at2), ...noteRegisterReadings(model, app, at2)];
55460
+ }
55461
+ function checkFilesShown(model, app, at2) {
55462
+ const sections = app.record?.sections;
55463
+ const entity = entityOf(model, app.entity);
55464
+ if (sections === void 0 || entity === void 0) return [];
55465
+ const display = recordDisplay(model, app.entity);
55466
+ const shown = /* @__PURE__ */ new Set([
55467
+ ...app.register?.columns ?? [],
55468
+ ...display?.image === void 0 ? [] : [display.image],
55469
+ ...display?.subtitle ?? [],
55470
+ ...sections.flatMap((section) => [...section.fields ?? [], ...(section.blocks ?? []).flatMap((block2) => "files" in block2 ? block2.files : [])])
55471
+ ]);
55472
+ return entity.fields.filter((field2) => field2.type === "files" && !shown.has(field2.alias)).map(
55473
+ (field2) => error51(`${at2}.record`, `"${app.entity}" holds "${field2.alias}" files no screen shows \u2014 place it in a section's \`fields\` or a \`files\` block, or make it the \`image\``)
55474
+ );
55475
+ }
55476
+ function checkDocumentsPictured(model, resolved, at2) {
55477
+ const targets = /* @__PURE__ */ new Set();
55478
+ for (const [alias2, names] of appDrawnFields(model, resolved)) {
55479
+ const entity = entityOf(model, alias2);
55480
+ if (entity === void 0) continue;
55481
+ for (const name of names) {
55482
+ const field2 = fieldOf(entity, name);
55483
+ if (field2?.type === "select_record_link") targets.add(field2.target_entity);
55484
+ }
55485
+ }
55486
+ return [...targets].flatMap((target) => {
55487
+ const entity = entityOf(model, target);
55488
+ const [files, ...more] = entity?.fields.filter((field2) => field2.type === "files") ?? [];
55489
+ if (files === void 0 || more.length > 0 || recordDisplay(model, target)?.image !== void 0) return [];
55490
+ return [error51(at2, `links to "${target}" rows, documents holding "${files.alias}" files, and "${target}" has no \`image\` \u2014 a link to one previews nothing; declare \`records.${target}.image: "${files.alias}"\``)];
55491
+ });
55492
+ }
55493
+ function noteRegisterReadings(model, app, at2) {
55494
+ if (app.register?.readings !== void 0) return [];
55495
+ const entity = entityOf(model, app.entity);
55496
+ const display = recordDisplay(model, app.entity);
55497
+ if (entity === void 0) return [];
55498
+ const broken = display?.figure ?? display?.status?.field ?? entity.fields.find((field2) => isSingleSelect(field2))?.alias;
55499
+ if (broken === void 0) return [];
55500
+ return [note(`${at2}.register`, `states no \`readings\` over "${app.entity}", which has "${broken}" to read \u2014 a register reads faster with its readings`)];
55501
+ }
55502
+
55503
+ // ../shared/src/schemas/app_model_validate_acts.ts
55504
+ init_define_LOTICS_KIT_VERSIONS();
55505
+
55506
+ // ../shared/src/app_workflow_inputs.ts
55507
+ init_define_LOTICS_KIT_VERSIONS();
55508
+ var RECORD_INPUT = "record_id";
55509
+ var RECORDS_INPUT = "record_ids";
55510
+ var RECORDS_INPUT_MAX = 200;
55511
+ var ADDED_SUFFIX = "_added";
55512
+ var REMOVED_SUFFIX = "_removed";
55513
+
55514
+ // ../shared/src/schemas/app_model_validate_acts.ts
55515
+ function checkChildCheck(model, check2, children, acts, path40) {
55516
+ const child = check2.of === void 0 ? void 0 : entityOf(model, check2.of);
55517
+ if (child === void 0 || !children.has(child.alias)) {
55518
+ return [error51(`${path40}.of`, `names "${String(check2.of)}", which this record lists no rows of \u2014 a check of a child is of rows the record lists`)];
55519
+ }
55520
+ const field2 = fieldOf(child, check2.field);
55521
+ if (field2 === void 0) return [error51(`${path40}.field`, `names "${check2.field}", which is not a field of "${child.alias}"`)];
55522
+ const result = field2.type === "formula" ? resolvedFieldType(field2, child, model.entities) : void 0;
55523
+ const findings = [];
55524
+ if (result === void 0 || !["boolean", "text"].includes(result)) {
55525
+ findings.push(error51(`${path40}.field`, `"${check2.field}" is a ${field2.type} field \u2014 a check is a yes/no or text formula that stands while true or non-empty`));
55526
+ }
55527
+ findings.push(...checkRestatesMeter(model, child, check2, path40));
55528
+ if (check2.blocks !== void 0 && check2.blocks !== "all") {
55529
+ for (const [index, target] of check2.blocks.entries()) {
55530
+ if (target === "edit" || target === "delete" || acts.get(target) === child.alias) continue;
55531
+ findings.push(error51(`${path40}.blocks.${index}`, `names "${target}" \u2014 a check of a child row refuses its saves ("edit"), its Delete ("delete"), an act of "${child.alias}" or "all"`));
55532
+ }
55533
+ }
55534
+ return findings;
55535
+ }
55536
+ function checkRestatesMeter(model, entity, check2, path40) {
55537
+ const display = model.records?.[entity.alias];
55538
+ const figure = display?.figure;
55539
+ const bound = figure === void 0 ? void 0 : display?.limits?.[figure];
55540
+ const gate = figure === void 0 ? void 0 : display?.gates?.[figure];
55541
+ if (check2.blocks !== void 0 || figure === void 0 || bound === void 0 || gate === void 0) return [];
55542
+ const reads = formulaReads(entity, check2.field);
55543
+ const marks = [bound, gate].filter((mark) => typeof mark === "string");
55544
+ if (!reads.has(figure) || !marks.some((mark) => reads.has(mark))) return [];
55545
+ const pass = typeof gate === "string" ? `"${gate}"` : String(gate);
55546
+ return [
55547
+ error51(
55548
+ `${path40}.field`,
55549
+ `"${check2.field}" reads "${figure}" against its pass mark ${pass}, which the figure's meter already draws \u2014 named under its track, amber until reached; drop the check`
55550
+ )
55551
+ ];
55552
+ }
55553
+ function formulaReads(entity, alias2) {
55554
+ const reads = /* @__PURE__ */ new Set();
55555
+ const visit2 = (name) => {
55556
+ const field2 = fieldOf(entity, name);
55557
+ if (field2?.type !== "formula") return;
55558
+ for (const ref of contractFormulaFieldRefs(field2.formula.expression)) {
55559
+ if (reads.has(ref)) continue;
55560
+ reads.add(ref);
55561
+ visit2(ref);
55562
+ }
55563
+ };
55564
+ visit2(alias2);
55565
+ return reads;
55566
+ }
55567
+ function checkAct(model, entity, act, at2) {
55568
+ const findings = [];
55569
+ const stages = new Set((milestonesOf(recordDisplay(model, entity.alias)) ?? []).map((stage) => stage.field));
55570
+ if (act.on === "view" && (act.requires ?? []).length > 0) {
55571
+ findings.push(error51(`${at2}.requires`, "runs over every row in view, and a field one row lacks would refuse them all \u2014 state a `when` the view is narrowed to, or run it on each record"));
55572
+ }
55573
+ const own = (name, path40) => {
55574
+ const found = fieldOf(entity, name);
55575
+ if (found === void 0) findings.push(error51(path40, `names "${name}", which is not a field of "${entity.alias}"`));
55576
+ return found;
55577
+ };
55578
+ findings.push(...checkWhen(model.entities, entity, act.when, `${at2}.when`, "offer"));
55579
+ for (const [index, name] of (act.requires ?? []).entries()) own(name, `${at2}.requires.${index}`);
55580
+ for (const [index, name] of (act.recommends ?? []).entries()) {
55581
+ own(name, `${at2}.recommends.${index}`);
55582
+ if ((act.requires ?? []).includes(name)) findings.push(error51(`${at2}.recommends.${index}`, `"${name}" is also in \`requires\` \u2014 the act runs without a field it recommends`));
55583
+ }
55584
+ const inputs = [];
55585
+ for (const [index, ask] of (act.asks ?? []).entries()) {
55586
+ if (isInput(ask)) {
55587
+ findings.push(...checkInput(model, entity, ask, inputs, `${at2}.asks.${index}`));
55588
+ inputs.push(ask);
55589
+ continue;
55590
+ }
55591
+ const field2 = own(ask, `${at2}.asks.${index}`);
55592
+ if (field2 !== void 0 && !isAsked(field2)) findings.push(error51(`${at2}.asks.${index}`, `asks "${ask}", a ${field2.type} field nobody writes`));
55593
+ }
55594
+ for (const [name, value] of Object.entries(act.set ?? {})) {
55595
+ const field2 = own(name, `${at2}.set.${name}`);
55596
+ if (field2 === void 0) continue;
55597
+ const written = actSetValue(field2, value, inputs);
55598
+ if ("refused" in written) findings.push(error51(`${at2}.set.${name}`, written.refused));
55599
+ else if (written.kind === "clear" && stages.has(name)) {
55600
+ findings.push(error51(`${at2}.set.${name}`, `clears the milestone "${name}" \u2014 a milestone is cleared by unticking it, which clears every later one`));
55601
+ }
55602
+ }
55603
+ if (act.template !== void 0) findings.push(...checkTemplate(model, act.template, `${at2}.template`));
55604
+ if (act.into !== void 0) {
55605
+ const into = own(act.into, `${at2}.into`);
55606
+ if (into !== void 0 && into.type !== "files") findings.push(error51(`${at2}.into`, `"${act.into}" is a ${into.type} field \u2014 a made document is kept in a files field`));
55607
+ if (act.template === void 0) findings.push(error51(`${at2}.into`, `keeps a document no template makes \u2014 name the \`template\``));
55608
+ if (act.on === "rows" || act.on === "view") findings.push(error51(`${at2}.into`, `keeps the one document made of many rows in each of them \u2014 over several rows the file is handed back, not kept`));
55609
+ }
55610
+ return findings;
55611
+ }
55612
+ function checkHistoryCopies(model, entity, act, at2) {
55613
+ const shape = historyShape(model, entity.alias);
55614
+ const status = model.records?.[entity.alias]?.status?.field;
55615
+ if (shape === void 0 || "refused" in shape || status === void 0 || act.set?.[status] === void 0) return [];
55616
+ const history = entityOf(model, shape.entity);
55617
+ if (history === void 0) return [];
55618
+ const taken = /* @__PURE__ */ new Set([shape.link, shape.status.field, shape.at.field, ...shape.by === void 0 ? [] : [shape.by]]);
55619
+ const optionsHeld2 = (into, options) => into.type === "select" && into.multi !== true && options.every((one) => into.options.some((option) => option.alias === one.alias));
55620
+ return (act.asks ?? []).flatMap((ask, index) => {
55621
+ const name = isInput(ask) ? ask.input : ask;
55622
+ const into = fieldOf(history, name);
55623
+ if (into === void 0 || taken.has(name)) return [];
55624
+ const holds = (() => {
55625
+ if (isInput(ask)) {
55626
+ switch (ask.type) {
55627
+ case "text":
55628
+ case "long_text":
55629
+ return into.type === "text";
55630
+ case "select":
55631
+ return optionsHeld2(into, ask.options ?? []);
55632
+ case "link":
55633
+ return into.type === "select_record_link" && into.target_entity === ask.entity && into.cardinality === "one";
55634
+ case "member":
55635
+ return into.type === "select_member";
55636
+ default:
55637
+ return into.type === ask.type;
55638
+ }
55639
+ }
55640
+ const from = fieldOf(entity, ask);
55641
+ if (from === void 0 || from.type !== into.type) return false;
55642
+ if (from.type === "select") return from.multi !== true && optionsHeld2(into, from.options);
55643
+ if (from.type === "select_record_link") return into.type === "select_record_link" && into.target_entity === from.target_entity && from.cardinality === "one" && into.cardinality === "one";
55644
+ return from.type !== "files";
55645
+ })();
55646
+ return holds ? [] : [error51(`${at2}.asks.${index}`, `asks "${name}", which the move copies onto "${history.alias}"'s "${name}", a ${into.type} field that cannot hold it \u2014 rename one, or make them one kind`)];
55647
+ });
55648
+ }
55649
+ function checkInput(model, entity, ask, before, at2) {
55650
+ const findings = [];
55651
+ if (before.some((one) => one.input === ask.input) || fieldOf(entity, ask.input) !== void 0) {
55652
+ findings.push(error51(`${at2}.input`, `"${ask.input}" names another ask or a field of "${entity.alias}" \u2014 an input's name is its own`));
55653
+ }
55654
+ if (ask.type === "select" !== (ask.options !== void 0)) {
55655
+ findings.push(error51(at2, ask.type === "select" ? `"${ask.input}" is a select with no \`options\` \u2014 a choice names what it offers` : `"${ask.input}" is a ${ask.type} and states \`options\`, which only a select offers`));
55656
+ }
55657
+ if (ask.type === "link" !== (ask.entity !== void 0)) {
55658
+ findings.push(error51(at2, ask.type === "link" ? `"${ask.input}" is a link with no \`entity\` \u2014 a link names the entity it picks a row of` : `"${ask.input}" is a ${ask.type} and states \`entity\`, which only a link picks from`));
55659
+ }
55660
+ if (ask.entity !== void 0 && entityOf(model, ask.entity) === void 0) {
55661
+ findings.push(error51(`${at2}.entity`, `names entity "${ask.entity}", which this model does not declare`));
55662
+ }
55663
+ if (ask.default !== void 0) findings.push(...checkInputDefault(model, entity, ask, `${at2}.default`));
55664
+ if (ask.input === RECORD_INPUT || ask.input === RECORDS_INPUT) {
55665
+ findings.push(error51(`${at2}.input`, `"${ask.input}" is the input the act's workflow takes its record in \u2014 name the input for what the reader states`));
55666
+ }
55667
+ return findings;
55668
+ }
55669
+ function checkInputDefault(model, entity, ask, at2) {
55670
+ const name = ask.default;
55671
+ if (name === void 0) return [];
55672
+ const field2 = fieldOf(entity, name);
55673
+ if (field2 === void 0) return [error51(at2, `names "${name}", which is not a field of "${entity.alias}"`)];
55674
+ const type = resolvedFieldType(field2, entity, model.entities) ?? field2.type;
55675
+ const holds = (() => {
55676
+ switch (ask.type) {
55677
+ case "text":
55678
+ case "long_text":
55679
+ return type === "text";
55680
+ case "number":
55681
+ case "date":
55682
+ return type === ask.type;
55683
+ case "select":
55684
+ return field2.type === "select" && field2.multi !== true && field2.options.every((option) => (ask.options ?? []).some((one) => one.alias === option.alias));
55685
+ case "link":
55686
+ return field2.type === "select_record_link" && field2.cardinality === "one" && field2.target_entity === ask.entity;
55687
+ case "member":
55688
+ return field2.type === "select_member" && field2.multi !== true;
55689
+ }
55690
+ })();
55691
+ return holds ? [] : [error51(at2, `"${name}" is a ${type} field and "${ask.input}" asks a ${ask.type} \u2014 an input starts from a field holding what it states`)];
55692
+ }
55693
+ function offeredTogether2(one, other2) {
55694
+ return Object.entries(one.when ?? {}).every(([field2, held2]) => {
55695
+ const theirs = other2.when?.[field2];
55696
+ if (theirs === void 0) return true;
55697
+ if (typeof held2 === "boolean" || typeof theirs === "boolean") return held2 === theirs;
55698
+ return held2.some((option) => theirs.includes(option));
55699
+ });
55700
+ }
55701
+ function checkFootNeeds(model, entity, section, drawn, act, path40) {
55702
+ const asked = new Set((act.asks ?? []).filter((ask) => typeof ask === "string"));
55703
+ const writers = registerApps(model).flatMap((app) => (app.acts ?? []).filter((one) => actEntity(app, one) === entity.alias));
55704
+ const writtenBy = (one) => [...Object.keys(one.set ?? {}), ...(one.asks ?? []).filter((ask) => typeof ask === "string"), ...one.into === void 0 ? [] : [one.into]];
55705
+ const owned = new Set(writers.flatMap(writtenBy));
55706
+ return (act.requires ?? []).flatMap((name, index) => {
55707
+ const field2 = fieldOf(entity, name);
55708
+ if (asked.has(name) || field2 === void 0 || !isAsked(field2) && !owned.has(name)) return [];
55709
+ const at2 = `${path40}.requires.${index}`;
55710
+ if (!drawn.has(name)) {
55711
+ return [error51(at2, `"${act.alias}" needs "${name}", which "${section.title}" does not draw \u2014 its line would name a field the reader cannot see there; place it in the section, or have the act ask it`)];
55712
+ }
55713
+ if (!owned.has(name)) return [];
55714
+ if (writers.some((one) => one.alias !== act.alias && writtenBy(one).includes(name) && offeredTogether2(one, act))) return [];
55715
+ return [error51(at2, `"${act.alias}" needs "${name}", which no act offered together with it writes \u2014 it reads locked while "${act.alias}" waits; have "${act.alias}" ask it`)];
55716
+ });
55717
+ }
55718
+
55719
+ // ../shared/src/schemas/app_model_resolve.ts
55720
+ init_define_LOTICS_KIT_VERSIONS();
55721
+
55722
+ // ../shared/src/template_fields.ts
55723
+ init_define_LOTICS_KIT_VERSIONS();
55724
+ var import_handlebars = __toESM(require_lib(), 1);
55725
+ function rootName(node) {
55726
+ if (!("type" in node) || node.type !== "PathExpression") return void 0;
55727
+ if ("data" in node && node.data === true) return void 0;
55728
+ if ("depth" in node && node.depth !== 0) return void 0;
55729
+ const parts = "parts" in node && Array.isArray(node.parts) ? node.parts : [];
55730
+ const [first2] = parts;
55731
+ return typeof first2 === "string" ? first2 : void 0;
55732
+ }
55733
+ function visit(node, into) {
55734
+ if (Array.isArray(node)) {
55735
+ for (const child of node) visit(child, into);
55736
+ return;
55737
+ }
55738
+ if (typeof node !== "object" || node === null || !("type" in node)) return;
55739
+ const own = rootName(node);
55740
+ if (own !== void 0) {
55741
+ into.add(own);
55742
+ return;
55743
+ }
55744
+ const params = "params" in node ? node.params : [];
55745
+ const hash2 = "hash" in node ? node.hash : void 0;
55746
+ if (node.type === "MustacheStatement" || node.type === "SubExpression") {
55747
+ const called = Array.isArray(params) && params.length > 0;
55748
+ if (!called && "path" in node) visit(node.path, into);
55749
+ visit(params, into);
55750
+ visit(hash2, into);
55751
+ return;
55752
+ }
55753
+ if (node.type === "BlockStatement") {
55754
+ const helper = "path" in node && typeof node.path === "object" && node.path !== null ? rootName(node.path) : void 0;
55755
+ visit(params, into);
55756
+ visit(hash2, into);
55757
+ if (helper !== "each" && helper !== "with") {
55758
+ if ("program" in node) visit(node.program, into);
55759
+ if ("inverse" in node) visit(node.inverse, into);
55760
+ }
55761
+ return;
55762
+ }
55763
+ for (const value of Object.values(node)) visit(value, into);
55764
+ }
55765
+ function templateFields(content) {
55766
+ const into = /* @__PURE__ */ new Set();
55767
+ visit(import_handlebars.default.parse(content), into);
55768
+ return [...into];
55769
+ }
55770
+
55536
55771
  // ../shared/src/schemas/app_model_resolve.ts
55537
- var whenOf = (stated2) => Object.entries(stated2 ?? {}).map(([field2, options]) => ({ field: field2, options }));
55772
+ var whereOf2 = (stated2) => Object.entries(stated2 ?? {}).map(([field2, options]) => ({ field: field2, options }));
55538
55773
  function resolveModelApp(model, app) {
55539
55774
  const entity = entityOf(model, app.entity);
55540
55775
  if (entity === void 0) throw new Error(`resolveModelApp: "${app.entity}" is not an entity of this model \u2014 validate it first`);
@@ -55556,17 +55791,17 @@ function resolveModelApp(model, app) {
55556
55791
  // A move into the section's stage from outside it is its way in, drawn first and alone while it waits.
55557
55792
  ...splitEntry(model, app, section.acts ?? [], at2),
55558
55793
  actsLast: false,
55559
- when: whenOf(section.when),
55794
+ when: whenConditions(section.when),
55560
55795
  ...section.page === true ? { page: pageSegment(section.title) } : {}
55561
55796
  };
55562
55797
  });
55563
55798
  if (statedSections === void 0) {
55564
55799
  defaulted.push("record.sections");
55565
- const history2 = historyOf(model, entity.alias);
55800
+ const history = historyOf(model, entity.alias);
55566
55801
  const header = /* @__PURE__ */ new Set([...recordHeaderFields(display), ...scope === void 0 ? [] : [scope.field]]);
55567
55802
  const fields = entity.fields.flatMap((field2) => {
55568
55803
  if (!isAsked(field2) || setBy.has(field2.alias) || header.has(field2.alias)) return [];
55569
- if (field2.type === "select_record_link" && (field2.target_entity === history2?.entity || childList(model, entity, field2))) return [];
55804
+ if (field2.type === "select_record_link" && (field2.target_entity === history?.entity || childList(model, entity, field2))) return [];
55570
55805
  return [field2.alias];
55571
55806
  });
55572
55807
  const acts2 = (app.acts ?? []).flatMap((act) => act.of === void 0 && (act.on ?? "record") === "record" ? [act.alias] : []);
@@ -55597,7 +55832,7 @@ function resolveModelApp(model, app) {
55597
55832
  const readings = figures.map((one) => one.reading);
55598
55833
  defaulted.push(...figures.flatMap((one) => one.defaulted));
55599
55834
  const stated2 = app.register?.export;
55600
- const history = recordHistory(model, entity.alias);
55835
+ const moves = recordHistory(model, entity.alias);
55601
55836
  const resolved = {
55602
55837
  alias: app.alias,
55603
55838
  name: app.name,
@@ -55613,12 +55848,13 @@ function resolveModelApp(model, app) {
55613
55848
  filters: app.register?.filters ?? [],
55614
55849
  ...app.register?.search === void 0 ? {} : { search: app.register.search },
55615
55850
  ...sort === void 0 ? {} : { sort },
55851
+ ...app.register?.group === void 0 ? {} : { group: app.register.group },
55616
55852
  layout,
55617
55853
  ...create === void 0 || create.length === 0 ? {} : { create },
55618
55854
  ...stated2 === void 0 ? {} : { export: stated2 === true ? true : { template: stated2.template } },
55619
55855
  readings
55620
55856
  },
55621
- record: { door, sections, comments: app.record?.comments === true, ...history === void 0 ? {} : { history } },
55857
+ record: { door, sections, comments: app.record?.comments === true, history: app.record?.history === true, ...moves === void 0 ? {} : { moves } },
55622
55858
  acts,
55623
55859
  checks
55624
55860
  };
@@ -55661,7 +55897,7 @@ function scopeOf(entity, field2) {
55661
55897
  }
55662
55898
  function resolveBlock(model, parent, block2, at2) {
55663
55899
  const defaulted = [];
55664
- const when = whenOf(block2.when);
55900
+ const when = whenConditions(block2.when);
55665
55901
  const titled = (stated2, fallback) => {
55666
55902
  if (stated2 !== void 0) return stated2;
55667
55903
  defaulted.push(`${at2}.title`);
@@ -55677,6 +55913,31 @@ function resolveBlock(model, parent, block2, at2) {
55677
55913
  if ("refused" in link) throw new Error(`resolveModelApp: block ${at2} ${link.refused}`);
55678
55914
  return { block: { kind: "reading", reading, via: link.link, when }, defaulted };
55679
55915
  }
55916
+ if (isAgendaBlock(block2)) {
55917
+ const child = entityOf(model, block2.agenda);
55918
+ if (child === void 0) throw new Error(`resolveModelApp: block ${at2} reads "${block2.agenda}", which is not an entity \u2014 validate it first`);
55919
+ const link = childLink(child, parent.alias, block2.via);
55920
+ if ("refused" in link) throw new Error(`resolveModelApp: block ${at2} ${link.refused}`);
55921
+ const axis = agendaAxis(model, child);
55922
+ if ("refused" in axis) throw new Error(`resolveModelApp: block ${at2} ${axis.refused}`);
55923
+ const stated2 = block2.create;
55924
+ const create = Array.isArray(stated2) ? stated2 : stated2 === false || !peopleWrite(child) ? [] : defaultBlockCreate(model, child, [link.link], block2.columns ?? []);
55925
+ if (stated2 === void 0 && create.length > 0) defaulted.push(`${at2}.create`);
55926
+ return {
55927
+ block: {
55928
+ kind: "agenda",
55929
+ entity: child.alias,
55930
+ via: link.link,
55931
+ title: titled(block2.title, child.label),
55932
+ columns: block2.columns ?? [],
55933
+ ...create.length === 0 ? {} : { create },
55934
+ ...axis,
55935
+ ...block2.start === void 0 ? {} : { start: block2.start },
55936
+ when
55937
+ },
55938
+ defaulted
55939
+ };
55940
+ }
55680
55941
  if ("rows" in block2 || "timeline" in block2) {
55681
55942
  const childAlias = "rows" in block2 ? block2.rows : block2.timeline;
55682
55943
  const child = entityOf(model, childAlias);
@@ -55686,11 +55947,12 @@ function resolveBlock(model, parent, block2, at2) {
55686
55947
  const title = titled(block2.title, child.label);
55687
55948
  const stated2 = block2.create;
55688
55949
  if ("rows" in block2) {
55689
- const where = whenOf(block2.where);
55690
- const stating = Array.isArray(stated2) ? stated2 : stated2 === false || !peopleWrite(child) ? [] : defaultCreate(model, child, [link.link]);
55950
+ const where = whereOf2(block2.where);
55951
+ const stating = Array.isArray(stated2) ? stated2 : stated2 === false || !peopleWrite(child) ? [] : defaultBlockCreate(model, child, [link.link], block2.columns ?? []);
55691
55952
  if (stated2 === void 0 && stating.length > 0) defaulted.push(`${at2}.create`);
55692
55953
  const held2 = where.map((one) => one.field);
55693
55954
  const create2 = stating.length === 0 ? [] : block2.expect === void 0 ? unique([...held2, ...stating]) : unique([block2.expect, ...held2, ...stating, ...expectedColumns(model, child, block2.columns ?? [])]);
55955
+ const under = block2.under === void 0 ? void 0 : resolveUnder(model, parent.alias, child, block2.under, where);
55694
55956
  return {
55695
55957
  block: {
55696
55958
  kind: "rows",
@@ -55702,6 +55964,7 @@ function resolveBlock(model, parent, block2, at2) {
55702
55964
  where,
55703
55965
  ...block2.expect === void 0 ? {} : { expect: block2.expect },
55704
55966
  ...block2.of === void 0 ? {} : { of: block2.of },
55967
+ ...under === void 0 ? {} : { under },
55705
55968
  when
55706
55969
  },
55707
55970
  defaulted
@@ -55739,7 +56002,7 @@ function blockHeld(model, entity, blocks) {
55739
56002
  const own = blocks.flatMap((block2) => block2.kind === "files" ? block2.fields : block2.kind === "text" ? [block2.field] : []);
55740
56003
  const links = new Set(
55741
56004
  blocks.flatMap(
55742
- (block2) => block2.kind === "rows" || block2.kind === "timeline" ? entity.fields.flatMap((field2) => field2.type === "select_record_link" && field2.target_entity === block2.entity && field2.paired_field_alias === block2.via ? [field2.alias] : []) : []
56005
+ (block2) => block2.kind === "rows" || block2.kind === "agenda" || block2.kind === "timeline" ? entity.fields.flatMap((field2) => field2.type === "select_record_link" && field2.target_entity === block2.entity && field2.paired_field_alias === block2.via ? [field2.alias] : []) : []
55743
56006
  )
55744
56007
  );
55745
56008
  const held2 = /* @__PURE__ */ new Set([...own, ...links]);
@@ -55802,7 +56065,7 @@ function resolveAct(model, app, act, checks) {
55802
56065
  label: act.label,
55803
56066
  on: act.on ?? "record",
55804
56067
  ...act.of === void 0 ? {} : { of: act.of },
55805
- when: whenOf(act.when),
56068
+ when: whenConditions(act.when),
55806
56069
  requires,
55807
56070
  recommends: act.recommends ?? [],
55808
56071
  asks,
@@ -55815,6 +56078,19 @@ function resolveAct(model, app, act, checks) {
55815
56078
  blockedBy: checksBlocking(checks, act.alias, act.of)
55816
56079
  };
55817
56080
  }
56081
+ function resolveUnder(model, parent, child, field2, where) {
56082
+ const shape = documentUnder(model, parent, child, field2);
56083
+ if ("refused" in shape) throw new Error(`resolveModelApp: the lines of "${child.alias}" ${shape.refused} \u2014 validate it first`);
56084
+ const document2 = entityOf(model, shape.entity);
56085
+ if (document2 === void 0) throw new Error(`resolveModelApp: "${shape.entity}" is not an entity \u2014 validate it first`);
56086
+ const held2 = where.filter((one) => {
56087
+ const own = fieldOf(document2, one.field);
56088
+ return own !== void 0 && isSingleSelect(own) && one.options.every((option) => own.options.some((declared) => declared.alias === option));
56089
+ });
56090
+ const sent = [shape.back, ...held2.map((one) => one.field)];
56091
+ const create = peopleWrite(document2) ? unique([...sent, ...defaultBlockCreate(model, document2, [shape.via, ...sent], [])]) : [];
56092
+ return { ...shape, where: held2, ...create.length === 0 ? {} : { create } };
56093
+ }
55818
56094
  function expectedColumns(model, child, columns) {
55819
56095
  const owned = actOwnedFields(model, child.alias);
55820
56096
  return columns.filter((name) => {
@@ -55826,6 +56102,7 @@ function expectedColumns(model, child, columns) {
55826
56102
  // ../shared/src/schemas/app_model_validate.ts
55827
56103
  var NAMING_TYPES = /* @__PURE__ */ new Set(["text", "autonumber", "select_record_link", "select_member", "select"]);
55828
56104
  var FILTER_TYPES = /* @__PURE__ */ new Set(["select", "select_member", "select_record_link", "date", "boolean"]);
56105
+ var GROUP_TYPES = /* @__PURE__ */ new Set([...FILTER_TYPES, "text"]);
55829
56106
  var SEARCH_TYPES = /* @__PURE__ */ new Set(["text", "autonumber", "select_record_link"]);
55830
56107
  function partsOf(display) {
55831
56108
  if (display === void 0) return /* @__PURE__ */ new Map();
@@ -55867,6 +56144,8 @@ function validateModelApps(model) {
55867
56144
  if (own.length > 0) continue;
55868
56145
  const resolved = resolveModelApp(model, app);
55869
56146
  findings.push(...checkCreates(model, resolved, at2));
56147
+ findings.push(...noteChildLists(model, app, at2));
56148
+ findings.push(...checkFirstClass(model, app, resolved, at2));
55870
56149
  findings.push(...checkReadsRows(model, app.reads, resolved.entities, at2));
55871
56150
  }
55872
56151
  return findings;
@@ -55886,8 +56165,8 @@ function checkCreates(model, resolved, at2) {
55886
56165
  const lists = [
55887
56166
  ...resolved.register.create === void 0 ? [] : [{ entity: resolved.entity, asked: resolved.register.create, fills: resolved.scope === void 0 ? [] : [resolved.scope.field], path: `${at2}.register.create` }],
55888
56167
  ...resolved.record.sections.flatMap(
55889
- (section, index) => section.blocks.flatMap(
55890
- (block2, order) => (block2.kind === "rows" || block2.kind === "timeline") && block2.create !== void 0 ? [
56168
+ (section, index) => section.blocks.flatMap((block2, order) => [
56169
+ ...(block2.kind === "rows" || block2.kind === "agenda" || block2.kind === "timeline") && block2.create !== void 0 ? [
55891
56170
  {
55892
56171
  entity: block2.entity,
55893
56172
  asked: block2.create,
@@ -55895,8 +56174,18 @@ function checkCreates(model, resolved, at2) {
55895
56174
  fills: [block2.via, ...block2.kind === "timeline" ? [block2.at, ...block2.by === void 0 ? [] : [block2.by]] : []],
55896
56175
  path: `${at2}.record.sections.${index}.blocks.${order}.create`
55897
56176
  }
56177
+ ] : [],
56178
+ // A document made from its lines is filed under the record by its own link, over the lines picked and
56179
+ // the options the block's lines hold: sent by the make, never asked.
56180
+ ...block2.kind === "rows" && block2.under?.create !== void 0 ? [
56181
+ {
56182
+ entity: block2.under.entity,
56183
+ asked: block2.under.create,
56184
+ fills: [block2.under.via, block2.under.back, ...block2.under.where.map((one) => one.field)],
56185
+ path: `${at2}.record.sections.${index}.blocks.${order}.under`
56186
+ }
55898
56187
  ] : []
55899
- )
56188
+ ])
55900
56189
  )
55901
56190
  ];
55902
56191
  return lists.flatMap(({ entity, asked, fills, path: path40 }) => {
@@ -55909,8 +56198,9 @@ function checkCreates(model, resolved, at2) {
55909
56198
  };
55910
56199
  return [
55911
56200
  ...checkCreateMilestones(model, entity, asked, path40),
55912
- // An act's outcome is the act's to write: an add stating it would decide what only the act decides.
55913
- ...asked.flatMap((name) => {
56201
+ // An act's outcome is the act's to write: an add stating it would decide what only the act decides. What the
56202
+ // add fills itself (the record, a document's lines) is no reader's statement.
56203
+ ...asked.filter((name) => !fills.includes(name)).flatMap((name) => {
55914
56204
  const act = setter(name);
55915
56205
  return act === void 0 ? [] : [error51(path40, `asks "${name}", which ${act} sets \u2014 the act writes it, so an add never states it; drop it here`)];
55916
56206
  }),
@@ -55982,6 +56272,7 @@ function checkApp(model, app, at2) {
55982
56272
  scoped(name, path40, "it lists");
55983
56273
  }
55984
56274
  if (app.register?.sort !== void 0) own(app.register.sort.field, `${at2}.register.sort.field`);
56275
+ if (app.register?.group !== void 0) findings.push(...checkGroup(model, entity, display, app, app.register.group, `${at2}.register.group`));
55985
56276
  const create = app.register?.create;
55986
56277
  if (Array.isArray(create)) {
55987
56278
  if (app.writes === "children") {
@@ -56026,10 +56317,13 @@ function checkApp(model, app, at2) {
56026
56317
  if (scope !== void 0 && !placed2.has(scope)) place(scope, "the header's folder", `${at2}.record`);
56027
56318
  const sections = app.record?.sections ?? [];
56028
56319
  findings.push(...checkPages(app.record?.door, sections, `${at2}.record`));
56320
+ if (app.record?.history === true && display.status?.history === void 0) {
56321
+ findings.push(error51(`${at2}.record.history`, `draws the status history of "${entity.alias}", which records none \u2014 name the history entity in \`records.${entity.alias}.status.history\`, or drop it`));
56322
+ }
56029
56323
  const inFacts = /* @__PURE__ */ new Map();
56030
56324
  for (const [index, section] of sections.entries()) {
56031
56325
  const path40 = `${at2}.record.sections.${index}`;
56032
- findings.push(...checkSelectsHold(entity, section.when, `${path40}.when`));
56326
+ findings.push(...checkWhen(model.entities, entity, section.when, `${path40}.when`, "offer"));
56033
56327
  if (section.fields === void 0 && section.blocks === void 0 && section.acts === void 0) {
56034
56328
  findings.push(error51(path40, `"${section.title}" holds no fields, blocks or acts \u2014 a section is where the reader fills, reads or does something; give it one, or drop it`));
56035
56329
  }
@@ -56069,9 +56363,10 @@ function checkApp(model, app, at2) {
56069
56363
  for (const [index, section] of sections.entries()) {
56070
56364
  for (const [order, block2] of (section.blocks ?? []).entries()) {
56071
56365
  const path40 = `${at2}.record.sections.${index}.blocks.${order}`;
56072
- findings.push(...checkSelectsHold(entity, block2.when, `${path40}.when`));
56073
- if ("timeline" in block2 && block2.timeline === display.status?.history) {
56074
- findings.push(error51(path40, `reads "${block2.timeline}", the record's status history, which its companion reads beside the comments \u2014 never a section; drop the block`));
56366
+ findings.push(...checkWhen(model.entities, entity, block2.when, `${path40}.when`, "offer"));
56367
+ const listed = "timeline" in block2 ? block2.timeline : isRowsBlock(block2) ? block2.rows : isAgendaBlock(block2) ? block2.agenda : void 0;
56368
+ if (listed !== void 0 && listed === display.status?.history) {
56369
+ findings.push(error51(path40, `reads "${listed}", the record's status history \u2014 never a section: \`record.history\` draws it beside the record; drop the block`));
56075
56370
  }
56076
56371
  const identity = blockIdentity(block2);
56077
56372
  const first2 = identity === void 0 ? void 0 : blocksPlaced.get(identity);
@@ -56089,14 +56384,15 @@ function checkApp(model, app, at2) {
56089
56384
  }
56090
56385
  }
56091
56386
  }
56092
- const children = new Set(recordBlocks(app.record).flatMap((block2) => isRowsBlock(block2) ? [block2.rows] : []));
56387
+ findings.push(...checkDocumentsListed(model, entity, sections, at2));
56388
+ const children = new Set(recordChildren(model, entity.alias, recordBlocks(app.record)));
56093
56389
  const acts = /* @__PURE__ */ new Map();
56094
56390
  for (const [index, act] of (app.acts ?? []).entries()) {
56095
56391
  const path40 = `${at2}.acts.${index}`;
56096
56392
  if (acts.has(act.alias)) findings.push(error51(`${path40}.alias`, `"${act.alias}" names two acts of this app \u2014 an act alias is unique`));
56097
56393
  acts.set(act.alias, act.of);
56098
56394
  if (act.of !== void 0 && !children.has(act.of)) {
56099
- findings.push(error51(`${path40}.of`, `names "${act.of}", which no rows block of this record holds \u2014 an act of a child works on rows the record lists`));
56395
+ findings.push(error51(`${path40}.of`, `names "${act.of}", which this record lists no rows of \u2014 an act of a child works on rows the record lists`));
56100
56396
  continue;
56101
56397
  }
56102
56398
  if (act.of !== void 0 && act.on !== void 0 && act.on !== "record") {
@@ -56123,7 +56419,7 @@ function checkApp(model, app, at2) {
56123
56419
  const drawnBy = (section) => [
56124
56420
  ...section.fields ?? [],
56125
56421
  ...(section.blocks ?? []).flatMap(
56126
- (block2) => "files" in block2 ? block2.files : "text" in block2 ? [block2.text] : isRowsBlock(block2) ? entity.fields.flatMap((field2) => field2.type === "select_record_link" && field2.target_entity === block2.rows ? [field2.alias] : []) : []
56422
+ (block2) => "files" in block2 ? block2.files : "text" in block2 ? [block2.text] : isRowsBlock(block2) || isAgendaBlock(block2) ? entity.fields.flatMap((field2) => field2.type === "select_record_link" && field2.target_entity === (isRowsBlock(block2) ? block2.rows : block2.agenda) ? [field2.alias] : []) : []
56127
56423
  )
56128
56424
  ];
56129
56425
  const unstaged = sections.filter((section) => section.at === void 0).flatMap(drawnBy);
@@ -56173,6 +56469,7 @@ function checkApp(model, app, at2) {
56173
56469
  if (drawn !== void 0) {
56174
56470
  findings.push(error51(`${path40}.field`, `"${check2.field}" is a check, whose line under the header says it, and it is also in ${drawn} \u2014 drop it there`));
56175
56471
  }
56472
+ findings.push(...checkRestatesMeter(model, entity, check2, path40));
56176
56473
  if (check2.blocks === void 0 || check2.blocks === "all") continue;
56177
56474
  for (const [at22, target] of check2.blocks.entries()) {
56178
56475
  if (target === "edit" || target === "delete") continue;
@@ -56182,12 +56479,73 @@ function checkApp(model, app, at2) {
56182
56479
  }
56183
56480
  return findings;
56184
56481
  }
56482
+ function noteChildLists(model, app, at2) {
56483
+ const placed2 = (app.record?.sections ?? []).flatMap(
56484
+ (section, index) => (section.blocks ?? []).flatMap((block2, order) => isRowsBlock(block2) ? [{ block: block2, path: `${at2}.record.sections.${index}.blocks.${order}` }] : [])
56485
+ );
56486
+ const planned = placed2.flatMap(({ block: block2, path: path40 }) => {
56487
+ const child = entityOf(model, block2.rows);
56488
+ const axis = child === void 0 ? void 0 : agendaAxis(model, child);
56489
+ if (axis === void 0 || "refused" in axis || axis.time === void 0 && axis.part === void 0) return [];
56490
+ const within2 = axis.time === void 0 ? `in a "${axis.part ?? ""}"` : "at an hour";
56491
+ return [note(path40, `lists "${block2.rows}" rows, each on a day ("${axis.day}") ${within2} \u2014 a table reads them as rows; an \`agenda\` draws them by day`)];
56492
+ });
56493
+ const covering = placed2.flatMap(({ block: block2, path: path40 }) => {
56494
+ const covers = placed2.find((other2) => {
56495
+ if (other2.block === block2 || other2.block.under !== void 0) return false;
56496
+ const lines = entityOf(model, other2.block.rows);
56497
+ const title = model.records?.[other2.block.rows]?.title;
56498
+ return lines?.fields.some((field2) => {
56499
+ if (field2.type !== "select_record_link" || field2.cardinality !== "one" || field2.required === true || field2.alias === title) return false;
56500
+ const shape = documentUnder(model, app.entity, lines, field2.alias);
56501
+ return !("refused" in shape) && shape.entity === block2.rows;
56502
+ });
56503
+ });
56504
+ return covers === void 0 ? [] : [note(path40, `"${block2.rows}" rows cover "${covers.block.rows}" lines listed in ${covers.path} \u2014 draw them over the lines they cover: \`"under"\` on that block`)];
56505
+ });
56506
+ return [...planned, ...covering];
56507
+ }
56508
+ function checkDocumentsListed(model, entity, sections, at2) {
56509
+ const placed2 = sections.flatMap((section, index) => (section.blocks ?? []).map((block2, order) => ({ block: block2, path: `${at2}.record.sections.${index}.blocks.${order}` })));
56510
+ const over = new Map(
56511
+ placed2.flatMap(({ block: block2 }) => {
56512
+ if (!isRowsBlock(block2) || block2.under === void 0) return [];
56513
+ const child = entityOf(model, block2.rows);
56514
+ const shape = child === void 0 ? void 0 : documentUnder(model, entity.alias, child, block2.under);
56515
+ return shape === void 0 || "refused" in shape ? [] : [[shape.entity, block2.title ?? child?.label ?? block2.rows]];
56516
+ })
56517
+ );
56518
+ return placed2.flatMap(({ block: block2, path: path40 }) => {
56519
+ if (!isRowsBlock(block2)) return [];
56520
+ const lines = over.get(block2.rows);
56521
+ return lines === void 0 ? [] : [error51(path40, `lists "${block2.rows}" rows, which stand over their lines in "${lines}" (\`under\`) \u2014 a second list says them twice; drop it`)];
56522
+ });
56523
+ }
56185
56524
  function hiddenWhenOffered(shown, offered) {
56186
56525
  return Object.entries(shown ?? {}).some(([field2, options]) => {
56187
56526
  const held2 = offered?.[field2];
56188
- return held2 !== void 0 && !held2.some((option) => options.includes(option));
56527
+ if (held2 === void 0) return false;
56528
+ if (typeof held2 === "boolean" || typeof options === "boolean") return held2 !== options;
56529
+ return !held2.some((option) => options.includes(option));
56189
56530
  });
56190
56531
  }
56532
+ function checkGroup(model, entity, display, app, name, path40) {
56533
+ const field2 = fieldOf(entity, name);
56534
+ if (field2 === void 0) return [error51(path40, `names "${name}", which is not a field of "${entity.alias}"`)];
56535
+ const type = resolvedFieldType(field2, entity, model.entities) ?? field2.type;
56536
+ const several = (field2.type === "select" || field2.type === "select_member") && field2.multi === true;
56537
+ const many = field2.type === "select_record_link" && field2.cardinality !== "one";
56538
+ if (several || many || !GROUP_TYPES.has(type)) {
56539
+ return [error51(path40, `"${name}" is a ${several || many ? `${field2.type} field holding several values` : `${field2.type} field`} \u2014 a row stands in one group: a single select, a one-row link, one member, a date, a yes/no or text`)];
56540
+ }
56541
+ if (name === display.status?.field) return [error51(path40, `"${name}" is the status, whose chips already split the rows \u2014 group by another field`)];
56542
+ if (name === app.scope) return [error51(path40, `"${name}" is the folder the app is inside, one group of every row \u2014 group by another field`)];
56543
+ if ((app.register?.layout ?? "table") !== "table") return [error51(path40, `groups a ${app.register?.layout ?? ""} register \u2014 rows are grouped in a table`)];
56544
+ if (name === display.title && (display.subtitle ?? []).length === 0) {
56545
+ return [error51(path40, `groups by "${name}", the row's title, so each row leads with its first subtitle \u2014 state \`records.${entity.alias}.subtitle\` (the person, the party)`)];
56546
+ }
56547
+ return [];
56548
+ }
56191
56549
  function misplacedMove(act, title, entry) {
56192
56550
  return `"${act.alias}" moves the status ${entry ? "into" : "on from"} "${title}", so it stands at that section's foot \u2014 ${entry ? "its way in" : "the step it finishes"}; name it there`;
56193
56551
  }
@@ -56267,7 +56625,7 @@ function checkStagedAct(display, section, act, path40) {
56267
56625
  }
56268
56626
  const field2 = status.field;
56269
56627
  if (field2 === void 0) return [];
56270
- const held2 = act.when?.[field2];
56628
+ const held2 = heldOptions(act.when, field2);
56271
56629
  if (held2 === void 0) {
56272
56630
  return [error51(path40, `"${act.alias}" is offered whatever "${field2}" holds, and "${section.title}" is the work only while it is ${at2.join(" or ")} \u2014 narrow its \`when\` to that, or ${elsewhere}`)];
56273
56631
  }
@@ -56280,6 +56638,7 @@ function blockIdentity(block2) {
56280
56638
  return `rows:${block2.rows}:${block2.via ?? ""}:${where}`;
56281
56639
  }
56282
56640
  if ("timeline" in block2) return `timeline:${block2.timeline}:${block2.via ?? ""}`;
56641
+ if (isAgendaBlock(block2)) return `agenda:${block2.agenda}:${block2.via ?? ""}`;
56283
56642
  if ("component" in block2) return `component:${block2.component}`;
56284
56643
  if (isReadingBlock(block2)) return `reading:${JSON.stringify(block2)}`;
56285
56644
  return void 0;
@@ -56303,6 +56662,64 @@ function checkWhere(child, block2, at2) {
56303
56662
  ];
56304
56663
  });
56305
56664
  }
56665
+ function checkChildColumns(model, child, names, back, expecting, at2) {
56666
+ const findings = [];
56667
+ const display = model.records?.[child.alias];
56668
+ const rowParts = partsOf(display);
56669
+ const bounds = meterBounds(display, names);
56670
+ const columns = /* @__PURE__ */ new Map();
56671
+ for (const [index, name] of names.entries()) {
56672
+ const path40 = `${at2}.columns.${index}`;
56673
+ if (fieldOf(child, name) === void 0) {
56674
+ findings.push(error51(path40, `names "${name}", which is not a field of "${child.alias}"`));
56675
+ continue;
56676
+ }
56677
+ const part = rowParts.get(name);
56678
+ const inPlace = expecting && name === display?.figure && expectedColumns(model, child, [name]).length > 0;
56679
+ if (part !== void 0 && !inPlace) findings.push(error51(path40, `"${name}" is already drawn by the "${child.alias}" row itself, as ${part} \u2014 drop the column`));
56680
+ const meter = bounds.get(name);
56681
+ if (meter !== void 0) findings.push(error51(path40, `"${name}" is the bound of the meter "${meter}" on the same row, which states it \u2014 drop the column`));
56682
+ if (name === back) findings.push(error51(path40, `"${name}" is the link back to this record, the same on every row of the block \u2014 drop the column`));
56683
+ const first2 = columns.get(name);
56684
+ if (first2 !== void 0) findings.push(error51(path40, `"${name}" is already column ${first2} \u2014 a field is one column`));
56685
+ else columns.set(name, index);
56686
+ }
56687
+ return findings;
56688
+ }
56689
+ function checkChildCreate(child, create, via, at2) {
56690
+ if (!Array.isArray(create)) return [];
56691
+ const findings = [];
56692
+ if (!peopleWrite(child)) findings.push(error51(at2, `adds rows to "${child.alias}", which no person writes`));
56693
+ for (const [index, name] of create.entries()) {
56694
+ const field2 = fieldOf(child, name);
56695
+ if (field2 === void 0) findings.push(error51(`${at2}.${index}`, `names "${name}", which is not a field of "${child.alias}"`));
56696
+ else if (name === via) findings.push(error51(`${at2}.${index}`, `asks "${name}", the link to this record, which an add sets itself`));
56697
+ else if (!isAsked(field2)) findings.push(error51(`${at2}.${index}`, `asks "${name}", a ${field2.type} field nobody writes`));
56698
+ }
56699
+ return findings;
56700
+ }
56701
+ function checkUnder(model, entity, child, block2, path40) {
56702
+ if (block2.under === void 0) return [];
56703
+ const at2 = `${path40}.under`;
56704
+ const shape = documentUnder(model, entity.alias, child, block2.under);
56705
+ if ("refused" in shape) return [error51(at2, shape.refused)];
56706
+ const findings = [];
56707
+ const line = fieldOf(child, block2.under);
56708
+ if (line !== void 0 && !isAsked(line)) findings.push(error51(at2, `"${block2.under}" is a field nobody writes \u2014 making a document links its lines`));
56709
+ if (model.records?.[shape.entity] === void 0) {
56710
+ findings.push(error51(at2, `stands the lines under "${shape.entity}", which has no \`records\` entry \u2014 say how a document is recognised (its title at least)`));
56711
+ }
56712
+ for (const [index, name] of (block2.columns ?? []).entries()) {
56713
+ const column = fieldOf(child, name);
56714
+ if (column?.type === "text" && column.format === "markdown") {
56715
+ findings.push(error51(`${path40}.columns.${index}`, `"${name}" is long text, drawn whole on a card per row, and lines under a document are a table of short facts \u2014 drop the column or \`under\``));
56716
+ continue;
56717
+ }
56718
+ if (name !== block2.under && !(column?.type === "lookup" && column.source_field_alias === block2.under)) continue;
56719
+ findings.push(error51(`${path40}.columns.${index}`, `"${name}" reads the "${shape.entity}" each line stands under, which its heading already reads \u2014 drop the column`));
56720
+ }
56721
+ return findings;
56722
+ }
56306
56723
  function checkBlock(model, entity, block2, at2, place, scoped) {
56307
56724
  const findings = [];
56308
56725
  const unruled = (child, through) => scoped && entity.read_scope !== void 0 && child.read_scope === void 0 && model.records?.[entity.alias]?.status?.history !== child.alias ? [
@@ -56319,6 +56736,24 @@ function checkBlock(model, entity, block2, at2, place, scoped) {
56319
56736
  if ("refused" in link) return [error51(at2, link.refused)];
56320
56737
  return [...unruled(child, link.link), ...checkReading(model, block2, at2)];
56321
56738
  }
56739
+ if (isAgendaBlock(block2)) {
56740
+ const child = entityOf(model, block2.agenda);
56741
+ if (child === void 0) return [error51(at2, `reads "${block2.agenda}", which this model does not declare`)];
56742
+ const link = childLink(child, entity.alias, block2.via);
56743
+ if ("refused" in link) findings.push(error51(at2, link.refused));
56744
+ findings.push(...unruled(child, "link" in link ? link.link : block2.via ?? entity.alias));
56745
+ const axis = agendaAxis(model, child);
56746
+ if ("refused" in axis) findings.push(error51(at2, axis.refused));
56747
+ const back = "link" in link ? link.link : block2.via;
56748
+ findings.push(...checkChildColumns(model, child, block2.columns ?? [], back, false, at2));
56749
+ findings.push(...checkChildCreate(child, block2.create, back, `${at2}.create`));
56750
+ if (block2.start !== void 0) {
56751
+ const start = fieldOf(entity, block2.start);
56752
+ if (start === void 0) findings.push(error51(`${at2}.start`, `names "${block2.start}", which is not a field of "${entity.alias}"`));
56753
+ else if (resolvedFieldType(start, entity, model.entities) !== "date") findings.push(error51(`${at2}.start`, `"${block2.start}" is a ${start.type} field \u2014 the first day is a date of the record`));
56754
+ }
56755
+ return findings;
56756
+ }
56322
56757
  if ("rows" in block2 || "timeline" in block2) {
56323
56758
  const childAlias = "rows" in block2 ? block2.rows : block2.timeline;
56324
56759
  const child = entityOf(model, childAlias);
@@ -56328,7 +56763,6 @@ function checkBlock(model, entity, block2, at2, place, scoped) {
56328
56763
  findings.push(...unruled(child, "link" in link ? link.link : block2.via ?? entity.alias));
56329
56764
  if ("rows" in block2) {
56330
56765
  const childDisplay = model.records?.[child.alias];
56331
- const rowParts = partsOf(childDisplay);
56332
56766
  const back = "link" in link ? link.link : block2.via;
56333
56767
  if (childDisplay !== void 0 && back !== void 0 && childDisplay.title === back) {
56334
56768
  const next = (childDisplay.subtitle ?? []).find((name) => name !== back);
@@ -56343,40 +56777,15 @@ function checkBlock(model, entity, block2, at2, place, scoped) {
56343
56777
  );
56344
56778
  }
56345
56779
  }
56346
- const columns = /* @__PURE__ */ new Map();
56347
- const blockBounds = meterBounds(childDisplay, block2.columns ?? []);
56348
- for (const [index, name] of (block2.columns ?? []).entries()) {
56349
- const path40 = `${at2}.columns.${index}`;
56350
- if (fieldOf(child, name) === void 0) {
56351
- findings.push(error51(path40, `names "${name}", which is not a field of "${child.alias}"`));
56352
- continue;
56353
- }
56354
- const part = rowParts.get(name);
56355
- const inPlace = block2.expect !== void 0 && name === childDisplay?.figure && expectedColumns(model, child, [name]).length > 0;
56356
- if (part !== void 0 && !inPlace) findings.push(error51(path40, `"${name}" is already drawn by the "${child.alias}" row itself, as ${part} \u2014 drop the column`));
56357
- const meter = blockBounds.get(name);
56358
- if (meter !== void 0) findings.push(error51(path40, `"${name}" is the bound of the meter "${meter}" on the same row, which states it \u2014 drop the column`));
56359
- if (name === back) findings.push(error51(path40, `"${name}" is the link back to this record, the same on every row of the block \u2014 drop the column`));
56360
- const first2 = columns.get(name);
56361
- if (first2 !== void 0) findings.push(error51(path40, `"${name}" is already column ${first2} \u2014 a field is one column`));
56362
- else columns.set(name, index);
56363
- }
56780
+ findings.push(...checkChildColumns(model, child, block2.columns ?? [], back, block2.expect !== void 0, at2));
56364
56781
  findings.push(...checkWhere(child, block2, at2));
56365
56782
  if (block2.expect !== void 0) findings.push(...checkExpect(model, child, block2, back, `${at2}.expect`));
56366
56783
  if (block2.of !== void 0) findings.push(...checkExpectOf(entity, child, block2, `${at2}.of`));
56784
+ if (block2.under !== void 0) findings.push(...checkUnder(model, entity, child, block2, at2));
56367
56785
  } else if (!child.fields.some((field2) => resolvedFieldType(field2, child, model.entities) === "date")) {
56368
56786
  findings.push(error51(at2, `reads "${child.alias}" as a timeline, and it has no date to place its entries by`));
56369
56787
  }
56370
- if (Array.isArray(block2.create)) {
56371
- if (!peopleWrite(child)) findings.push(error51(`${at2}.create`, `adds rows to "${child.alias}", which no person writes`));
56372
- const via = "link" in link ? link.link : block2.via;
56373
- for (const [index, name] of block2.create.entries()) {
56374
- const field2 = fieldOf(child, name);
56375
- if (field2 === void 0) findings.push(error51(`${at2}.create.${index}`, `names "${name}", which is not a field of "${child.alias}"`));
56376
- else if (name === via) findings.push(error51(`${at2}.create.${index}`, `asks "${name}", the link to this record, which an add sets itself`));
56377
- else if (!isAsked(field2)) findings.push(error51(`${at2}.create.${index}`, `asks "${name}", a ${field2.type} field nobody writes`));
56378
- }
56379
- }
56788
+ findings.push(...checkChildCreate(child, block2.create, "link" in link ? link.link : block2.via, `${at2}.create`));
56380
56789
  return findings;
56381
56790
  }
56382
56791
  if ("files" in block2) {
@@ -57568,7 +57977,7 @@ function resultSideEffects(result) {
57568
57977
 
57569
57978
  // src/version.ts
57570
57979
  init_define_LOTICS_KIT_VERSIONS();
57571
- var VERSION = "0.253.0";
57980
+ var VERSION = "0.255.0";
57572
57981
 
57573
57982
  // src/timezone.ts
57574
57983
  init_define_LOTICS_KIT_VERSIONS();
@@ -57591,7 +58000,7 @@ import path6 from "node:path";
57591
58000
  import { fileURLToPath } from "node:url";
57592
58001
 
57593
58002
  // src/model_reference.md
57594
- var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing a workspace: its tables, fields, options, views, roles,\nfirst rows, how a row of each table is recognised, and the apps over them. The\nfull form spells it out; the `from` form names a published preset and carries\nonly what this business differs by (\xA7 Starting from a preset \u2014 prefer it whenever\na preset fits the trade). Each \xA7 is `lotics docs model/<section>`.\n\n**What a model composes with**\n\n- **Entities and fields** (\xA7 Entity, \xA7 Field) \u2014 the tables, their columns, options and links.\n- **Records** (\xA7 Records) \u2014 how a row of each entity is RECOGNISED: its title, the line\n under it, its picture, its status, the one number it stands for. Stated once per entity\n and read by every surface that draws one of its rows.\n- **Write rules** (\xA7 Write rules) \u2014 what a write finds, copies, bounds and picks among.\n- **Apps** (\xA7 Apps) \u2014 one register over one entity and the record each row opens: which\n fields go where, the acts, and the checks that guard them \u2014 or a dashboard of readings.\n How each thing LOOKS is the runtime\'s, one treatment per concept; no key here changes it.\n\n**The working order**\n\n1. Name the people and each one\'s JOB \u2014 the work they alone decide or write.\n2. The entities and fields those jobs touch, and a `records` entry for every entity an\n app lists, opens or picks.\n3. One app per job in `apps[]`, each one register over one entity.\n4. `lotics scaffold check model.json` \u2014 offline, every problem in one run, and each app\n printed as its reader will see it.\n5. `lotics app preview model.json#<app> --shots <dir>` \u2014 each app drawn from `rows`, nothing created.\n6. `lotics workspace build model.json --deploy` \u2014 the tables, then every app, live\n (`lotics setup model.json --email you@company.com` first where no account exists).\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. Renaming a FIELD is\n `lotics field rename <table> <field> "<new label>" --model <this file>`, which\n moves the platform, this file and every app bound to it together; deleting is\n `lotics run delete_table`. Neither goes through the file.\n- **The file\'s own majority is the language.** A model names no locale \u2014 which\n language it is in is what it mostly says, and `scaffold check` notes the label\n written the other way. The generated screens read the kit\'s pack, and a\n generated WRITE cannot: its refusals run on the server, so they are worded in\n that same majority. Mix the two and the workspace answers in two languages.\n- **`lotics scaffold diff model.json` says where the file and the workspace have\n come apart**, joined on label, entity then field \u2014 the join adoption itself\n makes. It exits 1 on any difference.\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- **`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\nEvery table of keys on this page is generated from the schema `scaffold check`\nparses the file with, so it is the whole of what a key may hold. The first is the\nfull form; the second names a preset instead of restating one (\xA7 Starting from a\npreset).\n\n<!-- generated:start top-level -->\n\n#### Model file\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `entities` | list of [Entity](#entity) | yes | The tables this model creates, with their fields, options and views |\n| `roles` | list of [Role](#role) | no | Workspace groups to create; members are added to them afterwards |\n| `templates` | list of [Template](#template) | no | Document templates whose content travels inline (html or email) |\n| `rows` | map of alias \u2192 list of [Row](#row) | no | First records, keyed by entity alias \u2014 written only where every table they land in is empty |\n| `records` | map of alias \u2192 [Record](#record) | no | How a row of each entity is recognised, keyed by entity alias. Every entity an app lists, opens or picks has one |\n| `write_rules` | map of alias \u2192 [Entity write rules](#entity-write-rules) | no | Entity alias \u2192 what a create of that entity finds, copies and refuses |\n| `apps` | list of ([App](#app) \\| [Dashboard app](#dashboard-app)) | no | The apps this workspace will have \u2014 each one register over an entity, or a dashboard of readings |\n| `preset` | [Preset](#preset) | no | The trade\'s branches, for a model published to be read; `setup` and `apply` scaffold the base alone |\n| `apply` | list of [Package entry](#package-entry) | no | Published packages copied in once this model\'s tables exist, in this order |\n\n#### From file\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `from` | text | yes | The preset this model starts from, by its slug |\n| `variants` | list of text | no | The preset\'s variant slugs to merge onto its base, in order |\n| `rename` | map of alias \u2192 [Binding](#binding) | no | Entity alias \u2192 what this business calls that table and its fields |\n| `entities` | list of [Entity](#entity) | no | Tables this business has that the preset does not declare |\n| `rows` | map of alias \u2192 list of [Row](#row) | no | First records, keyed by entity alias \u2014 written only where every table they land in is empty |\n| `records` | map of alias \u2192 [Record](#record) | no | How rows of the preset\'s entities and this business\'s own are recognised, entity by entity over the preset\'s |\n| `write_rules` | map of alias \u2192 [Entity write rules](#entity-write-rules) | no | Create-time clauses on the preset\'s entities and this business\'s own, over whatever the preset declares |\n| `apps` | list of ([App](#app) \\| [Dashboard app](#dashboard-app)) | no | The apps this workspace will have \u2014 each one register over an entity, or a dashboard of readings |\n| `apply` | list of [Package entry](#package-entry) | no | Published packages copied in once this model\'s tables exist, in this order |\n\n<!-- generated:end top-level -->\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 what an agent states to make an app,\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<!-- generated:start entity -->\n\n#### Entity\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable entity alias, unique within the contract |\n| `label` | text | yes | Display label used as the table name at scaffold time |\n| `singular` | text | no | One row of this table, in the business\'s own words \u2014 what a create\'s button and panel name |\n| `description` | text | no | The table\'s description, written onto the table in the workspace |\n| `writes` | `false` | no | false: no person writes this table\'s rows \u2014 only the writes that keep it do, and no app opens or edits one |\n| `fields` | list of [Field](#field) (at least one) | yes | The table\'s columns |\n| `read_scope` | [Read scope](#read-scope) | no | Which rows a member reads. Absent, every member with access to the table reads every row. |\n| `unique` | list of list of alias (at least one) (at least one) | no | Sets of fields whose values no two live rows share \u2014 each a list of field aliases (text, number, date, a single select, or a link of cardinality "one"). A create or update landing a second row with the same values is refused. |\n| `views` | list of [View](#view) | no | Saved views, in the order they are listed; with none, the table still opens on its default grid |\n\n#### Read scope\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `any` | list of ([Read scope by role](#read-scope-by-role) \\| [Read scope by member](#read-scope-by-member) \\| [Read scope by option](#read-scope-by-option)) (at least one) | yes | A row is readable when ANY of these holds |\n\n#### Read scope by role\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `member_of` | alias | yes | A role alias: whoever is in the group it binds to reads the row |\n\n#### Read scope by member\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `through` | list of alias (1\u20133) | no | select_record_link aliases from this entity outward, each of cardinality "one" \u2014 the clause\'s field is on the entity the last hop lands on |\n| `field` | alias | yes | A select_member field alias on this entity, or on the entity `through` lands on |\n| `is` | `"self"` | yes | The members this column names on a row read that row |\n\n#### Read scope by option\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `through` | list of alias (1\u20133) | no | select_record_link aliases from this entity outward, each of cardinality "one" \u2014 the clause\'s field is on the entity the last hop lands on |\n| `field` | alias | yes | A single-select field alias on this entity, or on the entity `through` lands on |\n| `is` | list of alias (at least one) | yes | Its option aliases whose rows are readable \u2014 naming none would hide every row |\n\n<!-- generated:end entity -->\n\n**`unique` is a set of values no two live rows share.** Each entry names fields\nof this entity holding ONE value \u2014 text, number, date, a single select, a link\nof cardinality `"one"` \u2014 and a create or update landing a second row with the\nsame values is refused. A set of one text field is that field\'s own `unique:\ntrue`, so it is refused here. A create carries each set in the names its panel\nsends, so the panel can name the duplicate before the write does, and `scaffold\ncheck` prints it under *Who writes what* as `unique: Bed + Sown on`.\n\n**`singular` is what a create says** \u2014 `New Order`, `Add Claim line`, `H\u1ED3 s\u01A1\nm\u1EDBi` \u2014 while `label` names the table, so without it the button reads `New\nOrders`. Nothing derives it: English plurals are irregular, and no language is\nexempt. In a language without plural forms it is usually the label itself,\nless any word for the collection. `scaffold check` prints it beside the table\nunder *Who writes what* and REFUSES a model where a table some create opens \u2014 a\nregister\'s own, or a record section\'s add \u2014 states none.\n\n**`writes: false` says only the workspace\'s automations write these rows** \u2014 a\nlog of what the system sent, a copy of what another system holds. No app opens,\nedits or files one: a record listing them keeps the section and opens each row\nat rest, with no Add; a screen over the entity operates at most the rows its\nrecord owns; a party of it is picked, never found or minted; and the generator\nwrites no create, no update and no `lotics.writes` entry for it. `scaffold\ncheck` prints it under *Who writes what* as `written by automations` and never\nnotes it as created nowhere. A `lifecycle` on it is refused \u2014 a row walked\nthrough stages is worked by a person \u2014 and so is a publish desk over it. Absent,\npeople write the rows; `true` is not a value.\n\n**`read_scope` is a ROW rule, enforced by the platform.** It is resolved at\nscaffold into the table\'s own row filters, so an app, a workflow reading for a\nviewer, and the API all answer the same rows \u2014 a per-record visibility field the\napp merely honours is a convention, not a gate. A `"self"` clause reads a column\nof one member or several, and every role alias and option alias a clause names\nmust be one this model declares. Scaffold writes the rule onto a table it\nCREATES; a table it adopted that ALREADY CARRIES a rule keeps that one, because\nthe rule is the workspace\'s own statement about its rows \u2014 and a run whose model\nstates a different rule reports the entity rather than leaving the claim silent.\n\n**An app may state that its sharing is its read gate: `"reads": "shared"`.** An\napp reads as its owner, so the rule reaches a viewer only as the predicate every\nquery and editor guard of every app over the entity carries \u2014 right for a desk of\none\'s own rows, wrong for a desk whose audience its sharing already decides, where\nwidening the rule meant a role group nobody remembers to fill. Stated on an APP,\nits queries, pickers and guards carry no entity\'s rule and whoever the app is\nshared with reads and writes every row it draws; every other app and the table\'s\nown filters keep the rule. Share it deliberately. Refused on an app none of whose\ntables states a `read_scope`; `scaffold check` prints it on the app\'s line and\nnames the app beside the rule it is exempt from.\n\n**The rows under a private record INHERIT its rule.** An entity that states no\n`read_scope` and hangs under one that does \u2014 through its `parent` link, over one\nhop or several \u2014 is read by the ancestor\'s rule, answered through that link, on\nits table\'s own filters and in every query and guard alike; nothing is restated,\nso a child needs none of the ancestor\'s columns. Stating a rule on the child\nkeeps that one instead, an ancestor with no rule passes nothing down, and a row\nhanging further under the scoped one than a row filter reaches is refused by\nname \u2014 state a rule on it. So is a hop over a link that names more than one row:\nthe rule would admit a reader any one of them admits while the editor\'s guard\nreads the first, so give the link `"cardinality": "one"` or state a rule on the\nchild.\n\n**ONLY the `parent` role is walked.** A register a scoped record reaches by any\nother link \u2014 the rows that NAME it \u2014 is read by that record\'s id with no rule\ntravelling to it, so it is refused until it states one of its own.\n\n## Field\n\nEvery field carries the keys below, and its `type`\'s section adds the rest; a\ntype whose section names no `default` takes none. `label` may not contain `{` or\n`}` (formulas reference fields by label at the platform level). Every row in `rows` states each\n`required` field it carries (a default is not applied to them), and a required\nLINK is written with its row: the entity it names is created first, and entities\nwhose required links name each other are refused, since none of their rows could\never be created.\n\n<!-- generated:start field -->\n\n#### Field\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `type` | `"text"` \\| `"number"` \\| `"date"` \\| `"boolean"` \\| `"select"` \\| `"select_member"` \\| `"select_record_link"` \\| `"files"` \\| `"formula"` \\| `"rollup"` \\| `"lookup"` \\| `"autonumber"` | yes | What the field holds \u2014 each type takes the further keys its own section lists |\n| `alias` | alias | yes | Stable local alias, unique within the entity |\n| `label` | text | yes | Display label used as the field name at scaffold time |\n| `description` | text | no | The field\'s description, written onto the field in the workspace |\n| `required` | boolean | no | Refuse a record whose cell for this field is empty. Scaffold writes it onto the field, and every write path \u2014 create, update, an agent\'s tool call, a workflow\'s set \u2014 refuses the row by field name. |\n\n<!-- generated:end field -->\n\n### `text`\n\n<!-- generated:start field-text -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | text | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. |\n| `unique` | boolean | no | Unique values required |\n| `format` | `"text"` \\| `"link"` \\| `"markdown"` | no | How the words are drawn \u2014 plain, as a link that opens, or as markdown |\n\n<!-- generated:end field-text -->\n\n### `number`\n\n<!-- generated:start field-number -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | number | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. |\n| `format` | `"number"` \\| `"currency"` \\| `"percentage"` | no | What the figure is \u2014 a plain number, money in `currency`, or a percent |\n| `currency` | text | no | ISO 4217 code |\n\n<!-- generated:end field-number -->\n\n`format` is what the number IS, and every surface reads it: `currency` prints as\nmoney in the code the row or the field states, `percentage` as a whole percent\nwith its sign. An ABSENT number is drawn absent \u2014 the one exception is a\n`sum` or count rollup the plan reads as a **`measure`**: that is the thing\naccumulated toward a bound, so nothing accumulated yet is zero and the meter\ndraws it. The same rollup read as an `amount` keeps its blank, and so does every\nother role: nothing added to what a row is WORTH means unpriced, not free. A\n`min`, an `avg`, a percentage of nothing and every FORMULA stay blank in any\nrole, because none of them has an answer to give. This is why the pair on one\nscreen reads two ways \u2014 what has come in against what is owed \u2014 and why a\nmeasure\'s own LIMIT, an amount, leaves an unquoted row out of the count rather\nthan reporting it as nothing collected. **AND WHERE THAT LIMIT IS ABSENT \u2014 OR\nZERO \u2014 THERE IS NO LEVEL AT ALL**: a level is a reading AGAINST a bound, so a row\nthat states no bound, or a bound of nothing, draws nothing \u2014 cell, fact and all \u2014\nrather than a numerator whose whole meaning was the comparison. "Collected 0"\nbeside a blank total reads as money against a job worth nothing, and "0 of 0"\nagainst a count of nothing owed claims a comparison nobody can make. A measure the model gives no limit is a plain figure\nand is unaffected. A share is stored in percent units \u2014 68.1 is 68.1 % \u2014 and the\ncolumn, the fact behind it, the meter it is judged by and the figure over the\nregister all say so.\n\n### `date`\n\n<!-- generated:start field-date -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | text | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. A date string in the field\'s format. |\n| `format` | `"date"` \\| `"datetime"` \\| `"date_range"` \\| `"datetime_range"` | no | Whether the field holds a day or a moment, alone or as a span |\n| `timezone` | text | no | IANA timezone |\n| `derive_from` | `"created_at"` \\| `"updated_at"` | no | Auto-populate from the row\'s system timestamp; the field becomes read-only. |\n\n<!-- generated:end field-date -->\n\nA `default` is refused beside `derive_from`: the platform stamps that date.\n\n### `boolean`\n\n<!-- generated:start field-boolean -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | boolean | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. |\n\n<!-- generated:end field-boolean -->\n\n### `select`\n\n<!-- generated:start field-select -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | list of alias | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. Option alias(es) this field declares \u2014 one for single-select. |\n| `options` | list of [Select option](#select-option) (at least one) | yes | The choices, in the order every picker and every ladder lists them |\n| `multi` | boolean | no | Allow multiple selections |\n\n#### Select option\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable local alias, unique within the field |\n| `label` | text | yes | Display label for the option |\n| `color` | [colour](#select) | yes | The colour the option\'s badge is drawn in |\n| `mark` | [mark](#select) | no | The option\'s own mark, drawn in place of its colour dot wherever the option is shown: the channel it is ({kind: "brand", name: one of facebook, instagram, threads, meta, tiktok, google-ads, zalo, linkedin, x, google-meet, youtube}) or a kit glyph ({kind: "icon", name: "wrench"}). Every option of a field has one, or none does. A mark a reader does not draw falls back to the dot |\n\n<!-- generated:end field-select -->\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\nAn option may carry its own `mark`, drawn in place of its colour dot wherever\nthe option is shown \u2014 a stage, a chip, a filter, a fact, an entry of a log, a\nlookup of the select on another entity: the channel it IS\n(`{"kind": "brand", "name": "tiktok"}` \u2014 one of `facebook`, `instagram`,\n`threads`, `meta`, `tiktok`, `google-ads`, `zalo`, `linkedin`, `x`,\n`google-meet`, `youtube`), or a glyph from the kit\'s curated Lucide set\n(`{"kind": "icon", "name": "wrench"}`; a name outside it is refused naming the\nnearest ones). Every option of a select has one, or none does: a run of chips\nwhere one carries no mark reads as the one missing something. `apply` writes the\nmarks onto the table, sets one an adopted option lacks, and reports one it wears\ndifferently rather than overwrite it.\n\n```jsonc\n"options": [\n { "alias": "short_video", "label": "Short video", "color": "zinc", "mark": { "kind": "brand", "name": "tiktok" } },\n { "alias": "print", "label": "Print", "color": "amber", "mark": { "kind": "icon", "name": "newspaper" } }\n]\n```\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. With no role it is still drawn \u2014\nface and name, ranked as a `party` \u2014 in a screen\'s `columns` and in the register\na record draws of these rows.\n\n<!-- generated:start field-select_member -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `multi` | boolean | no | Allow multiple selections |\n\n<!-- generated:end field-select_member -->\n\n### `select_record_link`\n\n<!-- generated:start field-select_record_link -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `target_entity` | alias | yes | Alias of the entity this field links to |\n| `sync_both_ways` | boolean | no | Create a paired link field on the target entity for bidirectional sync |\n| `paired_field_alias` | alias | no | The pair edge of a bidirectional link: the field alias ON THE TARGET ENTITY that is this link\'s sync partner. Both sides of a pair carry it, each naming the other. Scaffold creates whichever side it reaches first WITH the pairing (the platform auto-creates the partner) and binds the partner alias to the auto-created field \u2014 without this edge the two contract fields would be created independently and collide with the auto-created partner. |\n| `cardinality` | `"one"` \\| `"many"` | no | How many linked records this field holds. Default \'many\'. \'one\' holds a single row and needs no partner; where the link IS paired, the partner side holds many. |\n| `display_field_aliases` | list of alias | no | Field aliases on the target entity shown as the link\'s display text / picker columns |\n\n<!-- generated:end field-select_record_link -->\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.\n\n**A single-valued link needs no partner.** `"cardinality": "one"` on its own is a\nlink that holds one row \u2014 one customer on an invoice, one project on a device \u2014\nand nothing is created on the target. The mirror invariant belongs to a PAIRED\nlink: pair a link when the target\'s own record should list what points at it, and\nleave it unpaired when it should not. Either way the record plan draws the\nrelation as a section on the side it points at, so an unpaired link costs the\ntarget nothing.\n\n### `files`\n\nNo keys beyond every field\'s; a row attaches documents to it (\xA7 Rows).\n\n### `formula`\n\n<!-- generated:start field-formula -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `formula` | [Formula](#formula) | yes | Formula config. The expression references other fields on the SAME entity by alias in braces, e.g. `{quantity} * {unit_price}`. |\n\n#### Formula\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `expression` | text | yes | The expression, over fields of THIS entity by alias in braces \u2014 `{quantity} * {unit_price}` |\n| `format` | `"number"` \\| `"currency"` \\| `"percentage"` \\| `"link"` | no | Display format. \'number\' / \'currency\' / \'percentage\' for numeric results; \'link\' for text-output formulas that return a URL \u2014 renders the result as a clickable link. |\n| `currency` | text | no | ISO 4217 currency code, e.g. USD, VND, EUR |\n| `output_type` | `"number"` \\| `"text"` \\| `"date"` \\| `"datetime"` \\| `"boolean"` | no | What the expression YIELDS \u2014 the kind the platform infers at write time, declared here so the offline checks can read it. `format` beside it is how that result is drawn, not what it is. Ignored on the wire (the platform re-infers it); `lotics scaffold export` writes the inferred value. |\n\n<!-- generated:end field-formula -->\n\n`output_type` is what lets a role or a screen clause accept a computed value: a\ncaption over a derived name (`output_type: "text"`), a period over a settled date\n(`"date"`). A formula declaring neither it nor a `format` says nothing about its\nresult, and every rule that needs one refuses it by name.\n\n**A select reaches a formula as the KEYS of its chosen options**, a list \u2014\nnever their labels, and never their aliases \u2014 and a model has no keys: the\nworkspace mints them when the table is made. So an option is named in a formula\nas `{field:option}`, both aliases, and the copy writes that option\'s key in its\nplace: `includes({kind}, {kind:crate})` for a select holding one or several,\n`{kind}[0] == {kind:crate}` for a single one. A select compared to its own words\n(`{kind} != "Crate"`) matches no row and computes the other branch everywhere,\nso `check` refuses it and names the token. A select looked up from another\nentity is tested there, in a formula of its own, and that result looked up.\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n<!-- generated:start field-rollup -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `source_field_alias` | alias | yes | Alias of a select_record_link field on this entity to roll up from |\n| `aggregate_option` | [aggregation](#rollup) | yes | Aggregation operation. Its `field_key` names a field alias on the linked entity. |\n| `filter` | [filter](#views) group | no | Only linked records matching this filter are aggregated. Every `field_key` in it names a field alias on the linked entity, and a select condition\'s value names an option alias there. A traversal node reaches past that entity, so its `path` hops and inner `field_key` are fully-qualified `entity.field` aliases. |\n\n<!-- generated:end field-rollup -->\n\n`aggregate_option` is `{ "operation": \u2026, "field_key": \u2026 }` \u2014 `field_key` a field\nalias on the linked entity (`count` may omit it), and `operation` one of `count`,\n`sum`, `avg`, `median`, `min`, `max`, `range`, `empty`, `filled`,\n`percent_empty`, `percent_filled`, `unique`, `percent_unique`, `earliest`,\n`latest`, `date_range`, `checked`, `unchecked`, `percent_checked`,\n`percent_unchecked`. The operation must be one the aggregated field\'s type\nallows \u2014 `sum` over a number, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n<!-- generated:start field-lookup -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `source_field_alias` | alias | yes | Alias of a select_record_link field on this entity to look up through |\n| `lookup_field_alias` | alias | yes | Alias of the field on the linked entity to display |\n| `order_by` | [Lookup order](#lookup-order) | no | Show ONE linked row\'s value \u2014 the first in this order \u2014 rather than every linked row\'s |\n\n#### Lookup order\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field_key` | text | yes | A field alias on the linked entity the rows are ordered by |\n| `direction` | `"asc"` \\| `"desc"` | yes | Which end of that order the one row is taken from |\n\n<!-- generated:end field-lookup -->\n\n### `autonumber`\n\n<!-- generated:start field-autonumber -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `prefix` | text | no | Literal prefix prepended to every display value (e.g. \'KH-\' \u2192 \'KH-001\'). Ignored when `template` is set. |\n| `padding` | integer | no | Zero-pad the integer to this width. Default 1 (no padding). 3 \u2192 \'001\', \'012\', \'123\', \'1234\' (overflow uses the actual width). Ignored when `template` is set. |\n| `template` | text | no | Format template with placeholder tokens evaluated at insert time. Tokens: {N} (raw integer), {N:W} (zero-padded to width W, e.g. {N:3} \u2192 001), {YEAR} (4-digit year), {YEAR:2} (2-digit year), {MONTH} (2-digit month), {DAY} (2-digit day). Date tokens use the workspace timezone. Example: \'HM-{YEAR}-{N:3}\' yields \'HM-2026-001\'. Stored as the composed string; subsequent template edits do NOT re-format existing rows (date tokens would lose the original creation date). |\n\n<!-- generated:end field-autonumber -->\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<!-- generated:start views -->\n\n#### View\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable view alias, unique within the entity |\n| `label` | text | yes | Display name of the view |\n| `description` | text | no | The view\'s description, written onto the view in the workspace |\n| `columns` | list of [View column](#view-column) (at least one) | no | The columns the view shows, in this order and no others; absent, every field |\n| `filters` | [filter](#views) | no | The rows the view keeps |\n| `sort` | [sort](#views) | no | The order the view reads its rows in |\n| `summary` | map of text \u2192 text | no | Field alias \u2192 the operation its footer cell states |\n| `frozen_columns` | integer \\| `null` | no | How many leading columns stay in place while the rest scroll |\n\n#### View column\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field_alias` | alias | yes | A field of this entity |\n| `visibility` | `"visible"` \\| `"hidden"` | yes | Field visibility state: \'visible\' = shown to everyone, \'hidden\' = not shown by default but members can toggle |\n| `width` | number | no | The column\'s width, in pixels |\n\n<!-- generated:end views -->\n\nA filter is a group \u2014 `{ "node_type": "group", "logic": "and" | "or",\n"children": [ \u2026 ] }` \u2014 or one condition on its own, `{ "node_type":\n"condition", "type": "select", "field_key": "tier", "operator": "has_any_of",\n"value": ["gold"] }`. A sort is a list of `{ "field_key": \u2026, "order": "asc" |\n"desc" | null }`. A condition\'s `type` is the field\'s type and its `operator` is\none that type admits \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## Roles\n\nA role becomes a workspace group.\n\n<!-- generated:start roles -->\n\n#### Role\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable role alias, bound to a workspace group when the starter is copied |\n| `label` | text | yes | The group\'s name in the workspace |\n\n<!-- generated:end roles -->\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes. `{{name}}` in the content is filled from the workflow\'s data.\n\n<!-- generated:start templates -->\n\n#### Template\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `type` | `"html"` \\| `"email"` | yes | html is a page a workflow renders to a PDF; email is a message a workflow sends |\n| `alias` | alias | yes | Stable template alias, unique within the contract |\n| `label` | text | yes | The template\'s name in the workspace |\n| `content_sha256` | text | no | sha256 (64-char lowercase hex) of the template content \u2014 inline: the utf-8 content string; file-backed: the bytes_ref bytes. The modified-detection reference. Optional in the schema so contracts published before template sha still parse; REQUIRED at publish (validatePackageContract). |\n| `content` | text | yes | Inline template content; placeholders reference field aliases |\n\n<!-- generated:end templates -->\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<!-- generated:start rows -->\n\n#### Row\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `ref` | text | yes | Local handle for this row, referenced by other rows\' link fields |\n| `fields` | map of text \u2192 any value | yes | Field alias \u2192 the value, read against the field\'s declared type |\n\n<!-- generated:end rows -->\n\nA `ref` is lowercase letters, digits and underscores, and 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, the WORKSPACE holds\nwhich record each row became, under the row\'s own `<entity>:<ref>`:\n`delete_records` over them is how a seeded set is reset, and applying again\nre-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**Documents can be attached on their own, afterwards.** Rows land only into empty\ntables, so a model whose binaries were added after the first apply has no second\napply to carry them: `lotics scaffold apply model.json --documents` writes ONLY\nthe `files` cells, onto the records the first run created, found by each row\'s\nown `ref` in the binding the workspace holds \u2014 so a row added to or removed from\nthe file since changes nothing about the rest. Running it twice attaches nothing\nthe second time: the same path keeps the same file, and a cell is a set.\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- on a date that holds its hour (`format: "datetime"`), that hour after the day, and always stated:\n `@today 14:30`, `@today+1 06:00`\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## Records\n\n`records` says how a row of each entity is RECOGNISED \u2014 once per entity, keyed by\nentity alias \u2014 and every surface that draws one of its rows reads the same\nstatement: the register\'s row, a picker\'s option, a link\'s chip, a child table\'s\nrow and the record\'s header. Every entity an app lists, opens or picks has one.\n\n```jsonc\n"records": {\n "visit": {\n "title": "container_no",\n "subtitle": ["customer", "arrived_on"],\n "image": "photos",\n "status": { "field": "stage", "closed": ["gone"], "history": "visit_history" },\n "figure": "total_fees",\n "due": ["free_until"]\n },\n // A release order read against what it allows: a meter wherever it shows.\n "release": { "title": "release_no", "figure": "issued", "limits": { "issued": "units" } },\n // A dossier\'s stage is the last date it reached \u2014 no stored select.\n "dossier": { "title": "applicant", "status": { "milestones": ["received_on", { "field": "appraised_on", "when": { "kind": ["loan"] } }, "signed_on"], "closed": ["signed_on"] } }\n}\n```\n\n<!-- generated:start records -->\n\n#### Record\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `title` | alias | yes | The field that names a row \u2014 the first thing every surface shows. A link names the row by the linked row (a job read by its customer), never a code or an autonumber |\n| `subtitle` | list of alias (1\u20132) | no | Up to two fields read under the title wherever a row is drawn (a code, a date, a party) |\n| `image` | alias | no | A files field that pictures the row |\n| `status` | [Status](#status) | no | The stage a row moves through \u2014 a single select or its milestone dates \u2014 drawn as a badge beside its title |\n| `figure` | alias | no | The one number the row stands for, read at its right (a total, a quantity left) |\n| `limits` | map of alias \u2192 (alias \\| number) | no | A number read against a bound \u2014 a field of the same row or a constant \u2014 drawn as a meter wherever it shows |\n| `gates` | map of alias \u2192 (alias \\| number) | no | A pass mark on a meter `limits` bounds \u2014 a field of the same row or a constant; a percent field is its share of the bound, any other number an amount. The meter marks it and fills complete once past it |\n| `tolerance` | map of alias \u2192 number | no | How far past its `limits` bound a meter may stand, in percent of the bound: the bound is an amount expected (received against ordered), not a cap. Without one the bound is a cap, and past it is an overrun |\n| `due` | list of alias (at least one) | no | Dates that are deadlines: each counts down and turns overdue |\n\n#### Status\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | no | The single select that is the row\'s state \u2014 or `milestones` instead |\n| `milestones` | list of (alias \\| [Milestone](#milestone)) (at least 2) | no | Dates in the order the work reaches them, instead of a stored select: the stage is the last one filled, ticking one stamps today, and clearing one clears every later one |\n| `closed` | list of alias (at least one) | no | Options (or milestones) that end the work: a register opens on the rows not at them, and the badge reads muted |\n| `history` | alias | no | A child entity every status change appends one row to (its link to this entity, the new status, the moment and who) \u2014 written by the app\'s own writes, never a table automation; with `field` only |\n\n#### Milestone\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | A date field: the stage is reached on the day it holds |\n| `when` | map of alias \u2192 list of alias (at least one) | yes | The stage applies only while each named select holds one of these options; otherwise the row skips it |\n\n<!-- generated:end records -->\n\n- **The title names the row.** A link names it by the linked row (a job read by its\n customer); a code or an autonumber is refused as a title \u2014 the code is the subtitle.\n- **The status** is one single select. `closed` options end the work: a register opens\n on the rows not in them. A `history` entity gets one row per move \u2014 its link to this\n entity, the new status (a select holding the same option aliases, or text), the\n moment and who \u2014 appended by the app\'s own writes that move the status, never by a\n table automation. A `history` the model does not declare is derived, and needs no\n `records` entry of its own.\n- **Milestones** are the alternative to a stored select: dates in the order the work\n reaches them, the stage being the last one filled (never stored). A milestone with a\n `when` applies only while its select holds those options; otherwise the row skips it.\n The record draws them as one block where ticking stamps today and clearing one clears\n every later one; every save and act re-checks that a date comes after each earlier one\n that applies. `closed` names the milestones that end the work. A register\'s add may ask\n the first milestone, never a later one; an act may stamp one, never clear it.\n- **A limit** reads a number against a bound \u2014 another field of the row, or a constant.\n A `gates` entry marks a pass on that meter: a percent field is its share of the\n bound, any other number an amount.\n\n## Write rules\n\n`write_rules` is what a WRITE meets, keyed by entity alias \u2014 this file\'s own:\n`check` proves it and the workspace never sees it. Every generated write of an\napp (`create_<entity>`, `update_<entity>`, each act) re-checks these on the\nserver; the screen only mirrors them.\n\n```jsonc\n"write_rules": {\n // A customer is RECOGNISED by their address. An add that names one \u2014 from the\n // customers register or from a picker\'s "new" \u2014 reuses the row it matches and\n // opens one only where nothing does, so the book never grows a second Acme.\n "customer": { "natural_key": ["email"] },\n "order_line": {\n "fields": {\n // A line of nothing is not a line. Refused on create and on update.\n "quantity": { "min": 1, "max": 9999 },\n // Shipped inside its order\'s window: a bound read off the row a link names\n // is held from both sides \u2014 moving the order\'s dates never strands a line.\n "ships_on": { "min": "order.placed_on", "max": "order.due_on" },\n // The price is fixed at the moment of ordering \u2014 COPIED off the product,\n // not looked up for ever after.\n "unit_price": { "default_from": "product.price" },\n // Nothing is sold off an empty shelf. The picker reads only the rows that\n // answer this, and the write refuses the same rows again.\n "product": {\n "options_where": {\n "node_type": "group", "logic": "and",\n "children": [{ "node_type": "condition", "type": "number",\n "field_key": "in_stock", "operator": "greater_than", "value": 0 }]\n }\n }\n }\n },\n // One booking per room at a time, while it is held: a create or a save whose\n // span overlaps another held booking of the same room is refused.\n "booking": {\n "fields": { "ends_on": { "min": "starts_on" } },\n "no_overlap": { "from": "starts_on", "to": "ends_on", "by": ["room"], "while": { "state": ["held"] } }\n }\n}\n```\n\n<!-- generated:start write-rules -->\n\n#### Entity write rules\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `natural_key` | list of alias (at least one) | no | The field aliases a row of this entity is RECOGNISED by. A write that names a row of this entity by these values reuses the row it finds, minting one only where nothing matches. |\n| `fields` | map of alias \u2192 [Field write rule](#field-write-rule) | no | Field alias \u2192 what that field\'s value is copied from, bounded by or picked among |\n| `no_overlap` | [No overlap](#no-overlap) | no | No two rows hold overlapping spans \u2014 a create or update that would is refused |\n\n#### Field write rule\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default_from` | text | no | Copy this field\'s value from the linked row at CREATE time \u2014 `<link alias>.<field alias>`, the link being a one-row link on this entity. The value is copied rather than looked up, so the source changing later leaves the row alone. |\n| `min` | number \\| text | no | Refuse a create or update whose value is below this: a constant for a number, or a field \u2014 `<field alias>` of the same row, or `<link alias>.<field alias>` of the one row a link names \u2014 of the same type (a return date after the departure, a line\'s day inside its trip) |\n| `max` | number \\| text | no | Refuse a create or update whose value is above this: a constant for a number, or a field of the same row or of the one row a link names |\n| `options_where` | [filter](#views) | no | Which rows of the target this link may point at \u2014 an `and` group of plain conditions over the TARGET entity\'s own fields, each `field_key` a field alias. It narrows the picker\'s read and is refused again where the write lands. |\n| `same` | list of alias (at least one) | no | One-row links this row and each row this link points at must name alike \u2014 a fee\'s invoice is one of its own visit\'s, a box\'s seal one of its own shipping line\'s: each a one-row link of both entities to the same entity. It narrows the picker\'s read to the rows naming what this row names, and a write is refused wherever it lands \u2014 on the link, or on what the two rows compare. |\n\n#### No overlap\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `from` | alias | yes | The date a row\'s span starts on |\n| `to` | alias | yes | The date a row\'s span ends on |\n| `by` | list of alias (at least one) | no | Fields two rows share to compete for a span (the same employee, the same room); absent, every row |\n| `while` | map of alias \u2192 list of alias (at least one) | no | Only rows at these options of these selects hold their span (running, not closed); absent, every row |\n\n<!-- generated:end write-rules -->\n\n- A `natural_key` is `text` or `number`, because a person types it back, or a\n one-row link, because a row is also recognised by the one row it belongs with (a\n fee by its visit and its kind). A key of one `text` field declares `unique: true`\n on the field itself \u2014 two rows sharing it would make find-or-create pick\n whichever the read answered first. An add asks the key by default.\n- A `min` or `max` naming a field is read off the row as the save would leave it,\n and one naming `<link>.<field>` off the row the link names \u2014 the link\'s side\n re-checks its own rows when that bound moves.\n- A `default_from` link is `required`, because there has to be a row to read, and\n the two field types must match. The copied field is not asked.\n- A `same` link names only rows that name what this row names \u2014 a fee\'s invoice\n is one of its own visit\'s \u2014 through a one-row link both entities hold to one\n entity; its picker offers nothing until the row names one. A save moving what\n the two compare, on either row, is refused while they would name apart.\n- An `options_where` link may be left empty; a row it names is refused again\n where the write lands unless it holds the narrowing. A link naming one kind of\n a book\'s rows (a "Shipping line" among the parties) narrows to that kind, or\n its picker offers every row.\n\nA field\'s own `required` is not here: the contract carries it, and every write\npath refuses the row by field name from it. `unique` is the text field\'s own\nclause (\xA7 `text`) \u2014 an add says so at the control before the column does. A\nfield the workspace writes (a formula, a rollup, a lookup, an autonumber, a date\nwith `derive_from`) is never asked, written or edited.\n\n## Apps\n\nAn app is ONE register over one entity and the record each row opens \u2014 or a dashboard\nof readings. The author states COMPOSITION \u2014 which fields go where; the runtime owns\nhow each thing looks, one treatment per concept. Nothing is drawn that the app does not\nname, and each fact appears once on a record. What the vocabulary has no word for is a\n`component` block or an act\'s own `workflow`.\n\n```jsonc\n"apps": [{\n "alias": "gate", "name": "Gate in and out", "entity": "visit",\n "register": { "columns": ["service"], "filters": ["customer", "service"] },\n "record": {\n "sections": [\n { "title": "In", "fields": ["customer", "service"], "blocks": [{ "rows": "fee", "columns": ["amount"] }] },\n { "title": "Out", "at": ["in_yard"], "fields": ["release", "seal"], "blocks": [{ "files": ["photos"] }], "acts": ["gate_out"] },\n { "title": "History", "blocks": [{ "timeline": "visit_history" }] }\n ],\n "comments": true\n },\n "acts": [{ "alias": "gate_out", "label": "Gate out", "when": { "stage": ["in_yard"] },\n "requires": ["release", "seal"], "set": { "stage": "gone", "left_on": "now" }, "confirm": true }],\n "checks": [{ "field": "unpaid", "blocks": ["gate_out"] }]\n}]\n```\n\n<!-- generated:start apps -->\n\n#### App\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | The app\'s name within the model \u2014 what `--from model.json#<alias>` picks |\n| `name` | text | yes | The job, in the words of the people who do it |\n| `description` | text | no | What the job is for, in a sentence |\n| `icon` | text | no | A lucide icon name the launcher tile draws |\n| `theme` | [Theme](#theme) | no | The launcher tile\'s colour |\n| `entity` | alias | yes | The entity whose rows this job works \u2014 the register\'s rows |\n| `scope` | alias | no | A required one-row link of the entity: the app works inside one row of the linked entity at a time (a project, a branch), picked from a list of them, and every row it reads, counts and adds is that row\'s |\n| `reads` | `"shared"` | no | Every member reads every row, whatever the entity\'s read scope |\n| `writes` | `"children"` | no | The reader adds and edits the record\'s child rows but not the record itself |\n| `register` | [Register](#register) | no | The rows: which columns and filters, the order, how a row is added |\n| `record` | [Record page](#record-page) | no | What a row opens: the door, its sections and its comment thread |\n| `acts` | list of [Act](#act) | no | What the reader does to a record \u2014 each a press with its conditions and its write |\n| `checks` | list of [Check](#check) | no | Formulas that warn while they stand, or refuse the acts and saves they name |\n\n#### Theme\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `color` | text | yes | The tile\'s colour |\n\n#### Dashboard app\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | The app\'s name within the model \u2014 what `--from model.json#<alias>` picks |\n| `name` | text | yes | The job, in the words of the people who do it |\n| `description` | text | no | What the job is for, in a sentence |\n| `icon` | text | no | A lucide icon name the launcher tile draws |\n| `theme` | [Theme](#theme) | no | The launcher tile\'s colour |\n| `reads` | `"shared"` | no | Every member reads every row, whatever the entity\'s read scope |\n| `dashboard` | list of ([Metric](#metric) \\| [Breakdown](#breakdown) \\| [Trend](#trend) \\| [Pivot](#pivot) \\| [List](#list)) (at least one) | yes | The readings, in order \u2014 each over every row of its entity, windowed by the one period the reader switches |\n\n<!-- generated:end apps -->\n\n`writes: "children"` is a desk that adds and edits a record\'s child rows and never\nthe record itself. `reads: "shared"` lifts every read scope the app\'s tables state \u2014\nrefused where none of them states one.\n\n`scope` names a required one-row link of the entity: the app works inside ONE row of\nthe linked entity at a time \u2014 a project, a branch, a season. With no folder remembered\nit opens on the list of them, each with how many of the app\'s rows it holds; inside,\nevery read, count and reading narrows to that one, an add files its row under it (the\nlink is never asked), and the record states its folder in the header. A scope narrows\nthe view; who may read what stays the entity\'s `read_scope`. The folder link is never a\ncolumn, a filter, a search field, a fact or an add\'s question.\n\nA dashboard is `{ "alias", "name", "dashboard": [readings] }` with no entity, register\nor record: each reading reads every row of its entity, and one period the reader\nswitches (this month \xB7 30 days \xB7 this quarter \xB7 this year) windows each reading placed\nin time. A `list` reading lists rows, each opening in its `app`.\n\n```jsonc\n{ "alias": "overview", "name": "Depot overview", "dashboard": [\n { "metric": "visit", "where": { "stage": ["in_yard"] }, "label": "In the yard" },\n { "trend": "visit", "over": "arrived_on", "label": "Gate-ins" },\n { "breakdown": "visit", "by": "line", "value": "fees" },\n { "list": "visit", "where": { "overdue": true }, "columns": ["line"], "app": "gate", "label": "Past free days" }\n] }\n```\n\n### Register\n\n```jsonc\n"register": { "columns": ["service", "release"], "filters": ["customer", "service"], "create": ["container_no", "customer"] }\n```\n\n<!-- generated:start apps-register -->\n\n#### Register\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `columns` | list of alias | no | Fields read as columns after the row\'s title; the status and the figure are placed by the runtime |\n| `filters` | list of alias (at most 3) | no | Fields the reader narrows the rows by (a select, a member, a link, a date, a yes/no), at most 3 \u2014 the ones narrowed by daily, in that order, each on the register\'s line. The status is the chips, never one of these; a column\'s header sorts by a day or an amount |\n| `search` | list of alias (at least one) | no | The fields the search box matches (a link by its row\'s title); a pasted list searches each line and names the lines no row matched. Absent, every word of the row |\n| `sort` | [Register order](#register-order) | no | The order rows open in; absent, the first deadline soonest first, else the newest first |\n| `layout` | `"table"` \\| `"cards"` \\| `"calendar"` | no | `cards` for rows read by their picture, `calendar` for rows read by their day; absent, a table |\n| `create` | list of alias (at least one) \\| `false` | no | The fields asked when a row is added; absent, the title and every required field; false, rows are not added here |\n| `export` | `true` \\| [Register export](#register-export) | no | The rows in view can be saved: `true`, as a spreadsheet of the columns; a `template`, as one document made from them |\n| `readings` | list of ([Metric](#metric) \\| [Breakdown](#breakdown) \\| [Trend](#trend) \\| [Pivot](#pivot)) (at least one) | no | Readings of the register\'s own entity above its rows, over the rows in view \u2014 the search, the status and the filters narrow them |\n\n#### Register order\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | The field rows are ordered by |\n| `desc` | `true` | no | Latest or largest first; absent, soonest or smallest first |\n\n#### Register export\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `template` | alias | yes | A document template made into one file of every row in view, the rows listed under the entity\'s alias |\n\n<!-- generated:end apps-register -->\n\nThe row is the entity\'s `records` entry: its picture, title and the line under it,\nthen the status, the columns, and the figure at the right; the title, the status and a\ncolumn of days or amounts sort the rows by their header. The status is always the chips, with\ncounts, opening on the rows not closed \u2014 never one of `filters`. `filters` are at most\nthree, in order of use, each on the line with the search and the chips, the grouping one\nchip at its end; a member filter offers "mine". A\ncolumn the row already draws is refused. Absent `sort`, rows open soonest\ndeadline first where the row has a `due`, else newest first. `search` names the fields\nthe search box matches \u2014 a link by its row\'s title \u2014 and a pasted list searches each\nline, naming the lines no row matched; absent, it matches every word of the row.\n`export: true` saves the rows in view as a spreadsheet of the columns; `export: {\n"template" }` makes one document of them, the rows listed under the entity\'s alias.\n`readings` read the register\'s own rows above them: the rows in view, so the search,\nthe status and the filters narrow each one. One line states the metrics; the pictures\nwait behind its Charts press, as each reader last left them. Pressing a bar, a cell or\na metric with a `where` adds its condition as a filter.\n\n### Readings\n\nA reading is one number, or one picture of numbers, over rows. Where it stands decides\nwhich rows: a dashboard reads every row, a register\'s `readings` the rows in view, a\nrecord\'s reading block the child rows under the record. The picture is the runtime\'s,\nchosen from what the reading means \u2014 no key picks a chart.\n\n```jsonc\n{ "metric": "visit", "value": "fees", "where": { "paid": false }, "over": "arrived_on", "label": "Unpaid" }\n{ "breakdown": "visit", "by": "line" }\n{ "trend": "visit", "over": "arrived_on", "value": "fees" }\n{ "pivot": "visit", "rows": "size", "columns": "line" }\n{ "list": "visit", "where": { "stage": ["in_yard"] }, "columns": ["line"], "app": "gate", "label": "In the yard" }\n```\n\n<!-- generated:start apps-readings -->\n\n#### Metric\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `metric` | alias | yes | The entity whose rows are counted, or summed, into one number |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `target` | number | no | The number it is read against, where no `limits` bounds its value |\n| `label` | text | yes | What the reading is, in the reader\'s words |\n\n#### Breakdown\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `breakdown` | alias | yes | The entity whose rows are split into parts |\n| `by` | alias | yes | The single select, one link, member or yes/no each row is counted under |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the `by` field\'s label |\n\n#### Trend\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `trend` | alias | yes | The entity whose rows are counted, or summed, per period |\n| `over` | alias | yes | The date each row falls on |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the value\'s label, else the entity\'s |\n\n#### Pivot\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `pivot` | alias | yes | The entity whose rows are counted, or summed, in a grid |\n| `rows` | alias | yes | The single select, one link, member or yes/no down the side |\n| `columns` | alias | yes | The single select, one link, member or yes/no across the top |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the two fields\' labels |\n\n#### List\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `list` | alias | yes | The entity whose rows are listed, each in its `records` anatomy |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `columns` | list of alias | no | Fields read after each row\'s title |\n| `app` | alias | no | An app of this model over the same entity that a row opens in |\n| `label` | text | yes | What the reading is, in the reader\'s words |\n\n<!-- generated:end apps-readings -->\n\nA reading counts its rows, or sums the number `value` names \u2014 never a percent. `by`,\n`rows` and `columns` split the rows by one value each: a single select, a one-row link,\none member, or a yes/no. `over` is a stored date: a trend\'s periods, a metric\'s recent\nperiods beside its number, and what a dashboard\'s period windows. `where` keeps rows\nwhose select holds one of the options, or whose yes/no is the value, every entry ANDed.\n`target` reads a metric against a number where no `limits` bounds its value. A register\nreads only its own entity; a `list` stands only on a dashboard.\n\n### Record\n\n```jsonc\n"record": {\n "door": "page",\n "sections": [\n { "title": "Details", "fields": ["size", "line", "built_on"] },\n { "title": "Gate in", "at": ["arriving", "in_yard"], "fields": ["arrived_on", "truck"],\n "blocks": [{ "rows": "fee", "where": { "leg": ["in"] }, "expect": "kind", "columns": ["amount", "paid_by"] }],\n "acts": ["gate_in_paper"] },\n { "title": "Sell to a buyer", "description": "Moves the container to the buyer\'s stock once it has left.", "acts": ["sell"] }\n ],\n "comments": true\n}\n```\n\n<!-- generated:start apps-record -->\n\n#### Record page\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `door` | `"page"` \\| `"drawer"` | no | A page for work read at length; a drawer for rows worked one after another from the register. Either stacks its sections, and from three sections on navigates them \u2014 a page by a rail, a drawer by tabs; absent, a page where a section has `at` or is a `page` or the record has more than three sections, else a drawer |\n| `sections` | list of [Section](#section) (at least one) | no | The record in the order its work reaches it \u2014 each section its fields, then its blocks, then its acts. Absent, one section of every field a person writes that no other place shows |\n| `comments` | `true` | no | The record\'s comment thread: who wrote what and when, with a composer, beside the sections |\n\n#### Section\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `title` | text | yes | What the section is, in the reader\'s words \u2014 its heading, and its name on the record\'s rail or tab |\n| `description` | text | no | One sentence under the title: what the section is for, or what its act does |\n| `at` | list of alias (at least one) | no | Options of the record\'s status (or its milestones) during which this section is the work now \u2014 a step of the path the record moves through: its rail item reads now while the status holds one and this app has work there, done once the record\'s history says it went through one and it stands before the section that is the work now, and waiting otherwise; the item of a section with no `at` is its title alone, and a record with no `at` marks no item. A page opened from the register opens at it. It never locks what the section holds: its fields are written as the write rules and checks allow at any stage. An act it names is offered during these alone: its `when` holds the status to them, or on milestones it stamps the step after one of them \u2014 a detour and an exit are sections of their own, a detour stated before the step it returns to |\n| `fields` | list of alias (at least one) | no | The record\'s own fields in this section, in the order they are read |\n| `blocks` | list of [block](#blocks) (at least one) | no | What the section holds besides its own fields, under them, in order |\n| `acts` | list of alias (at least one) | no | Acts of this app drawn under the section\'s fields, their asks as more of them and what each lacks said above its button; the header draws no act, so every act on the record is named by one section. An act moving the status stands in the section of the step it is an outcome of \u2014 the one stage its `when` holds \u2014 unless it enters a detour or an exit the work reaches no other way, whose section it opens. A destructive act that is not one of its step\'s outcomes is a correction: it waits in the section heading\'s \u22EF, and one no section names in the header\'s \u22EF |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n| `page` | `true` | no | A page of its own, off the record\'s scroll: its own heading and a way back, reached from the record\'s rail after the scroll\'s sections, or from a link at its place where the record has no rail \u2014 for what makes the record worse stacked in it: a long list of related rows, a workspace of its own. Only on a page door, never a step (`at`) |\n\n<!-- generated:end apps-record -->\n\nThe record is its `sections`, stacked in the order the work reaches them. A section is\nits fields, the acts under them, then its blocks \u2014 the acts at its foot instead where one\nreads what a block holds or none of its fields \u2014 and holds at least one of them. From\nthree sections on, the record is navigated by their titles: a page by a rail that jumps\nto each (a strip of the same names on a phone), a drawer by tabs, the companion the last;\nwith fewer, the sections just stack. `description` is one sentence under the title.\n`at` names the status options (or the milestones) during which the section is the work\nnow \u2014 only on a record that truly moves through those stages: the rail marks it done,\nnow or waiting (an item of no `at` is its title alone), the section stands even holding\nnothing yet, and a page opened from the register opens scrolled to it \u2014 it never hides a\nfield, locks it or gates an act. `page: true` makes a section a page of its own under the\nrecord\'s, off its scroll, with a way back \u2014 rarely, for a long related list or a workspace\nof its own that would make the record worse stacked; never on a drawer or a step. The\nheader draws no act: every act on the record is named by the section whose work it is, and one no section names is refused, naming its likely\nsection \u2014 only a correction no section owns waits in the header\'s \u22EF beside Delete. An act\nmoving the status stands in the section of the step it is an outcome of (its `when`);\none entering a detour or an exit \u2014 a step the work reaches no other way \u2014 stands in that\nsection, which draws it and its gaps alone while it waits. A destructive act offered\nbeside another move of the same step (T\u1EEB ch\u1ED1i beside Duy\u1EC7t) is a button of that step; any\nother destructive act corrects what is done and waits in the \u22EF of what it acts on: its\nsection\'s heading, its row. `comments: true` puts the record\'s comment thread beside the\nsections, and a status `history` is read there too, never as a section.\n\nA field, a block and an act is each placed ONCE on a record \u2014 the header, one section;\na second place is refused, and so is a section holding nothing, an `at` on a record\nwith no status, and a section\'s act that is a child\'s or runs over the register\'s rows.\nAbsent `sections`, one section holds every field a person writes that the header does\nnot show. An editable field rests as its control and saves as it changes; everything\nelse is plain text. A section or a block with `when` is shown only while each named\nselect of the record holds one of its options. The one layout serves every entity: a\ncustomer or a product is sections without `at`.\n\n### Blocks\n\n```jsonc\n"blocks": [\n { "rows": "fee", "columns": ["kind", "amount"] },\n { "rows": "fee", "where": { "leg": ["out"] }, "columns": ["kind", "amount"] },\n { "timeline": "note" },\n { "files": ["photos", "papers"] },\n { "text": "remarks", "when": { "stage": ["held"] } },\n { "metric": "fee", "value": "amount", "label": "Fees" },\n { "breakdown": "fee", "by": "kind" },\n { "trend": "fee", "over": "charged_on", "value": "amount" },\n { "pivot": "fee", "rows": "kind", "columns": "method" },\n { "component": "YardMap" }\n]\n```\n\n<!-- generated:start apps-blocks -->\n\n#### Rows block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `rows` | alias | yes | A child entity whose rows belong to this record |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `columns` | list of alias | no | The child\'s fields read as columns after its title |\n| `create` | list of alias (at least one) \\| `false` | no | The child\'s fields asked when a row is added here; absent, its title and every required field; false, rows are not added here |\n| `where` | map of alias \u2192 list of alias (at least one) | no | Only the child rows whose single select holds one of these options \u2014 every entry ANDed, read so on the server; a row added here holds them (one leg\'s fees, of a record holding both legs\'). Each select is one the child requires, so no row stands in no block |\n| `expect` | alias | no | The child\'s title when the record holds one row per value of it \u2014 a single select (one per option, in order) or a one-row link to a catalog (one per row the link may point at): a value not yet filled reads as its own empty line, and no value holds two rows |\n| `of` | alias | no | With `expect` on a select: the record\'s own multi-select holding the options this record expects a line for \u2014 the options it holds, in order, rather than every option. Its options are among the title\'s |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n\n#### Timeline block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `timeline` | alias | yes | A child entity read as dated entries, newest first, with a composer where the app writes it \u2014 never the record\'s status history, which the record\'s companion reads beside its comments |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `create` | list of alias (at least one) \\| `false` | no | The child\'s fields asked when a row is added here; absent, its title and every required field; false, rows are not added here |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n\n#### Files block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `files` | list of alias (at least one) | yes | Files fields of this record, read as pictures and documents |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n\n#### Text block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `text` | alias | yes | A long text field read as prose |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n\n#### Metric block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `metric` | alias | yes | The entity whose rows are counted, or summed, into one number |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `target` | number | no | The number it is read against, where no `limits` bounds its value |\n| `label` | text | yes | What the reading is, in the reader\'s words |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n\n#### Breakdown block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `breakdown` | alias | yes | The entity whose rows are split into parts |\n| `by` | alias | yes | The single select, one link, member or yes/no each row is counted under |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the `by` field\'s label |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n\n#### Trend block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `trend` | alias | yes | The entity whose rows are counted, or summed, per period |\n| `over` | alias | yes | The date each row falls on |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the value\'s label, else the entity\'s |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n\n#### Pivot block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `pivot` | alias | yes | The entity whose rows are counted, or summed, in a grid |\n| `rows` | alias | yes | The single select, one link, member or yes/no down the side |\n| `columns` | alias | yes | The single select, one link, member or yes/no across the top |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the two fields\' labels |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n\n#### Component block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `component` | text | yes | A component of the app (src/components) that draws this block |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `when` | map of alias \u2192 list of alias (at least one) | no | Shown only while each named select of the record holds one of these options |\n\n<!-- generated:end apps-blocks -->\n\nA `rows` or `timeline` block reads a child entity through its link to the record \u2014\n`via` names it where the child links more than once. A rows block adds rows (asking\nthe child\'s title and required fields) and opens each in a drawer; a timeline reads\nits child\'s first date as the moment and its member field as who, with a composer\nwhere the app writes it. `create` names the child\'s fields an add here asks \u2014\nthe link to this record is filled, never asked \u2014 and `false` adds none. `expect`\nnames the child\'s title where the record holds one row per value of it \u2014 a select\'s\noptions, or the rows a link to a catalog may point at (`options_where`): every value\nis a line, one not yet filled a quiet line whose first column makes its row, the add\noffers only the values not yet held, and every write path refuses a second row of one\nvalue under one record. A field that starts from the catalog row (`default_from`)\nshows that value as its hint, taken with one press. `where` keeps only the child rows\nwhose single select holds one of its options \u2014 read so on the server, a row added\nthere holding them, its lines expected once per value among them (one leg\'s fees\nbeside the other\'s), and its foot totalled by the record\'s sum narrowed alike. A `metric`, `breakdown`, `trend`\nor `pivot` block is a reading (\xA7 Readings) of a child entity\'s rows under this record,\nread only \u2014 `via` names the link where the child has more than one. A `component` is\nthe app\'s own, exported from `src/components`.\n\n### Acts\n\n```jsonc\n"acts": [{\n "alias": "gate_out", "label": "Gate out",\n "when": { "stage": ["in_yard"] }, "requires": ["release", "seal"],\n "asks": ["seal", { "input": "note", "label": "Note", "type": "long_text" }],\n "set": { "stage": "gone", "left_on": "now", "left_by": "me", "remark": "input:note" },\n "confirm": true\n}]\n```\n\n<!-- generated:start apps-acts -->\n\n#### Act\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | The act\'s name within its app \u2014 its workflow is `act_<alias>` |\n| `label` | text | yes | The verb, as the reader says it |\n| `on` | `"record"` \\| `"rows"` \\| `"view"` | no | `rows` runs on the rows ticked in the register, `view` on every row the register shows (offered while the view is narrowed to its `when`; it states no `requires`); absent, on one record (its page and its row\'s menu) |\n| `of` | alias | no | A rows block\'s child entity the act works on: offered on each child row, its conditions and its write that row\'s |\n| `when` | map of alias \u2192 list of alias (at least one) | no | The act works only while each named select holds one of these options |\n| `requires` | list of alias (at least one) | no | Fields that must be filled first; a blocked act names each one missing |\n| `recommends` | list of alias (at least one) | no | Fields the act runs without and reads when filled: while one is empty, the act says what it will leave blank |\n| `asks` | list of (alias \\| [Act input](#act-input)) (at least one) | no | What the reader states when pressing it \u2014 fields of the record (written by the act) or inputs |\n| `set` | map of alias \u2192 (text \\| number \\| boolean \\| `null`) | no | What the act writes: an option alias, a value, `"now"`, `"me"` or `"input:<name>"` |\n| `workflow` | alias | no | An authored workflow (src/workflows/<alias>.ts) the act runs after its checks, for what `set` cannot say |\n| `template` | alias | no | A document template the act makes a file from \u2014 over several rows, one file of them all, the rows listed under the entity\'s alias |\n| `into` | alias | no | The files field of the one record the made document is kept in |\n| `confirm` | `true` | no | The press asks first; the question lists every active warning |\n| `danger` | `true` | no | The act destroys or cannot be undone |\n\n#### Act input\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `input` | alias | yes | A value the act asks for that is not a field of the record |\n| `label` | text | yes | What the reader is asked, in their words |\n| `type` | `"text"` \\| `"long_text"` \\| `"number"` \\| `"date"` \\| `"select"` \\| `"link"` \\| `"member"` | yes | What the reader states |\n| `options` | list of [Select option](#select-option) (at least one) | no | The choices a `select` input offers, one picked \u2014 written into a select holding the same option aliases |\n| `entity` | alias | no | The entity a `link` input picks one row of |\n| `required` | `true` | no | The act is refused without it |\n| `default` | alias | no | A field of the row whose value the input starts from, still changed at will |\n\n<!-- generated:end apps-acts -->\n\nAn act whose `when` does not hold is not offered; every one that holds stands at the\nfoot of the section naming it in `acts` \u2014 a correction in that section heading\'s \u22EF, one\nof a child row on its row (a correction in the row\'s \u22EF). One whose `requires` are\nempty is offered disabled, naming each; one whose `recommends` are empty names what\nit will leave blank and still presses; one a standing check blocks is offered\ndisabled in the check\'s words. An input\'s `default` is the row\'s field it starts from. The generated `act_<alias>` workflow re-checks all three on the\nserver, writes `set` and what was asked, and appends the status history where the\nact moves the status \u2014 each thing it asked copied onto the history row where the\nhistory has a field of the same name. `template` makes a document from the row and\nkeeps it in `into`; over several rows it makes one document of them all, the rows\nlisted under the entity\'s alias, and hands it back. A field an act writes is\nread-only everywhere else. A `select` input offers its own options and is written into\na select holding the same option aliases; a `link` input picks one row of its `entity`\nand a `member` input one person, each written into a field of its kind. A field an act\nboth `requires` and `asks` never disables the press \u2014 the act asks it, required.\n\n`on: "view"` runs on every row the register shows \u2014 its search, status and filters \u2014\neach row checked before any is written. `of` names a rows block\'s child entity: the\nact is offered on each child row (its menu and its drawer), its `when`, `requires`,\n`set` and checks are the child\'s, it runs on that row, and a check of the record that\nlocks the child rows locks it too.\n\n### Checks\n\n```jsonc\n"checks": [{ "field": "release_warning" }, { "field": "unpaid", "blocks": ["gate_out"] }, { "field": "locked", "blocks": ["edit"] }]\n```\n\n<!-- generated:start apps-checks -->\n\n#### Check\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | A yes/no or text formula of the record (or of the child `of` names): true or non-empty means the check stands, and a text formula\'s words are what the reader sees |\n| `of` | alias | no | A rows block\'s child entity this check is a formula of: standing on a child row, it refuses what it blocks of that row |\n| `blocks` | `"all"` \\| list of (alias \\| `"edit"` \\| `"delete"`) (at least one) | no | Acts, "edit" (the saves and the Delete), "delete" (the Delete alone) or "all" that the standing check refuses; absent, it only warns |\n| `resolve` | alias | no | An act of this app, on the rows the check stands on, that clears it: the check\'s line links to the act where it stands, and the act keeps its own conditions |\n\n<!-- generated:end apps-checks -->\n\nA check is a yes/no or text formula of the record: it stands while true or\nnon-empty, and a text formula\'s words are what the reader sees. Without `blocks`\nit warns; with them it refuses the acts named, `"edit"` the record\'s own saves (and\nits Delete), `"delete"` its Delete alone, or `"all"` \u2014 on the server, in every write it\nnames. A check `of` a rows block\'s child stands on each child row: it refuses that\nrow\'s saves (`edit`), its Delete (`delete`), the acts `of` that child it names, or\n`all` of them \u2014 never the record\'s acts.\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": { "company": { "label": "Customers", "fields": { "name": "Company name" } } },\n "no_sample_data": true\n }\n]\n```\n\n<!-- generated:start apply -->\n\n#### Package entry\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `package` | text | yes | The package id to copy in |\n| `bind` | map of alias \u2192 [Binding](#binding) | no | Entity alias \u2192 the labels this workspace calls that entity and its fields |\n| `no_sample_data` | boolean | no | Decline the package\'s sample records |\n\n#### Binding\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `label` | text | no | The workspace\'s own table name for this entity. |\n| `fields` | map of alias \u2192 text | no | Field alias \u2192 the workspace\'s own field name for it. |\n\n<!-- generated:end apply -->\n\n`bind` holds the LABELS this workspace 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### What a run remembers\n\nThe WORKSPACE remembers what each entity, field, select option, role and\ntemplate alias became here, plus which record each first row landed on and the\nfile each document path was uploaded as. The model file itself holds no live id\n\u2014 it is the portable half, and the same file applies to a demo workspace and to\na customer\'s \u2014 so the join lives where the things it names live, and one model\napplied to two workspaces holds two bindings that know nothing of each other.\n\nIt is the workspace\'s and not the file\'s because a model file is never\ncommitted: memory kept beside it is one author\'s disk, absent for a teammate, on\na second machine or after a delete \u2014 and every one of those goes quietly back to\nbinding by label, which is what grows the second table.\n\nEvery later verb binds through it: `apply`, `diff`, `app create --from`,\n`app regenerate --from` and `workspace build` take the remembered id first and\nfall back to a label only for an alias nothing has bound \u2014 a field you have just\nadded, or a workspace nothing has applied this model to. That is what makes a\nrelabel on EITHER side a rename rather than one thing the workspace lacks and\none the model lacks: `diff` prints one line under the alias\n(`order.state: label: model "Stage" \xB7 workspace "Giai \u0111o\u1EA1n"`), and `apply` binds\nthe thing it has always meant, reports the move and names the command that makes\nthe two agree \u2014 which side is right is yours to decide, not a reason to refuse\nthe run.\n\nA bound target the workspace no longer holds IS a refusal, by name: re-binding\nto whatever carries that label today is how the model comes to point at\nsomebody else\'s table. `lotics run restore_table` puts a deleted table back, and\n`lotics scaffold unbind <kind> <alias>` forgets the binding \u2014 after which the\nnext apply creates a new one and nothing joins it to what was there.\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?"],\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\n<!-- generated:start presets -->\n\n#### Preset\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `name` | text | yes | The trade, as the shelf lists it |\n| `description` | text | yes | What the trade\'s workspace keeps, in a sentence |\n| `questions` | list of text (at most 2) | yes | What to ask before choosing a variant \u2014 at most two |\n| `variants` | map of alias \u2192 [Variant](#variant) | yes | Slug \u2192 a branch of the model, taken where its `when` describes the business |\n\n#### Variant\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `when` | text | yes | The condition, in the words a person would use, that selects this variant |\n| `entities` | list of [Entity](#entity) | no | Tables this variant adds |\n| `fields` | map of alias \u2192 list of [Field](#field) | no | Entity alias \u2192 the fields this variant adds to that entity |\n\n<!-- generated:end presets -->\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 "records": { "job": { "title": "code" } },\n "write_rules": { "customer": { "natural_key": ["email"] } },\n "apps": [ /* the apps this business 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`**, **`records`**, **`write_rules`**, **`apps`** and **`apply`** mean\n exactly what they mean in the full form \u2014 `"rows"` are this business\'s real\n first records, `"records"` and `"write_rules"` may name the preset\'s entities\n as well as its own (an entity this file names replaces the preset\'s whole entry\n for it), `"apps"` the apps 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 "singular": "Customer",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n { "alias": "logo", "label": "Logo", "type": "files" },\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 "singular": "Order",\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 { "alias": "note", "label": "Note", "type": "text" }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "records": {\n "customer": { "title": "name", "image": "logo", "figure": "total_ordered" },\n "order": { "title": "code", "subtitle": ["customer", "placed_on"], "figure": "amount" }\n },\n "apps": [\n {\n "alias": "customers",\n "name": "Customers",\n "entity": "customer",\n "register": { "columns": ["tier"], "filters": ["tier"] },\n "record": {\n "sections": [\n { "title": "Account", "fields": ["tier"] },\n { "title": "Orders", "blocks": [{ "rows": "order", "columns": ["total"] }] }\n ],\n "comments": true\n }\n },\n {\n "alias": "orders",\n "name": "Orders",\n "entity": "order",\n "register": { "filters": ["customer"] },\n "record": { "door": "drawer" }\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 prints the counts, then each app as its\nreader will see it:\n\n```\nOK \u2014 2 tables, 11 fields, 2 links, 1 view, 1 role, 4 rows, 2 apps.\n\nApp customers \u2014 "Customers" over Customers (customer)\n Register \u2014 table (default), newest first (default)\n row [Logo] \xB7 Name \xB7 figure Total ordered\n columns Tier\n filters Tier\n add Name (default)\n Record \u2014 a drawer (default)\n header image Logo \xB7 title Name \xB7 figure Total ordered\n checks \u2014\n header \u22EF Delete\n sections 1. "Account"\n fields Tier\n 2. "Orders"\n block Orders (default) \u2014 rows of Orders through Customer; columns Total with VAT; add asks Order no. (default)\n thread the record\'s comments, beside the sections\n history \u2014\n acts \u2014\n Reads Customers, Orders\n\nApp orders \u2014 "Orders" over Orders (order)\n Register \u2014 table (default), newest first (default)\n row Order no. \xB7 under it Customer, Placed on \xB7 figure Amount\n columns \u2014\n filters Customer\n add Order no. (default)\n Record \u2014 a drawer\n header title Order no. \xB7 subtitle Customer, Placed on \xB7 figure Amount\n checks \u2014\n header \u22EF Delete\n sections 1. the record\'s details (default)\n fields Note\n thread \u2014\n history \u2014\n acts \u2014\n Reads Orders, Customers\n\n (default) marks what the model left to the system. `lotics app preview <model.json>#<app>` draws it.\n```\n\nEach `(default)` is a value the model left to the system: the door a record opens\nthrough \u2014 a drawer, since neither record has a stage or more than three sections \u2014 the\norder rows open in, what an add asks, and the order\'s one section \u2014 every field a\nperson writes that the header does not already show. The customer\'s orders\nare a block in its "Orders" section, never its own `Orders` link as a field as well:\none fact, one place. `Total ordered` is a rollup, so it is read and never asked; the\norders app\'s record opens in a drawer, because its rows are worked one after another.\n';
58003
+ var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing a workspace: its tables, fields, options, views, roles,\nfirst rows, how a row of each table is recognised, and the apps over them. The\nfull form spells it out; the `from` form names a published preset and carries\nonly what this business differs by (\xA7 Starting from a preset \u2014 prefer it whenever\na preset fits the trade). Each \xA7 is `lotics docs model/<section>`.\n\n**What a model composes with**\n\n- **Entities and fields** (\xA7 Entity, \xA7 Field) \u2014 the tables, their columns, options and links.\n- **Records** (\xA7 Records) \u2014 how a row of each entity is RECOGNISED: its title, the line\n under it, its picture, its status, the one number it stands for. Stated once per entity\n and read by every surface that draws one of its rows.\n- **Write rules** (\xA7 Write rules) \u2014 what a write finds, copies, bounds and picks among.\n- **Apps** (\xA7 Apps) \u2014 one register over one entity and the record each row opens: which\n fields go where, the acts, and the checks that guard them \u2014 or a dashboard of readings.\n How each thing LOOKS is the runtime\'s, one treatment per concept; no key here changes it.\n\n**The working order**\n\n1. Name the people and each one\'s JOB \u2014 the work they alone decide or write.\n2. The entities and fields those jobs touch, and a `records` entry for every entity an\n app lists, opens or picks.\n3. One app per job in `apps[]`, each one register over one entity.\n4. `lotics scaffold check model.json` \u2014 offline, every problem in one run, and each app\n printed as its reader will see it.\n5. `lotics app preview model.json#<app> --shots <dir>` \u2014 each app drawn from `rows`, nothing created.\n6. `lotics workspace build model.json --deploy` \u2014 the tables, then every app, live\n (`lotics setup model.json --email you@company.com` first where no account exists).\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. Renaming a FIELD is\n `lotics field rename <table> <field> "<new label>" --model <this file>`, which\n moves the platform, this file and every app bound to it together; deleting is\n `lotics run delete_table`. Neither goes through the file.\n- **The file\'s own majority is the language.** A model names no locale \u2014 which\n language it is in is what it mostly says, and `scaffold check` notes the label\n written the other way. The generated screens read the kit\'s pack, and a\n generated WRITE cannot: its refusals run on the server, so they are worded in\n that same majority. Mix the two and the workspace answers in two languages.\n- **`lotics scaffold diff model.json` says where the file and the workspace have\n come apart**, joined on label, entity then field \u2014 the join adoption itself\n makes. It exits 1 on any difference.\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- **`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\nEvery table of keys on this page is generated from the schema `scaffold check`\nparses the file with, so it is the whole of what a key may hold. The first is the\nfull form; the second names a preset instead of restating one (\xA7 Starting from a\npreset).\n\n<!-- generated:start top-level -->\n\n#### Model file\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `entities` | list of [Entity](#entity) | yes | The tables this model creates, with their fields, options and views |\n| `roles` | list of [Role](#role) | no | Workspace groups to create; members are added to them afterwards |\n| `templates` | list of [Template](#template) | no | Document templates whose content travels inline (html or email) |\n| `rows` | map of alias \u2192 list of [Row](#row) | no | First records, keyed by entity alias \u2014 written only where every table they land in is empty |\n| `records` | map of alias \u2192 [Record](#record) | no | How a row of each entity is recognised, keyed by entity alias. Every entity an app lists, opens or picks has one |\n| `write_rules` | map of alias \u2192 [Entity write rules](#entity-write-rules) | no | Entity alias \u2192 what a create of that entity finds, copies and refuses |\n| `apps` | list of ([App](#app) \\| [Dashboard app](#dashboard-app)) | no | The apps this workspace will have \u2014 each one register over an entity, or a dashboard of readings |\n| `preset` | [Preset](#preset) | no | The trade\'s branches, for a model published to be read; `setup` and `apply` scaffold the base alone |\n| `apply` | list of [Package entry](#package-entry) | no | Published packages copied in once this model\'s tables exist, in this order |\n\n#### From file\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `from` | text | yes | The preset this model starts from, by its slug |\n| `variants` | list of text | no | The preset\'s variant slugs to merge onto its base, in order |\n| `rename` | map of alias \u2192 [Binding](#binding) | no | Entity alias \u2192 what this business calls that table and its fields |\n| `entities` | list of [Entity](#entity) | no | Tables this business has that the preset does not declare |\n| `rows` | map of alias \u2192 list of [Row](#row) | no | First records, keyed by entity alias \u2014 written only where every table they land in is empty |\n| `records` | map of alias \u2192 [Record](#record) | no | How rows of the preset\'s entities and this business\'s own are recognised, entity by entity over the preset\'s |\n| `write_rules` | map of alias \u2192 [Entity write rules](#entity-write-rules) | no | Create-time clauses on the preset\'s entities and this business\'s own, over whatever the preset declares |\n| `apps` | list of ([App](#app) \\| [Dashboard app](#dashboard-app)) | no | The apps this workspace will have \u2014 each one register over an entity, or a dashboard of readings |\n| `apply` | list of [Package entry](#package-entry) | no | Published packages copied in once this model\'s tables exist, in this order |\n\n<!-- generated:end top-level -->\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 what an agent states to make an app,\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<!-- generated:start entity -->\n\n#### Entity\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable entity alias, unique within the contract |\n| `label` | text | yes | Display label used as the table name at scaffold time |\n| `singular` | text | no | One row of this table, in the business\'s own words \u2014 what a create\'s button and panel name |\n| `description` | text | no | The table\'s description, written onto the table in the workspace |\n| `writes` | `false` | no | false: no person writes this table\'s rows \u2014 only the writes that keep it do, and no app opens or edits one |\n| `fields` | list of [Field](#field) (at least one) | yes | The table\'s columns |\n| `read_scope` | [Read scope](#read-scope) | no | Which rows a member reads. Absent, every member with access to the table reads every row. |\n| `unique` | list of list of alias (at least one) (at least one) | no | Sets of fields whose values no two live rows share \u2014 each a list of field aliases (text, number, date, a single select, or a link of cardinality "one"). A create or update landing a second row with the same values is refused. |\n| `views` | list of [View](#view) | no | Saved views, in the order they are listed; with none, the table still opens on its default grid |\n\n#### Read scope\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `any` | list of ([Read scope by role](#read-scope-by-role) \\| [Read scope by member](#read-scope-by-member) \\| [Read scope by option](#read-scope-by-option)) (at least one) | yes | A row is readable when ANY of these holds |\n\n#### Read scope by role\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `member_of` | alias | yes | A role alias: whoever is in the group it binds to reads the row |\n\n#### Read scope by member\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `through` | list of alias (1\u20133) | no | select_record_link aliases from this entity outward, each of cardinality "one" \u2014 the clause\'s field is on the entity the last hop lands on |\n| `field` | alias | yes | A select_member field alias on this entity, or on the entity `through` lands on |\n| `is` | `"self"` | yes | The members this column names on a row read that row |\n\n#### Read scope by option\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `through` | list of alias (1\u20133) | no | select_record_link aliases from this entity outward, each of cardinality "one" \u2014 the clause\'s field is on the entity the last hop lands on |\n| `field` | alias | yes | A single-select field alias on this entity, or on the entity `through` lands on |\n| `is` | list of alias (at least one) | yes | Its option aliases whose rows are readable \u2014 naming none would hide every row |\n\n<!-- generated:end entity -->\n\n**`unique` is a set of values no two live rows share.** Each entry names fields\nof this entity holding ONE value \u2014 text, number, date, a single select, a link\nof cardinality `"one"` \u2014 and a create or update landing a second row with the\nsame values is refused. A set of one text field is that field\'s own `unique:\ntrue`, so it is refused here. A create carries each set in the names its panel\nsends, so the panel can name the duplicate before the write does, and `scaffold\ncheck` prints it under *Who writes what* as `unique: Bed + Sown on`.\n\n**`singular` is what a create says** \u2014 `New Order`, `Add Claim line`, `H\u1ED3 s\u01A1\nm\u1EDBi` \u2014 while `label` names the table, so without it the button reads `New\nOrders`. Nothing derives it: English plurals are irregular, and no language is\nexempt. In a language without plural forms it is usually the label itself,\nless any word for the collection. `scaffold check` prints it beside the table\nunder *Who writes what* and REFUSES a model where a table some create opens \u2014 a\nregister\'s own, or a record section\'s add \u2014 states none.\n\n**`writes: false` says only the workspace\'s automations write these rows** \u2014 a\nlog of what the system sent, a copy of what another system holds. No app opens,\nedits or files one: a record listing them keeps the section and opens each row\nat rest, with no Add; a screen over the entity operates at most the rows its\nrecord owns; a party of it is picked, never found or minted; and the generator\nwrites no create, no update and no `lotics.writes` entry for it. `scaffold\ncheck` prints it under *Who writes what* as `written by automations` and never\nnotes it as created nowhere. A `lifecycle` on it is refused \u2014 a row walked\nthrough stages is worked by a person \u2014 and so is a publish desk over it. Absent,\npeople write the rows; `true` is not a value.\n\n**`read_scope` is a ROW rule, enforced by the platform.** It is resolved at\nscaffold into the table\'s own row filters, so an app, a workflow reading for a\nviewer, and the API all answer the same rows \u2014 a per-record visibility field the\napp merely honours is a convention, not a gate. A `"self"` clause reads a column\nof one member or several, and every role alias and option alias a clause names\nmust be one this model declares. Scaffold writes the rule onto a table it\nCREATES; a table it adopted that ALREADY CARRIES a rule keeps that one, because\nthe rule is the workspace\'s own statement about its rows \u2014 and a run whose model\nstates a different rule reports the entity rather than leaving the claim silent.\n\n**An app may state that its sharing is its read gate: `"reads": "shared"`.** An\napp reads as its owner, so the rule reaches a viewer only as the predicate every\nquery and editor guard of every app over the entity carries \u2014 right for a desk of\none\'s own rows, wrong for a desk whose audience its sharing already decides, where\nwidening the rule meant a role group nobody remembers to fill. Stated on an APP,\nits queries, pickers and guards carry no entity\'s rule and whoever the app is\nshared with reads and writes every row it draws; every other app and the table\'s\nown filters keep the rule. Share it deliberately. Refused on an app none of whose\ntables states a `read_scope`; `scaffold check` prints it on the app\'s line and\nnames the app beside the rule it is exempt from.\n\n**The rows under a private record INHERIT its rule.** An entity that states no\n`read_scope` and hangs under one that does \u2014 through its `parent` link, over one\nhop or several \u2014 is read by the ancestor\'s rule, answered through that link, on\nits table\'s own filters and in every query and guard alike; nothing is restated,\nso a child needs none of the ancestor\'s columns. Stating a rule on the child\nkeeps that one instead, an ancestor with no rule passes nothing down, and a row\nhanging further under the scoped one than a row filter reaches is refused by\nname \u2014 state a rule on it. So is a hop over a link that names more than one row:\nthe rule would admit a reader any one of them admits while the editor\'s guard\nreads the first, so give the link `"cardinality": "one"` or state a rule on the\nchild.\n\n**ONLY the `parent` role is walked.** A register a scoped record reaches by any\nother link \u2014 the rows that NAME it \u2014 is read by that record\'s id with no rule\ntravelling to it, so it is refused until it states one of its own.\n\n## Field\n\nEvery field carries the keys below, and its `type`\'s section adds the rest; a\ntype whose section names no `default` takes none. `label` may not contain `{` or\n`}` (formulas reference fields by label at the platform level). Every row in `rows` states each\n`required` field it carries (a default is not applied to them), and a required\nLINK is written with its row: the entity it names is created first, and entities\nwhose required links name each other are refused, since none of their rows could\never be created.\n\n<!-- generated:start field -->\n\n#### Field\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `type` | `"text"` \\| `"number"` \\| `"date"` \\| `"boolean"` \\| `"select"` \\| `"select_member"` \\| `"select_record_link"` \\| `"files"` \\| `"formula"` \\| `"rollup"` \\| `"lookup"` \\| `"autonumber"` | yes | What the field holds \u2014 each type takes the further keys its own section lists |\n| `alias` | alias | yes | Stable local alias, unique within the entity |\n| `label` | text | yes | Display label used as the field name at scaffold time |\n| `description` | text | no | The field\'s description, written onto the field in the workspace |\n| `required` | boolean | no | Refuse a record whose cell for this field is empty. Scaffold writes it onto the field, and every write path \u2014 create, update, an agent\'s tool call, a workflow\'s set \u2014 refuses the row by field name. |\n\n<!-- generated:end field -->\n\n### `text`\n\n<!-- generated:start field-text -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | text | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. |\n| `unique` | boolean | no | Unique values required |\n| `format` | `"text"` \\| `"link"` \\| `"markdown"` | no | How the words are drawn \u2014 plain, as a link that opens, or as markdown |\n\n<!-- generated:end field-text -->\n\n### `number`\n\n<!-- generated:start field-number -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | number | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. |\n| `format` | `"number"` \\| `"currency"` \\| `"percentage"` | no | What the figure is \u2014 a plain number, money in `currency`, or a percent |\n| `currency` | text | no | ISO 4217 code |\n\n<!-- generated:end field-number -->\n\n`format` is what the number IS, and every surface reads it: `currency` prints as\nmoney in the code the row or the field states, `percentage` as a whole percent\nwith its sign. An ABSENT number is drawn absent \u2014 the one exception is a\n`sum` or count rollup the plan reads as a **`measure`**: that is the thing\naccumulated toward a bound, so nothing accumulated yet is zero and the meter\ndraws it. The same rollup read as an `amount` keeps its blank, and so does every\nother role: nothing added to what a row is WORTH means unpriced, not free. A\n`min`, an `avg`, a percentage of nothing and every FORMULA stay blank in any\nrole, because none of them has an answer to give. This is why the pair on one\nscreen reads two ways \u2014 what has come in against what is owed \u2014 and why a\nmeasure\'s own LIMIT, an amount, leaves an unquoted row out of the count rather\nthan reporting it as nothing collected. **AND WHERE THAT LIMIT IS ABSENT \u2014 OR\nZERO \u2014 THERE IS NO LEVEL AT ALL**: a level is a reading AGAINST a bound, so a row\nthat states no bound, or a bound of nothing, draws nothing \u2014 cell, fact and all \u2014\nrather than a numerator whose whole meaning was the comparison. "Collected 0"\nbeside a blank total reads as money against a job worth nothing, and "0 of 0"\nagainst a count of nothing owed claims a comparison nobody can make. A measure the model gives no limit is a plain figure\nand is unaffected. A share is stored in percent units \u2014 68.1 is 68.1 % \u2014 and the\ncolumn, the fact behind it, the meter it is judged by and the figure over the\nregister all say so.\n\n### `date`\n\n<!-- generated:start field-date -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | text | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. A date string in the field\'s format. |\n| `format` | `"date"` \\| `"datetime"` \\| `"date_range"` \\| `"datetime_range"` | no | Whether the field holds a day or a moment, alone or as a span |\n| `timezone` | text | no | IANA timezone |\n| `derive_from` | `"created_at"` \\| `"updated_at"` | no | Auto-populate from the row\'s system timestamp; the field becomes read-only. |\n\n<!-- generated:end field-date -->\n\nA `default` is refused beside `derive_from`: the platform stamps that date.\n\n### `boolean`\n\n<!-- generated:start field-boolean -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | boolean | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. |\n\n<!-- generated:end field-boolean -->\n\n### `select`\n\n<!-- generated:start field-select -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default` | list of alias | no | Value pre-filled into a new record when none is supplied for this field. Applied on create only \u2014 existing records are never backfilled. Option alias(es) this field declares \u2014 one for single-select. |\n| `options` | list of [Select option](#select-option) (at least one) | yes | The choices, in the order every picker and every ladder lists them |\n| `multi` | boolean | no | Allow multiple selections |\n\n#### Select option\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable local alias, unique within the field |\n| `label` | text | yes | Display label for the option |\n| `color` | [colour](#select) | yes | The colour the option\'s badge is drawn in |\n| `mark` | [mark](#select) | no | The option\'s own mark, drawn in place of its colour dot wherever the option is shown: the channel it is ({kind: "brand", name: one of facebook, instagram, threads, meta, tiktok, google-ads, zalo, linkedin, x, google-meet, youtube}) or a kit glyph ({kind: "icon", name: "wrench"}). Every option of a field has one, or none does. A mark a reader does not draw falls back to the dot |\n\n<!-- generated:end field-select -->\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\nAn option may carry its own `mark`, drawn in place of its colour dot wherever\nthe option is shown \u2014 a stage, a chip, a filter, a fact, an entry of a log, a\nlookup of the select on another entity: the channel it IS\n(`{"kind": "brand", "name": "tiktok"}` \u2014 one of `facebook`, `instagram`,\n`threads`, `meta`, `tiktok`, `google-ads`, `zalo`, `linkedin`, `x`,\n`google-meet`, `youtube`), or a glyph from the kit\'s curated Lucide set\n(`{"kind": "icon", "name": "wrench"}`; a name outside it is refused naming the\nnearest ones). Every option of a select has one, or none does: a run of chips\nwhere one carries no mark reads as the one missing something. `apply` writes the\nmarks onto the table, sets one an adopted option lacks, and reports one it wears\ndifferently rather than overwrite it.\n\n```jsonc\n"options": [\n { "alias": "short_video", "label": "Short video", "color": "zinc", "mark": { "kind": "brand", "name": "tiktok" } },\n { "alias": "print", "label": "Print", "color": "amber", "mark": { "kind": "icon", "name": "newspaper" } }\n]\n```\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. With no role it is still drawn \u2014\nface and name, ranked as a `party` \u2014 in a screen\'s `columns` and in the register\na record draws of these rows.\n\n<!-- generated:start field-select_member -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `multi` | boolean | no | Allow multiple selections |\n\n<!-- generated:end field-select_member -->\n\n### `select_record_link`\n\n<!-- generated:start field-select_record_link -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `target_entity` | alias | yes | Alias of the entity this field links to |\n| `sync_both_ways` | boolean | no | Create a paired link field on the target entity for bidirectional sync |\n| `paired_field_alias` | alias | no | The pair edge of a bidirectional link: the field alias ON THE TARGET ENTITY that is this link\'s sync partner. Both sides of a pair carry it, each naming the other. Scaffold creates whichever side it reaches first WITH the pairing (the platform auto-creates the partner) and binds the partner alias to the auto-created field \u2014 without this edge the two contract fields would be created independently and collide with the auto-created partner. |\n| `cardinality` | `"one"` \\| `"many"` | no | How many linked records this field holds. Default \'many\'. \'one\' holds a single row and needs no partner; where the link IS paired, the partner side holds many. |\n| `display_field_aliases` | list of alias | no | Field aliases on the target entity shown as the link\'s display text / picker columns |\n\n<!-- generated:end field-select_record_link -->\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.\n\n**A single-valued link needs no partner.** `"cardinality": "one"` on its own is a\nlink that holds one row \u2014 one customer on an invoice, one project on a device \u2014\nand nothing is created on the target. The mirror invariant belongs to a PAIRED\nlink: pair a link when the target\'s own record should list what points at it, and\nleave it unpaired when it should not. Either way the record plan draws the\nrelation as a section on the side it points at, so an unpaired link costs the\ntarget nothing.\n\n### `files`\n\nNo keys beyond every field\'s; a row attaches documents to it (\xA7 Rows).\n\n### `formula`\n\n<!-- generated:start field-formula -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `formula` | [Formula](#formula) | yes | Formula config. The expression references other fields on the SAME entity by alias in braces, e.g. `{quantity} * {unit_price}`. |\n\n#### Formula\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `expression` | text | yes | The expression, over fields of THIS entity by alias in braces \u2014 `{quantity} * {unit_price}` |\n| `format` | `"number"` \\| `"currency"` \\| `"percentage"` \\| `"link"` | no | Display format. \'number\' / \'currency\' / \'percentage\' for numeric results; \'link\' for text-output formulas that return a URL \u2014 renders the result as a clickable link. |\n| `currency` | text | no | ISO 4217 currency code, e.g. USD, VND, EUR |\n| `output_type` | `"number"` \\| `"text"` \\| `"date"` \\| `"datetime"` \\| `"boolean"` | no | What the expression YIELDS \u2014 the kind the platform infers at write time, declared here so the offline checks can read it. `format` beside it is how that result is drawn, not what it is. Ignored on the wire (the platform re-infers it); `lotics scaffold export` writes the inferred value. |\n\n<!-- generated:end field-formula -->\n\n`output_type` is what lets a role or a screen clause accept a computed value: a\ncaption over a derived name (`output_type: "text"`), a period over a settled date\n(`"date"`). A formula declaring neither it nor a `format` says nothing about its\nresult, and every rule that needs one refuses it by name.\n\n**A select reaches a formula as the KEYS of its chosen options**, a list \u2014\nnever their labels, and never their aliases \u2014 and a model has no keys: the\nworkspace mints them when the table is made. So an option is named in a formula\nas `{field:option}`, both aliases, and the copy writes that option\'s key in its\nplace: `includes({kind}, {kind:crate})` for a select holding one or several,\n`{kind}[0] == {kind:crate}` for a single one. A select compared to its own words\n(`{kind} != "Crate"`) matches no row and computes the other branch everywhere,\nso `check` refuses it and names the token. A select looked up from another\nentity is tested there, in a formula of its own, and that result looked up.\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n<!-- generated:start field-rollup -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `source_field_alias` | alias | yes | Alias of a select_record_link field on this entity to roll up from |\n| `aggregate_option` | [aggregation](#rollup) | yes | Aggregation operation. Its `field_key` names a field alias on the linked entity. |\n| `filter` | [filter](#views) group | no | Only linked records matching this filter are aggregated. Every `field_key` in it names a field alias on the linked entity, and a select condition\'s value names an option alias there. A traversal node reaches past that entity, so its `path` hops and inner `field_key` are fully-qualified `entity.field` aliases. |\n\n<!-- generated:end field-rollup -->\n\n`aggregate_option` is `{ "operation": \u2026, "field_key": \u2026 }` \u2014 `field_key` a field\nalias on the linked entity (`count` may omit it), and `operation` one of `count`,\n`sum`, `avg`, `median`, `min`, `max`, `range`, `empty`, `filled`,\n`percent_empty`, `percent_filled`, `unique`, `percent_unique`, `earliest`,\n`latest`, `date_range`, `checked`, `unchecked`, `percent_checked`,\n`percent_unchecked`. The operation must be one the aggregated field\'s type\nallows \u2014 `sum` over a number, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n<!-- generated:start field-lookup -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `source_field_alias` | alias | yes | Alias of a select_record_link field on this entity to look up through |\n| `lookup_field_alias` | alias | yes | Alias of the field on the linked entity to display |\n| `order_by` | [Lookup order](#lookup-order) | no | Show ONE linked row\'s value \u2014 the first in this order \u2014 rather than every linked row\'s |\n\n#### Lookup order\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field_key` | text | yes | A field alias on the linked entity the rows are ordered by |\n| `direction` | `"asc"` \\| `"desc"` | yes | Which end of that order the one row is taken from |\n\n<!-- generated:end field-lookup -->\n\n### `autonumber`\n\n<!-- generated:start field-autonumber -->\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `prefix` | text | no | Literal prefix prepended to every display value (e.g. \'KH-\' \u2192 \'KH-001\'). Ignored when `template` is set. |\n| `padding` | integer | no | Zero-pad the integer to this width. Default 1 (no padding). 3 \u2192 \'001\', \'012\', \'123\', \'1234\' (overflow uses the actual width). Ignored when `template` is set. |\n| `template` | text | no | Format template with placeholder tokens evaluated at insert time. Tokens: {N} (raw integer), {N:W} (zero-padded to width W, e.g. {N:3} \u2192 001), {YEAR} (4-digit year), {YEAR:2} (2-digit year), {MONTH} (2-digit month), {DAY} (2-digit day). Date tokens use the workspace timezone. Example: \'HM-{YEAR}-{N:3}\' yields \'HM-2026-001\'. Stored as the composed string; subsequent template edits do NOT re-format existing rows (date tokens would lose the original creation date). |\n\n<!-- generated:end field-autonumber -->\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<!-- generated:start views -->\n\n#### View\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable view alias, unique within the entity |\n| `label` | text | yes | Display name of the view |\n| `description` | text | no | The view\'s description, written onto the view in the workspace |\n| `columns` | list of [View column](#view-column) (at least one) | no | The columns the view shows, in this order and no others; absent, every field |\n| `filters` | [filter](#views) | no | The rows the view keeps |\n| `sort` | [sort](#views) | no | The order the view reads its rows in |\n| `summary` | map of text \u2192 text | no | Field alias \u2192 the operation its footer cell states |\n| `frozen_columns` | integer \\| `null` | no | How many leading columns stay in place while the rest scroll |\n\n#### View column\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field_alias` | alias | yes | A field of this entity |\n| `visibility` | `"visible"` \\| `"hidden"` | yes | Field visibility state: \'visible\' = shown to everyone, \'hidden\' = not shown by default but members can toggle |\n| `width` | number | no | The column\'s width, in pixels |\n\n<!-- generated:end views -->\n\nA filter is a group \u2014 `{ "node_type": "group", "logic": "and" | "or",\n"children": [ \u2026 ] }` \u2014 or one condition on its own, `{ "node_type":\n"condition", "type": "select", "field_key": "tier", "operator": "has_any_of",\n"value": ["gold"] }`. A sort is a list of `{ "field_key": \u2026, "order": "asc" |\n"desc" | null }`. A condition\'s `type` is the field\'s type and its `operator` is\none that type admits \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## Roles\n\nA role becomes a workspace group.\n\n<!-- generated:start roles -->\n\n#### Role\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | Stable role alias, bound to a workspace group when the starter is copied |\n| `label` | text | yes | The group\'s name in the workspace |\n\n<!-- generated:end roles -->\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes. `{{name}}` in the content is filled from the workflow\'s data.\n\n<!-- generated:start templates -->\n\n#### Template\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `type` | `"html"` \\| `"email"` | yes | html is a page a workflow renders to a PDF; email is a message a workflow sends |\n| `alias` | alias | yes | Stable template alias, unique within the contract |\n| `label` | text | yes | The template\'s name in the workspace |\n| `content_sha256` | text | no | sha256 (64-char lowercase hex) of the template content \u2014 inline: the utf-8 content string; file-backed: the bytes_ref bytes. The modified-detection reference. Optional in the schema so contracts published before template sha still parse; REQUIRED at publish (validatePackageContract). |\n| `content` | text | yes | Inline template content; placeholders reference field aliases |\n\n<!-- generated:end templates -->\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<!-- generated:start rows -->\n\n#### Row\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `ref` | text | yes | Local handle for this row, referenced by other rows\' link fields |\n| `fields` | map of text \u2192 any value | yes | Field alias \u2192 the value, read against the field\'s declared type |\n\n<!-- generated:end rows -->\n\nA `ref` is lowercase letters, digits and underscores, and 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, the WORKSPACE holds\nwhich record each row became, under the row\'s own `<entity>:<ref>`:\n`delete_records` over them is how a seeded set is reset, and applying again\nre-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**Documents can be attached on their own, afterwards.** Rows land only into empty\ntables, so a model whose binaries were added after the first apply has no second\napply to carry them: `lotics scaffold apply model.json --documents` writes ONLY\nthe `files` cells, onto the records the first run created, found by each row\'s\nown `ref` in the binding the workspace holds \u2014 so a row added to or removed from\nthe file since changes nothing about the rest. Running it twice attaches nothing\nthe second time: the same path keeps the same file, and a cell is a set.\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- on a date that holds its hour (`format: "datetime"`), that hour after the day, and always stated:\n `@today 14:30`, `@today+1 06:00`\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## Records\n\n`records` says how a row of each entity is RECOGNISED \u2014 once per entity, keyed by\nentity alias \u2014 and every surface that draws one of its rows reads the same\nstatement: the register\'s row, a picker\'s option, a link\'s chip, a child table\'s\nrow and the record\'s header. Every entity an app lists, opens or picks has one.\n\n```jsonc\n"records": {\n "visit": {\n "title": "container_no",\n "subtitle": ["customer", "arrived_on"],\n "image": "photos",\n "status": { "field": "stage", "closed": ["gone"], "history": "visit_history" },\n "figure": "total_fees",\n "due": ["free_until"]\n },\n // A release order read against what it allows: a meter wherever it shows.\n "release": { "title": "release_no", "figure": "issued", "limits": { "issued": "units" } },\n // A dossier\'s stage is the last date it reached \u2014 no stored select.\n "dossier": { "title": "applicant", "status": { "milestones": ["received_on", { "field": "appraised_on", "when": { "kind": ["loan"] } }, "signed_on"], "closed": ["signed_on"] } }\n}\n```\n\n<!-- generated:start records -->\n\n#### Record\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `title` | alias | yes | The field that names a row \u2014 the first thing every surface shows. A link names the row by the linked row (a job read by its customer), never a code or an autonumber |\n| `subtitle` | list of alias (1\u20132) | no | Up to two fields read under the title wherever a row is drawn (a code, a date, a party) |\n| `image` | alias | no | A files field that pictures the row |\n| `status` | [Status](#status) | no | The stage a row moves through \u2014 a single select or its milestone dates \u2014 drawn as a badge beside its title |\n| `figure` | alias | no | The one number the row stands for, read at its right (a total, a quantity left) |\n| `limits` | map of alias \u2192 (alias \\| number) | no | A number read against a bound \u2014 a field of the same row or a constant \u2014 drawn as a meter wherever it shows |\n| `gates` | map of alias \u2192 (alias \\| number) | no | A pass mark on a meter `limits` bounds \u2014 a field of the same row or a constant; a percent field is its share of the bound, any other number an amount. The meter marks it and fills complete once past it |\n| `tolerance` | map of alias \u2192 number | no | How far past its `limits` bound a meter may stand, in percent of the bound: the bound is an amount expected (received against ordered), not a cap. Without one the bound is a cap, and past it is an overrun |\n| `due` | list of alias (at least one) | no | Dates that are deadlines: each counts down and turns overdue |\n| `starts` | map of alias \u2192 `"today"` \\| `"me"` | no | What a field of a row being added starts at, where that start is certain: `today` a date (a moment starts now), `me` a member, the reader adding it; every other field starts empty unless the reader narrowed the register to a value or the field declares a `default` |\n\n#### Status\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | no | The single select that is the row\'s state \u2014 or `milestones` instead |\n| `milestones` | list of (alias \\| [Milestone](#milestone)) (at least 2) | no | Dates in the order the work reaches them, instead of a stored select: the stage is the last one filled, ticking one stamps today, and clearing one clears every later one |\n| `closed` | list of alias (at least one) | no | Options (or milestones) that end the work: a register opens on the rows not at them, and the badge reads muted |\n| `history` | alias | no | A child entity every status change appends one row to (its link to this entity, the new status, the moment and who) \u2014 written by the app\'s own writes, never a table automation; with `field` only |\n\n#### Milestone\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | A date field: the stage is reached on the day it holds |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | yes | The stage applies only while each named select holds one of these options, and each named stored yes/no is this value; otherwise the row skips it |\n\n<!-- generated:end records -->\n\n- **The title names the row.** A link names it by the linked row (a job read by its\n customer); a code or an autonumber is refused as a title \u2014 the code is the subtitle.\n- **The status** is one single select. `closed` options end the work: a register opens\n on the rows not in them. A `history` entity gets one row per move \u2014 its link to this\n entity, the new status (a select holding the same option aliases, or text), the\n moment and who \u2014 appended by the app\'s own writes that move the status, never by a\n table automation. A `history` the model does not declare is derived, and needs no\n `records` entry of its own.\n- **Milestones** are the alternative to a stored select: dates in the order the work\n reaches them, the stage being the last one filled (never stored). A milestone with a\n `when` applies only while its select holds those options and its stored yes/no that\n value; otherwise the row skips it.\n The record draws them as one block where ticking stamps today and clearing one clears\n every later one; every save and act re-checks that a date comes after each earlier one\n that applies. `closed` names the milestones that end the work. A register\'s add may ask\n the first milestone, never a later one; an act may stamp one, never clear it.\n- **A limit** reads a number against a bound \u2014 another field of the row, or a constant.\n A `gates` entry marks a pass on that meter: a percent field is its share of the\n bound, any other number an amount.\n- **`starts`** is where an add\'s field begins, stated only where that is certain \u2014 a\n log\'s own day at `today`, a request\'s requester at `me`. Nothing else starts a field\n but its declared `default` and what the reader narrowed the register to.\n\n## Write rules\n\n`write_rules` is what a WRITE meets, keyed by entity alias \u2014 this file\'s own:\n`check` proves it and the workspace never sees it. Every generated write of an\napp (`create_<entity>`, `update_<entity>`, each act) re-checks these on the\nserver; the screen only mirrors them.\n\n```jsonc\n"write_rules": {\n // A customer is RECOGNISED by their address. An add that names one \u2014 from the\n // customers register or from a picker\'s "new" \u2014 reuses the row it matches and\n // opens one only where nothing does, so the book never grows a second Acme.\n "customer": { "natural_key": ["email"] },\n "order_line": {\n "fields": {\n // A line of nothing is not a line. Refused on create and on update.\n "quantity": { "min": 1, "max": 9999 },\n // Shipped inside its order\'s window: a bound read off the row a link names\n // is held from both sides \u2014 moving the order\'s dates never strands a line.\n "ships_on": { "min": "order.placed_on", "max": "order.due_on" },\n // The price is fixed at the moment of ordering \u2014 COPIED off the product,\n // not looked up for ever after.\n "unit_price": { "default_from": "product.price" },\n // Nothing is sold off an empty shelf. The picker reads only the rows that\n // answer this, and the write refuses the same rows again.\n "product": {\n "options_where": {\n "node_type": "group", "logic": "and",\n "children": [{ "node_type": "condition", "type": "number",\n "field_key": "in_stock", "operator": "greater_than", "value": 0 }]\n }\n }\n }\n },\n // One booking per room at a time, while it is held: a create or a save whose\n // span overlaps another held booking of the same room is refused.\n "booking": {\n "fields": { "ends_on": { "min": "starts_on" } },\n "no_overlap": { "from": "starts_on", "to": "ends_on", "by": ["room"], "while": { "state": ["held"] } }\n }\n}\n```\n\n<!-- generated:start write-rules -->\n\n#### Entity write rules\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `natural_key` | list of alias (at least one) | no | The field aliases a row of this entity is RECOGNISED by. A write that names a row of this entity by these values reuses the row it finds, minting one only where nothing matches. |\n| `fields` | map of alias \u2192 [Field write rule](#field-write-rule) | no | Field alias \u2192 what that field\'s value is copied from, bounded by or picked among |\n| `no_overlap` | [No overlap](#no-overlap) | no | No two rows hold overlapping spans \u2014 a create or update that would is refused |\n\n#### Field write rule\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `default_from` | text | no | Copy this field\'s value from the linked row at CREATE time \u2014 `<link alias>.<field alias>`, the link being a one-row link on this entity. The value is copied rather than looked up, so the source changing later leaves the row alone. |\n| `min` | number \\| text | no | Refuse a create or update whose value is below this: a constant for a number, or a field \u2014 `<field alias>` of the same row, or `<link alias>.<field alias>` of the one row a link names \u2014 of the same type (a return date after the departure, a line\'s day inside its trip) |\n| `max` | number \\| text | no | Refuse a create or update whose value is above this: a constant for a number, or a field of the same row or of the one row a link names |\n| `options_where` | [filter](#views) | no | Which rows of the target this link may point at \u2014 an `and` group of plain conditions over the TARGET entity\'s own fields, each `field_key` a field alias. It narrows the picker\'s read and is refused again where the write lands. |\n| `same` | list of alias (at least one) | no | One-row links this row and each row this link points at must name alike \u2014 a fee\'s invoice is one of its own visit\'s, a box\'s seal one of its own shipping line\'s: each a one-row link of both entities to the same entity. It narrows the picker\'s read to the rows naming what this row names, and a write is refused wherever it lands \u2014 on the link, or on what the two rows compare. |\n\n#### No overlap\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `from` | alias | yes | The date a row\'s span starts on |\n| `to` | alias | yes | The date a row\'s span ends on |\n| `by` | list of alias (at least one) | no | Fields two rows share to compete for a span (the same employee, the same room); absent, every row |\n| `while` | map of alias \u2192 list of alias (at least one) | no | Only rows at these options of these selects hold their span (running, not closed); absent, every row |\n\n<!-- generated:end write-rules -->\n\n- A `natural_key` is `text` or `number`, because a person types it back, or a\n one-row link, because a row is also recognised by the one row it belongs with (a\n fee by its visit and its kind). A key of one `text` field declares `unique: true`\n on the field itself \u2014 two rows sharing it would make find-or-create pick\n whichever the read answered first. An add asks the key by default.\n- A `min` or `max` naming a field is read off the row as the save would leave it,\n and one naming `<link>.<field>` off the row the link names \u2014 the link\'s side\n re-checks its own rows when that bound moves.\n- A `default_from` link is `required`, because there has to be a row to read, and\n the two field types must match. The copied field is not asked.\n- A `same` link names only rows that name what this row names \u2014 a fee\'s invoice\n is one of its own visit\'s \u2014 through a one-row link both entities hold to one\n entity; its picker offers nothing until the row names one. A save moving what\n the two compare, on either row, is refused while they would name apart.\n- An `options_where` link may be left empty; a row it names is refused again\n where the write lands unless it holds the narrowing. A link naming one kind of\n a book\'s rows (a "Shipping line" among the parties) narrows to that kind, or\n its picker offers every row.\n\nA field\'s own `required` is not here: the contract carries it, and every write\npath refuses the row by field name from it. `unique` is the text field\'s own\nclause (\xA7 `text`) \u2014 an add says so at the control before the column does. A\nfield the workspace writes (a formula, a rollup, a lookup, an autonumber, a date\nwith `derive_from`) is never asked, written or edited.\n\n## Apps\n\nAn app is ONE register over one entity and the record each row opens \u2014 or a dashboard\nof readings. The author states COMPOSITION \u2014 which fields go where; the runtime owns\nhow each thing looks, one treatment per concept. Nothing is drawn that the app does not\nname, and each fact appears once on a record. What the vocabulary has no word for is a\n`component` block or an act\'s own `workflow`.\n\n```jsonc\n"apps": [{\n "alias": "gate", "name": "Gate in and out", "entity": "visit",\n "register": { "columns": ["service"], "filters": ["customer", "service"] },\n "record": {\n "sections": [\n { "title": "In", "fields": ["customer", "service"], "blocks": [{ "rows": "fee", "columns": ["amount"] }] },\n { "title": "Out", "at": ["in_yard"], "fields": ["release", "seal"], "blocks": [{ "files": ["photos"] }], "acts": ["gate_out"] }\n ],\n "comments": true,\n "history": true\n },\n "acts": [{ "alias": "gate_out", "label": "Gate out", "when": { "stage": ["in_yard"] },\n "requires": ["release", "seal"], "set": { "stage": "gone", "left_on": "now" }, "confirm": true }],\n "checks": [{ "field": "unpaid", "blocks": ["gate_out"] }]\n}]\n```\n\n<!-- generated:start apps -->\n\n#### App\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | The app\'s name within the model \u2014 what `--from model.json#<alias>` picks |\n| `name` | text | yes | The job, in the words of the people who do it |\n| `description` | text | no | What the job is for, in a sentence |\n| `icon` | text | no | A lucide icon name the launcher tile draws |\n| `theme` | [Theme](#theme) | no | The launcher tile\'s colour |\n| `entity` | alias | yes | The entity whose rows this job works \u2014 the register\'s rows |\n| `scope` | alias | no | A required one-row link of the entity: the app works inside one row of the linked entity at a time (a project, a branch), picked from a list of them, and every row it reads, counts and adds is that row\'s |\n| `reads` | `"shared"` | no | Every member reads every row, whatever the entity\'s read scope |\n| `writes` | `"children"` | no | The reader adds and edits the record\'s child rows but not the record itself |\n| `register` | [Register](#register) | no | The rows: which columns and filters, the order, how a row is added |\n| `record` | [Record page](#record-page) | no | What a row opens: the door, its sections, and beside them its comment thread and its status history where stated |\n| `acts` | list of [Act](#act) | no | What the reader does to a record \u2014 each a press with its conditions and its write |\n| `checks` | list of [Check](#check) | no | Formulas that warn while they stand, or refuse the acts and saves they name |\n\n#### Theme\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `color` | text | yes | The tile\'s colour |\n\n#### Dashboard app\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | The app\'s name within the model \u2014 what `--from model.json#<alias>` picks |\n| `name` | text | yes | The job, in the words of the people who do it |\n| `description` | text | no | What the job is for, in a sentence |\n| `icon` | text | no | A lucide icon name the launcher tile draws |\n| `theme` | [Theme](#theme) | no | The launcher tile\'s colour |\n| `reads` | `"shared"` | no | Every member reads every row, whatever the entity\'s read scope |\n| `dashboard` | list of ([Metric](#metric) \\| [Breakdown](#breakdown) \\| [Trend](#trend) \\| [Pivot](#pivot) \\| [List](#list)) (at least one) | yes | The readings, in order \u2014 each over every row of its entity, windowed by the one period the reader switches |\n\n<!-- generated:end apps -->\n\n`writes: "children"` is a desk that adds and edits a record\'s child rows and never\nthe record itself. `reads: "shared"` lifts every read scope the app\'s tables state \u2014\nrefused where none of them states one.\n\n`scope` names a required one-row link of the entity: the app works inside ONE row of\nthe linked entity at a time \u2014 a project, a branch, a season. With no folder remembered\nit opens on the list of them, each with how many of the app\'s rows it holds; inside,\nevery read, count and reading narrows to that one, an add files its row under it (the\nlink is never asked), and the record states its folder in the header. A scope narrows\nthe view; who may read what stays the entity\'s `read_scope`. The folder link is never a\ncolumn, a filter, a search field, a fact or an add\'s question.\n\nA dashboard is `{ "alias", "name", "dashboard": [readings] }` with no entity, register\nor record: each reading reads every row of its entity, and one period the reader\nswitches (this month \xB7 30 days \xB7 this quarter \xB7 this year) windows each reading placed\nin time. A `list` reading lists rows, each opening in its `app`.\n\n```jsonc\n{ "alias": "overview", "name": "Depot overview", "dashboard": [\n { "metric": "visit", "where": { "stage": ["in_yard"] }, "label": "In the yard" },\n { "trend": "visit", "over": "arrived_on", "label": "Gate-ins" },\n { "breakdown": "visit", "by": "line", "value": "fees" },\n { "list": "visit", "where": { "overdue": true }, "columns": ["line"], "app": "gate", "label": "Past free days" }\n] }\n```\n\n### Register\n\n```jsonc\n"register": { "columns": ["service", "release"], "filters": ["customer", "service"], "create": ["container_no", "customer"] }\n```\n\n<!-- generated:start apps-register -->\n\n#### Register\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `columns` | list of alias | no | Fields read as columns after the row\'s title; the status and the figure are placed by the runtime |\n| `filters` | list of alias (at most 3) | no | Fields the reader narrows the rows by (a select, a member, a link, a date, a yes/no), at most 3 \u2014 the ones narrowed by daily, in that order, each on the register\'s line. The status is the chips, never one of these; a column\'s header sorts by a day or an amount |\n| `search` | list of alias (at least one) | no | The fields the search box matches (a link by its row\'s title); a pasted list searches each line and names the lines no row matched. Absent, every word of the row |\n| `group` | alias | no | The field the rows open grouped by, the reader\'s grouping starting there \u2014 a period\'s rows read under each period, one per person; absent, ungrouped |\n| `sort` | [Register order](#register-order) | no | The order rows open in; absent, the first deadline soonest first, else the newest first |\n| `layout` | `"table"` \\| `"cards"` \\| `"calendar"` | no | `cards` for rows read by their picture, `calendar` for rows read by their day; absent, a table |\n| `create` | list of alias (at least one) \\| `false` | no | The fields asked when a row is added; absent, the title and every required field; false, rows are not added here |\n| `export` | `true` \\| [Register export](#register-export) | no | The rows in view can be saved: `true`, as a spreadsheet of the columns; a `template`, as one document made from them |\n| `readings` | list of ([Metric](#metric) \\| [Breakdown](#breakdown) \\| [Trend](#trend) \\| [Pivot](#pivot)) (at least one) | no | Readings of the register\'s own entity above its rows, over the rows in view \u2014 the search, the status and the filters narrow them |\n\n#### Register order\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | The field rows are ordered by |\n| `desc` | `true` | no | Latest or largest first; absent, soonest or smallest first |\n\n#### Register export\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `template` | alias | yes | A document template made into one file of every row in view, the rows listed under the entity\'s alias |\n\n<!-- generated:end apps-register -->\n\nThe row is the entity\'s `records` entry: its picture, title and the line under it,\nthen the status, the columns, and the figure at the right; the title, the status and a\ncolumn of days or amounts sort the rows by their header. The status is always the chips, with\ncounts, opening on the rows not closed \u2014 never one of `filters`. `filters` are at most\nthree, in order of use, each on the line with the search and the chips, the grouping one\nchip at its end; a member filter offers "mine". A\ncolumn the row already draws is refused. Absent `sort`, rows open soonest\ndeadline first where the row has a `due`, else newest first. `search` names the fields\nthe search box matches \u2014 a link by its row\'s title \u2014 and a pasted list searches each\nline, naming the lines no row matched; absent, it matches every word of the row.\n`export: true` saves the rows in view as a spreadsheet of the columns; `export: {\n"template" }` makes one document of them, the rows listed under the entity\'s alias.\n`readings` read the register\'s own rows above them: the rows in view, so the search,\nthe status and the filters narrow each one. One line states the metrics; the pictures\nwait behind its Charts press, as each reader last left them. Pressing a bar, a cell or\na metric with a `where` adds its condition as a filter.\n\n### Readings\n\nA reading is one number, or one picture of numbers, over rows. Where it stands decides\nwhich rows: a dashboard reads every row, a register\'s `readings` the rows in view, a\nrecord\'s reading block the child rows under the record. The picture is the runtime\'s,\nchosen from what the reading means \u2014 no key picks a chart.\n\n```jsonc\n{ "metric": "visit", "value": "fees", "where": { "paid": false }, "over": "arrived_on", "label": "Unpaid" }\n{ "breakdown": "visit", "by": "line" }\n{ "trend": "visit", "over": "arrived_on", "value": "fees" }\n{ "pivot": "visit", "rows": "size", "columns": "line" }\n{ "list": "visit", "where": { "stage": ["in_yard"] }, "columns": ["line"], "app": "gate", "label": "In the yard" }\n```\n\n<!-- generated:start apps-readings -->\n\n#### Metric\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `metric` | alias | yes | The entity whose rows are counted, or summed, into one number |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `target` | number | no | The number it is read against, where no `limits` bounds its value |\n| `label` | text | yes | What the reading is, in the reader\'s words |\n\n#### Breakdown\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `breakdown` | alias | yes | The entity whose rows are split into parts |\n| `by` | alias | yes | The single select, one link, member or yes/no each row is counted under |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the `by` field\'s label |\n\n#### Trend\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `trend` | alias | yes | The entity whose rows are counted, or summed, per period |\n| `over` | alias | yes | The date each row falls on |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the value\'s label, else the entity\'s |\n\n#### Pivot\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `pivot` | alias | yes | The entity whose rows are counted, or summed, in a grid |\n| `rows` | alias | yes | The single select, one link, member or yes/no down the side |\n| `columns` | alias | yes | The single select, one link, member or yes/no across the top |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the two fields\' labels |\n\n#### List\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `list` | alias | yes | The entity whose rows are listed, each in its `records` anatomy |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `columns` | list of alias | no | Fields read after each row\'s title |\n| `app` | alias | no | An app of this model over the same entity that a row opens in |\n| `label` | text | yes | What the reading is, in the reader\'s words |\n\n<!-- generated:end apps-readings -->\n\nA reading counts its rows, or sums the number `value` names \u2014 never a percent. `by`,\n`rows` and `columns` split the rows by one value each: a single select, a one-row link,\none member, or a yes/no. `over` is a stored date: a trend\'s periods, a metric\'s recent\nperiods beside its number, and what a dashboard\'s period windows. `where` keeps rows\nwhose select holds one of the options, or whose yes/no is the value, every entry ANDed.\n`target` reads a metric against a number where no `limits` bounds its value. A register\nreads only its own entity; a `list` stands only on a dashboard.\n\n### Record\n\n```jsonc\n"record": {\n "door": "page",\n "sections": [\n { "title": "Details", "fields": ["size", "line", "built_on"] },\n { "title": "Gate in", "at": ["arriving", "in_yard"], "fields": ["arrived_on", "truck"],\n "blocks": [{ "rows": "fee", "where": { "leg": ["in"] }, "expect": "kind", "columns": ["amount", "paid_by"] }],\n "acts": ["gate_in_paper"] },\n { "title": "Sell to a buyer", "description": "Moves the container to the buyer\'s stock once it has left.", "acts": ["sell"] }\n ],\n "comments": true\n}\n```\n\n<!-- generated:start apps-record -->\n\n#### Record page\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `door` | `"page"` \\| `"drawer"` | no | A page for work read at length; a drawer for rows worked one after another from the register. Either stacks its sections, and from three sections on navigates them \u2014 a page by a rail, a drawer by tabs; absent, a page where a section has `at` or is a `page` or the record has more than three sections, else a drawer |\n| `sections` | list of [Section](#section) (at least one) | no | The record in the order its work reaches it \u2014 each section its fields, then its blocks, then its acts. Absent, one section of every field a person writes that no other place shows |\n| `comments` | `true` | no | The record\'s comment thread: who wrote what and when, with a composer, beside the sections |\n| `history` | `true` | no | The record\'s status history \u2014 each move, when and who \u2014 beside the sections, for a lifecycle whose past steps are looked up (a shipment, a container\'s gate moves, an order); the entity\'s `status.history` is written either way. Absent, it is drawn nowhere |\n\n#### Section\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `title` | text | yes | What the section is, in the reader\'s words \u2014 its heading, and its name on the record\'s rail or tab |\n| `description` | text | no | One sentence under the title: what the section is for, or what its act does |\n| `at` | list of alias (at least one) | no | Options of the record\'s status (or its milestones) during which this section is the work now \u2014 a step of the path the record moves through: its rail item reads now while the status holds one and this app has work there, done once the record\'s history says it went through one and it stands before the section that is the work now, and waiting otherwise; the item of a section with no `at` is its title alone, and a record with no `at` marks no item. A page opened from the register opens at it. It never locks what the section holds: its fields are written as the write rules and checks allow at any stage. An act it names is offered during these alone: its `when` holds the status to them, or on milestones it stamps the step after one of them \u2014 a detour and an exit are sections of their own, a detour stated before the step it returns to |\n| `fields` | list of alias (at least one) | no | The record\'s own fields in this section, in the order they are read |\n| `blocks` | list of [block](#blocks) (at least one) | no | What the section holds besides its own fields, under them, in order |\n| `acts` | list of alias (at least one) | no | Acts of this app drawn under the section\'s fields, their asks as more of them and what each lacks said above its button; the header draws no act, so every act on the record is named by one section. An act moving the status stands in the section of the step it is an outcome of \u2014 the one stage its `when` holds \u2014 unless it enters a detour or an exit the work reaches no other way, whose section it opens. A destructive act that is not one of its step\'s outcomes is a correction: it waits in the section heading\'s \u22EF, and one no section names in the header\'s \u22EF |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n| `page` | `true` | no | A page of its own, off the record\'s scroll: its own heading and a way back, reached from the record\'s rail after the scroll\'s sections, or from a link at its place where the record has no rail \u2014 for what makes the record worse stacked in it: a long list of related rows, a workspace of its own. Only on a page door, never a step (`at`) |\n\n<!-- generated:end apps-record -->\n\nThe record is its `sections`, stacked in the order the work reaches them. A section is\nits fields, the acts under them, then its blocks \u2014 the acts at its foot instead where one\nreads what a block holds or none of its fields \u2014 and holds at least one of them. From\nthree sections on, the record is navigated by their titles: a page by a rail that jumps\nto each (a strip of the same names on a phone), a drawer by tabs, the companion the last;\nwith fewer, the sections just stack. `description` is one sentence under the title.\n`at` names the status options (or the milestones) during which the section is the work\nnow \u2014 only on a record that truly moves through those stages: the rail marks it done,\nnow or waiting (an item of no `at` is its title alone), and the section stands even holding\nnothing yet \u2014 it never hides a field, locks it or gates an act, and a record opens at its\nhead. `page: true` makes a section a page of its own under the\nrecord\'s, off its scroll, with a way back \u2014 rarely, for a long related list or a workspace\nof its own that would make the record worse stacked; never on a drawer or a step. The\nheader draws no act: every act on the record is named by the section whose work it is, and one no section names is refused, naming its likely\nsection \u2014 only a correction no section owns waits in the header\'s \u22EF beside Delete. An act\nmoving the status stands in the section of the step it is an outcome of (its `when`);\none entering a detour or an exit \u2014 a step the work reaches no other way \u2014 stands in that\nsection, which draws it and its gaps alone while it waits. A destructive act offered\nbeside another move of the same step (T\u1EEB ch\u1ED1i beside Duy\u1EC7t) is a button of that step; any\nother destructive act corrects what is done and waits in the \u22EF of what it acts on: its\nsection\'s heading, its row. `comments: true` puts the record\'s comment thread beside the\nsections, and `history: true` its status history \u2014 each only where stated, and the\nhistory never as a section: for a lifecycle whose past steps are looked up (a shipment,\na container\'s gate moves, an order), not every record with a status.\n\nA field, a block and an act is each placed ONCE on a record \u2014 the header, one section;\na second place is refused, and so is a section holding nothing, an `at` on a record\nwith no status, and a section\'s act that is a child\'s or runs over the register\'s rows.\nAbsent `sections`, one section holds every field a person writes that the header does\nnot show. An editable field rests as its control and saves as it changes; everything\nelse is plain text. A section or a block with `when` is shown only while each named\nselect of the record holds one of its options. The one layout serves every entity: a\ncustomer or a product is sections without `at`.\n\n### Blocks\n\n```jsonc\n"blocks": [\n { "rows": "fee", "columns": ["kind", "amount"] },\n { "rows": "fee", "where": { "leg": ["out"] }, "columns": ["kind", "amount"], "under": "invoice" },\n { "agenda": "booking", "columns": ["guide"], "start": "arrival" },\n { "timeline": "note" },\n { "files": ["photos", "papers"] },\n { "text": "remarks", "when": { "stage": ["held"] } },\n { "metric": "fee", "value": "amount", "label": "Fees" },\n { "breakdown": "fee", "by": "kind" },\n { "trend": "fee", "over": "charged_on", "value": "amount" },\n { "pivot": "fee", "rows": "kind", "columns": "method" },\n { "component": "YardMap" }\n]\n```\n\n<!-- generated:start apps-blocks -->\n\n#### Rows block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `rows` | alias | yes | A child entity whose rows belong to this record |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `columns` | list of alias | no | The child\'s fields read as columns after its title |\n| `create` | list of alias (at least one) \\| `false` | no | The child\'s fields asked when a row is added here; absent, its title, its picture, the block\'s columns and every required field a person writes; false, rows are not added here |\n| `where` | map of alias \u2192 list of alias (at least one) | no | Only the child rows whose single select holds one of these options \u2014 every entry ANDed, read so on the server; a row added here holds them (one leg\'s fees, of a record holding both legs\'). Each select is one the child requires, so no row stands in no block |\n| `expect` | alias | no | The child\'s title when the record holds one row per value of it \u2014 a single select (one per option, in order) or a one-row link to a catalog (one per row the link may point at): a value not yet filled reads as its own empty line, and no value holds two rows |\n| `of` | alias | no | With `expect` on a select: the record\'s own multi-select holding the options this record expects a line for \u2014 the options it holds, in order, rather than every option. Its options are among the title\'s |\n| `under` | alias | no | The child\'s one-link to a document that covers its rows (an invoice over its fee lines), whose many-link points back and which links to this record: each document stands as a heading over the lines it covers, the lines no document covers first under their own heading, where the document\'s add makes one from the lines picked. Never also a rows block of the document |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Timeline block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `timeline` | alias | yes | A child entity read as a log of dated entries, newest first, with a composer where the app writes it (entries planned ahead are an `agenda`) \u2014 never the record\'s status history, which `record.history` draws beside the record |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `create` | list of alias (at least one) \\| `false` | no | The child\'s fields asked when a row is added here; absent, its title and every required field a person writes; false, rows are not added here |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Agenda block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `agenda` | alias | yes | A child entity read as planned entries on a time axis: by day, soonest first, each entry with its picture and the day that is today marked. The day is the first date on the child\'s row line (its `records` title or subtitle); within a day entries follow a datetime there, else the first single select after the date \u2014 its options in the day\'s order |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `columns` | list of alias | no | The child\'s facts read on an entry\'s line after its title |\n| `create` | list of alias (at least one) \\| `false` | no | The child\'s fields asked when a row is added here; absent, its title, its picture, the block\'s columns and every required field a person writes; false, rows are not added here |\n| `start` | alias | no | The record\'s date that is the first day: each day\'s heading counts from it (Day 1, Day 2); absent, the weekday and the date alone |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Files block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `files` | list of alias (at least one) | yes | Files fields of this record, read as pictures and documents |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Text block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `text` | alias | yes | A long text field read as prose |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Metric block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `metric` | alias | yes | The entity whose rows are counted, or summed, into one number |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `target` | number | no | The number it is read against, where no `limits` bounds its value |\n| `label` | text | yes | What the reading is, in the reader\'s words |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Breakdown block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `breakdown` | alias | yes | The entity whose rows are split into parts |\n| `by` | alias | yes | The single select, one link, member or yes/no each row is counted under |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the `by` field\'s label |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Trend block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `trend` | alias | yes | The entity whose rows are counted, or summed, per period |\n| `over` | alias | yes | The date each row falls on |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the value\'s label, else the entity\'s |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Pivot block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `pivot` | alias | yes | The entity whose rows are counted, or summed, in a grid |\n| `rows` | alias | yes | The single select, one link, member or yes/no down the side |\n| `columns` | alias | yes | The single select, one link, member or yes/no across the top |\n| `value` | alias | no | A number field summed over the rows; absent, the rows are counted |\n| `where` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Only the rows whose select holds one of these options, or whose yes/no is this value \u2014 every entry ANDed |\n| `over` | alias | no | The date that places a row in time: a dashboard\'s period windows the rows by it, and a metric reads its recent periods beside the number |\n| `label` | text | no | What the reading is, in the reader\'s words; absent, the two fields\' labels |\n| `via` | alias | no | The child\'s link to this record, where it has more than one |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n#### Component block\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `component` | text | yes | A component of the app (src/components) that draws this block |\n| `title` | text | no | The block\'s heading; absent, the child entity\'s or the field\'s own label |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | Shown only while each named select of the record holds one of these options, and each named yes/no (stored, or a formula) is this value |\n\n<!-- generated:end apps-blocks -->\n\nA `rows`, `agenda` or `timeline` block reads a child entity through its link to the\nrecord \u2014 `via` names it where the child links more than once. A rows block adds rows\n(asking the child\'s title, its picture, the block\'s columns and its required fields)\nand opens each in a drawer. An `agenda` draws planned entries by day, soonest first:\nthe first date on the child\'s row line is the day, a datetime there or the first single\nselect after the date orders a day, and `start` \u2014 the record\'s date \u2014 is day 1. `under`\nnames the child\'s one-link to a document covering its lines (an invoice over its fees,\nits many-link paired back, linking to the record): each document heads the lines it\ncovers, the uncovered lines first, where a document is made from the lines picked \u2014 the\nrecord never lists the documents again as a block of their own. A timeline reads\nits child\'s first date as the moment and its member field as who, with a composer\nwhere the app writes it. `create` names the child\'s fields an add here asks \u2014\nthe link to this record is filled, never asked \u2014 and `false` adds none. `expect`\nnames the child\'s title where the record holds one row per value of it \u2014 a select\'s\noptions, or the rows a link to a catalog may point at (`options_where`): every value\nis a line, one not yet filled a quiet line whose first column makes its row, the add\noffers only the values not yet held, and every write path refuses a second row of one\nvalue under one record. A field that starts from the catalog row (`default_from`)\nshows that value as its hint, taken with one press. `where` keeps only the child rows\nwhose single select holds one of its options \u2014 read so on the server, a row added\nthere holding them, its lines expected once per value among them (one leg\'s fees\nbeside the other\'s), and its foot totalled by the record\'s sum narrowed alike. A `metric`, `breakdown`, `trend`\nor `pivot` block is a reading (\xA7 Readings) of a child entity\'s rows under this record,\nread only \u2014 `via` names the link where the child has more than one. A `component` is\nthe app\'s own, exported from `src/components`.\n\n### Acts\n\n```jsonc\n"acts": [{\n "alias": "gate_out", "label": "Gate out",\n "when": { "stage": ["in_yard"] }, "requires": ["release", "seal"],\n "asks": ["seal", { "input": "note", "label": "Note", "type": "long_text" }],\n "set": { "stage": "gone", "left_on": "now", "left_by": "me", "remark": "input:note" },\n "confirm": true\n}]\n```\n\n<!-- generated:start apps-acts -->\n\n#### Act\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `alias` | alias | yes | The act\'s name within its app \u2014 its workflow is `act_<alias>` |\n| `label` | text | yes | The verb, as the reader says it |\n| `on` | `"record"` \\| `"rows"` \\| `"view"` | no | `rows` runs on the rows ticked in the register, `view` on every row the register shows (offered while the view is narrowed to its `when`; it states no `requires`); absent, on one record (its page and its row\'s menu) |\n| `of` | alias | no | A rows block\'s child entity the act works on: offered on each child row, its conditions and its write that row\'s |\n| `when` | map of alias \u2192 (list of alias (at least one) \\| boolean) | no | The act works only while each named select holds one of these options, and each named yes/no (stored, or a formula) is this value |\n| `requires` | list of alias (at least one) | no | Fields that must be filled first; a blocked act names each one missing |\n| `recommends` | list of alias (at least one) | no | Fields the act runs without and reads when filled: while one is empty, the act says what it will leave blank |\n| `asks` | list of (alias \\| [Act input](#act-input)) (at least one) | no | What the reader states when pressing it \u2014 fields of the record (written by the act) or inputs |\n| `set` | map of alias \u2192 (text \\| number \\| boolean \\| `null`) | no | What the act writes: an option alias, a value, `"now"`, `"me"` or `"input:<name>"` |\n| `workflow` | alias | no | An authored workflow (src/workflows/<alias>.ts) the act runs after its checks, for what `set` cannot say |\n| `template` | alias | no | A document template the act makes a file from \u2014 over several rows, one file of them all, the rows listed under the entity\'s alias |\n| `into` | alias | no | The files field of the one record the made document is kept in |\n| `confirm` | `true` | no | The press asks first; the question lists every active warning |\n| `danger` | `true` | no | The act destroys or cannot be undone |\n\n#### Act input\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `input` | alias | yes | A value the act asks for that is not a field of the record |\n| `label` | text | yes | What the reader is asked, in their words |\n| `type` | `"text"` \\| `"long_text"` \\| `"number"` \\| `"date"` \\| `"select"` \\| `"link"` \\| `"member"` | yes | What the reader states |\n| `options` | list of [Select option](#select-option) (at least one) | no | The choices a `select` input offers, one picked \u2014 written into a select holding the same option aliases |\n| `entity` | alias | no | The entity a `link` input picks one row of |\n| `required` | `true` | no | The act is refused without it |\n| `default` | alias | no | A field of the row whose value the input starts from, still changed at will |\n\n<!-- generated:end apps-acts -->\n\nAn act whose `when` does not hold is not offered \u2014 each select named holding one of its\noptions, each yes/no named (stored, or a formula: a period that has ended) its `true` or\n`false`; every one that holds stands at the\nfoot of the section naming it in `acts` \u2014 a correction in that section heading\'s \u22EF, one\nof a child row on its row (a correction in the row\'s \u22EF). One whose `requires` are\nempty is offered disabled, naming each; one whose `recommends` are empty names what\nit will leave blank and still presses; one a standing check blocks is offered\ndisabled in the check\'s words. An input\'s `default` is the row\'s field it starts from. The generated `act_<alias>` workflow re-checks all three on the\nserver, writes `set` and what was asked, and appends the status history where the\nact moves the status \u2014 each thing it asked copied onto the history row where the\nhistory has a field of the same name. `template` makes a document from the row and\nkeeps it in `into`; over several rows it makes one document of them all, the rows\nlisted under the entity\'s alias, and hands it back. A field an act writes is\nread-only everywhere else. A `select` input offers its own options and is written into\na select holding the same option aliases; a `link` input picks one row of its `entity`\nand a `member` input one person, each written into a field of its kind. A field an act\nboth `requires` and `asks` never disables the press \u2014 the act asks it, required.\n\n`on: "view"` runs on every row the register shows \u2014 its search, status and filters \u2014\neach row checked before any is written. `of` names a rows block\'s child entity: the\nact is offered on each child row (its menu and its drawer), its `when`, `requires`,\n`set` and checks are the child\'s, it runs on that row, and a check of the record that\nlocks the child rows locks it too.\n\n### Checks\n\n```jsonc\n"checks": [{ "field": "release_warning" }, { "field": "unpaid", "blocks": ["gate_out"] }, { "field": "locked", "blocks": ["edit"] }]\n```\n\n<!-- generated:start apps-checks -->\n\n#### Check\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `field` | alias | yes | A yes/no or text formula of the record (or of the child `of` names): true or non-empty means the check stands, and a text formula\'s words are what the reader sees |\n| `of` | alias | no | A rows block\'s child entity this check is a formula of: standing on a child row, it refuses what it blocks of that row |\n| `blocks` | `"all"` \\| list of (alias \\| `"edit"` \\| `"delete"`) (at least one) | no | Acts, "edit" (the saves and the Delete), "delete" (the Delete alone) or "all" that the standing check refuses; absent, it only warns |\n| `resolve` | alias | no | An act of this app, on the rows the check stands on, that clears it: the check\'s line links to the act where it stands, and the act keeps its own conditions |\n\n<!-- generated:end apps-checks -->\n\nA check is a yes/no or text formula of the record: it stands while true or\nnon-empty, and a text formula\'s words are what the reader sees. Without `blocks`\nit warns; with them it refuses the acts named, `"edit"` the record\'s own saves (and\nits Delete), `"delete"` its Delete alone, or `"all"` \u2014 on the server, in every write it\nnames. A check `of` a rows block\'s child stands on each child row: it refuses that\nrow\'s saves (`edit`), its Delete (`delete`), the acts `of` that child it names, or\n`all` of them \u2014 never the record\'s acts. A warning reading the figure of a meter that\n`gates` marks against its bound or its pass mark is refused: the meter already draws it.\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": { "company": { "label": "Customers", "fields": { "name": "Company name" } } },\n "no_sample_data": true\n }\n]\n```\n\n<!-- generated:start apply -->\n\n#### Package entry\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `package` | text | yes | The package id to copy in |\n| `bind` | map of alias \u2192 [Binding](#binding) | no | Entity alias \u2192 the labels this workspace calls that entity and its fields |\n| `no_sample_data` | boolean | no | Decline the package\'s sample records |\n\n#### Binding\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `label` | text | no | The workspace\'s own table name for this entity. |\n| `fields` | map of alias \u2192 text | no | Field alias \u2192 the workspace\'s own field name for it. |\n\n<!-- generated:end apply -->\n\n`bind` holds the LABELS this workspace 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### What a run remembers\n\nThe WORKSPACE remembers what each entity, field, select option, role and\ntemplate alias became here, plus which record each first row landed on and the\nfile each document path was uploaded as. The model file itself holds no live id\n\u2014 it is the portable half, and the same file applies to a demo workspace and to\na customer\'s \u2014 so the join lives where the things it names live, and one model\napplied to two workspaces holds two bindings that know nothing of each other.\n\nIt is the workspace\'s and not the file\'s because a model file is never\ncommitted: memory kept beside it is one author\'s disk, absent for a teammate, on\na second machine or after a delete \u2014 and every one of those goes quietly back to\nbinding by label, which is what grows the second table.\n\nEvery later verb binds through it: `apply`, `diff`, `app create --from`,\n`app regenerate --from` and `workspace build` take the remembered id first and\nfall back to a label only for an alias nothing has bound \u2014 a field you have just\nadded, or a workspace nothing has applied this model to. That is what makes a\nrelabel on EITHER side a rename rather than one thing the workspace lacks and\none the model lacks: `diff` prints one line under the alias\n(`order.state: label: model "Stage" \xB7 workspace "Giai \u0111o\u1EA1n"`), and `apply` binds\nthe thing it has always meant, reports the move and names the command that makes\nthe two agree \u2014 which side is right is yours to decide, not a reason to refuse\nthe run.\n\nA bound target the workspace no longer holds IS a refusal, by name: re-binding\nto whatever carries that label today is how the model comes to point at\nsomebody else\'s table. `lotics run restore_table` puts a deleted table back, and\n`lotics scaffold unbind <kind> <alias>` forgets the binding \u2014 after which the\nnext apply creates a new one and nothing joins it to what was there.\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?"],\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\n<!-- generated:start presets -->\n\n#### Preset\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `name` | text | yes | The trade, as the shelf lists it |\n| `description` | text | yes | What the trade\'s workspace keeps, in a sentence |\n| `questions` | list of text (at most 2) | yes | What to ask before choosing a variant \u2014 at most two |\n| `variants` | map of alias \u2192 [Variant](#variant) | yes | Slug \u2192 a branch of the model, taken where its `when` describes the business |\n\n#### Variant\n\n| Key | Type | Required | Meaning |\n|---|---|---|---|\n| `when` | text | yes | The condition, in the words a person would use, that selects this variant |\n| `entities` | list of [Entity](#entity) | no | Tables this variant adds |\n| `fields` | map of alias \u2192 list of [Field](#field) | no | Entity alias \u2192 the fields this variant adds to that entity |\n\n<!-- generated:end presets -->\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 "records": { "job": { "title": "code" } },\n "write_rules": { "customer": { "natural_key": ["email"] } },\n "apps": [ /* the apps this business 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`**, **`records`**, **`write_rules`**, **`apps`** and **`apply`** mean\n exactly what they mean in the full form \u2014 `"rows"` are this business\'s real\n first records, `"records"` and `"write_rules"` may name the preset\'s entities\n as well as its own (an entity this file names replaces the preset\'s whole entry\n for it), `"apps"` the apps 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 "singular": "Customer",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n { "alias": "logo", "label": "Logo", "type": "files" },\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 "singular": "Order",\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 { "alias": "note", "label": "Note", "type": "text" }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "records": {\n "customer": { "title": "name", "image": "logo", "figure": "total_ordered" },\n "order": { "title": "code", "subtitle": ["customer", "placed_on"], "figure": "amount", "starts": { "placed_on": "today" } }\n },\n "apps": [\n {\n "alias": "customers",\n "name": "Customers",\n "entity": "customer",\n "register": {\n "columns": ["tier"],\n "filters": ["tier"],\n "readings": [{ "breakdown": "customer", "by": "tier", "value": "total_ordered" }]\n },\n "record": {\n "sections": [\n { "title": "Account", "fields": ["tier"] },\n { "title": "Orders", "blocks": [{ "rows": "order", "columns": ["total"] }] }\n ],\n "comments": true\n }\n },\n {\n "alias": "orders",\n "name": "Orders",\n "entity": "order",\n "register": {\n "filters": ["customer"],\n "create": ["code", "customer", "placed_on"],\n "readings": [{ "trend": "order", "over": "placed_on", "value": "amount" }]\n },\n "record": { "door": "drawer" }\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 prints the counts, then each app as its\nreader will see it:\n\n```\nOK \u2014 2 tables, 11 fields, 2 links, 1 view, 1 role, 4 rows, 2 apps.\n\nApp customers \u2014 "Customers" over Customers (customer)\n Register \u2014 table (default), newest first (default)\n row [Logo] \xB7 Name \xB7 figure Total ordered\n columns Tier\n filters Tier\n readings "Tier" \u2014 Total ordered summed per Tier, over the rows in view (default)\n add Name (default)\n Record \u2014 a drawer (default)\n header image Logo \xB7 title Name \xB7 figure Total ordered\n checks \u2014\n header \u22EF Delete\n sections 1. "Account"\n fields Tier\n 2. "Orders"\n block Orders (default) \u2014 rows of Orders through Customer; columns Total with VAT; add asks Order no. (default)\n thread the record\'s comments, beside the sections\n history \u2014\n acts \u2014\n Reads Customers, Orders\n\nApp orders \u2014 "Orders" over Orders (order)\n Register \u2014 table (default), newest first (default)\n row Order no. \xB7 under it Customer, Placed on \xB7 figure Amount\n columns \u2014\n filters Customer\n readings "Amount" \u2014 Amount summed per period by Placed on, over the rows in view (default)\n add Order no., Customer, Placed on \u2014 starting Placed on at today\n Record \u2014 a drawer\n header title Order no. \xB7 subtitle Customer, Placed on \xB7 figure Amount\n checks \u2014\n header \u22EF Delete\n sections 1. the record\'s details (default)\n fields Note\n thread \u2014\n history \u2014\n acts \u2014\n Reads Orders, Customers\n\n (default) marks what the model left to the system. `lotics app preview <model.json>#<app>` draws it.\n```\n\nEach `(default)` is a value the model left to the system: the door a record opens\nthrough \u2014 a drawer, since neither record has a stage or more than three sections \u2014 the\norder rows open in, what an add asks, and the order\'s one section \u2014 every field a\nperson writes that the header does not already show. The customer\'s orders\nare a block in its "Orders" section, never its own `Orders` link as a field as well:\none fact, one place. `Total ordered` is a rollup, so it is read and never asked; the\norders app\'s record opens in a drawer, because its rows are worked one after another.\n';
57595
58004
 
57596
58005
  // src/docs_command.ts
57597
58006
  var READING_ORDER = ["@lotics/app-runtime", "@lotics/ui", "@lotics/cli"];
@@ -58733,7 +59142,7 @@ function setMachineOutput(on) {
58733
59142
  function isMachineOutput() {
58734
59143
  return machineMode;
58735
59144
  }
58736
- function note(text) {
59145
+ function note2(text) {
58737
59146
  if (machineMode) return;
58738
59147
  console.error(text);
58739
59148
  }
@@ -58775,7 +59184,7 @@ async function offerSignInLink(client, redirect_path) {
58775
59184
  return null;
58776
59185
  }
58777
59186
  if (url2 !== null) {
58778
- note(`
59187
+ note2(`
58779
59188
  Open it \u2014 one-time sign-in link, expires in 15 minutes:
58780
59189
  ${url2}`);
58781
59190
  }
@@ -58878,7 +59287,7 @@ function printPresetRows(rows) {
58878
59287
  }
58879
59288
  function noteCopiedContent(templates, knowledgeDocs) {
58880
59289
  if (templates.length > 0) {
58881
- note(
59290
+ note2(
58882
59291
  `
58883
59292
  ${templates.length} document template${templates.length === 1 ? "" : "s"}: ${templates.join(", ")}
58884
59293
  These carry the publisher's wording and details, not yours \u2014 decide which should
@@ -58886,7 +59295,7 @@ function noteCopiedContent(templates, knowledgeDocs) {
58886
59295
  );
58887
59296
  }
58888
59297
  if (knowledgeDocs.length > 0) {
58889
- note(
59298
+ note2(
58890
59299
  `
58891
59300
  ${knowledgeDocs.length} knowledge doc${knowledgeDocs.length === 1 ? "" : "s"}: ${knowledgeDocs.join(", ")}
58892
59301
  These describe how the publisher works, and the app's agents read them.
@@ -59109,7 +59518,7 @@ function reportKnowledgeWarnings(warnings) {
59109
59518
  }
59110
59519
  async function copyPackage(client, args) {
59111
59520
  const pkg = await client.getStarter(args.starter_id);
59112
- note(
59521
+ note2(
59113
59522
  `Copying ${pkg.name}${pkg.is_official ? " (official)" : ""} into this workspace\u2026`
59114
59523
  );
59115
59524
  const result = await client.instantiateStarter(args.starter_id, {
@@ -59130,7 +59539,7 @@ async function copyPackage(client, args) {
59130
59539
  version_number: app.deployed?.version_number ?? null,
59131
59540
  error: app.error
59132
59541
  }));
59133
- note(
59542
+ note2(
59134
59543
  ` Created ${tables.length} table${tables.length === 1 ? "" : "s"}, ${templates.length} template${templates.length === 1 ? "" : "s"}, ${knowledgeDocs.length} knowledge doc${knowledgeDocs.length === 1 ? "" : "s"}, ${records} sample record${records === 1 ? "" : "s"}, ${apps.length} app${apps.length === 1 ? "" : "s"}.`
59135
59544
  );
59136
59545
  reportKnowledgeWarnings(result.knowledge_warnings);
@@ -59142,7 +59551,7 @@ ${pkg.name} is the publisher's code \u2014 its apps, workflows and agents \u2014
59142
59551
  );
59143
59552
  }
59144
59553
  for (const app of apps) {
59145
- note(
59554
+ note2(
59146
59555
  app.error === null ? ` \u2713 ${app.name} (${app.app_id}, v${app.version_number})` : ` \u2717 ${app.name} (${app.app_id}): ${app.error.split("\n")[0]}`
59147
59556
  );
59148
59557
  }
@@ -59156,7 +59565,7 @@ ${failed.length} app${failed.length === 1 ? "" : "s"} landed without a version:
59156
59565
  );
59157
59566
  process.exitCode = 1;
59158
59567
  }
59159
- note(
59568
+ note2(
59160
59569
  (apps.length > 0 && failed.length === apps.length ? `
59161
59570
  ${pkg.name} landed, but none of its ${apps.length} app${apps.length === 1 ? "" : "s"} is live.
59162
59571
  ` : `
@@ -59169,7 +59578,7 @@ Done \u2014 ${pkg.name} is live and yours.
59169
59578
  How to change it: lotics docs building_an_app (then \`lotics docs\` for the rest)`
59170
59579
  );
59171
59580
  if (records > 0) {
59172
- note(
59581
+ note2(
59173
59582
  `
59174
59583
  ${records} sample record${records === 1 ? "" : "s"} came with it. They are ordinary records \u2014
59175
59584
  delete them whenever you like, or re-run with --no-sample-data next time.`
@@ -59360,8 +59769,18 @@ function orderWords(model, entity, field2, desc) {
59360
59769
  return desc ? "Z\u2192A" : "A\u2192Z";
59361
59770
  }
59362
59771
  var marked = (app, path40) => app.defaulted.includes(path40) ? DEFAULT : "";
59772
+ function startWords(model, entity, asked) {
59773
+ const starts = model.records?.[entity]?.starts ?? {};
59774
+ const words = asked.flatMap((name) => {
59775
+ const start = starts[name];
59776
+ return start === void 0 ? [] : [`${label(model, entity, name)} ${start === "today" ? "at today" : "at the reader"}`];
59777
+ });
59778
+ return words.length === 0 ? "" : ` \u2014 starting ${words.join(", ")}`;
59779
+ }
59363
59780
  function whileWords(model, entity, when) {
59364
- return when.map((gate) => `while ${label(model, entity, gate.field)} is ${gate.options.map((option) => optionLabel(model, entity, gate.field, option)).join(" or ")}`).join(", ");
59781
+ return when.map(
59782
+ (gate) => `while ${label(model, entity, gate.field)} is ${"value" in gate ? gate.value ? "yes" : "no" : gate.options.map((option) => optionLabel(model, entity, gate.field, option)).join(" or ")}`
59783
+ ).join(", ");
59365
59784
  }
59366
59785
  function describeApp(model, app) {
59367
59786
  const entity = entityOf2(model, app.entity);
@@ -59394,11 +59813,18 @@ function describeApp(model, app) {
59394
59813
  const closed = (option) => stages === void 0 || status?.field !== void 0 ? optionLabel(model, app.entity, status?.field ?? "", option) : label(model, app.entity, option);
59395
59814
  const statusChips = stageWord === void 0 ? [] : [`${stageWord} (opens on ${status?.closed === void 0 ? "every row" : `all but ${status.closed.map(closed).join(", ")}`})`];
59396
59815
  lines.push(` filters ${[...statusChips, ...app.register.filters.map((field2) => label(model, app.entity, field2))].join(" \xB7 ") || "\u2014"}`);
59816
+ if (app.register.group !== void 0) {
59817
+ const lead = app.register.group === display?.title ? display.subtitle?.[0] : void 0;
59818
+ lines.push(
59819
+ ` grouped by ${label(model, app.entity, app.register.group)}, the reader's to change \u2014 each group's head says it once${lead === void 0 ? "" : `, and each row leads with ${label(model, app.entity, lead)}`}`
59820
+ );
59821
+ }
59397
59822
  if (app.register.search !== void 0) {
59398
59823
  lines.push(` search ${labels(model, app.entity, app.register.search)} \u2014 a pasted list searches each line and names the lines with no row`);
59399
59824
  }
59400
59825
  if (app.register.readings.length > 0) lines.push(...block("readings", app.register.readings.map((reading, index) => `${describeReading(model, reading, "over the rows in view")}${marked(app, `register.readings.${index}.label`)}`)));
59401
- lines.push(` add ${app.register.create === void 0 ? "\u2014 rows are not added here" : labels(model, app.entity, app.register.create)}${marked(app, "register.create")}`);
59826
+ const create = app.register.create;
59827
+ lines.push(` add ${create === void 0 ? "\u2014 rows are not added here" : labels(model, app.entity, create)}${marked(app, "register.create")}${create === void 0 ? "" : startWords(model, app.entity, create)}`);
59402
59828
  const exported = app.register.export;
59403
59829
  if (exported === true) lines.push(` export the register's columns, as a spreadsheet`);
59404
59830
  else if (exported !== void 0) lines.push(` export ${exported.template}, one document of every row in view`);
@@ -59437,7 +59863,7 @@ function describeApp(model, app) {
59437
59863
  ...block(
59438
59864
  "stages",
59439
59865
  stages.map((stage, index) => {
59440
- const when = Object.entries(stage.when).map(([field2, options]) => ({ field: field2, options }));
59866
+ const when = whenConditions(stage.when);
59441
59867
  return `${index + 1}. ${label(model, app.entity, stage.field)}${when.length === 0 ? "" : ` (${whileWords(model, app.entity, when)})`} \u2014 ticking stamps today, clearing clears every later one`;
59442
59868
  })
59443
59869
  )
@@ -59445,9 +59871,9 @@ function describeApp(model, app) {
59445
59871
  }
59446
59872
  lines.push(...describeSections(model, app));
59447
59873
  lines.push(` thread ${app.record.comments ? "the record's comments, beside the sections" : "\u2014"}`);
59448
- const history = app.record.history;
59874
+ const moves = app.record.moves;
59449
59875
  lines.push(
59450
- ` history ${history === void 0 ? "\u2014" : `${history.title} \u2014 each move of the status by ${label(model, history.entity, history.at)}${history.by === void 0 ? "" : `, who ${label(model, history.entity, history.by)}`}, beside the sections`}`
59876
+ ` history ${moves === void 0 || !app.record.history ? "\u2014" : `${moves.title} \u2014 each move of the status by ${label(model, moves.entity, moves.at)}${moves.by === void 0 ? "" : `, who ${label(model, moves.entity, moves.by)}`}, beside the sections`}`
59451
59877
  );
59452
59878
  lines.push(...block("acts", app.acts.map((act) => describeAct(model, app, act))));
59453
59879
  lines.push(` Reads ${app.entities.map((alias2) => entityOf2(model, alias2)?.label ?? alias2).join(", ")}`);
@@ -59535,7 +59961,7 @@ function describeSections(model, app) {
59535
59961
  }
59536
59962
  function describeBlock(model, app, one, path40) {
59537
59963
  const titled = (title) => `${title}${marked(app, `${path40}.title`)}`;
59538
- const add = (create, entity) => create === void 0 ? "; rows are not added here" : `; add asks ${labels(model, entity, create)}${marked(app, `${path40}.create`)}`;
59964
+ const add = (create, entity) => create === void 0 ? "; rows are not added here" : `; add asks ${labels(model, entity, create)}${marked(app, `${path40}.create`)}${startWords(model, entity, create)}`;
59539
59965
  const shown = one.when.length === 0 ? "" : ` (${whileWords(model, app.entity, one.when)})`;
59540
59966
  const line = (() => {
59541
59967
  switch (one.kind) {
@@ -59543,7 +59969,12 @@ function describeBlock(model, app, one, path40) {
59543
59969
  const where = one.where.length === 0 ? "" : ` ${whileWords(model, one.entity, one.where).replace(/^while /, "where ").replaceAll(", while ", " and ")}`;
59544
59970
  const set2 = new Set(one.where.flatMap((gate) => gate.options.length === 1 ? [gate.field] : []));
59545
59971
  const asked = one.create?.filter((field2) => !set2.has(field2));
59546
- return `${titled(one.title)} \u2014 ${one.expect === void 0 ? "rows of" : `one line per ${label(model, one.entity, one.expect)} of`} ${entityOf2(model, one.entity)?.label ?? one.entity}${where} through ${label(model, one.entity, one.via)}; columns ${labels(model, one.entity, one.columns)}${add(asked, one.entity)}`;
59972
+ return `${titled(one.title)} \u2014 ${one.expect === void 0 ? "rows of" : `one line per ${label(model, one.entity, one.expect)} of`} ${entityOf2(model, one.entity)?.label ?? one.entity}${where} through ${label(model, one.entity, one.via)}; columns ${labels(model, one.entity, one.columns)}${add(asked, one.entity)}${one.under === void 0 ? "" : describeUnder(model, one.under)}`;
59973
+ }
59974
+ case "agenda": {
59975
+ const within2 = one.time !== void 0 ? `at ${label(model, one.entity, one.time)}` : one.part !== void 0 ? `in ${label(model, one.entity, one.part)}'s order` : "in the order added";
59976
+ const first2 = one.start === void 0 ? "" : `, day 1 on ${label(model, app.entity, one.start)}`;
59977
+ return `${titled(one.title)} \u2014 ${entityOf2(model, one.entity)?.label ?? one.entity} planned by ${label(model, one.entity, one.day)}, soonest first, ${within2}${first2}, through ${label(model, one.entity, one.via)}; columns ${labels(model, one.entity, one.columns)}${add(one.create, one.entity)}`;
59547
59978
  }
59548
59979
  case "timeline":
59549
59980
  return `${titled(one.title)} \u2014 entries of ${entityOf2(model, one.entity)?.label ?? one.entity} by ${label(model, one.entity, one.at)}${one.by === void 0 ? "" : `, who ${label(model, one.entity, one.by)}`}${add(one.create, one.entity)}`;
@@ -59559,6 +59990,14 @@ function describeBlock(model, app, one, path40) {
59559
59990
  })();
59560
59991
  return `${line}${shown}`;
59561
59992
  }
59993
+ function describeUnder(model, under) {
59994
+ const document2 = entityOf2(model, under.entity);
59995
+ const named = document2?.singular ?? document2?.label ?? under.entity;
59996
+ const sent = /* @__PURE__ */ new Set([under.back, ...under.where.map((one) => one.field)]);
59997
+ const asked = under.create?.filter((field2) => !sent.has(field2)) ?? [];
59998
+ const make = under.create === void 0 ? "; documents are not made here" : `; a ${named} is made over the lines picked, asking ${asked.length === 0 ? "nothing more" : labels(model, under.entity, asked)}`;
59999
+ return `; each under its ${named} (${label(model, under.entity, under.back)}), the lines with none first${make}`;
60000
+ }
59562
60001
  function describeAct(model, app, act) {
59563
60002
  const at2 = act.of ?? app.entity;
59564
60003
  const parts = [
@@ -59617,6 +60056,40 @@ function placed(model, app, act) {
59617
60056
  function count(n, noun) {
59618
60057
  return `${n} ${noun}${n === 1 ? "" : "s"}`;
59619
60058
  }
60059
+ function cellWords(value) {
60060
+ if (value === void 0 || value === null) return "";
60061
+ if (Array.isArray(value)) return value.map(cellWords).join(", ");
60062
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
60063
+ }
60064
+ function indistinctRows(records, rows, apps) {
60065
+ const registered = new Set(apps.flatMap((app) => isDashboardApp(app) ? [] : [app.entity]));
60066
+ return Object.entries(rows).flatMap(([entity, list3]) => {
60067
+ const display = records[entity];
60068
+ if (display === void 0 || !registered.has(entity)) return [];
60069
+ const read2 = [display.title, ...display.subtitle ?? []];
60070
+ const status = display.status?.field;
60071
+ const closed = new Set(status === void 0 ? [] : display.status?.closed ?? []);
60072
+ const alike = /* @__PURE__ */ new Map();
60073
+ for (const [index, row] of list3.entries()) {
60074
+ const held2 = status === void 0 ? void 0 : row.fields[status];
60075
+ if (row.fields[display.title] === void 0 || typeof held2 === "string" && closed.has(held2)) continue;
60076
+ const key = JSON.stringify(read2.map((field2) => row.fields[field2] ?? null));
60077
+ alike.set(key, [...alike.get(key) ?? [], index]);
60078
+ }
60079
+ return [...alike.values()].flatMap((indexes) => {
60080
+ const [first2] = indexes;
60081
+ if (first2 === void 0 || indexes.length < 2) return [];
60082
+ const line = read2.map((field2) => cellWords(list3[first2]?.fields[field2])).filter((words) => words !== "").join(" \xB7 ");
60083
+ return [
60084
+ {
60085
+ severity: "error",
60086
+ path: `rows.${entity}.${first2}`,
60087
+ message: `${indexes.length} open rows read "${line}" (rows ${indexes.join(", ")}) \u2014 the register cannot tell them apart; give each its own title, or a subtitle that differs`
60088
+ }
60089
+ ];
60090
+ });
60091
+ });
60092
+ }
59620
60093
  function checkWorkspaceModel(raw, options = {}) {
59621
60094
  const read2 = parseModelFile(raw);
59622
60095
  if (read2.type === "invalid") return { errors: read2.findings, notes: [], model: null };
@@ -59633,6 +60106,8 @@ function checkWorkspaceModel(raw, options = {}) {
59633
60106
  // The model's language — a judgement about an authored file, so not in the validator the server
59634
60107
  // also runs over stored copies.
59635
60108
  ...checkModelLanguage(contract.entities),
60109
+ // Rows are the first a register draws: two it cannot tell apart are a judgement of the authored file too.
60110
+ ...indistinctRows(records, rows, apps),
59636
60111
  // Every preset branch, applied and checked as a model of its own.
59637
60112
  ...preset === void 0 ? [] : validateModelPreset(contract, preset, rows),
59638
60113
  // A document named by path must be beside the file, or `apply` fails after the tables exist.
@@ -59683,10 +60158,10 @@ async function uploadDocumentPaths(client, paths, baseDir, known) {
59683
60158
  const idByPath = new Map(Object.entries(known).filter(([relative3]) => paths.includes(relative3)));
59684
60159
  const fresh = paths.filter((relative3) => !idByPath.has(relative3));
59685
60160
  if (fresh.length === 0) {
59686
- note(`${count(paths.length, "document")} already uploaded \u2014 reusing the ids the last run recorded.`);
60161
+ note2(`${count(paths.length, "document")} already uploaded \u2014 reusing the ids the last run recorded.`);
59687
60162
  return idByPath;
59688
60163
  }
59689
- note(`Uploading ${count(fresh.length, "document")} the rows attach\u2026`);
60164
+ note2(`Uploading ${count(fresh.length, "document")} the rows attach\u2026`);
59690
60165
  for (const relative3 of fresh) {
59691
60166
  const uploaded = await client.uploadFiles([path9.resolve(baseDir, relative3)]);
59692
60167
  const error52 = uploaded.errors[0];
@@ -59696,7 +60171,7 @@ async function uploadDocumentPaths(client, paths, baseDir, known) {
59696
60171
  const id = uploaded.files[0]?.id;
59697
60172
  if (id === void 0) throw new Error(`The upload returned no file for "${relative3}".`);
59698
60173
  idByPath.set(relative3, id);
59699
- note(` uploaded ${relative3} ${id}`);
60174
+ note2(` uploaded ${relative3} ${id}`);
59700
60175
  }
59701
60176
  return idByPath;
59702
60177
  }
@@ -59794,7 +60269,7 @@ async function resolveOverlay(file2, raw, overlay) {
59794
60269
  if (model === null || dropped.length > 0) {
59795
60270
  return { kind: "error", findings: [...dropped, ...errors], notes };
59796
60271
  }
59797
- note(`${file2} starts from ${read2.name} (${overlay.from}).`);
60272
+ note2(`${file2} starts from ${read2.name} (${overlay.from}).`);
59798
60273
  return { kind: "ok", model, notes };
59799
60274
  }
59800
60275
  async function knownSlugs(named) {
@@ -60091,7 +60566,7 @@ async function applyModelDocuments(client, model, options) {
60091
60566
  (document2) => document2.kind === "path"
60092
60567
  );
60093
60568
  if (documents.length === 0) {
60094
- note(`No row in ${options.file} attaches a document by path \u2014 nothing to write.`);
60569
+ note2(`No row in ${options.file} attaches a document by path \u2014 nothing to write.`);
60095
60570
  return { documents: {}, attached: 0 };
60096
60571
  }
60097
60572
  const binding = await client.getModelBinding();
@@ -60158,7 +60633,7 @@ async function applyModelDocuments(client, model, options) {
60158
60633
  }
60159
60634
  }
60160
60635
  const written = Object.fromEntries(idByPath);
60161
- note(
60636
+ note2(
60162
60637
  `
60163
60638
  Attached ${count(attached, "document")} across ${count(entities.length, "table")}. Running this again attaches nothing \u2014 this workspace holds the id each path was uploaded as.`
60164
60639
  );
@@ -60182,7 +60657,7 @@ function modelSignInPath(run) {
60182
60657
  async function applyModelPackages(client, entries2) {
60183
60658
  const applied = [];
60184
60659
  for (const entry of entries2) {
60185
- note(`
60660
+ note2(`
60186
60661
  Applying ${entry.package}\u2026`);
60187
60662
  try {
60188
60663
  const copy = await copyPackage(client, {
@@ -60218,7 +60693,7 @@ function boundByNote(boundBy) {
60218
60693
  async function scaffoldUnbind(client, kind, alias2) {
60219
60694
  const { forgotten } = await client.deleteModelBinding(kind, alias2);
60220
60695
  const next = kind === "app" ? `An app regenerated from now on draws its sibling act naming "${alias2}" disabled until \`lotics app create --from <model.json>#${alias2}\` binds a new one.` : `The next apply binds it by label, and creates one where no label matches.`;
60221
- note(`Forgot ${count(forgotten, "binding")} under ${kind} "${alias2}". ${next}`);
60696
+ note2(`Forgot ${count(forgotten, "binding")} under ${kind} "${alias2}". ${next}`);
60222
60697
  return { forgotten };
60223
60698
  }
60224
60699
  async function scaffoldApply(client, model, options) {
@@ -60226,7 +60701,7 @@ async function scaffoldApply(client, model, options) {
60226
60701
  }
60227
60702
  async function applyModel(client, model, options) {
60228
60703
  const labels2 = new Map(model.contract.entities.map((entity) => [entity.alias, entity.label]));
60229
- note(`Applying the model \u2014 ${summarizeModel(model)}\u2026`);
60704
+ note2(`Applying the model \u2014 ${summarizeModel(model)}\u2026`);
60230
60705
  const attaches = modelRowDocuments(model.contract, model.rows).some((document2) => document2.kind === "path");
60231
60706
  const known = attaches ? (await client.getModelBinding()).documents : {};
60232
60707
  const uploaded = await uploadModelDocuments(client, model, path9.dirname(path9.resolve(options.file)), known);
@@ -60236,16 +60711,16 @@ async function applyModel(client, model, options) {
60236
60711
  const result = await postModel(client, { ...model, rows: uploaded.rows }, options);
60237
60712
  const width = Math.max(...result.entities.map((entity) => entity.alias.length), 0);
60238
60713
  for (const entity of result.entities) {
60239
- note(
60714
+ note2(
60240
60715
  ` ${entity.created ? "created" : "adopted"} ${entity.alias.padEnd(width)} ` + `${entity.table_id} ${labels2.get(entity.alias) ?? ""}`.trimEnd() + scaffoldDelta(entity) + boundByNote(entity.bound_by)
60241
60716
  );
60242
60717
  }
60243
60718
  for (const role of result.roles) {
60244
- note(` ${role.created ? "created" : "adopted"} role ${role.alias.padEnd(width)} ${role.group_id}`);
60719
+ note2(` ${role.created ? "created" : "adopted"} role ${role.alias.padEnd(width)} ${role.group_id}`);
60245
60720
  }
60246
60721
  for (const [alias2, ids] of Object.entries(result.record_ids)) {
60247
60722
  if (ids.length === 0) continue;
60248
- note(` rows ${alias2.padEnd(width)} ${ids.length}`);
60723
+ note2(` rows ${alias2.padEnd(width)} ${ids.length}`);
60249
60724
  }
60250
60725
  for (const alias2 of result.carried_rules ?? []) {
60251
60726
  warn(` kept ${labels2.get(alias2) ?? alias2}'s own row rule (differs from the model) \u2014 every door serves the table's, not this file's`);
@@ -60291,7 +60766,7 @@ ${count(drifted.length, "name")} ${drifted.length === 1 ? "has" : "have"} moved
60291
60766
  const applied = await applyModelPackages(client, model.apply);
60292
60767
  const created = result.entities.filter((entity) => entity.created).length;
60293
60768
  const apps = applied.flatMap((entry) => entry.apps);
60294
- note(
60769
+ note2(
60295
60770
  `
60296
60771
  ${created === result.entities.length ? "Done" : `Done \u2014 ${created} of ${result.entities.length} tables created, the rest adopted`}.
60297
60772
  Everything here is ordinary: edit the tables, add fields, delete what you do not want.
@@ -60359,7 +60834,7 @@ function aggregateRows(rows, aggregate) {
60359
60834
  }
60360
60835
 
60361
60836
  // ../shared/src/schemas/app_spec.ts
60362
- var APP_SPEC_VERSION = 3;
60837
+ var APP_SPEC_VERSION = 4;
60363
60838
  var specTableIdSchema = zod_default.string().refine(isTableId, "a table must be a live table id (`tbl_\u2026`, or an older table's UUID), not a model alias");
60364
60839
  var specFieldIdSchema = zod_default.string().refine(isStoredKey, "a field must be a live field key, not a label");
60365
60840
  var specOptionIdSchema = zod_default.string().refine((id) => id.startsWith("opt_") && isStoredKey(id), "an option must be a live `opt_` key, not a model alias");
@@ -60368,7 +60843,9 @@ var specReadSchema = zod_default.object({
60368
60843
  query: zod_default.string().min(1),
60369
60844
  param: zod_default.string().min(1).optional()
60370
60845
  }).strict();
60371
- var specWhenSchema = zod_default.array(zod_default.object({ field: specColumnAliasSchema, options: zod_default.array(specOptionIdSchema).min(1) }).strict());
60846
+ var specOptionsHeldSchema = zod_default.object({ field: specColumnAliasSchema, options: zod_default.array(specOptionIdSchema).min(1) }).strict();
60847
+ var specConditionSchema = zod_default.union([specOptionsHeldSchema, zod_default.object({ field: specColumnAliasSchema, value: zod_default.boolean() }).strict()]);
60848
+ var specWhenSchema = zod_default.array(specConditionSchema);
60372
60849
  var specFieldTypeSchema = zod_default.enum([
60373
60850
  "text",
60374
60851
  "number",
@@ -60411,6 +60888,9 @@ var specFieldSchema = zod_default.object({
60411
60888
  /** A rollup of the model sums it: an amount that adds up, so a column of it totals at the foot — a
60412
60889
  * price or a rate, which nothing sums, has none. */
60413
60890
  summed: zod_default.literal(true).optional(),
60891
+ /** What an add starts at where the model says it is certain: a date at today (a moment at now), a
60892
+ * member at the reader adding the row. */
60893
+ starts: zod_default.enum(["today", "me"]).optional(),
60414
60894
  /** What an add starts from; the workspace fills it where a create leaves the field out. A select's
60415
60895
  * is its option ids. */
60416
60896
  default: zod_default.union([zod_default.string(), zod_default.number(), zod_default.boolean(), zod_default.array(specOptionIdSchema)]).optional(),
@@ -60420,6 +60900,10 @@ var specFieldSchema = zod_default.object({
60420
60900
  link: contractAliasSchema.optional(),
60421
60901
  /** A single link to a pictured entity: the column its linked row's picture is read under. */
60422
60902
  image: specColumnAliasSchema.optional(),
60903
+ /** A link to several rows of a pictured entity (`link`): the read their pictures come through, apart from the
60904
+ * link — a read beside it reaches only its first row. Every row in view hands its linked ids in `param` at
60905
+ * once, and each id answers under `link`'s own `image` and `title`. */
60906
+ pictures: specReadSchema.extend({ param: zod_default.string().min(1) }).strict().optional(),
60423
60907
  /** A single link: the linked row's own fields read beside it — its line, its figure and that figure's
60424
60908
  * bound, and each value a field of this row starts from — by the linked entity's field alias, each to
60425
60909
  * the column it is read under. */
@@ -60472,10 +60956,6 @@ var specEntitySchema = zod_default.object({
60472
60956
  ctx.issues.push({ code: "custom", input: ctx.value, path: ["milestones"], message: "a row's stage is its status select or its milestones, never both" });
60473
60957
  }
60474
60958
  });
60475
- var specReadingWhereSchema = zod_default.union([
60476
- zod_default.object({ field: specColumnAliasSchema, options: zod_default.array(specOptionIdSchema).min(1) }).strict(),
60477
- zod_default.object({ field: specColumnAliasSchema, value: zod_default.boolean() }).strict()
60478
- ]);
60479
60959
  var specAggregateSchema = zod_default.object({
60480
60960
  by: zod_default.array(specColumnAliasSchema).min(1).max(AGGREGATE_MAX_BY).optional(),
60481
60961
  day: specColumnAliasSchema.optional(),
@@ -60494,7 +60974,7 @@ var readingBase = {
60494
60974
  */
60495
60975
  read: specReadSchema.extend({ aggregate: specAggregateSchema.optional() }).strict(),
60496
60976
  /** The conditions it keeps rows by — pressed, each is the filter it adds. */
60497
- where: zod_default.array(specReadingWhereSchema).optional()
60977
+ where: specWhenSchema.optional()
60498
60978
  };
60499
60979
  var specReadingSchema = zod_default.discriminatedUnion("kind", [
60500
60980
  zod_default.object({
@@ -60542,6 +61022,8 @@ var specRegisterSchema = zod_default.object({
60542
61022
  /** The columns the search box matches, a link by its display — each line of a pasted list on its own. Absent, the list read's `search` param. */
60543
61023
  search: zod_default.array(specColumnAliasSchema).min(1).optional(),
60544
61024
  sort: zod_default.object({ field: specColumnAliasSchema, desc: zod_default.literal(true).optional() }).strict().optional(),
61025
+ /** The column the rows open grouped by — the reader's grouping starts at it and may change it. */
61026
+ group: specColumnAliasSchema.optional(),
60545
61027
  layout: zod_default.enum(["table", "cards", "calendar"]),
60546
61028
  /** What an add from the register asks; absent, rows are not added here. */
60547
61029
  create: zod_default.array(specColumnAliasSchema).min(1).optional(),
@@ -60593,7 +61075,46 @@ var specBlockSchema = zod_default.discriminatedUnion("kind", [
60593
61075
  of: specColumnAliasSchema.optional(),
60594
61076
  /** The child's selects each row here holds one of these options of: its read keeps only them, and a
60595
61077
  * row added here is written holding them. */
60596
- where: specWhenSchema.optional(),
61078
+ where: zod_default.array(specOptionsHeldSchema).optional(),
61079
+ /** The document the lines stand under, each a heading over the lines it covers. A document is made here
61080
+ * where `entities[entity].create` is: the lines picked are sent as `back`'s pieces added, the record in
61081
+ * `via`, and `where` as it states — its other fields are the form's. */
61082
+ under: zod_default.object({
61083
+ /** The line's one-link to the document. */
61084
+ field: specColumnAliasSchema,
61085
+ /** The document's entity — a key of `entities`. */
61086
+ entity: contractAliasSchema,
61087
+ /** The document's one-link to the record. */
61088
+ via: specColumnAliasSchema,
61089
+ /** The document's many-link to the lines it covers. */
61090
+ back: specColumnAliasSchema,
61091
+ /** The documents under one record; `param` takes the record's id. */
61092
+ read: specReadSchema,
61093
+ /** The document's selects a document made here holds, as its lines hold them. */
61094
+ where: zod_default.array(specOptionsHeldSchema).optional()
61095
+ }).strict().optional(),
61096
+ when: specWhenSchema.optional()
61097
+ }).strict(),
61098
+ /** Planned entries of a child entity under the record, by day, soonest first. */
61099
+ zod_default.object({
61100
+ kind: zod_default.literal("agenda"),
61101
+ entity: contractAliasSchema,
61102
+ via: specColumnAliasSchema,
61103
+ /** The child's rows under one record; `param` takes the record's id. */
61104
+ read: specReadSchema,
61105
+ title: zod_default.string().min(1),
61106
+ /** The facts on an entry's line, after its title. */
61107
+ columns: zod_default.array(specColumnAliasSchema),
61108
+ /** The child's date an entry stands on. */
61109
+ day: specColumnAliasSchema,
61110
+ /** A datetime ordering a day's entries — the day itself where it holds the hour. */
61111
+ time: specColumnAliasSchema.optional(),
61112
+ /** Else a single select ordering them: its options in the day's order. */
61113
+ part: specColumnAliasSchema.optional(),
61114
+ /** The record's date that is the first day: each day's heading counts from it. */
61115
+ start: specColumnAliasSchema.optional(),
61116
+ /** What an add asks, the link to the record set for it; absent, entries are not added here. */
61117
+ create: zod_default.array(specColumnAliasSchema).min(1).optional(),
60597
61118
  when: specWhenSchema.optional()
60598
61119
  }).strict(),
60599
61120
  specTimelineBlockSchema,
@@ -60631,10 +61152,16 @@ var specRecordSchema = zod_default.object({
60631
61152
  sections: zod_default.array(specSectionSchema),
60632
61153
  /** The record's comment thread stands beside its sections. */
60633
61154
  comments: zod_default.literal(true).optional(),
60634
- /** The record's status history, read beside its sections with the thread — never one of them. */
60635
- history: specTimelineBlockSchema.optional()
61155
+ /** The record's status history is drawn beside its sections with the thread — never one of them. */
61156
+ history: zod_default.literal(true).optional(),
61157
+ /** The moves of the record's status history, read wherever the entity has one — the steps the record went
61158
+ * through and how long its status has held, drawn or not; `history` draws them. */
61159
+ moves: specTimelineBlockSchema.optional()
60636
61160
  }).strict().check((ctx) => {
60637
- const { door, sections } = ctx.value;
61161
+ const { door, sections, history, moves } = ctx.value;
61162
+ if (history === true && moves === void 0) {
61163
+ ctx.issues.push({ code: "custom", input: history, path: ["history"], message: "draws a status history it does not read \u2014 `moves` is the read" });
61164
+ }
60638
61165
  const seen = /* @__PURE__ */ new Set();
60639
61166
  sections.forEach((section, index) => {
60640
61167
  const page = section.page;
@@ -60737,12 +61264,15 @@ function specQueryAliases(file2) {
60737
61264
  ...Object.values(file2.entities).flatMap((entity) => [
60738
61265
  entity.list.query,
60739
61266
  ...entity.one === void 0 ? [] : [entity.one.query],
60740
- ...Object.values(entity.fields).flatMap((field2) => field2.pick === void 0 ? [] : [field2.pick.query])
61267
+ ...Object.values(entity.fields).flatMap((field2) => [...field2.pick === void 0 ? [] : [field2.pick.query], ...field2.pictures === void 0 ? [] : [field2.pictures.query]])
60741
61268
  ]),
60742
61269
  ...specReadings(file2).map((reading) => reading.read.query),
60743
61270
  ...isDashboardSpec(file2) ? [] : [
60744
- ...specBlocks(file2).flatMap((block2) => block2.kind === "rows" || block2.kind === "timeline" ? [block2.read.query] : []),
60745
- ...file2.record.history === void 0 ? [] : [file2.record.history.read.query],
61271
+ ...specBlocks(file2).flatMap((block2) => [
61272
+ ...block2.kind === "rows" || block2.kind === "agenda" || block2.kind === "timeline" ? [block2.read.query] : [],
61273
+ ...block2.kind === "rows" && block2.under !== void 0 ? [block2.under.read.query] : []
61274
+ ]),
61275
+ ...file2.record.moves === void 0 ? [] : [file2.record.moves.read.query],
60746
61276
  ...file2.scope === void 0 ? [] : [file2.scope.counts.query]
60747
61277
  ]
60748
61278
  ];
@@ -61969,7 +62499,7 @@ async function runAppTypecheck(projectDir) {
61969
62499
  warn("package.json has no `typecheck` script \u2014 the app's types were not checked.");
61970
62500
  return;
61971
62501
  }
61972
- note("Typechecking...");
62502
+ note2("Typechecking...");
61973
62503
  await runNpm(["run", "typecheck"], projectDir);
61974
62504
  }
61975
62505
  function npmInstallArgs(hasLockfile, specs = []) {
@@ -61978,7 +62508,7 @@ function npmInstallArgs(hasLockfile, specs = []) {
61978
62508
  }
61979
62509
  function runNpmInstall(cwd, specs = []) {
61980
62510
  const hasLockfile = fs11.existsSync(path12.join(cwd, "package-lock.json"));
61981
- note(
62511
+ note2(
61982
62512
  specs.length > 0 ? `Installing ${specs.join(" ")} (resolving the rest of the tree, no install scripts)...` : hasLockfile ? "Installing npm dependencies (npm ci \u2014 the lockfile's exact tree, no install scripts)..." : "Installing npm dependencies (no lockfile yet \u2014 resolving ranges, no install scripts)..."
61983
62513
  );
61984
62514
  return runNpm(npmInstallArgs(hasLockfile, specs), cwd);
@@ -65354,7 +65884,7 @@ function stateRanges(projectDir, ranges) {
65354
65884
  }
65355
65885
  function noteMigration(migrated) {
65356
65886
  if (migrated === null) return;
65357
- note(
65887
+ note2(
65358
65888
  `Migrated from ${FOLDED_SDK_MOVE}: dropped the dependency` + (migrated.length === 0 ? "; no file named it." : ` and rewrote ${migrated.join(", ")}.`)
65359
65889
  );
65360
65890
  }
@@ -65422,14 +65952,14 @@ async function appKit(args) {
65422
65952
  const prior = record2?.replaced?.runtime ?? null;
65423
65953
  const listedUi = dependenciesOf(readManifest2(projectDir))[KIT_UI] ?? null;
65424
65954
  const ranges = prior === null ? await requireKitRanges() : { runtime: prior, ui: record2?.replaced?.ui ?? listedUi };
65425
- note(
65955
+ note2(
65426
65956
  prior !== null ? `Restoring ${KIT_PACKAGE} to ${prior}, the range this app listed before the checkout went in\u2026` : record2 === void 0 ? `Moving ${KIT_PACKAGE} to the published ${ranges.runtime}\u2026` : `Restoring ${KIT_PACKAGE} to the published ${ranges.runtime} \u2014 the checkout's record names no range this app listed before it\u2026`
65427
65957
  );
65428
65958
  unpinLocalKit(projectDir, KIT_PACKAGE, ranges.runtime);
65429
65959
  const migrated2 = stateRanges(projectDir, ranges);
65430
65960
  await runNpmInstall(projectDir, [`${KIT_PACKAGE}@${ranges.runtime}`]);
65431
65961
  noteMigration(migrated2);
65432
- note(`${KIT_PACKAGE} is ${ranges.runtime}.`);
65962
+ note2(`${KIT_PACKAGE} is ${ranges.runtime}.`);
65433
65963
  return;
65434
65964
  }
65435
65965
  const packageDir = path14.resolve(args.packagePath);
@@ -65463,7 +65993,7 @@ async function appKit(args) {
65463
65993
  }
65464
65994
  });
65465
65995
  noteMigration(migrated);
65466
- note(
65996
+ note2(
65467
65997
  `${name}@${packed.version} installed from ${packageDir} (${witness} matches).
65468
65998
  'lotics app deploy' refuses this app until the kit is published, or takes --allow-local-kit.`
65469
65999
  );
@@ -65471,7 +66001,7 @@ async function appKit(args) {
65471
66001
  async function packCheckout(packageDir, kitDir, accepted = [KIT_PACKAGE]) {
65472
66002
  const { name, version: version2, files, builds } = kitPackageAt(packageDir, accepted);
65473
66003
  if (builds) {
65474
- note(`Building ${name} in ${packageDir}\u2026`);
66004
+ note2(`Building ${name} in ${packageDir}\u2026`);
65475
66005
  try {
65476
66006
  await runNpm(["run", "build"], packageDir);
65477
66007
  } catch (error52) {
@@ -65487,7 +66017,7 @@ async function packCheckout(packageDir, kitDir, accepted = [KIT_PACKAGE]) {
65487
66017
  for (const entry of fs13.readdirSync(kitDir)) {
65488
66018
  if (entry.startsWith(prefix) && entry.endsWith(".tgz")) fs13.rmSync(path14.join(kitDir, entry), { force: true });
65489
66019
  }
65490
- note(`Packing ${name}@${version2}\u2026`);
66020
+ note2(`Packing ${name}@${version2}\u2026`);
65491
66021
  const listing = packListingSchema.parse(JSON.parse(await npmOutput(["pack", "--json", "--ignore-scripts", "--pack-destination", kitDir], packageDir)));
65492
66022
  const tarball = tarballName(name, version2);
65493
66023
  if (!fs13.existsSync(path14.join(kitDir, tarball))) {
@@ -74222,7 +74752,7 @@ async function writeGeneratedAppFields(client, projectDir, app, manifest) {
74222
74752
  }
74223
74753
  const groups = await client.getMemberGroups();
74224
74754
  const fieldsPath = writeAppFields(projectDir, tables, groups);
74225
- note(
74755
+ note2(
74226
74756
  `Wrote ${fieldsPath} (baked form \u2014 bespoke; ${tables.length} table${tables.length === 1 ? "" : "s"}, ${groups.length} group${groups.length === 1 ? "" : "s"})`
74227
74757
  );
74228
74758
  } catch (err) {
@@ -74247,7 +74777,7 @@ async function writeAppSiblings(client, projectDir) {
74247
74777
  }
74248
74778
  fs16.mkdirSync(path17.dirname(file2), { recursive: true });
74249
74779
  fs16.writeFileSync(file2, generateAppSiblings(apps));
74250
- note(`Wrote ${file2} (${apps.size} app${apps.size === 1 ? "" : "s"} of this workspace's model)`);
74780
+ note2(`Wrote ${file2} (${apps.size} app${apps.size === 1 ? "" : "s"} of this workspace's model)`);
74251
74781
  }
74252
74782
  function warnAppFieldsUnwritten(projectDir, err) {
74253
74783
  const reason = err instanceof Error ? err.message : String(err);
@@ -74443,7 +74973,7 @@ async function hydrateAppProject(client, targetPath, app, opts) {
74443
74973
  );
74444
74974
  opts.kept.push(...kept);
74445
74975
  if (written.length > 0) {
74446
- note(
74976
+ note2(
74447
74977
  `Wrote ${written.length} workflow ${written.length === 1 ? "body" : "bodies"} to ${WORKFLOWS_DIR}/ (${written.join(", ")})`
74448
74978
  );
74449
74979
  }
@@ -74476,7 +75006,7 @@ async function hydrateAppProject(client, targetPath, app, opts) {
74476
75006
  unseenLiveProse.push({ alias: alias2, parked: parkLiveAgentProse(targetPath, alias2, instructions) });
74477
75007
  }
74478
75008
  if (written.length > 0) {
74479
- note(
75009
+ note2(
74480
75010
  `Wrote ${written.length} agent instruction ${written.length === 1 ? "file" : "files"} to ${AGENTS_DIR}/ (${written.join(", ")})`
74481
75011
  );
74482
75012
  }
@@ -77258,7 +77788,7 @@ async function appDeploy(client, args) {
77258
77788
  );
77259
77789
  }
77260
77790
  await runAppTypecheck(projectDir);
77261
- note("Building...");
77791
+ note2("Building...");
77262
77792
  await runNpm(["run", "build"], projectDir);
77263
77793
  const distDir = path23.join(projectDir, "dist");
77264
77794
  if (!fs23.existsSync(distDir)) {
@@ -77269,15 +77799,15 @@ async function appDeploy(client, args) {
77269
77799
  const tmpSource = path23.join(tmpdir2(), `lotics-source-${Date.now()}.tar.gz`);
77270
77800
  const tmpDist = path23.join(tmpdir2(), `lotics-dist-${Date.now()}.tar.gz`);
77271
77801
  try {
77272
- note("Packaging source...");
77802
+ note2("Packaging source...");
77273
77803
  await runTar(
77274
77804
  ["-czf", tmpSource, ...ARCHIVE_EXCLUDES.map((pattern) => `--exclude=${pattern}`), "."],
77275
77805
  projectDir
77276
77806
  );
77277
- note("Packaging dist...");
77807
+ note2("Packaging dist...");
77278
77808
  await runTar(["-czf", tmpDist, "-C", distDir, "."], projectDir);
77279
77809
  const liveQueries = await readLiveQueries(client, meta3.app_id);
77280
- note("Uploading...");
77810
+ note2("Uploading...");
77281
77811
  try {
77282
77812
  const result = await client.deployAppVersion({
77283
77813
  queries: liveQueries,
@@ -77320,15 +77850,15 @@ async function appDeploy(client, args) {
77320
77850
  // below. Omitting it preserves the previous set, which is what keeps an
77321
77851
  // unresolved orphan reportable on the next run.
77322
77852
  });
77323
- note(`Deployed v${result.version_number} (${result.version_id})`);
77324
- note(`Bundle size: ${(result.bundle_size_bytes / 1024).toFixed(1)} KB`);
77325
- if (result.origin !== void 0) note(`Address: ${result.origin}`);
77853
+ note2(`Deployed v${result.version_number} (${result.version_id})`);
77854
+ note2(`Bundle size: ${(result.bundle_size_bytes / 1024).toFixed(1)} KB`);
77855
+ if (result.origin !== void 0) note2(`Address: ${result.origin}`);
77326
77856
  try {
77327
77857
  const drifted = staleWorkflowGlobals(projectDir, meta3.workflows ?? {});
77328
77858
  for (const { alias: alias2, declaration, hadTypes } of drifted) {
77329
77859
  const refreshed = await refreshWorkflowTypes(client, projectDir, meta3.app_id, alias2, declaration);
77330
77860
  if (refreshed.kind !== "refreshed") continue;
77331
- note(
77861
+ note2(
77332
77862
  hadTypes ? `Refreshed workflow types for ${alias2} \u2014 this deploy moved its declaration.` : `Wrote workflow types for ${alias2} \u2014 this project had none.`
77333
77863
  );
77334
77864
  }
@@ -77389,7 +77919,7 @@ Unbinding ${retired.queries.length + retired.workflows.length} binding(s) the pl
77389
77919
  let outstanding = orphans;
77390
77920
  if (args.prune) {
77391
77921
  if (!meta3.bundle_calls && nothingOrphaned(undeclared2)) {
77392
- note(
77922
+ note2(
77393
77923
  "Nothing to prune yet: this project has no record of what the previous bundle called, so there is no way to tell a retired binding from an agent-facing one. This deploy records it; the next can compare."
77394
77924
  );
77395
77925
  }
@@ -77682,6 +78212,9 @@ function pickQueryAlias(entity, field2) {
77682
78212
  function readingQueryAlias(place, index) {
77683
78213
  return `${place}_reading_${index}`;
77684
78214
  }
78215
+ function picturesQueryAlias(entity) {
78216
+ return `${entity}_pictures`;
78217
+ }
77685
78218
  function folderCountsQueryAlias(entity, scope) {
77686
78219
  return `${entity}_count_by_${scope}`;
77687
78220
  }
@@ -77695,6 +78228,9 @@ var SEARCH_PARAM = "search";
77695
78228
  function recordParam(entity) {
77696
78229
  return `${entity}_id`;
77697
78230
  }
78231
+ function recordsParam(entity) {
78232
+ return `${entity}_ids`;
78233
+ }
77698
78234
  function createAlias(entity) {
77699
78235
  return `create_${entity}`;
77700
78236
  }
@@ -78089,7 +78625,7 @@ function appendHistory(history, record2, moved, copies = []) {
78089
78625
  ];
78090
78626
  }
78091
78627
  function milestoneOrder(stages, touches, left, words, landing) {
78092
- const applies = (stage) => stage.when.map((gate) => `includes([${gate.options.map(str).join(", ")}], ${left(gate.field)})`);
78628
+ const applies = (stage) => stage.when.map((gate) => "value" in gate ? `${left(gate.field)} ${gate.value ? "==" : "!="} true` : `includes([${gate.options.map(str).join(", ")}], ${left(gate.field)})`);
78093
78629
  const dated = (stage) => left(stage.field) === "now()" ? "true" : `!(${heldEmpty(stage.field, left(stage.field))})`;
78094
78630
  return validateBlock(
78095
78631
  stages.flatMap(
@@ -78197,7 +78733,7 @@ function actGates(plan) {
78197
78733
  ...scopeGuard(plan.table, "row", words),
78198
78734
  ...validateBlock([
78199
78735
  ...plan.when.map(
78200
- (gate) => checkLine(
78736
+ (gate) => "value" in gate ? checkLine(`row.data[${str(gate.field.id)}] ${gate.value ? "!=" : "=="} true`, void 0, str(words.refusals.notWhile(plan.label, gate.label, gate.value))) : checkLine(
78201
78737
  `!includes([${gate.options.map(str).join(", ")}], row.data[${str(gate.field.id)}])`,
78202
78738
  void 0,
78203
78739
  str(words.refusals.notNow(plan.label, gate.label, gate.names))
@@ -78264,7 +78800,8 @@ function actHistory(plan, record2) {
78264
78800
  }
78265
78801
  function actRuled(plan) {
78266
78802
  const { rules } = plan;
78267
- return rules.written.some((one) => one.min?.kind === "value" || one.max?.kind === "value" || (one.link?.same.length ?? 0) > 0) || rules.bounded.length > 0 || rules.held.length > 0 || rules.kept.length > 0 || rules.strays.length > 0 || rules.overlap !== void 0;
78803
+ const cleared = new Set(plan.set.filter((one) => one.value === "null").map((one) => one.field.id));
78804
+ return rules.written.some((one) => !cleared.has(one.field.id) && (one.min?.kind === "value" || one.max?.kind === "value" || (one.link?.same.length ?? 0) > 0)) || rules.bounded.length > 0 || rules.held.length > 0 || rules.kept.length > 0 || rules.strays.length > 0 || rules.overlap !== void 0;
78268
78805
  }
78269
78806
  function actLeaves(plan) {
78270
78807
  if (!actRuled(plan)) return [];
@@ -78407,6 +78944,32 @@ function createValue(field2) {
78407
78944
  if (field2.field.type === "select_record_link") return editedByDelta(field2.field) ? `isEmpty(${handed}) ? null : ${handed}` : `isNull(${handed}) ? null : [${handed}]`;
78408
78945
  return handed;
78409
78946
  }
78947
+ function optionAsOwn(theirs, own, value) {
78948
+ const pairs = [...theirs.options ?? []].flatMap(([alias2, id]) => {
78949
+ const to = own.options?.get(alias2);
78950
+ return to === void 0 ? [] : [`${value} == ${str(id)} ? ${str(to)} : `];
78951
+ });
78952
+ return `(${pairs.join("")}null)`;
78953
+ }
78954
+ function coversGuard(covers, record2, words) {
78955
+ const sent = deltaOf(covers.back).added;
78956
+ const cell = (field2) => `coveredLine.data[${str(field2.id)}]`;
78957
+ return [
78958
+ ...validateBlock([checkLine(`isEmpty(i.${sent})`, sent, str(words.refusals.required(covers.back.field.label)))]),
78959
+ `for (const picked of i.${sent}) {`,
78960
+ ...indent([
78961
+ `const coveredLine = await get_record({ table_id: ${str(covers.table.id)}, record_id: picked });`,
78962
+ ...scopeGuard(covers.table, "coveredLine", words),
78963
+ ...validateBlock([
78964
+ checkLine(`!includes(coalesce(${cell(covers.via)}, []), ${record2})`, sent, str(words.refusals.notUnder(covers.line, covers.record))),
78965
+ checkLine(`!isEmpty(${cell(covers.under)})`, sent, str(words.refusals.covered(covers.line, covers.document))),
78966
+ ...covers.held.map((pair) => checkLine(`${optionAsOwn(pair.theirs, pair.own, cell(pair.theirs))} != i.${pair.own.alias}`, pair.own.alias, str(words.refusals.mismatched(covers.line, pair.own.field.label))))
78967
+ ]),
78968
+ ...checksGuard(covers.checks, "coveredLine")
78969
+ ]),
78970
+ `}`
78971
+ ];
78972
+ }
78410
78973
  function createSource(plan) {
78411
78974
  const { words } = plan;
78412
78975
  const handedFields = [...plan.fields.map((one) => one.field), ...plan.parent === void 0 ? [] : [plan.parent.field]];
@@ -78461,6 +79024,7 @@ function createSource(plan) {
78461
79024
  );
78462
79025
  }
78463
79026
  lines.push(...linkChecks(plan.fields, words, (field2) => createdInput(field2)));
79027
+ if (plan.covers !== void 0 && plan.parent !== void 0) lines.push(...coversGuard(plan.covers, `i.${plan.parent.field.alias}`, words));
78464
79028
  const copied = /* @__PURE__ */ new Map();
78465
79029
  for (const copy of plan.copies) {
78466
79030
  const local = `${camel(copy.link.alias)}Source`;
@@ -78685,6 +79249,36 @@ function linkImage(ctx, entity, alias2) {
78685
79249
  if (image === void 0 || !ctx.drawn.has(target)) return void 0;
78686
79250
  return { column: linkImageColumn(alias2), link: link.id, image: fieldOf2(ctx, target, image).id };
78687
79251
  }
79252
+ function picturedTarget(ctx, target) {
79253
+ return recordDisplay(ctx.model, target)?.image !== void 0 && ctx.drawn.has(target);
79254
+ }
79255
+ function linkPictures(ctx, entity, alias2) {
79256
+ const link = fieldOf2(ctx, entity, alias2);
79257
+ if (link.field.type !== "select_record_link" || !fieldHoldsSeveral(link.field)) return void 0;
79258
+ const target = link.field.target_entity;
79259
+ return picturedTarget(ctx, target) ? { query: picturesQueryAlias(target), param: recordsParam(target) } : void 0;
79260
+ }
79261
+ function picturesRead(ctx, target) {
79262
+ const query = picturesQueryAlias(target);
79263
+ const linked = [...ctx.drawn].some(([entity, fields]) => fields.some((alias2) => linkPictures(ctx, entity, alias2)?.query === query));
79264
+ const display = recordDisplay(ctx.model, target);
79265
+ if (!linked || display?.image === void 0) return {};
79266
+ const bent = entityOf3(ctx, target);
79267
+ const param = recordsParam(target);
79268
+ const ids = { node_type: "condition", type: "record_id", operator: "is_any_of", value: `{{params.${param}}}` };
79269
+ const filter2 = withScope(ctx, target, ids);
79270
+ return {
79271
+ [query]: {
79272
+ ast: {
79273
+ kind: "project",
79274
+ from: { kind: "from_table", table_id: bent.table, ...filter2 === void 0 ? {} : { filter: filter2 } },
79275
+ columns: [.../* @__PURE__ */ new Set([display.title, display.image])].map((alias2) => ({ source: fieldOf2(ctx, target, alias2).id, output: alias2 }))
79276
+ },
79277
+ params: { [param]: { type: "array", items: { type: "record_link", table_id: bent.table }, required: true } },
79278
+ description: ctx.words.queries.pictures(bent.entity.label)
79279
+ }
79280
+ };
79281
+ }
78688
79282
  var FACE_TYPES = { text: "text", autonumber: "text", number: "number", date: "date", boolean: "boolean", select: "select" };
78689
79283
  function linkFace(ctx, entity, alias2) {
78690
79284
  const link = fieldOf2(ctx, entity, alias2);
@@ -78797,7 +79391,8 @@ function entityReads(ctx, alias2) {
78797
79391
  ast: { kind: "project", from: { kind: "from_table", table_id: bent.table, ...oneFilter === void 0 ? {} : { filter: oneFilter } }, columns },
78798
79392
  params: { [recordParam(alias2)]: { type: "record_link", table_id: bent.table, required: true } },
78799
79393
  description: ctx.words.queries.one(bent.entity.label)
78800
- }
79394
+ },
79395
+ ...picturesRead(ctx, alias2)
78801
79396
  };
78802
79397
  }
78803
79398
  function folderCounts(ctx) {
@@ -79114,7 +79709,14 @@ function parentLocks(ctx, alias2) {
79114
79709
  if (alias2 === ctx.app.entity) return [];
79115
79710
  const checks = bodyChecks(ctx, "edit");
79116
79711
  if (checks.length === 0) return [];
79117
- const vias = [...new Set(resolvedBlocks(ctx.app.record).flatMap((block2) => (block2.kind === "rows" || block2.kind === "timeline") && block2.entity === alias2 ? [block2.via] : []))];
79712
+ const vias = [
79713
+ ...new Set(
79714
+ resolvedBlocks(ctx.app.record).flatMap((block2) => [
79715
+ ...(block2.kind === "rows" || block2.kind === "agenda" || block2.kind === "timeline") && block2.entity === alias2 ? [block2.via] : [],
79716
+ ...block2.kind === "rows" && block2.under?.entity === alias2 ? [block2.under.via] : []
79717
+ ])
79718
+ )
79719
+ ];
79118
79720
  const label2 = entityOf3(ctx, ctx.app.entity).entity.label;
79119
79721
  return vias.map((via) => ({ link: fieldOf2(ctx, alias2, via), label: label2, checks }));
79120
79722
  }
@@ -79124,9 +79726,9 @@ function bodyMilestones(ctx, entity) {
79124
79726
  return {
79125
79727
  field: field2,
79126
79728
  label: field2.field.label,
79127
- when: Object.entries(stage.when).map(([name, options]) => {
79128
- const select = fieldOf2(ctx, entity, name);
79129
- return { field: select, options: options.flatMap((option) => select.options?.get(option) ?? []) };
79729
+ when: whenConditions(stage.when).map((gate) => {
79730
+ const bound = fieldOf2(ctx, entity, gate.field);
79731
+ return "value" in gate ? { field: bound, value: gate.value } : { field: bound, options: gate.options.flatMap((option) => bound.options?.get(option) ?? []) };
79130
79732
  })
79131
79733
  };
79132
79734
  });
@@ -79162,6 +79764,25 @@ function boundHistory(ctx, entity, shape) {
79162
79764
  function historyFields(history) {
79163
79765
  return [history.link, history.status, ...history.at === void 0 ? [] : [history.at], ...history.by === void 0 ? [] : [history.by]];
79164
79766
  }
79767
+ function bodyCovers(ctx, alias2) {
79768
+ const blocks = resolvedBlocks(ctx.app.record).flatMap((block3) => block3.kind === "rows" && block3.under?.entity === alias2 ? [{ block: block3, under: block3.under }] : []);
79769
+ const [first2] = blocks;
79770
+ if (first2 === void 0) return void 0;
79771
+ const { block: block2, under } = first2;
79772
+ const named = (entity) => modelEntity(ctx, entity).singular ?? modelEntity(ctx, entity).label;
79773
+ const held2 = [...new Set(blocks.flatMap((one) => one.under.where.map((where) => where.field)))];
79774
+ return {
79775
+ back: fieldOf2(ctx, alias2, under.back),
79776
+ table: bodyTable(ctx, block2.entity),
79777
+ line: named(block2.entity),
79778
+ record: named(ctx.app.entity),
79779
+ document: named(alias2),
79780
+ via: fieldOf2(ctx, block2.entity, block2.via),
79781
+ under: fieldOf2(ctx, block2.entity, under.field),
79782
+ held: held2.map((name) => ({ own: fieldOf2(ctx, alias2, name), theirs: fieldOf2(ctx, block2.entity, name) })),
79783
+ checks: bodyChecks(ctx, "edit", block2.entity)
79784
+ };
79785
+ }
79165
79786
  function createRequired(field2) {
79166
79787
  return field2.required === true && !hasDefault(field2);
79167
79788
  }
@@ -79189,6 +79810,7 @@ function createPlan(ctx, alias2, writes) {
79189
79810
  const unique3 = fields.flatMap((one) => one.field.field.type === "text" && one.field.field.unique === true && !keyed.has(one.field.alias) ? [one.field] : []);
79190
79811
  const history = bodyHistory(ctx, alias2);
79191
79812
  const overlap = bodyOverlap(ctx, alias2);
79813
+ const covers = bodyCovers(ctx, alias2);
79192
79814
  return {
79193
79815
  table: bodyTable(ctx, alias2),
79194
79816
  fields,
@@ -79201,6 +79823,7 @@ function createPlan(ctx, alias2, writes) {
79201
79823
  expects: bodyExpects(ctx, alias2),
79202
79824
  ...history === void 0 ? {} : { history },
79203
79825
  milestones: bodyMilestones(ctx, alias2),
79826
+ ...covers === void 0 || !asked.has(covers.back.alias) ? {} : { covers },
79204
79827
  words: ctx.words
79205
79828
  };
79206
79829
  }
@@ -79298,6 +79921,7 @@ function actPlan(ctx, act) {
79298
79921
  param: act.on === "record" ? RECORD_INPUT : RECORDS_INPUT,
79299
79922
  when: act.when.map((gate) => {
79300
79923
  const bf = fieldOf2(ctx, entity, gate.field);
79924
+ if ("value" in gate) return { field: bf, label: bf.field.label, value: gate.value, names: [] };
79301
79925
  const declared = bf.field.type === "select" ? bf.field.options : [];
79302
79926
  return {
79303
79927
  field: bf,
@@ -79366,9 +79990,10 @@ function createInputs(ctx, alias2, writes) {
79366
79990
  if (via.field.type !== "select_record_link") fail(`"${alias2}.${via.alias}" files a row under its record, so it must be a link`);
79367
79991
  inputs[via.alias] = { type: "record_link", table_id: entityOf3(ctx, via.field.target_entity).table, required: true };
79368
79992
  }
79993
+ const lines = bodyCovers(ctx, alias2)?.back.alias;
79369
79994
  for (const name of writes.create ?? []) {
79370
79995
  const bf = fieldOf2(ctx, alias2, name);
79371
- const required2 = createRequired(bf.field);
79996
+ const required2 = createRequired(bf.field) || name === lines;
79372
79997
  if (editedByDelta(bf.field)) inputs[deltaOf(bf).added] = { ...fieldInput(ctx, bf, required2), multi: true };
79373
79998
  else inputs[name] = fieldInput(ctx, bf, required2);
79374
79999
  }
@@ -79484,6 +80109,7 @@ function specField(ctx, entity, alias2, pick3) {
79484
80109
  const readonly2 = isPlatformWrittenField(field2) || !isAsked(field2) || setBy;
79485
80110
  const stated2 = specDefault(field2, bf);
79486
80111
  const face = linkImage(ctx, entity, alias2);
80112
+ const pictures = linkPictures(ctx, entity, alias2);
79487
80113
  const linked = linkFace(ctx, entity, alias2);
79488
80114
  const offered = defaultFrom(ctx, entity, alias2);
79489
80115
  return {
@@ -79503,11 +80129,13 @@ function specField(ctx, entity, alias2, pick3) {
79503
80129
  ...gate === void 0 ? {} : { gate: typeof gate === "number" ? { value: gate } : { field: gate } },
79504
80130
  ...tolerance === void 0 ? {} : { tolerance: tolerance / 100 },
79505
80131
  ...(display?.due ?? []).includes(alias2) ? { due: true } : {},
80132
+ ...display?.starts?.[alias2] === void 0 ? {} : { starts: display.starts[alias2] },
79506
80133
  ...isSummed(ctx.model, entity, alias2) ? { summed: true } : {},
79507
80134
  ...stated2 === void 0 ? {} : { default: stated2 },
79508
80135
  ...options === void 0 ? {} : { options },
79509
80136
  ...field2.type === "select_record_link" && ctx.drawn.has(field2.target_entity) ? { link: field2.target_entity } : {},
79510
80137
  ...face === void 0 ? {} : { image: face.column },
80138
+ ...pictures === void 0 ? {} : { pictures },
79511
80139
  ...linked === void 0 ? {} : { face: Object.fromEntries(linked.columns.map((one) => [one.field, one.column])) },
79512
80140
  ...offered === void 0 ? {} : { default_from: offered },
79513
80141
  ...pick3 === void 0 ? {} : { pick: { query: pick3.query, param: SEARCH_PARAM, ...pick3.same.length === 0 ? {} : { same: [...pick3.same] } } }
@@ -79522,7 +80150,13 @@ function defaultFrom(ctx, entity, alias2) {
79522
80150
  }
79523
80151
  function specWhen(ctx, entity, when) {
79524
80152
  if (when.length === 0) return void 0;
79525
- return when.map((gate) => ({ field: gate.field, options: gate.options.flatMap((option) => fieldOf2(ctx, entity, gate.field).options?.get(option) ?? []) }));
80153
+ return when.map((gate) => "value" in gate ? { field: gate.field, value: gate.value } : optionsHeld(ctx, entity, gate));
80154
+ }
80155
+ function specWhere(ctx, entity, where) {
80156
+ return where.length === 0 ? void 0 : where.map((gate) => optionsHeld(ctx, entity, gate));
80157
+ }
80158
+ function optionsHeld(ctx, entity, gate) {
80159
+ return { field: gate.field, options: gate.options.flatMap((option) => fieldOf2(ctx, entity, gate.field).options?.get(option) ?? []) };
79526
80160
  }
79527
80161
  function specAt(ctx, entity, at2) {
79528
80162
  if (at2.length === 0) return void 0;
@@ -79559,11 +80193,7 @@ function specEntity(ctx, alias2, picks) {
79559
80193
  ...stages === void 0 ? {} : {
79560
80194
  milestones: {
79561
80195
  stages: stages.map((stage) => {
79562
- const when = specWhen(
79563
- ctx,
79564
- alias2,
79565
- Object.entries(stage.when).map(([field2, options]) => ({ field: field2, options }))
79566
- );
80196
+ const when = specWhen(ctx, alias2, whenConditions(stage.when));
79567
80197
  return { field: stage.field, ...when === void 0 ? {} : { when } };
79568
80198
  }),
79569
80199
  ...display.status?.closed === void 0 ? {} : { closed: [...display.status.closed] }
@@ -79618,7 +80248,6 @@ function specBlock(ctx, block2, index, entities) {
79618
80248
  return { spec: { kind: "reading", reading: spec, via: block2.via, ...when }, queries: queries2 };
79619
80249
  }
79620
80250
  const parent = ctx.app.entity;
79621
- const child = entityOf3(ctx, block2.entity);
79622
80251
  const via = fieldOf2(ctx, block2.entity, block2.via);
79623
80252
  const where = block2.kind === "rows" ? block2.where : [];
79624
80253
  const query = childrenQueryAlias(block2.entity, block2.via, where);
@@ -79626,23 +80255,33 @@ function specBlock(ctx, block2, index, entities) {
79626
80255
  const own = { node_type: "condition", type: "select_record_link", field_key: via.id, operator: "has_any_of", value: [`{{params.${param}}}`] };
79627
80256
  const filter2 = withScope(ctx, block2.entity, allOf([own, ...whereConditions(ctx, block2.entity, where)]));
79628
80257
  const sort = block2.kind === "timeline" ? [{ field_key: fieldOf2(ctx, block2.entity, block2.at).id, order: "desc" }] : rowOrder(ctx, block2.entity);
79629
- const queries = {
79630
- [query]: {
79631
- ast: {
79632
- kind: "project",
79633
- from: { kind: "from_table", table_id: child.table, ...sort === void 0 ? {} : { sort }, ...filter2 === void 0 ? {} : { filter: filter2 } },
79634
- columns: projection(ctx, block2.entity)
79635
- },
79636
- params: { [param]: { type: "record_link", table_id: entityOf3(ctx, parent).table, required: true } },
79637
- description: ctx.words.queries.children(child.entity.label, entityOf3(ctx, parent).entity.label, via.field.label)
79638
- }
79639
- };
80258
+ const queries = { [query]: childrenQuery(ctx, block2.entity, block2.via, filter2, sort) };
79640
80259
  const create = entities[block2.entity]?.create !== void 0 && ctx.plan.get(block2.entity)?.parent === block2.via ? block2.create : void 0;
80260
+ if (block2.kind === "agenda") {
80261
+ return {
80262
+ spec: {
80263
+ kind: "agenda",
80264
+ entity: block2.entity,
80265
+ via: block2.via,
80266
+ read: { query, param },
80267
+ title: block2.title,
80268
+ columns: [...block2.columns],
80269
+ day: block2.day,
80270
+ ...block2.time === void 0 ? {} : { time: block2.time },
80271
+ ...block2.part === void 0 ? {} : { part: block2.part },
80272
+ ...block2.start === void 0 ? {} : { start: block2.start },
80273
+ ...create === void 0 ? {} : { create: [...create] },
80274
+ ...when
80275
+ },
80276
+ queries
80277
+ };
80278
+ }
79641
80279
  if (block2.kind === "rows") {
80280
+ const under = block2.under === void 0 ? void 0 : specUnder(ctx, block2.under);
79642
80281
  const totals = blockTotals(ctx.model, parent, block2.entity, block2.via, block2.columns, block2.where);
79643
80282
  const narrower = narrowerTotals(ctx.model, parent, totals, block2.where);
79644
80283
  const soonest = blockSoonest(ctx.model, parent, block2.entity, block2.via);
79645
- const narrowed = specWhen(ctx, block2.entity, block2.where);
80284
+ const narrowed = specWhere(ctx, block2.entity, block2.where);
79646
80285
  return {
79647
80286
  spec: {
79648
80287
  kind: "rows",
@@ -79658,9 +80297,10 @@ function specBlock(ctx, block2, index, entities) {
79658
80297
  ...block2.expect === void 0 ? {} : { expect: block2.expect },
79659
80298
  ...block2.of === void 0 ? {} : { of: block2.of },
79660
80299
  ...narrowed === void 0 ? {} : { where: narrowed },
80300
+ ...under === void 0 ? {} : { under: under.spec },
79661
80301
  ...when
79662
80302
  },
79663
- queries
80303
+ queries: { ...queries, ...under?.queries }
79664
80304
  };
79665
80305
  }
79666
80306
  return {
@@ -79678,6 +80318,28 @@ function specBlock(ctx, block2, index, entities) {
79678
80318
  queries
79679
80319
  };
79680
80320
  }
80321
+ function childrenQuery(ctx, entity, via, filter2, sort) {
80322
+ const parent = entityOf3(ctx, ctx.app.entity);
80323
+ return {
80324
+ ast: {
80325
+ kind: "project",
80326
+ from: { kind: "from_table", table_id: entityOf3(ctx, entity).table, ...sort === void 0 ? {} : { sort }, ...filter2 === void 0 ? {} : { filter: filter2 } },
80327
+ columns: projection(ctx, entity)
80328
+ },
80329
+ params: { [recordParam(ctx.app.entity)]: { type: "record_link", table_id: parent.table, required: true } },
80330
+ description: ctx.words.queries.children(entityOf3(ctx, entity).entity.label, parent.entity.label, fieldOf2(ctx, entity, via).field.label)
80331
+ };
80332
+ }
80333
+ function specUnder(ctx, under) {
80334
+ const query = childrenQueryAlias(under.entity, under.via, []);
80335
+ const param = recordParam(ctx.app.entity);
80336
+ const own = { node_type: "condition", type: "select_record_link", field_key: fieldOf2(ctx, under.entity, under.via).id, operator: "has_any_of", value: [`{{params.${param}}}`] };
80337
+ const where = specWhere(ctx, under.entity, under.where);
80338
+ return {
80339
+ spec: { field: under.field, entity: under.entity, via: under.via, back: under.back, read: { query, param }, ...where === void 0 ? {} : { where } },
80340
+ queries: { [query]: childrenQuery(ctx, under.entity, under.via, withScope(ctx, under.entity, own), rowOrder(ctx, under.entity)) }
80341
+ };
80342
+ }
79681
80343
  function specHistory(ctx, history, entities) {
79682
80344
  const { spec, queries } = specBlock(ctx, { kind: "timeline", ...history, when: [] }, 0, entities);
79683
80345
  if (spec.kind !== "timeline") return fail(`compileApp: the status history of "${ctx.app.entity}" compiled to a ${spec.kind} block.`);
@@ -79730,10 +80392,13 @@ var PLAIN = {
79730
80392
  offered: (target) => `Not one of the ${target.toLowerCase()} this may point at.`,
79731
80393
  mismatched: (rows, field2) => `A ${rows.toLowerCase()} linked with this row names another ${field2.toLowerCase()} \u2014 change it first.`,
79732
80394
  notNow: (act, field2, options) => `${act} works only while ${field2} is ${options.join(" or ")}.`,
80395
+ notWhile: (act, field2, value) => `${act} works only while ${field2} is ${value ? "yes" : "no"}.`,
79733
80396
  unreadable: (record2) => `The ${record2.toLowerCase()} this row belongs to cannot be read, so it cannot be changed.`,
79734
80397
  milestoneOrder: (stage, before) => `${stage} comes after ${before} \u2014 fill that first.`,
79735
80398
  milestoneBefore: (stage, before) => `${stage} is dated before ${before} \u2014 a later step is reached on or after the day of the one before it.`,
79736
- stepDone: (act, step) => `${act} is done: ${step} is already dated.`
80399
+ stepDone: (act, step) => `${act} is done: ${step} is already dated.`,
80400
+ notUnder: (line, record2) => `A ${line.toLowerCase()} picked is not one of this ${record2.toLowerCase()}'s.`,
80401
+ covered: (line, document2) => `A ${line.toLowerCase()} picked already has its ${document2.toLowerCase()}.`
79737
80402
  },
79738
80403
  done: {
79739
80404
  opened: (entity) => `Opened one row of ${entity}.`,
@@ -79749,7 +80414,8 @@ var PLAIN = {
79749
80414
  children: (child, parent, link) => `The ${child} rows under one ${parent}, through "${link}".`,
79750
80415
  pick: (target, link) => `The ${target} rows "${link}" may point at, narrowed by what the reader typed.`,
79751
80416
  reading: (label2, entity) => `${label2}: the ${entity} rows counted, or summed, per value.`,
79752
- folders: (entity, folder) => `How many ${entity} rows each ${folder} holds.`
80417
+ folders: (entity, folder) => `How many ${entity} rows each ${folder} holds.`,
80418
+ pictures: (entity) => `The picture and name of each ${entity} row among the ids handed in.`
79753
80419
  },
79754
80420
  workflows: {
79755
80421
  create: (entity) => `Opens one row of ${entity}.`,
@@ -79776,10 +80442,13 @@ var DIACRITICS = {
79776
80442
  offered: (target) => `D\xF2ng ${target.toLowerCase()} n\xE0y kh\xF4ng n\u1EB1m trong danh s\xE1ch \u0111\u01B0\u1EE3c ch\u1ECDn.`,
79777
80443
  mismatched: (rows, field2) => `C\xF3 ${rows.toLowerCase()} g\u1EAFn v\u1EDBi d\xF2ng n\xE0y mang ${field2.toLowerCase()} kh\xE1c \u2014 h\xE3y s\u1EEDa tr\u01B0\u1EDBc.`,
79778
80444
  notNow: (act, field2, options) => `Ch\u1EC9 ${act.toLowerCase()} \u0111\u01B0\u1EE3c khi ${field2.toLowerCase()} l\xE0 ${options.join(" ho\u1EB7c ")}.`,
80445
+ notWhile: (act, field2, value) => `Ch\u1EC9 ${act.toLowerCase()} \u0111\u01B0\u1EE3c khi ${value ? "" : "kh\xF4ng "}${field2.toLowerCase()}.`,
79779
80446
  unreadable: (record2) => `Kh\xF4ng \u0111\u1ECDc \u0111\u01B0\u1EE3c ${record2.toLowerCase()} ch\u1EE9a d\xF2ng n\xE0y, n\xEAn kh\xF4ng th\u1EC3 thay \u0111\u1ED5i n\xF3.`,
79780
80447
  milestoneOrder: (stage, before) => `${stage} \u0111\u1EBFn sau ${before.toLowerCase()} \u2014 h\xE3y \u0111i\u1EC1n m\u1ED1c \u0111\xF3 tr\u01B0\u1EDBc.`,
79781
80448
  milestoneBefore: (stage, before) => `Ng\xE0y ${stage.toLowerCase()} s\u1EDBm h\u01A1n ng\xE0y ${before.toLowerCase()} \u2014 m\u1ED1c sau \u0111\u1EBFn c\xF9ng ng\xE0y ho\u1EB7c sau m\u1ED1c tr\u01B0\u1EDBc n\xF3.`,
79782
- stepDone: (act, step) => `${act} \u0111\xE3 xong: ${step.toLowerCase()} \u0111\xE3 c\xF3 ng\xE0y.`
80449
+ stepDone: (act, step) => `${act} \u0111\xE3 xong: ${step.toLowerCase()} \u0111\xE3 c\xF3 ng\xE0y.`,
80450
+ notUnder: (line, record2) => `C\xF3 ${line.toLowerCase()} \u0111\u01B0\u1EE3c ch\u1ECDn kh\xF4ng thu\u1ED9c ${record2.toLowerCase()} n\xE0y.`,
80451
+ covered: (line, document2) => `C\xF3 ${line.toLowerCase()} \u0111\u01B0\u1EE3c ch\u1ECDn \u0111\xE3 c\xF3 ${document2.toLowerCase()}.`
79783
80452
  },
79784
80453
  done: {
79785
80454
  opened: (entity) => `\u0110\xE3 m\u1EDF m\u1ED9t d\xF2ng ${entity}.`,
@@ -79795,7 +80464,8 @@ var DIACRITICS = {
79795
80464
  children: (child, parent, link) => `C\xE1c d\xF2ng ${child} thu\u1ED9c m\u1ED9t ${parent}, qua "${link}".`,
79796
80465
  pick: (target, link) => `C\xE1c d\xF2ng ${target} m\xE0 "${link}" \u0111\u01B0\u1EE3c ch\u1ECDn, l\u1ECDc theo ch\u1EEF ng\u01B0\u1EDDi \u0111\u1ECDc g\xF5.`,
79797
80466
  reading: (label2, entity) => `${label2}: c\xE1c d\xF2ng ${entity} \u0111\u01B0\u1EE3c \u0111\u1EBFm, ho\u1EB7c c\u1ED9ng, theo t\u1EEBng gi\xE1 tr\u1ECB.`,
79798
- folders: (entity, folder) => `S\u1ED1 d\xF2ng ${entity} trong m\u1ED7i ${folder}.`
80467
+ folders: (entity, folder) => `S\u1ED1 d\xF2ng ${entity} trong m\u1ED7i ${folder}.`,
80468
+ pictures: (entity) => `\u1EA2nh v\xE0 t\xEAn c\u1EE7a t\u1EEBng d\xF2ng ${entity} trong c\xE1c m\xE3 \u0111\u01B0\u1EE3c giao.`
79799
80469
  },
79800
80470
  workflows: {
79801
80471
  create: (entity) => `M\u1EDF m\u1ED9t d\xF2ng ${entity}.`,
@@ -79858,7 +80528,7 @@ function compileApp(model, app, bound, tables) {
79858
80528
  Object.assign(queries, entityReads(ctx, alias2), ...picks.map((pick3) => pick3.query));
79859
80529
  const spec2 = specEntity(ctx, alias2, new Map(picks.map((pick3) => [pick3.field, { query: pick3.alias, same: pick3.same }])));
79860
80530
  const table = bodyTable(ctx, alias2);
79861
- const history2 = bodyHistory(ctx, alias2);
80531
+ const history = bodyHistory(ctx, alias2);
79862
80532
  if (writes?.create !== void 0) {
79863
80533
  const workflow = createAlias(alias2);
79864
80534
  const create = createPlan(ctx, alias2, writes);
@@ -79871,14 +80541,14 @@ function compileApp(model, app, bound, tables) {
79871
80541
  ...create.copies.map((copy) => copy.into),
79872
80542
  ...create.stamps.map((stamp) => stamp.field)
79873
80543
  ]);
79874
- if (history2 !== void 0) wrote(history2.table, historyFields(history2));
80544
+ if (history !== void 0) wrote(history.table, historyFields(history));
79875
80545
  spec2.create = { workflow, fields: [...writes.create] };
79876
80546
  }
79877
80547
  if (writes !== void 0 && writes.update.length > 0) {
79878
80548
  const workflow = updateAlias(alias2);
79879
80549
  const fields = writes.update.map((name) => bodyField(ctx, alias2, name));
79880
80550
  const checks = bodyChecks(ctx, "edit", alias2);
79881
- const moves = history2 !== void 0 && fields.some((one) => one.field.id === history2.source.id) ? history2 : void 0;
80551
+ const moves2 = history !== void 0 && fields.some((one) => one.field.id === history.source.id) ? history : void 0;
79882
80552
  const overlap = bodyOverlap(ctx, alias2);
79883
80553
  const stages = bodyMilestones(ctx, alias2);
79884
80554
  const milestones = touchesMilestones(stages, new Set(fields.map((one) => one.field.alias))) ? stages : [];
@@ -79893,7 +80563,7 @@ function compileApp(model, app, bound, tables) {
79893
80563
  strays: bodyStrays(ctx, alias2, fields),
79894
80564
  ...overlap === void 0 ? {} : { overlap },
79895
80565
  expects: bodyExpects(ctx, alias2, new Set(fields.map((one) => one.field.alias))),
79896
- ...moves === void 0 ? {} : { history: moves },
80566
+ ...moves2 === void 0 ? {} : { history: moves2 },
79897
80567
  locks: parentLocks(ctx, alias2),
79898
80568
  milestones,
79899
80569
  words
@@ -79901,7 +80571,7 @@ function compileApp(model, app, bound, tables) {
79901
80571
  bodies[workflow] = body.source;
79902
80572
  workflows[workflow] = { inputs: updateInputs(ctx, alias2, fields), outputs: body.outputs, description: described(words.workflows.update(bent.entity.label), bent.entity) };
79903
80573
  wrote(bent.table, [...fields.map((one) => one.field), ...milestones.map((stage) => stage.field)]);
79904
- if (moves !== void 0) wrote(moves.table, historyFields(moves));
80574
+ if (moves2 !== void 0) wrote(moves2.table, historyFields(moves2));
79905
80575
  spec2.update = { workflow };
79906
80576
  }
79907
80577
  if (writes?.remove === true) {
@@ -79958,8 +80628,8 @@ function compileApp(model, app, bound, tables) {
79958
80628
  const readings = app.register.readings.map((reading) => specReading(ctx, reading, { kind: "register" }));
79959
80629
  const before = (at2) => app.record.sections.slice(0, at2).reduce((count2, section) => count2 + section.blocks.length, 0);
79960
80630
  const sections = app.record.sections.map((section, at2) => ({ section, blocks: section.blocks.map((block2, index) => specBlock(ctx, block2, before(at2) + index, entities)) }));
79961
- const history = app.record.history === void 0 ? void 0 : specHistory(ctx, app.record.history, entities);
79962
- for (const read2 of [...readings, ...sections.flatMap((one) => one.blocks), ...history === void 0 ? [] : [history]]) Object.assign(queries, read2.queries);
80631
+ const moves = app.record.moves === void 0 ? void 0 : specHistory(ctx, app.record.moves, entities);
80632
+ for (const read2 of [...readings, ...sections.flatMap((one) => one.blocks), ...moves === void 0 ? [] : [moves]]) Object.assign(queries, read2.queries);
79963
80633
  const spec = {
79964
80634
  version: APP_SPEC_VERSION,
79965
80635
  app: {
@@ -79976,6 +80646,7 @@ function compileApp(model, app, bound, tables) {
79976
80646
  filters: [...app.register.filters],
79977
80647
  ...app.register.search === void 0 ? {} : { search: [...app.register.search] },
79978
80648
  ...app.register.sort === void 0 ? {} : { sort: app.register.sort },
80649
+ ...app.register.group === void 0 ? {} : { group: app.register.group },
79979
80650
  layout: app.register.layout,
79980
80651
  ...entities[app.entity]?.create === void 0 || app.register.create === void 0 ? {} : { create: [...app.register.create] },
79981
80652
  ...exportSpec === void 0 ? {} : { export: exportSpec },
@@ -80000,7 +80671,8 @@ function compileApp(model, app, bound, tables) {
80000
80671
  };
80001
80672
  }),
80002
80673
  ...app.record.comments ? { comments: true } : {},
80003
- ...history === void 0 ? {} : { history: history.spec }
80674
+ ...app.record.history && moves !== void 0 ? { history: true } : {},
80675
+ ...moves === void 0 ? {} : { moves: moves.spec }
80004
80676
  },
80005
80677
  acts,
80006
80678
  checks: app.checks.map((check2) => ({
@@ -80166,8 +80838,10 @@ function columnRefusals(spec) {
80166
80838
  ...main3 === void 0 ? [] : refusalsOf(main3, spec.register.columns, "register.columns", void 0),
80167
80839
  ...spec.record.sections.flatMap(
80168
80840
  (section, index) => section.blocks.flatMap((block2, order) => {
80169
- const child = block2.kind === "rows" ? spec.entities[block2.entity] : void 0;
80170
- return block2.kind !== "rows" || child === void 0 ? [] : refusalsOf(child, block2.columns, `record.sections.${index}.blocks.${order}.columns`, block2.via, block2.expect !== void 0);
80841
+ if (block2.kind !== "rows" && block2.kind !== "agenda") return [];
80842
+ const child = spec.entities[block2.entity];
80843
+ const expected = block2.kind === "rows" && block2.expect !== void 0;
80844
+ return child === void 0 ? [] : refusalsOf(child, block2.columns, `record.sections.${index}.blocks.${order}.columns`, block2.via, expected);
80171
80845
  })
80172
80846
  )
80173
80847
  ];
@@ -80213,7 +80887,7 @@ function checkSection(spec, section, at2) {
80213
80887
  })
80214
80888
  ];
80215
80889
  }
80216
- function checkRead(at2, read2, entity, manifest) {
80890
+ function checkRead(at2, read2, entity, manifest, drawn = Object.values(entity.fields)) {
80217
80891
  const declaration = manifest.queries?.[read2.query];
80218
80892
  if (declaration === void 0) return [{ at: at2, message: `reads "${read2.query}", which package.json#lotics.queries does not declare` }];
80219
80893
  const findings = [];
@@ -80225,7 +80899,7 @@ function checkRead(at2, read2, entity, manifest) {
80225
80899
  return [...findings, { at: at2, message: `reads "${read2.query}", which is not a projection over one table \u2014 a row is read by the columns its query names` }];
80226
80900
  }
80227
80901
  if (projection2.table !== entity.table) findings.push({ at: at2, message: `reads ${entity.label} (${entity.table}) through "${read2.query}", which reads ${projection2.table}` });
80228
- for (const field2 of Object.values(entity.fields)) {
80902
+ for (const field2 of drawn) {
80229
80903
  if (!projection2.outputs.has(field2.alias)) findings.push({ at: at2, message: `draws "${field2.alias}" (${field2.label}), which "${read2.query}" does not project` });
80230
80904
  const key = projection2.keys.get(field2.alias);
80231
80905
  if (key !== void 0 && key !== field2.id) findings.push({ at: at2, message: `reads "${field2.alias}" as field \`${field2.id}\`, and "${read2.query}" projects it from \`${key}\`` });
@@ -80235,6 +80909,11 @@ function checkRead(at2, read2, entity, manifest) {
80235
80909
  }
80236
80910
  return findings;
80237
80911
  }
80912
+ function checkPictures(at2, read2, target, manifest) {
80913
+ if (target.image === void 0) return [{ at: at2, message: `reads the pictures of ${target.label}, whose rows name no \`image\`` }];
80914
+ const drawn = [target.title, target.image].flatMap((alias2) => target.fields[alias2] ?? []);
80915
+ return checkRead(at2, read2, target, manifest, drawn);
80916
+ }
80238
80917
  function checkReadingRead(at2, reading, manifest) {
80239
80918
  const { query, param, aggregate } = reading.read;
80240
80919
  const declaration = manifest.queries?.[query];
@@ -80275,6 +80954,7 @@ function checkAppSpec(spec, manifest, components) {
80275
80954
  }
80276
80955
  const target = field2.link === void 0 ? void 0 : spec.entities[field2.link];
80277
80956
  if (field2.pick !== void 0 && target !== void 0) findings.push(...checkRead(`${at2}.fields.${field2.alias}.pick`, field2.pick, target, manifest));
80957
+ if (field2.pictures !== void 0 && target !== void 0) findings.push(...checkPictures(`${at2}.fields.${field2.alias}.pictures`, field2.pictures, target, manifest));
80278
80958
  }
80279
80959
  if (entity.create !== void 0) findings.push(...checkWorkflow(`${at2}.create`, entity.create.workflow, entity.create.fields.map((field2) => createInput(entity, field2)), manifest));
80280
80960
  if (entity.update !== void 0) findings.push(...checkWorkflow(`${at2}.update`, entity.update.workflow, [], manifest));
@@ -80301,14 +80981,14 @@ function checkAppSpec(spec, manifest, components) {
80301
80981
  for (const [index, section] of spec.record.sections.entries()) findings.push(...checkSection(spec, section, `record.sections.${index}`));
80302
80982
  const blocks = [
80303
80983
  ...spec.record.sections.flatMap((section, index) => section.blocks.map((one, order) => ({ at: `record.sections.${index}.blocks.${order}`, one }))),
80304
- ...spec.record.history === void 0 ? [] : [{ at: "record.history", one: spec.record.history }]
80984
+ ...spec.record.moves === void 0 ? [] : [{ at: "record.moves", one: spec.record.moves }]
80305
80985
  ];
80306
80986
  for (const { at: at2, one } of blocks) {
80307
80987
  if (one.kind === "reading") {
80308
80988
  findings.push(...checkReadingRead(at2, one.reading, manifest));
80309
80989
  continue;
80310
80990
  }
80311
- if (one.kind !== "rows" && one.kind !== "timeline") continue;
80991
+ if (one.kind !== "rows" && one.kind !== "agenda" && one.kind !== "timeline") continue;
80312
80992
  const child = spec.entities[one.entity];
80313
80993
  if (child === void 0) {
80314
80994
  findings.push({ at: at2, message: `reads "${one.entity}", which \`entities\` does not carry` });
@@ -80321,6 +81001,10 @@ function checkAppSpec(spec, manifest, components) {
80321
81001
  if (one.kind === "rows" && one.expect !== void 0 && one.expect !== child.title) {
80322
81002
  findings.push({ at: `${at2}.expect`, message: `expects a line per "${one.expect}", which is not the title of "${one.entity}" \u2014 each line is named by its key` });
80323
81003
  }
81004
+ const under = one.kind === "rows" ? one.under : void 0;
81005
+ const document2 = under === void 0 ? void 0 : spec.entities[under.entity];
81006
+ if (under !== void 0 && document2 === void 0) findings.push({ at: `${at2}.under`, message: `stands the lines under "${under.entity}", which \`entities\` does not carry` });
81007
+ if (under !== void 0 && document2 !== void 0) findings.push(...checkRead(`${at2}.under`, under.read, document2, manifest));
80324
81008
  }
80325
81009
  for (const [index, act] of spec.acts.entries()) {
80326
81010
  const on = spec.entities[act.of ?? spec.entity];
@@ -81235,6 +81919,10 @@ function matchCell(cell, field2, filterCondition, looseCondition, memberContext)
81235
81919
  } else {
81236
81920
  matches = normalizedValue === void 0 || normalizedFilterValues.every((fv) => normalizedValue !== fv);
81237
81921
  }
81922
+ } else if (filterCondition.operator === "contains_any_of") {
81923
+ const needles = (Array.isArray(filterCondition.value) ? filterCondition.value : []).map((v) => foldDiacritics(v.trim().toLowerCase())).filter((v) => v !== "");
81924
+ if (needles.length === 0) return null;
81925
+ matches = foldedValue !== void 0 && needles.some((needle) => foldedValue.includes(needle));
81238
81926
  }
81239
81927
  } else if (filterCondition.type === "number") {
81240
81928
  const numberValue = columnValue;
@@ -82434,19 +83122,22 @@ function answerQuery(data, call) {
82434
83122
  }
82435
83123
  const params = call.params ?? {};
82436
83124
  const term = typeof params.search === "string" ? params.search.trim().toLowerCase() : "";
82437
- const given = Object.entries(params).flatMap(([name, value]) => typeof value === "string" && value !== "" ? [[name, value]] : []);
83125
+ const given = Object.entries(params).flatMap(([name, value]) => {
83126
+ const values2 = asList2(value).filter((one) => typeof one === "string" && one !== "");
83127
+ return values2.length === 0 ? [] : [[name, values2]];
83128
+ });
82438
83129
  const read2 = declared.rows.filter((row) => {
82439
83130
  if (term !== "" && declared.search && !matchesTerm(row, term)) return false;
82440
83131
  const conditions = declared.narrow[String(row.__source_table_id)] ?? {};
82441
83132
  const id = String(row.__source_record_id);
82442
83133
  return given.every(
82443
83134
  ([name, wanted2]) => (conditions[name] ?? []).every((by) => {
82444
- if ("own" in by) return row.__source_record_id === wanted2 === (by.own === "is");
83135
+ if ("own" in by) return wanted2.includes(id) === (by.own === "is");
82445
83136
  if ("day" in by) {
82446
83137
  const day = by.day[id];
82447
- return day !== void 0 && (by.side === "from" ? day >= wanted2 : day <= wanted2);
83138
+ return day !== void 0 && wanted2.some((bound) => by.side === "from" ? day >= bound : day <= bound);
82448
83139
  }
82449
- return (by.holds[id] ?? []).includes(wanted2);
83140
+ return (by.holds[id] ?? []).some((held2) => wanted2.includes(held2));
82450
83141
  })
82451
83142
  );
82452
83143
  });
@@ -85486,7 +86177,7 @@ async function holdRenderProject(project2, app, pollMs = 1e3) {
85486
86177
  }
85487
86178
  }
85488
86179
  if (!told) {
85489
- note(
86180
+ note2(
85490
86181
  holder.success ? `Waiting for the render of "${holder.data.app}" (pid ${holder.data.pid}), which holds ${project2.dir}\u2026` : `Waiting on ${lock}, whose holder has not yet said who it is\u2026`
85491
86182
  );
85492
86183
  told = true;
@@ -86135,11 +86826,11 @@ async function appApiPublish(client, args) {
86135
86826
  emitJson(result);
86136
86827
  return;
86137
86828
  }
86138
- note(
86829
+ note2(
86139
86830
  `Published the API of ${appId} \u2014 contract version ${result.contract_version}, ${result.published_at}.`
86140
86831
  );
86141
- for (const warning of result.warnings) note(` \u26A0 ${warning}`);
86142
- note(` The document its callers read: lotics app api spec ${appId}`);
86832
+ for (const warning of result.warnings) note2(` \u26A0 ${warning}`);
86833
+ note2(` The document its callers read: lotics app api spec ${appId}`);
86143
86834
  }
86144
86835
  async function appApiUnpublish(client, args) {
86145
86836
  const appId = apiTargetApp(args.app_id);
@@ -86148,7 +86839,7 @@ async function appApiUnpublish(client, args) {
86148
86839
  emitJson(result);
86149
86840
  return;
86150
86841
  }
86151
- note(
86842
+ note2(
86152
86843
  result.unpublished ? `Stopped publishing the API of ${appId}.` : `${appId} was publishing no API \u2014 nothing changed.`
86153
86844
  );
86154
86845
  }
@@ -86173,7 +86864,7 @@ async function appApiSpec(client, args) {
86173
86864
  return;
86174
86865
  }
86175
86866
  fs28.writeFileSync(args.outputPath, text);
86176
- note(`Wrote the OpenAPI document of ${appId} to ${args.outputPath}`);
86867
+ note2(`Wrote the OpenAPI document of ${appId} to ${args.outputPath}`);
86177
86868
  }
86178
86869
 
86179
86870
  // src/app_check.ts
@@ -87451,7 +88142,7 @@ async function workspaceBuild(client, args, actions = REAL_ACTIONS) {
87451
88142
  );
87452
88143
  }
87453
88144
  const differences = diffModelAgainstWorkspace(model.contract.entities, live.data, binding);
87454
- for (const line of describeDiff(differences)) note(line);
88145
+ for (const line of describeDiff(differences)) note2(line);
87455
88146
  let scaffold = "unchanged";
87456
88147
  if (differences.length > 0) {
87457
88148
  if (dryRun) scaffold = "not run";
@@ -87488,7 +88179,7 @@ async function buildOneApp(client, actions, context) {
87488
88179
  };
87489
88180
  if (dryRun) return outcome;
87490
88181
  try {
87491
- note(`
88182
+ note2(`
87492
88183
  ${app.alias} \u2014 ${exists ? "regenerating" : "creating"} ${dir}`);
87493
88184
  const from = `${file2}#${app.alias}`;
87494
88185
  if (exists) {
@@ -87657,10 +88348,10 @@ async function fieldRename(client, args) {
87657
88348
  { format: "json" }
87658
88349
  );
87659
88350
  if (written.error) throw new Error(`Could not rename ${table.name}.${field2.name}: ${written.error}`);
87660
- note(`Renamed ${table.name}.${field2.name} \u2192 ${args.newLabel} (${field2.id})`);
88351
+ note2(`Renamed ${table.name}.${field2.name} \u2192 ${args.newLabel} (${field2.id})`);
87661
88352
  const after = await readTableSchema(client, table.id);
87662
88353
  const moves = aliasMoves(before, after);
87663
- for (const move of moves) note(` alias ${move.table}.${move.from} \u2192 ${move.table}.${move.to}`);
88354
+ for (const move of moves) note2(` alias ${move.table}.${move.from} \u2192 ${move.table}.${move.to}`);
87664
88355
  if (args.model !== void 0) {
87665
88356
  const raw = JSON.parse(fs34.readFileSync(args.model, "utf-8"));
87666
88357
  const { changed, file: file2 } = renameInModel(raw, {
@@ -87671,9 +88362,9 @@ async function fieldRename(client, args) {
87671
88362
  if (changed) {
87672
88363
  fs34.writeFileSync(args.model, `${JSON.stringify(file2, null, 2)}
87673
88364
  `);
87674
- note(` model ${args.model}`);
88365
+ note2(` model ${args.model}`);
87675
88366
  } else {
87676
- note(
88367
+ note2(
87677
88368
  ` model ${args.model} declares no "${table.name}.${field2.name}" \u2014 nothing moved there. lotics scaffold diff ${args.model} says how far apart they are.`
87678
88369
  );
87679
88370
  }
@@ -87687,7 +88378,7 @@ async function fieldRename(client, args) {
87687
88378
  fs34.writeFileSync(file2, next);
87688
88379
  rewritten.push(path37.relative(resolved, file2));
87689
88380
  }
87690
- note(
88381
+ note2(
87691
88382
  ` app ${resolved} \u2014 ${rewritten.length === 0 ? "no call site named it" : rewritten.join(", ")}`
87692
88383
  );
87693
88384
  await appCodegen({ projectDir: resolved, client });
@@ -89005,13 +89696,13 @@ async function resolveContextOrClaim(flags, options) {
89005
89696
  const claim = await claimPendingLogin();
89006
89697
  if (claim.status === "signed_in") {
89007
89698
  const { account } = claim;
89008
- note(`Signed in as ${account.email} \u2014 org ${account.orgName} (${account.orgId}).`);
89699
+ note2(`Signed in as ${account.email} \u2014 org ${account.orgName} (${account.orgId}).`);
89009
89700
  return { ctx: resolveContext(flags, appWorkspaceId), claimed: account };
89010
89701
  }
89011
89702
  const resolved = resolveContext(flags, appWorkspaceId);
89012
89703
  if (claim.status === "none") return { ctx: resolved, claimed: null };
89013
89704
  if (resolved) {
89014
- note(claim.message);
89705
+ note2(claim.message);
89015
89706
  return { ctx: resolved, claimed: null };
89016
89707
  }
89017
89708
  return { ctx: null, claimed: null, refusal: claim.message };
@@ -89249,10 +89940,10 @@ async function main2(argv2) {
89249
89940
  process.exit(1);
89250
89941
  }
89251
89942
  const account = await awaitCliLogin(email3, flags.local ? { local: true } : {});
89252
- note(`Signed in as ${account.email} \u2014 org ${account.orgName} (${account.orgId}).`);
89943
+ note2(`Signed in as ${account.email} \u2014 org ${account.orgName} (${account.orgId}).`);
89253
89944
  } else {
89254
89945
  const { account } = outcome;
89255
- note(`Account created for ${account.email} \u2014 org ${account.orgName} (${account.orgId}).`);
89946
+ note2(`Account created for ${account.email} \u2014 org ${account.orgName} (${account.orgId}).`);
89256
89947
  }
89257
89948
  } catch (error52) {
89258
89949
  console.error(error52 instanceof Error ? error52.message : String(error52));