@lotics/cli 0.207.0 → 0.208.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli.js +1030 -263
- package/dist/src/client.d.ts +7 -0
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -15807,12 +15807,12 @@ var LoticsClient = class {
|
|
|
15807
15807
|
const disposition = response.headers.get("content-disposition") ?? "";
|
|
15808
15808
|
const originalFilename = parseContentDispositionFilename(disposition) ?? fileId;
|
|
15809
15809
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
15810
|
-
const { dir, named } = destination === void 0 ? { dir: process.cwd() } : "file" in destination ? {
|
|
15810
|
+
const { dir, named: named2 } = destination === void 0 ? { dir: process.cwd() } : "file" in destination ? {
|
|
15811
15811
|
dir: path2.dirname(path2.resolve(destination.file)),
|
|
15812
15812
|
named: path2.basename(destination.file)
|
|
15813
15813
|
} : { dir: path2.resolve(destination.dir) };
|
|
15814
15814
|
await fs2.promises.mkdir(dir, { recursive: true });
|
|
15815
|
-
const filename =
|
|
15815
|
+
const filename = named2 ?? findAvailableFilename(dir, originalFilename, options?.reserved);
|
|
15816
15816
|
const absolutePath = path2.join(dir, filename);
|
|
15817
15817
|
await fs2.promises.writeFile(absolutePath, buffer);
|
|
15818
15818
|
return { path: absolutePath, filename, stored_filename: originalFilename };
|
|
@@ -33624,8 +33624,8 @@ function conditionIssues(node, path23, fieldMap) {
|
|
|
33624
33624
|
return [`${path23}: a button field holds no data and cannot be filtered.`];
|
|
33625
33625
|
}
|
|
33626
33626
|
if (typeof filterType !== "string" || !VALID_FILTER_TYPES.includes(filterType)) {
|
|
33627
|
-
const
|
|
33628
|
-
return [`${path23}: invalid filter type ${
|
|
33627
|
+
const named2 = fieldMap && filterType !== obj.type ? `'${String(filterType)}' (resolved from field '${obj.field_key}')` : `'${String(obj.type)}'`;
|
|
33628
|
+
return [`${path23}: invalid filter type ${named2}. Valid: ${VALID_FILTER_TYPES.join(", ")}`];
|
|
33629
33629
|
}
|
|
33630
33630
|
const validOps = VALID_OPERATORS[filterType];
|
|
33631
33631
|
if (typeof obj.operator !== "string" || !validOps.includes(obj.operator)) {
|
|
@@ -36941,6 +36941,10 @@ var contractFieldSchema = zod_default.discriminatedUnion("type", [
|
|
|
36941
36941
|
contractLookupFieldSchema,
|
|
36942
36942
|
contractAutonumberFieldSchema
|
|
36943
36943
|
]);
|
|
36944
|
+
function fieldHoldsSeveral(field) {
|
|
36945
|
+
if (field.type === "select") return field.multi === true;
|
|
36946
|
+
return field.type === "select_record_link" && field.cardinality !== "one";
|
|
36947
|
+
}
|
|
36944
36948
|
var contractViewSchema = zod_default.object({
|
|
36945
36949
|
alias: contractAliasSchema.describe("Stable view alias, unique within the entity"),
|
|
36946
36950
|
label: zod_default.string().min(1).describe("Display name of the view"),
|
|
@@ -37473,8 +37477,8 @@ function checkOutputRefs(output, path23, entityAliases, optionAliases, fieldAlia
|
|
|
37473
37477
|
checkOutputRefs(output.items, `${path23}[]`, entityAliases, optionAliases, fieldAliases, errors);
|
|
37474
37478
|
}
|
|
37475
37479
|
}
|
|
37476
|
-
function checkSourceLinks(source, path23,
|
|
37477
|
-
const recur = (child) => checkSourceLinks(child, path23,
|
|
37480
|
+
function checkSourceLinks(source, path23, parentEntity2, fieldByEntity, errors) {
|
|
37481
|
+
const recur = (child) => checkSourceLinks(child, path23, parentEntity2, fieldByEntity, errors);
|
|
37478
37482
|
if (typeof source === "string") return;
|
|
37479
37483
|
if ("eq" in source) return source.eq.forEach(recur);
|
|
37480
37484
|
if ("neq" in source) return source.neq.forEach(recur);
|
|
@@ -37484,7 +37488,7 @@ function checkSourceLinks(source, path23, parentEntity, fieldByEntity, errors) {
|
|
|
37484
37488
|
if ("concat" in source) return source.concat.forEach(recur);
|
|
37485
37489
|
if (!("link" in source) && !("link_agg" in source)) return;
|
|
37486
37490
|
const reach = "link" in source ? source.link : source.link_agg;
|
|
37487
|
-
if (
|
|
37491
|
+
if (parentEntity2 === null) {
|
|
37488
37492
|
errors.push({
|
|
37489
37493
|
severity: "error",
|
|
37490
37494
|
path: path23,
|
|
@@ -37492,14 +37496,14 @@ function checkSourceLinks(source, path23, parentEntity, fieldByEntity, errors) {
|
|
|
37492
37496
|
});
|
|
37493
37497
|
return;
|
|
37494
37498
|
}
|
|
37495
|
-
const parentFields = fieldByEntity.get(
|
|
37499
|
+
const parentFields = fieldByEntity.get(parentEntity2);
|
|
37496
37500
|
if (parentFields === void 0) return;
|
|
37497
37501
|
const sourceField = parentFields.get(reach.source);
|
|
37498
37502
|
if (sourceField === void 0) {
|
|
37499
37503
|
errors.push({
|
|
37500
37504
|
severity: "error",
|
|
37501
37505
|
path: path23,
|
|
37502
|
-
message: `link source "${reach.source}" is not a field on entity "${
|
|
37506
|
+
message: `link source "${reach.source}" is not a field on entity "${parentEntity2}"`
|
|
37503
37507
|
});
|
|
37504
37508
|
return;
|
|
37505
37509
|
}
|
|
@@ -37507,7 +37511,7 @@ function checkSourceLinks(source, path23, parentEntity, fieldByEntity, errors) {
|
|
|
37507
37511
|
errors.push({
|
|
37508
37512
|
severity: "error",
|
|
37509
37513
|
path: path23,
|
|
37510
|
-
message: `link source "${reach.source}" on entity "${
|
|
37514
|
+
message: `link source "${reach.source}" on entity "${parentEntity2}" is a ${sourceField.type} field, not select_record_link`
|
|
37511
37515
|
});
|
|
37512
37516
|
return;
|
|
37513
37517
|
}
|
|
@@ -38265,8 +38269,11 @@ function resolvedFieldType(field, entity, entities, hops = 0) {
|
|
|
38265
38269
|
}
|
|
38266
38270
|
var ANSWER_KEYS = ["true", "false"];
|
|
38267
38271
|
var requiredBySchema = zod_default.object({
|
|
38272
|
+
from: zod_default.enum(["own", "parent"]).optional().describe(
|
|
38273
|
+
`WHOSE field decides it, which the set's own shape settles: a multi-select holds the whole set, so "own" (the default, this entity's field); a single select is one entry per row, so "parent" \u2014 the record these rows hang under, reached through this entity's \`parent\` link. The other way round is refused`
|
|
38274
|
+
),
|
|
38268
38275
|
field: contractAliasSchema.describe(
|
|
38269
|
-
"The field
|
|
38276
|
+
"The field whose value decides which entries of this set are required \u2014 a single select, or a yes/no answered on that row"
|
|
38270
38277
|
),
|
|
38271
38278
|
options: zod_default.record(contractAliasSchema, zod_default.array(contractAliasSchema)).describe(
|
|
38272
38279
|
`That field's option alias \u2014 or "true"/"false" where it is a yes/no \u2014 \u2192 the option aliases of THIS set required under it. A value with no entry is omitted, not empty.`
|
|
@@ -38285,14 +38292,19 @@ var fieldRoleDeclSchema = zod_default.object({
|
|
|
38285
38292
|
'With against: what that limit IS. "fill" (the default) is a WHOLE the level is a share of \u2014 6 of 8 lines shipped. "threshold" is an alarm the level stays one side of \u2014 a reorder minimum, a margin floor \u2014 and every reading on the safe side is inside it, so there is no share to draw'
|
|
38286
38293
|
),
|
|
38287
38294
|
outcomes: zod_default.array(contractAliasSchema).optional().describe("For a lifecycle: the options that END the flow \u2014 a row in one has arrived, not advanced"),
|
|
38295
|
+
due: zod_default.literal(true).optional().describe(
|
|
38296
|
+
"For a when: this date is a DEADLINE the row counts down to, and a row past it is late. Absent, it is the plain day it is \u2014 the day a lead arrived is neither early nor late"
|
|
38297
|
+
),
|
|
38288
38298
|
until: contractAliasSchema.optional().describe(
|
|
38289
|
-
"For a when: the option of this entity's lifecycle the countdown STOPS at \u2014 at or past that stage, and at every outcome, the date is drawn plain"
|
|
38299
|
+
"For a when or an obligation: the option of this entity's lifecycle the countdown STOPS at \u2014 at or past that stage, and at every outcome, the date is drawn plain. On a when it also says the date is a deadline"
|
|
38290
38300
|
),
|
|
38291
38301
|
signed_by: contractAliasSchema.optional().describe("For an amount: the select on the same entity that says which direction it moved"),
|
|
38292
38302
|
outflow: zod_default.array(contractAliasSchema).optional().describe("With signed_by: the options of that select that spend \u2014 the amount reads negative under them"),
|
|
38293
38303
|
required_by: requiredBySchema.optional().describe("For an expected_set: the select whose value decides which entries a row owes"),
|
|
38294
38304
|
label: zod_default.string().min(1).optional().describe("For an obligation: what is OWED, as the reader says it \u2014 absent, the date field's own label"),
|
|
38295
|
-
satisfied_by: contractAliasSchema.optional().describe(
|
|
38305
|
+
satisfied_by: contractAliasSchema.optional().describe(
|
|
38306
|
+
"For an obligation: the field on the same entity that CLOSES it \u2014 the date it was done, or the file that proves it. Where nothing is stamped, `until` names the stage the record stops owing it at instead"
|
|
38307
|
+
)
|
|
38296
38308
|
}).strict();
|
|
38297
38309
|
var fieldRolesSchema = zod_default.record(
|
|
38298
38310
|
contractAliasSchema,
|
|
@@ -38301,6 +38313,14 @@ var fieldRolesSchema = zod_default.record(
|
|
|
38301
38313
|
zod_default.preprocess((value) => typeof value === "string" ? { role: value } : value, fieldRoleDeclSchema).nullable()
|
|
38302
38314
|
)
|
|
38303
38315
|
);
|
|
38316
|
+
function countsDown(decl) {
|
|
38317
|
+
if (decl?.role === "obligation") return true;
|
|
38318
|
+
return decl?.role === "when" && (decl.due === true || decl.until !== void 0);
|
|
38319
|
+
}
|
|
38320
|
+
function parentEntity(entity, entities, roles) {
|
|
38321
|
+
const link = entity.fields.find((field) => roleOf(roles, entity.alias, field.alias)?.role === "parent");
|
|
38322
|
+
return link === void 0 ? void 0 : linkTarget(entity, entities, link.alias);
|
|
38323
|
+
}
|
|
38304
38324
|
function roleOf(roles, entity, field) {
|
|
38305
38325
|
if (!Object.hasOwn(roles, entity)) return void 0;
|
|
38306
38326
|
const fields = roles[entity];
|
|
@@ -38349,9 +38369,10 @@ function checkFieldRoles(entities, roles) {
|
|
|
38349
38369
|
errors.push(...checkCurrencyOptions(decl, field, path23));
|
|
38350
38370
|
errors.push(...checkOutcomes(decl, field, path23));
|
|
38351
38371
|
errors.push(...checkUntil(decl, entity, roles, path23));
|
|
38372
|
+
errors.push(...checkDue(decl, path23));
|
|
38352
38373
|
errors.push(...checkSlot(decl, field, path23));
|
|
38353
38374
|
errors.push(...checkSign(decl, fieldByAlias, entityAlias, path23));
|
|
38354
|
-
errors.push(...checkRequiredBy(decl, field,
|
|
38375
|
+
errors.push(...checkRequiredBy(decl, field, entity, entities, roles, path23));
|
|
38355
38376
|
errors.push(...checkObligation(decl, fieldAlias, fieldByAlias, entityAlias, path23));
|
|
38356
38377
|
errors.push(...checkCounts(decl, field, entity, entities, path23));
|
|
38357
38378
|
if (decl.reading !== void 0 && (decl.role !== "measure" || decl.against === void 0)) {
|
|
@@ -38509,17 +38530,40 @@ function requiredByReads(condition, entity, entities) {
|
|
|
38509
38530
|
if (condition.type === "select") return condition.multi === true ? void 0 : "option";
|
|
38510
38531
|
return resolvedFieldType(condition, entity, entities) === "boolean" ? "answer" : void 0;
|
|
38511
38532
|
}
|
|
38512
|
-
function
|
|
38533
|
+
function requiredByFrom(field) {
|
|
38534
|
+
return field.type === "select" && field.multi !== true ? "parent" : "own";
|
|
38535
|
+
}
|
|
38536
|
+
function checkRequiredBy(decl, field, entity, entities, roles, path23) {
|
|
38513
38537
|
const requiredBy = decl.required_by;
|
|
38514
38538
|
if (requiredBy === void 0) return [];
|
|
38515
38539
|
if (decl.role !== "expected_set") {
|
|
38516
38540
|
return [{ severity: "error", path: path23, message: `required_by belongs to an expected_set \u2014 this role is "${decl.role}"` }];
|
|
38517
38541
|
}
|
|
38518
|
-
const
|
|
38542
|
+
const from = requiredByFrom(field);
|
|
38543
|
+
if ((requiredBy.from ?? "own") !== from) {
|
|
38544
|
+
return [
|
|
38545
|
+
{
|
|
38546
|
+
severity: "error",
|
|
38547
|
+
path: path23,
|
|
38548
|
+
message: from === "parent" ? `required_by on "${field.alias}" reads this row's own column \u2014 one row carries one entry of this set, so which entries are owed is stated by the record they hang under: from: "parent"` : `required_by on "${field.alias}" reads the parent's column \u2014 this row carries the whole set, so the value deciding it is this row's own: drop from`
|
|
38549
|
+
}
|
|
38550
|
+
];
|
|
38551
|
+
}
|
|
38552
|
+
const deciding = from === "parent" ? parentEntity(entity, entities, roles) : entity;
|
|
38553
|
+
if (deciding === void 0) {
|
|
38554
|
+
return [
|
|
38555
|
+
{
|
|
38556
|
+
severity: "error",
|
|
38557
|
+
path: path23,
|
|
38558
|
+
message: `required_by reads the parent's "${requiredBy.field}" \u2014 "${entity.alias}" declares no parent link to reach one through`
|
|
38559
|
+
}
|
|
38560
|
+
];
|
|
38561
|
+
}
|
|
38562
|
+
const condition = deciding.fields.find((candidate) => candidate.alias === requiredBy.field);
|
|
38519
38563
|
if (condition === void 0) {
|
|
38520
|
-
return [{ severity: "error", path: path23, message: `required_by names "${requiredBy.field}", which is not a field of "${
|
|
38564
|
+
return [{ severity: "error", path: path23, message: `required_by names "${requiredBy.field}", which is not a field of "${deciding.alias}"` }];
|
|
38521
38565
|
}
|
|
38522
|
-
const reads = requiredByReads(condition,
|
|
38566
|
+
const reads = requiredByReads(condition, deciding, entities);
|
|
38523
38567
|
if (reads === void 0) {
|
|
38524
38568
|
return [
|
|
38525
38569
|
{
|
|
@@ -38564,11 +38608,12 @@ function checkObligation(decl, fieldAlias, fieldByAlias, entityAlias, path23) {
|
|
|
38564
38608
|
return [{ severity: "error", path: path23, message: `label and satisfied_by belong to an obligation \u2014 this role is "${decl.role}"` }];
|
|
38565
38609
|
}
|
|
38566
38610
|
if (decl.satisfied_by === void 0) {
|
|
38611
|
+
if (decl.until !== void 0) return [];
|
|
38567
38612
|
return [
|
|
38568
38613
|
{
|
|
38569
38614
|
severity: "error",
|
|
38570
38615
|
path: path23,
|
|
38571
|
-
message: `an obligation names what
|
|
38616
|
+
message: `an obligation names what ENDS it \u2014 satisfied_by, the date it was done or the file that proves it, or until, the stage the record stops owing it at; without one every obligation ever met stays on the desk`
|
|
38572
38617
|
}
|
|
38573
38618
|
];
|
|
38574
38619
|
}
|
|
@@ -38618,8 +38663,8 @@ function checkOutcomes(decl, field, path23) {
|
|
|
38618
38663
|
}
|
|
38619
38664
|
function checkUntil(decl, entity, roles, path23) {
|
|
38620
38665
|
if (decl.until === void 0) return [];
|
|
38621
|
-
if (decl.role !== "when") {
|
|
38622
|
-
return [{ severity: "error", path: path23, message: `until belongs to a when \u2014 this role is "${decl.role}"` }];
|
|
38666
|
+
if (decl.role !== "when" && decl.role !== "obligation") {
|
|
38667
|
+
return [{ severity: "error", path: path23, message: `until belongs to a when or an obligation \u2014 this role is "${decl.role}"` }];
|
|
38623
38668
|
}
|
|
38624
38669
|
const lifecycle = entity.fields.find((field) => roleOf(roles, entity.alias, field.alias)?.role === "lifecycle");
|
|
38625
38670
|
if (lifecycle === void 0) {
|
|
@@ -38630,6 +38675,16 @@ function checkUntil(decl, entity, roles, path23) {
|
|
|
38630
38675
|
}
|
|
38631
38676
|
return [];
|
|
38632
38677
|
}
|
|
38678
|
+
function checkDue(decl, path23) {
|
|
38679
|
+
if (decl.due === void 0 || decl.role === "when") return [];
|
|
38680
|
+
return [
|
|
38681
|
+
{
|
|
38682
|
+
severity: "error",
|
|
38683
|
+
path: path23,
|
|
38684
|
+
message: decl.role === "obligation" ? `due marks a when as a deadline \u2014 an obligation is a day something is OWED by and already counts down` : `due marks a when as a deadline \u2014 this role is "${decl.role}", which holds no day to be late against`
|
|
38685
|
+
}
|
|
38686
|
+
];
|
|
38687
|
+
}
|
|
38633
38688
|
function checkSlot(decl, field, path23) {
|
|
38634
38689
|
if (decl.role !== "slot" || field.type !== "date") return [];
|
|
38635
38690
|
if (field.format === "datetime" || field.format === "datetime_range") return [];
|
|
@@ -38908,7 +38963,13 @@ var SHAPE_REGISTRY = {
|
|
|
38908
38963
|
slots: {
|
|
38909
38964
|
// THE ROW IS A GROUP BY — a party, an option or a period — and every
|
|
38910
38965
|
// column beside it is an aggregate over the set behind it.
|
|
38911
|
-
|
|
38966
|
+
//
|
|
38967
|
+
// SEVERAL OF THEM ARE A HIERARCHY. "What is each customer worth" and
|
|
38968
|
+
// "which of their projects" are one question asked at two depths, and a
|
|
38969
|
+
// register per depth is the same figures folded twice with nothing saying
|
|
38970
|
+
// they are the same money — so the slot takes the tiers in order and the
|
|
38971
|
+
// reader walks down them.
|
|
38972
|
+
subject: slot(["category", "party", "when"], true, { subject: true, tiers: true }),
|
|
38912
38973
|
amount: slot("amount"),
|
|
38913
38974
|
measure: slot("measure"),
|
|
38914
38975
|
// WHAT ONE ROW BEHIND A FIGURE IS CALLED. The set is what a group opens
|
|
@@ -39034,6 +39095,16 @@ var SHAPE_REGISTRY = {
|
|
|
39034
39095
|
};
|
|
39035
39096
|
var SHAPE_NAMES = Object.keys(SHAPE_REGISTRY);
|
|
39036
39097
|
var CUSTOM_SHAPE = "custom";
|
|
39098
|
+
function slotTakes(shape, name) {
|
|
39099
|
+
if (shape === CUSTOM_SHAPE) return [];
|
|
39100
|
+
const slots = SHAPE_REGISTRY[shape].slots;
|
|
39101
|
+
return slots[name]?.roles ?? [];
|
|
39102
|
+
}
|
|
39103
|
+
function slotTakesTiers(shape, name) {
|
|
39104
|
+
if (shape === CUSTOM_SHAPE) return false;
|
|
39105
|
+
const slots = SHAPE_REGISTRY[shape].slots;
|
|
39106
|
+
return slots[name]?.tiers === true;
|
|
39107
|
+
}
|
|
39037
39108
|
function shapeDrawsBand(shape, band) {
|
|
39038
39109
|
if (shape === CUSTOM_SHAPE) return true;
|
|
39039
39110
|
const declared = SHAPE_REGISTRY[shape];
|
|
@@ -39072,16 +39143,25 @@ function shapeFansObligations(shape) {
|
|
|
39072
39143
|
const declared = SHAPE_REGISTRY[shape];
|
|
39073
39144
|
return declared.obligations === true;
|
|
39074
39145
|
}
|
|
39146
|
+
var CONTEXT_COLUMNS = 4;
|
|
39147
|
+
var SEATED_COLUMNS = 6;
|
|
39148
|
+
function shapeDrawsColumns(shape) {
|
|
39149
|
+
return shapeListsRows(shape) && shapeRowsAreTable(shape) && !shapeFansObligations(shape);
|
|
39150
|
+
}
|
|
39151
|
+
function shapesDrawingColumns() {
|
|
39152
|
+
return SHAPE_NAMES.filter(shapeDrawsColumns);
|
|
39153
|
+
}
|
|
39075
39154
|
function registerReadsForward(shape) {
|
|
39076
39155
|
if (shape === CUSTOM_SHAPE) return false;
|
|
39077
39156
|
const declared = SHAPE_REGISTRY[shape];
|
|
39078
39157
|
return declared.forward === true;
|
|
39079
39158
|
}
|
|
39080
|
-
var PRESENTATION_LEADS = ["mark", "picture", "figure", "none"];
|
|
39159
|
+
var PRESENTATION_LEADS = ["mark", "picture", "paper", "figure", "none"];
|
|
39081
39160
|
var PRESENTATION_DENSITIES = ["roomy", "dense"];
|
|
39082
39161
|
var SCREEN_LAYOUTS = ["table", "list", "cards", "gallery", "board", "calendar", "timeline", "gantt"];
|
|
39083
|
-
function layoutsFor(
|
|
39084
|
-
const
|
|
39162
|
+
function layoutsFor(drawn) {
|
|
39163
|
+
const columns = [...drawn];
|
|
39164
|
+
const held = new Set(columns.map((column) => column.role));
|
|
39085
39165
|
const afforded = /* @__PURE__ */ new Set(["table", "list"]);
|
|
39086
39166
|
if (held.has("mark")) {
|
|
39087
39167
|
afforded.add("cards");
|
|
@@ -39091,7 +39171,7 @@ function layoutsFor(roles) {
|
|
|
39091
39171
|
if (held.has("when")) {
|
|
39092
39172
|
afforded.add("calendar");
|
|
39093
39173
|
afforded.add("timeline");
|
|
39094
|
-
if (
|
|
39174
|
+
if (columns.some((column) => column.role === "measure" && column.counts === "days")) afforded.add("gantt");
|
|
39095
39175
|
}
|
|
39096
39176
|
return SCREEN_LAYOUTS.filter((layout) => afforded.has(layout));
|
|
39097
39177
|
}
|
|
@@ -39127,7 +39207,17 @@ var contractPresentationSchema = zod_default.object({
|
|
|
39127
39207
|
* columns to be and a calendar over rows with no date draws every row on no
|
|
39128
39208
|
* day — so a layout the rows cannot answer is refused with the set they can.
|
|
39129
39209
|
*/
|
|
39130
|
-
layout: screenLayoutSchema.optional().describe("How the rows are arranged \u2014 table, list, cards, gallery, board, calendar, timeline, gantt; absent, a table")
|
|
39210
|
+
layout: screenLayoutSchema.optional().describe("How the rows are arranged \u2014 table, list, cards, gallery, board, calendar, timeline, gantt; absent, a table"),
|
|
39211
|
+
/**
|
|
39212
|
+
* WHERE EACH BAR ENDS, where the rows state the day rather than the span.
|
|
39213
|
+
*
|
|
39214
|
+
* A chart needs a length, and a business records one two ways: a project
|
|
39215
|
+
* says how many days it runs, a lease says the day it is up. The days are
|
|
39216
|
+
* what AFFORDS the chart ({@link layoutsFor}); this is the column the bar is
|
|
39217
|
+
* drawn to when the row states one, which is the more exact answer wherever
|
|
39218
|
+
* a row has both.
|
|
39219
|
+
*/
|
|
39220
|
+
until: contractAliasSchema.optional().describe("For a gantt: the date field each bar is drawn TO; absent, the bar runs for the days its measure counts")
|
|
39131
39221
|
}).strict();
|
|
39132
39222
|
function leadRefusal(shape, drawn, lead) {
|
|
39133
39223
|
if (lead === "none") return void 0;
|
|
@@ -39136,7 +39226,10 @@ function leadRefusal(shape, drawn, lead) {
|
|
|
39136
39226
|
}
|
|
39137
39227
|
if (!drawn.has("mark")) return `leads with the ${lead}, and no column of these rows is drawn as their mark`;
|
|
39138
39228
|
if (shapeMarksParty(shape) === (lead === "mark")) return void 0;
|
|
39139
|
-
|
|
39229
|
+
if (lead === "mark") {
|
|
39230
|
+
return "leads with the mark, and these rows are things \u2014 a thing has no initials to stand in for a picture, so it leads with `picture`";
|
|
39231
|
+
}
|
|
39232
|
+
return `leads with the ${lead}, and these rows are parties \u2014 a party wears its own mark, which carries the picture where there is one`;
|
|
39140
39233
|
}
|
|
39141
39234
|
var AGEING_BUCKETS = [30, 60, 90];
|
|
39142
39235
|
var contractActPlaceSchema = zod_default.literal("cta").describe("Draw this act ON the row rather than in its \u22EF menu \u2014 at most one per screen");
|
|
@@ -39154,7 +39247,18 @@ var contractAgentActSchema = zod_default.strictObject({
|
|
|
39154
39247
|
fills: zod_default.array(contractAliasSchema).min(1).describe("The fields of this entity the run may write \u2014 reviewed by the reader, then written as ONE update"),
|
|
39155
39248
|
place: contractActPlaceSchema.optional()
|
|
39156
39249
|
});
|
|
39157
|
-
var
|
|
39250
|
+
var RECORD_INPUT = "record";
|
|
39251
|
+
var contractWorkflowActSchema = zod_default.strictObject({
|
|
39252
|
+
kind: zod_default.literal("workflow"),
|
|
39253
|
+
label: zod_default.string().min(1).describe("The act's own words, as the reader reads them in the menu"),
|
|
39254
|
+
workflow: contractAliasSchema.describe("A workflow alias the APP declares and binds (package.json#lotics.workflows)"),
|
|
39255
|
+
inputs: zod_default.record(
|
|
39256
|
+
contractAliasSchema,
|
|
39257
|
+
contractAliasSchema.describe('A field of this entity, or "record" \u2014 which names the row itself before any field of that alias')
|
|
39258
|
+
).describe("What the run is handed: the workflow's own input name \u2192 the value on the row it is pressed on"),
|
|
39259
|
+
place: contractActPlaceSchema.optional()
|
|
39260
|
+
});
|
|
39261
|
+
var contractActSchema = zod_default.union([contractAgentActSchema, contractWorkflowActSchema, contractTemplateActSchema], {
|
|
39158
39262
|
error: (issue2) => typeof issue2.input === "object" && issue2.input !== null && "screen" in issue2.input ? "a row act opens this app's record or another app" : void 0
|
|
39159
39263
|
});
|
|
39160
39264
|
var contractImportActSchema = zod_default.strictObject({
|
|
@@ -39166,6 +39270,7 @@ var contractImportActSchema = zod_default.strictObject({
|
|
|
39166
39270
|
});
|
|
39167
39271
|
var contractSlotSchema = zod_default.union([
|
|
39168
39272
|
contractAliasSchema,
|
|
39273
|
+
zod_default.array(contractAliasSchema).min(2).describe("The tiers this slot folds by, outermost first \u2014 for a slot that takes a hierarchy"),
|
|
39169
39274
|
zod_default.strictObject({
|
|
39170
39275
|
field: contractAliasSchema.describe("The field this slot takes"),
|
|
39171
39276
|
quick: zod_default.literal(true).describe("Draw the column as its OWN editor \u2014 for a decision the reader makes from the row alone"),
|
|
@@ -39214,6 +39319,11 @@ var contractPredicateSchema = zod_default.strictObject({
|
|
|
39214
39319
|
"An `and` group of plain conditions over the entity's OWN fields, each `field_key` a field alias \u2014 the rows this predicate keeps"
|
|
39215
39320
|
)
|
|
39216
39321
|
});
|
|
39322
|
+
var contractSectionDrawSchema = zod_default.strictObject({
|
|
39323
|
+
draw: zod_default.literal("worksheet").describe("Priced lines the reader works down in place, each part footed and the sheet closing under them"),
|
|
39324
|
+
cost: contractAliasSchema.optional().describe("The child's field holding what a line COSTS \u2014 the base the margin is taken against"),
|
|
39325
|
+
sell: contractAliasSchema.optional().describe("The child's field holding what it SELLS for \u2014 the figure the sheet is read for")
|
|
39326
|
+
}).describe("How a section of this screen's record is drawn, where its rows are priced lines rather than a register");
|
|
39217
39327
|
var contractLensSchema = zod_default.strictObject({
|
|
39218
39328
|
label: zod_default.string().min(1).describe("What the chip is called"),
|
|
39219
39329
|
predicates: zod_default.array(contractPredicateSchema).min(1).describe("The sets it offers, in this order")
|
|
@@ -39231,6 +39341,12 @@ var contractScreenSchema = zod_default.object({
|
|
|
39231
39341
|
tabs: contractAliasSchema.nullable().optional().describe("A select field on the entity whose options are the tab strip; null for none; absent, the shape decides"),
|
|
39232
39342
|
slots: zod_default.record(zod_default.string(), contractSlotSchema).optional().describe("Slot \u2192 the field that fills it, where roles alone cannot decide"),
|
|
39233
39343
|
roles: zod_default.record(zod_default.string(), fieldRoleSchema).optional().describe(`For "${CUSTOM_SHAPE}" only: slot \u2192 the role that fills it`),
|
|
39344
|
+
// THE SLOTS ARE THE ROW'S ANSWER; THESE ARE ITS CONTEXT — the facts a reader
|
|
39345
|
+
// needs beside that answer and the shape has no slot for. Drawn after the
|
|
39346
|
+
// slots and shed before any of them, so a phone keeps the answer.
|
|
39347
|
+
columns: zod_default.array(contractAliasSchema).min(1).max(CONTEXT_COLUMNS).optional().describe(
|
|
39348
|
+
`Extra fields drawn after the slots, in this order \u2014 at most ${CONTEXT_COLUMNS}, each holding ONE value; never a files field, and never a field a slot already draws`
|
|
39349
|
+
),
|
|
39234
39350
|
// Absent IS true: a desk that can only be read is the exception, so the file
|
|
39235
39351
|
// says that and says nothing in the ordinary case.
|
|
39236
39352
|
writes: zod_default.boolean().optional().describe("false makes this screen's record read-only \u2014 no field editor, no stage advance; absent, it is operable"),
|
|
@@ -39256,6 +39372,7 @@ var contractScreenSchema = zod_default.object({
|
|
|
39256
39372
|
section_acts: zod_default.record(contractAliasSchema, zod_default.array(contractActSchema).min(1)).optional().describe(
|
|
39257
39373
|
"Acts on the RECORD's own sections, keyed by the alias each section is derived from \u2014 a field alias (its progress, its prose, its set, its charge, its files) or a child entity's alias (its register, its desk, its run, its thread)"
|
|
39258
39374
|
),
|
|
39375
|
+
sections: zod_default.record(contractAliasSchema, contractSectionDrawSchema).optional().describe("How a section of the RECORD is DRAWN, keyed by the child entity alias its register is derived from"),
|
|
39259
39376
|
filters: zod_default.array(zod_default.union([contractAliasSchema, contractLensSchema])).min(1).optional().describe(
|
|
39260
39377
|
"The chips beside the search \u2014 a single-select field's alias, or a lens this model states as predicates over the entity's fields"
|
|
39261
39378
|
),
|
|
@@ -39276,7 +39393,11 @@ var contractScreenSchema = zod_default.object({
|
|
|
39276
39393
|
amount: contractAliasSchema.describe("The figure each bucket adds up \u2014 what is still owed on the row"),
|
|
39277
39394
|
due: contractAliasSchema.describe("The date the row was owed by; a row is bucketed by how far past it is"),
|
|
39278
39395
|
buckets: zod_default.array(zod_default.number().int().positive()).min(1).optional().describe(`The bucket edges, in days past due, ascending \u2014 absent, ${AGEING_BUCKETS.join(", ")}`)
|
|
39279
|
-
}).strict().optional().describe("How old the money over this register is \u2014 the amount split by how many days past its date each row is")
|
|
39396
|
+
}).strict().optional().describe("How old the money over this register is \u2014 the amount split by how many days past its date each row is"),
|
|
39397
|
+
trend: zod_default.object({
|
|
39398
|
+
field: contractAliasSchema.describe("The number this register adds up in each bucket of the window"),
|
|
39399
|
+
direction: zod_default.enum(["up", "down"]).describe("Which way is GOOD \u2014 a book wants `up`, a backlog `down`; the arrow follows the change and the colour follows this")
|
|
39400
|
+
}).strict().optional().describe("Which way this figure moved across the window the reader is reading \u2014 needs a `period` to be a window of")
|
|
39280
39401
|
}).strict().optional().describe("What the rows in view come to, stated before or after their names"),
|
|
39281
39402
|
period: contractAliasSchema.optional().describe("A date field on the entity the reader narrows the view by; absent, the view is every row"),
|
|
39282
39403
|
presentation: contractPresentationSchema.optional().describe("How this screen and the record it opens are DRAWN, where the shape's own answer is not the one wanted")
|
|
@@ -39342,7 +39463,8 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
|
|
|
39342
39463
|
const stated2 = (name) => {
|
|
39343
39464
|
const clause = screen.slots?.[name];
|
|
39344
39465
|
if (clause === void 0) return void 0;
|
|
39345
|
-
|
|
39466
|
+
if (typeof clause === "string") return { field: clause };
|
|
39467
|
+
return Array.isArray(clause) ? { field: clause[0], tiers: clause.slice(1) } : clause;
|
|
39346
39468
|
};
|
|
39347
39469
|
if (entity === void 0) return { type: "invalid", findings };
|
|
39348
39470
|
const record2 = screen.record ?? recordDoor(screen.shape, entity, roles);
|
|
@@ -39356,14 +39478,14 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
|
|
|
39356
39478
|
const boundBy = /* @__PURE__ */ new Map();
|
|
39357
39479
|
for (const [name, spec] of Object.entries(slotSpec)) {
|
|
39358
39480
|
const clause = stated2(name);
|
|
39359
|
-
const
|
|
39481
|
+
const named2 = clause?.field;
|
|
39360
39482
|
const takes = spec.roles.map((role) => `"${role}"`).join(" or a ");
|
|
39361
39483
|
let field = null;
|
|
39362
39484
|
let filled = spec.roles[0];
|
|
39363
|
-
if (
|
|
39364
|
-
const candidate = fieldByAlias.get(
|
|
39485
|
+
if (named2 !== void 0) {
|
|
39486
|
+
const candidate = fieldByAlias.get(named2);
|
|
39365
39487
|
if (candidate === void 0) {
|
|
39366
|
-
findings.push({ severity: "error", path: `${path23}.slots.${name}`, message: `names "${
|
|
39488
|
+
findings.push({ severity: "error", path: `${path23}.slots.${name}`, message: `names "${named2}", which is not a field of entity "${entity.alias}"` });
|
|
39367
39489
|
continue;
|
|
39368
39490
|
}
|
|
39369
39491
|
const role = roleOfField(candidate);
|
|
@@ -39371,7 +39493,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
|
|
|
39371
39493
|
findings.push({
|
|
39372
39494
|
severity: "error",
|
|
39373
39495
|
path: `${path23}.slots.${name}`,
|
|
39374
|
-
message: `names "${
|
|
39496
|
+
message: `names "${named2}", whose role is ${role === void 0 ? "not declared" : `"${role}"`} \u2014 this slot takes a ${takes} field; declare the role in field_roles`
|
|
39375
39497
|
});
|
|
39376
39498
|
continue;
|
|
39377
39499
|
}
|
|
@@ -39388,7 +39510,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
|
|
|
39388
39510
|
}
|
|
39389
39511
|
boundBy.set(candidate.alias, name);
|
|
39390
39512
|
}
|
|
39391
|
-
slots.push({ name, role: spec.roles[0], field: candidates2[0], also: candidates2.slice(1), ambiguous: [] });
|
|
39513
|
+
slots.push({ name, role: spec.roles[0], field: candidates2[0], also: candidates2.slice(1), tiers: [], ambiguous: [] });
|
|
39392
39514
|
continue;
|
|
39393
39515
|
}
|
|
39394
39516
|
if (candidates2.length > 1) {
|
|
@@ -39400,7 +39522,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
|
|
|
39400
39522
|
});
|
|
39401
39523
|
continue;
|
|
39402
39524
|
}
|
|
39403
|
-
slots.push({ name, role: filled, field: null, also: [], ambiguous: candidates2.map((f) => f.alias) });
|
|
39525
|
+
slots.push({ name, role: filled, field: null, also: [], tiers: [], ambiguous: candidates2.map((f) => f.alias) });
|
|
39404
39526
|
continue;
|
|
39405
39527
|
}
|
|
39406
39528
|
if (candidates2.length === 0 && spec.required) {
|
|
@@ -39418,6 +39540,30 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
|
|
|
39418
39540
|
}
|
|
39419
39541
|
boundBy.set(field.alias, name);
|
|
39420
39542
|
}
|
|
39543
|
+
const tiers = [];
|
|
39544
|
+
if (clause?.tiers !== void 0) {
|
|
39545
|
+
if (!slotTakesTiers(screen.shape, name)) {
|
|
39546
|
+
findings.push({ severity: "error", path: `${path23}.slots.${name}`, message: `names several fields, and "${shapeLabel}" folds its ${name} by one \u2014 a list is for a slot that takes a hierarchy` });
|
|
39547
|
+
continue;
|
|
39548
|
+
}
|
|
39549
|
+
const refused = clause.tiers.flatMap((alias) => {
|
|
39550
|
+
const candidate = fieldByAlias.get(alias);
|
|
39551
|
+
if (candidate === void 0) return [`"${alias}", which is not a field of entity "${entity.alias}"`];
|
|
39552
|
+
const role = roleOfField(candidate);
|
|
39553
|
+
if (role === void 0 || !spec.roles.includes(role)) {
|
|
39554
|
+
return [`"${alias}", whose role is ${role === void 0 ? "not declared" : `"${role}"`} \u2014 every tier takes a ${takes} field`];
|
|
39555
|
+
}
|
|
39556
|
+
const other = boundBy.get(alias);
|
|
39557
|
+
if (other !== void 0) return [`"${alias}", which already fills ${other} \u2014 a field fills one slot`];
|
|
39558
|
+
boundBy.set(alias, name);
|
|
39559
|
+
tiers.push(candidate);
|
|
39560
|
+
return [];
|
|
39561
|
+
});
|
|
39562
|
+
if (refused.length > 0) {
|
|
39563
|
+
findings.push({ severity: "error", path: `${path23}.slots.${name}`, message: `folds by ${refused.join("; and by ")}` });
|
|
39564
|
+
continue;
|
|
39565
|
+
}
|
|
39566
|
+
}
|
|
39421
39567
|
if (clause?.quick === true) {
|
|
39422
39568
|
const refused = quickRefusal(filled, clause.order) ?? (screen.writes === false ? "is `quick` on a screen that states `writes: false` \u2014 a reader who may operate nothing has no decision to take from the row" : void 0);
|
|
39423
39569
|
if (refused !== void 0) {
|
|
@@ -39430,6 +39576,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
|
|
|
39430
39576
|
role: filled,
|
|
39431
39577
|
field,
|
|
39432
39578
|
also: [],
|
|
39579
|
+
tiers,
|
|
39433
39580
|
ambiguous: [],
|
|
39434
39581
|
...clause?.quick === true ? { quick: true } : {},
|
|
39435
39582
|
...clause?.order === void 0 ? {} : { order: clause.order }
|
|
@@ -39448,6 +39595,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
|
|
|
39448
39595
|
tabs = field;
|
|
39449
39596
|
}
|
|
39450
39597
|
}
|
|
39598
|
+
const columns = resolveColumns(path23, screen, entity, [...entityByAlias.values()], fieldByAlias, boundBy, shapeLabel, findings);
|
|
39451
39599
|
const acts = resolveActs(path23, screen, entity, entityByAlias, rules, fieldByAlias, templates, findings);
|
|
39452
39600
|
const sectionActs2 = resolveSectionActs(path23, screen, entity, fieldByAlias, templates, findings);
|
|
39453
39601
|
const summary = resolveSummary(path23, screen, entity, [...entityByAlias.values()], roles, slots, findings);
|
|
@@ -39474,6 +39622,7 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
|
|
|
39474
39622
|
tabs,
|
|
39475
39623
|
slots,
|
|
39476
39624
|
outcomes,
|
|
39625
|
+
columns,
|
|
39477
39626
|
acts,
|
|
39478
39627
|
sectionActs: sectionActs2,
|
|
39479
39628
|
summary,
|
|
@@ -39485,6 +39634,53 @@ function resolveScreen(app, entityByAlias, roles, templates = [], rules = {}) {
|
|
|
39485
39634
|
}
|
|
39486
39635
|
};
|
|
39487
39636
|
}
|
|
39637
|
+
function resolveColumns(path23, screen, entity, entities, fieldByAlias, boundBy, shapeLabel, findings) {
|
|
39638
|
+
const named2 = screen.columns ?? [];
|
|
39639
|
+
if (named2.length === 0) return [];
|
|
39640
|
+
const at2 = `${path23}.columns`;
|
|
39641
|
+
if (!shapeDrawsColumns(screen.shape)) {
|
|
39642
|
+
findings.push({
|
|
39643
|
+
severity: "error",
|
|
39644
|
+
path: at2,
|
|
39645
|
+
message: `names columns, and a ${shapeLabel} does not stand this entity's records in a table \u2014 the shapes that draw columns are ${shapesDrawingColumns().join(", ")}`
|
|
39646
|
+
});
|
|
39647
|
+
return [];
|
|
39648
|
+
}
|
|
39649
|
+
const layout = screen.presentation?.layout;
|
|
39650
|
+
if (layout !== void 0 && layout !== "table") {
|
|
39651
|
+
findings.push({ severity: "error", path: at2, message: `names columns beside \`layout: "${layout}"\`, which draws the rows with a device of its own instead of the register's columns` });
|
|
39652
|
+
return [];
|
|
39653
|
+
}
|
|
39654
|
+
const drawn = [];
|
|
39655
|
+
const seen = /* @__PURE__ */ new Set();
|
|
39656
|
+
for (const alias of named2) {
|
|
39657
|
+
const field = fieldByAlias.get(alias);
|
|
39658
|
+
if (field === void 0) {
|
|
39659
|
+
findings.push({ severity: "error", path: at2, message: `names "${alias}", which is not a field of entity "${entity.alias}"` });
|
|
39660
|
+
continue;
|
|
39661
|
+
}
|
|
39662
|
+
const slot2 = boundBy.get(alias);
|
|
39663
|
+
if (slot2 !== void 0) {
|
|
39664
|
+
findings.push({ severity: "error", path: at2, message: `names "${alias}", which already fills ${slot2} \u2014 a column states what the slots do not` });
|
|
39665
|
+
continue;
|
|
39666
|
+
}
|
|
39667
|
+
if (seen.has(alias)) {
|
|
39668
|
+
findings.push({ severity: "error", path: at2, message: `names "${alias}" twice` });
|
|
39669
|
+
continue;
|
|
39670
|
+
}
|
|
39671
|
+
if (resolvedFieldType(field, entity, entities) === "files") {
|
|
39672
|
+
findings.push({ severity: "error", path: at2, message: `names "${alias}", a files field \u2014 files are READ, so they are a section of the record rather than a column beside it` });
|
|
39673
|
+
continue;
|
|
39674
|
+
}
|
|
39675
|
+
if (fieldHoldsSeveral(field)) {
|
|
39676
|
+
findings.push({ severity: "error", path: at2, message: `names "${alias}", which holds SEVERAL values \u2014 a column of comma lists cannot be compared down its own column` });
|
|
39677
|
+
continue;
|
|
39678
|
+
}
|
|
39679
|
+
seen.add(alias);
|
|
39680
|
+
drawn.push(field);
|
|
39681
|
+
}
|
|
39682
|
+
return drawn;
|
|
39683
|
+
}
|
|
39488
39684
|
function resolveScope(app, entity, entityByAlias, findings) {
|
|
39489
39685
|
const declared = app.scope;
|
|
39490
39686
|
if (declared === void 0) return null;
|
|
@@ -39573,9 +39769,13 @@ function resolveObligations(path23, screen, entity, shapeLabel, roles, fieldByAl
|
|
|
39573
39769
|
if (!shapeFansObligations(screen.shape)) return [];
|
|
39574
39770
|
const found = entity.fields.flatMap((field) => {
|
|
39575
39771
|
const decl = roleOf(roles, entity.alias, field.alias);
|
|
39576
|
-
if (decl?.role !== "obligation"
|
|
39772
|
+
if (decl?.role !== "obligation") return [];
|
|
39773
|
+
const label = decl.label ?? field.label;
|
|
39774
|
+
if (decl.satisfied_by === void 0) {
|
|
39775
|
+
return recordUntil(entity, roles, field) === void 0 ? [] : [{ field, label }];
|
|
39776
|
+
}
|
|
39577
39777
|
const satisfiedBy = fieldByAlias.get(decl.satisfied_by);
|
|
39578
|
-
return satisfiedBy === void 0 ? [] : [{ field, label
|
|
39778
|
+
return satisfiedBy === void 0 ? [] : [{ field, label, satisfiedBy }];
|
|
39579
39779
|
});
|
|
39580
39780
|
if (found.length === 0) {
|
|
39581
39781
|
findings.push({
|
|
@@ -39608,20 +39808,33 @@ function resolveFactGroups(path23, screen, entity, fieldByAlias, findings) {
|
|
|
39608
39808
|
})
|
|
39609
39809
|
}));
|
|
39610
39810
|
}
|
|
39811
|
+
function actDuplicates(acts, at2, menu, findings) {
|
|
39812
|
+
const refuse = (dups, said) => {
|
|
39813
|
+
for (const dup of dups) findings.push({ severity: "error", path: at2, message: said(dup) });
|
|
39814
|
+
};
|
|
39815
|
+
refuse(
|
|
39816
|
+
findDuplicates(acts.map((act) => act.label)),
|
|
39817
|
+
(dup) => `two acts are both called "${dup}" \u2014 ${menu} reads one line per act`
|
|
39818
|
+
);
|
|
39819
|
+
refuse(
|
|
39820
|
+
findDuplicates(acts.flatMap((act) => "kind" in act ? [] : [act.template])),
|
|
39821
|
+
(dup) => `two acts both generate "${dup}" \u2014 one template is one paper, however it is worded`
|
|
39822
|
+
);
|
|
39823
|
+
refuse(
|
|
39824
|
+
findDuplicates(acts.flatMap((act) => "kind" in act && act.kind === "agent" ? [act.agent] : [])),
|
|
39825
|
+
(dup) => `two acts both run "${dup}" \u2014 one agent is one run, however it is worded`
|
|
39826
|
+
);
|
|
39827
|
+
refuse(
|
|
39828
|
+
findDuplicates(acts.flatMap((act) => "kind" in act && act.kind === "workflow" ? [act.workflow] : [])),
|
|
39829
|
+
(dup) => `two acts both hand off through "${dup}" \u2014 one workflow is one hand-off, however it is worded`
|
|
39830
|
+
);
|
|
39831
|
+
}
|
|
39611
39832
|
function resolveActs(path23, screen, entity, entityByAlias, rules, fieldByAlias, templates, findings) {
|
|
39612
39833
|
const declared = screen.acts;
|
|
39613
39834
|
if (declared === void 0) return { row: [], selection: [], record: [], export: null, import: null };
|
|
39614
39835
|
const stated2 = declared.row ?? [];
|
|
39615
39836
|
const at2 = `${path23}.acts.row`;
|
|
39616
|
-
|
|
39617
|
-
findings.push({ severity: "error", path: at2, message: `two acts are both called "${dup}" \u2014 a row's menu reads one line per act` });
|
|
39618
|
-
}
|
|
39619
|
-
for (const dup of findDuplicates(stated2.flatMap((act) => "kind" in act ? [] : [act.template]))) {
|
|
39620
|
-
findings.push({ severity: "error", path: at2, message: `two acts both generate "${dup}" \u2014 one template is one paper, however it is worded` });
|
|
39621
|
-
}
|
|
39622
|
-
for (const dup of findDuplicates(stated2.flatMap((act) => "kind" in act ? [act.agent] : []))) {
|
|
39623
|
-
findings.push({ severity: "error", path: at2, message: `two acts both run "${dup}" \u2014 one agent is one run, however it is worded` });
|
|
39624
|
-
}
|
|
39837
|
+
actDuplicates(stated2, at2, "a row's menu", findings);
|
|
39625
39838
|
for (const refusal of ctaRefusals(declared)) {
|
|
39626
39839
|
findings.push({ severity: "error", path: `${path23}.acts.${refusal.reach}`, message: refusal.message });
|
|
39627
39840
|
}
|
|
@@ -39704,9 +39917,7 @@ function resolveSectionActs(path23, screen, entity, fieldByAlias, templates, fin
|
|
|
39704
39917
|
const resolved = /* @__PURE__ */ new Map();
|
|
39705
39918
|
for (const [alias, acts] of Object.entries(screen.section_acts ?? {})) {
|
|
39706
39919
|
const at2 = `${path23}.section_acts.${alias}`;
|
|
39707
|
-
|
|
39708
|
-
findings.push({ severity: "error", path: at2, message: `two acts are both called "${dup}" \u2014 a section's menu reads one line per act` });
|
|
39709
|
-
}
|
|
39920
|
+
actDuplicates(acts, at2, "a section's menu", findings);
|
|
39710
39921
|
for (const act of acts) {
|
|
39711
39922
|
if (act.place === "cta") {
|
|
39712
39923
|
findings.push({
|
|
@@ -39724,15 +39935,7 @@ function resolveRecordActs(path23, screen, entity, fieldByAlias, templates, find
|
|
|
39724
39935
|
const declared = screen.acts?.record;
|
|
39725
39936
|
if (declared === void 0) return [];
|
|
39726
39937
|
const at2 = `${path23}.acts.record`;
|
|
39727
|
-
|
|
39728
|
-
findings.push({ severity: "error", path: at2, message: `two acts are both called "${dup}" \u2014 the record's menu reads one line per act` });
|
|
39729
|
-
}
|
|
39730
|
-
for (const dup of findDuplicates(declared.flatMap((act) => "kind" in act ? [] : [act.template]))) {
|
|
39731
|
-
findings.push({ severity: "error", path: at2, message: `two acts both generate "${dup}" \u2014 one template is one paper, however it is worded` });
|
|
39732
|
-
}
|
|
39733
|
-
for (const dup of findDuplicates(declared.flatMap((act) => "kind" in act ? [act.agent] : []))) {
|
|
39734
|
-
findings.push({ severity: "error", path: at2, message: `two acts both run "${dup}" \u2014 one agent is one run, however it is worded` });
|
|
39735
|
-
}
|
|
39938
|
+
actDuplicates(declared, at2, "the record's menu", findings);
|
|
39736
39939
|
for (const act of declared) {
|
|
39737
39940
|
if (act.place === "cta") {
|
|
39738
39941
|
findings.push({ severity: "error", path: at2, message: `"${act.label}" states \`place: "cta"\` \u2014 a record's acts are its header menu, and there is no row to draw a verb on` });
|
|
@@ -39752,6 +39955,10 @@ function resolveAct(act, at2, screen, entity, fieldByAlias, templates, findings)
|
|
|
39752
39955
|
const template = resolveTemplateAct(act.template, templates, at2, findings);
|
|
39753
39956
|
return template === void 0 ? [] : [{ kind: "template", label: act.label, ...place, template }];
|
|
39754
39957
|
}
|
|
39958
|
+
if (act.kind === "workflow") {
|
|
39959
|
+
const inputs = resolveActInputs(act.inputs, at2, entity, fieldByAlias, findings);
|
|
39960
|
+
return inputs === void 0 ? [] : [{ kind: "workflow", label: act.label, ...place, workflow: act.workflow, inputs }];
|
|
39961
|
+
}
|
|
39755
39962
|
if (screen.writes === false) {
|
|
39756
39963
|
findings.push({
|
|
39757
39964
|
severity: "error",
|
|
@@ -39777,6 +39984,31 @@ function resolveAct(act, at2, screen, entity, fieldByAlias, templates, findings)
|
|
|
39777
39984
|
});
|
|
39778
39985
|
return fills.length === 0 ? [] : [{ kind: "agent", label: act.label, ...place, agent: act.agent, fills }];
|
|
39779
39986
|
}
|
|
39987
|
+
function resolveActInputs(declared, at2, entity, fieldByAlias, findings) {
|
|
39988
|
+
const stated2 = Object.entries(declared);
|
|
39989
|
+
if (stated2.length === 0) {
|
|
39990
|
+
findings.push({ severity: "error", path: at2, message: `hands its workflow nothing \u2014 name the input the row is sent under, as \`{"<input>": "record"}\`` });
|
|
39991
|
+
return void 0;
|
|
39992
|
+
}
|
|
39993
|
+
const inputs = [];
|
|
39994
|
+
for (const [name, alias] of stated2) {
|
|
39995
|
+
if (alias === RECORD_INPUT) {
|
|
39996
|
+
inputs.push({ name, field: null });
|
|
39997
|
+
continue;
|
|
39998
|
+
}
|
|
39999
|
+
const field = fieldByAlias.get(alias);
|
|
40000
|
+
if (field === void 0) {
|
|
40001
|
+
findings.push({ severity: "error", path: at2, message: `sends "${alias}", which is not a field of entity "${entity.alias}"` });
|
|
40002
|
+
return void 0;
|
|
40003
|
+
}
|
|
40004
|
+
if (field.type === "files") {
|
|
40005
|
+
findings.push({ severity: "error", path: at2, message: `sends "${alias}", a files column \u2014 an input takes a value, and the body reads the row's files off the row it is handed` });
|
|
40006
|
+
return void 0;
|
|
40007
|
+
}
|
|
40008
|
+
inputs.push({ name, field });
|
|
40009
|
+
}
|
|
40010
|
+
return inputs;
|
|
40011
|
+
}
|
|
39780
40012
|
function resolveSelectionActs(path23, screen, entity, fieldByAlias, templates, findings) {
|
|
39781
40013
|
const declared = screen.acts?.selection;
|
|
39782
40014
|
if (declared === void 0) return [];
|
|
@@ -39789,14 +40021,15 @@ function resolveSelectionActs(path23, screen, entity, fieldByAlias, templates, f
|
|
|
39789
40021
|
});
|
|
39790
40022
|
return [];
|
|
39791
40023
|
}
|
|
39792
|
-
|
|
39793
|
-
|
|
39794
|
-
|
|
39795
|
-
|
|
39796
|
-
findings.push({
|
|
39797
|
-
|
|
39798
|
-
|
|
39799
|
-
|
|
40024
|
+
actDuplicates(declared, at2, "the selection bar", findings);
|
|
40025
|
+
for (const act of declared) {
|
|
40026
|
+
if (!("kind" in act)) continue;
|
|
40027
|
+
const verb = act.kind === "agent" ? `runs "${act.agent}"` : `hands the row to "${act.workflow}"`;
|
|
40028
|
+
findings.push({
|
|
40029
|
+
severity: "error",
|
|
40030
|
+
path: at2,
|
|
40031
|
+
message: `"${act.label}" ${verb}, which is about ONE row \u2014 the bar makes one paper from many, so a per-row verb belongs in the row's \u22EF`
|
|
40032
|
+
});
|
|
39800
40033
|
}
|
|
39801
40034
|
return declared.flatMap((act, index) => resolveAct(act, `${at2}.${index}`, screen, entity, fieldByAlias, templates, findings));
|
|
39802
40035
|
}
|
|
@@ -39837,7 +40070,7 @@ function resolveSummary(path23, screen, entity, entities, roles, slots, findings
|
|
|
39837
40070
|
const declared = screen.summary;
|
|
39838
40071
|
const fieldByAlias = new Map(entity.fields.map((field) => [field.alias, field]));
|
|
39839
40072
|
if (declared === void 0) {
|
|
39840
|
-
return { columnTotals: [], bandTotals: [], above: defaultAbove(screen, entity, entities, roles, slots), ageing: null };
|
|
40073
|
+
return { columnTotals: [], bandTotals: [], above: defaultAbove(screen, entity, entities, roles, slots), ageing: null, trend: null };
|
|
39841
40074
|
}
|
|
39842
40075
|
const figures = (aliases, at2) => aliases.flatMap((alias) => {
|
|
39843
40076
|
const field = fieldByAlias.get(alias);
|
|
@@ -39874,10 +40107,10 @@ function resolveSummary(path23, screen, entity, entities, roles, slots, findings
|
|
|
39874
40107
|
});
|
|
39875
40108
|
return false;
|
|
39876
40109
|
};
|
|
39877
|
-
const
|
|
40110
|
+
const named2 = declared.totals === void 0 || !bandSaid("totals", `${path23}.summary.totals`) ? [] : figures(declared.totals, `${path23}.summary.totals`);
|
|
39878
40111
|
const drawn = new Set(slots.flatMap((slot2) => slot2.field === null ? [] : [slot2.field.alias]));
|
|
39879
|
-
const columnTotals =
|
|
39880
|
-
const bandTotals =
|
|
40112
|
+
const columnTotals = named2.filter((field) => drawn.has(field.alias));
|
|
40113
|
+
const bandTotals = named2.filter((field) => !drawn.has(field.alias));
|
|
39881
40114
|
if (screen.shape !== CUSTOM_SHAPE && columnTotals.length > 0) {
|
|
39882
40115
|
findings.push({
|
|
39883
40116
|
severity: "error",
|
|
@@ -39903,7 +40136,32 @@ function resolveSummary(path23, screen, entity, entities, roles, slots, findings
|
|
|
39903
40136
|
};
|
|
39904
40137
|
const above = declared.above === void 0 ? defaultAbove(screen, entity, entities, roles, slots) : !bandSaid("above", `${path23}.summary.above`) ? null : declared.above === "counts" ? "counts" : fanned(`${path23}.summary.above`) ? null : bandFigures(declared.above, `${path23}.summary.above`, screen, figures, findings);
|
|
39905
40138
|
const ageing = declared.ageing !== void 0 && fanned(`${path23}.summary.ageing`) ? null : resolveAgeing(path23, declared.ageing, entity, entities, figures, bandSaid, findings);
|
|
39906
|
-
|
|
40139
|
+
const trend = declared.trend !== void 0 && fanned(`${path23}.summary.trend`) ? null : resolveTrend(path23, declared.trend, screen, figures, bandSaid, findings);
|
|
40140
|
+
return { columnTotals, bandTotals, above, ageing, trend };
|
|
40141
|
+
}
|
|
40142
|
+
function resolveTrend(path23, declared, screen, figures, bandSaid, findings) {
|
|
40143
|
+
if (declared === void 0) return null;
|
|
40144
|
+
const at2 = `${path23}.summary.trend`;
|
|
40145
|
+
const already2 = shapePeriod(screen.shape);
|
|
40146
|
+
if (already2 !== void 0) {
|
|
40147
|
+
findings.push({
|
|
40148
|
+
severity: "error",
|
|
40149
|
+
path: at2,
|
|
40150
|
+
message: already2 === "own" ? `a "${screen.shape}" draws its own series over its own period \u2014 a second reading of one window states the same movement twice` : `a "${screen.shape}" answers what is true NOW \u2014 it reads no window, so there is nothing for a movement to be across`
|
|
40151
|
+
});
|
|
40152
|
+
return null;
|
|
40153
|
+
}
|
|
40154
|
+
if (screen.period === void 0) {
|
|
40155
|
+
findings.push({
|
|
40156
|
+
severity: "error",
|
|
40157
|
+
path: at2,
|
|
40158
|
+
message: `reads "${declared.field}" across the window, and this screen states no \`period\` \u2014 there is no window to cut into buckets`
|
|
40159
|
+
});
|
|
40160
|
+
return null;
|
|
40161
|
+
}
|
|
40162
|
+
if (!bandSaid("above", at2)) return null;
|
|
40163
|
+
const field = figures([declared.field], at2)[0];
|
|
40164
|
+
return field === void 0 ? null : { field, direction: declared.direction };
|
|
39907
40165
|
}
|
|
39908
40166
|
function bandFigures(declared, at2, screen, figures, findings) {
|
|
39909
40167
|
return declared.flatMap((one) => {
|
|
@@ -40098,13 +40356,17 @@ function mirrorsOwnOneLink(entity, child, link) {
|
|
|
40098
40356
|
);
|
|
40099
40357
|
return back.length === 1 && own.length === 1 && own[0].cardinality === "one";
|
|
40100
40358
|
}
|
|
40101
|
-
function requiredSubset(entity, entities, roles, field) {
|
|
40359
|
+
function requiredSubset(entity, entities, roles, field, parent) {
|
|
40102
40360
|
const requiredBy = roleOf(roles, entity.alias, field.alias)?.required_by;
|
|
40103
40361
|
if (requiredBy === void 0) return void 0;
|
|
40104
|
-
const
|
|
40362
|
+
const from = requiredByFrom(field);
|
|
40363
|
+
if ((requiredBy.from ?? "own") !== from) return void 0;
|
|
40364
|
+
const deciding = from === "parent" ? parentEntity(entity, entities, roles) : entity;
|
|
40365
|
+
if (deciding === void 0 || from === "parent" && deciding.alias !== parent?.alias) return void 0;
|
|
40366
|
+
const condition = deciding.fields.find((candidate) => candidate.alias === requiredBy.field);
|
|
40105
40367
|
if (condition === void 0) return void 0;
|
|
40106
|
-
const reads = requiredByReads(condition,
|
|
40107
|
-
return reads === void 0 ? void 0 : { field: condition, reads, options: requiredBy.options };
|
|
40368
|
+
const reads = requiredByReads(condition, deciding, entities);
|
|
40369
|
+
return reads === void 0 ? void 0 : { field: condition, from, reads, options: requiredBy.options };
|
|
40108
40370
|
}
|
|
40109
40371
|
function owedRows(entity, roles, child, link) {
|
|
40110
40372
|
for (const field of entity.fields) {
|
|
@@ -40154,8 +40416,17 @@ function childThread(child, roles, via) {
|
|
|
40154
40416
|
...awaiting === void 0 ? {} : { awaiting }
|
|
40155
40417
|
};
|
|
40156
40418
|
}
|
|
40419
|
+
function threadShortOf(child, roles) {
|
|
40420
|
+
if (fieldsWithRole(child, roles, "identity").length > 0) return void 0;
|
|
40421
|
+
if (!child.fields.some((field) => field.type === "text" && field.format === "markdown")) return void 0;
|
|
40422
|
+
const missing = [
|
|
40423
|
+
{ name: 'a "party"', has: fieldsWithRole(child, roles, "party").length > 0 },
|
|
40424
|
+
{ name: 'a "when"', has: fieldsWithRole(child, roles, "when").length > 0 }
|
|
40425
|
+
].filter((one) => !one.has);
|
|
40426
|
+
return missing.length === 1 ? missing[0].name : void 0;
|
|
40427
|
+
}
|
|
40157
40428
|
function recordDeadline(entity, roles) {
|
|
40158
|
-
return fieldsWithRole(entity, roles, "obligation")[0] ?? fieldsWithRole(entity, roles, "when")
|
|
40429
|
+
return fieldsWithRole(entity, roles, "obligation")[0] ?? fieldsWithRole(entity, roles, "when").find((field) => countsDown(roleOf(roles, entity.alias, field.alias)));
|
|
40159
40430
|
}
|
|
40160
40431
|
function fileFields(entity, roles) {
|
|
40161
40432
|
const files = entity.fields.filter((field) => field.type === "files");
|
|
@@ -40169,9 +40440,9 @@ function filesVerdict(entity, roles, files) {
|
|
|
40169
40440
|
(field) => field.type === "formula" && parseFieldRefTokens(field.formula.expression).some((ref) => piles.has(ref))
|
|
40170
40441
|
);
|
|
40171
40442
|
}
|
|
40172
|
-
function recordKey(entity, roles,
|
|
40443
|
+
function recordKey(entity, roles, named2) {
|
|
40173
40444
|
const role = (field) => roleOf(roles, entity.alias, field.alias)?.role;
|
|
40174
|
-
const spare = entity.fields.filter((field) => field.alias !==
|
|
40445
|
+
const spare = entity.fields.filter((field) => field.alias !== named2 && role(field) !== "identity");
|
|
40175
40446
|
return spare.find((field) => role(field) === "parent") ?? spare.find((field) => role(field) === "reference") ?? spare.find((field) => field.type === "autonumber" && role(field) === void 0) ?? spare.find((field) => field.type === "formula" && formulaResultType(field.formula) === "text" && role(field) === void 0);
|
|
40176
40447
|
}
|
|
40177
40448
|
function recordHeader(screen, roles) {
|
|
@@ -40234,26 +40505,26 @@ function recordSections(entity, entities, roles, header, shape = CUSTOM_SHAPE, d
|
|
|
40234
40505
|
link,
|
|
40235
40506
|
setField,
|
|
40236
40507
|
filesField: fileFields(child, roles)[0],
|
|
40237
|
-
requiredBy: requiredSubset(child, entities, roles, setField)
|
|
40508
|
+
requiredBy: requiredSubset(child, entities, roles, setField, entity)
|
|
40238
40509
|
});
|
|
40239
40510
|
continue;
|
|
40240
40511
|
}
|
|
40241
|
-
const
|
|
40512
|
+
const byRole2 = CHILD_COLUMN_ROLES.flatMap((role) => fieldsWithRole(child, roles, role)).filter(
|
|
40242
40513
|
(field) => field.alias !== link.alias
|
|
40243
40514
|
);
|
|
40244
40515
|
const stops = childTimeline(child, entities, roles);
|
|
40245
40516
|
if (stops !== void 0) {
|
|
40246
|
-
children.push({ kind: "timeline", child, via, link, fields:
|
|
40517
|
+
children.push({ kind: "timeline", child, via, link, fields: byRole2, ...stops });
|
|
40247
40518
|
continue;
|
|
40248
40519
|
}
|
|
40249
40520
|
const said = childThread(child, roles, via);
|
|
40250
40521
|
if (said !== void 0) {
|
|
40251
40522
|
const clock = via === "parent" ? recordDeadline(entity, roles) : void 0;
|
|
40252
|
-
children.push({ kind: "thread", child, via, link, fields:
|
|
40523
|
+
children.push({ kind: "thread", child, via, link, fields: byRole2, ...said, ...clock === void 0 ? {} : { clock } });
|
|
40253
40524
|
continue;
|
|
40254
40525
|
}
|
|
40255
40526
|
const expected = owedRows(entity, roles, child, link);
|
|
40256
|
-
children.push({ kind: "children", child, via, link, fields:
|
|
40527
|
+
children.push({ kind: "children", child, via, link, fields: byRole2, ...expected === void 0 ? {} : { expected } });
|
|
40257
40528
|
}
|
|
40258
40529
|
}
|
|
40259
40530
|
}
|
|
@@ -40372,10 +40643,10 @@ function recordCharge(shape, entity, roles, door = recordDoor(shape, entity, rol
|
|
|
40372
40643
|
(token) => entity.fields.find((field) => field.alias === token)
|
|
40373
40644
|
);
|
|
40374
40645
|
if (operands.length !== 2) return void 0;
|
|
40375
|
-
const
|
|
40376
|
-
if (
|
|
40377
|
-
const priced =
|
|
40378
|
-
const counted =
|
|
40646
|
+
const named2 = operands.filter((field) => field !== void 0);
|
|
40647
|
+
if (named2.length !== 2) return void 0;
|
|
40648
|
+
const priced = named2.filter((field) => field.type === "number" && field.format === "currency");
|
|
40649
|
+
const counted = named2.filter((field) => field.type === "number" && field.format !== "currency");
|
|
40379
40650
|
if (priced.length !== 1 || counted.length !== 1) return void 0;
|
|
40380
40651
|
return { amount, quantity: counted[0], unitPrice: priced[0] };
|
|
40381
40652
|
}
|
|
@@ -40607,6 +40878,7 @@ function validateWorkspacePlan(model, roles, apps, rules = {}) {
|
|
|
40607
40878
|
findings.push({ severity: "error", path: "apps", message: `duplicate app name "${dup}"` });
|
|
40608
40879
|
}
|
|
40609
40880
|
const entityByAlias = new Map(model.entities.map((entity) => [entity.alias, entity]));
|
|
40881
|
+
const registered = new Set(apps.map((app) => app.screen.entity));
|
|
40610
40882
|
for (const app of apps) {
|
|
40611
40883
|
const byReach = /* @__PURE__ */ new Map();
|
|
40612
40884
|
for (const [reach, acts] of [
|
|
@@ -40641,8 +40913,11 @@ function validateWorkspacePlan(model, roles, apps, rules = {}) {
|
|
|
40641
40913
|
}
|
|
40642
40914
|
findings.push(...checkFactGroups(resolved.screen, model.entities, roles));
|
|
40643
40915
|
findings.push(...checkSectionActs(resolved.screen, model.entities, roles));
|
|
40916
|
+
findings.push(...checkSectionDraws(resolved.screen, model.entities, roles));
|
|
40917
|
+
findings.push(...checkChildRegisters(resolved.screen, model.entities, roles, registered));
|
|
40644
40918
|
findings.push(...checkDeskStages(resolved.screen));
|
|
40645
|
-
findings.push(...
|
|
40919
|
+
findings.push(...checkSeatedColumns(resolved.screen));
|
|
40920
|
+
findings.push(...checkPresentation(resolved.screen, roles, model.entities));
|
|
40646
40921
|
}
|
|
40647
40922
|
return findings;
|
|
40648
40923
|
}
|
|
@@ -40661,16 +40936,53 @@ function checkDeskStages(screen) {
|
|
|
40661
40936
|
}
|
|
40662
40937
|
];
|
|
40663
40938
|
}
|
|
40664
|
-
function
|
|
40939
|
+
function checkSeatedColumns(screen) {
|
|
40940
|
+
const context = screen.columns.length;
|
|
40941
|
+
const bySlots = screen.slots.filter((slot2) => slot2.field !== null && slot2.role !== "mark").length;
|
|
40942
|
+
if (context === 0 || bySlots + context <= SEATED_COLUMNS) return [];
|
|
40943
|
+
return [
|
|
40944
|
+
{
|
|
40945
|
+
severity: "note",
|
|
40946
|
+
path: `apps.${screen.app.alias}.screen.columns`,
|
|
40947
|
+
message: `adds ${context} column${context === 1 ? "" : "s"} to the ${bySlots} this ${screen.shapeLabel}'s slots already draw, and a register seats about ${SEATED_COLUMNS} at any width \u2014 the context is given up before a slot, so these draw only while the rows are short; leave a slot unbound or drop a column`
|
|
40948
|
+
}
|
|
40949
|
+
];
|
|
40950
|
+
}
|
|
40951
|
+
function drawnColumns(screen, roles) {
|
|
40952
|
+
return screen.slots.flatMap((slot2) => {
|
|
40953
|
+
if (slot2.field === null) return [];
|
|
40954
|
+
const counts = roleOf(roles, screen.entity.alias, slot2.field.alias)?.counts;
|
|
40955
|
+
return [{ role: slot2.role, ...counts === void 0 ? {} : { counts } }];
|
|
40956
|
+
});
|
|
40957
|
+
}
|
|
40958
|
+
function checkPresentation(screen, roles, entities) {
|
|
40665
40959
|
const stated2 = screen.screen.presentation;
|
|
40666
40960
|
if (stated2 === void 0) return [];
|
|
40667
40961
|
const path23 = `apps.${screen.app.alias}.screen.presentation`;
|
|
40668
|
-
const drawn =
|
|
40962
|
+
const drawn = drawnColumns(screen, roles);
|
|
40963
|
+
const held = new Set(drawn.map((column) => column.role));
|
|
40669
40964
|
return [
|
|
40670
|
-
...stated2.lead === void 0 ? [] : [leadRefusal(screen.screen.shape,
|
|
40671
|
-
...stated2.layout === void 0 ? [] : [layoutRefusal(drawn, stated2.layout)]
|
|
40965
|
+
...stated2.lead === void 0 ? [] : [leadRefusal(screen.screen.shape, held, stated2.lead)],
|
|
40966
|
+
...stated2.layout === void 0 ? [] : [layoutRefusal(drawn, stated2.layout)],
|
|
40967
|
+
...stated2.until === void 0 ? [] : [untilRefusal(screen, entities, drawn, stated2.until)]
|
|
40672
40968
|
].flatMap((message2) => message2 === void 0 ? [] : [{ severity: "error", path: path23, message: message2 }]);
|
|
40673
40969
|
}
|
|
40970
|
+
function untilRefusal(screen, entities, drawn, until) {
|
|
40971
|
+
if (!layoutsFor(drawn).includes("gantt")) {
|
|
40972
|
+
return `ends its bars at "${until}", and these rows cannot be drawn as a gantt \u2014 a bar needs a date to start on and a measure counted in days`;
|
|
40973
|
+
}
|
|
40974
|
+
const field = screen.entity.fields.find((candidate) => candidate.alias === until);
|
|
40975
|
+
if (field === void 0) return `ends its bars at "${until}", which is not a field of entity "${screen.entity.alias}"`;
|
|
40976
|
+
const resolved = resolvedFieldType(field, screen.entity, entities);
|
|
40977
|
+
if (resolved !== "date") {
|
|
40978
|
+
return `ends its bars at "${until}", a ${resolved ?? field.type} \u2014 a bar ends on a day`;
|
|
40979
|
+
}
|
|
40980
|
+
const start = screen.slots.find((slot2) => slot2.role === "when")?.field ?? null;
|
|
40981
|
+
if (start !== null && start.alias === until) {
|
|
40982
|
+
return `ends its bars at "${until}", which is the day they start on \u2014 a bar between one date and itself has no length`;
|
|
40983
|
+
}
|
|
40984
|
+
return void 0;
|
|
40985
|
+
}
|
|
40674
40986
|
function checkFactGroups(screen, entities, roles) {
|
|
40675
40987
|
if (screen.factGroups.length === 0) return [];
|
|
40676
40988
|
const path23 = `apps.${screen.app.alias}.screen.facts.groups`;
|
|
@@ -40688,6 +41000,93 @@ function checkFactGroups(screen, entities, roles) {
|
|
|
40688
41000
|
)
|
|
40689
41001
|
);
|
|
40690
41002
|
}
|
|
41003
|
+
function childRegisters(screen, entities, roles) {
|
|
41004
|
+
const sections = recordSections(screen.entity, entities, roles, recordHeader(screen, roles), screen.screen.shape, screen.record);
|
|
41005
|
+
return new Map(sections.flatMap((section) => section.kind === "children" ? [[section.child.alias, section]] : []));
|
|
41006
|
+
}
|
|
41007
|
+
function checkSectionDraws(screen, entities, roles) {
|
|
41008
|
+
const drawn = Object.entries(screen.screen.sections ?? {});
|
|
41009
|
+
if (drawn.length === 0) return [];
|
|
41010
|
+
const registers = childRegisters(screen, entities, roles);
|
|
41011
|
+
const takes = SHAPE_REGISTRY.worksheet.slots.sell.roles;
|
|
41012
|
+
return drawn.flatMap(([alias, clause]) => {
|
|
41013
|
+
const at2 = `apps.${screen.app.alias}.screen.sections.${alias}`;
|
|
41014
|
+
const section = registers.get(alias);
|
|
41015
|
+
if (section === void 0) {
|
|
41016
|
+
const offered = [...registers.keys()];
|
|
41017
|
+
return [
|
|
41018
|
+
{
|
|
41019
|
+
severity: "error",
|
|
41020
|
+
path: at2,
|
|
41021
|
+
message: offered.length === 0 ? `names "${alias}", and this record draws no register of a child's rows for a sheet to be worked down` : `names "${alias}", which this record draws no register of \u2014 its registers are addressed by ${offered.join(", ")}`
|
|
41022
|
+
}
|
|
41023
|
+
];
|
|
41024
|
+
}
|
|
41025
|
+
if (section.via !== "parent") {
|
|
41026
|
+
return [
|
|
41027
|
+
{
|
|
41028
|
+
severity: "error",
|
|
41029
|
+
path: at2,
|
|
41030
|
+
message: `names "${alias}", whose rows this record is merely the ${section.via} of \u2014 a sheet prices the lines a record OWNS, and a line added to a history belongs to the job it is about rather than to what is reading it`
|
|
41031
|
+
}
|
|
41032
|
+
];
|
|
41033
|
+
}
|
|
41034
|
+
const named2 = [["cost", clause.cost], ["sell", clause.sell]].flatMap(([slot2, field]) => field === void 0 ? [] : [{ slot: slot2, field }]);
|
|
41035
|
+
if (named2.length === 0) {
|
|
41036
|
+
return [
|
|
41037
|
+
{
|
|
41038
|
+
severity: "error",
|
|
41039
|
+
path: at2,
|
|
41040
|
+
message: "states neither a cost nor a sell \u2014 a sheet with no figure to work down is the register it is already drawn as"
|
|
41041
|
+
}
|
|
41042
|
+
];
|
|
41043
|
+
}
|
|
41044
|
+
const findings = named2.flatMap(({ slot: slot2, field }) => {
|
|
41045
|
+
const carried = section.child.fields.find((one) => one.alias === field);
|
|
41046
|
+
if (carried === void 0) {
|
|
41047
|
+
return [{ severity: "error", path: `${at2}.${slot2}`, message: `names "${field}", which is not a field of entity "${alias}"` }];
|
|
41048
|
+
}
|
|
41049
|
+
const role = roleOf(roles, alias, field)?.role;
|
|
41050
|
+
return role !== void 0 && takes.includes(role) ? [] : [
|
|
41051
|
+
{
|
|
41052
|
+
severity: "error",
|
|
41053
|
+
path: `${at2}.${slot2}`,
|
|
41054
|
+
message: `names "${field}", whose role is ${role === void 0 ? "not declared" : `"${role}"`} \u2014 a sheet's figures are ${takes.map((one) => `"${one}"`).join(" or ")} fields; declare the role in field_roles`
|
|
41055
|
+
}
|
|
41056
|
+
];
|
|
41057
|
+
});
|
|
41058
|
+
const both = clause.cost;
|
|
41059
|
+
if (findings.length === 0 && both !== void 0 && both === clause.sell) {
|
|
41060
|
+
return [
|
|
41061
|
+
{
|
|
41062
|
+
severity: "error",
|
|
41063
|
+
path: at2,
|
|
41064
|
+
message: `prices "${both}" as both its cost and its sell \u2014 the margin between a figure and itself is nothing, drawn down every line`
|
|
41065
|
+
}
|
|
41066
|
+
];
|
|
41067
|
+
}
|
|
41068
|
+
return findings;
|
|
41069
|
+
});
|
|
41070
|
+
}
|
|
41071
|
+
function unregisteredChildren(screen, entities, roles, registered) {
|
|
41072
|
+
const entries2 = recordRecipe(screen.screen.shape, screen.entity, entities, roles, recordHeader(screen, roles), screen.record);
|
|
41073
|
+
const priced = new Set(Object.keys(screen.screen.sections ?? {}));
|
|
41074
|
+
const read2 = new Set(
|
|
41075
|
+
entries2.flatMap(({ draw, section }) => {
|
|
41076
|
+
if (draw !== "open") return [];
|
|
41077
|
+
if (section.kind === "children" || section.kind === "timeline") return [section.child.alias];
|
|
41078
|
+
return section.kind === "expected_set" && section.source === "child" ? [section.child.alias] : [];
|
|
41079
|
+
})
|
|
41080
|
+
);
|
|
41081
|
+
return [...read2].filter((alias) => !registered.has(alias) && !priced.has(alias));
|
|
41082
|
+
}
|
|
41083
|
+
function checkChildRegisters(screen, entities, roles, registered) {
|
|
41084
|
+
return unregisteredChildren(screen, entities, roles, registered).map((alias) => ({
|
|
41085
|
+
severity: "note",
|
|
41086
|
+
path: `apps.${screen.app.alias}.screen`,
|
|
41087
|
+
message: `"${alias}" has no register \u2014 its rows are read here and created nowhere`
|
|
41088
|
+
}));
|
|
41089
|
+
}
|
|
40691
41090
|
function sectionActTargets(screen, entities, roles) {
|
|
40692
41091
|
const sections = recordSections(screen.entity, entities, roles, recordHeader(screen, roles), screen.screen.shape, screen.record);
|
|
40693
41092
|
const found = /* @__PURE__ */ new Map();
|
|
@@ -40722,7 +41121,7 @@ function checkSectionActs(screen, entities, roles) {
|
|
|
40722
41121
|
function reachableEntries(decl, field, entityRows) {
|
|
40723
41122
|
const all = new Set(field.type === "select" ? field.options.map((option) => option.alias) : []);
|
|
40724
41123
|
const requiredBy = decl.required_by;
|
|
40725
|
-
if (decl.role !== "expected_set" || requiredBy === void 0) return all;
|
|
41124
|
+
if (decl.role !== "expected_set" || requiredBy === void 0 || requiredByFrom(field) === "parent") return all;
|
|
40726
41125
|
const reachable = /* @__PURE__ */ new Set();
|
|
40727
41126
|
for (const row of entityRows) {
|
|
40728
41127
|
const said = row.fields[requiredBy.field];
|
|
@@ -40809,6 +41208,16 @@ function roleCoverage(model, roles, rows) {
|
|
|
40809
41208
|
message: `${section.child.label} hangs under ${entity.label} and declares no identity \u2014 its rows on the record have no name`
|
|
40810
41209
|
});
|
|
40811
41210
|
}
|
|
41211
|
+
if (section.kind === "children") {
|
|
41212
|
+
const short = threadShortOf(section.child, roles);
|
|
41213
|
+
if (short !== void 0) {
|
|
41214
|
+
notes.push({
|
|
41215
|
+
severity: "note",
|
|
41216
|
+
path: `field_roles.${section.child.alias}`,
|
|
41217
|
+
message: `${section.child.label} hangs under ${entity.label} and is ${short} short of a thread \u2014 its rows read as a register until it declares one`
|
|
41218
|
+
});
|
|
41219
|
+
}
|
|
41220
|
+
}
|
|
40812
41221
|
if (section.kind === "expected_set" && section.source === "child" && section.filesField === void 0) {
|
|
40813
41222
|
notes.push({
|
|
40814
41223
|
severity: "note",
|
|
@@ -40860,9 +41269,17 @@ function optionsWhereConditions(filter2, target) {
|
|
|
40860
41269
|
}
|
|
40861
41270
|
return out.length === 0 ? void 0 : out;
|
|
40862
41271
|
}
|
|
40863
|
-
var
|
|
40864
|
-
|
|
40865
|
-
);
|
|
41272
|
+
var lensRelativePointSchema = zod_default.object({ type: zod_default.literal("relative"), offset: zod_default.number().int(), unit: zod_default.enum(["days", "weeks", "months", "years"]) }).strict();
|
|
41273
|
+
function lensRelativePoint(value) {
|
|
41274
|
+
const read2 = lensRelativePointSchema.safeParse(value);
|
|
41275
|
+
return read2.success ? { offset: read2.data.offset, unit: read2.data.unit } : void 0;
|
|
41276
|
+
}
|
|
41277
|
+
var LENS_OPERATORS = {
|
|
41278
|
+
...Object.fromEntries(
|
|
41279
|
+
Object.entries(OPTIONS_WHERE_OPERATORS).map(([type, operators]) => [type, [...operators, "is_empty", "is_not_empty"]])
|
|
41280
|
+
),
|
|
41281
|
+
date: ["before", "after", "on_or_before", "on_or_after", "on", "is_empty", "is_not_empty"]
|
|
41282
|
+
};
|
|
40866
41283
|
function lensOperatorSentence() {
|
|
40867
41284
|
return Object.entries(LENS_OPERATORS).map(([type, operators]) => `${type} ${operators.join("/")}`).join("; ");
|
|
40868
41285
|
}
|
|
@@ -40886,6 +41303,9 @@ function lensConditions(filter2, entity, entities) {
|
|
|
40886
41303
|
if (!(LENS_OPERATORS[type] ?? []).includes(child.operator)) {
|
|
40887
41304
|
return `reading "${field.alias}" as a ${type} with "${child.operator}" \u2014 a lens is read off the row, so its operators are ${lensOperatorSentence()}`;
|
|
40888
41305
|
}
|
|
41306
|
+
if (type === "date" && child.operator !== "is_empty" && child.operator !== "is_not_empty" && lensRelativePoint(child.value) === void 0) {
|
|
41307
|
+
return `reading "${field.alias}" against a day this app would carry forever \u2014 a dated lens names a point relative to the reader's clock, as \`{ "type": "relative", "offset": 0, "unit": "days" }\``;
|
|
41308
|
+
}
|
|
40889
41309
|
out.push({ type, field, operator: child.operator, value: child.value });
|
|
40890
41310
|
}
|
|
40891
41311
|
return out.length === 0 ? `keeping every row \u2014 a predicate that narrows nothing is not a set` : out;
|
|
@@ -40998,12 +41418,12 @@ function checkWriteRules(entities, rules, roles) {
|
|
|
40998
41418
|
continue;
|
|
40999
41419
|
}
|
|
41000
41420
|
const declared = new Set(condition.field.options.map((option) => option.alias));
|
|
41001
|
-
for (const
|
|
41002
|
-
if (typeof
|
|
41421
|
+
for (const named2 of condition.value) {
|
|
41422
|
+
if (typeof named2 === "string" && declared.has(named2)) continue;
|
|
41003
41423
|
findings.push({
|
|
41004
41424
|
severity: "error",
|
|
41005
41425
|
path: at3,
|
|
41006
|
-
message: `names option "${String(
|
|
41426
|
+
message: `names option "${String(named2)}", which "${condition.field.label}" does not declare (${[...declared].join(", ")})`
|
|
41007
41427
|
});
|
|
41008
41428
|
}
|
|
41009
41429
|
}
|
|
@@ -41292,12 +41712,12 @@ function checkValue(field, value, path23, refs, errors) {
|
|
|
41292
41712
|
});
|
|
41293
41713
|
continue;
|
|
41294
41714
|
}
|
|
41295
|
-
const
|
|
41296
|
-
if (
|
|
41715
|
+
const named2 = raw.slice(0, raw.indexOf(":"));
|
|
41716
|
+
if (named2 === field.target_entity) continue;
|
|
41297
41717
|
errors.push({
|
|
41298
41718
|
severity: "error",
|
|
41299
41719
|
path: path23,
|
|
41300
|
-
message: `names a row of entity "${
|
|
41720
|
+
message: `names a row of entity "${named2}", but this field links to "${field.target_entity}"`
|
|
41301
41721
|
});
|
|
41302
41722
|
}
|
|
41303
41723
|
return;
|
|
@@ -41641,7 +42061,7 @@ function resultSideEffects(result) {
|
|
|
41641
42061
|
}
|
|
41642
42062
|
|
|
41643
42063
|
// src/version.ts
|
|
41644
|
-
var VERSION = "0.
|
|
42064
|
+
var VERSION = "0.208.0";
|
|
41645
42065
|
|
|
41646
42066
|
// src/timezone.ts
|
|
41647
42067
|
function machineTimezone() {
|
|
@@ -49957,9 +50377,9 @@ function declaredTable(aliased, key) {
|
|
|
49957
50377
|
(candidate) => candidate.table.id === key || candidate.alias === key || candidate.alias === slugifyAlias(key, true) || candidate.table.name === key
|
|
49958
50378
|
);
|
|
49959
50379
|
}
|
|
49960
|
-
function declaredField(table,
|
|
50380
|
+
function declaredField(table, named2) {
|
|
49961
50381
|
return table.fields.find(
|
|
49962
|
-
(candidate) => candidate.field.id ===
|
|
50382
|
+
(candidate) => candidate.field.id === named2 || candidate.alias === named2 || candidate.alias === slugifyAlias(named2, false) || candidate.field.name === named2
|
|
49963
50383
|
);
|
|
49964
50384
|
}
|
|
49965
50385
|
function carriedWrites(declaration, tables) {
|
|
@@ -50087,12 +50507,12 @@ function writeFindings(declaration, bodies, tables) {
|
|
|
50087
50507
|
}
|
|
50088
50508
|
const fields = /* @__PURE__ */ new Set();
|
|
50089
50509
|
for (const entry of entries2) {
|
|
50090
|
-
const
|
|
50091
|
-
const field = declaredField(table,
|
|
50510
|
+
const named2 = writtenField(entry);
|
|
50511
|
+
const field = declaredField(table, named2);
|
|
50092
50512
|
if (field === void 0) {
|
|
50093
50513
|
findings.push({
|
|
50094
50514
|
where: key,
|
|
50095
|
-
message: `package.json#lotics.writes declares ${key}.${
|
|
50515
|
+
message: `package.json#lotics.writes declares ${key}.${named2}, which ${table.table.name} does not carry \u2014 it was renamed or removed, so the declaration covers nothing.`
|
|
50096
50516
|
});
|
|
50097
50517
|
continue;
|
|
50098
50518
|
}
|
|
@@ -50104,15 +50524,15 @@ function writeFindings(declaration, bodies, tables) {
|
|
|
50104
50524
|
const labelById = new Map(aliased.map((table) => [table.table.id, table.table.name]));
|
|
50105
50525
|
const unknownTools = /* @__PURE__ */ new Set();
|
|
50106
50526
|
for (const body of bodies) {
|
|
50107
|
-
const
|
|
50527
|
+
const named2 = /* @__PURE__ */ new Set();
|
|
50108
50528
|
const scanned = bodyWrites(body.source);
|
|
50109
50529
|
for (const tool of scanned.unknownTools) unknownTools.add(tool);
|
|
50110
50530
|
for (const write of scanned.writes) {
|
|
50111
50531
|
const declared = write.table === null ? anyDeclared : byTable.get(write.table) ?? /* @__PURE__ */ new Set();
|
|
50112
50532
|
if (declared.has(write.field)) continue;
|
|
50113
50533
|
const fieldName2 = fieldLabel(aliased, write.table, write.field);
|
|
50114
|
-
if (
|
|
50115
|
-
|
|
50534
|
+
if (named2.has(fieldName2)) continue;
|
|
50535
|
+
named2.add(fieldName2);
|
|
50116
50536
|
const table = write.table === null ? null : labelById.get(write.table) ?? write.table;
|
|
50117
50537
|
findings.push({
|
|
50118
50538
|
where: body.alias,
|
|
@@ -50183,8 +50603,8 @@ function whereValue(at2, bound, condition, missing) {
|
|
|
50183
50603
|
missing.push(`${at2} narrows "${bound.label}" by no option at all \u2014 a select narrowing names the options it admits`);
|
|
50184
50604
|
return void 0;
|
|
50185
50605
|
}
|
|
50186
|
-
const
|
|
50187
|
-
const ids =
|
|
50606
|
+
const named2 = condition.value;
|
|
50607
|
+
const ids = named2.flatMap((option) => {
|
|
50188
50608
|
const live = typeof option === "string" ? bound.options?.get(option) : void 0;
|
|
50189
50609
|
if (live === void 0) {
|
|
50190
50610
|
missing.push(`${at2} names option "${String(option)}", which "${bound.label}" does not carry in this workspace`);
|
|
@@ -50192,7 +50612,7 @@ function whereValue(at2, bound, condition, missing) {
|
|
|
50192
50612
|
}
|
|
50193
50613
|
return [live.id];
|
|
50194
50614
|
});
|
|
50195
|
-
return ids.length ===
|
|
50615
|
+
return ids.length === named2.length ? { value: ids } : void 0;
|
|
50196
50616
|
}
|
|
50197
50617
|
var WORDS = {
|
|
50198
50618
|
plain: {
|
|
@@ -50287,8 +50707,9 @@ function childSurfaceScreen(parent, entry, mounts) {
|
|
|
50287
50707
|
tabs: null,
|
|
50288
50708
|
slots: [],
|
|
50289
50709
|
outcomes: {},
|
|
50710
|
+
columns: [],
|
|
50290
50711
|
acts: { row: [], selection: [], record: [], export: null, import: null },
|
|
50291
|
-
summary: { columnTotals: [], bandTotals: [], above: null, ageing: null },
|
|
50712
|
+
summary: { columnTotals: [], bandTotals: [], above: null, ageing: null, trend: null },
|
|
50292
50713
|
period: null,
|
|
50293
50714
|
filters: [],
|
|
50294
50715
|
obligations: [],
|
|
@@ -50479,6 +50900,12 @@ function namingField2(child, link, entities, roles) {
|
|
|
50479
50900
|
(field) => field.type === "formula" && roleOf(roles, child.alias, field.alias) === void 0 && resolvedFieldType(field, child, entities) === "text"
|
|
50480
50901
|
) ?? own("text") ?? own("select_record_link");
|
|
50481
50902
|
}
|
|
50903
|
+
function drawnColumns2(child, fields, roles) {
|
|
50904
|
+
return fields.filter((field) => {
|
|
50905
|
+
const role = roleOf(roles, child.alias, field.alias)?.role;
|
|
50906
|
+
return role !== void 0 && drawnAsColumn(role, field);
|
|
50907
|
+
});
|
|
50908
|
+
}
|
|
50482
50909
|
function withScope(own, scope) {
|
|
50483
50910
|
if (scope === void 0) return own;
|
|
50484
50911
|
if (own === void 0) return scope.filter;
|
|
@@ -50557,7 +50984,7 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
|
|
|
50557
50984
|
// A slot's own field, then the further fields an ORDERED role reads after
|
|
50558
50985
|
// it: they are the same slot, so they lead the entity's own list together.
|
|
50559
50986
|
...screen2.slots.flatMap(
|
|
50560
|
-
(slot2) => slot2.field === null ? [] : [...slot2.field === identity ? [] : [slot2.field], ...slot2.also]
|
|
50987
|
+
(slot2) => slot2.field === null ? [] : [...slot2.field === identity ? [] : [slot2.field], ...slot2.also, ...slot2.tiers]
|
|
50561
50988
|
),
|
|
50562
50989
|
...screen2.tabs === null ? [] : [screen2.tabs]
|
|
50563
50990
|
];
|
|
@@ -50587,21 +51014,16 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
|
|
|
50587
51014
|
...factLinkTargets(screen2, planned, entities, roles, owns).map((one) => ({ ...one, on: "fact" })),
|
|
50588
51015
|
...createLinkTargets(screen2, entities, roles, owns, under).map((one) => ({ ...one, on: "create" }))
|
|
50589
51016
|
];
|
|
50590
|
-
|
|
50591
|
-
const bound = links.get(field.alias);
|
|
50592
|
-
if (on === "create" && bound !== void 0) {
|
|
50593
|
-
createLinks.set(field.alias, bound);
|
|
50594
|
-
continue;
|
|
50595
|
-
}
|
|
51017
|
+
const bindPicker = (owner, field, target, display) => {
|
|
50596
51018
|
const scoped = new Set((target.read_scope?.any ?? []).flatMap((clause) => "field" in clause ? [clause.field] : []));
|
|
50597
51019
|
const declared = rules[target.alias]?.natural_key ?? [];
|
|
50598
51020
|
const keyFields = target.fields.filter((candidate) => declared.includes(candidate.alias));
|
|
50599
51021
|
const mintFields = mintedWith(target, display, keyFields);
|
|
50600
|
-
const narrowing2 = writeRuleOf(rules,
|
|
51022
|
+
const narrowing2 = writeRuleOf(rules, owner.alias, field.alias)?.options_where;
|
|
50601
51023
|
const conditions = narrowing2 === void 0 ? void 0 : optionsWhereConditions(narrowing2, target);
|
|
50602
51024
|
const sourceAliases = new Set(
|
|
50603
|
-
|
|
50604
|
-
const stated2 = writeRuleOf(rules,
|
|
51025
|
+
owner.fields.flatMap((candidate) => {
|
|
51026
|
+
const stated2 = writeRuleOf(rules, owner.alias, candidate.alias)?.default_from;
|
|
50605
51027
|
return stated2 === void 0 || !stated2.startsWith(`${field.alias}.`) ? [] : [stated2.slice(field.alias.length + 1)];
|
|
50606
51028
|
})
|
|
50607
51029
|
);
|
|
@@ -50615,22 +51037,22 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
|
|
|
50615
51037
|
...target.fields.filter((candidate) => scoped.has(candidate.alias))
|
|
50616
51038
|
];
|
|
50617
51039
|
const found = bindTable(`${at2}.${field.alias}`, target, entities, roles, wantedOnTarget, aliased, live, missing);
|
|
50618
|
-
if (found === void 0)
|
|
51040
|
+
if (found === void 0) return void 0;
|
|
50619
51041
|
const shown = found.fields.get(display.alias);
|
|
50620
|
-
if (shown === void 0)
|
|
51042
|
+
if (shown === void 0) return void 0;
|
|
50621
51043
|
const rule = scopeOf(target, found.fields);
|
|
50622
51044
|
const boundOf = (fields) => fields.flatMap((candidate) => {
|
|
50623
51045
|
const one = found.fields.get(candidate.alias);
|
|
50624
51046
|
return one === void 0 ? [] : [one];
|
|
50625
51047
|
});
|
|
50626
|
-
const narrowedAt = `write_rules.${
|
|
51048
|
+
const narrowedAt = `write_rules.${owner.alias}.fields.${field.alias}.options_where`;
|
|
50627
51049
|
const where = (conditions ?? []).flatMap((condition) => {
|
|
50628
51050
|
const one = found.fields.get(condition.field.alias);
|
|
50629
51051
|
if (one === void 0) return [];
|
|
50630
51052
|
const resolved = whereValue(narrowedAt, one, condition, missing);
|
|
50631
51053
|
return resolved === void 0 ? [] : [{ field: one, type: condition.type, operator: condition.operator, value: resolved.value }];
|
|
50632
51054
|
});
|
|
50633
|
-
|
|
51055
|
+
return {
|
|
50634
51056
|
alias: optionsQueryAlias(target.alias, display.alias),
|
|
50635
51057
|
param: OPTIONS_SEARCH_PARAM,
|
|
50636
51058
|
table: found.table,
|
|
@@ -50651,11 +51073,20 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
|
|
|
50651
51073
|
...rule === void 0 ? {} : { scope: rule },
|
|
50652
51074
|
...where.length === 0 ? {} : { where }
|
|
50653
51075
|
};
|
|
51076
|
+
};
|
|
51077
|
+
for (const { field, target, display, on } of linkTargets) {
|
|
51078
|
+
const bound = links.get(field.alias);
|
|
51079
|
+
if (on === "create" && bound !== void 0) {
|
|
51080
|
+
createLinks.set(field.alias, bound);
|
|
51081
|
+
continue;
|
|
51082
|
+
}
|
|
51083
|
+
const link = bindPicker(screen2.entity, field, target, display);
|
|
51084
|
+
if (link === void 0) continue;
|
|
50654
51085
|
if (on === "fact") links.set(field.alias, link);
|
|
50655
51086
|
createLinks.set(field.alias, link);
|
|
50656
51087
|
}
|
|
50657
51088
|
const children = /* @__PURE__ */ new Map();
|
|
50658
|
-
const bindChild = (entry2, drawn, set2, files,
|
|
51089
|
+
const bindChild = (entry2, drawn, set2, files, named2, reads = []) => {
|
|
50659
51090
|
const relation = sectionRelation(entry2.section);
|
|
50660
51091
|
if (relation === void 0) return;
|
|
50661
51092
|
const { slot: step, draw } = entry2;
|
|
@@ -50702,7 +51133,7 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
|
|
|
50702
51133
|
const boundField = found.fields.get(field.alias);
|
|
50703
51134
|
if (boundField === void 0) continue;
|
|
50704
51135
|
fields.set(field.alias, boundField);
|
|
50705
|
-
const role = field.alias ===
|
|
51136
|
+
const role = field.alias === named2?.alias ? "identity" : roleOf(roles, child.alias, field.alias)?.role;
|
|
50706
51137
|
if (role !== void 0) drawnRoles.set(field.alias, role);
|
|
50707
51138
|
}
|
|
50708
51139
|
const operands = /* @__PURE__ */ new Map([[linkField.alias, link]]);
|
|
@@ -50738,7 +51169,7 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
|
|
|
50738
51169
|
fields,
|
|
50739
51170
|
operands,
|
|
50740
51171
|
roles: drawnRoles,
|
|
50741
|
-
identity:
|
|
51172
|
+
identity: named2?.alias ?? child.fields.find((field) => roleOf(roles, child.alias, field.alias)?.role === "identity")?.alias,
|
|
50742
51173
|
set: set2?.alias,
|
|
50743
51174
|
files: files?.alias,
|
|
50744
51175
|
...itinerary === void 0 ? {} : { itinerary },
|
|
@@ -50758,22 +51189,19 @@ function bindScreen(screen, live, roles, entities, rules, groups = /* @__PURE__
|
|
|
50758
51189
|
continue;
|
|
50759
51190
|
}
|
|
50760
51191
|
if (section.kind === "children") {
|
|
50761
|
-
const drawn = section.fields
|
|
50762
|
-
const role = roleOf(roles, section.child.alias, field.alias)?.role;
|
|
50763
|
-
return role !== void 0 && drawnAsColumn(role, field);
|
|
50764
|
-
});
|
|
51192
|
+
const drawn = drawnColumns2(section.child, section.fields, roles);
|
|
50765
51193
|
if (drawn.length > 0) {
|
|
50766
51194
|
bindChild(entry2, drawn);
|
|
50767
51195
|
continue;
|
|
50768
51196
|
}
|
|
50769
|
-
const
|
|
50770
|
-
if (
|
|
51197
|
+
const named2 = namingField2(section.child, section.link, entities, roles);
|
|
51198
|
+
if (named2 === void 0) {
|
|
50771
51199
|
missing.push(
|
|
50772
51200
|
`${at2}.${section.child.alias}: its rows cannot be drawn \u2014 "${section.child.label}" declares no role and carries no reference, autonumber, text formula, text or link field beside "${section.link.label}"`
|
|
50773
51201
|
);
|
|
50774
51202
|
continue;
|
|
50775
51203
|
}
|
|
50776
|
-
bindChild(entry2, [
|
|
51204
|
+
bindChild(entry2, [named2], void 0, void 0, named2);
|
|
50777
51205
|
continue;
|
|
50778
51206
|
}
|
|
50779
51207
|
if (section.kind !== "expected_set" || section.source !== "child") continue;
|
|
@@ -50855,14 +51283,15 @@ function stated(entry, field) {
|
|
|
50855
51283
|
}
|
|
50856
51284
|
function factLinkTargets(screen, sections, entities, roles, owns) {
|
|
50857
51285
|
if (screen.screen.writes === false || !owns) return [];
|
|
50858
|
-
return sections.flatMap((section) => section.kind === "facts" ? section.fields : []).flatMap((field) =>
|
|
50859
|
-
|
|
50860
|
-
|
|
50861
|
-
|
|
50862
|
-
|
|
50863
|
-
|
|
50864
|
-
|
|
50865
|
-
|
|
51286
|
+
return sections.flatMap((section) => section.kind === "facts" ? section.fields : []).flatMap((field) => linkTarget2(field, entities, roles));
|
|
51287
|
+
}
|
|
51288
|
+
function linkTarget2(field, entities, roles) {
|
|
51289
|
+
if (field.type !== "select_record_link" || !isEditable(field)) return [];
|
|
51290
|
+
const target = entities.find((candidate) => candidate.alias === field.target_entity);
|
|
51291
|
+
if (target === void 0) return [];
|
|
51292
|
+
const named2 = field.display_field_aliases?.[0];
|
|
51293
|
+
const display = named2 === void 0 ? target.fields.find((candidate) => roleOf(roles, target.alias, candidate.alias)?.role === "identity") : target.fields.find((candidate) => candidate.alias === named2);
|
|
51294
|
+
return display === void 0 ? [] : [{ field, target, display }];
|
|
50866
51295
|
}
|
|
50867
51296
|
function parentLink(entity, roles) {
|
|
50868
51297
|
const field = entity.fields.find((candidate) => roleOf(roles, entity.alias, candidate.alias)?.role === "parent");
|
|
@@ -50870,14 +51299,7 @@ function parentLink(entity, roles) {
|
|
|
50870
51299
|
}
|
|
50871
51300
|
function createLinkTargets(screen, entities, roles, owns, under) {
|
|
50872
51301
|
if (screen.screen.writes === false || !owns) return [];
|
|
50873
|
-
return screen.entity.fields.flatMap((field) =>
|
|
50874
|
-
if (field.type !== "select_record_link" || !isEditable(field) || field.alias === under?.alias) return [];
|
|
50875
|
-
const target = entities.find((candidate) => candidate.alias === field.target_entity);
|
|
50876
|
-
if (target === void 0) return [];
|
|
50877
|
-
const named = field.display_field_aliases?.[0];
|
|
50878
|
-
const display = named === void 0 ? target.fields.find((candidate) => roleOf(roles, target.alias, candidate.alias)?.role === "identity") : target.fields.find((candidate) => candidate.alias === named);
|
|
50879
|
-
return display === void 0 ? [] : [{ field, target, display }];
|
|
50880
|
-
});
|
|
51302
|
+
return screen.entity.fields.flatMap((field) => field.alias === under?.alias ? [] : linkTarget2(field, entities, roles));
|
|
50881
51303
|
}
|
|
50882
51304
|
function mintedWith(target, display, keys2) {
|
|
50883
51305
|
const keyed = new Set(keys2.map((field) => field.alias));
|
|
@@ -51220,14 +51642,37 @@ var specFieldSchema = zod_default.object({
|
|
|
51220
51642
|
zeroWhenEmpty: zod_default.literal(true).optional(),
|
|
51221
51643
|
sign: specSignSchema.optional(),
|
|
51222
51644
|
level: specLevelSchema.optional(),
|
|
51223
|
-
|
|
51224
|
-
|
|
51645
|
+
/**
|
|
51646
|
+
* THIS DATE IS A DEADLINE — the row counts down to it and a row past it is
|
|
51647
|
+
* late. The MODEL's statement and the only one any surface reads: derived
|
|
51648
|
+
* from the row instead, the day a lead arrived read as overdue on every desk
|
|
51649
|
+
* it was drawn on. `until` says where the countdown stops.
|
|
51650
|
+
*/
|
|
51651
|
+
due: zod_default.literal(true).optional(),
|
|
51652
|
+
until: specUntilSchema.optional(),
|
|
51653
|
+
/**
|
|
51654
|
+
* WHAT THIS MEASURE'S NUMBER COUNTS, where the unit decides how a surface
|
|
51655
|
+
* DRAWS the row — days are what give a bar its length, and a figure with no
|
|
51656
|
+
* unit is drawn as the number it is.
|
|
51657
|
+
*/
|
|
51658
|
+
counts: zod_default.literal("days").optional()
|
|
51659
|
+
}).strict().check((ctx) => {
|
|
51660
|
+
if (ctx.value.until !== void 0 && ctx.value.due !== true) {
|
|
51661
|
+
ctx.issues.push({
|
|
51662
|
+
code: "custom",
|
|
51663
|
+
input: ctx.value,
|
|
51664
|
+
message: "`until` says where a countdown stops \u2014 declare `due: true` beside it, or drop it"
|
|
51665
|
+
});
|
|
51666
|
+
}
|
|
51667
|
+
});
|
|
51225
51668
|
var specFieldsSchema = zod_default.record(specColumnAliasSchema, specFieldSchema);
|
|
51226
51669
|
var specPresentationSchema = zod_default.object({
|
|
51227
51670
|
lead: presentationLeadSchema.optional(),
|
|
51228
51671
|
density: presentationDensitySchema.optional(),
|
|
51229
51672
|
/** How the rows are ARRANGED — the same rows and the same slots in a different geometry. */
|
|
51230
|
-
layout: screenLayoutSchema.optional()
|
|
51673
|
+
layout: screenLayoutSchema.optional(),
|
|
51674
|
+
/** WHERE EACH BAR ENDS on a gantt — the column holding the day it is done, rather than the span it runs. */
|
|
51675
|
+
until: specColumnAliasSchema.optional()
|
|
51231
51676
|
}).strict();
|
|
51232
51677
|
var specActPlaceSchema = zod_default.literal("cta");
|
|
51233
51678
|
var specTemplateActSchema = zod_default.object({
|
|
@@ -51267,7 +51712,23 @@ var specAgentActSchema = zod_default.object({
|
|
|
51267
51712
|
fills: zod_default.array(specColumnAliasSchema).min(1),
|
|
51268
51713
|
place: specActPlaceSchema.optional()
|
|
51269
51714
|
}).strict();
|
|
51270
|
-
var
|
|
51715
|
+
var RECORD_ID_COLUMN = "__source_record_id";
|
|
51716
|
+
var specActInputSchema = zod_default.object({
|
|
51717
|
+
name: zod_default.string().min(1),
|
|
51718
|
+
field: specColumnAliasSchema
|
|
51719
|
+
}).strict();
|
|
51720
|
+
var specWorkflowActSchema = zod_default.object({
|
|
51721
|
+
kind: zod_default.literal("workflow"),
|
|
51722
|
+
label: zod_default.string().min(1),
|
|
51723
|
+
/** The act's own key, stable across renders — the workflow's alias. */
|
|
51724
|
+
key: contractAliasSchema,
|
|
51725
|
+
/** The alias `package.json#lotics.workflows` binds this body under. */
|
|
51726
|
+
workflow: runtimeAliasSchema,
|
|
51727
|
+
/** What the press hands the run, in the order the act named them. */
|
|
51728
|
+
inputs: zod_default.array(specActInputSchema).min(1),
|
|
51729
|
+
place: specActPlaceSchema.optional()
|
|
51730
|
+
}).strict();
|
|
51731
|
+
var specActSchema = zod_default.discriminatedUnion("kind", [specTemplateActSchema, specAgentActSchema, specWorkflowActSchema]);
|
|
51271
51732
|
var specColumnsExportSchema = zod_default.object({
|
|
51272
51733
|
kind: zod_default.literal("columns"),
|
|
51273
51734
|
/** Each drawn column, in the order the register draws them: the sheet's heading, and the column it reads. */
|
|
@@ -51305,13 +51766,23 @@ var specSlotSchema = zod_default.object({
|
|
|
51305
51766
|
* rest states it on every line rather than drawing a blank on most.
|
|
51306
51767
|
*/
|
|
51307
51768
|
also: zod_default.array(specColumnAliasSchema).min(1).optional(),
|
|
51769
|
+
/**
|
|
51770
|
+
* THE TIERS UNDER THE ONE IN `field`, outermost first — a hierarchy the slot
|
|
51771
|
+
* folds by rather than second homes for one value.
|
|
51772
|
+
*
|
|
51773
|
+
* Two lists and not one, because they are two readings: `also` is read
|
|
51774
|
+
* ACROSS, one value wherever the row happens to state it, and this is read
|
|
51775
|
+
* DOWN, a narrower question at each depth.
|
|
51776
|
+
*/
|
|
51777
|
+
tiers: zod_default.array(specColumnAliasSchema).min(1).optional(),
|
|
51308
51778
|
/**
|
|
51309
51779
|
* THE COLUMN IS THE READER'S OWN CONTROL FOR THIS VALUE — pressed, it writes
|
|
51310
51780
|
* that single field as a diff through the record's update, blockers and an
|
|
51311
51781
|
* outcome's confirm included.
|
|
51312
51782
|
*
|
|
51313
51783
|
* Only where the decision is one a reader takes FROM THE ROW ALONE, which is
|
|
51314
|
-
* why `app check` refuses it on every role but `lifecycle` and
|
|
51784
|
+
* why `app check` refuses it on every role but `lifecycle`, `verdict` and
|
|
51785
|
+
* `measure` (`quickRefusal`, the one rule both the plan and the spec ask).
|
|
51315
51786
|
*/
|
|
51316
51787
|
quick: zod_default.literal(true).optional(),
|
|
51317
51788
|
/** A quick lifecycle whose stages are a WALK — the cell advances to the next rather than offering them all. */
|
|
@@ -51339,11 +51810,18 @@ var specSummarySchema = zod_default.object({
|
|
|
51339
51810
|
/** Ascending edges in days past due; the last is open-ended. */
|
|
51340
51811
|
buckets: zod_default.array(zod_default.number().int().positive()).min(1)
|
|
51341
51812
|
}).strict().optional(),
|
|
51342
|
-
directions: specDirectionsSchema.optional()
|
|
51813
|
+
directions: specDirectionsSchema.optional(),
|
|
51814
|
+
/**
|
|
51815
|
+
* WHICH WAY THE FIGURE MOVED ACROSS THE WINDOW — read over the screen's own
|
|
51816
|
+
* `period`, which the plan refuses this clause without. `direction` is which
|
|
51817
|
+
* way is GOOD: the arrow follows the change, the colour follows this.
|
|
51818
|
+
*/
|
|
51819
|
+
trend: zod_default.object({ field: specColumnAliasSchema, direction: zod_default.enum(["up", "down"]) }).strict().optional()
|
|
51343
51820
|
}).strict();
|
|
51821
|
+
var specRelativePointSchema = zod_default.object({ offset: zod_default.number().int(), unit: zod_default.enum(["days", "weeks", "months", "years"]) }).strict();
|
|
51344
51822
|
var specConditionSchema = zod_default.object({
|
|
51345
51823
|
field: specColumnAliasSchema,
|
|
51346
|
-
type: zod_default.enum(["number", "text", "boolean", "select"]),
|
|
51824
|
+
type: zod_default.enum(["number", "text", "boolean", "select", "date"]),
|
|
51347
51825
|
operator: zod_default.enum([
|
|
51348
51826
|
"equals",
|
|
51349
51827
|
"not_equals",
|
|
@@ -51353,11 +51831,20 @@ var specConditionSchema = zod_default.object({
|
|
|
51353
51831
|
"less_than_or_equal_to",
|
|
51354
51832
|
"has_any_of",
|
|
51355
51833
|
"has_none_of",
|
|
51834
|
+
"before",
|
|
51835
|
+
"after",
|
|
51836
|
+
"on_or_before",
|
|
51837
|
+
"on_or_after",
|
|
51838
|
+
"on",
|
|
51356
51839
|
"is_empty",
|
|
51357
51840
|
"is_not_empty"
|
|
51358
51841
|
]),
|
|
51359
|
-
/**
|
|
51360
|
-
|
|
51842
|
+
/**
|
|
51843
|
+
* What the operator compares against — absent where it compares against
|
|
51844
|
+
* nothing. A DATE's is an offset from today and never a day: a lens written
|
|
51845
|
+
* as a date is a set that stops meaning what it said tomorrow.
|
|
51846
|
+
*/
|
|
51847
|
+
value: zod_default.union([zod_default.number(), zod_default.string(), zod_default.boolean(), zod_default.array(zod_default.string()), specRelativePointSchema]).optional()
|
|
51361
51848
|
}).strict();
|
|
51362
51849
|
var specPredicateSchema = zod_default.object({
|
|
51363
51850
|
label: zod_default.string().min(1),
|
|
@@ -51372,14 +51859,24 @@ var specLensSchema = zod_default.union([
|
|
|
51372
51859
|
var specObligationSchema = zod_default.object({
|
|
51373
51860
|
label: zod_default.string().min(1),
|
|
51374
51861
|
due: specColumnAliasSchema,
|
|
51375
|
-
/**
|
|
51376
|
-
|
|
51862
|
+
/**
|
|
51863
|
+
* Filled, this obligation is met and its row leaves the desk — absent where
|
|
51864
|
+
* the business stamps nothing and the LIFECYCLE ends it instead, which the
|
|
51865
|
+
* `due` column's own `until` says and the same read decides.
|
|
51866
|
+
*/
|
|
51867
|
+
satisfiedBy: specColumnAliasSchema.optional()
|
|
51377
51868
|
}).strict();
|
|
51378
51869
|
var specScreenSchema = zod_default.object({
|
|
51379
51870
|
alias: contractAliasSchema,
|
|
51380
51871
|
label: zod_default.string().min(1),
|
|
51381
51872
|
/** The entity this screen's rows are of — the key of its record in `records`. */
|
|
51382
51873
|
entity: contractAliasSchema,
|
|
51874
|
+
/**
|
|
51875
|
+
* WHAT A SET OF THESE ROWS IS CALLED — the entity's own noun, for the column
|
|
51876
|
+
* a shape heads with a count. Optional: an app deployed before this clause
|
|
51877
|
+
* carries none and the frame's own word stands in.
|
|
51878
|
+
*/
|
|
51879
|
+
rows: zod_default.string().min(1).optional(),
|
|
51383
51880
|
table: specTableIdSchema,
|
|
51384
51881
|
shape: specShapeSchema,
|
|
51385
51882
|
/** How one record opens from the list — a page, a drawer, or revealed in the row itself. */
|
|
@@ -51390,6 +51887,16 @@ var specScreenSchema = zod_default.object({
|
|
|
51390
51887
|
writes: zod_default.boolean(),
|
|
51391
51888
|
fields: specFieldsSchema,
|
|
51392
51889
|
slots: zod_default.array(specSlotSchema),
|
|
51890
|
+
/**
|
|
51891
|
+
* THE CONTEXT COLUMNS — extra facts this register draws AFTER its slots, in
|
|
51892
|
+
* the plan's order, each by what its column IS rather than by a role.
|
|
51893
|
+
*
|
|
51894
|
+
* They carry no rank: the frame ranks every one of them beneath every column
|
|
51895
|
+
* the shape drew, so a width that cannot seat the row sheds the context
|
|
51896
|
+
* before the answer. Bounded here as well as in the plan, because a spec is
|
|
51897
|
+
* also written by hand.
|
|
51898
|
+
*/
|
|
51899
|
+
columns: zod_default.array(specColumnAliasSchema).min(1).max(CONTEXT_COLUMNS).optional(),
|
|
51393
51900
|
/**
|
|
51394
51901
|
* For each lifecycle this screen draws, the options that END the flow. A row
|
|
51395
51902
|
* in one has ARRIVED, so the ladder draws it beside the flow rather than as
|
|
@@ -51454,16 +51961,21 @@ var specContactSchema = zod_default.discriminatedUnion("kind", [
|
|
|
51454
51961
|
reach: zod_default.enum(["email", "phone", "place", "link", "handle"])
|
|
51455
51962
|
}).strict()
|
|
51456
51963
|
]);
|
|
51964
|
+
var requiredByFrom2 = {
|
|
51965
|
+
from: zod_default.literal("parent").optional()
|
|
51966
|
+
};
|
|
51457
51967
|
var specRequiredBySchema = zod_default.discriminatedUnion("reads", [
|
|
51458
51968
|
zod_default.object({
|
|
51459
51969
|
reads: zod_default.literal("option"),
|
|
51460
51970
|
field: specColumnAliasSchema,
|
|
51971
|
+
...requiredByFrom2,
|
|
51461
51972
|
/** The conditioning option → the entries required under it. */
|
|
51462
51973
|
options: zod_default.record(specOptionIdSchema, zod_default.array(specOptionIdSchema).min(1))
|
|
51463
51974
|
}).strict(),
|
|
51464
51975
|
zod_default.object({
|
|
51465
51976
|
reads: zod_default.literal("answer"),
|
|
51466
51977
|
field: specColumnAliasSchema,
|
|
51978
|
+
...requiredByFrom2,
|
|
51467
51979
|
/**
|
|
51468
51980
|
* The row's own answer → the entries required under it. PARTIAL, because
|
|
51469
51981
|
* an answer that owes nothing is omitted rather than named with an empty
|
|
@@ -51498,6 +52010,24 @@ var specItinerarySchema = zod_default.object({
|
|
|
51498
52010
|
/** The closing line's label, where the record's band does not already state that sum. */
|
|
51499
52011
|
total: zod_default.string().min(1).optional()
|
|
51500
52012
|
}).strict();
|
|
52013
|
+
var specWorksheetSchema = zod_default.object({
|
|
52014
|
+
/** HOW MANY the line is for — never summed, because two units have no sum. */
|
|
52015
|
+
quantity: specColumnAliasSchema.optional(),
|
|
52016
|
+
/** What the line COSTS — the base the margin is taken against. */
|
|
52017
|
+
cost: specColumnAliasSchema.optional(),
|
|
52018
|
+
/** What it SELLS for — the figure the sheet closes on. */
|
|
52019
|
+
sell: specColumnAliasSchema.optional(),
|
|
52020
|
+
/** The part of the job each line falls under, and the run its own foot closes. */
|
|
52021
|
+
group: specColumnAliasSchema.optional()
|
|
52022
|
+
}).strict().check((ctx) => {
|
|
52023
|
+
if (ctx.value.cost === void 0 && ctx.value.sell === void 0) {
|
|
52024
|
+
ctx.issues.push({
|
|
52025
|
+
code: "custom",
|
|
52026
|
+
input: ctx.value,
|
|
52027
|
+
message: "states neither a cost nor a sell \u2014 a sheet works one of the two down"
|
|
52028
|
+
});
|
|
52029
|
+
}
|
|
52030
|
+
});
|
|
51501
52031
|
var specChildSchema = zod_default.object({
|
|
51502
52032
|
entity: contractAliasSchema,
|
|
51503
52033
|
table: specTableIdSchema,
|
|
@@ -51537,6 +52067,7 @@ var specChildSchema = zod_default.object({
|
|
|
51537
52067
|
*/
|
|
51538
52068
|
write: zod_default.object({ alias: runtimeAliasSchema, param: zod_default.string().min(1) }).strict().optional()
|
|
51539
52069
|
}).strict();
|
|
52070
|
+
var specPricedChildSchema = specChildSchema.extend({ worksheet: specWorksheetSchema });
|
|
51540
52071
|
var sectionActs = { acts: zod_default.array(specActSchema).min(1).optional() };
|
|
51541
52072
|
var specSectionSchema = zod_default.discriminatedUnion("kind", [
|
|
51542
52073
|
zod_default.object({ kind: zod_default.literal("facts"), key: zod_default.string().min(1), groups: zod_default.array(specFactGroupSchema), ...sectionActs }).strict(),
|
|
@@ -51558,16 +52089,39 @@ var specSectionSchema = zod_default.discriminatedUnion("kind", [
|
|
|
51558
52089
|
...sectionActs
|
|
51559
52090
|
}).strict(),
|
|
51560
52091
|
/** A child whose rows carry one entry of the set each — a document desk. */
|
|
51561
|
-
zod_default.object({ kind: zod_default.literal("desk"), key: zod_default.string().min(1), heading: zod_default.string().min(1), child: specChildSchema, ...sectionActs }).strict(),
|
|
51562
|
-
/** The record's own rows, as a register, a run, or a book of movements. */
|
|
51563
52092
|
zod_default.object({
|
|
51564
|
-
kind: zod_default.literal("
|
|
52093
|
+
kind: zod_default.literal("desk"),
|
|
51565
52094
|
key: zod_default.string().min(1),
|
|
51566
52095
|
heading: zod_default.string().min(1),
|
|
51567
|
-
draw: zod_default.enum(["register", "itinerary", "ledger"]),
|
|
51568
52096
|
child: specChildSchema,
|
|
52097
|
+
/**
|
|
52098
|
+
* WHICH ENTRIES THIS RECORD OWES, where its own column decides them —
|
|
52099
|
+
* read off the record's row, because how many documents an order needs is
|
|
52100
|
+
* a fact about the order and no paper states it.
|
|
52101
|
+
*/
|
|
52102
|
+
requiredBy: specRequiredBySchema.optional(),
|
|
51569
52103
|
...sectionActs
|
|
51570
52104
|
}).strict(),
|
|
52105
|
+
/** The record's own rows, as a register, a run, a book of movements, or a priced sheet. */
|
|
52106
|
+
zod_default.discriminatedUnion("draw", [
|
|
52107
|
+
zod_default.object({
|
|
52108
|
+
kind: zod_default.literal("children"),
|
|
52109
|
+
key: zod_default.string().min(1),
|
|
52110
|
+
heading: zod_default.string().min(1),
|
|
52111
|
+
draw: zod_default.enum(["register", "itinerary", "ledger"]),
|
|
52112
|
+
child: specChildSchema,
|
|
52113
|
+
...sectionActs
|
|
52114
|
+
}).strict(),
|
|
52115
|
+
/** THE SHEET AND ITS FIGURES ARE ONE CLAUSE — neither is declarable without the other. */
|
|
52116
|
+
zod_default.object({
|
|
52117
|
+
kind: zod_default.literal("children"),
|
|
52118
|
+
key: zod_default.string().min(1),
|
|
52119
|
+
heading: zod_default.string().min(1),
|
|
52120
|
+
draw: zod_default.literal("worksheet"),
|
|
52121
|
+
child: specPricedChildSchema,
|
|
52122
|
+
...sectionActs
|
|
52123
|
+
}).strict()
|
|
52124
|
+
]),
|
|
51571
52125
|
/**
|
|
51572
52126
|
* WHERE THE RECORD IS AGAINST WHERE IT SHOULD BE — a child whose rows are the
|
|
51573
52127
|
* ordered stops, each carrying the day it was promised for and the day it
|
|
@@ -51820,9 +52374,9 @@ function specActSites(spec) {
|
|
|
51820
52374
|
function specAllActs(spec) {
|
|
51821
52375
|
return specActSites(spec).map((site) => site.act);
|
|
51822
52376
|
}
|
|
51823
|
-
function actsPointedAt(spec,
|
|
52377
|
+
function actsPointedAt(spec, named2) {
|
|
51824
52378
|
const paper = (at2, act) => {
|
|
51825
|
-
const component =
|
|
52379
|
+
const component = named2.get(at2);
|
|
51826
52380
|
return component === void 0 ? act : { ...act, component };
|
|
51827
52381
|
};
|
|
51828
52382
|
const point = (at2, act) => act.kind === "template" ? paper(at2, act) : act;
|
|
@@ -51861,9 +52415,11 @@ function specWorkflowAliases(spec) {
|
|
|
51861
52415
|
const taken = spec.screen.acts?.import;
|
|
51862
52416
|
return [
|
|
51863
52417
|
.../* @__PURE__ */ new Set([
|
|
51864
|
-
//
|
|
51865
|
-
//
|
|
51866
|
-
|
|
52418
|
+
// THE TWO ARMS THAT NAME A BODY — a paper the one the generator wrote, a
|
|
52419
|
+
// hand-off the one the author binds, both called by alias from the same
|
|
52420
|
+
// press. An agent act lands the record's own update, which `record.write`
|
|
52421
|
+
// below already declares.
|
|
52422
|
+
...specAllActs(spec).flatMap((act) => act.kind === "template" || act.kind === "workflow" ? [act.workflow] : []),
|
|
51867
52423
|
...taken === void 0 ? [] : [taken.workflow],
|
|
51868
52424
|
...Object.values(spec.records).flatMap((record2) => [
|
|
51869
52425
|
...record2.write === void 0 ? [] : [record2.write.alias],
|
|
@@ -52102,6 +52658,43 @@ function sourceOnTarget(link, alias) {
|
|
|
52102
52658
|
return link.sources.get(alias);
|
|
52103
52659
|
}
|
|
52104
52660
|
|
|
52661
|
+
// src/plan_worksheets.ts
|
|
52662
|
+
function byRole(child, role, taken) {
|
|
52663
|
+
for (const [alias, bound] of child.fields) {
|
|
52664
|
+
if (child.roles.get(alias) === role && !taken.has(alias)) return { alias, bound };
|
|
52665
|
+
}
|
|
52666
|
+
return void 0;
|
|
52667
|
+
}
|
|
52668
|
+
function named(child, alias) {
|
|
52669
|
+
const bound = alias === void 0 ? void 0 : child.fields.get(alias);
|
|
52670
|
+
return alias === void 0 || bound === void 0 ? void 0 : { alias, bound };
|
|
52671
|
+
}
|
|
52672
|
+
function planWorksheets(entry) {
|
|
52673
|
+
const drawn = entry.screen.screen.sections ?? {};
|
|
52674
|
+
return entry.sections.flatMap(({ draw, section }) => {
|
|
52675
|
+
if (draw === "related" || section.kind !== "children" || section.via !== "parent") return [];
|
|
52676
|
+
const clause = drawn[section.child.alias];
|
|
52677
|
+
if (clause === void 0) return [];
|
|
52678
|
+
const child = entry.children.get(childKey(section.child.alias, section.link.alias));
|
|
52679
|
+
if (child === void 0) return [];
|
|
52680
|
+
const cost = named(child, clause.cost);
|
|
52681
|
+
const sell = named(child, clause.sell);
|
|
52682
|
+
const priced = new Set([cost, sell].flatMap((figure) => figure === void 0 ? [] : [figure.alias]));
|
|
52683
|
+
const quantity = byRole(child, "measure", priced);
|
|
52684
|
+
const group = byRole(child, "category", priced);
|
|
52685
|
+
return [
|
|
52686
|
+
{
|
|
52687
|
+
child,
|
|
52688
|
+
key: `${child.entity.alias}_${child.linkAlias}`,
|
|
52689
|
+
...quantity === void 0 ? {} : { quantity },
|
|
52690
|
+
...cost === void 0 ? {} : { cost },
|
|
52691
|
+
...sell === void 0 ? {} : { sell },
|
|
52692
|
+
...group === void 0 ? {} : { group }
|
|
52693
|
+
}
|
|
52694
|
+
];
|
|
52695
|
+
});
|
|
52696
|
+
}
|
|
52697
|
+
|
|
52105
52698
|
// src/plan_record_facts.ts
|
|
52106
52699
|
function factOrder(screen, roles, fields) {
|
|
52107
52700
|
const slots = new Set(screen.slots.flatMap((slot2) => slot2.field === null ? [] : [slot2.field.alias]));
|
|
@@ -52130,8 +52723,8 @@ function factsPlan(screen, roles, fields) {
|
|
|
52130
52723
|
if (screen.factGroups.length === 0) return derived(fields);
|
|
52131
52724
|
const own = new Set(fields.map((field) => field.alias));
|
|
52132
52725
|
const claimed = new Set(screen.factGroups.flatMap((group) => group.fields.map((field) => field.alias)));
|
|
52133
|
-
const
|
|
52134
|
-
return [...
|
|
52726
|
+
const named2 = screen.factGroups.map((group) => ({ caption: group.caption, fields: group.fields.filter((field) => own.has(field.alias)) }));
|
|
52727
|
+
return [...named2.filter((group) => group.fields.length > 0), ...derived(fields.filter((field) => !claimed.has(field.alias)))];
|
|
52135
52728
|
}
|
|
52136
52729
|
|
|
52137
52730
|
// src/plan_record_children.ts
|
|
@@ -52156,8 +52749,8 @@ var surfaceOf = (fields) => ({
|
|
|
52156
52749
|
ref: (alias) => fields.get(alias)?.alias
|
|
52157
52750
|
});
|
|
52158
52751
|
var childReads = (child) => child.surface?.fields ?? new Map([...child.fields, ...child.operands]);
|
|
52159
|
-
function optionIds(field,
|
|
52160
|
-
return (
|
|
52752
|
+
function optionIds(field, named2) {
|
|
52753
|
+
return (named2 ?? []).flatMap((alias) => {
|
|
52161
52754
|
const live = field?.options?.get(alias);
|
|
52162
52755
|
return live === void 0 ? [] : [live.id];
|
|
52163
52756
|
});
|
|
@@ -52187,7 +52780,7 @@ function untilOf(entry, roles, field) {
|
|
|
52187
52780
|
return lifecycle === void 0 || settled.length === 0 ? void 0 : { lifecycle: lifecycle.alias, settled };
|
|
52188
52781
|
}
|
|
52189
52782
|
function specField(bound, declared, extras = {}) {
|
|
52190
|
-
const multi = declared
|
|
52783
|
+
const multi = declared !== void 0 && fieldHoldsSeveral(declared);
|
|
52191
52784
|
const money = bound.money === void 0 ? void 0 : {
|
|
52192
52785
|
...bound.money.currency === void 0 ? {} : { currency: bound.money.currency },
|
|
52193
52786
|
...bound.money.row === void 0 ? {} : { row: bound.money.row }
|
|
@@ -52207,7 +52800,9 @@ function specField(bound, declared, extras = {}) {
|
|
|
52207
52800
|
...bound.zeroWhenEmpty === true ? { zeroWhenEmpty: true } : {},
|
|
52208
52801
|
...extras.sign === void 0 ? {} : { sign: extras.sign },
|
|
52209
52802
|
...extras.level === void 0 ? {} : { level: extras.level },
|
|
52210
|
-
...extras.
|
|
52803
|
+
...extras.due === void 0 ? {} : { due: extras.due },
|
|
52804
|
+
...extras.until === void 0 ? {} : { until: extras.until },
|
|
52805
|
+
...extras.counts === void 0 ? {} : { counts: extras.counts }
|
|
52211
52806
|
};
|
|
52212
52807
|
}
|
|
52213
52808
|
function specFields(fields, declared, extras = () => ({})) {
|
|
@@ -52225,11 +52820,21 @@ function screenExtras(entry, roles) {
|
|
|
52225
52820
|
const declared = new Map(entry.screen.entity.fields.map((field) => [field.alias, field]));
|
|
52226
52821
|
return (modelAlias) => {
|
|
52227
52822
|
const field = declared.get(modelAlias);
|
|
52228
|
-
const
|
|
52823
|
+
const decl = roleOf(roles, entry.screen.entity.alias, modelAlias);
|
|
52824
|
+
const role = decl?.role;
|
|
52229
52825
|
return {
|
|
52230
52826
|
...signOf(entry.screen.entity, roles, modelAlias, over) === void 0 ? {} : { sign: signOf(entry.screen.entity, roles, modelAlias, over) },
|
|
52231
52827
|
...role === "measure" && levelOf(entry, roles, modelAlias) !== void 0 ? { level: levelOf(entry, roles, modelAlias) } : {},
|
|
52232
|
-
|
|
52828
|
+
// A DATE IS A DEADLINE BECAUSE THE MODEL SAID SO. Carried on the column so
|
|
52829
|
+
// every surface drawing the value reads one answer — the register's cell,
|
|
52830
|
+
// the band's countdown, a board's hatching — instead of each deciding it
|
|
52831
|
+
// off the row and tinting a lead's arrival red.
|
|
52832
|
+
...countsDown(decl) ? { due: true } : {},
|
|
52833
|
+
...field === void 0 || untilOf(entry, roles, field) === void 0 ? {} : { until: untilOf(entry, roles, field) },
|
|
52834
|
+
// WHAT THE NUMBER COUNTS, where the model said — the unit decides whether
|
|
52835
|
+
// a surface may draw this measure as a LENGTH, and a figure whose unit
|
|
52836
|
+
// never reached the spec is a bar of dollars.
|
|
52837
|
+
...role === "measure" && decl?.counts !== void 0 ? { counts: decl.counts } : {}
|
|
52233
52838
|
};
|
|
52234
52839
|
};
|
|
52235
52840
|
}
|
|
@@ -52256,6 +52861,14 @@ function childExtras(child, roles) {
|
|
|
52256
52861
|
}
|
|
52257
52862
|
function boundAct(entry, templates, act, input) {
|
|
52258
52863
|
const place = act.place === void 0 ? {} : { place: act.place };
|
|
52864
|
+
if (act.kind === "workflow") {
|
|
52865
|
+
const inputs = act.inputs.flatMap((taken) => {
|
|
52866
|
+
if (taken.field === null) return [{ name: taken.name, field: RECORD_ID_COLUMN }];
|
|
52867
|
+
const bound = entry.fields.get(taken.field.alias);
|
|
52868
|
+
return bound === void 0 ? [] : [{ name: taken.name, field: bound.alias }];
|
|
52869
|
+
});
|
|
52870
|
+
return inputs.length < act.inputs.length ? void 0 : { kind: "workflow", label: act.label, key: act.workflow, workflow: act.workflow, inputs, ...place };
|
|
52871
|
+
}
|
|
52259
52872
|
if (act.kind === "agent") {
|
|
52260
52873
|
const fills = act.fills.flatMap((field) => {
|
|
52261
52874
|
const bound = entry.fields.get(field.alias);
|
|
@@ -52292,7 +52905,7 @@ function specActs(entry, templates) {
|
|
|
52292
52905
|
});
|
|
52293
52906
|
const saved = entry.screen.acts.export;
|
|
52294
52907
|
const paper = saved === null || saved === true ? void 0 : one(saved, recordsParam(entity));
|
|
52295
|
-
const columns = saved === true ?
|
|
52908
|
+
const columns = saved === true ? drawnColumns3(entry) : [];
|
|
52296
52909
|
const exported = columns.length > 0 ? { kind: "columns", columns } : paper?.kind === "template" ? paper : void 0;
|
|
52297
52910
|
const taken = specImport(entry);
|
|
52298
52911
|
if (row.length === 0 && selection.length === 0 && exported === void 0 && taken === void 0) return void 0;
|
|
@@ -52303,11 +52916,11 @@ function specActs(entry, templates) {
|
|
|
52303
52916
|
...taken === void 0 ? {} : { import: taken }
|
|
52304
52917
|
};
|
|
52305
52918
|
}
|
|
52306
|
-
function
|
|
52919
|
+
function drawnColumns3(entry) {
|
|
52307
52920
|
const seen = /* @__PURE__ */ new Set();
|
|
52308
|
-
|
|
52309
|
-
|
|
52310
|
-
const bound = entry.fields.get(
|
|
52921
|
+
const drawn = [...entry.screen.slots.flatMap((slot2) => slot2.field === null ? [] : [slot2.field]), ...entry.screen.columns];
|
|
52922
|
+
return drawn.flatMap((field) => {
|
|
52923
|
+
const bound = entry.fields.get(field.alias);
|
|
52311
52924
|
if (bound === void 0 || seen.has(bound.alias)) return [];
|
|
52312
52925
|
seen.add(bound.alias);
|
|
52313
52926
|
return [{ label: bound.label, field: bound.alias }];
|
|
@@ -52318,11 +52931,11 @@ function specImport(entry) {
|
|
|
52318
52931
|
if (taken === null) return void 0;
|
|
52319
52932
|
const key = entry.fields.get(taken.key.alias);
|
|
52320
52933
|
if (key === void 0) return void 0;
|
|
52321
|
-
const
|
|
52934
|
+
const named2 = taken.columns.flatMap((field) => {
|
|
52322
52935
|
const bound = entry.fields.get(field.alias);
|
|
52323
52936
|
return bound === void 0 ? [] : [specField(bound, field)];
|
|
52324
52937
|
});
|
|
52325
|
-
const columns =
|
|
52938
|
+
const columns = named2.length > 0 ? named2 : entry.screen.entity.fields.flatMap((field) => {
|
|
52326
52939
|
const bound = entry.fields.get(field.alias);
|
|
52327
52940
|
return bound === void 0 || !stated(entry, field) || !importableField(field) ? [] : [specField(bound, field)];
|
|
52328
52941
|
});
|
|
@@ -52350,7 +52963,7 @@ function specDirections(entry, roles) {
|
|
|
52350
52963
|
const outflow = words(true);
|
|
52351
52964
|
return inflow === "" || outflow === "" ? void 0 : { field: bound.alias, inflow, outflow };
|
|
52352
52965
|
}
|
|
52353
|
-
var LENS_TYPES = ["number", "text", "boolean", "select"];
|
|
52966
|
+
var LENS_TYPES = ["number", "text", "boolean", "select", "date"];
|
|
52354
52967
|
var LENS_OPERATORS2 = [
|
|
52355
52968
|
"equals",
|
|
52356
52969
|
"not_equals",
|
|
@@ -52360,6 +52973,11 @@ var LENS_OPERATORS2 = [
|
|
|
52360
52973
|
"less_than_or_equal_to",
|
|
52361
52974
|
"has_any_of",
|
|
52362
52975
|
"has_none_of",
|
|
52976
|
+
"before",
|
|
52977
|
+
"after",
|
|
52978
|
+
"on_or_before",
|
|
52979
|
+
"on_or_after",
|
|
52980
|
+
"on",
|
|
52363
52981
|
"is_empty",
|
|
52364
52982
|
"is_not_empty"
|
|
52365
52983
|
];
|
|
@@ -52375,6 +52993,10 @@ function lensValue(field, type, value) {
|
|
|
52375
52993
|
const ids = value.map((alias) => field.options?.get(alias)?.id);
|
|
52376
52994
|
return ids.every((id) => id !== void 0) ? { value: ids.filter((id) => id !== void 0) } : void 0;
|
|
52377
52995
|
}
|
|
52996
|
+
if (type === "date") {
|
|
52997
|
+
const point = lensRelativePoint(value);
|
|
52998
|
+
return point === void 0 ? value == null ? {} : void 0 : { value: point };
|
|
52999
|
+
}
|
|
52378
53000
|
if (typeof value === "number" || typeof value === "string" || typeof value === "boolean") return { value };
|
|
52379
53001
|
return {};
|
|
52380
53002
|
}
|
|
@@ -52403,11 +53025,11 @@ function specSummary(entry, roles) {
|
|
|
52403
53025
|
const bound = entry.fields.get(field.alias);
|
|
52404
53026
|
return bound === void 0 ? [] : [bound.alias];
|
|
52405
53027
|
});
|
|
52406
|
-
const
|
|
53028
|
+
const named2 = summary.above === null || summary.above === "counts" ? void 0 : summary.above.flatMap((figure) => {
|
|
52407
53029
|
const bound = entry.fields.get(figure.field.alias);
|
|
52408
53030
|
return bound === void 0 ? [] : [figure.at === void 0 ? bound.alias : { field: bound.alias, at: figure.at }];
|
|
52409
53031
|
});
|
|
52410
|
-
const above = summary.above === "counts" ? "counts" :
|
|
53032
|
+
const above = summary.above === "counts" ? "counts" : named2 === void 0 || named2.length === 0 ? void 0 : named2;
|
|
52411
53033
|
const columnTotals = refs(summary.columnTotals);
|
|
52412
53034
|
const bandTotals = refs(summary.bandTotals);
|
|
52413
53035
|
const plan = summary.ageing;
|
|
@@ -52415,7 +53037,10 @@ function specSummary(entry, roles) {
|
|
|
52415
53037
|
const due = plan === null ? void 0 : entry.fields.get(plan.due.alias);
|
|
52416
53038
|
const ageing = plan === null || amount === void 0 || due === void 0 ? void 0 : { amount: amount.alias, due: due.alias, buckets: [...plan.buckets] };
|
|
52417
53039
|
const directions = specDirections(entry, roles);
|
|
52418
|
-
|
|
53040
|
+
const moved = summary.trend;
|
|
53041
|
+
const moving = moved === null ? void 0 : entry.fields.get(moved.field.alias);
|
|
53042
|
+
const trend = moved === null || moving === void 0 ? void 0 : { field: moving.alias, direction: moved.direction };
|
|
53043
|
+
if (above === void 0 && columnTotals.length === 0 && bandTotals.length === 0 && ageing === void 0 && directions === void 0 && trend === void 0) {
|
|
52419
53044
|
return void 0;
|
|
52420
53045
|
}
|
|
52421
53046
|
return {
|
|
@@ -52423,7 +53048,8 @@ function specSummary(entry, roles) {
|
|
|
52423
53048
|
...columnTotals.length === 0 ? {} : { columnTotals },
|
|
52424
53049
|
...bandTotals.length === 0 ? {} : { bandTotals },
|
|
52425
53050
|
...ageing === void 0 ? {} : { ageing },
|
|
52426
|
-
...directions === void 0 ? {} : { directions }
|
|
53051
|
+
...directions === void 0 ? {} : { directions },
|
|
53052
|
+
...trend === void 0 ? {} : { trend }
|
|
52427
53053
|
};
|
|
52428
53054
|
}
|
|
52429
53055
|
function specScreen(entry, roles, templates, inbound) {
|
|
@@ -52438,21 +53064,30 @@ function specScreen(entry, roles, templates, inbound) {
|
|
|
52438
53064
|
const bound = ref(other);
|
|
52439
53065
|
return bound === void 0 ? [] : [bound];
|
|
52440
53066
|
});
|
|
53067
|
+
const tiers = slot2.tiers.flatMap((under) => {
|
|
53068
|
+
const bound = ref(under);
|
|
53069
|
+
return bound === void 0 ? [] : [bound];
|
|
53070
|
+
});
|
|
52441
53071
|
return [
|
|
52442
53072
|
{
|
|
52443
53073
|
name: slot2.name,
|
|
52444
53074
|
role: slot2.role,
|
|
52445
53075
|
field,
|
|
52446
53076
|
...also.length === 0 ? {} : { also },
|
|
53077
|
+
...tiers.length === 0 ? {} : { tiers },
|
|
52447
53078
|
...slot2.quick === void 0 ? {} : { quick: slot2.quick },
|
|
52448
53079
|
...slot2.order === void 0 ? {} : { order: slot2.order }
|
|
52449
53080
|
}
|
|
52450
53081
|
];
|
|
52451
53082
|
});
|
|
53083
|
+
const columns = screen.columns.flatMap((field) => {
|
|
53084
|
+
const bound = ref(field);
|
|
53085
|
+
return bound === void 0 ? [] : [bound];
|
|
53086
|
+
});
|
|
52452
53087
|
const outcomes = {};
|
|
52453
|
-
for (const [fieldAlias,
|
|
53088
|
+
for (const [fieldAlias, named2] of Object.entries(screen.outcomes)) {
|
|
52454
53089
|
const bound = entry.fields.get(fieldAlias);
|
|
52455
|
-
const ids = optionIds(bound,
|
|
53090
|
+
const ids = optionIds(bound, named2);
|
|
52456
53091
|
if (bound !== void 0 && ids.length > 0) outcomes[bound.alias] = ids;
|
|
52457
53092
|
}
|
|
52458
53093
|
const lenses = screen.filters.flatMap((lens) => specLens(entry, lens));
|
|
@@ -52469,8 +53104,10 @@ function specScreen(entry, roles, templates, inbound) {
|
|
|
52469
53104
|
];
|
|
52470
53105
|
const obligations = screen.obligations.flatMap((owed) => {
|
|
52471
53106
|
const due = ref(owed.field);
|
|
52472
|
-
|
|
52473
|
-
|
|
53107
|
+
if (due === void 0) return [];
|
|
53108
|
+
const satisfiedBy = owed.satisfiedBy === void 0 ? void 0 : ref(owed.satisfiedBy);
|
|
53109
|
+
if (satisfiedBy !== void 0) return [{ label: owed.label, due, satisfiedBy }];
|
|
53110
|
+
return owed.satisfiedBy === void 0 && untilOf(entry, roles, owed.field) !== void 0 ? [{ label: owed.label, due }] : [];
|
|
52474
53111
|
});
|
|
52475
53112
|
const runs = entry.day === void 0 || entry.runSlot === void 0 ? void 0 : { day: entry.day.alias, slot: entry.runSlot.alias };
|
|
52476
53113
|
const acts = specActs(entry, templates);
|
|
@@ -52481,6 +53118,9 @@ function specScreen(entry, roles, templates, inbound) {
|
|
|
52481
53118
|
alias,
|
|
52482
53119
|
label: screen.screen.label,
|
|
52483
53120
|
entity,
|
|
53121
|
+
// THE ENTITY'S OWN NOUN, for the column a shape heads with a count — the
|
|
53122
|
+
// entity label names the SET, which is what a count is of.
|
|
53123
|
+
rows: screen.entity.label,
|
|
52484
53124
|
table: entry.table.id,
|
|
52485
53125
|
shape: screen.screen.shape,
|
|
52486
53126
|
record: screen.record,
|
|
@@ -52488,6 +53128,7 @@ function specScreen(entry, roles, templates, inbound) {
|
|
|
52488
53128
|
writes: screen.screen.writes !== false,
|
|
52489
53129
|
fields: specFields(entry.fields, screen.entity.fields, screenExtras(entry, roles)),
|
|
52490
53130
|
slots,
|
|
53131
|
+
...columns.length === 0 ? {} : { columns },
|
|
52491
53132
|
...Object.keys(outcomes).length === 0 ? {} : { outcomes },
|
|
52492
53133
|
...tabs === void 0 ? {} : { tabs },
|
|
52493
53134
|
...lenses.length === 0 ? {} : { lenses },
|
|
@@ -52505,11 +53146,13 @@ function specScreen(entry, roles, templates, inbound) {
|
|
|
52505
53146
|
function specPresentation(entry) {
|
|
52506
53147
|
const stated2 = entry.screen.screen.presentation;
|
|
52507
53148
|
if (stated2 === void 0 || Object.values(stated2).every((clause) => clause === void 0)) return {};
|
|
53149
|
+
const until = stated2.until === void 0 ? void 0 : entry.fields.get(stated2.until)?.alias;
|
|
52508
53150
|
return {
|
|
52509
53151
|
presentation: {
|
|
52510
53152
|
...stated2.lead === void 0 ? {} : { lead: stated2.lead },
|
|
52511
53153
|
...stated2.density === void 0 ? {} : { density: stated2.density },
|
|
52512
|
-
...stated2.layout === void 0 ? {} : { layout: stated2.layout }
|
|
53154
|
+
...stated2.layout === void 0 ? {} : { layout: stated2.layout },
|
|
53155
|
+
...until === void 0 ? {} : { until }
|
|
52513
53156
|
}
|
|
52514
53157
|
};
|
|
52515
53158
|
}
|
|
@@ -52563,7 +53206,8 @@ function specRequiredBy(entry, field, requiredBy) {
|
|
|
52563
53206
|
if (entries2.length > 0) options[key] = entries2;
|
|
52564
53207
|
}
|
|
52565
53208
|
if (Object.keys(options).length === 0) return void 0;
|
|
52566
|
-
|
|
53209
|
+
const from = requiredBy.from === "parent" ? { from: "parent" } : {};
|
|
53210
|
+
return requiredBy.reads === "answer" ? { reads: "answer", field: by.alias, ...from, options } : { reads: "option", field: by.alias, ...from, options };
|
|
52567
53211
|
}
|
|
52568
53212
|
function specChild(child, roles, archetype, slot2, entry, expected) {
|
|
52569
53213
|
const reads = childReads(child);
|
|
@@ -52614,6 +53258,14 @@ function specChild(child, roles, archetype, slot2, entry, expected) {
|
|
|
52614
53258
|
...written === void 0 ? {} : { write: { alias: writeAlias(written), param: recordParam(written) } }
|
|
52615
53259
|
};
|
|
52616
53260
|
}
|
|
53261
|
+
function specWorksheet(sheet) {
|
|
53262
|
+
return {
|
|
53263
|
+
...sheet.quantity === void 0 ? {} : { quantity: sheet.quantity.bound.alias },
|
|
53264
|
+
...sheet.cost === void 0 ? {} : { cost: sheet.cost.bound.alias },
|
|
53265
|
+
...sheet.sell === void 0 ? {} : { sell: sheet.sell.bound.alias },
|
|
53266
|
+
...sheet.group === void 0 ? {} : { group: sheet.group.bound.alias }
|
|
53267
|
+
};
|
|
53268
|
+
}
|
|
52617
53269
|
function itineraryTotal(entry, child) {
|
|
52618
53270
|
const amountAlias = [...child.fields.keys()].find((alias) => child.roles.get(alias) === "amount");
|
|
52619
53271
|
const amount = amountAlias === void 0 ? void 0 : child.fields.get(amountAlias);
|
|
@@ -52720,11 +53372,14 @@ function specRecord(entry, roles, templates) {
|
|
|
52720
53372
|
}
|
|
52721
53373
|
const child = entry.children.get(childKey(section.child.alias, section.link.alias));
|
|
52722
53374
|
if (child === void 0 || child.set === void 0) break;
|
|
53375
|
+
const setField = child.fields.get(child.set);
|
|
53376
|
+
const deskRequiredBy = setField === void 0 ? void 0 : specRequiredBy(entry, setField, section.requiredBy);
|
|
52723
53377
|
sections.push({
|
|
52724
53378
|
kind: "desk",
|
|
52725
53379
|
key: sectionKey(child.entity.alias, child.linkAlias),
|
|
52726
53380
|
heading: child.heading,
|
|
52727
53381
|
child: specChild(child, roles, archetype, slot2, entry, void 0),
|
|
53382
|
+
...deskRequiredBy === void 0 ? {} : { requiredBy: deskRequiredBy },
|
|
52728
53383
|
...stands
|
|
52729
53384
|
});
|
|
52730
53385
|
break;
|
|
@@ -52744,12 +53399,19 @@ function specRecord(entry, roles, templates) {
|
|
|
52744
53399
|
break;
|
|
52745
53400
|
}
|
|
52746
53401
|
const ledger = slot2 === "ledger" && section.via === "parent";
|
|
53402
|
+
const sheet = planWorksheets(entry).find((one) => one.child === child);
|
|
53403
|
+
const key = sectionKey(child.entity.alias, child.linkAlias);
|
|
53404
|
+
const bound = specChild(child, roles, archetype, slot2, entry, section.expected);
|
|
53405
|
+
if (sheet !== void 0) {
|
|
53406
|
+
sections.push({ kind: "children", key, heading: child.heading, draw: "worksheet", child: { ...bound, worksheet: specWorksheet(sheet) }, ...stands });
|
|
53407
|
+
break;
|
|
53408
|
+
}
|
|
52747
53409
|
sections.push({
|
|
52748
53410
|
kind: "children",
|
|
52749
|
-
key
|
|
53411
|
+
key,
|
|
52750
53412
|
heading: child.heading,
|
|
52751
53413
|
draw: ledger ? "ledger" : child.itinerary === void 0 ? "register" : "itinerary",
|
|
52752
|
-
child:
|
|
53414
|
+
child: bound,
|
|
52753
53415
|
...stands
|
|
52754
53416
|
});
|
|
52755
53417
|
break;
|
|
@@ -53036,6 +53698,12 @@ function checkRecord(spec, key, record2, manifest) {
|
|
|
53036
53698
|
if (section.kind === "thread" && section.clock !== void 0 && !Object.hasOwn(record2.fields, section.clock)) {
|
|
53037
53699
|
findings.push({ at: childAt, message: `counts down to "${section.clock}", which this record does not project` });
|
|
53038
53700
|
}
|
|
53701
|
+
const priced = section.kind === "children" && section.draw === "worksheet" ? section.child.worksheet : {};
|
|
53702
|
+
for (const alias of Object.values(priced)) {
|
|
53703
|
+
if (!Object.hasOwn(child.fields, alias)) {
|
|
53704
|
+
findings.push({ at: childAt, message: `prices "${alias}", which "${child.query}" does not project` });
|
|
53705
|
+
}
|
|
53706
|
+
}
|
|
53039
53707
|
if (child.write !== void 0 && manifest.workflows?.[child.write.alias] === void 0) {
|
|
53040
53708
|
findings.push({ at: childAt, message: `changes its rows through "${child.write.alias}", which package.json#lotics.workflows does not declare` });
|
|
53041
53709
|
}
|
|
@@ -53089,6 +53757,17 @@ function checkAct(at2, act, fields, manifest, surface) {
|
|
|
53089
53757
|
if (declared === void 0) {
|
|
53090
53758
|
return [{ at: at2, message: `an act runs "${act.workflow}", which package.json#lotics.workflows does not declare` }];
|
|
53091
53759
|
}
|
|
53760
|
+
if (act.kind === "workflow") {
|
|
53761
|
+
for (const taken of act.inputs) {
|
|
53762
|
+
if (!Object.hasOwn(declared.inputs ?? {}, taken.name)) {
|
|
53763
|
+
findings.push({ at: at2, message: `an act sends "${taken.name}" to "${act.workflow}", which declares no such input` });
|
|
53764
|
+
}
|
|
53765
|
+
if (taken.field !== RECORD_ID_COLUMN && !Object.hasOwn(fields, taken.field)) {
|
|
53766
|
+
findings.push({ at: at2, message: `the "${act.label}" act sends "${taken.field}", which this ${surface} does not project` });
|
|
53767
|
+
}
|
|
53768
|
+
}
|
|
53769
|
+
return findings;
|
|
53770
|
+
}
|
|
53092
53771
|
if (!Object.hasOwn(declared.inputs ?? {}, act.input)) {
|
|
53093
53772
|
findings.push({ at: at2, message: `an act sends "${act.input}" to "${act.workflow}", which declares no such input` });
|
|
53094
53773
|
}
|
|
@@ -53114,11 +53793,19 @@ function checkAppSpec(spec, manifest, components) {
|
|
|
53114
53793
|
const refused = leadRefusal(screen.shape, bound, lead);
|
|
53115
53794
|
if (refused !== void 0) findings.push({ at: at2, message: refused });
|
|
53116
53795
|
}
|
|
53796
|
+
const drawnColumns4 = screen.slots.map((slot2) => {
|
|
53797
|
+
const counts = screen.fields[slot2.field]?.counts;
|
|
53798
|
+
return { role: slot2.role, ...counts === void 0 ? {} : { counts } };
|
|
53799
|
+
});
|
|
53117
53800
|
const layout = screen.presentation?.layout;
|
|
53118
53801
|
if (layout !== void 0) {
|
|
53119
|
-
const refused = layoutRefusal(
|
|
53802
|
+
const refused = layoutRefusal(drawnColumns4, layout);
|
|
53120
53803
|
if (refused !== void 0) findings.push({ at: at2, message: refused });
|
|
53121
53804
|
}
|
|
53805
|
+
const until = screen.presentation?.until;
|
|
53806
|
+
if (until !== void 0 && screen.fields[until] === void 0) {
|
|
53807
|
+
findings.push({ at: at2, message: `ends its bars at "${until}", which "${screen.query}" does not project` });
|
|
53808
|
+
}
|
|
53122
53809
|
const scope = spec.scope;
|
|
53123
53810
|
if (scope !== void 0) {
|
|
53124
53811
|
findings.push(
|
|
@@ -53156,6 +53843,19 @@ function checkAppSpec(spec, manifest, components) {
|
|
|
53156
53843
|
const refused = quickRefusal(slot2.role, slot2.order);
|
|
53157
53844
|
if (refused !== void 0) findings.push({ at: at2, message: `the "${slot2.name}" slot ${refused}` });
|
|
53158
53845
|
}
|
|
53846
|
+
const columns = screen.columns ?? [];
|
|
53847
|
+
if (columns.length > 0 && !shapeDrawsColumns(screen.shape)) {
|
|
53848
|
+
findings.push({
|
|
53849
|
+
at: at2,
|
|
53850
|
+
message: `names columns, and a "${screen.shape}" does not stand this entity's records in a table \u2014 the shapes that draw columns are ${shapesDrawingColumns().join(", ")}`
|
|
53851
|
+
});
|
|
53852
|
+
} else if (columns.length > 0 && layout !== void 0 && layout !== "table") {
|
|
53853
|
+
findings.push({ at: at2, message: `names columns beside \`layout: "${layout}"\`, which draws the rows with a device of its own instead of the register's columns` });
|
|
53854
|
+
}
|
|
53855
|
+
for (const alias of columns) {
|
|
53856
|
+
if (Object.hasOwn(screen.fields, alias)) continue;
|
|
53857
|
+
findings.push({ at: at2, message: `draws the column "${alias}", which this register does not project` });
|
|
53858
|
+
}
|
|
53159
53859
|
for (const lens of screen.lenses ?? []) {
|
|
53160
53860
|
if (typeof lens === "string") continue;
|
|
53161
53861
|
for (const predicate of lens.predicates) {
|
|
@@ -53175,6 +53875,15 @@ function checkAppSpec(spec, manifest, components) {
|
|
|
53175
53875
|
findings.push({ at: at2, message: `reads "${figure.field}" at the window's end, and this screen states no period to be the end of` });
|
|
53176
53876
|
}
|
|
53177
53877
|
}
|
|
53878
|
+
const moved = screen.summary?.trend;
|
|
53879
|
+
if (moved !== void 0) {
|
|
53880
|
+
if (screen.period === void 0) {
|
|
53881
|
+
findings.push({ at: at2, message: `reads "${moved.field}" across the window, and this screen states no period to be the window` });
|
|
53882
|
+
}
|
|
53883
|
+
if (!Object.hasOwn(screen.fields, moved.field)) {
|
|
53884
|
+
findings.push({ at: at2, message: `reads "${moved.field}" across the window, which this register does not project` });
|
|
53885
|
+
}
|
|
53886
|
+
}
|
|
53178
53887
|
const revealed = expandRefusal(screen.shape, screen.record);
|
|
53179
53888
|
if (revealed !== void 0) findings.push({ at: at2, message: revealed });
|
|
53180
53889
|
const arranged = revealedLayoutRefusal(screen.record, screen.presentation?.layout);
|
|
@@ -53295,8 +54004,8 @@ function componentsIn(projectDir) {
|
|
|
53295
54004
|
}
|
|
53296
54005
|
for (const block of text.matchAll(/\bexport\s*\{([^}]*)\}/g)) {
|
|
53297
54006
|
for (const part of block[1].split(",")) {
|
|
53298
|
-
const
|
|
53299
|
-
if (
|
|
54007
|
+
const named2 = /(?:\bas\s+)?([A-Za-z_$][\w$]*)\s*$/.exec(part.trim());
|
|
54008
|
+
if (named2 !== null) names.add(named2[1]);
|
|
53300
54009
|
}
|
|
53301
54010
|
}
|
|
53302
54011
|
}
|
|
@@ -54408,7 +55117,7 @@ Captured ${totalRows} row${totalRows === 1 ? "" : "s"} across ${result.captured.
|
|
|
54408
55117
|
}
|
|
54409
55118
|
|
|
54410
55119
|
// src/model_reference.md
|
|
54411
|
-
var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing the tables, fields, options, views, roles and first rows\na workspace starts with. `lotics scaffold check model.json` proves it offline \u2014\nno account, no network. `lotics setup model.json --email you@company.com` creates\nthe account and applies it. `lotics scaffold apply model.json` applies it again,\ninto the workspace the credential names.\n\n**There are two forms of this file.** The full one, below, spells the model out.\nThe `from` one names a published preset and carries only what this business\ndiffers by \u2014 see \xA7 Starting from a preset, and prefer it whenever a preset fits\nthe trade.\n\nApps are PLANNED here and built afterwards: `apps` names each app\'s screens as a\nshape over an entity, checked against the roles `field_roles` gives its fields,\nso the plan is refused before anyone builds a screen (\xA7 Apps and screens). The\nbuilt app lives in the workspace; publishing that workspace as a package is how\nit ships.\n\n## The rules\n\n- **At least one entity, at most 50.** More tables than that is a data model\n being designed, not scaffolded \u2014 scaffold the rest in a second call.\n- **Adoption is explicit.** `lotics setup` REFUSES an entity whose `label`\n already names a table in the workspace, naming every colliding label at once.\n `lotics scaffold apply` adopts those tables and adds the fields, options and\n views they are missing. Nothing is ever modified or deleted, so applying the\n same model twice creates nothing the second time.\n- **Adoption is by LABEL, not alias.** Change an entity\'s `label` and the next\n run asks for a NEW table beside the old one. Renaming a FIELD is\n `lotics field rename <table> <field> "<new label>" --model <this file>`, which\n moves the platform, this file and every app bound to it together; deleting is\n `lotics run delete_table`. Neither goes through the file.\n- **The file\'s own majority is the language.** A model names no locale \u2014 which\n language it is in is what it mostly says, and `scaffold check` notes the label\n written the other way. The generated screens read the kit\'s pack, and a\n generated WRITE cannot: its refusals run on the server, so they are worded in\n that same majority. Mix the two and the workspace answers in two languages.\n- **`lotics scaffold diff model.json` says where the file and the workspace have\n come apart**, joined on label, entity then field \u2014 the join adoption itself\n makes. It exits 1 on any difference, so a model that is about to be published\n as a preset carries the labels in use rather than the ones it was written with.\n- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere, and the result says\n `rows_skipped: true`: sample rows landing among a customer\'s real ones cannot\n be told apart from them.\n- **After the first run the WORKSPACE is the source of truth.** The file is an\n authoring input, not a mirror \u2014 scaffold never deletes what the file stopped\n naming.\n- **`lotics scaffold check` decides all of it offline**, and reports every\n problem in one run rather than the first: an alias that resolves to nothing, a\n link whose pair is not symmetric, and the rows themselves \u2014 a field the entity\n does not declare, an option alias the field does not declare, a link naming no\n row in the file, a `ref` used twice, a date that is not one, a value on a\n platform-computed field, and a files cell that is neither a relative path\n beside this file nor a `fil_` id.\n\n## Top level\n\n```jsonc\n{\n "entities": [ /* the tables */ ],\n "roles": [ /* workspace groups to create */ ], // optional\n "templates":[ /* inline html / email templates */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* the reporting role each field plays, keyed by entity then field */ }, // optional\n "write_rules": { /* what a CREATE finds, defaults and refuses, keyed by entity */ }, // optional\n "apps": [ /* the screens each app will have, as shapes over entities */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ], // optional\n "preset": { /* a trade\'s branches, for a PUBLISHED model */ } // optional\n}\n```\n\nThe other form names a preset instead of restating one:\n\n```jsonc\n{\n "from": "field_service", // the preset this model starts from, by slug\n "variants": ["crews"], // optional \u2014 its branches to merge in, in order\n "rename": { // optional \u2014 what THIS business calls each table\n "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } }\n },\n "entities": [ /* tables the preset does not declare */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* roles on the preset\'s fields and this business\'s own */ }, // optional\n "write_rules": { /* create-time clauses on the preset\'s entities and its own */ }, // optional\n "apps": [ /* the screens each app will have */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ] // optional\n}\n```\n\n**A model may not carry** `fixtures`, `knowledge` or `knowledge_expects`, and no\n`excel` / `word` / `pdf-form` template: each of those is content that lives in a\npublished bundle, which a model has none of. `apps` here is a plan of screens,\nnever built code. An unknown top-level key is an error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because scaffold matches by label.\n\n## Entity\n\n```jsonc\n{\n "alias": "order",\n "label": "Orders", // the table\'s name, which names the SET it holds\n "singular": "Order", // optional \u2014 ONE of them, for the act that opens one\n "description": "\u2026", // optional\n "fields": [ /* at least one */ ],\n "views": [ /* optional; an entity with none still gets the default grid */ ],\n "read_scope": { /* optional; absent, everyone with access to the table reads every row */\n "any": [\n { "member_of": "sales" }, // a role alias this model declares\n { "field": "scope", "is": ["shared"] } // a single select on this entity, by option alias\n ]\n }\n}\n```\n\n**`singular` is what a create says.** A register\'s own act and the panel it\nopens name the row being made \u2014 `New Order`, `H\u1ED3 s\u01A1 m\u1EDBi` \u2014 while `label` names\nthe table, so without it the button reads `New Orders`. Nothing derives it\n(English plurals are irregular), and most models need none: a Vietnamese noun is\nthe same word either way. `scaffold check` prints it beside the table under *Who\nwrites what* and NOTES a table that will name the set. An act on a child section\nkeeps the label: it adds a line to the register under the record the reader is\nalready standing in.\n\n**`read_scope` is a ROW rule, enforced by the platform.** A row is readable when\nANY clause holds: the reader is in that role\'s group, or the named select carries\none of those options. It is resolved at scaffold into the table\'s own row\nfilters, so an app, a workflow reading for a viewer, and the API all answer the\nsame rows \u2014 a per-record visibility field the app merely honours is a convention,\nnot a gate. `any` may not be empty (a rule nobody satisfies hides the table), and\nevery role alias and option alias in it must be one this model declares.\n\n## Field\n\nEvery field carries `alias`, `label`, an optional `description`, and an optional\n`required` \u2014 advisory only, read by app forms and workflows; the table itself has\nno required constraint. `label` may not contain `{` or `}` (formulas reference\nfields by label at the platform level).\n\n`default` is the value pre-filled into a NEW record. It applies on create only;\nexisting records are never backfilled. Only the types listed below accept one.\n\n### `text`\n\n```jsonc\n{ "alias": "name", "label": "Name", "type": "text",\n "unique": false, // optional \u2014 require distinct values\n "format": "text", // optional \u2014 "text" | "link" | "markdown"\n "default": "" } // optional\n```\n\n### `number`\n\n```jsonc\n{ "alias": "amount", "label": "Amount", "type": "number",\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage"\n "currency": "VND", // optional \u2014 ISO 4217\n "default": 0 } // optional\n```\n\n`format` is what the number IS, and every surface reads it: `currency` prints as\nmoney in the code the row or the field states, `percentage` as a whole percent\nwith its sign. An ABSENT number is drawn absent \u2014 the one exception is a\n`sum` or count rollup the plan reads as a **`measure`**: that is the thing\naccumulated toward a bound, so nothing accumulated yet is zero and the meter\ndraws it. The same rollup read as an `amount` keeps its blank, and so does every\nother role: nothing added to what a row is WORTH means unpriced, not free. A\n`min`, an `avg`, a percentage of nothing and every FORMULA stay blank in any\nrole, because none of them has an answer to give. This is why the pair on one\nscreen reads two ways \u2014 what has come in against what is owed \u2014 and why a\nmeasure\'s own LIMIT, an amount, leaves an unquoted row out of the count rather\nthan reporting it as nothing collected. **AND WHERE THAT LIMIT IS ABSENT THERE IS\nNO LEVEL AT ALL**: a level is a reading AGAINST a bound, so a row that states no\nbound draws nothing \u2014 cell, fact and all \u2014 rather than a numerator whose whole\nmeaning was the comparison. "Collected 0" beside a blank total reads as money\nagainst a job worth nothing. A measure the model gives no limit is a plain figure\nand is unaffected. A share is stored in percent units \u2014 68.1 is 68.1 % \u2014 and the\ncolumn, the fact behind it, the meter it is judged by and the figure over the\nregister all say so.\n\n### `date`\n\n```jsonc\n{ "alias": "placed_on", "label": "Placed on", "type": "date",\n "format": "date", // optional \u2014 "date" | "datetime" | "date_range" | "datetime_range"\n "timezone": "Asia/Ho_Chi_Minh", // optional \u2014 IANA name\n "derive_from": "created_at", // optional \u2014 "created_at" | "updated_at"; makes the field read-only\n "default": "2026-01-01" } // optional; refused together with derive_from\n```\n\n### `boolean`\n\n```jsonc\n{ "alias": "paid", "label": "Paid", "type": "boolean", "default": false }\n```\n\n### `select`\n\n```jsonc\n{ "alias": "tier", "label": "Tier", "type": "select",\n "options": [ // at least one\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "multi": false, // optional\n "default": ["standard"] } // optional \u2014 option ALIASES; one unless multi\n```\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet.\n\n```jsonc\n{ "alias": "owner", "label": "Owner", "type": "select_member", "multi": false }\n```\n\n### `select_record_link`\n\n```jsonc\n{ "alias": "customer", "label": "Customer", "type": "select_record_link",\n "target_entity": "customer", // an entity alias this model declares\n "cardinality": "one", // optional \u2014 "one" | "many" (default "many")\n "sync_both_ways": true, // optional \u2014 keep a paired field on the target\n "paired_field_alias": "orders", // the partner field ON THE TARGET entity\n "display_field_aliases": ["name"] } // optional \u2014 what the link shows / the picker\'s columns\n```\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused.\n\n**A single-valued link needs no partner.** `"cardinality": "one"` on its own is a\nlink that holds one row \u2014 one customer on an invoice, one project on a device \u2014\nand nothing is created on the target. The mirror invariant belongs to a PAIRED\nlink: pair a link when the target\'s own record should list what points at it, and\nleave it unpaired when it should not. Either way the record plan draws the\nrelation as a section on the side it points at, so an unpaired link costs the\ntarget nothing.\n\n### `files`\n\n```jsonc\n{ "alias": "attachments", "label": "Attachments", "type": "files" }\n```\n\n### `formula`\n\n```jsonc\n{ "alias": "total", "label": "Total", "type": "formula",\n "formula": {\n "expression": "{amount} * 1.1", // fields on THIS entity, by alias, in braces\n "output_type": "number", // optional \u2014 what it YIELDS: "number" | "text" | "date" | "datetime" | "boolean"\n "format": "currency", // optional \u2014 how that result is DRAWN: "number" | "currency" | "percentage" | "link"\n "currency": "VND" // optional\n } }\n```\n\n`output_type` is what the expression answers WITH; `format` is how it is printed.\nThe platform infers the result at write time and ignores what you declare, so\nthis is a statement the offline checks read \u2014 which is what lets a role or a\nscreen clause accept a computed value: a caption over a derived name\n(`output_type: "text"`), a period over a settled date (`"date"`). A formula\ndeclaring neither says nothing about its result, and every rule that needs one\nrefuses it by name. `lotics scaffold export` writes the result a live workspace\ncomputed, so exporting a workspace fills these in.\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n```jsonc\n{ "alias": "total_ordered", "label": "Total ordered", "type": "rollup",\n "source_field_alias": "orders", // a select_record_link field on THIS entity\n "aggregate_option": {\n "operation": "sum", // count | sum | avg | median | min | max | range |\n // empty | filled | percent_empty | percent_filled |\n // unique | percent_unique |\n // earliest | latest | date_range |\n // checked | unchecked | percent_checked |\n // percent_unchecked\n "field_key": "amount" // a field ALIAS on the linked entity ("count" may omit it)\n },\n "filter": { /* optional \u2014 see Views; every field_key is an alias on the LINKED entity */ } }\n```\n\nThe operation must be one the aggregated field\'s type allows \u2014 `sum` over a\nnumber, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n```jsonc\n{ "alias": "customer_tier", "label": "Customer tier", "type": "lookup",\n "source_field_alias": "customer", // a select_record_link field on THIS entity\n "lookup_field_alias": "tier", // a field alias on the linked entity\n "order_by": { "field_key": "placed_on", "direction": "desc" } } // optional \u2014 pick the single extreme row\n```\n\n### `autonumber`\n\n```jsonc\n{ "alias": "seq", "label": "No.", "type": "autonumber",\n "prefix": "SO-", // optional \u2014 ignored when template is set\n "padding": 4, // optional \u2014 1..20, zero-pads the integer\n "template": "SO-{YEAR}-{N:4}" } // optional \u2014 {N}, {N:W}, {YEAR}, {YEAR:2}, {MONTH}, {DAY}\n```\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n```jsonc\n{\n "alias": "gold",\n "label": "Gold customers",\n "description": "\u2026", // optional\n "columns": [ // optional \u2014 omit to show every field\n { "field_alias": "name", "visibility": "visible", "width": 240 },\n { "field_alias": "tier", "visibility": "hidden" }\n ],\n "filters": { // optional\n "node_type": "group",\n "logic": "and", // "and" | "or"\n "children": [\n { "node_type": "condition", "type": "select", "field_key": "tier",\n "operator": "has_any_of", "value": ["gold"] }\n ]\n },\n "sort": [ { "field_key": "name", "order": "asc" } ], // optional; order is "asc" | "desc" | null\n "summary": { "amount": "sum" }, // optional \u2014 field alias \u2192 footer operation\n "frozen_columns": 1 // optional\n}\n```\n\nA condition\'s `type` is the field\'s type and its `operator` is one that type\nadmits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n`columns`, when present, is exhaustive and must not be empty: a view renders\nexactly the entries it holds. Omit the key to show every field.\n\n## Roles\n\nA role becomes a workspace group. Members are added afterwards, in the app.\n\n```jsonc\n{ "alias": "sales", "label": "Sales" }\n```\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes. An `html` template renders to a PDF when a workflow generates\nfrom it; `{{name}}` is filled from the workflow\'s data.\n\n```jsonc\n{ "alias": "order_ack", "label": "Order acknowledgement", "type": "email",\n "content": "<p>Hello {{customer}}\u2026</p>" }\n```\n\nA paper that has to look like a counterparty produced it \u2014 an official letter,\nan acceptance minute, a supplier\'s bill \u2014 is the same `html` template with a\nshell around the body: a letterhead, a reference line, a seal and a signature\nblock, and paper grain over everything. One shell, many bodies; the data is the\nonly thing that changes, so a workflow can re-issue it over any record.\n\n```jsonc\n{ "alias": "cong_van", "label": "C\xF4ng v\u0103n", "type": "html",\n "content": "\u2026the page below, as one JSON string\u2026" }\n```\n\n```html\n<style>\n .sheet{position:relative;width:718px;padding:44px 58px 30px;background:#fbfaf6;color:#111;font:14.2px/1.5 \'Liberation Serif\',serif}\n .grain{position:absolute;inset:0;opacity:.34;mix-blend-mode:multiply;background:url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'140\' height=\'140\'><filter id=\'f\'><feTurbulence baseFrequency=\'.9\' numOctaves=\'2\'/><feColorMatrix values=\'0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0\'/></filter><rect width=\'140\' height=\'140\' filter=\'url(%23f)\'/></svg>")}\n .top{display:flex;text-align:center;font-size:13.4px} .top>div{flex:1} .u{display:inline-block;border-bottom:1px solid #111;font-weight:700}\n .ref{display:flex;text-align:center;font-size:13.4px;margin-top:6px} .ref>div{flex:1} .ref .r{font-style:italic}\n h1{text-align:center;font-size:15.6px;margin:26px 0 18px} p{text-align:justify;text-indent:26px;margin:0 0 9px}\n .sig{display:flex;margin-top:20px} .sig .l{flex:1} .sig .r{width:290px;text-align:center;position:relative}\n .sig .nm{font-weight:700;margin-top:96px} .seal{position:absolute;left:4px;top:8px;width:166px;height:166px;opacity:.66;mix-blend-mode:multiply;transform:rotate(-17deg)}\n </style>\n <div class=\'sheet\'><div class=\'grain\'></div>\n <div class=\'top\'><div><b>{{issuer_parent}}</b><br><span class=\'u\'>{{issuer}}</span></div>\n <div><b>C\u1ED8NG H\xD2A X\xC3 H\u1ED8I CH\u1EE6 NGH\u0128A VI\u1EC6T NAM</b><br><span class=\'u\'>\u0110\u1ED9c l\u1EADp - T\u1EF1 do - H\u1EA1nh ph\xFAc</span></div></div>\n <div class=\'ref\'><div>S\u1ED1: {{number}}</div><div class=\'r\'>{{place}}, ng\xE0y {{day}} th\xE1ng {{month}} n\u0103m {{year}}</div></div>\n <h1>{{title}}</h1>\n <p>K\xEDnh g\u1EEDi: {{recipient}}.</p>\n {{{body}}}\n <div class=\'sig\'><div class=\'l\'><b>N\u01A1i nh\u1EADn:</b><br>- Nh\u01B0 tr\xEAn;<br>- L\u01B0u VT.</div>\n <div class=\'r\'><img class=\'seal\' src=\'{{seal_url}}\'><b>{{signer_title}}</b><div class=\'nm\'>{{signer}}</div></div></div>\n </div>\n```\n\n`lotics preview <file.html>` renders any such page to a PNG the way a demo\'s\nprops are made, sized to its content, so a paper can be looked at before it is\nput in a template.\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model, attaching at most 2000 documents between them \u2014 a real data set\nbelongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n`ref` is a local handle (lowercase letters, digits, underscores) that other rows\'\nlink fields address. It is never persisted.\n\nA `files` cell attaches documents: paths relative to this file (no `..`, never\nabsolute), which `check` proves exist and `apply` uploads into the workspace\nbefore any row is written \u2014 a paperwork business seeds its papers with its\nrows. The server accepts only `fil_` ids of files this workspace owns, which is\nwhat the upload leaves behind. After a run that wrote rows, `apply` writes the\nrecord ids beside the file (`<model>.last_run.json`): `delete_records` over\nthem is how a seeded set is reset, and applying again re-dates it.\n\n`fields` is keyed by field alias, and every value is read against the field\'s\nDECLARED type:\n\n| Field type | Value |\n|---|---|\n| `text` / `number` / `boolean` | the value itself |\n| `date` | `"2026-03-14"`, or a relative expression (below) |\n| `select` | the option ALIAS \u2014 `"gold"`, or `["gold","vip"]` for a multi-select |\n| `select_record_link` | `"<entity-alias>:<ref>"` naming another row in this file \u2014 `"customer:acme"`, or an array for several |\n| `select_member` | `"self"` only \u2014 the person applying the model |\n| `files` | paths beside this file \u2014 `["scans/pccc_letter.png"]` \u2014 uploaded by `apply`/`setup` before the rows are posted; or `fil_` ids of files already in this workspace |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n**Documents can be attached on their own, afterwards.** Rows land only into empty\ntables, so a model whose binaries were added after the first apply has no second\napply to carry them: `lotics scaffold apply model.json --documents` writes ONLY\nthe `files` cells, onto the records the first run created, joined through the\n`<model>.last_run.json` beside the file. Running it twice attaches nothing the\nsecond time \u2014 the same path keeps the same file, and a cell is a set.\n\n### Relative dates\n\nA date cell holds a literal `YYYY-MM-DD`, or an expression relative to the day\nthe model is applied, so a screen that opens on "this month" is not empty a month\nlater:\n\n- `@today` \u2014 the day of the run, in the workspace\'s timezone\n- `@month-start` \u2014 the 1st of that month\n- either with a whole-day offset: `@today-14`, `@month-start+9`\n\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Field roles\n\n`field_roles` names the reporting role a field plays on its entity \u2014 keyed by\nentity alias, then field alias \u2014 so every screen over the entity agrees on\nwhich column names the row and which select is the stage. A shape\'s slot binds\nto it (\xA7 Apps and screens). Like `rows` and `apps`, it is this file\'s: `check`\nproves it and the workspace never sees it. Each role sits on the types that can\nanswer it:\n\n| Role | On | Meaning |\n|---|---|---|\n| `identity` | `text`, `autonumber`, `select_record_link`, `formula` | names the row \u2014 the register\'s first column; a link where the row is "the product, at this branch"; a formula where the name is computed, and then it may not be `format`ted as a figure. One per entity |\n| `reference` | `text`, `autonumber`, `formula` | the key the SYSTEM files the row under \u2014 a booking number, a container code \u2014 drawn as the supporting line under the name rather than as a column of its own. One per entity |\n| `mark` | `files`, or a `lookup`/`rollup` that resolves to one | the row\'s picture \u2014 its own, or the linked record\'s. One per entity |\n| `lifecycle` | single `select` | the ordered stages a row walks; option order is the order. Which of them END the flow is `outcomes`. One per entity |\n| `category` | single `select` | what the row IS \u2014 a kind, a service, a book. One badge in the field\'s own colour, with no ladder behind it and no flow to advance. Repeats: a row can be of two kinds of thing |\n| `measure` | `number`, `formula`, `rollup` | a level read against a limit \u2014 see `against` and `alert`. Money where the model says so, and then it prints as money |\n| `expected_set` | `select` | its OPTIONS are the required set (documents, checks, services); an option no row has is a gap to show, not nothing. A multi-select draws as a ring and a fraction wherever it is drawn \u2014 absence is the information, and a list of what IS there says nothing about what is missing |\n| `amount` | `number`, `formula`, `rollup` | THE money of a ledger row; `signed_by` says which way it moved, `against` names the reference it is quoted off. One per entity |\n| `when` | `date`, or a `formula`/`rollup`/`lookup` that yields one | the ledger or timeline date \u2014 stated, or derived: the day money MOVED is a formula over the two columns that could hold it. `until` names the stage of this entity\'s own `lifecycle` at which its COUNTDOWN is spent. One per entity |\n| `slot` | single `select`, or a `datetime` `date` | where in the DAY a row sits \u2014 the run it is read in under its day\'s heading on an itinerary. A select\'s option order IS the run\'s order; a time is ordered by the clock. A plain `date` is refused: that is the day itself. One per entity |\n| `party` | `select_record_link` | the counterparty. One per entity |\n| `parent` | `select_record_link` | the record this row belongs to \u2014 a line\'s order, a paper\'s case. The parent\'s record shows these rows; the row shows the parent as a fact. One per entity, and it links to an entity this model declares |\n| `contact` | `text` | a way to reach a party \u2014 an address, a number, the town it is in. The record draws them as ONE row of links under the name, so the role sits on every field that is one of them. Repeats |\n| `currency` | single `select` whose option LABELS are ISO 4217 codes | the money THIS ROW\'s figures are in. Every amount and every money measure on the entity is then printed per row rather than in one code for the whole column, and the select is not drawn as a fact of its own. One per entity |\n| `verdict` | `boolean`, `formula` | a settled pass/fail \u2014 ticked, or computed. TRUE is the PASSING side wherever it is drawn, a register\'s risk flag included, so a field whose true means trouble is the same fact asked the other way round. One per entity |\n| `obligation` | `date` | a day something is OWED by \u2014 a cut-off, a permit expiry, a payment due. `satisfied_by` names what CLOSES it, `label` what is owed. Repeats: a row owes several things, and a shape that fans them draws one row each |\n\n**`until` stops a countdown where the work is DONE.** A deadline counts down so\nsomebody acts on it, and a row that has arrived needs nothing: without the clause\na delivered order is tinted for a date it met, and every settled row past its day\njoins the urgent ones in the reader\'s glance. It names one option of the entity\'s\n`lifecycle`; at that stage, at every stage past it and at every `outcome`, the\ndate is drawn as the plain day it is.\n\n"One per entity" is one COLUMN, never one value on screen. A second counterparty\nor a second parent stays a ROLELESS `select_record_link`: which field fills a\nslot is the screen\'s answer (\xA7 Apps and screens), and the extra link is a fact on\nthe row with its own section on the record, headed by that link\'s own label. A\nrole naming two fields would move the ambiguity into every shape that reads it.\n\nA COMPUTED field is admitted where its RESULT is what the role needs, and\nrefused where it is not: `identity` and `reference` need `text`, `when` a `date`,\n`mark` `files`. A formula\'s result is its `output_type`; a rollup\'s and a\nlookup\'s is read through the walk. So a formula yielding a number is refused in a\nname\'s place, a lookup landing on anything but `files` is refused as a `mark`, and\na formula declaring NO result is refused by all four \u2014 naming `output_type` as\nthe remedy, because a value drawn as a type nobody stated is the wrong cell with\nnothing saying so. The result also decides the DEVICE every screen draws the value\nwith: a rollup taking the `latest` of a set of dates is drawn as a date, a lookup\nof a select as the words it holds.\n\nA bare role name is the common form. A role that is read against a SECOND field\ntakes the object form:\n\n- **`measure`** names its limit: `against` \u2014 a constant, or a field on the same\n entity the row states as a number (a `number`, or a formula or rollup whose\n result is one) \u2014 and `alert`, which side of it needs attention, `over` a\n capacity or `under` a minimum. The two come together.\n- **`measure`** names what its number COUNTS, where the unit changes how the row\n is DRAWN rather than how the figure reads: `counts: "days"` spans a stop across\n that many days of a run, so a three-night stay fills three of its days instead\n of only the one it starts on. Stated, never inferred \u2014 nothing about a `3` says\n whether it is nights, pallets or hours, and a quantity drawn across a week is a\n run nobody can read.\n- **`lifecycle`** names the stages that END the flow: `outcomes`, option aliases\n of that select. A row in one has ARRIVED \u2014 delivered, cancelled, written off \u2014\n so a desk draws them apart from the ladder instead of chaining them after each\n other. Not every option may be an outcome, and terminal-ness belongs here, never\n written into a stage\'s label.\n- **`amount`** names the REFERENCE it is read against: `against`, a field on the\n same entity the row states as a number. A price against the list it is quoted\n off, a rate against the going one \u2014 and the catalogue item\'s record draws the\n pair on one axis with the gap said in words. Never a constant (a price typed\n into the model ages with nothing to update it) and never an `alert`: a limit is\n a ceiling a row can pass, and beating a reference is the point.\n- **`amount`** names the select that SIGNS it: `signed_by`, a select on the same\n entity, and `outflow`, the options of that select under which the amount is\n money going out. Without it every shape over the ledger adds both directions\n together: a month whose net was small reads as the total of everything that\n moved, and the trend plots one rising line. The two come together, and\n `outflow` is some of that select\'s options, never all.\n- **`expected_set`** names what CONDITIONS the set: `required_by`, a field on the\n same entity, and `options`, which of that field\'s values require which entries.\n A row whose value is not named requires nothing. Without it the denominator is\n every option, so a set whose entries are mutually exclusive by kind reads\n `1 of 4` on every complete row \u2014 a denominator no row can reach is worse than\n no role. The field is a single `select`, keyed by its option aliases \u2014 or a\n YES/NO the row answers itself (a `boolean`, or a formula resolving to one),\n keyed by `"true"` and `"false"`: a check that FAILED owes its consequence \u2014 the\n photograph, the owner, the day it is due \u2014 and there is no select of the record\n that says so.\n- **`obligation`** names what CLOSES it: `satisfied_by`, a `date` or a `files`\n field on the same entity \u2014 the day it was done, or the paper that proves it \u2014\n and optionally `label`, what is OWED as the reader says it ("G\u1EEDi SI"), which is\n not what the column holding the date is called ("SI cut-off"). Both are\n required in the sense that matters: without the stamp every obligation the\n business ever met stays on the desk, so the role is refused without one.\n\n```jsonc\n"field_roles": {\n "product": { "name": "identity", "photo": "mark" },\n "stock": { "on_hand": { "role": "measure", "against": "minimum", "alert": "under" },\n "uptime": { "role": "measure", "against": 80, "alert": "under" } },\n "order": { "stage": { "role": "lifecycle", "outcomes": ["delivered", "cancelled"] },\n // The day the papers are owed by, and the day they went.\n "papers_due": { "role": "obligation", "label": "File the papers", "satisfied_by": "papers_sent" } },\n // `kind` is the direction select \u2014 options `received` and `paid_out`, no role of its own.\n // Every figure on a quotation is in the currency that row names.\n "quote": { "unit": "currency", "total": "amount" },\n // What the price is quoted off, so the item\'s page reads one against the other.\n "service": { "price": { "role": "amount", "against": "list_price" } },\n "payment": { "value": { "role": "amount", "signed_by": "kind", "outflow": ["paid_out"] },\n // A receipt owes a receipt voucher; a payment owes an invoice and a payment voucher.\n "papers": { "role": "expected_set",\n "required_by": { "field": "kind",\n "options": { "received": ["receipt_voucher"],\n "paid_out": ["invoice", "payment_voucher"] } } } },\n // A CHECK THAT FAILED OWES ITS CONSEQUENCE \u2014 keyed by the row\'s own answer,\n // and the answer that owes nothing is omitted rather than named with an empty list.\n "check": { "passed": "verdict",\n "evidence": { "role": "expected_set",\n "required_by": { "field": "passed",\n "options": { "false": ["photo", "owner", "due"] } } } }\n}\n```\n\nIn a file that starts from a preset (\xA7 Starting from a preset), `field_roles`\nmay name the preset\'s fields as well as this business\'s own; a role the preset\ndeclares itself is kept unless this file names the same field, and `null`\nclears it.\n\n## Write rules\n\n`write_rules` is what only a CREATE meets \u2014 keyed by entity alias, like\n`field_roles`, and this file\'s in the same way: `check` proves it and the\nworkspace never sees it. An update names one field that moved; a create takes a\ndraft whole, so it has to know which row a name already belongs to, where a\nvalue comes from when nobody types it, and what a figure or a picker may hold.\nThe generator writes one `create_<entity>` workflow and one `New<Entity>Dialog`\nper entity an app can operate, from these clauses and the fields\' own\n`required`.\n\n```jsonc\n"write_rules": {\n // A customer is RECOGNISED by their address. Where an entity names a customer\n // as its `party`, the create takes the email instead of a picker, reuses the\n // row it matches and mints one only where nothing does \u2014 so the book never\n // grows a second Acme because somebody typed the name differently.\n "customer": { "natural_key": ["email"] },\n "order_line": {\n "fields": {\n // A line of nothing is not a line. Refused on create and on update, at\n // the control the figure was typed into.\n "quantity": { "min": 1, "max": 9999 },\n // The price is fixed at the moment of ordering \u2014 COPIED off the product,\n // not looked up for ever after, so the price list moving next week does\n // not silently reprice an order already placed.\n "unit_price": { "default_from": "product.price" },\n // And nothing is sold off an empty shelf. The picker reads only the rows\n // that answer this, and the write refuses the same rows again \u2014 a caller\n // who never opened the picker is bound by it too.\n "product": {\n "options_where": {\n "node_type": "group", "logic": "and",\n "children": [{ "node_type": "condition", "type": "number",\n "field_key": "in_stock", "operator": "greater_than", "value": 0 }]\n }\n }\n }\n }\n}\n```\n\n| Clause | On | Meaning |\n|---|---|---|\n| `natural_key` | the entity | the field aliases a row is recognised by. A `text` key declares `unique: true` on the field itself \u2014 two rows sharing it would make find-or-create pick whichever the read answered first \u2014 and a key is `text` or `number`, because a person types it back |\n| `default_from` | a field | `"<link alias>.<field alias>"` \u2014 the value is copied from the linked row when the row is created, never asked. The link is a one-row link on this entity and `required`, because there has to be a row to read, and the two field types must match |\n| `min` / `max` | a `number` | the figure is refused outside them, on create and on update |\n| `options_where` | a `select_record_link` | an `and` group of plain conditions over the TARGET\'s own fields, each `field_key` a field alias. It narrows the picker\'s read and is refused again where the write lands, so the link is `required` |\n\nA field\'s own `required` is not here: the contract carries it, every write path\nrefuses the row by field name from it, and the generated panel stands its commit\ndown until the same set is filled. `unique` is the text field\'s own clause\n(\xA7 `text`) \u2014 the create says so at the control before the column does.\n\nWhat a create asks is what a person STATES. A `default_from` field is not drawn,\na `lifecycle` opens at its first rung, the stamp an obligation\'s `satisfied_by`\nnames is not asked (the row is only now taking that on), and files are attached\nto the row afterwards \u2014 one fact, one place. `scaffold check` prints every clause\nunder its entity in **Who writes what**.\n\n**The `parent` is prefilled only in the door that mounts the act.** A row opened\nfrom the record it hangs under takes that record from the door it was pressed\nin, so the link is neither asked for nor drawn. The same entity\'s act on its own\nregister has no such row, so there the parent is an ordinary picker the draft\ncannot be committed without \u2014 a payment opened from a money app belongs to a\ncase either way.\n\n## Apps and screens\n\n`apps` is the plan: each app the reader will build, as ONE REGISTER \u2014 a SHAPE\nover an ENTITY \u2014 under `screen`. Nothing here is built by the scaffold: the plan\nis what `lotics scaffold check` prints back, register by register with the field\nin every slot, so it is read and corrected before a screen exists.\n\n**ONE APP IS ONE REGISTER AND THE RECORDS IT OPENS.** A second register beside\nit is a second job on one page: the reader arrives on whichever the nav listed\nfirst and decides, every time, which of the two they came for. So an app has one\ndestination and everything else about a row is DISCLOSED by opening it \u2014 the\nfacts by tier, the children as lists, the related as counts, one primary act, and\na filter or a group where a strip of destinations would have been. What used to\nbe a second screen is a section of the record, or another app; a model still\nsaying `screens` is refused by name.\n\n**The unit of an app is a JOB, not a person and not a table.** A job has its\nown outcome (something exists or is settled when it is done), its own subject\n(the record it advances), and an end that does not wait on the rest of the\nwork. Two tasks are ONE job when neither finishes without the other and both\nadvance the same record. One app per job, and that is what makes an app a\nwrite-ownership boundary: what the job settles is its app\'s alone to write,\nplus everything it must read to settle it well. A person holding several jobs\nopens several apps \u2014 one app for everything one person does is the dump. A step\nof the job is a section of the record, never a register beside it; a reference\nthe job needs at hand is read inside the record it is about.\n\n**Two people signing is two jobs**, because the outcome belongs to the signer:\nthe desk that prepares against the gate that releases. So is a different\ncadence \u2014 reference data edited monthly and read by the public site, beside a\ndaily desk \u2014 and a different reader, the owner\'s read-only questions. Device,\nplace and step never split a job. A super app holds more than one job; an\nover-split holds less than one, a job cut by device, place or step. The count\nper workspace falls out of the jobs, typically two to six, and is never the\ninput: review the plan app by app, naming who holds it, the job in one\nsentence, what it writes and what it reads.\n\n*A two-van appliance repair shop.* The technician\'s job is the call-out: the\noutcome is a visit done, the subject is the call-out record, and quoting it and\nscheduling it are one job, because neither finishes without the other and both\nadvance that record. The owner holds two of his own \u2014 billing the month\n(outcome invoiced, subject the invoice, settled against visits already closed)\nand the price list the booking page quotes from, edited monthly. Three jobs,\nthree apps, and the owner opens two of them.\n\n**Inside a job the caps hold**: six flow stages on a desk, seven facts before a\nrecord\'s fold. Past them, look for the second job hiding inside. And a job\'s app\nis DENSE: every create the job needs lives in it, so its user never leaves it to\ncorrect a figure the screen in front of them is stating \u2014 three workflows is a\nscreen, not a desk.\n\n**A handoff is a stage change.** Where one job ends the record moves stage and\nthe next job\'s desk opens on it; a field two jobs must both write is declared\nshared at the split, naming the stage each may write it in.\n\n**Name an app for the JOB, in the trade\'s own words, never for who it is for.**\nA title on the door says nothing about what the person who opened it came to\ndo, and it is wrong the day the org chart moves.\n\n```jsonc\n"apps": [\n {\n "alias": "sales", "name": "Sales",\n "description": "\u2026", "icon": "briefcase", "theme": { "color": "blue" }, // optional\n "screen":\n { "alias": "orders", "label": "Orders", "shape": "lifecycle_desk", "entity": "order",\n "record": "drawer", // optional \u2014 "drawer" | "page"; absent, the shape decides\n "tabs": "stage", // optional \u2014 a select on the entity, or null; absent, the shape decides\n "writes": false, // optional \u2014 default TRUE; false makes this screen a reader\n "period": "due_date", // optional \u2014 any field whose value is a date, derived ones included\n "filters": ["kind", // optional \u2014 a single-select, or a lens the model states itself\n { "label": "Qu\xE1 h\u1EA1n l\u01B0u", "predicates": [\n { "label": "\u0110\xE3 qu\xE1", "tone": "red",\n "where": { "node_type": "group", "logic": "and", "children": [\n { "node_type": "condition", "field_key": "owed", "operator": "greater_than", "value": 0 }] } }] }],\n "summary": { "totals": ["total"], "above": ["total", { "field": "owed", "at": "end" }],\n "ageing": { "amount": "owed", "due": "due_date", "buckets": [30, 60, 90] } }, // optional \u2014 see below\n "facts": { "groups": [{ "caption": "Pricing", "fields": ["rate", "surcharge"] }] }, // optional \u2014 the record\'s named bands\n "presentation": { "lead": "none", "density": "dense" }, // optional \u2014 how it is DRAWN; absent, what the rows are decides\n "acts": { "row": [{ "label": "Issue the note", "template": "debit_note", "place": "cta" },\n { "kind": "agent", "label": "Read the papers", "agent": "reader", "fills": ["ref", "due_date"] }],\n "record": [{ "label": "Print the file", "template": "dossier" }], // optional \u2014 the record\'s own header menu\n "selection": [{ "label": "Statement", "template": "statement" }], // optional \u2014 the work it hands on\n "export": true, // optional \u2014 the rows in view, saved\n "import": { "kind": "import", "label": "Upload the sheet", // optional \u2014 a file turned into rows\n "entity": "order", "key": "code", "columns": ["rate"] } },\n "section_acts": { "line": [{ "label": "Chase the lines", "template": "chaser" }] }, // optional \u2014 a verb on one section\n "slots": { "identity": "code", // optional \u2014 slot \u2192 field, where the roles cannot decide alone\n "stage": { "field": "state", "quick": true } } } // \u2026or the field AND the reader\'s own control for it\n }\n]\n```\n\nA shape is a proven screen with named SLOTS, each filled by a field carrying a\nrole (\xA7 Field roles). A slot with exactly one candidate on the entity binds by itself;\ntwo candidates need naming in `slots`; a field fills one slot; a required slot\nwith none is refused \u2014 a lifecycle desk over an entity with no `lifecycle`\nselect cannot be built.\n\n**A QUICK SLOT IS FOR A DECISION THE READER MAKES FROM THE ROW ALONE.** A slot\'s\nvalue in `slots` is normally the field\'s alias; `{"field": \u2026, "quick": true}`\nsays the column is not a reading of that value but the CONTROL for it \u2014 pressed,\nit writes that one field as a diff through the record\'s own update, so the\nregister and the page behind it land the same write. Only two roles are such a\ndecision, and `quick` on any other is refused: a `lifecycle`, which rests as the\nstage chip and opens the field\'s own stages with the ones that END the flow last,\nand a `verdict`, which is the switch. Both carry the record\'s own blockers \u2014 a\nmove the ladder holds is a move the cell holds, and it names the entries the row\nstill owes \u2014 and an outcome asks before it commits. Add `"order": "sequence"`\nwhere the stages are a WALK rather than a set of destinations: the cell is then\nthe next step alone, the same advance `RecordProgress` draws under the run, and\nthe doors out of the flow stay on the record. A screen stating `writes: false`\nrefuses `quick`: a reader who may operate nothing has no decision to take.\n\n| Shape | Answers | Required | Also fills | Record | Tabs |\n|---|---|---|---|---|---|\n| `lifecycle_desk` | what is stuck, what do I move next | `lifecycle`, `identity` | `mark`, `party`, `amount`, `measure` (level), `when` | page, or drawer where the entity carries `parent` | the lifecycle\'s stages |\n| `party_register` | who is this, our history, is there a risk | `identity` | `mark`, `contact`, `measure` (worth), `verdict` (risk) | page | none |\n| `offering_register` | what do we offer, at what price, can I sell it | `identity` | `mark`, `amount` (price), `measure` (availability) | page | none |\n| `transaction_ledger` | does this period reconcile, what is unexplained | `when`, `amount` | `identity` (reference \u2014 the line\'s own number), `party`, `lifecycle` (classification), `expected_set` (document) | drawer | none |\n| `monitored_asset_set` | what needs attention, is that number normal | `identity`, `measure` (level) | `mark`, `lifecycle` | drawer | none |\n| `obligation_desk` | what is due next, and has it been done | `identity`, and at least one `obligation` on the entity | `when` (runway) | page | none |\n| `trend_deep_dive` | how did the period go, and why | `when` | `measure`, `amount` | drawer | none |\n| `reconciliation_desk` | what does not match, and by how much | `identity` (reference), and two figures \u2014 `ours` and `theirs`, each an `amount` or a `measure` | `category` (reason), `verdict`, `when` | drawer, on the PAIR | the dated runs, where the model names a select |\n| `entry_matrix` | one value per subject per period | `identity` (subject), `across` \u2014 a `when`, or a `category` for a fixed column set | `measure` (value), `lifecycle` (state), `category` (note) | drawer, on the cell\'s row | none |\n| `guided_run` | complete an ordered sequence, a step at a time | `identity` (step) | `slot` (sequence), `capture` \u2014 a `measure`, `verdict` or `expected_set` \u2014 `verdict` (gate), `mark` (media) | page: the run IS the record | none |\n| `live_board` | is everything OK, right now | `lifecycle` (state), `identity` | `verdict` (exception), `when` (since), `measure` (level), `category` (where) | drawer | none |\n| `group_register` | what does each group come to, and what is behind it | `subject` \u2014 a `category`, `party` or `when` | `amount`, `measure`, `identity` (member) | page: a row is an aggregate, and the door is the SET behind it | none |\n| `media_set` | scan a body of pictures by where they belong | `mark` (picture), `identity` | `category` or `party` (group), `when`, `verdict` (current against superseded) | drawer | none |\n| `day_sheet` | what happened on each day | `when` (the day) | `lifecycle` (state), `measure` (level), `amount`, `category` (note) | page: a day composes several child logs | none |\n| `resource_schedule` | what is on each resource, in what order, under what ceiling | `lane` \u2014 a `party` or a `parent` \u2014 `identity`, `when` (the start) | `measure` (duration), and `load` and `bulk`, each a `measure` read against a ceiling of the LANE\'s \u2014 a vehicle fills by weight and by room and stops at whichever runs out first; `lifecycle` (stage) | drawer, over the lanes | none |\n| `worksheet` | priced lines the reader edits, totalling to one figure | `identity` | `category` (group), `measure` (quantity), and `cost` and `sell`, each an `amount` or a `measure` | drawer, per line | none |\n\n**A record is one of five KINDS, and the shape plus the roles decide which.**\nEvery one of them is ONE READING COLUMN, and the ORDER of that column is the\nwhole of what the kind means.\n\n| Kind | The column, top to bottom |\n|---|---|\n| WORK RECORD \u2014 a `lifecycle_desk` over rows belonging to nothing | the one act that moves it \xB7 what it IS \xB7 the papers it owes \xB7 the rows it is made of \xB7 the book of what it came to \xB7 its own words |\n| LINE \u2014 the same shape over rows carrying `parent` | its ladder \xB7 the arithmetic behind its amount \xB7 the rows and papers under it \xB7 its particulars, last (the key it is filed by is the line under its title) |\n| PROFILE \u2014 a `party_register` | its history, OPEN, grouped by year \xB7 the rest of its registers \xB7 its own words \xB7 its facts (the ways to reach it are under its name, its settlement is the band) |\n| CATALOGUE ITEM \u2014 an `offering_register` | its own words (the picture leads the header at media scale, the price is read against its reference in the band) \xB7 what uses it \xB7 its facts |\n| EVIDENCE \u2014 a `transaction_ledger` | the PROOF \xB7 what the paper is, compactly \xB7 the rest \xB7 the links to the record it settles and the party it was with |\n\nNothing in the file states the kind: a clause that could say it could say the\nwrong one, and `parent` already says whether a row stands alone. `record`\noverrides the DOOR where the shape\'s own answer is not the one wanted \u2014 and its\nthird value, `"expand"`, is not a door at all: the row REVEALS what it holds in\nplace, where there is nothing behind it worth navigating to. A row read that way\nreads as a LINE whatever the shape would have opened: its ladder, the arithmetic\nbehind its amount, then its particulars, because there is no header above it to\nhave stated the name first. It is REFUSED for two reasons, and they are not the\nsame refusal. On a shape whose record is a page by nature \u2014 a `guided_run`, a\n`group_register`, a `day_sheet`, an `obligation_desk` \u2014 because each of those\nstates the opposite where its door is declared: what its row leads to is read\nwhole, not as four more columns than the register had room for. And on a shape\nthat draws its rows with a device of its OWN \u2014 an `entry_matrix`, a `media_set` \u2014\nbecause a cell of a grid and a tile on a wall have no band under them for the row\nto reveal into. For the same reason a revealed register is drawn as a TABLE: a\n`presentation.layout` of anything else is refused, and the reader is not offered\nthe arrangement either, since it would take away the only door the register has.\n\n**`scope` \u2014 the one subject every read of the app narrows to.** Stated on the\nAPP rather than on its screen, because it is not a filter: a record opened under\nit stays under it, the pick survives closing the app, and every app declaring the\nsame `entity` shares one pick per viewer \u2014 which is the whole value, and what\nstops a construction workspace asking which project twelve times. `entity` is the\nmodel\'s entity whose one row the app is read inside, and `param` is what the\napp\'s reads take that row by.\n\n```jsonc\n{ "alias": "site_work", "name": "Site work",\n "scope": { "entity": "project", "param": "project_id" },\n "screen": { \u2026 } }\n```\n\nThe register\'s own entity has to REACH it \u2014 its own rows, or rows that name one\nthrough a link \u2014 or the switcher is a control that changes nothing. Until a\nsubject is picked the app reads NOTHING and says which one it is waiting for: an\nunscoped read over a scoped app is every row in the workspace drawn as one\nproject\'s work, which looks correct.\n\n**ONE RECORD SURFACE PER ENTITY, AND ITS KIND IS THE ENTITY\'S.** An app is one\nregister, so the record its rows open is the register\'s own and the kind comes\nfrom that register\'s shape. Another app over the same entity opens the same kind\nof record: a payment read from a cash book and one read from a trend are both\nmovements, and both read proof first. A shape with no kind of its own (a trend, a\nmonitored set, a `custom` screen) says only where the record opens.\n\n**Two things a register states about a row beyond which field fills which slot.**\nA `measure` is read AGAINST its limit (`against`/`alert`) where the shape\'s slot\ntakes one \u2014 `level`, on a lifecycle desk and a monitored set, and every slot of a\n`custom` screen, which composes the frame\'s own column; `worth`, `availability`\nand a trend\'s headline are plain figures, and a limit there would read as a meter\nin the record over a number the row printed bare. And the name carries a\nSUPPORTING LINE where the entity states a key to file the row under: the `parent`\nit belongs to, its `reference`, an `autonumber`, a text formula \u2014 in that order,\nfirst hit wins, never a field a person types prose into. A LINE\'s record heads\nwith that same key, so the register and the door it opens name one row one way.\n\n**The act that opens a row belongs to a register that LISTS the entity\'s rows**\n\u2014 a desk, a register, a ledger, a monitored set, or a `custom` screen. A shape\nwhose rows are not records has neither that act nor the door a counted line\nelsewhere leads to: an obligation desk\'s rows are deadlines and a trend\'s are\nperiods, so a create pressed there would open a row the screen cannot show. An\nentity whose only app is one of those has no create at all \u2014 give it an app whose\nregister its rows are read in.\n\n**An `obligation_desk`\'s rows are not its entity\'s rows.** The shape fans the\nentity\'s `obligation` fields out \u2014 one row per thing still owed, which is one\nwhose date is SET and whose `satisfied_by` is empty \u2014 so a l\xF4 with four cut-offs\nis four rows, and the three it has met are not there at all. The row\'s name is\nthe obligation\'s, its supporting line the record\'s `identity`, and the door is\nthat record. A `when` bound to `runway` is what the countdown\'s ring is measured\nfrom. Over an entity declaring no `obligation` the shape is refused: there is\nnothing to count down to.\n\n**What a register carries beyond its columns.** Six clauses, each emitted as\nthe prop the kit draws it with; every one is optional and a screen that states\nnone gets a register of columns and nothing else.\n\n- **`period`** \u2014 a field on the entity whose value is a DATE. The toolbar gains a\n date-range control (this month to start), and the rows it keeps are what the\n register AND every figure below are computed over, so a band can never be over a\n different window than the rows under it. Any such field, not only the `when`\n role, and however the date got there: one table is read two ways (a cost ledger\n by the day a line arose, the cash book over the same table by the day it\n settled), a role is one field\'s, and the day money MOVED is often a formula over\n the two columns that could hold it \u2014 which then declares `output_type: "date"`.\n A `trend_deep_dive` brings its own period and takes none here; a `live_board`\n is PERIODLESS and takes none either, for the opposite reason \u2014 it answers what\n is true NOW, and a date range over it draws what was true in the window in the\n live state\'s own colours.\n- **`filters`** \u2014 the chips beside the search. A single-`select` field\'s alias\n offers that field\'s own live options; it is the lens a register is read through\n over and above the one band its strip gives, so the strip\'s own field is\n refused here (one dimension, one control) and so is a multi-select (a row would\n answer the chip several ways at once). The rows the register and every figure\n below are computed over are what the chips AND the period kept.\n A DERIVED LENS is the other form \u2014 `{"label": \u2026, "predicates": [{"label": \u2026,\n "tone": \u2026, "where": \u2026}]}` \u2014 where the sets a reader narrows by are ones the\n MODEL names and no column holds: rate validity, plan urgency, free-time\n overrun. Each `where` is an `and` group of plain conditions over the entity\'s\n own fields, each `field_key` a field alias, and it is read off the row, so the\n operators are the comparisons a value answers \u2014 `number` and `text` equality,\n `number` ordering, `boolean` equality, `select` `has_any_of`/`has_none_of`, and\n `is_empty`/`is_not_empty` on any of them. Nothing DATED: a relative point needs\n a clock, and a screen states one date control (`period`), so a second beside it\n would be two windows with nothing saying which a figure was computed over \u2014 a\n date question is asked as a column the model derives and read here as a number\n or a yes/no. `tone` is the option palette\'s, because the chip draws these\n exactly as it draws a select\'s options.\n- **`summary`** \u2014 what the rows in view come to. `above` is a band OVER the\n register: `"counts"` for how many rows are in view and, for the FIRST\n `measure` the model gives a limit, how many are past it (accented only when\n one is) \u2014 one such set, because a band naming three is the dashboard a\n register is not; a list of number-field aliases for what they add up to, and a\n `percentage` named there is refused, since a share summed over the rows in\n view is a figure of no kind (draw it as a column, whose own total reads the\n mean). A figure written `{"field": \u2026, "at": "end"}` is a STOCK instead: the\n reading standing at the END of the window rather than the sum of the readings\n in it, which is the only way opening + in \u2212 out = closing reconciles \u2014 a month\n of daily closing stocks added together is thirty warehouses. The band labels it\n as the snapshot it is, and it is refused on a screen that states no `period`,\n since there is then no window for it to stand at the end of.\n `ageing` stands in that same\n band: an `amount` split by how many days past its `due` date each row is,\n drawn as a `Breakdown` of the edges in `buckets` (30, 60 and 90 days unless the\n model states its own, ascending). The ladder is the kit\'s \u2014 its boundary, its\n words and its ramp \u2014 and it opens with what is NOT YET DUE, because a bar of\n overdue bands alone is full at every input: a book with one late invoice and a\n book that has gone entirely bad would paint the same solid width. Against the\n current money the bar\'s own shape is the reading. A row whose date is empty is\n in no band. `totals` is what the whole\n view adds up, and it is drawn in that SAME band above the rows: a register\n states one aggregate, over the rows the reader can see, in one place, and a\n closing line under them is a second place to look and a second question about\n which rows it covers. A closing line belongs to the one device that IS a\n statement \u2014 a record\'s `Ledger`, whose balance is what it is read for, labelled\n by its amount\'s own word. Where a shape has no band above (a trend, whose own\n band is the chart) the figure stands under the register instead. A\n `transaction_ledger` sums its amount column itself and takes `above` alone. An\n `obligation_desk`\'s rows are DEADLINES and not records, so its band states\n `"counts"` \u2014 how many are open \u2014 and a figure list or an `ageing` over it is\n refused: a l\xF4 with three cut-offs is three rows, and its freight added over\n them is three times the freight.\n- **`facts`** \u2014 how the RECORD\'s facts are banded. `groups` is a list of\n `{caption, fields}`: what a set of facts has in common is a sentence about the\n business and nothing derives it, so a plan that states one gets exactly those\n bands, in its own order, captioned. Everything it leaves unnamed reads in the\n derived order below it. A field the header, the band, the ladder, a required\n set, the files or a register below already draws is refused: it is not a fact,\n so a band naming it would either state it twice or draw nothing. Naming any\n band IS the answer to which facts fold \u2014 see the TIERS below.\n- **`presentation`** \u2014 how the screen and the record it opens are DRAWN, where\n what the rows are is not the answer wanted. `lead` is what each row leads\n with: `"mark"` the subject\'s own mark, always spent because a party\'s initials\n stand in for a picture nobody uploaded; `"picture"` the photograph, spent only\n on the rows that carry one and, on the record, at media scale above the name;\n `"figure"` the amount, with no gutter at all; `"none"` neither. `density` is\n how many lines a row\'s subject may take \u2014 `"roomy"` two, `"dense"` one.\n Both default, and the DEFAULT is what the rows are: a party register leads\n with the mark, an offering register with the picture, a ledger with its\n figure, and a desk \u2014 rows that are work still to do, scanned \u2014 stands its rows\n one line each while a record\'s own rows are read roomy. So state it only to\n differ, and a plan that states nothing still gets screens that vary. A lead\n the rows cannot carry is refused: a mark where no field is drawn as one, a\n figure where no column is the amount, and the other subject\'s mark (which of\n the two a row wears is the shape\'s, so the register and the record it opens\n cannot call one row two kinds of thing).\n `layout` is how the rows are ARRANGED \u2014 `"table"`, `"list"`, `"cards"`,\n `"gallery"`, `"board"`, `"calendar"`, `"timeline"`, `"gantt"` \u2014 and it is\n AFFORDED by what the rows carry, not by the shape: every register affords a\n table and a list, a `mark` affords the two picture-led grids, a `lifecycle`\n affords a board, a `when` affords a calendar and a timeline, and a `when`\n beside a `measure` affords a gantt, because a bar with no length is a calendar\n drawn sideways. A layout the bound roles do not afford is refused, naming the\n ones they do.\n- **`acts`** \u2014 where this screen hands work on: `row` is the \u22EF menu on every row,\n `record` is the \u22EF on the RECORD\'s own page header \u2014 the same reach as a row\'s,\n standing where the work is open instead of while a list is scanned, and refused\n on a record that opens as a drawer, which has no header of its own and whose\n row already carries the register\'s menu \u2014 and `selection` is the bar over the\n TICKED rows. `export` is the third reach \u2014\n the WHOLE VIEW, saved in one press: `true` writes the rows as the register drew\n them, in the columns it drew, as an `.xlsx`, and\n `{"template": "\u2026"}` says the layout is the trade\'s and makes the paper with a\n workflow like any other. It is never a list of columns: which columns the\n export carries is what the screen already draws, and a second statement of it\n disagrees the moment a slot moves \u2014 the generator reads them off the drawn\n slots and writes them into the app, so the sheet\'s headings are the labels the\n register drew them under.\n `import` is the reach OPPOSITE it \u2014 a file turned into rows:\n `{"kind": "import", "label": \u2026, "entity": \u2026, "key": \u2026, "columns": [...]}`. The\n file is mapped column by column, each row is validated, what would change is\n previewed, and the commit UPSERTS by `key` \u2014 which is why a sheet sent twice\n moves no counts. That key is one of the entity\'s own `natural_key` fields\n (\xA7 Write rules), and an entity declaring none is refused: without a key the\n second run is a second set of the same rows. `entity` is the register\'s own \u2014\n an app is one register, and rows filed into another entity are a count nobody\n on this screen can check. `columns` is what the sheet may fill, in that order;\n absent, it is every column a person states IN WORDS. A computed one is refused\n because the workspace writes it, and a link, a member or a files column is\n refused because a cell of a sheet is words and those hold a row of another\n table or bytes \u2014 a file reaches another table through the party pattern (a\n `natural_key` on the target, named rather than picked), which is a create\'s.\n The generator writes the whole verb, as it does for a paper: an\n `import_<entity>` workflow that finds the row by `key` and then changes it or\n opens it, answering which of the two, and the staged run over it \u2014 drop, map,\n the per-row verdict, the commit. A line whose cell the column cannot hold is\n refused with the reason and the rest of the sheet still lands.\n Both spreadsheet reaches are read and written IN THE BROWSER, so the generated\n `src/main.tsx` imports `@lotics/app-runtime/sheets` wherever the plan states\n one of them and not otherwise; an app that has neither carries no spreadsheet\n engine at all. An act names ONE of two things, never both:\n - **`template`** \u2014 an `html` template this model declares (\xA7 Templates). The\n paper is filled from ONE row in `row`, and from the whole ticked set in\n `selection`. The generator writes the whole verb \u2014 a `generate_<template>`\n workflow that reads the row and hands the file back, and a menu item that\n opens it \u2014 so there is nothing left to bind. An `email` template is sent\n rather than opened and is refused here; a file-backed template (`excel`,\n `word`, `pdf-form`) is bytes a model has none of, so those stay the author\'s\n own `lotics app workflow set`. Two screens over DIFFERENT entities cannot\n name one template: one paper is filled from one kind of row.\n - **`{"kind": "agent", \u2026}`** \u2014 a run over ONE row. `agent` is an alias the APP\n declares (`package.json#lotics.agents`), because an agent is prose and tools\n rather than anything a workspace model can state \u2014 `lotics app check` refuses\n one nothing declares, exactly as it refuses a workflow nothing bound. `fills`\n is the fields the run may write: pressed, the reader watches the run, reviews\n what it proposes field by field against what the row holds now, and applies\n the ones they kept as ONE write through the record\'s own update. A run can\n reach nothing outside `fills`, a computed column there is refused (the\n workspace writes those), and a screen stating `writes: false` refuses an\n agent act outright. It belongs in `row`; the selection bar makes ONE paper\n from many rows, and a run per ticked row is the row\'s own act many times.\n\n **`"place": "cta"` draws an act ON the row** instead of in its \u22EF \u2014 the one verb\n a reader presses without opening a menu first. At most one per screen and every\n other act stays in the menu, because the trailing gutter is paid for by every\n row: a second is refused with *one verb on the row, the rest in its menu*. The\n ticked set has no row of its own, so `cta` is refused there.\n\n Every other verb \u2014 record an interaction, close a request, push a voucher \u2014 is\n a workflow the author writes and binds with `lotics app workflow set`; a plan\n that could name one would scaffold a button wired to a body nobody had written.\n\n A `selection` act is a `template` and nothing else: an act over many rows makes\n ONE paper from them. The generator turns the register\'s checkbox gutter\n on, emits a `generate_<template>` workflow taking the ticked ids, and the body\n reads each row and passes them as `rows` \u2014 which is what the template iterates\n (`{{#each rows}}`). One template is one paper AND one reach: naming the same\n one from a row\'s \u22EF and from the bar is refused, because those are two bodies.\n A shape whose rows are DERIVED rather than records \u2014 an `obligation_desk` \u2014\n has no set to tick and refuses the clause. The paper is handed BACK, never\n filed: a ticked set can hold rows of three different parents, so there is no\n record on which "this document belongs here" is true.\n\n**`section_acts` puts a verb WHERE ITS EFFECT LANDS.** "Chase the paperwork"\nbelongs on the papers, not in the register\'s \u22EF two surfaces away. It is keyed by\nthe alias each section is DERIVED from \u2014 a field\'s (its progress, its prose, its\nrequired set, its charge, one of its files) or a CHILD ENTITY\'s (its register,\nits desk, its run, its thread) \u2014 because the sections come off the roles and the\nkey the app addresses one by does not exist until it is built. An alias naming no\nsection is refused, listing the ones that do; the facts band is what every other\nsection left over, so nothing addresses it. Each act is a `template` or an\n`agent`, exactly as a row\'s is, and `place: "cta"` is refused \u2014 a section is a\nband with a heading and has no row to draw a verb on. `scaffold check` prints\nwhich section each alias landed on, which is the half an author cannot see in\ntheir own file.\n\n`scaffold check` prints every clause a screen states, on its own line under the\nslots, spelling the screen\'s `writes` clause `operable` \u2014 the app manifest\'s\n`lotics.writes` keeps that word, and the two are different facts: `app create`\nSEEDS the manifest from the fields the screens\' own editors write, and from then\non the app owns the declaration `lotics app check` holds its bodies to.\n\nEach shape is a `@lotics/ui` component of the same name (`LifecycleDesk`,\n`PartyRegister`, \u2026) whose props are these slots, so once the tables exist\n`lotics app create <name> --from <this file>#<app alias>` scaffolds the app with\none screen per entry, each slot reading the field the plan bound.\n\n`"shape": "custom"` is the screen no registry row covers: it declares its own\n`roles` (slot name \u2192 role), and it is emitted on `ShapeFrame` \u2014 the one anatomy\nthe six shapes above are each a configuration of \u2014 so its strip, search,\nordinal, fit budget, empty and waiting states and record door are the same ones\nthey have. It opens the record its `record` says, drawer or page, like any other.\nA bound `lifecycle` slot IS its strip, the stages in the field\'s order as on a\ndesk, so such a screen names no `tabs`; a `tabs` select is the flat strip a\nscreen with no lifecycle gets.\n\n**An app IS its `app.json`.** `create --from` writes the bound plan \u2014 every\nscreen with its shape and slots, the record each row opens with its sections in\nthe archetype\'s order, the create panels, the acts \u2014 as one JSON document, plus\na five-line `src/main.tsx` that mounts `@lotics/app-runtime` over it and one\n`src/workflows/<alias>.ts` per write. There is no screen source: the runtime\ndraws the spec, so a kit correction reaches every app with its next install.\nWhere the plan has no word for what a screen or a section IS \u2014 or for what a\npaper act should ASK before it is made \u2014 the spec names one of the app\'s own\ncomponents and `src/components/index.ts` registers it, the one file under `src/`\na regeneration never rewrites. `lotics app eject <screen|<section key>|<act\nlabel>>` writes each of those, starting from what the runtime already drew.\n\n**How a spec reads a row.** Each query projects its columns under the alias the\nworkspace mints from the field\'s LABEL \u2014 never the alias this file keys it\nunder, which is the model\'s own namespace and which the workspace never saw. So\na field this file calls `partner` and labels `\u0110\u1ED1i t\xE1c` is referenced as\n`doi_tac` throughout the spec. Every id in it is live: the `tbl_` a screen is\nover, the `fld_` of each column, and the `opt_` of every option a role names.\n\n**Every query is described, in the model\'s own language.** The line an agent\nchooses between aliases by is written from this file\'s nouns \u2014 the entity\'s\nlabel, the screen\'s, the link\'s \u2014 and the sentence around them is the language\nthe file is mostly written in, decided the same way a generated write\'s refusals\nare. Hand-editing one is erased by the next regeneration; `lotics app check`\nrefuses an alias that carries none.\n\nA screen is that list and the RECORD it opens, and the record comes off the same\nroles \u2014 nothing to declare for it. It opens with a **header**: the entity\'s\n`identity` as the record\'s name \u2014 whether or not the list has a column for it, so\na ledger\'s and a trend\'s records are named too \u2014 the `when` this screen reads\nunder it, and ONE headline\nfigure (the `amount` the screen reads, else its `measure`). BOTH DOORS state the\nname: which door a screen uses is a layout answer, and what the record is ABOUT\nis not.\n\n**A JOB\'S HEADER ALSO STATES WHO IT IS FOR.** Where the record is the work itself\n\u2014 a desk\'s rows that belong to nothing, opened on a page \u2014 its `party` is a link\nunder the name, beside the ways to reach the subject, and it is then not a fact\nas well: a page headed by a code alone said nothing about whose work it is, and\nthe one thing a reader arriving from the desk already knows the row by sat in the\ngrid below as a row among forty. A record opening in a DRAWER has no row of links\nto hang it on, so its party stays a fact; so does a LINE\'s, whose register files\nit under the thing it belongs to and whose party is that parent\'s, restated per\nrow.\n\n**A record whose figures are a SENTENCE states them under the header, in a\nband**, and the header then states neither of them: a job reads what it comes to,\nhow far through it is (the `measure` against its limit), what is left of it (a\nformula that is exactly `{amount} - {measure}`) and how long there is (an\n`obligation`, else its `when`, counted down); a party reads its settlement, the\n`amount` and `measure` roles it carries in the order the model declares them; a\ncatalogue item reads its `amount` against the reference that amount names. Two\nfigures are the floor and four the ceiling \u2014 under two, the header keeps its one.\nA band is a PAGE\'s strip, so a screen whose `record` is a drawer keeps every\nfigure where a drawer reads them. Each figure is drawn exactly once: the limit a\nmeter states is not a fact beside it, and the day a countdown counts to is not\nthe provenance line above it. The `lifecycle` is not badged there, because the progress section is the\nrung it stands on. Then its sections, in the order its KIND reads them: the\n**facts** (every field neither the header, the band nor another section owns, the\nrow\'s own `parent` among them \u2014 led by the ones THIS screen\'s slots bound, then\nthe key it is filed under, then the order the model declares them in),\nthe record\'s own **body** (every `text` field the entity formats as `markdown` \u2014\nprose read at the width of the work, never wrapped to a label in a column\nbeside it), the **charge** (where the `amount` is a formula over exactly one\nquantity and one money field, the line states `quantity \xD7 unit price = amount`\nand neither figure is typed twice \u2014 the section is headed by the amount\'s own\nlabel and the closing row says what the figure IS, so the word is said once),\nthe **progress** (the `lifecycle`\'s stages, and the ONE act that moves the record\non \u2014 every other move, outcomes included, is an item in the menu beside it), a\n**required set** (a multi-select `expected_set`, or a child entity whose rows\ncarry one entry of it each \u2014 that child\'s `files` field is what a paper attaches\nto), the record\'s **own rows** (any other child, its role-bound fields as\ncolumns), and its **files** (every `files` field, the `mark` first \u2014 a section of\nthe column like any other, straight after the facts it is the evidence for,\nexcept on an EVIDENCE record, which OPENS on the paper it exists for; the\nheading carries the Add verb, and stands it down while the pile is empty, where\nthe drop well is already the door; and a `verdict` whose FORMULA reads one of\nthose files fields is the pile\'s OWN state rather than a fact row \u2014 the file it\nis short, drawn as a ghost, and the mark the rail sends a reader to). A child is\nan entity that LINKS here, ONE SECTION PER LINK: the rows it owns (`parent`), the\nrows that name it (`party` \u2014 so a party\'s record is its history), and the rows\nreaching it through a link carrying neither role, headed by that link\'s own\nlabel. A child hanging off two records therefore declares one `parent` and\nleaves the second relationship a plain link, which still gets its section.\n\n**THE ROWS A RECORD OWNS ARE ADDED AND OPENED FROM THEIR SECTION.** A `parent`\nsection\'s heading carries the Add for its rows, handing the record as the\nparent \u2014 filled, never asked. Every row opens its own record \u2014 a line of a book,\na paper on a desk, a stop on a timeline alike: where this app already routes a\npage for those rows (the register\'s own, under one of them) it is navigated to;\notherwise it is a DRAWER mounted on the record it belongs to, stepped \u25C0 \u25B6 over\nthe section\'s rows as they are drawn, with the row\'s framed editors, its ladder\nand its files, saving through `update_<child>` \u2014 the one editor those rows have,\nwhich any control the section draws over a row writes through too. Never the\n`parent` link itself: it is the key the row is FILED under, stated by the record\nthe drawer was opened from, so it is neither a fact of the drawer nor an input\nof that editor. ONE LEVEL, and only from a PAGE: a child\'s own children draw as\nthe kit\'s panel of the row, with no drawer and no Add, and a register whose own\nrecord is a DRAWER is the same for the children it owns \u2014 a drawer mounts no\ndrawer. **A ROW IS ADDED WHERE IT CAN BE OPENED AGAIN**, so those sections lose\ntheir Add with their drawer; a row of the register\'s OWN kind keeps it, because\nthe register\'s list opens it. A correspondence\'s entries are the one owned row\nthat opens nothing anywhere: the composer under them is the section\'s Add\nwhatever door the record has, and the answer mark is one field of the reply\'s\nown editor. The rows a `party`\'s record lists, and those reaching a record\nthrough a roleless link, are read only \u2014 a history, never a place to open or\nadd.\n\n**TWO CHILDREN ARE READ AS A RUN RATHER THAN AS A REGISTER**, and the model says\nwhich by the roles it declares on them. A child carrying an `obligation` whose\n`satisfied_by` is a DATE is a **timeline**: its rows are the ordered stops, each\nstating the day it was promised for and the day it happened, with the delta named\nwhere the rung was late \u2014 drawn as a register those are two date columns and the\nreader subtracts them by eye, which is exactly what "three days late at\ndischarge" costs to learn. An obligation closed by a FILE says nothing about\nwhen, so those rows stay the register they were. A child carrying a `markdown`\ntext field beside a `party` and a `when` is a **thread**: the entry is the body,\nthe author and the day lead it, and a `verdict` on the child is the yes/no one\nreply is marked the ANSWER with \u2014 a register of messages is a table whose one\nuseful column is the one it cannot draw. A SECOND `party` on those rows is who\nthe entry was left WITH, and the latest one names the ball in court: while\nnothing answers, the record says whose move it is and counts its own deadline\n(an `obligation`, else its `when`) against them. Both are read only under the\nrecord that OWNS the messages \u2014 under the party who wrote them the same rows are\nthat party\'s history, several questions at once, and the last of them says\nnothing about any. That record also REPLIES: the section\'s Add is a composer \u2014\nthe message is all a reply asks \u2014 and the answer mark is one field of the\nreply\'s own editor.\n\n**NOTHING SITS BESIDE THE RECORD.** A page is ONE reading column \u2014 its fields,\nits files, the rows it owns, its notes, all sections of it in the archetype\'s\norder \u2014 over a reserved left gutter holding a floating NAVIGATION RAIL: the way\nback, then one line per section with that section\'s category mark, and nothing\nelse. No files, no counts, no group headings. Too narrow to seat the rail, it is\none pinned bar of the same list across the top. A section HEADING never wears\nthe mark; the rail\'s line for it does.\n\n**A RELATION THE RECORD NEVER ACTS ON CLOSES THE COLUMN.** Every register that\nmerely NAMES the record is one counted line of a single last section: its label,\nhow many rows name this record (a COUNT, never a page of rows measured), and a\npress that opens the register owning them narrowed to this record. An entity\nthis app lists nowhere is a count with no door, and where every count is zero\nthe section is not drawn at all. The one exception is a PROFILE, whose history\nIS its body and stands open in the column.\n\n**Rows the record owns whose `amount` is `signed_by` a direction are a\nBOOK**, drawn as a statement \u2014 each line dated and signed, the balance under\nthem where there is more than one line to add up, labelled by the amount\'s own\nword \u2014 where the same rows on the party they\nwere transacted with stay that party\'s history, which is scanned for the line\nshort of its paperwork rather than struck to a balance. **Rows that carry a\n`when` read as RUNS**: a party\'s history under the year \u2014 and only where the rows\nin hand span more than one, since a subhead over every line of a book names the\nyear and separates nothing \u2014 and a job\'s own rows under the day they fall on,\nwhich, where those rows also carry a `lifecycle`, is an ITINERARY rather than a\ngrouped table: the day heads the run, each row is one line (its `slot`, its name,\nits party, its stage, its `category` and `reference`, its amount), the figures end\non one edge and the run closes with their sum \u2014 unless the record\'s own BAND is a\nsum rollup over exactly these rows, which states it once already. A `category`\npaints the node the run is read down, in the option\'s own colour; a `reference`\ncloses the line with the code somebody quotes on the phone. Both are PROJECTED for\nthe run whether or not the register\'s column budget would have drawn them.\n**EACH DAY\'S HEAD CARRIES THE SECTION\'S OWN ADD** as well, handing the panel that\nday, so a line made from Tuesday opens with Tuesday answered; the heading keeps\nthe verb too, for the first line and for a day nothing is planned on yet. **THE\nKEY LEAVES THE COLUMNS where the head states the whole of it** \u2014 a day head does,\na year head states four digits of the date and the day is what the column is\nstill there to carry.\n**A run is READ FROM THE END THE READER WANTS**: a job\'s own rows and an itinerary\nascending, a book and a history descending.\n\n**A DESK OVER AN ENTITY THAT CARRIES A `slot` IS ITSELF A DAY\'S RUN.** Its own\nregister is drawn under one subhead per day, sorted by that day and then by the\npart of it, and the date column goes \u2014 the subhead states it once for the whole\nrun, and a column repeating it down every line is the same value twice. Without\nit an itinerary was one flat list in whatever order the rows arrived.\nA document desk keeps its head either way \u2014 the entries it owes are its\ncontent, filed or not. The name and whatever figure is left to it are the header\'s\nalone \u2014 it states them in full, so a fact for either would be the same sentence\ntwice.\n\n**A FACT IS PRIMARY OR IT IS PROVENANCE.** An `autonumber`, a date the platform\nstamps (`derive_from`) and a date a formula works out that no role names are what\nthe SYSTEM wrote; they fold, with every optional field this record does not\nstate, behind the grid\'s ONE link. Everything a person types or picks is primary\nand is shown, empty or not, because absence is work somebody owes. **A screen\nthat named `facts.groups` has already said which:** a field in a group is\nprimary, and one in none folds \u2014 grouping says what the reader decides with, and\nby saying so says the rest are not. A `tier` on a `facts` entry exists only for\nthe case that derivation gets provably wrong.\n\n**A `contact` field whose kind the\nmodel decides** \u2014 an email, a number, a place, a `link`-formatted text, or a\nMESSAGING APP its label names (WhatsApp, Zalo, Telegram, Viber, WeChat, Line,\nMessenger) \u2014 leaves the facts too, for the row of links under the name on a\nrecord that opens as a page; one whose kind nothing decides stays a labelled\nfact, because a scheme nobody stated is a link that opens nothing. A handle is\ndrawn under its app\'s name \u2014 the app is what identifies the person there, and a\nbare number beside an envelope says the wrong thing \u2014 and it dials only where its\nvalue IS a number.\n`check` prints the record under each screen\'s slots:\n\n```\n Orders \u2014 lifecycle desk over Orders (12 rows) \xB7 page \xB7 tabs: Stage (New \u2192 Quoted \u2192 Confirmed \u2192 Shipped \u2192 Done)\n stage Stage \xB7 identity Order no. \xB7 mark Photo \xB7 party Customer \xB7 amount Total \xB7 level (none) \xB7 when Due\n record: header (Order no. \xB7 Customer) \xB7 band (Total \xB7 Shipped against Lines \xB7 Due counting down) \xB7 progress: Stage (5 stages) \xB7 facts (Ship to \xB7 \u2026 2 filed) \xB7 files: Photos \xB7 documents: Papers (Kind: 4 required) \xB7 itinerary: Order lines by Ship by at Window, kind Handling, ref Waybill (Product \xB7 Quantity \xB7 Line total) \xB7 related: Visits (count via Order)\n```\n\nThe ORDER of that line is the record\'s own, top to bottom, and it is the KIND\'s,\nand every entry of it is a section of the one column. `related:` is the counted\nline that closes it \u2014 a register the record never acts on, named and counted \u2014\nand `\u2026 N filed` is how many facts fold behind the grid\'s one link.\nA child register whose `amount` is `signed_by` a direction is a BOOK of movements\n\u2014 what the record came to rather than what it is made of \u2014 so it closes the\ncolumn rather than standing among the rows the job is made of.\n\n**A section over a child is named by the CHILD\'s own label**, because the reader\nalready knows which record they are on: "\u0110\u01A1n h\xE0ng", not "\u0110\u01A1n h\xE0ng \u2014 Kh\xE1ch h\xE0ng".\nWhere two links from one entity reach this record, that label names two sections\nand each takes its own link\'s label as a qualifier \u2014 `H\u1ED3 s\u01A1 (\u0110\u01A1n h\xE0ng)` and\n`H\u1ED3 s\u01A1 (\u0110\u01A1n g\u1ED1c)`.\n\nIn that line `documents:` is a `RecordExpectedSet`, and `lines:` a `RecordChildren` over the rows\nthis record owns \u2014 `history:` where they name it as their `party` instead; a required set\nwhose entries are a CHILD entity carrying files takes `kind="files"`, and the entity\'s own\nmulti-select takes `kind="items"`, since nothing attaches to an option.\n\n**A section that knows its size says so.** Where a `measure` on the record is a\n`count` rollup over the very link a rows section hangs on, the limit it is read\n`against` is how many rows that section is OWED: the printout adds `\u2014 expects\n<limit>` and the screen draws that many ghost rows until the first one lands,\ninstead of "nothing here" two bands under a count saying three are outstanding.\n\nA figure printed as `Total (USD)` is MONEY in the currency named \u2014 a `formula`\nstates it in `formula.currency`, and a `rollup` or a `lookup` inherits it from the\nfield it reads, so read those brackets: a rate that should be in dollars and is\nprinted bare will be drawn in the workspace\'s own money. Where the entity carries\na `currency` role, the ROW\'s code outranks the field\'s on every line that states\none, and a line stating none falls back to the field\'s.\n\n**A record surface can be OPERATED, and that is the default.** Every screen with\na record gets a workflow that writes its editable facts \u2014 every field a person\nstates \u2014 so the record\'s values are edited in place and its stage is advanced\nfrom the progress section. `"writes": false` makes one screen a reader: use it\nfor a screen over rows another desk owns, never as the default. A desk nobody\ncan act on is a viewer of state somebody must go and set somewhere else.\n\n**Which facts those are is the FIELD\'s answer.** Text, number, date, yes/no and\nselect are typed or picked. A `"cardinality": "one"` link is RE-POINTED, through\na picker over the target entity named by its `display_field_aliases` \u2014 else the\ntarget\'s `identity` \u2014 narrowed by what the reader types and carrying that\nentity\'s own `read_scope`, so a link the plan gives neither column is read\ninstead of offering an empty list. A files field is ATTACHED TO and DETACHED\nFROM, as the delta rather than the pile, so two readers filing at once each keep\ntheir file. A many-link, an `autonumber` and every computed field are read: the\nfirst is a list a fact cannot state, and the rest are the platform\'s to write.\n\n**A one-link is that fact WHATEVER its sync.** `sync_both_ways` is one relation\nwith a field on each side, and the sides are not the same surface: the record\nthat names ONE row states it as a fact, and the MANY side is the register on the\nother entity\'s record. Declaring both directions does not give this record a\nregister of the single row its own fact already names.\n\n**The write is the record SURFACE\'s.** Its inputs are the fields that surface\noffers to save \u2014 the facts with an editor, the lifecycle its ladder advances, and\na files section over exactly one stated field \u2014 never every field a person could\nin principle type. What the header states, a limit a meter folds in, a required\nset with a section of its own and the pile a page\'s mark draws are read there, so\nno input is declared for them. A second screen over the same entity draws no\nsurface of its own and adds nothing.\n\nWhere two operable screens of one plan reach the same entity, `scaffold check`\nnames them and the apps they belong to: two desks writing one record is a\ndecision, and the split is stated on the FIELD in each app\'s\n`package.json#lotics.writes` rather than left to whoever edits second.\n\nThat workflow lands in the app as source, like every other: its body in\n`src/workflows/update_<entity>.ts` and its declaration in\n`package.json#lotics.workflows`. The two together are the binding \u2014 `lotics app\ndeploy` pushes whatever differs from what it last saw live \u2014 so the write is\nversion-controlled and travels with the app rather than being bound by hand\nafterwards.\n\nA `custom` screen declares its slots under `roles` (slot \u2192 role) and they bind\nthe same way:\n\n```jsonc\n{ "alias": "readings", "label": "Readings", "shape": "custom", "entity": "reading",\n "roles": { "subject": "identity", "reading": "measure" } }\n```\n\n## Applying packages\n\n`apply` copies published packages into the workspace AFTER the model\'s own\ntables exist \u2014 apps over the tables you just described, and any tables of their\nown they still need. Ordered, and run by `lotics setup` and `lotics scaffold\napply` alike.\n\n```jsonc\n"apply": [\n {\n "package": "apg_k3nf82ldpq",\n "bind": { // optional \u2014 which of YOUR tables each entity is\n "company": { "label": "Customers", "fields": { "name": "Company name" } }\n },\n "no_sample_data": true // optional\n }\n]\n```\n\n`bind` is keyed by the package\'s entity alias and holds the LABELS this\nworkspace uses: scaffold adopts by label, so binding points the package at the\ntables the model created instead of a second set beside them. Only naming\nmoves \u2014 a bound field must be the TYPE the package declares, or the copy is\nrefused. `lotics library list` is the shelf, and `lotics library show <apg_id>`\nlists the aliases to bind.\n\nEntries run in the order they are written, because a later one may bind onto a\ntable an earlier one created. **A refused entry stops the run and the entries\nbefore it stay** \u2014 they are separate copies, committed as they land, so the\nrefusal names them rather than leaving a caller to re-run the file and copy them\ntwice.\n\n## Presets\n\nA preset is a trade\'s model, published to be READ. An assistant reads it, asks\nat most two questions, picks a variant and writes a `model.json` from it \u2014\nnothing is copied, and a preset is a file rather than anything a workspace\ninstalls.\n\n```jsonc\n"preset": {\n "name": "Field service",\n "description": "Jobs, the crew that runs them, and what each one billed.",\n "questions": ["Do you dispatch crews, or one person per job?"], // at most 2\n "variants": {\n "crews": {\n "when": "work is dispatched to crews rather than to one person",\n "entities": [ /* tables this branch ADDS */ ],\n "fields": { "job": [ /* fields this branch ADDS to `job` */ ] }\n }\n }\n}\n```\n\nVariants are **additive only**: a branch adds entities and fields and never\nremoves them, so the base is a model in its own right rather than a draft.\n`lotics scaffold check` proves the base AND every variant merged onto it, so a\npreset ships with every branch already proven \u2014 the branch nobody took is the\none that fails in the workspace of whoever takes it.\n\n`preset` is not scaffolded. `lotics setup` and `lotics scaffold apply` ignore\nit and create the base model\'s tables.\n\n`lotics scaffold export` prints a workspace that already works as one of these\nfiles \u2014 the starting point for a preset or for another business\'s model, never a\nsource of truth: it carries one business\'s words and stops describing that\nworkspace the moment either changes.\n\n## Starting from a preset\n\n`lotics library list` is the shelf of them and `lotics library show <slug>`\nprints one whole: its questions, every table as `alias \xB7 label` with each field\nas `alias:type`, and each variant as `slug \xB7 when` followed by the tables and\nfields that branch adds. When one of them is the trade in front of you, do not\ntranscribe it \u2014 name it:\n\n```jsonc\n{\n "from": "field_service",\n "variants": ["crews"],\n "rename": { "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } } },\n "entities": [ /* a table this business has that the preset does not */ ],\n "rows": { "job": [ { "ref": "j1", "fields": { "code": "J-1" } } ] },\n "field_roles": { "job": { "code": "identity" } },\n "write_rules": { "customer": { "natural_key": ["email"] } },\n "apps": [ /* the screens this business\'s apps will have */ ]\n}\n```\n\n- **`from`** is the preset\'s SLUG \u2014 its own file name, a lowercase slug. Naming\n it is what makes `entities` optional; every other rule on this page is\n unchanged, because the file is resolved into the full form and then checked and\n applied exactly as one. A slug nothing serves is refused with the ones there\n are, never resolved against something else.\n- **`variants`** names the branches to merge onto the base, in order. Pick the\n one whose `when` describes what the person said; a slug the preset does not\n declare is refused rather than ignored.\n- **`rename`** is keyed by the preset\'s entity alias and holds the labels this\n business uses \u2014 the same shape `apply[].bind` takes, and the same rule: only\n naming moves. An alias the preset does not declare, and a label that is\n already another table\'s, are both refused.\n- **`entities`** are added after the rename, already in this business\'s own\n words.\n- **`rows`**, **`field_roles`**, **`write_rules`**, **`apps`** and **`apply`**\n mean exactly what they mean in the full form \u2014 `"rows"` are this business\'s\n real first records,\n `"field_roles"` may name the preset\'s fields as well as its own (a role the\n preset declares itself is kept unless this file names the same field, or\n clears it with `null`), `"write_rules"` the create-time clauses on either\n (an entity this file names replaces the preset\'s whole entry for it),\n `"apps"` the screens it will have (a preset carries none), `"apply"` the\n packages copied in once its tables exist.\n\nThis is the ONE thing on this page that needs the network: `check` reads the\npreset it names, once. Everything after that read is the same offline check.\n\nWrite the full form when no preset is the trade.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "singular": "Customer",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "singular": "Order",\n "fields": [\n { "alias": "code", "label": "Order no.", "type": "text", "unique": true },\n { "alias": "placed_on", "label": "Placed on", "type": "date", "format": "date" },\n {\n "alias": "amount",\n "label": "Amount",\n "type": "number",\n "format": "currency",\n "currency": "VND"\n },\n {\n "alias": "total",\n "label": "Total with VAT",\n "type": "formula",\n "formula": { "expression": "{amount} * 1.1", "format": "currency", "currency": "VND" }\n },\n {\n "alias": "customer",\n "label": "Customer",\n "type": "select_record_link",\n "target_entity": "customer",\n "cardinality": "one",\n "sync_both_ways": true,\n "paired_field_alias": "orders",\n "display_field_aliases": ["name"]\n }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "field_roles": {\n "customer": { "name": "identity" },\n "order": { "code": "identity", "placed_on": "when", "amount": "amount", "customer": "party" }\n },\n "apps": [\n {\n "alias": "customers",\n "name": "Customers",\n "screen": { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" }\n },\n {\n "alias": "orders",\n "name": "Orders",\n "screen": { "alias": "orders", "label": "Orders", "shape": "transaction_ledger", "entity": "order" }\n }\n ],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\n`lotics scaffold check` on this file reports\n`2 tables, 9 fields, 2 links, 1 view, 1 role, 4 rows, 2 apps`, then\nthe plan:\n\n```\nCustomers\n Customers \u2014 party register over Customers (2 rows) \xB7 page \xB7 tabs: none\n identity Name \xB7 mark (none) \xB7 contact (none) \xB7 worth (none) \xB7 risk (none)\n above: rows\n record: header (Name) \xB7 history: Orders (Order no. \xB7 Placed on \xB7 Amount (VND)) \xB7 facts (Tier \xB7 Total ordered (VND))\nOrders\n Orders \u2014 transaction ledger over Orders (2 rows) \xB7 drawer \xB7 tabs: none\n when Placed on \xB7 amount Amount (VND) \xB7 reference Order no. \xB7 party Customer \xB7 classification (none) \xB7 document (none)\n above: Amount (VND)\n record: header (Order no. \xB7 Placed on \xB7 Amount (VND)) \xB7 facts (Placed on \xB7 Total with VAT (VND) \xB7 Customer)\nWho writes what:\n Customers (customer, one: Customer): Customers\n Orders (order, one: Order): Orders\n```\n\nThe last block is the WRITERS matrix \u2014 one line per record surface the plan\nleaves operable, the word one of its rows is called, and the app whose register\nopens it. Part of the verdict rather than a diagnostic beside it: which desk\nchanges a table is answerable from the plan, and two apps on one line is the\nsplit to state in each of their `package.json#lotics.writes`.\n\nEvery `(none)` is a slot no field fills \u2014 the picture a register has none of,\nthe contact nobody declared. Read it as the screen a person will see. Neither\nentity carries a stage, a required set or files, so the rest is facts \u2014 except\nthe sections neither entity declares: the customer\'s `history:` is the orders\nthat name it as their party, and the order\'s `via Orders:` is the customers whose\n`Orders` link names it. **A link is a section, not a fact**, on the side it\npoints AT and on the side that holds it: `Customers.Orders` therefore leaves the\ncustomer\'s facts, because the section below already lists the same relation and\na link fact can only ever show the first of them. Both records state their name\nin the header and nowhere else \u2014 a ledger\'s columns are the date and the figure,\nso `Order no.` is on no column of that list and is still what the drawer behind\na line is called. `Total ordered (VND)` is the rollup inheriting the currency of\nthe column it sums: money is read off the model, never off the field\'s own line.\n';
|
|
55120
|
+
var model_reference_default = '# The Lotics workspace model (`model.json`)\n\nOne JSON file describing the tables, fields, options, views, roles and first rows\na workspace starts with. `lotics scaffold check model.json` proves it offline \u2014\nno account, no network. `lotics setup model.json --email you@company.com` creates\nthe account and applies it. `lotics scaffold apply model.json` applies it again,\ninto the workspace the credential names.\n\n**There are two forms of this file.** The full one, below, spells the model out.\nThe `from` one names a published preset and carries only what this business\ndiffers by \u2014 see \xA7 Starting from a preset, and prefer it whenever a preset fits\nthe trade.\n\nApps are PLANNED here and built afterwards: `apps` names each app\'s screens as a\nshape over an entity, checked against the roles `field_roles` gives its fields,\nso the plan is refused before anyone builds a screen (\xA7 Apps and screens). The\nbuilt app lives in the workspace; publishing that workspace as a package is how\nit ships.\n\n## The rules\n\n- **At least one entity, at most 50.** More tables than that is a data model\n being designed, not scaffolded \u2014 scaffold the rest in a second call.\n- **Adoption is explicit.** `lotics setup` REFUSES an entity whose `label`\n already names a table in the workspace, naming every colliding label at once.\n `lotics scaffold apply` adopts those tables and adds the fields, options and\n views they are missing. Nothing is ever modified or deleted, so applying the\n same model twice creates nothing the second time.\n- **Adoption is by LABEL, not alias.** Change an entity\'s `label` and the next\n run asks for a NEW table beside the old one. Renaming a FIELD is\n `lotics field rename <table> <field> "<new label>" --model <this file>`, which\n moves the platform, this file and every app bound to it together; deleting is\n `lotics run delete_table`. Neither goes through the file.\n- **The file\'s own majority is the language.** A model names no locale \u2014 which\n language it is in is what it mostly says, and `scaffold check` notes the label\n written the other way. The generated screens read the kit\'s pack, and a\n generated WRITE cannot: its refusals run on the server, so they are worded in\n that same majority. Mix the two and the workspace answers in two languages.\n- **`lotics scaffold diff model.json` says where the file and the workspace have\n come apart**, joined on label, entity then field \u2014 the join adoption itself\n makes. It exits 1 on any difference, so a model that is about to be published\n as a preset carries the labels in use rather than the ones it was written with.\n- **Rows land only where every bound table is empty.** One table already holding\n records and no rows are written anywhere, and the result says\n `rows_skipped: true`: sample rows landing among a customer\'s real ones cannot\n be told apart from them.\n- **After the first run the WORKSPACE is the source of truth.** The file is an\n authoring input, not a mirror \u2014 scaffold never deletes what the file stopped\n naming.\n- **`lotics scaffold check` decides all of it offline**, and reports every\n problem in one run rather than the first: an alias that resolves to nothing, a\n link whose pair is not symmetric, and the rows themselves \u2014 a field the entity\n does not declare, an option alias the field does not declare, a link naming no\n row in the file, a `ref` used twice, a date that is not one, a value on a\n platform-computed field, and a files cell that is neither a relative path\n beside this file nor a `fil_` id.\n\n## Top level\n\n```jsonc\n{\n "entities": [ /* the tables */ ],\n "roles": [ /* workspace groups to create */ ], // optional\n "templates":[ /* inline html / email templates */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* the reporting role each field plays, keyed by entity then field */ }, // optional\n "write_rules": { /* what a CREATE finds, defaults and refuses, keyed by entity */ }, // optional\n "apps": [ /* the screens each app will have, as shapes over entities */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ], // optional\n "preset": { /* a trade\'s branches, for a PUBLISHED model */ } // optional\n}\n```\n\nThe other form names a preset instead of restating one:\n\n```jsonc\n{\n "from": "field_service", // the preset this model starts from, by slug\n "variants": ["crews"], // optional \u2014 its branches to merge in, in order\n "rename": { // optional \u2014 what THIS business calls each table\n "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } }\n },\n "entities": [ /* tables the preset does not declare */ ], // optional\n "rows": { /* first records, keyed by entity alias */ }, // optional\n "field_roles": { /* roles on the preset\'s fields and this business\'s own */ }, // optional\n "write_rules": { /* create-time clauses on the preset\'s entities and its own */ }, // optional\n "apps": [ /* the screens each app will have */ ], // optional\n "apply": [ /* published packages to copy in afterwards */ ] // optional\n}\n```\n\n**A model may not carry** `fixtures`, `knowledge` or `knowledge_expects`, and no\n`excel` / `word` / `pdf-form` template: each of those is content that lives in a\npublished bundle, which a model has none of. `apps` here is a plan of screens,\nnever built code. An unknown top-level key is an error, never ignored.\n\n### Aliases\n\nEvery `alias` is a lowercase slug \u2014 a letter, then letters, digits and\nunderscores (`unit_price`, `so_1001`). Aliases are how the file cross-references\nitself; they are never shown to anyone. `label` is what a person sees.\n\nLabels must be unique within their namespace \u2014 two entities, two fields on one\nentity, two options on one field, two views on one entity, two roles or two\ntemplates cannot share a label, because scaffold matches by label.\n\n## Entity\n\n```jsonc\n{\n "alias": "order",\n "label": "Orders", // the table\'s name, which names the SET it holds\n "singular": "Order", // optional \u2014 ONE of them, for the act that opens one\n "description": "\u2026", // optional\n "fields": [ /* at least one */ ],\n "views": [ /* optional; an entity with none still gets the default grid */ ],\n "read_scope": { /* optional; absent, everyone with access to the table reads every row */\n "any": [\n { "member_of": "sales" }, // a role alias this model declares\n { "field": "scope", "is": ["shared"] } // a single select on this entity, by option alias\n ]\n }\n}\n```\n\n**`singular` is what a create says.** A register\'s own act and the panel it\nopens name the row being made \u2014 `New Order`, `H\u1ED3 s\u01A1 m\u1EDBi` \u2014 while `label` names\nthe table, so without it the button reads `New Orders`. Nothing derives it\n(English plurals are irregular), and most models need none: a Vietnamese noun is\nthe same word either way. `scaffold check` prints it beside the table under *Who\nwrites what* and NOTES a table that will name the set. An act on a child section\nkeeps the label: it adds a line to the register under the record the reader is\nalready standing in.\n\n**`read_scope` is a ROW rule, enforced by the platform.** A row is readable when\nANY clause holds: the reader is in that role\'s group, or the named select carries\none of those options. It is resolved at scaffold into the table\'s own row\nfilters, so an app, a workflow reading for a viewer, and the API all answer the\nsame rows \u2014 a per-record visibility field the app merely honours is a convention,\nnot a gate. `any` may not be empty (a rule nobody satisfies hides the table), and\nevery role alias and option alias in it must be one this model declares.\n\n## Field\n\nEvery field carries `alias`, `label`, an optional `description`, and an optional\n`required` \u2014 advisory only, read by app forms and workflows; the table itself has\nno required constraint. `label` may not contain `{` or `}` (formulas reference\nfields by label at the platform level).\n\n`default` is the value pre-filled into a NEW record. It applies on create only;\nexisting records are never backfilled. Only the types listed below accept one.\n\n### `text`\n\n```jsonc\n{ "alias": "name", "label": "Name", "type": "text",\n "unique": false, // optional \u2014 require distinct values\n "format": "text", // optional \u2014 "text" | "link" | "markdown"\n "default": "" } // optional\n```\n\n### `number`\n\n```jsonc\n{ "alias": "amount", "label": "Amount", "type": "number",\n "format": "currency", // optional \u2014 "number" | "currency" | "percentage"\n "currency": "VND", // optional \u2014 ISO 4217\n "default": 0 } // optional\n```\n\n`format` is what the number IS, and every surface reads it: `currency` prints as\nmoney in the code the row or the field states, `percentage` as a whole percent\nwith its sign. An ABSENT number is drawn absent \u2014 the one exception is a\n`sum` or count rollup the plan reads as a **`measure`**: that is the thing\naccumulated toward a bound, so nothing accumulated yet is zero and the meter\ndraws it. The same rollup read as an `amount` keeps its blank, and so does every\nother role: nothing added to what a row is WORTH means unpriced, not free. A\n`min`, an `avg`, a percentage of nothing and every FORMULA stay blank in any\nrole, because none of them has an answer to give. This is why the pair on one\nscreen reads two ways \u2014 what has come in against what is owed \u2014 and why a\nmeasure\'s own LIMIT, an amount, leaves an unquoted row out of the count rather\nthan reporting it as nothing collected. **AND WHERE THAT LIMIT IS ABSENT THERE IS\nNO LEVEL AT ALL**: a level is a reading AGAINST a bound, so a row that states no\nbound draws nothing \u2014 cell, fact and all \u2014 rather than a numerator whose whole\nmeaning was the comparison. "Collected 0" beside a blank total reads as money\nagainst a job worth nothing. A measure the model gives no limit is a plain figure\nand is unaffected. A share is stored in percent units \u2014 68.1 is 68.1 % \u2014 and the\ncolumn, the fact behind it, the meter it is judged by and the figure over the\nregister all say so.\n\n### `date`\n\n```jsonc\n{ "alias": "placed_on", "label": "Placed on", "type": "date",\n "format": "date", // optional \u2014 "date" | "datetime" | "date_range" | "datetime_range"\n "timezone": "Asia/Ho_Chi_Minh", // optional \u2014 IANA name\n "derive_from": "created_at", // optional \u2014 "created_at" | "updated_at"; makes the field read-only\n "default": "2026-01-01" } // optional; refused together with derive_from\n```\n\n### `boolean`\n\n```jsonc\n{ "alias": "paid", "label": "Paid", "type": "boolean", "default": false }\n```\n\n### `select`\n\n```jsonc\n{ "alias": "tier", "label": "Tier", "type": "select",\n "options": [ // at least one\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "multi": false, // optional\n "default": ["standard"] } // optional \u2014 option ALIASES; one unless multi\n```\n\n`color` is one of: `red`, `orange`, `amber`, `yellow`, `lime`, `green`,\n`emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`,\n`fuchsia`, `pink`, `rose`, `slate`, `gray`, `zinc`, `neutral`, `stone`.\n\n### `select_member`\n\nA person picker over the workspace\'s members. No default: a model cannot name\nmembers of a workspace that does not exist yet.\n\n```jsonc\n{ "alias": "owner", "label": "Owner", "type": "select_member", "multi": false }\n```\n\n### `select_record_link`\n\n```jsonc\n{ "alias": "customer", "label": "Customer", "type": "select_record_link",\n "target_entity": "customer", // an entity alias this model declares\n "cardinality": "one", // optional \u2014 "one" | "many" (default "many")\n "sync_both_ways": true, // optional \u2014 keep a paired field on the target\n "paired_field_alias": "orders", // the partner field ON THE TARGET entity\n "display_field_aliases": ["name"] } // optional \u2014 what the link shows / the picker\'s columns\n```\n\nA two-way link is declared on BOTH sides, each naming the other as its\n`paired_field_alias`; the pair must be symmetric or the model is refused.\n\n**A single-valued link needs no partner.** `"cardinality": "one"` on its own is a\nlink that holds one row \u2014 one customer on an invoice, one project on a device \u2014\nand nothing is created on the target. The mirror invariant belongs to a PAIRED\nlink: pair a link when the target\'s own record should list what points at it, and\nleave it unpaired when it should not. Either way the record plan draws the\nrelation as a section on the side it points at, so an unpaired link costs the\ntarget nothing.\n\n### `files`\n\n```jsonc\n{ "alias": "attachments", "label": "Attachments", "type": "files" }\n```\n\n### `formula`\n\n```jsonc\n{ "alias": "total", "label": "Total", "type": "formula",\n "formula": {\n "expression": "{amount} * 1.1", // fields on THIS entity, by alias, in braces\n "output_type": "number", // optional \u2014 what it YIELDS: "number" | "text" | "date" | "datetime" | "boolean"\n "format": "currency", // optional \u2014 how that result is DRAWN: "number" | "currency" | "percentage" | "link"\n "currency": "VND" // optional\n } }\n```\n\n`output_type` is what the expression answers WITH; `format` is how it is printed.\nThe platform infers the result at write time and ignores what you declare, so\nthis is a statement the offline checks read \u2014 which is what lets a role or a\nscreen clause accept a computed value: a caption over a derived name\n(`output_type: "text"`), a period over a settled date (`"date"`). A formula\ndeclaring neither says nothing about its result, and every rule that needs one\nrefuses it by name. `lotics scaffold export` writes the result a live workspace\ncomputed, so exporting a workspace fills these in.\n\n### `rollup`\n\nAggregates the records reached through a link on this entity.\n\n```jsonc\n{ "alias": "total_ordered", "label": "Total ordered", "type": "rollup",\n "source_field_alias": "orders", // a select_record_link field on THIS entity\n "aggregate_option": {\n "operation": "sum", // count | sum | avg | median | min | max | range |\n // empty | filled | percent_empty | percent_filled |\n // unique | percent_unique |\n // earliest | latest | date_range |\n // checked | unchecked | percent_checked |\n // percent_unchecked\n "field_key": "amount" // a field ALIAS on the linked entity ("count" may omit it)\n },\n "filter": { /* optional \u2014 see Views; every field_key is an alias on the LINKED entity */ } }\n```\n\nThe operation must be one the aggregated field\'s type allows \u2014 `sum` over a\nnumber, `earliest` over a date, `filled` over anything.\n\n### `lookup`\n\nDisplays a field from the linked records.\n\n```jsonc\n{ "alias": "customer_tier", "label": "Customer tier", "type": "lookup",\n "source_field_alias": "customer", // a select_record_link field on THIS entity\n "lookup_field_alias": "tier", // a field alias on the linked entity\n "order_by": { "field_key": "placed_on", "direction": "desc" } } // optional \u2014 pick the single extreme row\n```\n\n### `autonumber`\n\n```jsonc\n{ "alias": "seq", "label": "No.", "type": "autonumber",\n "prefix": "SO-", // optional \u2014 ignored when template is set\n "padding": 4, // optional \u2014 1..20, zero-pads the integer\n "template": "SO-{YEAR}-{N:4}" } // optional \u2014 {N}, {N:W}, {YEAR}, {YEAR:2}, {MONTH}, {DAY}\n```\n\n## Views\n\nSaved views live under the entity they belong to. Every field reference is a\nfield ALIAS on that entity.\n\n```jsonc\n{\n "alias": "gold",\n "label": "Gold customers",\n "description": "\u2026", // optional\n "columns": [ // optional \u2014 omit to show every field\n { "field_alias": "name", "visibility": "visible", "width": 240 },\n { "field_alias": "tier", "visibility": "hidden" }\n ],\n "filters": { // optional\n "node_type": "group",\n "logic": "and", // "and" | "or"\n "children": [\n { "node_type": "condition", "type": "select", "field_key": "tier",\n "operator": "has_any_of", "value": ["gold"] }\n ]\n },\n "sort": [ { "field_key": "name", "order": "asc" } ], // optional; order is "asc" | "desc" | null\n "summary": { "amount": "sum" }, // optional \u2014 field alias \u2192 footer operation\n "frozen_columns": 1 // optional\n}\n```\n\nA condition\'s `type` is the field\'s type and its `operator` is one that type\nadmits \u2014 `has_any_of` / `has_none_of` / `has_all_of` / `is_empty` /\n`is_not_empty` for a select, `equals` / `greater_than` / `less_than` for a\nnumber, `on` / `before` / `after` / `between` for a date, `contains` /\n`is_any_of` for text. A select condition\'s `value` names option ALIASES.\n\n`columns`, when present, is exhaustive and must not be empty: a view renders\nexactly the entries it holds. Omit the key to show every field.\n\n## Roles\n\nA role becomes a workspace group. Members are added afterwards, in the app.\n\n```jsonc\n{ "alias": "sales", "label": "Sales" }\n```\n\n## Templates\n\nOnly inline `html` and `email` templates \u2014 the rest are file-backed and a model\nhas no bytes. An `html` template renders to a PDF when a workflow generates\nfrom it; `{{name}}` is filled from the workflow\'s data.\n\n```jsonc\n{ "alias": "order_ack", "label": "Order acknowledgement", "type": "email",\n "content": "<p>Hello {{customer}}\u2026</p>" }\n```\n\nA paper that has to look like a counterparty produced it \u2014 an official letter,\nan acceptance minute, a supplier\'s bill \u2014 is the same `html` template with a\nshell around the body: a letterhead, a reference line, a seal and a signature\nblock, and paper grain over everything. One shell, many bodies; the data is the\nonly thing that changes, so a workflow can re-issue it over any record.\n\n```jsonc\n{ "alias": "cong_van", "label": "C\xF4ng v\u0103n", "type": "html",\n "content": "\u2026the page below, as one JSON string\u2026" }\n```\n\n```html\n<style>\n .sheet{position:relative;width:718px;padding:44px 58px 30px;background:#fbfaf6;color:#111;font:14.2px/1.5 \'Liberation Serif\',serif}\n .grain{position:absolute;inset:0;opacity:.34;mix-blend-mode:multiply;background:url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'140\' height=\'140\'><filter id=\'f\'><feTurbulence baseFrequency=\'.9\' numOctaves=\'2\'/><feColorMatrix values=\'0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0\'/></filter><rect width=\'140\' height=\'140\' filter=\'url(%23f)\'/></svg>")}\n .top{display:flex;text-align:center;font-size:13.4px} .top>div{flex:1} .u{display:inline-block;border-bottom:1px solid #111;font-weight:700}\n .ref{display:flex;text-align:center;font-size:13.4px;margin-top:6px} .ref>div{flex:1} .ref .r{font-style:italic}\n h1{text-align:center;font-size:15.6px;margin:26px 0 18px} p{text-align:justify;text-indent:26px;margin:0 0 9px}\n .sig{display:flex;margin-top:20px} .sig .l{flex:1} .sig .r{width:290px;text-align:center;position:relative}\n .sig .nm{font-weight:700;margin-top:96px} .seal{position:absolute;left:4px;top:8px;width:166px;height:166px;opacity:.66;mix-blend-mode:multiply;transform:rotate(-17deg)}\n </style>\n <div class=\'sheet\'><div class=\'grain\'></div>\n <div class=\'top\'><div><b>{{issuer_parent}}</b><br><span class=\'u\'>{{issuer}}</span></div>\n <div><b>C\u1ED8NG H\xD2A X\xC3 H\u1ED8I CH\u1EE6 NGH\u0128A VI\u1EC6T NAM</b><br><span class=\'u\'>\u0110\u1ED9c l\u1EADp - T\u1EF1 do - H\u1EA1nh ph\xFAc</span></div></div>\n <div class=\'ref\'><div>S\u1ED1: {{number}}</div><div class=\'r\'>{{place}}, ng\xE0y {{day}} th\xE1ng {{month}} n\u0103m {{year}}</div></div>\n <h1>{{title}}</h1>\n <p>K\xEDnh g\u1EEDi: {{recipient}}.</p>\n {{{body}}}\n <div class=\'sig\'><div class=\'l\'><b>N\u01A1i nh\u1EADn:</b><br>- Nh\u01B0 tr\xEAn;<br>- L\u01B0u VT.</div>\n <div class=\'r\'><img class=\'seal\' src=\'{{seal_url}}\'><b>{{signer_title}}</b><div class=\'nm\'>{{signer}}</div></div></div>\n </div>\n```\n\n`lotics preview <file.html>` renders any such page to a PNG the way a demo\'s\nprops are made, sized to its content, so a paper can be looked at before it is\nput in a template.\n\n## Rows\n\nFirst records, keyed by entity alias. Up to 200 rows per entity and 2000 across\nthe model, attaching at most 2000 documents between them \u2014 a real data set\nbelongs in an import, not a model.\n\n```jsonc\n"rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } }\n ]\n}\n```\n\n`ref` is a local handle (lowercase letters, digits, underscores) that other rows\'\nlink fields address. It is never persisted.\n\nA `files` cell attaches documents: paths relative to this file (no `..`, never\nabsolute), which `check` proves exist and `apply` uploads into the workspace\nbefore any row is written \u2014 a paperwork business seeds its papers with its\nrows. The server accepts only `fil_` ids of files this workspace owns, which is\nwhat the upload leaves behind. After a run that wrote rows, `apply` writes the\nrecord ids beside the file (`<model>.last_run.json`): `delete_records` over\nthem is how a seeded set is reset, and applying again re-dates it.\n\n`fields` is keyed by field alias, and every value is read against the field\'s\nDECLARED type:\n\n| Field type | Value |\n|---|---|\n| `text` / `number` / `boolean` | the value itself |\n| `date` | `"2026-03-14"`, or a relative expression (below) |\n| `select` | the option ALIAS \u2014 `"gold"`, or `["gold","vip"]` for a multi-select |\n| `select_record_link` | `"<entity-alias>:<ref>"` naming another row in this file \u2014 `"customer:acme"`, or an array for several |\n| `select_member` | `"self"` only \u2014 the person applying the model |\n| `files` | paths beside this file \u2014 `["scans/pccc_letter.png"]` \u2014 uploaded by `apply`/`setup` before the rows are posted; or `fil_` ids of files already in this workspace |\n| `formula`, `rollup`, `lookup`, `autonumber` | not allowed \u2014 the platform writes these |\n\n**Documents can be attached on their own, afterwards.** Rows land only into empty\ntables, so a model whose binaries were added after the first apply has no second\napply to carry them: `lotics scaffold apply model.json --documents` writes ONLY\nthe `files` cells, onto the records the first run created, joined through the\n`<model>.last_run.json` beside the file. Running it twice attaches nothing the\nsecond time \u2014 the same path keeps the same file, and a cell is a set.\n\n### Relative dates\n\nA date cell holds a literal `YYYY-MM-DD`, or an expression relative to the day\nthe model is applied, so a screen that opens on "this month" is not empty a month\nlater:\n\n- `@today` \u2014 the day of the run, in the workspace\'s timezone\n- `@month-start` \u2014 the 1st of that month\n- either with a whole-day offset: `@today-14`, `@month-start+9`\n\n`@month-start` exists because `@today-N` cannot promise a month: applied on the\n2nd, `@today-3` lands in the previous one.\n\n## Field roles\n\n`field_roles` names the reporting role a field plays on its entity \u2014 keyed by\nentity alias, then field alias \u2014 so every screen over the entity agrees on\nwhich column names the row and which select is the stage. A shape\'s slot binds\nto it (\xA7 Apps and screens). Like `rows` and `apps`, it is this file\'s: `check`\nproves it and the workspace never sees it. Each role sits on the types that can\nanswer it:\n\n| Role | On | Meaning |\n|---|---|---|\n| `identity` | `text`, `autonumber`, `select_record_link`, `formula` | names the row \u2014 the register\'s first column; a link where the row is "the product, at this branch"; a formula where the name is computed, and then it may not be `format`ted as a figure. One per entity |\n| `reference` | `text`, `autonumber`, `formula` | the key the SYSTEM files the row under \u2014 a booking number, a container code \u2014 drawn as the supporting line under the name rather than as a column of its own. One per entity |\n| `mark` | `files`, or a `lookup`/`rollup` that resolves to one | the row\'s picture \u2014 its own, or the linked record\'s. One per entity |\n| `lifecycle` | single `select` | the ordered stages a row walks; option order is the order. Which of them END the flow is `outcomes`. One per entity |\n| `category` | single `select` | what the row IS \u2014 a kind, a service, a book. One badge in the field\'s own colour, with no ladder behind it and no flow to advance. Repeats: a row can be of two kinds of thing |\n| `measure` | `number`, `formula`, `rollup` | a level read against a limit \u2014 see `against` and `alert`. Money where the model says so, and then it prints as money |\n| `expected_set` | `select` | its OPTIONS are the required set (documents, checks, services); an option no row has is a gap to show, not nothing. A multi-select draws as a ring and a fraction wherever it is drawn \u2014 absence is the information, and a list of what IS there says nothing about what is missing |\n| `amount` | `number`, `formula`, `rollup` | THE money of a ledger row; `signed_by` says which way it moved, `against` names the reference it is quoted off. One per entity |\n| `when` | `date`, or a `formula`/`rollup`/`lookup` that yields one | the ledger or timeline date \u2014 stated, or derived: the day money MOVED is a formula over the two columns that could hold it. `due` makes it a DEADLINE; without it the date is the plain day it is. `until` names the stage of this entity\'s own `lifecycle` at which the countdown is spent, and says `due` by saying so. One per entity |\n| `slot` | single `select`, or a `datetime` `date` | where in the DAY a row sits \u2014 the run it is read in under its day\'s heading on an itinerary. A select\'s option order IS the run\'s order; a time is ordered by the clock. A plain `date` is refused: that is the day itself. One per entity |\n| `party` | `select_record_link` | the counterparty. One per entity |\n| `parent` | `select_record_link` | the record this row belongs to \u2014 a line\'s order, a paper\'s case. The parent\'s record shows these rows; the row shows the parent as a fact. One per entity, and it links to an entity this model declares |\n| `contact` | `text` | a way to reach a party \u2014 an address, a number, the town it is in. The record draws them as ONE row of links under the name, so the role sits on every field that is one of them. Repeats |\n| `currency` | single `select` whose option LABELS are ISO 4217 codes | the money THIS ROW\'s figures are in. Every amount and every money measure on the entity is then printed per row rather than in one code for the whole column, and the select is not drawn as a fact of its own. One per entity |\n| `verdict` | `boolean`, `formula` | a settled pass/fail \u2014 ticked, or computed. TRUE is the PASSING side wherever it is drawn, a register\'s risk flag included, so a field whose true means trouble is the same fact asked the other way round. One per entity |\n| `obligation` | `date` | a day something is OWED by \u2014 a cut-off, a permit expiry, a payment due. It counts down by definition. `satisfied_by` names what CLOSES it (or `until`, the stage the record stops owing it at), `label` what is owed. Repeats: a row owes several things, and a shape that fans them draws one row each |\n\n**`due` is what makes a `when` a deadline.** A date a row is merely filed by \u2014 the\nday a lead arrived, the day a movement happened \u2014 is neither early nor late,\nhowever long ago it was. Without the clause every desk drew every `when` as a\ncountdown and a two-month-old lead read "40 days overdue" on a record nobody was\nlate on. So a bare `when` is a plain date, and `{ "role": "when", "due": true }`\nis the one that counts down. An `obligation` needs no clause: a day something is\nowed by is a deadline already.\n\n**`until` stops a countdown where the work is DONE.** A deadline counts down so\nsomebody acts on it, and a row that has arrived needs nothing: without the clause\na delivered order is tinted for a date it met, and every settled row past its day\njoins the urgent ones in the reader\'s glance. It names one option of the entity\'s\n`lifecycle`; at that stage, at every stage past it and at every `outcome`, the\ndate is drawn as the plain day it is. On a `when` it also says the date is `due`.\n\n"One per entity" is one COLUMN, never one value on screen. A second counterparty\nor a second parent stays a ROLELESS `select_record_link`: which field fills a\nslot is the screen\'s answer (\xA7 Apps and screens), and the extra link is a fact on\nthe row with its own section on the record, headed by that link\'s own label. A\nrole naming two fields would move the ambiguity into every shape that reads it.\n\nA COMPUTED field is admitted where its RESULT is what the role needs, and\nrefused where it is not: `identity` and `reference` need `text`, `when` a `date`,\n`mark` `files`. A formula\'s result is its `output_type`; a rollup\'s and a\nlookup\'s is read through the walk. So a formula yielding a number is refused in a\nname\'s place, a lookup landing on anything but `files` is refused as a `mark`, and\na formula declaring NO result is refused by all four \u2014 naming `output_type` as\nthe remedy, because a value drawn as a type nobody stated is the wrong cell with\nnothing saying so. The result also decides the DEVICE every screen draws the value\nwith: a rollup taking the `latest` of a set of dates is drawn as a date, a lookup\nof a select as the words it holds.\n\nA bare role name is the common form. A role that is read against a SECOND field\ntakes the object form:\n\n- **`measure`** names its limit: `against` \u2014 a constant, or a field on the same\n entity the row states as a number (a `number`, or a formula or rollup whose\n result is one) \u2014 and `alert`, which side of it needs attention, `over` a\n capacity or `under` a minimum. The two come together.\n- **`measure`** names what its number COUNTS, where the unit changes how the row\n is DRAWN rather than how the figure reads: `counts: "days"` spans a stop across\n that many days of a run, so a three-night stay fills three of its days instead\n of only the one it starts on. Stated, never inferred \u2014 nothing about a `3` says\n whether it is nights, pallets or hours, and a quantity drawn across a week is a\n run nobody can read.\n- **`lifecycle`** names the stages that END the flow: `outcomes`, option aliases\n of that select. A row in one has ARRIVED \u2014 delivered, cancelled, written off \u2014\n so a desk draws them apart from the ladder instead of chaining them after each\n other. Not every option may be an outcome, and terminal-ness belongs here, never\n written into a stage\'s label.\n- **`amount`** names the REFERENCE it is read against: `against`, a field on the\n same entity the row states as a number. A price against the list it is quoted\n off, a rate against the going one \u2014 and the catalogue item\'s record draws the\n pair on one axis with the gap said in words. Never a constant (a price typed\n into the model ages with nothing to update it) and never an `alert`: a limit is\n a ceiling a row can pass, and beating a reference is the point.\n- **`amount`** names the select that SIGNS it: `signed_by`, a select on the same\n entity, and `outflow`, the options of that select under which the amount is\n money going out. Without it every shape over the ledger adds both directions\n together: a month whose net was small reads as the total of everything that\n moved, and the trend plots one rising line. The two come together, and\n `outflow` is some of that select\'s options, never all.\n- **`expected_set`** names what CONDITIONS the set: `required_by`, a field on the\n same entity, and `options`, which of that field\'s values require which entries.\n A row whose value is not named requires nothing. Without it the denominator is\n every option, so a set whose entries are mutually exclusive by kind reads\n `1 of 4` on every complete row \u2014 a denominator no row can reach is worse than\n no role. The field is a single `select`, keyed by its option aliases \u2014 or a\n YES/NO the row answers itself (a `boolean`, or a formula resolving to one),\n keyed by `"true"` and `"false"`: a check that FAILED owes its consequence \u2014 the\n photograph, the owner, the day it is due \u2014 and there is no select of the record\n that says so. `from: "parent"` reads that field off the record these rows hang\n under instead, through this entity\'s own `parent` link: which documents an order\n owes is the ORDER\'s type, and the papers carry no column that says it. Which of\n the two a set takes is the set\'s own shape and not a choice \u2014 a multi-select\n holds the whole set on one row and is narrowed by that row (`from` omitted), a\n single select is one entry per row and takes `from: "parent"` \u2014 and the other\n way round is refused rather than left conditioning nothing.\n- **`obligation`** names what ENDS it: `satisfied_by`, a `date` or a `files` field\n on the same entity \u2014 the day it was done, or the paper that proves it \u2014 or,\n where the business stamps nothing, `until`, the stage of this entity\'s\n `lifecycle` at which the record stops owing it: a deposit is owed until the\n order is paid, and nobody records the act of paying twice. One or the other,\n never neither \u2014 without one every obligation the business ever met stays on the\n desk. Optionally `label`, what is OWED as the reader says it ("G\u1EEDi SI"), which\n is not what the column holding the date is called ("SI cut-off").\n\n```jsonc\n"field_roles": {\n "product": { "name": "identity", "photo": "mark" },\n "stock": { "on_hand": { "role": "measure", "against": "minimum", "alert": "under" },\n "uptime": { "role": "measure", "against": 80, "alert": "under" } },\n "order": { "stage": { "role": "lifecycle", "outcomes": ["delivered", "cancelled"] },\n // A DEADLINE COUNTS DOWN \u2014 `payment.paid_on` below is a plain day.\n "ship_by": { "role": "when", "due": true },\n // The day the papers are owed by, and the day they went.\n "papers_due": { "role": "obligation", "label": "File the papers", "satisfied_by": "papers_sent" },\n // Nothing stamps a deposit paid \u2014 the order\'s own stage ends it.\n "deposit_due": { "role": "obligation", "label": "Take the deposit", "until": "delivered" } },\n // WHICH PAPERS THIS ONE OWES IS THE ORDER\'S OWN TYPE, and no document says it.\n "document": { "order": "parent",\n "kind": { "role": "expected_set",\n "required_by": { "from": "parent", "field": "kind",\n "options": { "export": ["invoice", "customs"] } } } },\n // `kind` is the direction select \u2014 options `received` and `paid_out`, no role of its own.\n // Every figure on a quotation is in the currency that row names.\n "quote": { "unit": "currency", "total": "amount" },\n // What the price is quoted off, so the item\'s page reads one against the other.\n "service": { "price": { "role": "amount", "against": "list_price" } },\n // The day the money moved is neither early nor late, so it stays a bare `when`.\n "payment": { "paid_on": "when",\n "value": { "role": "amount", "signed_by": "kind", "outflow": ["paid_out"] },\n // A receipt owes a receipt voucher; a payment owes an invoice and a payment voucher.\n "papers": { "role": "expected_set",\n "required_by": { "field": "kind",\n "options": { "received": ["receipt_voucher"],\n "paid_out": ["invoice", "payment_voucher"] } } } },\n // A CHECK THAT FAILED OWES ITS CONSEQUENCE \u2014 keyed by the row\'s own answer,\n // and the answer that owes nothing is omitted rather than named with an empty list.\n "check": { "passed": "verdict",\n "evidence": { "role": "expected_set",\n "required_by": { "field": "passed",\n "options": { "false": ["photo", "owner", "due"] } } } }\n}\n```\n\nIn a file that starts from a preset (\xA7 Starting from a preset), `field_roles`\nmay name the preset\'s fields as well as this business\'s own; a role the preset\ndeclares itself is kept unless this file names the same field, and `null`\nclears it.\n\n## Write rules\n\n`write_rules` is what only a CREATE meets \u2014 keyed by entity alias, like\n`field_roles`, and this file\'s in the same way: `check` proves it and the\nworkspace never sees it. An update names one field that moved; a create takes a\ndraft whole, so it has to know which row a name already belongs to, where a\nvalue comes from when nobody types it, and what a figure or a picker may hold.\nThe generator writes one `create_<entity>` workflow and one `New<Entity>Dialog`\nper entity an app can operate, from these clauses and the fields\' own\n`required`.\n\n```jsonc\n"write_rules": {\n // A customer is RECOGNISED by their address. Where an entity names a customer\n // as its `party`, the create takes the email instead of a picker, reuses the\n // row it matches and mints one only where nothing does \u2014 so the book never\n // grows a second Acme because somebody typed the name differently.\n "customer": { "natural_key": ["email"] },\n "order_line": {\n "fields": {\n // A line of nothing is not a line. Refused on create and on update, at\n // the control the figure was typed into.\n "quantity": { "min": 1, "max": 9999 },\n // The price is fixed at the moment of ordering \u2014 COPIED off the product,\n // not looked up for ever after, so the price list moving next week does\n // not silently reprice an order already placed.\n "unit_price": { "default_from": "product.price" },\n // And nothing is sold off an empty shelf. The picker reads only the rows\n // that answer this, and the write refuses the same rows again \u2014 a caller\n // who never opened the picker is bound by it too.\n "product": {\n "options_where": {\n "node_type": "group", "logic": "and",\n "children": [{ "node_type": "condition", "type": "number",\n "field_key": "in_stock", "operator": "greater_than", "value": 0 }]\n }\n }\n }\n }\n}\n```\n\n| Clause | On | Meaning |\n|---|---|---|\n| `natural_key` | the entity | the field aliases a row is recognised by. A `text` key declares `unique: true` on the field itself \u2014 two rows sharing it would make find-or-create pick whichever the read answered first \u2014 and a key is `text` or `number`, because a person types it back |\n| `default_from` | a field | `"<link alias>.<field alias>"` \u2014 the value is copied from the linked row when the row is created, never asked. The link is a one-row link on this entity and `required`, because there has to be a row to read, and the two field types must match |\n| `min` / `max` | a `number` | the figure is refused outside them, on create and on update |\n| `options_where` | a `select_record_link` | an `and` group of plain conditions over the TARGET\'s own fields, each `field_key` a field alias. It narrows the picker\'s read and is refused again where the write lands, so the link is `required` |\n\nA field\'s own `required` is not here: the contract carries it, every write path\nrefuses the row by field name from it, and the generated panel stands its commit\ndown until the same set is filled. `unique` is the text field\'s own clause\n(\xA7 `text`) \u2014 the create says so at the control before the column does.\n\nWhat a create asks is what a person STATES. A `default_from` field is not drawn,\na `lifecycle` opens at its first rung, the stamp an obligation\'s `satisfied_by`\nnames is not asked (the row is only now taking that on), and files are attached\nto the row afterwards \u2014 one fact, one place. `scaffold check` prints every clause\nunder its entity in **Who writes what**.\n\n**The `parent` is prefilled only in the door that mounts the act.** A row opened\nfrom the record it hangs under takes that record from the door it was pressed\nin, so the link is neither asked for nor drawn. The same entity\'s act on its own\nregister has no such row, so there the parent is an ordinary picker the draft\ncannot be committed without \u2014 a payment opened from a money app belongs to a\ncase either way.\n\n## Apps and screens\n\n`apps` is the plan: each app the reader will build, as ONE REGISTER \u2014 a SHAPE\nover an ENTITY \u2014 under `screen`. Nothing here is built by the scaffold: the plan\nis what `lotics scaffold check` prints back, register by register with the field\nin every slot, so it is read and corrected before a screen exists.\n\n**THREE THINGS DECIDE EVERY SCREEN AND EVERY RECORD**: the SHAPE, whose slots are\nthe table below; the ROLES the entities declare (\xA7 Field roles), which fill those\nslots and decide what each child\'s rows become; and the CLAUSES, which are where\nthe plan overrides what the two would answer. This index says which is which, so\na clause is reached for only where the first two have no answer.\n\n**What a child\'s rows become.** One section per LINK into the record, and what\nthe CHILD declares decides its kind \u2014 the first line it answers wins.\n\n| Printed as | The child declares | The reader gets |\n|---|---|---|\n| `documents:` | an `expected_set` select, one entry per row | the desk of what is owed, each row\'s files attached to it |\n| `timeline:` | an `obligation` closed by a DATE | the ordered stops, the promised day against the day it happened |\n| `thread:` | a `markdown` body, a `party` and a `when` | the correspondence, its answer, and whose move it is |\n| `itinerary:` | a `when` and a `lifecycle`, on rows a JOB\'s record OWNS | the day heads the run, one line per stop, the sum under it |\n| `lines:` | anything else the record owns (`parent`) | the register of the rows it is made of |\n| `history:` | anything else naming it as their `party` | the same rows read as that party\'s own history |\n| `via <link>:` | a link carrying neither role | the register, headed by that link\'s own label |\n| `related:` | rows of an entity this app lists NOWHERE | one counted line, and a press that opens their register |\n\nA child carrying the BODY and one of the other two is a plain register, and\n`scaffold check` names the declaration it is one short of rather than letting it\nfall through silently. A `party` beside a `when` and no body is not a near miss \u2014\nthat is every ledger line there is.\n\n**The clauses, by name.** On the APP: `scope`. On the `screen`: `record`, `tabs`,\n`slots` (`roles` on a `custom` one), `columns`, `writes`, `period`, `filters`,\n`summary` (`above`, `totals`, `ageing`, `trend`), `facts`, `presentation`\n(`lead`, `density`, `layout`, `until`), `acts` (`row`, `record`, `selection`,\n`export`, `import`), `section_acts`, `sections`. Every one is optional, every one\nis printed back by `scaffold check`, and a screen that states none gets what its\nshape and its roles answer by themselves.\n\n**ONE APP IS ONE REGISTER AND THE RECORDS IT OPENS.** A second register beside\nit is a second job on one page: the reader arrives on whichever the nav listed\nfirst and decides, every time, which of the two they came for. So an app has one\ndestination and everything else about a row is DISCLOSED by opening it \u2014 the\nfacts by tier, the children as lists, the related as counts, one primary act, and\na filter or a group where a strip of destinations would have been. What used to\nbe a second screen is a section of the record, or another app; a model still\nsaying `screens` is refused by name.\n\n**The unit of an app is a JOB, not a person and not a table.** A job has its\nown outcome (something exists or is settled when it is done), its own subject\n(the record it advances), and an end that does not wait on the rest of the\nwork. Two tasks are ONE job when neither finishes without the other and both\nadvance the same record. One app per job, and that is what makes an app a\nwrite-ownership boundary: what the job settles is its app\'s alone to write,\nplus everything it must read to settle it well. A person holding several jobs\nopens several apps \u2014 one app for everything one person does is the dump. A step\nof the job is a section of the record, never a register beside it; a reference\nthe job needs at hand is read inside the record it is about.\n\n**Two people signing is two jobs**, because the outcome belongs to the signer:\nthe desk that prepares against the gate that releases. So is a different\ncadence \u2014 reference data edited monthly and read by the public site, beside a\ndaily desk \u2014 and a different reader, the owner\'s read-only questions. Device,\nplace and step never split a job. A super app holds more than one job; an\nover-split holds less than one, a job cut by device, place or step. The count\nper workspace falls out of the jobs, typically two to six, and is never the\ninput: review the plan app by app, naming who holds it, the job in one\nsentence, what it writes and what it reads.\n\n*A two-van appliance repair shop.* The technician\'s job is the call-out: the\noutcome is a visit done, the subject is the call-out record, and quoting it and\nscheduling it are one job, because neither finishes without the other and both\nadvance that record. The owner holds two of his own \u2014 billing the month\n(outcome invoiced, subject the invoice, settled against visits already closed)\nand the price list the booking page quotes from, edited monthly. Three jobs,\nthree apps, and the owner opens two of them.\n\n**Inside a job the caps hold**: six flow stages on a desk, seven facts before a\nrecord\'s fold. Past them, look for the second job hiding inside. And a job\'s app\nis DENSE: every create the job needs lives in it, so its user never leaves it to\ncorrect a figure the screen in front of them is stating \u2014 three workflows is a\nscreen, not a desk.\n\n**A handoff is a stage change.** Where one job ends the record moves stage and\nthe next job\'s desk opens on it; a field two jobs must both write is declared\nshared at the split, naming the stage each may write it in.\n\n**Name an app for the JOB, in the trade\'s own words, never for who it is for.**\nA title on the door says nothing about what the person who opened it came to\ndo, and it is wrong the day the org chart moves.\n\n```jsonc\n"apps": [\n {\n "alias": "sales", "name": "Sales",\n "description": "\u2026", "icon": "briefcase", "theme": { "color": "blue" }, // optional\n "screen":\n { "alias": "orders", "label": "Orders", "shape": "lifecycle_desk", "entity": "order",\n "record": "drawer", // optional \u2014 "drawer" | "page"; absent, the shape decides\n "tabs": "stage", // optional \u2014 a select on the entity, or null; absent, the shape decides\n "writes": false, // optional \u2014 default TRUE; false makes this screen a reader\n "period": "due_date", // optional \u2014 any field whose value is a date, derived ones included\n "filters": ["kind", // optional \u2014 a single-select, or a lens the model states itself\n { "label": "Qu\xE1 h\u1EA1n l\u01B0u", "predicates": [\n { "label": "\u0110\xE3 qu\xE1", "tone": "red",\n "where": { "node_type": "group", "logic": "and", "children": [\n { "node_type": "condition", "field_key": "owed", "operator": "greater_than", "value": 0 }] } }] }],\n "summary": { "totals": ["total"], "above": ["total", { "field": "owed", "at": "end" }],\n "ageing": { "amount": "owed", "due": "due_date", "buckets": [30, 60, 90] },\n "trend": { "field": "total", "direction": "up" } }, // optional \u2014 see below\n "facts": { "groups": [{ "caption": "Pricing", "fields": ["rate", "surcharge"] }] }, // optional \u2014 the record\'s named bands\n "presentation": { "lead": "none", "density": "dense" }, // optional \u2014 how it is DRAWN; absent, what the rows are decides\n "acts": { "row": [{ "label": "Issue the note", "template": "debit_note", "place": "cta" },\n { "kind": "agent", "label": "Read the papers", "agent": "reader", "fills": ["ref", "due_date"] },\n { "kind": "workflow", "label": "Hand it to dispatch", "workflow": "hand_to_dispatch",\n "inputs": { "order_id": "record", "owed_by": "due_date" } }],\n "record": [{ "label": "Print the file", "template": "dossier" }], // optional \u2014 the record\'s own header menu\n "selection": [{ "label": "Statement", "template": "statement" }], // optional \u2014 the work it hands on\n "export": true, // optional \u2014 the rows in view, saved\n "import": { "kind": "import", "label": "Upload the sheet", // optional \u2014 a file turned into rows\n "entity": "order", "key": "code", "columns": ["rate"] } },\n "section_acts": { "line": [{ "label": "Chase the lines", "template": "chaser" }] }, // optional \u2014 a verb on one section\n "sections": { "line": { "draw": "worksheet", "cost": "buy_rate", "sell": "rate" } }, // optional \u2014 priced lines, worked down in place\n "slots": { "identity": "code", // optional \u2014 slot \u2192 field, where the roles cannot decide alone\n "subject": ["customer", "project"], // \u2026or the TIERS a fold walks down, outermost first\n "stage": { "field": "state", "quick": true } }, // \u2026or the field AND the reader\'s own control for it\n "columns": ["source", "owner"] } // optional \u2014 extra facts after the slots, shed first at narrow width\n }\n]\n```\n\nA shape is a proven screen with named SLOTS, each filled by a field carrying a\nrole (\xA7 Field roles). A slot with exactly one candidate on the entity binds by itself;\ntwo candidates need naming in `slots`; a field fills one slot; a required slot\nwith none is refused \u2014 a lifecycle desk over an entity with no `lifecycle`\nselect cannot be built.\n\n**A LIST IS A HIERARCHY, not a set of columns.** A group register\'s `subject`\ntakes one \u2014 `"subject": ["customer", "project", "order"]`, outermost first \u2014 and\nthe register becomes one the reader walks DOWN: it folds by the first tier, a\nnode narrows to the tier inside it, the last tier opens the set behind the\nfigure, and a trail says where they are. Every tier is a field of this entity\ncarrying a role that slot accepts, and none of them fills a second slot. It is\nthe slot\'s own answer that decides: a list on any other is refused, because two\ncolumns in one cell is not a fold. Without it the same business needs a register\nper depth, and the same money is folded twice with nothing saying it is the same\nmoney.\n\n**A QUICK SLOT IS FOR A DECISION THE READER MAKES FROM THE ROW ALONE.** A slot\'s\nvalue in `slots` is normally the field\'s alias; `{"field": \u2026, "quick": true}`\nsays the column is not a reading of that value but the CONTROL for it \u2014 pressed,\nit writes that one field as a diff through the record\'s own update, so the\nregister and the page behind it land the same write. THREE roles are such a\ndecision, and `quick` on any other is refused: a `lifecycle`, which rests as the\nstage chip and opens the field\'s own stages with the ones that END the flow last;\na `verdict`, which is the switch; and a `measure`, which is the figure typed in\nits own cell \u2014 a reading taken per row IS the row, which is what a timesheet\'s\nhours and a count sheet\'s tally are. Everything else is decided against what the\nrecord holds, and the record has to be open. All three carry the record\'s own\nblockers \u2014 a move the ladder holds is a move the cell holds, and it names the\nentries the row still owes \u2014 and an outcome asks before it commits. Add `"order": "sequence"`\nwhere the stages are a WALK rather than a set of destinations: the cell is then\nthe next step alone, the same advance `RecordProgress` draws under the run, and\nthe doors out of the flow stay on the record. A screen stating `writes: false`\nrefuses `quick`: a reader who may operate nothing has no decision to take.\n\n**THE SLOTS ARE THE ROW\'S ANSWER; `columns` IS ITS CONTEXT.** `"columns":\n["nguon", "phu_trach", "nhu_cau"]` names extra fields of the entity, drawn AFTER\nevery slot and in this order, each by what its column IS \u2014 a chip for a select,\nthe person\'s name for a member, the day for a date, the figure for a number, a\ntick for a boolean, the words for everything else. They are ranked BENEATH every\nslot, so a phone sheds all of them before it sheds any of the shape\'s own\ncolumns. At most four, and refused where one would say what a slot already says,\nover a files field (files are read, so they are a section of the record), over a\ncell holding several (a column of comma lists compares nothing), on a shape whose\nrows are not the entity\'s records in a table, and beside a `presentation.layout`\nthat draws the rows with a device of its own. A fifth fact worth the width is a\nslot the shape is missing \u2014 say so rather than widening this clause.\n\n| Shape | Answers | Required | Also fills | Record | Tabs |\n|---|---|---|---|---|---|\n| `lifecycle_desk` | what is stuck, what do I move next | `lifecycle`, `identity` | `mark`, `party`, `amount`, `measure` (level), `when` | page, or drawer where the entity carries `parent` | the lifecycle\'s stages |\n| `party_register` | who is this, our history, is there a risk | `identity` | `mark`, `contact`, `measure` (worth), `verdict` (risk) | page | none |\n| `offering_register` | what do we offer, at what price, can I sell it | `identity` | `mark`, `amount` (price), `measure` (availability) | page | none |\n| `transaction_ledger` | does this period reconcile, what is unexplained | `when`, `amount` | `identity` (reference \u2014 the line\'s own number), `party`, `lifecycle` (classification), `expected_set` (document) | drawer | none |\n| `monitored_asset_set` | what needs attention, is that number normal | `identity`, `measure` (level) | `mark`, `lifecycle` | drawer | none |\n| `obligation_desk` | what is due next, and has it been done | `identity`, and at least one `obligation` on the entity | `when` (runway) | page | none |\n| `trend_deep_dive` | how did the period go, and why | `when` | `measure`, `amount` | drawer | none |\n| `reconciliation_desk` | what does not match, and by how much | `identity` (reference), and two figures \u2014 `ours` and `theirs`, each an `amount` or a `measure` | `category` (reason), `verdict`, `when` | drawer, on the PAIR | the dated runs, where the model names a select |\n| `entry_matrix` | one value per subject per period | `identity` (subject), `across` \u2014 a `when`, or a `category` for a fixed column set | `measure` (value), `lifecycle` (state), `category` (note) | drawer, on the cell\'s row | none |\n| `guided_run` | complete an ordered sequence, a step at a time | `identity` (step) | `slot` (sequence), `capture` \u2014 a `measure`, `verdict` or `expected_set` \u2014 `verdict` (gate), `mark` (media) | page: the run IS the record | none |\n| `live_board` | is everything OK, right now | `lifecycle` (state), `identity` | `verdict` (exception), `when` (since), `measure` (level), `category` (where) | drawer | none |\n| `group_register` | what does each group come to, and what is behind it | `subject` \u2014 a `category`, `party` or `when` | `amount`, `measure`, `identity` (member) | page: a row is an aggregate, and the door is the SET behind it | none |\n| `media_set` | scan a body of pictures by where they belong | `mark` (picture), `identity` | `category` or `party` (group), `when`, `verdict` (current against superseded) | drawer | none |\n| `day_sheet` | what happened on each day | `when` (the day) | `lifecycle` (state), `measure` (level), `amount`, `category` (note) | page: a day composes several child logs | none |\n| `resource_schedule` | what is on each resource, in what order, under what ceiling | `lane` \u2014 a `party` or a `parent` \u2014 `identity`, `when` (the start) | `measure` (duration), and `load` and `bulk`, each a `measure` read against a ceiling of the LANE\'s \u2014 a vehicle fills by weight and by room and stops at whichever runs out first; `lifecycle` (stage) | drawer, over the lanes | none |\n| `worksheet` | priced lines the reader edits, totalling to one figure | `identity` | `category` (group), `measure` (quantity), and `cost` and `sell`, each an `amount` or a `measure` | drawer, per line | none |\n\n**A record is one of five KINDS, and the shape plus the roles decide which.**\nEvery one of them is ONE READING COLUMN, and the ORDER of that column is the\nwhole of what the kind means.\n\n| Kind | The column, top to bottom |\n|---|---|\n| WORK RECORD \u2014 a `lifecycle_desk` over rows belonging to nothing | the one act that moves it \xB7 what it IS \xB7 the papers it owes \xB7 the rows it is made of \xB7 the book of what it came to \xB7 its own words |\n| LINE \u2014 the same shape over rows carrying `parent` | its ladder \xB7 the arithmetic behind its amount \xB7 the rows and papers under it \xB7 its particulars, last (the key it is filed by is the line under its title) |\n| PROFILE \u2014 a `party_register` | its history, OPEN, grouped by year \xB7 the rest of its registers \xB7 its own words \xB7 its facts (the ways to reach it are under its name, its settlement is the band) |\n| CATALOGUE ITEM \u2014 an `offering_register` | its own words (the picture leads the header at media scale, the price is read against its reference in the band) \xB7 what uses it \xB7 its facts |\n| EVIDENCE \u2014 a `transaction_ledger` | the PROOF \xB7 what the paper is, compactly \xB7 the rest \xB7 the links to the record it settles and the party it was with |\n\nNothing in the file states the kind: a clause that could say it could say the\nwrong one, and `parent` already says whether a row stands alone. `record`\noverrides the DOOR where the shape\'s own answer is not the one wanted \u2014 and its\nthird value, `"expand"`, is not a door at all: the row REVEALS what it holds in\nplace, where there is nothing behind it worth navigating to. A row read that way\nreads as a LINE whatever the shape would have opened: its ladder, the arithmetic\nbehind its amount, then its particulars, because there is no header above it to\nhave stated the name first. It is REFUSED for two reasons, and they are not the\nsame refusal. On a shape whose record is a page by nature \u2014 a `guided_run`, a\n`group_register`, a `day_sheet`, an `obligation_desk` \u2014 because each of those\nstates the opposite where its door is declared: what its row leads to is read\nwhole, not as four more columns than the register had room for. And on a shape\nthat draws its rows with a device of its OWN \u2014 an `entry_matrix`, a `media_set` \u2014\nbecause a cell of a grid and a tile on a wall have no band under them for the row\nto reveal into. For the same reason a revealed register is drawn as a TABLE: a\n`presentation.layout` of anything else is refused, and the reader is not offered\nthe arrangement either, since it would take away the only door the register has.\n\n**`scope` \u2014 the one subject every read of the app narrows to.** Stated on the\nAPP rather than on its screen, because it is not a filter: a record opened under\nit stays under it, the pick survives closing the app, and every app declaring the\nsame `entity` shares one pick per viewer \u2014 which is the whole value, and what\nstops a construction workspace asking which project twelve times. `entity` is the\nmodel\'s entity whose one row the app is read inside, and `param` is what the\napp\'s reads take that row by.\n\n```jsonc\n{ "alias": "site_work", "name": "Site work",\n "scope": { "entity": "project", "param": "project_id" },\n "screen": { \u2026 } }\n```\n\nThe register\'s own entity has to REACH it \u2014 its own rows, or rows that name one\nthrough a link \u2014 or the switcher is a control that changes nothing. Until a\nsubject is picked the app reads NOTHING and says which one it is waiting for: an\nunscoped read over a scoped app is every row in the workspace drawn as one\nproject\'s work, which looks correct.\n\n**ONE RECORD SURFACE PER ENTITY, AND ITS KIND IS THE ENTITY\'S.** An app is one\nregister, so the record its rows open is the register\'s own and the kind comes\nfrom that register\'s shape. Another app over the same entity opens the same kind\nof record: a payment read from a cash book and one read from a trend are both\nmovements, and both read proof first. A shape with no kind of its own (a trend, a\nmonitored set, a `custom` screen) says only where the record opens.\n\n**Two things a register states about a row beyond which field fills which slot.**\nA `measure` is read AGAINST its limit (`against`/`alert`) where the shape\'s slot\ntakes one \u2014 `level`, on a lifecycle desk and a monitored set, and every slot of a\n`custom` screen, which composes the frame\'s own column; `worth`, `availability`\nand a trend\'s headline are plain figures, and a limit there would read as a meter\nin the record over a number the row printed bare. And the name carries a\nSUPPORTING LINE where the entity states a key to file the row under: the `parent`\nit belongs to, its `reference`, an `autonumber`, a text formula \u2014 in that order,\nfirst hit wins, never a field a person types prose into. A LINE\'s record heads\nwith that same key, so the register and the door it opens name one row one way.\n\n**The act that opens a row belongs to a register that LISTS the entity\'s rows**\n\u2014 a desk, a register, a ledger, a monitored set, or a `custom` screen. A shape\nwhose rows are not records has neither that act nor the door a counted line\nelsewhere leads to: an obligation desk\'s rows are deadlines and a trend\'s are\nperiods, so a create pressed there would open a row the screen cannot show. An\nentity whose only app is one of those has no create at all \u2014 give it an app whose\nregister its rows are read in.\n\n**An `obligation_desk`\'s rows are not its entity\'s rows.** The shape fans the\nentity\'s `obligation` fields out \u2014 one row per thing still owed, which is one\nwhose date is SET and whose `satisfied_by` is empty \u2014 so a l\xF4 with four cut-offs\nis four rows, and the three it has met are not there at all. The row\'s name is\nthe obligation\'s, its supporting line the record\'s `identity`, and the door is\nthat record. A `when` bound to `runway` is what the countdown\'s ring is measured\nfrom. Over an entity declaring no `obligation` the shape is refused: there is\nnothing to count down to.\n\n**What a register carries beyond its columns.** Six clauses, each emitted as\nthe prop the kit draws it with; every one is optional and a screen that states\nnone gets a register of columns and nothing else.\n\n- **`period`** \u2014 a field on the entity whose value is a DATE. The toolbar gains a\n date-range control (this month to start), and the rows it keeps are what the\n register AND every figure below are computed over, so a band can never be over a\n different window than the rows under it. Any such field, not only the `when`\n role, and however the date got there: one table is read two ways (a cost ledger\n by the day a line arose, the cash book over the same table by the day it\n settled), a role is one field\'s, and the day money MOVED is often a formula over\n the two columns that could hold it \u2014 which then declares `output_type: "date"`.\n A `trend_deep_dive` brings its own period and takes none here; a `live_board`\n is PERIODLESS and takes none either, for the opposite reason \u2014 it answers what\n is true NOW, and a date range over it draws what was true in the window in the\n live state\'s own colours.\n- **`filters`** \u2014 the chips beside the search. A single-`select` field\'s alias\n offers that field\'s own live options; it is the lens a register is read through\n over and above the one band its strip gives, so the strip\'s own field is\n refused here (one dimension, one control) and so is a multi-select (a row would\n answer the chip several ways at once). The rows the register and every figure\n below are computed over are what the chips AND the period kept.\n A DERIVED LENS is the other form \u2014 `{"label": \u2026, "predicates": [{"label": \u2026,\n "tone": \u2026, "where": \u2026}]}` \u2014 where the sets a reader narrows by are ones the\n MODEL names and no column holds: rate validity, plan urgency, free-time\n overrun. Each `where` is an `and` group of plain conditions over the entity\'s\n own fields, each `field_key` a field alias, and it is read off the row, so the\n operators are the comparisons a value answers \u2014 `number` and `text` equality,\n `number` ordering, `boolean` equality, `select` `has_any_of`/`has_none_of`, and\n `is_empty`/`is_not_empty` on any of them. Nothing DATED: a relative point needs\n a clock, and a screen states one date control (`period`), so a second beside it\n would be two windows with nothing saying which a figure was computed over \u2014 a\n date question is asked as a column the model derives and read here as a number\n or a yes/no. `tone` is the option palette\'s, because the chip draws these\n exactly as it draws a select\'s options.\n- **`summary`** \u2014 what the rows in view come to. `above` is a band OVER the\n register: `"counts"` for how many rows are in view and, for the FIRST\n `measure` the model gives a limit, how many are past it (accented only when\n one is) \u2014 one such set, because a band naming three is the dashboard a\n register is not; a list of number-field aliases for what they add up to, and a\n `percentage` named there is refused, since a share summed over the rows in\n view is a figure of no kind (draw it as a column, whose own total reads the\n mean). A figure written `{"field": \u2026, "at": "end"}` is a STOCK instead: the\n reading standing at the END of the window rather than the sum of the readings\n in it, which is the only way opening + in \u2212 out = closing reconciles \u2014 a month\n of daily closing stocks added together is thirty warehouses. The band labels it\n as the snapshot it is, and it is refused on a screen that states no `period`,\n since there is then no window for it to stand at the end of.\n `ageing` stands in that same\n band: an `amount` split by how many days past its `due` date each row is,\n drawn as a `Breakdown` of the edges in `buckets` (30, 60 and 90 days unless the\n model states its own, ascending). The ladder is the kit\'s \u2014 its boundary, its\n words and its ramp \u2014 and it opens with what is NOT YET DUE, because a bar of\n overdue bands alone is full at every input: a book with one late invoice and a\n book that has gone entirely bad would paint the same solid width. Against the\n current money the bar\'s own shape is the reading. A row whose date is empty is\n in no band. `totals` is what the whole\n view adds up, and it is drawn in that SAME band above the rows: a register\n states one aggregate, over the rows the reader can see, in one place, and a\n closing line under them is a second place to look and a second question about\n which rows it covers. A closing line belongs to the one device that IS a\n statement \u2014 a record\'s `Ledger`, whose balance is what it is read for, labelled\n by its amount\'s own word. Where a shape has no band above (a trend, whose own\n band is the chart) the figure stands under the register instead. A\n `transaction_ledger` sums its amount column itself and takes `above` alone. An\n `obligation_desk`\'s rows are DEADLINES and not records, so its band states\n `"counts"` \u2014 how many are open \u2014 and a figure list, an `ageing` or a `trend`\n over it is\n refused: a l\xF4 with three cut-offs is three rows, and its freight added over\n them is three times the freight.\n `trend` is `{"field": \u2026, "direction": "up"|"down"}` and states WHICH WAY that\n figure moved across the window: the rows in view are cut into the window\'s own\n buckets \u2014 days over a month, weeks over half a year, months beyond \u2014 and the\n later half of the CLOSED ones is read against the earlier half, drawn beside\n the figures as one line, so a window the reader is still inside states the\n movement of the days that have run rather than a fall into the ones that have\n not. `direction` is which way is GOOD, which nothing about the number\n says: a book wants its takings rising and a backlog wants its days falling, and\n the same arrow is a win on one and an alarm on the other. It needs a `period`\n to be a window of, and it is refused on a shape that draws its own (a\n `trend_deep_dive` already draws the series) or that reads none at all (a\n `live_board` answers what is true now). A window whose earlier half took\n nothing draws no line: there is no base for a percentage, and 0 % would claim a\n steadiness nobody measured.\n- **`facts`** \u2014 how the RECORD\'s facts are banded. `groups` is a list of\n `{caption, fields}`: what a set of facts has in common is a sentence about the\n business and nothing derives it, so a plan that states one gets exactly those\n bands, in its own order, captioned. Everything it leaves unnamed reads in the\n derived order below it. A field the header, the band, the ladder, a required\n set, the files or a register below already draws is refused: it is not a fact,\n so a band naming it would either state it twice or draw nothing. Naming any\n band IS the answer to which facts fold \u2014 see the TIERS below.\n- **`presentation`** \u2014 how the screen and the record it opens are DRAWN, where\n what the rows are is not the answer wanted. `lead` is what each row leads\n with: `"mark"` the subject\'s own mark, always spent because a party\'s initials\n stand in for a picture nobody uploaded; `"picture"` the photograph, spent only\n on the rows that carry one and, on the record, at media scale above the name;\n `"paper"` the row\'s own files, drawn as the cards a pile of documents is read\n as; `"figure"` the amount, with no gutter at all; `"none"` neither. `density` is\n how many lines a row\'s subject may take \u2014 `"roomy"` two, `"dense"` one.\n Both default, and the DEFAULT is what the rows are: a party register leads\n with the mark, an offering register with the picture, a ledger with its\n figure, and a desk \u2014 rows that are work still to do, scanned \u2014 stands its rows\n one line each while a record\'s own rows are read roomy. So state it only to\n differ, and a plan that states nothing still gets screens that vary. A lead\n the rows cannot carry is refused: a mark, a picture or a paper where no column\n is drawn as the rows\' mark, a\n figure where no column is the amount, and the other subject\'s mark (which of\n the two a row wears is the shape\'s, so the register and the record it opens\n cannot call one row two kinds of thing).\n `layout` is how the rows are ARRANGED \u2014 `"table"`, `"list"`, `"cards"`,\n `"gallery"`, `"board"`, `"calendar"`, `"timeline"`, `"gantt"` \u2014 and it is\n AFFORDED by what the rows carry, not by the shape: every register affords a\n table and a list, a `mark` affords the two picture-led grids, a `lifecycle`\n affords a board, a `when` affords a calendar and a timeline, and a `when`\n beside a `measure` that `counts` DAYS affords a gantt, because a bar with no\n length is a calendar drawn sideways and a bar measured in money or litres is a\n length of another kind. `until` names the date field a bar ENDS on, where the\n rows state one, and the days measure is what the bar runs for without it. A\n layout the bound roles do not afford is refused, naming the ones they do.\n- **`acts`** \u2014 where this screen hands work on: `row` is the \u22EF menu on every row,\n `record` is the \u22EF on the RECORD\'s own page header \u2014 the same reach as a row\'s,\n standing where the work is open instead of while a list is scanned, and refused\n on a record that opens as a drawer, which has no header of its own and whose\n row already carries the register\'s menu \u2014 and `selection` is the bar over the\n TICKED rows. `export` is the third reach \u2014\n the WHOLE VIEW, saved in one press: `true` writes the rows as the register drew\n them, in the columns it drew, as an `.xlsx`, and\n `{"template": "\u2026"}` says the layout is the trade\'s and makes the paper with a\n workflow like any other. It is never a list of columns: which columns the\n export carries is what the screen already draws, and a second statement of it\n disagrees the moment a slot moves \u2014 the generator reads them off the drawn\n slots and writes them into the app, so the sheet\'s headings are the labels the\n register drew them under.\n `import` is the reach OPPOSITE it \u2014 a file turned into rows:\n `{"kind": "import", "label": \u2026, "entity": \u2026, "key": \u2026, "columns": [...]}`. The\n file is mapped column by column, each row is validated, what would change is\n previewed, and the commit UPSERTS by `key` \u2014 which is why a sheet sent twice\n moves no counts. That key is one of the entity\'s own `natural_key` fields\n (\xA7 Write rules), and an entity declaring none is refused: without a key the\n second run is a second set of the same rows. `entity` is the register\'s own \u2014\n an app is one register, and rows filed into another entity are a count nobody\n on this screen can check. `columns` is what the sheet may fill, in that order;\n absent, it is every column a person states IN WORDS. A computed one is refused\n because the workspace writes it, and a link, a member or a files column is\n refused because a cell of a sheet is words and those hold a row of another\n table or bytes \u2014 a file reaches another table through the party pattern (a\n `natural_key` on the target, named rather than picked), which is a create\'s.\n The generator writes the whole verb, as it does for a paper: an\n `import_<entity>` workflow that finds the row by `key` and then changes it or\n opens it, answering which of the two, and the staged run over it \u2014 drop, map,\n the per-row verdict, the commit. A line whose cell the column cannot hold is\n refused with the reason and the rest of the sheet still lands.\n Both spreadsheet reaches are read and written IN THE BROWSER, so the generated\n `src/main.tsx` imports `@lotics/app-runtime/sheets` wherever the plan states\n one of them and not otherwise; an app that has neither carries no spreadsheet\n engine at all. An act names ONE of three things, never two:\n - **`template`** \u2014 an `html` template this model declares (\xA7 Templates). The\n paper is filled from ONE row in `row`, and from the whole ticked set in\n `selection`. The generator writes the whole verb \u2014 a `generate_<template>`\n workflow that reads the row and hands the file back, and a menu item that\n opens it \u2014 so there is nothing left to bind. An `email` template is sent\n rather than opened and is refused here; a file-backed template (`excel`,\n `word`, `pdf-form`) is bytes a model has none of, so those stay the author\'s\n own `lotics app workflow set`. Two screens over DIFFERENT entities cannot\n name one template: one paper is filled from one kind of row.\n - **`{"kind": "agent", \u2026}`** \u2014 a run over ONE row. `agent` is an alias the APP\n declares (`package.json#lotics.agents`), because an agent is prose and tools\n rather than anything a workspace model can state \u2014 `lotics app check` refuses\n one nothing declares, exactly as it refuses a workflow nothing bound. `fills`\n is the fields the run may write: pressed, the reader watches the run, reviews\n what it proposes field by field against what the row holds now, and applies\n the ones they kept as ONE write through the record\'s own update. A run can\n reach nothing outside `fills`, a computed column there is refused (the\n workspace writes those), and a screen stating `writes: false` refuses an\n agent act outright. It belongs in `row`; the selection bar makes ONE paper\n from many rows, and a run per ticked row is the row\'s own act many times.\n - **`{"kind": "workflow", "workflow": "\u2026", "inputs": {\u2026}}`** \u2014 the work HANDED\n ON: a lead becomes an opportunity, an order is split into the orders placed\n on its suppliers, a request is closed. `workflow` is an alias the APP binds\n (`package.json#lotics.workflows`), because the rule behind it is the\n business\'s and no model states one \u2014 so the generator writes NO body and\n `lotics app check` refuses an alias nothing bound, exactly as it refuses an\n agent nothing declares. `inputs` is what the press hands that body: the\n workflow\'s own input name \u2192 `"record"` for the row it was pressed on, or a\n field of this entity for a value off that row. The value is what the\n workspace HOLDS \u2014 an option\'s key, a linked row\'s id, the figure \u2014 never the\n rendering. A files column is refused (the body reads the row\'s files off the\n row it is handed), a column that is not a field of the entity is refused by\n name, and one input short of the declaration is refused at the press, so the\n whole act is dropped rather than shipped half-bound. It runs ON the press:\n there is nothing to review first, which is the agent arm\'s law, and the\n body\'s own sentence is what the reader is told either way. It stands in\n `row`, in `record` and on a `section_acts` band \u2014 each reaches ONE row \u2014 and\n is refused over the ticked set, where one act makes one paper from many.\n\n **`"place": "cta"` draws an act ON the row** instead of in its \u22EF \u2014 the one verb\n a reader presses without opening a menu first. At most one per screen and every\n other act stays in the menu, because the trailing gutter is paid for by every\n row: a second is refused with *one verb on the row, the rest in its menu*. The\n ticked set has no row of its own, so `cta` is refused there.\n\n A hand-off\'s BODY is still the author\'s: `lotics app workflow set <alias>`\n binds it, and `scaffold check` prints the act as `"<label>" \u2192 <alias>(\u2026) [you\n write the body]` so it is read as work owed rather than as a verb already\n wired.\n\n A `selection` act is a `template` and nothing else: an act over many rows makes\n ONE paper from them. The generator turns the register\'s checkbox gutter\n on, emits a `generate_<template>` workflow taking the ticked ids, and the body\n reads each row and passes them as `rows` \u2014 which is what the template iterates\n (`{{#each rows}}`). One template is one paper AND one reach: naming the same\n one from a row\'s \u22EF and from the bar is refused, because those are two bodies.\n A shape whose rows are DERIVED rather than records \u2014 an `obligation_desk` \u2014\n has no set to tick and refuses the clause. The paper is handed BACK, never\n filed: a ticked set can hold rows of three different parents, so there is no\n record on which "this document belongs here" is true.\n\n**`section_acts` puts a verb WHERE ITS EFFECT LANDS.** "Chase the paperwork"\nbelongs on the papers, not in the register\'s \u22EF two surfaces away. It is keyed by\nthe alias each section is DERIVED from \u2014 a field\'s (its progress, its prose, its\nrequired set, its charge, one of its files) or a CHILD ENTITY\'s (its register,\nits desk, its run, its thread) \u2014 because the sections come off the roles and the\nkey the app addresses one by does not exist until it is built. An alias naming no\nsection is refused, listing the ones that do; the facts band is what every other\nsection left over, so nothing addresses it. Each act is a `template`, an `agent`\nor a `workflow`, exactly as a row\'s is \u2014 the reach is the same one row \u2014 and\n`place: "cta"` is refused: a section is a band with a heading and has no row to\ndraw a verb on. `scaffold check` prints\nwhich section each alias landed on, which is the half an author cannot see in\ntheir own file.\n\n**`sections` says HOW one of those sections is DRAWN**, keyed by the same\naliases. The one reading it carries is `"draw": "worksheet"`: the child\'s rows\nare PRICED LINES the reader works down in place, each part of the job footed and\nthe sheet closing under them. `cost` and `sell` name the child\'s two figures,\nwhich is the one thing the roles cannot decide \u2014 an entity carries one `amount`,\nand a line that is costed and sold carries two. Everything else is read off the\nchild\'s own roles: what a line is FOR is its `identity`, how many it is for is\nthe `measure` the pair left, and the part it falls under is its `category`. The\nMARGIN is on none of them, because it is arithmetic over the pair.\n\nA sheet is the WRITE side of a price, so it is the one child section whose cells\nare editors: a figure typed into a cell is written as a diff through the child\'s\nown update, which is the same editor the line\'s own drawer saves through. Every\nother child section is read where it is drawn and edited in the row\'s drawer. A\nline is ADDED from the section\'s heading as every child\'s row is, with the record\nit hangs under as the act\'s own context.\n\nRefused: an alias naming no register of this record, rows the record is merely\nthe `party` or `link` of (a line added to a history belongs to the job it is\nabout, not to what is reading it), a figure that is not a field of the child or\nwhose role is neither `amount` nor `measure`, a sheet naming neither figure, and\none figure priced as both.\n\n**A CHILD NOBODY REGISTERS IS A NOTE, not a refusal.** Where a record lists a\nchild entity no app is a register of, `scaffold check` says `"<entity>" has no\nregister \u2014 its rows are read here and created nowhere` and the plan printout\nmarks that entity `(no app)`. Rows can arrive from an import or a workflow, so\nit is a reading rather than a rule; what it catches is the register somebody\nmeant to plan and did not. A thread is exempt \u2014 a reply is written where it is\nread \u2014 and so is a sheet, which opens its own lines.\n\n`scaffold check` prints every clause a screen states, on its own line under the\nslots, spelling the screen\'s `writes` clause `operable` \u2014 the app manifest\'s\n`lotics.writes` keeps that word, and the two are different facts: `app create`\nSEEDS the manifest from the fields the screens\' own editors write, and from then\non the app owns the declaration `lotics app check` holds its bodies to.\n\nEach shape is a `@lotics/ui` component of the same name (`LifecycleDesk`,\n`PartyRegister`, \u2026) whose props are these slots, so once the tables exist\n`lotics app create <name> --from <this file>#<app alias>` scaffolds the app with\none screen per entry, each slot reading the field the plan bound.\n\n`"shape": "custom"` is the screen no registry row covers: it declares its own\n`roles` (slot name \u2192 role), and it is emitted on `ShapeFrame` \u2014 the one anatomy\nthe six shapes above are each a configuration of \u2014 so its strip, search,\nordinal, fit budget, empty and waiting states and record door are the same ones\nthey have. It opens the record its `record` says, drawer or page, like any other.\nA bound `lifecycle` slot IS its strip, the stages in the field\'s order as on a\ndesk, so such a screen names no `tabs`; a `tabs` select is the flat strip a\nscreen with no lifecycle gets.\n\n**An app IS its `app.json`.** `create --from` writes the bound plan \u2014 every\nscreen with its shape and slots, the record each row opens with its sections in\nthe archetype\'s order, the create panels, the acts \u2014 as one JSON document, plus\na five-line `src/main.tsx` that mounts `@lotics/app-runtime` over it and one\n`src/workflows/<alias>.ts` per write. There is no screen source: the runtime\ndraws the spec, so a kit correction reaches every app with its next install.\nWhere the plan has no word for what a screen or a section IS \u2014 or for what a\npaper act should ASK before it is made \u2014 the spec names one of the app\'s own\ncomponents and `src/components/index.ts` registers it, the one file under `src/`\na regeneration never rewrites. `lotics app eject <screen|<section key>|<act\nlabel>>` writes each of those, starting from what the runtime already drew.\n\n**How a spec reads a row.** Each query projects its columns under the alias the\nworkspace mints from the field\'s LABEL \u2014 never the alias this file keys it\nunder, which is the model\'s own namespace and which the workspace never saw. So\na field this file calls `partner` and labels `\u0110\u1ED1i t\xE1c` is referenced as\n`doi_tac` throughout the spec. Every id in it is live: the `tbl_` a screen is\nover, the `fld_` of each column, and the `opt_` of every option a role names.\n\n**Every query is described, in the model\'s own language.** The line an agent\nchooses between aliases by is written from this file\'s nouns \u2014 the entity\'s\nlabel, the screen\'s, the link\'s \u2014 and the sentence around them is the language\nthe file is mostly written in, decided the same way a generated write\'s refusals\nare. Hand-editing one is erased by the next regeneration; `lotics app check`\nrefuses an alias that carries none.\n\nA screen is that list and the RECORD it opens, and the record comes off the same\nroles \u2014 nothing to declare for it. It opens with a **header**: the entity\'s\n`identity` as the record\'s name \u2014 whether or not the list has a column for it, so\na ledger\'s and a trend\'s records are named too \u2014 the `when` this screen reads\nunder it, and ONE headline\nfigure (the `amount` the screen reads, else its `measure`). BOTH DOORS state the\nname: which door a screen uses is a layout answer, and what the record is ABOUT\nis not.\n\n**A JOB\'S HEADER ALSO STATES WHO IT IS FOR.** Where the record is the work itself\n\u2014 a desk\'s rows that belong to nothing, opened on a page \u2014 its `party` is a link\nunder the name, beside the ways to reach the subject, and it is then not a fact\nas well: a page headed by a code alone said nothing about whose work it is, and\nthe one thing a reader arriving from the desk already knows the row by sat in the\ngrid below as a row among forty. A record opening in a DRAWER has no row of links\nto hang it on, so its party stays a fact; so does a LINE\'s, whose register files\nit under the thing it belongs to and whose party is that parent\'s, restated per\nrow.\n\n**A record whose figures are a SENTENCE states them under the header, in a\nband**, and the header then states neither of them: a job reads what it comes to,\nhow far through it is (the `measure` against its limit), what is left of it (a\nformula that is exactly `{amount} - {measure}`) and how long there is (an\n`obligation`, else its `when`, counted down); a party reads its settlement, the\n`amount` and `measure` roles it carries in the order the model declares them; a\ncatalogue item reads its `amount` against the reference that amount names. Two\nfigures are the floor and four the ceiling \u2014 under two, the header keeps its one.\nA band is a PAGE\'s strip, so a screen whose `record` is a drawer keeps every\nfigure where a drawer reads them. Each figure is drawn exactly once: the limit a\nmeter states is not a fact beside it, and the day a countdown counts to is not\nthe provenance line above it. The `lifecycle` is not badged there, because the progress section is the\nrung it stands on. Then its sections, in the order its KIND reads them: the\n**facts** (every field neither the header, the band nor another section owns, the\nrow\'s own `parent` among them \u2014 led by the ones THIS screen\'s slots bound, then\nthe key it is filed under, then the order the model declares them in),\nthe record\'s own **body** (every `text` field the entity formats as `markdown` \u2014\nprose read at the width of the work, never wrapped to a label in a column\nbeside it), the **charge** (where the `amount` is a formula over exactly one\nquantity and one money field, the line states `quantity \xD7 unit price = amount`\nand neither figure is typed twice \u2014 the section is headed by the amount\'s own\nlabel and the closing row says what the figure IS, so the word is said once),\nthe **progress** (the `lifecycle`\'s stages, and the ONE act that moves the record\non \u2014 every other move, outcomes included, is an item in the menu beside it), a\n**required set** (a multi-select `expected_set`, or a child entity whose rows\ncarry one entry of it each \u2014 that child\'s `files` field is what a paper attaches\nto), the record\'s **own rows** (any other child, its role-bound fields as\ncolumns), and its **files** (every `files` field, the `mark` first \u2014 a section of\nthe column like any other, straight after the facts it is the evidence for,\nexcept on an EVIDENCE record, which OPENS on the paper it exists for; the\nheading carries the Add verb, and stands it down while the pile is empty, where\nthe drop well is already the door; and a `verdict` whose FORMULA reads one of\nthose files fields is the pile\'s OWN state rather than a fact row \u2014 the file it\nis short, drawn as a ghost, and the mark the rail sends a reader to). A child is\nan entity that LINKS here, ONE SECTION PER LINK: the rows it owns (`parent`), the\nrows that name it (`party` \u2014 so a party\'s record is its history), and the rows\nreaching it through a link carrying neither role, headed by that link\'s own\nlabel. A child hanging off two records therefore declares one `parent` and\nleaves the second relationship a plain link, which still gets its section.\n\n**THE ROWS A RECORD OWNS ARE ADDED AND OPENED FROM THEIR SECTION.** A `parent`\nsection\'s heading carries the Add for its rows, handing the record as the\nparent \u2014 filled, never asked. Every row opens its own record \u2014 a line of a book,\na paper on a desk, a stop on a timeline alike: where this app already routes a\npage for those rows (the register\'s own, under one of them) it is navigated to;\notherwise it is a DRAWER mounted on the record it belongs to, stepped \u25C0 \u25B6 over\nthe section\'s rows as they are drawn, with the row\'s framed editors, its ladder\nand its files, saving through `update_<child>` \u2014 the one editor those rows have,\nwhich any control the section draws over a row writes through too. Never the\n`parent` link itself: it is the key the row is FILED under, stated by the record\nthe drawer was opened from, so it is neither a fact of the drawer nor an input\nof that editor. ONE LEVEL, and only from a PAGE: a child\'s own children draw as\nthe kit\'s panel of the row, with no drawer and no Add, and a register whose own\nrecord is a DRAWER is the same for the children it owns \u2014 a drawer mounts no\ndrawer. **A ROW IS ADDED WHERE IT CAN BE OPENED AGAIN**, so those sections lose\ntheir Add with their drawer; a row of the register\'s OWN kind keeps it, because\nthe register\'s list opens it. A correspondence\'s entries are the one owned row\nthat opens nothing anywhere: the composer under them is the section\'s Add\nwhatever door the record has, and the answer mark is one field of the reply\'s\nown editor. The rows a `party`\'s record lists, and those reaching a record\nthrough a roleless link, are read only \u2014 a history, never a place to open or\nadd.\n\n**TWO CHILDREN ARE READ AS A RUN RATHER THAN AS A REGISTER**, and the model says\nwhich by the roles it declares on them. A child carrying an `obligation` whose\n`satisfied_by` is a DATE is a **timeline**: its rows are the ordered stops, each\nstating the day it was promised for and the day it happened, with the delta named\nwhere the rung was late \u2014 drawn as a register those are two date columns and the\nreader subtracts them by eye, which is exactly what "three days late at\ndischarge" costs to learn. An obligation closed by a FILE says nothing about\nwhen, so those rows stay the register they were. A child carrying a `markdown`\ntext field beside a `party` and a `when` is a **thread**: the entry is the body,\nthe author and the day lead it, and a `verdict` on the child is the yes/no one\nreply is marked the ANSWER with \u2014 a register of messages is a table whose one\nuseful column is the one it cannot draw. A SECOND `party` on those rows is who\nthe entry was left WITH, and the latest one names the ball in court: while\nnothing answers, the record says whose move it is and counts its own deadline\n(an `obligation`, else its `when`) against them. Both are read only under the\nrecord that OWNS the messages \u2014 under the party who wrote them the same rows are\nthat party\'s history, several questions at once, and the last of them says\nnothing about any. That record also REPLIES: the section\'s Add is a composer \u2014\nthe message is all a reply asks \u2014 and the answer mark is one field of the\nreply\'s own editor.\n\n**NOTHING SITS BESIDE THE RECORD.** A page is ONE reading column \u2014 its fields,\nits files, the rows it owns, its notes, all sections of it in the archetype\'s\norder \u2014 over a reserved left gutter holding a floating NAVIGATION RAIL: the way\nback, then one line per section with that section\'s category mark, and nothing\nelse. No files, no counts, no group headings. Too narrow to seat the rail, it is\none pinned bar of the same list across the top. A section HEADING never wears\nthe mark; the rail\'s line for it does.\n\n**A RELATION THE RECORD NEVER ACTS ON CLOSES THE COLUMN.** Every register that\nmerely NAMES the record is one counted line of a single last section: its label,\nhow many rows name this record (a COUNT, never a page of rows measured), and a\npress that opens the register owning them narrowed to this record. An entity\nthis app lists nowhere is a count with no door, and where every count is zero\nthe section is not drawn at all. The one exception is a PROFILE, whose history\nIS its body and stands open in the column.\n\n**Rows the record owns whose `amount` is `signed_by` a direction are a\nBOOK**, drawn as a statement \u2014 each line dated and signed, the balance under\nthem where there is more than one line to add up, labelled by the amount\'s own\nword \u2014 where the same rows on the party they\nwere transacted with stay that party\'s history, which is scanned for the line\nshort of its paperwork rather than struck to a balance. **Rows that carry a\n`when` read as RUNS**: a party\'s history under the year \u2014 and only where the rows\nin hand span more than one, since a subhead over every line of a book names the\nyear and separates nothing \u2014 and a job\'s own rows under the day they fall on,\nwhich, where those rows also carry a `lifecycle`, is an ITINERARY rather than a\ngrouped table: the day heads the run, each row is one line (its `slot`, its name,\nits party, its stage, its `category` and `reference`, its amount), the figures end\non one edge and the run closes with their sum \u2014 unless the record\'s own BAND is a\nsum rollup over exactly these rows, which states it once already. A `category`\npaints the node the run is read down, in the option\'s own colour; a `reference`\ncloses the line with the code somebody quotes on the phone. Both are PROJECTED for\nthe run whether or not the register\'s column budget would have drawn them.\n**EACH DAY\'S HEAD CARRIES THE SECTION\'S OWN ADD** as well, handing the panel that\nday, so a line made from Tuesday opens with Tuesday answered; the heading keeps\nthe verb too, for the first line and for a day nothing is planned on yet. **THE\nKEY LEAVES THE COLUMNS where the head states the whole of it** \u2014 a day head does,\na year head states four digits of the date and the day is what the column is\nstill there to carry.\n**A run is READ FROM THE END THE READER WANTS**: a job\'s own rows and an itinerary\nascending, a book and a history descending.\n\n**A DESK OVER AN ENTITY THAT CARRIES A `slot` IS ITSELF A DAY\'S RUN.** Its own\nregister is drawn under one subhead per day, sorted by that day and then by the\npart of it, and the date column goes \u2014 the subhead states it once for the whole\nrun, and a column repeating it down every line is the same value twice. Without\nit an itinerary was one flat list in whatever order the rows arrived.\nA document desk keeps its head either way \u2014 the entries it owes are its\ncontent, filed or not. The name and whatever figure is left to it are the header\'s\nalone \u2014 it states them in full, so a fact for either would be the same sentence\ntwice.\n\n**A FACT IS PRIMARY OR IT IS PROVENANCE.** An `autonumber`, a date the platform\nstamps (`derive_from`) and a date a formula works out that no role names are what\nthe SYSTEM wrote; they fold, with every optional field this record does not\nstate, behind the grid\'s ONE link. Everything a person types or picks is primary\nand is shown, empty or not, because absence is work somebody owes. **A screen\nthat named `facts.groups` has already said which:** a field in a group is\nprimary, and one in none folds \u2014 grouping says what the reader decides with, and\nby saying so says the rest are not. A `tier` on a `facts` entry exists only for\nthe case that derivation gets provably wrong.\n\n**A `contact` field whose kind the\nmodel decides** \u2014 an email, a number, a place, a `link`-formatted text, or a\nMESSAGING APP its label names (WhatsApp, Zalo, Telegram, Viber, WeChat, Line,\nMessenger) \u2014 leaves the facts too, for the row of links under the name on a\nrecord that opens as a page; one whose kind nothing decides stays a labelled\nfact, because a scheme nobody stated is a link that opens nothing. A handle is\ndrawn under its app\'s name \u2014 the app is what identifies the person there, and a\nbare number beside an envelope says the wrong thing \u2014 and it dials only where its\nvalue IS a number.\n`check` prints the record under each screen\'s slots:\n\n```\n Orders \u2014 lifecycle desk over Orders (12 rows) \xB7 page \xB7 tabs: Stage (New \u2192 Quoted \u2192 Confirmed \u2192 Shipped \u2192 Done)\n stage Stage \xB7 identity Order no. \xB7 mark Photo \xB7 party Customer \xB7 amount Total \xB7 level (none \u2014 no field declares "measure") \xB7 when Due\n record: header (Order no. \xB7 Customer) \xB7 band (Total \xB7 Shipped against Lines \xB7 Due counting down) \xB7 progress: Stage (5 stages) \xB7 facts (Ship to \xB7 \u2026 2 filed) \xB7 files: Photos \xB7 documents: Papers (Kind: 4 required) \xB7 itinerary: Order lines by Ship by at Window, kind Handling, ref Waybill (Product \xB7 Quantity \xB7 Line total) \xB7 related: Visits (count via Order)\n```\n\nThe ORDER of that line is the record\'s own, top to bottom, and it is the KIND\'s,\nand every entry of it is a section of the one column. `related:` is the counted\nline that closes it \u2014 a register the record never acts on, named and counted \u2014\nand `\u2026 N filed` is how many facts fold behind the grid\'s one link.\nA child register whose `amount` is `signed_by` a direction is a BOOK of movements\n\u2014 what the record came to rather than what it is made of \u2014 so it closes the\ncolumn rather than standing among the rows the job is made of.\n\n**A section over a child is named by the CHILD\'s own label**, because the reader\nalready knows which record they are on: "\u0110\u01A1n h\xE0ng", not "\u0110\u01A1n h\xE0ng \u2014 Kh\xE1ch h\xE0ng".\nWhere two links from one entity reach this record, that label names two sections\nand each takes its own link\'s label as a qualifier \u2014 `H\u1ED3 s\u01A1 (\u0110\u01A1n h\xE0ng)` and\n`H\u1ED3 s\u01A1 (\u0110\u01A1n g\u1ED1c)`.\n\nIn that line `documents:` is a `RecordExpectedSet`, and `lines:` a `RecordChildren` over the rows\nthis record owns \u2014 `history:` where they name it as their `party` instead; a required set\nwhose entries are a CHILD entity carrying files takes `kind="files"`, and the entity\'s own\nmulti-select takes `kind="items"`, since nothing attaches to an option.\n\n**A section that knows its size says so.** Where a `measure` on the record is a\n`count` rollup over the very link a rows section hangs on, the limit it is read\n`against` is how many rows that section is OWED: the printout adds `\u2014 expects\n<limit>` and the screen draws that many ghost rows until the first one lands,\ninstead of "nothing here" two bands under a count saying three are outstanding.\n\nA figure printed as `Total (USD)` is MONEY in the currency named \u2014 a `formula`\nstates it in `formula.currency`, and a `rollup` or a `lookup` inherits it from the\nfield it reads, so read those brackets: a rate that should be in dollars and is\nprinted bare will be drawn in the workspace\'s own money. Where the entity carries\na `currency` role, the ROW\'s code outranks the field\'s on every line that states\none, and a line stating none falls back to the field\'s.\n\n**A record surface can be OPERATED, and that is the default.** Every screen with\na record gets a workflow that writes its editable facts \u2014 every field a person\nstates \u2014 so the record\'s values are edited in place and its stage is advanced\nfrom the progress section. `"writes": false` makes one screen a reader: use it\nfor a screen over rows another desk owns, never as the default. A desk nobody\ncan act on is a viewer of state somebody must go and set somewhere else.\n\n**Which facts those are is the FIELD\'s answer.** Text, number, date, yes/no and\nselect are typed or picked. A `"cardinality": "one"` link is RE-POINTED, through\na picker over the target entity named by its `display_field_aliases` \u2014 else the\ntarget\'s `identity` \u2014 narrowed by what the reader types and carrying that\nentity\'s own `read_scope`, so a link the plan gives neither column is read\ninstead of offering an empty list. A files field is ATTACHED TO and DETACHED\nFROM, as the delta rather than the pile, so two readers filing at once each keep\ntheir file. A many-link, an `autonumber` and every computed field are read: the\nfirst is a list a fact cannot state, and the rest are the platform\'s to write.\n\n**A one-link is that fact WHATEVER its sync.** `sync_both_ways` is one relation\nwith a field on each side, and the sides are not the same surface: the record\nthat names ONE row states it as a fact, and the MANY side is the register on the\nother entity\'s record. Declaring both directions does not give this record a\nregister of the single row its own fact already names.\n\n**The write is the record SURFACE\'s.** Its inputs are the fields that surface\noffers to save \u2014 the facts with an editor, the lifecycle its ladder advances, and\na files section over exactly one stated field \u2014 never every field a person could\nin principle type. What the header states, a limit a meter folds in, a required\nset with a section of its own and the pile a page\'s mark draws are read there, so\nno input is declared for them. A second screen over the same entity draws no\nsurface of its own and adds nothing.\n\nWhere two operable screens of one plan reach the same entity, `scaffold check`\nnames them and the apps they belong to: two desks writing one record is a\ndecision, and the split is stated on the FIELD in each app\'s\n`package.json#lotics.writes` rather than left to whoever edits second.\n\nThat workflow lands in the app as source, like every other: its body in\n`src/workflows/update_<entity>.ts` and its declaration in\n`package.json#lotics.workflows`. The two together are the binding \u2014 `lotics app\ndeploy` pushes whatever differs from what it last saw live \u2014 so the write is\nversion-controlled and travels with the app rather than being bound by hand\nafterwards.\n\nA `custom` screen declares its slots under `roles` (slot \u2192 role) and they bind\nthe same way:\n\n```jsonc\n{ "alias": "readings", "label": "Readings", "shape": "custom", "entity": "reading",\n "roles": { "subject": "identity", "reading": "measure" } }\n```\n\n## Applying packages\n\n`apply` copies published packages into the workspace AFTER the model\'s own\ntables exist \u2014 apps over the tables you just described, and any tables of their\nown they still need. Ordered, and run by `lotics setup` and `lotics scaffold\napply` alike.\n\n```jsonc\n"apply": [\n {\n "package": "apg_k3nf82ldpq",\n "bind": { // optional \u2014 which of YOUR tables each entity is\n "company": { "label": "Customers", "fields": { "name": "Company name" } }\n },\n "no_sample_data": true // optional\n }\n]\n```\n\n`bind` is keyed by the package\'s entity alias and holds the LABELS this\nworkspace uses: scaffold adopts by label, so binding points the package at the\ntables the model created instead of a second set beside them. Only naming\nmoves \u2014 a bound field must be the TYPE the package declares, or the copy is\nrefused. `lotics library list` is the shelf, and `lotics library show <apg_id>`\nlists the aliases to bind.\n\nEntries run in the order they are written, because a later one may bind onto a\ntable an earlier one created. **A refused entry stops the run and the entries\nbefore it stay** \u2014 they are separate copies, committed as they land, so the\nrefusal names them rather than leaving a caller to re-run the file and copy them\ntwice.\n\n## Presets\n\nA preset is a trade\'s model, published to be READ. An assistant reads it, asks\nat most two questions, picks a variant and writes a `model.json` from it \u2014\nnothing is copied, and a preset is a file rather than anything a workspace\ninstalls.\n\n```jsonc\n"preset": {\n "name": "Field service",\n "description": "Jobs, the crew that runs them, and what each one billed.",\n "questions": ["Do you dispatch crews, or one person per job?"], // at most 2\n "variants": {\n "crews": {\n "when": "work is dispatched to crews rather than to one person",\n "entities": [ /* tables this branch ADDS */ ],\n "fields": { "job": [ /* fields this branch ADDS to `job` */ ] }\n }\n }\n}\n```\n\nVariants are **additive only**: a branch adds entities and fields and never\nremoves them, so the base is a model in its own right rather than a draft.\n`lotics scaffold check` proves the base AND every variant merged onto it, so a\npreset ships with every branch already proven \u2014 the branch nobody took is the\none that fails in the workspace of whoever takes it.\n\n`preset` is not scaffolded. `lotics setup` and `lotics scaffold apply` ignore\nit and create the base model\'s tables.\n\n`lotics scaffold export` prints a workspace that already works as one of these\nfiles \u2014 the starting point for a preset or for another business\'s model, never a\nsource of truth: it carries one business\'s words and stops describing that\nworkspace the moment either changes.\n\n## Starting from a preset\n\n`lotics library list` is the shelf of them and `lotics library show <slug>`\nprints one whole: its questions, every table as `alias \xB7 label` with each field\nas `alias:type`, and each variant as `slug \xB7 when` followed by the tables and\nfields that branch adds. When one of them is the trade in front of you, do not\ntranscribe it \u2014 name it:\n\n```jsonc\n{\n "from": "field_service",\n "variants": ["crews"],\n "rename": { "job": { "label": "\u0110\u01A1n h\xE0ng", "fields": { "code": "M\xE3 \u0111\u01A1n" } } },\n "entities": [ /* a table this business has that the preset does not */ ],\n "rows": { "job": [ { "ref": "j1", "fields": { "code": "J-1" } } ] },\n "field_roles": { "job": { "code": "identity" } },\n "write_rules": { "customer": { "natural_key": ["email"] } },\n "apps": [ /* the screens this business\'s apps will have */ ]\n}\n```\n\n- **`from`** is the preset\'s SLUG \u2014 its own file name, a lowercase slug. Naming\n it is what makes `entities` optional; every other rule on this page is\n unchanged, because the file is resolved into the full form and then checked and\n applied exactly as one. A slug nothing serves is refused with the ones there\n are, never resolved against something else.\n- **`variants`** names the branches to merge onto the base, in order. Pick the\n one whose `when` describes what the person said; a slug the preset does not\n declare is refused rather than ignored.\n- **`rename`** is keyed by the preset\'s entity alias and holds the labels this\n business uses \u2014 the same shape `apply[].bind` takes, and the same rule: only\n naming moves. An alias the preset does not declare, and a label that is\n already another table\'s, are both refused.\n- **`entities`** are added after the rename, already in this business\'s own\n words.\n- **`rows`**, **`field_roles`**, **`write_rules`**, **`apps`** and **`apply`**\n mean exactly what they mean in the full form \u2014 `"rows"` are this business\'s\n real first records,\n `"field_roles"` may name the preset\'s fields as well as its own (a role the\n preset declares itself is kept unless this file names the same field, or\n clears it with `null`), `"write_rules"` the create-time clauses on either\n (an entity this file names replaces the preset\'s whole entry for it),\n `"apps"` the screens it will have (a preset carries none), `"apply"` the\n packages copied in once its tables exist.\n\nThis is the ONE thing on this page that needs the network: `check` reads the\npreset it names, once. Everything after that read is the same offline check.\n\nWrite the full form when no preset is the trade.\n\n## A complete model\n\n```json\n{\n "entities": [\n {\n "alias": "customer",\n "label": "Customers",\n "singular": "Customer",\n "fields": [\n { "alias": "name", "label": "Name", "type": "text", "required": true },\n {\n "alias": "tier",\n "label": "Tier",\n "type": "select",\n "options": [\n { "alias": "standard", "label": "Standard", "color": "slate" },\n { "alias": "gold", "label": "Gold", "color": "amber" }\n ],\n "default": ["standard"]\n },\n {\n "alias": "orders",\n "label": "Orders",\n "type": "select_record_link",\n "target_entity": "order",\n "cardinality": "many",\n "sync_both_ways": true,\n "paired_field_alias": "customer",\n "display_field_aliases": ["code"]\n },\n {\n "alias": "total_ordered",\n "label": "Total ordered",\n "type": "rollup",\n "source_field_alias": "orders",\n "aggregate_option": { "operation": "sum", "field_key": "amount" }\n }\n ],\n "views": [\n {\n "alias": "gold",\n "label": "Gold customers",\n "filters": {\n "node_type": "condition",\n "type": "select",\n "field_key": "tier",\n "operator": "has_any_of",\n "value": ["gold"]\n },\n "sort": [{ "field_key": "name", "order": "asc" }]\n }\n ]\n },\n {\n "alias": "order",\n "label": "Orders",\n "singular": "Order",\n "fields": [\n { "alias": "code", "label": "Order no.", "type": "text", "unique": true },\n { "alias": "placed_on", "label": "Placed on", "type": "date", "format": "date" },\n {\n "alias": "amount",\n "label": "Amount",\n "type": "number",\n "format": "currency",\n "currency": "VND"\n },\n {\n "alias": "total",\n "label": "Total with VAT",\n "type": "formula",\n "formula": { "expression": "{amount} * 1.1", "format": "currency", "currency": "VND" }\n },\n {\n "alias": "customer",\n "label": "Customer",\n "type": "select_record_link",\n "target_entity": "customer",\n "cardinality": "one",\n "sync_both_ways": true,\n "paired_field_alias": "orders",\n "display_field_aliases": ["name"]\n }\n ]\n }\n ],\n "roles": [{ "alias": "sales", "label": "Sales" }],\n "field_roles": {\n "customer": { "name": "identity" },\n "order": { "code": "identity", "placed_on": "when", "amount": "amount", "customer": "party" }\n },\n "apps": [\n {\n "alias": "customers",\n "name": "Customers",\n "screen": { "alias": "customers", "label": "Customers", "shape": "party_register", "entity": "customer" }\n },\n {\n "alias": "orders",\n "name": "Orders",\n "screen": { "alias": "orders", "label": "Orders", "shape": "transaction_ledger", "entity": "order" }\n }\n ],\n "rows": {\n "customer": [\n { "ref": "acme", "fields": { "name": "Acme Trading", "tier": "gold" } },\n { "ref": "bluebird", "fields": { "name": "Bluebird Foods", "tier": "standard" } }\n ],\n "order": [\n {\n "ref": "so_1001",\n "fields": {\n "code": "SO-1001",\n "placed_on": "@month-start+2",\n "amount": 4200000,\n "customer": "customer:acme"\n }\n },\n {\n "ref": "so_1002",\n "fields": {\n "code": "SO-1002",\n "placed_on": "@today-3",\n "amount": 1150000,\n "customer": "customer:bluebird"\n }\n }\n ]\n }\n}\n```\n\n`lotics scaffold check` on this file reports\n`2 tables, 9 fields, 2 links, 1 view, 1 role, 4 rows, 2 apps`, then\nthe plan:\n\n```\nCustomers\n Customers \u2014 party register over Customers (2 rows) \xB7 page \xB7 tabs: none\n identity Name \xB7 mark (none \u2014 no field declares "mark") \xB7 contact (none \u2014 no field declares "contact") \xB7 worth (none \u2014 no field declares "measure") \xB7 risk (none \u2014 no field declares "verdict")\n above: rows (default)\n record: header (Name) \xB7 history: Orders (Order no. \xB7 Placed on \xB7 Amount (VND)) \xB7 facts (Tier \xB7 Total ordered (VND))\nOrders\n Orders \u2014 transaction ledger over Orders (2 rows) \xB7 drawer \xB7 tabs: none\n when Placed on \xB7 amount Amount (VND) \xB7 reference Order no. \xB7 party Customer \xB7 classification (none \u2014 no field declares "lifecycle") \xB7 document (none \u2014 no field declares "expected_set" or "verdict")\n above: Amount (VND) (default)\n record: header (Order no. \xB7 Placed on \xB7 Amount (VND)) \xB7 facts (Placed on \xB7 Total with VAT (VND) \xB7 Customer)\nWho writes what:\n Customers (customer, one: Customer): Customers\n Orders (order, one: Order): Orders\n```\n\nThe last block is the WRITERS matrix \u2014 one line per record surface the plan\nleaves operable, the word one of its rows is called, and the app whose register\nopens it. Part of the verdict rather than a diagnostic beside it: which desk\nchanges a table is answerable from the plan, and two apps on one line is the\nsplit to state in each of their `package.json#lotics.writes`.\n\nEvery `(none)` is a slot no field fills, and it says WHY: no field declares the\nrole, or several could and the plan named none of them (`(none \u2014 a, b; name\none)`). Read it as the screen a person will see. `above: \u2026 (default)` is the band\nnobody stated \u2014 the shape sums the amount its slot draws, and a plan naming its\nown figures loses the word. Neither\nentity carries a stage, a required set or files, so the rest is facts \u2014 except\nthe sections neither entity declares: the customer\'s `history:` is the orders\nthat name it as their party, and the order\'s `via Orders:` is the customers whose\n`Orders` link names it. **A link is a section, not a fact**, on the side it\npoints AT and on the side that holds it: `Customers.Orders` therefore leaves the\ncustomer\'s facts, because the section below already lists the same relation and\na link fact can only ever show the first of them. Both records state their name\nin the header and nowhere else \u2014 a ledger\'s columns are the date and the figure,\nso `Order no.` is on no column of that list and is still what the drawer behind\na line is called. `Total ordered (VND)` is the rollup inheriting the currency of\nthe column it sums: money is read off the model, never off the field\'s own line.\n';
|
|
54412
55121
|
|
|
54413
55122
|
// src/scaffold_commands.ts
|
|
54414
55123
|
function printModelReference() {
|
|
@@ -54601,11 +55310,14 @@ function describeRecord(model, resolved) {
|
|
|
54601
55310
|
const required2 = section.source === "own" ? section.field : section.setField;
|
|
54602
55311
|
const name = section.source === "own" ? section.field.label : section.child.label;
|
|
54603
55312
|
if (required2.type !== "select") return `documents: ${name}`;
|
|
54604
|
-
const set2 = section.requiredBy === void 0 ? `${required2.options.length} required` : `${count(required2.options.length, "option")}, ${section.requiredBy.field.label} decides which`;
|
|
55313
|
+
const set2 = section.requiredBy === void 0 ? `${required2.options.length} required` : `${count(required2.options.length, "option")}, ${section.requiredBy.field.label}${section.requiredBy.from === "parent" ? " on this record" : ""} decides which`;
|
|
54605
55314
|
return section.source === "own" ? `documents: ${name} (${set2})` : `documents: ${name} (${required2.label}: ${set2})`;
|
|
54606
55315
|
}
|
|
54607
55316
|
case "children": {
|
|
54608
55317
|
if (draw === "related") return `related: ${section.child.label} (count via ${section.link.label})`;
|
|
55318
|
+
if (resolved.screen.sections?.[section.child.alias] !== void 0) {
|
|
55319
|
+
return `sheet: ${section.child.label} (${labels(section.child, section.fields)})`;
|
|
55320
|
+
}
|
|
54609
55321
|
const run = recordItinerary(archetype, recipeSlot, section.child, model.field_roles);
|
|
54610
55322
|
if (run !== void 0) {
|
|
54611
55323
|
const at2 = run.slot === void 0 ? "" : ` at ${fieldName(model, section.child, run.slot)}`;
|
|
@@ -54667,9 +55379,13 @@ function describePlan(model) {
|
|
|
54667
55379
|
` ${resolved.slots.map((entry) => {
|
|
54668
55380
|
if (entry.field !== null) {
|
|
54669
55381
|
const operated = entry.quick !== true ? "" : entry.order === "sequence" ? " [quick, next step]" : " [quick]";
|
|
54670
|
-
|
|
55382
|
+
const under = entry.tiers.length === 0 ? "" : `, then ${entry.tiers.map((field) => fieldName(model, resolved.entity, field)).join(", then ")}`;
|
|
55383
|
+
return `${entry.name} ${[entry.field, ...entry.also].map((field) => fieldName(model, resolved.entity, field)).join(", else ")}${under}${operated}`;
|
|
54671
55384
|
}
|
|
54672
|
-
|
|
55385
|
+
if (entry.ambiguous.length > 0) return `${entry.name} (none \u2014 ${entry.ambiguous.join(", ")}; name one)`;
|
|
55386
|
+
const takes = slotTakes(resolved.screen.shape, entry.name);
|
|
55387
|
+
const roles = (takes.length === 0 ? [entry.role] : takes).map((role) => `"${role}"`);
|
|
55388
|
+
return `${entry.name} (none \u2014 no field declares ${roles.join(" or ")})`;
|
|
54673
55389
|
}).join(" \xB7 ")}`
|
|
54674
55390
|
);
|
|
54675
55391
|
const carried = describeCarried(model, resolved);
|
|
@@ -54683,11 +55399,20 @@ function describeCarried(model, resolved) {
|
|
|
54683
55399
|
const name = (field) => fieldName(model, resolved.entity, field);
|
|
54684
55400
|
const act = (entry) => {
|
|
54685
55401
|
const where = entry.place === "cta" ? " [on the row]" : "";
|
|
54686
|
-
|
|
55402
|
+
if (entry.kind === "agent") return `"${entry.label}" \u2192 ${entry.agent} fills ${entry.fills.map(name).join(", ")}${where}`;
|
|
55403
|
+
if (entry.kind === "workflow") {
|
|
55404
|
+
const taken = entry.inputs.map((input) => `${input.name}: ${input.field === null ? "the record" : name(input.field)}`).join(", ");
|
|
55405
|
+
return `"${entry.label}" \u2192 ${entry.workflow}(${taken}) [you write the body]${where}`;
|
|
55406
|
+
}
|
|
55407
|
+
return `"${entry.label}" \u2192 ${entry.template.label} (${entry.template.type})${where}`;
|
|
54687
55408
|
};
|
|
54688
55409
|
const runs = registerRuns(resolved, model.field_roles);
|
|
54689
55410
|
const drawn = resolved.screen.presentation;
|
|
54690
55411
|
const parts = [
|
|
55412
|
+
// THE CONTEXT, AFTER THE ANSWER — printed beside the slot line rather than
|
|
55413
|
+
// inside it, because the two are read differently: a slot is the shape's
|
|
55414
|
+
// question and a column is the author's own addition to the row.
|
|
55415
|
+
...resolved.columns.length === 0 ? [] : [`columns: ${resolved.columns.map(name).join(" \xB7 ")} (after the slots)`],
|
|
54691
55416
|
...runs === void 0 ? [] : [`runs: by ${name(runs.when)} at ${name(runs.slot)}`],
|
|
54692
55417
|
...drawn === void 0 || Object.values(drawn).every((clause) => clause === void 0) ? [] : [
|
|
54693
55418
|
`presentation: ${[
|
|
@@ -54697,13 +55422,19 @@ function describeCarried(model, resolved) {
|
|
|
54697
55422
|
// decided against what the rows carry: an author reading back
|
|
54698
55423
|
// "board" learns nothing about the three other arrangements the
|
|
54699
55424
|
// same screen could have taken.
|
|
54700
|
-
...drawn.layout === void 0 ? [] : [`as a ${drawn.layout} (of ${layoutsFor(resolved
|
|
55425
|
+
...drawn.layout === void 0 ? [] : [`as a ${drawn.layout} (of ${layoutsFor(drawnColumns(resolved, model.field_roles)).join(", ")})`],
|
|
55426
|
+
// WHERE THE BARS END, in the column's own words: an author who named
|
|
55427
|
+
// a finish date reads back which date the chart is drawn to.
|
|
55428
|
+
...drawn.until === void 0 ? [] : [`ending at ${drawn.until}`]
|
|
54701
55429
|
].join(", ")}`
|
|
54702
55430
|
],
|
|
54703
55431
|
...period === null ? [] : [`period: ${name(period)}`],
|
|
54704
55432
|
// WHAT THE ROWS ARE, where the shape fans them out of the record: one line
|
|
54705
|
-
// per thing owed, and
|
|
54706
|
-
|
|
55433
|
+
// per thing owed, and what takes it off the desk — the stamp, or the stage
|
|
55434
|
+
// the record stops owing it at.
|
|
55435
|
+
...resolved.obligations.length === 0 ? [] : [
|
|
55436
|
+
`obligations: ${resolved.obligations.map((owed) => `${owed.label} (${owed.field.label} \u2192 ${owed.satisfiedBy?.label ?? "settled"})`).join(" \xB7 ")}`
|
|
55437
|
+
],
|
|
54707
55438
|
// A DERIVED LENS IS PRINTED BY ITS SETS, never by its label alone: what an
|
|
54708
55439
|
// author has to check is that the predicates say what they meant, and the
|
|
54709
55440
|
// chip's own name is the one thing they already wrote down.
|
|
@@ -54723,6 +55454,10 @@ function describeCarried(model, resolved) {
|
|
|
54723
55454
|
// read as whatever the reader assumes, and the cuts are what a statement of
|
|
54724
55455
|
// account is compared against.
|
|
54725
55456
|
...summary.ageing === null ? [] : [`ageing: ${name(summary.ageing.amount)} by ${name(summary.ageing.due)} (${summary.ageing.buckets.join(" \xB7 ")} days)`],
|
|
55457
|
+
// WHICH WAY IS GOOD IS THE HALF NOBODY CAN READ OFF THE FIGURE. A book wants
|
|
55458
|
+
// its takings rising and a desk wants its backlog falling, and the same
|
|
55459
|
+
// arrow is a win on one and an alarm on the other.
|
|
55460
|
+
...summary.trend === null ? [] : [`trend: ${name(summary.trend.field)} across the period (${summary.trend.direction} is good)`],
|
|
54726
55461
|
// WHERE the sum is drawn is the reading a person checks. A register states
|
|
54727
55462
|
// ONE aggregate, over the rows the reader can see, in the band it already
|
|
54728
55463
|
// reads the count in — except a SHARE, which does not add up: what a column
|
|
@@ -54744,6 +55479,11 @@ function describeCarried(model, resolved) {
|
|
|
54744
55479
|
// A VERB ON A SECTION STANDS WHERE ITS EFFECT LANDS, and which SECTION each
|
|
54745
55480
|
// alias resolved to is the half the author cannot see in their own file.
|
|
54746
55481
|
...sectionActLines(model, resolved, act),
|
|
55482
|
+
// A SHEET IS THE ONE SECTION THAT WRITES, so the plan says which register it
|
|
55483
|
+
// prices and which of the child's figures is the base and which the answer.
|
|
55484
|
+
...Object.entries(resolved.screen.sections ?? {}).map(
|
|
55485
|
+
([alias, drawn2]) => `sheet on ${alias}: ${drawn2.cost ?? "(no cost)"} \u2192 ${drawn2.sell ?? "(no sell)"}`
|
|
55486
|
+
),
|
|
54747
55487
|
...resolved.screen.writes === false ? ["operable: no"] : []
|
|
54748
55488
|
];
|
|
54749
55489
|
return parts.join(" \xB7 ");
|
|
@@ -54780,42 +55520,52 @@ function foldsAbove(model, resolved, field) {
|
|
|
54780
55520
|
function aboveLine(model, resolved, name) {
|
|
54781
55521
|
const { above, columnTotals, bandTotals } = resolved.summary;
|
|
54782
55522
|
const folded = [...columnTotals, ...bandTotals].filter((field) => foldsAbove(model, resolved, field)).map((field) => ({ field }));
|
|
54783
|
-
const
|
|
54784
|
-
const once =
|
|
55523
|
+
const named2 = [...above === null || above === "counts" ? [] : above, ...folded];
|
|
55524
|
+
const once = named2.filter((figure, at2) => named2.findIndex((other) => other.field.alias === figure.field.alias) === at2);
|
|
54785
55525
|
const figures = [
|
|
54786
55526
|
...above === "counts" ? ["counts"] : [],
|
|
54787
55527
|
...once.map((figure) => `${name(figure.field)}${figure.at === void 0 ? "" : " at the window's end"}`)
|
|
54788
55528
|
];
|
|
54789
55529
|
if (above === null && figures.length === 0) return [];
|
|
54790
|
-
|
|
55530
|
+
const stated2 = resolved.screen.summary?.above !== void 0 || folded.length > 0;
|
|
55531
|
+
return [`above: ${figures.length === 0 ? "rows" : figures.join(" \xB7 ")}${stated2 ? "" : " (default)"}`];
|
|
54791
55532
|
}
|
|
54792
55533
|
function totalsUnder(model, resolved) {
|
|
54793
|
-
const
|
|
55534
|
+
const named2 = [...resolved.summary.columnTotals, ...resolved.summary.bandTotals].filter(
|
|
54794
55535
|
(field) => !foldsAbove(model, resolved, field)
|
|
54795
55536
|
);
|
|
54796
|
-
if (
|
|
54797
|
-
return [`totals: ${
|
|
55537
|
+
if (named2.length === 0) return [];
|
|
55538
|
+
return [`totals: ${named2.map((field) => fieldName(model, resolved.entity, field)).join(" \xB7 ")} (under the register)`];
|
|
54798
55539
|
}
|
|
54799
55540
|
function describeCoverage(model) {
|
|
54800
55541
|
return roleCoverage(model.contract, model.field_roles, model.rows).map((note2) => ` ${note2.path}: ${note2.message}`);
|
|
54801
55542
|
}
|
|
54802
55543
|
function describeWriters(model) {
|
|
54803
55544
|
const appsByEntity = /* @__PURE__ */ new Map();
|
|
54804
|
-
|
|
54805
|
-
|
|
54806
|
-
|
|
54807
|
-
|
|
54808
|
-
|
|
54809
|
-
|
|
54810
|
-
|
|
54811
|
-
if (
|
|
54812
|
-
|
|
55545
|
+
const resolved = resolvePlan(model);
|
|
55546
|
+
const named2 = (entity) => appsByEntity.get(entity.alias) ?? {
|
|
55547
|
+
label: entity.label,
|
|
55548
|
+
...entity.singular === void 0 ? {} : { singular: entity.singular },
|
|
55549
|
+
apps: []
|
|
55550
|
+
};
|
|
55551
|
+
for (const screen of resolved) {
|
|
55552
|
+
if (screen.screen.writes === false) continue;
|
|
55553
|
+
const entry = named2(screen.entity);
|
|
55554
|
+
if (!entry.apps.includes(screen.app.name)) entry.apps.push(screen.app.name);
|
|
55555
|
+
appsByEntity.set(screen.entity.alias, entry);
|
|
55556
|
+
}
|
|
55557
|
+
const registered = new Set(resolved.map((screen) => screen.entity.alias));
|
|
55558
|
+
for (const screen of resolved) {
|
|
55559
|
+
for (const alias of unregisteredChildren(screen, model.contract.entities, model.field_roles, registered)) {
|
|
55560
|
+
const entity = model.contract.entities.find((one) => one.alias === alias);
|
|
55561
|
+
if (entity !== void 0) appsByEntity.set(alias, named2(entity));
|
|
55562
|
+
}
|
|
54813
55563
|
}
|
|
54814
55564
|
if (appsByEntity.size === 0) return [];
|
|
54815
55565
|
const lines = [];
|
|
54816
55566
|
for (const [alias, entry] of appsByEntity) {
|
|
54817
55567
|
lines.push(
|
|
54818
|
-
` ${entry.label} (${alias}${entry.singular === void 0 ? "" : `, one: ${entry.singular}`}): ${entry.apps.join(", ")}` + (entry.apps.length > 1 ? ` \u2014 ${entry.apps.length} desks write this record; state the split in each app's package.json#lotics.writes` : "")
|
|
55568
|
+
` ${entry.label} (${alias}${entry.singular === void 0 ? "" : `, one: ${entry.singular}`}): ${entry.apps.length === 0 ? "(no app)" : entry.apps.join(", ")}` + (entry.apps.length > 1 ? ` \u2014 ${entry.apps.length} desks write this record; state the split in each app's package.json#lotics.writes` : "")
|
|
54819
55569
|
);
|
|
54820
55570
|
for (const clause of describeWriteRules(model, alias)) lines.push(` ${clause}`);
|
|
54821
55571
|
}
|
|
@@ -54845,9 +55595,9 @@ function writersJson(model) {
|
|
|
54845
55595
|
const writers = {};
|
|
54846
55596
|
for (const resolved of resolvePlan(model)) {
|
|
54847
55597
|
if (resolved.screen.writes === false) continue;
|
|
54848
|
-
const
|
|
54849
|
-
if (!
|
|
54850
|
-
else writers[resolved.entity.alias] =
|
|
55598
|
+
const named2 = writers[resolved.entity.alias] ?? [];
|
|
55599
|
+
if (!named2.includes(resolved.app.alias)) writers[resolved.entity.alias] = [...named2, resolved.app.alias];
|
|
55600
|
+
else writers[resolved.entity.alias] = named2;
|
|
54851
55601
|
}
|
|
54852
55602
|
return writers;
|
|
54853
55603
|
}
|
|
@@ -54878,7 +55628,7 @@ function planJson(model) {
|
|
|
54878
55628
|
obligations: resolved.obligations.map((owed) => ({
|
|
54879
55629
|
field: owed.field.alias,
|
|
54880
55630
|
label: owed.label,
|
|
54881
|
-
satisfied_by: owed.satisfiedBy.alias
|
|
55631
|
+
...owed.satisfiedBy === void 0 ? {} : { satisfied_by: owed.satisfiedBy.alias }
|
|
54882
55632
|
}))
|
|
54883
55633
|
},
|
|
54884
55634
|
...resolved.acts.row.length === 0 && resolved.acts.selection.length === 0 ? {} : {
|
|
@@ -54892,7 +55642,14 @@ function planJson(model) {
|
|
|
54892
55642
|
}
|
|
54893
55643
|
function actJson(act) {
|
|
54894
55644
|
const place = act.place === void 0 ? {} : { place: act.place };
|
|
54895
|
-
|
|
55645
|
+
if (act.kind === "agent") {
|
|
55646
|
+
return { kind: act.kind, label: act.label, agent: act.agent, fills: act.fills.map((field) => field.alias), ...place };
|
|
55647
|
+
}
|
|
55648
|
+
if (act.kind === "workflow") {
|
|
55649
|
+
const inputs = Object.fromEntries(act.inputs.map((input) => [input.name, input.field === null ? RECORD_INPUT : input.field.alias]));
|
|
55650
|
+
return { kind: act.kind, label: act.label, workflow: act.workflow, inputs, ...place };
|
|
55651
|
+
}
|
|
55652
|
+
return { label: act.label, template: act.template.alias, ...place };
|
|
54896
55653
|
}
|
|
54897
55654
|
var presetSourceSchema = zod_default.object({
|
|
54898
55655
|
entities: zod_default.array(contractEntitySchema),
|
|
@@ -54944,14 +55701,14 @@ async function resolveOverlay(file2, raw, overlay) {
|
|
|
54944
55701
|
note(`${file2} starts from ${read2.name} (${overlay.from}).`);
|
|
54945
55702
|
return { kind: "ok", model, notes };
|
|
54946
55703
|
}
|
|
54947
|
-
async function knownSlugs(
|
|
55704
|
+
async function knownSlugs(named2) {
|
|
54948
55705
|
let slugs;
|
|
54949
55706
|
try {
|
|
54950
55707
|
slugs = (await fetchPresetIndex()).map((row) => row.slug);
|
|
54951
55708
|
} catch {
|
|
54952
55709
|
return "";
|
|
54953
55710
|
}
|
|
54954
|
-
return slugs.includes(
|
|
55711
|
+
return slugs.includes(named2) ? "" : `
|
|
54955
55712
|
The presets there are: ${slugs.join(", ")}.`;
|
|
54956
55713
|
}
|
|
54957
55714
|
async function checkModelFile(file2) {
|
|
@@ -56073,11 +56830,11 @@ function rpcFailureBody(err) {
|
|
|
56073
56830
|
if (!(err instanceof LoticsRequestError)) return { message: message2 };
|
|
56074
56831
|
const code = err.body.code;
|
|
56075
56832
|
const raw = err.body.field_errors;
|
|
56076
|
-
const
|
|
56833
|
+
const named2 = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? Object.entries(raw).filter((e) => typeof e[1] === "string") : [];
|
|
56077
56834
|
return {
|
|
56078
56835
|
message: message2,
|
|
56079
56836
|
...typeof code === "string" ? { code } : {},
|
|
56080
|
-
...
|
|
56837
|
+
...named2.length > 0 ? { field_errors: Object.fromEntries(named2) } : {}
|
|
56081
56838
|
};
|
|
56082
56839
|
}
|
|
56083
56840
|
function servesWrapperPage(method, pathname) {
|
|
@@ -57870,15 +58627,15 @@ function warnLocalKit(projectDir) {
|
|
|
57870
58627
|
function assertKitShippable(projectDir, options) {
|
|
57871
58628
|
const entries2 = Object.entries(readLocalKit(projectDir));
|
|
57872
58629
|
if (entries2.length === 0) return;
|
|
57873
|
-
const
|
|
58630
|
+
const named2 = entries2.map(([name, record2]) => describeRecord2(name, record2)).join(", ");
|
|
57874
58631
|
if (options.allowLocalKit) {
|
|
57875
58632
|
warn(
|
|
57876
|
-
`\u26A0 Deploying against a local kit build (${
|
|
58633
|
+
`\u26A0 Deploying against a local kit build (${named2}) \u2014 the bundle carries it, but the source archive does not, so a clone of this app will not install.`
|
|
57877
58634
|
);
|
|
57878
58635
|
return;
|
|
57879
58636
|
}
|
|
57880
58637
|
fail(
|
|
57881
|
-
`This app is installed against a local kit build (${
|
|
58638
|
+
`This app is installed against a local kit build (${named2}), and ${KIT_DIR}/ is not in the source a deploy uploads.
|
|
57882
58639
|
Publish the kit and run: lotics app kit <path-to-package> --published
|
|
57883
58640
|
Or ship it anyway: lotics app deploy --allow-local-kit`
|
|
57884
58641
|
);
|
|
@@ -59630,8 +60387,8 @@ Ready. Next steps:`);
|
|
|
59630
60387
|
console.error(` lotics app check # every pre-flight a deploy runs`);
|
|
59631
60388
|
console.error(` lotics app deploy`);
|
|
59632
60389
|
} else {
|
|
59633
|
-
console.error(` # edit
|
|
59634
|
-
console.error(` lotics app check
|
|
60390
|
+
console.error(` # edit app.json \u2014 the screens, their records and their acts \u2014 then:`);
|
|
60391
|
+
console.error(` lotics app check # every pre-flight a deploy runs, the spec read against the project`);
|
|
59635
60392
|
console.error(` lotics app deploy`);
|
|
59636
60393
|
}
|
|
59637
60394
|
}
|
|
@@ -60385,6 +61142,7 @@ async function appDeploy(client, args) {
|
|
|
60385
61142
|
});
|
|
60386
61143
|
note(`Deployed v${result.version_number} (${result.version_id})`);
|
|
60387
61144
|
note(`Bundle size: ${(result.bundle_size_bytes / 1024).toFixed(1)} KB`);
|
|
61145
|
+
if (result.origin !== void 0) note(`Address: ${result.origin}`);
|
|
60388
61146
|
try {
|
|
60389
61147
|
const drifted = staleWorkflowGlobals(projectDir, meta3.workflows ?? {});
|
|
60390
61148
|
for (const { alias, declaration } of drifted) {
|
|
@@ -61327,11 +62085,11 @@ async function warnIfQueriesIgnoreRowRules(client, queries) {
|
|
|
61327
62085
|
const lines = [];
|
|
61328
62086
|
const unguarded = /* @__PURE__ */ new Set();
|
|
61329
62087
|
for (const [alias, scans] of byQuery) {
|
|
61330
|
-
const
|
|
62088
|
+
const named2 = /* @__PURE__ */ new Set();
|
|
61331
62089
|
for (const scan of scans) {
|
|
61332
62090
|
const name = scoped.get(scan.table_id);
|
|
61333
|
-
if (scan.guarded || name === void 0 ||
|
|
61334
|
-
|
|
62091
|
+
if (scan.guarded || name === void 0 || named2.has(name)) continue;
|
|
62092
|
+
named2.add(name);
|
|
61335
62093
|
unguarded.add(alias);
|
|
61336
62094
|
lines.push(
|
|
61337
62095
|
` \u2022 ${alias} over ${name} \u2014 ${name} declares a row rule; this query runs as the app's owner and returns every row. Add the viewer predicate the rule names.`
|
|
@@ -62397,7 +63155,16 @@ function candidates(spec) {
|
|
|
62397
63155
|
kind: "refused",
|
|
62398
63156
|
message: `"${act.label}" runs an agent, and the panel a run is reviewed in is the kit's \u2014 started once,
|
|
62399
63157
|
reviewed before it is applied, cancelled by closing. Only a paper act opens a panel of the app's own.`
|
|
62400
|
-
} :
|
|
63158
|
+
} : (
|
|
63159
|
+
// A HAND-OFF'S SURFACE IS THE BODY THE AUTHOR WROTE. What a panel
|
|
63160
|
+
// would ask for is an input, and an input is the plan's own clause
|
|
63161
|
+
// — so the answer to "ask first" is the workflow, not a component.
|
|
63162
|
+
act.kind === "workflow" ? {
|
|
63163
|
+
kind: "refused",
|
|
63164
|
+
message: `"${act.label}" hands the row to the "${act.workflow}" workflow, whose body is already yours \u2014 what a
|
|
63165
|
+
panel would ask for is an input, which the act's own \`inputs\` names. Only a paper act opens a panel.`
|
|
63166
|
+
} : act.component !== void 0 ? already(`"${act.label}"`, act.component) : { kind: "target", target: { kind: "act", at: at2, act } }
|
|
63167
|
+
)
|
|
62401
63168
|
)
|
|
62402
63169
|
};
|
|
62403
63170
|
})
|
|
@@ -62760,13 +63527,13 @@ var countOf = (outcomes, kind) => outcomes.filter((outcome) => outcome.kind ===
|
|
|
62760
63527
|
var list2 = (aliases) => aliases.length === 0 ? "\u2014" : aliases.join(", ");
|
|
62761
63528
|
function renderSummary(summary, dryRun) {
|
|
62762
63529
|
const { outcomes, deploy } = summary;
|
|
62763
|
-
const
|
|
63530
|
+
const named2 = (kind) => outcomes.filter((outcome) => outcome.kind === kind).map((outcome) => ` ${outcome.path}${outcome.kind === "kept" ? ` \u2014 ${outcome.why}` : ""}`);
|
|
62764
63531
|
const nothingToDeploy = deploy.added.length === 0 && deploy.changed.length === 0 && deploy.removed.length === 0;
|
|
62765
63532
|
const lines = [
|
|
62766
63533
|
dryRun ? "lotics app regenerate --dry-run \u2014 nothing was written." : "lotics app regenerate",
|
|
62767
63534
|
` Files: ${countOf(outcomes, "written")} written, ${countOf(outcomes, "kept")} kept, ${countOf(outcomes, "deleted")} deleted`,
|
|
62768
63535
|
...["kept", "deleted"].flatMap((kind) => {
|
|
62769
|
-
const rows =
|
|
63536
|
+
const rows = named2(kind);
|
|
62770
63537
|
return rows.length === 0 ? [] : [` ${kind}:`, ...rows];
|
|
62771
63538
|
}),
|
|
62772
63539
|
// A declaration losing an entry is not something to do quietly, and the two
|
|
@@ -63167,38 +63934,38 @@ function renameInModel(raw, args) {
|
|
|
63167
63934
|
function isRecord(value) {
|
|
63168
63935
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
63169
63936
|
}
|
|
63170
|
-
async function resolveTable(client,
|
|
63937
|
+
async function resolveTable(client, named2) {
|
|
63171
63938
|
const res = await client.execute("query_tables", {}, { format: "json" });
|
|
63172
63939
|
if (res.error) throw new Error(`Could not list this workspace's tables: ${res.error}`);
|
|
63173
63940
|
const rows = Array.isArray(res.result) ? res.result : [];
|
|
63174
63941
|
const tables = rows.flatMap(
|
|
63175
63942
|
(row) => typeof row.table_id === "string" && typeof row.table_name === "string" ? [{ id: row.table_id, name: row.table_name }] : []
|
|
63176
63943
|
);
|
|
63177
|
-
const byId2 = tables.find((table) => table.id ===
|
|
63944
|
+
const byId2 = tables.find((table) => table.id === named2);
|
|
63178
63945
|
if (byId2 !== void 0) return byId2;
|
|
63179
|
-
const byName = tables.filter((table) => table.name ===
|
|
63946
|
+
const byName = tables.filter((table) => table.name === named2);
|
|
63180
63947
|
if (byName.length === 1) return byName[0];
|
|
63181
63948
|
if (byName.length > 1) {
|
|
63182
63949
|
throw new Error(
|
|
63183
|
-
`"${
|
|
63950
|
+
`"${named2}" names ${byName.length} tables here. Name the one you mean by id: ${byName.map((t) => t.id).join(", ")}.`
|
|
63184
63951
|
);
|
|
63185
63952
|
}
|
|
63186
63953
|
throw new Error(
|
|
63187
|
-
`No table here is called "${
|
|
63954
|
+
`No table here is called "${named2}". This workspace has: ${tables.map((t) => t.name).join(", ")}.`
|
|
63188
63955
|
);
|
|
63189
63956
|
}
|
|
63190
|
-
function resolveField(table,
|
|
63191
|
-
const byId2 = table.fields.find((field) => field.id ===
|
|
63957
|
+
function resolveField(table, named2) {
|
|
63958
|
+
const byId2 = table.fields.find((field) => field.id === named2);
|
|
63192
63959
|
if (byId2 !== void 0) return byId2;
|
|
63193
|
-
const byName = table.fields.filter((field) => field.name ===
|
|
63960
|
+
const byName = table.fields.filter((field) => field.name === named2);
|
|
63194
63961
|
if (byName.length === 1) return byName[0];
|
|
63195
63962
|
if (byName.length > 1) {
|
|
63196
63963
|
throw new Error(
|
|
63197
|
-
`"${
|
|
63964
|
+
`"${named2}" names ${byName.length} fields on ${table.name}. Name the one you mean by key: ${byName.map((f) => f.id).join(", ")}.`
|
|
63198
63965
|
);
|
|
63199
63966
|
}
|
|
63200
63967
|
throw new Error(
|
|
63201
|
-
`${table.name} has no field called "${
|
|
63968
|
+
`${table.name} has no field called "${named2}". Its fields are: ${table.fields.map((f) => f.name).join(", ")}.`
|
|
63202
63969
|
);
|
|
63203
63970
|
}
|
|
63204
63971
|
async function readTableSchema(client, tableId) {
|