@lotics/cli 0.209.0 → 0.211.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 +1105 -115
- package/dist/src/client.d.ts +0 -15
- package/dist/src/client.js +0 -12
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -15481,18 +15481,6 @@ var LoticsClient = class {
|
|
|
15481
15481
|
async appFieldOptions(app_id, alias) {
|
|
15482
15482
|
return this.request("POST", `/v1/apps/${encodeURIComponent(app_id)}/field-options`, { alias });
|
|
15483
15483
|
}
|
|
15484
|
-
/**
|
|
15485
|
-
* When a record entered each option of one of its select fields — what a
|
|
15486
|
-
* lifecycle drawn as steps dates each step by. Mirrors
|
|
15487
|
-
* GET /v1/apps/{app_id}/records/{record_id}/field-history.
|
|
15488
|
-
*/
|
|
15489
|
-
async appFieldHistory(app_id, args) {
|
|
15490
|
-
const qs = new URLSearchParams({ table_id: args.table_id, field_id: args.field_id });
|
|
15491
|
-
return this.request(
|
|
15492
|
-
"GET",
|
|
15493
|
-
`/v1/apps/${encodeURIComponent(app_id)}/records/${encodeURIComponent(args.record_id)}/field-history?${qs.toString()}`
|
|
15494
|
-
);
|
|
15495
|
-
}
|
|
15496
15484
|
/**
|
|
15497
15485
|
* Execute a workflow by alias declared in package.json#lotics.workflows.
|
|
15498
15486
|
* Mirrors POST /v1/apps/{app_id}/workflows/{alias}/execute.
|
|
@@ -38193,6 +38181,219 @@ var bindingDriftSchema = zod_default.object({
|
|
|
38193
38181
|
id: zod_default.string().describe("The stale concrete id the alias was bound to")
|
|
38194
38182
|
});
|
|
38195
38183
|
|
|
38184
|
+
// ../shared/src/kit_icon_names.generated.ts
|
|
38185
|
+
var KIT_ICON_NAMES = [
|
|
38186
|
+
"activity",
|
|
38187
|
+
"align-center",
|
|
38188
|
+
"align-left",
|
|
38189
|
+
"align-right",
|
|
38190
|
+
"arrow-down",
|
|
38191
|
+
"arrow-down-up",
|
|
38192
|
+
"arrow-down-wide-narrow",
|
|
38193
|
+
"arrow-left",
|
|
38194
|
+
"arrow-left-from-line",
|
|
38195
|
+
"arrow-right",
|
|
38196
|
+
"arrow-right-from-line",
|
|
38197
|
+
"arrow-up",
|
|
38198
|
+
"arrow-up-down",
|
|
38199
|
+
"arrow-up-wide-narrow",
|
|
38200
|
+
"ban",
|
|
38201
|
+
"bed",
|
|
38202
|
+
"bell",
|
|
38203
|
+
"bold",
|
|
38204
|
+
"bolt",
|
|
38205
|
+
"building-2",
|
|
38206
|
+
"book-marked",
|
|
38207
|
+
"book-open",
|
|
38208
|
+
"book-text",
|
|
38209
|
+
"bot",
|
|
38210
|
+
"box",
|
|
38211
|
+
"brain",
|
|
38212
|
+
"brackets",
|
|
38213
|
+
"calculator",
|
|
38214
|
+
"calendar",
|
|
38215
|
+
"calendar-clock",
|
|
38216
|
+
"calendar-off",
|
|
38217
|
+
"camera",
|
|
38218
|
+
"car",
|
|
38219
|
+
"check",
|
|
38220
|
+
"chevron-down",
|
|
38221
|
+
"chevron-left",
|
|
38222
|
+
"chevron-right",
|
|
38223
|
+
"chevron-up",
|
|
38224
|
+
"chevrons-down-up",
|
|
38225
|
+
"chevrons-up-down",
|
|
38226
|
+
"circle-alert",
|
|
38227
|
+
"circle-check",
|
|
38228
|
+
"clipboard-list",
|
|
38229
|
+
"triangle-alert",
|
|
38230
|
+
"clock",
|
|
38231
|
+
"code",
|
|
38232
|
+
"code-xml",
|
|
38233
|
+
"columns-3",
|
|
38234
|
+
"columns-3-cog",
|
|
38235
|
+
"construction",
|
|
38236
|
+
"copy",
|
|
38237
|
+
"credit-card",
|
|
38238
|
+
"banknote",
|
|
38239
|
+
"arrow-right-left",
|
|
38240
|
+
"hand-coins",
|
|
38241
|
+
"database",
|
|
38242
|
+
"download",
|
|
38243
|
+
"ellipsis",
|
|
38244
|
+
"eraser",
|
|
38245
|
+
"expand",
|
|
38246
|
+
"external-link",
|
|
38247
|
+
"library-big",
|
|
38248
|
+
"eye",
|
|
38249
|
+
"eye-off",
|
|
38250
|
+
"file",
|
|
38251
|
+
"file-text",
|
|
38252
|
+
"file-csv",
|
|
38253
|
+
"file-down",
|
|
38254
|
+
"file-question",
|
|
38255
|
+
"file-spreadsheet",
|
|
38256
|
+
"file-stack",
|
|
38257
|
+
"file-up",
|
|
38258
|
+
"folder",
|
|
38259
|
+
"folder-closed",
|
|
38260
|
+
"folder-open",
|
|
38261
|
+
"folder-pen",
|
|
38262
|
+
"form",
|
|
38263
|
+
"funnel-plus",
|
|
38264
|
+
"funnel-x",
|
|
38265
|
+
"gauge",
|
|
38266
|
+
"gpu",
|
|
38267
|
+
"globe",
|
|
38268
|
+
"grip-vertical",
|
|
38269
|
+
"group",
|
|
38270
|
+
"heading",
|
|
38271
|
+
"heading-1",
|
|
38272
|
+
"heading-2",
|
|
38273
|
+
"heading-3",
|
|
38274
|
+
"history",
|
|
38275
|
+
"house",
|
|
38276
|
+
"image",
|
|
38277
|
+
"inbox",
|
|
38278
|
+
"info",
|
|
38279
|
+
"italic",
|
|
38280
|
+
"keyboard",
|
|
38281
|
+
"languages",
|
|
38282
|
+
"link-2",
|
|
38283
|
+
"link-2-off",
|
|
38284
|
+
"layout-dashboard",
|
|
38285
|
+
"layout-grid",
|
|
38286
|
+
"list",
|
|
38287
|
+
"list-checks",
|
|
38288
|
+
"list-ordered",
|
|
38289
|
+
"list-collapse",
|
|
38290
|
+
"list-filter",
|
|
38291
|
+
"list-filter-plus",
|
|
38292
|
+
"loader",
|
|
38293
|
+
"lock",
|
|
38294
|
+
"lock-keyhole",
|
|
38295
|
+
"lock-keyhole-open",
|
|
38296
|
+
"lock-open",
|
|
38297
|
+
"log-in",
|
|
38298
|
+
"log-out",
|
|
38299
|
+
"mail",
|
|
38300
|
+
"map-pin",
|
|
38301
|
+
"maximize-2",
|
|
38302
|
+
"megaphone",
|
|
38303
|
+
"menu",
|
|
38304
|
+
"facebook",
|
|
38305
|
+
"instagram",
|
|
38306
|
+
"linkedin",
|
|
38307
|
+
"message-circle",
|
|
38308
|
+
"message-circle-question-mark",
|
|
38309
|
+
"twitter",
|
|
38310
|
+
"message-square",
|
|
38311
|
+
"messages-square",
|
|
38312
|
+
"mic",
|
|
38313
|
+
"minimize-2",
|
|
38314
|
+
"minus",
|
|
38315
|
+
"monitor",
|
|
38316
|
+
"mouse",
|
|
38317
|
+
"mouse-pointer-click",
|
|
38318
|
+
"music",
|
|
38319
|
+
"newspaper",
|
|
38320
|
+
"notepad-text-dashed",
|
|
38321
|
+
"package",
|
|
38322
|
+
"paint-bucket",
|
|
38323
|
+
"palette",
|
|
38324
|
+
"panel-left",
|
|
38325
|
+
"panel-left-close",
|
|
38326
|
+
"panel-left-open",
|
|
38327
|
+
"panel-right",
|
|
38328
|
+
"panel-right-close",
|
|
38329
|
+
"panel-right-open",
|
|
38330
|
+
"paperclip",
|
|
38331
|
+
"pause",
|
|
38332
|
+
"pencil",
|
|
38333
|
+
"phone",
|
|
38334
|
+
"pin",
|
|
38335
|
+
"pin-off",
|
|
38336
|
+
"plane",
|
|
38337
|
+
"play",
|
|
38338
|
+
"plug",
|
|
38339
|
+
"plus",
|
|
38340
|
+
"receipt",
|
|
38341
|
+
"rectangle-ellipsis",
|
|
38342
|
+
"redo",
|
|
38343
|
+
"refresh-cw",
|
|
38344
|
+
"repeat",
|
|
38345
|
+
"rotate-ccw",
|
|
38346
|
+
"rotate-cw",
|
|
38347
|
+
"scan",
|
|
38348
|
+
"search",
|
|
38349
|
+
"send",
|
|
38350
|
+
"settings",
|
|
38351
|
+
"share",
|
|
38352
|
+
"share-2",
|
|
38353
|
+
"shield",
|
|
38354
|
+
"shield-alert",
|
|
38355
|
+
"shield-check",
|
|
38356
|
+
"shopping-cart",
|
|
38357
|
+
"sliders-horizontal",
|
|
38358
|
+
"sparkles",
|
|
38359
|
+
"split",
|
|
38360
|
+
"smile",
|
|
38361
|
+
"smile-plus",
|
|
38362
|
+
"square",
|
|
38363
|
+
"stethoscope",
|
|
38364
|
+
"square-check",
|
|
38365
|
+
"square-pen",
|
|
38366
|
+
"square-sigma",
|
|
38367
|
+
"sticky-note",
|
|
38368
|
+
"container",
|
|
38369
|
+
"table",
|
|
38370
|
+
"table-2",
|
|
38371
|
+
"tag",
|
|
38372
|
+
"ticket",
|
|
38373
|
+
"text-quote",
|
|
38374
|
+
"target",
|
|
38375
|
+
"thumbs-down",
|
|
38376
|
+
"thumbs-up",
|
|
38377
|
+
"trash",
|
|
38378
|
+
"wrench",
|
|
38379
|
+
"truck",
|
|
38380
|
+
"trending-down",
|
|
38381
|
+
"trending-up",
|
|
38382
|
+
"tv-minimal",
|
|
38383
|
+
"undo",
|
|
38384
|
+
"underline",
|
|
38385
|
+
"upload",
|
|
38386
|
+
"user-check",
|
|
38387
|
+
"user-pen",
|
|
38388
|
+
"user",
|
|
38389
|
+
"users",
|
|
38390
|
+
"utensils",
|
|
38391
|
+
"waypoints",
|
|
38392
|
+
"workflow",
|
|
38393
|
+
"x",
|
|
38394
|
+
"zap"
|
|
38395
|
+
];
|
|
38396
|
+
|
|
38196
38397
|
// ../shared/src/schemas/field_roles.ts
|
|
38197
38398
|
var fieldRoleSchema = zod_default.enum([
|
|
38198
38399
|
"identity",
|
|
@@ -38217,7 +38418,11 @@ var FIELD_ROLE_TYPES = {
|
|
|
38217
38418
|
reference: ["text", "autonumber", "formula"],
|
|
38218
38419
|
mark: ["files", "lookup", "rollup"],
|
|
38219
38420
|
lifecycle: ["select"],
|
|
38220
|
-
|
|
38421
|
+
// WHAT A ROW IS is often a fact about the thing it books rather than about the
|
|
38422
|
+
// row: a stop's kind is the service's own, read through a lookup, and a model
|
|
38423
|
+
// that had to copy the column to give it the role would keep two answers in
|
|
38424
|
+
// step by hand.
|
|
38425
|
+
category: ["select", "lookup"],
|
|
38221
38426
|
measure: ["number", "formula", "rollup"],
|
|
38222
38427
|
expected_set: ["select"],
|
|
38223
38428
|
amount: ["number", "formula", "rollup"],
|
|
@@ -38297,13 +38502,27 @@ var fieldRoleDeclSchema = zod_default.object({
|
|
|
38297
38502
|
"For a measure: the limit it is read against \u2014 a constant, or a field on the same entity by alias whose value is a number (a number field, or a formula or rollup whose result is one). For an amount: the REFERENCE it is quoted off \u2014 the list price, the going rate \u2014 by alias, never a constant, and with no alert"
|
|
38298
38503
|
),
|
|
38299
38504
|
alert: zod_default.enum(["over", "under"]).optional().describe("With against: which side of the limit needs attention \u2014 over a capacity, under a minimum"),
|
|
38300
|
-
counts: zod_default.enum(["days"]).optional().describe(
|
|
38301
|
-
'For a measure: WHAT the number counts, where the unit changes how a surface draws the row rather than how the figure reads. "days" makes the row span that many days of a run, so a stop of 3 is drawn across three of its days instead of only the one it starts on'
|
|
38505
|
+
counts: zod_default.enum(["days", "hours"]).optional().describe(
|
|
38506
|
+
'For a measure: WHAT the number counts, where the unit changes how a surface draws the row rather than how the figure reads. "days" makes the row span that many days of a run, so a stop of 3 is drawn across three of its days instead of only the one it starts on. "hours" is how long the row TAKES inside its day, stated beside it rather than spread across the run'
|
|
38302
38507
|
),
|
|
38303
38508
|
reading: zod_default.enum(["fill", "threshold"]).optional().describe(
|
|
38304
38509
|
'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'
|
|
38305
38510
|
),
|
|
38306
38511
|
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"),
|
|
38512
|
+
phases: zod_default.array(
|
|
38513
|
+
zod_default.strictObject({
|
|
38514
|
+
label: zod_default.string().min(1).describe("What this part of the flow is called, as the reader says it"),
|
|
38515
|
+
stages: zod_default.array(contractAliasSchema).min(1).describe("The option aliases of this lifecycle that fall under it, in order")
|
|
38516
|
+
}).describe("One part of the flow")
|
|
38517
|
+
).min(1).optional().describe(
|
|
38518
|
+
"For a lifecycle: the stages grouped into the parts of the flow they belong to, in order. Every stage that is not an outcome falls in exactly one; the outcomes may be placed in one or left out, and are then read as a last part of their own"
|
|
38519
|
+
),
|
|
38520
|
+
history: zod_default.union([zod_default.literal(true), contractAliasSchema]).optional().describe(
|
|
38521
|
+
"For a lifecycle: keep every stage this row reached, with the day it reached it and who moved it. The model derives the table \u2014 `true` names it `<entity>_history`, an alias names it \u2014 and no other file declares it"
|
|
38522
|
+
),
|
|
38523
|
+
icons: zod_default.record(contractAliasSchema, zod_default.string().min(1)).optional().describe(
|
|
38524
|
+
"For a category: option alias \u2192 the lucide icon name a surface draws that kind with, where a word alone is not enough (a run's node, a stop with no picture of its own)"
|
|
38525
|
+
),
|
|
38307
38526
|
due: zod_default.literal(true).optional().describe(
|
|
38308
38527
|
"For a when: this date is a DEADLINE the row counts down to, and a row past it is late. Absent, it is the plain day it is \u2014 the day a lead arrived is neither early nor late"
|
|
38309
38528
|
),
|
|
@@ -38338,6 +38557,18 @@ function roleOf(roles, entity, field) {
|
|
|
38338
38557
|
const fields = roles[entity];
|
|
38339
38558
|
return Object.hasOwn(fields, field) ? fields[field] ?? void 0 : void 0;
|
|
38340
38559
|
}
|
|
38560
|
+
var HISTORY_PARENT = "parent";
|
|
38561
|
+
var HISTORY_STAGE = "stage";
|
|
38562
|
+
var HISTORY_AT = "entered_at";
|
|
38563
|
+
var HISTORY_BY = "by";
|
|
38564
|
+
function lifecycleHistory(entity, roles) {
|
|
38565
|
+
for (const field of entity.fields) {
|
|
38566
|
+
const decl = roleOf(roles, entity.alias, field.alias);
|
|
38567
|
+
if (decl?.role !== "lifecycle" || decl.history === void 0) continue;
|
|
38568
|
+
return { field, decl, alias: decl.history === true ? `${entity.alias}_history` : decl.history };
|
|
38569
|
+
}
|
|
38570
|
+
return void 0;
|
|
38571
|
+
}
|
|
38341
38572
|
function checkFieldRoles(entities, roles) {
|
|
38342
38573
|
const errors = [];
|
|
38343
38574
|
const entityByAlias = new Map(entities.map((entity) => [entity.alias, entity]));
|
|
@@ -38375,12 +38606,15 @@ function checkFieldRoles(entities, roles) {
|
|
|
38375
38606
|
}
|
|
38376
38607
|
}
|
|
38377
38608
|
const one = ONE_OPTION_ROLES[decl.role];
|
|
38378
|
-
if (one !== void 0 && field
|
|
38609
|
+
if (one !== void 0 && optionSource(field, entity, entities)?.field.multi === true) {
|
|
38379
38610
|
errors.push({ severity: "error", path: path23, message: `a ${decl.role} is one ${one} at a time \u2014 a multi-select cannot be one` });
|
|
38380
38611
|
}
|
|
38381
38612
|
errors.push(...checkResultType(decl, field, entity, entities, path23));
|
|
38382
38613
|
errors.push(...checkCurrencyOptions(decl, field, path23));
|
|
38383
38614
|
errors.push(...checkOutcomes(decl, field, path23));
|
|
38615
|
+
errors.push(...checkPhases(decl, field, path23));
|
|
38616
|
+
errors.push(...checkHistory(decl, path23));
|
|
38617
|
+
errors.push(...checkIcons(decl, field, entity, entities, path23));
|
|
38384
38618
|
errors.push(...checkUntil(decl, entity, roles, path23));
|
|
38385
38619
|
errors.push(...checkDue(decl, path23));
|
|
38386
38620
|
errors.push(...checkSlot(decl, field, path23));
|
|
@@ -38472,7 +38706,8 @@ var ROLE_RESULT = {
|
|
|
38472
38706
|
identity: { type: "text", is: "identity names the row" },
|
|
38473
38707
|
reference: { type: "text", is: "reference is the key the row is filed under" },
|
|
38474
38708
|
when: { type: "date", is: "when is the day a row is read by" },
|
|
38475
|
-
mark: { type: "files", is: "mark is a picture" }
|
|
38709
|
+
mark: { type: "files", is: "mark is a picture" },
|
|
38710
|
+
category: { type: "select", is: "category is one option saying what the row IS" }
|
|
38476
38711
|
};
|
|
38477
38712
|
var COMPUTED_TYPES = ["formula", "rollup", "lookup"];
|
|
38478
38713
|
function checkResultType(decl, field, entity, entities, path23) {
|
|
@@ -38674,6 +38909,102 @@ function checkOutcomes(decl, field, path23) {
|
|
|
38674
38909
|
}
|
|
38675
38910
|
return errors;
|
|
38676
38911
|
}
|
|
38912
|
+
function optionSource(field, entity, entities, hops = 0) {
|
|
38913
|
+
if (hops >= RESULT_HOPS) return void 0;
|
|
38914
|
+
if (field.type === "select") return { field, entity };
|
|
38915
|
+
if (field.type !== "lookup" && field.type !== "rollup") return void 0;
|
|
38916
|
+
const source = field.type === "lookup" ? field.lookup_field_alias : field.aggregate_option.field_key;
|
|
38917
|
+
if (source === void 0) return void 0;
|
|
38918
|
+
const target = linkTarget(entity, entities, field.source_field_alias);
|
|
38919
|
+
const read2 = target?.fields.find((candidate) => candidate.alias === source);
|
|
38920
|
+
return target === void 0 || read2 === void 0 ? void 0 : optionSource(read2, target, entities, hops + 1);
|
|
38921
|
+
}
|
|
38922
|
+
function checkPhases(decl, field, path23) {
|
|
38923
|
+
if (decl.phases === void 0) return [];
|
|
38924
|
+
if (decl.role !== "lifecycle") {
|
|
38925
|
+
return [{ severity: "error", path: path23, message: `phases group a lifecycle's stages \u2014 this role is "${decl.role}"` }];
|
|
38926
|
+
}
|
|
38927
|
+
const errors = [];
|
|
38928
|
+
const named2 = decl.phases.flatMap((phase) => phase.stages);
|
|
38929
|
+
for (const alias of unknownOptions(field, named2)) {
|
|
38930
|
+
errors.push({ severity: "error", path: path23, message: `phases name "${alias}", which is not an option of "${field.alias}"` });
|
|
38931
|
+
}
|
|
38932
|
+
const seen = /* @__PURE__ */ new Set();
|
|
38933
|
+
for (const alias of named2) {
|
|
38934
|
+
if (seen.has(alias)) {
|
|
38935
|
+
errors.push({ severity: "error", path: path23, message: `phases name "${alias}" twice \u2014 a stage falls in one part of the flow` });
|
|
38936
|
+
}
|
|
38937
|
+
seen.add(alias);
|
|
38938
|
+
}
|
|
38939
|
+
if (field.type === "select") {
|
|
38940
|
+
const outcomes = new Set(decl.outcomes ?? []);
|
|
38941
|
+
const unplaced = field.options.filter((option) => !seen.has(option.alias) && !outcomes.has(option.alias));
|
|
38942
|
+
for (const option of unplaced) {
|
|
38943
|
+
errors.push({
|
|
38944
|
+
severity: "error",
|
|
38945
|
+
path: path23,
|
|
38946
|
+
message: `phases place no part of the flow over "${option.alias}" \u2014 every stage but an outcome falls in one`
|
|
38947
|
+
});
|
|
38948
|
+
}
|
|
38949
|
+
}
|
|
38950
|
+
const labels = /* @__PURE__ */ new Set();
|
|
38951
|
+
for (const phase of decl.phases) {
|
|
38952
|
+
if (labels.has(phase.label)) {
|
|
38953
|
+
errors.push({ severity: "error", path: path23, message: `phases are called "${phase.label}" twice \u2014 two parts of one flow read as one` });
|
|
38954
|
+
}
|
|
38955
|
+
labels.add(phase.label);
|
|
38956
|
+
}
|
|
38957
|
+
return errors;
|
|
38958
|
+
}
|
|
38959
|
+
function checkHistory(decl, path23) {
|
|
38960
|
+
if (decl.history === void 0) return [];
|
|
38961
|
+
if (decl.role !== "lifecycle") {
|
|
38962
|
+
return [{ severity: "error", path: path23, message: `history keeps a lifecycle's stage changes \u2014 this role is "${decl.role}"` }];
|
|
38963
|
+
}
|
|
38964
|
+
if (decl.phases === void 0) {
|
|
38965
|
+
return [
|
|
38966
|
+
{
|
|
38967
|
+
severity: "error",
|
|
38968
|
+
path: path23,
|
|
38969
|
+
message: `history dates the rungs of a ladder that names none \u2014 declare \`phases\`, which is what says which steps the ladder draws`
|
|
38970
|
+
}
|
|
38971
|
+
];
|
|
38972
|
+
}
|
|
38973
|
+
return [];
|
|
38974
|
+
}
|
|
38975
|
+
var KIT_DRAWS = new Set(KIT_ICON_NAMES);
|
|
38976
|
+
function checkIcons(decl, field, entity, entities, path23) {
|
|
38977
|
+
if (decl.icons === void 0) return [];
|
|
38978
|
+
if (decl.role !== "category") {
|
|
38979
|
+
return [{ severity: "error", path: path23, message: `icons draw the kinds of a category \u2014 this role is "${decl.role}"` }];
|
|
38980
|
+
}
|
|
38981
|
+
const errors = [];
|
|
38982
|
+
const options = optionSource(field, entity, entities)?.field.options;
|
|
38983
|
+
const declared = options === void 0 ? void 0 : new Set(options.map((option) => option.alias));
|
|
38984
|
+
for (const [alias, icon] of Object.entries(decl.icons)) {
|
|
38985
|
+
if (declared !== void 0 && !declared.has(alias)) {
|
|
38986
|
+
errors.push({ severity: "error", path: path23, message: `icons name "${alias}", which is not an option of "${field.alias}"` });
|
|
38987
|
+
}
|
|
38988
|
+
if (!isValidIconName(icon)) {
|
|
38989
|
+
errors.push({ severity: "error", path: path23, message: `icons draw "${alias}" with "${icon}", which is not an icon this platform has` });
|
|
38990
|
+
} else if (!KIT_DRAWS.has(icon)) {
|
|
38991
|
+
errors.push({
|
|
38992
|
+
severity: "note",
|
|
38993
|
+
path: path23,
|
|
38994
|
+
message: `icons draw "${alias}" with "${icon}", which the kit does not carry \u2014 the stop draws the neutral glyph until that glyph is added to \`Icon\``
|
|
38995
|
+
});
|
|
38996
|
+
}
|
|
38997
|
+
}
|
|
38998
|
+
const unglyphed = (options ?? []).filter((option) => !Object.hasOwn(decl.icons ?? {}, option.alias));
|
|
38999
|
+
if (declared !== void 0 && unglyphed.length > 0) {
|
|
39000
|
+
errors.push({
|
|
39001
|
+
severity: "note",
|
|
39002
|
+
path: path23,
|
|
39003
|
+
message: `icons draw ${Object.keys(decl.icons).length} of "${field.alias}"'s ${declared.size} kinds \u2014 ${unglyphed.map((option) => option.alias).join(", ")} ${unglyphed.length === 1 ? "falls" : "fall"} back to the neutral glyph`
|
|
39004
|
+
});
|
|
39005
|
+
}
|
|
39006
|
+
return errors;
|
|
39007
|
+
}
|
|
38677
39008
|
function checkUntil(decl, entity, roles, path23) {
|
|
38678
39009
|
if (decl.until === void 0) return [];
|
|
38679
39010
|
if (decl.role !== "when" && decl.role !== "obligation") {
|
|
@@ -39246,19 +39577,25 @@ function leadRefusal(shape, drawn, lead) {
|
|
|
39246
39577
|
}
|
|
39247
39578
|
var AGEING_BUCKETS = [30, 60, 90];
|
|
39248
39579
|
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");
|
|
39580
|
+
var contractActWhenSchema = zod_default.strictObject({
|
|
39581
|
+
field: contractAliasSchema.describe("The lifecycle field of this entity the state is read off"),
|
|
39582
|
+
in: zod_default.array(contractAliasSchema).min(1).describe("The option aliases of that lifecycle the act is offered at")
|
|
39583
|
+
}).describe("Offer this act only while the row stands at one of these stages");
|
|
39249
39584
|
var contractTemplateActSchema = zod_default.strictObject({
|
|
39250
39585
|
label: zod_default.string().min(1).describe("The act's own words, as the reader reads them in the menu"),
|
|
39251
39586
|
template: contractAliasSchema.describe(
|
|
39252
39587
|
"A template alias this model declares; a row's act generates that document from the row and opens it"
|
|
39253
39588
|
),
|
|
39254
|
-
place: contractActPlaceSchema.optional()
|
|
39589
|
+
place: contractActPlaceSchema.optional(),
|
|
39590
|
+
when: contractActWhenSchema.optional()
|
|
39255
39591
|
});
|
|
39256
39592
|
var contractAgentActSchema = zod_default.strictObject({
|
|
39257
39593
|
kind: zod_default.literal("agent"),
|
|
39258
39594
|
label: zod_default.string().min(1).describe("The act's own words, as the reader reads them in the menu"),
|
|
39259
39595
|
agent: contractAliasSchema.describe("An agent alias the APP declares (package.json#lotics.agents)"),
|
|
39260
39596
|
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"),
|
|
39261
|
-
place: contractActPlaceSchema.optional()
|
|
39597
|
+
place: contractActPlaceSchema.optional(),
|
|
39598
|
+
when: contractActWhenSchema.optional()
|
|
39262
39599
|
});
|
|
39263
39600
|
var RECORD_INPUT = "record";
|
|
39264
39601
|
var contractWorkflowActSchema = zod_default.strictObject({
|
|
@@ -39269,7 +39606,8 @@ var contractWorkflowActSchema = zod_default.strictObject({
|
|
|
39269
39606
|
contractAliasSchema,
|
|
39270
39607
|
contractAliasSchema.describe('A field of this entity, or "record" \u2014 which names the row itself before any field of that alias')
|
|
39271
39608
|
).describe("What the run is handed: the workflow's own input name \u2192 the value on the row it is pressed on"),
|
|
39272
|
-
place: contractActPlaceSchema.optional()
|
|
39609
|
+
place: contractActPlaceSchema.optional(),
|
|
39610
|
+
when: contractActWhenSchema.optional()
|
|
39273
39611
|
});
|
|
39274
39612
|
var contractActSchema = zod_default.union([contractAgentActSchema, contractWorkflowActSchema, contractTemplateActSchema], {
|
|
39275
39613
|
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
|
|
@@ -39332,11 +39670,22 @@ var contractPredicateSchema = zod_default.strictObject({
|
|
|
39332
39670
|
"An `and` group of plain conditions over the entity's OWN fields, each `field_key` a field alias \u2014 the rows this predicate keeps"
|
|
39333
39671
|
)
|
|
39334
39672
|
});
|
|
39335
|
-
var contractSectionDrawSchema = zod_default.
|
|
39336
|
-
|
|
39337
|
-
|
|
39338
|
-
|
|
39339
|
-
|
|
39673
|
+
var contractSectionDrawSchema = zod_default.discriminatedUnion("draw", [
|
|
39674
|
+
zod_default.strictObject({
|
|
39675
|
+
draw: zod_default.literal("worksheet").describe("Priced lines the reader works down in place, each part footed and the sheet closing under them"),
|
|
39676
|
+
cost: contractAliasSchema.optional().describe("The child's field holding what a line COSTS \u2014 the base the margin is taken against"),
|
|
39677
|
+
sell: contractAliasSchema.optional().describe("The child's field holding what it SELLS for \u2014 the figure the sheet is read for")
|
|
39678
|
+
}),
|
|
39679
|
+
// A BOOK HEADS WITH WHAT IT COMES TO. The rows are the movements; what the
|
|
39680
|
+
// record is OWED, has collected and still waits for are the record's own
|
|
39681
|
+
// figures, and stated above the rows they are the whole point of the
|
|
39682
|
+
// section — including at nought rows, where a book with no head is a
|
|
39683
|
+
// heading, a verb and a void.
|
|
39684
|
+
zod_default.strictObject({
|
|
39685
|
+
draw: zod_default.literal("ledger").describe("A book of movements, headed by what the record they are against comes to"),
|
|
39686
|
+
summary: zod_default.array(contractAliasSchema).min(1).describe("Number fields of THIS record, in this order \u2014 the statement the rows are worked down against, drawn above them")
|
|
39687
|
+
})
|
|
39688
|
+
]).describe("How a section of this screen's record is drawn, where its rows are more than a register");
|
|
39340
39689
|
var contractLensSchema = zod_default.strictObject({
|
|
39341
39690
|
label: zod_default.string().min(1).describe("What the chip is called"),
|
|
39342
39691
|
predicates: zod_default.array(contractPredicateSchema).min(1).describe("The sets it offers, in this order")
|
|
@@ -39610,8 +39959,8 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
|
|
|
39610
39959
|
}
|
|
39611
39960
|
}
|
|
39612
39961
|
const columns = resolveColumns(path23, screen, entity, [...entityByAlias.values()], fieldByAlias, boundBy, shapeLabel, findings);
|
|
39613
|
-
const acts = resolveActs(path23, screen, entity, entityByAlias, rules, fieldByAlias, templates, findings);
|
|
39614
|
-
const sectionActs2 = resolveSectionActs(path23, screen, entity, fieldByAlias, templates, findings);
|
|
39962
|
+
const acts = resolveActs(path23, screen, entity, roles, entityByAlias, rules, fieldByAlias, templates, findings);
|
|
39963
|
+
const sectionActs2 = resolveSectionActs(path23, screen, entity, roles, fieldByAlias, templates, findings);
|
|
39615
39964
|
const summary = resolveSummary(path23, screen, entity, [...entityByAlias.values()], roles, slots, findings);
|
|
39616
39965
|
const period = resolvePeriod(path23, screen, entity, [...entityByAlias.values()], fieldByAlias, findings);
|
|
39617
39966
|
const filters = resolveFilters(path23, screen, entity, [...entityByAlias.values()], fieldByAlias, tabs, findings);
|
|
@@ -39843,7 +40192,7 @@ function actDuplicates(acts, at2, menu, findings) {
|
|
|
39843
40192
|
(dup) => `two acts both hand off through "${dup}" \u2014 one workflow is one hand-off, however it is worded`
|
|
39844
40193
|
);
|
|
39845
40194
|
}
|
|
39846
|
-
function resolveActs(path23, screen, entity, entityByAlias, rules, fieldByAlias, templates, findings) {
|
|
40195
|
+
function resolveActs(path23, screen, entity, roles, entityByAlias, rules, fieldByAlias, templates, findings) {
|
|
39847
40196
|
const declared = screen.acts;
|
|
39848
40197
|
if (declared === void 0) return { row: [], selection: [], record: [], export: null, import: null };
|
|
39849
40198
|
const stated2 = declared.row ?? [];
|
|
@@ -39852,9 +40201,9 @@ function resolveActs(path23, screen, entity, entityByAlias, rules, fieldByAlias,
|
|
|
39852
40201
|
for (const refusal of ctaRefusals(declared)) {
|
|
39853
40202
|
findings.push({ severity: "error", path: `${path23}.acts.${refusal.reach}`, message: refusal.message });
|
|
39854
40203
|
}
|
|
39855
|
-
const row = stated2.flatMap((act, index) => resolveAct(act, `${at2}.${index}`, screen, entity, fieldByAlias, templates, findings));
|
|
39856
|
-
const selection = resolveSelectionActs(path23, screen, entity, fieldByAlias, templates, findings);
|
|
39857
|
-
const record2 = resolveRecordActs(path23, screen, entity, fieldByAlias, templates, findings);
|
|
40204
|
+
const row = stated2.flatMap((act, index) => resolveAct(act, `${at2}.${index}`, screen, entity, roles, fieldByAlias, templates, findings));
|
|
40205
|
+
const selection = resolveSelectionActs(path23, screen, entity, roles, fieldByAlias, templates, findings);
|
|
40206
|
+
const record2 = resolveRecordActs(path23, screen, entity, roles, fieldByAlias, templates, findings);
|
|
39858
40207
|
return {
|
|
39859
40208
|
row,
|
|
39860
40209
|
selection,
|
|
@@ -39927,7 +40276,7 @@ function resolveImport(path23, declared, entity, entityByAlias, rules, findings)
|
|
|
39927
40276
|
});
|
|
39928
40277
|
return { label: declared.label, entity: target, key, columns };
|
|
39929
40278
|
}
|
|
39930
|
-
function resolveSectionActs(path23, screen, entity, fieldByAlias, templates, findings) {
|
|
40279
|
+
function resolveSectionActs(path23, screen, entity, roles, fieldByAlias, templates, findings) {
|
|
39931
40280
|
const resolved = /* @__PURE__ */ new Map();
|
|
39932
40281
|
for (const [alias, acts] of Object.entries(screen.section_acts ?? {})) {
|
|
39933
40282
|
const at2 = `${path23}.section_acts.${alias}`;
|
|
@@ -39941,11 +40290,11 @@ function resolveSectionActs(path23, screen, entity, fieldByAlias, templates, fin
|
|
|
39941
40290
|
});
|
|
39942
40291
|
}
|
|
39943
40292
|
}
|
|
39944
|
-
resolved.set(alias, acts.flatMap((act, index) => resolveAct(act, `${at2}.${index}`, screen, entity, fieldByAlias, templates, findings)));
|
|
40293
|
+
resolved.set(alias, acts.flatMap((act, index) => resolveAct(act, `${at2}.${index}`, screen, entity, roles, fieldByAlias, templates, findings)));
|
|
39945
40294
|
}
|
|
39946
40295
|
return resolved;
|
|
39947
40296
|
}
|
|
39948
|
-
function resolveRecordActs(path23, screen, entity, fieldByAlias, templates, findings) {
|
|
40297
|
+
function resolveRecordActs(path23, screen, entity, roles, fieldByAlias, templates, findings) {
|
|
39949
40298
|
const declared = screen.acts?.record;
|
|
39950
40299
|
if (declared === void 0) return [];
|
|
39951
40300
|
const at2 = `${path23}.acts.record`;
|
|
@@ -39955,7 +40304,7 @@ function resolveRecordActs(path23, screen, entity, fieldByAlias, templates, find
|
|
|
39955
40304
|
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` });
|
|
39956
40305
|
}
|
|
39957
40306
|
}
|
|
39958
|
-
return declared.flatMap((act, index) => resolveAct(act, `${at2}.${index}`, screen, entity, fieldByAlias, templates, findings));
|
|
40307
|
+
return declared.flatMap((act, index) => resolveAct(act, `${at2}.${index}`, screen, entity, roles, fieldByAlias, templates, findings));
|
|
39959
40308
|
}
|
|
39960
40309
|
function resolveExport(path23, declared, templates, findings) {
|
|
39961
40310
|
if (declared === void 0) return null;
|
|
@@ -39963,15 +40312,17 @@ function resolveExport(path23, declared, templates, findings) {
|
|
|
39963
40312
|
const template = resolveTemplateAct(declared.template, templates, `${path23}.acts.export`, findings);
|
|
39964
40313
|
return template === void 0 ? null : { kind: "template", label: template.label, template };
|
|
39965
40314
|
}
|
|
39966
|
-
function resolveAct(act, at2, screen, entity, fieldByAlias, templates, findings) {
|
|
40315
|
+
function resolveAct(act, at2, screen, entity, roles, fieldByAlias, templates, findings) {
|
|
39967
40316
|
const place = act.place === void 0 ? {} : { place: act.place };
|
|
40317
|
+
const gate = resolveActWhen(act.when, at2, entity, roles, fieldByAlias, findings);
|
|
40318
|
+
const stands = gate === void 0 ? {} : { when: gate };
|
|
39968
40319
|
if (!("kind" in act)) {
|
|
39969
40320
|
const template = resolveTemplateAct(act.template, templates, at2, findings);
|
|
39970
|
-
return template === void 0 ? [] : [{ kind: "template", label: act.label, ...place, template }];
|
|
40321
|
+
return template === void 0 ? [] : [{ kind: "template", label: act.label, ...place, ...stands, template }];
|
|
39971
40322
|
}
|
|
39972
40323
|
if (act.kind === "workflow") {
|
|
39973
40324
|
const inputs = resolveActInputs(act.inputs, at2, entity, fieldByAlias, findings);
|
|
39974
|
-
return inputs === void 0 ? [] : [{ kind: "workflow", label: act.label, ...place, workflow: act.workflow, inputs }];
|
|
40325
|
+
return inputs === void 0 ? [] : [{ kind: "workflow", label: act.label, ...place, ...stands, workflow: act.workflow, inputs }];
|
|
39975
40326
|
}
|
|
39976
40327
|
if (screen.writes === false) {
|
|
39977
40328
|
findings.push({
|
|
@@ -39996,7 +40347,43 @@ function resolveAct(act, at2, screen, entity, fieldByAlias, templates, findings)
|
|
|
39996
40347
|
}
|
|
39997
40348
|
return [field];
|
|
39998
40349
|
});
|
|
39999
|
-
return fills.length === 0 ? [] : [{ kind: "agent", label: act.label, ...place, agent: act.agent, fills }];
|
|
40350
|
+
return fills.length === 0 ? [] : [{ kind: "agent", label: act.label, ...place, ...stands, agent: act.agent, fills }];
|
|
40351
|
+
}
|
|
40352
|
+
function resolveActWhen(declared, at2, entity, roles, fieldByAlias, findings) {
|
|
40353
|
+
if (declared === void 0) return void 0;
|
|
40354
|
+
const field = fieldByAlias.get(declared.field);
|
|
40355
|
+
if (field === void 0) {
|
|
40356
|
+
findings.push({ severity: "error", path: at2, message: `stands down unless "${declared.field}" holds a stage, and that is not a field of entity "${entity.alias}"` });
|
|
40357
|
+
return void 0;
|
|
40358
|
+
}
|
|
40359
|
+
if (roleOf(roles, entity.alias, field.alias)?.role !== "lifecycle" || field.type !== "select") {
|
|
40360
|
+
findings.push({
|
|
40361
|
+
severity: "error",
|
|
40362
|
+
path: at2,
|
|
40363
|
+
message: `is gated on "${declared.field}", which is not this entity's lifecycle \u2014 readiness is a stage, so name the field whose role is "lifecycle"`
|
|
40364
|
+
});
|
|
40365
|
+
return void 0;
|
|
40366
|
+
}
|
|
40367
|
+
const declaredOptions = new Set(field.options.map((option) => option.alias));
|
|
40368
|
+
const unknown2 = declared.in.filter((alias) => !declaredOptions.has(alias));
|
|
40369
|
+
for (const alias of unknown2) {
|
|
40370
|
+
findings.push({ severity: "error", path: at2, message: `is offered at "${alias}", which is not a stage of "${field.alias}"` });
|
|
40371
|
+
}
|
|
40372
|
+
if (unknown2.length > 0) return void 0;
|
|
40373
|
+
const repeated = findDuplicates(declared.in);
|
|
40374
|
+
for (const alias of repeated) {
|
|
40375
|
+
findings.push({ severity: "error", path: at2, message: `is offered at "${alias}" twice \u2014 a gate names each stage once` });
|
|
40376
|
+
}
|
|
40377
|
+
if (repeated.length > 0) return void 0;
|
|
40378
|
+
if (new Set(declared.in).size === field.options.length) {
|
|
40379
|
+
findings.push({
|
|
40380
|
+
severity: "error",
|
|
40381
|
+
path: at2,
|
|
40382
|
+
message: `is offered at every stage of "${field.alias}" \u2014 that is the act with no condition, so drop the clause`
|
|
40383
|
+
});
|
|
40384
|
+
return void 0;
|
|
40385
|
+
}
|
|
40386
|
+
return { field, in: declared.in };
|
|
40000
40387
|
}
|
|
40001
40388
|
function resolveActInputs(declared, at2, entity, fieldByAlias, findings) {
|
|
40002
40389
|
const stated2 = Object.entries(declared);
|
|
@@ -40023,7 +40410,7 @@ function resolveActInputs(declared, at2, entity, fieldByAlias, findings) {
|
|
|
40023
40410
|
}
|
|
40024
40411
|
return inputs;
|
|
40025
40412
|
}
|
|
40026
|
-
function resolveSelectionActs(path23, screen, entity, fieldByAlias, templates, findings) {
|
|
40413
|
+
function resolveSelectionActs(path23, screen, entity, roles, fieldByAlias, templates, findings) {
|
|
40027
40414
|
const declared = screen.acts?.selection;
|
|
40028
40415
|
if (declared === void 0) return [];
|
|
40029
40416
|
const at2 = `${path23}.acts.selection`;
|
|
@@ -40037,6 +40424,13 @@ function resolveSelectionActs(path23, screen, entity, fieldByAlias, templates, f
|
|
|
40037
40424
|
}
|
|
40038
40425
|
actDuplicates(declared, at2, "the selection bar", findings);
|
|
40039
40426
|
for (const act of declared) {
|
|
40427
|
+
if (act.when !== void 0) {
|
|
40428
|
+
findings.push({
|
|
40429
|
+
severity: "error",
|
|
40430
|
+
path: at2,
|
|
40431
|
+
message: `"${act.label}" states \`when\`, and the bar acts over a SET \u2014 a stage is read off one row, so there is none to read here`
|
|
40432
|
+
});
|
|
40433
|
+
}
|
|
40040
40434
|
if (!("kind" in act)) continue;
|
|
40041
40435
|
const verb = act.kind === "agent" ? `runs "${act.agent}"` : `hands the row to "${act.workflow}"`;
|
|
40042
40436
|
findings.push({
|
|
@@ -40045,7 +40439,7 @@ function resolveSelectionActs(path23, screen, entity, fieldByAlias, templates, f
|
|
|
40045
40439
|
message: `"${act.label}" ${verb}, which is about ONE row \u2014 the bar makes one paper from many, so a per-row verb belongs in the row's \u22EF`
|
|
40046
40440
|
});
|
|
40047
40441
|
}
|
|
40048
|
-
return declared.flatMap((act, index) => resolveAct(act, `${at2}.${index}`, screen, entity, fieldByAlias, templates, findings));
|
|
40442
|
+
return declared.flatMap((act, index) => resolveAct(act, `${at2}.${index}`, screen, entity, roles, fieldByAlias, templates, findings));
|
|
40049
40443
|
}
|
|
40050
40444
|
function resolveTemplateAct(alias, templates, at2, findings) {
|
|
40051
40445
|
const template = templates.find((candidate) => candidate.alias === alias);
|
|
@@ -40488,9 +40882,27 @@ function recordHeader(screen, roles) {
|
|
|
40488
40882
|
figure: figure === void 0 || inBand.has(figure.alias) ? void 0 : figure
|
|
40489
40883
|
};
|
|
40490
40884
|
}
|
|
40491
|
-
function
|
|
40885
|
+
function recordHistory(entity, entities, roles) {
|
|
40886
|
+
const declared = lifecycleHistory(entity, roles);
|
|
40887
|
+
if (declared === void 0) return void 0;
|
|
40888
|
+
const child = entities.find((candidate) => candidate.alias === declared.alias);
|
|
40889
|
+
const back = entity.fields.find(
|
|
40890
|
+
(field) => field.type === "select_record_link" && field.target_entity === declared.alias
|
|
40891
|
+
);
|
|
40892
|
+
if (child === void 0 || back === void 0) return void 0;
|
|
40893
|
+
const column = (alias) => child.fields.find((field) => field.alias === alias);
|
|
40894
|
+
const link = column(HISTORY_PARENT);
|
|
40895
|
+
const stage = column(HISTORY_STAGE);
|
|
40896
|
+
const at2 = column(HISTORY_AT);
|
|
40897
|
+
if (link === void 0 || stage === void 0 || at2 === void 0) return void 0;
|
|
40898
|
+
const by = column(HISTORY_BY);
|
|
40899
|
+
return { plan: { child, link, stage, at: at2, ...by === void 0 ? {} : { by } }, child, back };
|
|
40900
|
+
}
|
|
40901
|
+
function recordSections(entity, entities, roles, header, shape = CUSTOM_SHAPE, door = recordDoor(shape, entity, roles), drawn = void 0) {
|
|
40492
40902
|
const band = recordBand(shape, entity, roles, door);
|
|
40493
40903
|
const lifecycle = fieldsWithRole(entity, roles, "lifecycle")[0];
|
|
40904
|
+
const flow = lifecycle === void 0 ? void 0 : roleOf(roles, entity.alias, lifecycle.alias);
|
|
40905
|
+
const history = recordHistory(entity, entities, roles);
|
|
40494
40906
|
const prose = recipeOf(shape, entity, roles, door).leads === "prose" ? entity.fields.filter((field) => field.type === "text" && field.format === "markdown") : [];
|
|
40495
40907
|
const charge = recordCharge(shape, entity, roles, door);
|
|
40496
40908
|
const ownSets = fieldsWithRole(entity, roles, "expected_set").filter(
|
|
@@ -40507,6 +40919,7 @@ function recordSections(entity, entities, roles, header, shape = CUSTOM_SHAPE, d
|
|
|
40507
40919
|
const listed = /* @__PURE__ */ new Set();
|
|
40508
40920
|
for (const via of RECORD_CHILD_VIA) {
|
|
40509
40921
|
for (const child of entities) {
|
|
40922
|
+
if (child.alias === history?.child.alias) continue;
|
|
40510
40923
|
for (const link of childLinks(child, entity, roles, via)) {
|
|
40511
40924
|
if (child.alias !== entity.alias && mirrorsOwnOneLink(entity, child, link)) continue;
|
|
40512
40925
|
if (child.alias !== entity.alias) listed.add(child.alias);
|
|
@@ -40550,6 +40963,14 @@ function recordSections(entity, entities, roles, header, shape = CUSTOM_SHAPE, d
|
|
|
40550
40963
|
const owned = new Set(
|
|
40551
40964
|
[
|
|
40552
40965
|
...lifecycle === void 0 ? [] : [lifecycle],
|
|
40966
|
+
// THE BACK-REFERENCE THE DERIVED TABLE PAIRED ONTO THIS RECORD is the
|
|
40967
|
+
// ladder's own rows reached from the other side — a fact listing them by
|
|
40968
|
+
// name would be the history drawn twice, once as a list of ids.
|
|
40969
|
+
...history === void 0 ? [] : [history.back],
|
|
40970
|
+
// …AND THE FIGURES A SECTION BELOW HEADS ITSELF WITH.
|
|
40971
|
+
...Object.values(drawn ?? {}).flatMap(
|
|
40972
|
+
(clause) => clause.draw !== "ledger" ? [] : clause.summary.flatMap((alias) => entity.fields.filter((field) => field.alias === alias))
|
|
40973
|
+
),
|
|
40553
40974
|
...ownSets,
|
|
40554
40975
|
...files,
|
|
40555
40976
|
...judged === void 0 ? [] : [judged],
|
|
@@ -40593,7 +41014,9 @@ function recordSections(entity, entities, roles, header, shape = CUSTOM_SHAPE, d
|
|
|
40593
41014
|
{
|
|
40594
41015
|
kind: "progress",
|
|
40595
41016
|
field: lifecycle,
|
|
40596
|
-
outcomes:
|
|
41017
|
+
outcomes: flow?.outcomes ?? [],
|
|
41018
|
+
...flow?.phases === void 0 ? {} : { phases: flow.phases },
|
|
41019
|
+
...history === void 0 ? {} : { history: history.plan }
|
|
40597
41020
|
}
|
|
40598
41021
|
],
|
|
40599
41022
|
...sets,
|
|
@@ -40735,6 +41158,12 @@ function factTier(field, entity, roles, grouped) {
|
|
|
40735
41158
|
if (field.type === "formula" && formulaResultType(field.formula) === "date") return "tertiary";
|
|
40736
41159
|
return "primary";
|
|
40737
41160
|
}
|
|
41161
|
+
function lookedUpMark(child, roles, link) {
|
|
41162
|
+
if (link.type !== "select_record_link") return void 0;
|
|
41163
|
+
return child.fields.find(
|
|
41164
|
+
(field) => field.type === "lookup" && field.source_field_alias === link.alias && roleOf(roles, link.target_entity, field.lookup_field_alias)?.role === "mark"
|
|
41165
|
+
);
|
|
41166
|
+
}
|
|
40738
41167
|
function recordItinerary(archetype, recipeSlot, child, roles) {
|
|
40739
41168
|
if (archetype !== "work_record" || recipeSlot !== "children") return void 0;
|
|
40740
41169
|
const when = fieldsWithRole(child, roles, "when")[0];
|
|
@@ -40742,19 +41171,26 @@ function recordItinerary(archetype, recipeSlot, child, roles) {
|
|
|
40742
41171
|
if (when === void 0 || lifecycle === void 0) return void 0;
|
|
40743
41172
|
const slot2 = fieldsWithRole(child, roles, "slot")[0];
|
|
40744
41173
|
const kind = fieldsWithRole(child, roles, "category")[0];
|
|
41174
|
+
const icons = kind === void 0 ? void 0 : roleOf(roles, child.alias, kind.alias)?.icons;
|
|
40745
41175
|
const mark = fieldsWithRole(child, roles, "mark")[0];
|
|
41176
|
+
const provider = fieldsWithRole(child, roles, "party")[0];
|
|
41177
|
+
const providerMark = provider === void 0 ? void 0 : lookedUpMark(child, roles, provider);
|
|
40746
41178
|
const reference = fieldsWithRole(child, roles, "reference")[0];
|
|
40747
|
-
const
|
|
40748
|
-
|
|
40749
|
-
);
|
|
41179
|
+
const measures = fieldsWithRole(child, roles, "measure");
|
|
41180
|
+
const span = measures.find((field) => roleOf(roles, child.alias, field.alias)?.counts === "days");
|
|
41181
|
+
const duration3 = measures.find((field) => roleOf(roles, child.alias, field.alias)?.counts === "hours");
|
|
40750
41182
|
return {
|
|
40751
41183
|
when,
|
|
40752
41184
|
lifecycle,
|
|
40753
41185
|
...slot2 === void 0 ? {} : { slot: slot2 },
|
|
40754
41186
|
...kind === void 0 ? {} : { kind },
|
|
41187
|
+
...icons === void 0 ? {} : { icons },
|
|
40755
41188
|
...mark === void 0 ? {} : { mark },
|
|
41189
|
+
...provider === void 0 ? {} : { provider },
|
|
41190
|
+
...providerMark === void 0 ? {} : { providerMark },
|
|
40756
41191
|
...reference === void 0 ? {} : { reference },
|
|
40757
|
-
...span === void 0 ? {} : { span }
|
|
41192
|
+
...span === void 0 ? {} : { span },
|
|
41193
|
+
...duration3 === void 0 ? {} : { duration: duration3 }
|
|
40758
41194
|
};
|
|
40759
41195
|
}
|
|
40760
41196
|
function registerRuns(screen, roles) {
|
|
@@ -40882,8 +41318,8 @@ function slotOf(section, roles) {
|
|
|
40882
41318
|
function childRegisterShape(slot2) {
|
|
40883
41319
|
return slot2 === "ledger" ? "transaction_ledger" : "lifecycle_desk";
|
|
40884
41320
|
}
|
|
40885
|
-
function recordRecipe(shape, entity, entities, roles, header, door = recordDoor(shape, entity, roles)) {
|
|
40886
|
-
const sections = recordSections(entity, entities, roles, header, shape, door);
|
|
41321
|
+
function recordRecipe(shape, entity, entities, roles, header, door = recordDoor(shape, entity, roles), drawn = void 0) {
|
|
41322
|
+
const sections = recordSections(entity, entities, roles, header, shape, door, drawn);
|
|
40887
41323
|
const entries2 = Object.entries(recipeOf(shape, entity, roles, door).steps).flatMap(
|
|
40888
41324
|
([slot2, draw]) => sections.flatMap((section) => slotOf(section, roles) === slot2 ? [{ draw, slot: slot2, section }] : [])
|
|
40889
41325
|
);
|
|
@@ -40940,9 +41376,44 @@ function validateWorkspacePlan(model, roles, apps, rules = {}) {
|
|
|
40940
41376
|
findings.push(...checkDeskStages(resolved.screen));
|
|
40941
41377
|
findings.push(...checkSeatedColumns(resolved.screen));
|
|
40942
41378
|
findings.push(...checkPresentation(resolved.screen, roles, model.entities));
|
|
41379
|
+
findings.push(...recordSizeNotes(resolved.screen, model.entities, roles));
|
|
40943
41380
|
}
|
|
40944
41381
|
return findings;
|
|
40945
41382
|
}
|
|
41383
|
+
var FACTS_PER_SECTION = 8;
|
|
41384
|
+
var OPTIONS_IN_PLACE = 8;
|
|
41385
|
+
function recordSizeNotes(screen, entities, roles) {
|
|
41386
|
+
const at2 = `apps.${screen.app.alias}.screen`;
|
|
41387
|
+
const notes = [];
|
|
41388
|
+
const entity = screen.entity;
|
|
41389
|
+
for (const field of entity.fields) {
|
|
41390
|
+
const decl = roleOf(roles, entity.alias, field.alias);
|
|
41391
|
+
if (decl?.role === "lifecycle" && decl.history === void 0) {
|
|
41392
|
+
notes.push({
|
|
41393
|
+
severity: "note",
|
|
41394
|
+
path: `field_roles.${entity.alias}.${field.alias}`,
|
|
41395
|
+
message: `is a lifecycle keeping no history, so its steps draw no dates \u2014 declare \`history: true\` to keep the day each stage was reached`
|
|
41396
|
+
});
|
|
41397
|
+
}
|
|
41398
|
+
const options = decl?.role === "expected_set" ? optionSource(field, entity, entities)?.field.options : void 0;
|
|
41399
|
+
if (options !== void 0 && options.length > OPTIONS_IN_PLACE) {
|
|
41400
|
+
notes.push({
|
|
41401
|
+
severity: "note",
|
|
41402
|
+
path: `field_roles.${entity.alias}.${field.alias}`,
|
|
41403
|
+
message: `is a set of ${options.length} entries, above the ${OPTIONS_IN_PLACE} a record edits in place \u2014 the reader searches a list rather than reading a row of chips`
|
|
41404
|
+
});
|
|
41405
|
+
}
|
|
41406
|
+
}
|
|
41407
|
+
for (const group of screen.factGroups) {
|
|
41408
|
+
if (group.fields.length <= FACTS_PER_SECTION) continue;
|
|
41409
|
+
notes.push({
|
|
41410
|
+
severity: "note",
|
|
41411
|
+
path: `${at2}.facts`,
|
|
41412
|
+
message: `"${group.caption}" holds ${group.fields.length} facts, above the ${FACTS_PER_SECTION} one section reads at a glance \u2014 a band that long is two`
|
|
41413
|
+
});
|
|
41414
|
+
}
|
|
41415
|
+
return notes;
|
|
41416
|
+
}
|
|
40946
41417
|
var DESK_STAGES_MAX = 6;
|
|
40947
41418
|
function checkDeskStages(screen) {
|
|
40948
41419
|
const strip = screen.tabs;
|
|
@@ -41008,7 +41479,7 @@ function untilRefusal(screen, entities, drawn, until) {
|
|
|
41008
41479
|
function checkFactGroups(screen, entities, roles) {
|
|
41009
41480
|
if (screen.factGroups.length === 0) return [];
|
|
41010
41481
|
const path23 = `apps.${screen.app.alias}.screen.facts.groups`;
|
|
41011
|
-
const sections = recordSections(screen.entity, entities, roles, recordHeader(screen, roles), screen.screen.shape, screen.record);
|
|
41482
|
+
const sections = recordSections(screen.entity, entities, roles, recordHeader(screen, roles), screen.screen.shape, screen.record, screen.screen.sections);
|
|
41012
41483
|
const facts = new Set(sections.flatMap((section) => section.kind === "facts" ? section.fields.map((field) => field.alias) : []));
|
|
41013
41484
|
return screen.factGroups.flatMap(
|
|
41014
41485
|
(group) => group.fields.flatMap(
|
|
@@ -41023,7 +41494,7 @@ function checkFactGroups(screen, entities, roles) {
|
|
|
41023
41494
|
);
|
|
41024
41495
|
}
|
|
41025
41496
|
function childRegisters(screen, entities, roles) {
|
|
41026
|
-
const sections = recordSections(screen.entity, entities, roles, recordHeader(screen, roles), screen.screen.shape, screen.record);
|
|
41497
|
+
const sections = recordSections(screen.entity, entities, roles, recordHeader(screen, roles), screen.screen.shape, screen.record, screen.screen.sections);
|
|
41027
41498
|
return new Map(sections.flatMap((section) => section.kind === "children" ? [[section.child.alias, section]] : []));
|
|
41028
41499
|
}
|
|
41029
41500
|
function checkSectionDraws(screen, entities, roles) {
|
|
@@ -41040,7 +41511,7 @@ function checkSectionDraws(screen, entities, roles) {
|
|
|
41040
41511
|
{
|
|
41041
41512
|
severity: "error",
|
|
41042
41513
|
path: at2,
|
|
41043
|
-
message: offered.length === 0 ? `names "${alias}", and this record draws no register of a child's rows
|
|
41514
|
+
message: offered.length === 0 ? `names "${alias}", and this record draws no register of a child's rows to be drawn another way` : `names "${alias}", which this record draws no register of \u2014 its registers are addressed by ${offered.join(", ")}`
|
|
41044
41515
|
}
|
|
41045
41516
|
];
|
|
41046
41517
|
}
|
|
@@ -41049,10 +41520,26 @@ function checkSectionDraws(screen, entities, roles) {
|
|
|
41049
41520
|
{
|
|
41050
41521
|
severity: "error",
|
|
41051
41522
|
path: at2,
|
|
41052
|
-
message: `names "${alias}", whose rows this record is merely the ${section.via} of \u2014
|
|
41523
|
+
message: `names "${alias}", whose rows this record is merely the ${section.via} of \u2014 these draws are over the rows a record OWNS, and a line added to a history belongs to the job it is about rather than to what is reading it`
|
|
41053
41524
|
}
|
|
41054
41525
|
];
|
|
41055
41526
|
}
|
|
41527
|
+
if (clause.draw === "ledger") {
|
|
41528
|
+
return clause.summary.flatMap((field) => {
|
|
41529
|
+
const carried = screen.entity.fields.find((one) => one.alias === field);
|
|
41530
|
+
if (carried === void 0) {
|
|
41531
|
+
return [{ severity: "error", path: `${at2}.summary`, message: `heads "${alias}" with "${field}", which is not a field of entity "${screen.entity.alias}"` }];
|
|
41532
|
+
}
|
|
41533
|
+
const result = resolvedFieldType(carried, screen.entity, entities);
|
|
41534
|
+
return result === "number" ? [] : [
|
|
41535
|
+
{
|
|
41536
|
+
severity: "error",
|
|
41537
|
+
path: `${at2}.summary`,
|
|
41538
|
+
message: `heads "${alias}" with "${field}", ${result === void 0 ? `a ${carried.type} whose result this model does not state` : `a ${result}`} \u2014 a book's head is what the rows come to, which is a figure`
|
|
41539
|
+
}
|
|
41540
|
+
];
|
|
41541
|
+
});
|
|
41542
|
+
}
|
|
41056
41543
|
const named2 = [["cost", clause.cost], ["sell", clause.sell]].flatMap(([slot2, field]) => field === void 0 ? [] : [{ slot: slot2, field }]);
|
|
41057
41544
|
if (named2.length === 0) {
|
|
41058
41545
|
return [
|
|
@@ -41091,8 +41578,10 @@ function checkSectionDraws(screen, entities, roles) {
|
|
|
41091
41578
|
});
|
|
41092
41579
|
}
|
|
41093
41580
|
function unregisteredChildren(screen, entities, roles, registered) {
|
|
41094
|
-
const entries2 = recordRecipe(screen.screen.shape, screen.entity, entities, roles, recordHeader(screen, roles), screen.record);
|
|
41095
|
-
const priced = new Set(
|
|
41581
|
+
const entries2 = recordRecipe(screen.screen.shape, screen.entity, entities, roles, recordHeader(screen, roles), screen.record, screen.screen.sections);
|
|
41582
|
+
const priced = new Set(
|
|
41583
|
+
Object.entries(screen.screen.sections ?? {}).flatMap(([alias, clause]) => clause.draw === "worksheet" ? [alias] : [])
|
|
41584
|
+
);
|
|
41096
41585
|
const read2 = new Set(
|
|
41097
41586
|
entries2.flatMap(({ draw, section }) => {
|
|
41098
41587
|
if (draw !== "open") return [];
|
|
@@ -41110,7 +41599,7 @@ function checkChildRegisters(screen, entities, roles, registered) {
|
|
|
41110
41599
|
}));
|
|
41111
41600
|
}
|
|
41112
41601
|
function sectionActTargets(screen, entities, roles) {
|
|
41113
|
-
const sections = recordSections(screen.entity, entities, roles, recordHeader(screen, roles), screen.screen.shape, screen.record);
|
|
41602
|
+
const sections = recordSections(screen.entity, entities, roles, recordHeader(screen, roles), screen.screen.shape, screen.record, screen.screen.sections);
|
|
41114
41603
|
const found = /* @__PURE__ */ new Map();
|
|
41115
41604
|
for (const alias of screen.sectionActs.keys()) {
|
|
41116
41605
|
found.set(
|
|
@@ -41125,7 +41614,7 @@ function checkSectionActs(screen, entities, roles) {
|
|
|
41125
41614
|
const targets = sectionActTargets(screen, entities, roles);
|
|
41126
41615
|
const offered = [
|
|
41127
41616
|
...new Set(
|
|
41128
|
-
recordSections(screen.entity, entities, roles, recordHeader(screen, roles), screen.screen.shape, screen.record).flatMap(
|
|
41617
|
+
recordSections(screen.entity, entities, roles, recordHeader(screen, roles), screen.screen.shape, screen.record, screen.screen.sections).flatMap(
|
|
41129
41618
|
(section) => sectionAliases(section)
|
|
41130
41619
|
)
|
|
41131
41620
|
)
|
|
@@ -41821,6 +42310,167 @@ function modelWritesDiacritics(entities) {
|
|
|
41821
42310
|
const strings = readerFacingStrings(entities).filter((entry) => languageWords(entry.value).length > 0);
|
|
41822
42311
|
return strings.filter((entry) => hasDiacritic(entry.value)).length * 2 > strings.length;
|
|
41823
42312
|
}
|
|
42313
|
+
var HISTORY_WORDS = {
|
|
42314
|
+
plain: {
|
|
42315
|
+
table: (stage) => `${stage} history`,
|
|
42316
|
+
enteredAt: "Entered at",
|
|
42317
|
+
by: "Moved by"
|
|
42318
|
+
},
|
|
42319
|
+
diacritics: {
|
|
42320
|
+
table: (stage) => `L\u1ECBch s\u1EED ${stage.toLocaleLowerCase("vi")}`,
|
|
42321
|
+
enteredAt: "Th\u1EDDi \u0111i\u1EC3m",
|
|
42322
|
+
by: "Ng\u01B0\u1EDDi chuy\u1EC3n"
|
|
42323
|
+
}
|
|
42324
|
+
};
|
|
42325
|
+
function expandModelDerivations(model, roles) {
|
|
42326
|
+
const derived = [];
|
|
42327
|
+
const findings = [];
|
|
42328
|
+
const addedRoles = {};
|
|
42329
|
+
const backLinks = /* @__PURE__ */ new Map();
|
|
42330
|
+
const words = HISTORY_WORDS[modelWritesDiacritics(model.entities) ? "diacritics" : "plain"];
|
|
42331
|
+
const declared = new Map(model.entities.map((entity) => [entity.alias, entity]));
|
|
42332
|
+
const derivedBy = /* @__PURE__ */ new Map();
|
|
42333
|
+
for (const entity of model.entities) {
|
|
42334
|
+
const history = lifecycleHistory(entity, roles);
|
|
42335
|
+
if (history === void 0) continue;
|
|
42336
|
+
const at2 = `field_roles.${entity.alias}.${history.field.alias}`;
|
|
42337
|
+
if (history.field.type !== "select") continue;
|
|
42338
|
+
const owner = derivedBy.get(history.alias);
|
|
42339
|
+
if (owner !== void 0) {
|
|
42340
|
+
findings.push({
|
|
42341
|
+
severity: "error",
|
|
42342
|
+
path: at2,
|
|
42343
|
+
message: `history is kept in "${history.alias}", which is already "${owner}"'s \u2014 one table records one entity's stage changes, so name this one with \`history: "<alias>"\``
|
|
42344
|
+
});
|
|
42345
|
+
continue;
|
|
42346
|
+
}
|
|
42347
|
+
derivedBy.set(history.alias, entity.alias);
|
|
42348
|
+
const back = `${history.field.alias}_history`;
|
|
42349
|
+
const shown = entity.fields.find((field) => {
|
|
42350
|
+
const role = roleOf(roles, entity.alias, field.alias)?.role;
|
|
42351
|
+
return role === "identity" || role === "reference";
|
|
42352
|
+
});
|
|
42353
|
+
const backLink = {
|
|
42354
|
+
alias: back,
|
|
42355
|
+
label: words.table(history.field.label),
|
|
42356
|
+
type: "select_record_link",
|
|
42357
|
+
target_entity: history.alias,
|
|
42358
|
+
cardinality: "many",
|
|
42359
|
+
sync_both_ways: true,
|
|
42360
|
+
paired_field_alias: HISTORY_PARENT
|
|
42361
|
+
};
|
|
42362
|
+
const standing = entity.fields.find((field) => field.alias === back);
|
|
42363
|
+
if (standing !== void 0 && spellField(standing) !== spellField(backLink)) {
|
|
42364
|
+
findings.push({
|
|
42365
|
+
severity: "error",
|
|
42366
|
+
path: at2,
|
|
42367
|
+
message: `history derives a "${back}" link on "${entity.alias}", which already declares one of another shape \u2014 rename that field, or name the history table with \`history: "<alias>"\``
|
|
42368
|
+
});
|
|
42369
|
+
continue;
|
|
42370
|
+
}
|
|
42371
|
+
const table = {
|
|
42372
|
+
alias: history.alias,
|
|
42373
|
+
label: words.table(history.field.label),
|
|
42374
|
+
fields: [
|
|
42375
|
+
{
|
|
42376
|
+
alias: HISTORY_PARENT,
|
|
42377
|
+
// The ROW, not the set: this column holds one case, one order, one
|
|
42378
|
+
// job, and a link labelled with the table's plural reads as a list.
|
|
42379
|
+
label: entity.singular ?? entity.label,
|
|
42380
|
+
type: "select_record_link",
|
|
42381
|
+
target_entity: entity.alias,
|
|
42382
|
+
cardinality: "one",
|
|
42383
|
+
required: true,
|
|
42384
|
+
sync_both_ways: true,
|
|
42385
|
+
paired_field_alias: back,
|
|
42386
|
+
...shown === void 0 ? {} : { display_field_aliases: [shown.alias] }
|
|
42387
|
+
},
|
|
42388
|
+
{
|
|
42389
|
+
alias: HISTORY_STAGE,
|
|
42390
|
+
label: history.field.label,
|
|
42391
|
+
type: "select",
|
|
42392
|
+
options: history.field.options,
|
|
42393
|
+
required: true
|
|
42394
|
+
},
|
|
42395
|
+
// STATED, NOT STAMPED. `derive_from: "created_at"` would be true for
|
|
42396
|
+
// every row the app writes and unfixable for every row that predates
|
|
42397
|
+
// the table: the column is then read-only, so an older record's opening
|
|
42398
|
+
// rung can only be dated the day the table was made, which is a false
|
|
42399
|
+
// record. The write states the moment instead, and a backfill states
|
|
42400
|
+
// the true one.
|
|
42401
|
+
{ alias: HISTORY_AT, label: words.enteredAt, type: "date", format: "datetime", required: true },
|
|
42402
|
+
{ alias: HISTORY_BY, label: words.by, type: "select_member" }
|
|
42403
|
+
],
|
|
42404
|
+
views: []
|
|
42405
|
+
};
|
|
42406
|
+
const already2 = declared.get(history.alias);
|
|
42407
|
+
if (already2 !== void 0) {
|
|
42408
|
+
const differs = shapeDifference(already2, table);
|
|
42409
|
+
if (differs !== void 0) {
|
|
42410
|
+
findings.push({
|
|
42411
|
+
severity: "error",
|
|
42412
|
+
path: `entities.${history.alias}`,
|
|
42413
|
+
message: `is derived from ${at2}'s \`history\`, and this declaration differs \u2014 ${differs}. Drop it, or drop the flag and keep the table yours`
|
|
42414
|
+
});
|
|
42415
|
+
continue;
|
|
42416
|
+
}
|
|
42417
|
+
} else {
|
|
42418
|
+
derived.push(table);
|
|
42419
|
+
}
|
|
42420
|
+
addedRoles[history.alias] = {
|
|
42421
|
+
...roles[history.alias],
|
|
42422
|
+
[HISTORY_PARENT]: { role: "parent" },
|
|
42423
|
+
[HISTORY_STAGE]: { role: "category" },
|
|
42424
|
+
[HISTORY_AT]: { role: "when" }
|
|
42425
|
+
};
|
|
42426
|
+
if (standing === void 0) backLinks.set(entity.alias, backLink);
|
|
42427
|
+
}
|
|
42428
|
+
if (derived.length === 0 && backLinks.size === 0 && Object.keys(addedRoles).length === 0 && findings.length === 0) {
|
|
42429
|
+
return { model, field_roles: roles, findings };
|
|
42430
|
+
}
|
|
42431
|
+
return {
|
|
42432
|
+
model: {
|
|
42433
|
+
...model,
|
|
42434
|
+
entities: [
|
|
42435
|
+
...model.entities.map((entity) => {
|
|
42436
|
+
const back = backLinks.get(entity.alias);
|
|
42437
|
+
return back === void 0 ? entity : { ...entity, fields: [...entity.fields, back] };
|
|
42438
|
+
}),
|
|
42439
|
+
...derived
|
|
42440
|
+
]
|
|
42441
|
+
},
|
|
42442
|
+
field_roles: { ...roles, ...addedRoles },
|
|
42443
|
+
findings
|
|
42444
|
+
};
|
|
42445
|
+
}
|
|
42446
|
+
function spellField(field) {
|
|
42447
|
+
const say = (key, value) => `${key}=${value === void 0 ? "\u2014" : String(value)}`;
|
|
42448
|
+
return [
|
|
42449
|
+
say("type", field.type),
|
|
42450
|
+
say("required", field.required === true),
|
|
42451
|
+
say("target", field.type === "select_record_link" ? field.target_entity : void 0),
|
|
42452
|
+
// "many" is the schema's default, so a file that states nothing states it.
|
|
42453
|
+
say("cardinality", field.type === "select_record_link" ? field.cardinality ?? "many" : void 0),
|
|
42454
|
+
say("paired", field.type === "select_record_link" ? field.paired_field_alias ?? "\u2014" : void 0),
|
|
42455
|
+
say("synced", field.type === "select_record_link" ? field.sync_both_ways === true : void 0),
|
|
42456
|
+
say("format", field.type === "date" ? field.format ?? "\u2014" : void 0),
|
|
42457
|
+
say("options", field.type === "select" ? field.options.map((option) => option.alias).join("|") : void 0)
|
|
42458
|
+
].join(" ");
|
|
42459
|
+
}
|
|
42460
|
+
function shapeDifference(declared, derivedTable) {
|
|
42461
|
+
const held = new Map(declared.fields.map((field) => [field.alias, field]));
|
|
42462
|
+
for (const wanted3 of derivedTable.fields) {
|
|
42463
|
+
const field = held.get(wanted3.alias);
|
|
42464
|
+
if (field === void 0) return `it declares no "${wanted3.alias}"`;
|
|
42465
|
+
if (spellField(field) !== spellField(wanted3)) {
|
|
42466
|
+
return `its "${wanted3.alias}" is ${spellField(field)} where the derivation states ${spellField(wanted3)}`;
|
|
42467
|
+
}
|
|
42468
|
+
}
|
|
42469
|
+
const wanted2 = new Set(derivedTable.fields.map((field) => field.alias));
|
|
42470
|
+
const extra = declared.fields.filter((field) => !wanted2.has(field.alias));
|
|
42471
|
+
if (extra.length > 0) return `it also declares ${extra.map((field) => `"${field.alias}"`).join(", ")}, which the derivation does not write`;
|
|
42472
|
+
return void 0;
|
|
42473
|
+
}
|
|
41824
42474
|
function checkModelLanguage(entities) {
|
|
41825
42475
|
const strings = readerFacingStrings(entities).filter((entry) => languageWords(entry.value).length > 0);
|
|
41826
42476
|
const marked = strings.filter((entry) => hasDiacritic(entry.value));
|
|
@@ -42083,7 +42733,7 @@ function resultSideEffects(result) {
|
|
|
42083
42733
|
}
|
|
42084
42734
|
|
|
42085
42735
|
// src/version.ts
|
|
42086
|
-
var VERSION = "0.
|
|
42736
|
+
var VERSION = "0.211.0";
|
|
42087
42737
|
|
|
42088
42738
|
// src/timezone.ts
|
|
42089
42739
|
function machineTimezone() {
|
|
@@ -50661,7 +51311,8 @@ var WORDS = {
|
|
|
50661
51311
|
register: (entity, screen) => `Every row of ${entity} the reader may see, as the "${screen}" screen lists them.`,
|
|
50662
51312
|
record: (entity, screen) => `One row of ${entity} by id \u2014 the record page the "${screen}" screen opens.`,
|
|
50663
51313
|
picker: (table, column) => `The ${table} a "${column}" may point at, narrowed by what the reader typed.`,
|
|
50664
|
-
children: (child, parent, link) => `The ${child} that name one ${parent} through "${link}"
|
|
51314
|
+
children: (child, parent, link) => `The ${child} that name one ${parent} through "${link}".`,
|
|
51315
|
+
history: (child, parent) => `The ${child} of one row of ${parent}, latest first.`
|
|
50665
51316
|
}
|
|
50666
51317
|
},
|
|
50667
51318
|
diacritics: {
|
|
@@ -50688,7 +51339,8 @@ var WORDS = {
|
|
|
50688
51339
|
register: (entity, screen) => `M\u1ECDi d\xF2ng ${entity} m\xE0 ng\u01B0\u1EDDi xem \u0111\u01B0\u1EE3c ph\xE9p th\u1EA5y, theo c\xE1ch m\xE0n h\xECnh "${screen}" li\u1EC7t k\xEA.`,
|
|
50689
51340
|
record: (entity, screen) => `M\u1ED9t d\xF2ng ${entity} theo id \u2014 trang b\u1EA3n ghi m\xE0 m\xE0n h\xECnh "${screen}" m\u1EDF.`,
|
|
50690
51341
|
picker: (table, column) => `C\xE1c ${table} m\xE0 "${column}" c\xF3 th\u1EC3 tr\u1ECF t\u1EDBi, thu h\u1EB9p theo ch\u1EEF ng\u01B0\u1EDDi xem g\xF5.`,
|
|
50691
|
-
children: (child, parent, link) => `C\xE1c ${child} g\u1EAFn v\u1EDBi m\u1ED9t ${parent} qua "${link}"
|
|
51342
|
+
children: (child, parent, link) => `C\xE1c ${child} g\u1EAFn v\u1EDBi m\u1ED9t ${parent} qua "${link}".`,
|
|
51343
|
+
history: (child, parent) => `${child} c\u1EE7a m\u1ED9t d\xF2ng ${parent}, m\u1EDBi nh\u1EA5t tr\u01B0\u1EDBc.`
|
|
50692
51344
|
}
|
|
50693
51345
|
}
|
|
50694
51346
|
};
|
|
@@ -50699,6 +51351,9 @@ function sectionRelation(section) {
|
|
|
50699
51351
|
if (section.kind === "expected_set" && section.source === "child") {
|
|
50700
51352
|
return { kind: section.kind, child: section.child, via: section.via, link: section.link };
|
|
50701
51353
|
}
|
|
51354
|
+
if (section.kind === "progress" && section.history !== void 0) {
|
|
51355
|
+
return { kind: "progress", child: section.history.child, via: "parent", link: section.history.link };
|
|
51356
|
+
}
|
|
50702
51357
|
return void 0;
|
|
50703
51358
|
}
|
|
50704
51359
|
function sectionChild(section) {
|
|
@@ -50710,6 +51365,7 @@ function childQueryAlias(entity, child, link) {
|
|
|
50710
51365
|
function childSurfaceScreen(parent, entry, mounts) {
|
|
50711
51366
|
const relation = sectionRelation(entry.section);
|
|
50712
51367
|
if (relation === void 0 || relation.via !== "parent" || relation.child.alias === parent.entity.alias) return void 0;
|
|
51368
|
+
if (relation.kind === "progress") return void 0;
|
|
50713
51369
|
if (!mounts && relation.kind !== "thread") return void 0;
|
|
50714
51370
|
const { child, link } = relation;
|
|
50715
51371
|
const shape = childRegisterShape(entry.slot);
|
|
@@ -50787,6 +51443,12 @@ function signatureOf(drawn, summed = /* @__PURE__ */ new Set()) {
|
|
|
50787
51443
|
}
|
|
50788
51444
|
return void 0;
|
|
50789
51445
|
}
|
|
51446
|
+
function liveOptions(source, aliased) {
|
|
51447
|
+
const tables = aliased.filter((candidate) => candidate.table.name === source.entity.label);
|
|
51448
|
+
if (tables.length !== 1) return void 0;
|
|
51449
|
+
const found = tables[0].fields.filter((candidate) => candidate.field.name === source.field.label);
|
|
51450
|
+
return found.length === 1 ? found[0].options : void 0;
|
|
51451
|
+
}
|
|
50790
51452
|
function bindTable(at2, entity, entities, roles, wanted2, aliased, live, missing) {
|
|
50791
51453
|
const tables = aliased.filter((candidate) => candidate.table.name === entity.label);
|
|
50792
51454
|
if (tables.length !== 1) {
|
|
@@ -50814,9 +51476,11 @@ function bindTable(at2, entity, entities, roles, wanted2, aliased, live, missing
|
|
|
50814
51476
|
missing.push(`${at2}: field "${field.label}" is a ${type ?? "field of unknown type"} in this workspace and a ${field.type} in the model`);
|
|
50815
51477
|
continue;
|
|
50816
51478
|
}
|
|
50817
|
-
const
|
|
50818
|
-
|
|
50819
|
-
|
|
51479
|
+
const source = optionSource(field, entity, entities);
|
|
51480
|
+
const carrier = field.type === "select" ? found[0].options : source === void 0 ? void 0 : liveOptions(source, aliased);
|
|
51481
|
+
const options = source === void 0 || carrier === void 0 ? void 0 : new Map(
|
|
51482
|
+
source.field.options.flatMap((option) => {
|
|
51483
|
+
const live2 = carrier.find((candidate) => candidate.option.label === option.label);
|
|
50820
51484
|
return live2 === void 0 ? [] : [[option.alias, { alias: live2.alias, id: live2.option.id }]];
|
|
50821
51485
|
})
|
|
50822
51486
|
);
|
|
@@ -51025,7 +51689,7 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
|
|
|
51025
51689
|
const subject = bindSubject(at2, screen2, entities, roles, aliased, live, scopeOf, missing);
|
|
51026
51690
|
const narrowing = subject === void 0 ? void 0 : scopeNarrowing(screen2, subject, own.fields);
|
|
51027
51691
|
const band = recordBand(screen2.screen.shape, screen2.entity, roles, screen2.record);
|
|
51028
|
-
const sections = recordRecipe(screen2.screen.shape, screen2.entity, entities, roles, header, screen2.record).flatMap(
|
|
51692
|
+
const sections = recordRecipe(screen2.screen.shape, screen2.entity, entities, roles, header, screen2.record, screen2.screen.sections).flatMap(
|
|
51029
51693
|
(entry2) => unfiled(entry2, under)
|
|
51030
51694
|
);
|
|
51031
51695
|
const planned = sections.map((entry2) => entry2.section);
|
|
@@ -51134,6 +51798,9 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
|
|
|
51134
51798
|
// a face has to ask for the column itself, and a child whose rows open
|
|
51135
51799
|
// nothing has no other read to carry it.
|
|
51136
51800
|
...itinerary?.mark === void 0 ? [] : [itinerary.mark.alias],
|
|
51801
|
+
// …AND THE PROVIDER'S, which is a lookup no column of a register draws
|
|
51802
|
+
// either: the run is the only surface that puts a face beside the name.
|
|
51803
|
+
...itinerary?.providerMark === void 0 ? [] : [itinerary.providerMark.alias],
|
|
51137
51804
|
...drawn.flatMap((field) => {
|
|
51138
51805
|
const decl = roleOf(roles, child.alias, field.alias);
|
|
51139
51806
|
if (decl === void 0) return [];
|
|
@@ -51215,6 +51882,13 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
|
|
|
51215
51882
|
bindChild(entry2, section.fields, void 0, void 0, void 0, [section.body]);
|
|
51216
51883
|
continue;
|
|
51217
51884
|
}
|
|
51885
|
+
if (section.kind === "progress") {
|
|
51886
|
+
const history = section.history;
|
|
51887
|
+
if (history !== void 0) {
|
|
51888
|
+
bindChild(entry2, [history.stage, history.at], void 0, void 0, void 0, history.by === void 0 ? [] : [history.by]);
|
|
51889
|
+
}
|
|
51890
|
+
continue;
|
|
51891
|
+
}
|
|
51218
51892
|
if (section.kind === "children") {
|
|
51219
51893
|
const drawn = drawnColumns2(section.child, section.fields, roles);
|
|
51220
51894
|
if (drawn.length > 0) {
|
|
@@ -51417,7 +52091,7 @@ function planTableNames(screen, entities, roles) {
|
|
|
51417
52091
|
}
|
|
51418
52092
|
function surfaceTables(screen, entities, roles, mounts, under) {
|
|
51419
52093
|
const names = [screen.entity.label, ...screen.scope === null ? [] : [screen.scope.entity.label]];
|
|
51420
|
-
const recipe = recordRecipe(screen.screen.shape, screen.entity, entities, roles, recordHeader(screen, roles), screen.record);
|
|
52094
|
+
const recipe = recordRecipe(screen.screen.shape, screen.entity, entities, roles, recordHeader(screen, roles), screen.record, screen.screen.sections);
|
|
51421
52095
|
const sections = recipe.map((entry) => entry.section);
|
|
51422
52096
|
for (const { target } of factLinkTargets(screen, sections, entities, roles, true)) names.push(target.label);
|
|
51423
52097
|
for (const { target } of createLinkTargets(screen, entities, roles, true, under)) names.push(target.label);
|
|
@@ -51592,7 +52266,7 @@ function surfaceReads(queries, entry) {
|
|
|
51592
52266
|
columns: projection(unique2([...child.fields.values(), ...child.operands.values(), ...drawer]))
|
|
51593
52267
|
},
|
|
51594
52268
|
params: { [child.param]: { type: "record_link", table_id: entry.table.id } },
|
|
51595
|
-
description: entry.words.queries.children(child.entity.label, entry.screen.entity.label, child.link.label)
|
|
52269
|
+
description: child.section === "progress" ? entry.words.queries.history(child.entity.label, entry.screen.entity.label) : entry.words.queries.children(child.entity.label, entry.screen.entity.label, child.link.label)
|
|
51596
52270
|
};
|
|
51597
52271
|
if (child.surface !== void 0) surfaceReads(queries, child.surface);
|
|
51598
52272
|
}
|
|
@@ -51679,10 +52353,11 @@ var specFieldSchema = zod_default.object({
|
|
|
51679
52353
|
until: specUntilSchema.optional(),
|
|
51680
52354
|
/**
|
|
51681
52355
|
* WHAT THIS MEASURE'S NUMBER COUNTS, where the unit decides how a surface
|
|
51682
|
-
* DRAWS the row — days are what give a bar its length,
|
|
51683
|
-
* unit is drawn as the
|
|
52356
|
+
* DRAWS the row — days are what give a bar its length, hours are how long
|
|
52357
|
+
* one takes inside its day, and a figure with no unit is drawn as the
|
|
52358
|
+
* number it is.
|
|
51684
52359
|
*/
|
|
51685
|
-
counts: zod_default.
|
|
52360
|
+
counts: zod_default.enum(["days", "hours"]).optional()
|
|
51686
52361
|
}).strict().check((ctx) => {
|
|
51687
52362
|
if (ctx.value.until !== void 0 && ctx.value.due !== true) {
|
|
51688
52363
|
ctx.issues.push({
|
|
@@ -51702,6 +52377,10 @@ var specPresentationSchema = zod_default.object({
|
|
|
51702
52377
|
until: specColumnAliasSchema.optional()
|
|
51703
52378
|
}).strict();
|
|
51704
52379
|
var specActPlaceSchema = zod_default.literal("cta");
|
|
52380
|
+
var specActWhenSchema = zod_default.strictObject({
|
|
52381
|
+
field: specColumnAliasSchema,
|
|
52382
|
+
in: zod_default.array(specOptionIdSchema)
|
|
52383
|
+
}).describe("The act stands down unless this column holds one of these options");
|
|
51705
52384
|
var specTemplateActSchema = zod_default.object({
|
|
51706
52385
|
kind: zod_default.literal("template"),
|
|
51707
52386
|
label: zod_default.string().min(1),
|
|
@@ -51711,6 +52390,7 @@ var specTemplateActSchema = zod_default.object({
|
|
|
51711
52390
|
/** The input that workflow declares — one record for a row's act, the ticked set for a selection's. */
|
|
51712
52391
|
input: zod_default.string().min(1),
|
|
51713
52392
|
place: specActPlaceSchema.optional(),
|
|
52393
|
+
when: specActWhenSchema.optional(),
|
|
51714
52394
|
/**
|
|
51715
52395
|
* WHAT THE PRESS OPENS INSTEAD OF RUNNING — one of the app's own components,
|
|
51716
52396
|
* drawn in a panel the runtime owns, handed the act and the rows it was
|
|
@@ -51737,7 +52417,8 @@ var specAgentActSchema = zod_default.object({
|
|
|
51737
52417
|
agent: runtimeAliasSchema,
|
|
51738
52418
|
/** The columns of this surface the run may write, in the order the act named them. */
|
|
51739
52419
|
fills: zod_default.array(specColumnAliasSchema).min(1),
|
|
51740
|
-
place: specActPlaceSchema.optional()
|
|
52420
|
+
place: specActPlaceSchema.optional(),
|
|
52421
|
+
when: specActWhenSchema.optional()
|
|
51741
52422
|
}).strict();
|
|
51742
52423
|
var RECORD_ID_COLUMN = "__source_record_id";
|
|
51743
52424
|
var specActInputSchema = zod_default.object({
|
|
@@ -51753,7 +52434,8 @@ var specWorkflowActSchema = zod_default.object({
|
|
|
51753
52434
|
workflow: runtimeAliasSchema,
|
|
51754
52435
|
/** What the press hands the run, in the order the act named them. */
|
|
51755
52436
|
inputs: zod_default.array(specActInputSchema).min(1),
|
|
51756
|
-
place: specActPlaceSchema.optional()
|
|
52437
|
+
place: specActPlaceSchema.optional(),
|
|
52438
|
+
when: specActWhenSchema.optional()
|
|
51757
52439
|
}).strict();
|
|
51758
52440
|
var specActSchema = zod_default.discriminatedUnion("kind", [specTemplateActSchema, specAgentActSchema, specWorkflowActSchema]);
|
|
51759
52441
|
var specColumnsExportSchema = zod_default.object({
|
|
@@ -52037,6 +52719,22 @@ var specItinerarySchema = zod_default.object({
|
|
|
52037
52719
|
* or it is not drawn.
|
|
52038
52720
|
*/
|
|
52039
52721
|
mark: specColumnAliasSchema.optional(),
|
|
52722
|
+
/**
|
|
52723
|
+
* THE GLYPH EACH KIND IS DRAWN WITH, by option id — the model's own answer
|
|
52724
|
+
* ({@link fieldRolesSchema}'s `icons`).
|
|
52725
|
+
*
|
|
52726
|
+
* A run's node is a dot the width of a word, so a stop with no picture of
|
|
52727
|
+
* its own reads as every other stop. Nothing derives a glyph from an option
|
|
52728
|
+
* written in the business's language, and a kind this map leaves out draws
|
|
52729
|
+
* the neutral one rather than a guess.
|
|
52730
|
+
*/
|
|
52731
|
+
kinds: zod_default.record(specOptionIdSchema, zod_default.strictObject({ icon: zod_default.string().min(1) })).optional(),
|
|
52732
|
+
/** WHO PERFORMS THE STOP — the link naming them, whose row may carry a mark of its own. */
|
|
52733
|
+
provider: specColumnAliasSchema.optional(),
|
|
52734
|
+
/** THAT PROVIDER'S FACE, looked up onto this row — a name alone is a line of identical initials. */
|
|
52735
|
+
providerMark: specColumnAliasSchema.optional(),
|
|
52736
|
+
/** HOW LONG IT TAKES inside its day — the measure whose number counts hours. */
|
|
52737
|
+
duration: specColumnAliasSchema.optional(),
|
|
52040
52738
|
/** The confirmation the reader quotes when they ring about the stop. */
|
|
52041
52739
|
reference: specColumnAliasSchema.optional(),
|
|
52042
52740
|
/** How many days the stop RUNS — a stay of three nights is one row and three days of the run. */
|
|
@@ -52102,17 +52800,65 @@ var specChildSchema = zod_default.object({
|
|
|
52102
52800
|
write: zod_default.object({ alias: runtimeAliasSchema, param: zod_default.string().min(1) }).strict().optional()
|
|
52103
52801
|
}).strict();
|
|
52104
52802
|
var specPricedChildSchema = specChildSchema.extend({ worksheet: specWorksheetSchema });
|
|
52803
|
+
var specProgressHistorySchema = zod_default.strictObject({
|
|
52804
|
+
/** The named query these rows are read through. */
|
|
52805
|
+
query: runtimeAliasSchema,
|
|
52806
|
+
/** That query's one param — the record whose history these are. */
|
|
52807
|
+
param: zod_default.string().min(1),
|
|
52808
|
+
/** The stage the row records. */
|
|
52809
|
+
stage: specColumnAliasSchema,
|
|
52810
|
+
/** When it was reached. */
|
|
52811
|
+
at: specColumnAliasSchema,
|
|
52812
|
+
/** Who moved it, where the rows record that. */
|
|
52813
|
+
by: specColumnAliasSchema.optional(),
|
|
52814
|
+
/**
|
|
52815
|
+
* WHICH RUNG EACH RECORDED STAGE IS — the history's own option id → the
|
|
52816
|
+
* lifecycle's.
|
|
52817
|
+
*
|
|
52818
|
+
* The two are separate select fields and each mints its own ids, so a
|
|
52819
|
+
* surface matching one against the other matches nothing and every rung
|
|
52820
|
+
* draws undated. The join is by the MODEL's option alias, made where both
|
|
52821
|
+
* fields are bound; matching on the rendered label instead would stop the
|
|
52822
|
+
* first time somebody renames an option on one side.
|
|
52823
|
+
*/
|
|
52824
|
+
stages: zod_default.record(specOptionIdSchema, specOptionIdSchema)
|
|
52825
|
+
}).describe("The dated stage changes behind this ladder");
|
|
52826
|
+
var specProgressPhaseSchema = zod_default.strictObject({ label: zod_default.string().min(1), stages: zod_default.array(specOptionIdSchema).min(1) }).describe("One part of the flow and the stages under it");
|
|
52105
52827
|
var sectionActs = { acts: zod_default.array(specActSchema).min(1).optional() };
|
|
52106
52828
|
var specSectionSchema = zod_default.discriminatedUnion("kind", [
|
|
52107
|
-
zod_default.object({
|
|
52829
|
+
zod_default.object({
|
|
52830
|
+
kind: zod_default.literal("facts"),
|
|
52831
|
+
key: zod_default.string().min(1),
|
|
52832
|
+
/** What the band is called, where the plan named it; absent, the kit's own word. */
|
|
52833
|
+
heading: zod_default.string().min(1).optional(),
|
|
52834
|
+
/**
|
|
52835
|
+
* ONE GROUP PER NAMED BAND, so a section is one heading. SEVERAL only in
|
|
52836
|
+
* the one section nobody named — everything no group claimed, which the
|
|
52837
|
+
* record's derivation may itself split (a payment's particulars, then the
|
|
52838
|
+
* record and the party it was with) and which reads as one fold either
|
|
52839
|
+
* way. Two headingless sections would put the same word in the rail twice.
|
|
52840
|
+
*/
|
|
52841
|
+
groups: zod_default.array(specFactGroupSchema).min(1),
|
|
52842
|
+
...sectionActs
|
|
52843
|
+
}).strict(),
|
|
52108
52844
|
zod_default.object({
|
|
52109
52845
|
kind: zod_default.literal("progress"),
|
|
52110
52846
|
key: zod_default.string().min(1),
|
|
52111
52847
|
heading: zod_default.string().min(1),
|
|
52112
52848
|
field: specColumnAliasSchema,
|
|
52113
52849
|
outcomes: zod_default.array(specOptionIdSchema).min(1).optional(),
|
|
52850
|
+
phases: zod_default.array(specProgressPhaseSchema).min(1).optional(),
|
|
52851
|
+
history: specProgressHistorySchema.optional(),
|
|
52114
52852
|
...sectionActs
|
|
52115
|
-
}).strict()
|
|
52853
|
+
}).strict().check((ctx) => {
|
|
52854
|
+
if (ctx.value.history !== void 0 && ctx.value.phases === void 0) {
|
|
52855
|
+
ctx.issues.push({
|
|
52856
|
+
code: "custom",
|
|
52857
|
+
input: ctx.value,
|
|
52858
|
+
message: "dates its rungs and names none \u2014 a section carrying `history` states its `phases`, which is what says which steps the ladder draws"
|
|
52859
|
+
});
|
|
52860
|
+
}
|
|
52861
|
+
}),
|
|
52116
52862
|
/** The entity's own multi-select: the options ARE the set. */
|
|
52117
52863
|
zod_default.object({
|
|
52118
52864
|
kind: zod_default.literal("expected_set"),
|
|
@@ -52144,6 +52890,16 @@ var specSectionSchema = zod_default.discriminatedUnion("kind", [
|
|
|
52144
52890
|
heading: zod_default.string().min(1),
|
|
52145
52891
|
draw: zod_default.enum(["register", "itinerary", "ledger"]),
|
|
52146
52892
|
child: specChildSchema,
|
|
52893
|
+
/**
|
|
52894
|
+
* WHAT THE ROWS COME TO, stated ABOVE them — the record's own figures,
|
|
52895
|
+
* drawn as the section's statement head.
|
|
52896
|
+
*
|
|
52897
|
+
* A book of payments with nothing in it is a heading, a verb and a
|
|
52898
|
+
* void; the same section headed by what is owed says what the rows
|
|
52899
|
+
* would be for before the first one exists. So these are columns of the
|
|
52900
|
+
* RECORD, not of the child, and they are drawn at zero rows too.
|
|
52901
|
+
*/
|
|
52902
|
+
summary: zod_default.array(specColumnAliasSchema).min(1).optional(),
|
|
52147
52903
|
...sectionActs
|
|
52148
52904
|
}).strict(),
|
|
52149
52905
|
/** THE SHEET AND ITS FIGURES ARE ONE CLAUSE — neither is declarable without the other. */
|
|
@@ -52708,7 +53464,7 @@ function planWorksheets(entry) {
|
|
|
52708
53464
|
return entry.sections.flatMap(({ draw, section }) => {
|
|
52709
53465
|
if (draw === "related" || section.kind !== "children" || section.via !== "parent") return [];
|
|
52710
53466
|
const clause = drawn[section.child.alias];
|
|
52711
|
-
if (clause
|
|
53467
|
+
if (clause?.draw !== "worksheet") return [];
|
|
52712
53468
|
const child = entry.children.get(childKey(section.child.alias, section.link.alias));
|
|
52713
53469
|
if (child === void 0) return [];
|
|
52714
53470
|
const cost = named(child, clause.cost);
|
|
@@ -52760,6 +53516,13 @@ function factsPlan(screen, roles, fields) {
|
|
|
52760
53516
|
const named2 = screen.factGroups.map((group) => ({ caption: group.caption, fields: group.fields.filter((field) => own.has(field.alias)) }));
|
|
52761
53517
|
return [...named2.filter((group) => group.fields.length > 0), ...derived(fields.filter((field) => !claimed.has(field.alias)))];
|
|
52762
53518
|
}
|
|
53519
|
+
function factSections(bands) {
|
|
53520
|
+
const unnamed = bands.filter((band) => band.caption === void 0);
|
|
53521
|
+
return [
|
|
53522
|
+
...bands.filter((band) => band.caption !== void 0).map((band) => [band]),
|
|
53523
|
+
...unnamed.length === 0 ? [] : [unnamed]
|
|
53524
|
+
];
|
|
53525
|
+
}
|
|
52763
53526
|
|
|
52764
53527
|
// src/plan_record_children.ts
|
|
52765
53528
|
function childRole(child, role) {
|
|
@@ -52895,20 +53658,22 @@ function childExtras(child, roles) {
|
|
|
52895
53658
|
}
|
|
52896
53659
|
function boundAct(entry, templates, act, input) {
|
|
52897
53660
|
const place = act.place === void 0 ? {} : { place: act.place };
|
|
53661
|
+
const gateField = act.when === void 0 ? void 0 : entry.fields.get(act.when.field.alias);
|
|
53662
|
+
const stands = gateField === void 0 ? {} : { when: { field: gateField.alias, in: optionIds(gateField, act.when?.in ?? []) } };
|
|
52898
53663
|
if (act.kind === "workflow") {
|
|
52899
53664
|
const inputs = act.inputs.flatMap((taken) => {
|
|
52900
53665
|
if (taken.field === null) return [{ name: taken.name, field: RECORD_ID_COLUMN }];
|
|
52901
53666
|
const bound = entry.fields.get(taken.field.alias);
|
|
52902
53667
|
return bound === void 0 ? [] : [{ name: taken.name, field: bound.alias }];
|
|
52903
53668
|
});
|
|
52904
|
-
return inputs.length < act.inputs.length ? void 0 : { kind: "workflow", label: act.label, key: act.workflow, workflow: act.workflow, inputs, ...place };
|
|
53669
|
+
return inputs.length < act.inputs.length ? void 0 : { kind: "workflow", label: act.label, key: act.workflow, workflow: act.workflow, inputs, ...place, ...stands };
|
|
52905
53670
|
}
|
|
52906
53671
|
if (act.kind === "agent") {
|
|
52907
53672
|
const fills = act.fills.flatMap((field) => {
|
|
52908
53673
|
const bound = entry.fields.get(field.alias);
|
|
52909
53674
|
return bound === void 0 ? [] : [bound.alias];
|
|
52910
53675
|
});
|
|
52911
|
-
return fills.length === 0 ? void 0 : { kind: "agent", label: act.label, key: act.agent, agent: act.agent, fills, ...place };
|
|
53676
|
+
return fills.length === 0 ? void 0 : { kind: "agent", label: act.label, key: act.agent, agent: act.agent, fills, ...place, ...stands };
|
|
52912
53677
|
}
|
|
52913
53678
|
return templates.has(act.template.alias) ? {
|
|
52914
53679
|
kind: "template",
|
|
@@ -52916,7 +53681,8 @@ function boundAct(entry, templates, act, input) {
|
|
|
52916
53681
|
key: act.template.alias,
|
|
52917
53682
|
workflow: generateAlias(act.template.alias),
|
|
52918
53683
|
input,
|
|
52919
|
-
...place
|
|
53684
|
+
...place,
|
|
53685
|
+
...stands
|
|
52920
53686
|
} : void 0;
|
|
52921
53687
|
}
|
|
52922
53688
|
function specRecordActs(entry, templates) {
|
|
@@ -53258,15 +54024,21 @@ function specChild(child, roles, archetype, slot2, entry, expected) {
|
|
|
53258
54024
|
return [{ field: bound.alias, role, ...alias === signature ? { signature: true } : {} }];
|
|
53259
54025
|
});
|
|
53260
54026
|
const plan = child.itinerary;
|
|
54027
|
+
const kinds = plan === void 0 ? void 0 : itineraryKinds(child, plan);
|
|
54028
|
+
const total = itineraryTotal(entry, child);
|
|
53261
54029
|
const itinerary = plan === void 0 || ref(plan.when) === void 0 || ref(plan.lifecycle) === void 0 ? void 0 : {
|
|
53262
54030
|
when: ref(plan.when) ?? "",
|
|
53263
54031
|
lifecycle: ref(plan.lifecycle) ?? "",
|
|
53264
54032
|
...ref(plan.slot) === void 0 ? {} : { slot: ref(plan.slot) ?? "" },
|
|
53265
54033
|
...ref(plan.kind) === void 0 ? {} : { kind: ref(plan.kind) ?? "" },
|
|
54034
|
+
...kinds === void 0 ? {} : { kinds },
|
|
53266
54035
|
...ref(plan.mark) === void 0 ? {} : { mark: ref(plan.mark) ?? "" },
|
|
54036
|
+
...ref(plan.provider) === void 0 ? {} : { provider: ref(plan.provider) ?? "" },
|
|
54037
|
+
...ref(plan.providerMark) === void 0 ? {} : { providerMark: ref(plan.providerMark) ?? "" },
|
|
54038
|
+
...ref(plan.duration) === void 0 ? {} : { duration: ref(plan.duration) ?? "" },
|
|
53267
54039
|
...ref(plan.reference) === void 0 ? {} : { reference: ref(plan.reference) ?? "" },
|
|
53268
54040
|
...ref(plan.span) === void 0 ? {} : { span: ref(plan.span) ?? "" },
|
|
53269
|
-
...
|
|
54041
|
+
...total === void 0 ? {} : { total }
|
|
53270
54042
|
};
|
|
53271
54043
|
const opens = childOpens(entry, child);
|
|
53272
54044
|
const editor = childEditor(entry, child);
|
|
@@ -53293,6 +54065,15 @@ function specChild(child, roles, archetype, slot2, entry, expected) {
|
|
|
53293
54065
|
...written === void 0 ? {} : { write: { alias: writeAlias(written), param: recordParam(written) } }
|
|
53294
54066
|
};
|
|
53295
54067
|
}
|
|
54068
|
+
function itineraryKinds(child, plan) {
|
|
54069
|
+
if (plan.kind === void 0 || plan.icons === void 0) return void 0;
|
|
54070
|
+
const options = childReads(child).get(plan.kind.alias)?.options;
|
|
54071
|
+
const drawn = Object.entries(plan.icons).flatMap(([alias, icon]) => {
|
|
54072
|
+
const live = options?.get(alias);
|
|
54073
|
+
return live === void 0 ? [] : [[live.id, { icon }]];
|
|
54074
|
+
});
|
|
54075
|
+
return drawn.length === 0 ? void 0 : Object.fromEntries(drawn);
|
|
54076
|
+
}
|
|
53296
54077
|
function specWorksheet(sheet) {
|
|
53297
54078
|
return {
|
|
53298
54079
|
...sheet.quantity === void 0 ? {} : { quantity: sheet.quantity.bound.alias },
|
|
@@ -53323,6 +54104,22 @@ function itineraryTotal(entry, child) {
|
|
|
53323
54104
|
});
|
|
53324
54105
|
return banded ? void 0 : amount.label;
|
|
53325
54106
|
}
|
|
54107
|
+
function specFactSections(entry, roles, fields, stands) {
|
|
54108
|
+
return factSections(specFactGroups(entry, roles, fields)).map((bands, at2) => {
|
|
54109
|
+
const caption = bands[0]?.caption;
|
|
54110
|
+
return {
|
|
54111
|
+
kind: "facts",
|
|
54112
|
+
key: `facts:${at2}`,
|
|
54113
|
+
...caption === void 0 ? {} : { heading: caption },
|
|
54114
|
+
groups: bands.map(({ caption: _named, ...band }) => band),
|
|
54115
|
+
// THE VERBS THE PLAN PUT ON THE FACTS stand on the FIRST section: the
|
|
54116
|
+
// plan addresses a facts band by nothing (`sectionAliases`), so an act
|
|
54117
|
+
// that reaches here came from the record and belongs at its head rather
|
|
54118
|
+
// than repeated over every band.
|
|
54119
|
+
...at2 === 0 ? stands : {}
|
|
54120
|
+
};
|
|
54121
|
+
});
|
|
54122
|
+
}
|
|
53326
54123
|
function specFactGroups(entry, roles, fields) {
|
|
53327
54124
|
const grouped = groupedFacts(entry.screen);
|
|
53328
54125
|
return factsPlan(entry.screen, roles, fields).map((band) => ({
|
|
@@ -53351,6 +54148,26 @@ function specFilesOwed(entry, roles, held) {
|
|
|
53351
54148
|
const archetype = recordArchetype(entry.screen.screen.shape, entry.screen.entity, roles);
|
|
53352
54149
|
return archetype === "evidence" ? { count: 1 } : void 0;
|
|
53353
54150
|
}
|
|
54151
|
+
function specHistory(entry, section) {
|
|
54152
|
+
const plan = section.history;
|
|
54153
|
+
if (plan === void 0) return void 0;
|
|
54154
|
+
const child = entry.children.get(childKey(plan.child.alias, plan.link.alias));
|
|
54155
|
+
const ladder = entry.fields.get(section.field.alias);
|
|
54156
|
+
if (child === void 0 || ladder === void 0) return void 0;
|
|
54157
|
+
const reads = childReads(child);
|
|
54158
|
+
const stage = reads.get(plan.stage.alias);
|
|
54159
|
+
const at2 = reads.get(plan.at.alias)?.alias;
|
|
54160
|
+
if (stage === void 0 || at2 === void 0) return void 0;
|
|
54161
|
+
const stages = Object.fromEntries(
|
|
54162
|
+
[...stage.options ?? /* @__PURE__ */ new Map()].flatMap(([alias, recorded]) => {
|
|
54163
|
+
const rung = ladder.options?.get(alias);
|
|
54164
|
+
return rung === void 0 ? [] : [[recorded.id, rung.id]];
|
|
54165
|
+
})
|
|
54166
|
+
);
|
|
54167
|
+
if (Object.keys(stages).length === 0) return void 0;
|
|
54168
|
+
const by = plan.by === void 0 ? void 0 : reads.get(plan.by.alias)?.alias;
|
|
54169
|
+
return { query: child.alias, param: child.param, stage: stage.alias, at: at2, ...by === void 0 ? {} : { by }, stages };
|
|
54170
|
+
}
|
|
53354
54171
|
function specRecord(entry, roles, templates) {
|
|
53355
54172
|
const entity = entry.screen.entity.alias;
|
|
53356
54173
|
const page = entry.screen.record === "page";
|
|
@@ -53372,20 +54189,26 @@ function specRecord(entry, roles, templates) {
|
|
|
53372
54189
|
const stands = acts2.length === 0 ? {} : { acts: acts2 };
|
|
53373
54190
|
switch (section.kind) {
|
|
53374
54191
|
case "facts": {
|
|
53375
|
-
|
|
53376
|
-
if (groups.length > 0) sections.push({ kind: "facts", key: "facts", groups, ...stands });
|
|
54192
|
+
sections.push(...specFactSections(entry, roles, section.fields, stands));
|
|
53377
54193
|
break;
|
|
53378
54194
|
}
|
|
53379
54195
|
case "progress": {
|
|
53380
54196
|
const field = entry.fields.get(section.field.alias);
|
|
53381
54197
|
if (field === void 0) break;
|
|
53382
54198
|
const outcomes = optionIds(field, section.outcomes);
|
|
54199
|
+
const phases = (section.phases ?? []).flatMap((phase) => {
|
|
54200
|
+
const stages = optionIds(field, phase.stages);
|
|
54201
|
+
return stages.length === 0 ? [] : [{ label: phase.label, stages }];
|
|
54202
|
+
});
|
|
54203
|
+
const history = phases.length === 0 ? void 0 : specHistory(entry, section);
|
|
53383
54204
|
sections.push({
|
|
53384
54205
|
kind: "progress",
|
|
53385
54206
|
key: field.alias,
|
|
53386
54207
|
heading: field.label,
|
|
53387
54208
|
field: field.alias,
|
|
53388
54209
|
...outcomes.length === 0 ? {} : { outcomes },
|
|
54210
|
+
...phases.length === 0 ? {} : { phases },
|
|
54211
|
+
...history === void 0 ? {} : { history },
|
|
53389
54212
|
...stands
|
|
53390
54213
|
});
|
|
53391
54214
|
break;
|
|
@@ -53441,12 +54264,18 @@ function specRecord(entry, roles, templates) {
|
|
|
53441
54264
|
sections.push({ kind: "children", key, heading: child.heading, draw: "worksheet", child: { ...bound, worksheet: specWorksheet(sheet) }, ...stands });
|
|
53442
54265
|
break;
|
|
53443
54266
|
}
|
|
54267
|
+
const drawnAs = entry.screen.screen.sections?.[child.entity.alias];
|
|
54268
|
+
const summary = drawnAs?.draw === "ledger" ? drawnAs.summary.flatMap((alias) => {
|
|
54269
|
+
const figure = entry.fields.get(alias);
|
|
54270
|
+
return figure === void 0 ? [] : [figure.alias];
|
|
54271
|
+
}) : [];
|
|
53444
54272
|
sections.push({
|
|
53445
54273
|
kind: "children",
|
|
53446
54274
|
key,
|
|
53447
54275
|
heading: child.heading,
|
|
53448
|
-
draw: ledger ? "ledger" : child.itinerary === void 0 ? "register" : "itinerary",
|
|
54276
|
+
draw: ledger || drawnAs?.draw === "ledger" ? "ledger" : child.itinerary === void 0 ? "register" : "itinerary",
|
|
53449
54277
|
child: bound,
|
|
54278
|
+
...summary.length === 0 ? {} : { summary },
|
|
53450
54279
|
...stands
|
|
53451
54280
|
});
|
|
53452
54281
|
break;
|
|
@@ -53684,7 +54513,18 @@ function checkSurface(at2, alias, table, fields, manifest) {
|
|
|
53684
54513
|
var paramsOf = (manifest, alias) => new Set(Object.keys(manifest.queries?.[alias]?.params ?? {}));
|
|
53685
54514
|
function itineraryReads(plan) {
|
|
53686
54515
|
if (plan === void 0) return [];
|
|
53687
|
-
return [
|
|
54516
|
+
return [
|
|
54517
|
+
plan.when,
|
|
54518
|
+
plan.lifecycle,
|
|
54519
|
+
plan.slot,
|
|
54520
|
+
plan.kind,
|
|
54521
|
+
plan.mark,
|
|
54522
|
+
plan.provider,
|
|
54523
|
+
plan.providerMark,
|
|
54524
|
+
plan.duration,
|
|
54525
|
+
plan.reference,
|
|
54526
|
+
plan.span
|
|
54527
|
+
].filter(
|
|
53688
54528
|
(alias) => alias !== void 0
|
|
53689
54529
|
);
|
|
53690
54530
|
}
|
|
@@ -53718,6 +54558,34 @@ function checkRecord(spec, key, record2, manifest) {
|
|
|
53718
54558
|
for (const section of record2.sections) {
|
|
53719
54559
|
const childAt = `${at2}.${section.key}`;
|
|
53720
54560
|
for (const act of section.acts ?? []) findings.push(...checkAct(childAt, act, record2.fields, manifest, "record"));
|
|
54561
|
+
if (section.kind === "progress" && section.history !== void 0) {
|
|
54562
|
+
const { query, param, stage, at: when, by, stages } = section.history;
|
|
54563
|
+
const rungs = new Set(Object.values(stages));
|
|
54564
|
+
const drawn = [...section.outcomes ?? [], ...(section.phases ?? []).flatMap((phase) => phase.stages)];
|
|
54565
|
+
for (const rung of drawn) {
|
|
54566
|
+
if (!rungs.has(rung)) {
|
|
54567
|
+
findings.push({ at: childAt, message: `dates no step for "${rung}", which its ladder draws \u2014 every rung this section names is a value of its \`stages\` map` });
|
|
54568
|
+
}
|
|
54569
|
+
}
|
|
54570
|
+
const projection2 = projectionOf(manifest.queries?.[query]);
|
|
54571
|
+
if (manifest.queries?.[query] === void 0) {
|
|
54572
|
+
findings.push({ at: childAt, message: `dates its steps from "${query}", which package.json#lotics.queries does not declare` });
|
|
54573
|
+
} else if (!paramsOf(manifest, query).has(param)) {
|
|
54574
|
+
findings.push({ at: childAt, message: `dates its steps through "${param}", which "${query}" declares no param for` });
|
|
54575
|
+
}
|
|
54576
|
+
for (const alias of [stage, when, ...by === void 0 ? [] : [by]]) {
|
|
54577
|
+
if (projection2 !== void 0 && !projection2.outputs.has(alias)) {
|
|
54578
|
+
findings.push({ at: childAt, message: `dates its steps from "${alias}", which "${query}" does not project` });
|
|
54579
|
+
}
|
|
54580
|
+
}
|
|
54581
|
+
}
|
|
54582
|
+
if (section.kind === "children" && section.draw === "ledger") {
|
|
54583
|
+
for (const alias of section.summary ?? []) {
|
|
54584
|
+
if (!Object.hasOwn(record2.fields, alias)) {
|
|
54585
|
+
findings.push({ at: childAt, message: `heads its rows with "${alias}", which this record does not project` });
|
|
54586
|
+
}
|
|
54587
|
+
}
|
|
54588
|
+
}
|
|
53721
54589
|
const child = sectionChild2(section);
|
|
53722
54590
|
if (child === void 0) continue;
|
|
53723
54591
|
findings.push(...checkSurface(childAt, child.query, child.table, child.fields, manifest));
|
|
@@ -53787,6 +54655,12 @@ function checkRecord(spec, key, record2, manifest) {
|
|
|
53787
54655
|
}
|
|
53788
54656
|
function checkAct(at2, act, fields, manifest, surface) {
|
|
53789
54657
|
const findings = [];
|
|
54658
|
+
if (act.when !== void 0 && !Object.hasOwn(fields, act.when.field)) {
|
|
54659
|
+
findings.push({ at: at2, message: `the "${act.label}" act stands down unless "${act.when.field}" holds a stage, and this ${surface} does not project it` });
|
|
54660
|
+
}
|
|
54661
|
+
if (act.when !== void 0 && act.when.in.length === 0) {
|
|
54662
|
+
findings.push({ at: at2, message: `the "${act.label}" act stands down at every stage of "${act.when.field}" \u2014 its gate names none, so the verb can never be pressed` });
|
|
54663
|
+
}
|
|
53790
54664
|
if (act.kind === "agent") {
|
|
53791
54665
|
if (manifest.agents?.[act.agent] === void 0) {
|
|
53792
54666
|
findings.push({ at: at2, message: `an act runs the "${act.agent}" agent, which package.json#lotics.agents does not declare` });
|
|
@@ -53866,6 +54740,11 @@ function checkAppSpec(spec, manifest, components) {
|
|
|
53866
54740
|
findings.push({ at: at2, message: `is read inside one "${scope.entity}", which "${screen.query}" declares no "${scope.param}" param for` });
|
|
53867
54741
|
}
|
|
53868
54742
|
}
|
|
54743
|
+
const bulk = screen.acts?.export;
|
|
54744
|
+
for (const act of [...screen.acts?.selection ?? [], ...bulk === void 0 || bulk.kind === "columns" ? [] : [bulk]]) {
|
|
54745
|
+
if (act.when === void 0) continue;
|
|
54746
|
+
findings.push({ at: at2, message: `the "${act.label}" act states \`when\`, and it reaches a SET \u2014 a gate is read off one row, so there is none to read` });
|
|
54747
|
+
}
|
|
53869
54748
|
const saves = screen.acts?.export;
|
|
53870
54749
|
if (saves?.kind === "columns") {
|
|
53871
54750
|
for (const column of saves.columns) {
|
|
@@ -54117,6 +54996,15 @@ function planWorkflows(entry, roles, templates = /* @__PURE__ */ new Map()) {
|
|
|
54117
54996
|
description: `Change one row of ${surface.screen.entity.label} \u2014 the record surface's own editor, one field per call.`
|
|
54118
54997
|
};
|
|
54119
54998
|
record2(surface.tableAlias, writtenColumns(surface));
|
|
54999
|
+
const append = historyAppend(surface);
|
|
55000
|
+
if (append !== void 0) {
|
|
55001
|
+
record2(append.tableAlias, [
|
|
55002
|
+
append.link.alias,
|
|
55003
|
+
append.stage.alias,
|
|
55004
|
+
append.at.alias,
|
|
55005
|
+
...append.by === void 0 ? [] : [append.by.alias]
|
|
55006
|
+
]);
|
|
55007
|
+
}
|
|
54120
55008
|
}
|
|
54121
55009
|
for (const plan of planCreates(entry, roles)) {
|
|
54122
55010
|
declared[createAlias(plan.entity.alias)] = {
|
|
@@ -54270,6 +55158,7 @@ function writeSource(entry) {
|
|
|
54270
55158
|
const saved = savedBy(entry);
|
|
54271
55159
|
const entity = entry.screen.entity;
|
|
54272
55160
|
const param = recordParam(entity.alias);
|
|
55161
|
+
const append = historyAppend(entry);
|
|
54273
55162
|
const write = (op, name) => `if (includes(keys(i), ${str(name)})) {
|
|
54274
55163
|
await update_records({ table_id: ${str(entry.table.id)}, record_ids: [i.${param}], ${op} });
|
|
54275
55164
|
}`;
|
|
@@ -54286,6 +55175,18 @@ function writeSource(entry) {
|
|
|
54286
55175
|
if (field.type === "select_record_link") {
|
|
54287
55176
|
return [write(`set: { ${str(bound.id)}: i.${bound.alias} == null ? null : [i.${bound.alias}] }`, bound.alias)];
|
|
54288
55177
|
}
|
|
55178
|
+
if (append !== void 0 && field.alias === append.from.alias) {
|
|
55179
|
+
return [
|
|
55180
|
+
`if (includes(keys(i), ${str(bound.alias)})) {
|
|
55181
|
+
const stood = await get_record({ table_id: ${str(entry.table.id)}, record_id: i.${param} });
|
|
55182
|
+
await update_records({ table_id: ${str(entry.table.id)}, record_ids: [i.${param}], set: { ${str(bound.id)}: i.${bound.alias} } });
|
|
55183
|
+
const reached = ${mappedStage(append, `i.${bound.alias}`)};
|
|
55184
|
+
if (stood.data[${str(bound.id)}] != i.${bound.alias} && !isNull(reached)) {
|
|
55185
|
+
${historyRow(append, `i.${param}`, "reached", " ")}
|
|
55186
|
+
}
|
|
55187
|
+
}`
|
|
55188
|
+
];
|
|
55189
|
+
}
|
|
54289
55190
|
return [write(`set: { ${str(bound.id)}: i.${bound.alias} }`, bound.alias)];
|
|
54290
55191
|
});
|
|
54291
55192
|
return `// ${writeAlias(entity.alias)} \u2014 one row of ${entity.label}, changed by the record surface
|
|
@@ -54299,6 +55200,59 @@ validate({
|
|
|
54299
55200
|
${scopeGuard(entry, param)}${blocks.join("\n")}
|
|
54300
55201
|
`;
|
|
54301
55202
|
}
|
|
55203
|
+
function historyAppend(entry) {
|
|
55204
|
+
for (const { section } of entry.sections) {
|
|
55205
|
+
if (section.kind !== "progress" || section.history === void 0) continue;
|
|
55206
|
+
const child = entry.children.get(childKey(section.history.child.alias, section.history.link.alias));
|
|
55207
|
+
const from = entry.fields.get(section.field.alias);
|
|
55208
|
+
if (child === void 0 || from === void 0) continue;
|
|
55209
|
+
const stage = child.fields.get(section.history.stage.alias);
|
|
55210
|
+
const at2 = child.fields.get(section.history.at.alias) ?? child.operands.get(section.history.at.alias);
|
|
55211
|
+
const link = child.operands.get(section.history.link.alias) ?? child.fields.get(section.history.link.alias);
|
|
55212
|
+
if (stage === void 0 || at2 === void 0 || link === void 0) continue;
|
|
55213
|
+
const mapped = [...from.options ?? /* @__PURE__ */ new Map()].flatMap(([alias, option]) => {
|
|
55214
|
+
const mirrored = stage.options?.get(alias);
|
|
55215
|
+
return mirrored === void 0 ? [] : [[option.id, mirrored.id]];
|
|
55216
|
+
});
|
|
55217
|
+
if (mapped.length === 0) continue;
|
|
55218
|
+
const by = section.history.by === void 0 ? void 0 : child.operands.get(section.history.by.alias);
|
|
55219
|
+
return {
|
|
55220
|
+
table: child.table.id,
|
|
55221
|
+
tableAlias: child.tableAlias,
|
|
55222
|
+
link,
|
|
55223
|
+
stage,
|
|
55224
|
+
at: at2,
|
|
55225
|
+
...by === void 0 ? {} : { by },
|
|
55226
|
+
from,
|
|
55227
|
+
mapped
|
|
55228
|
+
};
|
|
55229
|
+
}
|
|
55230
|
+
return void 0;
|
|
55231
|
+
}
|
|
55232
|
+
function mappedStage(append, value) {
|
|
55233
|
+
return `${append.mapped.map(([mine, theirs]) => `${value} == ${str(mine)} ? ${str(theirs)} : `).join("")}null`;
|
|
55234
|
+
}
|
|
55235
|
+
function historyRow(append, record2, stage, pad) {
|
|
55236
|
+
const cells = [
|
|
55237
|
+
`${str(append.link.id)}: [${record2}],`,
|
|
55238
|
+
`${str(append.stage.id)}: ${stage},`,
|
|
55239
|
+
// THE MOMENT IS THE SERVER'S CLOCK, taken as the write lands — a caller's
|
|
55240
|
+
// idea of when this happened is a value any caller can write.
|
|
55241
|
+
`${str(append.at.id)}: now(),`,
|
|
55242
|
+
// WHO MOVED IT is the SERVER's answer, never the caller's: a member id sent
|
|
55243
|
+
// as an input is a value any caller can write, so the stamp would say
|
|
55244
|
+
// whoever the presser claimed to be.
|
|
55245
|
+
...append.by === void 0 ? [] : [`${str(append.by.id)}: isNull(runtime.triggered_by_member_id) ? null : [runtime.triggered_by_member_id],`]
|
|
55246
|
+
];
|
|
55247
|
+
return `${pad}await create_records({
|
|
55248
|
+
${pad} table_id: ${str(append.table)},
|
|
55249
|
+
${pad} records: [
|
|
55250
|
+
${pad} {
|
|
55251
|
+
${cells.map((cell) => `${pad} ${cell}`).join("\n")}
|
|
55252
|
+
${pad} },
|
|
55253
|
+
${pad} ],
|
|
55254
|
+
${pad}});`;
|
|
55255
|
+
}
|
|
54302
55256
|
function createValue(input) {
|
|
54303
55257
|
return input.link === void 0 ? `i.${input.name}` : `isNull(i.${input.name}) ? null : [i.${input.name}]`;
|
|
54304
55258
|
}
|
|
@@ -54436,7 +55390,15 @@ function createSource(plan) {
|
|
|
54436
55390
|
...cells.map((cell) => ` ${cell}`),
|
|
54437
55391
|
` },`,
|
|
54438
55392
|
` ],`,
|
|
54439
|
-
`})
|
|
55393
|
+
`});`
|
|
55394
|
+
);
|
|
55395
|
+
const opening = plan.opening;
|
|
55396
|
+
const append = opening === void 0 ? void 0 : historyAppend(entry);
|
|
55397
|
+
const opened = append?.mapped.find(([mine]) => mine === opening?.option.id)?.[1];
|
|
55398
|
+
if (append !== void 0 && opened !== void 0 && plan.table.id === entry.table.id) {
|
|
55399
|
+
lines.push(historyRow(append, "made.record_ids[0]", str(opened), ""));
|
|
55400
|
+
}
|
|
55401
|
+
lines.push(
|
|
54440
55402
|
`return({ status: "success", message: ${str(words.refusals.opened(entity.label))}, data: { ${recordParam(entity.alias)}: made.record_ids[0] } });`
|
|
54441
55403
|
);
|
|
54442
55404
|
return `// ${createAlias(entity.alias)} \u2014 open one row of ${entity.label}.
|
|
@@ -55162,7 +56124,7 @@ Captured ${totalRows} row${totalRows === 1 ? "" : "s"} across ${result.captured.
|
|
|
55162
56124
|
}
|
|
55163
56125
|
|
|
55164
56126
|
// src/model_reference.md
|
|
55165
|
-
var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing the tables, fields, options, views, roles and first rows\na workspace starts with. `lotics scaffold check model.json` proves it offline \u2014\nno account, no network. `lotics setup model.json --email you@company.com` creates\nthe account and applies it. `lotics scaffold apply model.json` applies it again,\ninto the workspace the credential names.\n\n**There are two forms of this file.** The full one, below, spells the model out.\nThe `from` one names a published preset and carries only what this business\ndiffers by \u2014 see \xA7 Starting from a preset, and prefer it whenever a preset fits\nthe trade.\n\nApps are PLANNED here and built afterwards: `apps` names each app\'s screens as a\nshape over an entity, checked against the roles `field_roles` gives its fields,\nso the plan is refused before anyone builds a screen (\xA7 Apps and screens). The\nbuilt app lives in the workspace; publishing that workspace as a package is how\nit ships.\n\n## The rules\n\n- **At least one entity, at most 50.** More tables than that is a data model\n being designed, not scaffolded \u2014 scaffold the rest in a second call.\n- **Adoption is explicit.** `lotics setup` REFUSES an entity whose `label`\n already names a table in the workspace, naming every colliding label at once.\n `lotics scaffold apply` adopts those tables and adds the fields, options and\n views they are missing. Nothing is ever modified or deleted, so applying the\n same model twice creates nothing the second time.\n- **Adoption is by LABEL, not alias.** Change an entity\'s `label` and the next\n run asks for a NEW table beside the old one. Renaming a FIELD is\n `lotics field rename <table> <field> "<new label>" --model <this file>`, which\n moves the platform, this file and every app bound to it together; deleting is\n `lotics run delete_table`. Neither goes through the file.\n- **The file\'s own majority is the language.** A model names no locale \u2014 which\n language it is in is what it mostly says, and `scaffold check` notes the label\n written the other way. The generated screens read the kit\'s pack, and a\n generated WRITE cannot: its refusals run on the server, so they are worded in\n that same majority. Mix the two and the workspace answers in two languages.\n- **`lotics scaffold diff model.json` says where the file and the workspace have\n come apart**, joined on label, entity then field \u2014 the join adoption itself\n makes. It exits 1 on any difference, so a model that is about to be published\n as a preset carries the labels in use rather than the ones it was written with.\n- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere, and the result says\n `rows_skipped: true`: sample rows landing among a customer\'s real ones cannot\n be told apart from them.\n- **After the first run the WORKSPACE is the source of truth.** The file is an\n authoring input, not a mirror \u2014 scaffold never deletes what the file stopped\n naming.\n- **`lotics scaffold check` decides all of it offline**, and reports every\n problem in one run rather than the first: an alias that resolves to nothing, a\n link whose pair is not symmetric, and the rows themselves \u2014 a field the entity\n does not declare, an option alias the field does not declare, a link naming no\n row in the file, a `ref` used twice, a date that is not one, a value on a\n platform-computed field, and a files cell that is neither a relative path\n beside this file nor a `fil_` id.\n\n## Top level\n\n```jsonc\n{\n "entities": [ /* the tables */ ],\n "roles": [ /* workspace groups to create */ ], // optional\n "templates":[ /* inline html / email templates */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* the reporting role each field plays, keyed by entity then field */ }, // optional\n "write_rules": { /* what a CREATE finds, defaults and refuses, keyed by entity */ }, // optional\n "apps": [ /* the screens each app will have, as shapes over entities */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ], // optional\n "preset": { /* a trade\'s branches, for a PUBLISHED model */ } // optional\n}\n```\n\nThe other form names a preset instead of restating one:\n\n```jsonc\n{\n "from": "field_service", // the preset this model starts from, by slug\n "variants": ["crews"], // optional \u2014 its branches to merge in, in order\n "rename": { // optional \u2014 what THIS business calls each table\n "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } }\n },\n "entities": [ /* tables the preset does not declare */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* roles on the preset\'s fields and this business\'s own */ }, // optional\n "write_rules": { /* create-time clauses on the preset\'s entities and its own */ }, // optional\n "apps": [ /* the screens each app will have */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ] // optional\n}\n```\n\n**A model may not carry** `fixtures`, `knowledge` or `knowledge_expects`, and no\n`excel` / `word` / `pdf-form` template: each of those is content that lives in a\npublished bundle, which a model has none of. `apps` here is a plan of screens,\nnever built code. An unknown top-level key is an error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because scaffold matches by label.\n\n## Entity\n\n```jsonc\n{\n "alias": "order",\n "label": "Orders", // the table\'s name, which names the SET it holds\n "singular": "Order", // optional \u2014 ONE of them, for the act that opens one\n "description": "\u2026", // optional\n "fields": [ /* at least one */ ],\n "views": [ /* optional; an entity with none still gets the default grid */ ],\n "read_scope": { /* optional; absent, everyone with access to the table reads every row */\n "any": [\n { "member_of": "sales" }, // a role alias this model declares\n { "field": "scope", "is": ["shared"] } // a single select on this entity, by option alias\n ]\n }\n}\n```\n\n**`singular` is what a create says.** A register\'s own act and the panel it\nopens name the row being made \u2014 `New Order`, `H\u1ED3 s\u01A1 m\u1EDBi` \u2014 while `label` names\nthe table, so without it the button reads `New Orders`. Nothing derives it\n(English plurals are irregular), and most models need none: a Vietnamese noun is\nthe same word either way. `scaffold check` prints it beside the table under *Who\nwrites what* and NOTES a table that will name the set. An act on a child section\nkeeps the label: it adds a line to the register under the record the reader is\nalready standing in.\n\n**`read_scope` is a ROW rule, enforced by the platform.** A row is readable when\nANY clause holds: the reader is in that role\'s group, or the named select carries\none of those options. It is resolved at scaffold into the table\'s own row\nfilters, so an app, a workflow reading for a viewer, and the API all answer the\nsame rows \u2014 a per-record visibility field the app merely honours is a convention,\nnot a gate. `any` may not be empty (a rule nobody satisfies hides the table), and\nevery role alias and option alias in it must be one this model declares.\n\n## Field\n\nEvery field carries `alias`, `label`, an optional `description`, and an optional\n`required` \u2014 advisory only, read by app forms and workflows; the table itself has\nno required constraint. `label` may not contain `{` or `}` (formulas reference\nfields by label at the platform level).\n\n`default` is the value pre-filled into a NEW record. It applies on create only;\nexisting records are never backfilled. Only the types listed below accept one.\n\n### `text`\n\n```jsonc\n{ "alias": "name", "label": "Name", "type": "text",\n "unique": false, // optional \u2014 require distinct values\n "format": "text", // optional \u2014 "text" | "link" | "markdown"\n "default": "" } // optional\n```\n\n### `number`\n\n```jsonc\n{ "alias": "amount", "label": "Amount", "type": "number",\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage"\n "currency": "VND", // optional \u2014 ISO 4217\n "default": 0 } // optional\n```\n\n`format` is what the number IS, and every surface reads it: `currency` prints as\nmoney in the code the row or the field states, `percentage` as a whole percent\nwith its sign. An ABSENT number is drawn absent \u2014 the one exception is a\n`sum` or count rollup the plan reads as a **`measure`**: that is the thing\naccumulated toward a bound, so nothing accumulated yet is zero and the meter\ndraws it. The same rollup read as an `amount` keeps its blank, and so does every\nother role: nothing added to what a row is WORTH means unpriced, not free. A\n`min`, an `avg`, a percentage of nothing and every FORMULA stay blank in any\nrole, because none of them has an answer to give. This is why the pair on one\nscreen reads two ways \u2014 what has come in against what is owed \u2014 and why a\nmeasure\'s own LIMIT, an amount, leaves an unquoted row out of the count rather\nthan reporting it as nothing collected. **AND WHERE THAT LIMIT IS ABSENT THERE IS\nNO LEVEL AT ALL**: a level is a reading AGAINST a bound, so a row that states no\nbound draws nothing \u2014 cell, fact and all \u2014 rather than a numerator whose whole\nmeaning was the comparison. "Collected 0" beside a blank total reads as money\nagainst a job worth nothing. A measure the model gives no limit is a plain figure\nand is unaffected. A share is stored in percent units \u2014 68.1 is 68.1 % \u2014 and the\ncolumn, the fact behind it, the meter it is judged by and the figure over the\nregister all say so.\n\n### `date`\n\n```jsonc\n{ "alias": "placed_on", "label": "Placed on", "type": "date",\n "format": "date", // optional \u2014 "date" | "datetime" | "date_range" | "datetime_range"\n "timezone": "Asia/Ho_Chi_Minh", // optional \u2014 IANA name\n "derive_from": "created_at", // optional \u2014 "created_at" | "updated_at"; makes the field read-only\n "default": "2026-01-01" } // optional; refused together with derive_from\n```\n\n### `boolean`\n\n```jsonc\n{ "alias": "paid", "label": "Paid", "type": "boolean", "default": false }\n```\n\n### `select`\n\n```jsonc\n{ "alias": "tier", "label": "Tier", "type": "select",\n "options": [ // at least one\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "multi": false, // optional\n "default": ["standard"] } // optional \u2014 option ALIASES; one unless multi\n```\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet.\n\n```jsonc\n{ "alias": "owner", "label": "Owner", "type": "select_member", "multi": false }\n```\n\n### `select_record_link`\n\n```jsonc\n{ "alias": "customer", "label": "Customer", "type": "select_record_link",\n "target_entity": "customer", // an entity alias this model declares\n "cardinality": "one", // optional \u2014 "one" | "many" (default "many")\n "sync_both_ways": true, // optional \u2014 keep a paired field on the target\n "paired_field_alias": "orders", // the partner field ON THE TARGET entity\n "display_field_aliases": ["name"] } // optional \u2014 what the link shows / the picker\'s columns\n```\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused.\n\n**A single-valued link needs no partner.** `"cardinality": "one"` on its own is a\nlink that holds one row \u2014 one customer on an invoice, one project on a device \u2014\nand nothing is created on the target. The mirror invariant belongs to a PAIRED\nlink: pair a link when the target\'s own record should list what points at it, and\nleave it unpaired when it should not. Either way the record plan draws the\nrelation as a section on the side it points at, so an unpaired link costs the\ntarget nothing.\n\n### `files`\n\n```jsonc\n{ "alias": "attachments", "label": "Attachments", "type": "files" }\n```\n\n### `formula`\n\n```jsonc\n{ "alias": "total", "label": "Total", "type": "formula",\n "formula": {\n "expression": "{amount} * 1.1", // fields on THIS entity, by alias, in braces\n "output_type": "number", // optional \u2014 what it YIELDS: "number" | "text" | "date" | "datetime" | "boolean"\n "format": "currency", // optional \u2014 how that result is DRAWN: "number" | "currency" | "percentage" | "link"\n "currency": "VND" // optional\n } }\n```\n\n`output_type` is what the expression answers WITH; `format` is how it is printed.\nThe platform infers the result at write time and ignores what you declare, so\nthis is a statement the offline checks read \u2014 which is what lets a role or a\nscreen clause accept a computed value: a caption over a derived name\n(`output_type: "text"`), a period over a settled date (`"date"`). A formula\ndeclaring neither says nothing about its result, and every rule that needs one\nrefuses it by name. `lotics scaffold export` writes the result a live workspace\ncomputed, so exporting a workspace fills these in.\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n```jsonc\n{ "alias": "total_ordered", "label": "Total ordered", "type": "rollup",\n "source_field_alias": "orders", // a select_record_link field on THIS entity\n "aggregate_option": {\n "operation": "sum", // count | sum | avg | median | min | max | range |\n // empty | filled | percent_empty | percent_filled |\n // unique | percent_unique |\n // earliest | latest | date_range |\n // checked | unchecked | percent_checked |\n // percent_unchecked\n "field_key": "amount" // a field ALIAS on the linked entity ("count" may omit it)\n },\n "filter": { /* optional \u2014 see Views; every field_key is an alias on the LINKED entity */ } }\n```\n\nThe operation must be one the aggregated field\'s type allows \u2014 `sum` over a\nnumber, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n```jsonc\n{ "alias": "customer_tier", "label": "Customer tier", "type": "lookup",\n "source_field_alias": "customer", // a select_record_link field on THIS entity\n "lookup_field_alias": "tier", // a field alias on the linked entity\n "order_by": { "field_key": "placed_on", "direction": "desc" } } // optional \u2014 pick the single extreme row\n```\n\n### `autonumber`\n\n```jsonc\n{ "alias": "seq", "label": "No.", "type": "autonumber",\n "prefix": "SO-", // optional \u2014 ignored when template is set\n "padding": 4, // optional \u2014 1..20, zero-pads the integer\n "template": "SO-{YEAR}-{N:4}" } // optional \u2014 {N}, {N:W}, {YEAR}, {YEAR:2}, {MONTH}, {DAY}\n```\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n```jsonc\n{\n "alias": "gold",\n "label": "Gold customers",\n "description": "\u2026", // optional\n "columns": [ // optional \u2014 omit to show every field\n { "field_alias": "name", "visibility": "visible", "width": 240 },\n { "field_alias": "tier", "visibility": "hidden" }\n ],\n "filters": { // optional\n "node_type": "group",\n "logic": "and", // "and" | "or"\n "children": [\n { "node_type": "condition", "type": "select", "field_key": "tier",\n "operator": "has_any_of", "value": ["gold"] }\n ]\n },\n "sort": [ { "field_key": "name", "order": "asc" } ], // optional; order is "asc" | "desc" | null\n "summary": { "amount": "sum" }, // optional \u2014 field alias \u2192 footer operation\n "frozen_columns": 1 // optional\n}\n```\n\nA condition\'s `type` is the field\'s type and its `operator` is one that type\nadmits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n`columns`, when present, is exhaustive and must not be empty: a view renders\nexactly the entries it holds. Omit the key to show every field.\n\n## Roles\n\nA role becomes a workspace group. Members are added afterwards, in the app.\n\n```jsonc\n{ "alias": "sales", "label": "Sales" }\n```\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes. An `html` template renders to a PDF when a workflow generates\nfrom it; `{{name}}` is filled from the workflow\'s data.\n\n```jsonc\n{ "alias": "order_ack", "label": "Order acknowledgement", "type": "email",\n "content": "<p>Hello {{customer}}\u2026</p>" }\n```\n\nA paper that has to look like a counterparty produced it \u2014 an official letter,\nan acceptance minute, a supplier\'s bill \u2014 is the same `html` template with a\nshell around the body: a letterhead, a reference line, a seal and a signature\nblock, and paper grain over everything. One shell, many bodies; the data is the\nonly thing that changes, so a workflow can re-issue it over any record.\n\n```jsonc\n{ "alias": "cong_van", "label": "C\xF4ng v\u0103n", "type": "html",\n "content": "\u2026the page below, as one JSON string\u2026" }\n```\n\n```html\n<style>\n .sheet{position:relative;width:718px;padding:44px 58px 30px;background:#fbfaf6;color:#111;font:14.2px/1.5 \'Liberation Serif\',serif}\n .grain{position:absolute;inset:0;opacity:.34;mix-blend-mode:multiply;background:url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'140\' height=\'140\'><filter id=\'f\'><feTurbulence baseFrequency=\'.9\' numOctaves=\'2\'/><feColorMatrix values=\'0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0\'/></filter><rect width=\'140\' height=\'140\' filter=\'url(%23f)\'/></svg>")}\n .top{display:flex;text-align:center;font-size:13.4px} .top>div{flex:1} .u{display:inline-block;border-bottom:1px solid #111;font-weight:700}\n .ref{display:flex;text-align:center;font-size:13.4px;margin-top:6px} .ref>div{flex:1} .ref .r{font-style:italic}\n h1{text-align:center;font-size:15.6px;margin:26px 0 18px} p{text-align:justify;text-indent:26px;margin:0 0 9px}\n .sig{display:flex;margin-top:20px} .sig .l{flex:1} .sig .r{width:290px;text-align:center;position:relative}\n .sig .nm{font-weight:700;margin-top:96px} .seal{position:absolute;left:4px;top:8px;width:166px;height:166px;opacity:.66;mix-blend-mode:multiply;transform:rotate(-17deg)}\n </style>\n <div class=\'sheet\'><div class=\'grain\'></div>\n <div class=\'top\'><div><b>{{issuer_parent}}</b><br><span class=\'u\'>{{issuer}}</span></div>\n <div><b>C\u1ED8NG H\xD2A X\xC3 H\u1ED8I CH\u1EE6 NGH\u0128A VI\u1EC6T NAM</b><br><span class=\'u\'>\u0110\u1ED9c l\u1EADp - T\u1EF1 do - H\u1EA1nh ph\xFAc</span></div></div>\n <div class=\'ref\'><div>S\u1ED1: {{number}}</div><div class=\'r\'>{{place}}, ng\xE0y {{day}} th\xE1ng {{month}} n\u0103m {{year}}</div></div>\n <h1>{{title}}</h1>\n <p>K\xEDnh g\u1EEDi: {{recipient}}.</p>\n {{{body}}}\n <div class=\'sig\'><div class=\'l\'><b>N\u01A1i nh\u1EADn:</b><br>- Nh\u01B0 tr\xEAn;<br>- L\u01B0u VT.</div>\n <div class=\'r\'><img class=\'seal\' src=\'{{seal_url}}\'><b>{{signer_title}}</b><div class=\'nm\'>{{signer}}</div></div></div>\n </div>\n```\n\n`lotics preview <file.html>` renders any such page to a PNG the way a demo\'s\nprops are made, sized to its content, so a paper can be looked at before it is\nput in a template.\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model, attaching at most 2000 documents between them \u2014 a real data set\nbelongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n`ref` is a local handle (lowercase letters, digits, underscores) that other rows\'\nlink fields address. It is never persisted.\n\nA `files` cell attaches documents: paths relative to this file (no `..`, never\nabsolute), which `check` proves exist and `apply` uploads into the workspace\nbefore any row is written \u2014 a paperwork business seeds its papers with its\nrows. The server accepts only `fil_` ids of files this workspace owns, which is\nwhat the upload leaves behind. After a run that wrote rows, `apply` writes the\nrecord ids beside the file (`<model>.last_run.json`): `delete_records` over\nthem is how a seeded set is reset, and applying again re-dates it.\n\n`fields` is keyed by field alias, and every value is read against the field\'s\nDECLARED type:\n\n| Field type | Value |\n|---|---|\n| `text` / `number` / `boolean` | the value itself |\n| `date` | `"2026-03-14"`, or a relative expression (below) |\n| `select` | the option ALIAS \u2014 `"gold"`, or `["gold","vip"]` for a multi-select |\n| `select_record_link` | `"<entity-alias>:<ref>"` naming another row in this file \u2014 `"customer:acme"`, or an array for several |\n| `select_member` | `"self"` only \u2014 the person applying the model |\n| `files` | paths beside this file \u2014 `["scans/pccc_letter.png"]` \u2014 uploaded by `apply`/`setup` before the rows are posted; or `fil_` ids of files already in this workspace |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n**Documents can be attached on their own, afterwards.** Rows land only into empty\ntables, so a model whose binaries were added after the first apply has no second\napply to carry them: `lotics scaffold apply model.json --documents` writes ONLY\nthe `files` cells, onto the records the first run created, joined through the\n`<model>.last_run.json` beside the file. Running it twice attaches nothing the\nsecond time \u2014 the same path keeps the same file, and a cell is a set.\n\n### Relative dates\n\nA date cell holds a literal `YYYY-MM-DD`, or an expression relative to the day\nthe model is applied, so a screen that opens on "this month" is not empty a month\nlater:\n\n- `@today` \u2014 the day of the run, in the workspace\'s timezone\n- `@month-start` \u2014 the 1st of that month\n- either with a whole-day offset: `@today-14`, `@month-start+9`\n\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Field roles\n\n`field_roles` names the reporting role a field plays on its entity \u2014 keyed by\nentity alias, then field alias \u2014 so every screen over the entity agrees on\nwhich column names the row and which select is the stage. A shape\'s slot binds\nto it (\xA7 Apps and screens). Like `rows` and `apps`, it is this file\'s: `check`\nproves it and the workspace never sees it. Each role sits on the types that can\nanswer it:\n\n| Role | On | Meaning |\n|---|---|---|\n| `identity` | `text`, `select_record_link`, `formula` | what a PERSON calls the row \u2014 the register\'s first column; a link where the row is "the product, at this branch"; a formula where the name is computed, and then it may not be `format`ted as a figure. NEVER an autonumber: a minted code is a `reference`. One per entity |\n| `reference` | `text`, `autonumber`, `formula` | the key the SYSTEM files the row under \u2014 a booking number, a container code \u2014 drawn as the supporting line under the name rather than as a column of its own, and leading where the entity has no `identity` at all. One per entity |\n| `mark` | `files`, or a `lookup`/`rollup` that resolves to one | the row\'s picture \u2014 its own, or the linked record\'s. NEVER A COLUMN: a register draws words, so a mark is projected only where a surface leads with one \u2014 the subject rung of a picture-led grid, and a record\'s itinerary. A child entity\'s mark reaches a record\'s run and nothing else. One per entity |\n| `lifecycle` | single `select` | the ordered stages a row walks; option order is the order. Which of them END the flow is `outcomes`. One per entity |\n| `category` | single `select` | what the row IS \u2014 a kind, a service, a book. One badge in the field\'s own colour, with no ladder behind it and no flow to advance. Repeats: a row can be of two kinds of thing |\n| `measure` | `number`, `formula`, `rollup` | a level read against a limit \u2014 see `against` and `alert`. Money where the model says so, and then it prints as money |\n| `expected_set` | `select` | its OPTIONS are the required set (documents, checks, services); an option no row has is a gap to show, not nothing. A multi-select draws as a ring and a fraction wherever it is drawn \u2014 absence is the information, and a list of what IS there says nothing about what is missing |\n| `amount` | `number`, `formula`, `rollup` | THE money of a ledger row; `signed_by` says which way it moved, `against` names the reference it is quoted off. One per entity |\n| `when` | `date`, or a `formula`/`rollup`/`lookup` that yields one | the ledger or timeline date \u2014 stated, or derived: the day money MOVED is a formula over the two columns that could hold it. `due` makes it a DEADLINE; without it the date is the plain day it is. `until` names the stage of this entity\'s own `lifecycle` at which the countdown is spent, and says `due` by saying so. One per entity |\n| `slot` | single `select`, or a `datetime` `date` | where in the DAY a row sits \u2014 the run it is read in under its day\'s heading on an itinerary. A select\'s option order IS the run\'s order; a time is ordered by the clock. A plain `date` is refused: that is the day itself. One per entity |\n| `party` | `select_record_link` | the counterparty. One per entity \u2014 and left off where the `identity` IS the link to them: the row is then NAMED by that record, and a strip under the title would state them twice |\n| `parent` | `select_record_link` | the record this row belongs to \u2014 a line\'s order, a paper\'s case. The parent\'s record shows these rows; the row shows the parent as a fact. One per entity, and it links to an entity this model declares |\n| `contact` | `text` | a way to reach a party \u2014 an address, a number, the town it is in. The record draws them as ONE row of links under the name, so the role sits on every field that is one of them. Repeats |\n| `currency` | single `select` whose option LABELS are ISO 4217 codes | the money THIS ROW\'s figures are in. Every amount and every money measure on the entity is then printed per row rather than in one code for the whole column, and the select is not drawn as a fact of its own. One per entity |\n| `verdict` | `boolean`, `formula` | a settled pass/fail \u2014 ticked, or computed. TRUE is the PASSING side wherever it is drawn, a register\'s risk flag included, so a field whose true means trouble is the same fact asked the other way round. One per entity |\n| `obligation` | `date` | a day something is OWED by \u2014 a cut-off, a permit expiry, a payment due. It counts down by definition. `satisfied_by` names what CLOSES it (or `until`, the stage the record stops owing it at), `label` what is owed. Repeats: a row owes several things, and a shape that fans them draws one row each |\n\n**`due` is what makes a `when` a deadline.** A date a row is merely filed by \u2014 the\nday a lead arrived, the day a movement happened \u2014 is neither early nor late,\nhowever long ago it was. Without the clause every desk drew every `when` as a\ncountdown and a two-month-old lead read "40 days overdue" on a record nobody was\nlate on. So a bare `when` is a plain date, and `{ "role": "when", "due": true }`\nis the one that counts down. An `obligation` needs no clause: a day something is\nowed by is a deadline already.\n\n**`until` stops a countdown where the work is DONE.** A deadline counts down so\nsomebody acts on it, and a row that has arrived needs nothing: without the clause\na delivered order is tinted for a date it met, and every settled row past its day\njoins the urgent ones in the reader\'s glance. It names one option of the entity\'s\n`lifecycle`; at that stage, at every stage past it and at every `outcome`, the\ndate is drawn as the plain day it is. On a `when` it also says the date is `due`.\n\n"One per entity" is one COLUMN, never one value on screen. A second counterparty\nor a second parent stays a ROLELESS `select_record_link`: which field fills a\nslot is the screen\'s answer (\xA7 Apps and screens), and the extra link is a fact on\nthe row with its own section on the record, headed by that link\'s own label. A\nrole naming two fields would move the ambiguity into every shape that reads it.\n\nA COMPUTED field is admitted where its RESULT is what the role needs, and\nrefused where it is not: `identity` and `reference` need `text`, `when` a `date`,\n`mark` `files`. A formula\'s result is its `output_type`; a rollup\'s and a\nlookup\'s is read through the walk. So a formula yielding a number is refused in a\nname\'s place, a lookup landing on anything but `files` is refused as a `mark`, and\na formula declaring NO result is refused by all four \u2014 naming `output_type` as\nthe remedy, because a value drawn as a type nobody stated is the wrong cell with\nnothing saying so. The result also decides the DEVICE every screen draws the value\nwith: a rollup taking the `latest` of a set of dates is drawn as a date, a lookup\nof a select as the words it holds.\n\nA bare role name is the common form. A role that is read against a SECOND field\ntakes the object form:\n\n- **`measure`** names its limit: `against` \u2014 a constant, or a field on the same\n entity the row states as a number (a `number`, or a formula or rollup whose\n result is one) \u2014 and `alert`, which side of it needs attention, `over` a\n capacity or `under` a minimum. The two come together.\n- **`measure`** names what its number COUNTS, where the unit changes how the row\n is DRAWN rather than how the figure reads: `counts: "days"` spans a stop across\n that many days of a run, so a three-night stay fills three of its days instead\n of only the one it starts on. Stated, never inferred \u2014 nothing about a `3` says\n whether it is nights, pallets or hours, and a quantity drawn across a week is a\n run nobody can read.\n- **`lifecycle`** names the stages that END the flow: `outcomes`, option aliases\n of that select. A row in one has ARRIVED \u2014 delivered, cancelled, written off \u2014\n so a desk draws them apart from the ladder instead of chaining them after each\n other. Not every option may be an outcome, and terminal-ness belongs here, never\n written into a stage\'s label.\n- **`amount`** names the REFERENCE it is read against: `against`, a field on the\n same entity the row states as a number. A price against the list it is quoted\n off, a rate against the going one \u2014 and the catalogue item\'s record draws the\n pair on one axis with the gap said in words. Never a constant (a price typed\n into the model ages with nothing to update it) and never an `alert`: a limit is\n a ceiling a row can pass, and beating a reference is the point.\n- **`amount`** names the select that SIGNS it: `signed_by`, a select on the same\n entity, and `outflow`, the options of that select under which the amount is\n money going out. Without it every shape over the ledger adds both directions\n together: a month whose net was small reads as the total of everything that\n moved, and the trend plots one rising line. The two come together, and\n `outflow` is some of that select\'s options, never all.\n- **`expected_set`** names what CONDITIONS the set: `required_by`, a field on the\n same entity, and `options`, which of that field\'s values require which entries.\n A row whose value is not named requires nothing. Without it the denominator is\n every option, so a set whose entries are mutually exclusive by kind reads\n `1 of 4` on every complete row \u2014 a denominator no row can reach is worse than\n no role. The field is a single `select`, keyed by its option aliases \u2014 or a\n YES/NO the row answers itself (a `boolean`, or a formula resolving to one),\n keyed by `"true"` and `"false"`: a check that FAILED owes its consequence \u2014 the\n photograph, the owner, the day it is due \u2014 and there is no select of the record\n that says so. `from: "parent"` reads that field off the record these rows hang\n under instead, through this entity\'s own `parent` link: which documents an order\n owes is the ORDER\'s type, and the papers carry no column that says it. Which of\n the two a set takes is the set\'s own shape and not a choice \u2014 a multi-select\n holds the whole set on one row and is narrowed by that row (`from` omitted), a\n single select is one entry per row and takes `from: "parent"` \u2014 and the other\n way round is refused rather than left conditioning nothing.\n- **`obligation`** names what ENDS it: `satisfied_by`, a `date` or a `files` field\n on the same entity \u2014 the day it was done, or the paper that proves it \u2014 or,\n where the business stamps nothing, `until`, the stage of this entity\'s\n `lifecycle` at which the record stops owing it: a deposit is owed until the\n order is paid, and nobody records the act of paying twice. One or the other,\n never neither \u2014 without one every obligation the business ever met stays on the\n desk. Optionally `label`, what is OWED as the reader says it ("G\u1EEDi SI"), which\n is not what the column holding the date is called ("SI cut-off").\n\n```jsonc\n"field_roles": {\n "product": { "name": "identity", "photo": "mark" },\n "stock": { "on_hand": { "role": "measure", "against": "minimum", "alert": "under" },\n "uptime": { "role": "measure", "against": 80, "alert": "under" } },\n "order": { "stage": { "role": "lifecycle", "outcomes": ["delivered", "cancelled"] },\n // A DEADLINE COUNTS DOWN \u2014 `payment.paid_on` below is a plain day.\n "ship_by": { "role": "when", "due": true },\n // The day the papers are owed by, and the day they went.\n "papers_due": { "role": "obligation", "label": "File the papers", "satisfied_by": "papers_sent" },\n // Nothing stamps a deposit paid \u2014 the order\'s own stage ends it.\n "deposit_due": { "role": "obligation", "label": "Take the deposit", "until": "delivered" } },\n // WHICH PAPERS THIS ONE OWES IS THE ORDER\'S OWN TYPE, and no document says it.\n "document": { "order": "parent",\n "kind": { "role": "expected_set",\n "required_by": { "from": "parent", "field": "kind",\n "options": { "export": ["invoice", "customs"] } } } },\n // `kind` is the direction select \u2014 options `received` and `paid_out`, no role of its own.\n // Every figure on a quotation is in the currency that row names.\n "quote": { "unit": "currency", "total": "amount" },\n // What the price is quoted off, so the item\'s page reads one against the other.\n "service": { "price": { "role": "amount", "against": "list_price" } },\n // The day the money moved is neither early nor late, so it stays a bare `when`.\n "payment": { "paid_on": "when",\n "value": { "role": "amount", "signed_by": "kind", "outflow": ["paid_out"] },\n // A receipt owes a receipt voucher; a payment owes an invoice and a payment voucher.\n "papers": { "role": "expected_set",\n "required_by": { "field": "kind",\n "options": { "received": ["receipt_voucher"],\n "paid_out": ["invoice", "payment_voucher"] } } } },\n // A CHECK THAT FAILED OWES ITS CONSEQUENCE \u2014 keyed by the row\'s own answer,\n // and the answer that owes nothing is omitted rather than named with an empty list.\n "check": { "passed": "verdict",\n "evidence": { "role": "expected_set",\n "required_by": { "field": "passed",\n "options": { "false": ["photo", "owner", "due"] } } } }\n}\n```\n\nIn a file that starts from a preset (\xA7 Starting from a preset), `field_roles`\nmay name the preset\'s fields as well as this business\'s own; a role the preset\ndeclares itself is kept unless this file names the same field, and `null`\nclears it.\n\n## Write rules\n\n`write_rules` is what only a CREATE meets \u2014 keyed by entity alias, like\n`field_roles`, and this file\'s in the same way: `check` proves it and the\nworkspace never sees it. An update names one field that moved; a create takes a\ndraft whole, so it has to know which row a name already belongs to, where a\nvalue comes from when nobody types it, and what a figure or a picker may hold.\nThe generator writes one `create_<entity>` workflow and one `New<Entity>Dialog`\nper entity an app can operate, from these clauses and the fields\' own\n`required`.\n\n```jsonc\n"write_rules": {\n // A customer is RECOGNISED by their address. Where an entity names a customer\n // as its `party`, the create takes the email instead of a picker, reuses the\n // row it matches and mints one only where nothing does \u2014 so the book never\n // grows a second Acme because somebody typed the name differently.\n "customer": { "natural_key": ["email"] },\n "order_line": {\n "fields": {\n // A line of nothing is not a line. Refused on create and on update, at\n // the control the figure was typed into.\n "quantity": { "min": 1, "max": 9999 },\n // The price is fixed at the moment of ordering \u2014 COPIED off the product,\n // not looked up for ever after, so the price list moving next week does\n // not silently reprice an order already placed.\n "unit_price": { "default_from": "product.price" },\n // And nothing is sold off an empty shelf. The picker reads only the rows\n // that answer this, and the write refuses the same rows again \u2014 a caller\n // who never opened the picker is bound by it too.\n "product": {\n "options_where": {\n "node_type": "group", "logic": "and",\n "children": [{ "node_type": "condition", "type": "number",\n "field_key": "in_stock", "operator": "greater_than", "value": 0 }]\n }\n }\n }\n }\n}\n```\n\n| Clause | On | Meaning |\n|---|---|---|\n| `natural_key` | the entity | the field aliases a row is recognised by. A `text` key declares `unique: true` on the field itself \u2014 two rows sharing it would make find-or-create pick whichever the read answered first \u2014 and a key is `text` or `number`, because a person types it back |\n| `default_from` | a field | `"<link alias>.<field alias>"` \u2014 the value is copied from the linked row when the row is created, never asked. The link is a one-row link on this entity and `required`, because there has to be a row to read, and the two field types must match |\n| `min` / `max` | a `number` | the figure is refused outside them, on create and on update |\n| `options_where` | a `select_record_link` | an `and` group of plain conditions over the TARGET\'s own fields, each `field_key` a field alias. It narrows the picker\'s read and is refused again where the write lands, so the link is `required` |\n\nA field\'s own `required` is not here: the contract carries it, every write path\nrefuses the row by field name from it, and the generated panel stands its commit\ndown until the same set is filled. `unique` is the text field\'s own clause\n(\xA7 `text`) \u2014 the create says so at the control before the column does.\n\nWhat a create asks is what a person STATES. A `default_from` field is not drawn,\na `lifecycle` opens at its first rung, the stamp an obligation\'s `satisfied_by`\nnames is not asked (the row is only now taking that on), and files are attached\nto the row afterwards \u2014 one fact, one place. `scaffold check` prints every clause\nunder its entity in **Who writes what**.\n\n**The `parent` is prefilled only in the door that mounts the act.** A row opened\nfrom the record it hangs under takes that record from the door it was pressed\nin, so the link is neither asked for nor drawn. The same entity\'s act on its own\nregister has no such row, so there the parent is an ordinary picker the draft\ncannot be committed without \u2014 a payment opened from a money app belongs to a\ncase either way.\n\n## Apps and screens\n\n`apps` is the plan: each app the reader will build, as ONE REGISTER \u2014 a SHAPE\nover an ENTITY \u2014 under `screen`. Nothing here is built by the scaffold: the plan\nis what `lotics scaffold check` prints back, register by register with the field\nin every slot, so it is read and corrected before a screen exists.\n\n**THREE THINGS DECIDE EVERY SCREEN AND EVERY RECORD**: the SHAPE, whose slots are\nthe table below; the ROLES the entities declare (\xA7 Field roles), which fill those\nslots and decide what each child\'s rows become; and the CLAUSES, which are where\nthe plan overrides what the two would answer. This index says which is which, so\na clause is reached for only where the first two have no answer.\n\n**What a child\'s rows become.** One section per LINK into the record, and what\nthe CHILD declares decides its kind \u2014 the first line it answers wins.\n\n| Printed as | The child declares | The reader gets |\n|---|---|---|\n| `documents:` | an `expected_set` select, one entry per row | the desk of what is owed, each row\'s files attached to it |\n| `timeline:` | an `obligation` closed by a DATE | the ordered stops, the promised day against the day it happened |\n| `thread:` | a `markdown` body, a `party` and a `when` | the correspondence, its answer, and whose move it is |\n| `itinerary:` | a `when` and a `lifecycle`, on rows a JOB\'s record OWNS | the day heads the run, one line per stop, the sum under it |\n| `lines:` | anything else the record owns (`parent`) | the register of the rows it is made of |\n| `history:` | anything else naming it as their `party` | the same rows read as that party\'s own history |\n| `via <link>:` | a link carrying neither role | the register, headed by that link\'s own label |\n| `related:` | rows of an entity this app lists NOWHERE | one counted line, and a press that opens their register |\n\nA child carrying the BODY and one of the other two is a plain register, and\n`scaffold check` names the declaration it is one short of rather than letting it\nfall through silently. A `party` beside a `when` and no body is not a near miss \u2014\nthat is every ledger line there is.\n\n**The clauses, by name.** On the APP: `scope`. On the `screen`: `record`, `tabs`,\n`slots` (`roles` on a `custom` one), `columns`, `writes`, `period`, `filters`,\n`summary` (`above`, `totals`, `ageing`, `trend`), `facts`, `presentation`\n(`lead`, `density`, `layout`, `until`), `acts` (`row`, `record`, `selection`,\n`export`, `import`), `section_acts`, `sections`. Every one is optional, every one\nis printed back by `scaffold check`, and a screen that states none gets what its\nshape and its roles answer by themselves.\n\n**ONE APP IS ONE REGISTER AND THE RECORDS IT OPENS.** A second register beside\nit is a second job on one page: the reader arrives on whichever the nav listed\nfirst and decides, every time, which of the two they came for. So an app has one\ndestination and everything else about a row is DISCLOSED by opening it \u2014 the\nfacts by tier, the children as lists, the related as counts, one primary act, and\na filter or a group where a strip of destinations would have been. What used to\nbe a second screen is a section of the record, or another app; a model still\nsaying `screens` is refused by name.\n\n**The unit of an app is a JOB, not a person and not a table.** A job has its\nown outcome (something exists or is settled when it is done), its own subject\n(the record it advances), and an end that does not wait on the rest of the\nwork. Two tasks are ONE job when neither finishes without the other and both\nadvance the same record. One app per job, and that is what makes an app a\nwrite-ownership boundary: what the job settles is its app\'s alone to write,\nplus everything it must read to settle it well. A person holding several jobs\nopens several apps \u2014 one app for everything one person does is the dump. A step\nof the job is a section of the record, never a register beside it; a reference\nthe job needs at hand is read inside the record it is about.\n\n**Two people signing is two jobs**, because the outcome belongs to the signer:\nthe desk that prepares against the gate that releases. So is a different\ncadence \u2014 reference data edited monthly and read by the public site, beside a\ndaily desk \u2014 and a different reader, the owner\'s read-only questions. Device,\nplace and step never split a job. A super app holds more than one job; an\nover-split holds less than one, a job cut by device, place or step. The count\nper workspace falls out of the jobs, typically two to six, and is never the\ninput: review the plan app by app, naming who holds it, the job in one\nsentence, what it writes and what it reads.\n\n*A two-van appliance repair shop.* The technician\'s job is the call-out: the\noutcome is a visit done, the subject is the call-out record, and quoting it and\nscheduling it are one job, because neither finishes without the other and both\nadvance that record. The owner holds two of his own \u2014 billing the month\n(outcome invoiced, subject the invoice, settled against visits already closed)\nand the price list the booking page quotes from, edited monthly. Three jobs,\nthree apps, and the owner opens two of them.\n\n**Inside a job the caps hold**: six flow stages on a desk, seven facts before a\nrecord\'s fold. Past them, look for the second job hiding inside. And a job\'s app\nis DENSE: every create the job needs lives in it, so its user never leaves it to\ncorrect a figure the screen in front of them is stating \u2014 three workflows is a\nscreen, not a desk.\n\n**A handoff is a stage change.** Where one job ends the record moves stage and\nthe next job\'s desk opens on it; a field two jobs must both write is declared\nshared at the split, naming the stage each may write it in.\n\n**Name an app for the JOB, in the trade\'s own words, never for who it is for.**\nA title on the door says nothing about what the person who opened it came to\ndo, and it is wrong the day the org chart moves.\n\n```jsonc\n"apps": [\n {\n "alias": "sales", "name": "Sales",\n "description": "\u2026", "icon": "briefcase", "theme": { "color": "blue" }, // optional\n "screen":\n { "alias": "orders", "label": "Orders", "shape": "lifecycle_desk", "entity": "order",\n "record": "drawer", // optional \u2014 "drawer" | "page"; absent, the shape decides\n "tabs": "stage", // optional \u2014 a select on the entity, or null; absent, the shape decides\n "writes": false, // optional \u2014 default TRUE; false makes this screen a reader\n "period": "due_date", // optional \u2014 any field whose value is a date, derived ones included\n "filters": ["kind", // optional \u2014 a single-select, or a lens the model states itself\n { "label": "Qu\xE1 h\u1EA1n l\u01B0u", "predicates": [\n { "label": "\u0110\xE3 qu\xE1", "tone": "red",\n "where": { "node_type": "group", "logic": "and", "children": [\n { "node_type": "condition", "field_key": "owed", "operator": "greater_than", "value": 0 }] } }] }],\n "summary": { "totals": ["total"], "above": ["total", { "field": "owed", "at": "end" }],\n "ageing": { "amount": "owed", "due": "due_date", "buckets": [30, 60, 90] },\n "trend": { "field": "total", "direction": "up" } }, // optional \u2014 see below\n "facts": { "groups": [{ "caption": "Pricing", "fields": ["rate", "surcharge"] }] }, // optional \u2014 the record\'s named bands\n "presentation": { "lead": "none", "density": "dense" }, // optional \u2014 how it is DRAWN; absent, what the rows are decides\n "acts": { "row": [{ "label": "Issue the note", "template": "debit_note", "place": "cta" },\n { "kind": "agent", "label": "Read the papers", "agent": "reader", "fills": ["ref", "due_date"] },\n { "kind": "workflow", "label": "Hand it to dispatch", "workflow": "hand_to_dispatch",\n "inputs": { "order_id": "record", "owed_by": "due_date" } }],\n "record": [{ "label": "Print the file", "template": "dossier" }], // optional \u2014 the record\'s own header menu\n "selection": [{ "label": "Statement", "template": "statement" }], // optional \u2014 the work it hands on\n "export": true, // optional \u2014 the rows in view, saved\n "import": { "kind": "import", "label": "Upload the sheet", // optional \u2014 a file turned into rows\n "entity": "order", "key": "code", "columns": ["rate"] } },\n "section_acts": { "line": [{ "label": "Chase the lines", "template": "chaser" }] }, // optional \u2014 a verb on one section\n "sections": { "line": { "draw": "worksheet", "cost": "buy_rate", "sell": "rate" } }, // optional \u2014 priced lines, worked down in place\n "slots": { "identity": "code", // optional \u2014 slot \u2192 field, where the roles cannot decide alone\n "subject": ["customer", "project"], // \u2026or the TIERS a fold walks down, outermost first\n "stage": { "field": "state", "quick": true } }, // \u2026or the field AND the reader\'s own control for it\n "columns": ["source", "owner"] } // optional \u2014 extra facts after the slots, shed first at narrow width\n }\n]\n```\n\nA shape is a proven screen with named SLOTS, each filled by a field carrying a\nrole (\xA7 Field roles). A slot with exactly one candidate on the entity binds by itself;\ntwo candidates need naming in `slots`; a field fills one slot; a required slot\nwith none is refused \u2014 a lifecycle desk over an entity with no `lifecycle`\nselect cannot be built.\n\n**A LIST IS A HIERARCHY, not a set of columns.** A group register\'s `subject`\ntakes one \u2014 `"subject": ["customer", "project", "order"]`, outermost first \u2014 and\nthe register becomes one the reader walks DOWN: it folds by the first tier, a\nnode narrows to the tier inside it, the last tier opens the set behind the\nfigure, and a trail says where they are. Every tier is a field of this entity\ncarrying a role that slot accepts, and none of them fills a second slot. It is\nthe slot\'s own answer that decides: a list on any other is refused, because two\ncolumns in one cell is not a fold. Without it the same business needs a register\nper depth, and the same money is folded twice with nothing saying it is the same\nmoney.\n\n**A QUICK SLOT IS FOR A DECISION THE READER MAKES FROM THE ROW ALONE.** A slot\'s\nvalue in `slots` is normally the field\'s alias; `{"field": \u2026, "quick": true}`\nsays the column is not a reading of that value but the CONTROL for it \u2014 pressed,\nit writes that one field as a diff through the record\'s own update, so the\nregister and the page behind it land the same write. THREE roles are such a\ndecision, and `quick` on any other is refused: a `lifecycle`, which rests as the\nstage\'s own dot and label, takes a rule under it on hover and focus, and opens\nthe field\'s stages with the ones that END the flow last \u2014 ONE control, which IS\nthe reading;\na `verdict`, which is the switch; and a `measure`, which is the figure typed in\nits own cell \u2014 a reading taken per row IS the row, which is what a timesheet\'s\nhours and a count sheet\'s tally are. Everything else is decided against what the\nrecord holds, and the record has to be open. All three carry the record\'s own\nblockers \u2014 a move the ladder holds is a move the cell holds, and it names the\nentries the row still owes \u2014 and an outcome asks before it commits. Add `"order": "sequence"`\nwhere the stages are a WALK rather than a set of destinations: the picker is then\nordered as the walk and the step the row takes next is MARKED in it. It never\nadds a second control \u2014 a column that answered with a reading in some rows and a\nverb in others is two columns about one value. A screen stating `writes: false`\nrefuses `quick`: a reader who may operate nothing has no decision to take.\n\n**THE SLOTS ARE THE ROW\'S ANSWER; `columns` IS ITS CONTEXT.** `"columns":\n["nguon", "phu_trach", "nhu_cau"]` names extra fields of the entity, drawn AFTER\nevery slot and in this order, each by what its column IS \u2014 a chip for a select,\nthe person\'s name for a member, the day for a date, the figure for a number, a\ntick for a boolean, the words for everything else. They are ranked BENEATH every\nslot, so a phone sheds all of them before it sheds any of the shape\'s own\ncolumns. At most four, and refused where one would say what a slot already says,\nover a files field (files are read, so they are a section of the record), over a\ncell holding several (a column of comma lists compares nothing), on a shape whose\nrows are not the entity\'s records in a table, and beside a `presentation.layout`\nthat draws the rows with a device of its own. A fifth fact worth the width is a\nslot the shape is missing \u2014 say so rather than widening this clause.\n\n| Shape | Answers | Required | Also fills | Record | Tabs |\n|---|---|---|---|---|---|\n| `lifecycle_desk` | what is stuck, what do I move next | `lifecycle`, `identity` | `mark`, `party`, `amount`, `measure` (level), `when` | page, or drawer where the entity carries `parent` | the lifecycle\'s stages |\n| `party_register` | who is this, our history, is there a risk | `identity` | `mark`, `contact`, `measure` (worth), `verdict` (risk) | page | none |\n| `offering_register` | what do we offer, at what price, can I sell it | `identity` | `mark`, `amount` (price), `measure` (availability) | page | none |\n| `transaction_ledger` | does this period reconcile, what is unexplained | `when`, `amount` | `identity` (reference \u2014 the line\'s own number), `party`, `lifecycle` (classification), `expected_set` (document) | drawer | none |\n| `monitored_asset_set` | what needs attention, is that number normal | `identity`, `measure` (level) | `mark`, `lifecycle` | drawer | none |\n| `obligation_desk` | what is due next, and has it been done | `identity`, and at least one `obligation` on the entity | `when` (runway) | page | none |\n| `trend_deep_dive` | how did the period go, and why | `when` | `measure`, `amount` | drawer | none |\n| `reconciliation_desk` | what does not match, and by how much | `identity` (reference), and two figures \u2014 `ours` and `theirs`, each an `amount` or a `measure` | `category` (reason), `verdict`, `when` | drawer, on the PAIR | the dated runs, where the model names a select |\n| `entry_matrix` | one value per subject per period | `identity` (subject), `across` \u2014 a `when`, or a `category` for a fixed column set | `measure` (value), `lifecycle` (state), `category` (note) | drawer, on the cell\'s row | none |\n| `guided_run` | complete an ordered sequence, a step at a time | `identity` (step) | `slot` (sequence), `capture` \u2014 a `measure`, `verdict` or `expected_set` \u2014 `verdict` (gate), `mark` (media) | page: the run IS the record | none |\n| `live_board` | is everything OK, right now | `lifecycle` (state), `identity` | `verdict` (exception), `when` (since), `measure` (level), `category` (where) | drawer | none |\n| `group_register` | what does each group come to, and what is behind it | `subject` \u2014 a `category`, `party` or `when` | `amount`, `measure`, `identity` (member) | page: a row is an aggregate, and the door is the SET behind it | none |\n| `media_set` | scan a body of pictures by where they belong | `mark` (picture), `identity` | `category` or `party` (group), `when`, `verdict` (current against superseded) | drawer | none |\n| `day_sheet` | what happened on each day | `when` (the day) | `lifecycle` (state), `measure` (level), `amount`, `category` (note) | page: a day composes several child logs | none |\n| `resource_schedule` | what is on each resource, in what order, under what ceiling | `lane` \u2014 a `party` or a `parent` \u2014 `identity`, `when` (the start) | `measure` (duration), and `load` and `bulk`, each a `measure` read against a ceiling of the LANE\'s \u2014 a vehicle fills by weight and by room and stops at whichever runs out first; `lifecycle` (stage) | drawer, over the lanes | none |\n| `worksheet` | priced lines the reader edits, totalling to one figure | `identity` | `category` (group), `measure` (quantity), and `cost` and `sell`, each an `amount` or a `measure` | drawer, per line | none |\n\n**A record is one of five KINDS, and the shape plus the roles decide which.**\nEvery one of them is ONE READING COLUMN, and the ORDER of that column is the\nwhole of what the kind means.\n\n| Kind | The column, top to bottom |\n|---|---|\n| WORK RECORD \u2014 a `lifecycle_desk` over rows belonging to nothing | the one act that moves it \xB7 what it IS \xB7 the papers it owes \xB7 the rows it is made of \xB7 the book of what it came to |\n| LINE \u2014 the same shape over rows carrying `parent` | its ladder \xB7 the arithmetic behind its amount \xB7 the rows and papers under it \xB7 its particulars, last (the key it is filed by is the line under its title) |\n| PROFILE \u2014 a `party_register` | its history, OPEN, grouped by year \xB7 the rest of its registers \xB7 its facts (the ways to reach it are under its name, its settlement is the band) |\n| CATALOGUE ITEM \u2014 an `offering_register` | its own words (the picture leads the header at media scale, the price is read against its reference in the band) \xB7 what uses it \xB7 its facts |\n| EVIDENCE \u2014 a `transaction_ledger` | the PROOF \xB7 what the paper is, compactly \xB7 the rest \xB7 the links to the record it settles and the party it was with |\n\nNothing in the file states the kind: a clause that could say it could say the\nwrong one, and `parent` already says whether a row stands alone. `record`\noverrides the DOOR where the shape\'s own answer is not the one wanted \u2014 and its\nthird value, `"expand"`, is not a door at all: the row REVEALS what it holds in\nplace, where there is nothing behind it worth navigating to. A row read that way\nreads as a LINE whatever the shape would have opened: its ladder, the arithmetic\nbehind its amount, then its particulars, because there is no header above it to\nhave stated the name first. It is REFUSED for two reasons, and they are not the\nsame refusal. On a shape whose record is a page by nature \u2014 a `guided_run`, a\n`group_register`, a `day_sheet`, an `obligation_desk` \u2014 because each of those\nstates the opposite where its door is declared: what its row leads to is read\nwhole, not as four more columns than the register had room for. And on a shape\nthat draws its rows with a device of its OWN \u2014 an `entry_matrix`, a `media_set` \u2014\nbecause a cell of a grid and a tile on a wall have no band under them for the row\nto reveal into. For the same reason a revealed register is drawn as a TABLE: a\n`presentation.layout` of anything else is refused, and the reader is not offered\nthe arrangement either, since it would take away the only door the register has.\n\n**`scope` \u2014 the one subject every read of the app narrows to.** Stated on the\nAPP rather than on its screen, because it is not a filter: a record opened under\nit stays under it, the pick survives closing the app, and every app declaring the\nsame `entity` shares one pick per viewer \u2014 which is the whole value, and what\nstops a construction workspace asking which project twelve times. `entity` is the\nmodel\'s entity whose one row the app is read inside, and `param` is what the\napp\'s reads take that row by.\n\n```jsonc\n{ "alias": "site_work", "name": "Site work",\n "scope": { "entity": "project", "param": "project_id" },\n "screen": { \u2026 } }\n```\n\nThe register\'s own entity has to REACH it \u2014 its own rows, or rows that name one\nthrough a link \u2014 or the switcher is a control that changes nothing. Until a\nsubject is picked the app reads NOTHING and says which one it is waiting for: an\nunscoped read over a scoped app is every row in the workspace drawn as one\nproject\'s work, which looks correct.\n\n**ONE RECORD SURFACE PER ENTITY, AND ITS KIND IS THE ENTITY\'S.** An app is one\nregister, so the record its rows open is the register\'s own and the kind comes\nfrom that register\'s shape. Another app over the same entity opens the same kind\nof record: a payment read from a cash book and one read from a trend are both\nmovements, and both read proof first. A shape with no kind of its own (a trend, a\nmonitored set, a `custom` screen) says only where the record opens.\n\n**Two things a register states about a row beyond which field fills which slot.**\nA `measure` is read AGAINST its limit (`against`/`alert`) where the shape\'s slot\ntakes one \u2014 `level`, on a lifecycle desk and a monitored set, and every slot of a\n`custom` screen, which composes the frame\'s own column; `worth`, `availability`\nand a trend\'s headline are plain figures, and a limit there would read as a meter\nin the record over a number the row printed bare. And the name carries a\nSUPPORTING LINE where the entity states a key to file the row under: the `parent`\nit belongs to, its `reference`, an `autonumber`, a text formula \u2014 in that order,\nfirst hit wins, never a field a person types prose into. Every record heads with\nthat same key, so the register and the door it opens name one row one way.\n\n**The act that opens a row belongs to a register that LISTS the entity\'s rows**\n\u2014 a desk, a register, a ledger, a monitored set, or a `custom` screen. A shape\nwhose rows are not records has neither that act nor the door a counted line\nelsewhere leads to: an obligation desk\'s rows are deadlines and a trend\'s are\nperiods, so a create pressed there would open a row the screen cannot show. An\nentity whose only app is one of those has no create at all \u2014 give it an app whose\nregister its rows are read in.\n\n**An `obligation_desk`\'s rows are not its entity\'s rows.** The shape fans the\nentity\'s `obligation` fields out \u2014 one row per thing still owed, which is one\nwhose date is SET and whose `satisfied_by` is empty \u2014 so a l\xF4 with four cut-offs\nis four rows, and the three it has met are not there at all. The row\'s name is\nthe obligation\'s, its supporting line the record\'s `identity`, and the door is\nthat record. A `when` bound to `runway` is what the countdown\'s ring is measured\nfrom. Over an entity declaring no `obligation` the shape is refused: there is\nnothing to count down to.\n\n**What a register carries beyond its columns.** Six clauses, each emitted as\nthe prop the kit draws it with; every one is optional and a screen that states\nnone gets a register of columns and nothing else.\n\n- **`period`** \u2014 a field on the entity whose value is a DATE. The toolbar gains a\n date-range control (this month to start), and the rows it keeps are what the\n register AND every figure below are computed over, so a band can never be over a\n different window than the rows under it. Any such field, not only the `when`\n role, and however the date got there: one table is read two ways (a cost ledger\n by the day a line arose, the cash book over the same table by the day it\n settled), a role is one field\'s, and the day money MOVED is often a formula over\n the two columns that could hold it \u2014 which then declares `output_type: "date"`.\n A `trend_deep_dive` brings its own period and takes none here; a `live_board`\n is PERIODLESS and takes none either, for the opposite reason \u2014 it answers what\n is true NOW, and a date range over it draws what was true in the window in the\n live state\'s own colours.\n- **`filters`** \u2014 the chips beside the search. A single-`select` field\'s alias\n offers that field\'s own live options; it is the lens a register is read through\n over and above the one band its strip gives, so the strip\'s own field is\n refused here (one dimension, one control) and so is a multi-select (a row would\n answer the chip several ways at once). The rows the register and every figure\n below are computed over are what the chips AND the period kept.\n A DERIVED LENS is the other form \u2014 `{"label": \u2026, "predicates": [{"label": \u2026,\n "tone": \u2026, "where": \u2026}]}` \u2014 where the sets a reader narrows by are ones the\n MODEL names and no column holds: rate validity, plan urgency, free-time\n overrun. Each `where` is an `and` group of plain conditions over the entity\'s\n own fields, each `field_key` a field alias, and it is read off the row, so the\n operators are the comparisons a value answers \u2014 `number` and `text` equality,\n `number` ordering, `boolean` equality, `select` `has_any_of`/`has_none_of`, and\n `is_empty`/`is_not_empty` on any of them. Nothing DATED: a relative point needs\n a clock, and a screen states one date control (`period`), so a second beside it\n would be two windows with nothing saying which a figure was computed over \u2014 a\n date question is asked as a column the model derives and read here as a number\n or a yes/no. `tone` is the option palette\'s, because the chip draws these\n exactly as it draws a select\'s options.\n- **`summary`** \u2014 what the rows in view come to. `above` is a band OVER the\n register: `"counts"` for how many rows are in view and, for the FIRST\n `measure` the model gives a limit, how many are past it (accented only when\n one is) \u2014 one such set, because a band naming three is the dashboard a\n register is not; a list of number-field aliases for what they add up to, and a\n `percentage` named there is refused, since a share summed over the rows in\n view is a figure of no kind (draw it as a column, whose own total reads the\n mean). A figure written `{"field": \u2026, "at": "end"}` is a STOCK instead: the\n reading standing at the END of the window rather than the sum of the readings\n in it, which is the only way opening + in \u2212 out = closing reconciles \u2014 a month\n of daily closing stocks added together is thirty warehouses. The band labels it\n as the snapshot it is, and it is refused on a screen that states no `period`,\n since there is then no window for it to stand at the end of.\n `ageing` stands in that same\n band: an `amount` split by how many days past its `due` date each row is,\n drawn as a `Breakdown` of the edges in `buckets` (30, 60 and 90 days unless the\n model states its own, ascending). The ladder is the kit\'s \u2014 its boundary, its\n words and its ramp \u2014 and it opens with what is NOT YET DUE, because a bar of\n overdue bands alone is full at every input: a book with one late invoice and a\n book that has gone entirely bad would paint the same solid width. Against the\n current money the bar\'s own shape is the reading. A row whose date is empty is\n in no band. `totals` is what the whole\n view adds up, and it is drawn in that SAME band above the rows: a register\n states one aggregate, over the rows the reader can see, in one place, and a\n closing line under them is a second place to look and a second question about\n which rows it covers. A closing line belongs to the one device that IS a\n statement \u2014 a record\'s `Ledger`, whose balance is what it is read for, labelled\n by its amount\'s own word. Where a shape has no band above (a trend, whose own\n band is the chart) the figure stands under the register instead. A\n `transaction_ledger` sums its amount column itself and takes `above` alone. An\n `obligation_desk`\'s rows are DEADLINES and not records, so its band states\n `"counts"` \u2014 how many are open \u2014 and a figure list, an `ageing` or a `trend`\n over it is\n refused: a l\xF4 with three cut-offs is three rows, and its freight added over\n them is three times the freight.\n `trend` is `{"field": \u2026, "direction": "up"|"down"}` and states WHICH WAY that\n figure moved across the window: the rows in view are cut into the window\'s own\n buckets \u2014 days over a month, weeks over half a year, months beyond \u2014 and the\n later half of the CLOSED ones is read against the earlier half, drawn beside\n the figures as one line, so a window the reader is still inside states the\n movement of the days that have run rather than a fall into the ones that have\n not. `direction` is which way is GOOD, which nothing about the number\n says: a book wants its takings rising and a backlog wants its days falling, and\n the same arrow is a win on one and an alarm on the other. It needs a `period`\n to be a window of, and it is refused on a shape that draws its own (a\n `trend_deep_dive` already draws the series) or that reads none at all (a\n `live_board` answers what is true now). A window whose earlier half took\n nothing draws no line: there is no base for a percentage, and 0 % would claim a\n steadiness nobody measured.\n- **`facts`** \u2014 how the RECORD\'s facts are banded. `groups` is a list of\n `{caption, fields}`: what a set of facts has in common is a sentence about the\n business and nothing derives it, so a plan that states one gets exactly those\n bands, in its own order, captioned. Everything it leaves unnamed reads in the\n derived order below it. A field the header, the band, the ladder, a required\n set, the files or a register below already draws is refused: it is not a fact,\n so a band naming it would either state it twice or draw nothing. Naming any\n band IS the answer to which facts fold \u2014 see the TIERS below.\n- **`presentation`** \u2014 how the screen and the record it opens are DRAWN, where\n what the rows are is not the answer wanted. `lead` is what each row leads\n with: `"mark"` the subject\'s own mark, always spent because a party\'s initials\n stand in for a picture nobody uploaded; `"picture"` the photograph, spent only\n on the rows that carry one and, on the record, at media scale above the name;\n `"paper"` the row\'s own files, drawn as the cards a pile of documents is read\n as; `"figure"` the amount, with no gutter at all; `"none"` neither. `density` is\n how many lines a row\'s subject may take \u2014 `"roomy"` two, `"dense"` one.\n Both default, and the DEFAULT is what the rows are: a party register leads\n with the mark, an offering register with the picture, a ledger with its\n figure, and a desk \u2014 rows that are work still to do, scanned \u2014 stands its rows\n one line each while a record\'s own rows are read roomy. So state it only to\n differ, and a plan that states nothing still gets screens that vary. A lead\n the rows cannot carry is refused: a mark, a picture or a paper where no column\n is drawn as the rows\' mark, a\n figure where no column is the amount, and the other subject\'s mark (which of\n the two a row wears is the shape\'s, so the register and the record it opens\n cannot call one row two kinds of thing).\n `layout` is how the rows are ARRANGED \u2014 `"table"`, `"list"`, `"cards"`,\n `"gallery"`, `"board"`, `"calendar"`, `"timeline"`, `"gantt"` \u2014 and it is\n AFFORDED by what the rows carry, not by the shape: every register affords a\n table and a list, a `mark` affords the two picture-led grids, a `lifecycle`\n affords a board, a `when` affords a calendar and a timeline, and a `when`\n beside a `measure` that `counts` DAYS affords a gantt, because a bar with no\n length is a calendar drawn sideways and a bar measured in money or litres is a\n length of another kind. `until` names the date field a bar ENDS on, where the\n rows state one, and the days measure is what the bar runs for without it. A\n layout the bound roles do not afford is refused, naming the ones they do.\n- **`acts`** \u2014 where this screen hands work on: `row` is the \u22EF menu on every row,\n `record` is the \u22EF on the RECORD\'s own page header \u2014 the same reach as a row\'s,\n standing where the work is open instead of while a list is scanned, and refused\n on a record that opens as a drawer, which has no header of its own and whose\n row already carries the register\'s menu \u2014 and `selection` is the bar over the\n TICKED rows. `export` is the third reach \u2014\n the WHOLE VIEW, saved in one press: `true` writes the rows as the register drew\n them, in the columns it drew, as an `.xlsx`, and\n `{"template": "\u2026"}` says the layout is the trade\'s and makes the paper with a\n workflow like any other. It is never a list of columns: which columns the\n export carries is what the screen already draws, and a second statement of it\n disagrees the moment a slot moves \u2014 the generator reads them off the drawn\n slots and writes them into the app, so the sheet\'s headings are the labels the\n register drew them under.\n `import` is the reach OPPOSITE it \u2014 a file turned into rows:\n `{"kind": "import", "label": \u2026, "entity": \u2026, "key": \u2026, "columns": [...]}`. The\n file is mapped column by column, each row is validated, what would change is\n previewed, and the commit UPSERTS by `key` \u2014 which is why a sheet sent twice\n moves no counts. That key is one of the entity\'s own `natural_key` fields\n (\xA7 Write rules), and an entity declaring none is refused: without a key the\n second run is a second set of the same rows. `entity` is the register\'s own \u2014\n an app is one register, and rows filed into another entity are a count nobody\n on this screen can check. `columns` is what the sheet may fill, in that order;\n absent, it is every column a person states IN WORDS. A computed one is refused\n because the workspace writes it, and a link, a member or a files column is\n refused because a cell of a sheet is words and those hold a row of another\n table or bytes \u2014 a file reaches another table through the party pattern (a\n `natural_key` on the target, named rather than picked), which is a create\'s.\n The generator writes the whole verb, as it does for a paper: an\n `import_<entity>` workflow that finds the row by `key` and then changes it or\n opens it, answering which of the two, and the staged run over it \u2014 drop, map,\n the per-row verdict, the commit. A line whose cell the column cannot hold is\n refused with the reason and the rest of the sheet still lands.\n Both spreadsheet reaches are read and written IN THE BROWSER, so the generated\n `src/main.tsx` imports `@lotics/app-runtime/sheets` wherever the plan states\n one of them and not otherwise; an app that has neither carries no spreadsheet\n engine at all. An act names ONE of three things, never two:\n - **`template`** \u2014 an `html` template this model declares (\xA7 Templates). The\n paper is filled from ONE row in `row`, and from the whole ticked set in\n `selection`. The generator writes the whole verb \u2014 a `generate_<template>`\n workflow that reads the row and hands the file back, and a menu item that\n opens it \u2014 so there is nothing left to bind. An `email` template is sent\n rather than opened and is refused here; a file-backed template (`excel`,\n `word`, `pdf-form`) is bytes a model has none of, so those stay the author\'s\n own `lotics app workflow set`. Two screens over DIFFERENT entities cannot\n name one template: one paper is filled from one kind of row.\n - **`{"kind": "agent", \u2026}`** \u2014 a run over ONE row. `agent` is an alias the APP\n declares (`package.json#lotics.agents`), because an agent is prose and tools\n rather than anything a workspace model can state \u2014 `lotics app check` refuses\n one nothing declares, exactly as it refuses a workflow nothing bound. `fills`\n is the fields the run may write: pressed, the reader watches the run, reviews\n what it proposes field by field against what the row holds now, and applies\n the ones they kept as ONE write through the record\'s own update. A run can\n reach nothing outside `fills`, a computed column there is refused (the\n workspace writes those), and a screen stating `writes: false` refuses an\n agent act outright. It belongs in `row`; the selection bar makes ONE paper\n from many rows, and a run per ticked row is the row\'s own act many times.\n - **`{"kind": "workflow", "workflow": "\u2026", "inputs": {\u2026}}`** \u2014 the work HANDED\n ON: a lead becomes an opportunity, an order is split into the orders placed\n on its suppliers, a request is closed. `workflow` is an alias the APP binds\n (`package.json#lotics.workflows`), because the rule behind it is the\n business\'s and no model states one \u2014 so the generator writes NO body and\n `lotics app check` refuses an alias nothing bound, exactly as it refuses an\n agent nothing declares. `inputs` is what the press hands that body: the\n workflow\'s own input name \u2192 `"record"` for the row it was pressed on, or a\n field of this entity for a value off that row. The value is what the\n workspace HOLDS \u2014 an option\'s key, a linked row\'s id, the figure \u2014 never the\n rendering. A files column is refused (the body reads the row\'s files off the\n row it is handed), a column that is not a field of the entity is refused by\n name, and one input short of the declaration is refused at the press, so the\n whole act is dropped rather than shipped half-bound. It runs ON the press:\n there is nothing to review first, which is the agent arm\'s law, and the\n body\'s own sentence is what the reader is told either way. It stands in\n `row`, in `record` and on a `section_acts` band \u2014 each reaches ONE row \u2014 and\n is refused over the ticked set, where one act makes one paper from many.\n\n **`"place": "cta"` draws an act ON the row** instead of in its \u22EF \u2014 the one verb\n a reader presses without opening a menu first. At most one per screen and every\n other act stays in the menu, because the trailing gutter is paid for by every\n row: a second is refused with *one verb on the row, the rest in its menu*. The\n ticked set has no row of its own, so `cta` is refused there.\n\n A hand-off\'s BODY is still the author\'s: `lotics app workflow set <alias>`\n binds it, and `scaffold check` prints the act as `"<label>" \u2192 <alias>(\u2026) [you\n write the body]` so it is read as work owed rather than as a verb already\n wired.\n\n A `selection` act is a `template` and nothing else: an act over many rows makes\n ONE paper from them. The generator turns the register\'s checkbox gutter\n on, emits a `generate_<template>` workflow taking the ticked ids, and the body\n reads each row and passes them as `rows` \u2014 which is what the template iterates\n (`{{#each rows}}`). One template is one paper AND one reach: naming the same\n one from a row\'s \u22EF and from the bar is refused, because those are two bodies.\n A shape whose rows are DERIVED rather than records \u2014 an `obligation_desk` \u2014\n has no set to tick and refuses the clause. The paper is handed BACK, never\n filed: a ticked set can hold rows of three different parents, so there is no\n record on which "this document belongs here" is true.\n\n**`section_acts` puts a verb WHERE ITS EFFECT LANDS.** "Chase the paperwork"\nbelongs on the papers, not in the register\'s \u22EF two surfaces away. It is keyed by\nthe alias each section is DERIVED from \u2014 a field\'s (its progress, its prose, its\nrequired set, its charge, one of its files) or a CHILD ENTITY\'s (its register,\nits desk, its run, its thread) \u2014 because the sections come off the roles and the\nkey the app addresses one by does not exist until it is built. An alias naming no\nsection is refused, listing the ones that do; the facts band is what every other\nsection left over, so nothing addresses it. Each act is a `template`, an `agent`\nor a `workflow`, exactly as a row\'s is \u2014 the reach is the same one row \u2014 and\n`place: "cta"` is refused: a section is a band with a heading and has no row to\ndraw a verb on. `scaffold check` prints\nwhich section each alias landed on, which is the half an author cannot see in\ntheir own file.\n\n**`sections` says HOW one of those sections is DRAWN**, keyed by the same\naliases. The one reading it carries is `"draw": "worksheet"`: the child\'s rows\nare PRICED LINES the reader works down in place, each part of the job footed and\nthe sheet closing under them. `cost` and `sell` name the child\'s two figures,\nwhich is the one thing the roles cannot decide \u2014 an entity carries one `amount`,\nand a line that is costed and sold carries two. Everything else is read off the\nchild\'s own roles: what a line is FOR is its `identity`, how many it is for is\nthe `measure` the pair left, and the part it falls under is its `category`. The\nMARGIN is on none of them, because it is arithmetic over the pair.\n\nA sheet is the WRITE side of a price, so it is the one child section whose cells\nare editors: a figure typed into a cell is written as a diff through the child\'s\nown update, which is the same editor the line\'s own drawer saves through. Every\nother child section is read where it is drawn and edited in the row\'s drawer. A\nline is ADDED from the section\'s heading as every child\'s row is, with the record\nit hangs under as the act\'s own context.\n\nRefused: an alias naming no register of this record, rows the record is merely\nthe `party` or `link` of (a line added to a history belongs to the job it is\nabout, not to what is reading it), a figure that is not a field of the child or\nwhose role is neither `amount` nor `measure`, a sheet naming neither figure, and\none figure priced as both.\n\n**A CHILD NOBODY REGISTERS IS A NOTE, not a refusal.** Where a record lists a\nchild entity no app is a register of, `scaffold check` says `"<entity>" has no\nregister \u2014 its rows are read here and created nowhere` and the plan printout\nmarks that entity `(no app)`. Rows can arrive from an import or a workflow, so\nit is a reading rather than a rule; what it catches is the register somebody\nmeant to plan and did not. A thread is exempt \u2014 a reply is written where it is\nread \u2014 and so is a sheet, which opens its own lines.\n\n`scaffold check` prints every clause a screen states, on its own line under the\nslots, spelling the screen\'s `writes` clause `operable` \u2014 the app manifest\'s\n`lotics.writes` keeps that word, and the two are different facts: `app create`\nSEEDS the manifest from the fields the screens\' own editors write, and from then\non the app owns the declaration `lotics app check` holds its bodies to.\n\nEach shape is a `@lotics/ui` component of the same name (`LifecycleDesk`,\n`PartyRegister`, \u2026) whose props are these slots, so once the tables exist\n`lotics app create <name> --from <this file>#<app alias>` scaffolds the app with\none screen per entry, each slot reading the field the plan bound.\n\n`"shape": "custom"` is the screen no registry row covers: it declares its own\n`roles` (slot name \u2192 role), and it is emitted on `ShapeFrame` \u2014 the one anatomy\nthe six shapes above are each a configuration of \u2014 so its strip, search,\nordinal, fit budget, empty and waiting states and record door are the same ones\nthey have. It opens the record its `record` says, drawer or page, like any other.\nA bound `lifecycle` slot IS its strip, the stages in the field\'s order as on a\ndesk, so such a screen names no `tabs`; a `tabs` select is the flat strip a\nscreen with no lifecycle gets.\n\n**An app IS its `app.json`.** `create --from` writes the bound plan \u2014 every\nscreen with its shape and slots, the record each row opens with its sections in\nthe archetype\'s order, the create panels, the acts \u2014 as one JSON document, plus\na five-line `src/main.tsx` that mounts `@lotics/app-runtime` over it and one\n`src/workflows/<alias>.ts` per write. There is no screen source: the runtime\ndraws the spec, so a kit correction reaches every app with its next install.\nWhere the plan has no word for what a screen or a section IS \u2014 or for what a\npaper act should ASK before it is made \u2014 the spec names one of the app\'s own\ncomponents and `src/components/index.ts` registers it, the one file under `src/`\na regeneration never rewrites. `lotics app eject <screen|<section key>|<act\nlabel>>` writes each of those, starting from what the runtime already drew.\n\n**How a spec reads a row.** Each query projects its columns under the alias the\nworkspace mints from the field\'s LABEL \u2014 never the alias this file keys it\nunder, which is the model\'s own namespace and which the workspace never saw. So\na field this file calls `partner` and labels `\u0110\u1ED1i t\xE1c` is referenced as\n`doi_tac` throughout the spec. Every id in it is live: the `tbl_` a screen is\nover, the `fld_` of each column, and the `opt_` of every option a role names.\n\n**Every query is described, in the model\'s own language.** The line an agent\nchooses between aliases by is written from this file\'s nouns \u2014 the entity\'s\nlabel, the screen\'s, the link\'s \u2014 and the sentence around them is the language\nthe file is mostly written in, decided the same way a generated write\'s refusals\nare. Hand-editing one is erased by the next regeneration; `lotics app check`\nrefuses an alias that carries none.\n\nA screen is that list and the RECORD it opens, and the record comes off the same\nroles \u2014 nothing to declare for it. It opens with a **header**: the entity\'s\n`identity` as the record\'s name \u2014 whether or not the list has a column for it, so\na ledger\'s and a trend\'s records are named too \u2014 the `when` this screen reads\nunder it, and ONE headline\nfigure (the `amount` the screen reads, else its `measure`). BOTH DOORS state the\nname: which door a screen uses is a layout answer, and what the record is ABOUT\nis not.\n\n**A JOB\'S HEADER ALSO STATES WHO IT IS FOR.** Where the record is the work itself\n\u2014 a desk\'s rows that belong to nothing, opened on a page \u2014 its `party` is a link\nunder the name, beside the ways to reach the subject, and it is then not a fact\nas well: a page headed by a code alone said nothing about whose work it is, and\nthe one thing a reader arriving from the desk already knows the row by sat in the\ngrid below as a row among forty. A record opening in a DRAWER has no row of links\nto hang it on, so its party stays a fact; so does a LINE\'s, whose register files\nit under the thing it belongs to and whose party is that parent\'s, restated per\nrow.\n\n**A record whose figures are a SENTENCE states them under the header, in a\nband**, and the header then states neither of them: a job reads what it comes to,\nhow far through it is (the `measure` against its limit), what is left of it (a\nformula that is exactly `{amount} - {measure}`) and how long there is (an\n`obligation`, else its `when`, counted down); a party reads its settlement, the\n`amount` and `measure` roles it carries in the order the model declares them; a\ncatalogue item reads its `amount` against the reference that amount names. Two\nfigures are the floor and four the ceiling \u2014 under two, the header keeps its one.\nA band is a PAGE\'s strip, so a screen whose `record` is a drawer keeps every\nfigure where a drawer reads them. Each figure is drawn exactly once: the limit a\nmeter states is not a fact beside it, and the day a countdown counts to is not\nthe provenance line above it. The `lifecycle` is not badged there, because the progress section is the\nrung it stands on. Then its sections, in the order its KIND reads them: the\n**facts** (every field neither the header, the band nor another section owns, the\nrow\'s own `parent` among them \u2014 led by the ones THIS screen\'s slots bound, then\nthe key it is filed under, then the order the model declares them in \u2014 a\n`markdown` field among them, drawn across the grid\'s whole row rather than\nwrapped to a third of it), the record\'s own **body** (that same markdown, taken\nas a section instead, ONLY where the kind leads with it: a catalogue item IS its\ndescription, and a job is not its notes), the **charge** (where the `amount` is a formula over exactly one\nquantity and one money field, the line states `quantity \xD7 unit price = amount`\nand neither figure is typed twice \u2014 the section is headed by the amount\'s own\nlabel and the closing row says what the figure IS, so the word is said once),\nthe **progress** (the `lifecycle`\'s stages, and the ONE act that moves the record\non \u2014 every other move, outcomes included, is an item in the menu beside it), a\n**required set** (a multi-select `expected_set`, or a child entity whose rows\ncarry one entry of it each \u2014 that child\'s `files` field is what a paper attaches\nto), the record\'s **own rows** (any other child, its role-bound fields as\ncolumns), and its **files** (every `files` field, the `mark` first \u2014 a section of\nthe column like any other, straight after the facts it is the evidence for,\nexcept on an EVIDENCE record, which OPENS on the paper it exists for; the\nheading carries the Add verb, and stands it down while the pile is empty, where\nthe drop well is already the door; and a `verdict` whose FORMULA reads one of\nthose files fields is the pile\'s OWN state rather than a fact row \u2014 the file it\nis short, drawn as a ghost, and the mark the rail sends a reader to). A child is\nan entity that LINKS here, ONE SECTION PER LINK: the rows it owns (`parent`), the\nrows that name it (`party` \u2014 so a party\'s record is its history), and the rows\nreaching it through a link carrying neither role, headed by that link\'s own\nlabel. A child hanging off two records therefore declares one `parent` and\nleaves the second relationship a plain link, which still gets its section.\n\n**THE ROWS A RECORD OWNS ARE ADDED AND OPENED FROM THEIR SECTION.** A `parent`\nsection\'s heading carries the Add for its rows, handing the record as the\nparent \u2014 filled, never asked. Every row opens its own record \u2014 a line of a book,\na paper on a desk, a stop on a timeline alike: where this app already routes a\npage for those rows (the register\'s own, under one of them) it is navigated to;\notherwise it is a DRAWER mounted on the record it belongs to, stepped \u25C0 \u25B6 over\nthe section\'s rows as they are drawn, with the row\'s framed editors, its ladder\nand its files, saving through `update_<child>` \u2014 the one editor those rows have,\nwhich any control the section draws over a row writes through too. Never the\n`parent` link itself: it is the key the row is FILED under, stated by the record\nthe drawer was opened from, so it is neither a fact of the drawer nor an input\nof that editor. ONE LEVEL, and only from a PAGE: a child\'s own children draw as\nthe kit\'s panel of the row, with no drawer and no Add, and a register whose own\nrecord is a DRAWER is the same for the children it owns \u2014 a drawer mounts no\ndrawer. **A ROW IS ADDED WHERE IT CAN BE OPENED AGAIN**, so those sections lose\ntheir Add with their drawer; a row of the register\'s OWN kind keeps it, because\nthe register\'s list opens it. A correspondence\'s entries are the one owned row\nthat opens nothing anywhere: the composer under them is the section\'s Add\nwhatever door the record has, and the answer mark is one field of the reply\'s\nown editor. The rows a `party`\'s record lists, and those reaching a record\nthrough a roleless link, are read only \u2014 a history, never a place to open or\nadd.\n\n**TWO CHILDREN ARE READ AS A RUN RATHER THAN AS A REGISTER**, and the model says\nwhich by the roles it declares on them. A child carrying an `obligation` whose\n`satisfied_by` is a DATE is a **timeline**: its rows are the ordered stops, each\nstating the day it was promised for and the day it happened, with the delta named\nwhere the rung was late \u2014 drawn as a register those are two date columns and the\nreader subtracts them by eye, which is exactly what "three days late at\ndischarge" costs to learn. An obligation closed by a FILE says nothing about\nwhen, so those rows stay the register they were. A child carrying a `markdown`\ntext field beside a `party` and a `when` is a **thread**: the entry is the body,\nthe author and the day lead it, and a `verdict` on the child is the yes/no one\nreply is marked the ANSWER with \u2014 a register of messages is a table whose one\nuseful column is the one it cannot draw. A SECOND `party` on those rows is who\nthe entry was left WITH, and the latest one names the ball in court: while\nnothing answers, the record says whose move it is and counts its own deadline\n(an `obligation`, else its `when`) against them. Both are read only under the\nrecord that OWNS the messages \u2014 under the party who wrote them the same rows are\nthat party\'s history, several questions at once, and the last of them says\nnothing about any. That record also REPLIES: the section\'s Add is a composer \u2014\nthe message is all a reply asks \u2014 and the answer mark is one field of the\nreply\'s own editor.\n\n**NOTHING SITS BESIDE THE RECORD.** A page is ONE reading column \u2014 its fields,\nits files, the rows it owns, its notes, all sections of it in the archetype\'s\norder \u2014 over a reserved left gutter holding a floating NAVIGATION RAIL: the way\nback, then one line per section with that section\'s category mark, and nothing\nelse. No files, no counts, no group headings. Too narrow to seat the rail, it is\none pinned bar of the same list across the top. A section HEADING never wears\nthe mark; the rail\'s line for it does.\n\n**A RELATION THE RECORD NEVER ACTS ON CLOSES THE COLUMN.** Every register that\nmerely NAMES the record is one counted line of a single last section: its label,\nhow many rows name this record (a COUNT, never a page of rows measured), and a\npress that opens the register owning them narrowed to this record. An entity\nthis app lists nowhere is a count with no door, and where every count is zero\nthe section is not drawn at all. The one exception is a PROFILE, whose history\nIS its body and stands open in the column.\n\n**Rows the record owns whose `amount` is `signed_by` a direction are a\nBOOK**, drawn as a statement \u2014 each line dated and signed, the balance under\nthem where there is more than one line to add up, labelled by the amount\'s own\nword \u2014 where the same rows on the party they\nwere transacted with stay that party\'s history, which is scanned for the line\nshort of its paperwork rather than struck to a balance. **Rows that carry a\n`when` read as RUNS**: a party\'s history under the year \u2014 and only where the rows\nin hand span more than one, since a subhead over every line of a book names the\nyear and separates nothing \u2014 and a job\'s own rows under the day they fall on,\nwhich, where those rows also carry a `lifecycle`, is an ITINERARY rather than a\ngrouped table: the day heads the run, each row is one line (its `slot`, its name,\nits party, its stage, its `category` and `reference`, its amount), the figures end\non one edge and the run closes with their sum \u2014 unless the record\'s own BAND is a\nsum rollup over exactly these rows, which states it once already. A `category`\npaints the node the run is read down, in the option\'s own colour; a `reference`\ncloses the line with the code somebody quotes on the phone. **A `mark` LEADS the\nline with the stop\'s own picture** \u2014 a hotel, a dish, a boat \u2014 and it is the one\nplace on a record where a child entity\'s mark is drawn at all, since no register\ncolumn is ever a picture; a line whose subject holds none is marked with its\n`category`\'s glyph instead, so the run keeps one left edge. All three are\nPROJECTED for the run whether or not the register\'s column budget would have\ndrawn them, and a mark is projected NOWHERE ELSE.\nA line\'s mark is most often the SUBJECT\'s rather than the line\'s own \u2014 the\nservice, the product, the room it is a booking of \u2014 which is a `lookup` through\nthat link onto the subject\'s own `files`, given the role on the line.\n**EACH DAY\'S HEAD CARRIES THE SECTION\'S OWN ADD** as well, handing the panel that\nday, so a line made from Tuesday opens with Tuesday answered; the heading keeps\nthe verb too, for the first line and for a day nothing is planned on yet. **THE\nKEY LEAVES THE COLUMNS where the head states the whole of it** \u2014 a day head does,\na year head states four digits of the date and the day is what the column is\nstill there to carry.\n**A run is READ FROM THE END THE READER WANTS**: a job\'s own rows and an itinerary\nascending, a book and a history descending.\n\n**A DESK OVER AN ENTITY THAT CARRIES A `slot` IS ITSELF A DAY\'S RUN.** Its own\nregister is drawn under one subhead per day, sorted by that day and then by the\npart of it, and the date column goes \u2014 the subhead states it once for the whole\nrun, and a column repeating it down every line is the same value twice. Without\nit an itinerary was one flat list in whatever order the rows arrived.\nA document desk keeps its head either way \u2014 the entries it owes are its\ncontent, filed or not. The name and whatever figure is left to it are the header\'s\nalone \u2014 it states them in full, so a fact for either would be the same sentence\ntwice.\n\n**A FACT IS PRIMARY OR IT IS PROVENANCE.** An `autonumber`, a date the platform\nstamps (`derive_from`) and a date a formula works out that no role names are what\nthe SYSTEM wrote; they fold, with every optional field this record does not\nstate, behind the grid\'s ONE link. Everything a person types or picks is primary\nand is shown, empty or not, because absence is work somebody owes. **A screen\nthat named `facts.groups` has already said which:** a field in a group is\nprimary, and one in none folds \u2014 grouping says what the reader decides with, and\nby saying so says the rest are not. A `tier` on a `facts` entry exists only for\nthe case that derivation gets provably wrong.\n\n**A `contact` field whose kind the\nmodel decides** \u2014 an email, a number, a place, a `link`-formatted text, or a\nMESSAGING APP its label names (WhatsApp, Zalo, Telegram, Viber, WeChat, Line,\nMessenger) \u2014 leaves the facts too, for the row of links under the name on a\nrecord that opens as a page; one whose kind nothing decides stays a labelled\nfact, because a scheme nobody stated is a link that opens nothing. A handle is\ndrawn under its app\'s name \u2014 the app is what identifies the person there, and a\nbare number beside an envelope says the wrong thing \u2014 and it dials only where its\nvalue IS a number.\n`check` prints the record under each screen\'s slots:\n\n```\n Orders \u2014 lifecycle desk over Orders (12 rows) \xB7 page \xB7 tabs: Stage (New \u2192 Quoted \u2192 Confirmed \u2192 Shipped \u2192 Done)\n stage Stage \xB7 identity Order no. \xB7 mark Photo \xB7 party Customer \xB7 amount Total \xB7 level (none \u2014 no field declares "measure") \xB7 when Due\n record: header (Order no. \xB7 Customer) \xB7 band (Total \xB7 Shipped against Lines \xB7 Due counting down) \xB7 progress: Stage (5 stages) \xB7 facts (Ship to \xB7 \u2026 2 filed) \xB7 files: Photos \xB7 documents: Papers (Kind: 4 required) \xB7 itinerary: Order lines by Ship by at Window, kind Handling, ref Waybill (Product \xB7 Quantity \xB7 Line total) \xB7 related: Visits (count via Order)\n```\n\nThe ORDER of that line is the record\'s own, top to bottom, and it is the KIND\'s,\nand every entry of it is a section of the one column. `related:` is the counted\nline that closes it \u2014 a register the record never acts on, named and counted \u2014\nand `\u2026 N filed` is how many facts fold behind the grid\'s one link.\nA child register whose `amount` is `signed_by` a direction is a BOOK of movements\n\u2014 what the record came to rather than what it is made of \u2014 so it closes the\ncolumn rather than standing among the rows the job is made of.\n\n**A section over a child is named by the CHILD\'s own label**, because the reader\nalready knows which record they are on: "\u0110\u01A1n h\xE0ng", not "\u0110\u01A1n h\xE0ng \u2014 Kh\xE1ch h\xE0ng".\nWhere two links from one entity reach this record, that label names two sections\nand each takes its own link\'s label as a qualifier \u2014 `H\u1ED3 s\u01A1 (\u0110\u01A1n h\xE0ng)` and\n`H\u1ED3 s\u01A1 (\u0110\u01A1n g\u1ED1c)`.\n\nIn that line `documents:` is a `RecordExpectedSet`, and `lines:` a `RecordChildren` over the rows\nthis record owns \u2014 `history:` where they name it as their `party` instead; a required set\nwhose entries are a CHILD entity carrying files takes `kind="files"`, and the entity\'s own\nmulti-select takes `kind="items"`, since nothing attaches to an option.\n\n**A section that knows its size says so.** Where a `measure` on the record is a\n`count` rollup over the very link a rows section hangs on, the limit it is read\n`against` is how many rows that section is OWED: the printout adds `\u2014 expects\n<limit>` and the screen draws that many ghost rows until the first one lands,\ninstead of "nothing here" two bands under a count saying three are outstanding.\n\nA figure printed as `Total (USD)` is MONEY in the currency named \u2014 a `formula`\nstates it in `formula.currency`, and a `rollup` or a `lookup` inherits it from the\nfield it reads, so read those brackets: a rate that should be in dollars and is\nprinted bare will be drawn in the workspace\'s own money. Where the entity carries\na `currency` role, the ROW\'s code outranks the field\'s on every line that states\none, and a line stating none falls back to the field\'s.\n\n**A record surface can be OPERATED, and that is the default.** Every screen with\na record gets a workflow that writes its editable facts \u2014 every field a person\nstates \u2014 so the record\'s values are edited in place and its stage is advanced\nfrom the progress section. `"writes": false` makes one screen a reader: use it\nfor a screen over rows another desk owns, never as the default. A desk nobody\ncan act on is a viewer of state somebody must go and set somewhere else.\n\n**Which facts those are is the FIELD\'s answer.** Text, number, date, yes/no and\nselect are typed or picked. A `"cardinality": "one"` link is RE-POINTED, through\na picker over the target entity named by its `display_field_aliases` \u2014 else the\ntarget\'s `identity` \u2014 narrowed by what the reader types and carrying that\nentity\'s own `read_scope`, so a link the plan gives neither column is read\ninstead of offering an empty list. A files field is ATTACHED TO and DETACHED\nFROM, as the delta rather than the pile, so two readers filing at once each keep\ntheir file. A many-link, an `autonumber` and every computed field are read: the\nfirst is a list a fact cannot state, and the rest are the platform\'s to write.\n\n**A one-link is that fact WHATEVER its sync.** `sync_both_ways` is one relation\nwith a field on each side, and the sides are not the same surface: the record\nthat names ONE row states it as a fact, and the MANY side is the register on the\nother entity\'s record. Declaring both directions does not give this record a\nregister of the single row its own fact already names.\n\n**The write is the record SURFACE\'s.** Its inputs are the fields that surface\noffers to save \u2014 the facts with an editor, the lifecycle its ladder advances, and\na files section over exactly one stated field \u2014 never every field a person could\nin principle type. What the header states, a limit a meter folds in, a required\nset with a section of its own and the pile a page\'s mark draws are read there, so\nno input is declared for them. A second screen over the same entity draws no\nsurface of its own and adds nothing.\n\nWhere two operable screens of one plan reach the same entity, `scaffold check`\nnames them and the apps they belong to: two desks writing one record is a\ndecision, and the split is stated on the FIELD in each app\'s\n`package.json#lotics.writes` rather than left to whoever edits second.\n\nThat workflow lands in the app as source, like every other: its body in\n`src/workflows/update_<entity>.ts` and its declaration in\n`package.json#lotics.workflows`. The two together are the binding \u2014 `lotics app\ndeploy` pushes whatever differs from what it last saw live \u2014 so the write is\nversion-controlled and travels with the app rather than being bound by hand\nafterwards.\n\nA `custom` screen declares its slots under `roles` (slot \u2192 role) and they bind\nthe same way:\n\n```jsonc\n{ "alias": "readings", "label": "Readings", "shape": "custom", "entity": "reading",\n "roles": { "subject": "identity", "reading": "measure" } }\n```\n\n## Applying packages\n\n`apply` copies published packages into the workspace AFTER the model\'s own\ntables exist \u2014 apps over the tables you just described, and any tables of their\nown they still need. Ordered, and run by `lotics setup` and `lotics scaffold\napply` alike.\n\n```jsonc\n"apply": [\n {\n "package": "apg_k3nf82ldpq",\n "bind": { // optional \u2014 which of YOUR tables each entity is\n "company": { "label": "Customers", "fields": { "name": "Company name" } }\n },\n "no_sample_data": true // optional\n }\n]\n```\n\n`bind` is keyed by the package\'s entity alias and holds the LABELS this\nworkspace uses: scaffold adopts by label, so binding points the package at the\ntables the model created instead of a second set beside them. Only naming\nmoves \u2014 a bound field must be the TYPE the package declares, or the copy is\nrefused. `lotics library list` is the shelf, and `lotics library show <apg_id>`\nlists the aliases to bind.\n\nEntries run in the order they are written, because a later one may bind onto a\ntable an earlier one created. **A refused entry stops the run and the entries\nbefore it stay** \u2014 they are separate copies, committed as they land, so the\nrefusal names them rather than leaving a caller to re-run the file and copy them\ntwice.\n\n## Presets\n\nA preset is a trade\'s model, published to be READ. An assistant reads it, asks\nat most two questions, picks a variant and writes a `model.json` from it \u2014\nnothing is copied, and a preset is a file rather than anything a workspace\ninstalls.\n\n```jsonc\n"preset": {\n "name": "Field service",\n "description": "Jobs, the crew that runs them, and what each one billed.",\n "questions": ["Do you dispatch crews, or one person per job?"], // at most 2\n "variants": {\n "crews": {\n "when": "work is dispatched to crews rather than to one person",\n "entities": [ /* tables this branch ADDS */ ],\n "fields": { "job": [ /* fields this branch ADDS to `job` */ ] }\n }\n }\n}\n```\n\nVariants are **additive only**: a branch adds entities and fields and never\nremoves them, so the base is a model in its own right rather than a draft.\n`lotics scaffold check` proves the base AND every variant merged onto it, so a\npreset ships with every branch already proven \u2014 the branch nobody took is the\none that fails in the workspace of whoever takes it.\n\n`preset` is not scaffolded. `lotics setup` and `lotics scaffold apply` ignore\nit and create the base model\'s tables.\n\n`lotics scaffold export` prints a workspace that already works as one of these\nfiles \u2014 the starting point for a preset or for another business\'s model, never a\nsource of truth: it carries one business\'s words and stops describing that\nworkspace the moment either changes.\n\n## Starting from a preset\n\n`lotics library list` is the shelf of them and `lotics library show <slug>`\nprints one whole: its questions, every table as `alias \xB7 label` with each field\nas `alias:type`, and each variant as `slug \xB7 when` followed by the tables and\nfields that branch adds. When one of them is the trade in front of you, do not\ntranscribe it \u2014 name it:\n\n```jsonc\n{\n "from": "field_service",\n "variants": ["crews"],\n "rename": { "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } } },\n "entities": [ /* a table this business has that the preset does not */ ],\n "rows": { "job": [ { "ref": "j1", "fields": { "code": "J-1" } } ] },\n "field_roles": { "job": { "code": "identity" } },\n "write_rules": { "customer": { "natural_key": ["email"] } },\n "apps": [ /* the screens this business\'s apps will have */ ]\n}\n```\n\n- **`from`** is the preset\'s SLUG \u2014 its own file name, a lowercase slug. Naming\n it is what makes `entities` optional; every other rule on this page is\n unchanged, because the file is resolved into the full form and then checked and\n applied exactly as one. A slug nothing serves is refused with the ones there\n are, never resolved against something else.\n- **`variants`** names the branches to merge onto the base, in order. Pick the\n one whose `when` describes what the person said; a slug the preset does not\n declare is refused rather than ignored.\n- **`rename`** is keyed by the preset\'s entity alias and holds the labels this\n business uses \u2014 the same shape `apply[].bind` takes, and the same rule: only\n naming moves. An alias the preset does not declare, and a label that is\n already another table\'s, are both refused.\n- **`entities`** are added after the rename, already in this business\'s own\n words.\n- **`rows`**, **`field_roles`**, **`write_rules`**, **`apps`** and **`apply`**\n mean exactly what they mean in the full form \u2014 `"rows"` are this business\'s\n real first records,\n `"field_roles"` may name the preset\'s fields as well as its own (a role the\n preset declares itself is kept unless this file names the same field, or\n clears it with `null`), `"write_rules"` the create-time clauses on either\n (an entity this file names replaces the preset\'s whole entry for it),\n `"apps"` the screens it will have (a preset carries none), `"apply"` the\n packages copied in once its tables exist.\n\nThis is the ONE thing on this page that needs the network: `check` reads the\npreset it names, once. Everything after that read is the same offline check.\n\nWrite the full form when no preset is the trade.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "singular": "Customer",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "singular": "Order",\n "fields": [\n { "alias": "code", "label": "Order no.", "type": "text", "unique": true },\n { "alias": "placed_on", "label": "Placed on", "type": "date", "format": "date" },\n {\n "alias": "amount",\n "label": "Amount",\n "type": "number",\n "format": "currency",\n "currency": "VND"\n },\n {\n "alias": "total",\n "label": "Total with VAT",\n "type": "formula",\n "formula": { "expression": "{amount} * 1.1", "format": "currency", "currency": "VND" }\n },\n {\n "alias": "customer",\n "label": "Customer",\n "type": "select_record_link",\n "target_entity": "customer",\n "cardinality": "one",\n "sync_both_ways": true,\n "paired_field_alias": "orders",\n "display_field_aliases": ["name"]\n }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "field_roles": {\n "customer": { "name": "identity" },\n "order": { "code": "identity", "placed_on": "when", "amount": "amount", "customer": "party" }\n },\n "apps": [\n {\n "alias": "customers",\n "name": "Customers",\n "screen": { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" }\n },\n {\n "alias": "orders",\n "name": "Orders",\n "screen": { "alias": "orders", "label": "Orders", "shape": "transaction_ledger", "entity": "order" }\n }\n ],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\n`lotics scaffold check` on this file reports\n`2 tables, 9 fields, 2 links, 1 view, 1 role, 4 rows, 2 apps`, then\nthe plan:\n\n```\nCustomers\n Customers \u2014 party register over Customers (2 rows) \xB7 page \xB7 tabs: none\n identity Name \xB7 mark (none \u2014 no field declares "mark") \xB7 contact (none \u2014 no field declares "contact") \xB7 worth (none \u2014 no field declares "measure") \xB7 risk (none \u2014 no field declares "verdict")\n above: rows (default)\n record: header (Name) \xB7 history: Orders (Order no. \xB7 Placed on \xB7 Amount (VND)) \xB7 facts (Tier \xB7 Total ordered (VND))\nOrders\n Orders \u2014 transaction ledger over Orders (2 rows) \xB7 drawer \xB7 tabs: none\n when Placed on \xB7 amount Amount (VND) \xB7 reference Order no. \xB7 party Customer \xB7 classification (none \u2014 no field declares "lifecycle") \xB7 document (none \u2014 no field declares "expected_set" or "verdict")\n above: Amount (VND) (default)\n record: header (Order no. \xB7 Placed on \xB7 Amount (VND)) \xB7 facts (Placed on \xB7 Total with VAT (VND) \xB7 Customer)\nWho writes what:\n Customers (customer, one: Customer): Customers\n Orders (order, one: Order): Orders\n```\n\nThe last block is the WRITERS matrix \u2014 one line per record surface the plan\nleaves operable, the word one of its rows is called, and the app whose register\nopens it. Part of the verdict rather than a diagnostic beside it: which desk\nchanges a table is answerable from the plan, and two apps on one line is the\nsplit to state in each of their `package.json#lotics.writes`.\n\nEvery `(none)` is a slot no field fills, and it says WHY: no field declares the\nrole, or several could and the plan named none of them (`(none \u2014 a, b; name\none)`). Read it as the screen a person will see. `above: \u2026 (default)` is the band\nnobody stated \u2014 the shape sums the amount its slot draws, and a plan naming its\nown figures loses the word. Neither\nentity carries a stage, a required set or files, so the rest is facts \u2014 except\nthe sections neither entity declares: the customer\'s `history:` is the orders\nthat name it as their party, and the order\'s `via Orders:` is the customers whose\n`Orders` link names it. **A link is a section, not a fact**, on the side it\npoints AT and on the side that holds it: `Customers.Orders` therefore leaves the\ncustomer\'s facts, because the section below already lists the same relation and\na link fact can only ever show the first of them. Both records state their name\nin the header and nowhere else \u2014 a ledger\'s columns are the date and the figure,\nso `Order no.` is on no column of that list and is still what the drawer behind\na line is called. `Total ordered (VND)` is the rollup inheriting the currency of\nthe column it sums: money is read off the model, never off the field\'s own line.\n';
|
|
56127
|
+
var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing the tables, fields, options, views, roles and first rows\na workspace starts with. `lotics scaffold check model.json` proves it offline \u2014\nno account, no network. `lotics setup model.json --email you@company.com` creates\nthe account and applies it. `lotics scaffold apply model.json` applies it again,\ninto the workspace the credential names.\n\n**There are two forms of this file.** The full one, below, spells the model out.\nThe `from` one names a published preset and carries only what this business\ndiffers by \u2014 see \xA7 Starting from a preset, and prefer it whenever a preset fits\nthe trade.\n\nApps are PLANNED here and built afterwards: `apps` names each app\'s screens as a\nshape over an entity, checked against the roles `field_roles` gives its fields,\nso the plan is refused before anyone builds a screen (\xA7 Apps and screens). The\nbuilt app lives in the workspace; publishing that workspace as a package is how\nit ships.\n\n## The rules\n\n- **At least one entity, at most 50.** More tables than that is a data model\n being designed, not scaffolded \u2014 scaffold the rest in a second call.\n- **Adoption is explicit.** `lotics setup` REFUSES an entity whose `label`\n already names a table in the workspace, naming every colliding label at once.\n `lotics scaffold apply` adopts those tables and adds the fields, options and\n views they are missing. Nothing is ever modified or deleted, so applying the\n same model twice creates nothing the second time.\n- **Adoption is by LABEL, not alias.** Change an entity\'s `label` and the next\n run asks for a NEW table beside the old one. Renaming a FIELD is\n `lotics field rename <table> <field> "<new label>" --model <this file>`, which\n moves the platform, this file and every app bound to it together; deleting is\n `lotics run delete_table`. Neither goes through the file.\n- **The file\'s own majority is the language.** A model names no locale \u2014 which\n language it is in is what it mostly says, and `scaffold check` notes the label\n written the other way. The generated screens read the kit\'s pack, and a\n generated WRITE cannot: its refusals run on the server, so they are worded in\n that same majority. Mix the two and the workspace answers in two languages.\n- **`lotics scaffold diff model.json` says where the file and the workspace have\n come apart**, joined on label, entity then field \u2014 the join adoption itself\n makes. It exits 1 on any difference, so a model that is about to be published\n as a preset carries the labels in use rather than the ones it was written with.\n- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere, and the result says\n `rows_skipped: true`: sample rows landing among a customer\'s real ones cannot\n be told apart from them.\n- **After the first run the WORKSPACE is the source of truth.** The file is an\n authoring input, not a mirror \u2014 scaffold never deletes what the file stopped\n naming.\n- **`lotics scaffold check` decides all of it offline**, and reports every\n problem in one run rather than the first: an alias that resolves to nothing, a\n link whose pair is not symmetric, and the rows themselves \u2014 a field the entity\n does not declare, an option alias the field does not declare, a link naming no\n row in the file, a `ref` used twice, a date that is not one, a value on a\n platform-computed field, and a files cell that is neither a relative path\n beside this file nor a `fil_` id.\n\n## Top level\n\n```jsonc\n{\n "entities": [ /* the tables */ ],\n "roles": [ /* workspace groups to create */ ], // optional\n "templates":[ /* inline html / email templates */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* the reporting role each field plays, keyed by entity then field */ }, // optional\n "write_rules": { /* what a CREATE finds, defaults and refuses, keyed by entity */ }, // optional\n "apps": [ /* the screens each app will have, as shapes over entities */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ], // optional\n "preset": { /* a trade\'s branches, for a PUBLISHED model */ } // optional\n}\n```\n\nThe other form names a preset instead of restating one:\n\n```jsonc\n{\n "from": "field_service", // the preset this model starts from, by slug\n "variants": ["crews"], // optional \u2014 its branches to merge in, in order\n "rename": { // optional \u2014 what THIS business calls each table\n "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } }\n },\n "entities": [ /* tables the preset does not declare */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* roles on the preset\'s fields and this business\'s own */ }, // optional\n "write_rules": { /* create-time clauses on the preset\'s entities and its own */ }, // optional\n "apps": [ /* the screens each app will have */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ] // optional\n}\n```\n\n**A model may not carry** `fixtures`, `knowledge` or `knowledge_expects`, and no\n`excel` / `word` / `pdf-form` template: each of those is content that lives in a\npublished bundle, which a model has none of. `apps` here is a plan of screens,\nnever built code. An unknown top-level key is an error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because scaffold matches by label.\n\n## Entity\n\n```jsonc\n{\n "alias": "order",\n "label": "Orders", // the table\'s name, which names the SET it holds\n "singular": "Order", // optional \u2014 ONE of them, for the act that opens one\n "description": "\u2026", // optional\n "fields": [ /* at least one */ ],\n "views": [ /* optional; an entity with none still gets the default grid */ ],\n "read_scope": { /* optional; absent, everyone with access to the table reads every row */\n "any": [\n { "member_of": "sales" }, // a role alias this model declares\n { "field": "scope", "is": ["shared"] } // a single select on this entity, by option alias\n ]\n }\n}\n```\n\n**`singular` is what a create says.** A register\'s own act and the panel it\nopens name the row being made \u2014 `New Order`, `H\u1ED3 s\u01A1 m\u1EDBi` \u2014 while `label` names\nthe table, so without it the button reads `New Orders`. Nothing derives it\n(English plurals are irregular), and most models need none: a Vietnamese noun is\nthe same word either way. `scaffold check` prints it beside the table under *Who\nwrites what* and NOTES a table that will name the set. An act on a child section\nkeeps the label: it adds a line to the register under the record the reader is\nalready standing in.\n\n**`read_scope` is a ROW rule, enforced by the platform.** A row is readable when\nANY clause holds: the reader is in that role\'s group, or the named select carries\none of those options. It is resolved at scaffold into the table\'s own row\nfilters, so an app, a workflow reading for a viewer, and the API all answer the\nsame rows \u2014 a per-record visibility field the app merely honours is a convention,\nnot a gate. `any` may not be empty (a rule nobody satisfies hides the table), and\nevery role alias and option alias in it must be one this model declares.\n\n## Field\n\nEvery field carries `alias`, `label`, an optional `description`, and an optional\n`required` \u2014 advisory only, read by app forms and workflows; the table itself has\nno required constraint. `label` may not contain `{` or `}` (formulas reference\nfields by label at the platform level).\n\n`default` is the value pre-filled into a NEW record. It applies on create only;\nexisting records are never backfilled. Only the types listed below accept one.\n\n### `text`\n\n```jsonc\n{ "alias": "name", "label": "Name", "type": "text",\n "unique": false, // optional \u2014 require distinct values\n "format": "text", // optional \u2014 "text" | "link" | "markdown"\n "default": "" } // optional\n```\n\n### `number`\n\n```jsonc\n{ "alias": "amount", "label": "Amount", "type": "number",\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage"\n "currency": "VND", // optional \u2014 ISO 4217\n "default": 0 } // optional\n```\n\n`format` is what the number IS, and every surface reads it: `currency` prints as\nmoney in the code the row or the field states, `percentage` as a whole percent\nwith its sign. An ABSENT number is drawn absent \u2014 the one exception is a\n`sum` or count rollup the plan reads as a **`measure`**: that is the thing\naccumulated toward a bound, so nothing accumulated yet is zero and the meter\ndraws it. The same rollup read as an `amount` keeps its blank, and so does every\nother role: nothing added to what a row is WORTH means unpriced, not free. A\n`min`, an `avg`, a percentage of nothing and every FORMULA stay blank in any\nrole, because none of them has an answer to give. This is why the pair on one\nscreen reads two ways \u2014 what has come in against what is owed \u2014 and why a\nmeasure\'s own LIMIT, an amount, leaves an unquoted row out of the count rather\nthan reporting it as nothing collected. **AND WHERE THAT LIMIT IS ABSENT THERE IS\nNO LEVEL AT ALL**: a level is a reading AGAINST a bound, so a row that states no\nbound draws nothing \u2014 cell, fact and all \u2014 rather than a numerator whose whole\nmeaning was the comparison. "Collected 0" beside a blank total reads as money\nagainst a job worth nothing. A measure the model gives no limit is a plain figure\nand is unaffected. A share is stored in percent units \u2014 68.1 is 68.1 % \u2014 and the\ncolumn, the fact behind it, the meter it is judged by and the figure over the\nregister all say so.\n\n### `date`\n\n```jsonc\n{ "alias": "placed_on", "label": "Placed on", "type": "date",\n "format": "date", // optional \u2014 "date" | "datetime" | "date_range" | "datetime_range"\n "timezone": "Asia/Ho_Chi_Minh", // optional \u2014 IANA name\n "derive_from": "created_at", // optional \u2014 "created_at" | "updated_at"; makes the field read-only\n "default": "2026-01-01" } // optional; refused together with derive_from\n```\n\n### `boolean`\n\n```jsonc\n{ "alias": "paid", "label": "Paid", "type": "boolean", "default": false }\n```\n\n### `select`\n\n```jsonc\n{ "alias": "tier", "label": "Tier", "type": "select",\n "options": [ // at least one\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "multi": false, // optional\n "default": ["standard"] } // optional \u2014 option ALIASES; one unless multi\n```\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet.\n\n```jsonc\n{ "alias": "owner", "label": "Owner", "type": "select_member", "multi": false }\n```\n\n### `select_record_link`\n\n```jsonc\n{ "alias": "customer", "label": "Customer", "type": "select_record_link",\n "target_entity": "customer", // an entity alias this model declares\n "cardinality": "one", // optional \u2014 "one" | "many" (default "many")\n "sync_both_ways": true, // optional \u2014 keep a paired field on the target\n "paired_field_alias": "orders", // the partner field ON THE TARGET entity\n "display_field_aliases": ["name"] } // optional \u2014 what the link shows / the picker\'s columns\n```\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused.\n\n**A single-valued link needs no partner.** `"cardinality": "one"` on its own is a\nlink that holds one row \u2014 one customer on an invoice, one project on a device \u2014\nand nothing is created on the target. The mirror invariant belongs to a PAIRED\nlink: pair a link when the target\'s own record should list what points at it, and\nleave it unpaired when it should not. Either way the record plan draws the\nrelation as a section on the side it points at, so an unpaired link costs the\ntarget nothing.\n\n### `files`\n\n```jsonc\n{ "alias": "attachments", "label": "Attachments", "type": "files" }\n```\n\n### `formula`\n\n```jsonc\n{ "alias": "total", "label": "Total", "type": "formula",\n "formula": {\n "expression": "{amount} * 1.1", // fields on THIS entity, by alias, in braces\n "output_type": "number", // optional \u2014 what it YIELDS: "number" | "text" | "date" | "datetime" | "boolean"\n "format": "currency", // optional \u2014 how that result is DRAWN: "number" | "currency" | "percentage" | "link"\n "currency": "VND" // optional\n } }\n```\n\n`output_type` is what the expression answers WITH; `format` is how it is printed.\nThe platform infers the result at write time and ignores what you declare, so\nthis is a statement the offline checks read \u2014 which is what lets a role or a\nscreen clause accept a computed value: a caption over a derived name\n(`output_type: "text"`), a period over a settled date (`"date"`). A formula\ndeclaring neither says nothing about its result, and every rule that needs one\nrefuses it by name. `lotics scaffold export` writes the result a live workspace\ncomputed, so exporting a workspace fills these in.\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n```jsonc\n{ "alias": "total_ordered", "label": "Total ordered", "type": "rollup",\n "source_field_alias": "orders", // a select_record_link field on THIS entity\n "aggregate_option": {\n "operation": "sum", // count | sum | avg | median | min | max | range |\n // empty | filled | percent_empty | percent_filled |\n // unique | percent_unique |\n // earliest | latest | date_range |\n // checked | unchecked | percent_checked |\n // percent_unchecked\n "field_key": "amount" // a field ALIAS on the linked entity ("count" may omit it)\n },\n "filter": { /* optional \u2014 see Views; every field_key is an alias on the LINKED entity */ } }\n```\n\nThe operation must be one the aggregated field\'s type allows \u2014 `sum` over a\nnumber, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n```jsonc\n{ "alias": "customer_tier", "label": "Customer tier", "type": "lookup",\n "source_field_alias": "customer", // a select_record_link field on THIS entity\n "lookup_field_alias": "tier", // a field alias on the linked entity\n "order_by": { "field_key": "placed_on", "direction": "desc" } } // optional \u2014 pick the single extreme row\n```\n\n### `autonumber`\n\n```jsonc\n{ "alias": "seq", "label": "No.", "type": "autonumber",\n "prefix": "SO-", // optional \u2014 ignored when template is set\n "padding": 4, // optional \u2014 1..20, zero-pads the integer\n "template": "SO-{YEAR}-{N:4}" } // optional \u2014 {N}, {N:W}, {YEAR}, {YEAR:2}, {MONTH}, {DAY}\n```\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n```jsonc\n{\n "alias": "gold",\n "label": "Gold customers",\n "description": "\u2026", // optional\n "columns": [ // optional \u2014 omit to show every field\n { "field_alias": "name", "visibility": "visible", "width": 240 },\n { "field_alias": "tier", "visibility": "hidden" }\n ],\n "filters": { // optional\n "node_type": "group",\n "logic": "and", // "and" | "or"\n "children": [\n { "node_type": "condition", "type": "select", "field_key": "tier",\n "operator": "has_any_of", "value": ["gold"] }\n ]\n },\n "sort": [ { "field_key": "name", "order": "asc" } ], // optional; order is "asc" | "desc" | null\n "summary": { "amount": "sum" }, // optional \u2014 field alias \u2192 footer operation\n "frozen_columns": 1 // optional\n}\n```\n\nA condition\'s `type` is the field\'s type and its `operator` is one that type\nadmits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n`columns`, when present, is exhaustive and must not be empty: a view renders\nexactly the entries it holds. Omit the key to show every field.\n\n## Roles\n\nA role becomes a workspace group. Members are added afterwards, in the app.\n\n```jsonc\n{ "alias": "sales", "label": "Sales" }\n```\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes. An `html` template renders to a PDF when a workflow generates\nfrom it; `{{name}}` is filled from the workflow\'s data.\n\n```jsonc\n{ "alias": "order_ack", "label": "Order acknowledgement", "type": "email",\n "content": "<p>Hello {{customer}}\u2026</p>" }\n```\n\nA paper that has to look like a counterparty produced it \u2014 an official letter,\nan acceptance minute, a supplier\'s bill \u2014 is the same `html` template with a\nshell around the body: a letterhead, a reference line, a seal and a signature\nblock, and paper grain over everything. One shell, many bodies; the data is the\nonly thing that changes, so a workflow can re-issue it over any record.\n\n```jsonc\n{ "alias": "cong_van", "label": "C\xF4ng v\u0103n", "type": "html",\n "content": "\u2026the page below, as one JSON string\u2026" }\n```\n\n```html\n<style>\n .sheet{position:relative;width:718px;padding:44px 58px 30px;background:#fbfaf6;color:#111;font:14.2px/1.5 \'Liberation Serif\',serif}\n .grain{position:absolute;inset:0;opacity:.34;mix-blend-mode:multiply;background:url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'140\' height=\'140\'><filter id=\'f\'><feTurbulence baseFrequency=\'.9\' numOctaves=\'2\'/><feColorMatrix values=\'0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0\'/></filter><rect width=\'140\' height=\'140\' filter=\'url(%23f)\'/></svg>")}\n .top{display:flex;text-align:center;font-size:13.4px} .top>div{flex:1} .u{display:inline-block;border-bottom:1px solid #111;font-weight:700}\n .ref{display:flex;text-align:center;font-size:13.4px;margin-top:6px} .ref>div{flex:1} .ref .r{font-style:italic}\n h1{text-align:center;font-size:15.6px;margin:26px 0 18px} p{text-align:justify;text-indent:26px;margin:0 0 9px}\n .sig{display:flex;margin-top:20px} .sig .l{flex:1} .sig .r{width:290px;text-align:center;position:relative}\n .sig .nm{font-weight:700;margin-top:96px} .seal{position:absolute;left:4px;top:8px;width:166px;height:166px;opacity:.66;mix-blend-mode:multiply;transform:rotate(-17deg)}\n </style>\n <div class=\'sheet\'><div class=\'grain\'></div>\n <div class=\'top\'><div><b>{{issuer_parent}}</b><br><span class=\'u\'>{{issuer}}</span></div>\n <div><b>C\u1ED8NG H\xD2A X\xC3 H\u1ED8I CH\u1EE6 NGH\u0128A VI\u1EC6T NAM</b><br><span class=\'u\'>\u0110\u1ED9c l\u1EADp - T\u1EF1 do - H\u1EA1nh ph\xFAc</span></div></div>\n <div class=\'ref\'><div>S\u1ED1: {{number}}</div><div class=\'r\'>{{place}}, ng\xE0y {{day}} th\xE1ng {{month}} n\u0103m {{year}}</div></div>\n <h1>{{title}}</h1>\n <p>K\xEDnh g\u1EEDi: {{recipient}}.</p>\n {{{body}}}\n <div class=\'sig\'><div class=\'l\'><b>N\u01A1i nh\u1EADn:</b><br>- Nh\u01B0 tr\xEAn;<br>- L\u01B0u VT.</div>\n <div class=\'r\'><img class=\'seal\' src=\'{{seal_url}}\'><b>{{signer_title}}</b><div class=\'nm\'>{{signer}}</div></div></div>\n </div>\n```\n\n`lotics preview <file.html>` renders any such page to a PNG the way a demo\'s\nprops are made, sized to its content, so a paper can be looked at before it is\nput in a template.\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model, attaching at most 2000 documents between them \u2014 a real data set\nbelongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n`ref` is a local handle (lowercase letters, digits, underscores) that other rows\'\nlink fields address. It is never persisted.\n\nA `files` cell attaches documents: paths relative to this file (no `..`, never\nabsolute), which `check` proves exist and `apply` uploads into the workspace\nbefore any row is written \u2014 a paperwork business seeds its papers with its\nrows. The server accepts only `fil_` ids of files this workspace owns, which is\nwhat the upload leaves behind. After a run that wrote rows, `apply` writes the\nrecord ids beside the file (`<model>.last_run.json`): `delete_records` over\nthem is how a seeded set is reset, and applying again re-dates it.\n\n`fields` is keyed by field alias, and every value is read against the field\'s\nDECLARED type:\n\n| Field type | Value |\n|---|---|\n| `text` / `number` / `boolean` | the value itself |\n| `date` | `"2026-03-14"`, or a relative expression (below) |\n| `select` | the option ALIAS \u2014 `"gold"`, or `["gold","vip"]` for a multi-select |\n| `select_record_link` | `"<entity-alias>:<ref>"` naming another row in this file \u2014 `"customer:acme"`, or an array for several |\n| `select_member` | `"self"` only \u2014 the person applying the model |\n| `files` | paths beside this file \u2014 `["scans/pccc_letter.png"]` \u2014 uploaded by `apply`/`setup` before the rows are posted; or `fil_` ids of files already in this workspace |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n**Documents can be attached on their own, afterwards.** Rows land only into empty\ntables, so a model whose binaries were added after the first apply has no second\napply to carry them: `lotics scaffold apply model.json --documents` writes ONLY\nthe `files` cells, onto the records the first run created, joined through the\n`<model>.last_run.json` beside the file. Running it twice attaches nothing the\nsecond time \u2014 the same path keeps the same file, and a cell is a set.\n\n### Relative dates\n\nA date cell holds a literal `YYYY-MM-DD`, or an expression relative to the day\nthe model is applied, so a screen that opens on "this month" is not empty a month\nlater:\n\n- `@today` \u2014 the day of the run, in the workspace\'s timezone\n- `@month-start` \u2014 the 1st of that month\n- either with a whole-day offset: `@today-14`, `@month-start+9`\n\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Field roles\n\n`field_roles` names the reporting role a field plays on its entity \u2014 keyed by\nentity alias, then field alias \u2014 so every screen over the entity agrees on\nwhich column names the row and which select is the stage. A shape\'s slot binds\nto it (\xA7 Apps and screens). Like `rows` and `apps`, it is this file\'s: `check`\nproves it and the workspace never sees it. Each role sits on the types that can\nanswer it:\n\n| Role | On | Meaning |\n|---|---|---|\n| `identity` | `text`, `select_record_link`, `formula` | what a PERSON calls the row \u2014 the register\'s first column; a link where the row is "the product, at this branch"; a formula where the name is computed, and then it may not be `format`ted as a figure. NEVER an autonumber: a minted code is a `reference`. One per entity |\n| `reference` | `text`, `autonumber`, `formula` | the key the SYSTEM files the row under \u2014 an order number, a container code \u2014 drawn as the supporting line under the name rather than as a column of its own, and leading where the entity has no `identity` at all. One per entity |\n| `mark` | `files`, or a `lookup`/`rollup` that resolves to one | the row\'s picture \u2014 its own, or the linked record\'s. NEVER A COLUMN: a register draws words, so a mark is projected only where a surface leads with one \u2014 the subject rung of a picture-led grid, and a record\'s itinerary. A child entity\'s mark reaches a record\'s run and nothing else. One per entity |\n| `lifecycle` | single `select` | the ordered stages a row walks; option order is the order. Which of them END the flow is `outcomes`, which parts of the flow they group into is `phases`, and `history` keeps the day each one was reached. One per entity |\n| `category` | single `select`, or a `lookup` that resolves to one | 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. Often a fact about the thing the row books rather than about the row, and then it is the lookup that reads it. `icons` gives each kind the glyph a run draws it with. Repeats: a row can be of two kinds of thing |\n| `measure` | `number`, `formula`, `rollup` | a level read against a limit \u2014 see `against` and `alert`. Money where the model says so, and then it prints as money |\n| `expected_set` | `select` | its OPTIONS are the required set (documents, checks, services); an option no row has is a gap to show, not nothing. A multi-select draws as a ring and a fraction wherever it is drawn \u2014 absence is the information, and a list of what IS there says nothing about what is missing |\n| `amount` | `number`, `formula`, `rollup` | THE money of a ledger row; `signed_by` says which way it moved, `against` names the reference it is quoted off. One per entity |\n| `when` | `date`, or a `formula`/`rollup`/`lookup` that yields one | the ledger or timeline date \u2014 stated, or derived: the day money MOVED is a formula over the two columns that could hold it. `due` makes it a DEADLINE; without it the date is the plain day it is. `until` names the stage of this entity\'s own `lifecycle` at which the countdown is spent, and says `due` by saying so. One per entity |\n| `slot` | single `select`, or a `datetime` `date` | where in the DAY a row sits \u2014 the run it is read in under its day\'s heading on an itinerary. A select\'s option order IS the run\'s order; a time is ordered by the clock. A plain `date` is refused: that is the day itself. One per entity |\n| `party` | `select_record_link` | the counterparty. One per entity \u2014 and left off where the `identity` IS the link to them: the row is then NAMED by that record, and a strip under the title would state them twice |\n| `parent` | `select_record_link` | the record this row belongs to \u2014 a line\'s order, a paper\'s case. The parent\'s record shows these rows; the row shows the parent as a fact. One per entity, and it links to an entity this model declares |\n| `contact` | `text` | a way to reach a party \u2014 an address, a number, the town it is in. The record draws them as ONE row of links under the name, so the role sits on every field that is one of them. Repeats |\n| `currency` | single `select` whose option LABELS are ISO 4217 codes | the money THIS ROW\'s figures are in. Every amount and every money measure on the entity is then printed per row rather than in one code for the whole column, and the select is not drawn as a fact of its own. One per entity |\n| `verdict` | `boolean`, `formula` | a settled pass/fail \u2014 ticked, or computed. TRUE is the PASSING side wherever it is drawn, a register\'s risk flag included, so a field whose true means trouble is the same fact asked the other way round. One per entity |\n| `obligation` | `date` | a day something is OWED by \u2014 a cut-off, a permit expiry, a payment due. It counts down by definition. `satisfied_by` names what CLOSES it (or `until`, the stage the record stops owing it at), `label` what is owed. Repeats: a row owes several things, and a shape that fans them draws one row each |\n\n**`due` is what makes a `when` a deadline.** A date a row is merely filed by \u2014 the\nday a lead arrived, the day a movement happened \u2014 is neither early nor late,\nhowever long ago it was. Without the clause every desk drew every `when` as a\ncountdown and a two-month-old lead read "40 days overdue" on a record nobody was\nlate on. So a bare `when` is a plain date, and `{ "role": "when", "due": true }`\nis the one that counts down. An `obligation` needs no clause: a day something is\nowed by is a deadline already.\n\n**`until` stops a countdown where the work is DONE.** A deadline counts down so\nsomebody acts on it, and a row that has arrived needs nothing: without the clause\na delivered order is tinted for a date it met, and every settled row past its day\njoins the urgent ones in the reader\'s glance. It names one option of the entity\'s\n`lifecycle`; at that stage, at every stage past it and at every `outcome`, the\ndate is drawn as the plain day it is. On a `when` it also says the date is `due`.\n\n"One per entity" is one COLUMN, never one value on screen. A second counterparty\nor a second parent stays a ROLELESS `select_record_link`: which field fills a\nslot is the screen\'s answer (\xA7 Apps and screens), and the extra link is a fact on\nthe row with its own section on the record, headed by that link\'s own label. A\nrole naming two fields would move the ambiguity into every shape that reads it.\n\nA COMPUTED field is admitted where its RESULT is what the role needs, and\nrefused where it is not: `identity` and `reference` need `text`, `when` a `date`,\n`mark` `files`. A formula\'s result is its `output_type`; a rollup\'s and a\nlookup\'s is read through the walk. So a formula yielding a number is refused in a\nname\'s place, a lookup landing on anything but `files` is refused as a `mark`, and\na formula declaring NO result is refused by all four \u2014 naming `output_type` as\nthe remedy, because a value drawn as a type nobody stated is the wrong cell with\nnothing saying so. The result also decides the DEVICE every screen draws the value\nwith: a rollup taking the `latest` of a set of dates is drawn as a date, a lookup\nof a select as the words it holds.\n\nA bare role name is the common form. A role that is read against a SECOND field\ntakes the object form:\n\n- **`measure`** names its limit: `against` \u2014 a constant, or a field on the same\n entity the row states as a number (a `number`, or a formula or rollup whose\n result is one) \u2014 and `alert`, which side of it needs attention, `over` a\n capacity or `under` a minimum. The two come together.\n- **`measure`** names what its number COUNTS, where the unit changes how the row\n is DRAWN rather than how the figure reads: `counts: "days"` spans a stop across\n that many days of a run, so a three-night stay fills three of its days instead\n of only the one it starts on, and `counts: "hours"` is how long a stop takes\n INSIDE its day, stated beside it. Stated, never inferred \u2014 nothing about a `3`\n says whether it is nights, pallets or hours, and a quantity drawn across a week\n is a 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- **`lifecycle`** groups its stages into the parts of the flow they belong to:\n `phases`, each a `label` and the `stages` under it. Nine rungs down one column\n is a list nobody reads as a flow; the same rungs under three headings are three\n pieces of work, and which belong together is the business\'s answer. Every stage\n but an `outcome` falls in exactly one \u2014 a stage in two is drawn twice, a stage\n in none vanishes off the ladder \u2014 and the outcomes may be placed in a phase or\n left out, and are then read as a last part of their own.\n- **`lifecycle`** keeps every stage the row reached: `history: true`, or\n `history: "<alias>"` to name the table. A ladder with no history reports where\n a row IS and nothing about how it got there, which is the whole of what a\n reader asks a pipeline. It is declared BESIDE `phases`, and refused without\n them: the rungs a ladder draws are the live select\'s options, which nothing\n reading a file or an app\'s spec can see, so `phases` is the one statement of\n what the dates have to cover \u2014 and without it a rung the history cannot reach\n draws undated on every record with nothing anywhere saying so. The flag is the\n ONE statement: the model DERIVES a\n child table from it \u2014 the link back to this record, the stage, the moment it\n was reached and who moved it \u2014 and `check`, `apply`, `diff` and the app\n generator all see that table exactly as they see a written one. Nothing else\n declares it, and a file that also writes it by hand is refused. The generated\n create stamps the rung the flow opens at and the generated editor stamps every\n move after it; a stage changed anywhere ELSE is a rung with no date\n (`docs/platform_gaps.md`). **A first apply backfills nothing** \u2014 `apply` writes\n rows only into empty tables, and rows that existed before the flag walked their\n stages before that table existed. The honest backfill is ONE opening row per\n existing parent, at its FIRST stage, dated when that parent was created (true\n by construction); the stage it stands on NOW is not dated unless that is also\n the first. It is a create per parent \u2014 `lotics run create_records` \u2014 and\n `scaffold apply` prints it as the work it did not do.\n- **`category`** gives each kind its glyph: `icons`, option alias \u2192 a lucide icon\n name. A run\'s node is a dot the width of a word, so a stop with no picture of\n its own reads as every other stop. Nothing derives a glyph from an option\n written in the business\'s own language, and a kind the map leaves out draws the\n neutral one. A name lucide does not have at all is refused; one lucide ships\n and the kit has not imported is a NOTE \u2014 it draws the neutral glyph, and the\n fix is either another name or one more glyph in the kit.\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 // Two pieces of work, and every step dated.\n "phases": [{ "label": "Taking it on", "stages": ["placed", "confirmed"] },\n { "label": "Getting it out", "stages": ["picked", "shipped"] }],\n "history": true },\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 // What each kind of service looks like on a run\'s node.\n "kind": { "role": "category", "icons": { "install": "wrench", "collection": "truck" } } },\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 "payment": { "draw": "ledger", "summary": ["total", "paid", "outstanding"] } }, // optional \u2014 a book headed by what it comes to\n "slots": { "identity": "code", // optional \u2014 slot \u2192 field, where the roles cannot decide alone\n "subject": ["customer", "project"], // \u2026or the TIERS a fold walks down, outermost first\n "stage": { "field": "state", "quick": true } }, // \u2026or the field AND the reader\'s own control for it\n "columns": ["source", "owner"] } // optional \u2014 extra facts after the slots, shed first at narrow width\n }\n]\n```\n\nA shape is a proven screen with named SLOTS, each filled by a field carrying a\nrole (\xA7 Field roles). A slot with exactly one candidate on the entity binds by itself;\ntwo candidates need naming in `slots`; a field fills one slot; a required slot\nwith none is refused \u2014 a lifecycle desk over an entity with no `lifecycle`\nselect cannot be built.\n\n**A LIST IS A HIERARCHY, not a set of columns.** A group register\'s `subject`\ntakes one \u2014 `"subject": ["customer", "project", "order"]`, outermost first \u2014 and\nthe register becomes one the reader walks DOWN: it folds by the first tier, a\nnode narrows to the tier inside it, the last tier opens the set behind the\nfigure, and a trail says where they are. Every tier is a field of this entity\ncarrying a role that slot accepts, and none of them fills a second slot. It is\nthe slot\'s own answer that decides: a list on any other is refused, because two\ncolumns in one cell is not a fold. Without it the same business needs a register\nper depth, and the same money is folded twice with nothing saying it is the same\nmoney.\n\n**A QUICK SLOT IS FOR A DECISION THE READER MAKES FROM THE ROW ALONE.** A slot\'s\nvalue in `slots` is normally the field\'s alias; `{"field": \u2026, "quick": true}`\nsays the column is not a reading of that value but the CONTROL for it \u2014 pressed,\nit writes that one field as a diff through the record\'s own update, so the\nregister and the page behind it land the same write. THREE roles are such a\ndecision, and `quick` on any other is refused: a `lifecycle`, which rests as the\nstage\'s own dot and label, takes a rule under it on hover and focus, and opens\nthe field\'s stages with the ones that END the flow last \u2014 ONE control, which IS\nthe reading;\na `verdict`, which is the switch; and a `measure`, which is the figure typed in\nits own cell \u2014 a reading taken per row IS the row, which is what a timesheet\'s\nhours and a count sheet\'s tally are. Everything else is decided against what the\nrecord holds, and the record has to be open. All three carry the record\'s own\nblockers \u2014 a move the ladder holds is a move the cell holds, and it names the\nentries the row still owes \u2014 and an outcome asks before it commits. Add `"order": "sequence"`\nwhere the stages are a WALK rather than a set of destinations: the picker is then\nordered as the walk and the step the row takes next is MARKED in it. It never\nadds a second control \u2014 a column that answered with a reading in some rows and a\nverb in others is two columns about one value. A screen stating `writes: false`\nrefuses `quick`: a reader who may operate nothing has no decision to take.\n\n**THE SLOTS ARE THE ROW\'S ANSWER; `columns` IS ITS CONTEXT.** `"columns":\n["nguon", "phu_trach", "nhu_cau"]` names extra fields of the entity, drawn AFTER\nevery slot and in this order, each by what its column IS \u2014 a chip for a select,\nthe person\'s name for a member, the day for a date, the figure for a number, a\ntick for a boolean, the words for everything else. They are ranked BENEATH every\nslot, so a phone sheds all of them before it sheds any of the shape\'s own\ncolumns. At most four, and refused where one would say what a slot already says,\nover a files field (files are read, so they are a section of the record), over a\ncell holding several (a column of comma lists compares nothing), on a shape whose\nrows are not the entity\'s records in a table, and beside a `presentation.layout`\nthat draws the rows with a device of its own. A fifth fact worth the width is a\nslot the shape is missing \u2014 say so rather than widening this clause.\n\n| Shape | Answers | Required | Also fills | Record | Tabs |\n|---|---|---|---|---|---|\n| `lifecycle_desk` | what is stuck, what do I move next | `lifecycle`, `identity` | `mark`, `party`, `amount`, `measure` (level), `when` | page, or drawer where the entity carries `parent` | the lifecycle\'s stages |\n| `party_register` | who is this, our history, is there a risk | `identity` | `mark`, `contact`, `measure` (worth), `verdict` (risk) | page | none |\n| `offering_register` | what do we offer, at what price, can I sell it | `identity` | `mark`, `amount` (price), `measure` (availability) | page | none |\n| `transaction_ledger` | does this period reconcile, what is unexplained | `when`, `amount` | `identity` (reference \u2014 the line\'s own number), `party`, `lifecycle` (classification), `expected_set` (document) | drawer | none |\n| `monitored_asset_set` | what needs attention, is that number normal | `identity`, `measure` (level) | `mark`, `lifecycle` | drawer | none |\n| `obligation_desk` | what is due next, and has it been done | `identity`, and at least one `obligation` on the entity | `when` (runway) | page | none |\n| `trend_deep_dive` | how did the period go, and why | `when` | `measure`, `amount` | drawer | none |\n| `reconciliation_desk` | what does not match, and by how much | `identity` (reference), and two figures \u2014 `ours` and `theirs`, each an `amount` or a `measure` | `category` (reason), `verdict`, `when` | drawer, on the PAIR | the dated runs, where the model names a select |\n| `entry_matrix` | one value per subject per period | `identity` (subject), `across` \u2014 a `when`, or a `category` for a fixed column set | `measure` (value), `lifecycle` (state), `category` (note) | drawer, on the cell\'s row | none |\n| `guided_run` | complete an ordered sequence, a step at a time | `identity` (step) | `slot` (sequence), `capture` \u2014 a `measure`, `verdict` or `expected_set` \u2014 `verdict` (gate), `mark` (media) | page: the run IS the record | none |\n| `live_board` | is everything OK, right now | `lifecycle` (state), `identity` | `verdict` (exception), `when` (since), `measure` (level), `category` (where) | drawer | none |\n| `group_register` | what does each group come to, and what is behind it | `subject` \u2014 a `category`, `party` or `when` | `amount`, `measure`, `identity` (member) | page: a row is an aggregate, and the door is the SET behind it | none |\n| `media_set` | scan a body of pictures by where they belong | `mark` (picture), `identity` | `category` or `party` (group), `when`, `verdict` (current against superseded) | drawer | none |\n| `day_sheet` | what happened on each day | `when` (the day) | `lifecycle` (state), `measure` (level), `amount`, `category` (note) | page: a day composes several child logs | none |\n| `resource_schedule` | what is on each resource, in what order, under what ceiling | `lane` \u2014 a `party` or a `parent` \u2014 `identity`, `when` (the start) | `measure` (duration), and `load` and `bulk`, each a `measure` read against a ceiling of the LANE\'s \u2014 a vehicle fills by weight and by room and stops at whichever runs out first; `lifecycle` (stage) | drawer, over the lanes | none |\n| `worksheet` | priced lines the reader edits, totalling to one figure | `identity` | `category` (group), `measure` (quantity), and `cost` and `sell`, each an `amount` or a `measure` | drawer, per line | none |\n\n**A record is one of five KINDS, and the shape plus the roles decide which.**\nEvery one of them is ONE READING COLUMN, and the ORDER of that column is the\nwhole of what the kind means.\n\n| Kind | The column, top to bottom |\n|---|---|\n| WORK RECORD \u2014 a `lifecycle_desk` over rows belonging to nothing | the one act that moves it \xB7 what it IS \xB7 the papers it owes \xB7 the rows it is made of \xB7 the book of what it came to |\n| LINE \u2014 the same shape over rows carrying `parent` | its ladder \xB7 the arithmetic behind its amount \xB7 the rows and papers under it \xB7 its particulars, last (the key it is filed by is the line under its title) |\n| PROFILE \u2014 a `party_register` | its history, OPEN, grouped by year \xB7 the rest of its registers \xB7 its facts (the ways to reach it are under its name, its settlement is the band) |\n| CATALOGUE ITEM \u2014 an `offering_register` | its own words (the picture leads the header at media scale, the price is read against its reference in the band) \xB7 what uses it \xB7 its facts |\n| EVIDENCE \u2014 a `transaction_ledger` | the PROOF \xB7 what the paper is, compactly \xB7 the rest \xB7 the links to the record it settles and the party it was with |\n\nNothing in the file states the kind: a clause that could say it could say the\nwrong one, and `parent` already says whether a row stands alone. `record`\noverrides the DOOR where the shape\'s own answer is not the one wanted \u2014 and its\nthird value, `"expand"`, is not a door at all: the row REVEALS what it holds in\nplace, where there is nothing behind it worth navigating to. A row read that way\nreads as a LINE whatever the shape would have opened: its ladder, the arithmetic\nbehind its amount, then its particulars, because there is no header above it to\nhave stated the name first. It is REFUSED for two reasons, and they are not the\nsame refusal. On a shape whose record is a page by nature \u2014 a `guided_run`, a\n`group_register`, a `day_sheet`, an `obligation_desk` \u2014 because each of those\nstates the opposite where its door is declared: what its row leads to is read\nwhole, not as four more columns than the register had room for. And on a shape\nthat draws its rows with a device of its OWN \u2014 an `entry_matrix`, a `media_set` \u2014\nbecause a cell of a grid and a tile on a wall have no band under them for the row\nto reveal into. For the same reason a revealed register is drawn as a TABLE: a\n`presentation.layout` of anything else is refused, and the reader is not offered\nthe arrangement either, since it would take away the only door the register has.\n\n**`scope` \u2014 the one subject every read of the app narrows to.** Stated on the\nAPP rather than on its screen, because it is not a filter: a record opened under\nit stays under it, the pick survives closing the app, and every app declaring the\nsame `entity` shares one pick per viewer \u2014 which is the whole value, and what\nstops a construction workspace asking which project twelve times. `entity` is the\nmodel\'s entity whose one row the app is read inside, and `param` is what the\napp\'s reads take that row by.\n\n```jsonc\n{ "alias": "site_work", "name": "Site work",\n "scope": { "entity": "project", "param": "project_id" },\n "screen": { \u2026 } }\n```\n\nThe register\'s own entity has to REACH it \u2014 its own rows, or rows that name one\nthrough a link \u2014 or the switcher is a control that changes nothing. Until a\nsubject is picked the app reads NOTHING and says which one it is waiting for: an\nunscoped read over a scoped app is every row in the workspace drawn as one\nproject\'s work, which looks correct.\n\n**ONE RECORD SURFACE PER ENTITY, AND ITS KIND IS THE ENTITY\'S.** An app is one\nregister, so the record its rows open is the register\'s own and the kind comes\nfrom that register\'s shape. Another app over the same entity opens the same kind\nof record: a payment read from a cash book and one read from a trend are both\nmovements, and both read proof first. A shape with no kind of its own (a trend, a\nmonitored set, a `custom` screen) says only where the record opens.\n\n**Two things a register states about a row beyond which field fills which slot.**\nA `measure` is read AGAINST its limit (`against`/`alert`) where the shape\'s slot\ntakes one \u2014 `level`, on a lifecycle desk and a monitored set, and every slot of a\n`custom` screen, which composes the frame\'s own column; `worth`, `availability`\nand a trend\'s headline are plain figures, and a limit there would read as a meter\nin the record over a number the row printed bare. And the name carries a\nSUPPORTING LINE where the entity states a key to file the row under: the `parent`\nit belongs to, its `reference`, an `autonumber`, a text formula \u2014 in that order,\nfirst hit wins, never a field a person types prose into. Every record heads with\nthat same key, so the register and the door it opens name one row one way.\n\n**The act that opens a row belongs to a register that LISTS the entity\'s rows**\n\u2014 a desk, a register, a ledger, a monitored set, or a `custom` screen. A shape\nwhose rows are not records has neither that act nor the door a counted line\nelsewhere leads to: an obligation desk\'s rows are deadlines and a trend\'s are\nperiods, so a create pressed there would open a row the screen cannot show. An\nentity whose only app is one of those has no create at all \u2014 give it an app whose\nregister its rows are read in.\n\n**An `obligation_desk`\'s rows are not its entity\'s rows.** The shape fans the\nentity\'s `obligation` fields out \u2014 one row per thing still owed, which is one\nwhose date is SET and whose `satisfied_by` is empty \u2014 so a l\xF4 with four cut-offs\nis four rows, and the three it has met are not there at all. The row\'s name is\nthe obligation\'s, its supporting line the record\'s `identity`, and the door is\nthat record. A `when` bound to `runway` is what the countdown\'s ring is measured\nfrom. Over an entity declaring no `obligation` the shape is refused: there is\nnothing to count down to.\n\n**What a register carries beyond its columns.** Six clauses, each emitted as\nthe prop the kit draws it with; every one is optional and a screen that states\nnone gets a register of columns and nothing else.\n\n- **`period`** \u2014 a field on the entity whose value is a DATE. The toolbar gains a\n date-range control (this month to start), and the rows it keeps are what the\n register AND every figure below are computed over, so a band can never be over a\n different window than the rows under it. Any such field, not only the `when`\n role, and however the date got there: one table is read two ways (a cost ledger\n by the day a line arose, the cash book over the same table by the day it\n settled), a role is one field\'s, and the day money MOVED is often a formula over\n the two columns that could hold it \u2014 which then declares `output_type: "date"`.\n A `trend_deep_dive` brings its own period and takes none here; a `live_board`\n is PERIODLESS and takes none either, for the opposite reason \u2014 it answers what\n is true NOW, and a date range over it draws what was true in the window in the\n live state\'s own colours.\n- **`filters`** \u2014 the chips beside the search. A single-`select` field\'s alias\n offers that field\'s own live options; it is the lens a register is read through\n over and above the one band its strip gives, so the strip\'s own field is\n refused here (one dimension, one control) and so is a multi-select (a row would\n answer the chip several ways at once). The rows the register and every figure\n below are computed over are what the chips AND the period kept.\n A DERIVED LENS is the other form \u2014 `{"label": \u2026, "predicates": [{"label": \u2026,\n "tone": \u2026, "where": \u2026}]}` \u2014 where the sets a reader narrows by are ones the\n MODEL names and no column holds: rate validity, plan urgency, free-time\n overrun. Each `where` is an `and` group of plain conditions over the entity\'s\n own fields, each `field_key` a field alias, and it is read off the row, so the\n operators are the comparisons a value answers \u2014 `number` and `text` equality,\n `number` ordering, `boolean` equality, `select` `has_any_of`/`has_none_of`, and\n `is_empty`/`is_not_empty` on any of them. Nothing DATED: a relative point needs\n a clock, and a screen states one date control (`period`), so a second beside it\n would be two windows with nothing saying which a figure was computed over \u2014 a\n date question is asked as a column the model derives and read here as a number\n or a yes/no. `tone` is the option palette\'s, because the chip draws these\n exactly as it draws a select\'s options.\n- **`summary`** \u2014 what the rows in view come to. `above` is a band OVER the\n register: `"counts"` for how many rows are in view and, for the FIRST\n `measure` the model gives a limit, how many are past it (accented only when\n one is) \u2014 one such set, because a band naming three is the dashboard a\n register is not; a list of number-field aliases for what they add up to, and a\n `percentage` named there is refused, since a share summed over the rows in\n view is a figure of no kind (draw it as a column, whose own total reads the\n mean). A figure written `{"field": \u2026, "at": "end"}` is a STOCK instead: the\n reading standing at the END of the window rather than the sum of the readings\n in it, which is the only way opening + in \u2212 out = closing reconciles \u2014 a month\n of daily closing stocks added together is thirty warehouses. The band labels it\n as the snapshot it is, and it is refused on a screen that states no `period`,\n since there is then no window for it to stand at the end of.\n `ageing` stands in that same\n band: an `amount` split by how many days past its `due` date each row is,\n drawn as a `Breakdown` of the edges in `buckets` (30, 60 and 90 days unless the\n model states its own, ascending). The ladder is the kit\'s \u2014 its boundary, its\n words and its ramp \u2014 and it opens with what is NOT YET DUE, because a bar of\n overdue bands alone is full at every input: a book with one late invoice and a\n book that has gone entirely bad would paint the same solid width. Against the\n current money the bar\'s own shape is the reading. A row whose date is empty is\n in no band. `totals` is what the whole\n view adds up, and it is drawn in that SAME band above the rows: a register\n states one aggregate, over the rows the reader can see, in one place, and a\n closing line under them is a second place to look and a second question about\n which rows it covers. A closing line belongs to the one device that IS a\n statement \u2014 a record\'s `Ledger`, whose balance is what it is read for, labelled\n by its amount\'s own word. Where a shape has no band above (a trend, whose own\n band is the chart) the figure stands under the register instead. A\n `transaction_ledger` sums its amount column itself and takes `above` alone. An\n `obligation_desk`\'s rows are DEADLINES and not records, so its band states\n `"counts"` \u2014 how many are open \u2014 and a figure list, an `ageing` or a `trend`\n over it is\n refused: a l\xF4 with three cut-offs is three rows, and its freight added over\n them is three times the freight.\n `trend` is `{"field": \u2026, "direction": "up"|"down"}` and states WHICH WAY that\n figure moved across the window: the rows in view are cut into the window\'s own\n buckets \u2014 days over a month, weeks over half a year, months beyond \u2014 and the\n later half of the CLOSED ones is read against the earlier half, drawn beside\n the figures as one line, so a window the reader is still inside states the\n movement of the days that have run rather than a fall into the ones that have\n not. `direction` is which way is GOOD, which nothing about the number\n says: a book wants its takings rising and a backlog wants its days falling, and\n the same arrow is a win on one and an alarm on the other. It needs a `period`\n to be a window of, and it is refused on a shape that draws its own (a\n `trend_deep_dive` already draws the series) or that reads none at all (a\n `live_board` answers what is true now). A window whose earlier half took\n nothing draws no line: there is no base for a percentage, and 0 % would claim a\n steadiness nobody measured.\n- **`facts`** \u2014 how the RECORD\'s facts are banded. `groups` is a list of\n `{caption, fields}`: what a set of facts has in common is a sentence about the\n business and nothing derives it, so a plan that states one gets exactly those\n bands, in its own order, captioned. EACH BAND IS A SECTION of the record \u2014 its\n own heading, its own entry in the rail, its own place in the column: under one\n heading the reader is handed every particular at once, in a caption weight that\n cannot out-rank the heading above it, with nothing in the rail to reach any of\n them by. Everything the plan leaves unnamed is the LAST section and carries no\n heading, since there is no sentence to head "whatever no group claimed" with \u2014\n ONE section, even where the record\'s own recipe bands what was left in two (an\n evidence record reads its particulars before the record and the party it was\n with): both are groups inside it, because the word the kit heads "the rest"\n with cannot name two entries in the rail.\n A band of more than eight facts is NOTED: a section that long is two. 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 **`"when"` stands an act down until the work is ready.** `{ "field": "<the\n entity\'s lifecycle>", "in": ["<stage>", \u2026] }` offers the verb only while the\n row stands at one of those stages. A verb that cannot work yet is worse than\n no verb: the reader presses it, the body refuses, and nothing on the screen\n ever said the work was not ready. Readiness is what a lifecycle states, so the\n field named is this entity\'s `lifecycle` and nothing else \u2014 a category says\n what a row is and a verdict answers a question, and neither moves. Refused: a\n field that is not the lifecycle, a stage it does not declare, and every stage\n at once, which is the act with no condition.\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. It carries two readings.\n\n`"draw": "ledger"` heads a book of movements with what the record it is against\ncomes to: `summary`, number fields of THIS record, in the order they read. A\nsection with nothing in it is otherwise a heading, a verb and a void \u2014 and the\nemptiness is exactly when the reader most needs to be told what the rows would\nbe for, so the statement is drawn at nought rows too. The figures are the\nRECORD\'s rather than the child\'s for the same reason: a sum of the rows under it\nis the one statement a book with no rows cannot make.\n\n`"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, one\nfigure priced as both, and a book headed by anything but a number field of the\nrecord.\n\n**A CHILD NOBODY REGISTERS IS A NOTE, not a refusal.** Where a record lists a\nchild entity no app is a register of, `scaffold check` says `"<entity>" has no\nregister \u2014 its rows are read here and created nowhere` and the plan printout\nmarks that entity `(no app)`. Rows can arrive from an import or a workflow, so\nit is a reading rather than a rule; what it catches is the register somebody\nmeant to plan and did not. A thread is exempt \u2014 a reply is written where it is\nread \u2014 and so is a sheet, which opens its own lines.\n\n`scaffold check` prints every clause a screen states, on its own line under the\nslots, spelling the screen\'s `writes` clause `operable` \u2014 the app manifest\'s\n`lotics.writes` keeps that word, and the two are different facts: `app create`\nSEEDS the manifest from the fields the screens\' own editors write, and from then\non the app owns the declaration `lotics app check` holds its bodies to.\n\nEach shape is a `@lotics/ui` component of the same name (`LifecycleDesk`,\n`PartyRegister`, \u2026) whose props are these slots, so once the tables exist\n`lotics app create <name> --from <this file>#<app alias>` scaffolds the app with\none screen per entry, each slot reading the field the plan bound.\n\n`"shape": "custom"` is the screen no registry row covers: it declares its own\n`roles` (slot name \u2192 role), and it is emitted on `ShapeFrame` \u2014 the one anatomy\nthe six shapes above are each a configuration of \u2014 so its strip, search,\nordinal, fit budget, empty and waiting states and record door are the same ones\nthey have. It opens the record its `record` says, drawer or page, like any other.\nA bound `lifecycle` slot IS its strip, the stages in the field\'s order as on a\ndesk, so such a screen names no `tabs`; a `tabs` select is the flat strip a\nscreen with no lifecycle gets.\n\n**An app IS its `app.json`.** `create --from` writes the bound plan \u2014 every\nscreen with its shape and slots, the record each row opens with its sections in\nthe archetype\'s order, the create panels, the acts \u2014 as one JSON document, plus\na five-line `src/main.tsx` that mounts `@lotics/app-runtime` over it and one\n`src/workflows/<alias>.ts` per write. There is no screen source: the runtime\ndraws the spec, so a kit correction reaches every app with its next install.\nWhere the plan has no word for what a screen or a section IS \u2014 or for what a\npaper act should ASK before it is made \u2014 the spec names one of the app\'s own\ncomponents and `src/components/index.ts` registers it, the one file under `src/`\na regeneration never rewrites. `lotics app eject <screen|<section key>|<act\nlabel>>` writes each of those, starting from what the runtime already drew.\n\n**How a spec reads a row.** Each query projects its columns under the alias the\nworkspace mints from the field\'s LABEL \u2014 never the alias this file keys it\nunder, which is the model\'s own namespace and which the workspace never saw. So\na field this file calls `partner` and labels `\u0110\u1ED1i t\xE1c` is referenced as\n`doi_tac` throughout the spec. Every id in it is live: the `tbl_` a screen is\nover, the `fld_` of each column, and the `opt_` of every option a role names.\n\n**Every query is described, in the model\'s own language.** The line an agent\nchooses between aliases by is written from this file\'s nouns \u2014 the entity\'s\nlabel, the screen\'s, the link\'s \u2014 and the sentence around them is the language\nthe file is mostly written in, decided the same way a generated write\'s refusals\nare. Hand-editing one is erased by the next regeneration; `lotics app check`\nrefuses an alias that carries none.\n\nA screen is that list and the RECORD it opens, and the record comes off the same\nroles \u2014 nothing to declare for it. It opens with a **header**: the entity\'s\n`identity` as the record\'s name \u2014 whether or not the list has a column for it, so\na ledger\'s and a trend\'s records are named too \u2014 the `when` this screen reads\nunder it, and ONE headline\nfigure (the `amount` the screen reads, else its `measure`). BOTH DOORS state the\nname: which door a screen uses is a layout answer, and what the record is ABOUT\nis not.\n\n**A JOB\'S HEADER ALSO STATES WHO IT IS FOR.** Where the record is the work itself\n\u2014 a desk\'s rows that belong to nothing, opened on a page \u2014 its `party` is a link\nunder the name, beside the ways to reach the subject, and it is then not a fact\nas well: a page headed by a code alone said nothing about whose work it is, and\nthe one thing a reader arriving from the desk already knows the row by sat in the\ngrid below as a row among forty. A record opening in a DRAWER has no row of links\nto hang it on, so its party stays a fact; so does a LINE\'s, whose register files\nit under the thing it belongs to and whose party is that parent\'s, restated per\nrow.\n\n**A record whose figures are a SENTENCE states them under the header, in a\nband**, and the header then states neither of them: a job reads what it comes to,\nhow far through it is (the `measure` against its limit), what is left of it (a\nformula that is exactly `{amount} - {measure}`) and how long there is (an\n`obligation`, else its `when`, counted down); a party reads its settlement, the\n`amount` and `measure` roles it carries in the order the model declares them; a\ncatalogue item reads its `amount` against the reference that amount names. Two\nfigures are the floor and four the ceiling \u2014 under two, the header keeps its one.\nA band is a PAGE\'s strip, so a screen whose `record` is a drawer keeps every\nfigure where a drawer reads them. Each figure is drawn exactly once: the limit a\nmeter states is not a fact beside it, and the day a countdown counts to is not\nthe provenance line above it. The `lifecycle` is not badged there, because the progress section is the\nrung it stands on. Then its sections, in the order its KIND reads them: the\n**facts** (every field neither the header, the band nor another section owns, the\nrow\'s own `parent` among them \u2014 led by the ones THIS screen\'s slots bound, then\nthe key it is filed under, then the order the model declares them in \u2014 a\n`markdown` field among them, drawn across the grid\'s whole row rather than\nwrapped to a third of it), the record\'s own **body** (that same markdown, taken\nas a section instead, ONLY where the kind leads with it: a catalogue item IS its\ndescription, and a job is not its notes), the **charge** (where the `amount` is a formula over exactly one\nquantity and one money field, the line states `quantity \xD7 unit price = amount`\nand neither figure is typed twice \u2014 the section is headed by the amount\'s own\nlabel and the closing row says what the figure IS, so the word is said once),\nthe **progress** (the `lifecycle`\'s stages as a ladder the reader ticks a rung of\nto move the record on, grouped into its `phases` where the model states them, and\neach rung carrying the day it was reached where the lifecycle keeps a\n`history`), a\n**required set** (a multi-select `expected_set`, or a child entity whose rows\ncarry one entry of it each \u2014 that child\'s `files` field is what a paper attaches\nto), the record\'s **own rows** (any other child, its role-bound fields as\ncolumns), and its **files** (every `files` field, the `mark` first \u2014 a section of\nthe column like any other, straight after the facts it is the evidence for,\nexcept on an EVIDENCE record, which OPENS on the paper it exists for; the\nheading carries the Add verb, and stands it down while the pile is empty, where\nthe drop well is already the door; and a `verdict` whose FORMULA reads one of\nthose files fields is the pile\'s OWN state rather than a fact row \u2014 the file it\nis short, drawn as a ghost, and the mark the rail sends a reader to). A child is\nan entity that LINKS here, ONE SECTION PER LINK: the rows it owns (`parent`), the\nrows that name it (`party` \u2014 so a party\'s record is its history), and the rows\nreaching it through a link carrying neither role, headed by that link\'s own\nlabel. A child hanging off two records therefore declares one `parent` and\nleaves the second relationship a plain link, which still gets its section.\n\n**THE ROWS A RECORD OWNS ARE ADDED AND OPENED FROM THEIR SECTION.** A `parent`\nsection\'s heading carries the Add for its rows, handing the record as the\nparent \u2014 filled, never asked. Every row opens its own record \u2014 a line of a book,\na paper on a desk, a stop on a timeline alike: where this app already routes a\npage for those rows (the register\'s own, under one of them) it is navigated to;\notherwise it is a DRAWER mounted on the record it belongs to, stepped \u25C0 \u25B6 over\nthe section\'s rows as they are drawn, with the row\'s framed editors, its ladder\nand its files, saving through `update_<child>` \u2014 the one editor those rows have,\nwhich any control the section draws over a row writes through too. Never the\n`parent` link itself: it is the key the row is FILED under, stated by the record\nthe drawer was opened from, so it is neither a fact of the drawer nor an input\nof that editor. ONE LEVEL, and only from a PAGE: a child\'s own children draw as\nthe kit\'s panel of the row, with no drawer and no Add, and a register whose own\nrecord is a DRAWER is the same for the children it owns \u2014 a drawer mounts no\ndrawer. **A ROW IS ADDED WHERE IT CAN BE OPENED AGAIN**, so those sections lose\ntheir Add with their drawer; a row of the register\'s OWN kind keeps it, because\nthe register\'s list opens it. A correspondence\'s entries are the one owned row\nthat opens nothing anywhere: the composer under them is the section\'s Add\nwhatever door the record has, and the answer mark is one field of the reply\'s\nown editor. The rows a `party`\'s record lists, and those reaching a record\nthrough a roleless link, are read only \u2014 a history, never a place to open or\nadd.\n\n**TWO CHILDREN ARE READ AS A RUN RATHER THAN AS A REGISTER**, and the model says\nwhich by the roles it declares on them. A child carrying an `obligation` whose\n`satisfied_by` is a DATE is a **timeline**: its rows are the ordered stops, each\nstating the day it was promised for and the day it happened, with the delta named\nwhere the rung was late \u2014 drawn as a register those are two date columns and the\nreader subtracts them by eye, which is exactly what "three days late at\ndischarge" costs to learn. An obligation closed by a FILE says nothing about\nwhen, so those rows stay the register they were. A child carrying a `markdown`\ntext field beside a `party` and a `when` is a **thread**: the entry is the body,\nthe author and the day lead it, and a `verdict` on the child is the yes/no one\nreply is marked the ANSWER with \u2014 a register of messages is a table whose one\nuseful column is the one it cannot draw. A SECOND `party` on those rows is who\nthe entry was left WITH, and the latest one names the ball in court: while\nnothing answers, the record says whose move it is and counts its own deadline\n(an `obligation`, else its `when`) against them. Both are read only under the\nrecord that OWNS the messages \u2014 under the party who wrote them the same rows are\nthat party\'s history, several questions at once, and the last of them says\nnothing about any. That record also REPLIES: the section\'s Add is a composer \u2014\nthe message is all a reply asks \u2014 and the answer mark is one field of the\nreply\'s own editor.\n\n**NOTHING SITS BESIDE THE RECORD.** A page is ONE reading column \u2014 its fields,\nits files, the rows it owns, its notes, all sections of it in the archetype\'s\norder \u2014 over a reserved left gutter holding a floating NAVIGATION RAIL: the way\nback, then one line per section with that section\'s category mark, and nothing\nelse. No files, no counts, no group headings. Too narrow to seat the rail, it is\none pinned bar of the same list across the top. A section HEADING never wears\nthe mark; the rail\'s line for it does.\n\n**A RELATION THE RECORD NEVER ACTS ON CLOSES THE COLUMN.** Every register that\nmerely NAMES the record is one counted line of a single last section: its label,\nhow many rows name this record (a COUNT, never a page of rows measured), and a\npress that opens the register owning them narrowed to this record. An entity\nthis app lists nowhere is a count with no door, and where every count is zero\nthe section is not drawn at all. The one exception is a PROFILE, whose history\nIS its body and stands open in the column.\n\n**Rows the record owns whose `amount` is `signed_by` a direction are a\nBOOK**, drawn as a statement \u2014 each line dated and signed, the balance under\nthem where there is more than one line to add up, labelled by the amount\'s own\nword \u2014 where the same rows on the party they\nwere transacted with stay that party\'s history, which is scanned for the line\nshort of its paperwork rather than struck to a balance. **Rows that carry a\n`when` read as RUNS**: a party\'s history under the year \u2014 and only where the rows\nin hand span more than one, since a subhead over every line of a book names the\nyear and separates nothing \u2014 and a job\'s own rows under the day they fall on,\nwhich, where those rows also carry a `lifecycle`, is an ITINERARY rather than a\ngrouped table: the day heads the run, each row is one line (its `slot`, its name,\nits party, its stage, its `category` and `reference`, its amount), the figures end\non one edge and the run closes with their sum \u2014 unless the record\'s own BAND is a\nsum rollup over exactly these rows, which states it once already. A `category`\npaints the node the run is read down, in the option\'s own colour; a `reference`\ncloses the line with the code somebody quotes on the phone. **A `mark` LEADS the\nline with the stop\'s own picture** \u2014 a venue, a vehicle, a machine \u2014 and it is the one\nplace on a record where a child entity\'s mark is drawn at all, since no register\ncolumn is ever a picture; a line whose subject holds none is marked with its\n`category`\'s glyph instead, so the run keeps one left edge. All three are\nPROJECTED for the run whether or not the register\'s column budget would have\ndrawn them, and a mark is projected NOWHERE ELSE.\nA line\'s mark is most often the SUBJECT\'s rather than the line\'s own \u2014 the\nservice, the product, the asset it is a line of \u2014 which is a `lookup` through\nthat link onto the subject\'s own `files`, given the role on the line.\n**EACH DAY\'S HEAD CARRIES THE SECTION\'S OWN ADD** as well, handing the panel that\nday, so a line made from Tuesday opens with Tuesday answered; the heading keeps\nthe verb too, for the first line and for a day nothing is planned on yet. **THE\nKEY LEAVES THE COLUMNS where the head states the whole of it** \u2014 a day head does,\na year head states four digits of the date and the day is what the column is\nstill there to carry.\n**A run is READ FROM THE END THE READER WANTS**: a job\'s own rows and an itinerary\nascending, a book and a history descending.\n\n**A DESK OVER AN ENTITY THAT CARRIES A `slot` IS ITSELF A DAY\'S RUN.** Its own\nregister is drawn under one subhead per day, sorted by that day and then by the\npart of it, and the date column goes \u2014 the subhead states it once for the whole\nrun, and a column repeating it down every line is the same value twice. Without\nit an itinerary was one flat list in whatever order the rows arrived.\nA document desk keeps its head either way \u2014 the entries it owes are its\ncontent, filed or not. The name and whatever figure is left to it are the header\'s\nalone \u2014 it states them in full, so a fact for either would be the same sentence\ntwice.\n\n**A FACT IS PRIMARY OR IT IS PROVENANCE.** An `autonumber`, a date the platform\nstamps (`derive_from`) and a date a formula works out that no role names are what\nthe SYSTEM wrote; they fold, with every optional field this record does not\nstate, behind the grid\'s ONE link. Everything a person types or picks is primary\nand is shown, empty or not, because absence is work somebody owes. **A screen\nthat named `facts.groups` has already said which:** a field in a group is\nprimary, and one in none folds \u2014 grouping says what the reader decides with, and\nby saying so says the rest are not. A `tier` on a `facts` entry exists only for\nthe case that derivation gets provably wrong.\n\n**A `contact` field whose kind the\nmodel decides** \u2014 an email, a number, a place, a `link`-formatted text, or a\nMESSAGING APP its label names (WhatsApp, Zalo, Telegram, Viber, WeChat, Line,\nMessenger) \u2014 leaves the facts too, for the row of links under the name on a\nrecord that opens as a page; one whose kind nothing decides stays a labelled\nfact, because a scheme nobody stated is a link that opens nothing. A handle is\ndrawn under its app\'s name \u2014 the app is what identifies the person there, and a\nbare number beside an envelope says the wrong thing \u2014 and it dials only where its\nvalue IS a number.\n`check` prints the record under each screen\'s slots:\n\n```\n Orders \u2014 lifecycle desk over Orders (12 rows) \xB7 page \xB7 tabs: Stage (New \u2192 Quoted \u2192 Confirmed \u2192 Shipped \u2192 Done)\n stage Stage \xB7 identity Order no. \xB7 mark Photo \xB7 party Customer \xB7 amount Total \xB7 level (none \u2014 no field declares "measure") \xB7 when Due\n record: header (Order no. \xB7 Customer) \xB7 band (Total \xB7 Shipped against Lines \xB7 Due counting down) \xB7 progress: Stage (5 stages) \xB7 facts (Ship to \xB7 \u2026 2 filed) \xB7 files: Photos \xB7 documents: Papers (Kind: 4 required) \xB7 itinerary: Order lines by Ship by at Window, kind Handling, ref Waybill (Product \xB7 Quantity \xB7 Line total) \xB7 related: Visits (count via Order)\n```\n\nThe ORDER of that line is the record\'s own, top to bottom, and it is the KIND\'s,\nand every entry of it is a section of the one column. `related:` is the counted\nline that closes it \u2014 a register the record never acts on, named and counted \u2014\nand `\u2026 N filed` is how many facts fold behind the grid\'s one link.\nA child register whose `amount` is `signed_by` a direction is a BOOK of movements\n\u2014 what the record came to rather than what it is made of \u2014 so it closes the\ncolumn rather than standing among the rows the job is made of.\n\n**A section over a child is named by the CHILD\'s own label**, because the reader\nalready knows which record they are on: "\u0110\u01A1n h\xE0ng", not "\u0110\u01A1n h\xE0ng \u2014 Kh\xE1ch h\xE0ng".\nWhere two links from one entity reach this record, that label names two sections\nand each takes its own link\'s label as a qualifier \u2014 `H\u1ED3 s\u01A1 (\u0110\u01A1n h\xE0ng)` and\n`H\u1ED3 s\u01A1 (\u0110\u01A1n g\u1ED1c)`.\n\nIn that line `documents:` is a `RecordExpectedSet`, and `lines:` a `RecordChildren` over the rows\nthis record owns \u2014 `history:` where they name it as their `party` instead; a required set\nwhose entries are a CHILD entity carrying files takes `kind="files"`, and the entity\'s own\nmulti-select takes `kind="items"`, since nothing attaches to an option.\n\n**A section that knows its size says so.** Where a `measure` on the record is a\n`count` rollup over the very link a rows section hangs on, the limit it is read\n`against` is how many rows that section is OWED: the printout adds `\u2014 expects\n<limit>` and the screen draws that many ghost rows until the first one lands,\ninstead of "nothing here" two bands under a count saying three are outstanding.\n\nA figure printed as `Total (USD)` is MONEY in the currency named \u2014 a `formula`\nstates it in `formula.currency`, and a `rollup` or a `lookup` inherits it from the\nfield it reads, so read those brackets: a rate that should be in dollars and is\nprinted bare will be drawn in the workspace\'s own money. Where the entity carries\na `currency` role, the ROW\'s code outranks the field\'s on every line that states\none, and a line stating none falls back to the field\'s.\n\n**A record surface can be OPERATED, and that is the default.** Every screen with\na record gets a workflow that writes its editable facts \u2014 every field a person\nstates \u2014 so the record\'s values are edited in place and its stage is advanced\nfrom the progress section. `"writes": false` makes one screen a reader: use it\nfor a screen over rows another desk owns, never as the default. A desk nobody\ncan act on is a viewer of state somebody must go and set somewhere else.\n\n**Which facts those are is the FIELD\'s answer.** Text, number, date, yes/no and\nselect are typed or picked. A `"cardinality": "one"` link is RE-POINTED, through\na picker over the target entity named by its `display_field_aliases` \u2014 else the\ntarget\'s `identity` \u2014 narrowed by what the reader types and carrying that\nentity\'s own `read_scope`, so a link the plan gives neither column is read\ninstead of offering an empty list. A files field is ATTACHED TO and DETACHED\nFROM, as the delta rather than the pile, so two readers filing at once each keep\ntheir file. A many-link, an `autonumber` and every computed field are read: the\nfirst is a list a fact cannot state, and the rest are the platform\'s to write.\n\n**A one-link is that fact WHATEVER its sync.** `sync_both_ways` is one relation\nwith a field on each side, and the sides are not the same surface: the record\nthat names ONE row states it as a fact, and the MANY side is the register on the\nother entity\'s record. Declaring both directions does not give this record a\nregister of the single row its own fact already names.\n\n**The write is the record SURFACE\'s.** Its inputs are the fields that surface\noffers to save \u2014 the facts with an editor, the lifecycle its ladder advances, and\na files section over exactly one stated field \u2014 never every field a person could\nin principle type. What the header states, a limit a meter folds in, a required\nset with a section of its own and the pile a page\'s mark draws are read there, so\nno input is declared for them. A second screen over the same entity draws no\nsurface of its own and adds nothing.\n\nWhere two operable screens of one plan reach the same entity, `scaffold check`\nnames them and the apps they belong to: two desks writing one record is a\ndecision, and the split is stated on the FIELD in each app\'s\n`package.json#lotics.writes` rather than left to whoever edits second.\n\nThat workflow lands in the app as source, like every other: its body in\n`src/workflows/update_<entity>.ts` and its declaration in\n`package.json#lotics.workflows`. The two together are the binding \u2014 `lotics app\ndeploy` pushes whatever differs from what it last saw live \u2014 so the write is\nversion-controlled and travels with the app rather than being bound by hand\nafterwards.\n\nA `custom` screen declares its slots under `roles` (slot \u2192 role) and they bind\nthe same way:\n\n```jsonc\n{ "alias": "readings", "label": "Readings", "shape": "custom", "entity": "reading",\n "roles": { "subject": "identity", "reading": "measure" } }\n```\n\n## Applying packages\n\n`apply` copies published packages into the workspace AFTER the model\'s own\ntables exist \u2014 apps over the tables you just described, and any tables of their\nown they still need. Ordered, and run by `lotics setup` and `lotics scaffold\napply` alike.\n\n```jsonc\n"apply": [\n {\n "package": "apg_k3nf82ldpq",\n "bind": { // optional \u2014 which of YOUR tables each entity is\n "company": { "label": "Customers", "fields": { "name": "Company name" } }\n },\n "no_sample_data": true // optional\n }\n]\n```\n\n`bind` is keyed by the package\'s entity alias and holds the LABELS this\nworkspace uses: scaffold adopts by label, so binding points the package at the\ntables the model created instead of a second set beside them. Only naming\nmoves \u2014 a bound field must be the TYPE the package declares, or the copy is\nrefused. `lotics library list` is the shelf, and `lotics library show <apg_id>`\nlists the aliases to bind.\n\nEntries run in the order they are written, because a later one may bind onto a\ntable an earlier one created. **A refused entry stops the run and the entries\nbefore it stay** \u2014 they are separate copies, committed as they land, so the\nrefusal names them rather than leaving a caller to re-run the file and copy them\ntwice.\n\n## Presets\n\nA preset is a trade\'s model, published to be READ. An assistant reads it, asks\nat most two questions, picks a variant and writes a `model.json` from it \u2014\nnothing is copied, and a preset is a file rather than anything a workspace\ninstalls.\n\n```jsonc\n"preset": {\n "name": "Field service",\n "description": "Jobs, the crew that runs them, and what each one billed.",\n "questions": ["Do you dispatch crews, or one person per job?"], // at most 2\n "variants": {\n "crews": {\n "when": "work is dispatched to crews rather than to one person",\n "entities": [ /* tables this branch ADDS */ ],\n "fields": { "job": [ /* fields this branch ADDS to `job` */ ] }\n }\n }\n}\n```\n\nVariants are **additive only**: a branch adds entities and fields and never\nremoves them, so the base is a model in its own right rather than a draft.\n`lotics scaffold check` proves the base AND every variant merged onto it, so a\npreset ships with every branch already proven \u2014 the branch nobody took is the\none that fails in the workspace of whoever takes it.\n\n`preset` is not scaffolded. `lotics setup` and `lotics scaffold apply` ignore\nit and create the base model\'s tables.\n\n`lotics scaffold export` prints a workspace that already works as one of these\nfiles \u2014 the starting point for a preset or for another business\'s model, never a\nsource of truth: it carries one business\'s words and stops describing that\nworkspace the moment either changes.\n\n## Starting from a preset\n\n`lotics library list` is the shelf of them and `lotics library show <slug>`\nprints one whole: its questions, every table as `alias \xB7 label` with each field\nas `alias:type`, and each variant as `slug \xB7 when` followed by the tables and\nfields that branch adds. When one of them is the trade in front of you, do not\ntranscribe it \u2014 name it:\n\n```jsonc\n{\n "from": "field_service",\n "variants": ["crews"],\n "rename": { "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } } },\n "entities": [ /* a table this business has that the preset does not */ ],\n "rows": { "job": [ { "ref": "j1", "fields": { "code": "J-1" } } ] },\n "field_roles": { "job": { "code": "identity" } },\n "write_rules": { "customer": { "natural_key": ["email"] } },\n "apps": [ /* the screens this business\'s apps will have */ ]\n}\n```\n\n- **`from`** is the preset\'s SLUG \u2014 its own file name, a lowercase slug. Naming\n it is what makes `entities` optional; every other rule on this page is\n unchanged, because the file is resolved into the full form and then checked and\n applied exactly as one. A slug nothing serves is refused with the ones there\n are, never resolved against something else.\n- **`variants`** names the branches to merge onto the base, in order. Pick the\n one whose `when` describes what the person said; a slug the preset does not\n declare is refused rather than ignored.\n- **`rename`** is keyed by the preset\'s entity alias and holds the labels this\n business uses \u2014 the same shape `apply[].bind` takes, and the same rule: only\n naming moves. An alias the preset does not declare, and a label that is\n already another table\'s, are both refused.\n- **`entities`** are added after the rename, already in this business\'s own\n words.\n- **`rows`**, **`field_roles`**, **`write_rules`**, **`apps`** and **`apply`**\n mean exactly what they mean in the full form \u2014 `"rows"` are this business\'s\n real first records,\n `"field_roles"` may name the preset\'s fields as well as its own (a role the\n preset declares itself is kept unless this file names the same field, or\n clears it with `null`), `"write_rules"` the create-time clauses on either\n (an entity this file names replaces the preset\'s whole entry for it),\n `"apps"` the screens it will have (a preset carries none), `"apply"` the\n packages copied in once its tables exist.\n\nThis is the ONE thing on this page that needs the network: `check` reads the\npreset it names, once. Everything after that read is the same offline check.\n\nWrite the full form when no preset is the trade.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "singular": "Customer",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "singular": "Order",\n "fields": [\n { "alias": "code", "label": "Order no.", "type": "text", "unique": true },\n { "alias": "placed_on", "label": "Placed on", "type": "date", "format": "date" },\n {\n "alias": "amount",\n "label": "Amount",\n "type": "number",\n "format": "currency",\n "currency": "VND"\n },\n {\n "alias": "total",\n "label": "Total with VAT",\n "type": "formula",\n "formula": { "expression": "{amount} * 1.1", "format": "currency", "currency": "VND" }\n },\n {\n "alias": "customer",\n "label": "Customer",\n "type": "select_record_link",\n "target_entity": "customer",\n "cardinality": "one",\n "sync_both_ways": true,\n "paired_field_alias": "orders",\n "display_field_aliases": ["name"]\n }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "field_roles": {\n "customer": { "name": "identity" },\n "order": { "code": "identity", "placed_on": "when", "amount": "amount", "customer": "party" }\n },\n "apps": [\n {\n "alias": "customers",\n "name": "Customers",\n "screen": { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" }\n },\n {\n "alias": "orders",\n "name": "Orders",\n "screen": { "alias": "orders", "label": "Orders", "shape": "transaction_ledger", "entity": "order" }\n }\n ],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\n`lotics scaffold check` on this file reports\n`2 tables, 9 fields, 2 links, 1 view, 1 role, 4 rows, 2 apps`, then\nthe plan:\n\n```\nCustomers\n Customers \u2014 party register over Customers (2 rows) \xB7 page \xB7 tabs: none\n identity Name \xB7 mark (none \u2014 no field declares "mark") \xB7 contact (none \u2014 no field declares "contact") \xB7 worth (none \u2014 no field declares "measure") \xB7 risk (none \u2014 no field declares "verdict")\n above: rows (default)\n record: header (Name) \xB7 history: Orders (Order no. \xB7 Placed on \xB7 Amount (VND)) \xB7 facts (Tier \xB7 Total ordered (VND))\nOrders\n Orders \u2014 transaction ledger over Orders (2 rows) \xB7 drawer \xB7 tabs: none\n when Placed on \xB7 amount Amount (VND) \xB7 reference Order no. \xB7 party Customer \xB7 classification (none \u2014 no field declares "lifecycle") \xB7 document (none \u2014 no field declares "expected_set" or "verdict")\n above: Amount (VND) (default)\n record: header (Order no. \xB7 Placed on \xB7 Amount (VND)) \xB7 facts (Placed on \xB7 Total with VAT (VND) \xB7 Customer)\nWho writes what:\n Customers (customer, one: Customer): Customers\n Orders (order, one: Order): Orders\n```\n\nThe last block is the WRITERS matrix \u2014 one line per record surface the plan\nleaves operable, the word one of its rows is called, and the app whose register\nopens it. Part of the verdict rather than a diagnostic beside it: which desk\nchanges a table is answerable from the plan, and two apps on one line is the\nsplit to state in each of their `package.json#lotics.writes`.\n\nEvery `(none)` is a slot no field fills, and it says WHY: no field declares the\nrole, or several could and the plan named none of them (`(none \u2014 a, b; name\none)`). Read it as the screen a person will see. `above: \u2026 (default)` is the band\nnobody stated \u2014 the shape sums the amount its slot draws, and a plan naming its\nown figures loses the word. Neither\nentity carries a stage, a required set or files, so the rest is facts \u2014 except\nthe sections neither entity declares: the customer\'s `history:` is the orders\nthat name it as their party, and the order\'s `via Orders:` is the customers whose\n`Orders` link names it. **A link is a section, not a fact**, on the side it\npoints AT and on the side that holds it: `Customers.Orders` therefore leaves the\ncustomer\'s facts, because the section below already lists the same relation and\na link fact can only ever show the first of them. Both records state their name\nin the header and nowhere else \u2014 a ledger\'s columns are the date and the figure,\nso `Order no.` is on no column of that list and is still what the drawer behind\na line is called. `Total ordered (VND)` is the rollup inheriting the currency of\nthe column it sums: money is read off the model, never off the field\'s own line.\n';
|
|
55166
56128
|
|
|
55167
56129
|
// src/scaffold_commands.ts
|
|
55168
56130
|
function printModelReference() {
|
|
@@ -55187,10 +56149,14 @@ function checkWorkspaceModel(raw, options = {}) {
|
|
|
55187
56149
|
model: null
|
|
55188
56150
|
};
|
|
55189
56151
|
}
|
|
55190
|
-
const { rows = {}, field_roles = {}, write_rules = {}, apps = [], apply = [], preset, ...
|
|
56152
|
+
const { rows = {}, field_roles: authored = {}, write_rules = {}, apps = [], apply = [], preset, ...authoredContract } = parsed.data;
|
|
56153
|
+
const expanded = expandModelDerivations(authoredContract, authored);
|
|
56154
|
+
const contract = expanded.model;
|
|
56155
|
+
const field_roles = expanded.field_roles;
|
|
55191
56156
|
const findings = [
|
|
55192
56157
|
// What the parse dropped is a mistake the author cannot otherwise see.
|
|
55193
56158
|
...unknownModelKeys(raw, parsed.data),
|
|
56159
|
+
...expanded.findings,
|
|
55194
56160
|
...validateWorkspaceModel(contract, rows),
|
|
55195
56161
|
// The roles, and every screen against them — the CLI's own checks, because
|
|
55196
56162
|
// neither a role nor the plan reaches the server.
|
|
@@ -55328,7 +56294,7 @@ function describeRecord(model, resolved) {
|
|
|
55328
56294
|
(field) => field !== void 0
|
|
55329
56295
|
);
|
|
55330
56296
|
const archetype = recordArchetype(resolved.screen.shape, resolved.entity, model.field_roles);
|
|
55331
|
-
const sections = recordRecipe(resolved.screen.shape, resolved.entity, model.contract.entities, model.field_roles, header, resolved.record).map(({ draw, slot: recipeSlot, section }) => {
|
|
56297
|
+
const sections = recordRecipe(resolved.screen.shape, resolved.entity, model.contract.entities, model.field_roles, header, resolved.record, resolved.screen.sections).map(({ draw, slot: recipeSlot, section }) => {
|
|
55332
56298
|
switch (section.kind) {
|
|
55333
56299
|
case "facts": {
|
|
55334
56300
|
const fact = (field) => {
|
|
@@ -55337,20 +56303,29 @@ function describeRecord(model, resolved) {
|
|
|
55337
56303
|
if (level === void 0) return name;
|
|
55338
56304
|
return `${name} against ${typeof level.limit === "number" ? level.limit : fieldName(model, resolved.entity, level.limit)}`;
|
|
55339
56305
|
};
|
|
55340
|
-
const bands = factsPlan(resolved, model.field_roles, section.fields);
|
|
55341
56306
|
const grouped = groupedFacts(resolved);
|
|
55342
|
-
|
|
55343
|
-
const
|
|
55344
|
-
const
|
|
55345
|
-
|
|
55346
|
-
|
|
56307
|
+
return factSections(factsPlan(resolved, model.field_roles, section.fields)).map((bands) => {
|
|
56308
|
+
const fields = bands.flatMap((band2) => band2.fields);
|
|
56309
|
+
const primary = fields.filter((field) => factTier(field, resolved.entity, model.field_roles, grouped) === "primary");
|
|
56310
|
+
const filed = fields.length - primary.length;
|
|
56311
|
+
const held = [
|
|
56312
|
+
...primary.length === 0 ? [] : [primary.map(fact).join(" \xB7 ")],
|
|
55347
56313
|
...filed === 0 ? [] : [`\u2026 ${filed} filed`]
|
|
55348
56314
|
].join(" \xB7 ");
|
|
56315
|
+
return `facts${bands[0]?.caption === void 0 ? "" : ` ${bands[0].caption}`} (${held})`;
|
|
55349
56316
|
}).join(" \xB7 ");
|
|
55350
|
-
return `facts (${stated3})`;
|
|
55351
56317
|
}
|
|
55352
|
-
case "progress":
|
|
55353
|
-
|
|
56318
|
+
case "progress": {
|
|
56319
|
+
if (section.field.type !== "select") return `progress: ${section.field.label}`;
|
|
56320
|
+
const stages = new Map(section.field.options.map((option) => [option.alias, option.label]));
|
|
56321
|
+
const grouped = (section.phases ?? []).map(
|
|
56322
|
+
(phase) => `${phase.label}: ${phase.stages.map((alias) => stages.get(alias) ?? alias).join(" \u2192 ")}`
|
|
56323
|
+
);
|
|
56324
|
+
const outcomes = section.outcomes.filter((alias) => !grouped.some((phase) => phase.includes(stages.get(alias) ?? alias)));
|
|
56325
|
+
const parts = grouped.length === 0 ? count(section.field.options.length, "stage") : [...grouped, ...outcomes.length === 0 ? [] : [outcomes.map((alias) => stages.get(alias) ?? alias).join(" \xB7 ")]].join(" \xB7 ");
|
|
56326
|
+
const dated = section.history === void 0 ? ", no dates" : `, dated from ${section.history.child.label}${section.history.by === void 0 ? "" : ` (with who moved it)`} \u2014 only this app's writes append`;
|
|
56327
|
+
return `progress: ${section.field.label} (${parts})${dated}`;
|
|
56328
|
+
}
|
|
55354
56329
|
case "expected_set": {
|
|
55355
56330
|
const required2 = section.source === "own" ? section.field : section.setField;
|
|
55356
56331
|
const name = section.source === "own" ? section.field.label : section.child.label;
|
|
@@ -55360,17 +56335,21 @@ function describeRecord(model, resolved) {
|
|
|
55360
56335
|
}
|
|
55361
56336
|
case "children": {
|
|
55362
56337
|
if (draw === "related") return `related: ${section.child.label} (count via ${section.link.label})`;
|
|
55363
|
-
if (resolved.screen.sections?.[section.child.alias]
|
|
56338
|
+
if (resolved.screen.sections?.[section.child.alias]?.draw === "worksheet") {
|
|
55364
56339
|
return `sheet: ${section.child.label} (${labels(section.child, section.fields)})`;
|
|
55365
56340
|
}
|
|
55366
56341
|
const run = recordItinerary(archetype, recipeSlot, section.child, model.field_roles);
|
|
55367
56342
|
if (run !== void 0) {
|
|
55368
56343
|
const at2 = run.slot === void 0 ? "" : ` at ${fieldName(model, section.child, run.slot)}`;
|
|
55369
|
-
const
|
|
55370
|
-
const
|
|
56344
|
+
const glyphs = run.icons === void 0 ? "" : ` (${Object.keys(run.icons).length} with a glyph)`;
|
|
56345
|
+
const kind = run.kind === void 0 ? "" : `, kind ${fieldName(model, section.child, run.kind)}${glyphs}`;
|
|
56346
|
+
const face = run.providerMark === void 0 ? "" : " with their own mark";
|
|
56347
|
+
const by = run.provider === void 0 ? "" : `, by ${fieldName(model, section.child, run.provider)}${face}`;
|
|
56348
|
+
const lasting = run.duration === void 0 ? "" : `, lasting ${fieldName(model, section.child, run.duration)} hours`;
|
|
56349
|
+
const marked = run.mark === void 0 ? "" : `, marked ${fieldName(model, section.child, run.mark)}`;
|
|
55371
56350
|
const quoted = run.reference === void 0 ? "" : `, ref ${fieldName(model, section.child, run.reference)}`;
|
|
55372
56351
|
const over = run.span === void 0 ? "" : `, over ${fieldName(model, section.child, run.span)} days`;
|
|
55373
|
-
return `itinerary: ${section.child.label} by ${fieldName(model, section.child, run.when)}${at2}${kind}${
|
|
56352
|
+
return `itinerary: ${section.child.label} by ${fieldName(model, section.child, run.when)}${at2}${kind}${marked}${by}${lasting}${quoted}${over} (${labels(section.child, section.fields)})`;
|
|
55374
56353
|
}
|
|
55375
56354
|
const heading = section.via === "party" ? "history" : section.via === "link" ? `via ${section.link.label}` : "lines";
|
|
55376
56355
|
const owed = section.expected === void 0 ? "" : ` \u2014 expects ${typeof section.expected === "number" ? section.expected : fieldName(model, resolved.entity, section.expected)}`;
|
|
@@ -55526,9 +56505,14 @@ function describeCarried(model, resolved) {
|
|
|
55526
56505
|
// alias resolved to is the half the author cannot see in their own file.
|
|
55527
56506
|
...sectionActLines(model, resolved, act),
|
|
55528
56507
|
// A SHEET IS THE ONE SECTION THAT WRITES, so the plan says which register it
|
|
55529
|
-
// prices and which of the child's figures is the base and which the answer
|
|
56508
|
+
// prices and which of the child's figures is the base and which the answer —
|
|
56509
|
+
// and a BOOK says what the record it is against comes to, which is the line
|
|
56510
|
+
// its reader sees before the first row exists.
|
|
55530
56511
|
...Object.entries(resolved.screen.sections ?? {}).map(
|
|
55531
|
-
([alias, drawn2]) => `
|
|
56512
|
+
([alias, drawn2]) => drawn2.draw === "ledger" ? `book on ${alias}: headed ${drawn2.summary.map((field) => {
|
|
56513
|
+
const carried = resolved.entity.fields.find((one) => one.alias === field);
|
|
56514
|
+
return carried === void 0 ? field : fieldName(model, resolved.entity, carried);
|
|
56515
|
+
}).join(" \xB7 ")}` : `sheet on ${alias}: ${drawn2.cost ?? "(no cost)"} \u2192 ${drawn2.sell ?? "(no sell)"}`
|
|
55532
56516
|
),
|
|
55533
56517
|
...resolved.screen.writes === false ? ["operable: no"] : []
|
|
55534
56518
|
];
|
|
@@ -56116,6 +57100,25 @@ No rows were written \u2014 a table this model bound already holds records${adop
|
|
|
56116
57100
|
the sample data, or add the rows yourself.`
|
|
56117
57101
|
);
|
|
56118
57102
|
}
|
|
57103
|
+
for (const entity of model.contract.entities) {
|
|
57104
|
+
const owner = model.contract.entities.find(
|
|
57105
|
+
(candidate) => lifecycleHistory(candidate, model.field_roles)?.alias === entity.alias
|
|
57106
|
+
);
|
|
57107
|
+
if (owner === void 0) continue;
|
|
57108
|
+
const parents = result.record_ids[owner.alias]?.length ?? 0;
|
|
57109
|
+
warn(
|
|
57110
|
+
`
|
|
57111
|
+
${entity.label} starts empty \u2014 it records stage changes from now on, and the rows
|
|
57112
|
+
${owner.label} already holds walked their stages before it existed. The honest backfill is ONE
|
|
57113
|
+
opening row per existing ${owner.label} at its FIRST stage, dated when that row was created;
|
|
57114
|
+
the stage it stands on now is not dated unless that IS the first.
|
|
57115
|
+
Write it as one create per parent: lotics run create_records '{"table_id": "<${entity.label}>", "records": [ \u2026 ]}'${parents > 0 ? `
|
|
57116
|
+
(this run opened ${parents} of them)` : ""}
|
|
57117
|
+
And it stays incomplete going forward for any stage moved OUTSIDE this workspace's apps \u2014
|
|
57118
|
+
the table explorer, an import, a chat agent \u2014 until a model can declare the table automation
|
|
57119
|
+
that appends on every write path (docs/platform_gaps.md GAP-487).`
|
|
57120
|
+
);
|
|
57121
|
+
}
|
|
56119
57122
|
const written = Object.values(result.record_ids).reduce((n, ids) => n + ids.length, 0);
|
|
56120
57123
|
const runFile = writeRunHandle(options.file, {
|
|
56121
57124
|
applied_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -56185,7 +57188,6 @@ var READ_OPS = /* @__PURE__ */ new Set([
|
|
|
56185
57188
|
"context",
|
|
56186
57189
|
"query",
|
|
56187
57190
|
"field_options",
|
|
56188
|
-
"field_history",
|
|
56189
57191
|
"members",
|
|
56190
57192
|
"agentRuns",
|
|
56191
57193
|
"agentRun.get",
|
|
@@ -56218,7 +57220,6 @@ function refusalMessage(refused) {
|
|
|
56218
57220
|
var SUPPORTED_OPS = /* @__PURE__ */ new Set([
|
|
56219
57221
|
"query",
|
|
56220
57222
|
"field_options",
|
|
56221
|
-
"field_history",
|
|
56222
57223
|
"workflow",
|
|
56223
57224
|
"members",
|
|
56224
57225
|
"context",
|
|
@@ -56264,17 +57265,6 @@ async function dispatchRpc(client, body, opts) {
|
|
|
56264
57265
|
}
|
|
56265
57266
|
return client.appFieldOptions(body.app_id, p.alias);
|
|
56266
57267
|
}
|
|
56267
|
-
case "field_history": {
|
|
56268
|
-
const p = body.payload;
|
|
56269
|
-
if (!p || typeof p.table_id !== "string" || typeof p.record_id !== "string" || typeof p.field_id !== "string") {
|
|
56270
|
-
throw new Error("field_history payload must include table_id, record_id, field_id");
|
|
56271
|
-
}
|
|
56272
|
-
return client.appFieldHistory(body.app_id, {
|
|
56273
|
-
table_id: p.table_id,
|
|
56274
|
-
record_id: p.record_id,
|
|
56275
|
-
field_id: p.field_id
|
|
56276
|
-
});
|
|
56277
|
-
}
|
|
56278
57268
|
case "workflow": {
|
|
56279
57269
|
const p = body.payload;
|
|
56280
57270
|
if (!p || typeof p.alias !== "string") {
|
|
@@ -63168,7 +64158,7 @@ function componentName(target) {
|
|
|
63168
64158
|
return /^[A-Za-z]/.test(raw) ? raw : `Section${raw}`;
|
|
63169
64159
|
}
|
|
63170
64160
|
function ejectedHeading(section) {
|
|
63171
|
-
if ("heading" in section) return section.heading;
|
|
64161
|
+
if ("heading" in section && section.heading !== void 0) return section.heading;
|
|
63172
64162
|
const words = section.key.split(/[^A-Za-z0-9]+/).filter((part) => part.length > 0);
|
|
63173
64163
|
return words.length === 0 ? section.key : [`${words[0][0].toUpperCase()}${words[0].slice(1)}`, ...words.slice(1)].join(" ");
|
|
63174
64164
|
}
|