@lotics/cli 0.207.0 → 0.209.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
@@ -15481,6 +15481,18 @@ var LoticsClient = class {
15481
15481
  async appFieldOptions(app_id, alias) {
15482
15482
  return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/field-options`, { alias });
15483
15483
  }
15484
+ /**
15485
+ * When a record entered each option of one of its select fields — what a
15486
+ * lifecycle drawn as steps dates each step by. Mirrors
15487
+ * GET /v1/apps/{app_id}/records/{record_id}/field-history.
15488
+ */
15489
+ async appFieldHistory(app_id, args) {
15490
+ const qs = new URLSearchParams({ table_id: args.table_id, field_id: args.field_id });
15491
+ return this.request(
15492
+ "GET",
15493
+ `/v1/apps/${encodeURIComponent(app_id)}/records/${encodeURIComponent(args.record_id)}/field-history?${qs.toString()}`
15494
+ );
15495
+ }
15484
15496
  /**
15485
15497
  * Execute a workflow by alias declared in package.json#lotics.workflows.
15486
15498
  * Mirrors POST /v1/apps/{app_id}/workflows/{alias}/execute.
@@ -15807,12 +15819,12 @@ var LoticsClient = class {
15807
15819
  const disposition = response.headers.get("content-disposition") ?? "";
15808
15820
  const originalFilename = parseContentDispositionFilename(disposition) ?? fileId;
15809
15821
  const buffer = Buffer.from(await response.arrayBuffer());
15810
- const { dir, named } = destination === void 0 ? { dir: process.cwd() } : "file" in destination ? {
15822
+ const { dir, named: named2 } = destination === void 0 ? { dir: process.cwd() } : "file" in destination ? {
15811
15823
  dir: path2.dirname(path2.resolve(destination.file)),
15812
15824
  named: path2.basename(destination.file)
15813
15825
  } : { dir: path2.resolve(destination.dir) };
15814
15826
  await fs2.promises.mkdir(dir, { recursive: true });
15815
- const filename = named ?? findAvailableFilename(dir, originalFilename, options?.reserved);
15827
+ const filename = named2 ?? findAvailableFilename(dir, originalFilename, options?.reserved);
15816
15828
  const absolutePath = path2.join(dir, filename);
15817
15829
  await fs2.promises.writeFile(absolutePath, buffer);
15818
15830
  return { path: absolutePath, filename, stored_filename: originalFilename };
@@ -33624,8 +33636,8 @@ function conditionIssues(node, path23, fieldMap) {
33624
33636
  return [`${path23}: a button field holds no data and cannot be filtered.`];
33625
33637
  }
33626
33638
  if (typeof filterType !== "string" || !VALID_FILTER_TYPES.includes(filterType)) {
33627
- const named = fieldMap && filterType !== obj.type ? `'${String(filterType)}' (resolved from field '${obj.field_key}')` : `'${String(obj.type)}'`;
33628
- return [`${path23}: invalid filter type ${named}. Valid: ${VALID_FILTER_TYPES.join(", ")}`];
33639
+ const named2 = fieldMap && filterType !== obj.type ? `'${String(filterType)}' (resolved from field '${obj.field_key}')` : `'${String(obj.type)}'`;
33640
+ return [`${path23}: invalid filter type ${named2}. Valid: ${VALID_FILTER_TYPES.join(", ")}`];
33629
33641
  }
33630
33642
  const validOps = VALID_OPERATORS[filterType];
33631
33643
  if (typeof obj.operator !== "string" || !validOps.includes(obj.operator)) {
@@ -36941,6 +36953,10 @@ var contractFieldSchema = zod_default.discriminatedUnion("type", [
36941
36953
  contractLookupFieldSchema,
36942
36954
  contractAutonumberFieldSchema
36943
36955
  ]);
36956
+ function fieldHoldsSeveral(field) {
36957
+ if (field.type === "select") return field.multi === true;
36958
+ return field.type === "select_record_link" && field.cardinality !== "one";
36959
+ }
36944
36960
  var contractViewSchema = zod_default.object({
36945
36961
  alias: contractAliasSchema.describe("Stable view alias, unique within the entity"),
36946
36962
  label: zod_default.string().min(1).describe("Display name of the view"),
@@ -37473,8 +37489,8 @@ function checkOutputRefs(output, path23, entityAliases, optionAliases, fieldAlia
37473
37489
  checkOutputRefs(output.items, `${path23}[]`, entityAliases, optionAliases, fieldAliases, errors);
37474
37490
  }
37475
37491
  }
37476
- function checkSourceLinks(source, path23, parentEntity, fieldByEntity, errors) {
37477
- const recur = (child) => checkSourceLinks(child, path23, parentEntity, fieldByEntity, errors);
37492
+ function checkSourceLinks(source, path23, parentEntity2, fieldByEntity, errors) {
37493
+ const recur = (child) => checkSourceLinks(child, path23, parentEntity2, fieldByEntity, errors);
37478
37494
  if (typeof source === "string") return;
37479
37495
  if ("eq" in source) return source.eq.forEach(recur);
37480
37496
  if ("neq" in source) return source.neq.forEach(recur);
@@ -37484,7 +37500,7 @@ function checkSourceLinks(source, path23, parentEntity, fieldByEntity, errors) {
37484
37500
  if ("concat" in source) return source.concat.forEach(recur);
37485
37501
  if (!("link" in source) && !("link_agg" in source)) return;
37486
37502
  const reach = "link" in source ? source.link : source.link_agg;
37487
- if (parentEntity === null) {
37503
+ if (parentEntity2 === null) {
37488
37504
  errors.push({
37489
37505
  severity: "error",
37490
37506
  path: path23,
@@ -37492,14 +37508,14 @@ function checkSourceLinks(source, path23, parentEntity, fieldByEntity, errors) {
37492
37508
  });
37493
37509
  return;
37494
37510
  }
37495
- const parentFields = fieldByEntity.get(parentEntity);
37511
+ const parentFields = fieldByEntity.get(parentEntity2);
37496
37512
  if (parentFields === void 0) return;
37497
37513
  const sourceField = parentFields.get(reach.source);
37498
37514
  if (sourceField === void 0) {
37499
37515
  errors.push({
37500
37516
  severity: "error",
37501
37517
  path: path23,
37502
- message: `link source "${reach.source}" is not a field on entity "${parentEntity}"`
37518
+ message: `link source "${reach.source}" is not a field on entity "${parentEntity2}"`
37503
37519
  });
37504
37520
  return;
37505
37521
  }
@@ -37507,7 +37523,7 @@ function checkSourceLinks(source, path23, parentEntity, fieldByEntity, errors) {
37507
37523
  errors.push({
37508
37524
  severity: "error",
37509
37525
  path: path23,
37510
- message: `link source "${reach.source}" on entity "${parentEntity}" is a ${sourceField.type} field, not select_record_link`
37526
+ message: `link source "${reach.source}" on entity "${parentEntity2}" is a ${sourceField.type} field, not select_record_link`
37511
37527
  });
37512
37528
  return;
37513
37529
  }
@@ -38197,7 +38213,7 @@ var fieldRoleSchema = zod_default.enum([
38197
38213
  "obligation"
38198
38214
  ]);
38199
38215
  var FIELD_ROLE_TYPES = {
38200
- identity: ["text", "autonumber", "select_record_link", "formula"],
38216
+ identity: ["text", "select_record_link", "formula"],
38201
38217
  reference: ["text", "autonumber", "formula"],
38202
38218
  mark: ["files", "lookup", "rollup"],
38203
38219
  lifecycle: ["select"],
@@ -38265,8 +38281,11 @@ function resolvedFieldType(field, entity, entities, hops = 0) {
38265
38281
  }
38266
38282
  var ANSWER_KEYS = ["true", "false"];
38267
38283
  var requiredBySchema = zod_default.object({
38284
+ from: zod_default.enum(["own", "parent"]).optional().describe(
38285
+ `WHOSE field decides it, which the set's own shape settles: a multi-select holds the whole set, so "own" (the default, this entity's field); a single select is one entry per row, so "parent" \u2014 the record these rows hang under, reached through this entity's \`parent\` link. The other way round is refused`
38286
+ ),
38268
38287
  field: contractAliasSchema.describe(
38269
- "The field on the same entity whose value decides which entries of this set are required \u2014 a single select, or a yes/no the row answers itself"
38288
+ "The field whose value decides which entries of this set are required \u2014 a single select, or a yes/no answered on that row"
38270
38289
  ),
38271
38290
  options: zod_default.record(contractAliasSchema, zod_default.array(contractAliasSchema)).describe(
38272
38291
  `That field's option alias \u2014 or "true"/"false" where it is a yes/no \u2014 \u2192 the option aliases of THIS set required under it. A value with no entry is omitted, not empty.`
@@ -38285,14 +38304,19 @@ var fieldRoleDeclSchema = zod_default.object({
38285
38304
  'With against: what that limit IS. "fill" (the default) is a WHOLE the level is a share of \u2014 6 of 8 lines shipped. "threshold" is an alarm the level stays one side of \u2014 a reorder minimum, a margin floor \u2014 and every reading on the safe side is inside it, so there is no share to draw'
38286
38305
  ),
38287
38306
  outcomes: zod_default.array(contractAliasSchema).optional().describe("For a lifecycle: the options that END the flow \u2014 a row in one has arrived, not advanced"),
38307
+ due: zod_default.literal(true).optional().describe(
38308
+ "For a when: this date is a DEADLINE the row counts down to, and a row past it is late. Absent, it is the plain day it is \u2014 the day a lead arrived is neither early nor late"
38309
+ ),
38288
38310
  until: contractAliasSchema.optional().describe(
38289
- "For a when: the option of this entity's lifecycle the countdown STOPS at \u2014 at or past that stage, and at every outcome, the date is drawn plain"
38311
+ "For a when or an obligation: the option of this entity's lifecycle the countdown STOPS at \u2014 at or past that stage, and at every outcome, the date is drawn plain. On a when it also says the date is a deadline"
38290
38312
  ),
38291
38313
  signed_by: contractAliasSchema.optional().describe("For an amount: the select on the same entity that says which direction it moved"),
38292
38314
  outflow: zod_default.array(contractAliasSchema).optional().describe("With signed_by: the options of that select that spend \u2014 the amount reads negative under them"),
38293
38315
  required_by: requiredBySchema.optional().describe("For an expected_set: the select whose value decides which entries a row owes"),
38294
38316
  label: zod_default.string().min(1).optional().describe("For an obligation: what is OWED, as the reader says it \u2014 absent, the date field's own label"),
38295
- satisfied_by: contractAliasSchema.optional().describe("For an obligation: the field on the same entity that CLOSES it \u2014 the date it was done, or the file that proves it")
38317
+ satisfied_by: contractAliasSchema.optional().describe(
38318
+ "For an obligation: the field on the same entity that CLOSES it \u2014 the date it was done, or the file that proves it. Where nothing is stamped, `until` names the stage the record stops owing it at instead"
38319
+ )
38296
38320
  }).strict();
38297
38321
  var fieldRolesSchema = zod_default.record(
38298
38322
  contractAliasSchema,
@@ -38301,6 +38325,14 @@ var fieldRolesSchema = zod_default.record(
38301
38325
  zod_default.preprocess((value) => typeof value === "string" ? { role: value } : value, fieldRoleDeclSchema).nullable()
38302
38326
  )
38303
38327
  );
38328
+ function countsDown(decl) {
38329
+ if (decl?.role === "obligation") return true;
38330
+ return decl?.role === "when" && (decl.due === true || decl.until !== void 0);
38331
+ }
38332
+ function parentEntity(entity, entities, roles) {
38333
+ const link = entity.fields.find((field) => roleOf(roles, entity.alias, field.alias)?.role === "parent");
38334
+ return link === void 0 ? void 0 : linkTarget(entity, entities, link.alias);
38335
+ }
38304
38336
  function roleOf(roles, entity, field) {
38305
38337
  if (!Object.hasOwn(roles, entity)) return void 0;
38306
38338
  const fields = roles[entity];
@@ -38331,7 +38363,8 @@ function checkFieldRoles(entities, roles) {
38331
38363
  }
38332
38364
  const allowed = FIELD_ROLE_TYPES[decl.role];
38333
38365
  if (!allowed.includes(field.type)) {
38334
- errors.push({ severity: "error", path: path23, message: `role "${decl.role}" sits on a ${allowed.join(" or ")} field, not a ${field.type}` });
38366
+ const named2 = decl.role === "identity" && field.type === "autonumber" ? "identity is what a person calls the row \u2014 a minted code is the `reference` drawn under that name" : `role "${decl.role}" sits on a ${allowed.join(" or ")} field, not a ${field.type}`;
38367
+ errors.push({ severity: "error", path: path23, message: named2 });
38335
38368
  }
38336
38369
  if (SINGLE_FIELD_ROLES.includes(decl.role)) {
38337
38370
  const first2 = seen.get(decl.role);
@@ -38349,9 +38382,10 @@ function checkFieldRoles(entities, roles) {
38349
38382
  errors.push(...checkCurrencyOptions(decl, field, path23));
38350
38383
  errors.push(...checkOutcomes(decl, field, path23));
38351
38384
  errors.push(...checkUntil(decl, entity, roles, path23));
38385
+ errors.push(...checkDue(decl, path23));
38352
38386
  errors.push(...checkSlot(decl, field, path23));
38353
38387
  errors.push(...checkSign(decl, fieldByAlias, entityAlias, path23));
38354
- errors.push(...checkRequiredBy(decl, field, fieldByAlias, entity, entities, path23));
38388
+ errors.push(...checkRequiredBy(decl, field, entity, entities, roles, path23));
38355
38389
  errors.push(...checkObligation(decl, fieldAlias, fieldByAlias, entityAlias, path23));
38356
38390
  errors.push(...checkCounts(decl, field, entity, entities, path23));
38357
38391
  if (decl.reading !== void 0 && (decl.role !== "measure" || decl.against === void 0)) {
@@ -38509,17 +38543,40 @@ function requiredByReads(condition, entity, entities) {
38509
38543
  if (condition.type === "select") return condition.multi === true ? void 0 : "option";
38510
38544
  return resolvedFieldType(condition, entity, entities) === "boolean" ? "answer" : void 0;
38511
38545
  }
38512
- function checkRequiredBy(decl, field, fieldByAlias, entity, entities, path23) {
38546
+ function requiredByFrom(field) {
38547
+ return field.type === "select" && field.multi !== true ? "parent" : "own";
38548
+ }
38549
+ function checkRequiredBy(decl, field, entity, entities, roles, path23) {
38513
38550
  const requiredBy = decl.required_by;
38514
38551
  if (requiredBy === void 0) return [];
38515
38552
  if (decl.role !== "expected_set") {
38516
38553
  return [{ severity: "error", path: path23, message: `required_by belongs to an expected_set \u2014 this role is "${decl.role}"` }];
38517
38554
  }
38518
- const condition = fieldByAlias.get(requiredBy.field);
38555
+ const from = requiredByFrom(field);
38556
+ if ((requiredBy.from ?? "own") !== from) {
38557
+ return [
38558
+ {
38559
+ severity: "error",
38560
+ path: path23,
38561
+ message: from === "parent" ? `required_by on "${field.alias}" reads this row's own column \u2014 one row carries one entry of this set, so which entries are owed is stated by the record they hang under: from: "parent"` : `required_by on "${field.alias}" reads the parent's column \u2014 this row carries the whole set, so the value deciding it is this row's own: drop from`
38562
+ }
38563
+ ];
38564
+ }
38565
+ const deciding = from === "parent" ? parentEntity(entity, entities, roles) : entity;
38566
+ if (deciding === void 0) {
38567
+ return [
38568
+ {
38569
+ severity: "error",
38570
+ path: path23,
38571
+ message: `required_by reads the parent's "${requiredBy.field}" \u2014 "${entity.alias}" declares no parent link to reach one through`
38572
+ }
38573
+ ];
38574
+ }
38575
+ const condition = deciding.fields.find((candidate) => candidate.alias === requiredBy.field);
38519
38576
  if (condition === void 0) {
38520
- return [{ severity: "error", path: path23, message: `required_by names "${requiredBy.field}", which is not a field of "${entity.alias}"` }];
38577
+ return [{ severity: "error", path: path23, message: `required_by names "${requiredBy.field}", which is not a field of "${deciding.alias}"` }];
38521
38578
  }
38522
- const reads = requiredByReads(condition, entity, entities);
38579
+ const reads = requiredByReads(condition, deciding, entities);
38523
38580
  if (reads === void 0) {
38524
38581
  return [
38525
38582
  {
@@ -38564,11 +38621,12 @@ function checkObligation(decl, fieldAlias, fieldByAlias, entityAlias, path23) {
38564
38621
  return [{ severity: "error", path: path23, message: `label and satisfied_by belong to an obligation \u2014 this role is "${decl.role}"` }];
38565
38622
  }
38566
38623
  if (decl.satisfied_by === void 0) {
38624
+ if (decl.until !== void 0) return [];
38567
38625
  return [
38568
38626
  {
38569
38627
  severity: "error",
38570
38628
  path: path23,
38571
- message: `an obligation names what CLOSES it \u2014 satisfied_by, the date it was done or the file that proves it; without one every obligation ever met stays on the desk`
38629
+ message: `an obligation names what ENDS it \u2014 satisfied_by, the date it was done or the file that proves it, or until, the stage the record stops owing it at; without one every obligation ever met stays on the desk`
38572
38630
  }
38573
38631
  ];
38574
38632
  }
@@ -38618,8 +38676,8 @@ function checkOutcomes(decl, field, path23) {
38618
38676
  }
38619
38677
  function checkUntil(decl, entity, roles, path23) {
38620
38678
  if (decl.until === void 0) return [];
38621
- if (decl.role !== "when") {
38622
- return [{ severity: "error", path: path23, message: `until belongs to a when \u2014 this role is "${decl.role}"` }];
38679
+ if (decl.role !== "when" && decl.role !== "obligation") {
38680
+ return [{ severity: "error", path: path23, message: `until belongs to a when or an obligation \u2014 this role is "${decl.role}"` }];
38623
38681
  }
38624
38682
  const lifecycle = entity.fields.find((field) => roleOf(roles, entity.alias, field.alias)?.role === "lifecycle");
38625
38683
  if (lifecycle === void 0) {
@@ -38630,6 +38688,16 @@ function checkUntil(decl, entity, roles, path23) {
38630
38688
  }
38631
38689
  return [];
38632
38690
  }
38691
+ function checkDue(decl, path23) {
38692
+ if (decl.due === void 0 || decl.role === "when") return [];
38693
+ return [
38694
+ {
38695
+ severity: "error",
38696
+ path: path23,
38697
+ message: decl.role === "obligation" ? `due marks a when as a deadline \u2014 an obligation is a day something is OWED by and already counts down` : `due marks a when as a deadline \u2014 this role is "${decl.role}", which holds no day to be late against`
38698
+ }
38699
+ ];
38700
+ }
38633
38701
  function checkSlot(decl, field, path23) {
38634
38702
  if (decl.role !== "slot" || field.type !== "date") return [];
38635
38703
  if (field.format === "datetime" || field.format === "datetime_range") return [];
@@ -38908,7 +38976,13 @@ var SHAPE_REGISTRY = {
38908
38976
  slots: {
38909
38977
  // THE ROW IS A GROUP BY — a party, an option or a period — and every
38910
38978
  // column beside it is an aggregate over the set behind it.
38911
- subject: slot(["category", "party", "when"], true, { subject: true }),
38979
+ //
38980
+ // SEVERAL OF THEM ARE A HIERARCHY. "What is each customer worth" and
38981
+ // "which of their projects" are one question asked at two depths, and a
38982
+ // register per depth is the same figures folded twice with nothing saying
38983
+ // they are the same money — so the slot takes the tiers in order and the
38984
+ // reader walks down them.
38985
+ subject: slot(["category", "party", "when"], true, { subject: true, tiers: true }),
38912
38986
  amount: slot("amount"),
38913
38987
  measure: slot("measure"),
38914
38988
  // WHAT ONE ROW BEHIND A FIGURE IS CALLED. The set is what a group opens
@@ -39034,6 +39108,16 @@ var SHAPE_REGISTRY = {
39034
39108
  };
39035
39109
  var SHAPE_NAMES = Object.keys(SHAPE_REGISTRY);
39036
39110
  var CUSTOM_SHAPE = "custom";
39111
+ function slotTakes(shape, name) {
39112
+ if (shape === CUSTOM_SHAPE) return [];
39113
+ const slots = SHAPE_REGISTRY[shape].slots;
39114
+ return slots[name]?.roles ?? [];
39115
+ }
39116
+ function slotTakesTiers(shape, name) {
39117
+ if (shape === CUSTOM_SHAPE) return false;
39118
+ const slots = SHAPE_REGISTRY[shape].slots;
39119
+ return slots[name]?.tiers === true;
39120
+ }
39037
39121
  function shapeDrawsBand(shape, band) {
39038
39122
  if (shape === CUSTOM_SHAPE) return true;
39039
39123
  const declared = SHAPE_REGISTRY[shape];
@@ -39072,16 +39156,25 @@ function shapeFansObligations(shape) {
39072
39156
  const declared = SHAPE_REGISTRY[shape];
39073
39157
  return declared.obligations === true;
39074
39158
  }
39159
+ var CONTEXT_COLUMNS = 4;
39160
+ var SEATED_COLUMNS = 6;
39161
+ function shapeDrawsColumns(shape) {
39162
+ return shapeListsRows(shape) && shapeRowsAreTable(shape) && !shapeFansObligations(shape);
39163
+ }
39164
+ function shapesDrawingColumns() {
39165
+ return SHAPE_NAMES.filter(shapeDrawsColumns);
39166
+ }
39075
39167
  function registerReadsForward(shape) {
39076
39168
  if (shape === CUSTOM_SHAPE) return false;
39077
39169
  const declared = SHAPE_REGISTRY[shape];
39078
39170
  return declared.forward === true;
39079
39171
  }
39080
- var PRESENTATION_LEADS = ["mark", "picture", "figure", "none"];
39172
+ var PRESENTATION_LEADS = ["mark", "picture", "paper", "figure", "none"];
39081
39173
  var PRESENTATION_DENSITIES = ["roomy", "dense"];
39082
39174
  var SCREEN_LAYOUTS = ["table", "list", "cards", "gallery", "board", "calendar", "timeline", "gantt"];
39083
- function layoutsFor(roles) {
39084
- const held = new Set(roles);
39175
+ function layoutsFor(drawn) {
39176
+ const columns = [...drawn];
39177
+ const held = new Set(columns.map((column) => column.role));
39085
39178
  const afforded = /* @__PURE__ */ new Set(["table", "list"]);
39086
39179
  if (held.has("mark")) {
39087
39180
  afforded.add("cards");
@@ -39091,7 +39184,7 @@ function layoutsFor(roles) {
39091
39184
  if (held.has("when")) {
39092
39185
  afforded.add("calendar");
39093
39186
  afforded.add("timeline");
39094
- if (held.has("measure")) afforded.add("gantt");
39187
+ if (columns.some((column) => column.role === "measure" && column.counts === "days")) afforded.add("gantt");
39095
39188
  }
39096
39189
  return SCREEN_LAYOUTS.filter((layout) => afforded.has(layout));
39097
39190
  }
@@ -39127,7 +39220,17 @@ var contractPresentationSchema = zod_default.object({
39127
39220
  * columns to be and a calendar over rows with no date draws every row on no
39128
39221
  * day — so a layout the rows cannot answer is refused with the set they can.
39129
39222
  */
39130
- layout: screenLayoutSchema.optional().describe("How the rows are arranged \u2014 table, list, cards, gallery, board, calendar, timeline, gantt; absent, a table")
39223
+ layout: screenLayoutSchema.optional().describe("How the rows are arranged \u2014 table, list, cards, gallery, board, calendar, timeline, gantt; absent, a table"),
39224
+ /**
39225
+ * WHERE EACH BAR ENDS, where the rows state the day rather than the span.
39226
+ *
39227
+ * A chart needs a length, and a business records one two ways: a project
39228
+ * says how many days it runs, a lease says the day it is up. The days are
39229
+ * what AFFORDS the chart ({@link layoutsFor}); this is the column the bar is
39230
+ * drawn to when the row states one, which is the more exact answer wherever
39231
+ * a row has both.
39232
+ */
39233
+ until: contractAliasSchema.optional().describe("For a gantt: the date field each bar is drawn TO; absent, the bar runs for the days its measure counts")
39131
39234
  }).strict();
39132
39235
  function leadRefusal(shape, drawn, lead) {
39133
39236
  if (lead === "none") return void 0;
@@ -39136,7 +39239,10 @@ function leadRefusal(shape, drawn, lead) {
39136
39239
  }
39137
39240
  if (!drawn.has("mark")) return `leads with the ${lead}, and no column of these rows is drawn as their mark`;
39138
39241
  if (shapeMarksParty(shape) === (lead === "mark")) return void 0;
39139
- return lead === "picture" ? "leads with the picture, and these rows are parties \u2014 a party wears its own mark, which carries the picture where there is one" : "leads with the mark, and these rows are things \u2014 a thing has no initials to stand in for a picture, so it leads with `picture`";
39242
+ if (lead === "mark") {
39243
+ return "leads with the mark, and these rows are things \u2014 a thing has no initials to stand in for a picture, so it leads with `picture`";
39244
+ }
39245
+ return `leads with the ${lead}, and these rows are parties \u2014 a party wears its own mark, which carries the picture where there is one`;
39140
39246
  }
39141
39247
  var AGEING_BUCKETS = [30, 60, 90];
39142
39248
  var contractActPlaceSchema = zod_default.literal("cta").describe("Draw this act ON the row rather than in its \u22EF menu \u2014 at most one per screen");
@@ -39154,7 +39260,18 @@ var contractAgentActSchema = zod_default.strictObject({
39154
39260
  fills: zod_default.array(contractAliasSchema).min(1).describe("The fields of this entity the run may write \u2014 reviewed by the reader, then written as ONE update"),
39155
39261
  place: contractActPlaceSchema.optional()
39156
39262
  });
39157
- var contractActSchema = zod_default.union([contractAgentActSchema, contractTemplateActSchema], {
39263
+ var RECORD_INPUT = "record";
39264
+ var contractWorkflowActSchema = zod_default.strictObject({
39265
+ kind: zod_default.literal("workflow"),
39266
+ label: zod_default.string().min(1).describe("The act's own words, as the reader reads them in the menu"),
39267
+ workflow: contractAliasSchema.describe("A workflow alias the APP declares and binds (package.json#lotics.workflows)"),
39268
+ inputs: zod_default.record(
39269
+ contractAliasSchema,
39270
+ contractAliasSchema.describe('A field of this entity, or "record" \u2014 which names the row itself before any field of that alias')
39271
+ ).describe("What the run is handed: the workflow's own input name \u2192 the value on the row it is pressed on"),
39272
+ place: contractActPlaceSchema.optional()
39273
+ });
39274
+ var contractActSchema = zod_default.union([contractAgentActSchema, contractWorkflowActSchema, contractTemplateActSchema], {
39158
39275
  error: (issue2) => typeof issue2.input === "object" && issue2.input !== null && "screen" in issue2.input ? "a row act opens this app's record or another app" : void 0
39159
39276
  });
39160
39277
  var contractImportActSchema = zod_default.strictObject({
@@ -39166,6 +39283,7 @@ var contractImportActSchema = zod_default.strictObject({
39166
39283
  });
39167
39284
  var contractSlotSchema = zod_default.union([
39168
39285
  contractAliasSchema,
39286
+ zod_default.array(contractAliasSchema).min(2).describe("The tiers this slot folds by, outermost first \u2014 for a slot that takes a hierarchy"),
39169
39287
  zod_default.strictObject({
39170
39288
  field: contractAliasSchema.describe("The field this slot takes"),
39171
39289
  quick: zod_default.literal(true).describe("Draw the column as its OWN editor \u2014 for a decision the reader makes from the row alone"),
@@ -39214,6 +39332,11 @@ var contractPredicateSchema = zod_default.strictObject({
39214
39332
  "An `and` group of plain conditions over the entity's OWN fields, each `field_key` a field alias \u2014 the rows this predicate keeps"
39215
39333
  )
39216
39334
  });
39335
+ var contractSectionDrawSchema = zod_default.strictObject({
39336
+ draw: zod_default.literal("worksheet").describe("Priced lines the reader works down in place, each part footed and the sheet closing under them"),
39337
+ cost: contractAliasSchema.optional().describe("The child's field holding what a line COSTS \u2014 the base the margin is taken against"),
39338
+ sell: contractAliasSchema.optional().describe("The child's field holding what it SELLS for \u2014 the figure the sheet is read for")
39339
+ }).describe("How a section of this screen's record is drawn, where its rows are priced lines rather than a register");
39217
39340
  var contractLensSchema = zod_default.strictObject({
39218
39341
  label: zod_default.string().min(1).describe("What the chip is called"),
39219
39342
  predicates: zod_default.array(contractPredicateSchema).min(1).describe("The sets it offers, in this order")
@@ -39231,6 +39354,12 @@ var contractScreenSchema = zod_default.object({
39231
39354
  tabs: contractAliasSchema.nullable().optional().describe("A select field on the entity whose options are the tab strip; null for none; absent, the shape decides"),
39232
39355
  slots: zod_default.record(zod_default.string(), contractSlotSchema).optional().describe("Slot \u2192 the field that fills it, where roles alone cannot decide"),
39233
39356
  roles: zod_default.record(zod_default.string(), fieldRoleSchema).optional().describe(`For "${CUSTOM_SHAPE}" only: slot \u2192 the role that fills it`),
39357
+ // THE SLOTS ARE THE ROW'S ANSWER; THESE ARE ITS CONTEXT — the facts a reader
39358
+ // needs beside that answer and the shape has no slot for. Drawn after the
39359
+ // slots and shed before any of them, so a phone keeps the answer.
39360
+ columns: zod_default.array(contractAliasSchema).min(1).max(CONTEXT_COLUMNS).optional().describe(
39361
+ `Extra fields drawn after the slots, in this order \u2014 at most ${CONTEXT_COLUMNS}, each holding ONE value; never a files field, and never a field a slot already draws`
39362
+ ),
39234
39363
  // Absent IS true: a desk that can only be read is the exception, so the file
39235
39364
  // says that and says nothing in the ordinary case.
39236
39365
  writes: zod_default.boolean().optional().describe("false makes this screen's record read-only \u2014 no field editor, no stage advance; absent, it is operable"),
@@ -39256,6 +39385,7 @@ var contractScreenSchema = zod_default.object({
39256
39385
  section_acts: zod_default.record(contractAliasSchema, zod_default.array(contractActSchema).min(1)).optional().describe(
39257
39386
  "Acts on the RECORD's own sections, keyed by the alias each section is derived from \u2014 a field alias (its progress, its prose, its set, its charge, its files) or a child entity's alias (its register, its desk, its run, its thread)"
39258
39387
  ),
39388
+ sections: zod_default.record(contractAliasSchema, contractSectionDrawSchema).optional().describe("How a section of the RECORD is DRAWN, keyed by the child entity alias its register is derived from"),
39259
39389
  filters: zod_default.array(zod_default.union([contractAliasSchema, contractLensSchema])).min(1).optional().describe(
39260
39390
  "The chips beside the search \u2014 a single-select field's alias, or a lens this model states as predicates over the entity's fields"
39261
39391
  ),
@@ -39276,7 +39406,11 @@ var contractScreenSchema = zod_default.object({
39276
39406
  amount: contractAliasSchema.describe("The figure each bucket adds up \u2014 what is still owed on the row"),
39277
39407
  due: contractAliasSchema.describe("The date the row was owed by; a row is bucketed by how far past it is"),
39278
39408
  buckets: zod_default.array(zod_default.number().int().positive()).min(1).optional().describe(`The bucket edges, in days past due, ascending \u2014 absent, ${AGEING_BUCKETS.join(", ")}`)
39279
- }).strict().optional().describe("How old the money over this register is \u2014 the amount split by how many days past its date each row is")
39409
+ }).strict().optional().describe("How old the money over this register is \u2014 the amount split by how many days past its date each row is"),
39410
+ trend: zod_default.object({
39411
+ field: contractAliasSchema.describe("The number this register adds up in each bucket of the window"),
39412
+ direction: zod_default.enum(["up", "down"]).describe("Which way is GOOD \u2014 a book wants `up`, a backlog `down`; the arrow follows the change and the colour follows this")
39413
+ }).strict().optional().describe("Which way this figure moved across the window the reader is reading \u2014 needs a `period` to be a window of")
39280
39414
  }).strict().optional().describe("What the rows in view come to, stated before or after their names"),
39281
39415
  period: contractAliasSchema.optional().describe("A date field on the entity the reader narrows the view by; absent, the view is every row"),
39282
39416
  presentation: contractPresentationSchema.optional().describe("How this screen and the record it opens are DRAWN, where the shape's own answer is not the one wanted")
@@ -39342,7 +39476,8 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39342
39476
  const stated2 = (name) => {
39343
39477
  const clause = screen.slots?.[name];
39344
39478
  if (clause === void 0) return void 0;
39345
- return typeof clause === "string" ? { field: clause } : clause;
39479
+ if (typeof clause === "string") return { field: clause };
39480
+ return Array.isArray(clause) ? { field: clause[0], tiers: clause.slice(1) } : clause;
39346
39481
  };
39347
39482
  if (entity === void 0) return { type: "invalid", findings };
39348
39483
  const record2 = screen.record ?? recordDoor(screen.shape, entity, roles);
@@ -39356,14 +39491,14 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39356
39491
  const boundBy = /* @__PURE__ */ new Map();
39357
39492
  for (const [name, spec] of Object.entries(slotSpec)) {
39358
39493
  const clause = stated2(name);
39359
- const named = clause?.field;
39494
+ const named2 = clause?.field;
39360
39495
  const takes = spec.roles.map((role) => `"${role}"`).join(" or a ");
39361
39496
  let field = null;
39362
39497
  let filled = spec.roles[0];
39363
- if (named !== void 0) {
39364
- const candidate = fieldByAlias.get(named);
39498
+ if (named2 !== void 0) {
39499
+ const candidate = fieldByAlias.get(named2);
39365
39500
  if (candidate === void 0) {
39366
- findings.push({ severity: "error", path: `${path23}.slots.${name}`, message: `names "${named}", which is not a field of entity "${entity.alias}"` });
39501
+ findings.push({ severity: "error", path: `${path23}.slots.${name}`, message: `names "${named2}", which is not a field of entity "${entity.alias}"` });
39367
39502
  continue;
39368
39503
  }
39369
39504
  const role = roleOfField(candidate);
@@ -39371,14 +39506,15 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39371
39506
  findings.push({
39372
39507
  severity: "error",
39373
39508
  path: `${path23}.slots.${name}`,
39374
- message: `names "${named}", whose role is ${role === void 0 ? "not declared" : `"${role}"`} \u2014 this slot takes a ${takes} field; declare the role in field_roles`
39509
+ message: `names "${named2}", whose role is ${role === void 0 ? "not declared" : `"${role}"`} \u2014 this slot takes a ${takes} field; declare the role in field_roles`
39375
39510
  });
39376
39511
  continue;
39377
39512
  }
39378
39513
  filled = role;
39379
39514
  field = candidate;
39380
39515
  } else {
39381
- const candidates2 = spec.roles.flatMap((role) => entity.fields.filter((f) => roleOfField(f) === role));
39516
+ const declared = spec.roles.flatMap((role) => entity.fields.filter((f) => roleOfField(f) === role));
39517
+ const candidates2 = declared.length === 0 && spec.roles.includes("identity") ? namingFields(entity, roles) : declared;
39382
39518
  if (candidates2.length > 1 && spec.roles.length === 1 && ORDERED_ROLES.includes(spec.roles[0])) {
39383
39519
  for (const candidate of candidates2) {
39384
39520
  const other = boundBy.get(candidate.alias);
@@ -39388,7 +39524,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39388
39524
  }
39389
39525
  boundBy.set(candidate.alias, name);
39390
39526
  }
39391
- slots.push({ name, role: spec.roles[0], field: candidates2[0], also: candidates2.slice(1), ambiguous: [] });
39527
+ slots.push({ name, role: spec.roles[0], field: candidates2[0], also: candidates2.slice(1), tiers: [], ambiguous: [] });
39392
39528
  continue;
39393
39529
  }
39394
39530
  if (candidates2.length > 1) {
@@ -39400,7 +39536,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39400
39536
  });
39401
39537
  continue;
39402
39538
  }
39403
- slots.push({ name, role: filled, field: null, also: [], ambiguous: candidates2.map((f) => f.alias) });
39539
+ slots.push({ name, role: filled, field: null, also: [], tiers: [], ambiguous: candidates2.map((f) => f.alias) });
39404
39540
  continue;
39405
39541
  }
39406
39542
  if (candidates2.length === 0 && spec.required) {
@@ -39408,7 +39544,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39408
39544
  continue;
39409
39545
  }
39410
39546
  field = candidates2[0] ?? null;
39411
- if (field !== null) filled = roleOfField(field) ?? filled;
39547
+ if (field !== null && declared.length > 0) filled = roleOfField(field) ?? filled;
39412
39548
  }
39413
39549
  if (field !== null) {
39414
39550
  const other = boundBy.get(field.alias);
@@ -39418,6 +39554,30 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39418
39554
  }
39419
39555
  boundBy.set(field.alias, name);
39420
39556
  }
39557
+ const tiers = [];
39558
+ if (clause?.tiers !== void 0) {
39559
+ if (!slotTakesTiers(screen.shape, name)) {
39560
+ findings.push({ severity: "error", path: `${path23}.slots.${name}`, message: `names several fields, and "${shapeLabel}" folds its ${name} by one \u2014 a list is for a slot that takes a hierarchy` });
39561
+ continue;
39562
+ }
39563
+ const refused = clause.tiers.flatMap((alias) => {
39564
+ const candidate = fieldByAlias.get(alias);
39565
+ if (candidate === void 0) return [`"${alias}", which is not a field of entity "${entity.alias}"`];
39566
+ const role = roleOfField(candidate);
39567
+ if (role === void 0 || !spec.roles.includes(role)) {
39568
+ return [`"${alias}", whose role is ${role === void 0 ? "not declared" : `"${role}"`} \u2014 every tier takes a ${takes} field`];
39569
+ }
39570
+ const other = boundBy.get(alias);
39571
+ if (other !== void 0) return [`"${alias}", which already fills ${other} \u2014 a field fills one slot`];
39572
+ boundBy.set(alias, name);
39573
+ tiers.push(candidate);
39574
+ return [];
39575
+ });
39576
+ if (refused.length > 0) {
39577
+ findings.push({ severity: "error", path: `${path23}.slots.${name}`, message: `folds by ${refused.join("; and by ")}` });
39578
+ continue;
39579
+ }
39580
+ }
39421
39581
  if (clause?.quick === true) {
39422
39582
  const refused = quickRefusal(filled, clause.order) ?? (screen.writes === false ? "is `quick` on a screen that states `writes: false` \u2014 a reader who may operate nothing has no decision to take from the row" : void 0);
39423
39583
  if (refused !== void 0) {
@@ -39430,6 +39590,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39430
39590
  role: filled,
39431
39591
  field,
39432
39592
  also: [],
39593
+ tiers,
39433
39594
  ambiguous: [],
39434
39595
  ...clause?.quick === true ? { quick: true } : {},
39435
39596
  ...clause?.order === void 0 ? {} : { order: clause.order }
@@ -39448,6 +39609,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39448
39609
  tabs = field;
39449
39610
  }
39450
39611
  }
39612
+ const columns = resolveColumns(path23, screen, entity, [...entityByAlias.values()], fieldByAlias, boundBy, shapeLabel, findings);
39451
39613
  const acts = resolveActs(path23, screen, entity, entityByAlias, rules, fieldByAlias, templates, findings);
39452
39614
  const sectionActs2 = resolveSectionActs(path23, screen, entity, fieldByAlias, templates, findings);
39453
39615
  const summary = resolveSummary(path23, screen, entity, [...entityByAlias.values()], roles, slots, findings);
@@ -39474,6 +39636,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39474
39636
  tabs,
39475
39637
  slots,
39476
39638
  outcomes,
39639
+ columns,
39477
39640
  acts,
39478
39641
  sectionActs: sectionActs2,
39479
39642
  summary,
@@ -39485,6 +39648,53 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39485
39648
  }
39486
39649
  };
39487
39650
  }
39651
+ function resolveColumns(path23, screen, entity, entities, fieldByAlias, boundBy, shapeLabel, findings) {
39652
+ const named2 = screen.columns ?? [];
39653
+ if (named2.length === 0) return [];
39654
+ const at2 = `${path23}.columns`;
39655
+ if (!shapeDrawsColumns(screen.shape)) {
39656
+ findings.push({
39657
+ severity: "error",
39658
+ path: at2,
39659
+ message: `names columns, and a ${shapeLabel} does not stand this entity's records in a table \u2014 the shapes that draw columns are ${shapesDrawingColumns().join(", ")}`
39660
+ });
39661
+ return [];
39662
+ }
39663
+ const layout = screen.presentation?.layout;
39664
+ if (layout !== void 0 && layout !== "table") {
39665
+ findings.push({ severity: "error", path: at2, message: `names columns beside \`layout: "${layout}"\`, which draws the rows with a device of its own instead of the register's columns` });
39666
+ return [];
39667
+ }
39668
+ const drawn = [];
39669
+ const seen = /* @__PURE__ */ new Set();
39670
+ for (const alias of named2) {
39671
+ const field = fieldByAlias.get(alias);
39672
+ if (field === void 0) {
39673
+ findings.push({ severity: "error", path: at2, message: `names "${alias}", which is not a field of entity "${entity.alias}"` });
39674
+ continue;
39675
+ }
39676
+ const slot2 = boundBy.get(alias);
39677
+ if (slot2 !== void 0) {
39678
+ findings.push({ severity: "error", path: at2, message: `names "${alias}", which already fills ${slot2} \u2014 a column states what the slots do not` });
39679
+ continue;
39680
+ }
39681
+ if (seen.has(alias)) {
39682
+ findings.push({ severity: "error", path: at2, message: `names "${alias}" twice` });
39683
+ continue;
39684
+ }
39685
+ if (resolvedFieldType(field, entity, entities) === "files") {
39686
+ findings.push({ severity: "error", path: at2, message: `names "${alias}", a files field \u2014 files are READ, so they are a section of the record rather than a column beside it` });
39687
+ continue;
39688
+ }
39689
+ if (fieldHoldsSeveral(field)) {
39690
+ findings.push({ severity: "error", path: at2, message: `names "${alias}", which holds SEVERAL values \u2014 a column of comma lists cannot be compared down its own column` });
39691
+ continue;
39692
+ }
39693
+ seen.add(alias);
39694
+ drawn.push(field);
39695
+ }
39696
+ return drawn;
39697
+ }
39488
39698
  function resolveScope(app, entity, entityByAlias, findings) {
39489
39699
  const declared = app.scope;
39490
39700
  if (declared === void 0) return null;
@@ -39573,9 +39783,13 @@ function resolveObligations(path23, screen, entity, shapeLabel, roles, fieldByAl
39573
39783
  if (!shapeFansObligations(screen.shape)) return [];
39574
39784
  const found = entity.fields.flatMap((field) => {
39575
39785
  const decl = roleOf(roles, entity.alias, field.alias);
39576
- if (decl?.role !== "obligation" || decl.satisfied_by === void 0) return [];
39786
+ if (decl?.role !== "obligation") return [];
39787
+ const label = decl.label ?? field.label;
39788
+ if (decl.satisfied_by === void 0) {
39789
+ return recordUntil(entity, roles, field) === void 0 ? [] : [{ field, label }];
39790
+ }
39577
39791
  const satisfiedBy = fieldByAlias.get(decl.satisfied_by);
39578
- return satisfiedBy === void 0 ? [] : [{ field, label: decl.label ?? field.label, satisfiedBy }];
39792
+ return satisfiedBy === void 0 ? [] : [{ field, label, satisfiedBy }];
39579
39793
  });
39580
39794
  if (found.length === 0) {
39581
39795
  findings.push({
@@ -39608,20 +39822,33 @@ function resolveFactGroups(path23, screen, entity, fieldByAlias, findings) {
39608
39822
  })
39609
39823
  }));
39610
39824
  }
39825
+ function actDuplicates(acts, at2, menu, findings) {
39826
+ const refuse = (dups, said) => {
39827
+ for (const dup of dups) findings.push({ severity: "error", path: at2, message: said(dup) });
39828
+ };
39829
+ refuse(
39830
+ findDuplicates(acts.map((act) => act.label)),
39831
+ (dup) => `two acts are both called "${dup}" \u2014 ${menu} reads one line per act`
39832
+ );
39833
+ refuse(
39834
+ findDuplicates(acts.flatMap((act) => "kind" in act ? [] : [act.template])),
39835
+ (dup) => `two acts both generate "${dup}" \u2014 one template is one paper, however it is worded`
39836
+ );
39837
+ refuse(
39838
+ findDuplicates(acts.flatMap((act) => "kind" in act && act.kind === "agent" ? [act.agent] : [])),
39839
+ (dup) => `two acts both run "${dup}" \u2014 one agent is one run, however it is worded`
39840
+ );
39841
+ refuse(
39842
+ findDuplicates(acts.flatMap((act) => "kind" in act && act.kind === "workflow" ? [act.workflow] : [])),
39843
+ (dup) => `two acts both hand off through "${dup}" \u2014 one workflow is one hand-off, however it is worded`
39844
+ );
39845
+ }
39611
39846
  function resolveActs(path23, screen, entity, entityByAlias, rules, fieldByAlias, templates, findings) {
39612
39847
  const declared = screen.acts;
39613
39848
  if (declared === void 0) return { row: [], selection: [], record: [], export: null, import: null };
39614
39849
  const stated2 = declared.row ?? [];
39615
39850
  const at2 = `${path23}.acts.row`;
39616
- for (const dup of findDuplicates(stated2.map((act) => act.label))) {
39617
- findings.push({ severity: "error", path: at2, message: `two acts are both called "${dup}" \u2014 a row's menu reads one line per act` });
39618
- }
39619
- for (const dup of findDuplicates(stated2.flatMap((act) => "kind" in act ? [] : [act.template]))) {
39620
- findings.push({ severity: "error", path: at2, message: `two acts both generate "${dup}" \u2014 one template is one paper, however it is worded` });
39621
- }
39622
- for (const dup of findDuplicates(stated2.flatMap((act) => "kind" in act ? [act.agent] : []))) {
39623
- findings.push({ severity: "error", path: at2, message: `two acts both run "${dup}" \u2014 one agent is one run, however it is worded` });
39624
- }
39851
+ actDuplicates(stated2, at2, "a row's menu", findings);
39625
39852
  for (const refusal of ctaRefusals(declared)) {
39626
39853
  findings.push({ severity: "error", path: `${path23}.acts.${refusal.reach}`, message: refusal.message });
39627
39854
  }
@@ -39704,9 +39931,7 @@ function resolveSectionActs(path23, screen, entity, fieldByAlias, templates, fin
39704
39931
  const resolved = /* @__PURE__ */ new Map();
39705
39932
  for (const [alias, acts] of Object.entries(screen.section_acts ?? {})) {
39706
39933
  const at2 = `${path23}.section_acts.${alias}`;
39707
- for (const dup of findDuplicates(acts.map((act) => act.label))) {
39708
- findings.push({ severity: "error", path: at2, message: `two acts are both called "${dup}" \u2014 a section's menu reads one line per act` });
39709
- }
39934
+ actDuplicates(acts, at2, "a section's menu", findings);
39710
39935
  for (const act of acts) {
39711
39936
  if (act.place === "cta") {
39712
39937
  findings.push({
@@ -39724,15 +39949,7 @@ function resolveRecordActs(path23, screen, entity, fieldByAlias, templates, find
39724
39949
  const declared = screen.acts?.record;
39725
39950
  if (declared === void 0) return [];
39726
39951
  const at2 = `${path23}.acts.record`;
39727
- for (const dup of findDuplicates(declared.map((act) => act.label))) {
39728
- findings.push({ severity: "error", path: at2, message: `two acts are both called "${dup}" \u2014 the record's menu reads one line per act` });
39729
- }
39730
- for (const dup of findDuplicates(declared.flatMap((act) => "kind" in act ? [] : [act.template]))) {
39731
- findings.push({ severity: "error", path: at2, message: `two acts both generate "${dup}" \u2014 one template is one paper, however it is worded` });
39732
- }
39733
- for (const dup of findDuplicates(declared.flatMap((act) => "kind" in act ? [act.agent] : []))) {
39734
- findings.push({ severity: "error", path: at2, message: `two acts both run "${dup}" \u2014 one agent is one run, however it is worded` });
39735
- }
39952
+ actDuplicates(declared, at2, "the record's menu", findings);
39736
39953
  for (const act of declared) {
39737
39954
  if (act.place === "cta") {
39738
39955
  findings.push({ severity: "error", path: at2, message: `"${act.label}" states \`place: "cta"\` \u2014 a record's acts are its header menu, and there is no row to draw a verb on` });
@@ -39752,6 +39969,10 @@ function resolveAct(act, at2, screen, entity, fieldByAlias, templates, findings)
39752
39969
  const template = resolveTemplateAct(act.template, templates, at2, findings);
39753
39970
  return template === void 0 ? [] : [{ kind: "template", label: act.label, ...place, template }];
39754
39971
  }
39972
+ if (act.kind === "workflow") {
39973
+ const inputs = resolveActInputs(act.inputs, at2, entity, fieldByAlias, findings);
39974
+ return inputs === void 0 ? [] : [{ kind: "workflow", label: act.label, ...place, workflow: act.workflow, inputs }];
39975
+ }
39755
39976
  if (screen.writes === false) {
39756
39977
  findings.push({
39757
39978
  severity: "error",
@@ -39777,6 +39998,31 @@ function resolveAct(act, at2, screen, entity, fieldByAlias, templates, findings)
39777
39998
  });
39778
39999
  return fills.length === 0 ? [] : [{ kind: "agent", label: act.label, ...place, agent: act.agent, fills }];
39779
40000
  }
40001
+ function resolveActInputs(declared, at2, entity, fieldByAlias, findings) {
40002
+ const stated2 = Object.entries(declared);
40003
+ if (stated2.length === 0) {
40004
+ findings.push({ severity: "error", path: at2, message: `hands its workflow nothing \u2014 name the input the row is sent under, as \`{"<input>": "record"}\`` });
40005
+ return void 0;
40006
+ }
40007
+ const inputs = [];
40008
+ for (const [name, alias] of stated2) {
40009
+ if (alias === RECORD_INPUT) {
40010
+ inputs.push({ name, field: null });
40011
+ continue;
40012
+ }
40013
+ const field = fieldByAlias.get(alias);
40014
+ if (field === void 0) {
40015
+ findings.push({ severity: "error", path: at2, message: `sends "${alias}", which is not a field of entity "${entity.alias}"` });
40016
+ return void 0;
40017
+ }
40018
+ if (field.type === "files") {
40019
+ findings.push({ severity: "error", path: at2, message: `sends "${alias}", a files column \u2014 an input takes a value, and the body reads the row's files off the row it is handed` });
40020
+ return void 0;
40021
+ }
40022
+ inputs.push({ name, field });
40023
+ }
40024
+ return inputs;
40025
+ }
39780
40026
  function resolveSelectionActs(path23, screen, entity, fieldByAlias, templates, findings) {
39781
40027
  const declared = screen.acts?.selection;
39782
40028
  if (declared === void 0) return [];
@@ -39789,14 +40035,15 @@ function resolveSelectionActs(path23, screen, entity, fieldByAlias, templates, f
39789
40035
  });
39790
40036
  return [];
39791
40037
  }
39792
- for (const dup of findDuplicates(declared.map((act) => act.label))) {
39793
- findings.push({ severity: "error", path: at2, message: `two acts are both called "${dup}" \u2014 the selection bar reads one line per act` });
39794
- }
39795
- for (const dup of findDuplicates(declared.flatMap((act) => "kind" in act ? [] : [act.template]))) {
39796
- findings.push({ severity: "error", path: at2, message: `two acts both generate "${dup}" \u2014 one template is one paper, however it is worded` });
39797
- }
39798
- for (const dup of findDuplicates(declared.flatMap((act) => "kind" in act ? [act.agent] : []))) {
39799
- findings.push({ severity: "error", path: at2, message: `two acts both run "${dup}" \u2014 one agent is one run, however it is worded` });
40038
+ actDuplicates(declared, at2, "the selection bar", findings);
40039
+ for (const act of declared) {
40040
+ if (!("kind" in act)) continue;
40041
+ const verb = act.kind === "agent" ? `runs "${act.agent}"` : `hands the row to "${act.workflow}"`;
40042
+ findings.push({
40043
+ severity: "error",
40044
+ path: at2,
40045
+ message: `"${act.label}" ${verb}, which is about ONE row \u2014 the bar makes one paper from many, so a per-row verb belongs in the row's \u22EF`
40046
+ });
39800
40047
  }
39801
40048
  return declared.flatMap((act, index) => resolveAct(act, `${at2}.${index}`, screen, entity, fieldByAlias, templates, findings));
39802
40049
  }
@@ -39837,7 +40084,7 @@ function resolveSummary(path23, screen, entity, entities, roles, slots, findings
39837
40084
  const declared = screen.summary;
39838
40085
  const fieldByAlias = new Map(entity.fields.map((field) => [field.alias, field]));
39839
40086
  if (declared === void 0) {
39840
- return { columnTotals: [], bandTotals: [], above: defaultAbove(screen, entity, entities, roles, slots), ageing: null };
40087
+ return { columnTotals: [], bandTotals: [], above: defaultAbove(screen, entity, entities, roles, slots), ageing: null, trend: null };
39841
40088
  }
39842
40089
  const figures = (aliases, at2) => aliases.flatMap((alias) => {
39843
40090
  const field = fieldByAlias.get(alias);
@@ -39874,10 +40121,10 @@ function resolveSummary(path23, screen, entity, entities, roles, slots, findings
39874
40121
  });
39875
40122
  return false;
39876
40123
  };
39877
- const named = declared.totals === void 0 || !bandSaid("totals", `${path23}.summary.totals`) ? [] : figures(declared.totals, `${path23}.summary.totals`);
40124
+ const named2 = declared.totals === void 0 || !bandSaid("totals", `${path23}.summary.totals`) ? [] : figures(declared.totals, `${path23}.summary.totals`);
39878
40125
  const drawn = new Set(slots.flatMap((slot2) => slot2.field === null ? [] : [slot2.field.alias]));
39879
- const columnTotals = named.filter((field) => drawn.has(field.alias));
39880
- const bandTotals = named.filter((field) => !drawn.has(field.alias));
40126
+ const columnTotals = named2.filter((field) => drawn.has(field.alias));
40127
+ const bandTotals = named2.filter((field) => !drawn.has(field.alias));
39881
40128
  if (screen.shape !== CUSTOM_SHAPE && columnTotals.length > 0) {
39882
40129
  findings.push({
39883
40130
  severity: "error",
@@ -39903,7 +40150,32 @@ function resolveSummary(path23, screen, entity, entities, roles, slots, findings
39903
40150
  };
39904
40151
  const above = declared.above === void 0 ? defaultAbove(screen, entity, entities, roles, slots) : !bandSaid("above", `${path23}.summary.above`) ? null : declared.above === "counts" ? "counts" : fanned(`${path23}.summary.above`) ? null : bandFigures(declared.above, `${path23}.summary.above`, screen, figures, findings);
39905
40152
  const ageing = declared.ageing !== void 0 && fanned(`${path23}.summary.ageing`) ? null : resolveAgeing(path23, declared.ageing, entity, entities, figures, bandSaid, findings);
39906
- return { columnTotals, bandTotals, above, ageing };
40153
+ const trend = declared.trend !== void 0 && fanned(`${path23}.summary.trend`) ? null : resolveTrend(path23, declared.trend, screen, figures, bandSaid, findings);
40154
+ return { columnTotals, bandTotals, above, ageing, trend };
40155
+ }
40156
+ function resolveTrend(path23, declared, screen, figures, bandSaid, findings) {
40157
+ if (declared === void 0) return null;
40158
+ const at2 = `${path23}.summary.trend`;
40159
+ const already2 = shapePeriod(screen.shape);
40160
+ if (already2 !== void 0) {
40161
+ findings.push({
40162
+ severity: "error",
40163
+ path: at2,
40164
+ message: already2 === "own" ? `a "${screen.shape}" draws its own series over its own period \u2014 a second reading of one window states the same movement twice` : `a "${screen.shape}" answers what is true NOW \u2014 it reads no window, so there is nothing for a movement to be across`
40165
+ });
40166
+ return null;
40167
+ }
40168
+ if (screen.period === void 0) {
40169
+ findings.push({
40170
+ severity: "error",
40171
+ path: at2,
40172
+ message: `reads "${declared.field}" across the window, and this screen states no \`period\` \u2014 there is no window to cut into buckets`
40173
+ });
40174
+ return null;
40175
+ }
40176
+ if (!bandSaid("above", at2)) return null;
40177
+ const field = figures([declared.field], at2)[0];
40178
+ return field === void 0 ? null : { field, direction: declared.direction };
39907
40179
  }
39908
40180
  function bandFigures(declared, at2, screen, figures, findings) {
39909
40181
  return declared.flatMap((one) => {
@@ -40098,13 +40370,17 @@ function mirrorsOwnOneLink(entity, child, link) {
40098
40370
  );
40099
40371
  return back.length === 1 && own.length === 1 && own[0].cardinality === "one";
40100
40372
  }
40101
- function requiredSubset(entity, entities, roles, field) {
40373
+ function requiredSubset(entity, entities, roles, field, parent) {
40102
40374
  const requiredBy = roleOf(roles, entity.alias, field.alias)?.required_by;
40103
40375
  if (requiredBy === void 0) return void 0;
40104
- const condition = entity.fields.find((candidate) => candidate.alias === requiredBy.field);
40376
+ const from = requiredByFrom(field);
40377
+ if ((requiredBy.from ?? "own") !== from) return void 0;
40378
+ const deciding = from === "parent" ? parentEntity(entity, entities, roles) : entity;
40379
+ if (deciding === void 0 || from === "parent" && deciding.alias !== parent?.alias) return void 0;
40380
+ const condition = deciding.fields.find((candidate) => candidate.alias === requiredBy.field);
40105
40381
  if (condition === void 0) return void 0;
40106
- const reads = requiredByReads(condition, entity, entities);
40107
- return reads === void 0 ? void 0 : { field: condition, reads, options: requiredBy.options };
40382
+ const reads = requiredByReads(condition, deciding, entities);
40383
+ return reads === void 0 ? void 0 : { field: condition, from, reads, options: requiredBy.options };
40108
40384
  }
40109
40385
  function owedRows(entity, roles, child, link) {
40110
40386
  for (const field of entity.fields) {
@@ -40121,6 +40397,10 @@ function owedRows(entity, roles, child, link) {
40121
40397
  function fieldsWithRole(entity, roles, role) {
40122
40398
  return entity.fields.filter((field) => roleOf(roles, entity.alias, field.alias)?.role === role);
40123
40399
  }
40400
+ function namingFields(entity, roles) {
40401
+ const named2 = fieldsWithRole(entity, roles, "identity");
40402
+ return named2.length > 0 ? named2 : fieldsWithRole(entity, roles, "reference");
40403
+ }
40124
40404
  function childTimeline(child, entities, roles) {
40125
40405
  for (const planned of fieldsWithRole(child, roles, "obligation")) {
40126
40406
  const stamp = roleOf(roles, child.alias, planned.alias)?.satisfied_by;
@@ -40154,12 +40434,21 @@ function childThread(child, roles, via) {
40154
40434
  ...awaiting === void 0 ? {} : { awaiting }
40155
40435
  };
40156
40436
  }
40437
+ function threadShortOf(child, roles) {
40438
+ if (fieldsWithRole(child, roles, "identity").length > 0) return void 0;
40439
+ if (!child.fields.some((field) => field.type === "text" && field.format === "markdown")) return void 0;
40440
+ const missing = [
40441
+ { name: 'a "party"', has: fieldsWithRole(child, roles, "party").length > 0 },
40442
+ { name: 'a "when"', has: fieldsWithRole(child, roles, "when").length > 0 }
40443
+ ].filter((one) => !one.has);
40444
+ return missing.length === 1 ? missing[0].name : void 0;
40445
+ }
40157
40446
  function recordDeadline(entity, roles) {
40158
- return fieldsWithRole(entity, roles, "obligation")[0] ?? fieldsWithRole(entity, roles, "when")[0];
40447
+ return fieldsWithRole(entity, roles, "obligation")[0] ?? fieldsWithRole(entity, roles, "when").find((field) => countsDown(roleOf(roles, entity.alias, field.alias)));
40159
40448
  }
40160
40449
  function fileFields(entity, roles) {
40161
40450
  const files = entity.fields.filter((field) => field.type === "files");
40162
- const mark = fieldsWithRole(entity, roles, "mark")[0];
40451
+ const mark = fieldsWithRole(entity, roles, "mark").find((field) => field.type === "files");
40163
40452
  return mark === void 0 ? files : [mark, ...files.filter((field) => field.alias !== mark.alias)];
40164
40453
  }
40165
40454
  function filesVerdict(entity, roles, files) {
@@ -40169,14 +40458,14 @@ function filesVerdict(entity, roles, files) {
40169
40458
  (field) => field.type === "formula" && parseFieldRefTokens(field.formula.expression).some((ref) => piles.has(ref))
40170
40459
  );
40171
40460
  }
40172
- function recordKey(entity, roles, named) {
40461
+ function recordKey(entity, roles, named2) {
40173
40462
  const role = (field) => roleOf(roles, entity.alias, field.alias)?.role;
40174
- const spare = entity.fields.filter((field) => field.alias !== named && role(field) !== "identity");
40463
+ const spare = entity.fields.filter((field) => field.alias !== named2 && role(field) !== "identity");
40175
40464
  return spare.find((field) => role(field) === "parent") ?? spare.find((field) => role(field) === "reference") ?? spare.find((field) => field.type === "autonumber" && role(field) === void 0) ?? spare.find((field) => field.type === "formula" && formulaResultType(field.formula) === "text" && role(field) === void 0);
40176
40465
  }
40177
40466
  function recordHeader(screen, roles) {
40178
40467
  const bound = (role) => screen.slots.find((entry) => entry.role === role && entry.field !== null)?.field ?? void 0;
40179
- const title = bound("identity") ?? fieldsWithRole(screen.entity, roles, "identity")[0];
40468
+ const title = bound("identity") ?? namingFields(screen.entity, roles)[0];
40180
40469
  const measure = bound("measure");
40181
40470
  const level = measure === void 0 ? void 0 : measureAlert(screen.entity, roles, measure.alias);
40182
40471
  const charge = recordCharge(screen.screen.shape, screen.entity, roles, screen.record);
@@ -40185,8 +40474,7 @@ function recordHeader(screen, roles) {
40185
40474
  ...charge === void 0 ? [] : [charge.amount.alias]
40186
40475
  ]);
40187
40476
  const archetype = recordArchetype(screen.screen.shape, screen.entity, roles);
40188
- const key = archetype === "line" ? recordKey(screen.entity, roles, title?.alias) : void 0;
40189
- const subtitle = key ?? bound("when");
40477
+ const subtitle = recordKey(screen.entity, roles, title?.alias) ?? bound("when");
40190
40478
  const partySlot = screen.slots.find((entry) => entry.role === "party" && entry.field !== null);
40191
40479
  const party = archetype === "work_record" && screen.record === "page" ? partySlot === void 0 || partySlot.field === null ? fieldsWithRole(screen.entity, roles, "party") : [partySlot.field, ...partySlot.also] : [];
40192
40480
  const figure = bound("amount") ?? (level === void 0 ? measure : void 0);
@@ -40203,7 +40491,7 @@ function recordHeader(screen, roles) {
40203
40491
  function recordSections(entity, entities, roles, header, shape = CUSTOM_SHAPE, door = recordDoor(shape, entity, roles)) {
40204
40492
  const band = recordBand(shape, entity, roles, door);
40205
40493
  const lifecycle = fieldsWithRole(entity, roles, "lifecycle")[0];
40206
- const prose = entity.fields.filter((field) => field.type === "text" && field.format === "markdown");
40494
+ const prose = recipeOf(shape, entity, roles, door).leads === "prose" ? entity.fields.filter((field) => field.type === "text" && field.format === "markdown") : [];
40207
40495
  const charge = recordCharge(shape, entity, roles, door);
40208
40496
  const ownSets = fieldsWithRole(entity, roles, "expected_set").filter(
40209
40497
  (field) => field.type === "select" && field.multi === true
@@ -40234,26 +40522,26 @@ function recordSections(entity, entities, roles, header, shape = CUSTOM_SHAPE, d
40234
40522
  link,
40235
40523
  setField,
40236
40524
  filesField: fileFields(child, roles)[0],
40237
- requiredBy: requiredSubset(child, entities, roles, setField)
40525
+ requiredBy: requiredSubset(child, entities, roles, setField, entity)
40238
40526
  });
40239
40527
  continue;
40240
40528
  }
40241
- const byRole = CHILD_COLUMN_ROLES.flatMap((role) => fieldsWithRole(child, roles, role)).filter(
40529
+ const byRole2 = CHILD_COLUMN_ROLES.flatMap((role) => fieldsWithRole(child, roles, role)).filter(
40242
40530
  (field) => field.alias !== link.alias
40243
40531
  );
40244
40532
  const stops = childTimeline(child, entities, roles);
40245
40533
  if (stops !== void 0) {
40246
- children.push({ kind: "timeline", child, via, link, fields: byRole, ...stops });
40534
+ children.push({ kind: "timeline", child, via, link, fields: byRole2, ...stops });
40247
40535
  continue;
40248
40536
  }
40249
40537
  const said = childThread(child, roles, via);
40250
40538
  if (said !== void 0) {
40251
40539
  const clock = via === "parent" ? recordDeadline(entity, roles) : void 0;
40252
- children.push({ kind: "thread", child, via, link, fields: byRole, ...said, ...clock === void 0 ? {} : { clock } });
40540
+ children.push({ kind: "thread", child, via, link, fields: byRole2, ...said, ...clock === void 0 ? {} : { clock } });
40253
40541
  continue;
40254
40542
  }
40255
40543
  const expected = owedRows(entity, roles, child, link);
40256
- children.push({ kind: "children", child, via, link, fields: byRole, ...expected === void 0 ? {} : { expected } });
40544
+ children.push({ kind: "children", child, via, link, fields: byRole2, ...expected === void 0 ? {} : { expected } });
40257
40545
  }
40258
40546
  }
40259
40547
  }
@@ -40372,10 +40660,10 @@ function recordCharge(shape, entity, roles, door = recordDoor(shape, entity, rol
40372
40660
  (token) => entity.fields.find((field) => field.alias === token)
40373
40661
  );
40374
40662
  if (operands.length !== 2) return void 0;
40375
- const named = operands.filter((field) => field !== void 0);
40376
- if (named.length !== 2) return void 0;
40377
- const priced = named.filter((field) => field.type === "number" && field.format === "currency");
40378
- const counted = named.filter((field) => field.type === "number" && field.format !== "currency");
40663
+ const named2 = operands.filter((field) => field !== void 0);
40664
+ if (named2.length !== 2) return void 0;
40665
+ const priced = named2.filter((field) => field.type === "number" && field.format === "currency");
40666
+ const counted = named2.filter((field) => field.type === "number" && field.format !== "currency");
40379
40667
  if (priced.length !== 1 || counted.length !== 1) return void 0;
40380
40668
  return { amount, quantity: counted[0], unitPrice: priced[0] };
40381
40669
  }
@@ -40454,6 +40742,7 @@ function recordItinerary(archetype, recipeSlot, child, roles) {
40454
40742
  if (when === void 0 || lifecycle === void 0) return void 0;
40455
40743
  const slot2 = fieldsWithRole(child, roles, "slot")[0];
40456
40744
  const kind = fieldsWithRole(child, roles, "category")[0];
40745
+ const mark = fieldsWithRole(child, roles, "mark")[0];
40457
40746
  const reference = fieldsWithRole(child, roles, "reference")[0];
40458
40747
  const span = fieldsWithRole(child, roles, "measure").find(
40459
40748
  (field) => roleOf(roles, child.alias, field.alias)?.counts === "days"
@@ -40463,6 +40752,7 @@ function recordItinerary(archetype, recipeSlot, child, roles) {
40463
40752
  lifecycle,
40464
40753
  ...slot2 === void 0 ? {} : { slot: slot2 },
40465
40754
  ...kind === void 0 ? {} : { kind },
40755
+ ...mark === void 0 ? {} : { mark },
40466
40756
  ...reference === void 0 ? {} : { reference },
40467
40757
  ...span === void 0 ? {} : { span }
40468
40758
  };
@@ -40577,6 +40867,10 @@ var RECIPES = {
40577
40867
  named_by: "related"
40578
40868
  }
40579
40869
  };
40870
+ function recipeOf(shape, entity, roles, door) {
40871
+ const steps = RECIPES[door === "expand" ? "line" : recordArchetype(shape, entity, roles) ?? "plain"];
40872
+ return { steps, leads: Object.keys(steps)[0] };
40873
+ }
40580
40874
  function slotOf(section, roles) {
40581
40875
  if (section.kind !== "children") return section.kind;
40582
40876
  const signed = fieldsWithRole(section.child, roles, "amount").some(
@@ -40590,8 +40884,7 @@ function childRegisterShape(slot2) {
40590
40884
  }
40591
40885
  function recordRecipe(shape, entity, entities, roles, header, door = recordDoor(shape, entity, roles)) {
40592
40886
  const sections = recordSections(entity, entities, roles, header, shape, door);
40593
- const recipe = RECIPES[door === "expand" ? "line" : recordArchetype(shape, entity, roles) ?? "plain"];
40594
- const entries2 = Object.entries(recipe).flatMap(
40887
+ const entries2 = Object.entries(recipeOf(shape, entity, roles, door).steps).flatMap(
40595
40888
  ([slot2, draw]) => sections.flatMap((section) => slotOf(section, roles) === slot2 ? [{ draw, slot: slot2, section }] : [])
40596
40889
  );
40597
40890
  const visiting = (entry) => entry.draw === "open" && entry.section.kind === "children" && entry.section.via !== "parent";
@@ -40607,6 +40900,7 @@ function validateWorkspacePlan(model, roles, apps, rules = {}) {
40607
40900
  findings.push({ severity: "error", path: "apps", message: `duplicate app name "${dup}"` });
40608
40901
  }
40609
40902
  const entityByAlias = new Map(model.entities.map((entity) => [entity.alias, entity]));
40903
+ const registered = new Set(apps.map((app) => app.screen.entity));
40610
40904
  for (const app of apps) {
40611
40905
  const byReach = /* @__PURE__ */ new Map();
40612
40906
  for (const [reach, acts] of [
@@ -40641,8 +40935,11 @@ function validateWorkspacePlan(model, roles, apps, rules = {}) {
40641
40935
  }
40642
40936
  findings.push(...checkFactGroups(resolved.screen, model.entities, roles));
40643
40937
  findings.push(...checkSectionActs(resolved.screen, model.entities, roles));
40938
+ findings.push(...checkSectionDraws(resolved.screen, model.entities, roles));
40939
+ findings.push(...checkChildRegisters(resolved.screen, model.entities, roles, registered));
40644
40940
  findings.push(...checkDeskStages(resolved.screen));
40645
- findings.push(...checkPresentation(resolved.screen));
40941
+ findings.push(...checkSeatedColumns(resolved.screen));
40942
+ findings.push(...checkPresentation(resolved.screen, roles, model.entities));
40646
40943
  }
40647
40944
  return findings;
40648
40945
  }
@@ -40661,16 +40958,53 @@ function checkDeskStages(screen) {
40661
40958
  }
40662
40959
  ];
40663
40960
  }
40664
- function checkPresentation(screen) {
40961
+ function checkSeatedColumns(screen) {
40962
+ const context = screen.columns.length;
40963
+ const bySlots = screen.slots.filter((slot2) => slot2.field !== null && slot2.role !== "mark").length;
40964
+ if (context === 0 || bySlots + context <= SEATED_COLUMNS) return [];
40965
+ return [
40966
+ {
40967
+ severity: "note",
40968
+ path: `apps.${screen.app.alias}.screen.columns`,
40969
+ message: `adds ${context} column${context === 1 ? "" : "s"} to the ${bySlots} this ${screen.shapeLabel}'s slots already draw, and a register seats about ${SEATED_COLUMNS} at any width \u2014 the context is given up before a slot, so these draw only while the rows are short; leave a slot unbound or drop a column`
40970
+ }
40971
+ ];
40972
+ }
40973
+ function drawnColumns(screen, roles) {
40974
+ return screen.slots.flatMap((slot2) => {
40975
+ if (slot2.field === null) return [];
40976
+ const counts = roleOf(roles, screen.entity.alias, slot2.field.alias)?.counts;
40977
+ return [{ role: slot2.role, ...counts === void 0 ? {} : { counts } }];
40978
+ });
40979
+ }
40980
+ function checkPresentation(screen, roles, entities) {
40665
40981
  const stated2 = screen.screen.presentation;
40666
40982
  if (stated2 === void 0) return [];
40667
40983
  const path23 = `apps.${screen.app.alias}.screen.presentation`;
40668
- const drawn = new Set(screen.slots.flatMap((slot2) => slot2.field === null ? [] : [slot2.role]));
40984
+ const drawn = drawnColumns(screen, roles);
40985
+ const held = new Set(drawn.map((column) => column.role));
40669
40986
  return [
40670
- ...stated2.lead === void 0 ? [] : [leadRefusal(screen.screen.shape, drawn, stated2.lead)],
40671
- ...stated2.layout === void 0 ? [] : [layoutRefusal(drawn, stated2.layout)]
40987
+ ...stated2.lead === void 0 ? [] : [leadRefusal(screen.screen.shape, held, stated2.lead)],
40988
+ ...stated2.layout === void 0 ? [] : [layoutRefusal(drawn, stated2.layout)],
40989
+ ...stated2.until === void 0 ? [] : [untilRefusal(screen, entities, drawn, stated2.until)]
40672
40990
  ].flatMap((message2) => message2 === void 0 ? [] : [{ severity: "error", path: path23, message: message2 }]);
40673
40991
  }
40992
+ function untilRefusal(screen, entities, drawn, until) {
40993
+ if (!layoutsFor(drawn).includes("gantt")) {
40994
+ return `ends its bars at "${until}", and these rows cannot be drawn as a gantt \u2014 a bar needs a date to start on and a measure counted in days`;
40995
+ }
40996
+ const field = screen.entity.fields.find((candidate) => candidate.alias === until);
40997
+ if (field === void 0) return `ends its bars at "${until}", which is not a field of entity "${screen.entity.alias}"`;
40998
+ const resolved = resolvedFieldType(field, screen.entity, entities);
40999
+ if (resolved !== "date") {
41000
+ return `ends its bars at "${until}", a ${resolved ?? field.type} \u2014 a bar ends on a day`;
41001
+ }
41002
+ const start = screen.slots.find((slot2) => slot2.role === "when")?.field ?? null;
41003
+ if (start !== null && start.alias === until) {
41004
+ return `ends its bars at "${until}", which is the day they start on \u2014 a bar between one date and itself has no length`;
41005
+ }
41006
+ return void 0;
41007
+ }
40674
41008
  function checkFactGroups(screen, entities, roles) {
40675
41009
  if (screen.factGroups.length === 0) return [];
40676
41010
  const path23 = `apps.${screen.app.alias}.screen.facts.groups`;
@@ -40688,6 +41022,93 @@ function checkFactGroups(screen, entities, roles) {
40688
41022
  )
40689
41023
  );
40690
41024
  }
41025
+ function childRegisters(screen, entities, roles) {
41026
+ const sections = recordSections(screen.entity, entities, roles, recordHeader(screen, roles), screen.screen.shape, screen.record);
41027
+ return new Map(sections.flatMap((section) => section.kind === "children" ? [[section.child.alias, section]] : []));
41028
+ }
41029
+ function checkSectionDraws(screen, entities, roles) {
41030
+ const drawn = Object.entries(screen.screen.sections ?? {});
41031
+ if (drawn.length === 0) return [];
41032
+ const registers = childRegisters(screen, entities, roles);
41033
+ const takes = SHAPE_REGISTRY.worksheet.slots.sell.roles;
41034
+ return drawn.flatMap(([alias, clause]) => {
41035
+ const at2 = `apps.${screen.app.alias}.screen.sections.${alias}`;
41036
+ const section = registers.get(alias);
41037
+ if (section === void 0) {
41038
+ const offered = [...registers.keys()];
41039
+ return [
41040
+ {
41041
+ severity: "error",
41042
+ path: at2,
41043
+ message: offered.length === 0 ? `names "${alias}", and this record draws no register of a child's rows for a sheet to be worked down` : `names "${alias}", which this record draws no register of \u2014 its registers are addressed by ${offered.join(", ")}`
41044
+ }
41045
+ ];
41046
+ }
41047
+ if (section.via !== "parent") {
41048
+ return [
41049
+ {
41050
+ severity: "error",
41051
+ path: at2,
41052
+ message: `names "${alias}", whose rows this record is merely the ${section.via} of \u2014 a sheet prices the lines a record OWNS, and a line added to a history belongs to the job it is about rather than to what is reading it`
41053
+ }
41054
+ ];
41055
+ }
41056
+ const named2 = [["cost", clause.cost], ["sell", clause.sell]].flatMap(([slot2, field]) => field === void 0 ? [] : [{ slot: slot2, field }]);
41057
+ if (named2.length === 0) {
41058
+ return [
41059
+ {
41060
+ severity: "error",
41061
+ path: at2,
41062
+ message: "states neither a cost nor a sell \u2014 a sheet with no figure to work down is the register it is already drawn as"
41063
+ }
41064
+ ];
41065
+ }
41066
+ const findings = named2.flatMap(({ slot: slot2, field }) => {
41067
+ const carried = section.child.fields.find((one) => one.alias === field);
41068
+ if (carried === void 0) {
41069
+ return [{ severity: "error", path: `${at2}.${slot2}`, message: `names "${field}", which is not a field of entity "${alias}"` }];
41070
+ }
41071
+ const role = roleOf(roles, alias, field)?.role;
41072
+ return role !== void 0 && takes.includes(role) ? [] : [
41073
+ {
41074
+ severity: "error",
41075
+ path: `${at2}.${slot2}`,
41076
+ message: `names "${field}", whose role is ${role === void 0 ? "not declared" : `"${role}"`} \u2014 a sheet's figures are ${takes.map((one) => `"${one}"`).join(" or ")} fields; declare the role in field_roles`
41077
+ }
41078
+ ];
41079
+ });
41080
+ const both = clause.cost;
41081
+ if (findings.length === 0 && both !== void 0 && both === clause.sell) {
41082
+ return [
41083
+ {
41084
+ severity: "error",
41085
+ path: at2,
41086
+ message: `prices "${both}" as both its cost and its sell \u2014 the margin between a figure and itself is nothing, drawn down every line`
41087
+ }
41088
+ ];
41089
+ }
41090
+ return findings;
41091
+ });
41092
+ }
41093
+ function unregisteredChildren(screen, entities, roles, registered) {
41094
+ const entries2 = recordRecipe(screen.screen.shape, screen.entity, entities, roles, recordHeader(screen, roles), screen.record);
41095
+ const priced = new Set(Object.keys(screen.screen.sections ?? {}));
41096
+ const read2 = new Set(
41097
+ entries2.flatMap(({ draw, section }) => {
41098
+ if (draw !== "open") return [];
41099
+ if (section.kind === "children" || section.kind === "timeline") return [section.child.alias];
41100
+ return section.kind === "expected_set" && section.source === "child" ? [section.child.alias] : [];
41101
+ })
41102
+ );
41103
+ return [...read2].filter((alias) => !registered.has(alias) && !priced.has(alias));
41104
+ }
41105
+ function checkChildRegisters(screen, entities, roles, registered) {
41106
+ return unregisteredChildren(screen, entities, roles, registered).map((alias) => ({
41107
+ severity: "note",
41108
+ path: `apps.${screen.app.alias}.screen`,
41109
+ message: `"${alias}" has no register \u2014 its rows are read here and created nowhere`
41110
+ }));
41111
+ }
40691
41112
  function sectionActTargets(screen, entities, roles) {
40692
41113
  const sections = recordSections(screen.entity, entities, roles, recordHeader(screen, roles), screen.screen.shape, screen.record);
40693
41114
  const found = /* @__PURE__ */ new Map();
@@ -40722,7 +41143,7 @@ function checkSectionActs(screen, entities, roles) {
40722
41143
  function reachableEntries(decl, field, entityRows) {
40723
41144
  const all = new Set(field.type === "select" ? field.options.map((option) => option.alias) : []);
40724
41145
  const requiredBy = decl.required_by;
40725
- if (decl.role !== "expected_set" || requiredBy === void 0) return all;
41146
+ if (decl.role !== "expected_set" || requiredBy === void 0 || requiredByFrom(field) === "parent") return all;
40726
41147
  const reachable = /* @__PURE__ */ new Set();
40727
41148
  for (const row of entityRows) {
40728
41149
  const said = row.fields[requiredBy.field];
@@ -40809,6 +41230,16 @@ function roleCoverage(model, roles, rows) {
40809
41230
  message: `${section.child.label} hangs under ${entity.label} and declares no identity \u2014 its rows on the record have no name`
40810
41231
  });
40811
41232
  }
41233
+ if (section.kind === "children") {
41234
+ const short = threadShortOf(section.child, roles);
41235
+ if (short !== void 0) {
41236
+ notes.push({
41237
+ severity: "note",
41238
+ path: `field_roles.${section.child.alias}`,
41239
+ message: `${section.child.label} hangs under ${entity.label} and is ${short} short of a thread \u2014 its rows read as a register until it declares one`
41240
+ });
41241
+ }
41242
+ }
40812
41243
  if (section.kind === "expected_set" && section.source === "child" && section.filesField === void 0) {
40813
41244
  notes.push({
40814
41245
  severity: "note",
@@ -40860,9 +41291,17 @@ function optionsWhereConditions(filter2, target) {
40860
41291
  }
40861
41292
  return out.length === 0 ? void 0 : out;
40862
41293
  }
40863
- var LENS_OPERATORS = Object.fromEntries(
40864
- Object.entries(OPTIONS_WHERE_OPERATORS).map(([type, operators]) => [type, [...operators, "is_empty", "is_not_empty"]])
40865
- );
41294
+ var lensRelativePointSchema = zod_default.object({ type: zod_default.literal("relative"), offset: zod_default.number().int(), unit: zod_default.enum(["days", "weeks", "months", "years"]) }).strict();
41295
+ function lensRelativePoint(value) {
41296
+ const read2 = lensRelativePointSchema.safeParse(value);
41297
+ return read2.success ? { offset: read2.data.offset, unit: read2.data.unit } : void 0;
41298
+ }
41299
+ var LENS_OPERATORS = {
41300
+ ...Object.fromEntries(
41301
+ Object.entries(OPTIONS_WHERE_OPERATORS).map(([type, operators]) => [type, [...operators, "is_empty", "is_not_empty"]])
41302
+ ),
41303
+ date: ["before", "after", "on_or_before", "on_or_after", "on", "is_empty", "is_not_empty"]
41304
+ };
40866
41305
  function lensOperatorSentence() {
40867
41306
  return Object.entries(LENS_OPERATORS).map(([type, operators]) => `${type} ${operators.join("/")}`).join("; ");
40868
41307
  }
@@ -40886,6 +41325,9 @@ function lensConditions(filter2, entity, entities) {
40886
41325
  if (!(LENS_OPERATORS[type] ?? []).includes(child.operator)) {
40887
41326
  return `reading "${field.alias}" as a ${type} with "${child.operator}" \u2014 a lens is read off the row, so its operators are ${lensOperatorSentence()}`;
40888
41327
  }
41328
+ if (type === "date" && child.operator !== "is_empty" && child.operator !== "is_not_empty" && lensRelativePoint(child.value) === void 0) {
41329
+ return `reading "${field.alias}" against a day this app would carry forever \u2014 a dated lens names a point relative to the reader's clock, as \`{ "type": "relative", "offset": 0, "unit": "days" }\``;
41330
+ }
40889
41331
  out.push({ type, field, operator: child.operator, value: child.value });
40890
41332
  }
40891
41333
  return out.length === 0 ? `keeping every row \u2014 a predicate that narrows nothing is not a set` : out;
@@ -40998,12 +41440,12 @@ function checkWriteRules(entities, rules, roles) {
40998
41440
  continue;
40999
41441
  }
41000
41442
  const declared = new Set(condition.field.options.map((option) => option.alias));
41001
- for (const named of condition.value) {
41002
- if (typeof named === "string" && declared.has(named)) continue;
41443
+ for (const named2 of condition.value) {
41444
+ if (typeof named2 === "string" && declared.has(named2)) continue;
41003
41445
  findings.push({
41004
41446
  severity: "error",
41005
41447
  path: at3,
41006
- message: `names option "${String(named)}", which "${condition.field.label}" does not declare (${[...declared].join(", ")})`
41448
+ message: `names option "${String(named2)}", which "${condition.field.label}" does not declare (${[...declared].join(", ")})`
41007
41449
  });
41008
41450
  }
41009
41451
  }
@@ -41292,12 +41734,12 @@ function checkValue(field, value, path23, refs, errors) {
41292
41734
  });
41293
41735
  continue;
41294
41736
  }
41295
- const named = raw.slice(0, raw.indexOf(":"));
41296
- if (named === field.target_entity) continue;
41737
+ const named2 = raw.slice(0, raw.indexOf(":"));
41738
+ if (named2 === field.target_entity) continue;
41297
41739
  errors.push({
41298
41740
  severity: "error",
41299
41741
  path: path23,
41300
- message: `names a row of entity "${named}", but this field links to "${field.target_entity}"`
41742
+ message: `names a row of entity "${named2}", but this field links to "${field.target_entity}"`
41301
41743
  });
41302
41744
  }
41303
41745
  return;
@@ -41641,7 +42083,7 @@ function resultSideEffects(result) {
41641
42083
  }
41642
42084
 
41643
42085
  // src/version.ts
41644
- var VERSION = "0.207.0";
42086
+ var VERSION = "0.209.0";
41645
42087
 
41646
42088
  // src/timezone.ts
41647
42089
  function machineTimezone() {
@@ -49957,9 +50399,9 @@ function declaredTable(aliased, key) {
49957
50399
  (candidate) => candidate.table.id === key || candidate.alias === key || candidate.alias === slugifyAlias(key, true) || candidate.table.name === key
49958
50400
  );
49959
50401
  }
49960
- function declaredField(table, named) {
50402
+ function declaredField(table, named2) {
49961
50403
  return table.fields.find(
49962
- (candidate) => candidate.field.id === named || candidate.alias === named || candidate.alias === slugifyAlias(named, false) || candidate.field.name === named
50404
+ (candidate) => candidate.field.id === named2 || candidate.alias === named2 || candidate.alias === slugifyAlias(named2, false) || candidate.field.name === named2
49963
50405
  );
49964
50406
  }
49965
50407
  function carriedWrites(declaration, tables) {
@@ -50087,12 +50529,12 @@ function writeFindings(declaration, bodies, tables) {
50087
50529
  }
50088
50530
  const fields = /* @__PURE__ */ new Set();
50089
50531
  for (const entry of entries2) {
50090
- const named = writtenField(entry);
50091
- const field = declaredField(table, named);
50532
+ const named2 = writtenField(entry);
50533
+ const field = declaredField(table, named2);
50092
50534
  if (field === void 0) {
50093
50535
  findings.push({
50094
50536
  where: key,
50095
- message: `package.json#lotics.writes declares ${key}.${named}, which ${table.table.name} does not carry \u2014 it was renamed or removed, so the declaration covers nothing.`
50537
+ message: `package.json#lotics.writes declares ${key}.${named2}, which ${table.table.name} does not carry \u2014 it was renamed or removed, so the declaration covers nothing.`
50096
50538
  });
50097
50539
  continue;
50098
50540
  }
@@ -50104,15 +50546,15 @@ function writeFindings(declaration, bodies, tables) {
50104
50546
  const labelById = new Map(aliased.map((table) => [table.table.id, table.table.name]));
50105
50547
  const unknownTools = /* @__PURE__ */ new Set();
50106
50548
  for (const body of bodies) {
50107
- const named = /* @__PURE__ */ new Set();
50549
+ const named2 = /* @__PURE__ */ new Set();
50108
50550
  const scanned = bodyWrites(body.source);
50109
50551
  for (const tool of scanned.unknownTools) unknownTools.add(tool);
50110
50552
  for (const write of scanned.writes) {
50111
50553
  const declared = write.table === null ? anyDeclared : byTable.get(write.table) ?? /* @__PURE__ */ new Set();
50112
50554
  if (declared.has(write.field)) continue;
50113
50555
  const fieldName2 = fieldLabel(aliased, write.table, write.field);
50114
- if (named.has(fieldName2)) continue;
50115
- named.add(fieldName2);
50556
+ if (named2.has(fieldName2)) continue;
50557
+ named2.add(fieldName2);
50116
50558
  const table = write.table === null ? null : labelById.get(write.table) ?? write.table;
50117
50559
  findings.push({
50118
50560
  where: body.alias,
@@ -50183,8 +50625,8 @@ function whereValue(at2, bound, condition, missing) {
50183
50625
  missing.push(`${at2} narrows "${bound.label}" by no option at all \u2014 a select narrowing names the options it admits`);
50184
50626
  return void 0;
50185
50627
  }
50186
- const named = condition.value;
50187
- const ids = named.flatMap((option) => {
50628
+ const named2 = condition.value;
50629
+ const ids = named2.flatMap((option) => {
50188
50630
  const live = typeof option === "string" ? bound.options?.get(option) : void 0;
50189
50631
  if (live === void 0) {
50190
50632
  missing.push(`${at2} names option "${String(option)}", which "${bound.label}" does not carry in this workspace`);
@@ -50192,7 +50634,7 @@ function whereValue(at2, bound, condition, missing) {
50192
50634
  }
50193
50635
  return [live.id];
50194
50636
  });
50195
- return ids.length === named.length ? { value: ids } : void 0;
50637
+ return ids.length === named2.length ? { value: ids } : void 0;
50196
50638
  }
50197
50639
  var WORDS = {
50198
50640
  plain: {
@@ -50287,8 +50729,9 @@ function childSurfaceScreen(parent, entry, mounts) {
50287
50729
  tabs: null,
50288
50730
  slots: [],
50289
50731
  outcomes: {},
50732
+ columns: [],
50290
50733
  acts: { row: [], selection: [], record: [], export: null, import: null },
50291
- summary: { columnTotals: [], bandTotals: [], above: null, ageing: null },
50734
+ summary: { columnTotals: [], bandTotals: [], above: null, ageing: null, trend: null },
50292
50735
  period: null,
50293
50736
  filters: [],
50294
50737
  obligations: [],
@@ -50479,6 +50922,12 @@ function namingField2(child, link, entities, roles) {
50479
50922
  (field) => field.type === "formula" && roleOf(roles, child.alias, field.alias) === void 0 && resolvedFieldType(field, child, entities) === "text"
50480
50923
  ) ?? own("text") ?? own("select_record_link");
50481
50924
  }
50925
+ function drawnColumns2(child, fields, roles) {
50926
+ return fields.filter((field) => {
50927
+ const role = roleOf(roles, child.alias, field.alias)?.role;
50928
+ return role !== void 0 && drawnAsColumn(role, field);
50929
+ });
50930
+ }
50482
50931
  function withScope(own, scope) {
50483
50932
  if (scope === void 0) return own;
50484
50933
  if (own === void 0) return scope.filter;
@@ -50557,7 +51006,7 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
50557
51006
  // A slot's own field, then the further fields an ORDERED role reads after
50558
51007
  // it: they are the same slot, so they lead the entity's own list together.
50559
51008
  ...screen2.slots.flatMap(
50560
- (slot2) => slot2.field === null ? [] : [...slot2.field === identity ? [] : [slot2.field], ...slot2.also]
51009
+ (slot2) => slot2.field === null ? [] : [...slot2.field === identity ? [] : [slot2.field], ...slot2.also, ...slot2.tiers]
50561
51010
  ),
50562
51011
  ...screen2.tabs === null ? [] : [screen2.tabs]
50563
51012
  ];
@@ -50587,21 +51036,16 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
50587
51036
  ...factLinkTargets(screen2, planned, entities, roles, owns).map((one) => ({ ...one, on: "fact" })),
50588
51037
  ...createLinkTargets(screen2, entities, roles, owns, under).map((one) => ({ ...one, on: "create" }))
50589
51038
  ];
50590
- for (const { field, target, display, on } of linkTargets) {
50591
- const bound = links.get(field.alias);
50592
- if (on === "create" && bound !== void 0) {
50593
- createLinks.set(field.alias, bound);
50594
- continue;
50595
- }
51039
+ const bindPicker = (owner, field, target, display) => {
50596
51040
  const scoped = new Set((target.read_scope?.any ?? []).flatMap((clause) => "field" in clause ? [clause.field] : []));
50597
51041
  const declared = rules[target.alias]?.natural_key ?? [];
50598
51042
  const keyFields = target.fields.filter((candidate) => declared.includes(candidate.alias));
50599
51043
  const mintFields = mintedWith(target, display, keyFields);
50600
- const narrowing2 = writeRuleOf(rules, screen2.entity.alias, field.alias)?.options_where;
51044
+ const narrowing2 = writeRuleOf(rules, owner.alias, field.alias)?.options_where;
50601
51045
  const conditions = narrowing2 === void 0 ? void 0 : optionsWhereConditions(narrowing2, target);
50602
51046
  const sourceAliases = new Set(
50603
- screen2.entity.fields.flatMap((candidate) => {
50604
- const stated2 = writeRuleOf(rules, screen2.entity.alias, candidate.alias)?.default_from;
51047
+ owner.fields.flatMap((candidate) => {
51048
+ const stated2 = writeRuleOf(rules, owner.alias, candidate.alias)?.default_from;
50605
51049
  return stated2 === void 0 || !stated2.startsWith(`${field.alias}.`) ? [] : [stated2.slice(field.alias.length + 1)];
50606
51050
  })
50607
51051
  );
@@ -50615,22 +51059,22 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
50615
51059
  ...target.fields.filter((candidate) => scoped.has(candidate.alias))
50616
51060
  ];
50617
51061
  const found = bindTable(`${at2}.${field.alias}`, target, entities, roles, wantedOnTarget, aliased, live, missing);
50618
- if (found === void 0) continue;
51062
+ if (found === void 0) return void 0;
50619
51063
  const shown = found.fields.get(display.alias);
50620
- if (shown === void 0) continue;
51064
+ if (shown === void 0) return void 0;
50621
51065
  const rule = scopeOf(target, found.fields);
50622
51066
  const boundOf = (fields) => fields.flatMap((candidate) => {
50623
51067
  const one = found.fields.get(candidate.alias);
50624
51068
  return one === void 0 ? [] : [one];
50625
51069
  });
50626
- const narrowedAt = `write_rules.${screen2.entity.alias}.fields.${field.alias}.options_where`;
51070
+ const narrowedAt = `write_rules.${owner.alias}.fields.${field.alias}.options_where`;
50627
51071
  const where = (conditions ?? []).flatMap((condition) => {
50628
51072
  const one = found.fields.get(condition.field.alias);
50629
51073
  if (one === void 0) return [];
50630
51074
  const resolved = whereValue(narrowedAt, one, condition, missing);
50631
51075
  return resolved === void 0 ? [] : [{ field: one, type: condition.type, operator: condition.operator, value: resolved.value }];
50632
51076
  });
50633
- const link = {
51077
+ return {
50634
51078
  alias: optionsQueryAlias(target.alias, display.alias),
50635
51079
  param: OPTIONS_SEARCH_PARAM,
50636
51080
  table: found.table,
@@ -50651,11 +51095,20 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
50651
51095
  ...rule === void 0 ? {} : { scope: rule },
50652
51096
  ...where.length === 0 ? {} : { where }
50653
51097
  };
51098
+ };
51099
+ for (const { field, target, display, on } of linkTargets) {
51100
+ const bound = links.get(field.alias);
51101
+ if (on === "create" && bound !== void 0) {
51102
+ createLinks.set(field.alias, bound);
51103
+ continue;
51104
+ }
51105
+ const link = bindPicker(screen2.entity, field, target, display);
51106
+ if (link === void 0) continue;
50654
51107
  if (on === "fact") links.set(field.alias, link);
50655
51108
  createLinks.set(field.alias, link);
50656
51109
  }
50657
51110
  const children = /* @__PURE__ */ new Map();
50658
- const bindChild = (entry2, drawn, set2, files, named, reads = []) => {
51111
+ const bindChild = (entry2, drawn, set2, files, named2, reads = []) => {
50659
51112
  const relation = sectionRelation(entry2.section);
50660
51113
  if (relation === void 0) return;
50661
51114
  const { slot: step, draw } = entry2;
@@ -50676,6 +51129,11 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
50676
51129
  // can draw its own anatomy.
50677
51130
  ...itinerary?.kind === void 0 ? [] : [itinerary.kind.alias],
50678
51131
  ...itinerary?.reference === void 0 ? [] : [itinerary.reference.alias],
51132
+ // AND SO IS THE STOP'S PICTURE. A `mark` is not in `DRAWN_ROLES`, so no
51133
+ // register column would ever have projected it — a run that leads with
51134
+ // a face has to ask for the column itself, and a child whose rows open
51135
+ // nothing has no other read to carry it.
51136
+ ...itinerary?.mark === void 0 ? [] : [itinerary.mark.alias],
50679
51137
  ...drawn.flatMap((field) => {
50680
51138
  const decl = roleOf(roles, child.alias, field.alias);
50681
51139
  if (decl === void 0) return [];
@@ -50702,7 +51160,7 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
50702
51160
  const boundField = found.fields.get(field.alias);
50703
51161
  if (boundField === void 0) continue;
50704
51162
  fields.set(field.alias, boundField);
50705
- const role = field.alias === named?.alias ? "identity" : roleOf(roles, child.alias, field.alias)?.role;
51163
+ const role = field.alias === named2?.alias ? "identity" : roleOf(roles, child.alias, field.alias)?.role;
50706
51164
  if (role !== void 0) drawnRoles.set(field.alias, role);
50707
51165
  }
50708
51166
  const operands = /* @__PURE__ */ new Map([[linkField.alias, link]]);
@@ -50738,7 +51196,7 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
50738
51196
  fields,
50739
51197
  operands,
50740
51198
  roles: drawnRoles,
50741
- identity: named?.alias ?? child.fields.find((field) => roleOf(roles, child.alias, field.alias)?.role === "identity")?.alias,
51199
+ identity: named2?.alias ?? child.fields.find((field) => roleOf(roles, child.alias, field.alias)?.role === "identity")?.alias,
50742
51200
  set: set2?.alias,
50743
51201
  files: files?.alias,
50744
51202
  ...itinerary === void 0 ? {} : { itinerary },
@@ -50758,22 +51216,19 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
50758
51216
  continue;
50759
51217
  }
50760
51218
  if (section.kind === "children") {
50761
- const drawn = section.fields.filter((field) => {
50762
- const role = roleOf(roles, section.child.alias, field.alias)?.role;
50763
- return role !== void 0 && drawnAsColumn(role, field);
50764
- });
51219
+ const drawn = drawnColumns2(section.child, section.fields, roles);
50765
51220
  if (drawn.length > 0) {
50766
51221
  bindChild(entry2, drawn);
50767
51222
  continue;
50768
51223
  }
50769
- const named = namingField2(section.child, section.link, entities, roles);
50770
- if (named === void 0) {
51224
+ const named2 = namingField2(section.child, section.link, entities, roles);
51225
+ if (named2 === void 0) {
50771
51226
  missing.push(
50772
51227
  `${at2}.${section.child.alias}: its rows cannot be drawn \u2014 "${section.child.label}" declares no role and carries no reference, autonumber, text formula, text or link field beside "${section.link.label}"`
50773
51228
  );
50774
51229
  continue;
50775
51230
  }
50776
- bindChild(entry2, [named], void 0, void 0, named);
51231
+ bindChild(entry2, [named2], void 0, void 0, named2);
50777
51232
  continue;
50778
51233
  }
50779
51234
  if (section.kind !== "expected_set" || section.source !== "child") continue;
@@ -50855,14 +51310,15 @@ function stated(entry, field) {
50855
51310
  }
50856
51311
  function factLinkTargets(screen, sections, entities, roles, owns) {
50857
51312
  if (screen.screen.writes === false || !owns) return [];
50858
- return sections.flatMap((section) => section.kind === "facts" ? section.fields : []).flatMap((field) => {
50859
- if (field.type !== "select_record_link" || !isEditable(field)) return [];
50860
- const target = entities.find((candidate) => candidate.alias === field.target_entity);
50861
- if (target === void 0) return [];
50862
- const named = field.display_field_aliases?.[0];
50863
- const display = named === void 0 ? target.fields.find((candidate) => roleOf(roles, target.alias, candidate.alias)?.role === "identity") : target.fields.find((candidate) => candidate.alias === named);
50864
- return display === void 0 ? [] : [{ field, target, display }];
50865
- });
51313
+ return sections.flatMap((section) => section.kind === "facts" ? section.fields : []).flatMap((field) => linkTarget2(field, entities, roles));
51314
+ }
51315
+ function linkTarget2(field, entities, roles) {
51316
+ if (field.type !== "select_record_link" || !isEditable(field)) return [];
51317
+ const target = entities.find((candidate) => candidate.alias === field.target_entity);
51318
+ if (target === void 0) return [];
51319
+ const named2 = field.display_field_aliases?.[0];
51320
+ const display = named2 === void 0 ? target.fields.find((candidate) => roleOf(roles, target.alias, candidate.alias)?.role === "identity") : target.fields.find((candidate) => candidate.alias === named2);
51321
+ return display === void 0 ? [] : [{ field, target, display }];
50866
51322
  }
50867
51323
  function parentLink(entity, roles) {
50868
51324
  const field = entity.fields.find((candidate) => roleOf(roles, entity.alias, candidate.alias)?.role === "parent");
@@ -50870,14 +51326,7 @@ function parentLink(entity, roles) {
50870
51326
  }
50871
51327
  function createLinkTargets(screen, entities, roles, owns, under) {
50872
51328
  if (screen.screen.writes === false || !owns) return [];
50873
- return screen.entity.fields.flatMap((field) => {
50874
- if (field.type !== "select_record_link" || !isEditable(field) || field.alias === under?.alias) return [];
50875
- const target = entities.find((candidate) => candidate.alias === field.target_entity);
50876
- if (target === void 0) return [];
50877
- const named = field.display_field_aliases?.[0];
50878
- const display = named === void 0 ? target.fields.find((candidate) => roleOf(roles, target.alias, candidate.alias)?.role === "identity") : target.fields.find((candidate) => candidate.alias === named);
50879
- return display === void 0 ? [] : [{ field, target, display }];
50880
- });
51329
+ return screen.entity.fields.flatMap((field) => field.alias === under?.alias ? [] : linkTarget2(field, entities, roles));
50881
51330
  }
50882
51331
  function mintedWith(target, display, keys2) {
50883
51332
  const keyed = new Set(keys2.map((field) => field.alias));
@@ -51220,14 +51669,37 @@ var specFieldSchema = zod_default.object({
51220
51669
  zeroWhenEmpty: zod_default.literal(true).optional(),
51221
51670
  sign: specSignSchema.optional(),
51222
51671
  level: specLevelSchema.optional(),
51223
- until: specUntilSchema.optional()
51224
- }).strict();
51672
+ /**
51673
+ * THIS DATE IS A DEADLINE — the row counts down to it and a row past it is
51674
+ * late. The MODEL's statement and the only one any surface reads: derived
51675
+ * from the row instead, the day a lead arrived read as overdue on every desk
51676
+ * it was drawn on. `until` says where the countdown stops.
51677
+ */
51678
+ due: zod_default.literal(true).optional(),
51679
+ until: specUntilSchema.optional(),
51680
+ /**
51681
+ * WHAT THIS MEASURE'S NUMBER COUNTS, where the unit decides how a surface
51682
+ * DRAWS the row — days are what give a bar its length, and a figure with no
51683
+ * unit is drawn as the number it is.
51684
+ */
51685
+ counts: zod_default.literal("days").optional()
51686
+ }).strict().check((ctx) => {
51687
+ if (ctx.value.until !== void 0 && ctx.value.due !== true) {
51688
+ ctx.issues.push({
51689
+ code: "custom",
51690
+ input: ctx.value,
51691
+ message: "`until` says where a countdown stops \u2014 declare `due: true` beside it, or drop it"
51692
+ });
51693
+ }
51694
+ });
51225
51695
  var specFieldsSchema = zod_default.record(specColumnAliasSchema, specFieldSchema);
51226
51696
  var specPresentationSchema = zod_default.object({
51227
51697
  lead: presentationLeadSchema.optional(),
51228
51698
  density: presentationDensitySchema.optional(),
51229
51699
  /** How the rows are ARRANGED — the same rows and the same slots in a different geometry. */
51230
- layout: screenLayoutSchema.optional()
51700
+ layout: screenLayoutSchema.optional(),
51701
+ /** WHERE EACH BAR ENDS on a gantt — the column holding the day it is done, rather than the span it runs. */
51702
+ until: specColumnAliasSchema.optional()
51231
51703
  }).strict();
51232
51704
  var specActPlaceSchema = zod_default.literal("cta");
51233
51705
  var specTemplateActSchema = zod_default.object({
@@ -51267,7 +51739,23 @@ var specAgentActSchema = zod_default.object({
51267
51739
  fills: zod_default.array(specColumnAliasSchema).min(1),
51268
51740
  place: specActPlaceSchema.optional()
51269
51741
  }).strict();
51270
- var specActSchema = zod_default.discriminatedUnion("kind", [specTemplateActSchema, specAgentActSchema]);
51742
+ var RECORD_ID_COLUMN = "__source_record_id";
51743
+ var specActInputSchema = zod_default.object({
51744
+ name: zod_default.string().min(1),
51745
+ field: specColumnAliasSchema
51746
+ }).strict();
51747
+ var specWorkflowActSchema = zod_default.object({
51748
+ kind: zod_default.literal("workflow"),
51749
+ label: zod_default.string().min(1),
51750
+ /** The act's own key, stable across renders — the workflow's alias. */
51751
+ key: contractAliasSchema,
51752
+ /** The alias `package.json#lotics.workflows` binds this body under. */
51753
+ workflow: runtimeAliasSchema,
51754
+ /** What the press hands the run, in the order the act named them. */
51755
+ inputs: zod_default.array(specActInputSchema).min(1),
51756
+ place: specActPlaceSchema.optional()
51757
+ }).strict();
51758
+ var specActSchema = zod_default.discriminatedUnion("kind", [specTemplateActSchema, specAgentActSchema, specWorkflowActSchema]);
51271
51759
  var specColumnsExportSchema = zod_default.object({
51272
51760
  kind: zod_default.literal("columns"),
51273
51761
  /** Each drawn column, in the order the register draws them: the sheet's heading, and the column it reads. */
@@ -51305,13 +51793,23 @@ var specSlotSchema = zod_default.object({
51305
51793
  * rest states it on every line rather than drawing a blank on most.
51306
51794
  */
51307
51795
  also: zod_default.array(specColumnAliasSchema).min(1).optional(),
51796
+ /**
51797
+ * THE TIERS UNDER THE ONE IN `field`, outermost first — a hierarchy the slot
51798
+ * folds by rather than second homes for one value.
51799
+ *
51800
+ * Two lists and not one, because they are two readings: `also` is read
51801
+ * ACROSS, one value wherever the row happens to state it, and this is read
51802
+ * DOWN, a narrower question at each depth.
51803
+ */
51804
+ tiers: zod_default.array(specColumnAliasSchema).min(1).optional(),
51308
51805
  /**
51309
51806
  * THE COLUMN IS THE READER'S OWN CONTROL FOR THIS VALUE — pressed, it writes
51310
51807
  * that single field as a diff through the record's update, blockers and an
51311
51808
  * outcome's confirm included.
51312
51809
  *
51313
51810
  * Only where the decision is one a reader takes FROM THE ROW ALONE, which is
51314
- * why `app check` refuses it on every role but `lifecycle` and `verdict`.
51811
+ * why `app check` refuses it on every role but `lifecycle`, `verdict` and
51812
+ * `measure` (`quickRefusal`, the one rule both the plan and the spec ask).
51315
51813
  */
51316
51814
  quick: zod_default.literal(true).optional(),
51317
51815
  /** A quick lifecycle whose stages are a WALK — the cell advances to the next rather than offering them all. */
@@ -51339,11 +51837,18 @@ var specSummarySchema = zod_default.object({
51339
51837
  /** Ascending edges in days past due; the last is open-ended. */
51340
51838
  buckets: zod_default.array(zod_default.number().int().positive()).min(1)
51341
51839
  }).strict().optional(),
51342
- directions: specDirectionsSchema.optional()
51840
+ directions: specDirectionsSchema.optional(),
51841
+ /**
51842
+ * WHICH WAY THE FIGURE MOVED ACROSS THE WINDOW — read over the screen's own
51843
+ * `period`, which the plan refuses this clause without. `direction` is which
51844
+ * way is GOOD: the arrow follows the change, the colour follows this.
51845
+ */
51846
+ trend: zod_default.object({ field: specColumnAliasSchema, direction: zod_default.enum(["up", "down"]) }).strict().optional()
51343
51847
  }).strict();
51848
+ var specRelativePointSchema = zod_default.object({ offset: zod_default.number().int(), unit: zod_default.enum(["days", "weeks", "months", "years"]) }).strict();
51344
51849
  var specConditionSchema = zod_default.object({
51345
51850
  field: specColumnAliasSchema,
51346
- type: zod_default.enum(["number", "text", "boolean", "select"]),
51851
+ type: zod_default.enum(["number", "text", "boolean", "select", "date"]),
51347
51852
  operator: zod_default.enum([
51348
51853
  "equals",
51349
51854
  "not_equals",
@@ -51353,11 +51858,20 @@ var specConditionSchema = zod_default.object({
51353
51858
  "less_than_or_equal_to",
51354
51859
  "has_any_of",
51355
51860
  "has_none_of",
51861
+ "before",
51862
+ "after",
51863
+ "on_or_before",
51864
+ "on_or_after",
51865
+ "on",
51356
51866
  "is_empty",
51357
51867
  "is_not_empty"
51358
51868
  ]),
51359
- /** What the operator compares against — absent where it compares against nothing. */
51360
- value: zod_default.union([zod_default.number(), zod_default.string(), zod_default.boolean(), zod_default.array(zod_default.string())]).optional()
51869
+ /**
51870
+ * What the operator compares against — absent where it compares against
51871
+ * nothing. A DATE's is an offset from today and never a day: a lens written
51872
+ * as a date is a set that stops meaning what it said tomorrow.
51873
+ */
51874
+ value: zod_default.union([zod_default.number(), zod_default.string(), zod_default.boolean(), zod_default.array(zod_default.string()), specRelativePointSchema]).optional()
51361
51875
  }).strict();
51362
51876
  var specPredicateSchema = zod_default.object({
51363
51877
  label: zod_default.string().min(1),
@@ -51372,14 +51886,24 @@ var specLensSchema = zod_default.union([
51372
51886
  var specObligationSchema = zod_default.object({
51373
51887
  label: zod_default.string().min(1),
51374
51888
  due: specColumnAliasSchema,
51375
- /** Filled, this obligation is met and its row leaves the desk. */
51376
- satisfiedBy: specColumnAliasSchema
51889
+ /**
51890
+ * Filled, this obligation is met and its row leaves the desk — absent where
51891
+ * the business stamps nothing and the LIFECYCLE ends it instead, which the
51892
+ * `due` column's own `until` says and the same read decides.
51893
+ */
51894
+ satisfiedBy: specColumnAliasSchema.optional()
51377
51895
  }).strict();
51378
51896
  var specScreenSchema = zod_default.object({
51379
51897
  alias: contractAliasSchema,
51380
51898
  label: zod_default.string().min(1),
51381
51899
  /** The entity this screen's rows are of — the key of its record in `records`. */
51382
51900
  entity: contractAliasSchema,
51901
+ /**
51902
+ * WHAT A SET OF THESE ROWS IS CALLED — the entity's own noun, for the column
51903
+ * a shape heads with a count. Optional: an app deployed before this clause
51904
+ * carries none and the frame's own word stands in.
51905
+ */
51906
+ rows: zod_default.string().min(1).optional(),
51383
51907
  table: specTableIdSchema,
51384
51908
  shape: specShapeSchema,
51385
51909
  /** How one record opens from the list — a page, a drawer, or revealed in the row itself. */
@@ -51390,6 +51914,16 @@ var specScreenSchema = zod_default.object({
51390
51914
  writes: zod_default.boolean(),
51391
51915
  fields: specFieldsSchema,
51392
51916
  slots: zod_default.array(specSlotSchema),
51917
+ /**
51918
+ * THE CONTEXT COLUMNS — extra facts this register draws AFTER its slots, in
51919
+ * the plan's order, each by what its column IS rather than by a role.
51920
+ *
51921
+ * They carry no rank: the frame ranks every one of them beneath every column
51922
+ * the shape drew, so a width that cannot seat the row sheds the context
51923
+ * before the answer. Bounded here as well as in the plan, because a spec is
51924
+ * also written by hand.
51925
+ */
51926
+ columns: zod_default.array(specColumnAliasSchema).min(1).max(CONTEXT_COLUMNS).optional(),
51393
51927
  /**
51394
51928
  * For each lifecycle this screen draws, the options that END the flow. A row
51395
51929
  * in one has ARRIVED, so the ladder draws it beside the flow rather than as
@@ -51454,16 +51988,21 @@ var specContactSchema = zod_default.discriminatedUnion("kind", [
51454
51988
  reach: zod_default.enum(["email", "phone", "place", "link", "handle"])
51455
51989
  }).strict()
51456
51990
  ]);
51991
+ var requiredByFrom2 = {
51992
+ from: zod_default.literal("parent").optional()
51993
+ };
51457
51994
  var specRequiredBySchema = zod_default.discriminatedUnion("reads", [
51458
51995
  zod_default.object({
51459
51996
  reads: zod_default.literal("option"),
51460
51997
  field: specColumnAliasSchema,
51998
+ ...requiredByFrom2,
51461
51999
  /** The conditioning option → the entries required under it. */
51462
52000
  options: zod_default.record(specOptionIdSchema, zod_default.array(specOptionIdSchema).min(1))
51463
52001
  }).strict(),
51464
52002
  zod_default.object({
51465
52003
  reads: zod_default.literal("answer"),
51466
52004
  field: specColumnAliasSchema,
52005
+ ...requiredByFrom2,
51467
52006
  /**
51468
52007
  * The row's own answer → the entries required under it. PARTIAL, because
51469
52008
  * an answer that owes nothing is omitted rather than named with an empty
@@ -51491,6 +52030,13 @@ var specItinerarySchema = zod_default.object({
51491
52030
  slot: specColumnAliasSchema.optional(),
51492
52031
  /** What KIND of stop it is — the word leading its muted line. */
51493
52032
  kind: specColumnAliasSchema.optional(),
52033
+ /**
52034
+ * THE PICTURE THE STOP LEADS WITH — the row's own, or the one looked up off
52035
+ * the thing it is a stop FOR. A `mark` is no column of a register, so the
52036
+ * run is the only place a record pictures these rows, and it is named here
52037
+ * or it is not drawn.
52038
+ */
52039
+ mark: specColumnAliasSchema.optional(),
51494
52040
  /** The confirmation the reader quotes when they ring about the stop. */
51495
52041
  reference: specColumnAliasSchema.optional(),
51496
52042
  /** How many days the stop RUNS — a stay of three nights is one row and three days of the run. */
@@ -51498,6 +52044,24 @@ var specItinerarySchema = zod_default.object({
51498
52044
  /** The closing line's label, where the record's band does not already state that sum. */
51499
52045
  total: zod_default.string().min(1).optional()
51500
52046
  }).strict();
52047
+ var specWorksheetSchema = zod_default.object({
52048
+ /** HOW MANY the line is for — never summed, because two units have no sum. */
52049
+ quantity: specColumnAliasSchema.optional(),
52050
+ /** What the line COSTS — the base the margin is taken against. */
52051
+ cost: specColumnAliasSchema.optional(),
52052
+ /** What it SELLS for — the figure the sheet closes on. */
52053
+ sell: specColumnAliasSchema.optional(),
52054
+ /** The part of the job each line falls under, and the run its own foot closes. */
52055
+ group: specColumnAliasSchema.optional()
52056
+ }).strict().check((ctx) => {
52057
+ if (ctx.value.cost === void 0 && ctx.value.sell === void 0) {
52058
+ ctx.issues.push({
52059
+ code: "custom",
52060
+ input: ctx.value,
52061
+ message: "states neither a cost nor a sell \u2014 a sheet works one of the two down"
52062
+ });
52063
+ }
52064
+ });
51501
52065
  var specChildSchema = zod_default.object({
51502
52066
  entity: contractAliasSchema,
51503
52067
  table: specTableIdSchema,
@@ -51537,6 +52101,7 @@ var specChildSchema = zod_default.object({
51537
52101
  */
51538
52102
  write: zod_default.object({ alias: runtimeAliasSchema, param: zod_default.string().min(1) }).strict().optional()
51539
52103
  }).strict();
52104
+ var specPricedChildSchema = specChildSchema.extend({ worksheet: specWorksheetSchema });
51540
52105
  var sectionActs = { acts: zod_default.array(specActSchema).min(1).optional() };
51541
52106
  var specSectionSchema = zod_default.discriminatedUnion("kind", [
51542
52107
  zod_default.object({ kind: zod_default.literal("facts"), key: zod_default.string().min(1), groups: zod_default.array(specFactGroupSchema), ...sectionActs }).strict(),
@@ -51558,16 +52123,39 @@ var specSectionSchema = zod_default.discriminatedUnion("kind", [
51558
52123
  ...sectionActs
51559
52124
  }).strict(),
51560
52125
  /** A child whose rows carry one entry of the set each — a document desk. */
51561
- zod_default.object({ kind: zod_default.literal("desk"), key: zod_default.string().min(1), heading: zod_default.string().min(1), child: specChildSchema, ...sectionActs }).strict(),
51562
- /** The record's own rows, as a register, a run, or a book of movements. */
51563
52126
  zod_default.object({
51564
- kind: zod_default.literal("children"),
52127
+ kind: zod_default.literal("desk"),
51565
52128
  key: zod_default.string().min(1),
51566
52129
  heading: zod_default.string().min(1),
51567
- draw: zod_default.enum(["register", "itinerary", "ledger"]),
51568
52130
  child: specChildSchema,
52131
+ /**
52132
+ * WHICH ENTRIES THIS RECORD OWES, where its own column decides them —
52133
+ * read off the record's row, because how many documents an order needs is
52134
+ * a fact about the order and no paper states it.
52135
+ */
52136
+ requiredBy: specRequiredBySchema.optional(),
51569
52137
  ...sectionActs
51570
52138
  }).strict(),
52139
+ /** The record's own rows, as a register, a run, a book of movements, or a priced sheet. */
52140
+ zod_default.discriminatedUnion("draw", [
52141
+ zod_default.object({
52142
+ kind: zod_default.literal("children"),
52143
+ key: zod_default.string().min(1),
52144
+ heading: zod_default.string().min(1),
52145
+ draw: zod_default.enum(["register", "itinerary", "ledger"]),
52146
+ child: specChildSchema,
52147
+ ...sectionActs
52148
+ }).strict(),
52149
+ /** THE SHEET AND ITS FIGURES ARE ONE CLAUSE — neither is declarable without the other. */
52150
+ zod_default.object({
52151
+ kind: zod_default.literal("children"),
52152
+ key: zod_default.string().min(1),
52153
+ heading: zod_default.string().min(1),
52154
+ draw: zod_default.literal("worksheet"),
52155
+ child: specPricedChildSchema,
52156
+ ...sectionActs
52157
+ }).strict()
52158
+ ]),
51571
52159
  /**
51572
52160
  * WHERE THE RECORD IS AGAINST WHERE IT SHOULD BE — a child whose rows are the
51573
52161
  * ordered stops, each carrying the day it was promised for and the day it
@@ -51820,9 +52408,9 @@ function specActSites(spec) {
51820
52408
  function specAllActs(spec) {
51821
52409
  return specActSites(spec).map((site) => site.act);
51822
52410
  }
51823
- function actsPointedAt(spec, named) {
52411
+ function actsPointedAt(spec, named2) {
51824
52412
  const paper = (at2, act) => {
51825
- const component = named.get(at2);
52413
+ const component = named2.get(at2);
51826
52414
  return component === void 0 ? act : { ...act, component };
51827
52415
  };
51828
52416
  const point = (at2, act) => act.kind === "template" ? paper(at2, act) : act;
@@ -51861,9 +52449,11 @@ function specWorkflowAliases(spec) {
51861
52449
  const taken = spec.screen.acts?.import;
51862
52450
  return [
51863
52451
  .../* @__PURE__ */ new Set([
51864
- // AN AGENT ACT NAMES NO WORKFLOW — what it lands is the record's own
51865
- // update, which `record.write` below already declares.
51866
- ...specAllActs(spec).flatMap((act) => act.kind === "template" ? [act.workflow] : []),
52452
+ // THE TWO ARMS THAT NAME A BODY — a paper the one the generator wrote, a
52453
+ // hand-off the one the author binds, both called by alias from the same
52454
+ // press. An agent act lands the record's own update, which `record.write`
52455
+ // below already declares.
52456
+ ...specAllActs(spec).flatMap((act) => act.kind === "template" || act.kind === "workflow" ? [act.workflow] : []),
51867
52457
  ...taken === void 0 ? [] : [taken.workflow],
51868
52458
  ...Object.values(spec.records).flatMap((record2) => [
51869
52459
  ...record2.write === void 0 ? [] : [record2.write.alias],
@@ -52102,6 +52692,43 @@ function sourceOnTarget(link, alias) {
52102
52692
  return link.sources.get(alias);
52103
52693
  }
52104
52694
 
52695
+ // src/plan_worksheets.ts
52696
+ function byRole(child, role, taken) {
52697
+ for (const [alias, bound] of child.fields) {
52698
+ if (child.roles.get(alias) === role && !taken.has(alias)) return { alias, bound };
52699
+ }
52700
+ return void 0;
52701
+ }
52702
+ function named(child, alias) {
52703
+ const bound = alias === void 0 ? void 0 : child.fields.get(alias);
52704
+ return alias === void 0 || bound === void 0 ? void 0 : { alias, bound };
52705
+ }
52706
+ function planWorksheets(entry) {
52707
+ const drawn = entry.screen.screen.sections ?? {};
52708
+ return entry.sections.flatMap(({ draw, section }) => {
52709
+ if (draw === "related" || section.kind !== "children" || section.via !== "parent") return [];
52710
+ const clause = drawn[section.child.alias];
52711
+ if (clause === void 0) return [];
52712
+ const child = entry.children.get(childKey(section.child.alias, section.link.alias));
52713
+ if (child === void 0) return [];
52714
+ const cost = named(child, clause.cost);
52715
+ const sell = named(child, clause.sell);
52716
+ const priced = new Set([cost, sell].flatMap((figure) => figure === void 0 ? [] : [figure.alias]));
52717
+ const quantity = byRole(child, "measure", priced);
52718
+ const group = byRole(child, "category", priced);
52719
+ return [
52720
+ {
52721
+ child,
52722
+ key: `${child.entity.alias}_${child.linkAlias}`,
52723
+ ...quantity === void 0 ? {} : { quantity },
52724
+ ...cost === void 0 ? {} : { cost },
52725
+ ...sell === void 0 ? {} : { sell },
52726
+ ...group === void 0 ? {} : { group }
52727
+ }
52728
+ ];
52729
+ });
52730
+ }
52731
+
52105
52732
  // src/plan_record_facts.ts
52106
52733
  function factOrder(screen, roles, fields) {
52107
52734
  const slots = new Set(screen.slots.flatMap((slot2) => slot2.field === null ? [] : [slot2.field.alias]));
@@ -52130,8 +52757,8 @@ function factsPlan(screen, roles, fields) {
52130
52757
  if (screen.factGroups.length === 0) return derived(fields);
52131
52758
  const own = new Set(fields.map((field) => field.alias));
52132
52759
  const claimed = new Set(screen.factGroups.flatMap((group) => group.fields.map((field) => field.alias)));
52133
- const named = screen.factGroups.map((group) => ({ caption: group.caption, fields: group.fields.filter((field) => own.has(field.alias)) }));
52134
- return [...named.filter((group) => group.fields.length > 0), ...derived(fields.filter((field) => !claimed.has(field.alias)))];
52760
+ const named2 = screen.factGroups.map((group) => ({ caption: group.caption, fields: group.fields.filter((field) => own.has(field.alias)) }));
52761
+ return [...named2.filter((group) => group.fields.length > 0), ...derived(fields.filter((field) => !claimed.has(field.alias)))];
52135
52762
  }
52136
52763
 
52137
52764
  // src/plan_record_children.ts
@@ -52156,8 +52783,8 @@ var surfaceOf = (fields) => ({
52156
52783
  ref: (alias) => fields.get(alias)?.alias
52157
52784
  });
52158
52785
  var childReads = (child) => child.surface?.fields ?? new Map([...child.fields, ...child.operands]);
52159
- function optionIds(field, named) {
52160
- return (named ?? []).flatMap((alias) => {
52786
+ function optionIds(field, named2) {
52787
+ return (named2 ?? []).flatMap((alias) => {
52161
52788
  const live = field?.options?.get(alias);
52162
52789
  return live === void 0 ? [] : [live.id];
52163
52790
  });
@@ -52187,7 +52814,7 @@ function untilOf(entry, roles, field) {
52187
52814
  return lifecycle === void 0 || settled.length === 0 ? void 0 : { lifecycle: lifecycle.alias, settled };
52188
52815
  }
52189
52816
  function specField(bound, declared, extras = {}) {
52190
- const multi = declared === void 0 ? false : declared.type === "select" ? declared.multi === true : declared.type === "select_record_link" && declared.cardinality !== "one";
52817
+ const multi = declared !== void 0 && fieldHoldsSeveral(declared);
52191
52818
  const money = bound.money === void 0 ? void 0 : {
52192
52819
  ...bound.money.currency === void 0 ? {} : { currency: bound.money.currency },
52193
52820
  ...bound.money.row === void 0 ? {} : { row: bound.money.row }
@@ -52207,7 +52834,9 @@ function specField(bound, declared, extras = {}) {
52207
52834
  ...bound.zeroWhenEmpty === true ? { zeroWhenEmpty: true } : {},
52208
52835
  ...extras.sign === void 0 ? {} : { sign: extras.sign },
52209
52836
  ...extras.level === void 0 ? {} : { level: extras.level },
52210
- ...extras.until === void 0 ? {} : { until: extras.until }
52837
+ ...extras.due === void 0 ? {} : { due: extras.due },
52838
+ ...extras.until === void 0 ? {} : { until: extras.until },
52839
+ ...extras.counts === void 0 ? {} : { counts: extras.counts }
52211
52840
  };
52212
52841
  }
52213
52842
  function specFields(fields, declared, extras = () => ({})) {
@@ -52225,11 +52854,21 @@ function screenExtras(entry, roles) {
52225
52854
  const declared = new Map(entry.screen.entity.fields.map((field) => [field.alias, field]));
52226
52855
  return (modelAlias) => {
52227
52856
  const field = declared.get(modelAlias);
52228
- const role = roleOf(roles, entry.screen.entity.alias, modelAlias)?.role;
52857
+ const decl = roleOf(roles, entry.screen.entity.alias, modelAlias);
52858
+ const role = decl?.role;
52229
52859
  return {
52230
52860
  ...signOf(entry.screen.entity, roles, modelAlias, over) === void 0 ? {} : { sign: signOf(entry.screen.entity, roles, modelAlias, over) },
52231
52861
  ...role === "measure" && levelOf(entry, roles, modelAlias) !== void 0 ? { level: levelOf(entry, roles, modelAlias) } : {},
52232
- ...field === void 0 || untilOf(entry, roles, field) === void 0 ? {} : { until: untilOf(entry, roles, field) }
52862
+ // A DATE IS A DEADLINE BECAUSE THE MODEL SAID SO. Carried on the column so
52863
+ // every surface drawing the value reads one answer — the register's cell,
52864
+ // the band's countdown, a board's hatching — instead of each deciding it
52865
+ // off the row and tinting a lead's arrival red.
52866
+ ...countsDown(decl) ? { due: true } : {},
52867
+ ...field === void 0 || untilOf(entry, roles, field) === void 0 ? {} : { until: untilOf(entry, roles, field) },
52868
+ // WHAT THE NUMBER COUNTS, where the model said — the unit decides whether
52869
+ // a surface may draw this measure as a LENGTH, and a figure whose unit
52870
+ // never reached the spec is a bar of dollars.
52871
+ ...role === "measure" && decl?.counts !== void 0 ? { counts: decl.counts } : {}
52233
52872
  };
52234
52873
  };
52235
52874
  }
@@ -52256,6 +52895,14 @@ function childExtras(child, roles) {
52256
52895
  }
52257
52896
  function boundAct(entry, templates, act, input) {
52258
52897
  const place = act.place === void 0 ? {} : { place: act.place };
52898
+ if (act.kind === "workflow") {
52899
+ const inputs = act.inputs.flatMap((taken) => {
52900
+ if (taken.field === null) return [{ name: taken.name, field: RECORD_ID_COLUMN }];
52901
+ const bound = entry.fields.get(taken.field.alias);
52902
+ return bound === void 0 ? [] : [{ name: taken.name, field: bound.alias }];
52903
+ });
52904
+ return inputs.length < act.inputs.length ? void 0 : { kind: "workflow", label: act.label, key: act.workflow, workflow: act.workflow, inputs, ...place };
52905
+ }
52259
52906
  if (act.kind === "agent") {
52260
52907
  const fills = act.fills.flatMap((field) => {
52261
52908
  const bound = entry.fields.get(field.alias);
@@ -52292,7 +52939,7 @@ function specActs(entry, templates) {
52292
52939
  });
52293
52940
  const saved = entry.screen.acts.export;
52294
52941
  const paper = saved === null || saved === true ? void 0 : one(saved, recordsParam(entity));
52295
- const columns = saved === true ? drawnColumns(entry) : [];
52942
+ const columns = saved === true ? drawnColumns3(entry) : [];
52296
52943
  const exported = columns.length > 0 ? { kind: "columns", columns } : paper?.kind === "template" ? paper : void 0;
52297
52944
  const taken = specImport(entry);
52298
52945
  if (row.length === 0 && selection.length === 0 && exported === void 0 && taken === void 0) return void 0;
@@ -52303,11 +52950,11 @@ function specActs(entry, templates) {
52303
52950
  ...taken === void 0 ? {} : { import: taken }
52304
52951
  };
52305
52952
  }
52306
- function drawnColumns(entry) {
52953
+ function drawnColumns3(entry) {
52307
52954
  const seen = /* @__PURE__ */ new Set();
52308
- return entry.screen.slots.flatMap((slot2) => {
52309
- if (slot2.field === null) return [];
52310
- const bound = entry.fields.get(slot2.field.alias);
52955
+ const drawn = [...entry.screen.slots.flatMap((slot2) => slot2.field === null ? [] : [slot2.field]), ...entry.screen.columns];
52956
+ return drawn.flatMap((field) => {
52957
+ const bound = entry.fields.get(field.alias);
52311
52958
  if (bound === void 0 || seen.has(bound.alias)) return [];
52312
52959
  seen.add(bound.alias);
52313
52960
  return [{ label: bound.label, field: bound.alias }];
@@ -52318,11 +52965,11 @@ function specImport(entry) {
52318
52965
  if (taken === null) return void 0;
52319
52966
  const key = entry.fields.get(taken.key.alias);
52320
52967
  if (key === void 0) return void 0;
52321
- const named = taken.columns.flatMap((field) => {
52968
+ const named2 = taken.columns.flatMap((field) => {
52322
52969
  const bound = entry.fields.get(field.alias);
52323
52970
  return bound === void 0 ? [] : [specField(bound, field)];
52324
52971
  });
52325
- const columns = named.length > 0 ? named : entry.screen.entity.fields.flatMap((field) => {
52972
+ const columns = named2.length > 0 ? named2 : entry.screen.entity.fields.flatMap((field) => {
52326
52973
  const bound = entry.fields.get(field.alias);
52327
52974
  return bound === void 0 || !stated(entry, field) || !importableField(field) ? [] : [specField(bound, field)];
52328
52975
  });
@@ -52350,7 +52997,7 @@ function specDirections(entry, roles) {
52350
52997
  const outflow = words(true);
52351
52998
  return inflow === "" || outflow === "" ? void 0 : { field: bound.alias, inflow, outflow };
52352
52999
  }
52353
- var LENS_TYPES = ["number", "text", "boolean", "select"];
53000
+ var LENS_TYPES = ["number", "text", "boolean", "select", "date"];
52354
53001
  var LENS_OPERATORS2 = [
52355
53002
  "equals",
52356
53003
  "not_equals",
@@ -52360,6 +53007,11 @@ var LENS_OPERATORS2 = [
52360
53007
  "less_than_or_equal_to",
52361
53008
  "has_any_of",
52362
53009
  "has_none_of",
53010
+ "before",
53011
+ "after",
53012
+ "on_or_before",
53013
+ "on_or_after",
53014
+ "on",
52363
53015
  "is_empty",
52364
53016
  "is_not_empty"
52365
53017
  ];
@@ -52375,6 +53027,10 @@ function lensValue(field, type, value) {
52375
53027
  const ids = value.map((alias) => field.options?.get(alias)?.id);
52376
53028
  return ids.every((id) => id !== void 0) ? { value: ids.filter((id) => id !== void 0) } : void 0;
52377
53029
  }
53030
+ if (type === "date") {
53031
+ const point = lensRelativePoint(value);
53032
+ return point === void 0 ? value == null ? {} : void 0 : { value: point };
53033
+ }
52378
53034
  if (typeof value === "number" || typeof value === "string" || typeof value === "boolean") return { value };
52379
53035
  return {};
52380
53036
  }
@@ -52403,11 +53059,11 @@ function specSummary(entry, roles) {
52403
53059
  const bound = entry.fields.get(field.alias);
52404
53060
  return bound === void 0 ? [] : [bound.alias];
52405
53061
  });
52406
- const named = summary.above === null || summary.above === "counts" ? void 0 : summary.above.flatMap((figure) => {
53062
+ const named2 = summary.above === null || summary.above === "counts" ? void 0 : summary.above.flatMap((figure) => {
52407
53063
  const bound = entry.fields.get(figure.field.alias);
52408
53064
  return bound === void 0 ? [] : [figure.at === void 0 ? bound.alias : { field: bound.alias, at: figure.at }];
52409
53065
  });
52410
- const above = summary.above === "counts" ? "counts" : named === void 0 || named.length === 0 ? void 0 : named;
53066
+ const above = summary.above === "counts" ? "counts" : named2 === void 0 || named2.length === 0 ? void 0 : named2;
52411
53067
  const columnTotals = refs(summary.columnTotals);
52412
53068
  const bandTotals = refs(summary.bandTotals);
52413
53069
  const plan = summary.ageing;
@@ -52415,7 +53071,10 @@ function specSummary(entry, roles) {
52415
53071
  const due = plan === null ? void 0 : entry.fields.get(plan.due.alias);
52416
53072
  const ageing = plan === null || amount === void 0 || due === void 0 ? void 0 : { amount: amount.alias, due: due.alias, buckets: [...plan.buckets] };
52417
53073
  const directions = specDirections(entry, roles);
52418
- if (above === void 0 && columnTotals.length === 0 && bandTotals.length === 0 && ageing === void 0 && directions === void 0) {
53074
+ const moved = summary.trend;
53075
+ const moving = moved === null ? void 0 : entry.fields.get(moved.field.alias);
53076
+ const trend = moved === null || moving === void 0 ? void 0 : { field: moving.alias, direction: moved.direction };
53077
+ if (above === void 0 && columnTotals.length === 0 && bandTotals.length === 0 && ageing === void 0 && directions === void 0 && trend === void 0) {
52419
53078
  return void 0;
52420
53079
  }
52421
53080
  return {
@@ -52423,7 +53082,8 @@ function specSummary(entry, roles) {
52423
53082
  ...columnTotals.length === 0 ? {} : { columnTotals },
52424
53083
  ...bandTotals.length === 0 ? {} : { bandTotals },
52425
53084
  ...ageing === void 0 ? {} : { ageing },
52426
- ...directions === void 0 ? {} : { directions }
53085
+ ...directions === void 0 ? {} : { directions },
53086
+ ...trend === void 0 ? {} : { trend }
52427
53087
  };
52428
53088
  }
52429
53089
  function specScreen(entry, roles, templates, inbound) {
@@ -52438,21 +53098,30 @@ function specScreen(entry, roles, templates, inbound) {
52438
53098
  const bound = ref(other);
52439
53099
  return bound === void 0 ? [] : [bound];
52440
53100
  });
53101
+ const tiers = slot2.tiers.flatMap((under) => {
53102
+ const bound = ref(under);
53103
+ return bound === void 0 ? [] : [bound];
53104
+ });
52441
53105
  return [
52442
53106
  {
52443
53107
  name: slot2.name,
52444
53108
  role: slot2.role,
52445
53109
  field,
52446
53110
  ...also.length === 0 ? {} : { also },
53111
+ ...tiers.length === 0 ? {} : { tiers },
52447
53112
  ...slot2.quick === void 0 ? {} : { quick: slot2.quick },
52448
53113
  ...slot2.order === void 0 ? {} : { order: slot2.order }
52449
53114
  }
52450
53115
  ];
52451
53116
  });
53117
+ const columns = screen.columns.flatMap((field) => {
53118
+ const bound = ref(field);
53119
+ return bound === void 0 ? [] : [bound];
53120
+ });
52452
53121
  const outcomes = {};
52453
- for (const [fieldAlias, named] of Object.entries(screen.outcomes)) {
53122
+ for (const [fieldAlias, named2] of Object.entries(screen.outcomes)) {
52454
53123
  const bound = entry.fields.get(fieldAlias);
52455
- const ids = optionIds(bound, named);
53124
+ const ids = optionIds(bound, named2);
52456
53125
  if (bound !== void 0 && ids.length > 0) outcomes[bound.alias] = ids;
52457
53126
  }
52458
53127
  const lenses = screen.filters.flatMap((lens) => specLens(entry, lens));
@@ -52469,8 +53138,10 @@ function specScreen(entry, roles, templates, inbound) {
52469
53138
  ];
52470
53139
  const obligations = screen.obligations.flatMap((owed) => {
52471
53140
  const due = ref(owed.field);
52472
- const satisfiedBy = ref(owed.satisfiedBy);
52473
- return due === void 0 || satisfiedBy === void 0 ? [] : [{ label: owed.label, due, satisfiedBy }];
53141
+ if (due === void 0) return [];
53142
+ const satisfiedBy = owed.satisfiedBy === void 0 ? void 0 : ref(owed.satisfiedBy);
53143
+ if (satisfiedBy !== void 0) return [{ label: owed.label, due, satisfiedBy }];
53144
+ return owed.satisfiedBy === void 0 && untilOf(entry, roles, owed.field) !== void 0 ? [{ label: owed.label, due }] : [];
52474
53145
  });
52475
53146
  const runs = entry.day === void 0 || entry.runSlot === void 0 ? void 0 : { day: entry.day.alias, slot: entry.runSlot.alias };
52476
53147
  const acts = specActs(entry, templates);
@@ -52481,6 +53152,9 @@ function specScreen(entry, roles, templates, inbound) {
52481
53152
  alias,
52482
53153
  label: screen.screen.label,
52483
53154
  entity,
53155
+ // THE ENTITY'S OWN NOUN, for the column a shape heads with a count — the
53156
+ // entity label names the SET, which is what a count is of.
53157
+ rows: screen.entity.label,
52484
53158
  table: entry.table.id,
52485
53159
  shape: screen.screen.shape,
52486
53160
  record: screen.record,
@@ -52488,6 +53162,7 @@ function specScreen(entry, roles, templates, inbound) {
52488
53162
  writes: screen.screen.writes !== false,
52489
53163
  fields: specFields(entry.fields, screen.entity.fields, screenExtras(entry, roles)),
52490
53164
  slots,
53165
+ ...columns.length === 0 ? {} : { columns },
52491
53166
  ...Object.keys(outcomes).length === 0 ? {} : { outcomes },
52492
53167
  ...tabs === void 0 ? {} : { tabs },
52493
53168
  ...lenses.length === 0 ? {} : { lenses },
@@ -52505,11 +53180,13 @@ function specScreen(entry, roles, templates, inbound) {
52505
53180
  function specPresentation(entry) {
52506
53181
  const stated2 = entry.screen.screen.presentation;
52507
53182
  if (stated2 === void 0 || Object.values(stated2).every((clause) => clause === void 0)) return {};
53183
+ const until = stated2.until === void 0 ? void 0 : entry.fields.get(stated2.until)?.alias;
52508
53184
  return {
52509
53185
  presentation: {
52510
53186
  ...stated2.lead === void 0 ? {} : { lead: stated2.lead },
52511
53187
  ...stated2.density === void 0 ? {} : { density: stated2.density },
52512
- ...stated2.layout === void 0 ? {} : { layout: stated2.layout }
53188
+ ...stated2.layout === void 0 ? {} : { layout: stated2.layout },
53189
+ ...until === void 0 ? {} : { until }
52513
53190
  }
52514
53191
  };
52515
53192
  }
@@ -52563,7 +53240,8 @@ function specRequiredBy(entry, field, requiredBy) {
52563
53240
  if (entries2.length > 0) options[key] = entries2;
52564
53241
  }
52565
53242
  if (Object.keys(options).length === 0) return void 0;
52566
- return requiredBy.reads === "answer" ? { reads: "answer", field: by.alias, options } : { reads: "option", field: by.alias, options };
53243
+ const from = requiredBy.from === "parent" ? { from: "parent" } : {};
53244
+ return requiredBy.reads === "answer" ? { reads: "answer", field: by.alias, ...from, options } : { reads: "option", field: by.alias, ...from, options };
52567
53245
  }
52568
53246
  function specChild(child, roles, archetype, slot2, entry, expected) {
52569
53247
  const reads = childReads(child);
@@ -52585,6 +53263,7 @@ function specChild(child, roles, archetype, slot2, entry, expected) {
52585
53263
  lifecycle: ref(plan.lifecycle) ?? "",
52586
53264
  ...ref(plan.slot) === void 0 ? {} : { slot: ref(plan.slot) ?? "" },
52587
53265
  ...ref(plan.kind) === void 0 ? {} : { kind: ref(plan.kind) ?? "" },
53266
+ ...ref(plan.mark) === void 0 ? {} : { mark: ref(plan.mark) ?? "" },
52588
53267
  ...ref(plan.reference) === void 0 ? {} : { reference: ref(plan.reference) ?? "" },
52589
53268
  ...ref(plan.span) === void 0 ? {} : { span: ref(plan.span) ?? "" },
52590
53269
  ...itineraryTotal(entry, child) === void 0 ? {} : { total: itineraryTotal(entry, child) }
@@ -52614,6 +53293,14 @@ function specChild(child, roles, archetype, slot2, entry, expected) {
52614
53293
  ...written === void 0 ? {} : { write: { alias: writeAlias(written), param: recordParam(written) } }
52615
53294
  };
52616
53295
  }
53296
+ function specWorksheet(sheet) {
53297
+ return {
53298
+ ...sheet.quantity === void 0 ? {} : { quantity: sheet.quantity.bound.alias },
53299
+ ...sheet.cost === void 0 ? {} : { cost: sheet.cost.bound.alias },
53300
+ ...sheet.sell === void 0 ? {} : { sell: sheet.sell.bound.alias },
53301
+ ...sheet.group === void 0 ? {} : { group: sheet.group.bound.alias }
53302
+ };
53303
+ }
52617
53304
  function itineraryTotal(entry, child) {
52618
53305
  const amountAlias = [...child.fields.keys()].find((alias) => child.roles.get(alias) === "amount");
52619
53306
  const amount = amountAlias === void 0 ? void 0 : child.fields.get(amountAlias);
@@ -52720,11 +53407,14 @@ function specRecord(entry, roles, templates) {
52720
53407
  }
52721
53408
  const child = entry.children.get(childKey(section.child.alias, section.link.alias));
52722
53409
  if (child === void 0 || child.set === void 0) break;
53410
+ const setField = child.fields.get(child.set);
53411
+ const deskRequiredBy = setField === void 0 ? void 0 : specRequiredBy(entry, setField, section.requiredBy);
52723
53412
  sections.push({
52724
53413
  kind: "desk",
52725
53414
  key: sectionKey(child.entity.alias, child.linkAlias),
52726
53415
  heading: child.heading,
52727
53416
  child: specChild(child, roles, archetype, slot2, entry, void 0),
53417
+ ...deskRequiredBy === void 0 ? {} : { requiredBy: deskRequiredBy },
52728
53418
  ...stands
52729
53419
  });
52730
53420
  break;
@@ -52744,12 +53434,19 @@ function specRecord(entry, roles, templates) {
52744
53434
  break;
52745
53435
  }
52746
53436
  const ledger = slot2 === "ledger" && section.via === "parent";
53437
+ const sheet = planWorksheets(entry).find((one) => one.child === child);
53438
+ const key = sectionKey(child.entity.alias, child.linkAlias);
53439
+ const bound = specChild(child, roles, archetype, slot2, entry, section.expected);
53440
+ if (sheet !== void 0) {
53441
+ sections.push({ kind: "children", key, heading: child.heading, draw: "worksheet", child: { ...bound, worksheet: specWorksheet(sheet) }, ...stands });
53442
+ break;
53443
+ }
52747
53444
  sections.push({
52748
53445
  kind: "children",
52749
- key: sectionKey(child.entity.alias, child.linkAlias),
53446
+ key,
52750
53447
  heading: child.heading,
52751
53448
  draw: ledger ? "ledger" : child.itinerary === void 0 ? "register" : "itinerary",
52752
- child: specChild(child, roles, archetype, slot2, entry, section.expected),
53449
+ child: bound,
52753
53450
  ...stands
52754
53451
  });
52755
53452
  break;
@@ -52985,6 +53682,12 @@ function checkSurface(at2, alias, table, fields, manifest) {
52985
53682
  return findings;
52986
53683
  }
52987
53684
  var paramsOf = (manifest, alias) => new Set(Object.keys(manifest.queries?.[alias]?.params ?? {}));
53685
+ function itineraryReads(plan) {
53686
+ if (plan === void 0) return [];
53687
+ return [plan.when, plan.lifecycle, plan.slot, plan.kind, plan.mark, plan.reference, plan.span].filter(
53688
+ (alias) => alias !== void 0
53689
+ );
53690
+ }
52988
53691
  function checkRecord(spec, key, record2, manifest) {
52989
53692
  const at2 = `records.${key}`;
52990
53693
  const findings = [];
@@ -53021,13 +53724,17 @@ function checkRecord(spec, key, record2, manifest) {
53021
53724
  if (manifest.queries?.[child.query] !== void 0 && !paramsOf(manifest, child.query).has(child.param)) {
53022
53725
  findings.push({ at: childAt, message: `filters on "${child.param}", which "${child.query}" declares no param for` });
53023
53726
  }
53024
- const reads = section.kind === "timeline" ? [section.planned, section.actual] : section.kind === "thread" ? [
53025
- section.body,
53026
- section.author,
53027
- section.when,
53028
- ...section.official === void 0 ? [] : [section.official],
53029
- ...section.awaiting === void 0 ? [] : [section.awaiting]
53030
- ] : [];
53727
+ const reads = [
53728
+ ...section.kind === "timeline" ? [section.planned, section.actual] : [],
53729
+ ...section.kind === "thread" ? [
53730
+ section.body,
53731
+ section.author,
53732
+ section.when,
53733
+ ...section.official === void 0 ? [] : [section.official],
53734
+ ...section.awaiting === void 0 ? [] : [section.awaiting]
53735
+ ] : [],
53736
+ ...itineraryReads(child.itinerary)
53737
+ ];
53031
53738
  for (const alias of reads) {
53032
53739
  if (!Object.hasOwn(child.fields, alias)) {
53033
53740
  findings.push({ at: childAt, message: `reads "${alias}", which "${child.query}" does not project` });
@@ -53036,6 +53743,12 @@ function checkRecord(spec, key, record2, manifest) {
53036
53743
  if (section.kind === "thread" && section.clock !== void 0 && !Object.hasOwn(record2.fields, section.clock)) {
53037
53744
  findings.push({ at: childAt, message: `counts down to "${section.clock}", which this record does not project` });
53038
53745
  }
53746
+ const priced = section.kind === "children" && section.draw === "worksheet" ? section.child.worksheet : {};
53747
+ for (const alias of Object.values(priced)) {
53748
+ if (!Object.hasOwn(child.fields, alias)) {
53749
+ findings.push({ at: childAt, message: `prices "${alias}", which "${child.query}" does not project` });
53750
+ }
53751
+ }
53039
53752
  if (child.write !== void 0 && manifest.workflows?.[child.write.alias] === void 0) {
53040
53753
  findings.push({ at: childAt, message: `changes its rows through "${child.write.alias}", which package.json#lotics.workflows does not declare` });
53041
53754
  }
@@ -53089,6 +53802,17 @@ function checkAct(at2, act, fields, manifest, surface) {
53089
53802
  if (declared === void 0) {
53090
53803
  return [{ at: at2, message: `an act runs "${act.workflow}", which package.json#lotics.workflows does not declare` }];
53091
53804
  }
53805
+ if (act.kind === "workflow") {
53806
+ for (const taken of act.inputs) {
53807
+ if (!Object.hasOwn(declared.inputs ?? {}, taken.name)) {
53808
+ findings.push({ at: at2, message: `an act sends "${taken.name}" to "${act.workflow}", which declares no such input` });
53809
+ }
53810
+ if (taken.field !== RECORD_ID_COLUMN && !Object.hasOwn(fields, taken.field)) {
53811
+ findings.push({ at: at2, message: `the "${act.label}" act sends "${taken.field}", which this ${surface} does not project` });
53812
+ }
53813
+ }
53814
+ return findings;
53815
+ }
53092
53816
  if (!Object.hasOwn(declared.inputs ?? {}, act.input)) {
53093
53817
  findings.push({ at: at2, message: `an act sends "${act.input}" to "${act.workflow}", which declares no such input` });
53094
53818
  }
@@ -53114,11 +53838,19 @@ function checkAppSpec(spec, manifest, components) {
53114
53838
  const refused = leadRefusal(screen.shape, bound, lead);
53115
53839
  if (refused !== void 0) findings.push({ at: at2, message: refused });
53116
53840
  }
53841
+ const drawnColumns4 = screen.slots.map((slot2) => {
53842
+ const counts = screen.fields[slot2.field]?.counts;
53843
+ return { role: slot2.role, ...counts === void 0 ? {} : { counts } };
53844
+ });
53117
53845
  const layout = screen.presentation?.layout;
53118
53846
  if (layout !== void 0) {
53119
- const refused = layoutRefusal(bound, layout);
53847
+ const refused = layoutRefusal(drawnColumns4, layout);
53120
53848
  if (refused !== void 0) findings.push({ at: at2, message: refused });
53121
53849
  }
53850
+ const until = screen.presentation?.until;
53851
+ if (until !== void 0 && screen.fields[until] === void 0) {
53852
+ findings.push({ at: at2, message: `ends its bars at "${until}", which "${screen.query}" does not project` });
53853
+ }
53122
53854
  const scope = spec.scope;
53123
53855
  if (scope !== void 0) {
53124
53856
  findings.push(
@@ -53156,6 +53888,19 @@ function checkAppSpec(spec, manifest, components) {
53156
53888
  const refused = quickRefusal(slot2.role, slot2.order);
53157
53889
  if (refused !== void 0) findings.push({ at: at2, message: `the "${slot2.name}" slot ${refused}` });
53158
53890
  }
53891
+ const columns = screen.columns ?? [];
53892
+ if (columns.length > 0 && !shapeDrawsColumns(screen.shape)) {
53893
+ findings.push({
53894
+ at: at2,
53895
+ message: `names columns, and a "${screen.shape}" does not stand this entity's records in a table \u2014 the shapes that draw columns are ${shapesDrawingColumns().join(", ")}`
53896
+ });
53897
+ } else if (columns.length > 0 && layout !== void 0 && layout !== "table") {
53898
+ findings.push({ at: at2, message: `names columns beside \`layout: "${layout}"\`, which draws the rows with a device of its own instead of the register's columns` });
53899
+ }
53900
+ for (const alias of columns) {
53901
+ if (Object.hasOwn(screen.fields, alias)) continue;
53902
+ findings.push({ at: at2, message: `draws the column "${alias}", which this register does not project` });
53903
+ }
53159
53904
  for (const lens of screen.lenses ?? []) {
53160
53905
  if (typeof lens === "string") continue;
53161
53906
  for (const predicate of lens.predicates) {
@@ -53175,6 +53920,15 @@ function checkAppSpec(spec, manifest, components) {
53175
53920
  findings.push({ at: at2, message: `reads "${figure.field}" at the window's end, and this screen states no period to be the end of` });
53176
53921
  }
53177
53922
  }
53923
+ const moved = screen.summary?.trend;
53924
+ if (moved !== void 0) {
53925
+ if (screen.period === void 0) {
53926
+ findings.push({ at: at2, message: `reads "${moved.field}" across the window, and this screen states no period to be the window` });
53927
+ }
53928
+ if (!Object.hasOwn(screen.fields, moved.field)) {
53929
+ findings.push({ at: at2, message: `reads "${moved.field}" across the window, which this register does not project` });
53930
+ }
53931
+ }
53178
53932
  const revealed = expandRefusal(screen.shape, screen.record);
53179
53933
  if (revealed !== void 0) findings.push({ at: at2, message: revealed });
53180
53934
  const arranged = revealedLayoutRefusal(screen.record, screen.presentation?.layout);
@@ -53295,8 +54049,8 @@ function componentsIn(projectDir) {
53295
54049
  }
53296
54050
  for (const block of text.matchAll(/\bexport\s*\{([^}]*)\}/g)) {
53297
54051
  for (const part of block[1].split(",")) {
53298
- const named = /(?:\bas\s+)?([A-Za-z_$][\w$]*)\s*$/.exec(part.trim());
53299
- if (named !== null) names.add(named[1]);
54052
+ const named2 = /(?:\bas\s+)?([A-Za-z_$][\w$]*)\s*$/.exec(part.trim());
54053
+ if (named2 !== null) names.add(named2[1]);
53300
54054
  }
53301
54055
  }
53302
54056
  }
@@ -54408,7 +55162,7 @@ Captured ${totalRows} row${totalRows === 1 ? "" : "s"} across ${result.captured.
54408
55162
  }
54409
55163
 
54410
55164
  // src/model_reference.md
54411
- var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing the tables, fields, options, views, roles and first rows\na workspace starts with. `lotics scaffold check model.json` proves it offline \u2014\nno account, no network. `lotics setup model.json --email you@company.com` creates\nthe account and applies it. `lotics scaffold apply model.json` applies it again,\ninto the workspace the credential names.\n\n**There are two forms of this file.** The full one, below, spells the model out.\nThe `from` one names a published preset and carries only what this business\ndiffers by \u2014 see \xA7 Starting from a preset, and prefer it whenever a preset fits\nthe trade.\n\nApps are PLANNED here and built afterwards: `apps` names each app\'s screens as a\nshape over an entity, checked against the roles `field_roles` gives its fields,\nso the plan is refused before anyone builds a screen (\xA7 Apps and screens). The\nbuilt app lives in the workspace; publishing that workspace as a package is how\nit ships.\n\n## The rules\n\n- **At least one entity, at most 50.** More tables than that is a data model\n being designed, not scaffolded \u2014 scaffold the rest in a second call.\n- **Adoption is explicit.** `lotics setup` REFUSES an entity whose `label`\n already names a table in the workspace, naming every colliding label at once.\n `lotics scaffold apply` adopts those tables and adds the fields, options and\n views they are missing. Nothing is ever modified or deleted, so applying the\n same model twice creates nothing the second time.\n- **Adoption is by LABEL, not alias.** Change an entity\'s `label` and the next\n run asks for a NEW table beside the old one. 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, so a model that is about to be published\n as a preset carries the labels in use rather than the ones it was written with.\n- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere, and the result says\n `rows_skipped: true`: sample rows landing among a customer\'s real ones cannot\n be told apart from them.\n- **After the first run the WORKSPACE is the source of truth.** The file is an\n authoring input, not a mirror \u2014 scaffold never deletes what the file stopped\n naming.\n- **`lotics scaffold check` decides all of it offline**, and reports every\n problem in one run rather than the first: an alias that resolves to nothing, a\n link whose pair is not symmetric, and the rows themselves \u2014 a field the entity\n does not declare, an option alias the field does not declare, a link naming no\n row in the file, a `ref` used twice, a date that is not one, a value on a\n platform-computed field, and a files cell that is neither a relative path\n beside this file nor a `fil_` id.\n\n## Top level\n\n```jsonc\n{\n "entities": [ /* the tables */ ],\n "roles": [ /* workspace groups to create */ ], // optional\n "templates":[ /* inline html / email templates */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* the reporting role each field plays, keyed by entity then field */ }, // optional\n "write_rules": { /* what a CREATE finds, defaults and refuses, keyed by entity */ }, // optional\n "apps": [ /* the screens each app will have, as shapes over entities */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ], // optional\n "preset": { /* a trade\'s branches, for a PUBLISHED model */ } // optional\n}\n```\n\nThe other form names a preset instead of restating one:\n\n```jsonc\n{\n "from": "field_service", // the preset this model starts from, by slug\n "variants": ["crews"], // optional \u2014 its branches to merge in, in order\n "rename": { // optional \u2014 what THIS business calls each table\n "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } }\n },\n "entities": [ /* tables the preset does not declare */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* roles on the preset\'s fields and this business\'s own */ }, // optional\n "write_rules": { /* create-time clauses on the preset\'s entities and its own */ }, // optional\n "apps": [ /* the screens each app will have */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ] // optional\n}\n```\n\n**A model may not carry** `fixtures`, `knowledge` or `knowledge_expects`, and no\n`excel` / `word` / `pdf-form` template: each of those is content that lives in a\npublished bundle, which a model has none of. `apps` here is a plan of screens,\nnever built code. An unknown top-level key is an error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because scaffold matches by label.\n\n## Entity\n\n```jsonc\n{\n "alias": "order",\n "label": "Orders", // the table\'s name, which names the SET it holds\n "singular": "Order", // optional \u2014 ONE of them, for the act that opens one\n "description": "\u2026", // optional\n "fields": [ /* at least one */ ],\n "views": [ /* optional; an entity with none still gets the default grid */ ],\n "read_scope": { /* optional; absent, everyone with access to the table reads every row */\n "any": [\n { "member_of": "sales" }, // a role alias this model declares\n { "field": "scope", "is": ["shared"] } // a single select on this entity, by option alias\n ]\n }\n}\n```\n\n**`singular` is what a create says.** A register\'s own act and the panel it\nopens name the row being made \u2014 `New Order`, `H\u1ED3 s\u01A1 m\u1EDBi` \u2014 while `label` names\nthe table, so without it the button reads `New Orders`. Nothing derives it\n(English plurals are irregular), and most models need none: a Vietnamese noun is\nthe same word either way. `scaffold check` prints it beside the table under *Who\nwrites what* and NOTES a table that will name the set. An act on a child section\nkeeps the label: it adds a line to the register under the record the reader is\nalready standing in.\n\n**`read_scope` is a ROW rule, enforced by the platform.** A row is readable when\nANY clause holds: the reader is in that role\'s group, or the named select carries\none of those options. It is resolved at scaffold into the table\'s own row\nfilters, so an app, a workflow reading for a viewer, and the API all answer the\nsame rows \u2014 a per-record visibility field the app merely honours is a convention,\nnot a gate. `any` may not be empty (a rule nobody satisfies hides the table), and\nevery role alias and option alias in it must be one this model declares.\n\n## Field\n\nEvery field carries `alias`, `label`, an optional `description`, and an optional\n`required` \u2014 advisory only, read by app forms and workflows; the table itself has\nno required constraint. `label` may not contain `{` or `}` (formulas reference\nfields by label at the platform level).\n\n`default` is the value pre-filled into a NEW record. It applies on create only;\nexisting records are never backfilled. Only the types listed below accept one.\n\n### `text`\n\n```jsonc\n{ "alias": "name", "label": "Name", "type": "text",\n "unique": false, // optional \u2014 require distinct values\n "format": "text", // optional \u2014 "text" | "link" | "markdown"\n "default": "" } // optional\n```\n\n### `number`\n\n```jsonc\n{ "alias": "amount", "label": "Amount", "type": "number",\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage"\n "currency": "VND", // optional \u2014 ISO 4217\n "default": 0 } // optional\n```\n\n`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 THERE IS\nNO LEVEL AT ALL**: a level is a reading AGAINST a bound, so a row that states no\nbound draws nothing \u2014 cell, fact and all \u2014 rather than a numerator whose whole\nmeaning was the comparison. "Collected 0" beside a blank total reads as money\nagainst a job worth nothing. 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```jsonc\n{ "alias": "placed_on", "label": "Placed on", "type": "date",\n "format": "date", // optional \u2014 "date" | "datetime" | "date_range" | "datetime_range"\n "timezone": "Asia/Ho_Chi_Minh", // optional \u2014 IANA name\n "derive_from": "created_at", // optional \u2014 "created_at" | "updated_at"; makes the field read-only\n "default": "2026-01-01" } // optional; refused together with derive_from\n```\n\n### `boolean`\n\n```jsonc\n{ "alias": "paid", "label": "Paid", "type": "boolean", "default": false }\n```\n\n### `select`\n\n```jsonc\n{ "alias": "tier", "label": "Tier", "type": "select",\n "options": [ // at least one\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "multi": false, // optional\n "default": ["standard"] } // optional \u2014 option ALIASES; one unless multi\n```\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet.\n\n```jsonc\n{ "alias": "owner", "label": "Owner", "type": "select_member", "multi": false }\n```\n\n### `select_record_link`\n\n```jsonc\n{ "alias": "customer", "label": "Customer", "type": "select_record_link",\n "target_entity": "customer", // an entity alias this model declares\n "cardinality": "one", // optional \u2014 "one" | "many" (default "many")\n "sync_both_ways": true, // optional \u2014 keep a paired field on the target\n "paired_field_alias": "orders", // the partner field ON THE TARGET entity\n "display_field_aliases": ["name"] } // optional \u2014 what the link shows / the picker\'s columns\n```\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused.\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\n```jsonc\n{ "alias": "attachments", "label": "Attachments", "type": "files" }\n```\n\n### `formula`\n\n```jsonc\n{ "alias": "total", "label": "Total", "type": "formula",\n "formula": {\n "expression": "{amount} * 1.1", // fields on THIS entity, by alias, in braces\n "output_type": "number", // optional \u2014 what it YIELDS: "number" | "text" | "date" | "datetime" | "boolean"\n "format": "currency", // optional \u2014 how that result is DRAWN: "number" | "currency" | "percentage" | "link"\n "currency": "VND" // optional\n } }\n```\n\n`output_type` is what the expression answers WITH; `format` is how it is printed.\nThe platform infers the result at write time and ignores what you declare, so\nthis is a statement the offline checks read \u2014 which is what lets a role or a\nscreen clause accept a computed value: a caption over a derived name\n(`output_type: "text"`), a period over a settled date (`"date"`). A formula\ndeclaring neither says nothing about its result, and every rule that needs one\nrefuses it by name. `lotics scaffold export` writes the result a live workspace\ncomputed, so exporting a workspace fills these in.\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n```jsonc\n{ "alias": "total_ordered", "label": "Total ordered", "type": "rollup",\n "source_field_alias": "orders", // a select_record_link field on THIS entity\n "aggregate_option": {\n "operation": "sum", // count | sum | avg | median | min | max | range |\n // empty | filled | percent_empty | percent_filled |\n // unique | percent_unique |\n // earliest | latest | date_range |\n // checked | unchecked | percent_checked |\n // percent_unchecked\n "field_key": "amount" // a field ALIAS on the linked entity ("count" may omit it)\n },\n "filter": { /* optional \u2014 see Views; every field_key is an alias on the LINKED entity */ } }\n```\n\nThe operation must be one the aggregated field\'s type allows \u2014 `sum` over a\nnumber, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n```jsonc\n{ "alias": "customer_tier", "label": "Customer tier", "type": "lookup",\n "source_field_alias": "customer", // a select_record_link field on THIS entity\n "lookup_field_alias": "tier", // a field alias on the linked entity\n "order_by": { "field_key": "placed_on", "direction": "desc" } } // optional \u2014 pick the single extreme row\n```\n\n### `autonumber`\n\n```jsonc\n{ "alias": "seq", "label": "No.", "type": "autonumber",\n "prefix": "SO-", // optional \u2014 ignored when template is set\n "padding": 4, // optional \u2014 1..20, zero-pads the integer\n "template": "SO-{YEAR}-{N:4}" } // optional \u2014 {N}, {N:W}, {YEAR}, {YEAR:2}, {MONTH}, {DAY}\n```\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n```jsonc\n{\n "alias": "gold",\n "label": "Gold customers",\n "description": "\u2026", // optional\n "columns": [ // optional \u2014 omit to show every field\n { "field_alias": "name", "visibility": "visible", "width": 240 },\n { "field_alias": "tier", "visibility": "hidden" }\n ],\n "filters": { // optional\n "node_type": "group",\n "logic": "and", // "and" | "or"\n "children": [\n { "node_type": "condition", "type": "select", "field_key": "tier",\n "operator": "has_any_of", "value": ["gold"] }\n ]\n },\n "sort": [ { "field_key": "name", "order": "asc" } ], // optional; order is "asc" | "desc" | null\n "summary": { "amount": "sum" }, // optional \u2014 field alias \u2192 footer operation\n "frozen_columns": 1 // optional\n}\n```\n\nA condition\'s `type` is the field\'s type and its `operator` is one that type\nadmits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n`columns`, when present, is exhaustive and must not be empty: a view renders\nexactly the entries it holds. Omit the key to show every field.\n\n## Roles\n\nA role becomes a workspace group. Members are added afterwards, in the app.\n\n```jsonc\n{ "alias": "sales", "label": "Sales" }\n```\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes. An `html` template renders to a PDF when a workflow generates\nfrom it; `{{name}}` is filled from the workflow\'s data.\n\n```jsonc\n{ "alias": "order_ack", "label": "Order acknowledgement", "type": "email",\n "content": "<p>Hello {{customer}}\u2026</p>" }\n```\n\nA paper that has to look like a counterparty produced it \u2014 an official letter,\nan acceptance minute, a supplier\'s bill \u2014 is the same `html` template with a\nshell around the body: a letterhead, a reference line, a seal and a signature\nblock, and paper grain over everything. One shell, many bodies; the data is the\nonly thing that changes, so a workflow can re-issue it over any record.\n\n```jsonc\n{ "alias": "cong_van", "label": "C\xF4ng v\u0103n", "type": "html",\n "content": "\u2026the page below, as one JSON string\u2026" }\n```\n\n```html\n<style>\n .sheet{position:relative;width:718px;padding:44px 58px 30px;background:#fbfaf6;color:#111;font:14.2px/1.5 \'Liberation Serif\',serif}\n .grain{position:absolute;inset:0;opacity:.34;mix-blend-mode:multiply;background:url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'140\' height=\'140\'><filter id=\'f\'><feTurbulence baseFrequency=\'.9\' numOctaves=\'2\'/><feColorMatrix values=\'0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0\'/></filter><rect width=\'140\' height=\'140\' filter=\'url(%23f)\'/></svg>")}\n .top{display:flex;text-align:center;font-size:13.4px} .top>div{flex:1} .u{display:inline-block;border-bottom:1px solid #111;font-weight:700}\n .ref{display:flex;text-align:center;font-size:13.4px;margin-top:6px} .ref>div{flex:1} .ref .r{font-style:italic}\n h1{text-align:center;font-size:15.6px;margin:26px 0 18px} p{text-align:justify;text-indent:26px;margin:0 0 9px}\n .sig{display:flex;margin-top:20px} .sig .l{flex:1} .sig .r{width:290px;text-align:center;position:relative}\n .sig .nm{font-weight:700;margin-top:96px} .seal{position:absolute;left:4px;top:8px;width:166px;height:166px;opacity:.66;mix-blend-mode:multiply;transform:rotate(-17deg)}\n </style>\n <div class=\'sheet\'><div class=\'grain\'></div>\n <div class=\'top\'><div><b>{{issuer_parent}}</b><br><span class=\'u\'>{{issuer}}</span></div>\n <div><b>C\u1ED8NG H\xD2A X\xC3 H\u1ED8I CH\u1EE6 NGH\u0128A VI\u1EC6T NAM</b><br><span class=\'u\'>\u0110\u1ED9c l\u1EADp - T\u1EF1 do - H\u1EA1nh ph\xFAc</span></div></div>\n <div class=\'ref\'><div>S\u1ED1: {{number}}</div><div class=\'r\'>{{place}}, ng\xE0y {{day}} th\xE1ng {{month}} n\u0103m {{year}}</div></div>\n <h1>{{title}}</h1>\n <p>K\xEDnh g\u1EEDi: {{recipient}}.</p>\n {{{body}}}\n <div class=\'sig\'><div class=\'l\'><b>N\u01A1i nh\u1EADn:</b><br>- Nh\u01B0 tr\xEAn;<br>- L\u01B0u VT.</div>\n <div class=\'r\'><img class=\'seal\' src=\'{{seal_url}}\'><b>{{signer_title}}</b><div class=\'nm\'>{{signer}}</div></div></div>\n </div>\n```\n\n`lotics preview <file.html>` renders any such page to a PNG the way a demo\'s\nprops are made, sized to its content, so a paper can be looked at before it is\nput in a template.\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model, attaching at most 2000 documents between them \u2014 a real data set\nbelongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n`ref` is a local handle (lowercase letters, digits, underscores) that other rows\'\nlink fields address. It is never persisted.\n\nA `files` cell attaches documents: paths relative to this file (no `..`, never\nabsolute), which `check` proves exist and `apply` uploads into the workspace\nbefore any row is written \u2014 a paperwork business seeds its papers with its\nrows. The server accepts only `fil_` ids of files this workspace owns, which is\nwhat the upload leaves behind. After a run that wrote rows, `apply` writes the\nrecord ids beside the file (`<model>.last_run.json`): `delete_records` over\nthem is how a seeded set is reset, and applying again re-dates it.\n\n`fields` is keyed by field alias, and every value is read against the field\'s\nDECLARED type:\n\n| Field type | Value |\n|---|---|\n| `text` / `number` / `boolean` | the value itself |\n| `date` | `"2026-03-14"`, or a relative expression (below) |\n| `select` | the option ALIAS \u2014 `"gold"`, or `["gold","vip"]` for a multi-select |\n| `select_record_link` | `"<entity-alias>:<ref>"` naming another row in this file \u2014 `"customer:acme"`, or an array for several |\n| `select_member` | `"self"` only \u2014 the person applying the model |\n| `files` | paths beside this file \u2014 `["scans/pccc_letter.png"]` \u2014 uploaded by `apply`/`setup` before the rows are posted; or `fil_` ids of files already in this workspace |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n**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, joined through the\n`<model>.last_run.json` beside the file. Running it twice attaches nothing the\nsecond time \u2014 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\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Field roles\n\n`field_roles` names the reporting role a field plays on its entity \u2014 keyed by\nentity alias, then field alias \u2014 so every screen over the entity agrees on\nwhich column names the row and which select is the stage. A shape\'s slot binds\nto it (\xA7 Apps and screens). Like `rows` and `apps`, it is this file\'s: `check`\nproves it and the workspace never sees it. Each role sits on the types that can\nanswer it:\n\n| Role | On | Meaning |\n|---|---|---|\n| `identity` | `text`, `autonumber`, `select_record_link`, `formula` | names the row \u2014 the register\'s first column; a link where the row is "the product, at this branch"; a formula where the name is computed, and then it may not be `format`ted as a figure. One per entity |\n| `reference` | `text`, `autonumber`, `formula` | the key the SYSTEM files the row under \u2014 a booking number, a container code \u2014 drawn as the supporting line under the name rather than as a column of its own. One per entity |\n| `mark` | `files`, or a `lookup`/`rollup` that resolves to one | the row\'s picture \u2014 its own, or the linked record\'s. One per entity |\n| `lifecycle` | single `select` | the ordered stages a row walks; option order is the order. Which of them END the flow is `outcomes`. One per entity |\n| `category` | single `select` | what the row IS \u2014 a kind, a service, a book. One badge in the field\'s own colour, with no ladder behind it and no flow to advance. Repeats: a row can be of two kinds of thing |\n| `measure` | `number`, `formula`, `rollup` | a level read against a limit \u2014 see `against` and `alert`. Money where the model says so, and then it prints as money |\n| `expected_set` | `select` | its OPTIONS are the required set (documents, checks, services); an option no row has is a gap to show, not nothing. A multi-select draws as a ring and a fraction wherever it is drawn \u2014 absence is the information, and a list of what IS there says nothing about what is missing |\n| `amount` | `number`, `formula`, `rollup` | THE money of a ledger row; `signed_by` says which way it moved, `against` names the reference it is quoted off. One per entity |\n| `when` | `date`, or a `formula`/`rollup`/`lookup` that yields one | the ledger or timeline date \u2014 stated, or derived: the day money MOVED is a formula over the two columns that could hold it. `until` names the stage of this entity\'s own `lifecycle` at which its COUNTDOWN is spent. One per entity |\n| `slot` | single `select`, or a `datetime` `date` | where in the DAY a row sits \u2014 the run it is read in under its day\'s heading on an itinerary. A select\'s option order IS the run\'s order; a time is ordered by the clock. A plain `date` is refused: that is the day itself. One per entity |\n| `party` | `select_record_link` | the counterparty. One per entity |\n| `parent` | `select_record_link` | the record this row belongs to \u2014 a line\'s order, a paper\'s case. The parent\'s record shows these rows; the row shows the parent as a fact. One per entity, and it links to an entity this model declares |\n| `contact` | `text` | a way to reach a party \u2014 an address, a number, the town it is in. The record draws them as ONE row of links under the name, so the role sits on every field that is one of them. Repeats |\n| `currency` | single `select` whose option LABELS are ISO 4217 codes | the money THIS ROW\'s figures are in. Every amount and every money measure on the entity is then printed per row rather than in one code for the whole column, and the select is not drawn as a fact of its own. One per entity |\n| `verdict` | `boolean`, `formula` | a settled pass/fail \u2014 ticked, or computed. TRUE is the PASSING side wherever it is drawn, a register\'s risk flag included, so a field whose true means trouble is the same fact asked the other way round. One per entity |\n| `obligation` | `date` | a day something is OWED by \u2014 a cut-off, a permit expiry, a payment due. `satisfied_by` names what CLOSES it, `label` what is owed. Repeats: a row owes several things, and a shape that fans them draws one row each |\n\n**`until` stops a countdown where the work is DONE.** A deadline counts down so\nsomebody acts on it, and a row that has arrived needs nothing: without the clause\na delivered order is tinted for a date it met, and every settled row past its day\njoins the urgent ones in the reader\'s glance. It names one option of the entity\'s\n`lifecycle`; at that stage, at every stage past it and at every `outcome`, the\ndate is drawn as the plain day it is.\n\n"One per entity" is one COLUMN, never one value on screen. A second counterparty\nor a second parent stays a ROLELESS `select_record_link`: which field fills a\nslot is the screen\'s answer (\xA7 Apps and screens), and the extra link is a fact on\nthe row with its own section on the record, headed by that link\'s own label. A\nrole naming two fields would move the ambiguity into every shape that reads it.\n\nA COMPUTED field is admitted where its RESULT is what the role needs, and\nrefused where it is not: `identity` and `reference` need `text`, `when` a `date`,\n`mark` `files`. A formula\'s result is its `output_type`; a rollup\'s and a\nlookup\'s is read through the walk. So a formula yielding a number is refused in a\nname\'s place, a lookup landing on anything but `files` is refused as a `mark`, and\na formula declaring NO result is refused by all four \u2014 naming `output_type` as\nthe remedy, because a value drawn as a type nobody stated is the wrong cell with\nnothing saying so. The result also decides the DEVICE every screen draws the value\nwith: a rollup taking the `latest` of a set of dates is drawn as a date, a lookup\nof a select as the words it holds.\n\nA bare role name is the common form. A role that is read against a SECOND field\ntakes the object form:\n\n- **`measure`** names its limit: `against` \u2014 a constant, or a field on the same\n entity the row states as a number (a `number`, or a formula or rollup whose\n result is one) \u2014 and `alert`, which side of it needs attention, `over` a\n capacity or `under` a minimum. The two come together.\n- **`measure`** names what its number COUNTS, where the unit changes how the row\n is DRAWN rather than how the figure reads: `counts: "days"` spans a stop across\n that many days of a run, so a three-night stay fills three of its days instead\n of only the one it starts on. Stated, never inferred \u2014 nothing about a `3` says\n whether it is nights, pallets or hours, and a quantity drawn across a week is a\n run nobody can read.\n- **`lifecycle`** names the stages that END the flow: `outcomes`, option aliases\n of that select. A row in one has ARRIVED \u2014 delivered, cancelled, written off \u2014\n so a desk draws them apart from the ladder instead of chaining them after each\n other. Not every option may be an outcome, and terminal-ness belongs here, never\n written into a stage\'s label.\n- **`amount`** names the REFERENCE it is read against: `against`, a field on the\n same entity the row states as a number. A price against the list it is quoted\n off, a rate against the going one \u2014 and the catalogue item\'s record draws the\n pair on one axis with the gap said in words. Never a constant (a price typed\n into the model ages with nothing to update it) and never an `alert`: a limit is\n a ceiling a row can pass, and beating a reference is the point.\n- **`amount`** names the select that SIGNS it: `signed_by`, a select on the same\n entity, and `outflow`, the options of that select under which the amount is\n money going out. Without it every shape over the ledger adds both directions\n together: a month whose net was small reads as the total of everything that\n moved, and the trend plots one rising line. The two come together, and\n `outflow` is some of that select\'s options, never all.\n- **`expected_set`** names what CONDITIONS the set: `required_by`, a field on the\n same entity, and `options`, which of that field\'s values require which entries.\n A row whose value is not named requires nothing. Without it the denominator is\n every option, so a set whose entries are mutually exclusive by kind reads\n `1 of 4` on every complete row \u2014 a denominator no row can reach is worse than\n no role. The field is a single `select`, keyed by its option aliases \u2014 or a\n YES/NO the row answers itself (a `boolean`, or a formula resolving to one),\n keyed by `"true"` and `"false"`: a check that FAILED owes its consequence \u2014 the\n photograph, the owner, the day it is due \u2014 and there is no select of the record\n that says so.\n- **`obligation`** names what CLOSES it: `satisfied_by`, a `date` or a `files`\n field on the same entity \u2014 the day it was done, or the paper that proves it \u2014\n and optionally `label`, what is OWED as the reader says it ("G\u1EEDi SI"), which is\n not what the column holding the date is called ("SI cut-off"). Both are\n required in the sense that matters: without the stamp every obligation the\n business ever met stays on the desk, so the role is refused without one.\n\n```jsonc\n"field_roles": {\n "product": { "name": "identity", "photo": "mark" },\n "stock": { "on_hand": { "role": "measure", "against": "minimum", "alert": "under" },\n "uptime": { "role": "measure", "against": 80, "alert": "under" } },\n "order": { "stage": { "role": "lifecycle", "outcomes": ["delivered", "cancelled"] },\n // The day the papers are owed by, and the day they went.\n "papers_due": { "role": "obligation", "label": "File the papers", "satisfied_by": "papers_sent" } },\n // `kind` is the direction select \u2014 options `received` and `paid_out`, no role of its own.\n // Every figure on a quotation is in the currency that row names.\n "quote": { "unit": "currency", "total": "amount" },\n // What the price is quoted off, so the item\'s page reads one against the other.\n "service": { "price": { "role": "amount", "against": "list_price" } },\n "payment": { "value": { "role": "amount", "signed_by": "kind", "outflow": ["paid_out"] },\n // A receipt owes a receipt voucher; a payment owes an invoice and a payment voucher.\n "papers": { "role": "expected_set",\n "required_by": { "field": "kind",\n "options": { "received": ["receipt_voucher"],\n "paid_out": ["invoice", "payment_voucher"] } } } },\n // A CHECK THAT FAILED OWES ITS CONSEQUENCE \u2014 keyed by the row\'s own answer,\n // and the answer that owes nothing is omitted rather than named with an empty list.\n "check": { "passed": "verdict",\n "evidence": { "role": "expected_set",\n "required_by": { "field": "passed",\n "options": { "false": ["photo", "owner", "due"] } } } }\n}\n```\n\nIn a file that starts from a preset (\xA7 Starting from a preset), `field_roles`\nmay name the preset\'s fields as well as this business\'s own; a role the preset\ndeclares itself is kept unless this file names the same field, and `null`\nclears it.\n\n## Write rules\n\n`write_rules` is what only a CREATE meets \u2014 keyed by entity alias, like\n`field_roles`, and this file\'s in the same way: `check` proves it and the\nworkspace never sees it. An update names one field that moved; a create takes a\ndraft whole, so it has to know which row a name already belongs to, where a\nvalue comes from when nobody types it, and what a figure or a picker may hold.\nThe generator writes one `create_<entity>` workflow and one `New<Entity>Dialog`\nper entity an app can operate, from these clauses and the fields\' own\n`required`.\n\n```jsonc\n"write_rules": {\n // A customer is RECOGNISED by their address. Where an entity names a customer\n // as its `party`, the create takes the email instead of a picker, reuses the\n // row it matches and mints one only where nothing does \u2014 so the book never\n // grows a second Acme because somebody typed the name differently.\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, at\n // the control the figure was typed into.\n "quantity": { "min": 1, "max": 9999 },\n // The price is fixed at the moment of ordering \u2014 COPIED off the product,\n // not looked up for ever after, so the price list moving next week does\n // not silently reprice an order already placed.\n "unit_price": { "default_from": "product.price" },\n // And nothing is sold off an empty shelf. The picker reads only the rows\n // that answer this, and the write refuses the same rows again \u2014 a caller\n // who never opened the picker is bound by it too.\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}\n```\n\n| Clause | On | Meaning |\n|---|---|---|\n| `natural_key` | the entity | the field aliases a row is recognised by. A `text` key declares `unique: true` on the field itself \u2014 two rows sharing it would make find-or-create pick whichever the read answered first \u2014 and a key is `text` or `number`, because a person types it back |\n| `default_from` | a field | `"<link alias>.<field alias>"` \u2014 the value is copied from the linked row when the row is created, never asked. The link is a one-row link on this entity and `required`, because there has to be a row to read, and the two field types must match |\n| `min` / `max` | a `number` | the figure is refused outside them, on create and on update |\n| `options_where` | a `select_record_link` | an `and` group of plain conditions over the TARGET\'s own fields, each `field_key` a field alias. It narrows the picker\'s read and is refused again where the write lands, so the link is `required` |\n\nA field\'s own `required` is not here: the contract carries it, every write path\nrefuses the row by field name from it, and the generated panel stands its commit\ndown until the same set is filled. `unique` is the text field\'s own clause\n(\xA7 `text`) \u2014 the create says so at the control before the column does.\n\nWhat a create asks is what a person STATES. A `default_from` field is not drawn,\na `lifecycle` opens at its first rung, the stamp an obligation\'s `satisfied_by`\nnames is not asked (the row is only now taking that on), and files are attached\nto the row afterwards \u2014 one fact, one place. `scaffold check` prints every clause\nunder its entity in **Who writes what**.\n\n**The `parent` is prefilled only in the door that mounts the act.** A row opened\nfrom the record it hangs under takes that record from the door it was pressed\nin, so the link is neither asked for nor drawn. The same entity\'s act on its own\nregister has no such row, so there the parent is an ordinary picker the draft\ncannot be committed without \u2014 a payment opened from a money app belongs to a\ncase either way.\n\n## Apps and screens\n\n`apps` is the plan: each app the reader will build, as ONE REGISTER \u2014 a SHAPE\nover an ENTITY \u2014 under `screen`. Nothing here is built by the scaffold: the plan\nis what `lotics scaffold check` prints back, register by register with the field\nin every slot, so it is read and corrected before a screen exists.\n\n**ONE APP IS ONE REGISTER AND THE RECORDS IT OPENS.** A second register beside\nit is a second job on one page: the reader arrives on whichever the nav listed\nfirst and decides, every time, which of the two they came for. So an app has one\ndestination and everything else about a row is DISCLOSED by opening it \u2014 the\nfacts by tier, the children as lists, the related as counts, one primary act, and\na filter or a group where a strip of destinations would have been. What used to\nbe a second screen is a section of the record, or another app; a model still\nsaying `screens` is refused by name.\n\n**The unit of an app is a JOB, not a person and not a table.** A job has its\nown outcome (something exists or is settled when it is done), its own subject\n(the record it advances), and an end that does not wait on the rest of the\nwork. Two tasks are ONE job when neither finishes without the other and both\nadvance the same record. One app per job, and that is what makes an app a\nwrite-ownership boundary: what the job settles is its app\'s alone to write,\nplus everything it must read to settle it well. A person holding several jobs\nopens several apps \u2014 one app for everything one person does is the dump. A step\nof the job is a section of the record, never a register beside it; a reference\nthe job needs at hand is read inside the record it is about.\n\n**Two people signing is two jobs**, because the outcome belongs to the signer:\nthe desk that prepares against the gate that releases. So is a different\ncadence \u2014 reference data edited monthly and read by the public site, beside a\ndaily desk \u2014 and a different reader, the owner\'s read-only questions. Device,\nplace and step never split a job. A super app holds more than one job; an\nover-split holds less than one, a job cut by device, place or step. The count\nper workspace falls out of the jobs, typically two to six, and is never the\ninput: review the plan app by app, naming who holds it, the job in one\nsentence, what it writes and what it reads.\n\n*A two-van appliance repair shop.* The technician\'s job is the call-out: the\noutcome is a visit done, the subject is the call-out record, and quoting it and\nscheduling it are one job, because neither finishes without the other and both\nadvance that record. The owner holds two of his own \u2014 billing the month\n(outcome invoiced, subject the invoice, settled against visits already closed)\nand the price list the booking page quotes from, edited monthly. Three jobs,\nthree apps, and the owner opens two of them.\n\n**Inside a job the caps hold**: six flow stages on a desk, seven facts before a\nrecord\'s fold. Past them, look for the second job hiding inside. And a job\'s app\nis DENSE: every create the job needs lives in it, so its user never leaves it to\ncorrect a figure the screen in front of them is stating \u2014 three workflows is a\nscreen, not a desk.\n\n**A handoff is a stage change.** Where one job ends the record moves stage and\nthe next job\'s desk opens on it; a field two jobs must both write is declared\nshared at the split, naming the stage each may write it in.\n\n**Name an app for the JOB, in the trade\'s own words, never for who it is for.**\nA title on the door says nothing about what the person who opened it came to\ndo, and it is wrong the day the org chart moves.\n\n```jsonc\n"apps": [\n {\n "alias": "sales", "name": "Sales",\n "description": "\u2026", "icon": "briefcase", "theme": { "color": "blue" }, // optional\n "screen":\n { "alias": "orders", "label": "Orders", "shape": "lifecycle_desk", "entity": "order",\n "record": "drawer", // optional \u2014 "drawer" | "page"; absent, the shape decides\n "tabs": "stage", // optional \u2014 a select on the entity, or null; absent, the shape decides\n "writes": false, // optional \u2014 default TRUE; false makes this screen a reader\n "period": "due_date", // optional \u2014 any field whose value is a date, derived ones included\n "filters": ["kind", // optional \u2014 a single-select, or a lens the model states itself\n { "label": "Qu\xE1 h\u1EA1n l\u01B0u", "predicates": [\n { "label": "\u0110\xE3 qu\xE1", "tone": "red",\n "where": { "node_type": "group", "logic": "and", "children": [\n { "node_type": "condition", "field_key": "owed", "operator": "greater_than", "value": 0 }] } }] }],\n "summary": { "totals": ["total"], "above": ["total", { "field": "owed", "at": "end" }],\n "ageing": { "amount": "owed", "due": "due_date", "buckets": [30, 60, 90] } }, // optional \u2014 see below\n "facts": { "groups": [{ "caption": "Pricing", "fields": ["rate", "surcharge"] }] }, // optional \u2014 the record\'s named bands\n "presentation": { "lead": "none", "density": "dense" }, // optional \u2014 how it is DRAWN; absent, what the rows are decides\n "acts": { "row": [{ "label": "Issue the note", "template": "debit_note", "place": "cta" },\n { "kind": "agent", "label": "Read the papers", "agent": "reader", "fills": ["ref", "due_date"] }],\n "record": [{ "label": "Print the file", "template": "dossier" }], // optional \u2014 the record\'s own header menu\n "selection": [{ "label": "Statement", "template": "statement" }], // optional \u2014 the work it hands on\n "export": true, // optional \u2014 the rows in view, saved\n "import": { "kind": "import", "label": "Upload the sheet", // optional \u2014 a file turned into rows\n "entity": "order", "key": "code", "columns": ["rate"] } },\n "section_acts": { "line": [{ "label": "Chase the lines", "template": "chaser" }] }, // optional \u2014 a verb on one section\n "slots": { "identity": "code", // optional \u2014 slot \u2192 field, where the roles cannot decide alone\n "stage": { "field": "state", "quick": true } } } // \u2026or the field AND the reader\'s own control for it\n }\n]\n```\n\nA shape is a proven screen with named SLOTS, each filled by a field carrying a\nrole (\xA7 Field roles). A slot with exactly one candidate on the entity binds by itself;\ntwo candidates need naming in `slots`; a field fills one slot; a required slot\nwith none is refused \u2014 a lifecycle desk over an entity with no `lifecycle`\nselect cannot be built.\n\n**A QUICK SLOT IS FOR A DECISION THE READER MAKES FROM THE ROW ALONE.** A slot\'s\nvalue in `slots` is normally the field\'s alias; `{"field": \u2026, "quick": true}`\nsays the column is not a reading of that value but the CONTROL for it \u2014 pressed,\nit writes that one field as a diff through the record\'s own update, so the\nregister and the page behind it land the same write. Only two roles are such a\ndecision, and `quick` on any other is refused: a `lifecycle`, which rests as the\nstage chip and opens the field\'s own stages with the ones that END the flow last,\nand a `verdict`, which is the switch. Both carry the record\'s own blockers \u2014 a\nmove the ladder holds is a move the cell holds, and it names the entries the row\nstill owes \u2014 and an outcome asks before it commits. Add `"order": "sequence"`\nwhere the stages are a WALK rather than a set of destinations: the cell is then\nthe next step alone, the same advance `RecordProgress` draws under the run, and\nthe doors out of the flow stay on the record. A screen stating `writes: false`\nrefuses `quick`: a reader who may operate nothing has no decision to take.\n\n| Shape | Answers | Required | Also fills | Record | Tabs |\n|---|---|---|---|---|---|\n| `lifecycle_desk` | what is stuck, what do I move next | `lifecycle`, `identity` | `mark`, `party`, `amount`, `measure` (level), `when` | page, or drawer where the entity carries `parent` | the lifecycle\'s stages |\n| `party_register` | who is this, our history, is there a risk | `identity` | `mark`, `contact`, `measure` (worth), `verdict` (risk) | page | none |\n| `offering_register` | what do we offer, at what price, can I sell it | `identity` | `mark`, `amount` (price), `measure` (availability) | page | none |\n| `transaction_ledger` | does this period reconcile, what is unexplained | `when`, `amount` | `identity` (reference \u2014 the line\'s own number), `party`, `lifecycle` (classification), `expected_set` (document) | drawer | none |\n| `monitored_asset_set` | what needs attention, is that number normal | `identity`, `measure` (level) | `mark`, `lifecycle` | drawer | none |\n| `obligation_desk` | what is due next, and has it been done | `identity`, and at least one `obligation` on the entity | `when` (runway) | page | none |\n| `trend_deep_dive` | how did the period go, and why | `when` | `measure`, `amount` | drawer | none |\n| `reconciliation_desk` | what does not match, and by how much | `identity` (reference), and two figures \u2014 `ours` and `theirs`, each an `amount` or a `measure` | `category` (reason), `verdict`, `when` | drawer, on the PAIR | the dated runs, where the model names a select |\n| `entry_matrix` | one value per subject per period | `identity` (subject), `across` \u2014 a `when`, or a `category` for a fixed column set | `measure` (value), `lifecycle` (state), `category` (note) | drawer, on the cell\'s row | none |\n| `guided_run` | complete an ordered sequence, a step at a time | `identity` (step) | `slot` (sequence), `capture` \u2014 a `measure`, `verdict` or `expected_set` \u2014 `verdict` (gate), `mark` (media) | page: the run IS the record | none |\n| `live_board` | is everything OK, right now | `lifecycle` (state), `identity` | `verdict` (exception), `when` (since), `measure` (level), `category` (where) | drawer | none |\n| `group_register` | what does each group come to, and what is behind it | `subject` \u2014 a `category`, `party` or `when` | `amount`, `measure`, `identity` (member) | page: a row is an aggregate, and the door is the SET behind it | none |\n| `media_set` | scan a body of pictures by where they belong | `mark` (picture), `identity` | `category` or `party` (group), `when`, `verdict` (current against superseded) | drawer | none |\n| `day_sheet` | what happened on each day | `when` (the day) | `lifecycle` (state), `measure` (level), `amount`, `category` (note) | page: a day composes several child logs | none |\n| `resource_schedule` | what is on each resource, in what order, under what ceiling | `lane` \u2014 a `party` or a `parent` \u2014 `identity`, `when` (the start) | `measure` (duration), and `load` and `bulk`, each a `measure` read against a ceiling of the LANE\'s \u2014 a vehicle fills by weight and by room and stops at whichever runs out first; `lifecycle` (stage) | drawer, over the lanes | none |\n| `worksheet` | priced lines the reader edits, totalling to one figure | `identity` | `category` (group), `measure` (quantity), and `cost` and `sell`, each an `amount` or a `measure` | drawer, per line | none |\n\n**A record is one of five KINDS, and the shape plus the roles decide which.**\nEvery one of them is ONE READING COLUMN, and the ORDER of that column is the\nwhole of what the kind means.\n\n| Kind | The column, top to bottom |\n|---|---|\n| WORK RECORD \u2014 a `lifecycle_desk` over rows belonging to nothing | the one act that moves it \xB7 what it IS \xB7 the papers it owes \xB7 the rows it is made of \xB7 the book of what it came to \xB7 its own words |\n| LINE \u2014 the same shape over rows carrying `parent` | its ladder \xB7 the arithmetic behind its amount \xB7 the rows and papers under it \xB7 its particulars, last (the key it is filed by is the line under its title) |\n| PROFILE \u2014 a `party_register` | its history, OPEN, grouped by year \xB7 the rest of its registers \xB7 its own words \xB7 its facts (the ways to reach it are under its name, its settlement is the band) |\n| CATALOGUE ITEM \u2014 an `offering_register` | its own words (the picture leads the header at media scale, the price is read against its reference in the band) \xB7 what uses it \xB7 its facts |\n| EVIDENCE \u2014 a `transaction_ledger` | the PROOF \xB7 what the paper is, compactly \xB7 the rest \xB7 the links to the record it settles and the party it was with |\n\nNothing in the file states the kind: a clause that could say it could say the\nwrong one, and `parent` already says whether a row stands alone. `record`\noverrides the DOOR where the shape\'s own answer is not the one wanted \u2014 and its\nthird value, `"expand"`, is not a door at all: the row REVEALS what it holds in\nplace, where there is nothing behind it worth navigating to. A row read that way\nreads as a LINE whatever the shape would have opened: its ladder, the arithmetic\nbehind its amount, then its particulars, because there is no header above it to\nhave stated the name first. It is REFUSED for two reasons, and they are not the\nsame refusal. On a shape whose record is a page by nature \u2014 a `guided_run`, a\n`group_register`, a `day_sheet`, an `obligation_desk` \u2014 because each of those\nstates the opposite where its door is declared: what its row leads to is read\nwhole, not as four more columns than the register had room for. And on a shape\nthat draws its rows with a device of its OWN \u2014 an `entry_matrix`, a `media_set` \u2014\nbecause a cell of a grid and a tile on a wall have no band under them for the row\nto reveal into. For the same reason a revealed register is drawn as a TABLE: a\n`presentation.layout` of anything else is refused, and the reader is not offered\nthe arrangement either, since it would take away the only door the register has.\n\n**`scope` \u2014 the one subject every read of the app narrows to.** Stated on the\nAPP rather than on its screen, because it is not a filter: a record opened under\nit stays under it, the pick survives closing the app, and every app declaring the\nsame `entity` shares one pick per viewer \u2014 which is the whole value, and what\nstops a construction workspace asking which project twelve times. `entity` is the\nmodel\'s entity whose one row the app is read inside, and `param` is what the\napp\'s reads take that row by.\n\n```jsonc\n{ "alias": "site_work", "name": "Site work",\n "scope": { "entity": "project", "param": "project_id" },\n "screen": { \u2026 } }\n```\n\nThe register\'s own entity has to REACH it \u2014 its own rows, or rows that name one\nthrough a link \u2014 or the switcher is a control that changes nothing. Until a\nsubject is picked the app reads NOTHING and says which one it is waiting for: an\nunscoped read over a scoped app is every row in the workspace drawn as one\nproject\'s work, which looks correct.\n\n**ONE RECORD SURFACE PER ENTITY, AND ITS KIND IS THE ENTITY\'S.** An app is one\nregister, so the record its rows open is the register\'s own and the kind comes\nfrom that register\'s shape. Another app over the same entity opens the same kind\nof record: a payment read from a cash book and one read from a trend are both\nmovements, and both read proof first. A shape with no kind of its own (a trend, a\nmonitored set, a `custom` screen) says only where the record opens.\n\n**Two things a register states about a row beyond which field fills which slot.**\nA `measure` is read AGAINST its limit (`against`/`alert`) where the shape\'s slot\ntakes one \u2014 `level`, on a lifecycle desk and a monitored set, and every slot of a\n`custom` screen, which composes the frame\'s own column; `worth`, `availability`\nand a trend\'s headline are plain figures, and a limit there would read as a meter\nin the record over a number the row printed bare. And the name carries a\nSUPPORTING LINE where the entity states a key to file the row under: the `parent`\nit belongs to, its `reference`, an `autonumber`, a text formula \u2014 in that order,\nfirst hit wins, never a field a person types prose into. A LINE\'s record heads\nwith that same key, so the register and the door it opens name one row one way.\n\n**The act that opens a row belongs to a register that LISTS the entity\'s rows**\n\u2014 a desk, a register, a ledger, a monitored set, or a `custom` screen. A shape\nwhose rows are not records has neither that act nor the door a counted line\nelsewhere leads to: an obligation desk\'s rows are deadlines and a trend\'s are\nperiods, so a create pressed there would open a row the screen cannot show. An\nentity whose only app is one of those has no create at all \u2014 give it an app whose\nregister its rows are read in.\n\n**An `obligation_desk`\'s rows are not its entity\'s rows.** The shape fans the\nentity\'s `obligation` fields out \u2014 one row per thing still owed, which is one\nwhose date is SET and whose `satisfied_by` is empty \u2014 so a l\xF4 with four cut-offs\nis four rows, and the three it has met are not there at all. The row\'s name is\nthe obligation\'s, its supporting line the record\'s `identity`, and the door is\nthat record. A `when` bound to `runway` is what the countdown\'s ring is measured\nfrom. Over an entity declaring no `obligation` the shape is refused: there is\nnothing to count down to.\n\n**What a register carries beyond its columns.** Six clauses, each emitted as\nthe prop the kit draws it with; every one is optional and a screen that states\nnone gets a register of columns and nothing else.\n\n- **`period`** \u2014 a field on the entity whose value is a DATE. The toolbar gains a\n date-range control (this month to start), and the rows it keeps are what the\n register AND every figure below are computed over, so a band can never be over a\n different window than the rows under it. Any such field, not only the `when`\n role, and however the date got there: one table is read two ways (a cost ledger\n by the day a line arose, the cash book over the same table by the day it\n settled), a role is one field\'s, and the day money MOVED is often a formula over\n the two columns that could hold it \u2014 which then declares `output_type: "date"`.\n A `trend_deep_dive` brings its own period and takes none here; a `live_board`\n is PERIODLESS and takes none either, for the opposite reason \u2014 it answers what\n is true NOW, and a date range over it draws what was true in the window in the\n live state\'s own colours.\n- **`filters`** \u2014 the chips beside the search. A single-`select` field\'s alias\n offers that field\'s own live options; it is the lens a register is read through\n over and above the one band its strip gives, so the strip\'s own field is\n refused here (one dimension, one control) and so is a multi-select (a row would\n answer the chip several ways at once). The rows the register and every figure\n below are computed over are what the chips AND the period kept.\n A DERIVED LENS is the other form \u2014 `{"label": \u2026, "predicates": [{"label": \u2026,\n "tone": \u2026, "where": \u2026}]}` \u2014 where the sets a reader narrows by are ones the\n MODEL names and no column holds: rate validity, plan urgency, free-time\n overrun. Each `where` is an `and` group of plain conditions over the entity\'s\n own fields, each `field_key` a field alias, and it is read off the row, so the\n operators are the comparisons a value answers \u2014 `number` and `text` equality,\n `number` ordering, `boolean` equality, `select` `has_any_of`/`has_none_of`, and\n `is_empty`/`is_not_empty` on any of them. Nothing DATED: a relative point needs\n a clock, and a screen states one date control (`period`), so a second beside it\n would be two windows with nothing saying which a figure was computed over \u2014 a\n date question is asked as a column the model derives and read here as a number\n or a yes/no. `tone` is the option palette\'s, because the chip draws these\n exactly as it draws a select\'s options.\n- **`summary`** \u2014 what the rows in view come to. `above` is a band OVER the\n register: `"counts"` for how many rows are in view and, for the FIRST\n `measure` the model gives a limit, how many are past it (accented only when\n one is) \u2014 one such set, because a band naming three is the dashboard a\n register is not; a list of number-field aliases for what they add up to, and a\n `percentage` named there is refused, since a share summed over the rows in\n view is a figure of no kind (draw it as a column, whose own total reads the\n mean). A figure written `{"field": \u2026, "at": "end"}` is a STOCK instead: the\n reading standing at the END of the window rather than the sum of the readings\n in it, which is the only way opening + in \u2212 out = closing reconciles \u2014 a month\n of daily closing stocks added together is thirty warehouses. The band labels it\n as the snapshot it is, and it is refused on a screen that states no `period`,\n since there is then no window for it to stand at the end of.\n `ageing` stands in that same\n band: an `amount` split by how many days past its `due` date each row is,\n drawn as a `Breakdown` of the edges in `buckets` (30, 60 and 90 days unless the\n model states its own, ascending). The ladder is the kit\'s \u2014 its boundary, its\n words and its ramp \u2014 and it opens with what is NOT YET DUE, because a bar of\n overdue bands alone is full at every input: a book with one late invoice and a\n book that has gone entirely bad would paint the same solid width. Against the\n current money the bar\'s own shape is the reading. A row whose date is empty is\n in no band. `totals` is what the whole\n view adds up, and it is drawn in that SAME band above the rows: a register\n states one aggregate, over the rows the reader can see, in one place, and a\n closing line under them is a second place to look and a second question about\n which rows it covers. A closing line belongs to the one device that IS a\n statement \u2014 a record\'s `Ledger`, whose balance is what it is read for, labelled\n by its amount\'s own word. Where a shape has no band above (a trend, whose own\n band is the chart) the figure stands under the register instead. A\n `transaction_ledger` sums its amount column itself and takes `above` alone. An\n `obligation_desk`\'s rows are DEADLINES and not records, so its band states\n `"counts"` \u2014 how many are open \u2014 and a figure list or an `ageing` over it is\n refused: a l\xF4 with three cut-offs is three rows, and its freight added over\n them is three times the freight.\n- **`facts`** \u2014 how the RECORD\'s facts are banded. `groups` is a list of\n `{caption, fields}`: what a set of facts has in common is a sentence about the\n business and nothing derives it, so a plan that states one gets exactly those\n bands, in its own order, captioned. Everything it leaves unnamed reads in the\n derived order below it. A field the header, the band, the ladder, a required\n set, the files or a register below already draws is refused: it is not a fact,\n so a band naming it would either state it twice or draw nothing. Naming any\n band IS the answer to which facts fold \u2014 see the TIERS below.\n- **`presentation`** \u2014 how the screen and the record it opens are DRAWN, where\n what the rows are is not the answer wanted. `lead` is what each row leads\n with: `"mark"` the subject\'s own mark, always spent because a party\'s initials\n stand in for a picture nobody uploaded; `"picture"` the photograph, spent only\n on the rows that carry one and, on the record, at media scale above the name;\n `"figure"` the amount, with no gutter at all; `"none"` neither. `density` is\n how many lines a row\'s subject may take \u2014 `"roomy"` two, `"dense"` one.\n Both default, and the DEFAULT is what the rows are: a party register leads\n with the mark, an offering register with the picture, a ledger with its\n figure, and a desk \u2014 rows that are work still to do, scanned \u2014 stands its rows\n one line each while a record\'s own rows are read roomy. So state it only to\n differ, and a plan that states nothing still gets screens that vary. A lead\n the rows cannot carry is refused: a mark where no field is drawn as one, a\n figure where no column is the amount, and the other subject\'s mark (which of\n the two a row wears is the shape\'s, so the register and the record it opens\n cannot call one row two kinds of thing).\n `layout` is how the rows are ARRANGED \u2014 `"table"`, `"list"`, `"cards"`,\n `"gallery"`, `"board"`, `"calendar"`, `"timeline"`, `"gantt"` \u2014 and it is\n AFFORDED by what the rows carry, not by the shape: every register affords a\n table and a list, a `mark` affords the two picture-led grids, a `lifecycle`\n affords a board, a `when` affords a calendar and a timeline, and a `when`\n beside a `measure` affords a gantt, because a bar with no length is a calendar\n drawn sideways. A layout the bound roles do not afford is refused, naming the\n ones they do.\n- **`acts`** \u2014 where this screen hands work on: `row` is the \u22EF menu on every row,\n `record` is the \u22EF on the RECORD\'s own page header \u2014 the same reach as a row\'s,\n standing where the work is open instead of while a list is scanned, and refused\n on a record that opens as a drawer, which has no header of its own and whose\n row already carries the register\'s menu \u2014 and `selection` is the bar over the\n TICKED rows. `export` is the third reach \u2014\n the WHOLE VIEW, saved in one press: `true` writes the rows as the register drew\n them, in the columns it drew, as an `.xlsx`, and\n `{"template": "\u2026"}` says the layout is the trade\'s and makes the paper with a\n workflow like any other. It is never a list of columns: which columns the\n export carries is what the screen already draws, and a second statement of it\n disagrees the moment a slot moves \u2014 the generator reads them off the drawn\n slots and writes them into the app, so the sheet\'s headings are the labels the\n register drew them under.\n `import` is the reach OPPOSITE it \u2014 a file turned into rows:\n `{"kind": "import", "label": \u2026, "entity": \u2026, "key": \u2026, "columns": [...]}`. The\n file is mapped column by column, each row is validated, what would change is\n previewed, and the commit UPSERTS by `key` \u2014 which is why a sheet sent twice\n moves no counts. That key is one of the entity\'s own `natural_key` fields\n (\xA7 Write rules), and an entity declaring none is refused: without a key the\n second run is a second set of the same rows. `entity` is the register\'s own \u2014\n an app is one register, and rows filed into another entity are a count nobody\n on this screen can check. `columns` is what the sheet may fill, in that order;\n absent, it is every column a person states IN WORDS. A computed one is refused\n because the workspace writes it, and a link, a member or a files column is\n refused because a cell of a sheet is words and those hold a row of another\n table or bytes \u2014 a file reaches another table through the party pattern (a\n `natural_key` on the target, named rather than picked), which is a create\'s.\n The generator writes the whole verb, as it does for a paper: an\n `import_<entity>` workflow that finds the row by `key` and then changes it or\n opens it, answering which of the two, and the staged run over it \u2014 drop, map,\n the per-row verdict, the commit. A line whose cell the column cannot hold is\n refused with the reason and the rest of the sheet still lands.\n Both spreadsheet reaches are read and written IN THE BROWSER, so the generated\n `src/main.tsx` imports `@lotics/app-runtime/sheets` wherever the plan states\n one of them and not otherwise; an app that has neither carries no spreadsheet\n engine at all. An act names ONE of two things, never both:\n - **`template`** \u2014 an `html` template this model declares (\xA7 Templates). The\n paper is filled from ONE row in `row`, and from the whole ticked set in\n `selection`. The generator writes the whole verb \u2014 a `generate_<template>`\n workflow that reads the row and hands the file back, and a menu item that\n opens it \u2014 so there is nothing left to bind. An `email` template is sent\n rather than opened and is refused here; a file-backed template (`excel`,\n `word`, `pdf-form`) is bytes a model has none of, so those stay the author\'s\n own `lotics app workflow set`. Two screens over DIFFERENT entities cannot\n name one template: one paper is filled from one kind of row.\n - **`{"kind": "agent", \u2026}`** \u2014 a run over ONE row. `agent` is an alias the APP\n declares (`package.json#lotics.agents`), because an agent is prose and tools\n rather than anything a workspace model can state \u2014 `lotics app check` refuses\n one nothing declares, exactly as it refuses a workflow nothing bound. `fills`\n is the fields the run may write: pressed, the reader watches the run, reviews\n what it proposes field by field against what the row holds now, and applies\n the ones they kept as ONE write through the record\'s own update. A run can\n reach nothing outside `fills`, a computed column there is refused (the\n workspace writes those), and a screen stating `writes: false` refuses an\n agent act outright. It belongs in `row`; the selection bar makes ONE paper\n from many rows, and a run per ticked row is the row\'s own act many times.\n\n **`"place": "cta"` draws an act ON the row** instead of in its \u22EF \u2014 the one verb\n a reader presses without opening a menu first. At most one per screen and every\n other act stays in the menu, because the trailing gutter is paid for by every\n row: a second is refused with *one verb on the row, the rest in its menu*. The\n ticked set has no row of its own, so `cta` is refused there.\n\n Every other verb \u2014 record an interaction, close a request, push a voucher \u2014 is\n a workflow the author writes and binds with `lotics app workflow set`; a plan\n that could name one would scaffold a button wired to a body nobody had written.\n\n A `selection` act is a `template` and nothing else: an act over many rows makes\n ONE paper from them. The generator turns the register\'s checkbox gutter\n on, emits a `generate_<template>` workflow taking the ticked ids, and the body\n reads each row and passes them as `rows` \u2014 which is what the template iterates\n (`{{#each rows}}`). One template is one paper AND one reach: naming the same\n one from a row\'s \u22EF and from the bar is refused, because those are two bodies.\n A shape whose rows are DERIVED rather than records \u2014 an `obligation_desk` \u2014\n has no set to tick and refuses the clause. The paper is handed BACK, never\n filed: a ticked set can hold rows of three different parents, so there is no\n record on which "this document belongs here" is true.\n\n**`section_acts` puts a verb WHERE ITS EFFECT LANDS.** "Chase the paperwork"\nbelongs on the papers, not in the register\'s \u22EF two surfaces away. It is keyed by\nthe alias each section is DERIVED from \u2014 a field\'s (its progress, its prose, its\nrequired set, its charge, one of its files) or a CHILD ENTITY\'s (its register,\nits desk, its run, its thread) \u2014 because the sections come off the roles and the\nkey the app addresses one by does not exist until it is built. An alias naming no\nsection is refused, listing the ones that do; the facts band is what every other\nsection left over, so nothing addresses it. Each act is a `template` or an\n`agent`, exactly as a row\'s is, and `place: "cta"` is refused \u2014 a section is a\nband with a heading and has no row to draw a verb on. `scaffold check` prints\nwhich section each alias landed on, which is the half an author cannot see in\ntheir own file.\n\n`scaffold check` prints every clause a screen states, on its own line under the\nslots, spelling the screen\'s `writes` clause `operable` \u2014 the app manifest\'s\n`lotics.writes` keeps that word, and the two are different facts: `app create`\nSEEDS the manifest from the fields the screens\' own editors write, and from then\non the app owns the declaration `lotics app check` holds its bodies to.\n\nEach shape is a `@lotics/ui` component of the same name (`LifecycleDesk`,\n`PartyRegister`, \u2026) whose props are these slots, so once the tables exist\n`lotics app create <name> --from <this file>#<app alias>` scaffolds the app with\none screen per entry, each slot reading the field the plan bound.\n\n`"shape": "custom"` is the screen no registry row covers: it declares its own\n`roles` (slot name \u2192 role), and it is emitted on `ShapeFrame` \u2014 the one anatomy\nthe six shapes above are each a configuration of \u2014 so its strip, search,\nordinal, fit budget, empty and waiting states and record door are the same ones\nthey have. It opens the record its `record` says, drawer or page, like any other.\nA bound `lifecycle` slot IS its strip, the stages in the field\'s order as on a\ndesk, so such a screen names no `tabs`; a `tabs` select is the flat strip a\nscreen with no lifecycle gets.\n\n**An app IS its `app.json`.** `create --from` writes the bound plan \u2014 every\nscreen with its shape and slots, the record each row opens with its sections in\nthe archetype\'s order, the create panels, the acts \u2014 as one JSON document, plus\na five-line `src/main.tsx` that mounts `@lotics/app-runtime` over it and one\n`src/workflows/<alias>.ts` per write. There is no screen source: the runtime\ndraws the spec, so a kit correction reaches every app with its next install.\nWhere the plan has no word for what a screen or a section IS \u2014 or for what a\npaper act should ASK before it is made \u2014 the spec names one of the app\'s own\ncomponents and `src/components/index.ts` registers it, the one file under `src/`\na regeneration never rewrites. `lotics app eject <screen|<section key>|<act\nlabel>>` writes each of those, starting from what the runtime already drew.\n\n**How a spec reads a row.** Each query projects its columns under the alias the\nworkspace mints from the field\'s LABEL \u2014 never the alias this file keys it\nunder, which is the model\'s own namespace and which the workspace never saw. So\na field this file calls `partner` and labels `\u0110\u1ED1i t\xE1c` is referenced as\n`doi_tac` throughout the spec. Every id in it is live: the `tbl_` a screen is\nover, the `fld_` of each column, and the `opt_` of every option a role names.\n\n**Every query is described, in the model\'s own language.** The line an agent\nchooses between aliases by is written from this file\'s nouns \u2014 the entity\'s\nlabel, the screen\'s, the link\'s \u2014 and the sentence around them is the language\nthe file is mostly written in, decided the same way a generated write\'s refusals\nare. Hand-editing one is erased by the next regeneration; `lotics app check`\nrefuses an alias that carries none.\n\nA screen is that list and the RECORD it opens, and the record comes off the same\nroles \u2014 nothing to declare for it. It opens with a **header**: the entity\'s\n`identity` as the record\'s name \u2014 whether or not the list has a column for it, so\na ledger\'s and a trend\'s records are named too \u2014 the `when` this screen reads\nunder it, and ONE headline\nfigure (the `amount` the screen reads, else its `measure`). BOTH DOORS state the\nname: which door a screen uses is a layout answer, and what the record is ABOUT\nis not.\n\n**A JOB\'S HEADER ALSO STATES WHO IT IS FOR.** Where the record is the work itself\n\u2014 a desk\'s rows that belong to nothing, opened on a page \u2014 its `party` is a link\nunder the name, beside the ways to reach the subject, and it is then not a fact\nas well: a page headed by a code alone said nothing about whose work it is, and\nthe one thing a reader arriving from the desk already knows the row by sat in the\ngrid below as a row among forty. A record opening in a DRAWER has no row of links\nto hang it on, so its party stays a fact; so does a LINE\'s, whose register files\nit under the thing it belongs to and whose party is that parent\'s, restated per\nrow.\n\n**A record whose figures are a SENTENCE states them under the header, in a\nband**, and the header then states neither of them: a job reads what it comes to,\nhow far through it is (the `measure` against its limit), what is left of it (a\nformula that is exactly `{amount} - {measure}`) and how long there is (an\n`obligation`, else its `when`, counted down); a party reads its settlement, the\n`amount` and `measure` roles it carries in the order the model declares them; a\ncatalogue item reads its `amount` against the reference that amount names. Two\nfigures are the floor and four the ceiling \u2014 under two, the header keeps its one.\nA band is a PAGE\'s strip, so a screen whose `record` is a drawer keeps every\nfigure where a drawer reads them. Each figure is drawn exactly once: the limit a\nmeter states is not a fact beside it, and the day a countdown counts to is not\nthe provenance line above it. The `lifecycle` is not badged there, because the progress section is the\nrung it stands on. Then its sections, in the order its KIND reads them: the\n**facts** (every field neither the header, the band nor another section owns, the\nrow\'s own `parent` among them \u2014 led by the ones THIS screen\'s slots bound, then\nthe key it is filed under, then the order the model declares them in),\nthe record\'s own **body** (every `text` field the entity formats as `markdown` \u2014\nprose read at the width of the work, never wrapped to a label in a column\nbeside it), the **charge** (where the `amount` is a formula over exactly one\nquantity and one money field, the line states `quantity \xD7 unit price = amount`\nand neither figure is typed twice \u2014 the section is headed by the amount\'s own\nlabel and the closing row says what the figure IS, so the word is said once),\nthe **progress** (the `lifecycle`\'s stages, and the ONE act that moves the record\non \u2014 every other move, outcomes included, is an item in the menu beside it), a\n**required set** (a multi-select `expected_set`, or a child entity whose rows\ncarry one entry of it each \u2014 that child\'s `files` field is what a paper attaches\nto), the record\'s **own rows** (any other child, its role-bound fields as\ncolumns), and its **files** (every `files` field, the `mark` first \u2014 a section of\nthe column like any other, straight after the facts it is the evidence for,\nexcept on an EVIDENCE record, which OPENS on the paper it exists for; the\nheading carries the Add verb, and stands it down while the pile is empty, where\nthe drop well is already the door; and a `verdict` whose FORMULA reads one of\nthose files fields is the pile\'s OWN state rather than a fact row \u2014 the file it\nis short, drawn as a ghost, and the mark the rail sends a reader to). A child is\nan entity that LINKS here, ONE SECTION PER LINK: the rows it owns (`parent`), the\nrows that name it (`party` \u2014 so a party\'s record is its history), and the rows\nreaching it through a link carrying neither role, headed by that link\'s own\nlabel. A child hanging off two records therefore declares one `parent` and\nleaves the second relationship a plain link, which still gets its section.\n\n**THE ROWS A RECORD OWNS ARE ADDED AND OPENED FROM THEIR SECTION.** A `parent`\nsection\'s heading carries the Add for its rows, handing the record as the\nparent \u2014 filled, never asked. Every row opens its own record \u2014 a line of a book,\na paper on a desk, a stop on a timeline alike: where this app already routes a\npage for those rows (the register\'s own, under one of them) it is navigated to;\notherwise it is a DRAWER mounted on the record it belongs to, stepped \u25C0 \u25B6 over\nthe section\'s rows as they are drawn, with the row\'s framed editors, its ladder\nand its files, saving through `update_<child>` \u2014 the one editor those rows have,\nwhich any control the section draws over a row writes through too. Never the\n`parent` link itself: it is the key the row is FILED under, stated by the record\nthe drawer was opened from, so it is neither a fact of the drawer nor an input\nof that editor. ONE LEVEL, and only from a PAGE: a child\'s own children draw as\nthe kit\'s panel of the row, with no drawer and no Add, and a register whose own\nrecord is a DRAWER is the same for the children it owns \u2014 a drawer mounts no\ndrawer. **A ROW IS ADDED WHERE IT CAN BE OPENED AGAIN**, so those sections lose\ntheir Add with their drawer; a row of the register\'s OWN kind keeps it, because\nthe register\'s list opens it. A correspondence\'s entries are the one owned row\nthat opens nothing anywhere: the composer under them is the section\'s Add\nwhatever door the record has, and the answer mark is one field of the reply\'s\nown editor. The rows a `party`\'s record lists, and those reaching a record\nthrough a roleless link, are read only \u2014 a history, never a place to open or\nadd.\n\n**TWO CHILDREN ARE READ AS A RUN RATHER THAN AS A REGISTER**, and the model says\nwhich by the roles it declares on them. A child carrying an `obligation` whose\n`satisfied_by` is a DATE is a **timeline**: its rows are the ordered stops, each\nstating the day it was promised for and the day it happened, with the delta named\nwhere the rung was late \u2014 drawn as a register those are two date columns and the\nreader subtracts them by eye, which is exactly what "three days late at\ndischarge" costs to learn. An obligation closed by a FILE says nothing about\nwhen, so those rows stay the register they were. A child carrying a `markdown`\ntext field beside a `party` and a `when` is a **thread**: the entry is the body,\nthe author and the day lead it, and a `verdict` on the child is the yes/no one\nreply is marked the ANSWER with \u2014 a register of messages is a table whose one\nuseful column is the one it cannot draw. A SECOND `party` on those rows is who\nthe entry was left WITH, and the latest one names the ball in court: while\nnothing answers, the record says whose move it is and counts its own deadline\n(an `obligation`, else its `when`) against them. Both are read only under the\nrecord that OWNS the messages \u2014 under the party who wrote them the same rows are\nthat party\'s history, several questions at once, and the last of them says\nnothing about any. That record also REPLIES: the section\'s Add is a composer \u2014\nthe message is all a reply asks \u2014 and the answer mark is one field of the\nreply\'s own editor.\n\n**NOTHING SITS BESIDE THE RECORD.** A page is ONE reading column \u2014 its fields,\nits files, the rows it owns, its notes, all sections of it in the archetype\'s\norder \u2014 over a reserved left gutter holding a floating NAVIGATION RAIL: the way\nback, then one line per section with that section\'s category mark, and nothing\nelse. No files, no counts, no group headings. Too narrow to seat the rail, it is\none pinned bar of the same list across the top. A section HEADING never wears\nthe mark; the rail\'s line for it does.\n\n**A RELATION THE RECORD NEVER ACTS ON CLOSES THE COLUMN.** Every register that\nmerely NAMES the record is one counted line of a single last section: its label,\nhow many rows name this record (a COUNT, never a page of rows measured), and a\npress that opens the register owning them narrowed to this record. An entity\nthis app lists nowhere is a count with no door, and where every count is zero\nthe section is not drawn at all. The one exception is a PROFILE, whose history\nIS its body and stands open in the column.\n\n**Rows the record owns whose `amount` is `signed_by` a direction are a\nBOOK**, drawn as a statement \u2014 each line dated and signed, the balance under\nthem where there is more than one line to add up, labelled by the amount\'s own\nword \u2014 where the same rows on the party they\nwere transacted with stay that party\'s history, which is scanned for the line\nshort of its paperwork rather than struck to a balance. **Rows that carry a\n`when` read as RUNS**: a party\'s history under the year \u2014 and only where the rows\nin hand span more than one, since a subhead over every line of a book names the\nyear and separates nothing \u2014 and a job\'s own rows under the day they fall on,\nwhich, where those rows also carry a `lifecycle`, is an ITINERARY rather than a\ngrouped table: the day heads the run, each row is one line (its `slot`, its name,\nits party, its stage, its `category` and `reference`, its amount), the figures end\non one edge and the run closes with their sum \u2014 unless the record\'s own BAND is a\nsum rollup over exactly these rows, which states it once already. A `category`\npaints the node the run is read down, in the option\'s own colour; a `reference`\ncloses the line with the code somebody quotes on the phone. Both are PROJECTED for\nthe run whether or not the register\'s column budget would have drawn them.\n**EACH DAY\'S HEAD CARRIES THE SECTION\'S OWN ADD** as well, handing the panel that\nday, so a line made from Tuesday opens with Tuesday answered; the heading keeps\nthe verb too, for the first line and for a day nothing is planned on yet. **THE\nKEY LEAVES THE COLUMNS where the head states the whole of it** \u2014 a day head does,\na year head states four digits of the date and the day is what the column is\nstill there to carry.\n**A run is READ FROM THE END THE READER WANTS**: a job\'s own rows and an itinerary\nascending, a book and a history descending.\n\n**A DESK OVER AN ENTITY THAT CARRIES A `slot` IS ITSELF A DAY\'S RUN.** Its own\nregister is drawn under one subhead per day, sorted by that day and then by the\npart of it, and the date column goes \u2014 the subhead states it once for the whole\nrun, and a column repeating it down every line is the same value twice. Without\nit an itinerary was one flat list in whatever order the rows arrived.\nA document desk keeps its head either way \u2014 the entries it owes are its\ncontent, filed or not. The name and whatever figure is left to it are the header\'s\nalone \u2014 it states them in full, so a fact for either would be the same sentence\ntwice.\n\n**A FACT IS PRIMARY OR IT IS PROVENANCE.** An `autonumber`, a date the platform\nstamps (`derive_from`) and a date a formula works out that no role names are what\nthe SYSTEM wrote; they fold, with every optional field this record does not\nstate, behind the grid\'s ONE link. Everything a person types or picks is primary\nand is shown, empty or not, because absence is work somebody owes. **A screen\nthat named `facts.groups` has already said which:** a field in a group is\nprimary, and one in none folds \u2014 grouping says what the reader decides with, and\nby saying so says the rest are not. A `tier` on a `facts` entry exists only for\nthe case that derivation gets provably wrong.\n\n**A `contact` field whose kind the\nmodel decides** \u2014 an email, a number, a place, a `link`-formatted text, or a\nMESSAGING APP its label names (WhatsApp, Zalo, Telegram, Viber, WeChat, Line,\nMessenger) \u2014 leaves the facts too, for the row of links under the name on a\nrecord that opens as a page; one whose kind nothing decides stays a labelled\nfact, because a scheme nobody stated is a link that opens nothing. A handle is\ndrawn under its app\'s name \u2014 the app is what identifies the person there, and a\nbare number beside an envelope says the wrong thing \u2014 and it dials only where its\nvalue IS a number.\n`check` prints the record under each screen\'s slots:\n\n```\n Orders \u2014 lifecycle desk over Orders (12 rows) \xB7 page \xB7 tabs: Stage (New \u2192 Quoted \u2192 Confirmed \u2192 Shipped \u2192 Done)\n stage Stage \xB7 identity Order no. \xB7 mark Photo \xB7 party Customer \xB7 amount Total \xB7 level (none) \xB7 when Due\n record: header (Order no. \xB7 Customer) \xB7 band (Total \xB7 Shipped against Lines \xB7 Due counting down) \xB7 progress: Stage (5 stages) \xB7 facts (Ship to \xB7 \u2026 2 filed) \xB7 files: Photos \xB7 documents: Papers (Kind: 4 required) \xB7 itinerary: Order lines by Ship by at Window, kind Handling, ref Waybill (Product \xB7 Quantity \xB7 Line total) \xB7 related: Visits (count via Order)\n```\n\nThe ORDER of that line is the record\'s own, top to bottom, and it is the KIND\'s,\nand every entry of it is a section of the one column. `related:` is the counted\nline that closes it \u2014 a register the record never acts on, named and counted \u2014\nand `\u2026 N filed` is how many facts fold behind the grid\'s one link.\nA child register whose `amount` is `signed_by` a direction is a BOOK of movements\n\u2014 what the record came to rather than what it is made of \u2014 so it closes the\ncolumn rather than standing among the rows the job is made of.\n\n**A section over a child is named by the CHILD\'s own label**, because the reader\nalready knows which record they are on: "\u0110\u01A1n h\xE0ng", not "\u0110\u01A1n h\xE0ng \u2014 Kh\xE1ch h\xE0ng".\nWhere two links from one entity reach this record, that label names two sections\nand each takes its own link\'s label as a qualifier \u2014 `H\u1ED3 s\u01A1 (\u0110\u01A1n h\xE0ng)` and\n`H\u1ED3 s\u01A1 (\u0110\u01A1n g\u1ED1c)`.\n\nIn that line `documents:` is a `RecordExpectedSet`, and `lines:` a `RecordChildren` over the rows\nthis record owns \u2014 `history:` where they name it as their `party` instead; a required set\nwhose entries are a CHILD entity carrying files takes `kind="files"`, and the entity\'s own\nmulti-select takes `kind="items"`, since nothing attaches to an option.\n\n**A section that knows its size says so.** Where a `measure` on the record is a\n`count` rollup over the very link a rows section hangs on, the limit it is read\n`against` is how many rows that section is OWED: the printout adds `\u2014 expects\n<limit>` and the screen draws that many ghost rows until the first one lands,\ninstead of "nothing here" two bands under a count saying three are outstanding.\n\nA figure printed as `Total (USD)` is MONEY in the currency named \u2014 a `formula`\nstates it in `formula.currency`, and a `rollup` or a `lookup` inherits it from the\nfield it reads, so read those brackets: a rate that should be in dollars and is\nprinted bare will be drawn in the workspace\'s own money. Where the entity carries\na `currency` role, the ROW\'s code outranks the field\'s on every line that states\none, and a line stating none falls back to the field\'s.\n\n**A record surface can be OPERATED, and that is the default.** Every screen with\na record gets a workflow that writes its editable facts \u2014 every field a person\nstates \u2014 so the record\'s values are edited in place and its stage is advanced\nfrom the progress section. `"writes": false` makes one screen a reader: use it\nfor a screen over rows another desk owns, never as the default. A desk nobody\ncan act on is a viewer of state somebody must go and set somewhere else.\n\n**Which facts those are is the FIELD\'s answer.** Text, number, date, yes/no and\nselect are typed or picked. A `"cardinality": "one"` link is RE-POINTED, through\na picker over the target entity named by its `display_field_aliases` \u2014 else the\ntarget\'s `identity` \u2014 narrowed by what the reader types and carrying that\nentity\'s own `read_scope`, so a link the plan gives neither column is read\ninstead of offering an empty list. A files field is ATTACHED TO and DETACHED\nFROM, as the delta rather than the pile, so two readers filing at once each keep\ntheir file. A many-link, an `autonumber` and every computed field are read: the\nfirst is a list a fact cannot state, and the rest are the platform\'s to write.\n\n**A one-link is that fact WHATEVER its sync.** `sync_both_ways` is one relation\nwith a field on each side, and the sides are not the same surface: the record\nthat names ONE row states it as a fact, and the MANY side is the register on the\nother entity\'s record. Declaring both directions does not give this record a\nregister of the single row its own fact already names.\n\n**The write is the record SURFACE\'s.** Its inputs are the fields that surface\noffers to save \u2014 the facts with an editor, the lifecycle its ladder advances, and\na files section over exactly one stated field \u2014 never every field a person could\nin principle type. What the header states, a limit a meter folds in, a required\nset with a section of its own and the pile a page\'s mark draws are read there, so\nno input is declared for them. A second screen over the same entity draws no\nsurface of its own and adds nothing.\n\nWhere two operable screens of one plan reach the same entity, `scaffold check`\nnames them and the apps they belong to: two desks writing one record is a\ndecision, and the split is stated on the FIELD in each app\'s\n`package.json#lotics.writes` rather than left to whoever edits second.\n\nThat workflow lands in the app as source, like every other: its body in\n`src/workflows/update_<entity>.ts` and its declaration in\n`package.json#lotics.workflows`. The two together are the binding \u2014 `lotics app\ndeploy` pushes whatever differs from what it last saw live \u2014 so the write is\nversion-controlled and travels with the app rather than being bound by hand\nafterwards.\n\nA `custom` screen declares its slots under `roles` (slot \u2192 role) and they bind\nthe same way:\n\n```jsonc\n{ "alias": "readings", "label": "Readings", "shape": "custom", "entity": "reading",\n "roles": { "subject": "identity", "reading": "measure" } }\n```\n\n## Applying packages\n\n`apply` copies published packages into the workspace AFTER the model\'s own\ntables exist \u2014 apps over the tables you just described, and any tables of their\nown they still need. Ordered, and run by `lotics setup` and `lotics scaffold\napply` alike.\n\n```jsonc\n"apply": [\n {\n "package": "apg_k3nf82ldpq",\n "bind": { // optional \u2014 which of YOUR tables each entity is\n "company": { "label": "Customers", "fields": { "name": "Company name" } }\n },\n "no_sample_data": true // optional\n }\n]\n```\n\n`bind` is keyed by the package\'s entity alias and holds the LABELS this\nworkspace uses: scaffold adopts by label, so binding points the package at the\ntables the model created instead of a second set beside them. Only naming\nmoves \u2014 a bound field must be the TYPE the package declares, or the copy is\nrefused. `lotics library list` is the shelf, and `lotics library show <apg_id>`\nlists the aliases to bind.\n\nEntries run in the order they are written, because a later one may bind onto a\ntable an earlier one created. **A refused entry stops the run and the entries\nbefore it stay** \u2014 they are separate copies, committed as they land, so the\nrefusal names them rather than leaving a caller to re-run the file and copy them\ntwice.\n\n## Presets\n\nA preset is a trade\'s model, published to be READ. An assistant reads it, asks\nat most two questions, picks a variant and writes a `model.json` from it \u2014\nnothing is copied, and a preset is a file rather than anything a workspace\ninstalls.\n\n```jsonc\n"preset": {\n "name": "Field service",\n "description": "Jobs, the crew that runs them, and what each one billed.",\n "questions": ["Do you dispatch crews, or one person per job?"], // at most 2\n "variants": {\n "crews": {\n "when": "work is dispatched to crews rather than to one person",\n "entities": [ /* tables this branch ADDS */ ],\n "fields": { "job": [ /* fields this branch ADDS to `job` */ ] }\n }\n }\n}\n```\n\nVariants are **additive only**: a branch adds entities and fields and never\nremoves them, so the base is a model in its own right rather than a draft.\n`lotics scaffold check` proves the base AND every variant merged onto it, so a\npreset ships with every branch already proven \u2014 the branch nobody took is the\none that fails in the workspace of whoever takes it.\n\n`preset` is not scaffolded. `lotics setup` and `lotics scaffold apply` ignore\nit and create the base model\'s tables.\n\n`lotics scaffold export` prints a workspace that already works as one of these\nfiles \u2014 the starting point for a preset or for another business\'s model, never a\nsource of truth: it carries one business\'s words and stops describing that\nworkspace the moment either changes.\n\n## Starting from a preset\n\n`lotics library list` is the shelf of them and `lotics library show <slug>`\nprints one whole: its questions, every table as `alias \xB7 label` with each field\nas `alias:type`, and each variant as `slug \xB7 when` followed by the tables and\nfields that branch adds. When one of them is the trade in front of you, do not\ntranscribe it \u2014 name it:\n\n```jsonc\n{\n "from": "field_service",\n "variants": ["crews"],\n "rename": { "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } } },\n "entities": [ /* a table this business has that the preset does not */ ],\n "rows": { "job": [ { "ref": "j1", "fields": { "code": "J-1" } } ] },\n "field_roles": { "job": { "code": "identity" } },\n "write_rules": { "customer": { "natural_key": ["email"] } },\n "apps": [ /* the screens this business\'s apps will have */ ]\n}\n```\n\n- **`from`** is the preset\'s SLUG \u2014 its own file name, a lowercase slug. Naming\n it is what makes `entities` optional; every other rule on this page is\n unchanged, because the file is resolved into the full form and then checked and\n applied exactly as one. A slug nothing serves is refused with the ones there\n are, never resolved against something else.\n- **`variants`** names the branches to merge onto the base, in order. Pick the\n one whose `when` describes what the person said; a slug the preset does not\n declare is refused rather than ignored.\n- **`rename`** is keyed by the preset\'s entity alias and holds the labels this\n business uses \u2014 the same shape `apply[].bind` takes, and the same rule: only\n naming moves. An alias the preset does not declare, and a label that is\n already another table\'s, are both refused.\n- **`entities`** are added after the rename, already in this business\'s own\n words.\n- **`rows`**, **`field_roles`**, **`write_rules`**, **`apps`** and **`apply`**\n mean exactly what they mean in the full form \u2014 `"rows"` are this business\'s\n real first records,\n `"field_roles"` may name the preset\'s fields as well as its own (a role the\n preset declares itself is kept unless this file names the same field, or\n clears it with `null`), `"write_rules"` the create-time clauses on either\n (an entity this file names replaces the preset\'s whole entry for it),\n `"apps"` the screens it will have (a preset carries none), `"apply"` the\n packages copied in once its tables exist.\n\nThis is the ONE thing on this page that needs the network: `check` reads the\npreset it names, once. Everything after that read is the same offline check.\n\nWrite the full form when no preset is the trade.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "singular": "Customer",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "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 ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "field_roles": {\n "customer": { "name": "identity" },\n "order": { "code": "identity", "placed_on": "when", "amount": "amount", "customer": "party" }\n },\n "apps": [\n {\n "alias": "customers",\n "name": "Customers",\n "screen": { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" }\n },\n {\n "alias": "orders",\n "name": "Orders",\n "screen": { "alias": "orders", "label": "Orders", "shape": "transaction_ledger", "entity": "order" }\n }\n ],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\n`lotics scaffold check` on this file reports\n`2 tables, 9 fields, 2 links, 1 view, 1 role, 4 rows, 2 apps`, then\nthe plan:\n\n```\nCustomers\n Customers \u2014 party register over Customers (2 rows) \xB7 page \xB7 tabs: none\n identity Name \xB7 mark (none) \xB7 contact (none) \xB7 worth (none) \xB7 risk (none)\n above: rows\n record: header (Name) \xB7 history: Orders (Order no. \xB7 Placed on \xB7 Amount (VND)) \xB7 facts (Tier \xB7 Total ordered (VND))\nOrders\n Orders \u2014 transaction ledger over Orders (2 rows) \xB7 drawer \xB7 tabs: none\n when Placed on \xB7 amount Amount (VND) \xB7 reference Order no. \xB7 party Customer \xB7 classification (none) \xB7 document (none)\n above: Amount (VND)\n record: header (Order no. \xB7 Placed on \xB7 Amount (VND)) \xB7 facts (Placed on \xB7 Total with VAT (VND) \xB7 Customer)\nWho writes what:\n Customers (customer, one: Customer): Customers\n Orders (order, one: Order): Orders\n```\n\nThe last block is the WRITERS matrix \u2014 one line per record surface the plan\nleaves operable, the word one of its rows is called, and the app whose register\nopens it. Part of the verdict rather than a diagnostic beside it: which desk\nchanges a table is answerable from the plan, and two apps on one line is the\nsplit to state in each of their `package.json#lotics.writes`.\n\nEvery `(none)` is a slot no field fills \u2014 the picture a register has none of,\nthe contact nobody declared. Read it as the screen a person will see. Neither\nentity carries a stage, a required set or files, so the rest is facts \u2014 except\nthe sections neither entity declares: the customer\'s `history:` is the orders\nthat name it as their party, and the order\'s `via Orders:` is the customers whose\n`Orders` link names it. **A link is a section, not a fact**, on the side it\npoints AT and on the side that holds it: `Customers.Orders` therefore leaves the\ncustomer\'s facts, because the section below already lists the same relation and\na link fact can only ever show the first of them. Both records state their name\nin the header and nowhere else \u2014 a ledger\'s columns are the date and the figure,\nso `Order no.` is on no column of that list and is still what the drawer behind\na line is called. `Total ordered (VND)` is the rollup inheriting the currency of\nthe column it sums: money is read off the model, never off the field\'s own line.\n';
55165
+ var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing the tables, fields, options, views, roles and first rows\na workspace starts with. `lotics scaffold check model.json` proves it offline \u2014\nno account, no network. `lotics setup model.json --email you@company.com` creates\nthe account and applies it. `lotics scaffold apply model.json` applies it again,\ninto the workspace the credential names.\n\n**There are two forms of this file.** The full one, below, spells the model out.\nThe `from` one names a published preset and carries only what this business\ndiffers by \u2014 see \xA7 Starting from a preset, and prefer it whenever a preset fits\nthe trade.\n\nApps are PLANNED here and built afterwards: `apps` names each app\'s screens as a\nshape over an entity, checked against the roles `field_roles` gives its fields,\nso the plan is refused before anyone builds a screen (\xA7 Apps and screens). The\nbuilt app lives in the workspace; publishing that workspace as a package is how\nit ships.\n\n## The rules\n\n- **At least one entity, at most 50.** More tables than that is a data model\n being designed, not scaffolded \u2014 scaffold the rest in a second call.\n- **Adoption is explicit.** `lotics setup` REFUSES an entity whose `label`\n already names a table in the workspace, naming every colliding label at once.\n `lotics scaffold apply` adopts those tables and adds the fields, options and\n views they are missing. Nothing is ever modified or deleted, so applying the\n same model twice creates nothing the second time.\n- **Adoption is by LABEL, not alias.** Change an entity\'s `label` and the next\n run asks for a NEW table beside the old one. 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, so a model that is about to be published\n as a preset carries the labels in use rather than the ones it was written with.\n- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere, and the result says\n `rows_skipped: true`: sample rows landing among a customer\'s real ones cannot\n be told apart from them.\n- **After the first run the WORKSPACE is the source of truth.** The file is an\n authoring input, not a mirror \u2014 scaffold never deletes what the file stopped\n naming.\n- **`lotics scaffold check` decides all of it offline**, and reports every\n problem in one run rather than the first: an alias that resolves to nothing, a\n link whose pair is not symmetric, and the rows themselves \u2014 a field the entity\n does not declare, an option alias the field does not declare, a link naming no\n row in the file, a `ref` used twice, a date that is not one, a value on a\n platform-computed field, and a files cell that is neither a relative path\n beside this file nor a `fil_` id.\n\n## Top level\n\n```jsonc\n{\n "entities": [ /* the tables */ ],\n "roles": [ /* workspace groups to create */ ], // optional\n "templates":[ /* inline html / email templates */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* the reporting role each field plays, keyed by entity then field */ }, // optional\n "write_rules": { /* what a CREATE finds, defaults and refuses, keyed by entity */ }, // optional\n "apps": [ /* the screens each app will have, as shapes over entities */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ], // optional\n "preset": { /* a trade\'s branches, for a PUBLISHED model */ } // optional\n}\n```\n\nThe other form names a preset instead of restating one:\n\n```jsonc\n{\n "from": "field_service", // the preset this model starts from, by slug\n "variants": ["crews"], // optional \u2014 its branches to merge in, in order\n "rename": { // optional \u2014 what THIS business calls each table\n "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } }\n },\n "entities": [ /* tables the preset does not declare */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* roles on the preset\'s fields and this business\'s own */ }, // optional\n "write_rules": { /* create-time clauses on the preset\'s entities and its own */ }, // optional\n "apps": [ /* the screens each app will have */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ] // optional\n}\n```\n\n**A model may not carry** `fixtures`, `knowledge` or `knowledge_expects`, and no\n`excel` / `word` / `pdf-form` template: each of those is content that lives in a\npublished bundle, which a model has none of. `apps` here is a plan of screens,\nnever built code. An unknown top-level key is an error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because scaffold matches by label.\n\n## Entity\n\n```jsonc\n{\n "alias": "order",\n "label": "Orders", // the table\'s name, which names the SET it holds\n "singular": "Order", // optional \u2014 ONE of them, for the act that opens one\n "description": "\u2026", // optional\n "fields": [ /* at least one */ ],\n "views": [ /* optional; an entity with none still gets the default grid */ ],\n "read_scope": { /* optional; absent, everyone with access to the table reads every row */\n "any": [\n { "member_of": "sales" }, // a role alias this model declares\n { "field": "scope", "is": ["shared"] } // a single select on this entity, by option alias\n ]\n }\n}\n```\n\n**`singular` is what a create says.** A register\'s own act and the panel it\nopens name the row being made \u2014 `New Order`, `H\u1ED3 s\u01A1 m\u1EDBi` \u2014 while `label` names\nthe table, so without it the button reads `New Orders`. Nothing derives it\n(English plurals are irregular), and most models need none: a Vietnamese noun is\nthe same word either way. `scaffold check` prints it beside the table under *Who\nwrites what* and NOTES a table that will name the set. An act on a child section\nkeeps the label: it adds a line to the register under the record the reader is\nalready standing in.\n\n**`read_scope` is a ROW rule, enforced by the platform.** A row is readable when\nANY clause holds: the reader is in that role\'s group, or the named select carries\none of those options. It is resolved at scaffold into the table\'s own row\nfilters, so an app, a workflow reading for a viewer, and the API all answer the\nsame rows \u2014 a per-record visibility field the app merely honours is a convention,\nnot a gate. `any` may not be empty (a rule nobody satisfies hides the table), and\nevery role alias and option alias in it must be one this model declares.\n\n## Field\n\nEvery field carries `alias`, `label`, an optional `description`, and an optional\n`required` \u2014 advisory only, read by app forms and workflows; the table itself has\nno required constraint. `label` may not contain `{` or `}` (formulas reference\nfields by label at the platform level).\n\n`default` is the value pre-filled into a NEW record. It applies on create only;\nexisting records are never backfilled. Only the types listed below accept one.\n\n### `text`\n\n```jsonc\n{ "alias": "name", "label": "Name", "type": "text",\n "unique": false, // optional \u2014 require distinct values\n "format": "text", // optional \u2014 "text" | "link" | "markdown"\n "default": "" } // optional\n```\n\n### `number`\n\n```jsonc\n{ "alias": "amount", "label": "Amount", "type": "number",\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage"\n "currency": "VND", // optional \u2014 ISO 4217\n "default": 0 } // optional\n```\n\n`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 THERE IS\nNO LEVEL AT ALL**: a level is a reading AGAINST a bound, so a row that states no\nbound draws nothing \u2014 cell, fact and all \u2014 rather than a numerator whose whole\nmeaning was the comparison. "Collected 0" beside a blank total reads as money\nagainst a job worth nothing. 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```jsonc\n{ "alias": "placed_on", "label": "Placed on", "type": "date",\n "format": "date", // optional \u2014 "date" | "datetime" | "date_range" | "datetime_range"\n "timezone": "Asia/Ho_Chi_Minh", // optional \u2014 IANA name\n "derive_from": "created_at", // optional \u2014 "created_at" | "updated_at"; makes the field read-only\n "default": "2026-01-01" } // optional; refused together with derive_from\n```\n\n### `boolean`\n\n```jsonc\n{ "alias": "paid", "label": "Paid", "type": "boolean", "default": false }\n```\n\n### `select`\n\n```jsonc\n{ "alias": "tier", "label": "Tier", "type": "select",\n "options": [ // at least one\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "multi": false, // optional\n "default": ["standard"] } // optional \u2014 option ALIASES; one unless multi\n```\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet.\n\n```jsonc\n{ "alias": "owner", "label": "Owner", "type": "select_member", "multi": false }\n```\n\n### `select_record_link`\n\n```jsonc\n{ "alias": "customer", "label": "Customer", "type": "select_record_link",\n "target_entity": "customer", // an entity alias this model declares\n "cardinality": "one", // optional \u2014 "one" | "many" (default "many")\n "sync_both_ways": true, // optional \u2014 keep a paired field on the target\n "paired_field_alias": "orders", // the partner field ON THE TARGET entity\n "display_field_aliases": ["name"] } // optional \u2014 what the link shows / the picker\'s columns\n```\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused.\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\n```jsonc\n{ "alias": "attachments", "label": "Attachments", "type": "files" }\n```\n\n### `formula`\n\n```jsonc\n{ "alias": "total", "label": "Total", "type": "formula",\n "formula": {\n "expression": "{amount} * 1.1", // fields on THIS entity, by alias, in braces\n "output_type": "number", // optional \u2014 what it YIELDS: "number" | "text" | "date" | "datetime" | "boolean"\n "format": "currency", // optional \u2014 how that result is DRAWN: "number" | "currency" | "percentage" | "link"\n "currency": "VND" // optional\n } }\n```\n\n`output_type` is what the expression answers WITH; `format` is how it is printed.\nThe platform infers the result at write time and ignores what you declare, so\nthis is a statement the offline checks read \u2014 which is what lets a role or a\nscreen clause accept a computed value: a caption over a derived name\n(`output_type: "text"`), a period over a settled date (`"date"`). A formula\ndeclaring neither says nothing about its result, and every rule that needs one\nrefuses it by name. `lotics scaffold export` writes the result a live workspace\ncomputed, so exporting a workspace fills these in.\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n```jsonc\n{ "alias": "total_ordered", "label": "Total ordered", "type": "rollup",\n "source_field_alias": "orders", // a select_record_link field on THIS entity\n "aggregate_option": {\n "operation": "sum", // count | sum | avg | median | min | max | range |\n // empty | filled | percent_empty | percent_filled |\n // unique | percent_unique |\n // earliest | latest | date_range |\n // checked | unchecked | percent_checked |\n // percent_unchecked\n "field_key": "amount" // a field ALIAS on the linked entity ("count" may omit it)\n },\n "filter": { /* optional \u2014 see Views; every field_key is an alias on the LINKED entity */ } }\n```\n\nThe operation must be one the aggregated field\'s type allows \u2014 `sum` over a\nnumber, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n```jsonc\n{ "alias": "customer_tier", "label": "Customer tier", "type": "lookup",\n "source_field_alias": "customer", // a select_record_link field on THIS entity\n "lookup_field_alias": "tier", // a field alias on the linked entity\n "order_by": { "field_key": "placed_on", "direction": "desc" } } // optional \u2014 pick the single extreme row\n```\n\n### `autonumber`\n\n```jsonc\n{ "alias": "seq", "label": "No.", "type": "autonumber",\n "prefix": "SO-", // optional \u2014 ignored when template is set\n "padding": 4, // optional \u2014 1..20, zero-pads the integer\n "template": "SO-{YEAR}-{N:4}" } // optional \u2014 {N}, {N:W}, {YEAR}, {YEAR:2}, {MONTH}, {DAY}\n```\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n```jsonc\n{\n "alias": "gold",\n "label": "Gold customers",\n "description": "\u2026", // optional\n "columns": [ // optional \u2014 omit to show every field\n { "field_alias": "name", "visibility": "visible", "width": 240 },\n { "field_alias": "tier", "visibility": "hidden" }\n ],\n "filters": { // optional\n "node_type": "group",\n "logic": "and", // "and" | "or"\n "children": [\n { "node_type": "condition", "type": "select", "field_key": "tier",\n "operator": "has_any_of", "value": ["gold"] }\n ]\n },\n "sort": [ { "field_key": "name", "order": "asc" } ], // optional; order is "asc" | "desc" | null\n "summary": { "amount": "sum" }, // optional \u2014 field alias \u2192 footer operation\n "frozen_columns": 1 // optional\n}\n```\n\nA condition\'s `type` is the field\'s type and its `operator` is one that type\nadmits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n`columns`, when present, is exhaustive and must not be empty: a view renders\nexactly the entries it holds. Omit the key to show every field.\n\n## Roles\n\nA role becomes a workspace group. Members are added afterwards, in the app.\n\n```jsonc\n{ "alias": "sales", "label": "Sales" }\n```\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes. An `html` template renders to a PDF when a workflow generates\nfrom it; `{{name}}` is filled from the workflow\'s data.\n\n```jsonc\n{ "alias": "order_ack", "label": "Order acknowledgement", "type": "email",\n "content": "<p>Hello {{customer}}\u2026</p>" }\n```\n\nA paper that has to look like a counterparty produced it \u2014 an official letter,\nan acceptance minute, a supplier\'s bill \u2014 is the same `html` template with a\nshell around the body: a letterhead, a reference line, a seal and a signature\nblock, and paper grain over everything. One shell, many bodies; the data is the\nonly thing that changes, so a workflow can re-issue it over any record.\n\n```jsonc\n{ "alias": "cong_van", "label": "C\xF4ng v\u0103n", "type": "html",\n "content": "\u2026the page below, as one JSON string\u2026" }\n```\n\n```html\n<style>\n .sheet{position:relative;width:718px;padding:44px 58px 30px;background:#fbfaf6;color:#111;font:14.2px/1.5 \'Liberation Serif\',serif}\n .grain{position:absolute;inset:0;opacity:.34;mix-blend-mode:multiply;background:url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'140\' height=\'140\'><filter id=\'f\'><feTurbulence baseFrequency=\'.9\' numOctaves=\'2\'/><feColorMatrix values=\'0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0\'/></filter><rect width=\'140\' height=\'140\' filter=\'url(%23f)\'/></svg>")}\n .top{display:flex;text-align:center;font-size:13.4px} .top>div{flex:1} .u{display:inline-block;border-bottom:1px solid #111;font-weight:700}\n .ref{display:flex;text-align:center;font-size:13.4px;margin-top:6px} .ref>div{flex:1} .ref .r{font-style:italic}\n h1{text-align:center;font-size:15.6px;margin:26px 0 18px} p{text-align:justify;text-indent:26px;margin:0 0 9px}\n .sig{display:flex;margin-top:20px} .sig .l{flex:1} .sig .r{width:290px;text-align:center;position:relative}\n .sig .nm{font-weight:700;margin-top:96px} .seal{position:absolute;left:4px;top:8px;width:166px;height:166px;opacity:.66;mix-blend-mode:multiply;transform:rotate(-17deg)}\n </style>\n <div class=\'sheet\'><div class=\'grain\'></div>\n <div class=\'top\'><div><b>{{issuer_parent}}</b><br><span class=\'u\'>{{issuer}}</span></div>\n <div><b>C\u1ED8NG H\xD2A X\xC3 H\u1ED8I CH\u1EE6 NGH\u0128A VI\u1EC6T NAM</b><br><span class=\'u\'>\u0110\u1ED9c l\u1EADp - T\u1EF1 do - H\u1EA1nh ph\xFAc</span></div></div>\n <div class=\'ref\'><div>S\u1ED1: {{number}}</div><div class=\'r\'>{{place}}, ng\xE0y {{day}} th\xE1ng {{month}} n\u0103m {{year}}</div></div>\n <h1>{{title}}</h1>\n <p>K\xEDnh g\u1EEDi: {{recipient}}.</p>\n {{{body}}}\n <div class=\'sig\'><div class=\'l\'><b>N\u01A1i nh\u1EADn:</b><br>- Nh\u01B0 tr\xEAn;<br>- L\u01B0u VT.</div>\n <div class=\'r\'><img class=\'seal\' src=\'{{seal_url}}\'><b>{{signer_title}}</b><div class=\'nm\'>{{signer}}</div></div></div>\n </div>\n```\n\n`lotics preview <file.html>` renders any such page to a PNG the way a demo\'s\nprops are made, sized to its content, so a paper can be looked at before it is\nput in a template.\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model, attaching at most 2000 documents between them \u2014 a real data set\nbelongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n`ref` is a local handle (lowercase letters, digits, underscores) that other rows\'\nlink fields address. It is never persisted.\n\nA `files` cell attaches documents: paths relative to this file (no `..`, never\nabsolute), which `check` proves exist and `apply` uploads into the workspace\nbefore any row is written \u2014 a paperwork business seeds its papers with its\nrows. The server accepts only `fil_` ids of files this workspace owns, which is\nwhat the upload leaves behind. After a run that wrote rows, `apply` writes the\nrecord ids beside the file (`<model>.last_run.json`): `delete_records` over\nthem is how a seeded set is reset, and applying again re-dates it.\n\n`fields` is keyed by field alias, and every value is read against the field\'s\nDECLARED type:\n\n| Field type | Value |\n|---|---|\n| `text` / `number` / `boolean` | the value itself |\n| `date` | `"2026-03-14"`, or a relative expression (below) |\n| `select` | the option ALIAS \u2014 `"gold"`, or `["gold","vip"]` for a multi-select |\n| `select_record_link` | `"<entity-alias>:<ref>"` naming another row in this file \u2014 `"customer:acme"`, or an array for several |\n| `select_member` | `"self"` only \u2014 the person applying the model |\n| `files` | paths beside this file \u2014 `["scans/pccc_letter.png"]` \u2014 uploaded by `apply`/`setup` before the rows are posted; or `fil_` ids of files already in this workspace |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n**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, joined through the\n`<model>.last_run.json` beside the file. Running it twice attaches nothing the\nsecond time \u2014 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\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Field roles\n\n`field_roles` names the reporting role a field plays on its entity \u2014 keyed by\nentity alias, then field alias \u2014 so every screen over the entity agrees on\nwhich column names the row and which select is the stage. A shape\'s slot binds\nto it (\xA7 Apps and screens). Like `rows` and `apps`, it is this file\'s: `check`\nproves it and the workspace never sees it. Each role sits on the types that can\nanswer it:\n\n| Role | On | Meaning |\n|---|---|---|\n| `identity` | `text`, `select_record_link`, `formula` | what a PERSON calls the row \u2014 the register\'s first column; a link where the row is "the product, at this branch"; a formula where the name is computed, and then it may not be `format`ted as a figure. NEVER an autonumber: a minted code is a `reference`. One per entity |\n| `reference` | `text`, `autonumber`, `formula` | the key the SYSTEM files the row under \u2014 a booking number, a container code \u2014 drawn as the supporting line under the name rather than as a column of its own, and leading where the entity has no `identity` at all. One per entity |\n| `mark` | `files`, or a `lookup`/`rollup` that resolves to one | the row\'s picture \u2014 its own, or the linked record\'s. NEVER A COLUMN: a register draws words, so a mark is projected only where a surface leads with one \u2014 the subject rung of a picture-led grid, and a record\'s itinerary. A child entity\'s mark reaches a record\'s run and nothing else. One per entity |\n| `lifecycle` | single `select` | the ordered stages a row walks; option order is the order. Which of them END the flow is `outcomes`. One per entity |\n| `category` | single `select` | what the row IS \u2014 a kind, a service, a book. One badge in the field\'s own colour, with no ladder behind it and no flow to advance. Repeats: a row can be of two kinds of thing |\n| `measure` | `number`, `formula`, `rollup` | a level read against a limit \u2014 see `against` and `alert`. Money where the model says so, and then it prints as money |\n| `expected_set` | `select` | its OPTIONS are the required set (documents, checks, services); an option no row has is a gap to show, not nothing. A multi-select draws as a ring and a fraction wherever it is drawn \u2014 absence is the information, and a list of what IS there says nothing about what is missing |\n| `amount` | `number`, `formula`, `rollup` | THE money of a ledger row; `signed_by` says which way it moved, `against` names the reference it is quoted off. One per entity |\n| `when` | `date`, or a `formula`/`rollup`/`lookup` that yields one | the ledger or timeline date \u2014 stated, or derived: the day money MOVED is a formula over the two columns that could hold it. `due` makes it a DEADLINE; without it the date is the plain day it is. `until` names the stage of this entity\'s own `lifecycle` at which the countdown is spent, and says `due` by saying so. One per entity |\n| `slot` | single `select`, or a `datetime` `date` | where in the DAY a row sits \u2014 the run it is read in under its day\'s heading on an itinerary. A select\'s option order IS the run\'s order; a time is ordered by the clock. A plain `date` is refused: that is the day itself. One per entity |\n| `party` | `select_record_link` | the counterparty. One per entity \u2014 and left off where the `identity` IS the link to them: the row is then NAMED by that record, and a strip under the title would state them twice |\n| `parent` | `select_record_link` | the record this row belongs to \u2014 a line\'s order, a paper\'s case. The parent\'s record shows these rows; the row shows the parent as a fact. One per entity, and it links to an entity this model declares |\n| `contact` | `text` | a way to reach a party \u2014 an address, a number, the town it is in. The record draws them as ONE row of links under the name, so the role sits on every field that is one of them. Repeats |\n| `currency` | single `select` whose option LABELS are ISO 4217 codes | the money THIS ROW\'s figures are in. Every amount and every money measure on the entity is then printed per row rather than in one code for the whole column, and the select is not drawn as a fact of its own. One per entity |\n| `verdict` | `boolean`, `formula` | a settled pass/fail \u2014 ticked, or computed. TRUE is the PASSING side wherever it is drawn, a register\'s risk flag included, so a field whose true means trouble is the same fact asked the other way round. One per entity |\n| `obligation` | `date` | a day something is OWED by \u2014 a cut-off, a permit expiry, a payment due. It counts down by definition. `satisfied_by` names what CLOSES it (or `until`, the stage the record stops owing it at), `label` what is owed. Repeats: a row owes several things, and a shape that fans them draws one row each |\n\n**`due` is what makes a `when` a deadline.** A date a row is merely filed by \u2014 the\nday a lead arrived, the day a movement happened \u2014 is neither early nor late,\nhowever long ago it was. Without the clause every desk drew every `when` as a\ncountdown and a two-month-old lead read "40 days overdue" on a record nobody was\nlate on. So a bare `when` is a plain date, and `{ "role": "when", "due": true }`\nis the one that counts down. An `obligation` needs no clause: a day something is\nowed by is a deadline already.\n\n**`until` stops a countdown where the work is DONE.** A deadline counts down so\nsomebody acts on it, and a row that has arrived needs nothing: without the clause\na delivered order is tinted for a date it met, and every settled row past its day\njoins the urgent ones in the reader\'s glance. It names one option of the entity\'s\n`lifecycle`; at that stage, at every stage past it and at every `outcome`, the\ndate is drawn as the plain day it is. On a `when` it also says the date is `due`.\n\n"One per entity" is one COLUMN, never one value on screen. A second counterparty\nor a second parent stays a ROLELESS `select_record_link`: which field fills a\nslot is the screen\'s answer (\xA7 Apps and screens), and the extra link is a fact on\nthe row with its own section on the record, headed by that link\'s own label. A\nrole naming two fields would move the ambiguity into every shape that reads it.\n\nA COMPUTED field is admitted where its RESULT is what the role needs, and\nrefused where it is not: `identity` and `reference` need `text`, `when` a `date`,\n`mark` `files`. A formula\'s result is its `output_type`; a rollup\'s and a\nlookup\'s is read through the walk. So a formula yielding a number is refused in a\nname\'s place, a lookup landing on anything but `files` is refused as a `mark`, and\na formula declaring NO result is refused by all four \u2014 naming `output_type` as\nthe remedy, because a value drawn as a type nobody stated is the wrong cell with\nnothing saying so. The result also decides the DEVICE every screen draws the value\nwith: a rollup taking the `latest` of a set of dates is drawn as a date, a lookup\nof a select as the words it holds.\n\nA bare role name is the common form. A role that is read against a SECOND field\ntakes the object form:\n\n- **`measure`** names its limit: `against` \u2014 a constant, or a field on the same\n entity the row states as a number (a `number`, or a formula or rollup whose\n result is one) \u2014 and `alert`, which side of it needs attention, `over` a\n capacity or `under` a minimum. The two come together.\n- **`measure`** names what its number COUNTS, where the unit changes how the row\n is DRAWN rather than how the figure reads: `counts: "days"` spans a stop across\n that many days of a run, so a three-night stay fills three of its days instead\n of only the one it starts on. Stated, never inferred \u2014 nothing about a `3` says\n whether it is nights, pallets or hours, and a quantity drawn across a week is a\n run nobody can read.\n- **`lifecycle`** names the stages that END the flow: `outcomes`, option aliases\n of that select. A row in one has ARRIVED \u2014 delivered, cancelled, written off \u2014\n so a desk draws them apart from the ladder instead of chaining them after each\n other. Not every option may be an outcome, and terminal-ness belongs here, never\n written into a stage\'s label.\n- **`amount`** names the REFERENCE it is read against: `against`, a field on the\n same entity the row states as a number. A price against the list it is quoted\n off, a rate against the going one \u2014 and the catalogue item\'s record draws the\n pair on one axis with the gap said in words. Never a constant (a price typed\n into the model ages with nothing to update it) and never an `alert`: a limit is\n a ceiling a row can pass, and beating a reference is the point.\n- **`amount`** names the select that SIGNS it: `signed_by`, a select on the same\n entity, and `outflow`, the options of that select under which the amount is\n money going out. Without it every shape over the ledger adds both directions\n together: a month whose net was small reads as the total of everything that\n moved, and the trend plots one rising line. The two come together, and\n `outflow` is some of that select\'s options, never all.\n- **`expected_set`** names what CONDITIONS the set: `required_by`, a field on the\n same entity, and `options`, which of that field\'s values require which entries.\n A row whose value is not named requires nothing. Without it the denominator is\n every option, so a set whose entries are mutually exclusive by kind reads\n `1 of 4` on every complete row \u2014 a denominator no row can reach is worse than\n no role. The field is a single `select`, keyed by its option aliases \u2014 or a\n YES/NO the row answers itself (a `boolean`, or a formula resolving to one),\n keyed by `"true"` and `"false"`: a check that FAILED owes its consequence \u2014 the\n photograph, the owner, the day it is due \u2014 and there is no select of the record\n that says so. `from: "parent"` reads that field off the record these rows hang\n under instead, through this entity\'s own `parent` link: which documents an order\n owes is the ORDER\'s type, and the papers carry no column that says it. Which of\n the two a set takes is the set\'s own shape and not a choice \u2014 a multi-select\n holds the whole set on one row and is narrowed by that row (`from` omitted), a\n single select is one entry per row and takes `from: "parent"` \u2014 and the other\n way round is refused rather than left conditioning nothing.\n- **`obligation`** names what ENDS it: `satisfied_by`, a `date` or a `files` field\n on the same entity \u2014 the day it was done, or the paper that proves it \u2014 or,\n where the business stamps nothing, `until`, the stage of this entity\'s\n `lifecycle` at which the record stops owing it: a deposit is owed until the\n order is paid, and nobody records the act of paying twice. One or the other,\n never neither \u2014 without one every obligation the business ever met stays on the\n desk. Optionally `label`, what is OWED as the reader says it ("G\u1EEDi SI"), which\n is not what the column holding the date is called ("SI cut-off").\n\n```jsonc\n"field_roles": {\n "product": { "name": "identity", "photo": "mark" },\n "stock": { "on_hand": { "role": "measure", "against": "minimum", "alert": "under" },\n "uptime": { "role": "measure", "against": 80, "alert": "under" } },\n "order": { "stage": { "role": "lifecycle", "outcomes": ["delivered", "cancelled"] },\n // A DEADLINE COUNTS DOWN \u2014 `payment.paid_on` below is a plain day.\n "ship_by": { "role": "when", "due": true },\n // The day the papers are owed by, and the day they went.\n "papers_due": { "role": "obligation", "label": "File the papers", "satisfied_by": "papers_sent" },\n // Nothing stamps a deposit paid \u2014 the order\'s own stage ends it.\n "deposit_due": { "role": "obligation", "label": "Take the deposit", "until": "delivered" } },\n // WHICH PAPERS THIS ONE OWES IS THE ORDER\'S OWN TYPE, and no document says it.\n "document": { "order": "parent",\n "kind": { "role": "expected_set",\n "required_by": { "from": "parent", "field": "kind",\n "options": { "export": ["invoice", "customs"] } } } },\n // `kind` is the direction select \u2014 options `received` and `paid_out`, no role of its own.\n // Every figure on a quotation is in the currency that row names.\n "quote": { "unit": "currency", "total": "amount" },\n // What the price is quoted off, so the item\'s page reads one against the other.\n "service": { "price": { "role": "amount", "against": "list_price" } },\n // The day the money moved is neither early nor late, so it stays a bare `when`.\n "payment": { "paid_on": "when",\n "value": { "role": "amount", "signed_by": "kind", "outflow": ["paid_out"] },\n // A receipt owes a receipt voucher; a payment owes an invoice and a payment voucher.\n "papers": { "role": "expected_set",\n "required_by": { "field": "kind",\n "options": { "received": ["receipt_voucher"],\n "paid_out": ["invoice", "payment_voucher"] } } } },\n // A CHECK THAT FAILED OWES ITS CONSEQUENCE \u2014 keyed by the row\'s own answer,\n // and the answer that owes nothing is omitted rather than named with an empty list.\n "check": { "passed": "verdict",\n "evidence": { "role": "expected_set",\n "required_by": { "field": "passed",\n "options": { "false": ["photo", "owner", "due"] } } } }\n}\n```\n\nIn a file that starts from a preset (\xA7 Starting from a preset), `field_roles`\nmay name the preset\'s fields as well as this business\'s own; a role the preset\ndeclares itself is kept unless this file names the same field, and `null`\nclears it.\n\n## Write rules\n\n`write_rules` is what only a CREATE meets \u2014 keyed by entity alias, like\n`field_roles`, and this file\'s in the same way: `check` proves it and the\nworkspace never sees it. An update names one field that moved; a create takes a\ndraft whole, so it has to know which row a name already belongs to, where a\nvalue comes from when nobody types it, and what a figure or a picker may hold.\nThe generator writes one `create_<entity>` workflow and one `New<Entity>Dialog`\nper entity an app can operate, from these clauses and the fields\' own\n`required`.\n\n```jsonc\n"write_rules": {\n // A customer is RECOGNISED by their address. Where an entity names a customer\n // as its `party`, the create takes the email instead of a picker, reuses the\n // row it matches and mints one only where nothing does \u2014 so the book never\n // grows a second Acme because somebody typed the name differently.\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, at\n // the control the figure was typed into.\n "quantity": { "min": 1, "max": 9999 },\n // The price is fixed at the moment of ordering \u2014 COPIED off the product,\n // not looked up for ever after, so the price list moving next week does\n // not silently reprice an order already placed.\n "unit_price": { "default_from": "product.price" },\n // And nothing is sold off an empty shelf. The picker reads only the rows\n // that answer this, and the write refuses the same rows again \u2014 a caller\n // who never opened the picker is bound by it too.\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}\n```\n\n| Clause | On | Meaning |\n|---|---|---|\n| `natural_key` | the entity | the field aliases a row is recognised by. A `text` key declares `unique: true` on the field itself \u2014 two rows sharing it would make find-or-create pick whichever the read answered first \u2014 and a key is `text` or `number`, because a person types it back |\n| `default_from` | a field | `"<link alias>.<field alias>"` \u2014 the value is copied from the linked row when the row is created, never asked. The link is a one-row link on this entity and `required`, because there has to be a row to read, and the two field types must match |\n| `min` / `max` | a `number` | the figure is refused outside them, on create and on update |\n| `options_where` | a `select_record_link` | an `and` group of plain conditions over the TARGET\'s own fields, each `field_key` a field alias. It narrows the picker\'s read and is refused again where the write lands, so the link is `required` |\n\nA field\'s own `required` is not here: the contract carries it, every write path\nrefuses the row by field name from it, and the generated panel stands its commit\ndown until the same set is filled. `unique` is the text field\'s own clause\n(\xA7 `text`) \u2014 the create says so at the control before the column does.\n\nWhat a create asks is what a person STATES. A `default_from` field is not drawn,\na `lifecycle` opens at its first rung, the stamp an obligation\'s `satisfied_by`\nnames is not asked (the row is only now taking that on), and files are attached\nto the row afterwards \u2014 one fact, one place. `scaffold check` prints every clause\nunder its entity in **Who writes what**.\n\n**The `parent` is prefilled only in the door that mounts the act.** A row opened\nfrom the record it hangs under takes that record from the door it was pressed\nin, so the link is neither asked for nor drawn. The same entity\'s act on its own\nregister has no such row, so there the parent is an ordinary picker the draft\ncannot be committed without \u2014 a payment opened from a money app belongs to a\ncase either way.\n\n## Apps and screens\n\n`apps` is the plan: each app the reader will build, as ONE REGISTER \u2014 a SHAPE\nover an ENTITY \u2014 under `screen`. Nothing here is built by the scaffold: the plan\nis what `lotics scaffold check` prints back, register by register with the field\nin every slot, so it is read and corrected before a screen exists.\n\n**THREE THINGS DECIDE EVERY SCREEN AND EVERY RECORD**: the SHAPE, whose slots are\nthe table below; the ROLES the entities declare (\xA7 Field roles), which fill those\nslots and decide what each child\'s rows become; and the CLAUSES, which are where\nthe plan overrides what the two would answer. This index says which is which, so\na clause is reached for only where the first two have no answer.\n\n**What a child\'s rows become.** One section per LINK into the record, and what\nthe CHILD declares decides its kind \u2014 the first line it answers wins.\n\n| Printed as | The child declares | The reader gets |\n|---|---|---|\n| `documents:` | an `expected_set` select, one entry per row | the desk of what is owed, each row\'s files attached to it |\n| `timeline:` | an `obligation` closed by a DATE | the ordered stops, the promised day against the day it happened |\n| `thread:` | a `markdown` body, a `party` and a `when` | the correspondence, its answer, and whose move it is |\n| `itinerary:` | a `when` and a `lifecycle`, on rows a JOB\'s record OWNS | the day heads the run, one line per stop, the sum under it |\n| `lines:` | anything else the record owns (`parent`) | the register of the rows it is made of |\n| `history:` | anything else naming it as their `party` | the same rows read as that party\'s own history |\n| `via <link>:` | a link carrying neither role | the register, headed by that link\'s own label |\n| `related:` | rows of an entity this app lists NOWHERE | one counted line, and a press that opens their register |\n\nA child carrying the BODY and one of the other two is a plain register, and\n`scaffold check` names the declaration it is one short of rather than letting it\nfall through silently. A `party` beside a `when` and no body is not a near miss \u2014\nthat is every ledger line there is.\n\n**The clauses, by name.** On the APP: `scope`. On the `screen`: `record`, `tabs`,\n`slots` (`roles` on a `custom` one), `columns`, `writes`, `period`, `filters`,\n`summary` (`above`, `totals`, `ageing`, `trend`), `facts`, `presentation`\n(`lead`, `density`, `layout`, `until`), `acts` (`row`, `record`, `selection`,\n`export`, `import`), `section_acts`, `sections`. Every one is optional, every one\nis printed back by `scaffold check`, and a screen that states none gets what its\nshape and its roles answer by themselves.\n\n**ONE APP IS ONE REGISTER AND THE RECORDS IT OPENS.** A second register beside\nit is a second job on one page: the reader arrives on whichever the nav listed\nfirst and decides, every time, which of the two they came for. So an app has one\ndestination and everything else about a row is DISCLOSED by opening it \u2014 the\nfacts by tier, the children as lists, the related as counts, one primary act, and\na filter or a group where a strip of destinations would have been. What used to\nbe a second screen is a section of the record, or another app; a model still\nsaying `screens` is refused by name.\n\n**The unit of an app is a JOB, not a person and not a table.** A job has its\nown outcome (something exists or is settled when it is done), its own subject\n(the record it advances), and an end that does not wait on the rest of the\nwork. Two tasks are ONE job when neither finishes without the other and both\nadvance the same record. One app per job, and that is what makes an app a\nwrite-ownership boundary: what the job settles is its app\'s alone to write,\nplus everything it must read to settle it well. A person holding several jobs\nopens several apps \u2014 one app for everything one person does is the dump. A step\nof the job is a section of the record, never a register beside it; a reference\nthe job needs at hand is read inside the record it is about.\n\n**Two people signing is two jobs**, because the outcome belongs to the signer:\nthe desk that prepares against the gate that releases. So is a different\ncadence \u2014 reference data edited monthly and read by the public site, beside a\ndaily desk \u2014 and a different reader, the owner\'s read-only questions. Device,\nplace and step never split a job. A super app holds more than one job; an\nover-split holds less than one, a job cut by device, place or step. The count\nper workspace falls out of the jobs, typically two to six, and is never the\ninput: review the plan app by app, naming who holds it, the job in one\nsentence, what it writes and what it reads.\n\n*A two-van appliance repair shop.* The technician\'s job is the call-out: the\noutcome is a visit done, the subject is the call-out record, and quoting it and\nscheduling it are one job, because neither finishes without the other and both\nadvance that record. The owner holds two of his own \u2014 billing the month\n(outcome invoiced, subject the invoice, settled against visits already closed)\nand the price list the booking page quotes from, edited monthly. Three jobs,\nthree apps, and the owner opens two of them.\n\n**Inside a job the caps hold**: six flow stages on a desk, seven facts before a\nrecord\'s fold. Past them, look for the second job hiding inside. And a job\'s app\nis DENSE: every create the job needs lives in it, so its user never leaves it to\ncorrect a figure the screen in front of them is stating \u2014 three workflows is a\nscreen, not a desk.\n\n**A handoff is a stage change.** Where one job ends the record moves stage and\nthe next job\'s desk opens on it; a field two jobs must both write is declared\nshared at the split, naming the stage each may write it in.\n\n**Name an app for the JOB, in the trade\'s own words, never for who it is for.**\nA title on the door says nothing about what the person who opened it came to\ndo, and it is wrong the day the org chart moves.\n\n```jsonc\n"apps": [\n {\n "alias": "sales", "name": "Sales",\n "description": "\u2026", "icon": "briefcase", "theme": { "color": "blue" }, // optional\n "screen":\n { "alias": "orders", "label": "Orders", "shape": "lifecycle_desk", "entity": "order",\n "record": "drawer", // optional \u2014 "drawer" | "page"; absent, the shape decides\n "tabs": "stage", // optional \u2014 a select on the entity, or null; absent, the shape decides\n "writes": false, // optional \u2014 default TRUE; false makes this screen a reader\n "period": "due_date", // optional \u2014 any field whose value is a date, derived ones included\n "filters": ["kind", // optional \u2014 a single-select, or a lens the model states itself\n { "label": "Qu\xE1 h\u1EA1n l\u01B0u", "predicates": [\n { "label": "\u0110\xE3 qu\xE1", "tone": "red",\n "where": { "node_type": "group", "logic": "and", "children": [\n { "node_type": "condition", "field_key": "owed", "operator": "greater_than", "value": 0 }] } }] }],\n "summary": { "totals": ["total"], "above": ["total", { "field": "owed", "at": "end" }],\n "ageing": { "amount": "owed", "due": "due_date", "buckets": [30, 60, 90] },\n "trend": { "field": "total", "direction": "up" } }, // optional \u2014 see below\n "facts": { "groups": [{ "caption": "Pricing", "fields": ["rate", "surcharge"] }] }, // optional \u2014 the record\'s named bands\n "presentation": { "lead": "none", "density": "dense" }, // optional \u2014 how it is DRAWN; absent, what the rows are decides\n "acts": { "row": [{ "label": "Issue the note", "template": "debit_note", "place": "cta" },\n { "kind": "agent", "label": "Read the papers", "agent": "reader", "fills": ["ref", "due_date"] },\n { "kind": "workflow", "label": "Hand it to dispatch", "workflow": "hand_to_dispatch",\n "inputs": { "order_id": "record", "owed_by": "due_date" } }],\n "record": [{ "label": "Print the file", "template": "dossier" }], // optional \u2014 the record\'s own header menu\n "selection": [{ "label": "Statement", "template": "statement" }], // optional \u2014 the work it hands on\n "export": true, // optional \u2014 the rows in view, saved\n "import": { "kind": "import", "label": "Upload the sheet", // optional \u2014 a file turned into rows\n "entity": "order", "key": "code", "columns": ["rate"] } },\n "section_acts": { "line": [{ "label": "Chase the lines", "template": "chaser" }] }, // optional \u2014 a verb on one section\n "sections": { "line": { "draw": "worksheet", "cost": "buy_rate", "sell": "rate" } }, // optional \u2014 priced lines, worked down in place\n "slots": { "identity": "code", // optional \u2014 slot \u2192 field, where the roles cannot decide alone\n "subject": ["customer", "project"], // \u2026or the TIERS a fold walks down, outermost first\n "stage": { "field": "state", "quick": true } }, // \u2026or the field AND the reader\'s own control for it\n "columns": ["source", "owner"] } // optional \u2014 extra facts after the slots, shed first at narrow width\n }\n]\n```\n\nA shape is a proven screen with named SLOTS, each filled by a field carrying a\nrole (\xA7 Field roles). A slot with exactly one candidate on the entity binds by itself;\ntwo candidates need naming in `slots`; a field fills one slot; a required slot\nwith none is refused \u2014 a lifecycle desk over an entity with no `lifecycle`\nselect cannot be built.\n\n**A LIST IS A HIERARCHY, not a set of columns.** A group register\'s `subject`\ntakes one \u2014 `"subject": ["customer", "project", "order"]`, outermost first \u2014 and\nthe register becomes one the reader walks DOWN: it folds by the first tier, a\nnode narrows to the tier inside it, the last tier opens the set behind the\nfigure, and a trail says where they are. Every tier is a field of this entity\ncarrying a role that slot accepts, and none of them fills a second slot. It is\nthe slot\'s own answer that decides: a list on any other is refused, because two\ncolumns in one cell is not a fold. Without it the same business needs a register\nper depth, and the same money is folded twice with nothing saying it is the same\nmoney.\n\n**A QUICK SLOT IS FOR A DECISION THE READER MAKES FROM THE ROW ALONE.** A slot\'s\nvalue in `slots` is normally the field\'s alias; `{"field": \u2026, "quick": true}`\nsays the column is not a reading of that value but the CONTROL for it \u2014 pressed,\nit writes that one field as a diff through the record\'s own update, so the\nregister and the page behind it land the same write. THREE roles are such a\ndecision, and `quick` on any other is refused: a `lifecycle`, which rests as the\nstage\'s own dot and label, takes a rule under it on hover and focus, and opens\nthe field\'s stages with the ones that END the flow last \u2014 ONE control, which IS\nthe reading;\na `verdict`, which is the switch; and a `measure`, which is the figure typed in\nits own cell \u2014 a reading taken per row IS the row, which is what a timesheet\'s\nhours and a count sheet\'s tally are. Everything else is decided against what the\nrecord holds, and the record has to be open. All three carry the record\'s own\nblockers \u2014 a move the ladder holds is a move the cell holds, and it names the\nentries the row still owes \u2014 and an outcome asks before it commits. Add `"order": "sequence"`\nwhere the stages are a WALK rather than a set of destinations: the picker is then\nordered as the walk and the step the row takes next is MARKED in it. It never\nadds a second control \u2014 a column that answered with a reading in some rows and a\nverb in others is two columns about one value. A screen stating `writes: false`\nrefuses `quick`: a reader who may operate nothing has no decision to take.\n\n**THE SLOTS ARE THE ROW\'S ANSWER; `columns` IS ITS CONTEXT.** `"columns":\n["nguon", "phu_trach", "nhu_cau"]` names extra fields of the entity, drawn AFTER\nevery slot and in this order, each by what its column IS \u2014 a chip for a select,\nthe person\'s name for a member, the day for a date, the figure for a number, a\ntick for a boolean, the words for everything else. They are ranked BENEATH every\nslot, so a phone sheds all of them before it sheds any of the shape\'s own\ncolumns. At most four, and refused where one would say what a slot already says,\nover a files field (files are read, so they are a section of the record), over a\ncell holding several (a column of comma lists compares nothing), on a shape whose\nrows are not the entity\'s records in a table, and beside a `presentation.layout`\nthat draws the rows with a device of its own. A fifth fact worth the width is a\nslot the shape is missing \u2014 say so rather than widening this clause.\n\n| Shape | Answers | Required | Also fills | Record | Tabs |\n|---|---|---|---|---|---|\n| `lifecycle_desk` | what is stuck, what do I move next | `lifecycle`, `identity` | `mark`, `party`, `amount`, `measure` (level), `when` | page, or drawer where the entity carries `parent` | the lifecycle\'s stages |\n| `party_register` | who is this, our history, is there a risk | `identity` | `mark`, `contact`, `measure` (worth), `verdict` (risk) | page | none |\n| `offering_register` | what do we offer, at what price, can I sell it | `identity` | `mark`, `amount` (price), `measure` (availability) | page | none |\n| `transaction_ledger` | does this period reconcile, what is unexplained | `when`, `amount` | `identity` (reference \u2014 the line\'s own number), `party`, `lifecycle` (classification), `expected_set` (document) | drawer | none |\n| `monitored_asset_set` | what needs attention, is that number normal | `identity`, `measure` (level) | `mark`, `lifecycle` | drawer | none |\n| `obligation_desk` | what is due next, and has it been done | `identity`, and at least one `obligation` on the entity | `when` (runway) | page | none |\n| `trend_deep_dive` | how did the period go, and why | `when` | `measure`, `amount` | drawer | none |\n| `reconciliation_desk` | what does not match, and by how much | `identity` (reference), and two figures \u2014 `ours` and `theirs`, each an `amount` or a `measure` | `category` (reason), `verdict`, `when` | drawer, on the PAIR | the dated runs, where the model names a select |\n| `entry_matrix` | one value per subject per period | `identity` (subject), `across` \u2014 a `when`, or a `category` for a fixed column set | `measure` (value), `lifecycle` (state), `category` (note) | drawer, on the cell\'s row | none |\n| `guided_run` | complete an ordered sequence, a step at a time | `identity` (step) | `slot` (sequence), `capture` \u2014 a `measure`, `verdict` or `expected_set` \u2014 `verdict` (gate), `mark` (media) | page: the run IS the record | none |\n| `live_board` | is everything OK, right now | `lifecycle` (state), `identity` | `verdict` (exception), `when` (since), `measure` (level), `category` (where) | drawer | none |\n| `group_register` | what does each group come to, and what is behind it | `subject` \u2014 a `category`, `party` or `when` | `amount`, `measure`, `identity` (member) | page: a row is an aggregate, and the door is the SET behind it | none |\n| `media_set` | scan a body of pictures by where they belong | `mark` (picture), `identity` | `category` or `party` (group), `when`, `verdict` (current against superseded) | drawer | none |\n| `day_sheet` | what happened on each day | `when` (the day) | `lifecycle` (state), `measure` (level), `amount`, `category` (note) | page: a day composes several child logs | none |\n| `resource_schedule` | what is on each resource, in what order, under what ceiling | `lane` \u2014 a `party` or a `parent` \u2014 `identity`, `when` (the start) | `measure` (duration), and `load` and `bulk`, each a `measure` read against a ceiling of the LANE\'s \u2014 a vehicle fills by weight and by room and stops at whichever runs out first; `lifecycle` (stage) | drawer, over the lanes | none |\n| `worksheet` | priced lines the reader edits, totalling to one figure | `identity` | `category` (group), `measure` (quantity), and `cost` and `sell`, each an `amount` or a `measure` | drawer, per line | none |\n\n**A record is one of five KINDS, and the shape plus the roles decide which.**\nEvery one of them is ONE READING COLUMN, and the ORDER of that column is the\nwhole of what the kind means.\n\n| Kind | The column, top to bottom |\n|---|---|\n| WORK RECORD \u2014 a `lifecycle_desk` over rows belonging to nothing | the one act that moves it \xB7 what it IS \xB7 the papers it owes \xB7 the rows it is made of \xB7 the book of what it came to |\n| LINE \u2014 the same shape over rows carrying `parent` | its ladder \xB7 the arithmetic behind its amount \xB7 the rows and papers under it \xB7 its particulars, last (the key it is filed by is the line under its title) |\n| PROFILE \u2014 a `party_register` | its history, OPEN, grouped by year \xB7 the rest of its registers \xB7 its facts (the ways to reach it are under its name, its settlement is the band) |\n| CATALOGUE ITEM \u2014 an `offering_register` | its own words (the picture leads the header at media scale, the price is read against its reference in the band) \xB7 what uses it \xB7 its facts |\n| EVIDENCE \u2014 a `transaction_ledger` | the PROOF \xB7 what the paper is, compactly \xB7 the rest \xB7 the links to the record it settles and the party it was with |\n\nNothing in the file states the kind: a clause that could say it could say the\nwrong one, and `parent` already says whether a row stands alone. `record`\noverrides the DOOR where the shape\'s own answer is not the one wanted \u2014 and its\nthird value, `"expand"`, is not a door at all: the row REVEALS what it holds in\nplace, where there is nothing behind it worth navigating to. A row read that way\nreads as a LINE whatever the shape would have opened: its ladder, the arithmetic\nbehind its amount, then its particulars, because there is no header above it to\nhave stated the name first. It is REFUSED for two reasons, and they are not the\nsame refusal. On a shape whose record is a page by nature \u2014 a `guided_run`, a\n`group_register`, a `day_sheet`, an `obligation_desk` \u2014 because each of those\nstates the opposite where its door is declared: what its row leads to is read\nwhole, not as four more columns than the register had room for. And on a shape\nthat draws its rows with a device of its OWN \u2014 an `entry_matrix`, a `media_set` \u2014\nbecause a cell of a grid and a tile on a wall have no band under them for the row\nto reveal into. For the same reason a revealed register is drawn as a TABLE: a\n`presentation.layout` of anything else is refused, and the reader is not offered\nthe arrangement either, since it would take away the only door the register has.\n\n**`scope` \u2014 the one subject every read of the app narrows to.** Stated on the\nAPP rather than on its screen, because it is not a filter: a record opened under\nit stays under it, the pick survives closing the app, and every app declaring the\nsame `entity` shares one pick per viewer \u2014 which is the whole value, and what\nstops a construction workspace asking which project twelve times. `entity` is the\nmodel\'s entity whose one row the app is read inside, and `param` is what the\napp\'s reads take that row by.\n\n```jsonc\n{ "alias": "site_work", "name": "Site work",\n "scope": { "entity": "project", "param": "project_id" },\n "screen": { \u2026 } }\n```\n\nThe register\'s own entity has to REACH it \u2014 its own rows, or rows that name one\nthrough a link \u2014 or the switcher is a control that changes nothing. Until a\nsubject is picked the app reads NOTHING and says which one it is waiting for: an\nunscoped read over a scoped app is every row in the workspace drawn as one\nproject\'s work, which looks correct.\n\n**ONE RECORD SURFACE PER ENTITY, AND ITS KIND IS THE ENTITY\'S.** An app is one\nregister, so the record its rows open is the register\'s own and the kind comes\nfrom that register\'s shape. Another app over the same entity opens the same kind\nof record: a payment read from a cash book and one read from a trend are both\nmovements, and both read proof first. A shape with no kind of its own (a trend, a\nmonitored set, a `custom` screen) says only where the record opens.\n\n**Two things a register states about a row beyond which field fills which slot.**\nA `measure` is read AGAINST its limit (`against`/`alert`) where the shape\'s slot\ntakes one \u2014 `level`, on a lifecycle desk and a monitored set, and every slot of a\n`custom` screen, which composes the frame\'s own column; `worth`, `availability`\nand a trend\'s headline are plain figures, and a limit there would read as a meter\nin the record over a number the row printed bare. And the name carries a\nSUPPORTING LINE where the entity states a key to file the row under: the `parent`\nit belongs to, its `reference`, an `autonumber`, a text formula \u2014 in that order,\nfirst hit wins, never a field a person types prose into. Every record heads with\nthat same key, so the register and the door it opens name one row one way.\n\n**The act that opens a row belongs to a register that LISTS the entity\'s rows**\n\u2014 a desk, a register, a ledger, a monitored set, or a `custom` screen. A shape\nwhose rows are not records has neither that act nor the door a counted line\nelsewhere leads to: an obligation desk\'s rows are deadlines and a trend\'s are\nperiods, so a create pressed there would open a row the screen cannot show. An\nentity whose only app is one of those has no create at all \u2014 give it an app whose\nregister its rows are read in.\n\n**An `obligation_desk`\'s rows are not its entity\'s rows.** The shape fans the\nentity\'s `obligation` fields out \u2014 one row per thing still owed, which is one\nwhose date is SET and whose `satisfied_by` is empty \u2014 so a l\xF4 with four cut-offs\nis four rows, and the three it has met are not there at all. The row\'s name is\nthe obligation\'s, its supporting line the record\'s `identity`, and the door is\nthat record. A `when` bound to `runway` is what the countdown\'s ring is measured\nfrom. Over an entity declaring no `obligation` the shape is refused: there is\nnothing to count down to.\n\n**What a register carries beyond its columns.** Six clauses, each emitted as\nthe prop the kit draws it with; every one is optional and a screen that states\nnone gets a register of columns and nothing else.\n\n- **`period`** \u2014 a field on the entity whose value is a DATE. The toolbar gains a\n date-range control (this month to start), and the rows it keeps are what the\n register AND every figure below are computed over, so a band can never be over a\n different window than the rows under it. Any such field, not only the `when`\n role, and however the date got there: one table is read two ways (a cost ledger\n by the day a line arose, the cash book over the same table by the day it\n settled), a role is one field\'s, and the day money MOVED is often a formula over\n the two columns that could hold it \u2014 which then declares `output_type: "date"`.\n A `trend_deep_dive` brings its own period and takes none here; a `live_board`\n is PERIODLESS and takes none either, for the opposite reason \u2014 it answers what\n is true NOW, and a date range over it draws what was true in the window in the\n live state\'s own colours.\n- **`filters`** \u2014 the chips beside the search. A single-`select` field\'s alias\n offers that field\'s own live options; it is the lens a register is read through\n over and above the one band its strip gives, so the strip\'s own field is\n refused here (one dimension, one control) and so is a multi-select (a row would\n answer the chip several ways at once). The rows the register and every figure\n below are computed over are what the chips AND the period kept.\n A DERIVED LENS is the other form \u2014 `{"label": \u2026, "predicates": [{"label": \u2026,\n "tone": \u2026, "where": \u2026}]}` \u2014 where the sets a reader narrows by are ones the\n MODEL names and no column holds: rate validity, plan urgency, free-time\n overrun. Each `where` is an `and` group of plain conditions over the entity\'s\n own fields, each `field_key` a field alias, and it is read off the row, so the\n operators are the comparisons a value answers \u2014 `number` and `text` equality,\n `number` ordering, `boolean` equality, `select` `has_any_of`/`has_none_of`, and\n `is_empty`/`is_not_empty` on any of them. Nothing DATED: a relative point needs\n a clock, and a screen states one date control (`period`), so a second beside it\n would be two windows with nothing saying which a figure was computed over \u2014 a\n date question is asked as a column the model derives and read here as a number\n or a yes/no. `tone` is the option palette\'s, because the chip draws these\n exactly as it draws a select\'s options.\n- **`summary`** \u2014 what the rows in view come to. `above` is a band OVER the\n register: `"counts"` for how many rows are in view and, for the FIRST\n `measure` the model gives a limit, how many are past it (accented only when\n one is) \u2014 one such set, because a band naming three is the dashboard a\n register is not; a list of number-field aliases for what they add up to, and a\n `percentage` named there is refused, since a share summed over the rows in\n view is a figure of no kind (draw it as a column, whose own total reads the\n mean). A figure written `{"field": \u2026, "at": "end"}` is a STOCK instead: the\n reading standing at the END of the window rather than the sum of the readings\n in it, which is the only way opening + in \u2212 out = closing reconciles \u2014 a month\n of daily closing stocks added together is thirty warehouses. The band labels it\n as the snapshot it is, and it is refused on a screen that states no `period`,\n since there is then no window for it to stand at the end of.\n `ageing` stands in that same\n band: an `amount` split by how many days past its `due` date each row is,\n drawn as a `Breakdown` of the edges in `buckets` (30, 60 and 90 days unless the\n model states its own, ascending). The ladder is the kit\'s \u2014 its boundary, its\n words and its ramp \u2014 and it opens with what is NOT YET DUE, because a bar of\n overdue bands alone is full at every input: a book with one late invoice and a\n book that has gone entirely bad would paint the same solid width. Against the\n current money the bar\'s own shape is the reading. A row whose date is empty is\n in no band. `totals` is what the whole\n view adds up, and it is drawn in that SAME band above the rows: a register\n states one aggregate, over the rows the reader can see, in one place, and a\n closing line under them is a second place to look and a second question about\n which rows it covers. A closing line belongs to the one device that IS a\n statement \u2014 a record\'s `Ledger`, whose balance is what it is read for, labelled\n by its amount\'s own word. Where a shape has no band above (a trend, whose own\n band is the chart) the figure stands under the register instead. A\n `transaction_ledger` sums its amount column itself and takes `above` alone. An\n `obligation_desk`\'s rows are DEADLINES and not records, so its band states\n `"counts"` \u2014 how many are open \u2014 and a figure list, an `ageing` or a `trend`\n over it is\n refused: a l\xF4 with three cut-offs is three rows, and its freight added over\n them is three times the freight.\n `trend` is `{"field": \u2026, "direction": "up"|"down"}` and states WHICH WAY that\n figure moved across the window: the rows in view are cut into the window\'s own\n buckets \u2014 days over a month, weeks over half a year, months beyond \u2014 and the\n later half of the CLOSED ones is read against the earlier half, drawn beside\n the figures as one line, so a window the reader is still inside states the\n movement of the days that have run rather than a fall into the ones that have\n not. `direction` is which way is GOOD, which nothing about the number\n says: a book wants its takings rising and a backlog wants its days falling, and\n the same arrow is a win on one and an alarm on the other. It needs a `period`\n to be a window of, and it is refused on a shape that draws its own (a\n `trend_deep_dive` already draws the series) or that reads none at all (a\n `live_board` answers what is true now). A window whose earlier half took\n nothing draws no line: there is no base for a percentage, and 0 % would claim a\n steadiness nobody measured.\n- **`facts`** \u2014 how the RECORD\'s facts are banded. `groups` is a list of\n `{caption, fields}`: what a set of facts has in common is a sentence about the\n business and nothing derives it, so a plan that states one gets exactly those\n bands, in its own order, captioned. Everything it leaves unnamed reads in the\n derived order below it. A field the header, the band, the ladder, a required\n set, the files or a register below already draws is refused: it is not a fact,\n so a band naming it would either state it twice or draw nothing. Naming any\n band IS the answer to which facts fold \u2014 see the TIERS below.\n- **`presentation`** \u2014 how the screen and the record it opens are DRAWN, where\n what the rows are is not the answer wanted. `lead` is what each row leads\n with: `"mark"` the subject\'s own mark, always spent because a party\'s initials\n stand in for a picture nobody uploaded; `"picture"` the photograph, spent only\n on the rows that carry one and, on the record, at media scale above the name;\n `"paper"` the row\'s own files, drawn as the cards a pile of documents is read\n as; `"figure"` the amount, with no gutter at all; `"none"` neither. `density` is\n how many lines a row\'s subject may take \u2014 `"roomy"` two, `"dense"` one.\n Both default, and the DEFAULT is what the rows are: a party register leads\n with the mark, an offering register with the picture, a ledger with its\n figure, and a desk \u2014 rows that are work still to do, scanned \u2014 stands its rows\n one line each while a record\'s own rows are read roomy. So state it only to\n differ, and a plan that states nothing still gets screens that vary. A lead\n the rows cannot carry is refused: a mark, a picture or a paper where no column\n is drawn as the rows\' mark, a\n figure where no column is the amount, and the other subject\'s mark (which of\n the two a row wears is the shape\'s, so the register and the record it opens\n cannot call one row two kinds of thing).\n `layout` is how the rows are ARRANGED \u2014 `"table"`, `"list"`, `"cards"`,\n `"gallery"`, `"board"`, `"calendar"`, `"timeline"`, `"gantt"` \u2014 and it is\n AFFORDED by what the rows carry, not by the shape: every register affords a\n table and a list, a `mark` affords the two picture-led grids, a `lifecycle`\n affords a board, a `when` affords a calendar and a timeline, and a `when`\n beside a `measure` that `counts` DAYS affords a gantt, because a bar with no\n length is a calendar drawn sideways and a bar measured in money or litres is a\n length of another kind. `until` names the date field a bar ENDS on, where the\n rows state one, and the days measure is what the bar runs for without it. A\n layout the bound roles do not afford is refused, naming the ones they do.\n- **`acts`** \u2014 where this screen hands work on: `row` is the \u22EF menu on every row,\n `record` is the \u22EF on the RECORD\'s own page header \u2014 the same reach as a row\'s,\n standing where the work is open instead of while a list is scanned, and refused\n on a record that opens as a drawer, which has no header of its own and whose\n row already carries the register\'s menu \u2014 and `selection` is the bar over the\n TICKED rows. `export` is the third reach \u2014\n the WHOLE VIEW, saved in one press: `true` writes the rows as the register drew\n them, in the columns it drew, as an `.xlsx`, and\n `{"template": "\u2026"}` says the layout is the trade\'s and makes the paper with a\n workflow like any other. It is never a list of columns: which columns the\n export carries is what the screen already draws, and a second statement of it\n disagrees the moment a slot moves \u2014 the generator reads them off the drawn\n slots and writes them into the app, so the sheet\'s headings are the labels the\n register drew them under.\n `import` is the reach OPPOSITE it \u2014 a file turned into rows:\n `{"kind": "import", "label": \u2026, "entity": \u2026, "key": \u2026, "columns": [...]}`. The\n file is mapped column by column, each row is validated, what would change is\n previewed, and the commit UPSERTS by `key` \u2014 which is why a sheet sent twice\n moves no counts. That key is one of the entity\'s own `natural_key` fields\n (\xA7 Write rules), and an entity declaring none is refused: without a key the\n second run is a second set of the same rows. `entity` is the register\'s own \u2014\n an app is one register, and rows filed into another entity are a count nobody\n on this screen can check. `columns` is what the sheet may fill, in that order;\n absent, it is every column a person states IN WORDS. A computed one is refused\n because the workspace writes it, and a link, a member or a files column is\n refused because a cell of a sheet is words and those hold a row of another\n table or bytes \u2014 a file reaches another table through the party pattern (a\n `natural_key` on the target, named rather than picked), which is a create\'s.\n The generator writes the whole verb, as it does for a paper: an\n `import_<entity>` workflow that finds the row by `key` and then changes it or\n opens it, answering which of the two, and the staged run over it \u2014 drop, map,\n the per-row verdict, the commit. A line whose cell the column cannot hold is\n refused with the reason and the rest of the sheet still lands.\n Both spreadsheet reaches are read and written IN THE BROWSER, so the generated\n `src/main.tsx` imports `@lotics/app-runtime/sheets` wherever the plan states\n one of them and not otherwise; an app that has neither carries no spreadsheet\n engine at all. An act names ONE of three things, never two:\n - **`template`** \u2014 an `html` template this model declares (\xA7 Templates). The\n paper is filled from ONE row in `row`, and from the whole ticked set in\n `selection`. The generator writes the whole verb \u2014 a `generate_<template>`\n workflow that reads the row and hands the file back, and a menu item that\n opens it \u2014 so there is nothing left to bind. An `email` template is sent\n rather than opened and is refused here; a file-backed template (`excel`,\n `word`, `pdf-form`) is bytes a model has none of, so those stay the author\'s\n own `lotics app workflow set`. Two screens over DIFFERENT entities cannot\n name one template: one paper is filled from one kind of row.\n - **`{"kind": "agent", \u2026}`** \u2014 a run over ONE row. `agent` is an alias the APP\n declares (`package.json#lotics.agents`), because an agent is prose and tools\n rather than anything a workspace model can state \u2014 `lotics app check` refuses\n one nothing declares, exactly as it refuses a workflow nothing bound. `fills`\n is the fields the run may write: pressed, the reader watches the run, reviews\n what it proposes field by field against what the row holds now, and applies\n the ones they kept as ONE write through the record\'s own update. A run can\n reach nothing outside `fills`, a computed column there is refused (the\n workspace writes those), and a screen stating `writes: false` refuses an\n agent act outright. It belongs in `row`; the selection bar makes ONE paper\n from many rows, and a run per ticked row is the row\'s own act many times.\n - **`{"kind": "workflow", "workflow": "\u2026", "inputs": {\u2026}}`** \u2014 the work HANDED\n ON: a lead becomes an opportunity, an order is split into the orders placed\n on its suppliers, a request is closed. `workflow` is an alias the APP binds\n (`package.json#lotics.workflows`), because the rule behind it is the\n business\'s and no model states one \u2014 so the generator writes NO body and\n `lotics app check` refuses an alias nothing bound, exactly as it refuses an\n agent nothing declares. `inputs` is what the press hands that body: the\n workflow\'s own input name \u2192 `"record"` for the row it was pressed on, or a\n field of this entity for a value off that row. The value is what the\n workspace HOLDS \u2014 an option\'s key, a linked row\'s id, the figure \u2014 never the\n rendering. A files column is refused (the body reads the row\'s files off the\n row it is handed), a column that is not a field of the entity is refused by\n name, and one input short of the declaration is refused at the press, so the\n whole act is dropped rather than shipped half-bound. It runs ON the press:\n there is nothing to review first, which is the agent arm\'s law, and the\n body\'s own sentence is what the reader is told either way. It stands in\n `row`, in `record` and on a `section_acts` band \u2014 each reaches ONE row \u2014 and\n is refused over the ticked set, where one act makes one paper from many.\n\n **`"place": "cta"` draws an act ON the row** instead of in its \u22EF \u2014 the one verb\n a reader presses without opening a menu first. At most one per screen and every\n other act stays in the menu, because the trailing gutter is paid for by every\n row: a second is refused with *one verb on the row, the rest in its menu*. The\n ticked set has no row of its own, so `cta` is refused there.\n\n A hand-off\'s BODY is still the author\'s: `lotics app workflow set <alias>`\n binds it, and `scaffold check` prints the act as `"<label>" \u2192 <alias>(\u2026) [you\n write the body]` so it is read as work owed rather than as a verb already\n wired.\n\n A `selection` act is a `template` and nothing else: an act over many rows makes\n ONE paper from them. The generator turns the register\'s checkbox gutter\n on, emits a `generate_<template>` workflow taking the ticked ids, and the body\n reads each row and passes them as `rows` \u2014 which is what the template iterates\n (`{{#each rows}}`). One template is one paper AND one reach: naming the same\n one from a row\'s \u22EF and from the bar is refused, because those are two bodies.\n A shape whose rows are DERIVED rather than records \u2014 an `obligation_desk` \u2014\n has no set to tick and refuses the clause. The paper is handed BACK, never\n filed: a ticked set can hold rows of three different parents, so there is no\n record on which "this document belongs here" is true.\n\n**`section_acts` puts a verb WHERE ITS EFFECT LANDS.** "Chase the paperwork"\nbelongs on the papers, not in the register\'s \u22EF two surfaces away. It is keyed by\nthe alias each section is DERIVED from \u2014 a field\'s (its progress, its prose, its\nrequired set, its charge, one of its files) or a CHILD ENTITY\'s (its register,\nits desk, its run, its thread) \u2014 because the sections come off the roles and the\nkey the app addresses one by does not exist until it is built. An alias naming no\nsection is refused, listing the ones that do; the facts band is what every other\nsection left over, so nothing addresses it. Each act is a `template`, an `agent`\nor a `workflow`, exactly as a row\'s is \u2014 the reach is the same one row \u2014 and\n`place: "cta"` is refused: a section is a band with a heading and has no row to\ndraw a verb on. `scaffold check` prints\nwhich section each alias landed on, which is the half an author cannot see in\ntheir own file.\n\n**`sections` says HOW one of those sections is DRAWN**, keyed by the same\naliases. The one reading it carries is `"draw": "worksheet"`: the child\'s rows\nare PRICED LINES the reader works down in place, each part of the job footed and\nthe sheet closing under them. `cost` and `sell` name the child\'s two figures,\nwhich is the one thing the roles cannot decide \u2014 an entity carries one `amount`,\nand a line that is costed and sold carries two. Everything else is read off the\nchild\'s own roles: what a line is FOR is its `identity`, how many it is for is\nthe `measure` the pair left, and the part it falls under is its `category`. The\nMARGIN is on none of them, because it is arithmetic over the pair.\n\nA sheet is the WRITE side of a price, so it is the one child section whose cells\nare editors: a figure typed into a cell is written as a diff through the child\'s\nown update, which is the same editor the line\'s own drawer saves through. Every\nother child section is read where it is drawn and edited in the row\'s drawer. A\nline is ADDED from the section\'s heading as every child\'s row is, with the record\nit hangs under as the act\'s own context.\n\nRefused: an alias naming no register of this record, rows the record is merely\nthe `party` or `link` of (a line added to a history belongs to the job it is\nabout, not to what is reading it), a figure that is not a field of the child or\nwhose role is neither `amount` nor `measure`, a sheet naming neither figure, and\none figure priced as both.\n\n**A CHILD NOBODY REGISTERS IS A NOTE, not a refusal.** Where a record lists a\nchild entity no app is a register of, `scaffold check` says `"<entity>" has no\nregister \u2014 its rows are read here and created nowhere` and the plan printout\nmarks that entity `(no app)`. Rows can arrive from an import or a workflow, so\nit is a reading rather than a rule; what it catches is the register somebody\nmeant to plan and did not. A thread is exempt \u2014 a reply is written where it is\nread \u2014 and so is a sheet, which opens its own lines.\n\n`scaffold check` prints every clause a screen states, on its own line under the\nslots, spelling the screen\'s `writes` clause `operable` \u2014 the app manifest\'s\n`lotics.writes` keeps that word, and the two are different facts: `app create`\nSEEDS the manifest from the fields the screens\' own editors write, and from then\non the app owns the declaration `lotics app check` holds its bodies to.\n\nEach shape is a `@lotics/ui` component of the same name (`LifecycleDesk`,\n`PartyRegister`, \u2026) whose props are these slots, so once the tables exist\n`lotics app create <name> --from <this file>#<app alias>` scaffolds the app with\none screen per entry, each slot reading the field the plan bound.\n\n`"shape": "custom"` is the screen no registry row covers: it declares its own\n`roles` (slot name \u2192 role), and it is emitted on `ShapeFrame` \u2014 the one anatomy\nthe six shapes above are each a configuration of \u2014 so its strip, search,\nordinal, fit budget, empty and waiting states and record door are the same ones\nthey have. It opens the record its `record` says, drawer or page, like any other.\nA bound `lifecycle` slot IS its strip, the stages in the field\'s order as on a\ndesk, so such a screen names no `tabs`; a `tabs` select is the flat strip a\nscreen with no lifecycle gets.\n\n**An app IS its `app.json`.** `create --from` writes the bound plan \u2014 every\nscreen with its shape and slots, the record each row opens with its sections in\nthe archetype\'s order, the create panels, the acts \u2014 as one JSON document, plus\na five-line `src/main.tsx` that mounts `@lotics/app-runtime` over it and one\n`src/workflows/<alias>.ts` per write. There is no screen source: the runtime\ndraws the spec, so a kit correction reaches every app with its next install.\nWhere the plan has no word for what a screen or a section IS \u2014 or for what a\npaper act should ASK before it is made \u2014 the spec names one of the app\'s own\ncomponents and `src/components/index.ts` registers it, the one file under `src/`\na regeneration never rewrites. `lotics app eject <screen|<section key>|<act\nlabel>>` writes each of those, starting from what the runtime already drew.\n\n**How a spec reads a row.** Each query projects its columns under the alias the\nworkspace mints from the field\'s LABEL \u2014 never the alias this file keys it\nunder, which is the model\'s own namespace and which the workspace never saw. So\na field this file calls `partner` and labels `\u0110\u1ED1i t\xE1c` is referenced as\n`doi_tac` throughout the spec. Every id in it is live: the `tbl_` a screen is\nover, the `fld_` of each column, and the `opt_` of every option a role names.\n\n**Every query is described, in the model\'s own language.** The line an agent\nchooses between aliases by is written from this file\'s nouns \u2014 the entity\'s\nlabel, the screen\'s, the link\'s \u2014 and the sentence around them is the language\nthe file is mostly written in, decided the same way a generated write\'s refusals\nare. Hand-editing one is erased by the next regeneration; `lotics app check`\nrefuses an alias that carries none.\n\nA screen is that list and the RECORD it opens, and the record comes off the same\nroles \u2014 nothing to declare for it. It opens with a **header**: the entity\'s\n`identity` as the record\'s name \u2014 whether or not the list has a column for it, so\na ledger\'s and a trend\'s records are named too \u2014 the `when` this screen reads\nunder it, and ONE headline\nfigure (the `amount` the screen reads, else its `measure`). BOTH DOORS state the\nname: which door a screen uses is a layout answer, and what the record is ABOUT\nis not.\n\n**A JOB\'S HEADER ALSO STATES WHO IT IS FOR.** Where the record is the work itself\n\u2014 a desk\'s rows that belong to nothing, opened on a page \u2014 its `party` is a link\nunder the name, beside the ways to reach the subject, and it is then not a fact\nas well: a page headed by a code alone said nothing about whose work it is, and\nthe one thing a reader arriving from the desk already knows the row by sat in the\ngrid below as a row among forty. A record opening in a DRAWER has no row of links\nto hang it on, so its party stays a fact; so does a LINE\'s, whose register files\nit under the thing it belongs to and whose party is that parent\'s, restated per\nrow.\n\n**A record whose figures are a SENTENCE states them under the header, in a\nband**, and the header then states neither of them: a job reads what it comes to,\nhow far through it is (the `measure` against its limit), what is left of it (a\nformula that is exactly `{amount} - {measure}`) and how long there is (an\n`obligation`, else its `when`, counted down); a party reads its settlement, the\n`amount` and `measure` roles it carries in the order the model declares them; a\ncatalogue item reads its `amount` against the reference that amount names. Two\nfigures are the floor and four the ceiling \u2014 under two, the header keeps its one.\nA band is a PAGE\'s strip, so a screen whose `record` is a drawer keeps every\nfigure where a drawer reads them. Each figure is drawn exactly once: the limit a\nmeter states is not a fact beside it, and the day a countdown counts to is not\nthe provenance line above it. The `lifecycle` is not badged there, because the progress section is the\nrung it stands on. Then its sections, in the order its KIND reads them: the\n**facts** (every field neither the header, the band nor another section owns, the\nrow\'s own `parent` among them \u2014 led by the ones THIS screen\'s slots bound, then\nthe key it is filed under, then the order the model declares them in \u2014 a\n`markdown` field among them, drawn across the grid\'s whole row rather than\nwrapped to a third of it), the record\'s own **body** (that same markdown, taken\nas a section instead, ONLY where the kind leads with it: a catalogue item IS its\ndescription, and a job is not its notes), the **charge** (where the `amount` is a formula over exactly one\nquantity and one money field, the line states `quantity \xD7 unit price = amount`\nand neither figure is typed twice \u2014 the section is headed by the amount\'s own\nlabel and the closing row says what the figure IS, so the word is said once),\nthe **progress** (the `lifecycle`\'s stages, and the ONE act that moves the record\non \u2014 every other move, outcomes included, is an item in the menu beside it), a\n**required set** (a multi-select `expected_set`, or a child entity whose rows\ncarry one entry of it each \u2014 that child\'s `files` field is what a paper attaches\nto), the record\'s **own rows** (any other child, its role-bound fields as\ncolumns), and its **files** (every `files` field, the `mark` first \u2014 a section of\nthe column like any other, straight after the facts it is the evidence for,\nexcept on an EVIDENCE record, which OPENS on the paper it exists for; the\nheading carries the Add verb, and stands it down while the pile is empty, where\nthe drop well is already the door; and a `verdict` whose FORMULA reads one of\nthose files fields is the pile\'s OWN state rather than a fact row \u2014 the file it\nis short, drawn as a ghost, and the mark the rail sends a reader to). A child is\nan entity that LINKS here, ONE SECTION PER LINK: the rows it owns (`parent`), the\nrows that name it (`party` \u2014 so a party\'s record is its history), and the rows\nreaching it through a link carrying neither role, headed by that link\'s own\nlabel. A child hanging off two records therefore declares one `parent` and\nleaves the second relationship a plain link, which still gets its section.\n\n**THE ROWS A RECORD OWNS ARE ADDED AND OPENED FROM THEIR SECTION.** A `parent`\nsection\'s heading carries the Add for its rows, handing the record as the\nparent \u2014 filled, never asked. Every row opens its own record \u2014 a line of a book,\na paper on a desk, a stop on a timeline alike: where this app already routes a\npage for those rows (the register\'s own, under one of them) it is navigated to;\notherwise it is a DRAWER mounted on the record it belongs to, stepped \u25C0 \u25B6 over\nthe section\'s rows as they are drawn, with the row\'s framed editors, its ladder\nand its files, saving through `update_<child>` \u2014 the one editor those rows have,\nwhich any control the section draws over a row writes through too. Never the\n`parent` link itself: it is the key the row is FILED under, stated by the record\nthe drawer was opened from, so it is neither a fact of the drawer nor an input\nof that editor. ONE LEVEL, and only from a PAGE: a child\'s own children draw as\nthe kit\'s panel of the row, with no drawer and no Add, and a register whose own\nrecord is a DRAWER is the same for the children it owns \u2014 a drawer mounts no\ndrawer. **A ROW IS ADDED WHERE IT CAN BE OPENED AGAIN**, so those sections lose\ntheir Add with their drawer; a row of the register\'s OWN kind keeps it, because\nthe register\'s list opens it. A correspondence\'s entries are the one owned row\nthat opens nothing anywhere: the composer under them is the section\'s Add\nwhatever door the record has, and the answer mark is one field of the reply\'s\nown editor. The rows a `party`\'s record lists, and those reaching a record\nthrough a roleless link, are read only \u2014 a history, never a place to open or\nadd.\n\n**TWO CHILDREN ARE READ AS A RUN RATHER THAN AS A REGISTER**, and the model says\nwhich by the roles it declares on them. A child carrying an `obligation` whose\n`satisfied_by` is a DATE is a **timeline**: its rows are the ordered stops, each\nstating the day it was promised for and the day it happened, with the delta named\nwhere the rung was late \u2014 drawn as a register those are two date columns and the\nreader subtracts them by eye, which is exactly what "three days late at\ndischarge" costs to learn. An obligation closed by a FILE says nothing about\nwhen, so those rows stay the register they were. A child carrying a `markdown`\ntext field beside a `party` and a `when` is a **thread**: the entry is the body,\nthe author and the day lead it, and a `verdict` on the child is the yes/no one\nreply is marked the ANSWER with \u2014 a register of messages is a table whose one\nuseful column is the one it cannot draw. A SECOND `party` on those rows is who\nthe entry was left WITH, and the latest one names the ball in court: while\nnothing answers, the record says whose move it is and counts its own deadline\n(an `obligation`, else its `when`) against them. Both are read only under the\nrecord that OWNS the messages \u2014 under the party who wrote them the same rows are\nthat party\'s history, several questions at once, and the last of them says\nnothing about any. That record also REPLIES: the section\'s Add is a composer \u2014\nthe message is all a reply asks \u2014 and the answer mark is one field of the\nreply\'s own editor.\n\n**NOTHING SITS BESIDE THE RECORD.** A page is ONE reading column \u2014 its fields,\nits files, the rows it owns, its notes, all sections of it in the archetype\'s\norder \u2014 over a reserved left gutter holding a floating NAVIGATION RAIL: the way\nback, then one line per section with that section\'s category mark, and nothing\nelse. No files, no counts, no group headings. Too narrow to seat the rail, it is\none pinned bar of the same list across the top. A section HEADING never wears\nthe mark; the rail\'s line for it does.\n\n**A RELATION THE RECORD NEVER ACTS ON CLOSES THE COLUMN.** Every register that\nmerely NAMES the record is one counted line of a single last section: its label,\nhow many rows name this record (a COUNT, never a page of rows measured), and a\npress that opens the register owning them narrowed to this record. An entity\nthis app lists nowhere is a count with no door, and where every count is zero\nthe section is not drawn at all. The one exception is a PROFILE, whose history\nIS its body and stands open in the column.\n\n**Rows the record owns whose `amount` is `signed_by` a direction are a\nBOOK**, drawn as a statement \u2014 each line dated and signed, the balance under\nthem where there is more than one line to add up, labelled by the amount\'s own\nword \u2014 where the same rows on the party they\nwere transacted with stay that party\'s history, which is scanned for the line\nshort of its paperwork rather than struck to a balance. **Rows that carry a\n`when` read as RUNS**: a party\'s history under the year \u2014 and only where the rows\nin hand span more than one, since a subhead over every line of a book names the\nyear and separates nothing \u2014 and a job\'s own rows under the day they fall on,\nwhich, where those rows also carry a `lifecycle`, is an ITINERARY rather than a\ngrouped table: the day heads the run, each row is one line (its `slot`, its name,\nits party, its stage, its `category` and `reference`, its amount), the figures end\non one edge and the run closes with their sum \u2014 unless the record\'s own BAND is a\nsum rollup over exactly these rows, which states it once already. A `category`\npaints the node the run is read down, in the option\'s own colour; a `reference`\ncloses the line with the code somebody quotes on the phone. **A `mark` LEADS the\nline with the stop\'s own picture** \u2014 a hotel, a dish, a boat \u2014 and it is the one\nplace on a record where a child entity\'s mark is drawn at all, since no register\ncolumn is ever a picture; a line whose subject holds none is marked with its\n`category`\'s glyph instead, so the run keeps one left edge. All three are\nPROJECTED for the run whether or not the register\'s column budget would have\ndrawn them, and a mark is projected NOWHERE ELSE.\nA line\'s mark is most often the SUBJECT\'s rather than the line\'s own \u2014 the\nservice, the product, the room it is a booking of \u2014 which is a `lookup` through\nthat link onto the subject\'s own `files`, given the role on the line.\n**EACH DAY\'S HEAD CARRIES THE SECTION\'S OWN ADD** as well, handing the panel that\nday, so a line made from Tuesday opens with Tuesday answered; the heading keeps\nthe verb too, for the first line and for a day nothing is planned on yet. **THE\nKEY LEAVES THE COLUMNS where the head states the whole of it** \u2014 a day head does,\na year head states four digits of the date and the day is what the column is\nstill there to carry.\n**A run is READ FROM THE END THE READER WANTS**: a job\'s own rows and an itinerary\nascending, a book and a history descending.\n\n**A DESK OVER AN ENTITY THAT CARRIES A `slot` IS ITSELF A DAY\'S RUN.** Its own\nregister is drawn under one subhead per day, sorted by that day and then by the\npart of it, and the date column goes \u2014 the subhead states it once for the whole\nrun, and a column repeating it down every line is the same value twice. Without\nit an itinerary was one flat list in whatever order the rows arrived.\nA document desk keeps its head either way \u2014 the entries it owes are its\ncontent, filed or not. The name and whatever figure is left to it are the header\'s\nalone \u2014 it states them in full, so a fact for either would be the same sentence\ntwice.\n\n**A FACT IS PRIMARY OR IT IS PROVENANCE.** An `autonumber`, a date the platform\nstamps (`derive_from`) and a date a formula works out that no role names are what\nthe SYSTEM wrote; they fold, with every optional field this record does not\nstate, behind the grid\'s ONE link. Everything a person types or picks is primary\nand is shown, empty or not, because absence is work somebody owes. **A screen\nthat named `facts.groups` has already said which:** a field in a group is\nprimary, and one in none folds \u2014 grouping says what the reader decides with, and\nby saying so says the rest are not. A `tier` on a `facts` entry exists only for\nthe case that derivation gets provably wrong.\n\n**A `contact` field whose kind the\nmodel decides** \u2014 an email, a number, a place, a `link`-formatted text, or a\nMESSAGING APP its label names (WhatsApp, Zalo, Telegram, Viber, WeChat, Line,\nMessenger) \u2014 leaves the facts too, for the row of links under the name on a\nrecord that opens as a page; one whose kind nothing decides stays a labelled\nfact, because a scheme nobody stated is a link that opens nothing. A handle is\ndrawn under its app\'s name \u2014 the app is what identifies the person there, and a\nbare number beside an envelope says the wrong thing \u2014 and it dials only where its\nvalue IS a number.\n`check` prints the record under each screen\'s slots:\n\n```\n Orders \u2014 lifecycle desk over Orders (12 rows) \xB7 page \xB7 tabs: Stage (New \u2192 Quoted \u2192 Confirmed \u2192 Shipped \u2192 Done)\n stage Stage \xB7 identity Order no. \xB7 mark Photo \xB7 party Customer \xB7 amount Total \xB7 level (none \u2014 no field declares "measure") \xB7 when Due\n record: header (Order no. \xB7 Customer) \xB7 band (Total \xB7 Shipped against Lines \xB7 Due counting down) \xB7 progress: Stage (5 stages) \xB7 facts (Ship to \xB7 \u2026 2 filed) \xB7 files: Photos \xB7 documents: Papers (Kind: 4 required) \xB7 itinerary: Order lines by Ship by at Window, kind Handling, ref Waybill (Product \xB7 Quantity \xB7 Line total) \xB7 related: Visits (count via Order)\n```\n\nThe ORDER of that line is the record\'s own, top to bottom, and it is the KIND\'s,\nand every entry of it is a section of the one column. `related:` is the counted\nline that closes it \u2014 a register the record never acts on, named and counted \u2014\nand `\u2026 N filed` is how many facts fold behind the grid\'s one link.\nA child register whose `amount` is `signed_by` a direction is a BOOK of movements\n\u2014 what the record came to rather than what it is made of \u2014 so it closes the\ncolumn rather than standing among the rows the job is made of.\n\n**A section over a child is named by the CHILD\'s own label**, because the reader\nalready knows which record they are on: "\u0110\u01A1n h\xE0ng", not "\u0110\u01A1n h\xE0ng \u2014 Kh\xE1ch h\xE0ng".\nWhere two links from one entity reach this record, that label names two sections\nand each takes its own link\'s label as a qualifier \u2014 `H\u1ED3 s\u01A1 (\u0110\u01A1n h\xE0ng)` and\n`H\u1ED3 s\u01A1 (\u0110\u01A1n g\u1ED1c)`.\n\nIn that line `documents:` is a `RecordExpectedSet`, and `lines:` a `RecordChildren` over the rows\nthis record owns \u2014 `history:` where they name it as their `party` instead; a required set\nwhose entries are a CHILD entity carrying files takes `kind="files"`, and the entity\'s own\nmulti-select takes `kind="items"`, since nothing attaches to an option.\n\n**A section that knows its size says so.** Where a `measure` on the record is a\n`count` rollup over the very link a rows section hangs on, the limit it is read\n`against` is how many rows that section is OWED: the printout adds `\u2014 expects\n<limit>` and the screen draws that many ghost rows until the first one lands,\ninstead of "nothing here" two bands under a count saying three are outstanding.\n\nA figure printed as `Total (USD)` is MONEY in the currency named \u2014 a `formula`\nstates it in `formula.currency`, and a `rollup` or a `lookup` inherits it from the\nfield it reads, so read those brackets: a rate that should be in dollars and is\nprinted bare will be drawn in the workspace\'s own money. Where the entity carries\na `currency` role, the ROW\'s code outranks the field\'s on every line that states\none, and a line stating none falls back to the field\'s.\n\n**A record surface can be OPERATED, and that is the default.** Every screen with\na record gets a workflow that writes its editable facts \u2014 every field a person\nstates \u2014 so the record\'s values are edited in place and its stage is advanced\nfrom the progress section. `"writes": false` makes one screen a reader: use it\nfor a screen over rows another desk owns, never as the default. A desk nobody\ncan act on is a viewer of state somebody must go and set somewhere else.\n\n**Which facts those are is the FIELD\'s answer.** Text, number, date, yes/no and\nselect are typed or picked. A `"cardinality": "one"` link is RE-POINTED, through\na picker over the target entity named by its `display_field_aliases` \u2014 else the\ntarget\'s `identity` \u2014 narrowed by what the reader types and carrying that\nentity\'s own `read_scope`, so a link the plan gives neither column is read\ninstead of offering an empty list. A files field is ATTACHED TO and DETACHED\nFROM, as the delta rather than the pile, so two readers filing at once each keep\ntheir file. A many-link, an `autonumber` and every computed field are read: the\nfirst is a list a fact cannot state, and the rest are the platform\'s to write.\n\n**A one-link is that fact WHATEVER its sync.** `sync_both_ways` is one relation\nwith a field on each side, and the sides are not the same surface: the record\nthat names ONE row states it as a fact, and the MANY side is the register on the\nother entity\'s record. Declaring both directions does not give this record a\nregister of the single row its own fact already names.\n\n**The write is the record SURFACE\'s.** Its inputs are the fields that surface\noffers to save \u2014 the facts with an editor, the lifecycle its ladder advances, and\na files section over exactly one stated field \u2014 never every field a person could\nin principle type. What the header states, a limit a meter folds in, a required\nset with a section of its own and the pile a page\'s mark draws are read there, so\nno input is declared for them. A second screen over the same entity draws no\nsurface of its own and adds nothing.\n\nWhere two operable screens of one plan reach the same entity, `scaffold check`\nnames them and the apps they belong to: two desks writing one record is a\ndecision, and the split is stated on the FIELD in each app\'s\n`package.json#lotics.writes` rather than left to whoever edits second.\n\nThat workflow lands in the app as source, like every other: its body in\n`src/workflows/update_<entity>.ts` and its declaration in\n`package.json#lotics.workflows`. The two together are the binding \u2014 `lotics app\ndeploy` pushes whatever differs from what it last saw live \u2014 so the write is\nversion-controlled and travels with the app rather than being bound by hand\nafterwards.\n\nA `custom` screen declares its slots under `roles` (slot \u2192 role) and they bind\nthe same way:\n\n```jsonc\n{ "alias": "readings", "label": "Readings", "shape": "custom", "entity": "reading",\n "roles": { "subject": "identity", "reading": "measure" } }\n```\n\n## Applying packages\n\n`apply` copies published packages into the workspace AFTER the model\'s own\ntables exist \u2014 apps over the tables you just described, and any tables of their\nown they still need. Ordered, and run by `lotics setup` and `lotics scaffold\napply` alike.\n\n```jsonc\n"apply": [\n {\n "package": "apg_k3nf82ldpq",\n "bind": { // optional \u2014 which of YOUR tables each entity is\n "company": { "label": "Customers", "fields": { "name": "Company name" } }\n },\n "no_sample_data": true // optional\n }\n]\n```\n\n`bind` is keyed by the package\'s entity alias and holds the LABELS this\nworkspace uses: scaffold adopts by label, so binding points the package at the\ntables the model created instead of a second set beside them. Only naming\nmoves \u2014 a bound field must be the TYPE the package declares, or the copy is\nrefused. `lotics library list` is the shelf, and `lotics library show <apg_id>`\nlists the aliases to bind.\n\nEntries run in the order they are written, because a later one may bind onto a\ntable an earlier one created. **A refused entry stops the run and the entries\nbefore it stay** \u2014 they are separate copies, committed as they land, so the\nrefusal names them rather than leaving a caller to re-run the file and copy them\ntwice.\n\n## Presets\n\nA preset is a trade\'s model, published to be READ. An assistant reads it, asks\nat most two questions, picks a variant and writes a `model.json` from it \u2014\nnothing is copied, and a preset is a file rather than anything a workspace\ninstalls.\n\n```jsonc\n"preset": {\n "name": "Field service",\n "description": "Jobs, the crew that runs them, and what each one billed.",\n "questions": ["Do you dispatch crews, or one person per job?"], // at most 2\n "variants": {\n "crews": {\n "when": "work is dispatched to crews rather than to one person",\n "entities": [ /* tables this branch ADDS */ ],\n "fields": { "job": [ /* fields this branch ADDS to `job` */ ] }\n }\n }\n}\n```\n\nVariants are **additive only**: a branch adds entities and fields and never\nremoves them, so the base is a model in its own right rather than a draft.\n`lotics scaffold check` proves the base AND every variant merged onto it, so a\npreset ships with every branch already proven \u2014 the branch nobody took is the\none that fails in the workspace of whoever takes it.\n\n`preset` is not scaffolded. `lotics setup` and `lotics scaffold apply` ignore\nit and create the base model\'s tables.\n\n`lotics scaffold export` prints a workspace that already works as one of these\nfiles \u2014 the starting point for a preset or for another business\'s model, never a\nsource of truth: it carries one business\'s words and stops describing that\nworkspace the moment either changes.\n\n## Starting from a preset\n\n`lotics library list` is the shelf of them and `lotics library show <slug>`\nprints one whole: its questions, every table as `alias \xB7 label` with each field\nas `alias:type`, and each variant as `slug \xB7 when` followed by the tables and\nfields that branch adds. When one of them is the trade in front of you, do not\ntranscribe it \u2014 name it:\n\n```jsonc\n{\n "from": "field_service",\n "variants": ["crews"],\n "rename": { "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } } },\n "entities": [ /* a table this business has that the preset does not */ ],\n "rows": { "job": [ { "ref": "j1", "fields": { "code": "J-1" } } ] },\n "field_roles": { "job": { "code": "identity" } },\n "write_rules": { "customer": { "natural_key": ["email"] } },\n "apps": [ /* the screens this business\'s apps will have */ ]\n}\n```\n\n- **`from`** is the preset\'s SLUG \u2014 its own file name, a lowercase slug. Naming\n it is what makes `entities` optional; every other rule on this page is\n unchanged, because the file is resolved into the full form and then checked and\n applied exactly as one. A slug nothing serves is refused with the ones there\n are, never resolved against something else.\n- **`variants`** names the branches to merge onto the base, in order. Pick the\n one whose `when` describes what the person said; a slug the preset does not\n declare is refused rather than ignored.\n- **`rename`** is keyed by the preset\'s entity alias and holds the labels this\n business uses \u2014 the same shape `apply[].bind` takes, and the same rule: only\n naming moves. An alias the preset does not declare, and a label that is\n already another table\'s, are both refused.\n- **`entities`** are added after the rename, already in this business\'s own\n words.\n- **`rows`**, **`field_roles`**, **`write_rules`**, **`apps`** and **`apply`**\n mean exactly what they mean in the full form \u2014 `"rows"` are this business\'s\n real first records,\n `"field_roles"` may name the preset\'s fields as well as its own (a role the\n preset declares itself is kept unless this file names the same field, or\n clears it with `null`), `"write_rules"` the create-time clauses on either\n (an entity this file names replaces the preset\'s whole entry for it),\n `"apps"` the screens it will have (a preset carries none), `"apply"` the\n packages copied in once its tables exist.\n\nThis is the ONE thing on this page that needs the network: `check` reads the\npreset it names, once. Everything after that read is the same offline check.\n\nWrite the full form when no preset is the trade.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "singular": "Customer",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "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 ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "field_roles": {\n "customer": { "name": "identity" },\n "order": { "code": "identity", "placed_on": "when", "amount": "amount", "customer": "party" }\n },\n "apps": [\n {\n "alias": "customers",\n "name": "Customers",\n "screen": { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" }\n },\n {\n "alias": "orders",\n "name": "Orders",\n "screen": { "alias": "orders", "label": "Orders", "shape": "transaction_ledger", "entity": "order" }\n }\n ],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\n`lotics scaffold check` on this file reports\n`2 tables, 9 fields, 2 links, 1 view, 1 role, 4 rows, 2 apps`, then\nthe plan:\n\n```\nCustomers\n Customers \u2014 party register over Customers (2 rows) \xB7 page \xB7 tabs: none\n identity Name \xB7 mark (none \u2014 no field declares "mark") \xB7 contact (none \u2014 no field declares "contact") \xB7 worth (none \u2014 no field declares "measure") \xB7 risk (none \u2014 no field declares "verdict")\n above: rows (default)\n record: header (Name) \xB7 history: Orders (Order no. \xB7 Placed on \xB7 Amount (VND)) \xB7 facts (Tier \xB7 Total ordered (VND))\nOrders\n Orders \u2014 transaction ledger over Orders (2 rows) \xB7 drawer \xB7 tabs: none\n when Placed on \xB7 amount Amount (VND) \xB7 reference Order no. \xB7 party Customer \xB7 classification (none \u2014 no field declares "lifecycle") \xB7 document (none \u2014 no field declares "expected_set" or "verdict")\n above: Amount (VND) (default)\n record: header (Order no. \xB7 Placed on \xB7 Amount (VND)) \xB7 facts (Placed on \xB7 Total with VAT (VND) \xB7 Customer)\nWho writes what:\n Customers (customer, one: Customer): Customers\n Orders (order, one: Order): Orders\n```\n\nThe last block is the WRITERS matrix \u2014 one line per record surface the plan\nleaves operable, the word one of its rows is called, and the app whose register\nopens it. Part of the verdict rather than a diagnostic beside it: which desk\nchanges a table is answerable from the plan, and two apps on one line is the\nsplit to state in each of their `package.json#lotics.writes`.\n\nEvery `(none)` is a slot no field fills, and it says WHY: no field declares the\nrole, or several could and the plan named none of them (`(none \u2014 a, b; name\none)`). Read it as the screen a person will see. `above: \u2026 (default)` is the band\nnobody stated \u2014 the shape sums the amount its slot draws, and a plan naming its\nown figures loses the word. Neither\nentity carries a stage, a required set or files, so the rest is facts \u2014 except\nthe sections neither entity declares: the customer\'s `history:` is the orders\nthat name it as their party, and the order\'s `via Orders:` is the customers whose\n`Orders` link names it. **A link is a section, not a fact**, on the side it\npoints AT and on the side that holds it: `Customers.Orders` therefore leaves the\ncustomer\'s facts, because the section below already lists the same relation and\na link fact can only ever show the first of them. Both records state their name\nin the header and nowhere else \u2014 a ledger\'s columns are the date and the figure,\nso `Order no.` is on no column of that list and is still what the drawer behind\na line is called. `Total ordered (VND)` is the rollup inheriting the currency of\nthe column it sums: money is read off the model, never off the field\'s own line.\n';
54412
55166
 
54413
55167
  // src/scaffold_commands.ts
54414
55168
  function printModelReference() {
@@ -54601,18 +55355,22 @@ function describeRecord(model, resolved) {
54601
55355
  const required2 = section.source === "own" ? section.field : section.setField;
54602
55356
  const name = section.source === "own" ? section.field.label : section.child.label;
54603
55357
  if (required2.type !== "select") return `documents: ${name}`;
54604
- const set2 = section.requiredBy === void 0 ? `${required2.options.length} required` : `${count(required2.options.length, "option")}, ${section.requiredBy.field.label} decides which`;
55358
+ const set2 = section.requiredBy === void 0 ? `${required2.options.length} required` : `${count(required2.options.length, "option")}, ${section.requiredBy.field.label}${section.requiredBy.from === "parent" ? " on this record" : ""} decides which`;
54605
55359
  return section.source === "own" ? `documents: ${name} (${set2})` : `documents: ${name} (${required2.label}: ${set2})`;
54606
55360
  }
54607
55361
  case "children": {
54608
55362
  if (draw === "related") return `related: ${section.child.label} (count via ${section.link.label})`;
55363
+ if (resolved.screen.sections?.[section.child.alias] !== void 0) {
55364
+ return `sheet: ${section.child.label} (${labels(section.child, section.fields)})`;
55365
+ }
54609
55366
  const run = recordItinerary(archetype, recipeSlot, section.child, model.field_roles);
54610
55367
  if (run !== void 0) {
54611
55368
  const at2 = run.slot === void 0 ? "" : ` at ${fieldName(model, section.child, run.slot)}`;
54612
55369
  const kind = run.kind === void 0 ? "" : `, kind ${fieldName(model, section.child, run.kind)}`;
55370
+ const face = run.mark === void 0 ? "" : `, marked ${fieldName(model, section.child, run.mark)}`;
54613
55371
  const quoted = run.reference === void 0 ? "" : `, ref ${fieldName(model, section.child, run.reference)}`;
54614
55372
  const over = run.span === void 0 ? "" : `, over ${fieldName(model, section.child, run.span)} days`;
54615
- return `itinerary: ${section.child.label} by ${fieldName(model, section.child, run.when)}${at2}${kind}${quoted}${over} (${labels(section.child, section.fields)})`;
55373
+ return `itinerary: ${section.child.label} by ${fieldName(model, section.child, run.when)}${at2}${kind}${face}${quoted}${over} (${labels(section.child, section.fields)})`;
54616
55374
  }
54617
55375
  const heading = section.via === "party" ? "history" : section.via === "link" ? `via ${section.link.label}` : "lines";
54618
55376
  const owed = section.expected === void 0 ? "" : ` \u2014 expects ${typeof section.expected === "number" ? section.expected : fieldName(model, resolved.entity, section.expected)}`;
@@ -54667,9 +55425,13 @@ function describePlan(model) {
54667
55425
  ` ${resolved.slots.map((entry) => {
54668
55426
  if (entry.field !== null) {
54669
55427
  const operated = entry.quick !== true ? "" : entry.order === "sequence" ? " [quick, next step]" : " [quick]";
54670
- return `${entry.name} ${[entry.field, ...entry.also].map((field) => fieldName(model, resolved.entity, field)).join(", else ")}${operated}`;
55428
+ const under = entry.tiers.length === 0 ? "" : `, then ${entry.tiers.map((field) => fieldName(model, resolved.entity, field)).join(", then ")}`;
55429
+ return `${entry.name} ${[entry.field, ...entry.also].map((field) => fieldName(model, resolved.entity, field)).join(", else ")}${under}${operated}`;
54671
55430
  }
54672
- return entry.ambiguous.length === 0 ? `${entry.name} (none)` : `${entry.name} (none \u2014 ${entry.ambiguous.join(", ")}; name one)`;
55431
+ if (entry.ambiguous.length > 0) return `${entry.name} (none \u2014 ${entry.ambiguous.join(", ")}; name one)`;
55432
+ const takes = slotTakes(resolved.screen.shape, entry.name);
55433
+ const roles = (takes.length === 0 ? [entry.role] : takes).map((role) => `"${role}"`);
55434
+ return `${entry.name} (none \u2014 no field declares ${roles.join(" or ")})`;
54673
55435
  }).join(" \xB7 ")}`
54674
55436
  );
54675
55437
  const carried = describeCarried(model, resolved);
@@ -54683,11 +55445,20 @@ function describeCarried(model, resolved) {
54683
55445
  const name = (field) => fieldName(model, resolved.entity, field);
54684
55446
  const act = (entry) => {
54685
55447
  const where = entry.place === "cta" ? " [on the row]" : "";
54686
- return entry.kind === "agent" ? `"${entry.label}" \u2192 ${entry.agent} fills ${entry.fills.map(name).join(", ")}${where}` : `"${entry.label}" \u2192 ${entry.template.label} (${entry.template.type})${where}`;
55448
+ if (entry.kind === "agent") return `"${entry.label}" \u2192 ${entry.agent} fills ${entry.fills.map(name).join(", ")}${where}`;
55449
+ if (entry.kind === "workflow") {
55450
+ const taken = entry.inputs.map((input) => `${input.name}: ${input.field === null ? "the record" : name(input.field)}`).join(", ");
55451
+ return `"${entry.label}" \u2192 ${entry.workflow}(${taken}) [you write the body]${where}`;
55452
+ }
55453
+ return `"${entry.label}" \u2192 ${entry.template.label} (${entry.template.type})${where}`;
54687
55454
  };
54688
55455
  const runs = registerRuns(resolved, model.field_roles);
54689
55456
  const drawn = resolved.screen.presentation;
54690
55457
  const parts = [
55458
+ // THE CONTEXT, AFTER THE ANSWER — printed beside the slot line rather than
55459
+ // inside it, because the two are read differently: a slot is the shape's
55460
+ // question and a column is the author's own addition to the row.
55461
+ ...resolved.columns.length === 0 ? [] : [`columns: ${resolved.columns.map(name).join(" \xB7 ")} (after the slots)`],
54691
55462
  ...runs === void 0 ? [] : [`runs: by ${name(runs.when)} at ${name(runs.slot)}`],
54692
55463
  ...drawn === void 0 || Object.values(drawn).every((clause) => clause === void 0) ? [] : [
54693
55464
  `presentation: ${[
@@ -54697,13 +55468,19 @@ function describeCarried(model, resolved) {
54697
55468
  // decided against what the rows carry: an author reading back
54698
55469
  // "board" learns nothing about the three other arrangements the
54699
55470
  // same screen could have taken.
54700
- ...drawn.layout === void 0 ? [] : [`as a ${drawn.layout} (of ${layoutsFor(resolved.slots.flatMap((slot2) => slot2.field === null ? [] : [slot2.role])).join(", ")})`]
55471
+ ...drawn.layout === void 0 ? [] : [`as a ${drawn.layout} (of ${layoutsFor(drawnColumns(resolved, model.field_roles)).join(", ")})`],
55472
+ // WHERE THE BARS END, in the column's own words: an author who named
55473
+ // a finish date reads back which date the chart is drawn to.
55474
+ ...drawn.until === void 0 ? [] : [`ending at ${drawn.until}`]
54701
55475
  ].join(", ")}`
54702
55476
  ],
54703
55477
  ...period === null ? [] : [`period: ${name(period)}`],
54704
55478
  // WHAT THE ROWS ARE, where the shape fans them out of the record: one line
54705
- // per thing owed, and the stamp that takes it off the desk.
54706
- ...resolved.obligations.length === 0 ? [] : [`obligations: ${resolved.obligations.map((owed) => `${owed.label} (${owed.field.label} \u2192 ${owed.satisfiedBy.label})`).join(" \xB7 ")}`],
55479
+ // per thing owed, and what takes it off the desk — the stamp, or the stage
55480
+ // the record stops owing it at.
55481
+ ...resolved.obligations.length === 0 ? [] : [
55482
+ `obligations: ${resolved.obligations.map((owed) => `${owed.label} (${owed.field.label} \u2192 ${owed.satisfiedBy?.label ?? "settled"})`).join(" \xB7 ")}`
55483
+ ],
54707
55484
  // A DERIVED LENS IS PRINTED BY ITS SETS, never by its label alone: what an
54708
55485
  // author has to check is that the predicates say what they meant, and the
54709
55486
  // chip's own name is the one thing they already wrote down.
@@ -54723,6 +55500,10 @@ function describeCarried(model, resolved) {
54723
55500
  // read as whatever the reader assumes, and the cuts are what a statement of
54724
55501
  // account is compared against.
54725
55502
  ...summary.ageing === null ? [] : [`ageing: ${name(summary.ageing.amount)} by ${name(summary.ageing.due)} (${summary.ageing.buckets.join(" \xB7 ")} days)`],
55503
+ // WHICH WAY IS GOOD IS THE HALF NOBODY CAN READ OFF THE FIGURE. A book wants
55504
+ // its takings rising and a desk wants its backlog falling, and the same
55505
+ // arrow is a win on one and an alarm on the other.
55506
+ ...summary.trend === null ? [] : [`trend: ${name(summary.trend.field)} across the period (${summary.trend.direction} is good)`],
54726
55507
  // WHERE the sum is drawn is the reading a person checks. A register states
54727
55508
  // ONE aggregate, over the rows the reader can see, in the band it already
54728
55509
  // reads the count in — except a SHARE, which does not add up: what a column
@@ -54744,6 +55525,11 @@ function describeCarried(model, resolved) {
54744
55525
  // A VERB ON A SECTION STANDS WHERE ITS EFFECT LANDS, and which SECTION each
54745
55526
  // alias resolved to is the half the author cannot see in their own file.
54746
55527
  ...sectionActLines(model, resolved, act),
55528
+ // A SHEET IS THE ONE SECTION THAT WRITES, so the plan says which register it
55529
+ // prices and which of the child's figures is the base and which the answer.
55530
+ ...Object.entries(resolved.screen.sections ?? {}).map(
55531
+ ([alias, drawn2]) => `sheet on ${alias}: ${drawn2.cost ?? "(no cost)"} \u2192 ${drawn2.sell ?? "(no sell)"}`
55532
+ ),
54747
55533
  ...resolved.screen.writes === false ? ["operable: no"] : []
54748
55534
  ];
54749
55535
  return parts.join(" \xB7 ");
@@ -54780,42 +55566,52 @@ function foldsAbove(model, resolved, field) {
54780
55566
  function aboveLine(model, resolved, name) {
54781
55567
  const { above, columnTotals, bandTotals } = resolved.summary;
54782
55568
  const folded = [...columnTotals, ...bandTotals].filter((field) => foldsAbove(model, resolved, field)).map((field) => ({ field }));
54783
- const named = [...above === null || above === "counts" ? [] : above, ...folded];
54784
- const once = named.filter((figure, at2) => named.findIndex((other) => other.field.alias === figure.field.alias) === at2);
55569
+ const named2 = [...above === null || above === "counts" ? [] : above, ...folded];
55570
+ const once = named2.filter((figure, at2) => named2.findIndex((other) => other.field.alias === figure.field.alias) === at2);
54785
55571
  const figures = [
54786
55572
  ...above === "counts" ? ["counts"] : [],
54787
55573
  ...once.map((figure) => `${name(figure.field)}${figure.at === void 0 ? "" : " at the window's end"}`)
54788
55574
  ];
54789
55575
  if (above === null && figures.length === 0) return [];
54790
- return [`above: ${figures.length === 0 ? "rows" : figures.join(" \xB7 ")}`];
55576
+ const stated2 = resolved.screen.summary?.above !== void 0 || folded.length > 0;
55577
+ return [`above: ${figures.length === 0 ? "rows" : figures.join(" \xB7 ")}${stated2 ? "" : " (default)"}`];
54791
55578
  }
54792
55579
  function totalsUnder(model, resolved) {
54793
- const named = [...resolved.summary.columnTotals, ...resolved.summary.bandTotals].filter(
55580
+ const named2 = [...resolved.summary.columnTotals, ...resolved.summary.bandTotals].filter(
54794
55581
  (field) => !foldsAbove(model, resolved, field)
54795
55582
  );
54796
- if (named.length === 0) return [];
54797
- return [`totals: ${named.map((field) => fieldName(model, resolved.entity, field)).join(" \xB7 ")} (under the register)`];
55583
+ if (named2.length === 0) return [];
55584
+ return [`totals: ${named2.map((field) => fieldName(model, resolved.entity, field)).join(" \xB7 ")} (under the register)`];
54798
55585
  }
54799
55586
  function describeCoverage(model) {
54800
55587
  return roleCoverage(model.contract, model.field_roles, model.rows).map((note2) => ` ${note2.path}: ${note2.message}`);
54801
55588
  }
54802
55589
  function describeWriters(model) {
54803
55590
  const appsByEntity = /* @__PURE__ */ new Map();
54804
- for (const resolved of resolvePlan(model)) {
54805
- if (resolved.screen.writes === false) continue;
54806
- const entry = appsByEntity.get(resolved.entity.alias) ?? {
54807
- label: resolved.entity.label,
54808
- ...resolved.entity.singular === void 0 ? {} : { singular: resolved.entity.singular },
54809
- apps: []
54810
- };
54811
- if (!entry.apps.includes(resolved.app.name)) entry.apps.push(resolved.app.name);
54812
- appsByEntity.set(resolved.entity.alias, entry);
55591
+ const resolved = resolvePlan(model);
55592
+ const named2 = (entity) => appsByEntity.get(entity.alias) ?? {
55593
+ label: entity.label,
55594
+ ...entity.singular === void 0 ? {} : { singular: entity.singular },
55595
+ apps: []
55596
+ };
55597
+ for (const screen of resolved) {
55598
+ if (screen.screen.writes === false) continue;
55599
+ const entry = named2(screen.entity);
55600
+ if (!entry.apps.includes(screen.app.name)) entry.apps.push(screen.app.name);
55601
+ appsByEntity.set(screen.entity.alias, entry);
55602
+ }
55603
+ const registered = new Set(resolved.map((screen) => screen.entity.alias));
55604
+ for (const screen of resolved) {
55605
+ for (const alias of unregisteredChildren(screen, model.contract.entities, model.field_roles, registered)) {
55606
+ const entity = model.contract.entities.find((one) => one.alias === alias);
55607
+ if (entity !== void 0) appsByEntity.set(alias, named2(entity));
55608
+ }
54813
55609
  }
54814
55610
  if (appsByEntity.size === 0) return [];
54815
55611
  const lines = [];
54816
55612
  for (const [alias, entry] of appsByEntity) {
54817
55613
  lines.push(
54818
- ` ${entry.label} (${alias}${entry.singular === void 0 ? "" : `, one: ${entry.singular}`}): ${entry.apps.join(", ")}` + (entry.apps.length > 1 ? ` \u2014 ${entry.apps.length} desks write this record; state the split in each app's package.json#lotics.writes` : "")
55614
+ ` ${entry.label} (${alias}${entry.singular === void 0 ? "" : `, one: ${entry.singular}`}): ${entry.apps.length === 0 ? "(no app)" : entry.apps.join(", ")}` + (entry.apps.length > 1 ? ` \u2014 ${entry.apps.length} desks write this record; state the split in each app's package.json#lotics.writes` : "")
54819
55615
  );
54820
55616
  for (const clause of describeWriteRules(model, alias)) lines.push(` ${clause}`);
54821
55617
  }
@@ -54845,9 +55641,9 @@ function writersJson(model) {
54845
55641
  const writers = {};
54846
55642
  for (const resolved of resolvePlan(model)) {
54847
55643
  if (resolved.screen.writes === false) continue;
54848
- const named = writers[resolved.entity.alias] ?? [];
54849
- if (!named.includes(resolved.app.alias)) writers[resolved.entity.alias] = [...named, resolved.app.alias];
54850
- else writers[resolved.entity.alias] = named;
55644
+ const named2 = writers[resolved.entity.alias] ?? [];
55645
+ if (!named2.includes(resolved.app.alias)) writers[resolved.entity.alias] = [...named2, resolved.app.alias];
55646
+ else writers[resolved.entity.alias] = named2;
54851
55647
  }
54852
55648
  return writers;
54853
55649
  }
@@ -54878,7 +55674,7 @@ function planJson(model) {
54878
55674
  obligations: resolved.obligations.map((owed) => ({
54879
55675
  field: owed.field.alias,
54880
55676
  label: owed.label,
54881
- satisfied_by: owed.satisfiedBy.alias
55677
+ ...owed.satisfiedBy === void 0 ? {} : { satisfied_by: owed.satisfiedBy.alias }
54882
55678
  }))
54883
55679
  },
54884
55680
  ...resolved.acts.row.length === 0 && resolved.acts.selection.length === 0 ? {} : {
@@ -54892,7 +55688,14 @@ function planJson(model) {
54892
55688
  }
54893
55689
  function actJson(act) {
54894
55690
  const place = act.place === void 0 ? {} : { place: act.place };
54895
- return act.kind === "agent" ? { label: act.label, agent: act.agent, fills: act.fills.map((field) => field.alias), ...place } : { label: act.label, template: act.template.alias, ...place };
55691
+ if (act.kind === "agent") {
55692
+ return { kind: act.kind, label: act.label, agent: act.agent, fills: act.fills.map((field) => field.alias), ...place };
55693
+ }
55694
+ if (act.kind === "workflow") {
55695
+ const inputs = Object.fromEntries(act.inputs.map((input) => [input.name, input.field === null ? RECORD_INPUT : input.field.alias]));
55696
+ return { kind: act.kind, label: act.label, workflow: act.workflow, inputs, ...place };
55697
+ }
55698
+ return { label: act.label, template: act.template.alias, ...place };
54896
55699
  }
54897
55700
  var presetSourceSchema = zod_default.object({
54898
55701
  entities: zod_default.array(contractEntitySchema),
@@ -54944,14 +55747,14 @@ async function resolveOverlay(file2, raw, overlay) {
54944
55747
  note(`${file2} starts from ${read2.name} (${overlay.from}).`);
54945
55748
  return { kind: "ok", model, notes };
54946
55749
  }
54947
- async function knownSlugs(named) {
55750
+ async function knownSlugs(named2) {
54948
55751
  let slugs;
54949
55752
  try {
54950
55753
  slugs = (await fetchPresetIndex()).map((row) => row.slug);
54951
55754
  } catch {
54952
55755
  return "";
54953
55756
  }
54954
- return slugs.includes(named) ? "" : `
55757
+ return slugs.includes(named2) ? "" : `
54955
55758
  The presets there are: ${slugs.join(", ")}.`;
54956
55759
  }
54957
55760
  async function checkModelFile(file2) {
@@ -55382,6 +56185,7 @@ var READ_OPS = /* @__PURE__ */ new Set([
55382
56185
  "context",
55383
56186
  "query",
55384
56187
  "field_options",
56188
+ "field_history",
55385
56189
  "members",
55386
56190
  "agentRuns",
55387
56191
  "agentRun.get",
@@ -55414,6 +56218,7 @@ function refusalMessage(refused) {
55414
56218
  var SUPPORTED_OPS = /* @__PURE__ */ new Set([
55415
56219
  "query",
55416
56220
  "field_options",
56221
+ "field_history",
55417
56222
  "workflow",
55418
56223
  "members",
55419
56224
  "context",
@@ -55459,6 +56264,17 @@ async function dispatchRpc(client, body, opts) {
55459
56264
  }
55460
56265
  return client.appFieldOptions(body.app_id, p.alias);
55461
56266
  }
56267
+ case "field_history": {
56268
+ const p = body.payload;
56269
+ if (!p || typeof p.table_id !== "string" || typeof p.record_id !== "string" || typeof p.field_id !== "string") {
56270
+ throw new Error("field_history payload must include table_id, record_id, field_id");
56271
+ }
56272
+ return client.appFieldHistory(body.app_id, {
56273
+ table_id: p.table_id,
56274
+ record_id: p.record_id,
56275
+ field_id: p.field_id
56276
+ });
56277
+ }
55462
56278
  case "workflow": {
55463
56279
  const p = body.payload;
55464
56280
  if (!p || typeof p.alias !== "string") {
@@ -56073,11 +56889,11 @@ function rpcFailureBody(err) {
56073
56889
  if (!(err instanceof LoticsRequestError)) return { message: message2 };
56074
56890
  const code = err.body.code;
56075
56891
  const raw = err.body.field_errors;
56076
- const named = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? Object.entries(raw).filter((e) => typeof e[1] === "string") : [];
56892
+ const named2 = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? Object.entries(raw).filter((e) => typeof e[1] === "string") : [];
56077
56893
  return {
56078
56894
  message: message2,
56079
56895
  ...typeof code === "string" ? { code } : {},
56080
- ...named.length > 0 ? { field_errors: Object.fromEntries(named) } : {}
56896
+ ...named2.length > 0 ? { field_errors: Object.fromEntries(named2) } : {}
56081
56897
  };
56082
56898
  }
56083
56899
  function servesWrapperPage(method, pathname) {
@@ -57870,15 +58686,15 @@ function warnLocalKit(projectDir) {
57870
58686
  function assertKitShippable(projectDir, options) {
57871
58687
  const entries2 = Object.entries(readLocalKit(projectDir));
57872
58688
  if (entries2.length === 0) return;
57873
- const named = entries2.map(([name, record2]) => describeRecord2(name, record2)).join(", ");
58689
+ const named2 = entries2.map(([name, record2]) => describeRecord2(name, record2)).join(", ");
57874
58690
  if (options.allowLocalKit) {
57875
58691
  warn(
57876
- `\u26A0 Deploying against a local kit build (${named}) \u2014 the bundle carries it, but the source archive does not, so a clone of this app will not install.`
58692
+ `\u26A0 Deploying against a local kit build (${named2}) \u2014 the bundle carries it, but the source archive does not, so a clone of this app will not install.`
57877
58693
  );
57878
58694
  return;
57879
58695
  }
57880
58696
  fail(
57881
- `This app is installed against a local kit build (${named}), and ${KIT_DIR}/ is not in the source a deploy uploads.
58697
+ `This app is installed against a local kit build (${named2}), and ${KIT_DIR}/ is not in the source a deploy uploads.
57882
58698
  Publish the kit and run: lotics app kit <path-to-package> --published
57883
58699
  Or ship it anyway: lotics app deploy --allow-local-kit`
57884
58700
  );
@@ -59630,8 +60446,8 @@ Ready. Next steps:`);
59630
60446
  console.error(` lotics app check # every pre-flight a deploy runs`);
59631
60447
  console.error(` lotics app deploy`);
59632
60448
  } else {
59633
- console.error(` # edit src/screens/*.tsx \u2014 one per plan screen \u2014 then:`);
59634
- console.error(` lotics app check --screens # every pre-flight a deploy runs, and the probes over each screen`);
60449
+ console.error(` # edit app.json \u2014 the screens, their records and their acts \u2014 then:`);
60450
+ console.error(` lotics app check # every pre-flight a deploy runs, the spec read against the project`);
59635
60451
  console.error(` lotics app deploy`);
59636
60452
  }
59637
60453
  }
@@ -60385,6 +61201,7 @@ async function appDeploy(client, args) {
60385
61201
  });
60386
61202
  note(`Deployed v${result.version_number} (${result.version_id})`);
60387
61203
  note(`Bundle size: ${(result.bundle_size_bytes / 1024).toFixed(1)} KB`);
61204
+ if (result.origin !== void 0) note(`Address: ${result.origin}`);
60388
61205
  try {
60389
61206
  const drifted = staleWorkflowGlobals(projectDir, meta3.workflows ?? {});
60390
61207
  for (const { alias, declaration } of drifted) {
@@ -61327,11 +62144,11 @@ async function warnIfQueriesIgnoreRowRules(client, queries) {
61327
62144
  const lines = [];
61328
62145
  const unguarded = /* @__PURE__ */ new Set();
61329
62146
  for (const [alias, scans] of byQuery) {
61330
- const named = /* @__PURE__ */ new Set();
62147
+ const named2 = /* @__PURE__ */ new Set();
61331
62148
  for (const scan of scans) {
61332
62149
  const name = scoped.get(scan.table_id);
61333
- if (scan.guarded || name === void 0 || named.has(name)) continue;
61334
- named.add(name);
62150
+ if (scan.guarded || name === void 0 || named2.has(name)) continue;
62151
+ named2.add(name);
61335
62152
  unguarded.add(alias);
61336
62153
  lines.push(
61337
62154
  ` \u2022 ${alias} over ${name} \u2014 ${name} declares a row rule; this query runs as the app's owner and returns every row. Add the viewer predicate the rule names.`
@@ -62397,7 +63214,16 @@ function candidates(spec) {
62397
63214
  kind: "refused",
62398
63215
  message: `"${act.label}" runs an agent, and the panel a run is reviewed in is the kit's \u2014 started once,
62399
63216
  reviewed before it is applied, cancelled by closing. Only a paper act opens a panel of the app's own.`
62400
- } : act.component !== void 0 ? already(`"${act.label}"`, act.component) : { kind: "target", target: { kind: "act", at: at2, act } }
63217
+ } : (
63218
+ // A HAND-OFF'S SURFACE IS THE BODY THE AUTHOR WROTE. What a panel
63219
+ // would ask for is an input, and an input is the plan's own clause
63220
+ // — so the answer to "ask first" is the workflow, not a component.
63221
+ act.kind === "workflow" ? {
63222
+ kind: "refused",
63223
+ message: `"${act.label}" hands the row to the "${act.workflow}" workflow, whose body is already yours \u2014 what a
63224
+ panel would ask for is an input, which the act's own \`inputs\` names. Only a paper act opens a panel.`
63225
+ } : act.component !== void 0 ? already(`"${act.label}"`, act.component) : { kind: "target", target: { kind: "act", at: at2, act } }
63226
+ )
62401
63227
  )
62402
63228
  };
62403
63229
  })
@@ -62760,13 +63586,13 @@ var countOf = (outcomes, kind) => outcomes.filter((outcome) => outcome.kind ===
62760
63586
  var list2 = (aliases) => aliases.length === 0 ? "\u2014" : aliases.join(", ");
62761
63587
  function renderSummary(summary, dryRun) {
62762
63588
  const { outcomes, deploy } = summary;
62763
- const named = (kind) => outcomes.filter((outcome) => outcome.kind === kind).map((outcome) => ` ${outcome.path}${outcome.kind === "kept" ? ` \u2014 ${outcome.why}` : ""}`);
63589
+ const named2 = (kind) => outcomes.filter((outcome) => outcome.kind === kind).map((outcome) => ` ${outcome.path}${outcome.kind === "kept" ? ` \u2014 ${outcome.why}` : ""}`);
62764
63590
  const nothingToDeploy = deploy.added.length === 0 && deploy.changed.length === 0 && deploy.removed.length === 0;
62765
63591
  const lines = [
62766
63592
  dryRun ? "lotics app regenerate --dry-run \u2014 nothing was written." : "lotics app regenerate",
62767
63593
  ` Files: ${countOf(outcomes, "written")} written, ${countOf(outcomes, "kept")} kept, ${countOf(outcomes, "deleted")} deleted`,
62768
63594
  ...["kept", "deleted"].flatMap((kind) => {
62769
- const rows = named(kind);
63595
+ const rows = named2(kind);
62770
63596
  return rows.length === 0 ? [] : [` ${kind}:`, ...rows];
62771
63597
  }),
62772
63598
  // A declaration losing an entry is not something to do quietly, and the two
@@ -63167,38 +63993,38 @@ function renameInModel(raw, args) {
63167
63993
  function isRecord(value) {
63168
63994
  return typeof value === "object" && value !== null && !Array.isArray(value);
63169
63995
  }
63170
- async function resolveTable(client, named) {
63996
+ async function resolveTable(client, named2) {
63171
63997
  const res = await client.execute("query_tables", {}, { format: "json" });
63172
63998
  if (res.error) throw new Error(`Could not list this workspace's tables: ${res.error}`);
63173
63999
  const rows = Array.isArray(res.result) ? res.result : [];
63174
64000
  const tables = rows.flatMap(
63175
64001
  (row) => typeof row.table_id === "string" && typeof row.table_name === "string" ? [{ id: row.table_id, name: row.table_name }] : []
63176
64002
  );
63177
- const byId2 = tables.find((table) => table.id === named);
64003
+ const byId2 = tables.find((table) => table.id === named2);
63178
64004
  if (byId2 !== void 0) return byId2;
63179
- const byName = tables.filter((table) => table.name === named);
64005
+ const byName = tables.filter((table) => table.name === named2);
63180
64006
  if (byName.length === 1) return byName[0];
63181
64007
  if (byName.length > 1) {
63182
64008
  throw new Error(
63183
- `"${named}" names ${byName.length} tables here. Name the one you mean by id: ${byName.map((t) => t.id).join(", ")}.`
64009
+ `"${named2}" names ${byName.length} tables here. Name the one you mean by id: ${byName.map((t) => t.id).join(", ")}.`
63184
64010
  );
63185
64011
  }
63186
64012
  throw new Error(
63187
- `No table here is called "${named}". This workspace has: ${tables.map((t) => t.name).join(", ")}.`
64013
+ `No table here is called "${named2}". This workspace has: ${tables.map((t) => t.name).join(", ")}.`
63188
64014
  );
63189
64015
  }
63190
- function resolveField(table, named) {
63191
- const byId2 = table.fields.find((field) => field.id === named);
64016
+ function resolveField(table, named2) {
64017
+ const byId2 = table.fields.find((field) => field.id === named2);
63192
64018
  if (byId2 !== void 0) return byId2;
63193
- const byName = table.fields.filter((field) => field.name === named);
64019
+ const byName = table.fields.filter((field) => field.name === named2);
63194
64020
  if (byName.length === 1) return byName[0];
63195
64021
  if (byName.length > 1) {
63196
64022
  throw new Error(
63197
- `"${named}" names ${byName.length} fields on ${table.name}. Name the one you mean by key: ${byName.map((f) => f.id).join(", ")}.`
64023
+ `"${named2}" names ${byName.length} fields on ${table.name}. Name the one you mean by key: ${byName.map((f) => f.id).join(", ")}.`
63198
64024
  );
63199
64025
  }
63200
64026
  throw new Error(
63201
- `${table.name} has no field called "${named}". Its fields are: ${table.fields.map((f) => f.name).join(", ")}.`
64027
+ `${table.name} has no field called "${named2}". Its fields are: ${table.fields.map((f) => f.name).join(", ")}.`
63202
64028
  );
63203
64029
  }
63204
64030
  async function readTableSchema(client, tableId) {