@lotics/cli 0.206.0 → 0.208.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
@@ -15807,12 +15807,12 @@ var LoticsClient = class {
15807
15807
  const disposition = response.headers.get("content-disposition") ?? "";
15808
15808
  const originalFilename = parseContentDispositionFilename(disposition) ?? fileId;
15809
15809
  const buffer = Buffer.from(await response.arrayBuffer());
15810
- const { dir, named } = destination === void 0 ? { dir: process.cwd() } : "file" in destination ? {
15810
+ const { dir, named: named2 } = destination === void 0 ? { dir: process.cwd() } : "file" in destination ? {
15811
15811
  dir: path2.dirname(path2.resolve(destination.file)),
15812
15812
  named: path2.basename(destination.file)
15813
15813
  } : { dir: path2.resolve(destination.dir) };
15814
15814
  await fs2.promises.mkdir(dir, { recursive: true });
15815
- const filename = named ?? findAvailableFilename(dir, originalFilename, options?.reserved);
15815
+ const filename = named2 ?? findAvailableFilename(dir, originalFilename, options?.reserved);
15816
15816
  const absolutePath = path2.join(dir, filename);
15817
15817
  await fs2.promises.writeFile(absolutePath, buffer);
15818
15818
  return { path: absolutePath, filename, stored_filename: originalFilename };
@@ -33624,8 +33624,8 @@ function conditionIssues(node, path23, fieldMap) {
33624
33624
  return [`${path23}: a button field holds no data and cannot be filtered.`];
33625
33625
  }
33626
33626
  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(", ")}`];
33627
+ const named2 = fieldMap && filterType !== obj.type ? `'${String(filterType)}' (resolved from field '${obj.field_key}')` : `'${String(obj.type)}'`;
33628
+ return [`${path23}: invalid filter type ${named2}. Valid: ${VALID_FILTER_TYPES.join(", ")}`];
33629
33629
  }
33630
33630
  const validOps = VALID_OPERATORS[filterType];
33631
33631
  if (typeof obj.operator !== "string" || !validOps.includes(obj.operator)) {
@@ -36941,6 +36941,10 @@ var contractFieldSchema = zod_default.discriminatedUnion("type", [
36941
36941
  contractLookupFieldSchema,
36942
36942
  contractAutonumberFieldSchema
36943
36943
  ]);
36944
+ function fieldHoldsSeveral(field) {
36945
+ if (field.type === "select") return field.multi === true;
36946
+ return field.type === "select_record_link" && field.cardinality !== "one";
36947
+ }
36944
36948
  var contractViewSchema = zod_default.object({
36945
36949
  alias: contractAliasSchema.describe("Stable view alias, unique within the entity"),
36946
36950
  label: zod_default.string().min(1).describe("Display name of the view"),
@@ -37473,8 +37477,8 @@ function checkOutputRefs(output, path23, entityAliases, optionAliases, fieldAlia
37473
37477
  checkOutputRefs(output.items, `${path23}[]`, entityAliases, optionAliases, fieldAliases, errors);
37474
37478
  }
37475
37479
  }
37476
- function checkSourceLinks(source, path23, parentEntity, fieldByEntity, errors) {
37477
- const recur = (child) => checkSourceLinks(child, path23, parentEntity, fieldByEntity, errors);
37480
+ function checkSourceLinks(source, path23, parentEntity2, fieldByEntity, errors) {
37481
+ const recur = (child) => checkSourceLinks(child, path23, parentEntity2, fieldByEntity, errors);
37478
37482
  if (typeof source === "string") return;
37479
37483
  if ("eq" in source) return source.eq.forEach(recur);
37480
37484
  if ("neq" in source) return source.neq.forEach(recur);
@@ -37484,7 +37488,7 @@ function checkSourceLinks(source, path23, parentEntity, fieldByEntity, errors) {
37484
37488
  if ("concat" in source) return source.concat.forEach(recur);
37485
37489
  if (!("link" in source) && !("link_agg" in source)) return;
37486
37490
  const reach = "link" in source ? source.link : source.link_agg;
37487
- if (parentEntity === null) {
37491
+ if (parentEntity2 === null) {
37488
37492
  errors.push({
37489
37493
  severity: "error",
37490
37494
  path: path23,
@@ -37492,14 +37496,14 @@ function checkSourceLinks(source, path23, parentEntity, fieldByEntity, errors) {
37492
37496
  });
37493
37497
  return;
37494
37498
  }
37495
- const parentFields = fieldByEntity.get(parentEntity);
37499
+ const parentFields = fieldByEntity.get(parentEntity2);
37496
37500
  if (parentFields === void 0) return;
37497
37501
  const sourceField = parentFields.get(reach.source);
37498
37502
  if (sourceField === void 0) {
37499
37503
  errors.push({
37500
37504
  severity: "error",
37501
37505
  path: path23,
37502
- message: `link source "${reach.source}" is not a field on entity "${parentEntity}"`
37506
+ message: `link source "${reach.source}" is not a field on entity "${parentEntity2}"`
37503
37507
  });
37504
37508
  return;
37505
37509
  }
@@ -37507,7 +37511,7 @@ function checkSourceLinks(source, path23, parentEntity, fieldByEntity, errors) {
37507
37511
  errors.push({
37508
37512
  severity: "error",
37509
37513
  path: path23,
37510
- message: `link source "${reach.source}" on entity "${parentEntity}" is a ${sourceField.type} field, not select_record_link`
37514
+ message: `link source "${reach.source}" on entity "${parentEntity2}" is a ${sourceField.type} field, not select_record_link`
37511
37515
  });
37512
37516
  return;
37513
37517
  }
@@ -38265,8 +38269,11 @@ function resolvedFieldType(field, entity, entities, hops = 0) {
38265
38269
  }
38266
38270
  var ANSWER_KEYS = ["true", "false"];
38267
38271
  var requiredBySchema = zod_default.object({
38272
+ from: zod_default.enum(["own", "parent"]).optional().describe(
38273
+ `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`
38274
+ ),
38268
38275
  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"
38276
+ "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
38277
  ),
38271
38278
  options: zod_default.record(contractAliasSchema, zod_default.array(contractAliasSchema)).describe(
38272
38279
  `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 +38292,19 @@ var fieldRoleDeclSchema = zod_default.object({
38285
38292
  '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
38293
  ),
38287
38294
  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"),
38295
+ due: zod_default.literal(true).optional().describe(
38296
+ "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"
38297
+ ),
38288
38298
  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"
38299
+ "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
38300
  ),
38291
38301
  signed_by: contractAliasSchema.optional().describe("For an amount: the select on the same entity that says which direction it moved"),
38292
38302
  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
38303
  required_by: requiredBySchema.optional().describe("For an expected_set: the select whose value decides which entries a row owes"),
38294
38304
  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")
38305
+ satisfied_by: contractAliasSchema.optional().describe(
38306
+ "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"
38307
+ )
38296
38308
  }).strict();
38297
38309
  var fieldRolesSchema = zod_default.record(
38298
38310
  contractAliasSchema,
@@ -38301,6 +38313,14 @@ var fieldRolesSchema = zod_default.record(
38301
38313
  zod_default.preprocess((value) => typeof value === "string" ? { role: value } : value, fieldRoleDeclSchema).nullable()
38302
38314
  )
38303
38315
  );
38316
+ function countsDown(decl) {
38317
+ if (decl?.role === "obligation") return true;
38318
+ return decl?.role === "when" && (decl.due === true || decl.until !== void 0);
38319
+ }
38320
+ function parentEntity(entity, entities, roles) {
38321
+ const link = entity.fields.find((field) => roleOf(roles, entity.alias, field.alias)?.role === "parent");
38322
+ return link === void 0 ? void 0 : linkTarget(entity, entities, link.alias);
38323
+ }
38304
38324
  function roleOf(roles, entity, field) {
38305
38325
  if (!Object.hasOwn(roles, entity)) return void 0;
38306
38326
  const fields = roles[entity];
@@ -38349,9 +38369,10 @@ function checkFieldRoles(entities, roles) {
38349
38369
  errors.push(...checkCurrencyOptions(decl, field, path23));
38350
38370
  errors.push(...checkOutcomes(decl, field, path23));
38351
38371
  errors.push(...checkUntil(decl, entity, roles, path23));
38372
+ errors.push(...checkDue(decl, path23));
38352
38373
  errors.push(...checkSlot(decl, field, path23));
38353
38374
  errors.push(...checkSign(decl, fieldByAlias, entityAlias, path23));
38354
- errors.push(...checkRequiredBy(decl, field, fieldByAlias, entity, entities, path23));
38375
+ errors.push(...checkRequiredBy(decl, field, entity, entities, roles, path23));
38355
38376
  errors.push(...checkObligation(decl, fieldAlias, fieldByAlias, entityAlias, path23));
38356
38377
  errors.push(...checkCounts(decl, field, entity, entities, path23));
38357
38378
  if (decl.reading !== void 0 && (decl.role !== "measure" || decl.against === void 0)) {
@@ -38509,17 +38530,40 @@ function requiredByReads(condition, entity, entities) {
38509
38530
  if (condition.type === "select") return condition.multi === true ? void 0 : "option";
38510
38531
  return resolvedFieldType(condition, entity, entities) === "boolean" ? "answer" : void 0;
38511
38532
  }
38512
- function checkRequiredBy(decl, field, fieldByAlias, entity, entities, path23) {
38533
+ function requiredByFrom(field) {
38534
+ return field.type === "select" && field.multi !== true ? "parent" : "own";
38535
+ }
38536
+ function checkRequiredBy(decl, field, entity, entities, roles, path23) {
38513
38537
  const requiredBy = decl.required_by;
38514
38538
  if (requiredBy === void 0) return [];
38515
38539
  if (decl.role !== "expected_set") {
38516
38540
  return [{ severity: "error", path: path23, message: `required_by belongs to an expected_set \u2014 this role is "${decl.role}"` }];
38517
38541
  }
38518
- const condition = fieldByAlias.get(requiredBy.field);
38542
+ const from = requiredByFrom(field);
38543
+ if ((requiredBy.from ?? "own") !== from) {
38544
+ return [
38545
+ {
38546
+ severity: "error",
38547
+ path: path23,
38548
+ 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`
38549
+ }
38550
+ ];
38551
+ }
38552
+ const deciding = from === "parent" ? parentEntity(entity, entities, roles) : entity;
38553
+ if (deciding === void 0) {
38554
+ return [
38555
+ {
38556
+ severity: "error",
38557
+ path: path23,
38558
+ message: `required_by reads the parent's "${requiredBy.field}" \u2014 "${entity.alias}" declares no parent link to reach one through`
38559
+ }
38560
+ ];
38561
+ }
38562
+ const condition = deciding.fields.find((candidate) => candidate.alias === requiredBy.field);
38519
38563
  if (condition === void 0) {
38520
- return [{ severity: "error", path: path23, message: `required_by names "${requiredBy.field}", which is not a field of "${entity.alias}"` }];
38564
+ return [{ severity: "error", path: path23, message: `required_by names "${requiredBy.field}", which is not a field of "${deciding.alias}"` }];
38521
38565
  }
38522
- const reads = requiredByReads(condition, entity, entities);
38566
+ const reads = requiredByReads(condition, deciding, entities);
38523
38567
  if (reads === void 0) {
38524
38568
  return [
38525
38569
  {
@@ -38564,11 +38608,12 @@ function checkObligation(decl, fieldAlias, fieldByAlias, entityAlias, path23) {
38564
38608
  return [{ severity: "error", path: path23, message: `label and satisfied_by belong to an obligation \u2014 this role is "${decl.role}"` }];
38565
38609
  }
38566
38610
  if (decl.satisfied_by === void 0) {
38611
+ if (decl.until !== void 0) return [];
38567
38612
  return [
38568
38613
  {
38569
38614
  severity: "error",
38570
38615
  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`
38616
+ 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
38617
  }
38573
38618
  ];
38574
38619
  }
@@ -38618,8 +38663,8 @@ function checkOutcomes(decl, field, path23) {
38618
38663
  }
38619
38664
  function checkUntil(decl, entity, roles, path23) {
38620
38665
  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}"` }];
38666
+ if (decl.role !== "when" && decl.role !== "obligation") {
38667
+ return [{ severity: "error", path: path23, message: `until belongs to a when or an obligation \u2014 this role is "${decl.role}"` }];
38623
38668
  }
38624
38669
  const lifecycle = entity.fields.find((field) => roleOf(roles, entity.alias, field.alias)?.role === "lifecycle");
38625
38670
  if (lifecycle === void 0) {
@@ -38630,6 +38675,16 @@ function checkUntil(decl, entity, roles, path23) {
38630
38675
  }
38631
38676
  return [];
38632
38677
  }
38678
+ function checkDue(decl, path23) {
38679
+ if (decl.due === void 0 || decl.role === "when") return [];
38680
+ return [
38681
+ {
38682
+ severity: "error",
38683
+ path: path23,
38684
+ 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`
38685
+ }
38686
+ ];
38687
+ }
38633
38688
  function checkSlot(decl, field, path23) {
38634
38689
  if (decl.role !== "slot" || field.type !== "date") return [];
38635
38690
  if (field.format === "datetime" || field.format === "datetime_range") return [];
@@ -38908,7 +38963,13 @@ var SHAPE_REGISTRY = {
38908
38963
  slots: {
38909
38964
  // THE ROW IS A GROUP BY — a party, an option or a period — and every
38910
38965
  // column beside it is an aggregate over the set behind it.
38911
- subject: slot(["category", "party", "when"], true, { subject: true }),
38966
+ //
38967
+ // SEVERAL OF THEM ARE A HIERARCHY. "What is each customer worth" and
38968
+ // "which of their projects" are one question asked at two depths, and a
38969
+ // register per depth is the same figures folded twice with nothing saying
38970
+ // they are the same money — so the slot takes the tiers in order and the
38971
+ // reader walks down them.
38972
+ subject: slot(["category", "party", "when"], true, { subject: true, tiers: true }),
38912
38973
  amount: slot("amount"),
38913
38974
  measure: slot("measure"),
38914
38975
  // WHAT ONE ROW BEHIND A FIGURE IS CALLED. The set is what a group opens
@@ -39034,6 +39095,16 @@ var SHAPE_REGISTRY = {
39034
39095
  };
39035
39096
  var SHAPE_NAMES = Object.keys(SHAPE_REGISTRY);
39036
39097
  var CUSTOM_SHAPE = "custom";
39098
+ function slotTakes(shape, name) {
39099
+ if (shape === CUSTOM_SHAPE) return [];
39100
+ const slots = SHAPE_REGISTRY[shape].slots;
39101
+ return slots[name]?.roles ?? [];
39102
+ }
39103
+ function slotTakesTiers(shape, name) {
39104
+ if (shape === CUSTOM_SHAPE) return false;
39105
+ const slots = SHAPE_REGISTRY[shape].slots;
39106
+ return slots[name]?.tiers === true;
39107
+ }
39037
39108
  function shapeDrawsBand(shape, band) {
39038
39109
  if (shape === CUSTOM_SHAPE) return true;
39039
39110
  const declared = SHAPE_REGISTRY[shape];
@@ -39072,16 +39143,25 @@ function shapeFansObligations(shape) {
39072
39143
  const declared = SHAPE_REGISTRY[shape];
39073
39144
  return declared.obligations === true;
39074
39145
  }
39146
+ var CONTEXT_COLUMNS = 4;
39147
+ var SEATED_COLUMNS = 6;
39148
+ function shapeDrawsColumns(shape) {
39149
+ return shapeListsRows(shape) && shapeRowsAreTable(shape) && !shapeFansObligations(shape);
39150
+ }
39151
+ function shapesDrawingColumns() {
39152
+ return SHAPE_NAMES.filter(shapeDrawsColumns);
39153
+ }
39075
39154
  function registerReadsForward(shape) {
39076
39155
  if (shape === CUSTOM_SHAPE) return false;
39077
39156
  const declared = SHAPE_REGISTRY[shape];
39078
39157
  return declared.forward === true;
39079
39158
  }
39080
- var PRESENTATION_LEADS = ["mark", "picture", "figure", "none"];
39159
+ var PRESENTATION_LEADS = ["mark", "picture", "paper", "figure", "none"];
39081
39160
  var PRESENTATION_DENSITIES = ["roomy", "dense"];
39082
39161
  var SCREEN_LAYOUTS = ["table", "list", "cards", "gallery", "board", "calendar", "timeline", "gantt"];
39083
- function layoutsFor(roles) {
39084
- const held = new Set(roles);
39162
+ function layoutsFor(drawn) {
39163
+ const columns = [...drawn];
39164
+ const held = new Set(columns.map((column) => column.role));
39085
39165
  const afforded = /* @__PURE__ */ new Set(["table", "list"]);
39086
39166
  if (held.has("mark")) {
39087
39167
  afforded.add("cards");
@@ -39091,7 +39171,7 @@ function layoutsFor(roles) {
39091
39171
  if (held.has("when")) {
39092
39172
  afforded.add("calendar");
39093
39173
  afforded.add("timeline");
39094
- if (held.has("measure")) afforded.add("gantt");
39174
+ if (columns.some((column) => column.role === "measure" && column.counts === "days")) afforded.add("gantt");
39095
39175
  }
39096
39176
  return SCREEN_LAYOUTS.filter((layout) => afforded.has(layout));
39097
39177
  }
@@ -39127,7 +39207,17 @@ var contractPresentationSchema = zod_default.object({
39127
39207
  * columns to be and a calendar over rows with no date draws every row on no
39128
39208
  * day — so a layout the rows cannot answer is refused with the set they can.
39129
39209
  */
39130
- layout: screenLayoutSchema.optional().describe("How the rows are arranged \u2014 table, list, cards, gallery, board, calendar, timeline, gantt; absent, a table")
39210
+ layout: screenLayoutSchema.optional().describe("How the rows are arranged \u2014 table, list, cards, gallery, board, calendar, timeline, gantt; absent, a table"),
39211
+ /**
39212
+ * WHERE EACH BAR ENDS, where the rows state the day rather than the span.
39213
+ *
39214
+ * A chart needs a length, and a business records one two ways: a project
39215
+ * says how many days it runs, a lease says the day it is up. The days are
39216
+ * what AFFORDS the chart ({@link layoutsFor}); this is the column the bar is
39217
+ * drawn to when the row states one, which is the more exact answer wherever
39218
+ * a row has both.
39219
+ */
39220
+ 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
39221
  }).strict();
39132
39222
  function leadRefusal(shape, drawn, lead) {
39133
39223
  if (lead === "none") return void 0;
@@ -39136,7 +39226,10 @@ function leadRefusal(shape, drawn, lead) {
39136
39226
  }
39137
39227
  if (!drawn.has("mark")) return `leads with the ${lead}, and no column of these rows is drawn as their mark`;
39138
39228
  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`";
39229
+ if (lead === "mark") {
39230
+ 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`";
39231
+ }
39232
+ 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
39233
  }
39141
39234
  var AGEING_BUCKETS = [30, 60, 90];
39142
39235
  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 +39247,18 @@ var contractAgentActSchema = zod_default.strictObject({
39154
39247
  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
39248
  place: contractActPlaceSchema.optional()
39156
39249
  });
39157
- var contractActSchema = zod_default.union([contractAgentActSchema, contractTemplateActSchema], {
39250
+ var RECORD_INPUT = "record";
39251
+ var contractWorkflowActSchema = zod_default.strictObject({
39252
+ kind: zod_default.literal("workflow"),
39253
+ label: zod_default.string().min(1).describe("The act's own words, as the reader reads them in the menu"),
39254
+ workflow: contractAliasSchema.describe("A workflow alias the APP declares and binds (package.json#lotics.workflows)"),
39255
+ inputs: zod_default.record(
39256
+ contractAliasSchema,
39257
+ contractAliasSchema.describe('A field of this entity, or "record" \u2014 which names the row itself before any field of that alias')
39258
+ ).describe("What the run is handed: the workflow's own input name \u2192 the value on the row it is pressed on"),
39259
+ place: contractActPlaceSchema.optional()
39260
+ });
39261
+ var contractActSchema = zod_default.union([contractAgentActSchema, contractWorkflowActSchema, contractTemplateActSchema], {
39158
39262
  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
39263
  });
39160
39264
  var contractImportActSchema = zod_default.strictObject({
@@ -39166,6 +39270,7 @@ var contractImportActSchema = zod_default.strictObject({
39166
39270
  });
39167
39271
  var contractSlotSchema = zod_default.union([
39168
39272
  contractAliasSchema,
39273
+ zod_default.array(contractAliasSchema).min(2).describe("The tiers this slot folds by, outermost first \u2014 for a slot that takes a hierarchy"),
39169
39274
  zod_default.strictObject({
39170
39275
  field: contractAliasSchema.describe("The field this slot takes"),
39171
39276
  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 +39319,11 @@ var contractPredicateSchema = zod_default.strictObject({
39214
39319
  "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
39320
  )
39216
39321
  });
39322
+ var contractSectionDrawSchema = zod_default.strictObject({
39323
+ draw: zod_default.literal("worksheet").describe("Priced lines the reader works down in place, each part footed and the sheet closing under them"),
39324
+ cost: contractAliasSchema.optional().describe("The child's field holding what a line COSTS \u2014 the base the margin is taken against"),
39325
+ sell: contractAliasSchema.optional().describe("The child's field holding what it SELLS for \u2014 the figure the sheet is read for")
39326
+ }).describe("How a section of this screen's record is drawn, where its rows are priced lines rather than a register");
39217
39327
  var contractLensSchema = zod_default.strictObject({
39218
39328
  label: zod_default.string().min(1).describe("What the chip is called"),
39219
39329
  predicates: zod_default.array(contractPredicateSchema).min(1).describe("The sets it offers, in this order")
@@ -39231,6 +39341,12 @@ var contractScreenSchema = zod_default.object({
39231
39341
  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
39342
  slots: zod_default.record(zod_default.string(), contractSlotSchema).optional().describe("Slot \u2192 the field that fills it, where roles alone cannot decide"),
39233
39343
  roles: zod_default.record(zod_default.string(), fieldRoleSchema).optional().describe(`For "${CUSTOM_SHAPE}" only: slot \u2192 the role that fills it`),
39344
+ // THE SLOTS ARE THE ROW'S ANSWER; THESE ARE ITS CONTEXT — the facts a reader
39345
+ // needs beside that answer and the shape has no slot for. Drawn after the
39346
+ // slots and shed before any of them, so a phone keeps the answer.
39347
+ columns: zod_default.array(contractAliasSchema).min(1).max(CONTEXT_COLUMNS).optional().describe(
39348
+ `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`
39349
+ ),
39234
39350
  // Absent IS true: a desk that can only be read is the exception, so the file
39235
39351
  // says that and says nothing in the ordinary case.
39236
39352
  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 +39372,7 @@ var contractScreenSchema = zod_default.object({
39256
39372
  section_acts: zod_default.record(contractAliasSchema, zod_default.array(contractActSchema).min(1)).optional().describe(
39257
39373
  "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
39374
  ),
39375
+ 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
39376
  filters: zod_default.array(zod_default.union([contractAliasSchema, contractLensSchema])).min(1).optional().describe(
39260
39377
  "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
39378
  ),
@@ -39276,7 +39393,11 @@ var contractScreenSchema = zod_default.object({
39276
39393
  amount: contractAliasSchema.describe("The figure each bucket adds up \u2014 what is still owed on the row"),
39277
39394
  due: contractAliasSchema.describe("The date the row was owed by; a row is bucketed by how far past it is"),
39278
39395
  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")
39396
+ }).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"),
39397
+ trend: zod_default.object({
39398
+ field: contractAliasSchema.describe("The number this register adds up in each bucket of the window"),
39399
+ 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")
39400
+ }).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
39401
  }).strict().optional().describe("What the rows in view come to, stated before or after their names"),
39281
39402
  period: contractAliasSchema.optional().describe("A date field on the entity the reader narrows the view by; absent, the view is every row"),
39282
39403
  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 +39463,8 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39342
39463
  const stated2 = (name) => {
39343
39464
  const clause = screen.slots?.[name];
39344
39465
  if (clause === void 0) return void 0;
39345
- return typeof clause === "string" ? { field: clause } : clause;
39466
+ if (typeof clause === "string") return { field: clause };
39467
+ return Array.isArray(clause) ? { field: clause[0], tiers: clause.slice(1) } : clause;
39346
39468
  };
39347
39469
  if (entity === void 0) return { type: "invalid", findings };
39348
39470
  const record2 = screen.record ?? recordDoor(screen.shape, entity, roles);
@@ -39356,14 +39478,14 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39356
39478
  const boundBy = /* @__PURE__ */ new Map();
39357
39479
  for (const [name, spec] of Object.entries(slotSpec)) {
39358
39480
  const clause = stated2(name);
39359
- const named = clause?.field;
39481
+ const named2 = clause?.field;
39360
39482
  const takes = spec.roles.map((role) => `"${role}"`).join(" or a ");
39361
39483
  let field = null;
39362
39484
  let filled = spec.roles[0];
39363
- if (named !== void 0) {
39364
- const candidate = fieldByAlias.get(named);
39485
+ if (named2 !== void 0) {
39486
+ const candidate = fieldByAlias.get(named2);
39365
39487
  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}"` });
39488
+ findings.push({ severity: "error", path: `${path23}.slots.${name}`, message: `names "${named2}", which is not a field of entity "${entity.alias}"` });
39367
39489
  continue;
39368
39490
  }
39369
39491
  const role = roleOfField(candidate);
@@ -39371,7 +39493,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39371
39493
  findings.push({
39372
39494
  severity: "error",
39373
39495
  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`
39496
+ 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
39497
  });
39376
39498
  continue;
39377
39499
  }
@@ -39388,7 +39510,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39388
39510
  }
39389
39511
  boundBy.set(candidate.alias, name);
39390
39512
  }
39391
- slots.push({ name, role: spec.roles[0], field: candidates2[0], also: candidates2.slice(1), ambiguous: [] });
39513
+ slots.push({ name, role: spec.roles[0], field: candidates2[0], also: candidates2.slice(1), tiers: [], ambiguous: [] });
39392
39514
  continue;
39393
39515
  }
39394
39516
  if (candidates2.length > 1) {
@@ -39400,7 +39522,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39400
39522
  });
39401
39523
  continue;
39402
39524
  }
39403
- slots.push({ name, role: filled, field: null, also: [], ambiguous: candidates2.map((f) => f.alias) });
39525
+ slots.push({ name, role: filled, field: null, also: [], tiers: [], ambiguous: candidates2.map((f) => f.alias) });
39404
39526
  continue;
39405
39527
  }
39406
39528
  if (candidates2.length === 0 && spec.required) {
@@ -39418,6 +39540,30 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39418
39540
  }
39419
39541
  boundBy.set(field.alias, name);
39420
39542
  }
39543
+ const tiers = [];
39544
+ if (clause?.tiers !== void 0) {
39545
+ if (!slotTakesTiers(screen.shape, name)) {
39546
+ 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` });
39547
+ continue;
39548
+ }
39549
+ const refused = clause.tiers.flatMap((alias) => {
39550
+ const candidate = fieldByAlias.get(alias);
39551
+ if (candidate === void 0) return [`"${alias}", which is not a field of entity "${entity.alias}"`];
39552
+ const role = roleOfField(candidate);
39553
+ if (role === void 0 || !spec.roles.includes(role)) {
39554
+ return [`"${alias}", whose role is ${role === void 0 ? "not declared" : `"${role}"`} \u2014 every tier takes a ${takes} field`];
39555
+ }
39556
+ const other = boundBy.get(alias);
39557
+ if (other !== void 0) return [`"${alias}", which already fills ${other} \u2014 a field fills one slot`];
39558
+ boundBy.set(alias, name);
39559
+ tiers.push(candidate);
39560
+ return [];
39561
+ });
39562
+ if (refused.length > 0) {
39563
+ findings.push({ severity: "error", path: `${path23}.slots.${name}`, message: `folds by ${refused.join("; and by ")}` });
39564
+ continue;
39565
+ }
39566
+ }
39421
39567
  if (clause?.quick === true) {
39422
39568
  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
39569
  if (refused !== void 0) {
@@ -39430,6 +39576,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39430
39576
  role: filled,
39431
39577
  field,
39432
39578
  also: [],
39579
+ tiers,
39433
39580
  ambiguous: [],
39434
39581
  ...clause?.quick === true ? { quick: true } : {},
39435
39582
  ...clause?.order === void 0 ? {} : { order: clause.order }
@@ -39448,6 +39595,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39448
39595
  tabs = field;
39449
39596
  }
39450
39597
  }
39598
+ const columns = resolveColumns(path23, screen, entity, [...entityByAlias.values()], fieldByAlias, boundBy, shapeLabel, findings);
39451
39599
  const acts = resolveActs(path23, screen, entity, entityByAlias, rules, fieldByAlias, templates, findings);
39452
39600
  const sectionActs2 = resolveSectionActs(path23, screen, entity, fieldByAlias, templates, findings);
39453
39601
  const summary = resolveSummary(path23, screen, entity, [...entityByAlias.values()], roles, slots, findings);
@@ -39474,6 +39622,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39474
39622
  tabs,
39475
39623
  slots,
39476
39624
  outcomes,
39625
+ columns,
39477
39626
  acts,
39478
39627
  sectionActs: sectionActs2,
39479
39628
  summary,
@@ -39485,6 +39634,53 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
39485
39634
  }
39486
39635
  };
39487
39636
  }
39637
+ function resolveColumns(path23, screen, entity, entities, fieldByAlias, boundBy, shapeLabel, findings) {
39638
+ const named2 = screen.columns ?? [];
39639
+ if (named2.length === 0) return [];
39640
+ const at2 = `${path23}.columns`;
39641
+ if (!shapeDrawsColumns(screen.shape)) {
39642
+ findings.push({
39643
+ severity: "error",
39644
+ path: at2,
39645
+ 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(", ")}`
39646
+ });
39647
+ return [];
39648
+ }
39649
+ const layout = screen.presentation?.layout;
39650
+ if (layout !== void 0 && layout !== "table") {
39651
+ 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` });
39652
+ return [];
39653
+ }
39654
+ const drawn = [];
39655
+ const seen = /* @__PURE__ */ new Set();
39656
+ for (const alias of named2) {
39657
+ const field = fieldByAlias.get(alias);
39658
+ if (field === void 0) {
39659
+ findings.push({ severity: "error", path: at2, message: `names "${alias}", which is not a field of entity "${entity.alias}"` });
39660
+ continue;
39661
+ }
39662
+ const slot2 = boundBy.get(alias);
39663
+ if (slot2 !== void 0) {
39664
+ findings.push({ severity: "error", path: at2, message: `names "${alias}", which already fills ${slot2} \u2014 a column states what the slots do not` });
39665
+ continue;
39666
+ }
39667
+ if (seen.has(alias)) {
39668
+ findings.push({ severity: "error", path: at2, message: `names "${alias}" twice` });
39669
+ continue;
39670
+ }
39671
+ if (resolvedFieldType(field, entity, entities) === "files") {
39672
+ 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` });
39673
+ continue;
39674
+ }
39675
+ if (fieldHoldsSeveral(field)) {
39676
+ 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` });
39677
+ continue;
39678
+ }
39679
+ seen.add(alias);
39680
+ drawn.push(field);
39681
+ }
39682
+ return drawn;
39683
+ }
39488
39684
  function resolveScope(app, entity, entityByAlias, findings) {
39489
39685
  const declared = app.scope;
39490
39686
  if (declared === void 0) return null;
@@ -39573,9 +39769,13 @@ function resolveObligations(path23, screen, entity, shapeLabel, roles, fieldByAl
39573
39769
  if (!shapeFansObligations(screen.shape)) return [];
39574
39770
  const found = entity.fields.flatMap((field) => {
39575
39771
  const decl = roleOf(roles, entity.alias, field.alias);
39576
- if (decl?.role !== "obligation" || decl.satisfied_by === void 0) return [];
39772
+ if (decl?.role !== "obligation") return [];
39773
+ const label = decl.label ?? field.label;
39774
+ if (decl.satisfied_by === void 0) {
39775
+ return recordUntil(entity, roles, field) === void 0 ? [] : [{ field, label }];
39776
+ }
39577
39777
  const satisfiedBy = fieldByAlias.get(decl.satisfied_by);
39578
- return satisfiedBy === void 0 ? [] : [{ field, label: decl.label ?? field.label, satisfiedBy }];
39778
+ return satisfiedBy === void 0 ? [] : [{ field, label, satisfiedBy }];
39579
39779
  });
39580
39780
  if (found.length === 0) {
39581
39781
  findings.push({
@@ -39608,20 +39808,33 @@ function resolveFactGroups(path23, screen, entity, fieldByAlias, findings) {
39608
39808
  })
39609
39809
  }));
39610
39810
  }
39811
+ function actDuplicates(acts, at2, menu, findings) {
39812
+ const refuse = (dups, said) => {
39813
+ for (const dup of dups) findings.push({ severity: "error", path: at2, message: said(dup) });
39814
+ };
39815
+ refuse(
39816
+ findDuplicates(acts.map((act) => act.label)),
39817
+ (dup) => `two acts are both called "${dup}" \u2014 ${menu} reads one line per act`
39818
+ );
39819
+ refuse(
39820
+ findDuplicates(acts.flatMap((act) => "kind" in act ? [] : [act.template])),
39821
+ (dup) => `two acts both generate "${dup}" \u2014 one template is one paper, however it is worded`
39822
+ );
39823
+ refuse(
39824
+ findDuplicates(acts.flatMap((act) => "kind" in act && act.kind === "agent" ? [act.agent] : [])),
39825
+ (dup) => `two acts both run "${dup}" \u2014 one agent is one run, however it is worded`
39826
+ );
39827
+ refuse(
39828
+ findDuplicates(acts.flatMap((act) => "kind" in act && act.kind === "workflow" ? [act.workflow] : [])),
39829
+ (dup) => `two acts both hand off through "${dup}" \u2014 one workflow is one hand-off, however it is worded`
39830
+ );
39831
+ }
39611
39832
  function resolveActs(path23, screen, entity, entityByAlias, rules, fieldByAlias, templates, findings) {
39612
39833
  const declared = screen.acts;
39613
39834
  if (declared === void 0) return { row: [], selection: [], record: [], export: null, import: null };
39614
39835
  const stated2 = declared.row ?? [];
39615
39836
  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
- }
39837
+ actDuplicates(stated2, at2, "a row's menu", findings);
39625
39838
  for (const refusal of ctaRefusals(declared)) {
39626
39839
  findings.push({ severity: "error", path: `${path23}.acts.${refusal.reach}`, message: refusal.message });
39627
39840
  }
@@ -39704,9 +39917,7 @@ function resolveSectionActs(path23, screen, entity, fieldByAlias, templates, fin
39704
39917
  const resolved = /* @__PURE__ */ new Map();
39705
39918
  for (const [alias, acts] of Object.entries(screen.section_acts ?? {})) {
39706
39919
  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
- }
39920
+ actDuplicates(acts, at2, "a section's menu", findings);
39710
39921
  for (const act of acts) {
39711
39922
  if (act.place === "cta") {
39712
39923
  findings.push({
@@ -39724,15 +39935,7 @@ function resolveRecordActs(path23, screen, entity, fieldByAlias, templates, find
39724
39935
  const declared = screen.acts?.record;
39725
39936
  if (declared === void 0) return [];
39726
39937
  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
- }
39938
+ actDuplicates(declared, at2, "the record's menu", findings);
39736
39939
  for (const act of declared) {
39737
39940
  if (act.place === "cta") {
39738
39941
  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 +39955,10 @@ function resolveAct(act, at2, screen, entity, fieldByAlias, templates, findings)
39752
39955
  const template = resolveTemplateAct(act.template, templates, at2, findings);
39753
39956
  return template === void 0 ? [] : [{ kind: "template", label: act.label, ...place, template }];
39754
39957
  }
39958
+ if (act.kind === "workflow") {
39959
+ const inputs = resolveActInputs(act.inputs, at2, entity, fieldByAlias, findings);
39960
+ return inputs === void 0 ? [] : [{ kind: "workflow", label: act.label, ...place, workflow: act.workflow, inputs }];
39961
+ }
39755
39962
  if (screen.writes === false) {
39756
39963
  findings.push({
39757
39964
  severity: "error",
@@ -39777,6 +39984,31 @@ function resolveAct(act, at2, screen, entity, fieldByAlias, templates, findings)
39777
39984
  });
39778
39985
  return fills.length === 0 ? [] : [{ kind: "agent", label: act.label, ...place, agent: act.agent, fills }];
39779
39986
  }
39987
+ function resolveActInputs(declared, at2, entity, fieldByAlias, findings) {
39988
+ const stated2 = Object.entries(declared);
39989
+ if (stated2.length === 0) {
39990
+ findings.push({ severity: "error", path: at2, message: `hands its workflow nothing \u2014 name the input the row is sent under, as \`{"<input>": "record"}\`` });
39991
+ return void 0;
39992
+ }
39993
+ const inputs = [];
39994
+ for (const [name, alias] of stated2) {
39995
+ if (alias === RECORD_INPUT) {
39996
+ inputs.push({ name, field: null });
39997
+ continue;
39998
+ }
39999
+ const field = fieldByAlias.get(alias);
40000
+ if (field === void 0) {
40001
+ findings.push({ severity: "error", path: at2, message: `sends "${alias}", which is not a field of entity "${entity.alias}"` });
40002
+ return void 0;
40003
+ }
40004
+ if (field.type === "files") {
40005
+ 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` });
40006
+ return void 0;
40007
+ }
40008
+ inputs.push({ name, field });
40009
+ }
40010
+ return inputs;
40011
+ }
39780
40012
  function resolveSelectionActs(path23, screen, entity, fieldByAlias, templates, findings) {
39781
40013
  const declared = screen.acts?.selection;
39782
40014
  if (declared === void 0) return [];
@@ -39789,14 +40021,15 @@ function resolveSelectionActs(path23, screen, entity, fieldByAlias, templates, f
39789
40021
  });
39790
40022
  return [];
39791
40023
  }
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` });
40024
+ actDuplicates(declared, at2, "the selection bar", findings);
40025
+ for (const act of declared) {
40026
+ if (!("kind" in act)) continue;
40027
+ const verb = act.kind === "agent" ? `runs "${act.agent}"` : `hands the row to "${act.workflow}"`;
40028
+ findings.push({
40029
+ severity: "error",
40030
+ path: at2,
40031
+ 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`
40032
+ });
39800
40033
  }
39801
40034
  return declared.flatMap((act, index) => resolveAct(act, `${at2}.${index}`, screen, entity, fieldByAlias, templates, findings));
39802
40035
  }
@@ -39837,7 +40070,7 @@ function resolveSummary(path23, screen, entity, entities, roles, slots, findings
39837
40070
  const declared = screen.summary;
39838
40071
  const fieldByAlias = new Map(entity.fields.map((field) => [field.alias, field]));
39839
40072
  if (declared === void 0) {
39840
- return { columnTotals: [], bandTotals: [], above: defaultAbove(screen, entity, entities, roles, slots), ageing: null };
40073
+ return { columnTotals: [], bandTotals: [], above: defaultAbove(screen, entity, entities, roles, slots), ageing: null, trend: null };
39841
40074
  }
39842
40075
  const figures = (aliases, at2) => aliases.flatMap((alias) => {
39843
40076
  const field = fieldByAlias.get(alias);
@@ -39874,10 +40107,10 @@ function resolveSummary(path23, screen, entity, entities, roles, slots, findings
39874
40107
  });
39875
40108
  return false;
39876
40109
  };
39877
- const named = declared.totals === void 0 || !bandSaid("totals", `${path23}.summary.totals`) ? [] : figures(declared.totals, `${path23}.summary.totals`);
40110
+ const named2 = declared.totals === void 0 || !bandSaid("totals", `${path23}.summary.totals`) ? [] : figures(declared.totals, `${path23}.summary.totals`);
39878
40111
  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));
40112
+ const columnTotals = named2.filter((field) => drawn.has(field.alias));
40113
+ const bandTotals = named2.filter((field) => !drawn.has(field.alias));
39881
40114
  if (screen.shape !== CUSTOM_SHAPE && columnTotals.length > 0) {
39882
40115
  findings.push({
39883
40116
  severity: "error",
@@ -39903,7 +40136,32 @@ function resolveSummary(path23, screen, entity, entities, roles, slots, findings
39903
40136
  };
39904
40137
  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
40138
  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 };
40139
+ const trend = declared.trend !== void 0 && fanned(`${path23}.summary.trend`) ? null : resolveTrend(path23, declared.trend, screen, figures, bandSaid, findings);
40140
+ return { columnTotals, bandTotals, above, ageing, trend };
40141
+ }
40142
+ function resolveTrend(path23, declared, screen, figures, bandSaid, findings) {
40143
+ if (declared === void 0) return null;
40144
+ const at2 = `${path23}.summary.trend`;
40145
+ const already2 = shapePeriod(screen.shape);
40146
+ if (already2 !== void 0) {
40147
+ findings.push({
40148
+ severity: "error",
40149
+ path: at2,
40150
+ 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`
40151
+ });
40152
+ return null;
40153
+ }
40154
+ if (screen.period === void 0) {
40155
+ findings.push({
40156
+ severity: "error",
40157
+ path: at2,
40158
+ message: `reads "${declared.field}" across the window, and this screen states no \`period\` \u2014 there is no window to cut into buckets`
40159
+ });
40160
+ return null;
40161
+ }
40162
+ if (!bandSaid("above", at2)) return null;
40163
+ const field = figures([declared.field], at2)[0];
40164
+ return field === void 0 ? null : { field, direction: declared.direction };
39907
40165
  }
39908
40166
  function bandFigures(declared, at2, screen, figures, findings) {
39909
40167
  return declared.flatMap((one) => {
@@ -40098,13 +40356,17 @@ function mirrorsOwnOneLink(entity, child, link) {
40098
40356
  );
40099
40357
  return back.length === 1 && own.length === 1 && own[0].cardinality === "one";
40100
40358
  }
40101
- function requiredSubset(entity, entities, roles, field) {
40359
+ function requiredSubset(entity, entities, roles, field, parent) {
40102
40360
  const requiredBy = roleOf(roles, entity.alias, field.alias)?.required_by;
40103
40361
  if (requiredBy === void 0) return void 0;
40104
- const condition = entity.fields.find((candidate) => candidate.alias === requiredBy.field);
40362
+ const from = requiredByFrom(field);
40363
+ if ((requiredBy.from ?? "own") !== from) return void 0;
40364
+ const deciding = from === "parent" ? parentEntity(entity, entities, roles) : entity;
40365
+ if (deciding === void 0 || from === "parent" && deciding.alias !== parent?.alias) return void 0;
40366
+ const condition = deciding.fields.find((candidate) => candidate.alias === requiredBy.field);
40105
40367
  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 };
40368
+ const reads = requiredByReads(condition, deciding, entities);
40369
+ return reads === void 0 ? void 0 : { field: condition, from, reads, options: requiredBy.options };
40108
40370
  }
40109
40371
  function owedRows(entity, roles, child, link) {
40110
40372
  for (const field of entity.fields) {
@@ -40138,6 +40400,7 @@ function childTimeline(child, entities, roles) {
40138
40400
  return void 0;
40139
40401
  }
40140
40402
  function childThread(child, roles, via) {
40403
+ if (fieldsWithRole(child, roles, "identity").length > 0) return void 0;
40141
40404
  const body = child.fields.find((field) => field.type === "text" && field.format === "markdown");
40142
40405
  const parties = fieldsWithRole(child, roles, "party");
40143
40406
  const author = parties[0];
@@ -40153,8 +40416,17 @@ function childThread(child, roles, via) {
40153
40416
  ...awaiting === void 0 ? {} : { awaiting }
40154
40417
  };
40155
40418
  }
40419
+ function threadShortOf(child, roles) {
40420
+ if (fieldsWithRole(child, roles, "identity").length > 0) return void 0;
40421
+ if (!child.fields.some((field) => field.type === "text" && field.format === "markdown")) return void 0;
40422
+ const missing = [
40423
+ { name: 'a "party"', has: fieldsWithRole(child, roles, "party").length > 0 },
40424
+ { name: 'a "when"', has: fieldsWithRole(child, roles, "when").length > 0 }
40425
+ ].filter((one) => !one.has);
40426
+ return missing.length === 1 ? missing[0].name : void 0;
40427
+ }
40156
40428
  function recordDeadline(entity, roles) {
40157
- return fieldsWithRole(entity, roles, "obligation")[0] ?? fieldsWithRole(entity, roles, "when")[0];
40429
+ return fieldsWithRole(entity, roles, "obligation")[0] ?? fieldsWithRole(entity, roles, "when").find((field) => countsDown(roleOf(roles, entity.alias, field.alias)));
40158
40430
  }
40159
40431
  function fileFields(entity, roles) {
40160
40432
  const files = entity.fields.filter((field) => field.type === "files");
@@ -40168,9 +40440,9 @@ function filesVerdict(entity, roles, files) {
40168
40440
  (field) => field.type === "formula" && parseFieldRefTokens(field.formula.expression).some((ref) => piles.has(ref))
40169
40441
  );
40170
40442
  }
40171
- function recordKey(entity, roles, named) {
40443
+ function recordKey(entity, roles, named2) {
40172
40444
  const role = (field) => roleOf(roles, entity.alias, field.alias)?.role;
40173
- const spare = entity.fields.filter((field) => field.alias !== named && role(field) !== "identity");
40445
+ const spare = entity.fields.filter((field) => field.alias !== named2 && role(field) !== "identity");
40174
40446
  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);
40175
40447
  }
40176
40448
  function recordHeader(screen, roles) {
@@ -40233,26 +40505,26 @@ function recordSections(entity, entities, roles, header, shape = CUSTOM_SHAPE, d
40233
40505
  link,
40234
40506
  setField,
40235
40507
  filesField: fileFields(child, roles)[0],
40236
- requiredBy: requiredSubset(child, entities, roles, setField)
40508
+ requiredBy: requiredSubset(child, entities, roles, setField, entity)
40237
40509
  });
40238
40510
  continue;
40239
40511
  }
40240
- const byRole = CHILD_COLUMN_ROLES.flatMap((role) => fieldsWithRole(child, roles, role)).filter(
40512
+ const byRole2 = CHILD_COLUMN_ROLES.flatMap((role) => fieldsWithRole(child, roles, role)).filter(
40241
40513
  (field) => field.alias !== link.alias
40242
40514
  );
40243
40515
  const stops = childTimeline(child, entities, roles);
40244
40516
  if (stops !== void 0) {
40245
- children.push({ kind: "timeline", child, via, link, fields: byRole, ...stops });
40517
+ children.push({ kind: "timeline", child, via, link, fields: byRole2, ...stops });
40246
40518
  continue;
40247
40519
  }
40248
40520
  const said = childThread(child, roles, via);
40249
40521
  if (said !== void 0) {
40250
40522
  const clock = via === "parent" ? recordDeadline(entity, roles) : void 0;
40251
- children.push({ kind: "thread", child, via, link, fields: byRole, ...said, ...clock === void 0 ? {} : { clock } });
40523
+ children.push({ kind: "thread", child, via, link, fields: byRole2, ...said, ...clock === void 0 ? {} : { clock } });
40252
40524
  continue;
40253
40525
  }
40254
40526
  const expected = owedRows(entity, roles, child, link);
40255
- children.push({ kind: "children", child, via, link, fields: byRole, ...expected === void 0 ? {} : { expected } });
40527
+ children.push({ kind: "children", child, via, link, fields: byRole2, ...expected === void 0 ? {} : { expected } });
40256
40528
  }
40257
40529
  }
40258
40530
  }
@@ -40371,10 +40643,10 @@ function recordCharge(shape, entity, roles, door = recordDoor(shape, entity, rol
40371
40643
  (token) => entity.fields.find((field) => field.alias === token)
40372
40644
  );
40373
40645
  if (operands.length !== 2) return void 0;
40374
- const named = operands.filter((field) => field !== void 0);
40375
- if (named.length !== 2) return void 0;
40376
- const priced = named.filter((field) => field.type === "number" && field.format === "currency");
40377
- const counted = named.filter((field) => field.type === "number" && field.format !== "currency");
40646
+ const named2 = operands.filter((field) => field !== void 0);
40647
+ if (named2.length !== 2) return void 0;
40648
+ const priced = named2.filter((field) => field.type === "number" && field.format === "currency");
40649
+ const counted = named2.filter((field) => field.type === "number" && field.format !== "currency");
40378
40650
  if (priced.length !== 1 || counted.length !== 1) return void 0;
40379
40651
  return { amount, quantity: counted[0], unitPrice: priced[0] };
40380
40652
  }
@@ -40584,6 +40856,9 @@ function slotOf(section, roles) {
40584
40856
  if (signed) return "ledger";
40585
40857
  return section.via === "parent" ? "children" : "named_by";
40586
40858
  }
40859
+ function childRegisterShape(slot2) {
40860
+ return slot2 === "ledger" ? "transaction_ledger" : "lifecycle_desk";
40861
+ }
40587
40862
  function recordRecipe(shape, entity, entities, roles, header, door = recordDoor(shape, entity, roles)) {
40588
40863
  const sections = recordSections(entity, entities, roles, header, shape, door);
40589
40864
  const recipe = RECIPES[door === "expand" ? "line" : recordArchetype(shape, entity, roles) ?? "plain"];
@@ -40603,6 +40878,7 @@ function validateWorkspacePlan(model, roles, apps, rules = {}) {
40603
40878
  findings.push({ severity: "error", path: "apps", message: `duplicate app name "${dup}"` });
40604
40879
  }
40605
40880
  const entityByAlias = new Map(model.entities.map((entity) => [entity.alias, entity]));
40881
+ const registered = new Set(apps.map((app) => app.screen.entity));
40606
40882
  for (const app of apps) {
40607
40883
  const byReach = /* @__PURE__ */ new Map();
40608
40884
  for (const [reach, acts] of [
@@ -40637,8 +40913,11 @@ function validateWorkspacePlan(model, roles, apps, rules = {}) {
40637
40913
  }
40638
40914
  findings.push(...checkFactGroups(resolved.screen, model.entities, roles));
40639
40915
  findings.push(...checkSectionActs(resolved.screen, model.entities, roles));
40916
+ findings.push(...checkSectionDraws(resolved.screen, model.entities, roles));
40917
+ findings.push(...checkChildRegisters(resolved.screen, model.entities, roles, registered));
40640
40918
  findings.push(...checkDeskStages(resolved.screen));
40641
- findings.push(...checkPresentation(resolved.screen));
40919
+ findings.push(...checkSeatedColumns(resolved.screen));
40920
+ findings.push(...checkPresentation(resolved.screen, roles, model.entities));
40642
40921
  }
40643
40922
  return findings;
40644
40923
  }
@@ -40657,16 +40936,53 @@ function checkDeskStages(screen) {
40657
40936
  }
40658
40937
  ];
40659
40938
  }
40660
- function checkPresentation(screen) {
40939
+ function checkSeatedColumns(screen) {
40940
+ const context = screen.columns.length;
40941
+ const bySlots = screen.slots.filter((slot2) => slot2.field !== null && slot2.role !== "mark").length;
40942
+ if (context === 0 || bySlots + context <= SEATED_COLUMNS) return [];
40943
+ return [
40944
+ {
40945
+ severity: "note",
40946
+ path: `apps.${screen.app.alias}.screen.columns`,
40947
+ 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`
40948
+ }
40949
+ ];
40950
+ }
40951
+ function drawnColumns(screen, roles) {
40952
+ return screen.slots.flatMap((slot2) => {
40953
+ if (slot2.field === null) return [];
40954
+ const counts = roleOf(roles, screen.entity.alias, slot2.field.alias)?.counts;
40955
+ return [{ role: slot2.role, ...counts === void 0 ? {} : { counts } }];
40956
+ });
40957
+ }
40958
+ function checkPresentation(screen, roles, entities) {
40661
40959
  const stated2 = screen.screen.presentation;
40662
40960
  if (stated2 === void 0) return [];
40663
40961
  const path23 = `apps.${screen.app.alias}.screen.presentation`;
40664
- const drawn = new Set(screen.slots.flatMap((slot2) => slot2.field === null ? [] : [slot2.role]));
40962
+ const drawn = drawnColumns(screen, roles);
40963
+ const held = new Set(drawn.map((column) => column.role));
40665
40964
  return [
40666
- ...stated2.lead === void 0 ? [] : [leadRefusal(screen.screen.shape, drawn, stated2.lead)],
40667
- ...stated2.layout === void 0 ? [] : [layoutRefusal(drawn, stated2.layout)]
40965
+ ...stated2.lead === void 0 ? [] : [leadRefusal(screen.screen.shape, held, stated2.lead)],
40966
+ ...stated2.layout === void 0 ? [] : [layoutRefusal(drawn, stated2.layout)],
40967
+ ...stated2.until === void 0 ? [] : [untilRefusal(screen, entities, drawn, stated2.until)]
40668
40968
  ].flatMap((message2) => message2 === void 0 ? [] : [{ severity: "error", path: path23, message: message2 }]);
40669
40969
  }
40970
+ function untilRefusal(screen, entities, drawn, until) {
40971
+ if (!layoutsFor(drawn).includes("gantt")) {
40972
+ 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`;
40973
+ }
40974
+ const field = screen.entity.fields.find((candidate) => candidate.alias === until);
40975
+ if (field === void 0) return `ends its bars at "${until}", which is not a field of entity "${screen.entity.alias}"`;
40976
+ const resolved = resolvedFieldType(field, screen.entity, entities);
40977
+ if (resolved !== "date") {
40978
+ return `ends its bars at "${until}", a ${resolved ?? field.type} \u2014 a bar ends on a day`;
40979
+ }
40980
+ const start = screen.slots.find((slot2) => slot2.role === "when")?.field ?? null;
40981
+ if (start !== null && start.alias === until) {
40982
+ return `ends its bars at "${until}", which is the day they start on \u2014 a bar between one date and itself has no length`;
40983
+ }
40984
+ return void 0;
40985
+ }
40670
40986
  function checkFactGroups(screen, entities, roles) {
40671
40987
  if (screen.factGroups.length === 0) return [];
40672
40988
  const path23 = `apps.${screen.app.alias}.screen.facts.groups`;
@@ -40684,6 +41000,93 @@ function checkFactGroups(screen, entities, roles) {
40684
41000
  )
40685
41001
  );
40686
41002
  }
41003
+ function childRegisters(screen, entities, roles) {
41004
+ const sections = recordSections(screen.entity, entities, roles, recordHeader(screen, roles), screen.screen.shape, screen.record);
41005
+ return new Map(sections.flatMap((section) => section.kind === "children" ? [[section.child.alias, section]] : []));
41006
+ }
41007
+ function checkSectionDraws(screen, entities, roles) {
41008
+ const drawn = Object.entries(screen.screen.sections ?? {});
41009
+ if (drawn.length === 0) return [];
41010
+ const registers = childRegisters(screen, entities, roles);
41011
+ const takes = SHAPE_REGISTRY.worksheet.slots.sell.roles;
41012
+ return drawn.flatMap(([alias, clause]) => {
41013
+ const at2 = `apps.${screen.app.alias}.screen.sections.${alias}`;
41014
+ const section = registers.get(alias);
41015
+ if (section === void 0) {
41016
+ const offered = [...registers.keys()];
41017
+ return [
41018
+ {
41019
+ severity: "error",
41020
+ path: at2,
41021
+ 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(", ")}`
41022
+ }
41023
+ ];
41024
+ }
41025
+ if (section.via !== "parent") {
41026
+ return [
41027
+ {
41028
+ severity: "error",
41029
+ path: at2,
41030
+ 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`
41031
+ }
41032
+ ];
41033
+ }
41034
+ const named2 = [["cost", clause.cost], ["sell", clause.sell]].flatMap(([slot2, field]) => field === void 0 ? [] : [{ slot: slot2, field }]);
41035
+ if (named2.length === 0) {
41036
+ return [
41037
+ {
41038
+ severity: "error",
41039
+ path: at2,
41040
+ 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"
41041
+ }
41042
+ ];
41043
+ }
41044
+ const findings = named2.flatMap(({ slot: slot2, field }) => {
41045
+ const carried = section.child.fields.find((one) => one.alias === field);
41046
+ if (carried === void 0) {
41047
+ return [{ severity: "error", path: `${at2}.${slot2}`, message: `names "${field}", which is not a field of entity "${alias}"` }];
41048
+ }
41049
+ const role = roleOf(roles, alias, field)?.role;
41050
+ return role !== void 0 && takes.includes(role) ? [] : [
41051
+ {
41052
+ severity: "error",
41053
+ path: `${at2}.${slot2}`,
41054
+ 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`
41055
+ }
41056
+ ];
41057
+ });
41058
+ const both = clause.cost;
41059
+ if (findings.length === 0 && both !== void 0 && both === clause.sell) {
41060
+ return [
41061
+ {
41062
+ severity: "error",
41063
+ path: at2,
41064
+ message: `prices "${both}" as both its cost and its sell \u2014 the margin between a figure and itself is nothing, drawn down every line`
41065
+ }
41066
+ ];
41067
+ }
41068
+ return findings;
41069
+ });
41070
+ }
41071
+ function unregisteredChildren(screen, entities, roles, registered) {
41072
+ const entries2 = recordRecipe(screen.screen.shape, screen.entity, entities, roles, recordHeader(screen, roles), screen.record);
41073
+ const priced = new Set(Object.keys(screen.screen.sections ?? {}));
41074
+ const read2 = new Set(
41075
+ entries2.flatMap(({ draw, section }) => {
41076
+ if (draw !== "open") return [];
41077
+ if (section.kind === "children" || section.kind === "timeline") return [section.child.alias];
41078
+ return section.kind === "expected_set" && section.source === "child" ? [section.child.alias] : [];
41079
+ })
41080
+ );
41081
+ return [...read2].filter((alias) => !registered.has(alias) && !priced.has(alias));
41082
+ }
41083
+ function checkChildRegisters(screen, entities, roles, registered) {
41084
+ return unregisteredChildren(screen, entities, roles, registered).map((alias) => ({
41085
+ severity: "note",
41086
+ path: `apps.${screen.app.alias}.screen`,
41087
+ message: `"${alias}" has no register \u2014 its rows are read here and created nowhere`
41088
+ }));
41089
+ }
40687
41090
  function sectionActTargets(screen, entities, roles) {
40688
41091
  const sections = recordSections(screen.entity, entities, roles, recordHeader(screen, roles), screen.screen.shape, screen.record);
40689
41092
  const found = /* @__PURE__ */ new Map();
@@ -40718,7 +41121,7 @@ function checkSectionActs(screen, entities, roles) {
40718
41121
  function reachableEntries(decl, field, entityRows) {
40719
41122
  const all = new Set(field.type === "select" ? field.options.map((option) => option.alias) : []);
40720
41123
  const requiredBy = decl.required_by;
40721
- if (decl.role !== "expected_set" || requiredBy === void 0) return all;
41124
+ if (decl.role !== "expected_set" || requiredBy === void 0 || requiredByFrom(field) === "parent") return all;
40722
41125
  const reachable = /* @__PURE__ */ new Set();
40723
41126
  for (const row of entityRows) {
40724
41127
  const said = row.fields[requiredBy.field];
@@ -40805,6 +41208,16 @@ function roleCoverage(model, roles, rows) {
40805
41208
  message: `${section.child.label} hangs under ${entity.label} and declares no identity \u2014 its rows on the record have no name`
40806
41209
  });
40807
41210
  }
41211
+ if (section.kind === "children") {
41212
+ const short = threadShortOf(section.child, roles);
41213
+ if (short !== void 0) {
41214
+ notes.push({
41215
+ severity: "note",
41216
+ path: `field_roles.${section.child.alias}`,
41217
+ 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`
41218
+ });
41219
+ }
41220
+ }
40808
41221
  if (section.kind === "expected_set" && section.source === "child" && section.filesField === void 0) {
40809
41222
  notes.push({
40810
41223
  severity: "note",
@@ -40856,9 +41269,17 @@ function optionsWhereConditions(filter2, target) {
40856
41269
  }
40857
41270
  return out.length === 0 ? void 0 : out;
40858
41271
  }
40859
- var LENS_OPERATORS = Object.fromEntries(
40860
- Object.entries(OPTIONS_WHERE_OPERATORS).map(([type, operators]) => [type, [...operators, "is_empty", "is_not_empty"]])
40861
- );
41272
+ var lensRelativePointSchema = zod_default.object({ type: zod_default.literal("relative"), offset: zod_default.number().int(), unit: zod_default.enum(["days", "weeks", "months", "years"]) }).strict();
41273
+ function lensRelativePoint(value) {
41274
+ const read2 = lensRelativePointSchema.safeParse(value);
41275
+ return read2.success ? { offset: read2.data.offset, unit: read2.data.unit } : void 0;
41276
+ }
41277
+ var LENS_OPERATORS = {
41278
+ ...Object.fromEntries(
41279
+ Object.entries(OPTIONS_WHERE_OPERATORS).map(([type, operators]) => [type, [...operators, "is_empty", "is_not_empty"]])
41280
+ ),
41281
+ date: ["before", "after", "on_or_before", "on_or_after", "on", "is_empty", "is_not_empty"]
41282
+ };
40862
41283
  function lensOperatorSentence() {
40863
41284
  return Object.entries(LENS_OPERATORS).map(([type, operators]) => `${type} ${operators.join("/")}`).join("; ");
40864
41285
  }
@@ -40882,6 +41303,9 @@ function lensConditions(filter2, entity, entities) {
40882
41303
  if (!(LENS_OPERATORS[type] ?? []).includes(child.operator)) {
40883
41304
  return `reading "${field.alias}" as a ${type} with "${child.operator}" \u2014 a lens is read off the row, so its operators are ${lensOperatorSentence()}`;
40884
41305
  }
41306
+ if (type === "date" && child.operator !== "is_empty" && child.operator !== "is_not_empty" && lensRelativePoint(child.value) === void 0) {
41307
+ 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" }\``;
41308
+ }
40885
41309
  out.push({ type, field, operator: child.operator, value: child.value });
40886
41310
  }
40887
41311
  return out.length === 0 ? `keeping every row \u2014 a predicate that narrows nothing is not a set` : out;
@@ -40994,12 +41418,12 @@ function checkWriteRules(entities, rules, roles) {
40994
41418
  continue;
40995
41419
  }
40996
41420
  const declared = new Set(condition.field.options.map((option) => option.alias));
40997
- for (const named of condition.value) {
40998
- if (typeof named === "string" && declared.has(named)) continue;
41421
+ for (const named2 of condition.value) {
41422
+ if (typeof named2 === "string" && declared.has(named2)) continue;
40999
41423
  findings.push({
41000
41424
  severity: "error",
41001
41425
  path: at3,
41002
- message: `names option "${String(named)}", which "${condition.field.label}" does not declare (${[...declared].join(", ")})`
41426
+ message: `names option "${String(named2)}", which "${condition.field.label}" does not declare (${[...declared].join(", ")})`
41003
41427
  });
41004
41428
  }
41005
41429
  }
@@ -41288,12 +41712,12 @@ function checkValue(field, value, path23, refs, errors) {
41288
41712
  });
41289
41713
  continue;
41290
41714
  }
41291
- const named = raw.slice(0, raw.indexOf(":"));
41292
- if (named === field.target_entity) continue;
41715
+ const named2 = raw.slice(0, raw.indexOf(":"));
41716
+ if (named2 === field.target_entity) continue;
41293
41717
  errors.push({
41294
41718
  severity: "error",
41295
41719
  path: path23,
41296
- message: `names a row of entity "${named}", but this field links to "${field.target_entity}"`
41720
+ message: `names a row of entity "${named2}", but this field links to "${field.target_entity}"`
41297
41721
  });
41298
41722
  }
41299
41723
  return;
@@ -41637,7 +42061,7 @@ function resultSideEffects(result) {
41637
42061
  }
41638
42062
 
41639
42063
  // src/version.ts
41640
- var VERSION = "0.206.0";
42064
+ var VERSION = "0.208.0";
41641
42065
 
41642
42066
  // src/timezone.ts
41643
42067
  function machineTimezone() {
@@ -49953,9 +50377,9 @@ function declaredTable(aliased, key) {
49953
50377
  (candidate) => candidate.table.id === key || candidate.alias === key || candidate.alias === slugifyAlias(key, true) || candidate.table.name === key
49954
50378
  );
49955
50379
  }
49956
- function declaredField(table, named) {
50380
+ function declaredField(table, named2) {
49957
50381
  return table.fields.find(
49958
- (candidate) => candidate.field.id === named || candidate.alias === named || candidate.alias === slugifyAlias(named, false) || candidate.field.name === named
50382
+ (candidate) => candidate.field.id === named2 || candidate.alias === named2 || candidate.alias === slugifyAlias(named2, false) || candidate.field.name === named2
49959
50383
  );
49960
50384
  }
49961
50385
  function carriedWrites(declaration, tables) {
@@ -50083,12 +50507,12 @@ function writeFindings(declaration, bodies, tables) {
50083
50507
  }
50084
50508
  const fields = /* @__PURE__ */ new Set();
50085
50509
  for (const entry of entries2) {
50086
- const named = writtenField(entry);
50087
- const field = declaredField(table, named);
50510
+ const named2 = writtenField(entry);
50511
+ const field = declaredField(table, named2);
50088
50512
  if (field === void 0) {
50089
50513
  findings.push({
50090
50514
  where: key,
50091
- 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.`
50515
+ 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.`
50092
50516
  });
50093
50517
  continue;
50094
50518
  }
@@ -50100,15 +50524,15 @@ function writeFindings(declaration, bodies, tables) {
50100
50524
  const labelById = new Map(aliased.map((table) => [table.table.id, table.table.name]));
50101
50525
  const unknownTools = /* @__PURE__ */ new Set();
50102
50526
  for (const body of bodies) {
50103
- const named = /* @__PURE__ */ new Set();
50527
+ const named2 = /* @__PURE__ */ new Set();
50104
50528
  const scanned = bodyWrites(body.source);
50105
50529
  for (const tool of scanned.unknownTools) unknownTools.add(tool);
50106
50530
  for (const write of scanned.writes) {
50107
50531
  const declared = write.table === null ? anyDeclared : byTable.get(write.table) ?? /* @__PURE__ */ new Set();
50108
50532
  if (declared.has(write.field)) continue;
50109
50533
  const fieldName2 = fieldLabel(aliased, write.table, write.field);
50110
- if (named.has(fieldName2)) continue;
50111
- named.add(fieldName2);
50534
+ if (named2.has(fieldName2)) continue;
50535
+ named2.add(fieldName2);
50112
50536
  const table = write.table === null ? null : labelById.get(write.table) ?? write.table;
50113
50537
  findings.push({
50114
50538
  where: body.alias,
@@ -50146,6 +50570,9 @@ function generateAlias(template) {
50146
50570
  function childKey(child, link) {
50147
50571
  return `${child}.${link}`;
50148
50572
  }
50573
+ function sectionKey(child, link) {
50574
+ return `${child}_${link}`;
50575
+ }
50149
50576
  function optionsQueryAlias(target, display) {
50150
50577
  return `${target}_${display}_options`;
50151
50578
  }
@@ -50176,8 +50603,8 @@ function whereValue(at2, bound, condition, missing) {
50176
50603
  missing.push(`${at2} narrows "${bound.label}" by no option at all \u2014 a select narrowing names the options it admits`);
50177
50604
  return void 0;
50178
50605
  }
50179
- const named = condition.value;
50180
- const ids = named.flatMap((option) => {
50606
+ const named2 = condition.value;
50607
+ const ids = named2.flatMap((option) => {
50181
50608
  const live = typeof option === "string" ? bound.options?.get(option) : void 0;
50182
50609
  if (live === void 0) {
50183
50610
  missing.push(`${at2} names option "${String(option)}", which "${bound.label}" does not carry in this workspace`);
@@ -50185,7 +50612,7 @@ function whereValue(at2, bound, condition, missing) {
50185
50612
  }
50186
50613
  return [live.id];
50187
50614
  });
50188
- return ids.length === named.length ? { value: ids } : void 0;
50615
+ return ids.length === named2.length ? { value: ids } : void 0;
50189
50616
  }
50190
50617
  var WORDS = {
50191
50618
  plain: {
@@ -50243,11 +50670,59 @@ var WORDS = {
50243
50670
  }
50244
50671
  }
50245
50672
  };
50246
- function sectionChild(section) {
50247
- if (section.kind === "children" || section.kind === "timeline" || section.kind === "thread") return section.child;
50248
- if (section.kind === "expected_set" && section.source === "child") return section.child;
50673
+ function sectionRelation(section) {
50674
+ if (section.kind === "children" || section.kind === "timeline" || section.kind === "thread") {
50675
+ return { kind: section.kind, child: section.child, via: section.via, link: section.link };
50676
+ }
50677
+ if (section.kind === "expected_set" && section.source === "child") {
50678
+ return { kind: section.kind, child: section.child, via: section.via, link: section.link };
50679
+ }
50249
50680
  return void 0;
50250
50681
  }
50682
+ function sectionChild(section) {
50683
+ return sectionRelation(section)?.child;
50684
+ }
50685
+ function childQueryAlias(entity, child, link) {
50686
+ return `${entity}_${child}_${link}`;
50687
+ }
50688
+ function childSurfaceScreen(parent, entry, mounts) {
50689
+ const relation = sectionRelation(entry.section);
50690
+ if (relation === void 0 || relation.via !== "parent" || relation.child.alias === parent.entity.alias) return void 0;
50691
+ if (!mounts && relation.kind !== "thread") return void 0;
50692
+ const { child, link } = relation;
50693
+ const shape = childRegisterShape(entry.slot);
50694
+ return {
50695
+ app: parent.app,
50696
+ screen: {
50697
+ alias: childQueryAlias(parent.entity.alias, child.alias, link.alias),
50698
+ label: child.label,
50699
+ shape,
50700
+ entity: child.alias,
50701
+ record: "drawer",
50702
+ ...parent.screen.writes === void 0 ? {} : { writes: parent.screen.writes }
50703
+ },
50704
+ entity: child,
50705
+ shapeLabel: SHAPE_REGISTRY[shape].label,
50706
+ record: "drawer",
50707
+ tabs: null,
50708
+ slots: [],
50709
+ outcomes: {},
50710
+ columns: [],
50711
+ acts: { row: [], selection: [], record: [], export: null, import: null },
50712
+ summary: { columnTotals: [], bandTotals: [], above: null, ageing: null, trend: null },
50713
+ period: null,
50714
+ filters: [],
50715
+ obligations: [],
50716
+ factGroups: [],
50717
+ scope: null,
50718
+ sectionActs: /* @__PURE__ */ new Map()
50719
+ };
50720
+ }
50721
+ function unfiled(entry, under) {
50722
+ if (under === void 0 || entry.section.kind !== "facts") return [entry];
50723
+ const fields = entry.section.fields.filter((field) => field.alias !== under.alias);
50724
+ return fields.length === 0 ? [] : [{ ...entry, section: { ...entry.section, fields } }];
50725
+ }
50251
50726
  function sectionHeading(child, link, sections, draw) {
50252
50727
  const reaching = sections.filter((entry) => entry.draw === draw && sectionChild(entry.section)?.alias === child.alias);
50253
50728
  return reaching.length > 1 ? `${child.label} (${link.label})` : child.label;
@@ -50425,6 +50900,12 @@ function namingField2(child, link, entities, roles) {
50425
50900
  (field) => field.type === "formula" && roleOf(roles, child.alias, field.alias) === void 0 && resolvedFieldType(field, child, entities) === "text"
50426
50901
  ) ?? own("text") ?? own("select_record_link");
50427
50902
  }
50903
+ function drawnColumns2(child, fields, roles) {
50904
+ return fields.filter((field) => {
50905
+ const role = roleOf(roles, child.alias, field.alias)?.role;
50906
+ return role !== void 0 && drawnAsColumn(role, field);
50907
+ });
50908
+ }
50428
50909
  function withScope(own, scope) {
50429
50910
  if (scope === void 0) return own;
50430
50911
  if (own === void 0) return scope.filter;
@@ -50493,59 +50974,56 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
50493
50974
  if (!scopes.has(entity.alias)) scopes.set(entity.alias, readRowRule(entity, fields, groups, missing));
50494
50975
  return scopes.get(entity.alias);
50495
50976
  };
50496
- const bind = () => {
50497
- const at2 = screen.app.alias;
50498
- const header = recordHeader(screen, roles);
50977
+ const bindOne = (screen2, at2, under) => {
50978
+ const mounts = under === void 0 && screen2.record === "page";
50979
+ const header = recordHeader(screen2, roles);
50499
50980
  const identity = header.title ?? null;
50500
50981
  const limits = /* @__PURE__ */ new Map();
50501
50982
  const wanted2 = [
50502
50983
  ...identity === null ? [] : [identity],
50503
50984
  // A slot's own field, then the further fields an ORDERED role reads after
50504
50985
  // it: they are the same slot, so they lead the entity's own list together.
50505
- ...screen.slots.flatMap(
50506
- (slot2) => slot2.field === null ? [] : [...slot2.field === identity ? [] : [slot2.field], ...slot2.also]
50986
+ ...screen2.slots.flatMap(
50987
+ (slot2) => slot2.field === null ? [] : [...slot2.field === identity ? [] : [slot2.field], ...slot2.also, ...slot2.tiers]
50507
50988
  ),
50508
- ...screen.tabs === null ? [] : [screen.tabs]
50989
+ ...screen2.tabs === null ? [] : [screen2.tabs]
50509
50990
  ];
50510
- for (const slot2 of screen.slots) {
50991
+ for (const slot2 of screen2.slots) {
50511
50992
  if (slot2.field === null || slot2.role !== "measure") continue;
50512
- const decl = roleOf(roles, screen.entity.alias, slot2.field.alias);
50993
+ const decl = roleOf(roles, screen2.entity.alias, slot2.field.alias);
50513
50994
  if (decl === void 0 || typeof decl.against !== "string") continue;
50514
- const limit = screen.entity.fields.find((candidate) => candidate.alias === decl.against);
50995
+ const limit = screen2.entity.fields.find((candidate) => candidate.alias === decl.against);
50515
50996
  if (limit === void 0) continue;
50516
50997
  wanted2.push(limit);
50517
50998
  limits.set(slot2.field.alias, limit.alias);
50518
50999
  }
50519
- wanted2.push(...screen.entity.fields);
50520
- const own = bindTable(at2, screen.entity, entities, roles, wanted2, aliased, live, missing);
51000
+ wanted2.push(...screen2.entity.fields);
51001
+ const own = bindTable(at2, screen2.entity, entities, roles, wanted2, aliased, live, missing);
50521
51002
  if (own === void 0) return void 0;
50522
- const subject = bindSubject(at2, screen, entities, roles, aliased, live, scopeOf, missing);
50523
- const narrowing = subject === void 0 ? void 0 : scopeNarrowing(screen, subject, own.fields);
50524
- const band = recordBand(screen.screen.shape, screen.entity, roles, screen.record);
50525
- const sections = recordRecipe(screen.screen.shape, screen.entity, entities, roles, header, screen.record);
51003
+ const subject = bindSubject(at2, screen2, entities, roles, aliased, live, scopeOf, missing);
51004
+ const narrowing = subject === void 0 ? void 0 : scopeNarrowing(screen2, subject, own.fields);
51005
+ const band = recordBand(screen2.screen.shape, screen2.entity, roles, screen2.record);
51006
+ const sections = recordRecipe(screen2.screen.shape, screen2.entity, entities, roles, header, screen2.record).flatMap(
51007
+ (entry2) => unfiled(entry2, under)
51008
+ );
50526
51009
  const planned = sections.map((entry2) => entry2.section);
50527
51010
  const links = /* @__PURE__ */ new Map();
50528
51011
  const createLinks = /* @__PURE__ */ new Map();
50529
51012
  const owns = true;
50530
51013
  const linkTargets = [
50531
- ...factLinkTargets(screen, planned, entities, roles, owns).map((one) => ({ ...one, on: "fact" })),
50532
- ...createLinkTargets(screen, entities, roles, owns).map((one) => ({ ...one, on: "create" }))
51014
+ ...factLinkTargets(screen2, planned, entities, roles, owns).map((one) => ({ ...one, on: "fact" })),
51015
+ ...createLinkTargets(screen2, entities, roles, owns, under).map((one) => ({ ...one, on: "create" }))
50533
51016
  ];
50534
- for (const { field, target, display, on } of linkTargets) {
50535
- const bound = links.get(field.alias);
50536
- if (on === "create" && bound !== void 0) {
50537
- createLinks.set(field.alias, bound);
50538
- continue;
50539
- }
51017
+ const bindPicker = (owner, field, target, display) => {
50540
51018
  const scoped = new Set((target.read_scope?.any ?? []).flatMap((clause) => "field" in clause ? [clause.field] : []));
50541
51019
  const declared = rules[target.alias]?.natural_key ?? [];
50542
51020
  const keyFields = target.fields.filter((candidate) => declared.includes(candidate.alias));
50543
51021
  const mintFields = mintedWith(target, display, keyFields);
50544
- const narrowing2 = writeRuleOf(rules, screen.entity.alias, field.alias)?.options_where;
51022
+ const narrowing2 = writeRuleOf(rules, owner.alias, field.alias)?.options_where;
50545
51023
  const conditions = narrowing2 === void 0 ? void 0 : optionsWhereConditions(narrowing2, target);
50546
51024
  const sourceAliases = new Set(
50547
- screen.entity.fields.flatMap((candidate) => {
50548
- const stated2 = writeRuleOf(rules, screen.entity.alias, candidate.alias)?.default_from;
51025
+ owner.fields.flatMap((candidate) => {
51026
+ const stated2 = writeRuleOf(rules, owner.alias, candidate.alias)?.default_from;
50549
51027
  return stated2 === void 0 || !stated2.startsWith(`${field.alias}.`) ? [] : [stated2.slice(field.alias.length + 1)];
50550
51028
  })
50551
51029
  );
@@ -50559,22 +51037,22 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
50559
51037
  ...target.fields.filter((candidate) => scoped.has(candidate.alias))
50560
51038
  ];
50561
51039
  const found = bindTable(`${at2}.${field.alias}`, target, entities, roles, wantedOnTarget, aliased, live, missing);
50562
- if (found === void 0) continue;
51040
+ if (found === void 0) return void 0;
50563
51041
  const shown = found.fields.get(display.alias);
50564
- if (shown === void 0) continue;
51042
+ if (shown === void 0) return void 0;
50565
51043
  const rule = scopeOf(target, found.fields);
50566
51044
  const boundOf = (fields) => fields.flatMap((candidate) => {
50567
51045
  const one = found.fields.get(candidate.alias);
50568
51046
  return one === void 0 ? [] : [one];
50569
51047
  });
50570
- const narrowedAt = `write_rules.${screen.entity.alias}.fields.${field.alias}.options_where`;
51048
+ const narrowedAt = `write_rules.${owner.alias}.fields.${field.alias}.options_where`;
50571
51049
  const where = (conditions ?? []).flatMap((condition) => {
50572
51050
  const one = found.fields.get(condition.field.alias);
50573
51051
  if (one === void 0) return [];
50574
51052
  const resolved = whereValue(narrowedAt, one, condition, missing);
50575
51053
  return resolved === void 0 ? [] : [{ field: one, type: condition.type, operator: condition.operator, value: resolved.value }];
50576
51054
  });
50577
- const link = {
51055
+ return {
50578
51056
  alias: optionsQueryAlias(target.alias, display.alias),
50579
51057
  param: OPTIONS_SEARCH_PARAM,
50580
51058
  table: found.table,
@@ -50595,12 +51073,25 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
50595
51073
  ...rule === void 0 ? {} : { scope: rule },
50596
51074
  ...where.length === 0 ? {} : { where }
50597
51075
  };
51076
+ };
51077
+ for (const { field, target, display, on } of linkTargets) {
51078
+ const bound = links.get(field.alias);
51079
+ if (on === "create" && bound !== void 0) {
51080
+ createLinks.set(field.alias, bound);
51081
+ continue;
51082
+ }
51083
+ const link = bindPicker(screen2.entity, field, target, display);
51084
+ if (link === void 0) continue;
50598
51085
  if (on === "fact") links.set(field.alias, link);
50599
51086
  createLinks.set(field.alias, link);
50600
51087
  }
50601
51088
  const children = /* @__PURE__ */ new Map();
50602
- const bindChild = (section, step, draw, child, via, linkField, drawn, set2, files, named, reads = []) => {
50603
- const itinerary = draw === "related" ? void 0 : recordItinerary(recordArchetype(screen.screen.shape, screen.entity, roles), step, child, roles);
51089
+ const bindChild = (entry2, drawn, set2, files, named2, reads = []) => {
51090
+ const relation = sectionRelation(entry2.section);
51091
+ if (relation === void 0) return;
51092
+ const { slot: step, draw } = entry2;
51093
+ const { kind, child, via, link: linkField } = relation;
51094
+ const itinerary = draw === "related" ? void 0 : recordItinerary(recordArchetype(screen2.screen.shape, screen2.entity, roles), step, child, roles);
50604
51095
  const operandAliases = /* @__PURE__ */ new Set([
50605
51096
  // The row's own currency is read the same way: a figure printed in the
50606
51097
  // workspace's money on a line the business quoted in another is a wrong
@@ -50629,7 +51120,10 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
50629
51120
  const operandFields = child.fields.filter((field) => operandAliases.has(field.alias));
50630
51121
  const scoped = new Set((child.read_scope?.any ?? []).flatMap((clause) => "field" in clause ? [clause.field] : []));
50631
51122
  const scopeFields = child.fields.filter((field) => scoped.has(field.alias));
50632
- const found = bindTable(`${at2}.${child.alias}`, child, entities, roles, [linkField, ...drawn, ...operandFields, ...scopeFields], aliased, live, missing);
51123
+ const opened = childSurfaceScreen(screen2, entry2, mounts);
51124
+ const surface = opened === void 0 ? void 0 : bindOne(opened, `${at2}.${child.alias}`, linkField);
51125
+ if (opened !== void 0 && surface === void 0) return;
51126
+ const found = surface === void 0 ? bindTable(`${at2}.${child.alias}`, child, entities, roles, [linkField, ...drawn, ...operandFields, ...scopeFields], aliased, live, missing) : { table: surface.table, tableAlias: surface.tableAlias, fields: surface.fields };
50633
51127
  if (found === void 0) return;
50634
51128
  const link = found.fields.get(linkField.alias);
50635
51129
  if (link === void 0) return;
@@ -50639,7 +51133,7 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
50639
51133
  const boundField = found.fields.get(field.alias);
50640
51134
  if (boundField === void 0) continue;
50641
51135
  fields.set(field.alias, boundField);
50642
- const role = field.alias === named?.alias ? "identity" : roleOf(roles, child.alias, field.alias)?.role;
51136
+ const role = field.alias === named2?.alias ? "identity" : roleOf(roles, child.alias, field.alias)?.role;
50643
51137
  if (role !== void 0) drawnRoles.set(field.alias, role);
50644
51138
  }
50645
51139
  const operands = /* @__PURE__ */ new Map([[linkField.alias, link]]);
@@ -50657,15 +51151,15 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
50657
51151
  ...runSlot2 === void 0 ? {} : { slot: runSlot2 }
50658
51152
  };
50659
51153
  children.set(childKey(child.alias, linkField.alias), {
50660
- section,
51154
+ section: kind,
50661
51155
  via,
50662
51156
  // Keyed by the ENTITY and the LINK, not the screen: two screens over one
50663
51157
  // entity read the same rows through the same filter, and a second alias
50664
51158
  // for them is a second manifest entry, a second `.d.ts` entry and a
50665
51159
  // second cache key for one query — while two LINKS from one child are
50666
51160
  // two relations, and one alias for them would draw the first one twice.
50667
- alias: `${screen.entity.alias}_${child.alias}_${linkField.alias}`,
50668
- param: recordParam(screen.entity.alias),
51161
+ alias: childQueryAlias(screen2.entity.alias, child.alias, linkField.alias),
51162
+ param: recordParam(screen2.entity.alias),
50669
51163
  linkAlias: linkField.alias,
50670
51164
  heading: sectionHeading(child, linkField, sections, draw),
50671
51165
  entity: child,
@@ -50675,69 +51169,58 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
50675
51169
  fields,
50676
51170
  operands,
50677
51171
  roles: drawnRoles,
50678
- identity: named?.alias ?? child.fields.find((field) => roleOf(roles, child.alias, field.alias)?.role === "identity")?.alias,
51172
+ identity: named2?.alias ?? child.fields.find((field) => roleOf(roles, child.alias, field.alias)?.role === "identity")?.alias,
50679
51173
  set: set2?.alias,
50680
51174
  files: files?.alias,
50681
51175
  ...itinerary === void 0 ? {} : { itinerary },
50682
51176
  ...scope2 === void 0 ? {} : { scope: scope2 },
50683
- ...order === void 0 ? {} : { order }
51177
+ ...order === void 0 ? {} : { order },
51178
+ ...surface === void 0 ? {} : { surface }
50684
51179
  });
50685
51180
  };
50686
- for (const { slot: step, draw, section } of sections) {
51181
+ for (const entry2 of sections) {
51182
+ const { section } = entry2;
50687
51183
  if (section.kind === "timeline") {
50688
- bindChild("children", step, draw, section.child, section.via, section.link, section.fields, void 0, void 0, void 0, [
50689
- section.planned,
50690
- section.actual
50691
- ]);
51184
+ bindChild(entry2, section.fields, void 0, void 0, void 0, [section.planned, section.actual]);
50692
51185
  continue;
50693
51186
  }
50694
51187
  if (section.kind === "thread") {
50695
- bindChild("children", step, draw, section.child, section.via, section.link, section.fields, void 0, void 0, void 0, [
50696
- section.body
50697
- ]);
51188
+ bindChild(entry2, section.fields, void 0, void 0, void 0, [section.body]);
50698
51189
  continue;
50699
51190
  }
50700
51191
  if (section.kind === "children") {
50701
- const drawn = section.fields.filter((field) => {
50702
- const role = roleOf(roles, section.child.alias, field.alias)?.role;
50703
- return role !== void 0 && drawnAsColumn(role, field);
50704
- });
51192
+ const drawn = drawnColumns2(section.child, section.fields, roles);
50705
51193
  if (drawn.length > 0) {
50706
- bindChild("children", step, draw, section.child, section.via, section.link, drawn);
51194
+ bindChild(entry2, drawn);
50707
51195
  continue;
50708
51196
  }
50709
- const named = namingField2(section.child, section.link, entities, roles);
50710
- if (named === void 0) {
51197
+ const named2 = namingField2(section.child, section.link, entities, roles);
51198
+ if (named2 === void 0) {
50711
51199
  missing.push(
50712
51200
  `${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}"`
50713
51201
  );
50714
51202
  continue;
50715
51203
  }
50716
- bindChild("children", step, draw, section.child, section.via, section.link, [named], void 0, void 0, named);
51204
+ bindChild(entry2, [named2], void 0, void 0, named2);
50717
51205
  continue;
50718
51206
  }
50719
51207
  if (section.kind !== "expected_set" || section.source !== "child") continue;
50720
51208
  const name = section.child.fields.filter((field) => roleOf(roles, section.child.alias, field.alias)?.role === "identity");
50721
51209
  bindChild(
50722
- "expected_set",
50723
- step,
50724
- draw,
50725
- section.child,
50726
- section.via,
50727
- section.link,
51210
+ entry2,
50728
51211
  [...name, section.setField, ...section.filesField === void 0 ? [] : [section.filesField]],
50729
51212
  section.setField,
50730
51213
  section.filesField
50731
51214
  );
50732
51215
  }
50733
- const scope = scopeOf(screen.entity, own.fields);
50734
- const mark = screen.entity.fields.find((field) => roleOf(roles, screen.entity.alias, field.alias)?.role === "mark");
50735
- const whenField = screenWhen(screen, roles);
51216
+ const scope = scopeOf(screen2.entity, own.fields);
51217
+ const mark = screen2.entity.fields.find((field) => roleOf(roles, screen2.entity.alias, field.alias)?.role === "mark");
51218
+ const whenField = screenWhen(screen2, roles);
50736
51219
  const day = whenField === void 0 ? void 0 : own.fields.get(whenField.alias);
50737
- const runs = registerRuns(screen, roles);
51220
+ const runs = registerRuns(screen2, roles);
50738
51221
  const runSlot = runs === void 0 ? void 0 : own.fields.get(runs.slot.alias);
50739
51222
  const entry = {
50740
- screen,
51223
+ screen: screen2,
50741
51224
  table: own.table,
50742
51225
  tableAlias: own.tableAlias,
50743
51226
  header,
@@ -50746,6 +51229,7 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
50746
51229
  limits,
50747
51230
  sections,
50748
51231
  children,
51232
+ mounts,
50749
51233
  links,
50750
51234
  createLinks,
50751
51235
  words,
@@ -50765,9 +51249,9 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
50765
51249
  );
50766
51250
  }
50767
51251
  }
50768
- const param = recordParam(screen.entity.alias);
51252
+ const param = recordParam(screen2.entity.alias);
50769
51253
  const taken = /* @__PURE__ */ new Map([[param, "the record's own id"]]);
50770
- for (const field of screen.entity.fields) {
51254
+ for (const field of screen2.entity.fields) {
50771
51255
  const column = own.fields.get(field.alias);
50772
51256
  if (column === void 0 || !stated(entry, field)) continue;
50773
51257
  for (const name of inputNames(field, column)) {
@@ -50778,7 +51262,7 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
50778
51262
  }
50779
51263
  return entry;
50780
51264
  };
50781
- return { bound: bind(), missing };
51265
+ return { bound: bindOne(screen, screen.app.alias), missing };
50782
51266
  }
50783
51267
  var STATED_FIELD_TYPES = [
50784
51268
  "text",
@@ -50799,29 +51283,23 @@ function stated(entry, field) {
50799
51283
  }
50800
51284
  function factLinkTargets(screen, sections, entities, roles, owns) {
50801
51285
  if (screen.screen.writes === false || !owns) return [];
50802
- return sections.flatMap((section) => section.kind === "facts" ? section.fields : []).flatMap((field) => {
50803
- if (field.type !== "select_record_link" || !isEditable(field)) return [];
50804
- const target = entities.find((candidate) => candidate.alias === field.target_entity);
50805
- if (target === void 0) return [];
50806
- const named = field.display_field_aliases?.[0];
50807
- const display = named === void 0 ? target.fields.find((candidate) => roleOf(roles, target.alias, candidate.alias)?.role === "identity") : target.fields.find((candidate) => candidate.alias === named);
50808
- return display === void 0 ? [] : [{ field, target, display }];
50809
- });
51286
+ return sections.flatMap((section) => section.kind === "facts" ? section.fields : []).flatMap((field) => linkTarget2(field, entities, roles));
51287
+ }
51288
+ function linkTarget2(field, entities, roles) {
51289
+ if (field.type !== "select_record_link" || !isEditable(field)) return [];
51290
+ const target = entities.find((candidate) => candidate.alias === field.target_entity);
51291
+ if (target === void 0) return [];
51292
+ const named2 = field.display_field_aliases?.[0];
51293
+ const display = named2 === void 0 ? target.fields.find((candidate) => roleOf(roles, target.alias, candidate.alias)?.role === "identity") : target.fields.find((candidate) => candidate.alias === named2);
51294
+ return display === void 0 ? [] : [{ field, target, display }];
50810
51295
  }
50811
51296
  function parentLink(entity, roles) {
50812
51297
  const field = entity.fields.find((candidate) => roleOf(roles, entity.alias, candidate.alias)?.role === "parent");
50813
51298
  return field === void 0 || field.type !== "select_record_link" ? void 0 : field;
50814
51299
  }
50815
- function createLinkTargets(screen, entities, roles, owns) {
51300
+ function createLinkTargets(screen, entities, roles, owns, under) {
50816
51301
  if (screen.screen.writes === false || !owns) return [];
50817
- return screen.entity.fields.flatMap((field) => {
50818
- if (field.type !== "select_record_link" || !isEditable(field)) return [];
50819
- const target = entities.find((candidate) => candidate.alias === field.target_entity);
50820
- if (target === void 0) return [];
50821
- const named = field.display_field_aliases?.[0];
50822
- const display = named === void 0 ? target.fields.find((candidate) => roleOf(roles, target.alias, candidate.alias)?.role === "identity") : target.fields.find((candidate) => candidate.alias === named);
50823
- return display === void 0 ? [] : [{ field, target, display }];
50824
- });
51302
+ return screen.entity.fields.flatMap((field) => field.alias === under?.alias ? [] : linkTarget2(field, entities, roles));
50825
51303
  }
50826
51304
  function mintedWith(target, display, keys2) {
50827
51305
  const keyed = new Set(keys2.map((field) => field.alias));
@@ -50840,7 +51318,27 @@ function countedRelations(entry) {
50840
51318
  });
50841
51319
  }
50842
51320
  function childOpens(entry, child) {
50843
- return child.entity.alias === entry.screen.entity.alias ? child.entity.alias : void 0;
51321
+ if (!entry.mounts || child.section === "thread") return void 0;
51322
+ return child.surface !== void 0 || child.entity.alias === entry.screen.entity.alias ? child.entity.alias : void 0;
51323
+ }
51324
+ function childSurfaces(entry) {
51325
+ return new Map(
51326
+ [...entry.children.values()].flatMap(
51327
+ (child) => child.surface === void 0 || childOpens(entry, child) === void 0 ? [] : [[child.entity.alias, child.surface]]
51328
+ )
51329
+ );
51330
+ }
51331
+ function childEditor(entry, child) {
51332
+ if (child.surface === void 0) return void 0;
51333
+ return childOpens(entry, child) !== void 0 || child.section === "thread" ? child.surface : void 0;
51334
+ }
51335
+ function childEditors(entry) {
51336
+ return new Map(
51337
+ [...entry.children.values()].flatMap((child) => {
51338
+ const surface = childEditor(entry, child);
51339
+ return surface === void 0 ? [] : [[child.entity.alias, surface]];
51340
+ })
51341
+ );
50844
51342
  }
50845
51343
  function planWrites(entry) {
50846
51344
  if (entry.screen.screen.writes === false) return void 0;
@@ -50888,22 +51386,24 @@ function scopeCondition(subject, narrowing) {
50888
51386
  return narrowing.kind === "own" ? { node_type: "condition", type: "record_id", operator: "is_any_of", value } : { node_type: "condition", type: "select_record_link", field_key: narrowing.field.id, operator: "has_any_of", value };
50889
51387
  }
50890
51388
  function planTableNames(screen, entities, roles) {
51389
+ return [...new Set(surfaceTables(screen, entities, roles, screen.record === "page"))];
51390
+ }
51391
+ function surfaceTables(screen, entities, roles, mounts, under) {
50891
51392
  const names = [screen.entity.label, ...screen.scope === null ? [] : [screen.scope.entity.label]];
50892
- const sections = recordRecipe(
50893
- screen.screen.shape,
50894
- screen.entity,
50895
- entities,
50896
- roles,
50897
- recordHeader(screen, roles),
50898
- screen.record
50899
- ).map((entry) => entry.section);
51393
+ const recipe = recordRecipe(screen.screen.shape, screen.entity, entities, roles, recordHeader(screen, roles), screen.record);
51394
+ const sections = recipe.map((entry) => entry.section);
50900
51395
  for (const { target } of factLinkTargets(screen, sections, entities, roles, true)) names.push(target.label);
50901
- for (const { target } of createLinkTargets(screen, entities, roles, true)) names.push(target.label);
50902
- for (const section of sections) {
50903
- const child = sectionChild(section);
51396
+ for (const { target } of createLinkTargets(screen, entities, roles, true, under)) names.push(target.label);
51397
+ for (const entry of recipe) {
51398
+ const opened = childSurfaceScreen(screen, entry, mounts);
51399
+ if (opened !== void 0) {
51400
+ names.push(...surfaceTables(opened, entities, roles, false, sectionRelation(entry.section)?.link));
51401
+ continue;
51402
+ }
51403
+ const child = sectionChild(entry.section);
50904
51404
  if (child !== void 0) names.push(child.label);
50905
51405
  }
50906
- return [...new Set(names)];
51406
+ return names;
50907
51407
  }
50908
51408
  function projection(fields) {
50909
51409
  return fields.map((field) => ({ source: field.id, output: field.alias }));
@@ -51004,6 +51504,10 @@ function planQueries(entry) {
51004
51504
  description: entry.words.queries.record(entry.screen.entity.label, entry.screen.screen.label)
51005
51505
  };
51006
51506
  }
51507
+ surfaceReads(queries, entry);
51508
+ return queries;
51509
+ }
51510
+ function surfaceReads(queries, entry) {
51007
51511
  for (const link of entry.createLinks.values()) {
51008
51512
  if (queries[link.alias] !== void 0) continue;
51009
51513
  queries[link.alias] = {
@@ -51033,7 +51537,8 @@ function planQueries(entry) {
51033
51537
  }
51034
51538
  for (const child of entry.children.values()) {
51035
51539
  if (queries[child.alias] !== void 0) continue;
51036
- const drawer = childOpens(entry, child) === void 0 ? [] : [...entry.fields.values()];
51540
+ const opened = child.surface ?? (childOpens(entry, child) === void 0 ? void 0 : entry);
51541
+ const drawer = opened === void 0 ? [] : [...opened.fields.values()];
51037
51542
  queries[child.alias] = {
51038
51543
  ast: {
51039
51544
  kind: "project",
@@ -51062,8 +51567,8 @@ function planQueries(entry) {
51062
51567
  params: { [child.param]: { type: "record_link", table_id: entry.table.id } },
51063
51568
  description: entry.words.queries.children(child.entity.label, entry.screen.entity.label, child.link.label)
51064
51569
  };
51570
+ if (child.surface !== void 0) surfaceReads(queries, child.surface);
51065
51571
  }
51066
- return queries;
51067
51572
  }
51068
51573
  function registerFilters(entry) {
51069
51574
  const filters = /* @__PURE__ */ new Map();
@@ -51137,14 +51642,37 @@ var specFieldSchema = zod_default.object({
51137
51642
  zeroWhenEmpty: zod_default.literal(true).optional(),
51138
51643
  sign: specSignSchema.optional(),
51139
51644
  level: specLevelSchema.optional(),
51140
- until: specUntilSchema.optional()
51141
- }).strict();
51645
+ /**
51646
+ * THIS DATE IS A DEADLINE — the row counts down to it and a row past it is
51647
+ * late. The MODEL's statement and the only one any surface reads: derived
51648
+ * from the row instead, the day a lead arrived read as overdue on every desk
51649
+ * it was drawn on. `until` says where the countdown stops.
51650
+ */
51651
+ due: zod_default.literal(true).optional(),
51652
+ until: specUntilSchema.optional(),
51653
+ /**
51654
+ * WHAT THIS MEASURE'S NUMBER COUNTS, where the unit decides how a surface
51655
+ * DRAWS the row — days are what give a bar its length, and a figure with no
51656
+ * unit is drawn as the number it is.
51657
+ */
51658
+ counts: zod_default.literal("days").optional()
51659
+ }).strict().check((ctx) => {
51660
+ if (ctx.value.until !== void 0 && ctx.value.due !== true) {
51661
+ ctx.issues.push({
51662
+ code: "custom",
51663
+ input: ctx.value,
51664
+ message: "`until` says where a countdown stops \u2014 declare `due: true` beside it, or drop it"
51665
+ });
51666
+ }
51667
+ });
51142
51668
  var specFieldsSchema = zod_default.record(specColumnAliasSchema, specFieldSchema);
51143
51669
  var specPresentationSchema = zod_default.object({
51144
51670
  lead: presentationLeadSchema.optional(),
51145
51671
  density: presentationDensitySchema.optional(),
51146
51672
  /** How the rows are ARRANGED — the same rows and the same slots in a different geometry. */
51147
- layout: screenLayoutSchema.optional()
51673
+ layout: screenLayoutSchema.optional(),
51674
+ /** WHERE EACH BAR ENDS on a gantt — the column holding the day it is done, rather than the span it runs. */
51675
+ until: specColumnAliasSchema.optional()
51148
51676
  }).strict();
51149
51677
  var specActPlaceSchema = zod_default.literal("cta");
51150
51678
  var specTemplateActSchema = zod_default.object({
@@ -51184,7 +51712,23 @@ var specAgentActSchema = zod_default.object({
51184
51712
  fills: zod_default.array(specColumnAliasSchema).min(1),
51185
51713
  place: specActPlaceSchema.optional()
51186
51714
  }).strict();
51187
- var specActSchema = zod_default.discriminatedUnion("kind", [specTemplateActSchema, specAgentActSchema]);
51715
+ var RECORD_ID_COLUMN = "__source_record_id";
51716
+ var specActInputSchema = zod_default.object({
51717
+ name: zod_default.string().min(1),
51718
+ field: specColumnAliasSchema
51719
+ }).strict();
51720
+ var specWorkflowActSchema = zod_default.object({
51721
+ kind: zod_default.literal("workflow"),
51722
+ label: zod_default.string().min(1),
51723
+ /** The act's own key, stable across renders — the workflow's alias. */
51724
+ key: contractAliasSchema,
51725
+ /** The alias `package.json#lotics.workflows` binds this body under. */
51726
+ workflow: runtimeAliasSchema,
51727
+ /** What the press hands the run, in the order the act named them. */
51728
+ inputs: zod_default.array(specActInputSchema).min(1),
51729
+ place: specActPlaceSchema.optional()
51730
+ }).strict();
51731
+ var specActSchema = zod_default.discriminatedUnion("kind", [specTemplateActSchema, specAgentActSchema, specWorkflowActSchema]);
51188
51732
  var specColumnsExportSchema = zod_default.object({
51189
51733
  kind: zod_default.literal("columns"),
51190
51734
  /** Each drawn column, in the order the register draws them: the sheet's heading, and the column it reads. */
@@ -51222,13 +51766,23 @@ var specSlotSchema = zod_default.object({
51222
51766
  * rest states it on every line rather than drawing a blank on most.
51223
51767
  */
51224
51768
  also: zod_default.array(specColumnAliasSchema).min(1).optional(),
51769
+ /**
51770
+ * THE TIERS UNDER THE ONE IN `field`, outermost first — a hierarchy the slot
51771
+ * folds by rather than second homes for one value.
51772
+ *
51773
+ * Two lists and not one, because they are two readings: `also` is read
51774
+ * ACROSS, one value wherever the row happens to state it, and this is read
51775
+ * DOWN, a narrower question at each depth.
51776
+ */
51777
+ tiers: zod_default.array(specColumnAliasSchema).min(1).optional(),
51225
51778
  /**
51226
51779
  * THE COLUMN IS THE READER'S OWN CONTROL FOR THIS VALUE — pressed, it writes
51227
51780
  * that single field as a diff through the record's update, blockers and an
51228
51781
  * outcome's confirm included.
51229
51782
  *
51230
51783
  * Only where the decision is one a reader takes FROM THE ROW ALONE, which is
51231
- * why `app check` refuses it on every role but `lifecycle` and `verdict`.
51784
+ * why `app check` refuses it on every role but `lifecycle`, `verdict` and
51785
+ * `measure` (`quickRefusal`, the one rule both the plan and the spec ask).
51232
51786
  */
51233
51787
  quick: zod_default.literal(true).optional(),
51234
51788
  /** A quick lifecycle whose stages are a WALK — the cell advances to the next rather than offering them all. */
@@ -51256,11 +51810,18 @@ var specSummarySchema = zod_default.object({
51256
51810
  /** Ascending edges in days past due; the last is open-ended. */
51257
51811
  buckets: zod_default.array(zod_default.number().int().positive()).min(1)
51258
51812
  }).strict().optional(),
51259
- directions: specDirectionsSchema.optional()
51813
+ directions: specDirectionsSchema.optional(),
51814
+ /**
51815
+ * WHICH WAY THE FIGURE MOVED ACROSS THE WINDOW — read over the screen's own
51816
+ * `period`, which the plan refuses this clause without. `direction` is which
51817
+ * way is GOOD: the arrow follows the change, the colour follows this.
51818
+ */
51819
+ trend: zod_default.object({ field: specColumnAliasSchema, direction: zod_default.enum(["up", "down"]) }).strict().optional()
51260
51820
  }).strict();
51821
+ var specRelativePointSchema = zod_default.object({ offset: zod_default.number().int(), unit: zod_default.enum(["days", "weeks", "months", "years"]) }).strict();
51261
51822
  var specConditionSchema = zod_default.object({
51262
51823
  field: specColumnAliasSchema,
51263
- type: zod_default.enum(["number", "text", "boolean", "select"]),
51824
+ type: zod_default.enum(["number", "text", "boolean", "select", "date"]),
51264
51825
  operator: zod_default.enum([
51265
51826
  "equals",
51266
51827
  "not_equals",
@@ -51270,11 +51831,20 @@ var specConditionSchema = zod_default.object({
51270
51831
  "less_than_or_equal_to",
51271
51832
  "has_any_of",
51272
51833
  "has_none_of",
51834
+ "before",
51835
+ "after",
51836
+ "on_or_before",
51837
+ "on_or_after",
51838
+ "on",
51273
51839
  "is_empty",
51274
51840
  "is_not_empty"
51275
51841
  ]),
51276
- /** What the operator compares against — absent where it compares against nothing. */
51277
- value: zod_default.union([zod_default.number(), zod_default.string(), zod_default.boolean(), zod_default.array(zod_default.string())]).optional()
51842
+ /**
51843
+ * What the operator compares against — absent where it compares against
51844
+ * nothing. A DATE's is an offset from today and never a day: a lens written
51845
+ * as a date is a set that stops meaning what it said tomorrow.
51846
+ */
51847
+ value: zod_default.union([zod_default.number(), zod_default.string(), zod_default.boolean(), zod_default.array(zod_default.string()), specRelativePointSchema]).optional()
51278
51848
  }).strict();
51279
51849
  var specPredicateSchema = zod_default.object({
51280
51850
  label: zod_default.string().min(1),
@@ -51289,14 +51859,24 @@ var specLensSchema = zod_default.union([
51289
51859
  var specObligationSchema = zod_default.object({
51290
51860
  label: zod_default.string().min(1),
51291
51861
  due: specColumnAliasSchema,
51292
- /** Filled, this obligation is met and its row leaves the desk. */
51293
- satisfiedBy: specColumnAliasSchema
51862
+ /**
51863
+ * Filled, this obligation is met and its row leaves the desk — absent where
51864
+ * the business stamps nothing and the LIFECYCLE ends it instead, which the
51865
+ * `due` column's own `until` says and the same read decides.
51866
+ */
51867
+ satisfiedBy: specColumnAliasSchema.optional()
51294
51868
  }).strict();
51295
51869
  var specScreenSchema = zod_default.object({
51296
51870
  alias: contractAliasSchema,
51297
51871
  label: zod_default.string().min(1),
51298
51872
  /** The entity this screen's rows are of — the key of its record in `records`. */
51299
51873
  entity: contractAliasSchema,
51874
+ /**
51875
+ * WHAT A SET OF THESE ROWS IS CALLED — the entity's own noun, for the column
51876
+ * a shape heads with a count. Optional: an app deployed before this clause
51877
+ * carries none and the frame's own word stands in.
51878
+ */
51879
+ rows: zod_default.string().min(1).optional(),
51300
51880
  table: specTableIdSchema,
51301
51881
  shape: specShapeSchema,
51302
51882
  /** How one record opens from the list — a page, a drawer, or revealed in the row itself. */
@@ -51307,6 +51887,16 @@ var specScreenSchema = zod_default.object({
51307
51887
  writes: zod_default.boolean(),
51308
51888
  fields: specFieldsSchema,
51309
51889
  slots: zod_default.array(specSlotSchema),
51890
+ /**
51891
+ * THE CONTEXT COLUMNS — extra facts this register draws AFTER its slots, in
51892
+ * the plan's order, each by what its column IS rather than by a role.
51893
+ *
51894
+ * They carry no rank: the frame ranks every one of them beneath every column
51895
+ * the shape drew, so a width that cannot seat the row sheds the context
51896
+ * before the answer. Bounded here as well as in the plan, because a spec is
51897
+ * also written by hand.
51898
+ */
51899
+ columns: zod_default.array(specColumnAliasSchema).min(1).max(CONTEXT_COLUMNS).optional(),
51310
51900
  /**
51311
51901
  * For each lifecycle this screen draws, the options that END the flow. A row
51312
51902
  * in one has ARRIVED, so the ladder draws it beside the flow rather than as
@@ -51371,16 +51961,21 @@ var specContactSchema = zod_default.discriminatedUnion("kind", [
51371
51961
  reach: zod_default.enum(["email", "phone", "place", "link", "handle"])
51372
51962
  }).strict()
51373
51963
  ]);
51964
+ var requiredByFrom2 = {
51965
+ from: zod_default.literal("parent").optional()
51966
+ };
51374
51967
  var specRequiredBySchema = zod_default.discriminatedUnion("reads", [
51375
51968
  zod_default.object({
51376
51969
  reads: zod_default.literal("option"),
51377
51970
  field: specColumnAliasSchema,
51971
+ ...requiredByFrom2,
51378
51972
  /** The conditioning option → the entries required under it. */
51379
51973
  options: zod_default.record(specOptionIdSchema, zod_default.array(specOptionIdSchema).min(1))
51380
51974
  }).strict(),
51381
51975
  zod_default.object({
51382
51976
  reads: zod_default.literal("answer"),
51383
51977
  field: specColumnAliasSchema,
51978
+ ...requiredByFrom2,
51384
51979
  /**
51385
51980
  * The row's own answer → the entries required under it. PARTIAL, because
51386
51981
  * an answer that owes nothing is omitted rather than named with an empty
@@ -51415,6 +52010,24 @@ var specItinerarySchema = zod_default.object({
51415
52010
  /** The closing line's label, where the record's band does not already state that sum. */
51416
52011
  total: zod_default.string().min(1).optional()
51417
52012
  }).strict();
52013
+ var specWorksheetSchema = zod_default.object({
52014
+ /** HOW MANY the line is for — never summed, because two units have no sum. */
52015
+ quantity: specColumnAliasSchema.optional(),
52016
+ /** What the line COSTS — the base the margin is taken against. */
52017
+ cost: specColumnAliasSchema.optional(),
52018
+ /** What it SELLS for — the figure the sheet closes on. */
52019
+ sell: specColumnAliasSchema.optional(),
52020
+ /** The part of the job each line falls under, and the run its own foot closes. */
52021
+ group: specColumnAliasSchema.optional()
52022
+ }).strict().check((ctx) => {
52023
+ if (ctx.value.cost === void 0 && ctx.value.sell === void 0) {
52024
+ ctx.issues.push({
52025
+ code: "custom",
52026
+ input: ctx.value,
52027
+ message: "states neither a cost nor a sell \u2014 a sheet works one of the two down"
52028
+ });
52029
+ }
52030
+ });
51418
52031
  var specChildSchema = zod_default.object({
51419
52032
  entity: contractAliasSchema,
51420
52033
  table: specTableIdSchema,
@@ -51454,6 +52067,7 @@ var specChildSchema = zod_default.object({
51454
52067
  */
51455
52068
  write: zod_default.object({ alias: runtimeAliasSchema, param: zod_default.string().min(1) }).strict().optional()
51456
52069
  }).strict();
52070
+ var specPricedChildSchema = specChildSchema.extend({ worksheet: specWorksheetSchema });
51457
52071
  var sectionActs = { acts: zod_default.array(specActSchema).min(1).optional() };
51458
52072
  var specSectionSchema = zod_default.discriminatedUnion("kind", [
51459
52073
  zod_default.object({ kind: zod_default.literal("facts"), key: zod_default.string().min(1), groups: zod_default.array(specFactGroupSchema), ...sectionActs }).strict(),
@@ -51475,16 +52089,39 @@ var specSectionSchema = zod_default.discriminatedUnion("kind", [
51475
52089
  ...sectionActs
51476
52090
  }).strict(),
51477
52091
  /** A child whose rows carry one entry of the set each — a document desk. */
51478
- zod_default.object({ kind: zod_default.literal("desk"), key: zod_default.string().min(1), heading: zod_default.string().min(1), child: specChildSchema, ...sectionActs }).strict(),
51479
- /** The record's own rows, as a register, a run, or a book of movements. */
51480
52092
  zod_default.object({
51481
- kind: zod_default.literal("children"),
52093
+ kind: zod_default.literal("desk"),
51482
52094
  key: zod_default.string().min(1),
51483
52095
  heading: zod_default.string().min(1),
51484
- draw: zod_default.enum(["register", "itinerary", "ledger"]),
51485
52096
  child: specChildSchema,
52097
+ /**
52098
+ * WHICH ENTRIES THIS RECORD OWES, where its own column decides them —
52099
+ * read off the record's row, because how many documents an order needs is
52100
+ * a fact about the order and no paper states it.
52101
+ */
52102
+ requiredBy: specRequiredBySchema.optional(),
51486
52103
  ...sectionActs
51487
52104
  }).strict(),
52105
+ /** The record's own rows, as a register, a run, a book of movements, or a priced sheet. */
52106
+ zod_default.discriminatedUnion("draw", [
52107
+ zod_default.object({
52108
+ kind: zod_default.literal("children"),
52109
+ key: zod_default.string().min(1),
52110
+ heading: zod_default.string().min(1),
52111
+ draw: zod_default.enum(["register", "itinerary", "ledger"]),
52112
+ child: specChildSchema,
52113
+ ...sectionActs
52114
+ }).strict(),
52115
+ /** THE SHEET AND ITS FIGURES ARE ONE CLAUSE — neither is declarable without the other. */
52116
+ zod_default.object({
52117
+ kind: zod_default.literal("children"),
52118
+ key: zod_default.string().min(1),
52119
+ heading: zod_default.string().min(1),
52120
+ draw: zod_default.literal("worksheet"),
52121
+ child: specPricedChildSchema,
52122
+ ...sectionActs
52123
+ }).strict()
52124
+ ]),
51488
52125
  /**
51489
52126
  * WHERE THE RECORD IS AGAINST WHERE IT SHOULD BE — a child whose rows are the
51490
52127
  * ordered stops, each carrying the day it was promised for and the day it
@@ -51737,9 +52374,9 @@ function specActSites(spec) {
51737
52374
  function specAllActs(spec) {
51738
52375
  return specActSites(spec).map((site) => site.act);
51739
52376
  }
51740
- function actsPointedAt(spec, named) {
52377
+ function actsPointedAt(spec, named2) {
51741
52378
  const paper = (at2, act) => {
51742
- const component = named.get(at2);
52379
+ const component = named2.get(at2);
51743
52380
  return component === void 0 ? act : { ...act, component };
51744
52381
  };
51745
52382
  const point = (at2, act) => act.kind === "template" ? paper(at2, act) : act;
@@ -51778,9 +52415,11 @@ function specWorkflowAliases(spec) {
51778
52415
  const taken = spec.screen.acts?.import;
51779
52416
  return [
51780
52417
  .../* @__PURE__ */ new Set([
51781
- // AN AGENT ACT NAMES NO WORKFLOW — what it lands is the record's own
51782
- // update, which `record.write` below already declares.
51783
- ...specAllActs(spec).flatMap((act) => act.kind === "template" ? [act.workflow] : []),
52418
+ // THE TWO ARMS THAT NAME A BODY — a paper the one the generator wrote, a
52419
+ // hand-off the one the author binds, both called by alias from the same
52420
+ // press. An agent act lands the record's own update, which `record.write`
52421
+ // below already declares.
52422
+ ...specAllActs(spec).flatMap((act) => act.kind === "template" || act.kind === "workflow" ? [act.workflow] : []),
51784
52423
  ...taken === void 0 ? [] : [taken.workflow],
51785
52424
  ...Object.values(spec.records).flatMap((record2) => [
51786
52425
  ...record2.write === void 0 ? [] : [record2.write.alias],
@@ -51844,44 +52483,42 @@ function filledByBody(entry, roles, mounted) {
51844
52483
  function planCreates(entry, roles) {
51845
52484
  if (planWrites(entry) === void 0) return [];
51846
52485
  const plan = planCreate(entry, roles);
51847
- return [...plan === void 0 ? [] : [plan], ...threadCreates(entry)];
51848
- }
51849
- function threadCreates(entry) {
51850
- return entry.sections.flatMap(({ section }) => {
51851
- if (section.kind !== "thread" || section.via !== "parent") return [];
51852
- const child = entry.children.get(childKey(section.child.alias, section.link.alias));
51853
- const bound = child?.fields.get(section.body.alias) ?? child?.operands.get(section.body.alias);
51854
- if (child === void 0 || bound === void 0) return [];
51855
- return [
51856
- {
51857
- entry,
51858
- entity: child.entity,
51859
- table: { id: child.table.id, alias: child.tableAlias },
51860
- options: child.alias,
51861
- parent: { bound: child.link, screen: entry, param: recordParam(entry.screen.entity.alias) },
51862
- reads: [],
51863
- unique: [],
51864
- inputs: [{ name: bound.alias, field: section.body, bound, label: bound.label, required: true, into: "row" }],
51865
- mount: { kind: "section", key: `${child.entity.alias}_${child.linkAlias}` }
51866
- }
51867
- ];
51868
- });
52486
+ return [...plan === void 0 ? [] : [plan], ...childCreates(entry, roles)];
51869
52487
  }
51870
- function threadMarks(entry) {
51871
- if (planWrites(entry) === void 0) return [];
52488
+ function childCreates(entry, roles) {
51872
52489
  return entry.sections.flatMap(({ section }) => {
51873
- if (section.kind !== "thread" || section.via !== "parent" || section.official === void 0) return [];
51874
- const child = entry.children.get(childKey(section.child.alias, section.link.alias));
51875
- const verdict = child?.fields.get(section.official.alias) ?? child?.operands.get(section.official.alias);
51876
- return child === void 0 || verdict === void 0 ? [] : [{ child, verdict }];
52490
+ const relation = sectionRelation(section);
52491
+ if (relation === void 0 || relation.via !== "parent") return [];
52492
+ const child = entry.children.get(childKey(relation.child.alias, relation.link.alias));
52493
+ if (child === void 0) return [];
52494
+ if (section.kind === "thread") {
52495
+ const bound = child.fields.get(section.body.alias) ?? child.operands.get(section.body.alias);
52496
+ if (bound === void 0) return [];
52497
+ return [
52498
+ {
52499
+ entry,
52500
+ entity: child.entity,
52501
+ table: { id: child.table.id, alias: child.tableAlias },
52502
+ options: child.alias,
52503
+ parent: { bound: child.link, screen: entry, param: recordParam(entry.screen.entity.alias) },
52504
+ reads: [],
52505
+ unique: [],
52506
+ inputs: [{ name: bound.alias, field: section.body, bound, label: bound.label, required: true, into: "row" }],
52507
+ mount: { kind: "section", key: sectionKey(child.entity.alias, child.linkAlias) }
52508
+ }
52509
+ ];
52510
+ }
52511
+ if (child.surface === void 0 || planWrites(child.surface) === void 0) return [];
52512
+ const plan = planCreate(child.surface, roles, entry);
52513
+ return plan === void 0 ? [] : [plan];
51877
52514
  });
51878
52515
  }
51879
- function planCreate(entry, roles) {
52516
+ function planCreate(entry, roles, on = entry) {
51880
52517
  const entity = entry.screen.entity;
51881
52518
  const later = filledLater(entry, roles);
51882
52519
  const roleOfField = (field) => roleOf(roles, entity.alias, field.alias)?.role;
51883
52520
  const parentField = parentLink(entity, roles);
51884
- const mountedOn = parentField !== void 0 && parentField.target_entity === entity.alias && entry.children.has(childKey(entity.alias, parentField.alias)) ? entry : void 0;
52521
+ const mountedOn = parentField !== void 0 && on.children.has(childKey(entity.alias, parentField.alias)) ? on : void 0;
51885
52522
  const parentBound = parentField === void 0 ? void 0 : entry.fields.get(parentField.alias);
51886
52523
  const parent = parentBound === void 0 || mountedOn === void 0 ? void 0 : { bound: parentBound, screen: mountedOn, param: recordParam(mountedOn.screen.entity.alias) };
51887
52524
  const filled = filledByBody(entry, roles, parent !== void 0);
@@ -51969,9 +52606,10 @@ function planCreate(entry, roles) {
51969
52606
  });
51970
52607
  }
51971
52608
  if (inputs.length === 0) return void 0;
51972
- const mount = parent !== void 0 ? { kind: "section", key: `${entity.alias}_${parentField?.alias ?? ""}` } : shapeListsRows(entry.screen.screen.shape) ? { kind: "register" } : void 0;
52609
+ const section = parent === void 0 || parentField === void 0 ? void 0 : parent.screen.children.get(childKey(entity.alias, parentField.alias));
52610
+ const mount = section !== void 0 ? { kind: "section", key: sectionKey(entity.alias, section.linkAlias) } : shapeListsRows(entry.screen.screen.shape) ? { kind: "register" } : void 0;
51973
52611
  if (mount === void 0) return void 0;
51974
- const run = parent === void 0 ? void 0 : parent.screen.children.get(childKey(entity.alias, parentField?.alias ?? ""))?.itinerary;
52612
+ const run = section?.itinerary;
51975
52613
  const day = run === void 0 ? void 0 : inputs.find((input) => input.link === void 0 && input.field.alias === run.when.alias)?.name;
51976
52614
  return {
51977
52615
  entry,
@@ -52020,6 +52658,43 @@ function sourceOnTarget(link, alias) {
52020
52658
  return link.sources.get(alias);
52021
52659
  }
52022
52660
 
52661
+ // src/plan_worksheets.ts
52662
+ function byRole(child, role, taken) {
52663
+ for (const [alias, bound] of child.fields) {
52664
+ if (child.roles.get(alias) === role && !taken.has(alias)) return { alias, bound };
52665
+ }
52666
+ return void 0;
52667
+ }
52668
+ function named(child, alias) {
52669
+ const bound = alias === void 0 ? void 0 : child.fields.get(alias);
52670
+ return alias === void 0 || bound === void 0 ? void 0 : { alias, bound };
52671
+ }
52672
+ function planWorksheets(entry) {
52673
+ const drawn = entry.screen.screen.sections ?? {};
52674
+ return entry.sections.flatMap(({ draw, section }) => {
52675
+ if (draw === "related" || section.kind !== "children" || section.via !== "parent") return [];
52676
+ const clause = drawn[section.child.alias];
52677
+ if (clause === void 0) return [];
52678
+ const child = entry.children.get(childKey(section.child.alias, section.link.alias));
52679
+ if (child === void 0) return [];
52680
+ const cost = named(child, clause.cost);
52681
+ const sell = named(child, clause.sell);
52682
+ const priced = new Set([cost, sell].flatMap((figure) => figure === void 0 ? [] : [figure.alias]));
52683
+ const quantity = byRole(child, "measure", priced);
52684
+ const group = byRole(child, "category", priced);
52685
+ return [
52686
+ {
52687
+ child,
52688
+ key: `${child.entity.alias}_${child.linkAlias}`,
52689
+ ...quantity === void 0 ? {} : { quantity },
52690
+ ...cost === void 0 ? {} : { cost },
52691
+ ...sell === void 0 ? {} : { sell },
52692
+ ...group === void 0 ? {} : { group }
52693
+ }
52694
+ ];
52695
+ });
52696
+ }
52697
+
52023
52698
  // src/plan_record_facts.ts
52024
52699
  function factOrder(screen, roles, fields) {
52025
52700
  const slots = new Set(screen.slots.flatMap((slot2) => slot2.field === null ? [] : [slot2.field.alias]));
@@ -52048,8 +52723,8 @@ function factsPlan(screen, roles, fields) {
52048
52723
  if (screen.factGroups.length === 0) return derived(fields);
52049
52724
  const own = new Set(fields.map((field) => field.alias));
52050
52725
  const claimed = new Set(screen.factGroups.flatMap((group) => group.fields.map((field) => field.alias)));
52051
- const named = screen.factGroups.map((group) => ({ caption: group.caption, fields: group.fields.filter((field) => own.has(field.alias)) }));
52052
- return [...named.filter((group) => group.fields.length > 0), ...derived(fields.filter((field) => !claimed.has(field.alias)))];
52726
+ const named2 = screen.factGroups.map((group) => ({ caption: group.caption, fields: group.fields.filter((field) => own.has(field.alias)) }));
52727
+ return [...named2.filter((group) => group.fields.length > 0), ...derived(fields.filter((field) => !claimed.has(field.alias)))];
52053
52728
  }
52054
52729
 
52055
52730
  // src/plan_record_children.ts
@@ -52073,9 +52748,9 @@ var surfaceOf = (fields) => ({
52073
52748
  bound: (alias) => fields.get(alias),
52074
52749
  ref: (alias) => fields.get(alias)?.alias
52075
52750
  });
52076
- var childReads = (child) => new Map([...child.fields, ...child.operands]);
52077
- function optionIds(field, named) {
52078
- return (named ?? []).flatMap((alias) => {
52751
+ var childReads = (child) => child.surface?.fields ?? new Map([...child.fields, ...child.operands]);
52752
+ function optionIds(field, named2) {
52753
+ return (named2 ?? []).flatMap((alias) => {
52079
52754
  const live = field?.options?.get(alias);
52080
52755
  return live === void 0 ? [] : [live.id];
52081
52756
  });
@@ -52105,7 +52780,7 @@ function untilOf(entry, roles, field) {
52105
52780
  return lifecycle === void 0 || settled.length === 0 ? void 0 : { lifecycle: lifecycle.alias, settled };
52106
52781
  }
52107
52782
  function specField(bound, declared, extras = {}) {
52108
- const multi = declared === void 0 ? false : declared.type === "select" ? declared.multi === true : declared.type === "select_record_link" && declared.cardinality !== "one";
52783
+ const multi = declared !== void 0 && fieldHoldsSeveral(declared);
52109
52784
  const money = bound.money === void 0 ? void 0 : {
52110
52785
  ...bound.money.currency === void 0 ? {} : { currency: bound.money.currency },
52111
52786
  ...bound.money.row === void 0 ? {} : { row: bound.money.row }
@@ -52125,7 +52800,9 @@ function specField(bound, declared, extras = {}) {
52125
52800
  ...bound.zeroWhenEmpty === true ? { zeroWhenEmpty: true } : {},
52126
52801
  ...extras.sign === void 0 ? {} : { sign: extras.sign },
52127
52802
  ...extras.level === void 0 ? {} : { level: extras.level },
52128
- ...extras.until === void 0 ? {} : { until: extras.until }
52803
+ ...extras.due === void 0 ? {} : { due: extras.due },
52804
+ ...extras.until === void 0 ? {} : { until: extras.until },
52805
+ ...extras.counts === void 0 ? {} : { counts: extras.counts }
52129
52806
  };
52130
52807
  }
52131
52808
  function specFields(fields, declared, extras = () => ({})) {
@@ -52143,11 +52820,21 @@ function screenExtras(entry, roles) {
52143
52820
  const declared = new Map(entry.screen.entity.fields.map((field) => [field.alias, field]));
52144
52821
  return (modelAlias) => {
52145
52822
  const field = declared.get(modelAlias);
52146
- const role = roleOf(roles, entry.screen.entity.alias, modelAlias)?.role;
52823
+ const decl = roleOf(roles, entry.screen.entity.alias, modelAlias);
52824
+ const role = decl?.role;
52147
52825
  return {
52148
52826
  ...signOf(entry.screen.entity, roles, modelAlias, over) === void 0 ? {} : { sign: signOf(entry.screen.entity, roles, modelAlias, over) },
52149
52827
  ...role === "measure" && levelOf(entry, roles, modelAlias) !== void 0 ? { level: levelOf(entry, roles, modelAlias) } : {},
52150
- ...field === void 0 || untilOf(entry, roles, field) === void 0 ? {} : { until: untilOf(entry, roles, field) }
52828
+ // A DATE IS A DEADLINE BECAUSE THE MODEL SAID SO. Carried on the column so
52829
+ // every surface drawing the value reads one answer — the register's cell,
52830
+ // the band's countdown, a board's hatching — instead of each deciding it
52831
+ // off the row and tinting a lead's arrival red.
52832
+ ...countsDown(decl) ? { due: true } : {},
52833
+ ...field === void 0 || untilOf(entry, roles, field) === void 0 ? {} : { until: untilOf(entry, roles, field) },
52834
+ // WHAT THE NUMBER COUNTS, where the model said — the unit decides whether
52835
+ // a surface may draw this measure as a LENGTH, and a figure whose unit
52836
+ // never reached the spec is a bar of dollars.
52837
+ ...role === "measure" && decl?.counts !== void 0 ? { counts: decl.counts } : {}
52151
52838
  };
52152
52839
  };
52153
52840
  }
@@ -52174,6 +52861,14 @@ function childExtras(child, roles) {
52174
52861
  }
52175
52862
  function boundAct(entry, templates, act, input) {
52176
52863
  const place = act.place === void 0 ? {} : { place: act.place };
52864
+ if (act.kind === "workflow") {
52865
+ const inputs = act.inputs.flatMap((taken) => {
52866
+ if (taken.field === null) return [{ name: taken.name, field: RECORD_ID_COLUMN }];
52867
+ const bound = entry.fields.get(taken.field.alias);
52868
+ return bound === void 0 ? [] : [{ name: taken.name, field: bound.alias }];
52869
+ });
52870
+ return inputs.length < act.inputs.length ? void 0 : { kind: "workflow", label: act.label, key: act.workflow, workflow: act.workflow, inputs, ...place };
52871
+ }
52177
52872
  if (act.kind === "agent") {
52178
52873
  const fills = act.fills.flatMap((field) => {
52179
52874
  const bound = entry.fields.get(field.alias);
@@ -52210,7 +52905,7 @@ function specActs(entry, templates) {
52210
52905
  });
52211
52906
  const saved = entry.screen.acts.export;
52212
52907
  const paper = saved === null || saved === true ? void 0 : one(saved, recordsParam(entity));
52213
- const columns = saved === true ? drawnColumns(entry) : [];
52908
+ const columns = saved === true ? drawnColumns3(entry) : [];
52214
52909
  const exported = columns.length > 0 ? { kind: "columns", columns } : paper?.kind === "template" ? paper : void 0;
52215
52910
  const taken = specImport(entry);
52216
52911
  if (row.length === 0 && selection.length === 0 && exported === void 0 && taken === void 0) return void 0;
@@ -52221,11 +52916,11 @@ function specActs(entry, templates) {
52221
52916
  ...taken === void 0 ? {} : { import: taken }
52222
52917
  };
52223
52918
  }
52224
- function drawnColumns(entry) {
52919
+ function drawnColumns3(entry) {
52225
52920
  const seen = /* @__PURE__ */ new Set();
52226
- return entry.screen.slots.flatMap((slot2) => {
52227
- if (slot2.field === null) return [];
52228
- const bound = entry.fields.get(slot2.field.alias);
52921
+ const drawn = [...entry.screen.slots.flatMap((slot2) => slot2.field === null ? [] : [slot2.field]), ...entry.screen.columns];
52922
+ return drawn.flatMap((field) => {
52923
+ const bound = entry.fields.get(field.alias);
52229
52924
  if (bound === void 0 || seen.has(bound.alias)) return [];
52230
52925
  seen.add(bound.alias);
52231
52926
  return [{ label: bound.label, field: bound.alias }];
@@ -52236,11 +52931,11 @@ function specImport(entry) {
52236
52931
  if (taken === null) return void 0;
52237
52932
  const key = entry.fields.get(taken.key.alias);
52238
52933
  if (key === void 0) return void 0;
52239
- const named = taken.columns.flatMap((field) => {
52934
+ const named2 = taken.columns.flatMap((field) => {
52240
52935
  const bound = entry.fields.get(field.alias);
52241
52936
  return bound === void 0 ? [] : [specField(bound, field)];
52242
52937
  });
52243
- const columns = named.length > 0 ? named : entry.screen.entity.fields.flatMap((field) => {
52938
+ const columns = named2.length > 0 ? named2 : entry.screen.entity.fields.flatMap((field) => {
52244
52939
  const bound = entry.fields.get(field.alias);
52245
52940
  return bound === void 0 || !stated(entry, field) || !importableField(field) ? [] : [specField(bound, field)];
52246
52941
  });
@@ -52268,7 +52963,7 @@ function specDirections(entry, roles) {
52268
52963
  const outflow = words(true);
52269
52964
  return inflow === "" || outflow === "" ? void 0 : { field: bound.alias, inflow, outflow };
52270
52965
  }
52271
- var LENS_TYPES = ["number", "text", "boolean", "select"];
52966
+ var LENS_TYPES = ["number", "text", "boolean", "select", "date"];
52272
52967
  var LENS_OPERATORS2 = [
52273
52968
  "equals",
52274
52969
  "not_equals",
@@ -52278,6 +52973,11 @@ var LENS_OPERATORS2 = [
52278
52973
  "less_than_or_equal_to",
52279
52974
  "has_any_of",
52280
52975
  "has_none_of",
52976
+ "before",
52977
+ "after",
52978
+ "on_or_before",
52979
+ "on_or_after",
52980
+ "on",
52281
52981
  "is_empty",
52282
52982
  "is_not_empty"
52283
52983
  ];
@@ -52293,6 +52993,10 @@ function lensValue(field, type, value) {
52293
52993
  const ids = value.map((alias) => field.options?.get(alias)?.id);
52294
52994
  return ids.every((id) => id !== void 0) ? { value: ids.filter((id) => id !== void 0) } : void 0;
52295
52995
  }
52996
+ if (type === "date") {
52997
+ const point = lensRelativePoint(value);
52998
+ return point === void 0 ? value == null ? {} : void 0 : { value: point };
52999
+ }
52296
53000
  if (typeof value === "number" || typeof value === "string" || typeof value === "boolean") return { value };
52297
53001
  return {};
52298
53002
  }
@@ -52321,11 +53025,11 @@ function specSummary(entry, roles) {
52321
53025
  const bound = entry.fields.get(field.alias);
52322
53026
  return bound === void 0 ? [] : [bound.alias];
52323
53027
  });
52324
- const named = summary.above === null || summary.above === "counts" ? void 0 : summary.above.flatMap((figure) => {
53028
+ const named2 = summary.above === null || summary.above === "counts" ? void 0 : summary.above.flatMap((figure) => {
52325
53029
  const bound = entry.fields.get(figure.field.alias);
52326
53030
  return bound === void 0 ? [] : [figure.at === void 0 ? bound.alias : { field: bound.alias, at: figure.at }];
52327
53031
  });
52328
- const above = summary.above === "counts" ? "counts" : named === void 0 || named.length === 0 ? void 0 : named;
53032
+ const above = summary.above === "counts" ? "counts" : named2 === void 0 || named2.length === 0 ? void 0 : named2;
52329
53033
  const columnTotals = refs(summary.columnTotals);
52330
53034
  const bandTotals = refs(summary.bandTotals);
52331
53035
  const plan = summary.ageing;
@@ -52333,7 +53037,10 @@ function specSummary(entry, roles) {
52333
53037
  const due = plan === null ? void 0 : entry.fields.get(plan.due.alias);
52334
53038
  const ageing = plan === null || amount === void 0 || due === void 0 ? void 0 : { amount: amount.alias, due: due.alias, buckets: [...plan.buckets] };
52335
53039
  const directions = specDirections(entry, roles);
52336
- if (above === void 0 && columnTotals.length === 0 && bandTotals.length === 0 && ageing === void 0 && directions === void 0) {
53040
+ const moved = summary.trend;
53041
+ const moving = moved === null ? void 0 : entry.fields.get(moved.field.alias);
53042
+ const trend = moved === null || moving === void 0 ? void 0 : { field: moving.alias, direction: moved.direction };
53043
+ if (above === void 0 && columnTotals.length === 0 && bandTotals.length === 0 && ageing === void 0 && directions === void 0 && trend === void 0) {
52337
53044
  return void 0;
52338
53045
  }
52339
53046
  return {
@@ -52341,7 +53048,8 @@ function specSummary(entry, roles) {
52341
53048
  ...columnTotals.length === 0 ? {} : { columnTotals },
52342
53049
  ...bandTotals.length === 0 ? {} : { bandTotals },
52343
53050
  ...ageing === void 0 ? {} : { ageing },
52344
- ...directions === void 0 ? {} : { directions }
53051
+ ...directions === void 0 ? {} : { directions },
53052
+ ...trend === void 0 ? {} : { trend }
52345
53053
  };
52346
53054
  }
52347
53055
  function specScreen(entry, roles, templates, inbound) {
@@ -52356,21 +53064,30 @@ function specScreen(entry, roles, templates, inbound) {
52356
53064
  const bound = ref(other);
52357
53065
  return bound === void 0 ? [] : [bound];
52358
53066
  });
53067
+ const tiers = slot2.tiers.flatMap((under) => {
53068
+ const bound = ref(under);
53069
+ return bound === void 0 ? [] : [bound];
53070
+ });
52359
53071
  return [
52360
53072
  {
52361
53073
  name: slot2.name,
52362
53074
  role: slot2.role,
52363
53075
  field,
52364
53076
  ...also.length === 0 ? {} : { also },
53077
+ ...tiers.length === 0 ? {} : { tiers },
52365
53078
  ...slot2.quick === void 0 ? {} : { quick: slot2.quick },
52366
53079
  ...slot2.order === void 0 ? {} : { order: slot2.order }
52367
53080
  }
52368
53081
  ];
52369
53082
  });
53083
+ const columns = screen.columns.flatMap((field) => {
53084
+ const bound = ref(field);
53085
+ return bound === void 0 ? [] : [bound];
53086
+ });
52370
53087
  const outcomes = {};
52371
- for (const [fieldAlias, named] of Object.entries(screen.outcomes)) {
53088
+ for (const [fieldAlias, named2] of Object.entries(screen.outcomes)) {
52372
53089
  const bound = entry.fields.get(fieldAlias);
52373
- const ids = optionIds(bound, named);
53090
+ const ids = optionIds(bound, named2);
52374
53091
  if (bound !== void 0 && ids.length > 0) outcomes[bound.alias] = ids;
52375
53092
  }
52376
53093
  const lenses = screen.filters.flatMap((lens) => specLens(entry, lens));
@@ -52387,8 +53104,10 @@ function specScreen(entry, roles, templates, inbound) {
52387
53104
  ];
52388
53105
  const obligations = screen.obligations.flatMap((owed) => {
52389
53106
  const due = ref(owed.field);
52390
- const satisfiedBy = ref(owed.satisfiedBy);
52391
- return due === void 0 || satisfiedBy === void 0 ? [] : [{ label: owed.label, due, satisfiedBy }];
53107
+ if (due === void 0) return [];
53108
+ const satisfiedBy = owed.satisfiedBy === void 0 ? void 0 : ref(owed.satisfiedBy);
53109
+ if (satisfiedBy !== void 0) return [{ label: owed.label, due, satisfiedBy }];
53110
+ return owed.satisfiedBy === void 0 && untilOf(entry, roles, owed.field) !== void 0 ? [{ label: owed.label, due }] : [];
52392
53111
  });
52393
53112
  const runs = entry.day === void 0 || entry.runSlot === void 0 ? void 0 : { day: entry.day.alias, slot: entry.runSlot.alias };
52394
53113
  const acts = specActs(entry, templates);
@@ -52399,6 +53118,9 @@ function specScreen(entry, roles, templates, inbound) {
52399
53118
  alias,
52400
53119
  label: screen.screen.label,
52401
53120
  entity,
53121
+ // THE ENTITY'S OWN NOUN, for the column a shape heads with a count — the
53122
+ // entity label names the SET, which is what a count is of.
53123
+ rows: screen.entity.label,
52402
53124
  table: entry.table.id,
52403
53125
  shape: screen.screen.shape,
52404
53126
  record: screen.record,
@@ -52406,6 +53128,7 @@ function specScreen(entry, roles, templates, inbound) {
52406
53128
  writes: screen.screen.writes !== false,
52407
53129
  fields: specFields(entry.fields, screen.entity.fields, screenExtras(entry, roles)),
52408
53130
  slots,
53131
+ ...columns.length === 0 ? {} : { columns },
52409
53132
  ...Object.keys(outcomes).length === 0 ? {} : { outcomes },
52410
53133
  ...tabs === void 0 ? {} : { tabs },
52411
53134
  ...lenses.length === 0 ? {} : { lenses },
@@ -52423,11 +53146,13 @@ function specScreen(entry, roles, templates, inbound) {
52423
53146
  function specPresentation(entry) {
52424
53147
  const stated2 = entry.screen.screen.presentation;
52425
53148
  if (stated2 === void 0 || Object.values(stated2).every((clause) => clause === void 0)) return {};
53149
+ const until = stated2.until === void 0 ? void 0 : entry.fields.get(stated2.until)?.alias;
52426
53150
  return {
52427
53151
  presentation: {
52428
53152
  ...stated2.lead === void 0 ? {} : { lead: stated2.lead },
52429
53153
  ...stated2.density === void 0 ? {} : { density: stated2.density },
52430
- ...stated2.layout === void 0 ? {} : { layout: stated2.layout }
53154
+ ...stated2.layout === void 0 ? {} : { layout: stated2.layout },
53155
+ ...until === void 0 ? {} : { until }
52431
53156
  }
52432
53157
  };
52433
53158
  }
@@ -52481,7 +53206,8 @@ function specRequiredBy(entry, field, requiredBy) {
52481
53206
  if (entries2.length > 0) options[key] = entries2;
52482
53207
  }
52483
53208
  if (Object.keys(options).length === 0) return void 0;
52484
- return requiredBy.reads === "answer" ? { reads: "answer", field: by.alias, options } : { reads: "option", field: by.alias, options };
53209
+ const from = requiredBy.from === "parent" ? { from: "parent" } : {};
53210
+ return requiredBy.reads === "answer" ? { reads: "answer", field: by.alias, ...from, options } : { reads: "option", field: by.alias, ...from, options };
52485
53211
  }
52486
53212
  function specChild(child, roles, archetype, slot2, entry, expected) {
52487
53213
  const reads = childReads(child);
@@ -52508,6 +53234,8 @@ function specChild(child, roles, archetype, slot2, entry, expected) {
52508
53234
  ...itineraryTotal(entry, child) === void 0 ? {} : { total: itineraryTotal(entry, child) }
52509
53235
  };
52510
53236
  const opens = childOpens(entry, child);
53237
+ const editor = childEditor(entry, child);
53238
+ const written = editor === void 0 ? void 0 : planWrites(editor);
52511
53239
  const identity = child.identity === void 0 ? void 0 : child.fields.get(child.identity)?.alias;
52512
53240
  const set2 = child.set === void 0 ? void 0 : child.fields.get(child.set)?.alias;
52513
53241
  const files = child.files === void 0 ? void 0 : child.fields.get(child.files)?.alias;
@@ -52526,7 +53254,16 @@ function specChild(child, roles, archetype, slot2, entry, expected) {
52526
53254
  ...set2 === void 0 ? {} : { set: set2 },
52527
53255
  ...files === void 0 ? {} : { files },
52528
53256
  ...owed === void 0 ? {} : { expected: owed },
52529
- ...opens === void 0 ? {} : { opens }
53257
+ ...opens === void 0 ? {} : { opens },
53258
+ ...written === void 0 ? {} : { write: { alias: writeAlias(written), param: recordParam(written) } }
53259
+ };
53260
+ }
53261
+ function specWorksheet(sheet) {
53262
+ return {
53263
+ ...sheet.quantity === void 0 ? {} : { quantity: sheet.quantity.bound.alias },
53264
+ ...sheet.cost === void 0 ? {} : { cost: sheet.cost.bound.alias },
53265
+ ...sheet.sell === void 0 ? {} : { sell: sheet.sell.bound.alias },
53266
+ ...sheet.group === void 0 ? {} : { group: sheet.group.bound.alias }
52530
53267
  };
52531
53268
  }
52532
53269
  function itineraryTotal(entry, child) {
@@ -52579,7 +53316,7 @@ function specFilesOwed(entry, roles, held) {
52579
53316
  const archetype = recordArchetype(entry.screen.screen.shape, entry.screen.entity, roles);
52580
53317
  return archetype === "evidence" ? { count: 1 } : void 0;
52581
53318
  }
52582
- function specRecord(entry, roles, creates, templates) {
53319
+ function specRecord(entry, roles, templates) {
52583
53320
  const entity = entry.screen.entity.alias;
52584
53321
  const page = entry.screen.record === "page";
52585
53322
  const archetype = entry.screen.record === "expand" ? "line" : recordArchetype(entry.screen.screen.shape, entry.screen.entity, roles);
@@ -52635,11 +53372,14 @@ function specRecord(entry, roles, creates, templates) {
52635
53372
  }
52636
53373
  const child = entry.children.get(childKey(section.child.alias, section.link.alias));
52637
53374
  if (child === void 0 || child.set === void 0) break;
53375
+ const setField = child.fields.get(child.set);
53376
+ const deskRequiredBy = setField === void 0 ? void 0 : specRequiredBy(entry, setField, section.requiredBy);
52638
53377
  sections.push({
52639
53378
  kind: "desk",
52640
- key: `${child.entity.alias}_${child.linkAlias}`,
53379
+ key: sectionKey(child.entity.alias, child.linkAlias),
52641
53380
  heading: child.heading,
52642
53381
  child: specChild(child, roles, archetype, slot2, entry, void 0),
53382
+ ...deskRequiredBy === void 0 ? {} : { requiredBy: deskRequiredBy },
52643
53383
  ...stands
52644
53384
  });
52645
53385
  break;
@@ -52648,9 +53388,9 @@ function specRecord(entry, roles, creates, templates) {
52648
53388
  const child = entry.children.get(childKey(section.child.alias, section.link.alias));
52649
53389
  if (child === void 0) break;
52650
53390
  if (draw === "related") {
52651
- const door = child.entity.alias === entity;
53391
+ const door = entry.mounts && child.entity.alias === entity;
52652
53392
  related.push({
52653
- key: `${child.entity.alias}_${child.linkAlias}`,
53393
+ key: sectionKey(child.entity.alias, child.linkAlias),
52654
53394
  heading: child.heading,
52655
53395
  query: child.alias,
52656
53396
  param: child.param,
@@ -52659,12 +53399,19 @@ function specRecord(entry, roles, creates, templates) {
52659
53399
  break;
52660
53400
  }
52661
53401
  const ledger = slot2 === "ledger" && section.via === "parent";
53402
+ const sheet = planWorksheets(entry).find((one) => one.child === child);
53403
+ const key = sectionKey(child.entity.alias, child.linkAlias);
53404
+ const bound = specChild(child, roles, archetype, slot2, entry, section.expected);
53405
+ if (sheet !== void 0) {
53406
+ sections.push({ kind: "children", key, heading: child.heading, draw: "worksheet", child: { ...bound, worksheet: specWorksheet(sheet) }, ...stands });
53407
+ break;
53408
+ }
52662
53409
  sections.push({
52663
53410
  kind: "children",
52664
- key: `${child.entity.alias}_${child.linkAlias}`,
53411
+ key,
52665
53412
  heading: child.heading,
52666
53413
  draw: ledger ? "ledger" : child.itinerary === void 0 ? "register" : "itinerary",
52667
- child: specChild(child, roles, archetype, slot2, entry, section.expected),
53414
+ child: bound,
52668
53415
  ...stands
52669
53416
  });
52670
53417
  break;
@@ -52703,7 +53450,7 @@ function specRecord(entry, roles, creates, templates) {
52703
53450
  if (planned === void 0 || actual === void 0) break;
52704
53451
  sections.push({
52705
53452
  kind: "timeline",
52706
- key: `${child.entity.alias}_${child.linkAlias}`,
53453
+ key: sectionKey(child.entity.alias, child.linkAlias),
52707
53454
  heading: child.heading,
52708
53455
  child: specChild(child, roles, archetype, slot2, entry, void 0),
52709
53456
  planned,
@@ -52723,15 +53470,11 @@ function specRecord(entry, roles, creates, templates) {
52723
53470
  const official = section.official === void 0 ? void 0 : reads.get(section.official.alias)?.alias;
52724
53471
  const awaiting = section.awaiting === void 0 ? void 0 : reads.get(section.awaiting.alias)?.alias;
52725
53472
  const clock = section.clock === void 0 ? void 0 : entry.fields.get(section.clock.alias)?.alias;
52726
- const marks = threadMarks(entry).some((one) => one.child === child);
52727
53473
  sections.push({
52728
53474
  kind: "thread",
52729
- key: `${child.entity.alias}_${child.linkAlias}`,
53475
+ key: sectionKey(child.entity.alias, child.linkAlias),
52730
53476
  heading: child.heading,
52731
- child: {
52732
- ...specChild(child, roles, archetype, slot2, entry, void 0),
52733
- ...marks ? { write: { alias: writeAlias(child.entity.alias), param: recordParam(child.entity.alias) } } : {}
52734
- },
53477
+ child: specChild(child, roles, archetype, slot2, entry, void 0),
52735
53478
  body,
52736
53479
  author,
52737
53480
  when,
@@ -52784,7 +53527,7 @@ function specRecord(entry, roles, creates, templates) {
52784
53527
  const contacts = specContacts(entry, roles);
52785
53528
  const band = specBand(entry, entry.band);
52786
53529
  const acts = page ? specRecordActs(entry, templates) : void 0;
52787
- const writes = operable && creates !== void 0 && entry.screen.entity.fields.some((field) => stated(entry, field));
53530
+ const writes = planWrites(entry) !== void 0;
52788
53531
  return {
52789
53532
  entity,
52790
53533
  table: entry.table.id,
@@ -52831,6 +53574,7 @@ function specCreate(plan) {
52831
53574
  function bindAppSpec(app, entry, roles, templates = /* @__PURE__ */ new Map()) {
52832
53575
  const creates = planCreates(entry, roles);
52833
53576
  const screen = specScreen(entry, roles, templates, [...registerFilters(entry).keys()]);
53577
+ const records = [entry, ...childSurfaces(entry).values()].map((surface) => [surface.screen.entity.alias, specRecord(surface, roles, templates)]);
52834
53578
  return {
52835
53579
  version: APP_SPEC_VERSION,
52836
53580
  app: {
@@ -52854,7 +53598,7 @@ function bindAppSpec(app, entry, roles, templates = /* @__PURE__ */ new Map()) {
52854
53598
  screen,
52855
53599
  // KEYED BY ENTITY, which is what a child's `opens` and the register's own
52856
53600
  // rows both name — one surface per entity this app opens a record of.
52857
- records: { [entry.screen.entity.alias]: specRecord(entry, roles, creates, templates) },
53601
+ records: Object.fromEntries(records),
52858
53602
  ...creates.length === 0 ? {} : { creates: creates.map(specCreate) }
52859
53603
  };
52860
53604
  }
@@ -52903,9 +53647,13 @@ function checkSurface(at2, alias, table, fields, manifest) {
52903
53647
  return findings;
52904
53648
  }
52905
53649
  var paramsOf = (manifest, alias) => new Set(Object.keys(manifest.queries?.[alias]?.params ?? {}));
52906
- function checkRecord(key, record2, manifest) {
53650
+ function checkRecord(spec, key, record2, manifest) {
52907
53651
  const at2 = `records.${key}`;
52908
53652
  const findings = [];
53653
+ const mounted = key !== spec.screen.entity;
53654
+ if (mounted && record2.door === "page") {
53655
+ findings.push({ at: at2, message: `opens as a page, and the one page this app routes is the "${spec.screen.entity}" record's \u2014 a child record is a drawer on it` });
53656
+ }
52909
53657
  if (record2.acts !== void 0 && record2.door !== "page") {
52910
53658
  findings.push({
52911
53659
  at: at2,
@@ -52950,9 +53698,40 @@ function checkRecord(key, record2, manifest) {
52950
53698
  if (section.kind === "thread" && section.clock !== void 0 && !Object.hasOwn(record2.fields, section.clock)) {
52951
53699
  findings.push({ at: childAt, message: `counts down to "${section.clock}", which this record does not project` });
52952
53700
  }
53701
+ const priced = section.kind === "children" && section.draw === "worksheet" ? section.child.worksheet : {};
53702
+ for (const alias of Object.values(priced)) {
53703
+ if (!Object.hasOwn(child.fields, alias)) {
53704
+ findings.push({ at: childAt, message: `prices "${alias}", which "${child.query}" does not project` });
53705
+ }
53706
+ }
52953
53707
  if (child.write !== void 0 && manifest.workflows?.[child.write.alias] === void 0) {
52954
53708
  findings.push({ at: childAt, message: `changes its rows through "${child.write.alias}", which package.json#lotics.workflows does not declare` });
52955
53709
  }
53710
+ if (child.opens === void 0) continue;
53711
+ const opened = spec.records[child.opens];
53712
+ if (opened === void 0) {
53713
+ findings.push({ at: childAt, message: `opens "${child.opens}", which this spec does not carry` });
53714
+ } else if (section.kind === "thread") {
53715
+ findings.push({ at: childAt, message: `opens "${child.opens}" \u2014 a correspondence's entries open nothing: the composer is its add and the mark its editor` });
53716
+ } else if (record2.door !== "page") {
53717
+ findings.push({ at: childAt, message: `opens "${child.opens}" from a record that opens as a ${record2.door} \u2014 only a page mounts a record of its own` });
53718
+ } else {
53719
+ if (child.write !== void 0 && opened.write?.alias !== child.write.alias) {
53720
+ findings.push({
53721
+ at: childAt,
53722
+ message: `changes its rows through "${child.write.alias}", and the record they open ${opened.write === void 0 ? "has no editor" : `saves through "${opened.write.alias}"`} \u2014 one row, one editor`
53723
+ });
53724
+ }
53725
+ const projection2 = projectionOf(manifest.queries?.[child.query]);
53726
+ if (opened.query !== void 0 || projection2 === void 0) continue;
53727
+ for (const field of Object.values(opened.fields)) {
53728
+ if (Object.hasOwn(child.fields, field.alias) || projection2.outputs.has(field.alias)) continue;
53729
+ findings.push({
53730
+ at: childAt,
53731
+ message: `opens "${child.opens}", which reads "${field.alias}" (${field.label}) \u2014 a column "${child.query}" does not project, and a mounted record is handed this section's row`
53732
+ });
53733
+ }
53734
+ }
52956
53735
  }
52957
53736
  for (const related of record2.related ?? []) {
52958
53737
  if (manifest.queries?.[related.query] === void 0) {
@@ -52978,6 +53757,17 @@ function checkAct(at2, act, fields, manifest, surface) {
52978
53757
  if (declared === void 0) {
52979
53758
  return [{ at: at2, message: `an act runs "${act.workflow}", which package.json#lotics.workflows does not declare` }];
52980
53759
  }
53760
+ if (act.kind === "workflow") {
53761
+ for (const taken of act.inputs) {
53762
+ if (!Object.hasOwn(declared.inputs ?? {}, taken.name)) {
53763
+ findings.push({ at: at2, message: `an act sends "${taken.name}" to "${act.workflow}", which declares no such input` });
53764
+ }
53765
+ if (taken.field !== RECORD_ID_COLUMN && !Object.hasOwn(fields, taken.field)) {
53766
+ findings.push({ at: at2, message: `the "${act.label}" act sends "${taken.field}", which this ${surface} does not project` });
53767
+ }
53768
+ }
53769
+ return findings;
53770
+ }
52981
53771
  if (!Object.hasOwn(declared.inputs ?? {}, act.input)) {
52982
53772
  findings.push({ at: at2, message: `an act sends "${act.input}" to "${act.workflow}", which declares no such input` });
52983
53773
  }
@@ -53003,11 +53793,19 @@ function checkAppSpec(spec, manifest, components) {
53003
53793
  const refused = leadRefusal(screen.shape, bound, lead);
53004
53794
  if (refused !== void 0) findings.push({ at: at2, message: refused });
53005
53795
  }
53796
+ const drawnColumns4 = screen.slots.map((slot2) => {
53797
+ const counts = screen.fields[slot2.field]?.counts;
53798
+ return { role: slot2.role, ...counts === void 0 ? {} : { counts } };
53799
+ });
53006
53800
  const layout = screen.presentation?.layout;
53007
53801
  if (layout !== void 0) {
53008
- const refused = layoutRefusal(bound, layout);
53802
+ const refused = layoutRefusal(drawnColumns4, layout);
53009
53803
  if (refused !== void 0) findings.push({ at: at2, message: refused });
53010
53804
  }
53805
+ const until = screen.presentation?.until;
53806
+ if (until !== void 0 && screen.fields[until] === void 0) {
53807
+ findings.push({ at: at2, message: `ends its bars at "${until}", which "${screen.query}" does not project` });
53808
+ }
53011
53809
  const scope = spec.scope;
53012
53810
  if (scope !== void 0) {
53013
53811
  findings.push(
@@ -53045,6 +53843,19 @@ function checkAppSpec(spec, manifest, components) {
53045
53843
  const refused = quickRefusal(slot2.role, slot2.order);
53046
53844
  if (refused !== void 0) findings.push({ at: at2, message: `the "${slot2.name}" slot ${refused}` });
53047
53845
  }
53846
+ const columns = screen.columns ?? [];
53847
+ if (columns.length > 0 && !shapeDrawsColumns(screen.shape)) {
53848
+ findings.push({
53849
+ at: at2,
53850
+ 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(", ")}`
53851
+ });
53852
+ } else if (columns.length > 0 && layout !== void 0 && layout !== "table") {
53853
+ 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` });
53854
+ }
53855
+ for (const alias of columns) {
53856
+ if (Object.hasOwn(screen.fields, alias)) continue;
53857
+ findings.push({ at: at2, message: `draws the column "${alias}", which this register does not project` });
53858
+ }
53048
53859
  for (const lens of screen.lenses ?? []) {
53049
53860
  if (typeof lens === "string") continue;
53050
53861
  for (const predicate of lens.predicates) {
@@ -53064,15 +53875,63 @@ function checkAppSpec(spec, manifest, components) {
53064
53875
  findings.push({ at: at2, message: `reads "${figure.field}" at the window's end, and this screen states no period to be the end of` });
53065
53876
  }
53066
53877
  }
53878
+ const moved = screen.summary?.trend;
53879
+ if (moved !== void 0) {
53880
+ if (screen.period === void 0) {
53881
+ findings.push({ at: at2, message: `reads "${moved.field}" across the window, and this screen states no period to be the window` });
53882
+ }
53883
+ if (!Object.hasOwn(screen.fields, moved.field)) {
53884
+ findings.push({ at: at2, message: `reads "${moved.field}" across the window, which this register does not project` });
53885
+ }
53886
+ }
53067
53887
  const revealed = expandRefusal(screen.shape, screen.record);
53068
53888
  if (revealed !== void 0) findings.push({ at: at2, message: revealed });
53069
53889
  const arranged = revealedLayoutRefusal(screen.record, screen.presentation?.layout);
53070
53890
  if (arranged !== void 0) findings.push({ at: at2, message: arranged });
53071
53891
  for (const { message: message2 } of ctaRefusals(screen.acts)) findings.push({ at: at2, message: message2 });
53072
53892
  for (const act of specActsOf(screen)) findings.push(...checkAct(at2, act, screen.fields, manifest, "register"));
53073
- for (const [key, record2] of Object.entries(spec.records)) findings.push(...checkRecord(key, record2, manifest));
53893
+ for (const [key, record2] of Object.entries(spec.records)) findings.push(...checkRecord(spec, key, record2, manifest));
53894
+ const register = spec.records[screen.entity];
53895
+ const mounts = /* @__PURE__ */ new Set();
53074
53896
  for (const create of spec.creates ?? []) {
53075
- const at3 = `creates.${create.entity}`;
53897
+ const mount = create.mount;
53898
+ const at3 = mount.kind === "register" ? `creates.${create.entity}` : `creates.${create.entity} (on ${mount.key})`;
53899
+ const where = mount.kind === "register" ? "the register" : `"${mount.key}"`;
53900
+ const slot2 = mount.kind === "register" ? `register ${create.entity}` : `section ${mount.key}`;
53901
+ if (mounts.has(slot2)) findings.push({ at: at3, message: `is mounted on ${where} twice` });
53902
+ mounts.add(slot2);
53903
+ if (mount.kind === "register") {
53904
+ if (create.entity !== screen.entity) {
53905
+ findings.push({ at: at3, message: `is mounted on the register, which opens "${screen.entity}" rows and never these` });
53906
+ }
53907
+ if (create.parent !== void 0) {
53908
+ findings.push({ at: at3, message: `names a parent and is mounted on the register, where there is no record to hand it` });
53909
+ }
53910
+ } else {
53911
+ if (create.parent === void 0) {
53912
+ findings.push({ at: at3, message: `is mounted on ${where} and names no parent \u2014 a row added from a record's section hangs under that record` });
53913
+ }
53914
+ if (register !== void 0) {
53915
+ const section = register.sections.find((one) => one.key === mount.key);
53916
+ const listed = section === void 0 ? void 0 : sectionChild2(section);
53917
+ if (listed === void 0) {
53918
+ findings.push({ at: at3, message: `is mounted on ${where}, which is not a section of the "${screen.entity}" record listing rows` });
53919
+ } else if (listed.entity !== create.entity) {
53920
+ findings.push({ at: at3, message: `is mounted on ${where}, whose rows are "${listed.entity}" and not "${create.entity}"` });
53921
+ } else {
53922
+ const reopened = listed.opens !== void 0 || create.entity === screen.entity || section?.kind === "thread";
53923
+ if (!reopened) {
53924
+ findings.push({
53925
+ at: at3,
53926
+ message: `is mounted on ${where}, which opens none of the rows it lists \u2014 a "${create.entity}" row added there could never be opened again`
53927
+ });
53928
+ }
53929
+ }
53930
+ }
53931
+ }
53932
+ if (create.day !== void 0 && !create.inputs.some((input) => input.name === create.day)) {
53933
+ findings.push({ at: at3, message: `lands the day in "${create.day}", which is not one of its inputs` });
53934
+ }
53076
53935
  if (manifest.queries?.[create.options] === void 0) {
53077
53936
  findings.push({ at: at3, message: `colours its options from "${create.options}", which package.json#lotics.queries does not declare` });
53078
53937
  }
@@ -53145,8 +54004,8 @@ function componentsIn(projectDir) {
53145
54004
  }
53146
54005
  for (const block of text.matchAll(/\bexport\s*\{([^}]*)\}/g)) {
53147
54006
  for (const part of block[1].split(",")) {
53148
- const named = /(?:\bas\s+)?([A-Za-z_$][\w$]*)\s*$/.exec(part.trim());
53149
- if (named !== null) names.add(named[1]);
54007
+ const named2 = /(?:\bas\s+)?([A-Za-z_$][\w$]*)\s*$/.exec(part.trim());
54008
+ if (named2 !== null) names.add(named2[1]);
53150
54009
  }
53151
54010
  }
53152
54011
  }
@@ -53205,13 +54064,14 @@ function planWorkflows(entry, roles, templates = /* @__PURE__ */ new Map()) {
53205
54064
  for (const field of fields) held.add(field);
53206
54065
  written.set(table, held);
53207
54066
  };
53208
- const operable = planWrites(entry);
53209
- if (operable !== void 0) {
54067
+ for (const surface of [entry, ...childEditors(entry).values()]) {
54068
+ const operable = planWrites(surface);
54069
+ if (operable === void 0) continue;
53210
54070
  declared[writeAlias(operable)] = {
53211
- inputs: writeInputs(entry),
53212
- description: `Change one row of ${entry.screen.entity.label} \u2014 the record surface's own editor, one field per call.`
54071
+ inputs: writeInputs(surface),
54072
+ description: `Change one row of ${surface.screen.entity.label} \u2014 the record surface's own editor, one field per call.`
53213
54073
  };
53214
- record2(entry.tableAlias, writtenColumns(entry));
54074
+ record2(surface.tableAlias, writtenColumns(surface));
53215
54075
  }
53216
54076
  for (const plan of planCreates(entry, roles)) {
53217
54077
  declared[createAlias(plan.entity.alias)] = {
@@ -53220,13 +54080,6 @@ function planWorkflows(entry, roles, templates = /* @__PURE__ */ new Map()) {
53220
54080
  };
53221
54081
  for (const landing of createWrites(plan)) record2(landing.table, landing.fields);
53222
54082
  }
53223
- for (const { child, verdict } of threadMarks(entry)) {
53224
- declared[writeAlias(child.entity.alias)] = {
53225
- inputs: markInputs(child, verdict),
53226
- description: `Mark one row of ${child.entity.label} \u2014 the reply a correspondence is answered by.`
53227
- };
53228
- record2(child.tableAlias, [verdict.alias]);
53229
- }
53230
54083
  const taken = entry.screen.acts.import;
53231
54084
  if (taken !== null) {
53232
54085
  declared[importAlias(taken.entity.alias)] = {
@@ -53401,26 +54254,6 @@ validate({
53401
54254
  ${scopeGuard(entry, param)}${blocks.join("\n")}
53402
54255
  `;
53403
54256
  }
53404
- function markSource(entry, child, verdict) {
53405
- const param = recordParam(child.entity.alias);
53406
- const guard = scopeGuard({ ...child.scope === void 0 ? {} : { scope: child.scope }, table: child.table, words: entry.words }, param);
53407
- return `// ${writeAlias(child.entity.alias)} \u2014 mark one row of ${child.entity.label}.
53408
- // ONE COLUMN, because the section that presses this draws one control: which
53409
- // entry ANSWERS. A call naming nothing to change reports success having done
53410
- // nothing, so it is refused instead.
53411
- const i = trigger.app_workflow.inputs;
53412
- validate({
53413
- checks: [{ fail_when: !includes(keys(i), ${str(verdict.alias)}), message: ${str(entry.words.refusals.noField)} }],
53414
- });
53415
- ${guard}await update_records({ table_id: ${str(child.table.id)}, record_ids: [i.${param}], set: { ${str(verdict.id)}: i.${verdict.alias} } });
53416
- `;
53417
- }
53418
- function markInputs(child, verdict) {
53419
- return {
53420
- [recordParam(child.entity.alias)]: { type: "record_link", table_id: child.table.id, required: true },
53421
- [verdict.alias]: { type: verdict.type, required: false }
53422
- };
53423
- }
53424
54257
  function createValue(input) {
53425
54258
  return input.link === void 0 ? `i.${input.name}` : `isNull(i.${input.name}) ? null : [i.${input.name}]`;
53426
54259
  }
@@ -53689,7 +54522,10 @@ export const components: RuntimeComponents = {};
53689
54522
  `;
53690
54523
  function buildPlanFiles(app, entry, roles, templates = /* @__PURE__ */ new Map()) {
53691
54524
  const creates = planCreates(entry, roles);
53692
- const operable = planWrites(entry);
54525
+ const editors = [entry, ...childEditors(entry).values()].flatMap((surface) => {
54526
+ const operable = planWrites(surface);
54527
+ return operable === void 0 ? [] : [{ path: `src/workflows/${writeAlias(operable)}.ts`, content: writeSource(surface) }];
54528
+ });
53693
54529
  const spec = bindAppSpec(app, entry, roles, templates);
53694
54530
  return [
53695
54531
  { path: APP_SPEC_PATH, content: `${JSON.stringify(spec, null, 2)}
@@ -53703,15 +54539,8 @@ function buildPlanFiles(app, entry, roles, templates = /* @__PURE__ */ new Map()
53703
54539
  content: main(spec.screen.acts?.export?.kind === "columns" || spec.screen.acts?.import !== void 0)
53704
54540
  },
53705
54541
  { path: COMPONENTS_INDEX, content: COMPONENTS },
53706
- ...operable === void 0 ? [] : [{ path: `src/workflows/${writeAlias(operable)}.ts`, content: writeSource(entry) }],
54542
+ ...editors,
53707
54543
  ...creates.map((plan) => ({ path: `src/workflows/${createAlias(plan.entity.alias)}.ts`, content: createSource(plan) })),
53708
- // THE ONE COLUMN A CORRESPONDENCE WRITES BACK on a row it lists — which
53709
- // reply answers. Its own body rather than the record's editor: that one is
53710
- // over the row the page is ABOUT.
53711
- ...threadMarks(entry).map(({ child, verdict }) => ({
53712
- path: `src/workflows/${writeAlias(child.entity.alias)}.ts`,
53713
- content: markSource(entry, child, verdict)
53714
- })),
53715
54544
  ...entry.screen.acts.import === null ? [] : [{ path: `src/workflows/${importAlias(entry.screen.acts.import.entity.alias)}.ts`, content: importSource(entry) }],
53716
54545
  ...templateActs(entry).flatMap((act) => {
53717
54546
  const id = templates.get(act.template.alias);
@@ -54288,7 +55117,7 @@ Captured ${totalRows} row${totalRows === 1 ? "" : "s"} across ${result.captured.
54288
55117
  }
54289
55118
 
54290
55119
  // src/model_reference.md
54291
- 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**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 takes a composer and the\napp declares the create behind it, which is the one row an app opens that is not\nits own register\'s.\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**, handing the panel that day, so\na line made from Tuesday opens with Tuesday answered; the heading keeps the verb\ntoo, for the first line and for a day nothing is planned on yet. **THE KEY LEAVES THE COLUMNS where the head states the whole of\nit** \u2014 a day head does, a year head states four digits of the date and the day is\nwhat the column is still there to carry. **EVERY LINE OPENS**: a page this app\nroutes for those rows, else that row\'s own record DRAWER, mounted here and\nstepped \u25C0 \u25B6 over the run.\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';
55120
+ 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. `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 |\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 chip and opens the field\'s own stages with the ones that END the flow last;\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 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**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 \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, 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),\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 \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';
54292
55121
 
54293
55122
  // src/scaffold_commands.ts
54294
55123
  function printModelReference() {
@@ -54481,11 +55310,14 @@ function describeRecord(model, resolved) {
54481
55310
  const required2 = section.source === "own" ? section.field : section.setField;
54482
55311
  const name = section.source === "own" ? section.field.label : section.child.label;
54483
55312
  if (required2.type !== "select") return `documents: ${name}`;
54484
- const set2 = section.requiredBy === void 0 ? `${required2.options.length} required` : `${count(required2.options.length, "option")}, ${section.requiredBy.field.label} decides which`;
55313
+ 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`;
54485
55314
  return section.source === "own" ? `documents: ${name} (${set2})` : `documents: ${name} (${required2.label}: ${set2})`;
54486
55315
  }
54487
55316
  case "children": {
54488
55317
  if (draw === "related") return `related: ${section.child.label} (count via ${section.link.label})`;
55318
+ if (resolved.screen.sections?.[section.child.alias] !== void 0) {
55319
+ return `sheet: ${section.child.label} (${labels(section.child, section.fields)})`;
55320
+ }
54489
55321
  const run = recordItinerary(archetype, recipeSlot, section.child, model.field_roles);
54490
55322
  if (run !== void 0) {
54491
55323
  const at2 = run.slot === void 0 ? "" : ` at ${fieldName(model, section.child, run.slot)}`;
@@ -54547,9 +55379,13 @@ function describePlan(model) {
54547
55379
  ` ${resolved.slots.map((entry) => {
54548
55380
  if (entry.field !== null) {
54549
55381
  const operated = entry.quick !== true ? "" : entry.order === "sequence" ? " [quick, next step]" : " [quick]";
54550
- return `${entry.name} ${[entry.field, ...entry.also].map((field) => fieldName(model, resolved.entity, field)).join(", else ")}${operated}`;
55382
+ const under = entry.tiers.length === 0 ? "" : `, then ${entry.tiers.map((field) => fieldName(model, resolved.entity, field)).join(", then ")}`;
55383
+ return `${entry.name} ${[entry.field, ...entry.also].map((field) => fieldName(model, resolved.entity, field)).join(", else ")}${under}${operated}`;
54551
55384
  }
54552
- return entry.ambiguous.length === 0 ? `${entry.name} (none)` : `${entry.name} (none \u2014 ${entry.ambiguous.join(", ")}; name one)`;
55385
+ if (entry.ambiguous.length > 0) return `${entry.name} (none \u2014 ${entry.ambiguous.join(", ")}; name one)`;
55386
+ const takes = slotTakes(resolved.screen.shape, entry.name);
55387
+ const roles = (takes.length === 0 ? [entry.role] : takes).map((role) => `"${role}"`);
55388
+ return `${entry.name} (none \u2014 no field declares ${roles.join(" or ")})`;
54553
55389
  }).join(" \xB7 ")}`
54554
55390
  );
54555
55391
  const carried = describeCarried(model, resolved);
@@ -54563,11 +55399,20 @@ function describeCarried(model, resolved) {
54563
55399
  const name = (field) => fieldName(model, resolved.entity, field);
54564
55400
  const act = (entry) => {
54565
55401
  const where = entry.place === "cta" ? " [on the row]" : "";
54566
- 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}`;
55402
+ if (entry.kind === "agent") return `"${entry.label}" \u2192 ${entry.agent} fills ${entry.fills.map(name).join(", ")}${where}`;
55403
+ if (entry.kind === "workflow") {
55404
+ const taken = entry.inputs.map((input) => `${input.name}: ${input.field === null ? "the record" : name(input.field)}`).join(", ");
55405
+ return `"${entry.label}" \u2192 ${entry.workflow}(${taken}) [you write the body]${where}`;
55406
+ }
55407
+ return `"${entry.label}" \u2192 ${entry.template.label} (${entry.template.type})${where}`;
54567
55408
  };
54568
55409
  const runs = registerRuns(resolved, model.field_roles);
54569
55410
  const drawn = resolved.screen.presentation;
54570
55411
  const parts = [
55412
+ // THE CONTEXT, AFTER THE ANSWER — printed beside the slot line rather than
55413
+ // inside it, because the two are read differently: a slot is the shape's
55414
+ // question and a column is the author's own addition to the row.
55415
+ ...resolved.columns.length === 0 ? [] : [`columns: ${resolved.columns.map(name).join(" \xB7 ")} (after the slots)`],
54571
55416
  ...runs === void 0 ? [] : [`runs: by ${name(runs.when)} at ${name(runs.slot)}`],
54572
55417
  ...drawn === void 0 || Object.values(drawn).every((clause) => clause === void 0) ? [] : [
54573
55418
  `presentation: ${[
@@ -54577,13 +55422,19 @@ function describeCarried(model, resolved) {
54577
55422
  // decided against what the rows carry: an author reading back
54578
55423
  // "board" learns nothing about the three other arrangements the
54579
55424
  // same screen could have taken.
54580
- ...drawn.layout === void 0 ? [] : [`as a ${drawn.layout} (of ${layoutsFor(resolved.slots.flatMap((slot2) => slot2.field === null ? [] : [slot2.role])).join(", ")})`]
55425
+ ...drawn.layout === void 0 ? [] : [`as a ${drawn.layout} (of ${layoutsFor(drawnColumns(resolved, model.field_roles)).join(", ")})`],
55426
+ // WHERE THE BARS END, in the column's own words: an author who named
55427
+ // a finish date reads back which date the chart is drawn to.
55428
+ ...drawn.until === void 0 ? [] : [`ending at ${drawn.until}`]
54581
55429
  ].join(", ")}`
54582
55430
  ],
54583
55431
  ...period === null ? [] : [`period: ${name(period)}`],
54584
55432
  // WHAT THE ROWS ARE, where the shape fans them out of the record: one line
54585
- // per thing owed, and the stamp that takes it off the desk.
54586
- ...resolved.obligations.length === 0 ? [] : [`obligations: ${resolved.obligations.map((owed) => `${owed.label} (${owed.field.label} \u2192 ${owed.satisfiedBy.label})`).join(" \xB7 ")}`],
55433
+ // per thing owed, and what takes it off the desk — the stamp, or the stage
55434
+ // the record stops owing it at.
55435
+ ...resolved.obligations.length === 0 ? [] : [
55436
+ `obligations: ${resolved.obligations.map((owed) => `${owed.label} (${owed.field.label} \u2192 ${owed.satisfiedBy?.label ?? "settled"})`).join(" \xB7 ")}`
55437
+ ],
54587
55438
  // A DERIVED LENS IS PRINTED BY ITS SETS, never by its label alone: what an
54588
55439
  // author has to check is that the predicates say what they meant, and the
54589
55440
  // chip's own name is the one thing they already wrote down.
@@ -54603,6 +55454,10 @@ function describeCarried(model, resolved) {
54603
55454
  // read as whatever the reader assumes, and the cuts are what a statement of
54604
55455
  // account is compared against.
54605
55456
  ...summary.ageing === null ? [] : [`ageing: ${name(summary.ageing.amount)} by ${name(summary.ageing.due)} (${summary.ageing.buckets.join(" \xB7 ")} days)`],
55457
+ // WHICH WAY IS GOOD IS THE HALF NOBODY CAN READ OFF THE FIGURE. A book wants
55458
+ // its takings rising and a desk wants its backlog falling, and the same
55459
+ // arrow is a win on one and an alarm on the other.
55460
+ ...summary.trend === null ? [] : [`trend: ${name(summary.trend.field)} across the period (${summary.trend.direction} is good)`],
54606
55461
  // WHERE the sum is drawn is the reading a person checks. A register states
54607
55462
  // ONE aggregate, over the rows the reader can see, in the band it already
54608
55463
  // reads the count in — except a SHARE, which does not add up: what a column
@@ -54624,6 +55479,11 @@ function describeCarried(model, resolved) {
54624
55479
  // A VERB ON A SECTION STANDS WHERE ITS EFFECT LANDS, and which SECTION each
54625
55480
  // alias resolved to is the half the author cannot see in their own file.
54626
55481
  ...sectionActLines(model, resolved, act),
55482
+ // A SHEET IS THE ONE SECTION THAT WRITES, so the plan says which register it
55483
+ // prices and which of the child's figures is the base and which the answer.
55484
+ ...Object.entries(resolved.screen.sections ?? {}).map(
55485
+ ([alias, drawn2]) => `sheet on ${alias}: ${drawn2.cost ?? "(no cost)"} \u2192 ${drawn2.sell ?? "(no sell)"}`
55486
+ ),
54627
55487
  ...resolved.screen.writes === false ? ["operable: no"] : []
54628
55488
  ];
54629
55489
  return parts.join(" \xB7 ");
@@ -54660,42 +55520,52 @@ function foldsAbove(model, resolved, field) {
54660
55520
  function aboveLine(model, resolved, name) {
54661
55521
  const { above, columnTotals, bandTotals } = resolved.summary;
54662
55522
  const folded = [...columnTotals, ...bandTotals].filter((field) => foldsAbove(model, resolved, field)).map((field) => ({ field }));
54663
- const named = [...above === null || above === "counts" ? [] : above, ...folded];
54664
- const once = named.filter((figure, at2) => named.findIndex((other) => other.field.alias === figure.field.alias) === at2);
55523
+ const named2 = [...above === null || above === "counts" ? [] : above, ...folded];
55524
+ const once = named2.filter((figure, at2) => named2.findIndex((other) => other.field.alias === figure.field.alias) === at2);
54665
55525
  const figures = [
54666
55526
  ...above === "counts" ? ["counts"] : [],
54667
55527
  ...once.map((figure) => `${name(figure.field)}${figure.at === void 0 ? "" : " at the window's end"}`)
54668
55528
  ];
54669
55529
  if (above === null && figures.length === 0) return [];
54670
- return [`above: ${figures.length === 0 ? "rows" : figures.join(" \xB7 ")}`];
55530
+ const stated2 = resolved.screen.summary?.above !== void 0 || folded.length > 0;
55531
+ return [`above: ${figures.length === 0 ? "rows" : figures.join(" \xB7 ")}${stated2 ? "" : " (default)"}`];
54671
55532
  }
54672
55533
  function totalsUnder(model, resolved) {
54673
- const named = [...resolved.summary.columnTotals, ...resolved.summary.bandTotals].filter(
55534
+ const named2 = [...resolved.summary.columnTotals, ...resolved.summary.bandTotals].filter(
54674
55535
  (field) => !foldsAbove(model, resolved, field)
54675
55536
  );
54676
- if (named.length === 0) return [];
54677
- return [`totals: ${named.map((field) => fieldName(model, resolved.entity, field)).join(" \xB7 ")} (under the register)`];
55537
+ if (named2.length === 0) return [];
55538
+ return [`totals: ${named2.map((field) => fieldName(model, resolved.entity, field)).join(" \xB7 ")} (under the register)`];
54678
55539
  }
54679
55540
  function describeCoverage(model) {
54680
55541
  return roleCoverage(model.contract, model.field_roles, model.rows).map((note2) => ` ${note2.path}: ${note2.message}`);
54681
55542
  }
54682
55543
  function describeWriters(model) {
54683
55544
  const appsByEntity = /* @__PURE__ */ new Map();
54684
- for (const resolved of resolvePlan(model)) {
54685
- if (resolved.screen.writes === false) continue;
54686
- const entry = appsByEntity.get(resolved.entity.alias) ?? {
54687
- label: resolved.entity.label,
54688
- ...resolved.entity.singular === void 0 ? {} : { singular: resolved.entity.singular },
54689
- apps: []
54690
- };
54691
- if (!entry.apps.includes(resolved.app.name)) entry.apps.push(resolved.app.name);
54692
- appsByEntity.set(resolved.entity.alias, entry);
55545
+ const resolved = resolvePlan(model);
55546
+ const named2 = (entity) => appsByEntity.get(entity.alias) ?? {
55547
+ label: entity.label,
55548
+ ...entity.singular === void 0 ? {} : { singular: entity.singular },
55549
+ apps: []
55550
+ };
55551
+ for (const screen of resolved) {
55552
+ if (screen.screen.writes === false) continue;
55553
+ const entry = named2(screen.entity);
55554
+ if (!entry.apps.includes(screen.app.name)) entry.apps.push(screen.app.name);
55555
+ appsByEntity.set(screen.entity.alias, entry);
55556
+ }
55557
+ const registered = new Set(resolved.map((screen) => screen.entity.alias));
55558
+ for (const screen of resolved) {
55559
+ for (const alias of unregisteredChildren(screen, model.contract.entities, model.field_roles, registered)) {
55560
+ const entity = model.contract.entities.find((one) => one.alias === alias);
55561
+ if (entity !== void 0) appsByEntity.set(alias, named2(entity));
55562
+ }
54693
55563
  }
54694
55564
  if (appsByEntity.size === 0) return [];
54695
55565
  const lines = [];
54696
55566
  for (const [alias, entry] of appsByEntity) {
54697
55567
  lines.push(
54698
- ` ${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` : "")
55568
+ ` ${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` : "")
54699
55569
  );
54700
55570
  for (const clause of describeWriteRules(model, alias)) lines.push(` ${clause}`);
54701
55571
  }
@@ -54725,9 +55595,9 @@ function writersJson(model) {
54725
55595
  const writers = {};
54726
55596
  for (const resolved of resolvePlan(model)) {
54727
55597
  if (resolved.screen.writes === false) continue;
54728
- const named = writers[resolved.entity.alias] ?? [];
54729
- if (!named.includes(resolved.app.alias)) writers[resolved.entity.alias] = [...named, resolved.app.alias];
54730
- else writers[resolved.entity.alias] = named;
55598
+ const named2 = writers[resolved.entity.alias] ?? [];
55599
+ if (!named2.includes(resolved.app.alias)) writers[resolved.entity.alias] = [...named2, resolved.app.alias];
55600
+ else writers[resolved.entity.alias] = named2;
54731
55601
  }
54732
55602
  return writers;
54733
55603
  }
@@ -54758,7 +55628,7 @@ function planJson(model) {
54758
55628
  obligations: resolved.obligations.map((owed) => ({
54759
55629
  field: owed.field.alias,
54760
55630
  label: owed.label,
54761
- satisfied_by: owed.satisfiedBy.alias
55631
+ ...owed.satisfiedBy === void 0 ? {} : { satisfied_by: owed.satisfiedBy.alias }
54762
55632
  }))
54763
55633
  },
54764
55634
  ...resolved.acts.row.length === 0 && resolved.acts.selection.length === 0 ? {} : {
@@ -54772,7 +55642,14 @@ function planJson(model) {
54772
55642
  }
54773
55643
  function actJson(act) {
54774
55644
  const place = act.place === void 0 ? {} : { place: act.place };
54775
- 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 };
55645
+ if (act.kind === "agent") {
55646
+ return { kind: act.kind, label: act.label, agent: act.agent, fills: act.fills.map((field) => field.alias), ...place };
55647
+ }
55648
+ if (act.kind === "workflow") {
55649
+ const inputs = Object.fromEntries(act.inputs.map((input) => [input.name, input.field === null ? RECORD_INPUT : input.field.alias]));
55650
+ return { kind: act.kind, label: act.label, workflow: act.workflow, inputs, ...place };
55651
+ }
55652
+ return { label: act.label, template: act.template.alias, ...place };
54776
55653
  }
54777
55654
  var presetSourceSchema = zod_default.object({
54778
55655
  entities: zod_default.array(contractEntitySchema),
@@ -54824,14 +55701,14 @@ async function resolveOverlay(file2, raw, overlay) {
54824
55701
  note(`${file2} starts from ${read2.name} (${overlay.from}).`);
54825
55702
  return { kind: "ok", model, notes };
54826
55703
  }
54827
- async function knownSlugs(named) {
55704
+ async function knownSlugs(named2) {
54828
55705
  let slugs;
54829
55706
  try {
54830
55707
  slugs = (await fetchPresetIndex()).map((row) => row.slug);
54831
55708
  } catch {
54832
55709
  return "";
54833
55710
  }
54834
- return slugs.includes(named) ? "" : `
55711
+ return slugs.includes(named2) ? "" : `
54835
55712
  The presets there are: ${slugs.join(", ")}.`;
54836
55713
  }
54837
55714
  async function checkModelFile(file2) {
@@ -55953,11 +56830,11 @@ function rpcFailureBody(err) {
55953
56830
  if (!(err instanceof LoticsRequestError)) return { message: message2 };
55954
56831
  const code = err.body.code;
55955
56832
  const raw = err.body.field_errors;
55956
- const named = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? Object.entries(raw).filter((e) => typeof e[1] === "string") : [];
56833
+ const named2 = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? Object.entries(raw).filter((e) => typeof e[1] === "string") : [];
55957
56834
  return {
55958
56835
  message: message2,
55959
56836
  ...typeof code === "string" ? { code } : {},
55960
- ...named.length > 0 ? { field_errors: Object.fromEntries(named) } : {}
56837
+ ...named2.length > 0 ? { field_errors: Object.fromEntries(named2) } : {}
55961
56838
  };
55962
56839
  }
55963
56840
  function servesWrapperPage(method, pathname) {
@@ -57750,15 +58627,15 @@ function warnLocalKit(projectDir) {
57750
58627
  function assertKitShippable(projectDir, options) {
57751
58628
  const entries2 = Object.entries(readLocalKit(projectDir));
57752
58629
  if (entries2.length === 0) return;
57753
- const named = entries2.map(([name, record2]) => describeRecord2(name, record2)).join(", ");
58630
+ const named2 = entries2.map(([name, record2]) => describeRecord2(name, record2)).join(", ");
57754
58631
  if (options.allowLocalKit) {
57755
58632
  warn(
57756
- `\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.`
58633
+ `\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.`
57757
58634
  );
57758
58635
  return;
57759
58636
  }
57760
58637
  fail(
57761
- `This app is installed against a local kit build (${named}), and ${KIT_DIR}/ is not in the source a deploy uploads.
58638
+ `This app is installed against a local kit build (${named2}), and ${KIT_DIR}/ is not in the source a deploy uploads.
57762
58639
  Publish the kit and run: lotics app kit <path-to-package> --published
57763
58640
  Or ship it anyway: lotics app deploy --allow-local-kit`
57764
58641
  );
@@ -59510,8 +60387,8 @@ Ready. Next steps:`);
59510
60387
  console.error(` lotics app check # every pre-flight a deploy runs`);
59511
60388
  console.error(` lotics app deploy`);
59512
60389
  } else {
59513
- console.error(` # edit src/screens/*.tsx \u2014 one per plan screen \u2014 then:`);
59514
- console.error(` lotics app check --screens # every pre-flight a deploy runs, and the probes over each screen`);
60390
+ console.error(` # edit app.json \u2014 the screens, their records and their acts \u2014 then:`);
60391
+ console.error(` lotics app check # every pre-flight a deploy runs, the spec read against the project`);
59515
60392
  console.error(` lotics app deploy`);
59516
60393
  }
59517
60394
  }
@@ -60265,6 +61142,7 @@ async function appDeploy(client, args) {
60265
61142
  });
60266
61143
  note(`Deployed v${result.version_number} (${result.version_id})`);
60267
61144
  note(`Bundle size: ${(result.bundle_size_bytes / 1024).toFixed(1)} KB`);
61145
+ if (result.origin !== void 0) note(`Address: ${result.origin}`);
60268
61146
  try {
60269
61147
  const drifted = staleWorkflowGlobals(projectDir, meta3.workflows ?? {});
60270
61148
  for (const { alias, declaration } of drifted) {
@@ -61207,11 +62085,11 @@ async function warnIfQueriesIgnoreRowRules(client, queries) {
61207
62085
  const lines = [];
61208
62086
  const unguarded = /* @__PURE__ */ new Set();
61209
62087
  for (const [alias, scans] of byQuery) {
61210
- const named = /* @__PURE__ */ new Set();
62088
+ const named2 = /* @__PURE__ */ new Set();
61211
62089
  for (const scan of scans) {
61212
62090
  const name = scoped.get(scan.table_id);
61213
- if (scan.guarded || name === void 0 || named.has(name)) continue;
61214
- named.add(name);
62091
+ if (scan.guarded || name === void 0 || named2.has(name)) continue;
62092
+ named2.add(name);
61215
62093
  unguarded.add(alias);
61216
62094
  lines.push(
61217
62095
  ` \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.`
@@ -62277,7 +63155,16 @@ function candidates(spec) {
62277
63155
  kind: "refused",
62278
63156
  message: `"${act.label}" runs an agent, and the panel a run is reviewed in is the kit's \u2014 started once,
62279
63157
  reviewed before it is applied, cancelled by closing. Only a paper act opens a panel of the app's own.`
62280
- } : act.component !== void 0 ? already(`"${act.label}"`, act.component) : { kind: "target", target: { kind: "act", at: at2, act } }
63158
+ } : (
63159
+ // A HAND-OFF'S SURFACE IS THE BODY THE AUTHOR WROTE. What a panel
63160
+ // would ask for is an input, and an input is the plan's own clause
63161
+ // — so the answer to "ask first" is the workflow, not a component.
63162
+ act.kind === "workflow" ? {
63163
+ kind: "refused",
63164
+ message: `"${act.label}" hands the row to the "${act.workflow}" workflow, whose body is already yours \u2014 what a
63165
+ panel would ask for is an input, which the act's own \`inputs\` names. Only a paper act opens a panel.`
63166
+ } : act.component !== void 0 ? already(`"${act.label}"`, act.component) : { kind: "target", target: { kind: "act", at: at2, act } }
63167
+ )
62281
63168
  )
62282
63169
  };
62283
63170
  })
@@ -62640,13 +63527,13 @@ var countOf = (outcomes, kind) => outcomes.filter((outcome) => outcome.kind ===
62640
63527
  var list2 = (aliases) => aliases.length === 0 ? "\u2014" : aliases.join(", ");
62641
63528
  function renderSummary(summary, dryRun) {
62642
63529
  const { outcomes, deploy } = summary;
62643
- const named = (kind) => outcomes.filter((outcome) => outcome.kind === kind).map((outcome) => ` ${outcome.path}${outcome.kind === "kept" ? ` \u2014 ${outcome.why}` : ""}`);
63530
+ const named2 = (kind) => outcomes.filter((outcome) => outcome.kind === kind).map((outcome) => ` ${outcome.path}${outcome.kind === "kept" ? ` \u2014 ${outcome.why}` : ""}`);
62644
63531
  const nothingToDeploy = deploy.added.length === 0 && deploy.changed.length === 0 && deploy.removed.length === 0;
62645
63532
  const lines = [
62646
63533
  dryRun ? "lotics app regenerate --dry-run \u2014 nothing was written." : "lotics app regenerate",
62647
63534
  ` Files: ${countOf(outcomes, "written")} written, ${countOf(outcomes, "kept")} kept, ${countOf(outcomes, "deleted")} deleted`,
62648
63535
  ...["kept", "deleted"].flatMap((kind) => {
62649
- const rows = named(kind);
63536
+ const rows = named2(kind);
62650
63537
  return rows.length === 0 ? [] : [` ${kind}:`, ...rows];
62651
63538
  }),
62652
63539
  // A declaration losing an entry is not something to do quietly, and the two
@@ -63047,38 +63934,38 @@ function renameInModel(raw, args) {
63047
63934
  function isRecord(value) {
63048
63935
  return typeof value === "object" && value !== null && !Array.isArray(value);
63049
63936
  }
63050
- async function resolveTable(client, named) {
63937
+ async function resolveTable(client, named2) {
63051
63938
  const res = await client.execute("query_tables", {}, { format: "json" });
63052
63939
  if (res.error) throw new Error(`Could not list this workspace's tables: ${res.error}`);
63053
63940
  const rows = Array.isArray(res.result) ? res.result : [];
63054
63941
  const tables = rows.flatMap(
63055
63942
  (row) => typeof row.table_id === "string" && typeof row.table_name === "string" ? [{ id: row.table_id, name: row.table_name }] : []
63056
63943
  );
63057
- const byId2 = tables.find((table) => table.id === named);
63944
+ const byId2 = tables.find((table) => table.id === named2);
63058
63945
  if (byId2 !== void 0) return byId2;
63059
- const byName = tables.filter((table) => table.name === named);
63946
+ const byName = tables.filter((table) => table.name === named2);
63060
63947
  if (byName.length === 1) return byName[0];
63061
63948
  if (byName.length > 1) {
63062
63949
  throw new Error(
63063
- `"${named}" names ${byName.length} tables here. Name the one you mean by id: ${byName.map((t) => t.id).join(", ")}.`
63950
+ `"${named2}" names ${byName.length} tables here. Name the one you mean by id: ${byName.map((t) => t.id).join(", ")}.`
63064
63951
  );
63065
63952
  }
63066
63953
  throw new Error(
63067
- `No table here is called "${named}". This workspace has: ${tables.map((t) => t.name).join(", ")}.`
63954
+ `No table here is called "${named2}". This workspace has: ${tables.map((t) => t.name).join(", ")}.`
63068
63955
  );
63069
63956
  }
63070
- function resolveField(table, named) {
63071
- const byId2 = table.fields.find((field) => field.id === named);
63957
+ function resolveField(table, named2) {
63958
+ const byId2 = table.fields.find((field) => field.id === named2);
63072
63959
  if (byId2 !== void 0) return byId2;
63073
- const byName = table.fields.filter((field) => field.name === named);
63960
+ const byName = table.fields.filter((field) => field.name === named2);
63074
63961
  if (byName.length === 1) return byName[0];
63075
63962
  if (byName.length > 1) {
63076
63963
  throw new Error(
63077
- `"${named}" names ${byName.length} fields on ${table.name}. Name the one you mean by key: ${byName.map((f) => f.id).join(", ")}.`
63964
+ `"${named2}" names ${byName.length} fields on ${table.name}. Name the one you mean by key: ${byName.map((f) => f.id).join(", ")}.`
63078
63965
  );
63079
63966
  }
63080
63967
  throw new Error(
63081
- `${table.name} has no field called "${named}". Its fields are: ${table.fields.map((f) => f.name).join(", ")}.`
63968
+ `${table.name} has no field called "${named2}". Its fields are: ${table.fields.map((f) => f.name).join(", ")}.`
63082
63969
  );
63083
63970
  }
63084
63971
  async function readTableSchema(client, tableId) {